feat(beehive): fence initial assignment independently of key possession

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-09 23:59:40 -04:00
parent 8a67b96786
commit ebc8b3cb24
16 changed files with 374 additions and 32 deletions

View File

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

View File

@@ -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 <number>`, `hosts`, `select <host-name>`, `show`, `start`, `save`, `stop`, `quit`.
TUI: `agents`, `operations`, `reconcile`, `retry <number>`, `hosts`, `select <number or unique host-name>`, `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": "<hex-private-key>" }`,
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

View File

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

18
beehive/src/assignment.ts Normal file
View File

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

View File

@@ -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 <new-directory> Create a NEW local owner identity
setup <new-host-directory> <identity-file> Guided local harness + key setup
assignment-export <host-directory> <new-file> Export public pinned genesis locally
migrate-assignment <host-directory> Explicit stopped legacy enrollment
auth-info <host-directory> Print exact local sign-in context (no login)
conversation-setup <host-directory> Attach external buzz-acp to EXISTING identity (Start gated)
relay <port> <owner-public-key> <log-file> Dedicated ciphertext relay
host <host-directory> <ws://127.0.0.1:port> Persistent foreground host
tui <identity-file> <ws://127.0.0.1:port> Relay-connected terminal UI
No import, Move, provider login RPC or production relay support yet.`;
setup accepts optional <local-key-file> <public-genesis-file> 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<string,Message>();
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 <number>, hosts, select <host>, 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 <number>, hosts, agents, select <number or unique host>, 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.`);
}

View File

@@ -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<string, { fingerprint: string; reply: Message }>; 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<string, { fingerprint: string; reply: Message }>; 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 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

10
beehive/test/provision.ts Normal file
View File

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

View File

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

View File

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