From 1bf0d11749909c1fc1b0a15e58d212bd8fb05fa7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 13:32:10 -0400 Subject: [PATCH] beehive: fence live credential reads with owned cancellation Signed-off-by: Logan Johnson --- beehive/CREDENTIAL_ASYNC_707E.md | 71 ++++++++++++++++++++++ beehive/src/cli.ts | 25 +++++++- beehive/src/credential-helper-child.ts | 16 +++++ beehive/src/credential-helper.ts | 55 +++++++++++++++++ beehive/src/credential-slots.ts | 68 ++++++++++++++++++++- beehive/src/credential-store.ts | 2 + beehive/src/host.ts | 46 ++++++++------ beehive/src/native-credentials.ts | 1 + beehive/src/slots.ts | 12 +++- beehive/test/credential-helper.test.ts | 84 ++++++++++++++++++++++++++ beehive/test/credential-slots.test.ts | 28 +++++++++ 11 files changed, 385 insertions(+), 23 deletions(-) create mode 100644 beehive/CREDENTIAL_ASYNC_707E.md create mode 100644 beehive/src/credential-helper-child.ts create mode 100644 beehive/src/credential-helper.ts create mode 100644 beehive/test/credential-helper.test.ts diff --git a/beehive/CREDENTIAL_ASYNC_707E.md b/beehive/CREDENTIAL_ASYNC_707E.md new file mode 100644 index 0000000000..61f041f120 --- /dev/null +++ b/beehive/CREDENTIAL_ASYNC_707E.md @@ -0,0 +1,71 @@ +# Cancellation-fenced credential access — implementation candidate, not OS readiness + +## Boundary + +`credentialHelperReader` owns one one-shot Node subprocess, stdin/stdout pipes only, +1 KiB response cap, 10-second default deadline (maximum 30 seconds), and refuses +concurrent reads. It sends only a public Beehive reference in the request. No +private argv, inherited provider environment, native error text, journals, daemon, +Worker termination assumption or secret cache. Cancellation/deadline sends SIGKILL +and waits for child `close`, not the boolean return from kill. The trusted child +has no subprocess creation. Blocking fixture uses Atomics.wait so its JS abort +handlers cannot run; tests check that the owned PID no longer exists on settlement. +This demonstrates process cancellation, **not** Keychain SecurityAgent dialog +cancellation or OS availability. An OS-owned dialog may survive the caller; actual +owner-present validation remains mandatory. Windows process semantics unvalidated. + +Default native live reads refuse before constructing any Entry. An explicit +operator-approved helper reader can be composed into `CredentialBackend.readAsync` +after independent review and the owner-present OS step; it is not automatically +installed/enabled by host startup. Local deliberate provision/import/remove retain +the existing synchronous adapter. Native missing/null is distinct from thrown +locked/denied/unavailable; timeout says possibly prompt-blocked, not proven locked. + +Host hydration and every pre-spawn reread use the async boundary. Stop cancellation +is received outside the serialized executor, aborts the credential read and waits +for cleanup before its queued completion. Close uses the same ownership boundary. +Late results are checked for cancellation, revision, placement, selected-next and +public-manifest changes; callers recheck after the prepare await before effects. +Startup cancellation cannot release host.lock before hydration cleanup completes. +Fresh key loading at Start remains; deliberately removed keys cannot come from a +hydrated-secret fallback. Explicit fixture backends can remain synchronous; native +backends cannot silently fall through to synchronous live reads. + +## V3 local progress + +- `addSlot` now dispatches v3 to store -> verify -> public-reference activation, + reusing a local binding without sibling key copies. Genesis preserves standby + assignment. Existing/public-only identity refuses duplicate creation. +- V3 `add-agent` uses a public-only overview, asks before generating a new agent, + or accepts a public standby genesis and hidden matching-key input. V3 does not + accept a plaintext private-key file. This is independent of the explicitly + retained legacy diagnostic command paths. +- `reconcile-provision` explicitly resumes FIRST-provision failure only: same + public setup, matching supplied key/genesis, exact inert journal, no active + manifest. Existing matching orphan key is verified, never overwritten. Missing + 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. + +## Evidence / remaining + +Workspace `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/CREDENTIAL_ASYNC_707E/` preserves exact +focused and candidate gate output. Controlled helper and v3 host tests cover blocked +read + Stop/close + deliberately late result + actual PID absence, and the prior +same-batch admission suite still covers receive-order/revision/auth fences. V3 +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. +No new v3 local provisioning CLI process journey is claimed yet (API tests only). +Existing installed owner-TUI private-wire reply journey remains separate evidence. +Direct ordinary membership is approved; source contract is now available in +`WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/DIRECT_MEMBER_EVIDENCE_AA0B3F26.md`; runtime +implementation remains. Private profile distribution, reconnect and new-wire Move +also remain. This is not the completed usable milestone or an install candidate. diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index eec5b06b55..78b5cdf276 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -1,4 +1,4 @@ -import { provisionCredentialSlot } from './credential-slots.ts'; +import { provisionCredentialSlot, reconcileCredentialProvision } from './credential-slots.ts'; import { readHostIdentity } from './host-identity.ts'; import { verifyHostRegistration } from './host-registration.ts'; import { setupOwner } from './host.ts'; @@ -35,6 +35,7 @@ const help = `Beehive — isolated development preview (loopback relay only) setup Offline host pairing/owner approval/import setup LEGACY loopback diagnostic setup (owner key copied) provision-agent Hidden matching agent import; OS credentials + reconcile-provision Explicit exact first-provision recovery catalog Retain verified public host registrations presets Local process-free preset discovery/setup guidance local-setup Bindings; new/reuse/hidden standby/restore identity @@ -55,7 +56,7 @@ Move is fixture-only experimental; containment acceptance remains gated. No prov async function main() { if (command === 'identity') { throw Error('Standalone Beehive uses your existing owner identity through explicit secure input. Creating a parallel controller identity or persisting a plaintext owner key is disabled.'); - } else if (command === 'provision-agent') { + } else if (command === 'provision-agent' || command === 'reconcile-provision') { const directory = resolve(text(args[0])); const identity = readHostIdentity(directory); verifyHostRegistration(identity.registration, identity.pairing); @@ -64,7 +65,8 @@ async function main() { const setup = validateSetup({ ...binding, host: identity.pairing.host, ownerPublic: identity.pairing.owner }); const genesis = validateGenesis(readPrivate(resolve(text(args[2])))); const secret = await readAgentSecret(); - provisionCredentialSlot(directory, setup, secret, genesis); + if (command === 'reconcile-provision') reconcileCredentialProvision(directory, setup, secret, genesis); + else provisionCredentialSlot(directory, setup, secret, genesis); console.log(`Provisioned STOPPED public slot ${publicKey(secret)}; owner ${identity.pairing.owner}. Key stored and read-back verified in Beehive OS credential namespace. No Start or relay admission performed.`); } else if (command === 'presets') { showPresets(); @@ -155,6 +157,23 @@ async function main() { } finally { ui.close(); } } else if (command === 'add-agent') { const dir = resolve(text(args[0])); + if (object(readPrivate(join(dir, 'setup.json'))).version === 3) { + if (args.length > 2) throw Error('V3 add-agent accepts only an optional public genesis file; never a private key file'); + // Public-only overview: adding an independent identity does not read sibling keys. + const first = installationSlots(dir, { read: () => null, create() { throw Error('Read only'); }, remove() { throw Error('Read only'); } })[0]!.setup; + const importedRoot = args[1] ? validateGenesis(readPrivate(resolve(text(args[1])))) : undefined; + if (importedRoot && (importedRoot.owner !== setupOwner(first) || importedRoot.initialHost === first.host)) throw Error('Standby requires matching owner and another initial host'); + const ui = createInterface({ input: stdin, output: stdout }); + let confirmed: boolean; + try { confirmed = await ui.question(importedRoot ? `Import exact standby ${importedRoot.agent}, still assigned to ${importedRoot.initialHost}? [yes/no]: ` : 'Create an independent NEW v3 agent using OS credentials and default binding? [yes/no]: ') === 'yes'; } + finally { ui.close(); } + if (!confirmed) return; + const secret = importedRoot ? await readAgentSecret() : newKey(); + const root = importedRoot ?? createGenesis(setupOwner(first), publicKey(secret), first.host); + addSlot(dir, secret, root); + console.log(`Added STOPPED public slot ${publicKey(secret)}; credential read-back verified; no relay admission or Start.`); + return; + } const first = installationSlots(dir)[0]!.setup; const importing = args.length > 1; const secret = importing ? text(object(readPrivate(resolve(text(args[1])))).secret) : newKey(); diff --git a/beehive/src/credential-helper-child.ts b/beehive/src/credential-helper-child.ts new file mode 100644 index 0000000000..952df6e7b9 --- /dev/null +++ b/beehive/src/credential-helper-child.ts @@ -0,0 +1,16 @@ +import { nativeCredentials } from './native-credentials.ts'; +import type { CredentialReference } from './credential-store.ts'; + +// Dedicated one-shot private pipe protocol. No errors or secrets to stderr/files. +let input = ''; +process.stdin.on('data', (chunk: Buffer) => { + if (Buffer.byteLength(input) + chunk.length > 512) process.exit(1); + input += chunk.toString('utf8'); +}); +process.stdin.on('end', () => { + try { + const reference = JSON.parse(input) as CredentialReference; input = ''; + const secret = nativeCredentials().read(reference); + process.stdout.end(JSON.stringify(secret === null ? { status: 'missing' } : { status: 'present', secret })); + } catch { process.stdout.end(JSON.stringify({ status: 'failed' })); } +}); diff --git a/beehive/src/credential-helper.ts b/beehive/src/credential-helper.ts new file mode 100644 index 0000000000..ec400a2fee --- /dev/null +++ b/beehive/src/credential-helper.ts @@ -0,0 +1,55 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import type { CredentialReference } from './credential-store.ts'; + +/** One owned process per read; no daemon, argv secret, journal or inherited IPC. + * SIGKILL interrupts the process even while its JS thread is in native code. + * Settlement waits for close (exit AND pipe closure), never just kill's return. + * This proves process cleanup, not dismissal of an OS-owned approval dialog. + */ +export function credentialHelperReader(options: { operatorApproved: true; timeoutMs?: number; helper?: URL }) { + const timeout = options.timeoutMs ?? 10_000; + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30_000) throw Error('Invalid credential helper deadline'); + let busy = false; + return async (reference: CredentialReference, signal?: AbortSignal): Promise => { + signal?.throwIfAborted(); + if (busy) throw Error('Credential helper already busy'); + if (options.operatorApproved !== true) throw Error('Explicit owner-present credential access approval required'); + busy = true; + try { + return await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [fileURLToPath(options.helper ?? new URL('./credential-helper-child.ts', import.meta.url))], { + stdio: ['pipe', 'pipe', 'ignore'], + // Do not inherit provider credentials, NODE_OPTIONS or loader overrides. + env: { PATH: '/usr/bin:/bin', ...(process.env.HOME ? { HOME: process.env.HOME } : {}) }, + }); + let output = '', error: Error | undefined; + const stop = (reason: Error) => { error ??= reason; child.kill('SIGKILL'); }; + const abort = () => stop(Error('Credential read cancelled; owned helper terminated')); + const timer = setTimeout(() => stop(Error('Credential read timed out (possibly permission-prompt blocked); owned helper terminated; OS readiness unverified')), timeout); + signal?.addEventListener('abort', abort, { once: true }); + child.stdout.on('data', (chunk: Buffer) => { + if (error) return; + if (Buffer.byteLength(output) + chunk.length > 1024) { output = ''; stop(Error('Invalid credential helper response')); return; } + output += chunk.toString('utf8'); + }); + child.on('error', () => { error ??= Error('Credential helper unavailable'); }); + child.stdin.on('error', () => stop(Error('Credential helper pipe failed'))); + child.on('close', code => { + clearTimeout(timer); signal?.removeEventListener('abort', abort); + if (signal?.aborted) error ??= Error('Credential read cancelled'); + if (error) { output = ''; reject(error); return; } + try { + if (code !== 0) throw Error(); + const value = JSON.parse(output); output = ''; + if (value.status === 'missing') { resolve(null); return; } + if (value.status === 'present' && typeof value.secret === 'string' && /^[0-9a-f]{64}$/.test(value.secret)) { resolve(value.secret); return; } + throw Error(); + } catch { reject(Error('OS credential read failed (locked, denied, unavailable, or invalid response); not missing')); } + }); + if (signal?.aborted) abort(); + else child.stdin.end(JSON.stringify(reference)); + }); + } finally { busy = false; } + }; +} diff --git a/beehive/src/credential-slots.ts b/beehive/src/credential-slots.ts index 58dc406763..53e5a7377c 100644 --- a/beehive/src/credential-slots.ts +++ b/beehive/src/credential-slots.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, rmdirSync } from 'node:fs'; import { join } from 'node:path'; -import { initialState, loadSlotState, setupOwner, validateSetup, type Setup } from './host.ts'; +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 { readPrivate, writePrivate } from './storage.ts'; @@ -48,6 +48,28 @@ export function credentialSlots(directory: string, backend: CredentialBackend = }); } +/** Cancellation-fenced live hydration. Public input is rechecked after each read; + * no secret result can hydrate a changed manifest or bypass a removed key. */ +export async function credentialSlotsAsync(directory: string, backend: CredentialBackend, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const manifest = readManifest(directory); + const snapshot = JSON.stringify(manifest); + const secrets = new Map(); + for (const entry of Object.values(manifest.agents)) { + signal?.throwIfAborted(); + const secret = backend.readAsync ? await backend.readAsync(entry.key, signal) : backend.read(entry.key); + signal?.throwIfAborted(); + if (JSON.stringify(readManifest(directory)) !== snapshot) throw Error('Credential manifest changed during read'); + if (secret !== null && publicKey(secret) !== entry.key.publicKey) throw Error('Credential identity mismatch'); + secrets.set(entry.key.publicKey, secret); + } + // Reuse the normal validation/materialization without another native read. + return credentialSlots(directory, { read: ref => { + if (!secrets.has(ref.publicKey)) throw Error('Credential snapshot changed'); + return secrets.get(ref.publicKey)!; + }, create() { throw Error('Read only'); }, remove() { throw Error('Read only'); } }); +} + function locked(directory: string, action: () => T): T { const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 }); @@ -97,3 +119,47 @@ export function importCredentialSlotKey(directory: string, agent: string, secret createCredential('agent', secret, backend); }); } + +/** Explicit same-identity recovery of an interrupted FIRST provision only. The + * complete inert genesis must match; active/public-only slots never enter here. + * No auto-adoption, key generation, overwriting or historical journal reset. */ +export function reconcileCredentialProvision(directory: string, setup: Setup, secret: string, root: Genesis, backend: CredentialBackend = systemCredentials): void { + if (setup.ownerSecret !== undefined || setup.agentSecret !== undefined) throw Error('Public provisioning setup required'); + validateSetup(setup); + const agent = publicKey(secret), genesis = validateGenesis(root), ownerPublic = setupOwner(setup); + if (genesis.owner !== ownerPublic || genesis.agent !== agent) throw Error('Genesis ownership mismatch'); + locked(directory, () => { + if (existsSync(manifestPath(directory))) throw Error('Active installation exists; use explicit retained-key import, never provision recovery'); + const expected = initialState({ ...setup, agentSecret: secret }, genesis); + const actual = readPrivate(journalPath(directory, agent)); + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error('Partial journal differs from exact inert genesis; 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. The journal is never rewritten. + if (readCredential(key, backend) !== secret || JSON.stringify(readPrivate(journalPath(directory, agent))) !== JSON.stringify(expected)) throw Error('Partial provision changed during reconciliation'); + const { host, ownerSecret: _owner, ownerPublic: _public, agentSecret: _agent, ...harness } = setup; + writePrivate(manifestPath(directory), { version: 3, host, ownerPublic, setups: { default: harness }, agents: { [agent]: { key, setup: 'default' } } }, true); + }); +} + +/** Add an independent v3 identity to an existing immutable local binding. Failed + * prefixes retain an inert journal for explicit reconciliation, never overwrite. */ +export function addCredentialSlot(directory: string, secret: string, root: Genesis, setupId = 'default', expectedFingerprint: string | undefined, backend: CredentialBackend = systemCredentials): void { + locked(directory, () => { + const manifest = readManifest(directory), agent = publicKey(secret), genesis = validateGenesis(root); + if (manifest.agents[agent] || Object.keys(manifest.agents).length >= 32) throw Error('Slot exists or 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'); + if (existsSync(journalPath(directory, agent))) throw Error('Partial slot requires explicit reconciliation; refusing reset'); + writePrivate(journalPath(directory, agent), initialState(setup, genesis), true); + const key = createCredential('agent', secret, backend); + manifest.agents[agent] = { key, setup: setupId }; + writePrivate(manifestPath(directory), manifest); + }); +} diff --git a/beehive/src/credential-store.ts b/beehive/src/credential-store.ts index c45bf532a6..3c90da4867 100644 --- a/beehive/src/credential-store.ts +++ b/beehive/src/credential-store.ts @@ -10,6 +10,8 @@ export type CredentialReference = { service: 'beehive'; role: 'host' | 'agent' | */ export interface CredentialBackend { read(reference: CredentialReference): string | null; + /** Live-host reads must honor cancellation and await owned cleanup. */ + readAsync?(reference: CredentialReference, signal?: AbortSignal): Promise; create(reference: CredentialReference, secret: string): void; remove(reference: CredentialReference): void; } diff --git a/beehive/src/host.ts b/beehive/src/host.ts index 34ec1ca3fb..a3f9f914aa 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -10,7 +10,7 @@ import { accessSync, constants, existsSync, mkdirSync, rmdirSync, realpathSync, import { join, isAbsolute } from 'node:path'; import { createHash } from 'node:crypto'; import { hash, semanticHash, selection, sameSelection, sameLaunchSelection, moveSelection, validateAssignment, extendsAssignment, type Assignment, type Grant } from './handoff.ts'; -import { installationSlots } from './slots.ts'; +import { installationSlotsAsync } from './slots.ts'; import { validateGenesis, type Genesis } from './assignment.ts'; import { connect, validateRelayURL } from './client.ts'; import { digest, fields, message, object, publicKey, text, type Message } from './protocol.ts'; @@ -115,7 +115,7 @@ export function loadSlotState(setup: Setup, path: string, agent: string): State return state; } /** Hosts consult durable assignment, never key presence or relay inventory, for authority. */ -function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string) => Setup, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record, retiredBindings: Record = {}) { +function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string, signal: AbortSignal) => Promise, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record, retiredBindings: Record = {}) { // Optional execution credential: a public-only slot (local key copy deliberately // removed) has none. There is no shadow identity and no reconstruction; execution // paths must load it explicitly and fail closed when absent. @@ -151,6 +151,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri }); } let closing = false; + let credentialRead: AbortController | undefined; let queue = Promise.resolve(); // Exact-fenced Stop retractions received ahead of their serialized handling, keyed // by that Stop's operation ID. One invariant covers every not-yet-committed Start @@ -172,6 +173,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri // Reserve receive order too: a conflicting queued ID is not a Stop authority. if (first && !closing && m.type === 'stop' && m.host === setup.host && m.agent === agent && state.assignment.assignedHost === setup.host && m.revision === state.revision && !Object.hasOwn(state.operations, m.id) && Object.keys(m.body).length === 0) { retracted.set(m.id,m.revision); + credentialRead?.abort(); restartProbe?.cancel(); // Only an executing admission has a session to interrupt; queued admissions // and the pre-commit recheck consume the retraction inside handle(). @@ -208,7 +210,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri const { id } = resolveBinding(selected); if (!['rename', 'remove'].includes(String(body.configurationAction)) && retiredBindings[id]) throw Error('Binding retired; explicitly select an available binding'); } - function prepareLocal(selected: Selection) { + async function prepareLocal(selected: Selection) { selected = selection(selected); const { setup, id } = resolveBinding(selected); if (retiredBindings[id]) throw Error('Binding retired; Start/Restart unavailable'); @@ -216,9 +218,15 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri // The optional execution credential is absent for a public-only slot: reject before // any spawn or source effect. The secret is never regenerated or inferred. if (executionSecret === undefined) throw Error('Local agent key removed; this public slot cannot launch; repair locally without resetting assignment'); + const revision = state.revision, assignment = hash(state.assignment), selectionBefore = hash(state.selected); + if (closing || retracting(revision)) throw Error('Credential admission cancelled'); + const controller = new AbortController(); credentialRead = controller; try { - if (semanticHash(currentSetup(id)) !== semanticHash(setup)) throw Error('changed'); - } catch { throw Error('Local agent key/setup missing or changed; repair locally without resetting assignment'); } + const fresh = await currentSetup(id, controller.signal); + controller.signal.throwIfAborted(); + if (closing || retracting(revision) || state.revision !== revision || hash(state.assignment) !== assignment || hash(state.selected) !== selectionBefore) throw Error('Credential admission invalidated'); + if (semanticHash(fresh) !== semanticHash(setup)) throw Error('Local agent key/setup missing or changed; repair locally without resetting assignment'); + } finally { if (credentialRead === controller) credentialRead = undefined; } accessSync(setup.runner, constants.X_OK); if (realpathSync(selected.workspace) !== selected.workspace || !statSync(selected.workspace).isDirectory()) throw Error('Workspace changed'); const launch: AgentLaunch | undefined = setup.mode === 'fixture' ? undefined : { executable: setup.runner, args: setup.args, workspace: selected.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), ...(setup.buzzProvider ? { buzzProvider: setup.buzzProvider } : {}), databricksHost: setup.mode === 'buzz-agent-api-key' || setup.mode === 'goose' || setup.mode === 'claude' || setup.mode === 'codex' ? '' : text(setup.databricksHost), ...(setup.mode === 'codex' ? { harness: 'codex' as const, codex: setup.codex } : {}), ...(setup.mode === 'claude' ? { harness: 'claude' as const, claude: setup.claude } : {}), ...(setup.mode === 'goose' ? { harness: 'goose' as const, provider: setup.gooseProvider, ...(setup.custom ? { custom: setup.custom } : {}) } : {}), model: selected.model, ...(selected.behavior ? { instructions: selected.behavior.instructions } : {}) }; @@ -284,14 +292,15 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri // Grant-acceptance evidence is immutable, even after restart. Replayed // prepare cannot replace the token already consumed by the source. if (!livePreparations.has(op.id)) throw Error('Preparation lifetime ended; use a new Move operation'); - if (prepareLocal(g.selection).token !== prior.token) throw Error('Prepared inputs changed'); + if ((await prepareLocal(g.selection)).token !== prior.token || closing || retracting(state.revision)) throw Error('Prepared inputs changed'); publish(prior.reply); return; } if (!prior && !sameLaunchSelection(op.body.selection, state.selected)) throw Error('Destination candidate changed'); if (!prior && Object.keys(state.preparations ?? {}).length >= 1000) throw Error('Preparation journal full; local reconciliation needed'); preparationStage = 'checking-local-prerequisites; not conversation readiness'; publish(inventory()); materializeMove(state.configurations, state.selected, g.selection); // Bound inventory before source can consume. - const prepared = prepareLocal(g.selection); + const prepared = await prepareLocal(g.selection); + if (closing || retracting(state.revision)) throw Error('Credential admission cancelled'); // Independent, identity-free prerequisite probe. It receives neither agent // signer nor conversation relay/tool configuration. A successful probe is // NOT readiness for the future conversation, which must be verified afresh. @@ -310,7 +319,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri } if (!probe.healthy) throw Error('ACP prerequisite invalidated during teardown'); } - if (closing || prepareLocal(g.selection).token !== prepared.token) throw Error('Preparation changed or host closing'); + if ((await prepareLocal(g.selection)).token !== prepared.token || closing || retracting(state.revision)) throw Error('Preparation changed or host closing'); const token = prepared.token; const reply = message('prepared', op.host, agent, op.revision, { prepare: m, token, materialization: g.materialization }); state.preparations ??= {}; state.preparations[op.id] = { request: m, token, reply, candidate: structuredClone(state.selected), ...(g.selection.configuration ? { reservedRevision: g.selection.configuration.revision } : {}) }; @@ -371,7 +380,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri let outcome: unknown; try { if (candidateChanged) throw Error('Destination candidate changed after preparation; assigned here, stopped; selected-next preserved'); - if (!livePreparations.has(g.operation.id) || prepareLocal(g.selection).token !== g.prepared) throw Error('Destination preparation invalidated; assigned here, stopped; repair local setup/key/auth then explicit Start'); + if (!livePreparations.has(g.operation.id) || (await prepareLocal(g.selection)).token !== g.prepared || closing || retracting(state.revision)) throw Error('Destination preparation invalidated; assigned here, stopped; repair local setup/key/auth then explicit Start'); const start = message('start', setup.host, agent, state.revision); start.id = g.operation.id; await handle(start); outcome = state.operations[start.id]?.reply.body.result; @@ -445,7 +454,8 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri if (retracting(m.revision)) throw Error('Start cancelled by concurrent Stop'); const phaseBeforePreparation = state.phase; const selected = structuredClone(state.selected); - const prepared = prepareLocal(selected); + const prepared = await prepareLocal(selected); + if (closing || retracting(m.revision)) throw Error('Start cancelled by concurrent Stop or close'); const { launch, setup } = prepared; if (m.type === 'restart') { // Prerequisites are checked without replacing the old ownership handle @@ -461,13 +471,13 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri } if (closing || retracting(m.revision)) throw Error('Restart cancelled by Stop or host close'); if (state.phase !== phaseBeforePreparation) throw Error('Restart ownership changed during preflight; reconcile before launch'); - if (prepareLocal(selected).token !== prepared.token) throw Error('Restart prepared inputs changed; existing run preserved'); + if ((await prepareLocal(selected)).token !== prepared.token || closing || retracting(m.revision)) throw Error('Restart prepared inputs changed; existing run preserved'); if (state.phase === 'running') { state.phase = 'transitioning'; save(); await stopOwned(); state.phase = 'stopped'; state.actual = null; save(); } if (closing || retracting(m.revision)) throw Error('Restart cancelled after Stop'); - if (prepareLocal(selected).token !== prepared.token) throw Error('Restart prepared inputs changed after Stop; assigned stopped'); + if ((await prepareLocal(selected)).token !== prepared.token || closing || retracting(m.revision)) throw Error('Restart prepared inputs changed after Stop; assigned stopped'); } state.actual = { selection: selected, harnessSetup: prepared.harnessSetup, preparedInputHash: prepared.token, appliedInstructions: { source: selected.behavior ? 'profile' : 'upstream-default', revision: selected.behavior?.revision ?? null, hash: selected.behavior ? digest(selected.behavior.instructions).toString('hex') : null }, executableHash: createHash('sha256').update(readFileSync(setup.runner)).digest('hex'), run: m.id }; state.phase = 'transitioning'; save(); @@ -530,7 +540,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri publish(inventory()); }, async close() { if (closing) return; closing = true; - restartProbe?.cancel(); acp?.cancel(); await queue; + credentialRead?.abort(); restartProbe?.cancel(); acp?.cancel(); await queue; if (restartProbe) { await restartProbe.owned.stop(); restartProbe = undefined; } if (owned) { state.phase = 'transitioning'; save(); @@ -564,13 +574,15 @@ export async function host(directory: string, url: string, signal?: AbortSignal, })(); // Abort uses the same owner as ready shutdown, including quarantine fences. const abort = () => { void close().catch(() => {}); }; - signal?.addEventListener('abort', abort, { once: true }); try { - const entries = installationSlots(directory, credentials); + const entries = await installationSlotsAsync(directory, credentials, signal); + signal?.throwIfAborted(); + signal?.addEventListener('abort', abort, { once: true }); for (const entry of entries) { if (transport && (entry.setup.host !== transport.binding.host || setupOwner(entry.setup) !== transport.binding.owner)) throw Error('Private transport differs from retained host/agent authority'); - slots.set(entry.agent, slot(entry.setup, entry.path, entry.agent, (id) => { - const current = installationSlots(directory, credentials).find(e => e.agent === entry.agent); + slots.set(entry.agent, slot(entry.setup, entry.path, entry.agent, async (id, readSignal) => { + const current = (await installationSlotsAsync(directory, credentials, readSignal)).find(e => e.agent === entry.agent); + readSignal.throwIfAborted(); if (!current) throw Error('Key removed'); const value = current.bindings[id]; if (!value) throw Error('Binding removed'); diff --git a/beehive/src/native-credentials.ts b/beehive/src/native-credentials.ts index cc7d350f7a..07b83cc38a 100644 --- a/beehive/src/native-credentials.ts +++ b/beehive/src/native-credentials.ts @@ -29,6 +29,7 @@ export function nativeCredentials(load: () => EntryConstructor = loadNativeEntry } } return { + async readAsync() { throw Error('OS live-host credential access requires explicit owner-present approval and cancellation review; no automatic OS access'); }, read: reference => operation(() => entry(reference).getPassword()), create: (reference, secret) => operation(() => { const item = entry(reference); diff --git a/beehive/src/slots.ts b/beehive/src/slots.ts index 9381e1ab4a..ca0b900378 100644 --- a/beehive/src/slots.ts +++ b/beehive/src/slots.ts @@ -1,4 +1,4 @@ -import { credentialSlots, removeCredentialSlotKey, importCredentialSlotKey } from './credential-slots.ts'; +import { credentialSlots, credentialSlotsAsync, addCredentialSlot, removeCredentialSlotKey, importCredentialSlotKey } from './credential-slots.ts'; import { systemCredentials, type CredentialBackend } from './credential-store.ts'; import { setupOwner } from './host.ts'; import { prepareAgent } from './acp.ts'; @@ -58,6 +58,13 @@ function readInstallation(directory: string): Installation { } return i; } +/** Live-host loading never performs synchronous native credential reads. */ +export async function installationSlotsAsync(directory: string, backend: CredentialBackend, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const raw = object(readPrivate(join(directory, 'setup.json'))); + if (raw.version === 3) return credentialSlotsAsync(directory, backend, signal); + return installationSlots(directory, backend); +} /** Resolve shared local harness inventory without copying agent keys into setups. */ export function installationSlots(directory: string, backend: CredentialBackend = systemCredentials): SlotEntry[] { const raw = object(readPrivate(join(directory, 'setup.json'))); @@ -96,7 +103,8 @@ export function migrateSlots(directory: string): void { } finally { rmdirSync(lock); } } /** Local key enrollment reuses a host-owned harness, never another agent's key. */ -export function addSlot(directory: string, secret: string, genesis: Genesis, setupName = 'default', expectedFingerprint?: string): void { +export function addSlot(directory: string, secret: string, genesis: Genesis, setupName = 'default', expectedFingerprint?: string, backend: CredentialBackend = systemCredentials): void { + if (object(readPrivate(join(directory, 'setup.json'))).version === 3) return addCredentialSlot(directory, secret, genesis, setupName, expectedFingerprint, backend); const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 }); try { const i = readInstallation(directory); const key = publicKey(secret); diff --git a/beehive/test/credential-helper.test.ts b/beehive/test/credential-helper.test.ts new file mode 100644 index 0000000000..2a309a8146 --- /dev/null +++ b/beehive/test/credential-helper.test.ts @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { credentialHelperReader } from '../src/credential-helper.ts'; +import { credentialReference, systemCredentials } from '../src/credential-store.ts'; +import { provisionCredentialSlot } from '../src/credential-slots.ts'; +import { host, validateSetup } from '../src/host.ts'; +import { createGenesis } from '../src/assignment.ts'; +import { newKey, publicKey, message, type Message } from '../src/protocol.ts'; +import { memoryCredentials } from './credential-fixture.ts'; + +async function until(predicate: () => boolean) { + for (let i = 0; i < 200; i++) { if (predicate()) return; await delay(10); } + throw Error('Fixture evidence missing'); +} +function blocker(directory: string) { + const pidFile = join(directory, 'helper.pid'), script = join(directory, 'helper.mjs'); + // PID-only fixture evidence, never credential material. Blocking JS cannot run + // an abort callback, approximating a native call's event-loop unresponsiveness. + writeFileSync(script, `import {writeFileSync} from 'node:fs';\nwriteFileSync(${JSON.stringify(pidFile)},String(process.pid));\nAtomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);\n`); + return { pidFile, helper: pathToFileURL(script) }; +} +function gone(pidFile: string) { + const pid = Number(readFileSync(pidFile, 'utf8')); + assert.throws(() => process.kill(pid, 0), (e: NodeJS.ErrnoException) => e.code === 'ESRCH'); +} + +test('one-shot blocked helper abort/deadline waits for actual exit, bounded concurrency, no OS calls', async () => { + const directory = mkdtempSync(join(tmpdir(), 'beehive-helper-')); + try { + const fixture = blocker(directory), ref = credentialReference('agent', publicKey(newKey())); + const read = credentialHelperReader({ operatorApproved: true, helper: fixture.helper, timeoutMs: 1000 }); + const controller = new AbortController(); + const pending = read(ref, controller.signal); + const rejected = assert.rejects(pending, /cancelled/); + await until(() => existsSync(fixture.pidFile)); + await assert.rejects(read(ref), /busy/); + controller.abort(); await rejected; gone(fixture.pidFile); + await assert.rejects(read(ref), /timed out.*possibly permission-prompt blocked/); gone(fixture.pidFile); + await assert.rejects(systemCredentials.readAsync!(ref), /owner-present/); + } finally { rmSync(directory, { recursive: true, force: true }); } +}); + +test('v3 host Stop and close cancel a pending credential child before any spawn; late fixture secrets are fenced', async () => { + for (const action of ['stop', 'close', 'late'] as const) { + const directory = mkdtempSync(join(tmpdir(), 'beehive-helper-host-')); + const backend = memoryCredentials(), owner = newKey(), secret = newKey(), agent = publicKey(secret); + const setup = validateSetup({ host: 'helper-host', ownerPublic: publicKey(owner), mode: 'fixture', runner: process.execPath, args: [resolve('test/runner.ts')], workspace: directory }); + provisionCredentialSlot(directory, setup, secret, createGenesis(publicKey(owner), agent, setup.host), backend); + const fixture = blocker(directory), read = credentialHelperReader({ operatorApproved: true, helper: fixture.helper }); + let hydration = true, pending = false, late: (() => void) | undefined; + const seen: Message[] = []; let receive!: (m: Message) => void; + const h = await host(directory, 'ws://fixture.invalid', undefined, { + binding: { host: setup.host, owner: publicKey(owner) }, validate() {}, + connect(_url, _secret, callback) { receive = callback; return { ready: Promise.resolve(), send(m) { seen.push(m); }, close() {} }; }, + }, { ...backend, async readAsync(ref, signal) { + if (hydration) return backend.read(ref); + pending = true; + if (action === 'late') { await new Promise(r => { late = r; }); return secret; } + return read(ref, signal); + } }); + hydration = false; + try { + const start = message('start', setup.host, agent, 0); receive(start); + await until(() => pending && (action === 'late' || existsSync(fixture.pidFile))); + if (action === 'close') await h.close(); + else { + const stop = message('stop', setup.host, agent, 0); receive(stop); late?.(); + await until(() => seen.some(m => m.type === 'receipt' && m.body.operation === stop.id)); + assert.equal(seen.find(m => m.body.operation === stop.id)?.body.result, 'accepted'); + } + if (action !== 'late') gone(fixture.pidFile); + const state = JSON.parse(readFileSync(join(directory, 'agents', agent, 'journal.json'), 'utf8')); + assert.equal(state.phase, 'stopped'); assert.equal(state.actual, null); + assert.notEqual(state.operations[start.id].reply.body.result, 'accepted'); + assert.equal(Object.keys(state.runs ?? {}).length, 0); + assert.ok(!seen.some(m => m.type === 'inventory' && m.body.phase === 'running')); + } finally { late?.(); await h.close(); rmSync(directory, { recursive: true, force: true }); } + } +}); diff --git a/beehive/test/credential-slots.test.ts b/beehive/test/credential-slots.test.ts index ff6ee85642..e05d72fc79 100644 --- a/beehive/test/credential-slots.test.ts +++ b/beehive/test/credential-slots.test.ts @@ -71,3 +71,31 @@ test('public owner validation rejects absent and conflicting identity', () => { assert.throws(() => setupOwner({ ownerPublic: 'bad' }), /Invalid/); assert.throws(() => setupOwner({ ownerPublic: publicKey(newKey()), ownerSecret: owner }), /Conflicting/); }); + +test('explicit exact first-provision recovery verifies orphan key; v3 additions preserve siblings and standby genesis', async () => { + const { reconcileCredentialProvision } = await import('../src/credential-slots.ts'); + const { addSlot } = await import('../src/slots.ts'); + for (const failure of ['before-store', 'after-store'] as const) { + const f = fixture(); + try { + assert.throws(() => provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, { + ...f.backend, create(ref, secret) { if (failure === 'after-store') f.backend.create(ref, secret); throw Error('injected store boundary'); }, + }), /injected/); + const path = join(f.directory, 'agents', f.agent, 'journal.json'), journal = readFileSync(path, 'utf8'); + assert.throws(() => reconcileCredentialProvision(f.directory, f.setup, newKey(), f.genesis, f.backend), /mismatch/); + reconcileCredentialProvision(f.directory, f.setup, f.secret, f.genesis, f.backend); + assert.equal(readFileSync(path, 'utf8'), journal); + const second = newKey(), key = publicKey(second), root = createGenesis(publicKey(f.owner), key, 'another-host'); + addSlot(f.directory, second, root, 'default', undefined, f.backend); + assert.equal(installationSlots(f.directory, f.backend).length, 2); + assert.equal((readPrivate(join(f.directory, 'agents', key, 'journal.json')) as any).assignment.assignedHost, 'another-host'); + assert.equal(readFileSync(path, 'utf8'), journal); + const manifest = readFileSync(join(f.directory, 'setup.json'), 'utf8'); + for (const secret of [f.secret, f.owner, second]) assert.ok(!manifest.includes(secret)); + removeSlotKey(f.directory, f.agent, f.backend); + assert.throws(() => reconcileCredentialProvision(f.directory, f.setup, f.secret, f.genesis, f.backend), /Active installation/); + assert.throws(() => addSlot(f.directory, f.secret, f.genesis, 'default', undefined, f.backend), /Slot exists/); + assert.equal(installationSlots(f.directory, f.backend).find(s => s.agent === f.agent)!.keyPresent, false); + } finally { rmSync(f.directory, { recursive: true, force: true }); } + } +});