beehive: integrate owner-public immutable bindings and local wizard

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 14:22:58 -04:00
parent 6caa4150de
commit 3dbf9aaaab
10 changed files with 289 additions and 39 deletions

View File

@@ -100,3 +100,30 @@ After these changes strict and the focused assignment/Move/Restart/helper set pa
12/12, default concurrency. No deadline increase, serialized suite, forced exit,
or second full rerun-to-green. **The repaired source has not passed a full suite.**
The full run did pass the installed owner-public v3 TUI signed-reply journey.
## V3 binding/wizard continuation (7fdd)
The existing binding add/replacement/retirement and normal-conversion owners now
accept validated v3 public manifests. Retirement is fingerprint-bound eligibility,
not deletion: every definition and journal remains. Public wizard inventory and
locked preview/confirmation do not query identity credentials or infer availability.
Normal conversion creates a new reference and validates local runtime/provider
inputs without reading an identity key; Start still re-reads that key through the
cancellation-fenced execution owner. Existing v2 conversion stays explicit; this
is not plaintext identity/provider migration or a silent manifest-version upgrade.
`v3-bindings.test.ts` covers actual wizard refusal/confirmation/replacement and
changed preview, neutral inventory with an unreadable synthetic store, retained
history, selected-next B versus actual A, explicit Restart, failed preflight old
actual survival, removed-key neutral Save/Stop and launch refusal. The additional
`catalog-tui.test.ts` v3-conversion case reuses the installed signed-reply journey:
actual local wizard -> private owner TUI binding selection -> installed ACP/CLI
signed reply -> Stop/replay/removal refusal. Source-shaped TS provider only.
Independent `CREDENTIAL_ASYNC_REVIEW_2F3D4755.md` found the unchanged async core
sound within its stated boundary, with one Low pre-spawn filesystem-path leak.
The receipt now sanitizes non-missing filesystem errno failures separately from
native missing/denied/timeout; the v3 host test injects EACCES and checks the
receipt. That review does not cover this wizard delta or the 6caa recovery delta.
Exact candidate execution and iteration failures are recorded in workspace
`WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/V3_BINDINGS_7FDD/` and its report.

View File

