beehive: fence live credential reads with owned cancellation

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 13:32:10 -04:00
parent aa0b3f26bd
commit 1bf0d11749
11 changed files with 385 additions and 23 deletions

View File

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

View File

@@ -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 <host-directory> Offline host pairing/owner approval/import
setup <host-directory> <identity-file> LEGACY loopback diagnostic setup (owner key copied)
provision-agent <host-directory> <binding-file> <genesis-file> Hidden matching agent import; OS credentials
reconcile-provision <host-directory> <binding-file> <genesis-file> Explicit exact first-provision recovery
catalog <new-file> <registration-files...> Retain verified public host registrations
presets Local process-free preset discovery/setup guidance
local-setup <host-directory> 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();

View File

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

View File

@@ -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<string | null> => {
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<string | null>((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; }
};
}

View File

@@ -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<SlotEntry[]> {
signal?.throwIfAborted();
const manifest = readManifest(directory);
const snapshot = JSON.stringify(manifest);
const secrets = new Map<string, string | null>();
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<T>(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);
});
}

View File

@@ -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<string | null>;
create(reference: CredentialReference, secret: string): void;
remove(reference: CredentialReference): void;
}

View File

@@ -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<string, Setup>, retiredBindings: Record<string, string> = {}) {
function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string, signal: AbortSignal) => Promise<Setup>, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record<string, Setup>, retiredBindings: Record<string, string> = {}) {
// 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');

View File

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

View File

@@ -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<SlotEntry[]> {
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);

View File

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

View File

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