diff --git a/beehive/UX_NAVIGATION_CHECKPOINT.md b/beehive/UX_NAVIGATION_CHECKPOINT.md index a4a6a31797..4733423a08 100644 --- a/beehive/UX_NAVIGATION_CHECKPOINT.md +++ b/beehive/UX_NAVIGATION_CHECKPOINT.md @@ -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. diff --git a/beehive/src/catalog-transport.ts b/beehive/src/catalog-transport.ts index a63e8476ad..c97bad8212 100644 --- a/beehive/src/catalog-transport.ts +++ b/beehive/src/catalog-transport.ts @@ -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); }); diff --git a/beehive/src/handoff.ts b/beehive/src/handoff.ts index 611859fe0c..0c0574def4 100644 --- a/beehive/src/handoff.ts +++ b/beehive/src/handoff.ts @@ -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'); diff --git a/beehive/src/host-transport.ts b/beehive/src/host-transport.ts index b158dec83f..b550baa0d5 100644 --- a/beehive/src/host-transport.ts +++ b/beehive/src/host-transport.ts @@ -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. }, diff --git a/beehive/src/host.ts b/beehive/src/host.ts index e7a106931b..4c43df106e 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -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, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record, retiredBindings: Record = {}, 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, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record, retiredBindings: Record = {}, 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; 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; 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; diff --git a/beehive/src/intents.ts b/beehive/src/intents.ts index 04d9b531c3..0cd9fa97ba 100644 --- a/beehive/src/intents.ts +++ b/beehive/src/intents.ts @@ -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(); }, }; diff --git a/beehive/src/manager-controller.ts b/beehive/src/manager-controller.ts index 23350d1759..a267dccd1a 100644 --- a/beehive/src/manager-controller.ts +++ b/beehive/src/manager-controller.ts @@ -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; target?: string; revision?: number }; export type ManagerItem = { target?: string; id: string; label: string; detail: string; evidence?: string; disabled?: Record }; -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 ${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); diff --git a/beehive/src/manager-view.ts b/beehive/src/manager-view.ts index 3f8e380bc1..1c992c521c 100644 --- a/beehive/src/manager-view.ts +++ b/beehive/src/manager-view.ts @@ -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') }, diff --git a/beehive/src/nostr-codec.ts b/beehive/src/nostr-codec.ts index 3a92d920f9..1a5d1b1582 100644 --- a/beehive/src/nostr-codec.ts +++ b/beehive/src/nostr-codec.ts @@ -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>, role: { host: string } | 'owner'): Message { const { sender, message } = input; diff --git a/beehive/src/private-handoff.ts b/beehive/src/private-handoff.ts new file mode 100644 index 0000000000..9844a39e1e --- /dev/null +++ b/beehive/src/private-handoff.ts @@ -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; +} diff --git a/beehive/test/manager-controller.test.ts b/beehive/test/manager-controller.test.ts index f035d10aad..97dce16628 100644 --- a/beehive/test/manager-controller.test.ts +++ b/beehive/test/manager-controller.test.ts @@ -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); diff --git a/beehive/test/private-move.test.ts b/beehive/test/private-move.test.ts new file mode 100644 index 0000000000..7e997abf34 --- /dev/null +++ b/beehive/test/private-move.test.ts @@ -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(); + 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); + 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(); + 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,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(); + 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); +}); diff --git a/beehive/test/registration-fixture.ts b/beehive/test/registration-fixture.ts index a1baa85500..2fcb8ec168 100644 --- a/beehive/test/registration-fixture.ts +++ b/beehive/test/registration-fixture.ts @@ -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) => { + 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; service: Awaited>} | 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) | undefined, afterCredential: (() => Promise) | 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 | 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){beforeCredential=fn;},afterCredential(fn?:()=>Promise){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){beforeCredential=fn;},afterCredential(fn?:()=>Promise){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; } diff --git a/beehive/tools/ux-walkthrough.sh b/beehive/tools/ux-walkthrough.sh index ca633f4d0a..9c76b1f786 100644 --- a/beehive/tools/ux-walkthrough.sh +++ b/beehive/tools/ux-walkthrough.sh @@ -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