mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
feat(beehive): fence initial assignment independently of key possession
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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`;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
18
beehive/src/assignment.ts
Normal 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;
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
|
||||
109
beehive/test/assignment.test.ts
Normal file
109
beehive/test/assignment.test.ts
Normal 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 }); }
|
||||
});
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
10
beehive/test/provision.ts
Normal 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));
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user