feat(beehive): restore retained local keys through a hidden import wizard

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 02:11:47 -04:00
parent 08be5d6eb4
commit 6a5a459246
7 changed files with 213 additions and 11 deletions

View File

@@ -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 <loganj@squareup.com>, 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.

View File

@@ -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 <host-directory> <agent-public-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.

View File

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

View File

@@ -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 <host-directory> Explicit stopped upgrade, preserves journal
add-agent <host-directory> New identity using shared local harness
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
migrate-assignment <host-directory> Explicit stopped legacy enrollment
auth-info <host-directory> 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;

19
beehive/src/key-input.ts Normal file
View File

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

View File

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

View File

@@ -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<ReturnType<typeof host>> | 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<void>(resolve => server.close(() => resolve())); rmSync(dir, { recursive: true, force: true });
}
});