beehive: recover interrupted added slots through explicit reconcile-agent

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 14:08:26 -04:00
parent 2f3d4755ef
commit 6caa4150de
7 changed files with 357 additions and 16 deletions

View File

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

View File

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

View File

@@ -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 <host-directory> Bindings; new/reuse/hidden standby/restore identity
migrate-slots <host-directory> Explicit stopped upgrade, preserves journal
add-agent <host-directory> New identity using shared local harness
reconcile-agent <host-directory> [agent-public-key] [public-genesis-file] Recover interrupted added slot
remove-agent-key <host-directory> [agent-public-key] Remove ONE local key copy (public slot retained)
import-agent-key <host-directory> <agent-public-key> Restore retained identity via hidden local entry
assignment-export <host-directory> <new-file> Export public pinned genesis locally
@@ -51,6 +53,7 @@ const help = `Beehive — isolated development preview (loopback relay only)
host <host-directory> <ws://127.0.0.1:port> Persistent foreground host
tui <identity-file> <ws://127.0.0.1:port> Relay-connected terminal UI
setup/add-agent accept optional <local-key-file> <public-genesis-file> 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');

View File

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

View File

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

View File

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

View File

@@ -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<typeof credentials.create>[0], secret: Parameters<typeof credentials.create>[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<string, { setup: string }>;
// 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<string, { runner: string; args: string[]; workspace: string; mode: string }> };
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 }); }
});