@@ -28,3 +28,14 @@ Owner chose ordinary **direct relay membership** (delegator steering event `ada7
Workspace evidence: `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/CREDENTIAL_3C24/`.
Bridge source vet: `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/KEYCHAIN_BRIDGE_VET_73100E59.md`.
Original failures retained: first strict compile after making owner private identity optional; first installed journey incorrectly asserted `actual.model` rather than the existing `actual.selection.model`. No production behavior/deadline was changed to conceal either failure. Initial two strict invocations used discovered Homebrew Node 26.8.1, then were repeated with the required resolved Node 24.15.0; only pinned runs are gate evidence.
## V3 immutable-binding continuation (7fdd)
V3 added-agent recovery was published at 6caa4150. Existing local-setup now uses
public-only identity inventory and supports immutable binding add/replacement,
retirement and normal conversation conversion without identity-key hydration.
A normal binding is configuration, not proof of key availability, placement or
provider admission. Conversion retains original diagnostic references and every
journal; owner-public manifests are never silently converted from v2. Provider
credentials retain their existing independent local contract. Actual native OS
verification and direct-membership integration remain separate pending gates.

View File

@@ -577,3 +577,15 @@ additional actual local replacement before explicit remote selection: installed
buzz-acp/CLI still produce four signed replies, including explicit Restart with
same identity/profile and unchanged sibling/history. Provider adapters remain
source-shaped TypeScript fixtures, not live Goose/Claude/Codex.
## Owner-public v3 immutable bindings (7fdd)
`v3-bindings.test.ts` binds the existing CLI wizard, public preview/hash/lock,
replacement/retirement, neutral conversion, actual A -> selected-next B -> Restart,
removed-key Save/Stop and failed-preflight survival. A deliberately invalid JSON
synthetic credential backend proves neutral wizard actions never query it. The
host errno test checks receipt sanitization, not inventory workspace suppression.
`catalog-tui.test.ts` adds v3 conversion through the actual local wizard and private
owner TUI before the installed ACP/Buzz CLI signed-reply journey. Other provider
journeys are unchanged; no provider parity expansion, OS-store or live enrollment
proof. Workspace V3_BINDINGS_7FDD records exact execution/failures.

View File

@@ -16,8 +16,15 @@ export type ConversationPlan = ReturnType<typeof prepareConversation>;
* A NIP-OA tag is an opaque host-local credential; only upstream verifies it.
*/
export function prepareConversation(input: ConversationSetup, agent: AgentLaunch, agentSecret: string, owner: string) {
const binding = prepareConversationBinding(input, agent, publicKey(agentSecret), owner);
return Object.freeze({ ...binding, env: Object.freeze<Record<string, string>>({ ...binding.env, BUZZ_PRIVATE_KEY: agentSecret }) });
}
/** Validate local runtime/provider binding without reading an identity credential.
* This public plan cannot launch: prepareConversation supplies the execution key. */
export function prepareConversationBinding(input: ConversationSetup, agent: AgentLaunch, agentPublicKey: string, owner: string) {
const prepared = prepareAgent(agent);
const agentPublicKey = publicKey(agentSecret);
if (!/^[a-f0-9]{64}$/.test(agentPublicKey)) throw Error('Invalid conversation agent public key');
if (!/^[a-f0-9]{64}$/.test(owner)) throw Error('Invalid conversation owner public key');
if (!isAbsolute(input.executable) || realpathSync(input.executable) !== input.executable) throw Error('Conversation executable must be a canonical absolute path');
accessSync(input.executable, constants.X_OK);
@@ -39,7 +46,6 @@ export function prepareConversation(input: ConversationSetup, agent: AgentLaunch
agentExecutableHash: prepared.executableHash,
// Authoritative identity/transport applied after provider env; no arbitrary env input.
env: Object.freeze<Record<string, string>>({ ...prepared.env,
BUZZ_PRIVATE_KEY: agentSecret,
BUZZ_RELAY_URL: url.href,
BUZZ_ACP_AGENT_OWNER: owner,
...(input.authTag === undefined ? {} : { BUZZ_AUTH_TAG: input.authTag }),

View File

@@ -6,14 +6,16 @@ import { object, publicKey, text } from './protocol.ts';
import { semanticHash } from './handoff.ts';
import { readPrivate, writePrivate } from './storage.ts';
import { credentialReference, createCredential, readCredential, type CredentialBackend, type CredentialReference, systemCredentials } from './credential-store.ts';
import type { ConversationSetup } from './conversation.ts';
import type { SlotEntry } from './slots.ts';
type Harness = Omit<Setup, 'host' | 'ownerSecret' | 'ownerPublic' | 'agentSecret'>;
type Manifest = { version: 3; host: string; ownerPublic: string; setups: Record<string, Harness>; agents: Record<string, { key: CredentialReference; setup: string }> };
type Manifest = { retiredBindings?: Record<string, string>; conversation?: ConversationSetup; version: 3; host: string; ownerPublic: string; setups: Record<string, Harness>; agents: Record<string, { key: CredentialReference; setup: string }> };
const manifestPath = (directory: string) => join(directory, 'setup.json');
const journalPath = (directory: string, agent: string) => join(directory, 'agents', agent, 'journal.json');
function readManifest(directory: string): Manifest {
/** Validate only public installation metadata and retained journals, never credentials. */
export function readCredentialManifest(directory: string): Manifest {
const raw = object(readPrivate(manifestPath(directory)));
if (raw.version !== 3 || Object.hasOwn(raw, 'ownerSecret')) throw Error('Public credential installation required; no automatic plaintext migration');
const manifest = raw as Manifest;
@@ -25,6 +27,15 @@ function readManifest(directory: string): Manifest {
if (['host', 'ownerPublic', 'ownerSecret', 'agentSecret'].some(key => Object.hasOwn(value, key))) throw Error('Binding cannot carry identity');
validateSetup({ ...value, host: manifest.host, ownerPublic: manifest.ownerPublic });
}
if (manifest.retiredBindings !== undefined) {
for (const [id, fingerprint] of Object.entries(object(manifest.retiredBindings))) {
if (!Object.hasOwn(manifest.setups, id) || fingerprint !== semanticHash(manifest.setups[id])) throw Error('Invalid retired binding definition');
}
}
const common = manifest.conversation ?? manifest.setups.default?.conversation;
for (const harness of Object.values(manifest.setups)) {
if (harness.conversation && semanticHash(harness.conversation) !== semanticHash(common ?? null)) throw Error('Binding cannot change conversation authority');
}
for (const [agent, value] of agents) {
const entry = object(value);
const ref = object(entry.key);
@@ -40,12 +51,12 @@ function readManifest(directory: string): Manifest {
/** Read OS entries anew on every load. Missing means public-only, never regeneration;
* locked/denied/unavailable errors propagate, rather than masquerading as absence. */
export function credentialSlots(directory: string, backend: CredentialBackend = systemCredentials): SlotEntry[] {
const manifest = readManifest(directory);
const manifest = readCredentialManifest(directory);
return Object.entries(manifest.agents).map(([agent, entry]) => {
const present = backend.read(entry.key) !== null;
const secret = present ? readCredential(entry.key, backend) : undefined;
const bindings = Object.fromEntries(Object.entries(manifest.setups).map(([id, harness]) => [id, validateSetup({ ...harness, host: manifest.host, ownerPublic: manifest.ownerPublic, ...(secret ? { agentSecret: secret } : {}) })]));
return { agent, path: journalPath(directory, agent), setupId: entry.setup, setup: bindings[entry.setup]!, bindings, keyPresent: present };
return { retiredBindings: manifest.retiredBindings, agent, path: journalPath(directory, agent), setupId: entry.setup, setup: bindings[entry.setup]!, bindings, keyPresent: present };
});
}
@@ -53,14 +64,14 @@ export function credentialSlots(directory: string, backend: CredentialBackend =
* no secret result can hydrate a changed manifest or bypass a removed key. */
export async function credentialSlotsAsync(directory: string, backend: CredentialBackend, signal?: AbortSignal): Promise<SlotEntry[]> {
signal?.throwIfAborted();
const manifest = readManifest(directory);
const manifest = readCredentialManifest(directory);
const snapshot = JSON.stringify(manifest);
const secrets = new Map<string, string | null>();
for (const entry of Object.values(manifest.agents)) {
signal?.throwIfAborted();
const secret = backend.readAsync ? await backend.readAsync(entry.key, signal) : backend.read(entry.key);
signal?.throwIfAborted();
if (JSON.stringify(readManifest(directory)) !== snapshot) throw Error('Credential manifest changed during read');
if (JSON.stringify(readCredentialManifest(directory)) !== snapshot) throw Error('Credential manifest changed during read');
if (secret !== null && publicKey(secret) !== entry.key.publicKey) throw Error('Credential identity mismatch');
secrets.set(entry.key.publicKey, secret);
}
@@ -100,7 +111,7 @@ export function provisionCredentialSlot(directory: string, setup: Setup, secret:
* A retry after successful deletion verifies absence; it never recreates a credential. */
export function removeCredentialSlotKey(directory: string, agent: string, backend: CredentialBackend = systemCredentials): void {
locked(directory, () => {
const manifest = readManifest(directory), entry = manifest.agents[agent];
const manifest = readCredentialManifest(directory), entry = manifest.agents[agent];
if (!entry) throw Error('Unknown retained public slot');
const state = loadSlotState({ ...manifest.setups[entry.setup]!, host: manifest.host, ownerPublic: manifest.ownerPublic }, journalPath(directory, agent), agent);
if (state.phase !== 'stopped' || state.actual !== null) throw Error('Key removal requires a stopped slot with no actual run');
@@ -113,7 +124,7 @@ export function removeCredentialSlotKey(directory: string, agent: string, backen
export function importCredentialSlotKey(directory: string, agent: string, secret: string, backend: CredentialBackend = systemCredentials): void {
if (publicKey(secret) !== agent) throw Error('Imported key does not match the selected public identity');
locked(directory, () => {
const manifest = readManifest(directory), entry = manifest.agents[agent];
const manifest = readCredentialManifest(directory), entry = manifest.agents[agent];
if (!entry) throw Error('Unknown retained public slot');
const state = loadSlotState({ ...manifest.setups[entry.setup]!, host: manifest.host, ownerPublic: manifest.ownerPublic }, journalPath(directory, agent), agent);
if (state.phase !== 'stopped' || state.actual !== null) throw Error('Key import requires a stopped slot with no actual run');
@@ -149,8 +160,9 @@ export function reconcileCredentialProvision(directory: string, setup: Setup, se
* prefixes retain an inert journal for explicit reconciliation, never overwrite. */
export function addCredentialSlot(directory: string, secret: string, root: Genesis, setupId = 'default', expectedFingerprint: string | undefined, backend: CredentialBackend = systemCredentials): void {
locked(directory, () => {
const manifest = readManifest(directory), agent = publicKey(secret), genesis = validateGenesis(root);
const manifest = readCredentialManifest(directory), agent = publicKey(secret), genesis = validateGenesis(root);
if (manifest.agents[agent] || Object.keys(manifest.agents).length >= 32) throw Error('Slot exists or installation full; retained keys require explicit import');
if (manifest.retiredBindings?.[setupId]) throw Error('Binding retired');
const harness = manifest.setups[setupId];
if (!harness) throw Error('Unknown host harness setup');
if (expectedFingerprint !== undefined && bindingFingerprint({ ...harness, host: manifest.host, ownerPublic: manifest.ownerPublic }) !== expectedFingerprint) throw Error('Binding definition changed; reopen local setup');
@@ -171,7 +183,7 @@ export function addCredentialSlot(directory: string, secret: string, root: Genes
* are already-public. Candidate bindings are derived from the journal's retained
* selection, never defaulted: a definition change or corrupt state fails closed. */
export function orphanCredentialSlots(directory: string): { host: string; ownerPublic: string; orphans: { agent: string; genesis: Genesis; candidates: { setupId: string; fingerprint: string }[] }[] } {
const manifest = readManifest(directory);
const manifest = readCredentialManifest(directory);
const orphans: { agent: string; genesis: Genesis; candidates: { setupId: string; fingerprint: string }[] }[] = [];
const agentsDirectory = join(directory, 'agents');
if (!existsSync(agentsDirectory)) return { host: manifest.host, ownerPublic: manifest.ownerPublic, orphans };
@@ -200,9 +212,10 @@ export function orphanCredentialSlots(directory: string): { host: string; ownerP
* enter here; wrong or foreign inputs leave every retained byte inert. */
export function reconcileCredentialSlot(directory: string, setupId: string, secret: string, root: Genesis, expectedFingerprint: string | undefined, backend: CredentialBackend = systemCredentials): void {
locked(directory, () => {
const manifest = readManifest(directory), agent = publicKey(secret), genesis = validateGenesis(root);
const manifest = readCredentialManifest(directory), agent = publicKey(secret), genesis = validateGenesis(root);
if (manifest.agents[agent]) throw Error('Retained slot exists; use explicit import-agent-key');
if (Object.keys(manifest.agents).length >= 32) throw Error('Installation full; retained keys require explicit import');
if (manifest.retiredBindings?.[setupId]) throw Error('Binding retired');
const harness = manifest.setups[setupId];
if (!harness) throw Error('Unknown host harness setup');
if (expectedFingerprint !== undefined && bindingFingerprint({ ...harness, host: manifest.host, ownerPublic: manifest.ownerPublic }) !== expectedFingerprint) throw Error('Binding definition changed; reopen local setup');

View File

@@ -230,6 +230,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri
// Keep the existing missing-setup receipt contract without hiding a native
// unavailable/denied/timeout result as an absent key. Never relay FS paths.
if (['ENOENT', 'ENOTDIR'].includes(String((error as NodeJS.ErrnoException)?.code))) throw Error('Local agent key/setup missing or changed; repair locally without resetting assignment');
if (typeof (error as NodeJS.ErrnoException)?.code === 'string') throw Error('Local credential/setup unreadable; repair permissions/resources locally without resetting assignment');
throw error;
} finally { if (credentialRead === controller) credentialRead = undefined; }
accessSync(setup.runner, constants.X_OK);

View File

@@ -6,8 +6,10 @@ import { codexGuidance } from './codex.ts';
import { claudeGuidance } from './claude.ts';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { join } from 'node:path';
import { object } from './protocol.ts';
import { realpathSync } from 'node:fs';
import { installationSlots, bindingPreview, retireHarnessBinding, addHarnessBinding, addSlot, importSlotKey } from './slots.ts';
import { installationPublicSlots, installationSlots, bindingPreview, retireHarnessBinding, addHarnessBinding, addSlot, importSlotKey } from './slots.ts';
import { bindingFingerprint } from './host.ts';
import { newKey, publicKey, text } from './protocol.ts';
import { readPrivate } from './storage.ts';
@@ -20,20 +22,22 @@ import { readAgentSecret } from './key-input.ts';
* Binding revisions are new IDs; existing definitions/history are not edited/deleted.
*/
export async function localSetup(directory: string): Promise<void> {
const entries = installationSlots(directory), first = entries[0]!;
const publicOnly = object(readPrivate(join(directory, 'setup.json'))).version === 3;
const entries = publicOnly ? installationPublicSlots(directory) : installationSlots(directory), first = entries[0]!;
const ui = createInterface({ input: stdin, output: stdout });
try {
console.log(`Local installation ${first.setup.host}. Stop the host before committing changes. No login or process launch.`);
for (const [id, setup] of Object.entries(first.bindings)) console.log(`Binding ${id}: ${setup.mode} | ${bindingFingerprint(setup)} | ${first.retiredBindings?.[id] ? 'retired' : 'available'}`);
for (const entry of entries) console.log(`Agent ${entry.agent}: ${entry.keyPresent ? 'reuse existing key' : 'public-only; exact local key restoration required'} | binding ${entry.setupId}`);
for (const entry of entries) console.log(`Agent ${entry.agent}: ${publicOnly ? 'credential reference retained; availability checked only on explicit key action or Start' : entry.keyPresent ? 'reuse existing key' : 'public-only; exact local key restoration required'} | binding ${entry.setupId}`);
const action = await ui.question('Local action [presets / add-preset / add-custom / reuse / new-agent / restore-key / import-standby / replace-binding / retire-binding / add-binding / add-goose / add-claude / add-codex / add-buzz-provider / normal / cancel]: ');
if (action === 'cancel') return;
if (action === 'presets') { showPresets(); return; }
if (action === 'normal') {
const agent = await ui.question('Existing agent public key: ');
const entry = entries.find(e => e.agent === agent);
if (!entry?.keyPresent) throw Error('Retained local key required; use explicit restore-key');
const state = readPrivate(entry.path) as { selected: { harnessSetup?: { id: string } } };
if (!entry || (!publicOnly && !entry.keyPresent)) throw Error('Retained local key required; use explicit restore-key');
const preview = bindingPreview(directory);
const state = preview.entries.find(e => e.agent === agent)!.state as { selected: { harnessSetup?: { id: string } } };
const id = state.selected.harnessSetup?.id ?? entry.setupId;
const setup = entry.bindings[id];
if (!setup || setup.mode === 'fixture' || setup.mode === 'diagnostic-acp') throw Error('Select an ACP binding remotely first; fixture cannot become a conversation harness');
@@ -44,7 +48,7 @@ export async function localSetup(directory: string): Promise<void> {
if (common) console.log(`Reusing pinned installation conversation runtime/relay/trust; Buzz CLI tool ${common.replyTool ? 'enabled' : 'disabled (legacy authority; not silently widened)'}. No authority change.`);
const conversation = common ?? await conversationInput(ui);
if (await ui.question('Save NEW normal binding under installation lock; preserve every selection/key/history? [yes/no]: ') !== 'yes') return;
addConversationBinding(directory, agent, { id, fingerprint }, nextId, conversation);
addConversationBinding(directory, agent, { id, fingerprint, confirmation: preview.confirmation }, nextId, conversation);
console.log(`Normal binding ${nextId} saved from selected ${id}. In remote TUI select this exact host/agent, binding ${nextId}, then explicit Start/Restart. Save uses public revision CAS; other agents remain unchanged. Provider sign-in and relay admission remain unverified.`);
return;
}
@@ -53,11 +57,11 @@ export async function localSetup(directory: string): Promise<void> {
const entry = entries.find(e => e.agent === agent);
if (!entry) throw Error('Unknown retained agent; cannot invent assignment');
if (action === 'reuse') {
if (!entry.keyPresent) throw Error('Local key missing; use restore-key explicitly');
if (!publicOnly && !entry.keyPresent) throw Error('Local key missing; use restore-key explicitly');
console.log(`Reuse ${agent}; no local mutation. In remote TUI select this host/agent, choose binding/configuration, Save then explicitly Restart. Assignment still gates Start.`);
return;
}
if (entry.keyPresent) throw Error('Local key already present; choose reuse');
if (!publicOnly && entry.keyPresent) throw Error('Local key already present; choose reuse');
if (await ui.question('Restore exact retained key only, preserving assignment/history? [yes/no]: ') !== 'yes') return;
ui.close(); // Secret reader owns terminal echo; never overlap readline owners.
importSlotKey(directory, agent, await readAgentSecret());

View File

@@ -1,4 +1,4 @@
import { credentialSlots, credentialSlotsAsync, addCredentialSlot, removeCredentialSlotKey, importCredentialSlotKey, reconcileCredentialSlot } from './credential-slots.ts';
import { readCredentialManifest, credentialSlots, credentialSlotsAsync, addCredentialSlot, removeCredentialSlotKey, importCredentialSlotKey, reconcileCredentialSlot } from './credential-slots.ts';
import { systemCredentials, type CredentialBackend } from './credential-store.ts';
import { setupOwner } from './host.ts';
import { prepareAgent } from './acp.ts';
@@ -8,7 +8,7 @@ import { validateSetup, initialState, loadSlotState, setupModels, bindingFingerp
import { readPrivate, writePrivate } from './storage.ts';
import { object, publicKey, text } from './protocol.ts';
import { validateGenesis, type Genesis } from './assignment.ts';
import { prepareConversation, type ConversationSetup } from './conversation.ts';
import { prepareConversationBinding, prepareConversation, type ConversationSetup } from './conversation.ts';
import { semanticHash } from './handoff.ts';
type Harness = Omit<Setup, 'host' | 'ownerSecret' | 'ownerPublic' | 'agentSecret'>;
@@ -58,6 +58,16 @@ function readInstallation(directory: string): Installation {
}
return i;
}
/** Public v3 inventory deliberately does not establish local key availability. */
export function installationPublicSlots(directory: string): SlotEntry[] {
return installationSlots(directory, { read: () => null, create() { throw Error('Read only'); }, remove() { throw Error('Read only'); } });
}
function readBindingInstallation(directory: string) {
return object(readPrivate(join(directory, 'setup.json'))).version === 3 ? readCredentialManifest(directory) : readInstallation(directory);
}
function installationOwner(i: ReturnType<typeof readBindingInstallation>) {
return i.version === 3 ? { ownerPublic: i.ownerPublic } : { ownerSecret: i.ownerSecret };
}
/** Live-host loading never performs synchronous native credential reads. */
export async function installationSlotsAsync(directory: string, backend: CredentialBackend, signal?: AbortSignal): Promise<SlotEntry[]> {
signal?.throwIfAborted();
@@ -193,7 +203,7 @@ export function addHarnessBinding(directory: string, id: string, value: Harness,
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(id) || ['constructor', 'prototype', '__proto__'].includes(id)) throw Error('Invalid binding ID');
const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 });
try {
const i = readInstallation(directory);
const i = readBindingInstallation(directory);
if (Object.hasOwn(i.setups, id) || Object.keys(i.setups).length >= 32) throw Error('Binding exists or inventory full');
if (source && (!Object.hasOwn(i.setups, source.id) || semanticHash(i.setups[source.id]) !== source.fingerprint)) throw Error('Binding definition changed; reopen local setup');
if (source && i.retiredBindings?.[source.id]) throw Error('Binding retired');
@@ -204,7 +214,7 @@ export function addHarnessBinding(directory: string, id: string, value: Harness,
}
const raw = object(value);
if (['host', 'ownerSecret', 'ownerPublic', 'agentSecret'].some(k => Object.hasOwn(raw, k))) throw Error('Binding cannot carry identity');
const setup = validateSetup({ ...raw, host: i.host, ownerSecret: i.ownerSecret });
const setup = validateSetup({ ...raw, host: i.host, ...installationOwner(i) });
if (setup.custom?.contract === 'goose-native') prepareAgent({ executable: setup.runner, args: setup.args, workspace: setup.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), databricksHost: '', harness: 'goose', provider: setup.gooseProvider, custom: setup.custom, model: setupModels(setup)[0]! });
if (setup.mode === 'codex') prepareAgent({ executable: setup.runner, args: setup.args, workspace: setup.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), databricksHost: '', harness: 'codex', codex: setup.codex, model: setupModels(setup)[0]! });
// Conversation authority is installation-owned, not a binding selector.
@@ -220,28 +230,30 @@ export function addHarnessBinding(directory: string, id: string, value: Harness,
* Old diagnostic references retain their exact meaning. Common authority is pinned
* once; this action cannot retarget another normal binding's runtime/tool/trust.
*/
export function addConversationBinding(directory: string, agent: string, source: { id: string; fingerprint: string }, id: string, conversation: ConversationSetup): void {
export function addConversationBinding(directory: string, agent: string, source: { id: string; fingerprint: string; confirmation?: string }, id: string, conversation: ConversationSetup): void {
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(id) || ['constructor', 'prototype', '__proto__'].includes(id)) throw Error('Invalid binding ID');
const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 });
try {
const i = readInstallation(directory), entries = installationSlots(directory);
const i = readBindingInstallation(directory), entries = i.version === 3 ? installationPublicSlots(directory) : installationSlots(directory);
if (source.confirmation !== undefined && source.confirmation !== bindingConfirmation(directory)) throw Error('Affected choices changed; reopen local setup');
for (const entry of entries) {
const state = loadSlotState(entry.setup, entry.path, entry.agent);
if (state.phase !== 'stopped' || state.actual !== null) throw Error('Conversation conversion requires every slot stopped');
}
if (i.retiredBindings?.[source.id]) throw Error('Binding retired');
const entry = entries.find(e => e.agent === agent), setup = entry?.bindings[source.id];
if (!entry?.keyPresent || !setup?.agentSecret) throw Error('Retained agent key required; no key recreation');
if (!entry || !setup || (i.version !== 3 && (!entry.keyPresent || !setup.agentSecret))) throw Error('Retained agent key required; no key recreation');
if (bindingFingerprint(setup) !== source.fingerprint) throw Error('Binding definition changed; reopen local setup');
if (setup.mode === 'fixture' || setup.mode === 'diagnostic-acp') throw Error('Choose a compatible ACP contract; fixture/diagnostic-only cannot become normal');
if (Object.hasOwn(i.setups, id) || Object.keys(i.setups).length >= 32) throw Error('Binding exists or inventory full');
const common = i.conversation ?? i.setups.default?.conversation;
if (common && semanticHash(common) !== semanticHash(conversation)) throw Error('Installation conversation authority already pinned; cannot retarget it');
prepareConversation(conversation, { executable: setup.runner, args: setup.args, workspace: setup.workspace,
const validateConversation = i.version === 3 ? prepareConversationBinding : prepareConversation;
validateConversation(conversation, { executable: setup.runner, args: setup.args, workspace: setup.workspace,
home: text(setup.serviceHome), configDirectory: text(setup.configDirectory),
...(setup.buzzProvider ? { buzzProvider: setup.buzzProvider } : {}), databricksHost: setup.mode === 'buzz-agent-api-key' || setup.mode === 'goose' || setup.mode === 'claude' || setup.mode === 'codex' ? '' : text(setup.databricksHost),
...(setup.mode === 'codex' ? { harness: 'codex' as const, codex: setup.codex } : {}), ...(setup.mode === 'claude' ? { harness: 'claude' as const, claude: setup.claude } : {}), ...(setup.mode === 'goose' ? { harness: 'goose' as const, provider: setup.gooseProvider, ...(setup.custom ? { custom: setup.custom } : {}) } : {}), model: setupModels(setup)[0]!
}, setup.agentSecret, setupOwner(setup));
}, i.version === 3 ? agent : setup.agentSecret!, setupOwner(setup));
i.conversation = structuredClone(conversation);
i.setups[id] = { ...i.setups[source.id]!, conversation: structuredClone(conversation) };
// Single fsynced manifest rename activates authority and new definition together.
@@ -251,7 +263,7 @@ export function addConversationBinding(directory: string, agent: string, source:
}
function requireStoppedBindings(directory: string): void {
for (const entry of installationSlots(directory)) {
for (const entry of installationPublicSlots(directory)) {
const state = loadSlotState(entry.setup, entry.path, entry.agent);
if (state.phase !== 'stopped' || state.actual !== null) throw Error('Binding retirement requires every slot stopped');
}
@@ -262,7 +274,7 @@ function requireStoppedBindings(directory: string): void {
export function retireHarnessBinding(directory: string, source: { id: string; fingerprint: string; confirmation?: string }): void {
const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 });
try {
const i = readInstallation(directory);
const i = readBindingInstallation(directory);
if (!Object.hasOwn(i.setups, source.id) || semanticHash(i.setups[source.id]) !== source.fingerprint) throw Error('Binding definition changed; reopen local setup');
if (i.retiredBindings?.[source.id]) throw Error('Binding already retired');
if (source.confirmation !== undefined && source.confirmation !== bindingConfirmation(directory)) throw Error('Affected choices changed; reopen local setup');
@@ -273,7 +285,7 @@ export function retireHarnessBinding(directory: string, source: { id: string; fi
}
function bindingSnapshot(directory: string) {
return installationSlots(directory).map(entry => ({ agent: entry.agent, setupId: entry.setupId, state: readPrivate(entry.path) }));
return installationPublicSlots(directory).map(entry => ({ agent: entry.agent, setupId: entry.setupId, state: readPrivate(entry.path) }));
}
/** Capture preview and fence together under the mutation lock, never during human input. */

View File

@@ -47,8 +47,10 @@ test('catalog verifies owner/signature/relay/expiry; labels cannot spoof identit
assert.throws(() => productionAdmission.admit({ relay, publicKey: publicKey(a), transport: 'nip42-nip59', ownerDelegation: false }), /pending/);
});
for (const installed of [false, true]) test(`actual owner TUI owner-public credential slots Start/Stop via private transport (${installed ? 'installed signed reply' : 'external runner'})`, { skip: installed && !process.env.BEEHIVE_REAL_BUZZ_ACP }, async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-catalog-tui-')));
for (const scenario of ['external', 'installed', 'v3-conversion']) {
const installed = scenario !== 'external', converting = scenario === 'v3-conversion';
test(`actual owner TUI owner-public credential slots Start/Stop via private transport (${scenario})`, { skip: installed && !process.env.BEEHIVE_REAL_BUZZ_ACP }, async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-catalog-tui-')));
const owner = newKey(), a = newKey(), b = newKey(), agent = newKey();
const members = new Set([owner, a, b].map(publicKey));
const relay = await nostrFixture(publicKey(owner), members);
@@ -58,8 +60,24 @@ for (const installed of [false, true]) test(`actual owner TUI owner-public crede
const directory = join(root, 'host'); mkdirSync(directory);
const credentials = memoryCredentials();
const conversation = installed ? await conversationRelayFixture(root, owner, publicKey(agent)) : undefined;
provisionCredentialSlot(directory, { host: publicKey(a), ownerPublic: publicKey(owner), runner: realpathSync(process.execPath), args: [resolve(installed ? 'test/conversation-harness-fixture.ts' : 'test/runner.ts')], workspace: root, mode: installed ? 'buzz-agent-databricks-v2' : 'fixture', ...(conversation ? { serviceHome: root, configDirectory: root, databricksHost: 'https://fixture.invalid', conversation: { executable: realpathSync(process.env.BEEHIVE_REAL_BUZZ_ACP!), relay: conversation.url, replyTool: { executable: realpathSync(join(process.env.BEEHIVE_REAL_BUZZ_ACP!, '..', 'buzz')) } } } : {}) }, agent, createGenesis(publicKey(owner), publicKey(agent), publicKey(a)), credentials);
provisionCredentialSlot(directory, { host: publicKey(a), ownerPublic: publicKey(owner), runner: realpathSync(process.execPath), args: [resolve(installed ? 'test/conversation-harness-fixture.ts' : 'test/runner.ts')], workspace: root, mode: installed ? 'buzz-agent-databricks-v2' : 'fixture', ...(conversation ? { serviceHome: root, configDirectory: root, databricksHost: 'https://fixture.invalid', ...(!converting ? { conversation: { executable: realpathSync(process.env.BEEHIVE_REAL_BUZZ_ACP!), relay: conversation.url, replyTool: { executable: realpathSync(join(process.env.BEEHIVE_REAL_BUZZ_ACP!, '..', 'buzz')) } } } : {}) } : {}) }, agent, createGenesis(publicKey(owner), publicKey(agent), publicKey(a)), credentials);
const journalPath = join(directory, 'agents', publicKey(agent), 'journal.json');
if (converting) {
const original = readFileSync(journalPath);
const wizard = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), 'src/cli.ts', 'local-setup', directory], { env: { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: join(root, 'unused-credentials.json') }, stdio: ['pipe', 'pipe', 'pipe'] });
let output = ''; wizard.stdout.on('data', b => output += b); wizard.stderr.on('data', b => output += b);
const exited = new Promise(resolve => wizard.on('close', resolve));
try {
for (const [prompt, answer] of [['Local action [', 'normal'], ['Existing agent public key:', publicKey(agent)], ['NEW immutable normal binding ID:', 'normal'], ['Absolute installed buzz-acp executable:', realpathSync(process.env.BEEHIVE_REAL_BUZZ_ACP!)], ['Buzz CONVERSATION relay URL', conversation!.url], ['Absolute installed buzz CLI executable:', realpathSync(join(process.env.BEEHIVE_REAL_BUZZ_ACP!, '..', 'buzz'))], ['Save NEW normal binding under installation lock', 'yes']]) {
for (let n = 0; n < 400 && !output.includes(prompt!); n++) { if (wizard.exitCode !== null) break; await delay(20); }
assert.ok(output.includes(prompt!), output); wizard.stdin.write(`${answer}\n`);
}
assert.equal(await exited, 0, output);
} finally { if (wizard.exitCode === null && wizard.signalCode === null) wizard.kill('SIGKILL'); await exited; }
assert.deepEqual(readFileSync(journalPath), original);
assert.equal(installationSlots(directory, credentials)[0]!.setup.conversation, undefined);
assert.ok(installationSlots(directory, credentials)[0]!.bindings.normal!.conversation);
}
const manifest = readFileSync(join(directory, 'setup.json'), 'utf8');
assert.ok(!manifest.includes(owner) && !manifest.includes(agent));
const running = await host(directory, relay.url, undefined, privateHostTransport(one, a, admission), credentials);
@@ -85,6 +103,14 @@ for (const installed of [false, true]) test(`actual owner TUI owner-public crede
assert.ok(rows.includes(publicKey(a))); assert.ok(rows.includes(publicKey(b)));
assert.match(rows, /recent host report/);
await command(`select ${publicKey(a)}`);
if (converting) {
await command('binding normal');
let selected = '';
for (let n = 0; n < 80; n++) { selected = await command('show'); if (selected.includes('"revision": 1')) break; await delay(25); }
assert.match(selected, /"revision": 1/);
const saved = JSON.parse(readFileSync(journalPath, 'utf8'));
assert.equal(saved.selected.harnessSetup.id, 'normal'); assert.equal(saved.actual, null);
}
await command('start');
await wait(() => output.includes('completed | accepted'));
let shown = '';
@@ -124,20 +150,20 @@ for (const installed of [false, true]) test(`actual owner TUI owner-public crede
assert.ok(!shown.includes('FORGED'));
assert.match(shown, /"phase": "stopped"/);
assert.equal(readFileSync(journalPath, 'utf8'), beforeReplay);
assert.equal(Object.keys(journal.operations).length, 2);
assert.equal(Object.keys(journal.operations).length, converting ? 3 : 2);
} finally { ownerWire.close(); }
assert.equal(foreignMessages.length, 0, 'another admitted host cannot read owner inventory or commands');
assert.ok(relay.history.every(e => e.kind === 1059 && e.tags.length === 1));
assert.ok(!output.includes(owner)); assert.ok(!output.includes(a)); assert.ok(!output.includes(agent));
const state = JSON.parse(readFileSync(journalPath, 'utf8'));
assert.equal(state.phase, 'stopped'); assert.equal(state.revision, 2);
assert.equal(state.phase, 'stopped'); assert.equal(state.revision, converting ? 3 : 2);
// Simulate deliberate OS deletion while the host still has its old hydrated
// setup in memory: the production pre-spawn re-read must refuse that cache.
const reference = JSON.parse(manifest).agents[publicKey(agent)].key;
credentials.remove(reference);
const runs = JSON.stringify(state.runs);
await command('start');
await wait(() => Object.keys(JSON.parse(readFileSync(journalPath, 'utf8')).operations).length === 3);
await wait(() => Object.keys(JSON.parse(readFileSync(journalPath, 'utf8')).operations).length === (converting ? 4 : 3));
const refused = JSON.parse(readFileSync(journalPath, 'utf8'));
assert.equal(refused.phase, 'stopped'); assert.equal(refused.actual, null);
assert.equal(JSON.stringify(refused.runs), runs);
@@ -153,3 +179,5 @@ for (const installed of [false, true]) test(`actual owner TUI owner-public crede
foreign.close(); await running.close(); await conversation?.close(); await relay.close(); rmSync(root, { recursive: true, force: true });
}
});
}

View File

@@ -0,0 +1,136 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, rmdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { setTimeout as delay } from 'node:timers/promises';
import { provisionCredentialSlot } from '../src/credential-slots.ts';
import { installationPublicSlots, installationSlots, addHarnessBinding, addSlot, bindingPreview, retireHarnessBinding } from '../src/slots.ts';
import { bindingFingerprint, host, validateSetup } from '../src/host.ts';
import { createGenesis } from '../src/assignment.ts';
import { newKey, publicKey, message, type Message } from '../src/protocol.ts';
import { readPrivate, writePrivate } from '../src/storage.ts';
import { isolatedFileCredentials } from './isolated-file-credentials.ts';
async function until(predicate: () => boolean) {
for (let i = 0; i < 600; i++) { if (predicate()) return; await delay(20); }
throw Error('Missing v3 binding evidence');
}
test('v3 actual local wizard: immutable replacement, neutral normal conversion, retired history and changed-preview fence', { timeout: 60000 }, async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-binding-')));
const directory = join(root, 'host'), file = join(root, 'credentials.json'), credentials = isolatedFileCredentials(file);
const owner = newKey(), secret = newKey(), agent = publicKey(secret);
const setup = validateSetup({ host: 'v3-local', ownerPublic: publicKey(owner), mode: 'goose', runner: realpathSync(process.execPath), args: ['acp'], workspace: root, serviceHome: root, configDirectory: root, gooseProvider: 'fixture', gooseModels: ['a'] });
async function cli(responses: [string, string, (() => void)?][], success = true) {
const child = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), 'src/cli.ts', 'local-setup', directory], { env: { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: file }, stdio: ['pipe', 'pipe', 'pipe'] });
let output = '', cursor = 0; child.stdout.on('data', b => output += b); child.stderr.on('data', b => output += b);
const exited = new Promise(resolve => child.on('close', resolve));
try {
for (const [prompt, answer, action] of responses) {
await until(() => output.indexOf(prompt, cursor) >= 0 || child.exitCode !== null);
assert.ok(output.indexOf(prompt, cursor) >= 0, output); cursor = output.length;
action?.(); child.stdin.write(`${answer}\n`);
}
assert.equal((await exited) === 0, success, output);
assert.ok(!output.includes(secret) && !output.includes(owner)); return output;
} finally { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); await exited; }
}
try {
provisionCredentialSlot(directory, setup, secret, createGenesis(publicKey(owner), agent, setup.host), credentials);
const sibling = newKey(); addSlot(directory, sibling, createGenesis(publicKey(owner), publicKey(sibling), 'other-host'), 'default', undefined, credentials);
const journal = join(directory, 'agents', agent, 'journal.json'), siblingJournal = join(directory, 'agents', publicKey(sibling), 'journal.json');
const original = readFileSync(journal), siblingBytes = readFileSync(siblingJournal), keys = readFileSync(file);
const initialManifest = readFileSync(join(directory, 'setup.json'));
const normal = (answer: string): [string, string][] => [['Local action [', 'normal'], ['Existing agent public key:', agent], ['NEW immutable normal binding ID:', 'normal'], ['Absolute installed buzz-acp executable:', setup.runner], ['Buzz CONVERSATION relay URL', 'ws://127.0.0.1:19490'], ['Absolute installed buzz CLI executable:', setup.runner], ['Save NEW normal binding under installation lock', answer]];
await cli(normal('no')); assert.deepEqual(readFileSync(join(directory, 'setup.json')), initialManifest);
// A malformed synthetic credential file makes ANY read fail. Neutral wizard paths
// must still work; the fixture loader prevents accidental native-store access.
writeFileSync(file, 'invalid JSON');
await cli(normal('yes'));
const entries = installationPublicSlots(directory), fingerprint = bindingFingerprint(entries[0]!.bindings.default!);
assert.equal(entries[0]!.bindings.default!.conversation, undefined);
assert.ok(entries[0]!.bindings.normal!.conversation);
await cli([['Local action [', 'replace-binding'], ['Existing binding ID to reuse:', 'default'], ['NEW immutable binding ID:', 'B'], ['Absolute compatible executable:', setup.runner], ['Allowed workspace', root], ['Retire default and replace', 'yes'], ['Save NEW binding only', 'yes']]);
const current = installationPublicSlots(directory)[0]!;
assert.equal(current.retiredBindings?.default, fingerprint);
assert.equal(bindingFingerprint(current.bindings.default!), fingerprint);
assert.equal(current.setupId, 'default');
assert.deepEqual(readFileSync(journal), original); assert.deepEqual(readFileSync(siblingJournal), siblingBytes);
const changed = await cli([['Local action [', 'retire-binding'], ['Existing binding ID to reuse:', 'B'], ['Retire B for new selection', 'yes', () => { const state = readPrivate(journal) as any; state.revision++; writePrivate(journal, state); }]], false);
assert.match(changed, /Affected choices changed/);
assert.equal(installationPublicSlots(directory)[0]!.retiredBindings?.B, undefined);
const preview = bindingPreview(directory);
mkdirSync(join(directory, 'host.lock'), { mode: 0o700 });
assert.throws(() => retireHarnessBinding(directory, { id: 'B', fingerprint, confirmation: preview.confirmation }), /EEXIST/);
rmdirSync(join(directory, 'host.lock'));
// Restore only test fixture backend bytes; production never migrates secrets.
writePrivate(file, JSON.parse(keys.toString()));
assert.throws(() => addSlot(directory, newKey(), createGenesis(publicKey(owner), publicKey(newKey()), setup.host), 'default', undefined, credentials), /retired/);
assert.equal(installationSlots(directory, credentials)[0]!.retiredBindings?.default, fingerprint);
const manifest = readPrivate(join(directory, 'setup.json')) as any;
manifest.retiredBindings.default = '0'.repeat(64); writePrivate(join(directory, 'setup.json'), manifest);
assert.throws(() => installationPublicSlots(directory), /Invalid retired/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('v3 host retired binding retains history and neutral Stop; unreadable credential receipt hides local paths', async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-binding-host-'))), file = join(root, 'credentials.json');
const credentials = isolatedFileCredentials(file), owner = newKey(), secret = newKey(), agent = publicKey(secret);
const setup = validateSetup({ host: 'v3-host', ownerPublic: publicKey(owner), mode: 'fixture', runner: realpathSync(process.execPath), args: [resolve('test/runner.ts')], workspace: root });
let hydration = true, reads = 0, receive!: (m: Message) => void;
const seen: Message[] = [];
const backend = { ...credentials, read(ref: Parameters<typeof credentials.read>[0]) { reads++; if (!hydration) throw Object.assign(Error(`EACCES: ${root}/setup.json`), { code: 'EACCES' }); return credentials.read(ref); } };
let h: Awaited<ReturnType<typeof host>> | undefined;
const open = () => host(root, 'ws://fixture.invalid', undefined, { binding: { host: setup.host, owner: publicKey(owner) }, validate() {}, connect(_url, _secret, callback) { receive = callback; return { ready: Promise.resolve(), send(m) { seen.push(m); }, close() {} }; } }, backend);
async function send(type: 'start' | 'stop', revision: number) { const m = message(type, setup.host, agent, revision); receive(m); await until(() => seen.some(r => r.body.operation === m.id)); return seen.find(r => r.body.operation === m.id)!.body.result; }
try {
provisionCredentialSlot(root, setup, secret, createGenesis(publicKey(owner), agent, setup.host), credentials);
h = await open(); hydration = false;
assert.match(String(await send('start', 0)), /unreadable/); assert.ok(!JSON.stringify(seen.filter(m => m.type === 'receipt')).includes(root));
const before = reads; assert.equal(await send('stop', 0), 'accepted'); assert.equal(reads, before);
await h.close(); h = undefined;
const entry = installationPublicSlots(root)[0]!;
retireHarnessBinding(root, { id: 'default', fingerprint: bindingFingerprint(entry.setup), confirmation: bindingPreview(root).confirmation });
hydration = true; h = await open(); hydration = false;
assert.match(String(await send('start', 1)), /retired/);
assert.equal((readPrivate(entry.path) as any).actual, null);
} finally { await h?.close(); rmSync(root, { recursive: true, force: true }); }
});
test('v3 selected-next B does not change actual A until Restart; removed key Save/Stop stay neutral', async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-binding-run-'))), file = join(root, 'credentials.json');
const credentials = isolatedFileCredentials(file), owner = newKey(), secret = newKey(), agent = publicKey(secret);
const setup = validateSetup({ host: 'v3-run', ownerPublic: publicKey(owner), mode: 'fixture', runner: realpathSync(process.execPath), args: [resolve('test/runner.ts')], workspace: root });
let receive!: (m: Message) => void; const seen: Message[] = [];
let h: Awaited<ReturnType<typeof host>> | undefined;
try {
provisionCredentialSlot(root, setup, secret, createGenesis(publicKey(owner), agent, setup.host), credentials);
const { host: _h, ownerPublic: _o, ...harness } = setup;
addHarnessBinding(root, 'B', { ...harness, args: [resolve('test/binding-runner.ts')] });
const entry = installationPublicSlots(root)[0]!, manifest = readFileSync(join(root, 'setup.json'));
let reads = 0; const backend = { ...credentials, read(ref: Parameters<typeof credentials.read>[0]) { reads++; return credentials.read(ref); } };
h = await host(root, 'ws://fixture.invalid', undefined, { binding: { host: setup.host, owner: publicKey(owner) }, validate() {}, connect(_url, _secret, callback) { receive = callback; return { ready: Promise.resolve(), send(m) { seen.push(m); }, close() {} }; } }, backend);
const state = () => readPrivate(entry.path) as any;
async function send(type: 'start' | 'stop' | 'save' | 'restart', body = {}) { const m = message(type, setup.host, agent, state().revision, body); receive(m); await until(() => seen.some(r => r.body.operation === m.id)); return seen.find(r => r.body.operation === m.id)!.body.result; }
assert.equal(await send('start'), 'accepted'); const actualA = state().actual;
const selection = { ...state().selected, harnessSetup: { id: 'B', fingerprint: bindingFingerprint(entry.bindings.B!) } };
const beforeSave = reads;
assert.equal(await send('save', selection), 'saved; running configuration unchanged'); assert.equal(reads, beforeSave);
assert.deepEqual(state().actual, actualA); assert.equal(state().selected.harnessSetup.id, 'B');
assert.equal(await send('restart'), 'accepted');
assert.equal(state().actual.selection.harnessSetup.id, 'B'); assert.notEqual(state().actual.run, actualA.run);
assert.ok(Object.values(state().runs).some((r: any) => r.run === actualA.run));
const actualB = state().actual;
const ref = (readPrivate(join(root, 'setup.json')) as any).agents[agent].key;
credentials.remove(ref); // fresh synthetic store only; bypass tests pre-spawn revalidation
assert.match(String(await send('restart')), /missing or changed/); assert.deepEqual(state().actual, actualB);
const beforeNeutral = reads;
assert.equal(await send('save', selection), 'saved; running configuration unchanged');
assert.equal(await send('stop'), 'accepted'); assert.equal(reads, beforeNeutral);
assert.match(String(await send('start')), /missing or changed/);
assert.equal(state().actual, null); assert.equal(credentials.read(ref), null);
assert.deepEqual(readFileSync(join(root, 'setup.json')), manifest);
} finally { await h?.close(); rmSync(root, { recursive: true, force: true }); }
});