diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index 3c1239a424..3e49c0a91e 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -346,6 +346,7 @@ async function main() { let action = line; if (line.startsWith('binding ')) { const binding = (current.body.harnessSetups as any[]).find(b => b.id === line.slice(8)); + if (binding?.availability === 'retired') { console.log('Binding retired; choose an available binding.'); continue; } if (!binding) { console.log('Unknown binding; use show for advertised harnessSetups.'); continue; } const next = current.body.selectedNext as Record; body = { ...next, harnessSetup: { id: binding.id, fingerprint: binding.fingerprint }, model: binding.models[0], workspace: binding.workspaces[0] }; diff --git a/beehive/src/host.ts b/beehive/src/host.ts index 3057df736a..d604856bd8 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -91,7 +91,7 @@ export function loadSlotState(setup: Setup, path: string, agent: string): State return state; } /** Hosts consult durable assignment, never key presence or relay inventory, for authority. */ -function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string) => Setup, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record) { +function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string) => Setup, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record, retiredBindings: Record = {}) { // Optional execution credential: a public-only slot (local key copy deliberately // removed) has none. There is no shadow identity and no reconstruction; execution // paths must load it explicitly and fail closed when absent. @@ -113,9 +113,9 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri let catalog: Catalog | { state: 'not-probed' | 'failed'; authentication: 'unverified' } = { state: 'not-probed', authentication: 'unverified' }; function inventory() { const candidate = bindings[state.selected.harnessSetup?.id ?? setupId]; - const nextSetup = candidate && (!state.selected.harnessSetup || state.selected.harnessSetup.fingerprint === bindingFingerprint(candidate)) ? candidate : undefined; + const nextSetup = !retiredBindings[state.selected.harnessSetup?.id ?? setupId] && candidate && (!state.selected.harnessSetup || state.selected.harnessSetup.fingerprint === bindingFingerprint(candidate)) ? candidate : undefined; return message('inventory',setup.host,agent,state.revision, { - harnessSetups: Object.entries(bindings).map(([id, value]) => ({ id, fingerprint: bindingFingerprint(value), kind: value.mode, models: setupModels(value), workspaces: value.allowedWorkspaces ?? [value.workspace] })), + harnessSetups: Object.entries(bindings).map(([id, value]) => ({ id, availability: retiredBindings[id] ? 'retired' : 'available', fingerprint: bindingFingerprint(value), kind: value.mode, models: setupModels(value), workspaces: value.allowedWorkspaces ?? [value.workspace] })), configurations: configurations(state.configurations, state.selected), runHistory: Object.values(state.runs ?? {}).slice(-8).map(run => ({ run: run.run, configuration: run.selection.configuration, harnessSetup: run.harnessSetup, model: run.selection.model, workspace: run.selection.workspace, appliedInstructions: run.appliedInstructions, preparedInputHash: run.preparedInputHash })), move: state.move ? 'destination preflight pending; source still assigned' : undefined, assignmentChain: state.assignment.chain ?? [], assignedHost: state.assignment.assignedHost, genesis: state.assignment.genesis, executionAuthority: state.assignment.assignedHost === setup.host, phase: state.phase, selectedNext: state.selected, actualRun: state.actual, @@ -153,7 +153,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri if (state.phase === 'transitioning' && state.actual) acp?.cancel(); } if (first && state.move && m.type === 'save' && m.revision === state.revision && state.assignment.assignedHost === setup.host && !Object.hasOwn(state.operations, m.id)) { - try { const v = configure(state.configurations, state.selected, m.body, state.revision + 1).selected; resolveBinding(v); if ((v.profile === 'default' || hash(profiles.resolve(v.profile)) === hash(v.behavior))) retracted.set(m.id, m.revision); } catch { /* Invalid Save cannot cancel. */ } + try { const v = configure(state.configurations, state.selected, m.body, state.revision + 1).selected; validateSaveBinding(v, m.body); if ((v.profile === 'default' || hash(profiles.resolve(v.profile)) === hash(v.behavior))) retracted.set(m.id, m.revision); } catch { /* Invalid Save cannot cancel. */ } } queue = queue.then(() => handle(m)).catch(() => { state.phase = 'quarantined'; save(); }); } @@ -179,9 +179,14 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri if (!setupModels(value).includes(selected.model) || !(value.allowedWorkspaces ?? [value.workspace]).includes(selected.workspace)) throw Error('Unsupported selection'); return { setup: value, id }; } + function validateSaveBinding(selected: Selection, body: Record) { + const { id } = resolveBinding(selected); + if (!['rename', 'remove'].includes(String(body.configurationAction)) && retiredBindings[id]) throw Error('Binding retired; explicitly select an available binding'); + } function prepareLocal(selected: Selection) { selected = selection(selected); const { setup, id } = resolveBinding(selected); + if (retiredBindings[id]) throw Error('Binding retired; Start/Restart unavailable'); if (!setupModels(setup).includes(selected.model) || !(setup.allowedWorkspaces ?? [setup.workspace]).includes(selected.workspace)) throw Error('Unsupported destination selection'); // The optional execution credential is absent for a public-only slot: reject before // any spawn or source effect. The secret is never regenerated or inferred. @@ -403,7 +408,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri const candidate = configure(state.configurations, state.selected, m.body, state.revision + 1); const selected = candidate.selected; if (selected.behavior && hash(profiles.resolve(selected.profile)) !== hash(selected.behavior)) throw Error('Profile revision conflict'); - resolveBinding(selected); + validateSaveBinding(selected, m.body); if (state.move) finishMove('Move cancelled before grant by save'); state.configurations = candidate.entries; state.selected = selected; result = 'saved; running configuration unchanged'; } else if (m.type === 'start' || m.type === 'restart') { @@ -541,8 +546,9 @@ export async function host(directory: string, url: string, signal?: AbortSignal) if (!current) throw Error('Key removed'); const value = current.bindings[id]; if (!value) throw Error('Binding removed'); + if (current.retiredBindings?.[id]) throw Error('Binding retired'); return value; - }, publish, profiles, entry.setupId, entry.bindings)); + }, publish, profiles, entry.setupId, entry.bindings, entry.retiredBindings)); } const setup = entries[0]!.setup; // Every slot is hydrated before dialing. Initial WS history may arrive in the diff --git a/beehive/src/local-setup.ts b/beehive/src/local-setup.ts index a19d186457..b8af269e17 100644 --- a/beehive/src/local-setup.ts +++ b/beehive/src/local-setup.ts @@ -3,7 +3,7 @@ import { claudeGuidance } from './claude.ts'; import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; import { realpathSync } from 'node:fs'; -import { installationSlots, addHarnessBinding, addSlot, importSlotKey } from './slots.ts'; +import { installationSlots, 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,9 +20,9 @@ export async function localSetup(directory: string): Promise { 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)}`); + 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}`); - const action = await ui.question('Local action [reuse / new-agent / restore-key / import-standby / add-binding / add-goose / add-claude / add-codex / normal / cancel]: '); + const action = await ui.question('Local action [reuse / new-agent / restore-key / import-standby / replace-binding / retire-binding / add-binding / add-goose / add-claude / add-codex / normal / cancel]: '); if (action === 'cancel') return; if (action === 'normal') { const agent = await ui.question('Existing agent public key: '); @@ -59,10 +59,24 @@ export async function localSetup(directory: string): Promise { console.log(`Restored local key for ${agent}; no assignment or history reset, no Start permission added.`); return; } - if (!['new-agent', 'add-binding', 'add-goose', 'add-claude', 'add-codex', 'import-standby'].includes(action)) throw Error('Unsupported local action'); + if (!['replace-binding', 'retire-binding', 'new-agent', 'add-binding', 'add-goose', 'add-claude', 'add-codex', 'import-standby'].includes(action)) throw Error('Unsupported local action'); const id = await ui.question('Existing binding ID to reuse: '); if (!Object.hasOwn(first.bindings, id)) throw Error('Unknown local binding'); const setup = first.bindings[id]!, fingerprint = bindingFingerprint(setup); + if (first.retiredBindings?.[id]) throw Error('Binding retired; choose an available source'); + if (action === 'replace-binding' || action === 'retire-binding') { + for (const entry of entries) { + const state = readPrivate(entry.path) as { selected: { harnessSetup?: { id: string } }; configurations?: Record }; + const affected = Object.entries(state.configurations ?? { default: state.selected }).filter(([, selection]) => (selection.harnessSetup?.id ?? entry.setupId) === id).map(([name]) => name); + console.log(`Agent ${entry.agent}: affected choices ${affected.join(', ') || 'none'}; selected ${(state.selected.harnessSetup?.id ?? entry.setupId) === id}. History retained; no automatic selection/Restart.`); + } + if (action === 'retire-binding') { + if (await ui.question(`Retire ${id} for new selection/execution, preserving history? [yes/no]: `) !== 'yes') return; + retireHarnessBinding(directory, { id, fingerprint }); + console.log(`Binding ${id} retired; selected references remain unavailable until explicit remote selection.`); + return; + } + } if (action === 'import-standby') { const genesis = validateGenesis(readPrivate(realpathSync(text(await ui.question('Local public genesis file (no key): '))))); if (genesis.owner !== publicKey(setup.ownerSecret) || genesis.initialHost === setup.host) throw Error('Standby import requires matching owner and another initial host'); @@ -129,9 +143,10 @@ export async function localSetup(directory: string): Promise { const workspace = realpathSync(text(await ui.question('Allowed workspace (absolute directory): '))); const args = setup.mode === 'fixture' ? [realpathSync(text(await ui.question('Absolute fixture TypeScript script: ')))] : setup.args; const { host: _host, ownerSecret: _owner, agentSecret: _agent, ...harness } = setup; - console.log(`Create ${nextId} from ${id}; same ${setup.mode} contract and local service auth context. This does not add a provider or change conversation relay/authority. No authentication tested. Old binding remains immutable; no edit/remove supported.`); + console.log(`Create ${nextId} from ${id}; same ${setup.mode} contract and local service auth context. This does not add a provider or change conversation relay/authority. No authentication tested. Old binding remains immutable; no definition edit/deletion. Replacement retires the source for new execution and selection.`); + if (action === 'replace-binding' && await ui.question(`Retire ${id} and replace with ${nextId}, without selecting it? [yes/no]: `) !== 'yes') return; if (await ui.question('Save NEW binding only (no selection, key change or restart)? [yes/no]: ') !== 'yes') return; - addHarnessBinding(directory, nextId, { ...harness, runner, args, workspace, allowedWorkspaces: [workspace] }, { id, fingerprint }); + addHarnessBinding(directory, nextId, { ...harness, runner, args, workspace, allowedWorkspaces: [workspace] }, { id, fingerprint }, action === 'replace-binding'); console.log(`Binding ${nextId} saved. Reopen local-setup to reuse it for a new identity, or select it in the remote TUI for an existing identity. Save does not Restart.`); } finally { ui.close(); } } diff --git a/beehive/src/slots.ts b/beehive/src/slots.ts index 658844aba2..08043f23c9 100644 --- a/beehive/src/slots.ts +++ b/beehive/src/slots.ts @@ -11,8 +11,8 @@ import { semanticHash } from './handoff.ts'; type Harness = Omit; /** A removed local key copy leaves a public-only slot: `secret: null` retains identity. */ type AgentEntry = { secret: string | null; setup: string; legacy?: true }; -type Installation = { conversation?: ConversationSetup; version: 2; host: string; ownerSecret: string; setups: Record; agents: Record }; -export type SlotEntry = { setup: Setup; path: string; setupId: string; agent: string; keyPresent: boolean; bindings: Record }; +type Installation = { retiredBindings?: Record; conversation?: ConversationSetup; version: 2; host: string; ownerSecret: string; setups: Record; agents: Record }; +export type SlotEntry = { retiredBindings?: Record; setup: Setup; path: string; setupId: string; agent: string; keyPresent: boolean; bindings: Record }; function slotPath(directory: string, key: string, legacy: boolean | undefined) { return legacy ? join(directory, 'journal.json') : join(directory, 'agents', key, 'journal.json'); } @@ -28,6 +28,11 @@ function readInstallation(directory: string): Installation { const harness = object(i.setups[name]); if (Object.hasOwn(harness, 'host') || Object.hasOwn(harness, 'ownerSecret') || Object.hasOwn(harness, 'agentSecret')) throw Error('Shared harness inventory must not copy key material'); } + if (i.retiredBindings !== undefined) { + for (const [id, fingerprint] of Object.entries(object(i.retiredBindings))) { + if (!Object.hasOwn(i.setups, id) || fingerprint !== semanticHash(i.setups[id])) throw Error('Invalid retired binding definition'); + } + } // One pinned installation authority. Legacy installations derive it from default. // Embedded binding snapshots are immutable mode opt-ins, never alternate authority. const common = i.conversation ?? i.setups.default?.conversation; @@ -61,6 +66,7 @@ export function installationSlots(directory: string): SlotEntry[] { const i = readInstallation(directory); return Object.entries(i.agents).map(([key, a]) => ({ setupId: a.setup, + retiredBindings: i.retiredBindings, bindings: Object.fromEntries(Object.entries(i.setups).map(([id, harness]) => [id, validateSetup({ ...harness, host: i.host, ownerSecret: i.ownerSecret, ...(a.secret === null ? {} : { agentSecret: a.secret }) })])), setup: validateSetup({ ...i.setups[a.setup], host: i.host, ownerSecret: i.ownerSecret, ...(a.secret === null ? {} : { agentSecret: a.secret }) }), path: a.legacy ? join(directory, 'journal.json') : join(directory, 'agents', key, 'journal.json'), @@ -93,6 +99,7 @@ export function addSlot(directory: string, secret: string, genesis: Genesis, set const existing = i.agents[key]; if (existing?.secret === null) throw Error('Public-only slot retains this identity; local key repair is explicit local reconciliation, never slot re-creation'); if (Object.keys(i.agents).length >= 32 || existing) throw Error('Slot exists or installation full'); + if (i.retiredBindings?.[setupName]) throw Error('Binding retired'); if (!Object.hasOwn(i.setups, setupName)) throw Error('Unknown host harness setup'); if (expectedFingerprint !== undefined && semanticHash(i.setups[setupName]) !== expectedFingerprint) throw Error('Binding definition changed; reopen local setup'); const setup = validateSetup({ ...i.setups[setupName], host: i.host, ownerSecret: i.ownerSecret, agentSecret: secret }); @@ -159,13 +166,18 @@ export function importSlotKey(directory: string, agentKey: string, secret: strin /** Provision an immutable reusable local binding under the service-start lock. * No identity, journal, selected configuration or running process is changed. */ -export function addHarnessBinding(directory: string, id: string, value: Harness, source?: { id: string; fingerprint: string }): void { +export function addHarnessBinding(directory: string, id: string, value: Harness, source?: { id: string; fingerprint: string }, retireSource = false): 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); 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'); + if (retireSource) { + if (!source) throw Error('Replacement requires source'); + requireStoppedBindings(directory); + } const raw = object(value); if (['host', 'ownerSecret', 'agentSecret'].some(k => Object.hasOwn(raw, k))) throw Error('Binding cannot carry identity'); const setup = validateSetup({ ...raw, host: i.host, ownerSecret: i.ownerSecret }); @@ -173,6 +185,7 @@ export function addHarnessBinding(directory: string, id: string, value: Harness, // Conversation authority is installation-owned, not a binding selector. if (value.conversation && semanticHash(value.conversation) !== semanticHash(i.conversation ?? i.setups.default?.conversation ?? null)) throw Error('Binding cannot change conversation authority'); i.setups[id] = structuredClone(value); + if (retireSource && source) i.retiredBindings = { ...i.retiredBindings, [source.id]: source.fingerprint }; writePrivate(join(directory, 'setup.json'), i); } finally { rmdirSync(lock); } } @@ -191,6 +204,7 @@ export function addConversationBinding(directory: string, agent: string, source: 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 (bindingFingerprint(setup) !== source.fingerprint) throw Error('Binding definition changed; reopen local setup'); @@ -210,3 +224,24 @@ export function addConversationBinding(directory: string, agent: string, source: writePrivate(join(directory, 'setup.json'), i); } finally { rmdirSync(lock); } } + +function requireStoppedBindings(directory: string): void { + for (const entry of installationSlots(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'); + } +} + +/** Retire eligibility only, retaining immutable definitions and every journal byte. + * Rechecks the confirmed reference and stopped authority under the startup lock. */ +export function retireHarnessBinding(directory: string, source: { id: string; fingerprint: string }): void { + const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 }); + try { + const i = readInstallation(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'); + requireStoppedBindings(directory); + i.retiredBindings = { ...i.retiredBindings, [source.id]: source.fingerprint }; + writePrivate(join(directory, 'setup.json'), i); + } finally { rmdirSync(lock); } +}