mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
feat(beehive): authenticate pinned-assignment Move between private hosts
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -485,3 +485,75 @@ question, not a claim that authenticated Start cannot authorize enrollment.
|
||||
Authenticated Move and Desktop parity remain mapped in “Precise subsequent work”
|
||||
above; no allowlists or provider/harness contracts were changed here. Larry owns
|
||||
scope resolution, subsequent dispatch and independent final UX acceptance.
|
||||
|
||||
## 064d8808 — authenticated Move candidate (existing pinned assignments only)
|
||||
|
||||
Agents → Move… now chooses a destination host and its selected local runtime,
|
||||
then submits the real durable owner intent. No genesis or initial enrollment path
|
||||
changed. Destination setup/credential repair remains explicitly host-local; a
|
||||
missing key shows the exact `import-agent-key` action, not a remote secret form.
|
||||
Hosts without this public slot still require local provisioning with the existing
|
||||
pinned genesis and local binding, followed by host startup/Refresh. The selector
|
||||
uses already-selected destination runtimes; it does not remotely create runtime
|
||||
bindings or copy workspace/session/key material.
|
||||
|
||||
Protocol/code owners:
|
||||
|
||||
- `catalog-transport.ts` / `intents.ts`: canonical authenticated owner → source
|
||||
Move, encrypted durable request before effects. Separate authenticated destination
|
||||
receipt persists in `.destination`; source consumption alone stays pending (or
|
||||
unknown on disconnect). Completed transfer history survives later Stop/Move and
|
||||
controller reopen. Reconcile queries the source outbox even after source acceptance
|
||||
while the destination result is missing; exact consumed grants are replayed once.
|
||||
- `host-transport.ts` / new `private-handoff.ts`: NIP-59 source/destination routing,
|
||||
exact authenticated sender/recipient/agent/owner pairing. Each new successor is
|
||||
source-signed over a domain-separated relay-bound grant. Verify unseen successors
|
||||
against the locally pinned prefix, never public directory/availability claims.
|
||||
Existing locally pinned unsigned history is not discarded; an unseen unsigned
|
||||
suffix is refused. This is trusted-holder continuity, not shared first enrollment.
|
||||
- `host.ts` / `handoff.ts`: existing per-slot revisions/CAS, durable preparations,
|
||||
selected/actual snapshots, awaited owned Stop, source-consumed grant outbox and
|
||||
prepared launch fences remain authoritative. Sign before persisting consumption;
|
||||
bound projected preparation inventory and grant wire bytes. Conflicting target
|
||||
operation IDs reject during preparation, before source Stop. Destination completion
|
||||
also requires the exact Move run to be actually running, never an old receipt.
|
||||
- `manager-controller.ts` / `manager-view.ts`: relay-listed selection, eligible
|
||||
destination/setup guidance, typed submitted completion, truthful host/running report,
|
||||
concise recent operation result. Source acceptance is not UI completion.
|
||||
|
||||
Evidence: `/Users/loganj/.buzz/artifacts/beehive-ux-064d8808/`.
|
||||
|
||||
- `focused-candidate-final.log`: **55/55**, including real private two-host owned
|
||||
processes, successful/reverse transfer, exact retry/reopen, cross-controller target
|
||||
ID conflict, stale target CAS, missing destination key before preparation, delayed
|
||||
prepared reply + Stop cancellation, lost-grant reconciliation, postconsumption key
|
||||
loss → assigned/stopped → repaired explicit Start, and injected owned source Stop
|
||||
failure → quarantine/actual retained → repaired Stop without destination execution.
|
||||
The same run includes legacy Move/named recovery/binding recovery, sibling isolation,
|
||||
registration/initial-enrollment refusal, intents, profiles/wire bounds and controllers.
|
||||
- `typecheck-last-corrected.log`: strict TypeScript passed. Renderer
|
||||
`renderer-candidate-final.log`: Bun 1.4.2 **4/4**.
|
||||
- `pty-candidate-final.log`, `walkthrough.json`, `01-host` through
|
||||
`11-destination-stopped` `.ansi`/`.txt`: actual 100×30 PTY, registration cancellation,
|
||||
matching hidden nsec/runtime, source actual gpt-5, Move chooser/confirmation,
|
||||
destination owned fixture-model run under signed successor, visible **Move ·
|
||||
Completed**, awaited destination Stop and exit 0. The nsec is absent from output.
|
||||
The PTY snapshots observe resulting states, not independent timing instrumentation
|
||||
of the Stop barrier. `tools/ux-walkthrough.sh` reproduces the same two-host fixture.
|
||||
- One default-concurrent package run `full-candidate.log`: **233 pass / 1 fail /
|
||||
19 skip**, natural 32.689s. The unchanged broker response-timeout assertion failed;
|
||||
no causal fix, deadline relaxation, serial masking or retry-to-green is claimed.
|
||||
This whole-package run precedes the final target-ID/actual-run completion guard and
|
||||
precise repair copy; final focused/typecheck/PTY cover that delta. Prior 202/4/19
|
||||
evidence remains historical. No further package-wide run was performed.
|
||||
- Failed evidence retained: first/second private fixture runs exposed a reused
|
||||
synthetic credential store across hosts (fixed with separate host-local stores);
|
||||
`focused-final.log` 42/43 retained an obsolete operation-copy assertion;
|
||||
`typecheck-last.log` caught TypeScript's stale narrowing across `handle(start)`.
|
||||
No safety assertion or timeout was weakened. Final checks above supersede them.
|
||||
|
||||
Installed host service/provider/relay/OS-store operations remain fenced. Both installed
|
||||
176497874 exports were untouched. Unchanged native historical 700-test evidence is
|
||||
reused, not rerun. No repository-wide CI, live provider/admission, independent UX
|
||||
acceptance, first-enrollment shared-root resolution or Desktop provider/harness parity
|
||||
is claimed. Larry owns independent Move UX review and those separately tracked scopes.
|
||||
|
||||
@@ -40,7 +40,7 @@ export function catalogTransport(catalog: HostCatalog) {
|
||||
void wire.publish({ ...m, body: { relay: url, profile: m.body } }, retained.owner).catch(() => { if (!closed && current === generation) disconnected?.(1008); });
|
||||
return;
|
||||
}
|
||||
if (!['metadata', 'inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) throw Error('Operation not integrated with private host transport');
|
||||
if (!['metadata', 'inspect', 'save', 'start', 'restart', 'stop', 'move'].includes(m.type)) throw Error('Operation not integrated with private host transport');
|
||||
const host = catalogHost(retained, m.host, Math.floor(Date.now() / 1000), offers);
|
||||
const current = generation;
|
||||
void wire.publish(m, host.request.host).catch(() => { if (!closed && current === generation) disconnected?.(1008); });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { validateGenesis, type Genesis } from './assignment.ts';
|
||||
/** Public exact launch selection; credentials and executable choices remain local. */
|
||||
export type Selection = { harnessSetup?: { id: string; fingerprint: string }; model: string; workspace: string; profile: string; behavior?: Profile; configuration?: { name: string; revision: number } };
|
||||
/** A source-consumed successor. Outer management authentication assumes trusted hosts. */
|
||||
export type Grant = { root: string; predecessor: string; source: string; target: string; agent: string; operation: Message; prepared: string; selection: Selection; targetRevision: number; sourceRun: string | null; materialization?: 'named-v1' };
|
||||
export type Grant = { proof?: string; root: string; predecessor: string; source: string; target: string; agent: string; operation: Message; prepared: string; selection: Selection; targetRevision: number; sourceRun: string | null; materialization?: 'named-v1' };
|
||||
export type Assignment = { genesis: Genesis; assignedHost: string; chain?: Grant[] };
|
||||
export function hash(value: unknown): string { return digest(JSON.stringify(value)).toString('hex'); }
|
||||
export function selection(value: unknown): Selection {
|
||||
@@ -63,7 +63,8 @@ export function validateAssignment(a: Assignment): void {
|
||||
const root = validateGenesis(a.genesis); let holder = root.initialHost, predecessor = hash(root);
|
||||
if (!Array.isArray(a.chain ?? []) || (a.chain?.length ?? 0) > 24) throw Error('Assignment chain limit');
|
||||
for (const g of a.chain ?? []) {
|
||||
fields(object(g), ['root','predecessor','source','target','agent','operation','prepared','selection','targetRevision','sourceRun', ...(g.materialization === undefined ? [] : ['materialization'])]);
|
||||
fields(object(g), ['root','predecessor','source','target','agent','operation','prepared','selection','targetRevision','sourceRun', ...(g.materialization === undefined ? [] : ['materialization']), ...(g.proof === undefined ? [] : ['proof'])]);
|
||||
if (g.proof !== undefined && !/^[0-9a-f]{128}$/.test(g.proof)) throw Error('Invalid successor proof');
|
||||
const op = parseMessage(g.operation); fields(op.body, ['target','targetRevision','selection']);
|
||||
selection(g.selection); text(g.prepared);
|
||||
if (g.root !== hash(root) || g.predecessor !== predecessor || g.source !== holder || g.agent !== root.agent || g.target === holder || g.target !== op.body.target || op.host !== holder || op.agent !== root.agent || op.type !== 'move' || g.targetRevision !== op.body.targetRevision || !sameSelection(g.selection, moveSelection(op.body.selection, g.targetRevision, g.materialization)) || !Number.isSafeInteger(g.targetRevision) || g.targetRevision < 0 || (g.sourceRun !== null && typeof g.sourceRun !== 'string')) throw Error('Invalid consumed grant chain');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { privateExchange, signSuccessor, verifySuccessors } from './private-handoff.ts';
|
||||
import type { Assignment, Grant } from './handoff.ts';
|
||||
import { connectNostr } from './nostr-client.ts';
|
||||
import { hostPairing, type HostPairing, type HostRegistration } from './host-registration.ts';
|
||||
import { message, publicKey, type Message } from './protocol.ts';
|
||||
@@ -11,6 +13,8 @@ export function privateHostTransport(configuration: HostPairing | HostRegistrati
|
||||
if (publicKey(secret) !== retained.request.host) throw Error('Wrong infrastructure signer');
|
||||
let connected = false;
|
||||
return {
|
||||
signGrant: (grant: Grant) => signSuccessor(grant, secret, retained.request.relay),
|
||||
verifyAssignment: (current: Assignment, next: Assignment) => verifySuccessors(current, next, retained.request.relay),
|
||||
get connected() { return connected; },
|
||||
availability: () => message('availability', retained.request.host, '', 0, { configuration: retained.request, observedAt: Date.now() }),
|
||||
binding: { host: retained.request.host, owner: retained.request.owner },
|
||||
@@ -21,7 +25,13 @@ export function privateHostTransport(configuration: HostPairing | HostRegistrati
|
||||
this.validate(url);
|
||||
const wire = connectNostr(url, Buffer.from(secret, 'hex'), input => {
|
||||
const m = input.message;
|
||||
if (input.sender !== retained.request.owner || m.host !== retained.request.host || !['metadata', 'inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) return;
|
||||
if (['prepare', 'prepared', 'grant'].includes(m.type)) {
|
||||
let authenticated: Message;
|
||||
try { authenticated = privateExchange(input, retained.request.owner, retained.request.host); } catch { return; }
|
||||
receive(authenticated);
|
||||
return;
|
||||
}
|
||||
if (input.sender !== retained.request.owner || m.host !== retained.request.host || !['metadata', 'inspect', 'save', 'start', 'restart', 'stop', 'move'].includes(m.type)) return;
|
||||
receive(m);
|
||||
}, () => { connected = false; }); // Existing durable host outbox survives; no automatic launch retry.
|
||||
return {
|
||||
@@ -29,6 +39,11 @@ export function privateHostTransport(configuration: HostPairing | HostRegistrati
|
||||
ready: wire.ready.then(() => wire.publish(message('availability', retained.request.host, '', 0, { configuration: retained.request, observedAt: Date.now() }), retained.request.owner)).then(() => { connected = true; }).catch(error => { connected = false; wire.close(); throw error; }),
|
||||
close: () => { connected = false; wire.close(); },
|
||||
send(m: Message) {
|
||||
if (['prepare', 'prepared', 'grant'].includes(m.type)) {
|
||||
privateExchange({sender: retained.request.host, message: m}, retained.request.owner, m.host);
|
||||
void wire.publish(m, m.host).catch(() => {}); // Exact durable slot exchange/outbox owns retries.
|
||||
return;
|
||||
}
|
||||
if (m.host !== retained.request.host || !['availability', 'inventory', 'receipt'].includes(m.type)) throw Error('Invalid private host report');
|
||||
void wire.publish(m, retained.request.owner).catch(() => {}); // Host outbox/heartbeat retains retry state.
|
||||
},
|
||||
|
||||
@@ -125,7 +125,7 @@ export function loadSlotState(setup: Setup, path: string, agent: string): State
|
||||
return state;
|
||||
}
|
||||
/** Hosts consult durable assignment, never key presence or relay inventory, for authority. */
|
||||
function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string, signal: AbortSignal) => Promise<Setup>, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record<string, Setup>, retiredBindings: Record<string, string> = {}, privateSnapshots = false, metadataCustody?: (signal: AbortSignal) => Promise<{ secret: string; relay: string; authTag: string }>, managementRelay?: string, providerRead = managerCredential) {
|
||||
function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string, signal: AbortSignal) => Promise<Setup>, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record<string, Setup>, retiredBindings: Record<string, string> = {}, privateSnapshots = false, metadataCustody?: (signal: AbortSignal) => Promise<{ secret: string; relay: string; authTag: string }>, managementRelay?: string, providerRead = managerCredential, transfer?: { signGrant?(g: Grant): Grant; verifyAssignment?(current: Assignment, next: Assignment): void }) {
|
||||
// Optional execution credential: a public-only slot (local key copy deliberately
|
||||
// removed) has none. There is no shadow identity and no reconstruction; execution
|
||||
// paths must load it explicitly and fail closed when absent.
|
||||
@@ -308,10 +308,14 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri
|
||||
if (m.type === 'prepare') {
|
||||
fields(m.body, ['assignment','operation']);
|
||||
const assignment = m.body.assignment as Assignment; validateAssignment(assignment);
|
||||
transfer?.verifyAssignment?.(state.assignment, assignment);
|
||||
const op = m.body.operation as Message;
|
||||
// Validate the proposed grant shape through the same chain validator.
|
||||
const g: Grant = { root: hash(assignment.genesis), predecessor: hash(assignment.chain?.at(-1) ?? assignment.genesis), source: op.host, target: setup.host, agent, operation: op, prepared: 'pending', selection: moveSelection(op.body.selection, m.revision, 'named-v1'), materialization: 'named-v1', targetRevision: m.revision, sourceRun: null };
|
||||
validateAssignment({ ...assignment, assignedHost: setup.host, chain: [...(assignment.chain ?? []), g] });
|
||||
const projected = inventory();
|
||||
serializeManagement({ ...projected, body: { ...projected.body, assignmentChain: [...(assignment.chain ?? []), privateSnapshots ? {...g,proof:'0'.repeat(128)} : g], selectedNext:g.selection } },8192);
|
||||
if (Object.hasOwn(state.operations,op.id)) throw Error('Destination operation ID already used');
|
||||
const prior = state.preparations?.[op.id];
|
||||
if (hash(assignment.genesis) !== hash(genesis) || !((state.assignment.chain ?? []).every((g, i) => hash(g) === hash(assignment.chain?.[i]))) || state.phase !== 'stopped' || state.assignment.assignedHost === setup.host || (prior?.reservedRevision ?? m.revision) !== state.revision) throw Error('Destination authority/revision conflict');
|
||||
if (prior) {
|
||||
@@ -369,9 +373,12 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri
|
||||
state.phase = 'stopped'; const sourceRun = state.actual?.run ?? null; state.actual = null;
|
||||
if (retracting(m.revision) || closing) { finishMove('Move cancelled after source Stop; no grant'); return; }
|
||||
const op = pending.request;
|
||||
const grant: Grant = { root: hash(genesis), predecessor: hash(state.assignment.chain?.at(-1) ?? genesis), source: setup.host, target: text(op.body.target), agent, operation: op, prepared: token, selection: effective, ...(materialization ? { materialization } : {}), targetRevision: Number(op.body.targetRevision), sourceRun };
|
||||
let grant: Grant = { root: hash(genesis), predecessor: hash(state.assignment.chain?.at(-1) ?? genesis), source: setup.host, target: text(op.body.target), agent, operation: op, prepared: token, selection: effective, ...(materialization ? { materialization } : {}), targetRevision: Number(op.body.targetRevision), sourceRun };
|
||||
if (privateSnapshots && !transfer?.signGrant) throw Error('Private successor signer unavailable');
|
||||
grant = transfer?.signGrant?.(grant) ?? grant;
|
||||
const assignment: Assignment = { genesis, assignedHost: grant.target, chain: [...(state.assignment.chain ?? []), grant] }; validateAssignment(assignment);
|
||||
const delivery = message('grant', grant.target, agent, grant.targetRevision, { assignment });
|
||||
serializeManagement(delivery);
|
||||
// Irreversible authority consumption + exact grant outbox precede publication.
|
||||
state.assignment = assignment; state.revision++; state.outbox.push(delivery);
|
||||
finishMove('accepted'); publish(delivery);
|
||||
@@ -381,6 +388,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri
|
||||
if (!g || g.target !== setup.host || g.agent !== agent || m.revision !== g.targetRevision) return;
|
||||
const prior = state.incoming?.[hash(g)];
|
||||
if (prior) { publish(prior); return; }
|
||||
transfer?.verifyAssignment?.(state.assignment, next);
|
||||
if (!extendsAssignment(state.assignment, next) || state.assignment.assignedHost === setup.host || state.phase !== 'stopped') return;
|
||||
const prep = state.preparations?.[g.operation.id];
|
||||
if (!prep || hash(prep.request.body.operation) !== hash(g.operation) || prep.token !== g.prepared || prep.reply.body.materialization !== g.materialization) return;
|
||||
@@ -410,10 +418,10 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri
|
||||
if (!livePreparations.has(g.operation.id) || (await prepareLocal(g.selection)).token !== g.prepared || closing || retracting(state.revision)) throw Error('Destination preparation invalidated; assigned here, stopped; repair local setup/key/auth then explicit Start');
|
||||
const start = message('start', setup.host, agent, state.revision); start.id = g.operation.id;
|
||||
await handle(start);
|
||||
outcome = state.operations[start.id]?.reply.body.result;
|
||||
outcome = String(state.phase) === 'running' && state.actual?.run === start.id ? state.operations[start.id]?.reply.body.result : 'Destination did not start this Move; inspect host';
|
||||
} catch (e) { outcome = e instanceof Error ? e.message : 'Destination launch failed'; }
|
||||
livePreparations.delete(g.operation.id); preparationStage = 'consumed; actual launch outcome recorded separately';
|
||||
const completed = message('receipt', setup.host, agent, state.revision, { operation: m.id, fingerprint: hash(m), result: outcome, assignedHost: setup.host, transfer: 'source consumed; destination assigned' });
|
||||
const completed = message('receipt', setup.host, agent, state.revision, { operation: m.id, fingerprint: hash(m), result: outcome, move: g.operation, assignedHost: setup.host, transfer: 'source consumed; destination assigned' });
|
||||
state.incoming[hash(g)] = completed; state.outbox.push(completed); save(); publish(completed); publish(inventory());
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -605,7 +613,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri
|
||||
}
|
||||
|
||||
/** One installation owns every slot, lock and management transport. */
|
||||
export async function host(directory: string, url: string, signal?: AbortSignal, transport?: { availability?(): Message; binding: { host: string; owner: string }; validate(url: string): void; connect(url: string, secret: string, receive: (m: Message) => void, recovered?: () => void): { ready: Promise<void>; send(m: Message): void; close(): void } }, credentials: CredentialBackend = systemCredentials, providerRead = managerCredential) {
|
||||
export async function host(directory: string, url: string, signal?: AbortSignal, transport?: { signGrant?(g: Grant): Grant; verifyAssignment?(current: Assignment, next: Assignment): void; availability?(): Message; binding: { host: string; owner: string }; validate(url: string): void; connect(url: string, secret: string, receive: (m: Message) => void, recovered?: () => void): { ready: Promise<void>; send(m: Message): void; close(): void } }, credentials: CredentialBackend = systemCredentials, providerRead = managerCredential) {
|
||||
signal?.throwIfAborted();
|
||||
(transport?.validate ?? validateRelayURL)(url);
|
||||
let effectiveSettings = readSettings(directory);
|
||||
@@ -678,7 +686,7 @@ export async function host(directory: string, url: string, signal?: AbortSignal,
|
||||
if (!current || current.host !== entry.setup.host || setupOwner(current) !== setupOwner(entry.setup) || !current.agentSecret || !current.conversation?.authTag || current.conversation.relay !== url) throw Error('Existing agent key/owner association/relay required');
|
||||
return { secret: current.agentSecret, relay: current.conversation.relay, authTag: current.conversation.authTag };
|
||||
function hostKeyForMetadata() { return transport?.binding.host ?? entry.setup.host; }
|
||||
}, url, providerRead));
|
||||
}, url, providerRead, transport));
|
||||
}
|
||||
applySettings(effectiveSettings);
|
||||
const setup = entries[0]?.setup;
|
||||
|
||||
@@ -8,13 +8,18 @@ import { digest, fields, message, object, open, publicKey, seal, type Envelope,
|
||||
import { metadataPublicationInterrupted } from './public-metadata.ts';
|
||||
import { readPrivate, writePrivate } from './storage.ts';
|
||||
|
||||
type Intent = { envelope: Envelope; request: Message; receipt?: Message; published: boolean; blocked?: boolean };
|
||||
type Intent = { envelope: Envelope; request: Message; receipt?: Message; destination?: Message; published: boolean; blocked?: boolean };
|
||||
const fingerprint = (m: Message) => digest(JSON.stringify(m)).toString('hex');
|
||||
function matches(request: Message, receipt: Message) {
|
||||
return receipt.type === 'receipt' && receipt.host === request.host && receipt.agent === request.agent
|
||||
&& receipt.body.operation === request.id && receipt.body.fingerprint === fingerprint(request)
|
||||
&& typeof receipt.body.result === 'string' && receipt.body.result.length > 0;
|
||||
}
|
||||
function matchesDestination(request: Message, receipt: Message) {
|
||||
return request.type === 'move' && receipt.type === 'receipt' && receipt.host === request.body.target && receipt.agent === request.agent
|
||||
&& receipt.body.move !== undefined && fingerprint(receipt.body.move as Message) === fingerprint(request)
|
||||
&& receipt.body.transfer === 'source consumed; destination assigned' && typeof receipt.body.result === 'string' && receipt.body.result.length > 0;
|
||||
}
|
||||
/** Local operator journal, scoped to exact relay URL and signer; no agent keys.
|
||||
* Immutable intent files precede all network effects. Receipt files are separate
|
||||
* so another reader's old publication observation cannot erase a terminal result.
|
||||
@@ -47,6 +52,13 @@ export function managementClient(root: string, url: string, secret: string, rece
|
||||
if (!matches(request, receipt)) throw Error('Unmatched journal receipt');
|
||||
intent.receipt = receipt;
|
||||
}
|
||||
const destinationPath = join(dir, `${request.id}.destination`);
|
||||
if (existsSync(destinationPath)) {
|
||||
if (statSync(destinationPath).size > 70000) throw Error('Oversized destination receipt');
|
||||
const destination = open(readPrivate(destinationPath),secret);
|
||||
if (!matchesDestination(request,destination)) throw Error('Unmatched destination receipt');
|
||||
intent.destination = destination;
|
||||
}
|
||||
const blockedPath = join(dir, `${request.id}.blocked`);
|
||||
if (existsSync(blockedPath)) {
|
||||
if (statSync(blockedPath).size > 70000) throw Error('Oversized blocked intent');
|
||||
@@ -62,7 +74,7 @@ export function managementClient(root: string, url: string, secret: string, rece
|
||||
try { transport.sendEnvelope(intent.envelope); } catch { break; } // Intent remains durable.
|
||||
}
|
||||
// Query host outbox too: relay history alone may lack a lost terminal receipt.
|
||||
const slots = new Map([...intents.values()].filter(i => !i.receipt && i.request.type !== 'profile').map(i => [JSON.stringify([i.request.host, i.request.agent]), i.request]));
|
||||
const slots = new Map([...intents.values()].filter(i => i.request.type === 'move' && !i.destination || !i.receipt && i.request.type !== 'profile').map(i => [JSON.stringify([i.request.host, i.request.agent]), i.request]));
|
||||
for (const request of slots.values()) {
|
||||
try { transport.send(message('inspect', request.host, request.agent)); } catch { break; }
|
||||
}
|
||||
@@ -71,6 +83,15 @@ export function managementClient(root: string, url: string, secret: string, rece
|
||||
const connectTransport = privateHosts ? catalogTransport(privateHosts.catalog) : connect;
|
||||
const transport = connectTransport(url, secret, m => {
|
||||
if (m.type === 'receipt') {
|
||||
const transfer = m.body.move as Message | undefined;
|
||||
const moving = transfer && intents.get(transfer.id);
|
||||
if (moving && !moving.destination && matchesDestination(moving.request,m)) {
|
||||
const path = join(dir, `${moving.request.id}.destination`);
|
||||
try { writePrivate(path,seal(m,secret),true); } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; }
|
||||
const persisted = open(readPrivate(path),secret);
|
||||
if (!matchesDestination(moving.request,persisted)) throw Error('Unmatched durable destination receipt');
|
||||
moving.destination = persisted; changed();
|
||||
}
|
||||
const intent = intents.get(String(m.body.operation));
|
||||
if (intent && !intent.receipt && matches(intent.request, m)) {
|
||||
// Persist before exposing completion. Failure propagates, never reports success.
|
||||
@@ -104,7 +125,6 @@ export function managementClient(root: string, url: string, secret: string, rece
|
||||
submit(request: Message) {
|
||||
if (closed) throw Error('UI closed');
|
||||
if (!['metadata','profile','save','start','restart','stop','move'].includes(request.type)) throw Error('Invalid operation');
|
||||
if (privateHosts && !['metadata', 'profile', 'save', 'start', 'restart', 'stop'].includes(request.type)) throw Error('Private Move authority is not integrated; no operation prepared');
|
||||
if (request.type === 'profile') {
|
||||
profile(request.body);
|
||||
if (request.host !== 'profiles' || request.agent !== 'profiles' || request.revision !== 0) throw Error('Invalid profile publication');
|
||||
@@ -130,10 +150,10 @@ export function managementClient(root: string, url: string, secret: string, rece
|
||||
},
|
||||
status() {
|
||||
return [...intents.values()].map(i => ({ request: structuredClone(i.request),
|
||||
state: i.request.type === 'profile' && i.published ? 'completed' : i.receipt ? ((['accepted','saved; running configuration unchanged'].includes(String(i.receipt.body.result)) || (i.request.type === 'metadata' && /^published; signed latest kind0 [a-f0-9]{64}$/.test(String(i.receipt.body.result)))) ? 'completed' : (i.receipt.body.result === 'interrupted-reconcile-locally' || (i.request.type === 'metadata' && i.receipt.body.result === metadataPublicationInterrupted)) ? 'unknown' : 'failed') : i.blocked ? 'unknown' : 'pending',
|
||||
state: i.request.type === 'move' && i.destination ? (i.destination.body.result === 'accepted' ? 'completed' : 'failed') : i.request.type === 'move' && i.receipt?.body.result === 'accepted' ? (i.blocked || transport.socket.readyState !== 1 ? 'unknown' : 'pending') : i.request.type === 'profile' && i.published ? 'completed' : i.receipt ? ((['accepted','saved; running configuration unchanged'].includes(String(i.receipt.body.result)) || (i.request.type === 'metadata' && /^published; signed latest kind0 [a-f0-9]{64}$/.test(String(i.receipt.body.result)))) ? 'completed' : (i.receipt.body.result === 'interrupted-reconcile-locally' || (i.request.type === 'metadata' && i.receipt.body.result === metadataPublicationInterrupted)) ? 'unknown' : 'failed') : i.blocked ? 'unknown' : 'pending',
|
||||
retryAvailable: !i.receipt && !(i.request.type === 'profile' && i.published) && !!i.blocked,
|
||||
publication: i.request.type === 'profile' && i.published ? 'immutable publication observed; no agent application' : i.receipt ? 'host terminal receipt' : i.blocked ? 'relay policy failure; automatic retry disabled; reconcile, then retry after policy repair' : i.published ? 'relay observed; not host admission' : 'unconfirmed',
|
||||
result: i.receipt?.body.result }));
|
||||
result: i.destination ? (i.destination.body.result === 'accepted' ? 'Move complete; destination running at completion.' : `Ownership transferred; destination launch failed: ${i.destination.body.result}`) : i.request.type === 'move' && i.receipt?.body.result === 'accepted' ? 'Source consumed; destination result pending. Check operation results.' : i.receipt?.body.result }));
|
||||
},
|
||||
close() { closed = true; transport.close(); },
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hash, selection } from './handoff.ts';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { fetchRelayDirectory, type DirectoryAgent } from './relay-directory.ts';
|
||||
@@ -31,7 +32,8 @@ export type ManagerResult =
|
||||
|
||||
export type ManagerRequest = { id: number; action: string; values?: Record<string, string>; target?: string; revision?: number };
|
||||
export type ManagerItem = { target?: string; id: string; label: string; detail: string; evidence?: string; disabled?: Record<string, string> };
|
||||
export type ManagerSnapshot = { managementRelay?: 'connected' | 'disconnected'; diagnostics?: ManagerItem[]; local: ManagerItem[]; agents: (ManagerItem & { revision: number; configurations: string[] })[]; routing?: { owner: string; relay: string }; owner?: string; status: string; settings?: Settings; service?: ServiceStatus; hostRelay?: string; relayName?: string; profilePreview?: RegisteredAgent; models?: string[]; runtimeExecutable?: string; harnesses?: DetectedHarness[]; databricksHost?: string };
|
||||
export type MoveDestination = { target: string; revision: number; label: string; reason?: string };
|
||||
export type ManagerSnapshot = { managementRelay?: 'connected' | 'disconnected'; diagnostics?: ManagerItem[]; local: ManagerItem[]; agents: (ManagerItem & { revision: number; configurations: string[]; destinations?: MoveDestination[] })[]; routing?: { owner: string; relay: string }; owner?: string; status: string; settings?: Settings; service?: ServiceStatus; hostRelay?: string; relayName?: string; profilePreview?: RegisteredAgent; models?: string[]; runtimeExecutable?: string; harnesses?: DetectedHarness[]; databricksHost?: string };
|
||||
const short = (s: string) => s.length > 22 ? `${s.slice(0,8)}…${s.slice(-6)}` : s;
|
||||
|
||||
/** Final plain display text thrown by this boundary; never rewrapped or retranslated. */
|
||||
@@ -182,7 +184,7 @@ export class ManagerController {
|
||||
const fresh = this.fresh(m);
|
||||
const blocked = !fresh ? 'The host report is old or the host cannot be reached. Wait for a recent report before you act.' : this.client?.status().some(o => o.request.host === m.host && o.request.agent === m.agent && !['completed','failed'].includes(o.state)) ? 'An operation has no confirmed result. Inspect operations before you act.' : '';
|
||||
return { id, label: `Agent ${short(m.agent)} · ${fresh ? m.body.phase : 'Unknown'}`, revision: m.revision, configurations: Object.keys((m.body.configurations ?? {}) as object),
|
||||
disabled: { 'select-config': blocked, stop: blocked, restart: blocked || (m.body.assignedHost !== m.host || !['stopped', 'running'].includes(String(m.body.phase)) ? 'Restart requires a recent report from the assigned host.' : ''), start: blocked || (m.body.phase !== 'stopped' || m.body.actualRun || m.body.assignedHost !== m.host ? 'Start requires a recent report from the assigned host. It must report the agent stopped with no current run.' : '') },
|
||||
disabled: { move: blocked || (m.body.assignedHost !== m.host || !['stopped','running'].includes(String(m.body.phase)) ? 'Move requires the assigned host.' : ''), 'select-config': blocked, stop: blocked, restart: blocked || (m.body.assignedHost !== m.host || !['stopped', 'running'].includes(String(m.body.phase)) ? 'Restart requires a recent report from the assigned host.' : ''), start: blocked || (m.body.phase !== 'stopped' || m.body.actualRun || m.body.assignedHost !== m.host ? 'Start requires a recent report from the assigned host. It must report the agent stopped with no current run.' : '') },
|
||||
evidence: JSON.stringify(m, null, 2), detail: `Agent ${short(m.agent)}
|
||||
Host ${short(m.host)}
|
||||
${fresh ? 'Recent host report' : 'Current status unknown. The report is old or the host cannot be reached. Last report:'}
|
||||
@@ -202,7 +204,7 @@ This choice does not change the current run.` };
|
||||
let registered = false;
|
||||
try { registered = readSettings(this.hostDirectory).agents.some(a => a.publicKey === agent.publicKey); } catch { /* Configuration error is shown in Local Host. */ }
|
||||
const unavailable = 'No unique assigned host report. Execution authority is unknown.';
|
||||
return { id:agent.publicKey,target:shown?.id,label:`${agent.name} · ${shown ? this.fresh(report!) ? report!.body.phase : 'Unknown' : 'Unknown'}`,revision:shown?.revision ?? -1,configurations:shown?.configurations ?? [],disabled:shown?.disabled ?? { start:unavailable,stop:unavailable,restart:unavailable,'select-config':unavailable },evidence:JSON.stringify({ discovery:agent,report },null,2),detail:`${agent.name}
|
||||
return { destinations: report ? this.moveDestinations(report) : [], id:agent.publicKey,target:shown?.id,label:`${agent.name} · ${shown ? this.fresh(report!) ? report!.body.phase : 'Unknown' : 'Unknown'}`,revision:shown?.revision ?? -1,configurations:shown?.configurations ?? [],disabled:shown?.disabled ?? { start:unavailable,stop:unavailable,restart:unavailable,move:unavailable,'select-config':unavailable },evidence:JSON.stringify({ discovery:agent,report },null,2),detail:`${agent.name}
|
||||
Agent: ${agent.publicKey}
|
||||
Registered here: ${registered ? 'Yes' : 'No'}
|
||||
Relay presence: ${agent.status} (discovery only)
|
||||
@@ -216,7 +218,7 @@ Host ${short(id)}
|
||||
${this.fresh(m) ? 'Recent host report' : 'Current status unknown. The report is old or the host cannot be reached.'}
|
||||
${describe(m.body, availabilityLabels)}
|
||||
A host report does not grant permission to start an agent.` });
|
||||
for (const operation of this.client?.status() ?? []) diagnostics.push({ id: `operation:${operation.request.id}`, label: `${plainOperationType(operation.request)} · ${plainStates[operation.state] ?? operation.state} · ${operation.request.id.slice(0,8)}`, evidence: JSON.stringify(operation, null, 2), detail: `Operation: ${plainOperationType(operation.request)}
|
||||
for (const operation of this.operationStatus()) diagnostics.push({ id: `operation:${operation.request.id}`, label: `${plainOperationType(operation.request)} · ${plainStates[operation.state] ?? operation.state} · ${operation.request.id.slice(0,8)}`, evidence: JSON.stringify(operation, null, 2), detail: `Operation: ${plainOperationType(operation.request)}
|
||||
State: ${plainStates[operation.state] ?? operation.state}
|
||||
Host: ${short(operation.request.host)}
|
||||
Agent: ${short(operation.request.agent)}
|
||||
@@ -236,6 +238,21 @@ A Stop result is not a recent host report that confirms the agent is stopped.` }
|
||||
return { managementRelay:this.owner ? this.client?.connected ? 'connected' : 'disconnected' : undefined, settings, service: this.service, hostRelay, relayName: this.relayName && this.relayName.relay === footerRelay ? this.relayName.name : undefined, profilePreview: this.profilePreview, models: this.models, runtimeExecutable: this.runtimeExecutable, harnesses: this.harnesses, databricksHost: this.databricksHost, local, agents, diagnostics, routing: routing ? { owner: routing.owner, relay: routing.relay } : undefined, owner: this.owner, status: this.status + (this.owner ? `\n${this.directoryState}` : '') };
|
||||
}
|
||||
|
||||
private moveDestinations(source: Message): MoveDestination[] {
|
||||
return [...this.offers.values()].filter(m => m.host !== source.host).map(offer => {
|
||||
const target = JSON.stringify([offer.host,source.agent]), report = this.inventory.get(target);
|
||||
const label = String((offer.body.configuration as {label?:string})?.label ?? short(offer.host));
|
||||
let reason: string | undefined;
|
||||
if (!this.fresh(offer) || !report || !this.fresh(report)) reason = `On ${label}, provision this agent's matching key and pinned assignment with a local runtime, then Refresh. No credentials are transferred.`;
|
||||
else if (hash(report.body.genesis) !== hash(source.body.genesis)) reason = 'Destination has a conflicting pinned assignment. Reconcile on that host.';
|
||||
else if (report.body.localKey !== 'present') reason = `On ${label}, stop its host service and run beehive import-agent-key <host-directory> ${source.agent}. Enter the matching key only there, restart that host and Refresh. Source will not stop.`;
|
||||
else if (report.body.phase !== 'stopped' || report.body.actualRun || report.body.assignedHost === report.host) reason = 'Destination is not an eligible stopped standby.';
|
||||
const selected = report?.body.selectedNext as {harnessSetup?:{id:string}; model?:string} | undefined;
|
||||
return {target,revision:report?.revision ?? -1,label:`${label} · ${selected?.harnessSetup?.id ?? 'default runtime'} · ${selected?.model ?? 'setup required'}`,reason};
|
||||
});
|
||||
}
|
||||
private operationStatus() { return this.client?.status() ?? []; }
|
||||
|
||||
private agentReport(agent: string) {
|
||||
const rows = [...this.inventory.values()].filter(m => m.agent === agent && m.body.assignedHost === m.host);
|
||||
return rows.length === 1 ? rows[0] : undefined;
|
||||
@@ -442,8 +459,8 @@ A Stop result is not a recent host report that confirms the agent is stopped.` }
|
||||
this.status = 'Private instructions draft saved on this computer. Not published or used by an agent. To resume, use beehive drafts ~/.beehive/owner or beehive tui.';
|
||||
} else if (request.action === 'reconcile') { this.client?.reconcile(); this.status = 'Checking operation results. Operations blocked by relay policy will not be retried.';
|
||||
} else if (request.action === 'operations') {
|
||||
this.status = this.client?.status().map(o => `${o.request.id} · ${o.request.host}/${o.request.agent} · ${plainOperationType(o.request)} · ${plainStates[o.state] ?? o.state}: ${operationText(o.result ?? o.publication)}`).join('\n') || 'No saved operations.';
|
||||
} else if (['start', 'stop', 'restart', 'select-config', 'select-runtime'].includes(request.action)) {
|
||||
this.status = this.operationStatus().slice(-5).reverse().map(o => `${plainOperationType(o.request)} · ${plainStates[o.state] ?? o.state} · ${o.request.id.slice(0,8)}\n${operationText(o.result ?? o.publication)}`).join('\n') || 'No saved operations.';
|
||||
} else if (['start', 'stop', 'restart', 'move', 'select-config', 'select-runtime'].includes(request.action)) {
|
||||
if (!this.owner || !this.client) throw plain('Owner sign-in required');
|
||||
const candidate = this.inventory.get(request.target ?? '');
|
||||
const current = candidate && this.directory.some(a => a.publicKey === candidate.agent) && this.agentReport(candidate.agent) === candidate ? candidate : undefined;
|
||||
@@ -461,6 +478,19 @@ A Stop result is not a recent host report that confirms the agent is stopped.` }
|
||||
return {state:'registration-required',continuation:this.continuation.token,agent:current.agent};
|
||||
}
|
||||
}
|
||||
if (request.action === 'move') {
|
||||
const destination = this.moveDestinations(current).find(d => d.target === v.destination);
|
||||
if (!destination || destination.reason || destination.revision !== Number(v.destinationRevision)) throw plain(destination?.reason ?? 'Destination changed. Select it again. No Move sent.');
|
||||
if (!['stopped','running'].includes(String(current.body.phase))) throw plain('Source ownership is unknown. No Move sent.');
|
||||
const target = this.inventory.get(destination.target)!;
|
||||
const selected = selection(target.body.selectedNext), source = selection(current.body.selectedNext);
|
||||
selected.profile = source.profile;
|
||||
if (source.behavior) selected.behavior = source.behavior; else delete selected.behavior;
|
||||
const operation = message('move',current.host,current.agent,current.revision,{target:target.host,targetRevision:target.revision,selection:selected});
|
||||
check(); this.client.submit(operation);
|
||||
this.status = `Move submitted (${operation.id}). Source remains assigned until preparation and verified Stop. Destination launch is reported separately.`;
|
||||
return {state:'submitted',operationId:operation.id};
|
||||
}
|
||||
if (request.action === 'select-runtime') {
|
||||
if (!existsSync(join(this.hostDirectory,'host-identity.json')) || readHostIdentityPublic(this.hostDirectory).pairing.host !== current.host) throw plain('Select a runtime on its local host.');
|
||||
const runtime = readSettings(this.hostDirectory).runtimes.find(r => r.id === v.runtime);
|
||||
|
||||
@@ -126,6 +126,18 @@ function render() {
|
||||
const result = await request(action, undefined, row.target, row.revision);
|
||||
if (result.state === 'registration-required') await registerForm(result.continuation,result.agent);
|
||||
} })),
|
||||
{ label: 'Move…', disabled: eligibility('move'), run: async () => {
|
||||
const row = snapshot.agents.find(r => r.id === selected); if (!row) return;
|
||||
const destinations = row.destinations ?? [];
|
||||
if (!destinations.length) { screen.notice('No destination hosts. Configure and start another host for this owner and relay, then Refresh.'); return; }
|
||||
const labels = destinations.map(d => `${d.label}${d.reason ? ' · Setup required' : ''}`);
|
||||
const chosen = await screen.choose('Move · destination host / runtime',labels); if (chosen === undefined) return;
|
||||
const destination = destinations[labels.indexOf(chosen)]; if (!destination) return;
|
||||
if (destination.reason) { screen.notice(destination.reason); return; }
|
||||
if (!await screen.confirm(`Move agent to ${destination.label}?
|
||||
The source stops before destination launch. Workspace, session and credentials stay on their hosts.`)) return;
|
||||
await request('move',{destination:destination.target,destinationRevision:String(destination.revision)},row.target,row.revision);
|
||||
} },
|
||||
{ label: 'Inspect operations', run: () => request('operations') },
|
||||
{ label: 'Check operation results', run: () => request('reconcile') },
|
||||
{ label: 'Sign out', run: () => request('signout') },
|
||||
|
||||
@@ -34,8 +34,8 @@ export function unwrapManagement(value: unknown, secret: Uint8Array): Authentica
|
||||
if (rumor.pubkey !== seal.pubkey || rumor.kind !== 14 || typeof rumor.content !== 'string' || Buffer.byteLength(rumor.content) > MANAGEMENT_WIRE_BYTES || JSON.stringify(rumor.tags) !== JSON.stringify([['p', recipient], ['subject', domain]]) || rumor.id !== getEventHash(rumor as unknown as Event)) throw Error('Invalid management rumor');
|
||||
return { sender: seal.pubkey, message: parseMessage(JSON.parse(rumor.content)) };
|
||||
}
|
||||
/** Tracer authority boundary. Host-to-host Move traffic is intentionally denied until
|
||||
* source authority + owner-attested host catalog is integrated with retained journals.
|
||||
/** Legacy tracer authority boundary. Peer exchanges are authenticated separately
|
||||
* by privateHostTransport/privateExchange and the retained signed-successor checks.
|
||||
*/
|
||||
export function authorizeManagement(input: AuthenticatedMessage, owner: string, hosts: Readonly<Record<string, string>>, role: { host: string } | 'owner'): Message {
|
||||
const { sender, message } = input;
|
||||
|
||||
51
beehive/src/private-handoff.ts
Normal file
51
beehive/src/private-handoff.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { schnorr } from '@noble/curves/secp256k1';
|
||||
import { digest, publicKey, type Message } from './protocol.ts';
|
||||
import { hash, validateAssignment, type Assignment, type Grant } from './handoff.ts';
|
||||
import type { AuthenticatedMessage } from './nostr-codec.ts';
|
||||
|
||||
const proofBytes = (grant: Grant, relay: string) => {
|
||||
const { proof: _proof, ...unsigned } = grant;
|
||||
return digest(JSON.stringify(['beehive-successor-v1', relay, unsigned]));
|
||||
};
|
||||
/** Sign before consuming authority and persisting the exact successor outbox. */
|
||||
export function signSuccessor(grant: Grant, secret: string, relay: string): Grant {
|
||||
if (grant.source !== publicKey(secret) || grant.proof) throw Error('Wrong successor signer');
|
||||
return { ...grant, proof: Buffer.from(schnorr.sign(proofBytes(grant, relay), secret)).toString('hex') };
|
||||
}
|
||||
/** Trust only the locally pinned prefix; every unseen successor needs its source
|
||||
* signature. A public directory/availability claim can never extend authority. */
|
||||
export function verifySuccessors(current: Assignment, next: Assignment, relay: string): void {
|
||||
validateAssignment(current); validateAssignment(next);
|
||||
const prefix = current.chain ?? [], chain = next.chain ?? [];
|
||||
if (hash(current.genesis) !== hash(next.genesis) || prefix.length > chain.length || !prefix.every((g, i) => hash(g) === hash(chain[i]))) throw Error('Conflicting pinned assignment');
|
||||
for (const g of chain.slice(prefix.length)) {
|
||||
if (!g.proof || !/^[0-9a-f]{128}$/.test(g.proof) || !/^[0-9a-f]{64}$/.test(g.source) || !schnorr.verify(g.proof, proofBytes(g, relay), g.source)) throw Error('Unsigned or invalid successor');
|
||||
}
|
||||
}
|
||||
/** Authenticate the immediate peer. The slot additionally verifies this claimed
|
||||
* chain against its retained prefix before preparation or grant acceptance. */
|
||||
export function privateExchange(input: AuthenticatedMessage, owner: string, host: string): Message {
|
||||
const m = input.message;
|
||||
if (m.host !== host) throw Error('Wrong exchange recipient');
|
||||
let assignment: Assignment, sender: string;
|
||||
if (m.type === 'prepare') {
|
||||
assignment = m.body.assignment as Assignment;
|
||||
const op = m.body.operation as Message;
|
||||
sender = assignment.assignedHost;
|
||||
if (op.host !== sender || op.agent !== m.agent || op.type !== 'move' || op.body.target !== host || op.body.targetRevision !== m.revision) throw Error('Wrong preparation pair');
|
||||
} else if (m.type === 'prepared') {
|
||||
const prepare = m.body.prepare as Message;
|
||||
assignment = prepare.body.assignment as Assignment;
|
||||
const op = prepare.body.operation as Message;
|
||||
sender = prepare.host;
|
||||
if (prepare.type !== 'prepare' || prepare.agent !== m.agent || op.host !== host || op.body.target !== sender || op.agent !== m.agent || op.revision !== m.revision || assignment.assignedHost !== host) throw Error('Wrong prepared pair');
|
||||
} else if (m.type === 'grant') {
|
||||
assignment = m.body.assignment as Assignment;
|
||||
const grant = assignment.chain?.at(-1);
|
||||
if (!grant || grant.target !== host || grant.agent !== m.agent) throw Error('Wrong grant pair');
|
||||
sender = grant.source;
|
||||
} else throw Error('Not a peer exchange');
|
||||
validateAssignment(assignment);
|
||||
if (input.sender !== sender || sender === host || assignment.genesis.owner !== owner || assignment.genesis.agent !== m.agent) throw Error('Wrong exchange authority');
|
||||
return m;
|
||||
}
|
||||
@@ -140,7 +140,7 @@ test('manager presentation preserves raw evidence, unknown states and literal co
|
||||
assert.match(shown.detail, /Host result: new backend diagnostic/);
|
||||
assert.deepEqual(JSON.parse(shown.evidence!), operation);
|
||||
await f.controller.request({ id: 2, action: 'operations' });
|
||||
assert.match(f.snapshot.status, /Choose configuration · Unknown: new backend diagnostic/);
|
||||
assert.ok(f.snapshot.status.startsWith(`Choose configuration · Unknown · ${request.id.slice(0,8)}\nnew backend diagnostic`));
|
||||
await f.controller.request({ id: 3, action: 'reconcile' });
|
||||
assert.match(f.snapshot.status, /Checking operation results. Operations blocked by relay policy will not be retried./);
|
||||
assert.equal(f.submitted.length, 0);
|
||||
|
||||
163
beehive/test/private-move.test.ts
Normal file
163
beehive/test/private-move.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { registerHooks } from 'node:module';
|
||||
const stopFault = { active:false };
|
||||
Object.assign(globalThis,{__beehivePrivateMoveStopFault:stopFault});
|
||||
registerHooks({load(url,context,next){
|
||||
const result=next(url,context);
|
||||
if (!url.endsWith('/src/owned.ts')) return result;
|
||||
const source=String(result.source), marker='async stop() {';
|
||||
if(!source.includes(marker)) throw Error('Owned Stop test seam changed');
|
||||
return {...result,source:source.replace(marker,marker+" if ((globalThis as any).__beehivePrivateMoveStopFault.active) throw Error('Synthetic owned Stop failure');")};
|
||||
}});
|
||||
const {registrationFixture}=await import('./registration-fixture.ts');
|
||||
import { managementClient } from '../src/intents.ts';
|
||||
import { message, newKey, publicKey, type Message } from '../src/protocol.ts';
|
||||
import { signSuccessor, verifySuccessors, privateExchange } from '../src/private-handoff.ts';
|
||||
import { createGenesis } from '../src/assignment.ts';
|
||||
import { hash, type Assignment, type Grant } from '../src/handoff.ts';
|
||||
async function until(fn:()=>boolean) { for(let n=0;n<400;n++) { if(fn()) return; await delay(20); } throw Error('Private Move evidence missing'); }
|
||||
|
||||
test('private two-host owned-process transfer, exact replay and reverse signed successor',async t=>{
|
||||
const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-')));
|
||||
const f=await registrationFixture(home,()=>{},true,true).catch(error=>{console.error(error);throw error;});
|
||||
const inventory=new Map<string,Message>();
|
||||
const c=managementClient(join(home,'client'),f.url,f.ownerSecret,m=>{if(m.type==='inventory')inventory.set(m.host,m);},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}});
|
||||
t.after(async()=>{c.close();await f.close();rmSync(home,{recursive:true,force:true});});
|
||||
await c.ready; await until(()=>inventory.size===2);
|
||||
const source=()=>inventory.get(f.host)!, dest=()=>inventory.get(f.destination!.host)!;
|
||||
const send=async(m:Message)=>{c.submit(m);await until(()=>c.status().some(s=>s.request.id===m.id&&s.state!=='pending'));return c.status().find(s=>s.request.id===m.id)!;};
|
||||
assert.equal((await send(message('start',f.host,f.agent,source().revision))).state,'completed');
|
||||
await until(()=>source().body.phase==='running');
|
||||
const original=source().body.actualRun;
|
||||
const prior=message('save',dest().host,f.agent,dest().revision,dest().body.selectedNext as Record<string,unknown>);
|
||||
assert.equal((await send(prior)).state,'completed');await until(()=>dest().revision>prior.revision);
|
||||
const duplicate=message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext});duplicate.id=prior.id;
|
||||
const other=managementClient(join(home,'other-controller'),f.url,f.ownerSecret,()=>{},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}});
|
||||
t.after(()=>other.close());await other.ready;await delay(100);other.submit(duplicate);
|
||||
await until(()=>other.status().some(o=>o.request.id===duplicate.id&&o.state==='failed'));
|
||||
assert.deepEqual(source().body.actualRun,original,'cross-controller destination ID conflict cannot stop source');
|
||||
|
||||
const move=message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext});
|
||||
assert.equal((await send(move)).state,'completed');
|
||||
await until(()=>dest().body.phase==='running'&&source().body.phase==='stopped');
|
||||
assert.equal(source().body.actualRun,null);assert.notDeepEqual(dest().body.actualRun,original);
|
||||
assert.equal(dest().body.assignedHost,dest().host);
|
||||
const chain=dest().body.assignmentChain as Grant[];assert.equal(chain.length,1);assert.ok(chain[0]!.proof);
|
||||
const before=JSON.stringify(dest().body.actualRun);
|
||||
c.reconcile();await delay(250);assert.equal(JSON.stringify(dest().body.actualRun),before);
|
||||
assert.throws(()=>c.submit(move),/already prepared/);
|
||||
assert.equal((await send(message('start',f.host,f.agent,source().revision))).state,'failed');
|
||||
const reverse=message('move',dest().host,f.agent,dest().revision,{target:f.host,targetRevision:source().revision,selection:source().body.selectedNext});
|
||||
assert.equal((await send(reverse)).state,'completed');
|
||||
await until(()=>source().body.phase==='running'&&dest().body.phase==='stopped');
|
||||
assert.equal((source().body.assignmentChain as Grant[]).length,2);
|
||||
assert.equal((await send(message('stop',f.host,f.agent,source().revision))).state,'completed');
|
||||
assert.equal(c.status().find(s=>s.request.id===move.id)!.state,'completed','later Stop/Move cannot rewrite historical completion');
|
||||
c.close();
|
||||
const reopened=managementClient(join(home,'client'),f.url,f.ownerSecret,()=>{},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}});
|
||||
t.after(()=>reopened.close());await reopened.ready;
|
||||
assert.equal(reopened.status().find(s=>s.request.id===move.id)!.state,'completed','durable destination receipt survives controller reopen');
|
||||
assert.ok(f.relay.independentWraps>0);assert.deepEqual(f.errors,[]);
|
||||
});
|
||||
|
||||
test('unseen successors require exact source/relay signature and authenticated pair',()=>{
|
||||
const secret=newKey(),source=publicKey(secret),owner=publicKey(newKey()),agent=publicKey(newKey()),target=publicKey(newKey());
|
||||
const genesis=createGenesis(owner,agent,source), current:Assignment={genesis,assignedHost:source};
|
||||
const selection={model:'fixture',workspace:'/tmp',profile:'default'};
|
||||
const op=message('move',source,agent,0,{target,targetRevision:0,selection});
|
||||
const unsigned:Grant={root:hash(genesis),predecessor:hash(genesis),source,target,agent,operation:op,prepared:'token',selection,targetRevision:0,sourceRun:null};
|
||||
const grant=signSuccessor(unsigned,secret,'wss://fixture.invalid');
|
||||
const next:Assignment={genesis,assignedHost:target,chain:[grant]};
|
||||
verifySuccessors(current,next,'wss://fixture.invalid');
|
||||
assert.throws(()=>verifySuccessors(current,{...next,chain:[unsigned]},'wss://fixture.invalid'),/Unsigned/);
|
||||
assert.throws(()=>verifySuccessors(current,next,'wss://other.invalid'),/invalid successor/);
|
||||
assert.throws(()=>verifySuccessors(current,{...next,chain:[{...grant,sourceRun:'forged'}]},'wss://fixture.invalid'),/invalid successor/);
|
||||
const m=message('grant',target,agent,0,{assignment:next});
|
||||
assert.equal(privateExchange({sender:source,message:m},owner,target),m);
|
||||
assert.throws(()=>privateExchange({sender:target,message:m},owner,target),/authority/);
|
||||
assert.throws(()=>privateExchange({sender:source,message:m},publicKey(newKey()),target),/authority/);
|
||||
});
|
||||
|
||||
test('private destination failure and stale CAS preserve source; cancelled late preparation cannot transfer; lost grant recovers once',async t=>{
|
||||
const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-faults-')));
|
||||
const f=await registrationFixture(home,()=>{},true,true);
|
||||
const inventory=new Map<string,Message>();
|
||||
const c=managementClient(join(home,'client'),f.url,f.ownerSecret,m=>{if(m.type==='inventory')inventory.set(m.host,m);},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}});
|
||||
t.after(async()=>{c.close();await f.close();rmSync(home,{recursive:true,force:true});});
|
||||
await c.ready;await until(()=>inventory.size===2);
|
||||
const source=()=>inventory.get(f.host)!,dest=()=>inventory.get(f.destination!.host)!;
|
||||
const outcome=async(m:Message)=>{c.submit(m);await until(()=>c.status().some(s=>s.request.id===m.id&&s.state!=='pending'));return c.status().find(s=>s.request.id===m.id)!;};
|
||||
await outcome(message('start',f.host,f.agent,source().revision));await until(()=>source().body.phase==='running');
|
||||
const actual=source().body.actualRun;
|
||||
const move=()=>message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext});
|
||||
const stale=move();stale.body.targetRevision=100;
|
||||
assert.equal((await outcome(stale)).state,'failed');assert.deepEqual(source().body.actualRun,actual);
|
||||
const {credentialReference}=await import('../src/credential-store.ts');
|
||||
const key=credentialReference('agent',f.agent),secret=f.destination!.credentials.read(key)!;
|
||||
f.destination!.credentials.remove(key);
|
||||
assert.equal((await outcome(move())).state,'failed');assert.deepEqual(source().body.actualRun,actual);
|
||||
f.destination!.credentials.create(key,secret);
|
||||
f.setHoldPrepared(true);const cancelled=move();c.submit(cancelled);await until(()=>f.heldPrepared>0);
|
||||
await outcome(message('stop',f.host,f.agent,source().revision));f.releasePrepared();await until(()=>source().body.phase==='stopped');await delay(100);
|
||||
assert.equal(source().body.assignedHost,f.host);assert.equal(dest().body.phase,'stopped');
|
||||
assert.equal(c.status().find(s=>s.request.id===cancelled.id)!.state,'failed');
|
||||
await until(()=>dest().revision>0);
|
||||
f.setDropGrant(true);const recover=move();c.submit(recover);
|
||||
await until(()=>source().body.assignedHost===dest().host);
|
||||
assert.equal(dest().body.phase,'stopped','lost grant does not manufacture destination authority');
|
||||
assert.equal(c.status().find(s=>s.request.id===recover.id)!.state,'pending');
|
||||
f.setDropGrant(false);c.reconcile();await until(()=>dest().body.phase==='running');
|
||||
const run=dest().body.actualRun;c.reconcile();await delay(200);assert.deepEqual(dest().body.actualRun,run);
|
||||
f.setDropGrant(true);
|
||||
const reverse=message('move',dest().host,f.agent,dest().revision,{target:source().host,targetRevision:source().revision,selection:source().body.selectedNext});
|
||||
c.submit(reverse);await until(()=>dest().body.assignedHost===source().host);
|
||||
f.credentials.remove(key);f.setDropGrant(false);c.reconcile();
|
||||
await until(()=>c.status().find(s=>s.request.id===reverse.id)?.state==='failed');
|
||||
await until(()=>source().body.assignedHost===source().host&&source().body.phase==='stopped');
|
||||
assert.equal(source().body.actualRun,null);assert.equal(dest().body.actualRun,null);
|
||||
f.credentials.create(key,secret);
|
||||
assert.equal((await outcome(message('start',source().host,f.agent,source().revision))).state,'completed');
|
||||
|
||||
});
|
||||
|
||||
test('relay-listed agent Move uses typed submission and observes destination before completion',async t=>{
|
||||
const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-manager-')));
|
||||
let snapshot: import('../src/manager-controller.ts').ManagerSnapshot;
|
||||
const f=await registrationFixture(home,s=>{snapshot=s;},true,true);
|
||||
t.after(async()=>{await f.close();rmSync(home,{recursive:true,force:true});});
|
||||
let id=0;const request=(action:string,values?:Record<string,string>,target?:string,revision?:number)=>f.controller.request({id:++id,action,values,target,revision});
|
||||
await request('signin');const row=()=>snapshot!.agents.find(a=>a.id===f.agent)!;
|
||||
await until(()=>!!row()?.target&&!!row()?.destinations?.length);
|
||||
f.setDropGrant(true);
|
||||
const d=row().destinations![0]!;
|
||||
const r=await request('move',{destination:d.target,destinationRevision:String(d.revision)},row().target,row().revision);
|
||||
assert.equal(r.state,'submitted',snapshot!.status);if(r.state!=='submitted')return;
|
||||
await until(()=>snapshot!.diagnostics!.some(o=>o.id===`operation:${r.operationId}`&&o.label.includes('Pending')));
|
||||
f.setDropGrant(false);await request('reconcile');
|
||||
await until(()=>row().label.includes('running')&&row().target===d.target);
|
||||
await until(()=>snapshot!.diagnostics!.some(o=>o.id===`operation:${r.operationId}`&&o.detail.includes('Move complete; destination running')));
|
||||
assert.ok(!JSON.stringify(snapshot!).includes(f.nsec));
|
||||
});
|
||||
|
||||
test('failed owned source Stop retains quarantine, actual run and source authority; explicit repaired Stop recovers without destination execution',async t=>{
|
||||
const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-stop-')));
|
||||
const f=await registrationFixture(home,()=>{},true,true),inventory=new Map<string,Message>();
|
||||
const c=managementClient(join(home,'client'),f.url,f.ownerSecret,m=>{if(m.type==='inventory')inventory.set(m.host,m);},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}});
|
||||
t.after(async()=>{stopFault.active=false;c.close();await f.close();rmSync(home,{recursive:true,force:true});});
|
||||
await c.ready;await until(()=>inventory.size===2);
|
||||
const source=()=>inventory.get(f.host)!,dest=()=>inventory.get(f.destination!.host)!;
|
||||
const outcome=async(m:Message)=>{c.submit(m);await until(()=>c.status().some(s=>s.request.id===m.id&&s.state!=='pending'));return c.status().find(s=>s.request.id===m.id)!;};
|
||||
await outcome(message('start',f.host,f.agent,source().revision));await until(()=>source().body.phase==='running');
|
||||
const actual=source().body.actualRun;stopFault.active=true;
|
||||
const move=message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext});
|
||||
assert.equal((await outcome(move)).state,'failed');
|
||||
await until(()=>source().body.phase==='quarantined');
|
||||
assert.deepEqual(source().body.actualRun,actual);assert.equal(source().body.assignedHost,f.host);assert.equal(dest().body.phase,'stopped');
|
||||
stopFault.active=false;assert.equal((await outcome(message('stop',f.host,f.agent,source().revision))).state,'completed');
|
||||
await until(()=>source().body.phase==='stopped');assert.equal(source().body.actualRun,null);assert.equal(dest().body.actualRun,null);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { schnorr } from '@noble/curves/secp256k1';
|
||||
import { finalizeEvent, verifyEvent, type Event } from 'nostr-tools/pure';
|
||||
import { nsecEncode } from 'nostr-tools/nip19';
|
||||
import { newKey, publicKey, digest } from '../src/protocol.ts';
|
||||
import { newKey, publicKey, digest, type Message } from '../src/protocol.ts';
|
||||
import { ManagerController, type ManagerSnapshot } from '../src/manager-controller.ts';
|
||||
import { fetchRelayDirectory } from '../src/relay-directory.ts';
|
||||
import { managementClient } from '../src/intents.ts';
|
||||
@@ -23,7 +23,7 @@ import { nostrFixture } from './nostr-fixture.ts';
|
||||
/** Product-shaped synthetic fixture. Real private relay, owner authentication,
|
||||
* host and owned ACP process. Public assignment is pre-provisioned explicitly by
|
||||
* this test, NOT derived by registration from directory metadata or nsec custody. */
|
||||
export async function registrationFixture(home: string, changed: (s: ManagerSnapshot) => void, assigned = true) {
|
||||
export async function registrationFixture(home: string, changed: (s: ManagerSnapshot) => void, assigned = true, move = false) {
|
||||
home = realpathSync(home);
|
||||
if (!home.includes('beehive-pairing-cli-')) throw Error('Fresh explicit fixture HOME required');
|
||||
const credentials = isolatedFileCredentials(join(home,'credentials.json'));
|
||||
@@ -55,7 +55,8 @@ export async function registrationFixture(home: string, changed: (s: ManagerSnap
|
||||
createCredential('owner',ownerSecret,credentials);
|
||||
createControllerConfig(join(home,'.beehive','owner'),owner,url);
|
||||
const setup = {host:identity.pairing.host,ownerPublic:owner,runner:realpathSync(process.execPath),args:['-e','setInterval(()=>{},1000)'],workspace:home,mode:'fixture' as const,serviceHome:home,configDirectory:home};
|
||||
if (assigned) provisionCredentialSlot(directory,setup,agentSecret,createGenesis(owner,agent,identity.pairing.host),credentials);
|
||||
const genesis = createGenesis(owner,agent,identity.pairing.host);
|
||||
if (assigned) provisionCredentialSlot(directory,setup,agentSecret,genesis,credentials);
|
||||
let providerKey: string | null = null;
|
||||
addOpenAI(directory,'Review OpenAI','synthetic-provider-key',{read:()=>providerKey,create:(_r,v)=>{providerKey=v;}});
|
||||
const executable = join(home,'synthetic-acp');
|
||||
@@ -63,7 +64,30 @@ export async function registrationFixture(home: string, changed: (s: ManagerSnap
|
||||
const settings = readSettings(directory), runtimeId = 'review-runtime';
|
||||
saveSettings(directory,{...settings,runtimes:[{id:runtimeId,name:'Review runtime',harness:'buzz-agent',executable,providerId:settings.providers[0]!.id,model:'gpt-5',effort:'high',environment:{REVIEW_MODE:'synthetic'}}]},settings.revision);
|
||||
let providerFailure = false;
|
||||
const service = await host(directory,url,undefined,privateHostTransport(identity.pairing,identity.secret),credentials,async (_input,signal) => { signal.throwIfAborted(); if (providerFailure) throw Error('Synthetic provider failure'); return {ok:true,secret:'synthetic-provider-key'}; });
|
||||
let holdPrepared = false, dropGrant = false;
|
||||
const held: (()=>void)[] = [];
|
||||
const faultTransport = (transport: ReturnType<typeof privateHostTransport>) => {
|
||||
const original = transport.connect.bind(transport);
|
||||
transport.connect = (...args) => {
|
||||
const wire = original(...args), send = wire.send.bind(wire);
|
||||
wire.send = (m: Message) => {
|
||||
if (m.type === 'grant' && dropGrant) return;
|
||||
if (m.type === 'prepared' && holdPrepared) { held.push(()=>send(m)); return; }
|
||||
send(m);
|
||||
};
|
||||
return wire;
|
||||
};
|
||||
return transport;
|
||||
};
|
||||
const service = await host(directory,url,undefined,faultTransport(privateHostTransport(identity.pairing,identity.secret)),credentials,async (_input,signal) => { signal.throwIfAborted(); if (providerFailure) throw Error('Synthetic provider failure'); return {ok:true,secret:'synthetic-provider-key'}; });
|
||||
let destination: {directory:string; host:string; credentials:ReturnType<typeof isolatedFileCredentials>; service: Awaited<ReturnType<typeof host>>} | undefined;
|
||||
if (move) {
|
||||
const directory = join(home,'destination'); mkdirSync(directory,{mode:0o700});
|
||||
const peerCredentials = isolatedFileCredentials(join(directory,'credentials.json'));
|
||||
const peer = bootstrapHostIdentity(directory,'Destination host',owner,url,peerCredentials);
|
||||
provisionCredentialSlot(directory,{...setup,host:peer.pairing.host},agentSecret,genesis,peerCredentials);
|
||||
destination = {directory,host:peer.pairing.host,credentials:peerCredentials,service:await host(directory,url,undefined,faultTransport(privateHostTransport(peer.pairing,peer.secret)),peerCredentials)};
|
||||
}
|
||||
let credentialFailure = false, beforeCredential: (() => Promise<void>) | undefined, afterCredential: (() => Promise<void>) | undefined;
|
||||
const credential = async (input: any, signal: AbortSignal) => {
|
||||
signal.throwIfAborted();
|
||||
@@ -78,13 +102,13 @@ export async function registrationFixture(home: string, changed: (s: ManagerSnap
|
||||
};
|
||||
const controller = new ManagerController(home,changed,credential,managementClient,async()=> 'Synthetic relay',fetchRelayDirectory,async()=>({profileState:'found',profile:{relay:url,name:'Review agent'}}));
|
||||
const originalClose = controller.close.bind(controller); let closing: Promise<void> | undefined;
|
||||
const close = () => closing ??= (async()=>{ originalClose(); await service.close(); await relay.close(); })();
|
||||
const close = () => closing ??= (async()=>{ originalClose(); await service.close(); await destination?.service.close(); await relay.close(); })();
|
||||
controller.close = () => { void close(); };
|
||||
return {controller,close,directory,agent,host:identity.pairing.host,runtimeId,nsec:nsecEncode(Buffer.from(agentSecret,'hex')),errors,credentials,relay,service,setCredentialFailure(v:boolean){credentialFailure=v;},setProviderFailure(v:boolean){providerFailure=v;},beforeCredential(fn?:()=>Promise<void>){beforeCredential=fn;},afterCredential(fn?:()=>Promise<void>){afterCredential=fn;}};
|
||||
return {controller,close,directory,agent,destination,ownerSecret,url,setHoldPrepared(v:boolean){holdPrepared=v;},setDropGrant(v:boolean){dropGrant=v;},get heldPrepared(){return held.length;},releasePrepared(){holdPrepared=false;for(const send of held.splice(0))send();},host:identity.pairing.host,runtimeId,nsec:nsecEncode(Buffer.from(agentSecret,'hex')),errors,credentials,relay,service,setCredentialFailure(v:boolean){credentialFailure=v;},setProviderFailure(v:boolean){providerFailure=v;},beforeCredential(fn?:()=>Promise<void>){beforeCredential=fn;},afterCredential(fn?:()=>Promise<void>){afterCredential=fn;}};
|
||||
}
|
||||
|
||||
/** Installed walkthrough uses the same real synthetic lifecycle fixture as tests. */
|
||||
export async function walkthroughController(home: string, changed: (s: ManagerSnapshot) => void) {
|
||||
const fixture = await registrationFixture(home,changed);
|
||||
const fixture = await registrationFixture(home,changed,true,true);
|
||||
return fixture.controller;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,12 @@ registration absent. Registration does NOT mint genesis or host placement.
|
||||
The real synthetic relay enforces owner signatures; the owned ACP process checks
|
||||
REVIEW_MODE=synthetic. No installed service, provider, OS-store or external relay
|
||||
is accessed. Host Start/Stop service buttons remain deliberately fenced.
|
||||
Authenticated Move remains unavailable.
|
||||
Move: after Start, choose Move… -> Destination host / default runtime -> yes.
|
||||
Wait for the agent to report its new host and running state. Inspect operations
|
||||
shows Move complete only after that destination run is observed. Repeat Move to
|
||||
return to Review host. Destination uses an isolated owned fixture runtime; no
|
||||
workspace, session or key is sent between hosts. Both hosts pin the same explicit
|
||||
public genesis and keep separate synthetic credential stores.
|
||||
HELP
|
||||
exit 0
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user