From 6caa4150de805e42cdb3a735cbfe74b22fa4c471 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 14:08:26 -0400 Subject: [PATCH] beehive: recover interrupted added slots through explicit reconcile-agent Signed-off-by: Logan Johnson --- beehive/CREDENTIAL_ASYNC_707E.md | 18 ++-- beehive/CREDENTIAL_SLOTS_3C24C205.md | 2 +- beehive/src/cli.ts | 38 +++++++- beehive/src/credential-slots.ts | 65 ++++++++++++- beehive/src/slots.ts | 10 +- beehive/test/credential-slots.test.ts | 106 +++++++++++++++++++- beehive/test/reconcile-agent.test.ts | 134 ++++++++++++++++++++++++++ 7 files changed, 357 insertions(+), 16 deletions(-) create mode 100644 beehive/test/reconcile-agent.test.ts diff --git a/beehive/CREDENTIAL_ASYNC_707E.md b/beehive/CREDENTIAL_ASYNC_707E.md index e2f74c978e..d94b97863a 100644 --- a/beehive/CREDENTIAL_ASYNC_707E.md +++ b/beehive/CREDENTIAL_ASYNC_707E.md @@ -46,11 +46,15 @@ backends cannot silently fall through to synchronous live reads. key is created only by this explicit matching-key action. Journal bytes remain; an active/public-only manifest refuses recovery and requires explicit import. -Filesystem + OS store are not a transaction. Added-agent failure can still leave -an inert orphan journal/key and currently refuses retry pending guided addition -reconciliation. V3 immutable binding add/retire/conversion and full local wizard -remain unfinished. No automatic plaintext migration/deletion, no owner key minted -or copied to the host, and no relay admission or Start during provisioning. +Filesystem + OS store are not a transaction. Added-agent failure can still +leave an inert orphan journal/key; it is recovered only through the explicit +guided `reconcile-agent` command: public-only orphan discovery, journal-derived +binding candidates, exact canonical inert-journal match (never rewritten or reset), +explicit matching-key adoption or one-time creation, and active/public-only slots +refusing in favor of `import-agent-key`. V3 immutable binding add/retire/conversion +and full local wizard remain unfinished. No automatic plaintext migration/deletion, +no owner key minted or copied to the host, and no relay admission or Start during +provisioning or recovery. ## Evidence / remaining @@ -62,7 +66,9 @@ failure injection covers before-store and after-store first-provision recovery, wrong identity, standby addition, retained deletion refusing recovery/recreation, and byte-identical sibling journal. These are isolated fixtures, no OS operations. -Independent async semantic review and actual owner-present OS verification remain. +The independent async semantic review was cancelled without a verdict; nothing in +this document or the added-slot recovery delta is independently reviewed. Actual +owner-present OS verification remains. Workspace `cli-journey.ts` additionally exercises actual v3 CLI processes with the existing explicit isolated-file credential loader: first-provision reconciliation, new agent, hidden standby import, removal, refusal to recover an active manifest, diff --git a/beehive/CREDENTIAL_SLOTS_3C24C205.md b/beehive/CREDENTIAL_SLOTS_3C24C205.md index 08980f2100..caa7863f1b 100644 --- a/beehive/CREDENTIAL_SLOTS_3C24C205.md +++ b/beehive/CREDENTIAL_SLOTS_3C24C205.md @@ -17,7 +17,7 @@ Native adapter contract tests inject an Entry implementation; lifecycle tests in ## Crash boundaries and unfinished work -Filesystem and OS credential store are not one transaction. Failure before credential write can leave an inert journal; failure after credential write/read-back but before manifest activation can leave an orphan OS entry. The journal contains the public agent identity from which its Beehive reference is derived; retry refuses partial provisioning. There is not yet a guided partial-provision reconciliation command. No automatic cleanup/migration is performed, and a deliberate removal is never re-created on recovery. +Filesystem and OS credential store are not one transaction. Failure before credential write can leave an inert journal; failure after credential write/read-back but before manifest activation can leave an orphan OS entry. The journal contains the public agent identity from which its Beehive reference is derived; implicit retry refuses partial reset, and guided explicit recovery now exists for first provision (`reconcile-provision`) and interrupted added slots (`reconcile-agent`). No automatic cleanup/migration is performed, and a deliberate removal is never re-created on recovery. V3 first-agent provisioning/load/remove/import is integrated. V3 add-agent, immutable binding addition/retirement/conversion and full normal setup wizard integration still need implementation; legacy versions retain existing functionality rather than silently upgrading. Private profile distribution and cross-host Move/reconnect remain subsequent work; the new-wire signed-reply journey uses upstream-default profile, not a new profile-distribution claim. Existing native protocol/model/session fences remain in the execution owner and separate installed tests. diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index 78b5cdf276..5bb3b0f5b9 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -1,4 +1,4 @@ -import { provisionCredentialSlot, reconcileCredentialProvision } from './credential-slots.ts'; +import { provisionCredentialSlot, reconcileCredentialProvision, orphanCredentialSlots } from './credential-slots.ts'; import { readHostIdentity } from './host-identity.ts'; import { verifyHostRegistration } from './host-registration.ts'; import { setupOwner } from './host.ts'; @@ -20,11 +20,12 @@ import { existsSync, mkdirSync, realpathSync, rmdirSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { relay } from './relay.ts'; import { host, validateSetup, provision, migrateAssignment, setupModels } from './host.ts'; -import { migrateSlots, addSlot, installationSlots, removeSlotKey, importSlotKey } from './slots.ts'; +import { migrateSlots, addSlot, installationSlots, removeSlotKey, importSlotKey, reconcileSlot } from './slots.ts'; import { managementClient } from './intents.ts'; import { message, newKey, publicKey, object, text, type Message } from './protocol.ts'; import { readPrivate, writePrivate } from './storage.ts'; import { createGenesis, validateGenesis } from './assignment.ts'; +import { semanticHash } from './handoff.ts'; import { prepareConversation } from './conversation.ts'; const operationLabel = (m: Message) => `${m.host} ${m.type}${m.type === 'move' ? ` → ${String(m.body.target)}` : ''} | agent ${m.agent} | operation ${m.id}`; @@ -41,6 +42,7 @@ const help = `Beehive — isolated development preview (loopback relay only) local-setup Bindings; new/reuse/hidden standby/restore identity migrate-slots Explicit stopped upgrade, preserves journal add-agent New identity using shared local harness + reconcile-agent [agent-public-key] [public-genesis-file] Recover interrupted added slot remove-agent-key [agent-public-key] Remove ONE local key copy (public slot retained) import-agent-key Restore retained identity via hidden local entry assignment-export Export public pinned genesis locally @@ -51,6 +53,7 @@ const help = `Beehive — isolated development preview (loopback relay only) host Persistent foreground host tui Relay-connected terminal UI setup/add-agent accept optional for standby import. +reconcile-agent derives the interrupted binding and genesis from the retained journal; an optional public genesis file must match it. assignment-export accepts agent public key after filename when several slots exist. Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC or production relay support.`; async function main() { @@ -184,6 +187,37 @@ async function main() { if (await ui.question(importing ? `Add standby identity assigned to ${root.initialHost}, reusing host harness default? [yes/no]: ` : 'Create independent NEW agent assigned here, reusing host harness default? [yes/no]: ') !== 'yes') return; addSlot(dir, secret, root); console.log(`Agent ${publicKey(secret)} added; start/configure remotely after restarting the host.`); } finally { ui.close(); } + } else if (command === 'reconcile-agent') { + if (args.length > 3) throw Error('Use host directory and optional agent public key and public genesis file only'); + const dir = resolve(text(args[0])); + if (args[1] !== undefined && !/^[0-9a-f]{64}$/.test(String(args[1]))) throw Error('Invalid agent public key'); + const listing = orphanCredentialSlots(dir); + // A named agent key gets its exact refusal whether the installation holds no + // interrupted journal at all or none for that key; the installation-level + // message belongs to the key-less discovery form only. + const orphan = args[1] !== undefined ? listing.orphans.find(o => o.agent === String(args[1])) : listing.orphans.length === 1 ? listing.orphans[0]! : undefined; + if (!orphan) throw Error(args[1] !== undefined ? 'No interrupted added-slot journal for this agent public key' + : listing.orphans.length ? 'Specify the agent public key for a multi-slot host' : 'No interrupted added-slot journal on this installation; nothing to reconcile'); + if (args[2] !== undefined && semanticHash(validateGenesis(readPrivate(resolve(text(args[2]))))) !== semanticHash(orphan.genesis)) throw Error('Provided public genesis differs from the retained journal; refusing substitution'); + let setupId: string | undefined, fingerprint: string | undefined; + const ui = createInterface({ input: stdin, output: stdout }); + try { + if (orphan.candidates.length === 1) { setupId = orphan.candidates[0]!.setupId; fingerprint = orphan.candidates[0]!.fingerprint; } + else { + if (!orphan.candidates.length) throw Error('Retained journal matches no current binding definition; explicit local repair required'); + for (const candidate of orphan.candidates) console.log(` binding ${candidate.setupId} | fingerprint ${candidate.fingerprint}`); + const chosen = text(await ui.question(`The retained journal matches ${orphan.candidates.length} binding definitions. Enter the binding id used by the interrupted add: `)); + const candidate = orphan.candidates.find(c => c.setupId === chosen); + if (!candidate) throw Error('Chosen binding does not match the retained journal'); + setupId = candidate.setupId; fingerprint = candidate.fingerprint; + } + console.log(`Interrupted added slot ${orphan.agent} | host ${listing.host} | owner ${listing.ownerPublic} | binding ${setupId} fingerprint ${fingerprint} | genesis owner ${orphan.genesis.owner}, initial host ${orphan.genesis.initialHost}.`); + console.log('The retained journal is never rewritten or reset; an existing matching credential is adopted, a missing one is created only by this explicit action, and foreign or ambiguous keys stay untouched.'); + if (await ui.question('Reconcile this interrupted added slot exactly as shown? [yes/no]: ') !== 'yes') return; + } finally { ui.close(); } + const secret = await readAgentSecret(); + reconcileSlot(dir, setupId!, secret, orphan.genesis, fingerprint); + console.log(`Reconciled added slot ${orphan.agent} on binding ${setupId!}; retained journal preserved byte-identically and manifest entry activated. No relay admission or Start performed.`); } else if (command === 'remove-agent-key') { const dir = resolve(text(args[0])); if (args[1] !== undefined && !/^[0-9a-f]{64}$/.test(String(args[1]))) throw Error('Invalid agent public key'); diff --git a/beehive/src/credential-slots.ts b/beehive/src/credential-slots.ts index 53e5a7377c..b9165b6233 100644 --- a/beehive/src/credential-slots.ts +++ b/beehive/src/credential-slots.ts @@ -1,8 +1,9 @@ -import { existsSync, mkdirSync, rmdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, rmdirSync } from 'node:fs'; import { join } from 'node:path'; import { initialState, loadSlotState, setupOwner, validateSetup, bindingFingerprint, setupModels, type Setup } from './host.ts'; import { validateGenesis, type Genesis } from './assignment.ts'; -import { object, publicKey } from './protocol.ts'; +import { object, publicKey, text } from './protocol.ts'; +import { semanticHash } from './handoff.ts'; import { readPrivate, writePrivate } from './storage.ts'; import { credentialReference, createCredential, readCredential, type CredentialBackend, type CredentialReference, systemCredentials } from './credential-store.ts'; import type { SlotEntry } from './slots.ts'; @@ -163,3 +164,63 @@ export function addCredentialSlot(directory: string, secret: string, root: Genes writePrivate(manifestPath(directory), manifest); }); } + +/** Public-only discovery of interrupted ADDED identities: an agent directory + * holding a retained journal that no manifest entry activates. No credential is + * read or created and no file is modified; journal binding/assignment contents + * are already-public. Candidate bindings are derived from the journal's retained + * selection, never defaulted: a definition change or corrupt state fails closed. */ +export function orphanCredentialSlots(directory: string): { host: string; ownerPublic: string; orphans: { agent: string; genesis: Genesis; candidates: { setupId: string; fingerprint: string }[] }[] } { + const manifest = readManifest(directory); + const orphans: { agent: string; genesis: Genesis; candidates: { setupId: string; fingerprint: string }[] }[] = []; + const agentsDirectory = join(directory, 'agents'); + if (!existsSync(agentsDirectory)) return { host: manifest.host, ownerPublic: manifest.ownerPublic, orphans }; + for (const agent of readdirSync(agentsDirectory)) { + if (!/^[0-9a-f]{64}$/.test(agent) || Object.hasOwn(manifest.agents, agent) || !existsSync(journalPath(directory, agent))) continue; + const state = object(readPrivate(journalPath(directory, agent))); + const binding = object(state.binding), assignment = object(state.assignment), selected = object(state.selected); + if (text(binding.agent) !== agent) throw Error(`Partial journal for ${agent} carries a foreign identity; refusing`); + const genesis = validateGenesis(assignment.genesis); + if (genesis.owner !== manifest.ownerPublic || genesis.agent !== agent) throw Error(`Partial journal for ${agent} is not owned by this installation; refusing`); + const model = text(selected.model), workspace = text(selected.workspace); + const candidates = Object.entries(manifest.setups) + .filter(([, harness]) => { const setup = { ...harness, host: manifest.host, ownerPublic: manifest.ownerPublic }; return setupModels(setup)[0] === model && setup.workspace === workspace; }) + .map(([id, harness]) => ({ setupId: id, fingerprint: bindingFingerprint({ ...harness, host: manifest.host, ownerPublic: manifest.ownerPublic }) })); + orphans.push({ agent, genesis, candidates }); + } + return { host: manifest.host, ownerPublic: manifest.ownerPublic, orphans }; +} + +/** Explicit recovery of an interrupted ADDED identity on an active installation. + * The chosen immutable binding and supplied genesis must reproduce the orphan + * journal's exact untouched initial state; the journal is never reset, rewritten + * or moved. An existing credential is adopted only when it equals the supplied + * key; a missing one is created only by this explicit action, and both stores are + * revalidated before the manifest entry activates. Active/public-only slots never + * enter here; wrong or foreign inputs leave every retained byte inert. */ +export function reconcileCredentialSlot(directory: string, setupId: string, secret: string, root: Genesis, expectedFingerprint: string | undefined, backend: CredentialBackend = systemCredentials): void { + locked(directory, () => { + const manifest = readManifest(directory), agent = publicKey(secret), genesis = validateGenesis(root); + if (manifest.agents[agent]) throw Error('Retained slot exists; use explicit import-agent-key'); + if (Object.keys(manifest.agents).length >= 32) throw Error('Installation full; retained keys require explicit import'); + const harness = manifest.setups[setupId]; + if (!harness) throw Error('Unknown host harness setup'); + if (expectedFingerprint !== undefined && bindingFingerprint({ ...harness, host: manifest.host, ownerPublic: manifest.ownerPublic }) !== expectedFingerprint) throw Error('Binding definition changed; reopen local setup'); + if (genesis.agent !== agent || genesis.owner !== manifest.ownerPublic) throw Error('Genesis ownership mismatch'); + const setup = validateSetup({ ...harness, host: manifest.host, ownerPublic: manifest.ownerPublic, agentSecret: secret }); + if (!setupModels(setup).length) throw Error('Diagnostic-only binding cannot enroll an executable agent'); + const path = journalPath(directory, agent); + if (!existsSync(path)) throw Error('No interrupted added-slot journal for this identity; nothing to reconcile'); + const expected = initialState(setup, genesis); + // Canonical equality: field order in a semantically identical genesis file is not identity. + if (semanticHash(readPrivate(path)) !== semanticHash(expected)) throw Error('Partial journal differs from exact inert state; refusing reset'); + const key = credentialReference('agent', agent); + const existing = backend.read(key); + if (existing === null) createCredential('agent', secret, backend); + else if (readCredential(key, backend) !== secret) throw Error('Existing credential differs; refusing adoption'); + // Recheck both stores before public activation; sibling journals are never touched. + if (readCredential(key, backend) !== secret || semanticHash(readPrivate(path)) !== semanticHash(expected)) throw Error('Partial slot changed during reconciliation'); + manifest.agents[agent] = { key, setup: setupId }; + writePrivate(manifestPath(directory), manifest); + }); +} diff --git a/beehive/src/slots.ts b/beehive/src/slots.ts index ca0b900378..f5837c387d 100644 --- a/beehive/src/slots.ts +++ b/beehive/src/slots.ts @@ -1,4 +1,4 @@ -import { credentialSlots, credentialSlotsAsync, addCredentialSlot, removeCredentialSlotKey, importCredentialSlotKey } from './credential-slots.ts'; +import { credentialSlots, credentialSlotsAsync, addCredentialSlot, removeCredentialSlotKey, importCredentialSlotKey, reconcileCredentialSlot } from './credential-slots.ts'; import { systemCredentials, type CredentialBackend } from './credential-store.ts'; import { setupOwner } from './host.ts'; import { prepareAgent } from './acp.ts'; @@ -130,6 +130,14 @@ function provisionSlotJournal(directory: string, setup: Setup, root: Genesis) { if (genesis.owner !== setupOwner(setup) || genesis.agent !== publicKey(setup.agentSecret)) throw Error('Genesis ownership mismatch'); writePrivate(join(directory, 'journal.json'), initialState(setup, genesis), true); } + +/** Explicit v3-only recovery of an interrupted added identity slot. Legacy + * installations migrate first; there is no v2 partial-slot recovery path and + * no automatic migration. Arguments mirror reconcileCredentialSlot exactly. */ +export function reconcileSlot(directory: string, setupId: string, secret: string, root: Genesis, expectedFingerprint: string | undefined, backend: CredentialBackend = systemCredentials): void { + if (object(readPrivate(join(directory, 'setup.json'))).version === 3) return reconcileCredentialSlot(directory, setupId, secret, root, expectedFingerprint, backend); + throw Error('Legacy installation requires explicit migrate-slots before added-slot recovery'); +} /** * Deliberate LOCAL removal of one agent's key copy. The public slot stays: identity, * genesis/assignment, configurations and the complete journal/receipt/run history are diff --git a/beehive/test/credential-slots.test.ts b/beehive/test/credential-slots.test.ts index e05d72fc79..4790af7871 100644 --- a/beehive/test/credential-slots.test.ts +++ b/beehive/test/credential-slots.test.ts @@ -1,11 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { provisionCredentialSlot } from '../src/credential-slots.ts'; -import { installationSlots, removeSlotKey, importSlotKey } from '../src/slots.ts'; -import { setupOwner, validateSetup } from '../src/host.ts'; +import { provisionCredentialSlot, orphanCredentialSlots } from '../src/credential-slots.ts'; +import { installationSlots, removeSlotKey, importSlotKey, addSlot, reconcileSlot, migrateSlots } from '../src/slots.ts'; +import { setupOwner, validateSetup, initialState, provision } from '../src/host.ts'; import { createGenesis } from '../src/assignment.ts'; import { newKey, publicKey } from '../src/protocol.ts'; import { memoryCredentials } from './credential-fixture.ts'; @@ -99,3 +99,101 @@ test('explicit exact first-provision recovery verifies orphan key; v3 additions } finally { rmSync(f.directory, { recursive: true, force: true }); } } }); + +test('explicit added-slot recovery reconciles interrupted additions without reset or key replacement', () => { + for (const failure of ['before-store', 'after-store'] as const) { + const f = fixture(); + try { + provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, f.backend); + const siblingJournal = readFileSync(join(f.directory, 'agents', f.agent, 'journal.json'), 'utf8'); + const second = newKey(), key = publicKey(second); + const standby = createGenesis(publicKey(f.owner), key, 'retained-source'); + assert.throws(() => addSlot(f.directory, second, standby, 'default', undefined, { + ...f.backend, create(ref, secret) { if (failure === 'after-store') f.backend.create(ref, secret); throw Error('injected store boundary'); }, + }), /injected/); + const orphanPath = join(f.directory, 'agents', key, 'journal.json'); + assert.ok(existsSync(orphanPath), 'interrupted add retains an inert orphan journal'); + const orphanJournal = readFileSync(orphanPath, 'utf8'), manifestBefore = readFileSync(join(f.directory, 'setup.json'), 'utf8'); + const discovered = orphanCredentialSlots(f.directory); + assert.equal(discovered.orphans.length, 1); + assert.equal(discovered.host, f.setup.host); + assert.equal(discovered.ownerPublic, publicKey(f.owner)); + const found = discovered.orphans[0]!; + assert.equal(found.agent, key); + assert.deepEqual(found.candidates.map(c => c.setupId), ['default']); + assert.equal(found.genesis.initialHost, 'retained-source'); + const fingerprint = found.candidates[0]!.fingerprint; + // Every wrong input refuses before any store or manifest mutation. + assert.throws(() => reconcileSlot(f.directory, 'default', second, standby, '0'.repeat(64), f.backend), /Binding definition changed/); + assert.throws(() => reconcileSlot(f.directory, 'missing', second, standby, undefined, f.backend), /Unknown host harness setup/); + assert.throws(() => reconcileSlot(f.directory, 'default', second, createGenesis(publicKey(f.owner), key, 'another-host'), fingerprint, f.backend), /differs from exact inert state/); + // A wrong key refuses against the supplied identity before any store or journal effect. + assert.throws(() => reconcileSlot(f.directory, 'default', newKey(), standby, fingerprint, f.backend), /Genesis ownership mismatch/); + assert.throws(() => reconcileSlot(f.directory, 'default', second, createGenesis(publicKey(newKey()), key, 'retained-source'), fingerprint, f.backend), /Genesis ownership mismatch/); + assert.equal(readFileSync(orphanPath, 'utf8'), orphanJournal); + assert.equal(readFileSync(join(f.directory, 'setup.json'), 'utf8'), manifestBefore, 'refusals leave the manifest inert'); + // Matching reconcile: an existing orphan key is adopted, a missing one is created exactly once. + reconcileSlot(f.directory, 'default', second, standby, fingerprint, failure === 'after-store' + ? { ...f.backend, create() { throw Error('must adopt the existing orphan key'); } } + : f.backend); + assert.equal(readFileSync(orphanPath, 'utf8'), orphanJournal, 'recovery never rewrites the retained journal'); + assert.equal(readFileSync(join(f.directory, 'agents', f.agent, 'journal.json'), 'utf8'), siblingJournal, 'sibling journals are untouched'); + const loaded = installationSlots(f.directory, f.backend); + assert.equal(loaded.length, 2); + const added = loaded.find(s => s.agent === key)!; + assert.equal(added.setupId, 'default'); + assert.equal(added.keyPresent, true); + assert.equal(added.setup.agentSecret, second); + assert.equal((readPrivate(orphanPath) as any).assignment.assignedHost, 'retained-source'); + const manifest = readFileSync(join(f.directory, 'setup.json'), 'utf8'); + for (const secret of [f.secret, f.owner, second]) assert.ok(!manifest.includes(secret), 'manifest must not persist secrets'); + // Active and public-only retained slots never enter recovery again. + assert.throws(() => reconcileSlot(f.directory, 'default', second, standby, fingerprint, f.backend), /Retained slot exists; use explicit import-agent-key/); + removeSlotKey(f.directory, key, f.backend); + assert.throws(() => reconcileSlot(f.directory, 'default', second, standby, fingerprint, f.backend), /Retained slot exists; use explicit import-agent-key/); + importSlotKey(f.directory, key, second, f.backend); + } finally { rmSync(f.directory, { recursive: true, force: true }); } + } +}); + +test('added-slot discovery is public-only and refuses foreign or absent partial state', () => { + const f = fixture(); + try { + assert.throws(() => orphanCredentialSlots(f.directory), /ENOENT/); + provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, f.backend); + assert.deepEqual(orphanCredentialSlots(f.directory).orphans, []); + const foreign = newKey(), foreignKey = publicKey(foreign), otherOwner = newKey(); + const setup = validateSetup({ host: 'host-a', ownerPublic: publicKey(otherOwner), mode: 'fixture', runner: process.execPath, args: [], workspace: f.directory, agentSecret: foreign }); + writePrivate(join(f.directory, 'agents', foreignKey, 'journal.json'), initialState(setup, createGenesis(publicKey(otherOwner), foreignKey, 'host-a')), true); + assert.throws(() => orphanCredentialSlots(f.directory), /not owned by this installation/); + } finally { rmSync(f.directory, { recursive: true, force: true }); } +}); + +test('fresh-assignment recovery, canonical genesis equality and v3-only wrapper', () => { + const f = fixture(); + try { + provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, f.backend); + const second = newKey(), key = publicKey(second), fresh = createGenesis(publicKey(f.owner), key, f.setup.host); + assert.throws(() => addSlot(f.directory, second, fresh, 'default', undefined, { ...f.backend, create() { throw Error('injected'); } }), /injected/); + const found = orphanCredentialSlots(f.directory).orphans[0]!; + assert.equal(found.genesis.initialHost, f.setup.host); + reconcileSlot(f.directory, 'default', second, fresh, found.candidates[0]!.fingerprint, f.backend); + assert.equal((readPrivate(join(f.directory, 'agents', key, 'journal.json')) as any).assignment.assignedHost, f.setup.host); + // A semantically identical genesis with reordered fields reconciles the same journal. + const third = newKey(), thirdKey = publicKey(third); + const root = createGenesis(publicKey(f.owner), thirdKey, f.setup.host); + assert.throws(() => addSlot(f.directory, third, root, 'default', undefined, { ...f.backend, create() { throw Error('injected'); } }), /injected/); + const reordered = { initialHost: root.initialHost, agent: root.agent, owner: root.owner, id: root.id, v: 1 as const }; + reconcileSlot(f.directory, 'default', third, reordered, orphanCredentialSlots(f.directory).orphans[0]!.candidates[0]!.fingerprint, f.backend); + assert.equal((readPrivate(join(f.directory, 'agents', thirdKey, 'journal.json')) as any).assignment.assignedHost, f.setup.host); + assert.equal(installationSlots(f.directory, f.backend).length, 3); + } finally { rmSync(f.directory, { recursive: true, force: true }); } + const directory = mkdtempSync(join(tmpdir(), 'beehive-legacy-reconcile-')); + try { + const owner = newKey(), secret = newKey(); + const setup = validateSetup({ host: 'legacy', ownerSecret: owner, agentSecret: secret, mode: 'fixture', runner: process.execPath, args: [], workspace: directory }); + provision(directory, setup, createGenesis(publicKey(owner), publicKey(secret), 'legacy')); + migrateSlots(directory); + assert.throws(() => reconcileSlot(directory, 'default', secret, createGenesis(publicKey(owner), publicKey(secret), 'legacy'), undefined, memoryCredentials()), /migrate-slots/); + } finally { rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/beehive/test/reconcile-agent.test.ts b/beehive/test/reconcile-agent.test.ts new file mode 100644 index 0000000000..27a0295f69 --- /dev/null +++ b/beehive/test/reconcile-agent.test.ts @@ -0,0 +1,134 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; +import { createGenesis } from '../src/assignment.ts'; +import { provisionCredentialSlot, addCredentialSlot, orphanCredentialSlots } from '../src/credential-slots.ts'; +import { installationSlots } from '../src/slots.ts'; +import { isolatedFileCredentials } from './isolated-file-credentials.ts'; +import { newKey, publicKey } from '../src/protocol.ts'; +import { readPrivate, writePrivate } from '../src/storage.ts'; +import { validateSetup } from '../src/host.ts'; + +/** Actual standalone v3 CLI journeys for interrupted added-slot recovery, using the + * existing explicit isolated-file credential loader. Synthetic fixtures only; no OS + * credential operation, no relay admission, no Start. */ +test('reconcile-agent CLI: interrupted added slots recover exactly; wrong key, foreign genesis and ambiguity refuse inertly', { timeout: 180000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-reconcile-')); + const credentialsFile = join(root, 'synthetic-credentials.json'); + const credentials = isolatedFileCredentials(credentialsFile); + const owner = newKey(), ownerPublic = publicKey(owner); + const host = 'reconcile-host'; + async function cli(args: string[], responses: [string, string][] = [], success = true) { + const env = { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: credentialsFile }; + const child = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), resolve('src/cli.ts'), ...args], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + let output = ''; child.stdout.on('data', b => output += b); child.stderr.on('data', b => output += b); + const exited = new Promise(resolve => child.on('close', resolve)); + try { + for (const [prompt, response] of responses) { + let found = false; + for (let i = 0; i < 300; i++) { if (output.includes(prompt)) { found = true; break; } if (child.exitCode !== null) break; await delay(10); } + assert.ok(found, `Missing CLI prompt ${prompt}`); + child.stdin.write(`${response}\n`); + } + const code = await exited; + assert.equal(code === 0, success, `CLI outcome for ${args.join(' ')}: ${output}`); + for (const [prompt, secret] of responses) if (/^[0-9a-f]{64}$/.test(secret)) assert.ok(!output.includes(secret), `hidden input ${prompt} leaked`); + return output; + } finally { if (child.exitCode === null) child.kill('SIGKILL'); await exited; } + } + const injected = (afterStore: boolean) => ({ ...credentials, create(reference: Parameters[0], secret: Parameters[1]) { if (afterStore) credentials.create(reference, secret); throw Error('injected store boundary'); } }); + try { + const dir = join(root, 'host'); + const binding = { mode: 'fixture', runner: process.execPath, args: [], workspace: root }; + const first = newKey(), firstKey = publicKey(first); + const setup = validateSetup({ ...binding, host, ownerPublic }); + provisionCredentialSlot(dir, setup, first, createGenesis(ownerPublic, firstKey, host), credentials); + const firstJournal = readFileSync(join(dir, 'agents', firstKey, 'journal.json'), 'utf8'); + const manifestBytes = () => readFileSync(join(dir, 'setup.json'), 'utf8'); + const journalOf = (agent: string) => readFileSync(join(dir, 'agents', agent, 'journal.json'), 'utf8'); + const agentsOf = () => JSON.parse(manifestBytes()).agents as Record; + // Zero interrupted additions refuses before any prompt. + assert.ok((await cli(['reconcile-agent', dir], [], false)).includes('No interrupted added-slot journal')); + // Interrupted standby addition (store-then-throw) retains an orphan journal and key. + const second = newKey(), secondKey = publicKey(second); + const standbyRoot = createGenesis(ownerPublic, secondKey, 'retained-source'); + assert.throws(() => addCredentialSlot(dir, second, standbyRoot, 'default', undefined, injected(true)), /injected/); + const secondJournal = journalOf(secondKey), manifestBefore = manifestBytes(); + assert.equal(orphanCredentialSlots(dir).orphans.length, 1); + // Matching recovery: preview names binding and fingerprint before any input. + const recovered = await cli(['reconcile-agent', dir], [['Reconcile this interrupted added slot', 'yes'], ['Agent private key', second]]); + assert.ok(recovered.includes('binding default') && recovered.includes(`owner ${ownerPublic}`), 'preview must expose the derived binding'); + assert.equal(journalOf(secondKey), secondJournal, 'recovery preserves the orphan journal byte-identically'); + assert.equal(agentsOf()[secondKey]!.setup, 'default'); + assert.equal(journalOf(firstKey), firstJournal, 'first journal preserved'); + // The activated identity is no longer an orphan; recovery refuses it. + assert.ok((await cli(['reconcile-agent', dir, secondKey], [], false)).includes('No interrupted added-slot journal for this agent public key')); + // Declining leaves all state inert. + const third = newKey(), thirdKey = publicKey(third); + assert.throws(() => addCredentialSlot(dir, third, createGenesis(ownerPublic, thirdKey, host), 'default', undefined, injected(false)), /injected/); + const thirdJournal = journalOf(thirdKey); + await cli(['reconcile-agent', dir, thirdKey], [['Reconcile this interrupted added slot', 'no']]); + const afterDecline = manifestBytes(); + assert.ok(afterDecline.includes(secondKey) && !afterDecline.includes(thirdKey), 'declined reconcile must not activate the slot'); + // A wrong key refuses inertly against the supplied identity. + assert.ok((await cli(['reconcile-agent', dir, thirdKey], [['Reconcile this interrupted added slot', 'yes'], ['Agent private key', newKey()]], false)).includes('Genesis ownership mismatch')); + assert.equal(manifestBytes(), afterDecline); + assert.equal(journalOf(thirdKey), thirdJournal); + // A provided public genesis must match the retained journal contents. + const foreignFile = join(root, 'foreign-genesis.json'); + writePrivate(foreignFile, createGenesis(ownerPublic, thirdKey, 'another-host')); + assert.ok((await cli(['reconcile-agent', dir, thirdKey, foreignFile], [], false)).includes('Provided public genesis differs')); + assert.equal(manifestBytes(), afterDecline); + // Ambiguous orphan selection without an agent public key refuses. + const fourth = newKey(), fourthKey = publicKey(fourth); + const fourthRoot = createGenesis(ownerPublic, fourthKey, 'retained-source'); + assert.throws(() => addCredentialSlot(dir, fourth, fourthRoot, 'default', undefined, injected(false)), /injected/); + const fourthJournal = journalOf(fourthKey); + assert.ok((await cli(['reconcile-agent', dir], [], false)).includes('Specify the agent public key for a multi-slot host')); + // Standby recovery with the optional matching public genesis file. + const genesisFile = join(root, 'standby-genesis.json'); + writePrivate(genesisFile, { initialHost: fourthRoot.initialHost, agent: fourthRoot.agent, owner: fourthRoot.owner, id: fourthRoot.id, v: 1 as const }); + await cli(['reconcile-agent', dir, fourthKey, genesisFile], [['Reconcile this interrupted added slot', 'yes'], ['Agent private key', fourth]]); + assert.equal(journalOf(fourthKey), fourthJournal); + assert.equal(agentsOf()[fourthKey]!.setup, 'default'); + assert.equal((readPrivate(join(dir, 'agents', fourthKey, 'journal.json')) as { assignment: { assignedHost: string } }).assignment.assignedHost, 'retained-source'); + // A non-default immutable binding is derived from the orphan journal, never defaulted. + // (Binding inventory extension is the separate v3 wizard scope; only recovery semantics are exercised here.) + const manifest = readPrivate(join(dir, 'setup.json')) as { setups: Record }; + manifest.setups.second = { ...manifest.setups.default!, workspace: join(root, 'w2') }; + writePrivate(join(dir, 'setup.json'), manifest); + const fifth = newKey(), fifthKey = publicKey(fifth); + assert.throws(() => addCredentialSlot(dir, fifth, createGenesis(ownerPublic, fifthKey, host), 'second', undefined, injected(false)), /injected/); + const fifthJournal = journalOf(fifthKey); + const chosen = await cli(['reconcile-agent', dir, fifthKey], [['Reconcile this interrupted added slot', 'yes'], ['Agent private key', fifth]]); + assert.ok(chosen.includes('binding second'), 'preview must expose the journal-derived binding'); + assert.equal(agentsOf()[fifthKey]!.setup, 'second'); + assert.equal(journalOf(fifthKey), fifthJournal); + // Ambiguous binding definitions require an explicit choice; a wrong choice refuses. + const extended = readPrivate(join(dir, 'setup.json')) as typeof manifest; + extended.setups.twin = { ...extended.setups.second!, args: [join(root, 'twin-arg')] }; + writePrivate(join(dir, 'setup.json'), extended); + const sixth = newKey(), sixthKey = publicKey(sixth); + assert.throws(() => addCredentialSlot(dir, sixth, createGenesis(ownerPublic, sixthKey, host), 'second', undefined, injected(false)), /injected/); + const sixthJournal = journalOf(sixthKey); + assert.ok((await cli(['reconcile-agent', dir, sixthKey], [['Enter the binding id', 'default']], false)).includes('Chosen binding does not match the retained journal')); + assert.equal(agentsOf()[sixthKey], undefined, 'wrong binding choice must not activate'); + await cli(['reconcile-agent', dir, sixthKey], [['Enter the binding id', 'twin'], ['Reconcile this interrupted added slot', 'yes'], ['Agent private key', sixth]]); + assert.equal(agentsOf()[sixthKey]!.setup, 'twin'); + assert.equal(journalOf(sixthKey), sixthJournal); + // Final state: five activated public slots, one retained orphan (third), no secrets anywhere. + assert.equal(Object.keys(agentsOf()).length, 5); + const remaining = orphanCredentialSlots(dir).orphans; + assert.equal(remaining.length, 1); + assert.equal(remaining[0]!.agent, thirdKey); + const loaded = installationSlots(dir, credentials); + assert.equal(loaded.length, 5); + const finalManifest = manifestBytes(); + for (const secret of [owner, first, second, third, fourth, fifth, sixth]) assert.ok(!finalManifest.includes(secret), 'manifest must not persist secrets'); + assert.equal(journalOf(firstKey), firstJournal, 'original journal byte-identical throughout'); + } finally { rmSync(root, { recursive: true, force: true }); } +});