From 6a5a459246cbeba4647fde6572569f93ea952291 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 02:11:47 -0400 Subject: [PATCH] feat(beehive): restore retained local keys through a hidden import wizard Signed-off-by: Logan Johnson --- beehive/CHECKPOINT.md | 51 +++++++++++++++++++++ beehive/README.md | 21 +++++++++ beehive/TESTING.md | 16 +++++++ beehive/src/cli.ts | 20 ++++++++- beehive/src/key-input.ts | 19 ++++++++ beehive/src/slots.ts | 19 ++++++++ beehive/test/remove-key.test.ts | 78 ++++++++++++++++++++++++++++----- 7 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 beehive/src/key-input.ts diff --git a/beehive/CHECKPOINT.md b/beehive/CHECKPOINT.md index 9d8bed63e1..4c61f16f1c 100644 --- a/beehive/CHECKPOINT.md +++ b/beehive/CHECKPOINT.md @@ -5,6 +5,29 @@ Base `051c3a270be9c73da9ab06700bcab7d5552fceaa`; continuation starts at `023c9274767ef50fa0f5b37ef1883336f8be59fd`. Candidate is the commit containing this checkpoint (`git rev-parse HEAD`). No merge/release. +## Secure local key reuse increment — intentionally partial second phase + +Published K1 separately as 08be5d6eb4c1a367363e16b36bb4e2824c138b0e. +This continuation adds an executable `import-agent-key` local wizard, hidden +terminal input (protected stdin supported), exact public-key match, installation +lock and shared retained-authority validation before a secret-only atomic manifest +write. It cannot mint authority, recreate unknown slots or replace existing keys. +Retained standby/source-consumed assignment is not changed by importing its key. +Actual CLI/PTY import test + real host/relay standby denial passes (1.41s); strict +passes. Initial PTY attempts failed because macOS script refuses Node socketpair +stdin; retained import-focused logs 1–4 capture this adapter failure, then fixed +adapter uses a real shell pipe. No product assertions or timeouts were weakened. +Evidence: `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/K1_928F/`. + +Grounding read: HARNESS_PROVIDER_UX_GROUNDING.md. No provider abstraction or +executable/auth claim added. **Second-phase partial:** multiple reusable local +bindings CRUD, remote exact A/B selection/fingerprints, integrated initial +new/import/reuse wizard, and second Goose executable/provider/service-env path +remain unimplemented. Next executable step is local binding management + advertised +selection at the governing host preflight/configuration boundary, followed by +source-pinned Goose ACP fixture acceptance; not a design-only task or parity claim. +Final strict/default concurrent installed-enabled results will be recorded below. + ## K1 retained-authority removal — explicit closure Inspected immutable 2151ea3a: K1 was NOT fixed by the named Move commit. @@ -1107,3 +1130,31 @@ Selected-next != actual-run; stopped identity remains assigned. No remote secret credential/workspace/session transfer or unreachable-host takeover. Hash/spawn TOCTOU, hostile journals, rollback/clones, outbox pruning and stronger OS containment remain outside evidence. + +## 928F final validation/publication record + +Final executable tree: strict passes (`K1_928F/final-strict.log`). Exactly one final +FULL DEFAULT concurrent installed-enabled package run naturally completed: **50/51, +1 failure, no skips/cancellations, 35.322s** (`full-installed-default.log`). All six +key-removal/import tests passed, including actual CLI K1 negatives, real Move and +hidden-input macOS PTY. Failure: `slots.test.ts:131` waiting for standby host +`Host online` via launch:50; stderr is discarded by that existing test driver. +Cause remains **unclassified**. No serial masking, rerun-to-green, timeout inflation, +assertion weakening or claim of full-suite acceptance. Prior four 110s timeouts and +other historical failures above remain independently scoped and unclassified except +for the specifically traced private-journal/TUI observation revision race. + +Self-review checked common retained-state loader extraction, exact key matching, +no import authority synthesis, key input/output boundaries, atomic manifest-only +writes and stopped/lock gates. macOS PTY adapter failures and strict extraction +failure are retained alongside successful focused runs. Installed binaries were +read-only and hashed separately: buzz-acp +`10612d0025d1420bdd9e9afcc2e7129377da2414e75049a8b640e81d857441ea`, buzz CLI +`147cc2ccf276ddedc5b84d13f5399a95282066303c9dca566bb2af5a37b856c5`. +These are installed runtime + deterministic ACP/loopback fixtures, NOT live provider, +OAuth, production kind-40002 or full-product parity evidence. No dependency changes. +Source hashes and publication verification live beside the logs; the final source +commit is the commit containing this section. Required author/committer Logan +Johnson , DCO, no cryptographic signing claim. No repo-wide CI, +merge or release. Remaining second-phase work and full-suite failure investigation +are explicit handoff items, not closed by this partial key-import tracer. diff --git a/beehive/README.md b/beehive/README.md index 9169c8e404..8afa195455 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -585,3 +585,24 @@ selected-next, and actual/history retain full snapshots. A later Save gets a lat revision, is never overwritten, and forces consumed-grant acceptance to remain stopped. Reservations alone never confer Start authority. Legacy saved grant/event identity hashes are not changed or re-signed; new materialization requires compatible host versions. + +### Explicit local key reuse for a retained public-only slot + +`node src/cli.ts import-agent-key ` is a small +local repair wizard: confirm the public identity, then paste the exact 64-hex +private key at a **hidden** prompt. Never put the private key in argv, environment, +a remote message or terminal transcript. Protected piped stdin is supported; +the key is never printed. It restores only a previously removed local copy, not +an unknown identity or an assignment. The host must be stopped and its lock free. +A valid retained journal is mandatory; wrong keys, missing/malformed authority, +non-stopped slots and already-present keys refuse without modifying state. +Standby or consumed-source import does **not** grant Start. All retained history, +public configuration, sibling keys and journals remain unchanged. Ordinary remote +Save/Start/Restart still never recreate removed keys. This is deliberate local +provisioning, not global revocation/rotation or restoration of compromised authority. + +This increment does not yet integrate import/new/reuse into the initial setup +wizard, manage multiple local harness bindings, or add Goose. Those, remote A/B +selection with immutable applied inputs, and remaining Claude/Codex/preset/custom/ +mesh/compute parity remain unfinished. Existing file-based standby enrollment is +unchanged; no provider login/cache access or live-provider acceptance is claimed. diff --git a/beehive/TESTING.md b/beehive/TESTING.md index ca29efd717..604d6be4bd 100644 --- a/beehive/TESTING.md +++ b/beehive/TESTING.md @@ -356,3 +356,19 @@ journal gate means the separate TUI has consumed inventory. Gate failures close owned UI and propagate; existing assertions and observation/test deadlines are unchanged. Retained direct diagnostic captured a stale-observation Restart revision-conflict; other older timeout observations lacking that trace remain unclassified. + +## K1 and explicit local key import continuation + +`remove-key.test.ts` adds actual removal CLI missing-assignment/mismatched-genesis +and malformed owner/holder/root/predecessor/grant/operation negatives, retaining +manifest and journal bytes. Stopped, standby and consumed-source positive controls +use the same read-only host admission loader. Existing real Move tests remain. + +The import test exercises actual CLI confirmation, wrong key and malformed hidden +input, lock and corrupt-authority refusal, exact secret-only manifest change, +0600/0700 permissions and unchanged sibling/history. On macOS it also runs the +actual wizard inside `/usr/bin/script`'s PTY and asserts no key echo. The fixed +`cat | script` test adapter gives macOS a real pipe instead of Node socketpair +stdin; it does not inject key argv. Other platforms exercise the subprocess but +not that macOS PTY. A real host and loopback relay then deny imported standby Start +without spawning or remotely mutating the manifest. No provider calls are involved. diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index 9fe66ab3ef..07e9a50196 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -1,3 +1,4 @@ +import { readAgentSecret } from './key-input.ts'; import { Profiles, profile, profileRevision, type Profile } from './profiles.ts'; import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; @@ -5,7 +6,7 @@ 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 } from './host.ts'; -import { migrateSlots, addSlot, installationSlots, saveDefaultHarness, removeSlotKey } from './slots.ts'; +import { migrateSlots, addSlot, installationSlots, saveDefaultHarness, removeSlotKey, importSlotKey } 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'; @@ -21,6 +22,7 @@ const help = `Beehive — isolated development preview (loopback relay only) migrate-slots Explicit stopped upgrade, preserves journal add-agent New identity using shared local harness 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 migrate-assignment Explicit stopped legacy enrollment auth-info Print exact local sign-in context (no login) @@ -104,6 +106,22 @@ async function main() { removeSlotKey(dir, entry.agent); console.log(`Local key copy removed for agent ${entry.agent}; public-only slot retained on ${entry.setup.host}. Assignment, journal history, configurations and receipts are unchanged; Stop/Save/history stay available. Start/Restart now reject before spawn; remote operations never recreate the secret. Sibling slots and the owner identity are unaffected. Re-provision requires explicit local reconciliation.`); } finally { ui.close(); } + } else if (command === 'import-agent-key') { + if (args.length !== 2) throw Error('Use host directory and public key only; private keys are never arguments'); + const dir = resolve(text(args[0])), agent = text(args[1]); + if (!/^[0-9a-f]{64}$/.test(agent)) throw Error('Invalid agent public key'); + const entry = installationSlots(dir).find(e => e.agent === agent); + if (!entry) throw Error('Unknown retained public slot; import cannot invent authority'); + if (entry.keyPresent) throw Error('Local key already present; reuse the existing identity without import'); + const ui = createInterface({ input: stdin, output: stdout }); + let confirmed = false; + try { + confirmed = await ui.question(`Restore ONLY the local key for ${agent}? Retained assignment/history remain unchanged; standby import never grants Start. [yes/no]: `) === 'yes'; + } finally { ui.close(); } + if (!confirmed) return; + const secret = await readAgentSecret(); + importSlotKey(dir, agent, secret); + console.log(`Local key restored for ${agent}; retained assignment and history unchanged. Start still requires this host's assignment. No keys or sessions transferred.`); } else if (command === 'assignment-export') { const entries = installationSlots(resolve(text(args[0]))); const entry = args[2] ? entries.find(e => e.agent === args[2]) : entries.length === 1 ? entries[0] : undefined; diff --git a/beehive/src/key-input.ts b/beehive/src/key-input.ts new file mode 100644 index 0000000000..4d6ad9a274 --- /dev/null +++ b/beehive/src/key-input.ts @@ -0,0 +1,19 @@ +import { createInterface } from 'node:readline/promises'; +import { Writable } from 'node:stream'; +import { stdin, stdout } from 'node:process'; +import { publicKey } from './protocol.ts'; + +/** Local secret entry only: disable terminal echo and never route key bytes to output. + * Piped input is supported for protected automation; no secret argv or environment. */ +export async function readAgentSecret(): Promise { + const muted = new Writable({ write(_chunk, _encoding, done) { done(); } }); + const ui = createInterface({ input: stdin, output: muted, terminal: Boolean(stdin.isTTY) }); + try { + stdout.write('Agent private key (64 hex; hidden, never passed as an argument): '); + const value = await ui.question(''); + if (!/^[0-9a-fA-F]{64}$/.test(value)) throw Error('Invalid private key; expected 64 hex characters'); + const secret = value.toLowerCase(); + try { publicKey(secret); } catch { throw Error('Invalid private key'); } + return secret; + } finally { ui.close(); muted.end(); stdout.write('\n'); } +} diff --git a/beehive/src/slots.ts b/beehive/src/slots.ts index 9c49a31c91..0a3bcd68ad 100644 --- a/beehive/src/slots.ts +++ b/beehive/src/slots.ts @@ -127,6 +127,25 @@ export function removeSlotKey(directory: string, agentKey: string): void { } finally { rmdirSync(lock); } } +/** Explicit local restoration of a retained public identity, never enrollment or + * assignment repair. Importing on standby/consumed source grants no execution. */ +export function importSlotKey(directory: string, agentKey: string, secret: string): void { + if (!/^[0-9a-f]{64}$/.test(agentKey) || publicKey(secret) !== agentKey) throw Error('Imported key does not match the selected public identity'); + const lock = join(directory, 'host.lock'); + mkdirSync(lock, { mode: 0o700 }); + try { + const i = readInstallation(directory); + const entry = i.agents[agentKey]; + if (!entry) throw Error('Unknown retained public slot; import cannot invent authority'); + if (entry.secret !== null) throw Error('Local key already present; reuse the existing identity without import'); + const setup = validateSetup({ ...i.setups[entry.setup], host: i.host, ownerSecret: i.ownerSecret }); + const state = loadSlotState(setup, slotPath(directory, agentKey, entry.legacy), agentKey); + if (state.phase !== 'stopped' || state.actual !== null) throw Error('Key import requires a stopped slot with no actual run'); + entry.secret = secret; + writePrivate(join(directory, 'setup.json'), i); + } finally { rmdirSync(lock); } +} + /** Caller holds the installation lock; update shared harness inventory without key copies. */ export function saveDefaultHarness(directory: string, setup: Setup): void { const raw = object(readPrivate(join(directory, 'setup.json'))); diff --git a/beehive/test/remove-key.test.ts b/beehive/test/remove-key.test.ts index 54da773aa1..635e4f59db 100644 --- a/beehive/test/remove-key.test.ts +++ b/beehive/test/remove-key.test.ts @@ -2,13 +2,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawn, type ChildProcess } from 'node:child_process'; import { once } from 'node:events'; -import { mkdtempSync, realpathSync, rmSync, readFileSync, existsSync, statSync } from 'node:fs'; +import { mkdtempSync, realpathSync, rmSync, readFileSync, existsSync, statSync, mkdirSync, rmdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { setTimeout as delay } from 'node:timers/promises'; import { createGenesis, validateGenesis } from '../src/assignment.ts'; import { provision, host, type Setup } from '../src/host.ts'; -import { migrateSlots, addSlot, removeSlotKey } from '../src/slots.ts'; +import { migrateSlots, addSlot, removeSlotKey, importSlotKey } from '../src/slots.ts'; import { newKey, publicKey, message, type Message } from '../src/protocol.ts'; import { readPrivate, writePrivate } from '../src/storage.ts'; import { relay } from '../src/relay.ts'; @@ -23,20 +23,25 @@ async function closeChild(child: ChildProcess) { const done = once(child, 'exit'); child.kill('SIGTERM'); await done; } /** Actual CLI subprocess path; answers are matched against real prompts. */ -async function cli(args: string[], answers: { prompt: string; answer: string }[] = [], timeoutMs = 20000): Promise<{ code: number | null; out: string; err: string }> { - const child = spawn(process.execPath, ['src/cli.ts', ...args], { stdio: ['pipe', 'pipe', 'pipe'] }); +async function cli(args: string[], answers: { prompt: string; answer: string }[] = [], timeoutMs = 20000, pty = false): Promise<{ code: number | null; out: string; err: string }> { + // macOS script needs a real pipe rather than Node's socketpair stdin. + // The fixed adapter changes no user command or input; secrets only traverse stdin. + const child = spawn(pty ? '/bin/sh' : process.execPath, pty + ? ['-c', 'cat | /usr/bin/script -q /dev/null "$@"', 'beehive-pty', process.execPath, 'src/cli.ts', ...args] + : ['src/cli.ts', ...args], { stdio: ['pipe', 'pipe', 'pipe'] }); let out = '', err = '', cursor = 0, index = 0, pending = false; - child.stderr.on('data', c => { err += c.toString(); }); - child.stdout.on('data', c => { - out += c.toString(); const step = answers[index]; + child.stderr?.on('data', c => { err += c.toString(); }); + const receive = (c: Buffer) => { + out += c.toString(); if (pty && out.includes('Local key restored')) child.stdin.end(); const step = answers[index]; if (!step || pending) return; const position = out.indexOf(step.prompt, cursor); if (position < 0) return; pending = true; - void (async () => { await delay(60); cursor = position + step.prompt.length; index++; pending = false; child.stdin.write(`${step.answer}\n`); })(); - }); + void (async () => { await delay(60); cursor = position + step.prompt.length; index++; pending = false; child.stdin!.write(`${step.answer}\n`); })(); + }; + child.stdout?.on('data', receive); const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs); try { const [code] = await once(child, 'exit'); return { code, out, err }; } - finally { clearTimeout(timer); if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); } + finally { clearTimeout(timer); child.stdin.end(); if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); } } test('deliberate local key removal keeps the public slot: CLI confirmation, retained sibling/history, reopen, denied Start/Restart before spawn, neutral Stop/Save, reconnect', { timeout: 40000 }, async () => { @@ -342,3 +347,56 @@ test('K1 actual removal CLI validates retained host authority before destructive } } finally { rmSync(dir, { recursive: true, force: true }); } }); + +test('explicit hidden local key import reuses retained identity without resetting standby authority (CLI and PTY)', { timeout: 30000 }, async () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-import-'))), d = join(dir, 'standby'); + const ownerSecret = newKey(), x = newKey(), y = newKey(), X = publicKey(x), Y = publicKey(y); + provision(d, { host: 'standby', ownerSecret, agentSecret: x, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' }, createGenesis(publicKey(ownerSecret), X, 'source')); + migrateSlots(d); addSlot(d, y, createGenesis(publicKey(ownerSecret), Y, 'standby')); removeSlotKey(d, X); + const jp = join(d, 'journal.json'), mp = join(d, 'setup.json'), yp = join(d, 'agents', Y, 'journal.json'); + const journalBefore = readFileSync(jp), siblingBefore = readFileSync(yp), removedManifest = readFileSync(mp); + let h: Awaited> | undefined; + const server = await relay(0, publicKey(ownerSecret), join(dir, 'relay.json')); + const address = server.address(); assert.ok(address && typeof address !== 'string'); + const url = `ws://127.0.0.1:${address.port}`, seen: Message[] = []; + const ui = connect(url, ownerSecret, m => seen.push(m)); await ui.ready; + try { + const declined = await cli(['import-agent-key', d, X], [{ prompt: 'Restore ONLY', answer: 'no' }]); + assert.equal(declined.code, 0); assert.deepEqual(readFileSync(mp), removedManifest); + for (const value of [y, 'invalid-private-value']) { + const rejected = await cli(['import-agent-key', d, X], [{ prompt: 'Restore ONLY', answer: 'yes' }, { prompt: 'Agent private key (', answer: value }]); + assert.equal(rejected.code, 1, rejected.out); assert.doesNotMatch(rejected.out, /Local key restored/); + assert.ok(!rejected.out.includes(value) && !rejected.err.includes(value)); + assert.deepEqual(readFileSync(mp), removedManifest); + } + mkdirSync(join(d, 'host.lock')); + assert.throws(() => importSlotKey(d, X, x)); assert.ok(existsSync(join(d, 'host.lock'))); + rmdirSync(join(d, 'host.lock')); + const bad = JSON.parse(journalBefore.toString()); delete bad.assignment; + writePrivate(jp, bad); assert.throws(() => importSlotKey(d, X, x), /assignment/); + assert.deepEqual(readFileSync(mp), removedManifest); writePrivate(jp, JSON.parse(journalBefore.toString())); + const imported = await cli(['import-agent-key', d, X], [{ prompt: 'Restore ONLY', answer: 'yes' }, { prompt: 'Agent private key (', answer: x }], 20000, process.platform === 'darwin'); + assert.equal(imported.code, 0, (imported.err + imported.out).replaceAll(x, '[redacted]')); assert.match(imported.out, /Local key restored/); + for (const secret of [x, y, ownerSecret]) assert.ok(!imported.out.includes(secret) && !imported.err.includes(secret), 'including real PTY echo'); + assert.deepEqual(readFileSync(jp), journalBefore); assert.deepEqual(readFileSync(yp), siblingBefore); + const restored = JSON.parse(readFileSync(mp, 'utf8')); const expected = JSON.parse(removedManifest.toString()); expected.agents[X].secret = x; + assert.deepEqual(restored, expected, 'only the selected local secret changes'); + assert.equal(statSync(mp).mode & 0o777, 0o600); assert.equal(statSync(d).mode & 0o777, 0o700); + assert.throws(() => importSlotKey(d, X, x), /already present/); + const restoredManifest = readFileSync(mp); + h = await host(d, url); + await until(() => seen.some(m => m.type === 'inventory' && m.agent === X)); + const start = message('start', 'standby', X, 0); ui.send(start); + await until(() => seen.some(m => m.type === 'receipt' && m.body.operation === start.id)); + assert.notEqual(seen.find(m => m.type === 'receipt' && m.body.operation === start.id)!.body.result, 'accepted'); + assert.equal(JSON.parse(readFileSync(jp, 'utf8')).assignment.assignedHost, 'source'); + assert.equal(existsSync(join(dir, 'received-instructions.jsonl')), false, 'imported standby cannot spawn'); + assert.deepEqual(readFileSync(mp), restoredManifest, 'remote Start cannot mutate local keys'); + assert.deepEqual(readFileSync(yp), siblingBefore, 'sibling remains untouched'); + await h.close(); h = undefined; + removeSlotKey(d, X); assert.equal(JSON.parse(readFileSync(mp, 'utf8')).agents[X].secret, null); + } finally { + await h?.close(); ui.close(); for (const c of server.clients) c.terminate(); + await new Promise(resolve => server.close(() => resolve())); rmSync(dir, { recursive: true, force: true }); + } +});