mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
Add immutable local binding replacement and retirement eligibility
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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<string, unknown>;
|
||||
body = { ...next, harnessSetup: { id: binding.id, fingerprint: binding.fingerprint }, model: binding.models[0], workspace: binding.workspaces[0] };
|
||||
|
||||
@@ -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<string, Setup>) {
|
||||
function slot(setup: Setup, path: string, agent: string, currentSetup: (id: string) => Setup, publish: (m: Message) => void, profiles: Profiles, setupId: string, bindings: Record<string, Setup>, retiredBindings: Record<string, string> = {}) {
|
||||
// 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<string, unknown>) {
|
||||
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
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string, { harnessSetup?: { id: string } }> };
|
||||
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<void> {
|
||||
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(); }
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import { semanticHash } from './handoff.ts';
|
||||
type Harness = Omit<Setup, 'host' | 'ownerSecret' | 'agentSecret'>;
|
||||
/** 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<string, Harness>; agents: Record<string, AgentEntry> };
|
||||
export type SlotEntry = { setup: Setup; path: string; setupId: string; agent: string; keyPresent: boolean; bindings: Record<string, Setup> };
|
||||
type Installation = { retiredBindings?: Record<string, string>; conversation?: ConversationSetup; version: 2; host: string; ownerSecret: string; setups: Record<string, Harness>; agents: Record<string, AgentEntry> };
|
||||
export type SlotEntry = { retiredBindings?: Record<string, string>; setup: Setup; path: string; setupId: string; agent: string; keyPresent: boolean; bindings: Record<string, Setup> };
|
||||
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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user