mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
beehive: fence live credential reads with owned cancellation
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
71
beehive/CREDENTIAL_ASYNC_707E.md
Normal file
71
beehive/CREDENTIAL_ASYNC_707E.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
16
beehive/src/credential-helper-child.ts
Normal file
16
beehive/src/credential-helper-child.ts
Normal 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' })); }
|
||||
});
|
||||
55
beehive/src/credential-helper.ts
Normal file
55
beehive/src/credential-helper.ts
Normal 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; }
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
84
beehive/test/credential-helper.test.ts
Normal file
84
beehive/test/credential-helper.test.ts
Normal 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 }); }
|
||||
}
|
||||
});
|
||||
@@ -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 }); }
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user