mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
beehive: recover interrupted added slots through explicit reconcile-agent
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
134
beehive/test/reconcile-agent.test.ts
Normal file
134
beehive/test/reconcile-agent.test.ts
Normal 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 }); }
|
||||
});
|
||||
Reference in New Issue
Block a user