feat(beehive): authenticate pinned-assignment Move between private hosts

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-16 09:48:34 -04:00
parent c6795ccc5f
commit f59436c1aa
14 changed files with 433 additions and 32 deletions

View File

@@ -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.

View File

@@ -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); });

View File

@@ -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');

View File

@@ -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.
},

View File

@@ -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;

View File

@@ -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(); },
};

View File

@@ -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);

View File

@@ -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') },

View File

@@ -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;

View 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;
}

View File

@@ -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);

View 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);
});

View File

@@ -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;
}

View File

@@ -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