diff --git a/beehive/CHECKPOINT.md b/beehive/CHECKPOINT.md index 123316f141..a260e008b1 100644 --- a/beehive/CHECKPOINT.md +++ b/beehive/CHECKPOINT.md @@ -5,6 +5,73 @@ Base `051c3a270be9c73da9ab06700bcab7d5552fceaa`; continuation starts at `023c9274767ef50fa0f5b37ef1883336f8be59fd`. Candidate is the commit containing this checkpoint (`git rev-parse HEAD`). No merge/release. +## Assignment foundation handoff — delegated fb23e116 + +Governing boundary: key possession is not assignment. Provisioning must create a +public pinned genesis (owner, agent, initial host, random enrollment ID) and an +explicit durable per-agent assignment before any host can Start. Host startup must +never recreate a missing authority journal. Existing journals require explicit +local stopped-state migration, not implicit re-enrollment. Import attaches to an +existing public genesis and cannot root authority at the importing installation. +Dedicated non-cloned/non-rollback installations and trusted one-time local enrollment +remain assumptions; shared-owner signatures are not physical-host attestation. + +Planned transaction successor (NOT implemented): reserve exact source revision and +immutable target inputs; relay target preflight; confirm all source owned groups +absent; atomically consume source authority plus exact destination grant/outbox; +accept once at destination before fresh launch. No timeout/receipt loss rolls back +a grant. Unreachable source blocks transfer even when last observed stopped. + +Implemented bootstrap fence in `src/assignment.ts` and `src/host.ts`: new local +provisioning pins explicit initial authority; startup cannot recreate missing +journals; explicit lock-protected stopped legacy migration preserves history. +CLI standby import binds the existing public root and identical locally provisioned +key; root export never exports keys. Host admission and early Stop cancellation +both check durable assignment. Source Stop/restart preserves assignment. Local key +absence blocks future Start without restoring hydrated keys; Stop stays available. + +TUI inventory now keys by host AND agent, groups identities with `agents`, and offers +numbered exact host/agent selection plus backward-compatible unique-host selection. +`assignment.test.ts` uses THREE distinct real TS host processes on one isolated WS +relay: source/standby share X; a third installation holds Y. Two identities run under +separate assignments and actual TUI subprocess selection shows both. Standby and +cross-agent Starts fail, source restart retains assignment, unreachable source cannot +authorize standby, deleted setup stays deleted, missing journals/migration fail closed. +This is not multiple slots on one host, nor an actual transfer. No duplicate daemon +directories are presented as slots. No Move protocol/interface stub is exposed. + +Validation: strict TypeScript passed. Full installed-enabled package suite repeated +on unchanged executable candidate: **21/21 passed, zero skips**, 16.72s. First full +run was 20/21: existing `reply-tool.test.ts:68` observed a descendant PID still present +after group Stop; isolated rerun passed and subsequent full run passed. No test was +weakened and no containment implementation changed. That intermittent observation +is unresolved, not claimed fixed; retain it for investigation before Move exit proof. +Both installed executable hashes freshly match retained pins. Evidence directory: +`WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/ASSIGNMENT_FB23_EVIDENCE/` contains `check.log`, +`full.log` (failure), `check-final.log`, and `full-repeat.log` (21/21). Existing installed +signed-conversation tests passed, but this new assignment test is lifecycle fixture +only; no destination conversation or live provider acceptance is claimed. + +Move and multi-agent slots on one installation remain gated. The current validator +intentionally requires assignedHost == genesis.initialHost; do not merely relax it +to simulate Move. Replace it with verified consumed-predecessor/grant-chain authority +only when source revocation, outbox and target acceptance are implemented together. +One dedicated owner scopes this development protocol; IDs route trusted installations, +not independent host attestation or production-community enrollment. No timestamp, +TTL, election, direct HTTP lifecycle route, remote secret or automatic takeover added. + +**ONE next executable action:** introduce per-agent slots under ONE installation lock +and ONE management connection, preserving this bootstrap fence and receipt/admission +state per slot; prove X and Y coexist before building the source-consumed Move +transaction specified above. Then implement preflight/exit/grant/accept/fresh launch +and failure/replay tests, including investigation of the intermittent descendant +observation. Do not treat this prerequisite candidate as completed two-host Move. +Remaining profiles/key CRUD/Restart, richer setup/provider/preset/mesh/compute parity, +real UI/live auth acceptance, retention and trusted-scope recovery remain in README. +No production services, owner stores, OAuth/provider access, Rust/native edits, +repo-wide/GitHub CI, PR, merge or release. Author/committer Logan Johnson + DCO +verified; no cryptographic signer configured. + ## Published continuation 4341 — U1 policy recovery UX source committed/pushed as `4fa4fd146961f91fdace04f3c6fb59e6300e2473`; diff --git a/beehive/README.md b/beehive/README.md index a30cb82af0..84dd36a21d 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -37,7 +37,7 @@ On Block-managed machines, the public npm registry is policy-blocked. Append to the install command to use the approved mirror. No credentials or persistent registry config are needed. The committed lockfile is registry-agnostic. -TUI: `operations`, `reconcile`, `retry `, `hosts`, `select `, `show`, `start`, `save`, `stop`, `quit`. +TUI: `agents`, `operations`, `reconcile`, `retry `, `hosts`, `select `, `show`, `start`, `save`, `stop`, `quit`. Save asks for advertised model, workspace and independent behavior profile. Only one fixture model and the `default` profile are currently supported; profile authoring/versioning and useful multi-setup selection remain to build. @@ -95,8 +95,8 @@ arbitrary Nostr kind is not an admission solution. One dedicated owner shares signing/decryption authority with fully trusted host installations. Host IDs route messages; they are NOT cryptographic host proof. -Agent identities are generated separately and only locally. Key import and -Move are disabled. Setup refuses an existing host directory; saved journal +Agent identities are generated separately and only locally. Standby key import +attaches to an existing public genesis; Move remains disabled. Setup refuses an existing host directory; saved journal bindings prevent owner/agent/host replacement from resetting admission. An owner can still bypass software by cloning/recreating installations: safety assumes non-cloned, non-rollback, exclusively supervised installations. @@ -132,7 +132,54 @@ assumes non-cloned, non-rollback, exclusively supervised installations. TUI pending intents/reconnect are implemented with a local encrypted journal. Lost connection means unknown, not stopped or success; exhausted recovery needs operator attention. - Inventory has freshness, not liveness proof. Host starts no action based on peer - agent presence. Only its locally bound agent is manageable. + agent presence. Only its locally bound agent is manageable; one slot per installation remains a + limitation, not multi-agent-per-host support. + +## Explicit initial assignment (Move prerequisite, not Move) + +New local setup writes both setup and authority journal before a host can launch. +The journal pins a public genesis (owner, agent, initial host, random enrollment +ID) and a separate assigned-host field. Start/Save/Stop are denied at a standby +host even with the identical local private key. Stop and service restart retain +assignment. Startup refuses a missing journal rather than recreating authority +from a cached key. Lifecycle checks current local key/setup availability before +Start and never writes keys back; Stop does not need provider auth or a key file. + +For a second, independently provisioned installation, export only the public root: + +```sh +node src/cli.ts assignment-export /absolute/source-host /new/public-genesis.json +node src/cli.ts setup /new/target-host /local/owner-identity.json /local/agent-key.json /local/public-genesis.json +``` + +The key file is an owner-only local JSON `{ "secret": "" }`, +provisioned by the local administrator, not sent over the management relay. The +public root file is also read/written owner-only by these commands. The key must +match that root; import cannot create a second initial-authority installation. +No credential, workspace, session or provider configuration is exported. This is +not existing unmanaged-agent enrollment, nsec parsing or a key-export feature. +New-key setup remains the ordinary guided command without those optional files. + +Existing pre-assignment **stopped journals** require explicit local migration: +`node src/cli.ts migrate-assignment /absolute/host-directory`. Verify there is +exactly one installation and no unmanaged execution of that identity, then confirm. +Migration preserves revision, receipts and configuration, uses the host exclusion +lock and refuses missing journals, unknown execution or an already pinned assignment. +It cannot recover journal loss, clone/rollback or retire an old installation. +A partial setup remains inert and requires local investigation, not automatic repair. + +`agents` groups observations by persistent public key; `hosts` numbers host/agent +rows, shows reported assignment, and `select` routes to the exact row. A unique host +name remains a backward-compatible shortcut. Observations are not consensus; an +unreachable source never makes its standby eligible. This candidate still has one +agent slot per installation. Tests use three distinct installations for two agent +identities; they do **not** disguise cloned daemons as multi-agent slots on one host. + +**Not yet implemented:** destination preflight, source reservation/whole-tree Stop +as a transfer, irreversible grant/outbox lineage, target acceptance/fresh launch, +and several independently managed agents in one host process. No `move` command +is exposed until these boundaries are implemented and tested together. The root +ID is an identity binding, not a lock service or partition/rollback solution. ## Real target: Buzz Agent + Databricks v2 diff --git a/beehive/TESTING.md b/beehive/TESTING.md index 6b3acbae0a..4996cf5a54 100644 --- a/beehive/TESTING.md +++ b/beehive/TESTING.md @@ -150,3 +150,17 @@ Both are read-only `/Applications/Buzz.app/Contents/MacOS/` binaries. Their dige are not a claim that they were built from the source base. The MCP adapter supports newline stdio and native CLI argv/stdin, with host-owned executable/identity/relay. Optional local attestation is forwarded using the normal CLI environment; no live admission/delegation policy proof is claimed from the fixture relay. + +## Assignment bootstrap continuation + +`assignment.test.ts` starts three actual TS host subprocesses over one isolated +management relay: the same fresh agent key on source/standby plus a second identity +on a third distinct installation. Destination Start and cross-agent routing fail; +source Start/Stop/service restart retains assignment; absent source cannot authorize +standby. Actual TUI selects and shows both identities with exact host routing. +Deleting a running host's local setup does not block Stop, but subsequent Start +fails without reconstructing the key. Missing journals fail closed; explicit legacy +migration preserves revision/history and refuses replacement. This is assignment +bootstrap evidence, NOT Move or multiple agent slots on one host evidence. +All existing fixture setups now explicitly provision initial authority; none relies +on host startup self-assigning from key presence. diff --git a/beehive/src/assignment.ts b/beehive/src/assignment.ts new file mode 100644 index 0000000000..3a957c23f0 --- /dev/null +++ b/beehive/src/assignment.ts @@ -0,0 +1,18 @@ +import { randomUUID } from 'node:crypto'; +import { fields, object, text } from './protocol.ts'; + +/** Public, locally pinned root. It conveys no secret and is not a bearer grant. */ +export type Genesis = { v: 1; id: string; owner: string; agent: string; initialHost: string }; +/** Only new-key creation or verified one-time legacy enrollment may mint a root. */ +export function createGenesis(owner: string, agent: string, initialHost: string): Genesis { + return validateGenesis({ v: 1, id: randomUUID(), owner, agent, initialHost }); +} +/** Validate exact public root fields before binding any local execution authority. */ +export function validateGenesis(value: unknown): Genesis { + const g = object(value); + fields(g, ['v', 'id', 'owner', 'agent', 'initialHost']); + if (g.v !== 1 || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(text(g.id))) throw Error('Invalid assignment genesis'); + for (const key of ['owner', 'agent']) if (!/^[0-9a-f]{64}$/.test(text(g[key]))) throw Error('Invalid genesis identity'); + text(g.initialHost); + return g as Genesis; +} diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index d046f65cbc..7362fcdf79 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -3,10 +3,11 @@ import { stdin, stdout } from 'node:process'; import { existsSync, mkdirSync, realpathSync, rmdirSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { relay } from './relay.ts'; -import { host, validateSetup } from './host.ts'; +import { host, validateSetup, provision, migrateAssignment } from './host.ts'; import { managementClient } from './intents.ts'; import { message, newKey, publicKey, object, text, type Message } from './protocol.ts'; import { readPrivate, writePrivate } from './storage.ts'; +import { createGenesis, validateGenesis } from './assignment.ts'; import { prepareConversation } from './conversation.ts'; const shellQuote = (s: string) => `'${s.replaceAll("'", "'\"'\"'")}'`; @@ -14,12 +15,15 @@ const [command, ...args] = process.argv.slice(2); const help = `Beehive — isolated development preview (loopback relay only) identity Create a NEW local owner identity setup Guided local harness + key setup + assignment-export Export public pinned genesis locally + migrate-assignment Explicit stopped legacy enrollment auth-info Print exact local sign-in context (no login) conversation-setup Attach external buzz-acp to EXISTING identity (Start gated) relay Dedicated ciphertext relay host Persistent foreground host tui Relay-connected terminal UI -No import, Move, provider login RPC or production relay support yet.`; +setup accepts optional for standby import. +No Move, provider login RPC or production relay support yet.`; async function main() { if (command === 'identity') { const dir = resolve(text(args[0])); @@ -41,13 +45,31 @@ async function main() { const extra = mode === '1' ? text(await ui.question('Absolute fixture TypeScript script: ')) : ''; const databricksHost = mode === '2' ? text(await ui.question('Databricks workspace HTTPS URL: ')) : undefined; if (databricksHost && new URL(databricksHost).protocol !== 'https:') throw Error('HTTPS workspace required'); - if ((await ui.question('Create a NEW agent identity assigned exclusively to this host? [yes/no]: ')) !== 'yes') return; + const importing = args.length > 2; + const agentSecret = importing ? text(object(readPrivate(resolve(text(args[2])))).secret) : newKey(); + const genesis = importing ? validateGenesis(readPrivate(resolve(text(args[3])))) : createGenesis(publicKey(secret), publicKey(agentSecret), name); + if (importing && genesis.initialHost === name) throw Error('Import cannot create a second initial authority installation'); + if ((await ui.question(importing ? `Provision matching key as standby only; assignment remains ${genesis.initialHost}? [yes/no]: ` : 'Create a NEW agent identity assigned exclusively to this host? [yes/no]: ')) !== 'yes') return; mkdirSync(dir,{ mode: 0o700 }); if (mode === '2') { mkdirSync(join(dir,'service-home'),{ mode: 0o700 }); mkdirSync(join(dir,'agent-config'),{ mode: 0o700 }); } - writePrivate(join(dir,'setup.json'),{ host: name, ownerSecret: secret, agentSecret: newKey(), runner, args: mode === '1' ? [resolve(extra)] : [], workspace, mode: mode === '1' ? 'fixture' : 'buzz-agent-databricks-v2', ...(databricksHost ? { databricksHost, serviceHome: join(dir,'service-home'), configDirectory: join(dir,'agent-config') } : {}) }); + provision(dir,{ host: name, ownerSecret: secret, agentSecret, runner, args: mode === '1' ? [resolve(extra)] : [], workspace, mode: mode === '1' ? 'fixture' : 'buzz-agent-databricks-v2', ...(databricksHost ? { databricksHost, serviceHome: join(dir,'service-home'), configDirectory: join(dir,'agent-config') } : {}) }, genesis); console.log('Local setup saved. Start the host separately; the TUI never owns its lifetime.'); if (mode === '2') console.log(`Not authenticated. Run auth-info ${shellQuote(dir)} for the exact local host/service-user login command. Start uses an ACP greeting probe, not yet a Buzz relay conversation agent.`); } finally { ui.close(); } + } else if (command === 'assignment-export') { + const state = object(readPrivate(join(resolve(text(args[0])), 'journal.json'))); + const genesis = validateGenesis(object(state.assignment).genesis); + writePrivate(resolve(text(args[1])), genesis, true); + console.log('Public genesis exported; no keys or execution grant. Provision the matching key separately on the destination.'); + } else if (command === 'migrate-assignment') { + const dir = resolve(text(args[0])); + const setup = validateSetup(readPrivate(join(dir, 'setup.json'))); + const ui = createInterface({ input: stdin, output: stdout }); + try { + if ((await ui.question('Enroll this existing stopped journal as the UNIQUE authority? Verify no clones or unmanaged execution of this identity exist. Missing journals cannot be repaired here. [yes/no]: ')) !== 'yes') return; + migrateAssignment(dir, createGenesis(publicKey(setup.ownerSecret), publicKey(setup.agentSecret), setup.host)); + console.log('Legacy journal pinned; lifecycle history retained. Do not clone or roll back this installation.'); + } finally { ui.close(); } } else if (command === 'conversation-setup') { const dir = resolve(text(args[0])); const lock = join(dir, 'host.lock'); @@ -89,8 +111,9 @@ async function main() { const inventory = new Map(); const client = managementClient(join(dirname(resolve(text(args[0]))), 'management-intents'),text(args[1]),secret,m => { if (m.type === 'inventory') { - const prior = inventory.get(m.host); - if (!prior || Number(m.body.observedAt) > Number(prior.body.observedAt)) inventory.set(m.host,m); + const key = JSON.stringify([m.host, m.agent]); + const prior = inventory.get(key); + if (!prior || Number(m.body.observedAt) > Number(prior.body.observedAt)) inventory.set(key,m); } }, () => { console.log(client.connected ? '\nManagement relay connected.' : '\nRelay disconnected: pending results UNKNOWN; automatic reconnect is bounded (disabled on policy refusal). Use reconcile after checking relay policy.'); @@ -98,7 +121,7 @@ async function main() { }); try { await client.ready; } catch (error) { client.close(); throw error; } const ui = createInterface({ input: stdin, output: stdout }); - console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: operations, reconcile, retry , hosts, select , show, save, start, stop, quit. Closing this UI does not stop hosts.'); + console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: operations, reconcile, retry , hosts, agents, select , show, save, start, stop, quit. Closing this UI does not stop hosts.'); let selected = ''; try { for (;;) { @@ -120,8 +143,19 @@ async function main() { } continue; } - if (line === 'hosts') { for (const [name,m] of inventory) console.log(`${name}: ${m.body.phase} | ${m.body.readiness} | ${Date.now()-Number(m.body.observedAt) > 6000 ? 'STALE/UNKNOWN' : 'recent host report'}`); continue; } - if (line.startsWith('select ')) { selected = line.slice(7); continue; } + if (line === 'hosts') { [...inventory.values()].forEach((m, index) => console.log(`${index + 1}. ${m.host} | agent ${m.agent} | assigned ${m.body.assignedHost} | ${m.body.phase} | ${m.body.readiness} | ${Date.now()-Number(m.body.observedAt) > 6000 ? 'STALE/UNKNOWN' : 'recent host report'}`)); continue; } + if (line === 'agents') { + const agents = new Set([...inventory.values()].map(m => m.agent)); + for (const agent of agents) console.log(`Agent ${agent}: ${[...inventory.values()].filter(m => m.agent === agent).map(m => `${m.host} (reports assigned ${m.body.assignedHost}, ${m.body.phase})`).join('; ')}`); + console.log('Per-host reports are observations, not global liveness or consensus. Use hosts then select a numbered host/agent row.'); continue; + } + if (line.startsWith('select ')) { + const choice = line.slice(7); + const rows = [...inventory.entries()]; + const matches = /^[1-9][0-9]*$/.test(choice) ? rows.slice(Number(choice)-1, Number(choice)) : rows.filter(([,m]) => m.host === choice); + if (matches.length !== 1) { console.log('Unknown or ambiguous host: use hosts and select its numbered host/agent row.'); continue; } + selected = matches[0]![0]; console.log(`Selected ${matches[0]![1].host} agent ${matches[0]![1].agent}`); continue; + } const current = inventory.get(selected); if (!current) { console.log('Select an advertised host first.'); continue; } if (line === 'show') { console.log(JSON.stringify(current,null,2)); continue; } @@ -132,7 +166,7 @@ async function main() { console.log(`Allowed models: ${JSON.stringify(current.body.models)}; workspaces: ${JSON.stringify(current.body.workspaces)}; behavior profiles: ${JSON.stringify(current.body.profiles)}`); body = { model: await ui.question('Model: '), workspace: await ui.question('Workspace: '), profile: await ui.question('Behavior profile: ') }; } - const request = message(line as 'save' | 'start' | 'stop',selected,current.agent,current.revision,body); + const request = message(line as 'save' | 'start' | 'stop',current.host,current.agent,current.revision,body); try { client.submit(request); } catch (error) { console.log(error instanceof Error ? error.message : 'Operation not submitted'); continue; } console.log(`Durably pending ${line} (${request.id}); publication is NOT host acceptance.`); } diff --git a/beehive/src/host.ts b/beehive/src/host.ts index cc1474ee3b..f4a70b2fc8 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -1,6 +1,7 @@ import { accessSync, constants, existsSync, mkdirSync, rmdirSync, realpathSync, statSync, readFileSync } from 'node:fs'; import { join, isAbsolute } from 'node:path'; import { createHash } from 'node:crypto'; +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'; import { readPrivate, writePrivate } from './storage.ts'; @@ -11,7 +12,7 @@ import { prepareConversation, conversationSummary, type ConversationSetup } from export type Setup = { host: string; ownerSecret: string; agentSecret: string; runner: string; args: string[]; workspace: string; mode: 'fixture' | 'buzz-agent-databricks-v2'; databricksHost?: string; serviceHome?: string; configDirectory?: string; conversation?: ConversationSetup }; type Selection = { model: string; workspace: string; profile: string }; -type State = { binding: { host: string; owner: string; agent: string }; revision: number; phase: 'stopped' | 'transitioning' | 'running' | 'quarantined'; selected: Selection; actual: null | { selection: Selection; executableHash: string; run: string; evidence?: Evidence }; operations: Record; outbox: Message[] }; +type State = { assignment: { genesis: Genesis; assignedHost: string }; binding: { host: string; owner: string; agent: string }; revision: number; phase: 'stopped' | 'transitioning' | 'running' | 'quarantined'; selected: Selection; actual: null | { selection: Selection; executableHash: string; run: string; evidence?: Evidence }; operations: Record; outbox: Message[] }; export function validateSetup(value: unknown): Setup { const s = object(value); for (const key of ['host','ownerSecret','agentSecret','runner','workspace']) text(s[key]); @@ -19,17 +20,46 @@ export function validateSetup(value: unknown): Setup { if (!isAbsolute(String(s.runner)) || !isAbsolute(String(s.workspace)) || !Array.isArray(s.args) || s.args.some(a => typeof a !== 'string') || !['fixture','buzz-agent-databricks-v2'].includes(String(s.mode))) throw Error('Invalid local setup'); return s as Setup; } -/** Fixed-authority first slice. Keys never constitute a remote assignment grant. */ +/** Provision once locally. A partial setup is inert; host startup never invents authority. */ +export function provision(directory: string, value: Setup, root: Genesis): void { + const setup = validateSetup(value); const genesis = validateGenesis(root); + if (genesis.owner !== publicKey(setup.ownerSecret) || genesis.agent !== publicKey(setup.agentSecret)) throw Error('Genesis ownership mismatch'); + if (existsSync(join(directory, 'setup.json')) || existsSync(join(directory, 'journal.json'))) throw Error('Existing installation; cannot reset authority'); + writePrivate(join(directory, 'setup.json'), setup, true); + writePrivate(join(directory, 'journal.json'), initialState(setup, genesis), true); +} +function initialState(setup: Setup, genesis: Genesis): State { + return { + assignment: { genesis, assignedHost: genesis.initialHost }, + binding: { host: setup.host, owner: publicKey(setup.ownerSecret), agent: publicKey(setup.agentSecret) }, revision: 0, phase: 'stopped', selected: { model: setup.mode === 'fixture' ? 'fixture-model' : 'databricks-claude-haiku-4-5', workspace: setup.workspace, profile: 'default' }, actual: null, operations: {}, outbox: [], + }; +} +/** Explicit offline legacy enrollment; never repairs missing journals or lost keys. */ +export function migrateAssignment(directory: string, genesis: Genesis): void { + const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 }); + try { + const setup = validateSetup(readPrivate(join(directory, 'setup.json'))); + const state = readPrivate(join(directory, 'journal.json')) as State; + validateGenesis(genesis); + if (state.assignment) throw Error('Assignment already pinned; cannot replace'); + if (state.phase !== 'stopped' || state.actual !== null || state.binding.host !== setup.host || state.binding.agent !== publicKey(setup.agentSecret) || state.binding.owner !== publicKey(setup.ownerSecret) || genesis.agent !== state.binding.agent || genesis.owner !== state.binding.owner || genesis.initialHost !== setup.host) throw Error('Legacy migration requires matching stopped ownership'); + state.assignment = { genesis, assignedHost: genesis.initialHost }; + writePrivate(join(directory, 'journal.json'), state); + } finally { rmdirSync(lock); } +} +/** Hosts consult durable assignment, never key presence or relay inventory, for authority. */ export async function host(directory: string, url: string) { validateRelayURL(url); const setup = validateSetup(readPrivate(join(directory,'setup.json'))); const agent = publicKey(setup.agentSecret); const lock = join(directory,'host.lock'); const path = join(directory,'journal.json'); - let state: State = existsSync(path) ? readPrivate(path) as State : { - binding: { host: setup.host, owner: publicKey(setup.ownerSecret), agent }, revision: 0, phase: 'stopped', selected: { model: setup.mode === 'fixture' ? 'fixture-model' : 'databricks-claude-haiku-4-5', workspace: setup.workspace, profile: 'default' }, actual: null, operations: {}, outbox: [], - }; + if (!existsSync(path)) throw Error('Authority journal missing; restore/reconcile locally, never re-enroll from key possession'); + let state = readPrivate(path) as State; if (state.binding.host !== setup.host || state.binding.owner !== publicKey(setup.ownerSecret) || state.binding.agent !== agent) throw Error('Saved ownership/assignment mismatch'); + if (!state.assignment) throw Error('Legacy assignment requires explicit local migrate-assignment while stopped'); + const genesis = validateGenesis(state.assignment.genesis); + if (genesis.owner !== state.binding.owner || genesis.agent !== agent || state.assignment.assignedHost !== genesis.initialHost) throw Error('Saved assignment mismatch'); mkdirSync(lock, { mode: 0o700 }); // No PID-only stale-lock recovery. if (state.phase !== 'stopped') state.phase = 'quarantined'; const save = () => writePrivate(path,state); @@ -39,7 +69,7 @@ export async function host(directory: string, url: string) { let catalog: Catalog | { state: 'not-probed' | 'failed'; authentication: 'unverified' } = { state: 'not-probed', authentication: 'unverified' }; function inventory() { return message('inventory',setup.host,agent,state.revision, { - assignedHost: setup.host, phase: state.phase, selectedNext: state.selected, actualRun: state.actual, + assignedHost: state.assignment.assignedHost, genesis: state.assignment.genesis, executionAuthority: state.assignment.assignedHost === setup.host, phase: state.phase, selectedNext: state.selected, actualRun: state.actual, ...(setup.conversation ? { conversation: conversationSummary(setup.conversation) } : {}), catalog, setup: setup.mode, models: [setup.mode === 'fixture' ? 'fixture-model' : 'databricks-claude-haiku-4-5'], workspaces: [setup.workspace], profiles: ['default'], readiness: setup.conversation ? (state.actual?.evidence ? 'Conversation session model acknowledged and response completed; relay delivery unverified' : 'Waiting for an admitted conversation and local provider sign-in; no synthetic prompt') : setup.mode === 'fixture' ? 'fixture-only' : (state.phase === 'running' && state.actual?.evidence ? 'ACP model acknowledged + same-session response (not provider attestation or Buzz relay agent)' : 'unverified: Start runs an ACP greeting probe; sign in locally as host service user if required'), observedAt: Date.now(), @@ -68,7 +98,7 @@ export async function host(directory: string, url: string) { const first = operation && !queuedOperations.has(m.id); if (operation) queuedOperations.add(m.id); // 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 && m.revision === state.revision && !Object.hasOwn(state.operations, m.id) && Object.keys(m.body).length === 0) { + 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); // Only an executing admission has a session to interrupt; queued admissions // and the pre-commit recheck consume the retraction inside handle(). @@ -100,7 +130,7 @@ export async function host(directory: string, url: string) { publish(previous.fingerprint === fingerprint ? previous.reply : message('receipt',setup.host,m.agent,state.revision,{ operation: m.id, fingerprint, result: 'operation-id-conflict' })); return; } let result = 'accepted'; - if (m.agent !== agent) result = 'not-authority'; + if (m.agent !== agent || state.assignment.assignedHost !== setup.host) result = 'not-authority'; else if (m.revision !== state.revision) result = 'revision-conflict'; else if ((state.phase === 'quarantined' || state.phase === 'transitioning') && !(m.type === 'stop' && (owned instanceof ConversationSession ? owned.connected : owned?.child.connected))) result = 'quarantined'; else { @@ -118,6 +148,11 @@ export async function host(directory: string, url: string) { if (state.phase !== 'stopped') throw Error('Already running'); // A retracted admission never begins effects, including any spawn. if (retracting(m.revision)) throw Error('Start cancelled by concurrent Stop'); + // Lifecycle never restores deleted keys from the in-memory setup snapshot. + try { + const current = validateSetup(readPrivate(join(directory, 'setup.json'))); + if (current.agentSecret !== setup.agentSecret || current.ownerSecret !== setup.ownerSecret || current.host !== setup.host) throw Error('changed'); + } catch { throw Error('Local agent key/setup missing or changed; repair locally without resetting assignment'); } accessSync(setup.runner,constants.X_OK); if (realpathSync(setup.workspace) !== setup.workspace || !statSync(setup.workspace).isDirectory()) throw Error('Workspace changed'); const launch: AgentLaunch | undefined = setup.mode === 'fixture' ? undefined : { executable: setup.runner, args: setup.args, workspace: state.selected.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), databricksHost: text(setup.databricksHost), model: state.selected.model }; diff --git a/beehive/test/acp.test.ts b/beehive/test/acp.test.ts index 1359d537c9..c15a737a20 100644 --- a/beehive/test/acp.test.ts +++ b/beehive/test/acp.test.ts @@ -1,3 +1,4 @@ +import { provisionSetup } from './provision.ts'; import { test } from 'node:test'; import { spawnSync } from 'node:child_process'; import assert from 'node:assert/strict'; @@ -54,7 +55,7 @@ test('production host Start uses external ACP evidence; Save and Stop do not nee const ownerSecret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); const p = plan(dir); const authDir = join(dir, 'isolated-auth'); mkdirSync(authDir); - writePrivate(join(hostDir, 'setup.json'), { host: 'acp-host', ownerSecret, agentSecret, runner: p.executable, args: p.args, workspace: dir, mode: 'buzz-agent-databricks-v2', serviceHome: dir, configDirectory: authDir, databricksHost: p.databricksHost }); + provisionSetup(join(hostDir, 'setup.json'), { host: 'acp-host', ownerSecret, agentSecret, runner: p.executable, args: p.args, workspace: dir, mode: 'buzz-agent-databricks-v2', serviceHome: dir, configDirectory: authDir, databricksHost: p.databricksHost }); const authInfo = spawnSync(process.execPath, ['src/cli.ts', 'auth-info', hostDir], { encoding: 'utf8', timeout: 3000 }); assert.equal(authInfo.status, 0); assert.match(authInfo.stdout, /acp-host/); assert.match(authInfo.stdout, /auth databricks/); assert.ok(!authInfo.stdout.includes(ownerSecret) && !authInfo.stdout.includes(agentSecret)); diff --git a/beehive/test/admission-cancel.test.ts b/beehive/test/admission-cancel.test.ts index 192e332301..274a98188f 100644 --- a/beehive/test/admission-cancel.test.ts +++ b/beehive/test/admission-cancel.test.ts @@ -1,3 +1,4 @@ +import { provisionSetup } from './provision.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, realpathSync, rmSync, readFileSync, existsSync } from 'node:fs'; @@ -23,7 +24,7 @@ test('a Stop received beside a pending Start cancels it before running, live and const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-admission-'))); const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); const acp = scenario !== 'fixture-live'; - writePrivate(join(dir, 'setup.json'), { host: 'admission-host', ownerSecret: secret, agentSecret, + provisionSetup(join(dir, 'setup.json'), { host: 'admission-host', ownerSecret: secret, agentSecret, runner: process.execPath, args: acp ? [resolve('test/acp-fixture.ts'), 'delayed'] : [resolve('test/runner.ts')], workspace: dir, mode: acp ? 'buzz-agent-databricks-v2' : 'fixture', ...(acp ? { serviceHome: dir, configDirectory: dir, databricksHost: 'https://fixture.invalid' } : {}) }); @@ -65,7 +66,7 @@ test('same-batch Stop cancellation keeps authority, ordering, duplicate and revi for (const control of ['wrong-authority', 'conflicting-id', 'invalid-body', 'stop-first', 'duplicate-replay'] as const) { const dir = realpathSync(mkdtempSync(join(tmpdir(), `beehive-admission-${control}-`))); const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); - writePrivate(join(dir, 'setup.json'), { host: 'admission-host', ownerSecret: secret, agentSecret, + provisionSetup(join(dir, 'setup.json'), { host: 'admission-host', ownerSecret: secret, agentSecret, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' }); const server = await relay(0, publicKey(secret), join(dir, 'relay.json')); const address = server.address(); assert.ok(address && typeof address !== 'string'); diff --git a/beehive/test/assignment.test.ts b/beehive/test/assignment.test.ts new file mode 100644 index 0000000000..76de6fc9e2 --- /dev/null +++ b/beehive/test/assignment.test.ts @@ -0,0 +1,109 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtempSync, realpathSync, rmSync, readFileSync, unlinkSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { setTimeout as delay } from 'node:timers/promises'; +import { createGenesis } from '../src/assignment.ts'; +import { provision, host, migrateAssignment, type Setup } from '../src/host.ts'; +import { newKey, publicKey, message, type Message } from '../src/protocol.ts'; +import { writePrivate } from '../src/storage.ts'; +import { relay } from '../src/relay.ts'; +import { connect } from '../src/client.ts'; + +test('real host processes: same key on standby never grants Start; second identity and TUI routing', { timeout: 25000 }, async () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-assignment-'))); + const ownerSecret = newKey(), x = newKey(), y = newKey(); + const gx = createGenesis(publicKey(ownerSecret), publicKey(x), 'source'); + const gy = createGenesis(publicKey(ownerSecret), publicKey(y), 'other'); + const setup = (name: string, agentSecret: string): Setup => ({ host: name, ownerSecret, agentSecret, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' }); + provision(join(dir, 'source'), setup('source', x), gx); + provision(join(dir, 'target'), setup('target', x), gx); + provision(join(dir, 'other'), setup('other', y), gy); + writePrivate(join(dir, 'identity.json'), { secret: ownerSecret }); + const server = await relay(0, publicKey(ownerSecret), join(dir, 'relay.json')); + const address = server.address(); assert.ok(address && typeof address !== 'string'); + const url = `ws://127.0.0.1:${address.port}`; + const children: ChildProcess[] = []; const seen: Message[] = []; + const ui = connect(url, ownerSecret, m => seen.push(m)); await ui.ready; + async function wait(predicate: (m: Message) => boolean) { + for (let i = 0; i < 240; i++) { const m = seen.find(predicate); if (m) return m; await delay(20); } + throw Error('Missing assignment boundary evidence'); + } + async function startHost(name: string) { + const child = spawn(process.execPath, ['src/cli.ts', 'host', join(dir, name), url], { stdio: ['ignore', 'pipe', 'pipe'] }); + children.push(child); child.stdout?.resume(); child.stderr?.resume(); + await wait(m => m.type === 'inventory' && m.host === name); return child; + } + async function closeHost(child: ChildProcess) { + if (child.exitCode !== null) return; + const done = once(child, 'exit'); child.kill('SIGTERM'); await done; + } + async function request(type: 'start' | 'stop' | 'save', name: string, agent: string, revision: number, body = {}) { + const m = message(type, name, agent, revision, body); ui.send(m); + return wait(r => r.type === 'receipt' && r.body.operation === m.id); + } + try { + let source = await startHost('source'); await startHost('target'); await startHost('other'); + assert.equal((await request('start', 'target', publicKey(x), 0)).body.result, 'not-authority'); + assert.equal((await request('start', 'source', publicKey(y), 0)).body.result, 'not-authority'); + assert.equal((await request('start', 'source', publicKey(x), 0)).body.result, 'accepted'); + assert.equal((await request('start', 'other', publicKey(y), 0)).body.result, 'accepted'); + assert.equal((await request('stop', 'source', publicKey(x), 1)).body.result, 'accepted'); + await closeHost(source); seen.length = 0; source = await startHost('source'); + const stopped = await wait(m => m.type === 'inventory' && m.host === 'source' && m.revision === 2); + assert.equal(stopped.body.assignedHost, 'source'); assert.equal(stopped.body.phase, 'stopped'); + assert.equal((await request('start', 'target', publicKey(x), 0)).body.result, 'not-authority'); + assert.equal((await request('start', 'source', publicKey(x), 2)).body.result, 'accepted'); + // Actual TUI subprocess selects distinct identities, without a singleton key cache. + const terminal = spawn(process.execPath, ['src/cli.ts', 'tui', join(dir, 'identity.json'), url], { stdio: ['pipe', 'pipe', 'pipe'] }); + children.push(terminal); let output = ''; let index = 0; + const commands = ['agents', 'hosts', 'select target', 'show', 'select other', 'show', 'quit']; + terminal.stdout.on('data', chunk => { + output += chunk.toString(); + if (output.endsWith('beehive> ') && index < commands.length) setTimeout(() => terminal.stdin.write(`${commands[index++]}\n`), 100); + }); + terminal.stderr.resume(); const [code] = await once(terminal, 'exit'); assert.equal(code, 0); + assert.match(output, new RegExp(`Selected target agent ${publicKey(x)}`)); + assert.match(output, new RegExp(`Selected other agent ${publicKey(y)}`)); + assert.match(output, /"executionAuthority": false/); assert.match(output, /"executionAuthority": true/); + // No reachable source is not an excuse to admit the provisioned standby. + await closeHost(source); + assert.equal((await request('start', 'target', publicKey(x), 0)).body.result, 'not-authority'); + assert.equal((await request('stop', 'other', publicKey(y), 1)).body.result, 'accepted'); + // Missing local key/setup is not reconstructed from hydrated memory. + assert.equal((await request('start', 'other', publicKey(y), 2)).body.result, 'accepted'); + unlinkSync(join(dir, 'other', 'setup.json')); + assert.equal((await request('stop', 'other', publicKey(y), 3)).body.result, 'accepted'); + assert.match(String((await request('start', 'other', publicKey(y), 4)).body.result), /Local agent key\/setup missing/); + assert.throws(() => readFileSync(join(dir, 'other', 'setup.json'))); + const targetState = JSON.parse(readFileSync(join(dir, 'target', 'journal.json'), 'utf8')); + assert.equal(targetState.assignment.assignedHost, 'source'); assert.equal(targetState.actual, null); assert.equal(targetState.revision, 0); + } finally { + for (const child of children) await closeHost(child); + ui.close(); for (const c of server.clients) c.terminate(); + await new Promise(resolve => server.close(() => resolve())); rmSync(dir, { recursive: true, force: true }); + } +}); + +test('missing journals fail closed; explicit stopped legacy migration preserves history and rejects replacement', async () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-migration-'))); + const ownerSecret = newKey(), agentSecret = newKey(); + const setup: Setup = { host: 'legacy', ownerSecret, agentSecret, runner: process.execPath, args: [], workspace: dir, mode: 'fixture' }; + const genesis = createGenesis(publicKey(ownerSecret), publicKey(agentSecret), setup.host); + try { + provision(dir, setup, genesis); + const path = join(dir, 'journal.json'); const state = JSON.parse(readFileSync(path, 'utf8')); + delete state.assignment; state.revision = 17; writePrivate(path, state); + await assert.rejects(host(dir, 'ws://127.0.0.1:1'), /explicit local migrate-assignment/); + migrateAssignment(dir, genesis); + assert.equal(JSON.parse(readFileSync(path, 'utf8')).revision, 17); + assert.throws(() => migrateAssignment(dir, genesis), /already pinned/); + unlinkSync(path); + await assert.rejects(host(dir, 'ws://127.0.0.1:1'), /Authority journal missing/); + assert.throws(() => migrateAssignment(dir, genesis), /ENOENT/); + assert.throws(() => provision(dir, setup, genesis), /cannot reset authority/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/beehive/test/cancellation.test.ts b/beehive/test/cancellation.test.ts index b5f15cbb59..f6428f3b68 100644 --- a/beehive/test/cancellation.test.ts +++ b/beehive/test/cancellation.test.ts @@ -1,3 +1,4 @@ +import { provisionSetup } from './provision.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, realpathSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs'; @@ -17,7 +18,7 @@ test('in-flight ACP Start is cancellable by authorized Stop and host close; malf const runtime = join(dir, 'runtime'); writeFileSync(runtime, `#!/bin/sh\nexec '${realpathSync(process.execPath)}' '${resolve('test/conversation-runtime-fixture.ts')}'\n`, { mode: 0o700 }); writeFileSync(join(dir, 'mode'), action === 'bad-tail' ? 'bad-tail' : 'delayed'); - writePrivate(join(dir, 'setup.json'), { host: 'cancel-host', ownerSecret: secret, agentSecret, runner: realpathSync(process.execPath), args: target === 'probe' ? [resolve('test/acp-fixture.ts'), action === 'bad-tail' ? 'bad-tail' : 'delayed'] : [resolve('test/conversation-harness-fixture.ts')], ...(target === 'conversation' ? { conversation: { executable: runtime, relay: 'ws://127.0.0.1:1' } } : {}), workspace: dir, mode: 'buzz-agent-databricks-v2', serviceHome: dir, configDirectory: dir, databricksHost: 'https://fixture.invalid' }); + provisionSetup(join(dir, 'setup.json'), { host: 'cancel-host', ownerSecret: secret, agentSecret, runner: realpathSync(process.execPath), args: target === 'probe' ? [resolve('test/acp-fixture.ts'), action === 'bad-tail' ? 'bad-tail' : 'delayed'] : [resolve('test/conversation-harness-fixture.ts')], ...(target === 'conversation' ? { conversation: { executable: runtime, relay: 'ws://127.0.0.1:1' } } : {}), workspace: dir, mode: 'buzz-agent-databricks-v2', serviceHome: dir, configDirectory: dir, databricksHost: 'https://fixture.invalid' }); const server = await relay(0, publicKey(secret), join(dir, 'relay.json')); const address = server.address(); assert.ok(address && typeof address !== 'string'); const url = `ws://127.0.0.1:${address.port}`; diff --git a/beehive/test/conversation.test.ts b/beehive/test/conversation.test.ts index 1e34bb21f2..5010029779 100644 --- a/beehive/test/conversation.test.ts +++ b/beehive/test/conversation.test.ts @@ -1,3 +1,4 @@ +import { provisionSetup } from './provision.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, realpathSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs'; @@ -54,7 +55,7 @@ test('real management WS runs identity-bearing external conversation through esc writeFileSync(runtime, `#!/bin/sh\nexec '${executable}' '${resolve('test/conversation-runtime-fixture.ts')}'\n`, { mode: 0o700 }); writeFileSync(join(dir, 'mode'), 'ok'); const setup = { host: 'conversation-host', ownerSecret, agentSecret, runner: executable, args: [resolve('test/conversation-harness-fixture.ts')], workspace: dir, mode: 'buzz-agent-databricks-v2', serviceHome: dir, configDirectory: dir, databricksHost: 'https://fixture.invalid', conversation: { executable: runtime, relay: 'wss://conversation.invalid', authTag: 'fixture-attestation' } }; - writePrivate(join(dir, 'setup.json'), setup); + provisionSetup(join(dir, 'setup.json'), setup); const server = await relay(0, publicKey(ownerSecret), join(dir, 'relay.json')); const address = server.address(); assert.ok(address && typeof address !== 'string'); const url = `ws://127.0.0.1:${address.port}`; diff --git a/beehive/test/intents.test.ts b/beehive/test/intents.test.ts index 1c531ebf39..944feaa435 100644 --- a/beehive/test/intents.test.ts +++ b/beehive/test/intents.test.ts @@ -1,3 +1,4 @@ +import { provisionSetup } from './provision.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, realpathSync, readFileSync, rmSync } from 'node:fs'; @@ -19,7 +20,7 @@ async function until(predicate: () => boolean) { test('durable client reopens exact unpublished intent, recovers lost post-commit receipt, and fences unrelated results', async () => { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-intents-'))); const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); - writePrivate(join(dir, 'setup.json'), { host: 'reconnect-host', ownerSecret: secret, agentSecret, + provisionSetup(join(dir, 'setup.json'), { host: 'reconnect-host', ownerSecret: secret, agentSecret, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' }); const server = await relay(0, publicKey(secret), join(dir, 'relay.json')); const address = server.address(); assert.ok(address && typeof address !== 'string'); diff --git a/beehive/test/policy-tui.test.ts b/beehive/test/policy-tui.test.ts index e9839777e9..eb9feafbcd 100644 --- a/beehive/test/policy-tui.test.ts +++ b/beehive/test/policy-tui.test.ts @@ -1,3 +1,4 @@ +import { provisionSetup } from './provision.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; @@ -22,7 +23,7 @@ test('actual TUI: policy repair, bounded unchanged retry, lost committed receipt const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-policy-tui-'))); const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); writePrivate(join(dir, 'identity.json'), { secret }); - writePrivate(join(dir, 'setup.json'), { host: 'policy-host', ownerSecret: secret, agentSecret, + provisionSetup(join(dir, 'setup.json'), { host: 'policy-host', ownerSecret: secret, agentSecret, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' }); const server = await relay(0, publicKey(secret), join(dir, 'relay.json')); const address = server.address(); assert.ok(address && typeof address !== 'string'); diff --git a/beehive/test/provision.ts b/beehive/test/provision.ts new file mode 100644 index 0000000000..63feca1ea6 --- /dev/null +++ b/beehive/test/provision.ts @@ -0,0 +1,10 @@ +import { dirname } from 'node:path'; +import { createGenesis } from '../src/assignment.ts'; +import { publicKey } from '../src/protocol.ts'; +import { provision, validateSetup } from '../src/host.ts'; + +/** Fresh fixture keys are explicitly enrolled once, just like local new-key setup. */ +export function provisionSetup(path: string, value: unknown): void { + const setup = validateSetup(value); + provision(dirname(path), setup, createGenesis(publicKey(setup.ownerSecret), publicKey(setup.agentSecret), setup.host)); +} diff --git a/beehive/test/reconnect.test.ts b/beehive/test/reconnect.test.ts index cc240a798f..9d8f5a6ffb 100644 --- a/beehive/test/reconnect.test.ts +++ b/beehive/test/reconnect.test.ts @@ -1,3 +1,4 @@ +import { provisionSetup } from './provision.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, realpathSync, readFileSync, rmSync } from 'node:fs'; @@ -18,7 +19,7 @@ async function until(predicate: () => boolean) { test('host reconnect replays durable relay intent and receipts without repeating lifecycle effects', async () => { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-reconnect-'))); const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); - writePrivate(join(dir, 'setup.json'), { host: 'reconnect-host', ownerSecret: secret, agentSecret, + provisionSetup(join(dir, 'setup.json'), { host: 'reconnect-host', ownerSecret: secret, agentSecret, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' }); const server = await relay(0, publicKey(secret), join(dir, 'relay.json')); const address = server.address(); assert.ok(address && typeof address !== 'string'); diff --git a/beehive/test/slice.test.ts b/beehive/test/slice.test.ts index 209ad7d868..f91c232b5f 100644 --- a/beehive/test/slice.test.ts +++ b/beehive/test/slice.test.ts @@ -1,3 +1,4 @@ +import { provisionSetup } from './provision.ts'; import { spawn } from 'node:child_process'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -25,7 +26,7 @@ test('real websocket relay, external runner, durable retry, UI close/reopen, hos const dir = realpathSync(mkdtempSync(join(tmpdir(),'beehive-test-'))); const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); const hostDir = join(dir,'host'); mkdirSync(hostDir); - writePrivate(join(hostDir,'setup.json'),{ host:'test-host',ownerSecret:secret,agentSecret,runner:process.execPath,args:[resolve('test/runner.ts')],workspace:dir,mode:'fixture' }); + provisionSetup(join(hostDir,'setup.json'),{ host:'test-host',ownerSecret:secret,agentSecret,runner:process.execPath,args:[resolve('test/runner.ts')],workspace:dir,mode:'fixture' }); const log = join(dir,'relay.json'); const server = await relay(0,publicKey(secret),log); const address = server.address(); assert.ok(address && typeof address !== 'string'); @@ -100,7 +101,7 @@ test('quarantined leader exit retains Stop and close teardown for owned descenda const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-orphan-'))); const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); const hd = join(dir, 'host'); mkdirSync(hd); - writePrivate(join(hd, 'setup.json'), { host: 'orphan-host', ownerSecret: secret, agentSecret, runner: process.execPath, args: [resolve('test/orphan-fixture.ts'), behavior], workspace: dir, mode: 'fixture' }); + provisionSetup(join(hd, 'setup.json'), { host: 'orphan-host', ownerSecret: secret, agentSecret, runner: process.execPath, args: [resolve('test/orphan-fixture.ts'), behavior], workspace: dir, mode: 'fixture' }); const server = await relay(0, publicKey(secret), join(dir, 'relay.json')); const address = server.address(); assert.ok(address && typeof address !== 'string'); const url = `ws://127.0.0.1:${address.port}`;