From 3dbf9aaaab76f0881499a46e65c077a30944983a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 14:22:58 -0400 Subject: [PATCH] beehive: integrate owner-public immutable bindings and local wizard Signed-off-by: Logan Johnson --- beehive/CREDENTIAL_ASYNC_707E.md | 27 ++++++ beehive/CREDENTIAL_SLOTS_3C24C205.md | 11 +++ beehive/TESTING.md | 12 +++ beehive/src/conversation.ts | 10 +- beehive/src/credential-slots.ts | 35 ++++--- beehive/src/host.ts | 1 + beehive/src/local-setup.ts | 20 ++-- beehive/src/slots.ts | 36 ++++--- beehive/test/catalog-tui.test.ts | 40 ++++++-- beehive/test/v3-bindings.test.ts | 136 +++++++++++++++++++++++++++ 10 files changed, 289 insertions(+), 39 deletions(-) create mode 100644 beehive/test/v3-bindings.test.ts diff --git a/beehive/CREDENTIAL_ASYNC_707E.md b/beehive/CREDENTIAL_ASYNC_707E.md index d94b97863a..9aa88497c1 100644 --- a/beehive/CREDENTIAL_ASYNC_707E.md +++ b/beehive/CREDENTIAL_ASYNC_707E.md @@ -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. diff --git a/beehive/CREDENTIAL_SLOTS_3C24C205.md b/beehive/CREDENTIAL_SLOTS_3C24C205.md index caa7863f1b..b90368dd58 100644 --- a/beehive/CREDENTIAL_SLOTS_3C24C205.md +++ b/beehive/CREDENTIAL_SLOTS_3C24C205.md @@ -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. diff --git a/beehive/TESTING.md b/beehive/TESTING.md index 1edf509d16..0e85137560 100644 --- a/beehive/TESTING.md +++ b/beehive/TESTING.md @@ -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. diff --git a/beehive/src/conversation.ts b/beehive/src/conversation.ts index b6cd75e17f..dd9d65536c 100644 --- a/beehive/src/conversation.ts +++ b/beehive/src/conversation.ts @@ -16,8 +16,15 @@ export type ConversationPlan = ReturnType; * 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>({ ...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>({ ...prepared.env, - BUZZ_PRIVATE_KEY: agentSecret, BUZZ_RELAY_URL: url.href, BUZZ_ACP_AGENT_OWNER: owner, ...(input.authTag === undefined ? {} : { BUZZ_AUTH_TAG: input.authTag }), diff --git a/beehive/src/credential-slots.ts b/beehive/src/credential-slots.ts index b9165b6233..1b8d7b62cc 100644 --- a/beehive/src/credential-slots.ts +++ b/beehive/src/credential-slots.ts @@ -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; -type Manifest = { version: 3; host: string; ownerPublic: string; setups: Record; agents: Record }; +type Manifest = { retiredBindings?: Record; conversation?: ConversationSetup; version: 3; host: string; ownerPublic: string; setups: Record; agents: Record }; 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 { signal?.throwIfAborted(); - const manifest = readManifest(directory); + const manifest = readCredentialManifest(directory); const snapshot = JSON.stringify(manifest); const secrets = new Map(); 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'); diff --git a/beehive/src/host.ts b/beehive/src/host.ts index 32e4330609..fc686193ee 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -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); diff --git a/beehive/src/local-setup.ts b/beehive/src/local-setup.ts index 751b0d7676..48318cfb18 100644 --- a/beehive/src/local-setup.ts +++ b/beehive/src/local-setup.ts @@ -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 { - 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 { 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 { 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()); diff --git a/beehive/src/slots.ts b/beehive/src/slots.ts index f5837c387d..1fa53150a3 100644 --- a/beehive/src/slots.ts +++ b/beehive/src/slots.ts @@ -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; @@ -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) { + 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 { 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. */ diff --git a/beehive/test/catalog-tui.test.ts b/beehive/test/catalog-tui.test.ts index 4f954c4cb9..27b6117636 100644 --- a/beehive/test/catalog-tui.test.ts +++ b/beehive/test/catalog-tui.test.ts @@ -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 }); } }); + +} diff --git a/beehive/test/v3-bindings.test.ts b/beehive/test/v3-bindings.test.ts new file mode 100644 index 0000000000..a182b4a1be --- /dev/null +++ b/beehive/test/v3-bindings.test.ts @@ -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[0]) { reads++; if (!hydration) throw Object.assign(Error(`EACCES: ${root}/setup.json`), { code: 'EACCES' }); return credentials.read(ref); } }; + let h: Awaited> | 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> | 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[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 }); } +});