mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
beehive: integrate owner-public immutable bindings and local wizard
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -16,8 +16,15 @@ export type ConversationPlan = ReturnType<typeof prepareConversation>;
|
||||
* A NIP-OA tag is an opaque host-local credential; only upstream verifies it.
|
||||
*/
|
||||
export function prepareConversation(input: ConversationSetup, agent: AgentLaunch, agentSecret: string, owner: string) {
|
||||
const binding = prepareConversationBinding(input, agent, publicKey(agentSecret), owner);
|
||||
return Object.freeze({ ...binding, env: Object.freeze<Record<string, string>>({ ...binding.env, BUZZ_PRIVATE_KEY: agentSecret }) });
|
||||
}
|
||||
|
||||
/** Validate local runtime/provider binding without reading an identity credential.
|
||||
* This public plan cannot launch: prepareConversation supplies the execution key. */
|
||||
export function prepareConversationBinding(input: ConversationSetup, agent: AgentLaunch, agentPublicKey: string, owner: string) {
|
||||
const prepared = prepareAgent(agent);
|
||||
const agentPublicKey = publicKey(agentSecret);
|
||||
if (!/^[a-f0-9]{64}$/.test(agentPublicKey)) throw Error('Invalid conversation agent public key');
|
||||
if (!/^[a-f0-9]{64}$/.test(owner)) throw Error('Invalid conversation owner public key');
|
||||
if (!isAbsolute(input.executable) || realpathSync(input.executable) !== input.executable) throw Error('Conversation executable must be a canonical absolute path');
|
||||
accessSync(input.executable, constants.X_OK);
|
||||
@@ -39,7 +46,6 @@ export function prepareConversation(input: ConversationSetup, agent: AgentLaunch
|
||||
agentExecutableHash: prepared.executableHash,
|
||||
// Authoritative identity/transport applied after provider env; no arbitrary env input.
|
||||
env: Object.freeze<Record<string, string>>({ ...prepared.env,
|
||||
BUZZ_PRIVATE_KEY: agentSecret,
|
||||
BUZZ_RELAY_URL: url.href,
|
||||
BUZZ_ACP_AGENT_OWNER: owner,
|
||||
...(input.authTag === undefined ? {} : { BUZZ_AUTH_TAG: input.authTag }),
|
||||
|
||||
@@ -6,14 +6,16 @@ import { object, publicKey, text } from './protocol.ts';
|
||||
import { semanticHash } from './handoff.ts';
|
||||
import { readPrivate, writePrivate } from './storage.ts';
|
||||
import { credentialReference, createCredential, readCredential, type CredentialBackend, type CredentialReference, systemCredentials } from './credential-store.ts';
|
||||
import type { ConversationSetup } from './conversation.ts';
|
||||
import type { SlotEntry } from './slots.ts';
|
||||
|
||||
type Harness = Omit<Setup, 'host' | 'ownerSecret' | 'ownerPublic' | 'agentSecret'>;
|
||||
type Manifest = { version: 3; host: string; ownerPublic: string; setups: Record<string, Harness>; agents: Record<string, { key: CredentialReference; setup: string }> };
|
||||
type Manifest = { retiredBindings?: Record<string, string>; conversation?: ConversationSetup; version: 3; host: string; ownerPublic: string; setups: Record<string, Harness>; agents: Record<string, { key: CredentialReference; setup: string }> };
|
||||
const manifestPath = (directory: string) => join(directory, 'setup.json');
|
||||
const journalPath = (directory: string, agent: string) => join(directory, 'agents', agent, 'journal.json');
|
||||
|
||||
function readManifest(directory: string): Manifest {
|
||||
/** Validate only public installation metadata and retained journals, never credentials. */
|
||||
export function readCredentialManifest(directory: string): Manifest {
|
||||
const raw = object(readPrivate(manifestPath(directory)));
|
||||
if (raw.version !== 3 || Object.hasOwn(raw, 'ownerSecret')) throw Error('Public credential installation required; no automatic plaintext migration');
|
||||
const manifest = raw as Manifest;
|
||||
@@ -25,6 +27,15 @@ function readManifest(directory: string): Manifest {
|
||||
if (['host', 'ownerPublic', 'ownerSecret', 'agentSecret'].some(key => Object.hasOwn(value, key))) throw Error('Binding cannot carry identity');
|
||||
validateSetup({ ...value, host: manifest.host, ownerPublic: manifest.ownerPublic });
|
||||
}
|
||||
if (manifest.retiredBindings !== undefined) {
|
||||
for (const [id, fingerprint] of Object.entries(object(manifest.retiredBindings))) {
|
||||
if (!Object.hasOwn(manifest.setups, id) || fingerprint !== semanticHash(manifest.setups[id])) throw Error('Invalid retired binding definition');
|
||||
}
|
||||
}
|
||||
const common = manifest.conversation ?? manifest.setups.default?.conversation;
|
||||
for (const harness of Object.values(manifest.setups)) {
|
||||
if (harness.conversation && semanticHash(harness.conversation) !== semanticHash(common ?? null)) throw Error('Binding cannot change conversation authority');
|
||||
}
|
||||
for (const [agent, value] of agents) {
|
||||
const entry = object(value);
|
||||
const ref = object(entry.key);
|
||||
@@ -40,12 +51,12 @@ function readManifest(directory: string): Manifest {
|
||||
/** Read OS entries anew on every load. Missing means public-only, never regeneration;
|
||||
* locked/denied/unavailable errors propagate, rather than masquerading as absence. */
|
||||
export function credentialSlots(directory: string, backend: CredentialBackend = systemCredentials): SlotEntry[] {
|
||||
const manifest = readManifest(directory);
|
||||
const manifest = readCredentialManifest(directory);
|
||||
return Object.entries(manifest.agents).map(([agent, entry]) => {
|
||||
const present = backend.read(entry.key) !== null;
|
||||
const secret = present ? readCredential(entry.key, backend) : undefined;
|
||||
const bindings = Object.fromEntries(Object.entries(manifest.setups).map(([id, harness]) => [id, validateSetup({ ...harness, host: manifest.host, ownerPublic: manifest.ownerPublic, ...(secret ? { agentSecret: secret } : {}) })]));
|
||||
return { agent, path: journalPath(directory, agent), setupId: entry.setup, setup: bindings[entry.setup]!, bindings, keyPresent: present };
|
||||
return { retiredBindings: manifest.retiredBindings, agent, path: journalPath(directory, agent), setupId: entry.setup, setup: bindings[entry.setup]!, bindings, keyPresent: present };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,14 +64,14 @@ export function credentialSlots(directory: string, backend: CredentialBackend =
|
||||
* no secret result can hydrate a changed manifest or bypass a removed key. */
|
||||
export async function credentialSlotsAsync(directory: string, backend: CredentialBackend, signal?: AbortSignal): Promise<SlotEntry[]> {
|
||||
signal?.throwIfAborted();
|
||||
const manifest = readManifest(directory);
|
||||
const manifest = readCredentialManifest(directory);
|
||||
const snapshot = JSON.stringify(manifest);
|
||||
const secrets = new Map<string, string | null>();
|
||||
for (const entry of Object.values(manifest.agents)) {
|
||||
signal?.throwIfAborted();
|
||||
const secret = backend.readAsync ? await backend.readAsync(entry.key, signal) : backend.read(entry.key);
|
||||
signal?.throwIfAborted();
|
||||
if (JSON.stringify(readManifest(directory)) !== snapshot) throw Error('Credential manifest changed during read');
|
||||
if (JSON.stringify(readCredentialManifest(directory)) !== snapshot) throw Error('Credential manifest changed during read');
|
||||
if (secret !== null && publicKey(secret) !== entry.key.publicKey) throw Error('Credential identity mismatch');
|
||||
secrets.set(entry.key.publicKey, secret);
|
||||
}
|
||||
@@ -100,7 +111,7 @@ export function provisionCredentialSlot(directory: string, setup: Setup, secret:
|
||||
* A retry after successful deletion verifies absence; it never recreates a credential. */
|
||||
export function removeCredentialSlotKey(directory: string, agent: string, backend: CredentialBackend = systemCredentials): void {
|
||||
locked(directory, () => {
|
||||
const manifest = readManifest(directory), entry = manifest.agents[agent];
|
||||
const manifest = readCredentialManifest(directory), entry = manifest.agents[agent];
|
||||
if (!entry) throw Error('Unknown retained public slot');
|
||||
const state = loadSlotState({ ...manifest.setups[entry.setup]!, host: manifest.host, ownerPublic: manifest.ownerPublic }, journalPath(directory, agent), agent);
|
||||
if (state.phase !== 'stopped' || state.actual !== null) throw Error('Key removal requires a stopped slot with no actual run');
|
||||
@@ -113,7 +124,7 @@ export function removeCredentialSlotKey(directory: string, agent: string, backen
|
||||
export function importCredentialSlotKey(directory: string, agent: string, secret: string, backend: CredentialBackend = systemCredentials): void {
|
||||
if (publicKey(secret) !== agent) throw Error('Imported key does not match the selected public identity');
|
||||
locked(directory, () => {
|
||||
const manifest = readManifest(directory), entry = manifest.agents[agent];
|
||||
const manifest = readCredentialManifest(directory), entry = manifest.agents[agent];
|
||||
if (!entry) throw Error('Unknown retained public slot');
|
||||
const state = loadSlotState({ ...manifest.setups[entry.setup]!, host: manifest.host, ownerPublic: manifest.ownerPublic }, journalPath(directory, agent), agent);
|
||||
if (state.phase !== 'stopped' || state.actual !== null) throw Error('Key import requires a stopped slot with no actual run');
|
||||
@@ -149,8 +160,9 @@ export function reconcileCredentialProvision(directory: string, setup: Setup, se
|
||||
* prefixes retain an inert journal for explicit reconciliation, never overwrite. */
|
||||
export function addCredentialSlot(directory: string, secret: string, root: Genesis, setupId = 'default', expectedFingerprint: string | undefined, backend: CredentialBackend = systemCredentials): void {
|
||||
locked(directory, () => {
|
||||
const manifest = readManifest(directory), agent = publicKey(secret), genesis = validateGenesis(root);
|
||||
const manifest = readCredentialManifest(directory), agent = publicKey(secret), genesis = validateGenesis(root);
|
||||
if (manifest.agents[agent] || Object.keys(manifest.agents).length >= 32) throw Error('Slot exists or installation full; retained keys require explicit import');
|
||||
if (manifest.retiredBindings?.[setupId]) throw Error('Binding retired');
|
||||
const harness = manifest.setups[setupId];
|
||||
if (!harness) throw Error('Unknown host harness setup');
|
||||
if (expectedFingerprint !== undefined && bindingFingerprint({ ...harness, host: manifest.host, ownerPublic: manifest.ownerPublic }) !== expectedFingerprint) throw Error('Binding definition changed; reopen local setup');
|
||||
@@ -171,7 +183,7 @@ export function addCredentialSlot(directory: string, secret: string, root: Genes
|
||||
* are already-public. Candidate bindings are derived from the journal's retained
|
||||
* selection, never defaulted: a definition change or corrupt state fails closed. */
|
||||
export function orphanCredentialSlots(directory: string): { host: string; ownerPublic: string; orphans: { agent: string; genesis: Genesis; candidates: { setupId: string; fingerprint: string }[] }[] } {
|
||||
const manifest = readManifest(directory);
|
||||
const manifest = readCredentialManifest(directory);
|
||||
const orphans: { agent: string; genesis: Genesis; candidates: { setupId: string; fingerprint: string }[] }[] = [];
|
||||
const agentsDirectory = join(directory, 'agents');
|
||||
if (!existsSync(agentsDirectory)) return { host: manifest.host, ownerPublic: manifest.ownerPublic, orphans };
|
||||
@@ -200,9 +212,10 @@ export function orphanCredentialSlots(directory: string): { host: string; ownerP
|
||||
* enter here; wrong or foreign inputs leave every retained byte inert. */
|
||||
export function reconcileCredentialSlot(directory: string, setupId: string, secret: string, root: Genesis, expectedFingerprint: string | undefined, backend: CredentialBackend = systemCredentials): void {
|
||||
locked(directory, () => {
|
||||
const manifest = readManifest(directory), agent = publicKey(secret), genesis = validateGenesis(root);
|
||||
const manifest = readCredentialManifest(directory), agent = publicKey(secret), genesis = validateGenesis(root);
|
||||
if (manifest.agents[agent]) throw Error('Retained slot exists; use explicit import-agent-key');
|
||||
if (Object.keys(manifest.agents).length >= 32) throw Error('Installation full; retained keys require explicit import');
|
||||
if (manifest.retiredBindings?.[setupId]) throw Error('Binding retired');
|
||||
const harness = manifest.setups[setupId];
|
||||
if (!harness) throw Error('Unknown host harness setup');
|
||||
if (expectedFingerprint !== undefined && bindingFingerprint({ ...harness, host: manifest.host, ownerPublic: manifest.ownerPublic }) !== expectedFingerprint) throw Error('Binding definition changed; reopen local setup');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,8 +6,10 @@ import { codexGuidance } from './codex.ts';
|
||||
import { claudeGuidance } from './claude.ts';
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { stdin, stdout } from 'node:process';
|
||||
import { join } from 'node:path';
|
||||
import { object } from './protocol.ts';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { installationSlots, bindingPreview, retireHarnessBinding, addHarnessBinding, addSlot, importSlotKey } from './slots.ts';
|
||||
import { installationPublicSlots, installationSlots, bindingPreview, retireHarnessBinding, addHarnessBinding, addSlot, importSlotKey } from './slots.ts';
|
||||
import { bindingFingerprint } from './host.ts';
|
||||
import { newKey, publicKey, text } from './protocol.ts';
|
||||
import { readPrivate } from './storage.ts';
|
||||
@@ -20,20 +22,22 @@ import { readAgentSecret } from './key-input.ts';
|
||||
* Binding revisions are new IDs; existing definitions/history are not edited/deleted.
|
||||
*/
|
||||
export async function localSetup(directory: string): Promise<void> {
|
||||
const entries = installationSlots(directory), first = entries[0]!;
|
||||
const publicOnly = object(readPrivate(join(directory, 'setup.json'))).version === 3;
|
||||
const entries = publicOnly ? installationPublicSlots(directory) : installationSlots(directory), first = entries[0]!;
|
||||
const ui = createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
console.log(`Local installation ${first.setup.host}. Stop the host before committing changes. No login or process launch.`);
|
||||
for (const [id, setup] of Object.entries(first.bindings)) console.log(`Binding ${id}: ${setup.mode} | ${bindingFingerprint(setup)} | ${first.retiredBindings?.[id] ? 'retired' : 'available'}`);
|
||||
for (const entry of entries) console.log(`Agent ${entry.agent}: ${entry.keyPresent ? 'reuse existing key' : 'public-only; exact local key restoration required'} | binding ${entry.setupId}`);
|
||||
for (const entry of entries) console.log(`Agent ${entry.agent}: ${publicOnly ? 'credential reference retained; availability checked only on explicit key action or Start' : entry.keyPresent ? 'reuse existing key' : 'public-only; exact local key restoration required'} | binding ${entry.setupId}`);
|
||||
const action = await ui.question('Local action [presets / add-preset / add-custom / reuse / new-agent / restore-key / import-standby / replace-binding / retire-binding / add-binding / add-goose / add-claude / add-codex / add-buzz-provider / normal / cancel]: ');
|
||||
if (action === 'cancel') return;
|
||||
if (action === 'presets') { showPresets(); return; }
|
||||
if (action === 'normal') {
|
||||
const agent = await ui.question('Existing agent public key: ');
|
||||
const entry = entries.find(e => e.agent === agent);
|
||||
if (!entry?.keyPresent) throw Error('Retained local key required; use explicit restore-key');
|
||||
const state = readPrivate(entry.path) as { selected: { harnessSetup?: { id: string } } };
|
||||
if (!entry || (!publicOnly && !entry.keyPresent)) throw Error('Retained local key required; use explicit restore-key');
|
||||
const preview = bindingPreview(directory);
|
||||
const state = preview.entries.find(e => e.agent === agent)!.state as { selected: { harnessSetup?: { id: string } } };
|
||||
const id = state.selected.harnessSetup?.id ?? entry.setupId;
|
||||
const setup = entry.bindings[id];
|
||||
if (!setup || setup.mode === 'fixture' || setup.mode === 'diagnostic-acp') throw Error('Select an ACP binding remotely first; fixture cannot become a conversation harness');
|
||||
@@ -44,7 +48,7 @@ export async function localSetup(directory: string): Promise<void> {
|
||||
if (common) console.log(`Reusing pinned installation conversation runtime/relay/trust; Buzz CLI tool ${common.replyTool ? 'enabled' : 'disabled (legacy authority; not silently widened)'}. No authority change.`);
|
||||
const conversation = common ?? await conversationInput(ui);
|
||||
if (await ui.question('Save NEW normal binding under installation lock; preserve every selection/key/history? [yes/no]: ') !== 'yes') return;
|
||||
addConversationBinding(directory, agent, { id, fingerprint }, nextId, conversation);
|
||||
addConversationBinding(directory, agent, { id, fingerprint, confirmation: preview.confirmation }, nextId, conversation);
|
||||
console.log(`Normal binding ${nextId} saved from selected ${id}. In remote TUI select this exact host/agent, binding ${nextId}, then explicit Start/Restart. Save uses public revision CAS; other agents remain unchanged. Provider sign-in and relay admission remain unverified.`);
|
||||
return;
|
||||
}
|
||||
@@ -53,11 +57,11 @@ export async function localSetup(directory: string): Promise<void> {
|
||||
const entry = entries.find(e => e.agent === agent);
|
||||
if (!entry) throw Error('Unknown retained agent; cannot invent assignment');
|
||||
if (action === 'reuse') {
|
||||
if (!entry.keyPresent) throw Error('Local key missing; use restore-key explicitly');
|
||||
if (!publicOnly && !entry.keyPresent) throw Error('Local key missing; use restore-key explicitly');
|
||||
console.log(`Reuse ${agent}; no local mutation. In remote TUI select this host/agent, choose binding/configuration, Save then explicitly Restart. Assignment still gates Start.`);
|
||||
return;
|
||||
}
|
||||
if (entry.keyPresent) throw Error('Local key already present; choose reuse');
|
||||
if (!publicOnly && entry.keyPresent) throw Error('Local key already present; choose reuse');
|
||||
if (await ui.question('Restore exact retained key only, preserving assignment/history? [yes/no]: ') !== 'yes') return;
|
||||
ui.close(); // Secret reader owns terminal echo; never overlap readline owners.
|
||||
importSlotKey(directory, agent, await readAgentSecret());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { credentialSlots, credentialSlotsAsync, addCredentialSlot, removeCredentialSlotKey, importCredentialSlotKey, reconcileCredentialSlot } from './credential-slots.ts';
|
||||
import { readCredentialManifest, credentialSlots, credentialSlotsAsync, addCredentialSlot, removeCredentialSlotKey, importCredentialSlotKey, reconcileCredentialSlot } from './credential-slots.ts';
|
||||
import { systemCredentials, type CredentialBackend } from './credential-store.ts';
|
||||
import { setupOwner } from './host.ts';
|
||||
import { prepareAgent } from './acp.ts';
|
||||
@@ -8,7 +8,7 @@ import { validateSetup, initialState, loadSlotState, setupModels, bindingFingerp
|
||||
import { readPrivate, writePrivate } from './storage.ts';
|
||||
import { object, publicKey, text } from './protocol.ts';
|
||||
import { validateGenesis, type Genesis } from './assignment.ts';
|
||||
import { prepareConversation, type ConversationSetup } from './conversation.ts';
|
||||
import { prepareConversationBinding, prepareConversation, type ConversationSetup } from './conversation.ts';
|
||||
import { semanticHash } from './handoff.ts';
|
||||
|
||||
type Harness = Omit<Setup, 'host' | 'ownerSecret' | 'ownerPublic' | 'agentSecret'>;
|
||||
@@ -58,6 +58,16 @@ function readInstallation(directory: string): Installation {
|
||||
}
|
||||
return i;
|
||||
}
|
||||
/** Public v3 inventory deliberately does not establish local key availability. */
|
||||
export function installationPublicSlots(directory: string): SlotEntry[] {
|
||||
return installationSlots(directory, { read: () => null, create() { throw Error('Read only'); }, remove() { throw Error('Read only'); } });
|
||||
}
|
||||
function readBindingInstallation(directory: string) {
|
||||
return object(readPrivate(join(directory, 'setup.json'))).version === 3 ? readCredentialManifest(directory) : readInstallation(directory);
|
||||
}
|
||||
function installationOwner(i: ReturnType<typeof readBindingInstallation>) {
|
||||
return i.version === 3 ? { ownerPublic: i.ownerPublic } : { ownerSecret: i.ownerSecret };
|
||||
}
|
||||
/** Live-host loading never performs synchronous native credential reads. */
|
||||
export async function installationSlotsAsync(directory: string, backend: CredentialBackend, signal?: AbortSignal): Promise<SlotEntry[]> {
|
||||
signal?.throwIfAborted();
|
||||
@@ -193,7 +203,7 @@ export function addHarnessBinding(directory: string, id: string, value: Harness,
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(id) || ['constructor', 'prototype', '__proto__'].includes(id)) throw Error('Invalid binding ID');
|
||||
const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 });
|
||||
try {
|
||||
const i = readInstallation(directory);
|
||||
const i = readBindingInstallation(directory);
|
||||
if (Object.hasOwn(i.setups, id) || Object.keys(i.setups).length >= 32) throw Error('Binding exists or inventory full');
|
||||
if (source && (!Object.hasOwn(i.setups, source.id) || semanticHash(i.setups[source.id]) !== source.fingerprint)) throw Error('Binding definition changed; reopen local setup');
|
||||
if (source && i.retiredBindings?.[source.id]) throw Error('Binding retired');
|
||||
@@ -204,7 +214,7 @@ export function addHarnessBinding(directory: string, id: string, value: Harness,
|
||||
}
|
||||
const raw = object(value);
|
||||
if (['host', 'ownerSecret', 'ownerPublic', 'agentSecret'].some(k => Object.hasOwn(raw, k))) throw Error('Binding cannot carry identity');
|
||||
const setup = validateSetup({ ...raw, host: i.host, ownerSecret: i.ownerSecret });
|
||||
const setup = validateSetup({ ...raw, host: i.host, ...installationOwner(i) });
|
||||
if (setup.custom?.contract === 'goose-native') prepareAgent({ executable: setup.runner, args: setup.args, workspace: setup.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), databricksHost: '', harness: 'goose', provider: setup.gooseProvider, custom: setup.custom, model: setupModels(setup)[0]! });
|
||||
if (setup.mode === 'codex') prepareAgent({ executable: setup.runner, args: setup.args, workspace: setup.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), databricksHost: '', harness: 'codex', codex: setup.codex, model: setupModels(setup)[0]! });
|
||||
// Conversation authority is installation-owned, not a binding selector.
|
||||
@@ -220,28 +230,30 @@ export function addHarnessBinding(directory: string, id: string, value: Harness,
|
||||
* Old diagnostic references retain their exact meaning. Common authority is pinned
|
||||
* once; this action cannot retarget another normal binding's runtime/tool/trust.
|
||||
*/
|
||||
export function addConversationBinding(directory: string, agent: string, source: { id: string; fingerprint: string }, id: string, conversation: ConversationSetup): void {
|
||||
export function addConversationBinding(directory: string, agent: string, source: { id: string; fingerprint: string; confirmation?: string }, id: string, conversation: ConversationSetup): void {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(id) || ['constructor', 'prototype', '__proto__'].includes(id)) throw Error('Invalid binding ID');
|
||||
const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 });
|
||||
try {
|
||||
const i = readInstallation(directory), entries = installationSlots(directory);
|
||||
const i = readBindingInstallation(directory), entries = i.version === 3 ? installationPublicSlots(directory) : installationSlots(directory);
|
||||
if (source.confirmation !== undefined && source.confirmation !== bindingConfirmation(directory)) throw Error('Affected choices changed; reopen local setup');
|
||||
for (const entry of entries) {
|
||||
const state = loadSlotState(entry.setup, entry.path, entry.agent);
|
||||
if (state.phase !== 'stopped' || state.actual !== null) throw Error('Conversation conversion requires every slot stopped');
|
||||
}
|
||||
if (i.retiredBindings?.[source.id]) throw Error('Binding retired');
|
||||
const entry = entries.find(e => e.agent === agent), setup = entry?.bindings[source.id];
|
||||
if (!entry?.keyPresent || !setup?.agentSecret) throw Error('Retained agent key required; no key recreation');
|
||||
if (!entry || !setup || (i.version !== 3 && (!entry.keyPresent || !setup.agentSecret))) throw Error('Retained agent key required; no key recreation');
|
||||
if (bindingFingerprint(setup) !== source.fingerprint) throw Error('Binding definition changed; reopen local setup');
|
||||
if (setup.mode === 'fixture' || setup.mode === 'diagnostic-acp') throw Error('Choose a compatible ACP contract; fixture/diagnostic-only cannot become normal');
|
||||
if (Object.hasOwn(i.setups, id) || Object.keys(i.setups).length >= 32) throw Error('Binding exists or inventory full');
|
||||
const common = i.conversation ?? i.setups.default?.conversation;
|
||||
if (common && semanticHash(common) !== semanticHash(conversation)) throw Error('Installation conversation authority already pinned; cannot retarget it');
|
||||
prepareConversation(conversation, { executable: setup.runner, args: setup.args, workspace: setup.workspace,
|
||||
const validateConversation = i.version === 3 ? prepareConversationBinding : prepareConversation;
|
||||
validateConversation(conversation, { executable: setup.runner, args: setup.args, workspace: setup.workspace,
|
||||
home: text(setup.serviceHome), configDirectory: text(setup.configDirectory),
|
||||
...(setup.buzzProvider ? { buzzProvider: setup.buzzProvider } : {}), databricksHost: setup.mode === 'buzz-agent-api-key' || setup.mode === 'goose' || setup.mode === 'claude' || setup.mode === 'codex' ? '' : text(setup.databricksHost),
|
||||
...(setup.mode === 'codex' ? { harness: 'codex' as const, codex: setup.codex } : {}), ...(setup.mode === 'claude' ? { harness: 'claude' as const, claude: setup.claude } : {}), ...(setup.mode === 'goose' ? { harness: 'goose' as const, provider: setup.gooseProvider, ...(setup.custom ? { custom: setup.custom } : {}) } : {}), model: setupModels(setup)[0]!
|
||||
}, setup.agentSecret, setupOwner(setup));
|
||||
}, i.version === 3 ? agent : setup.agentSecret!, setupOwner(setup));
|
||||
i.conversation = structuredClone(conversation);
|
||||
i.setups[id] = { ...i.setups[source.id]!, conversation: structuredClone(conversation) };
|
||||
// Single fsynced manifest rename activates authority and new definition together.
|
||||
@@ -251,7 +263,7 @@ export function addConversationBinding(directory: string, agent: string, source:
|
||||
}
|
||||
|
||||
function requireStoppedBindings(directory: string): void {
|
||||
for (const entry of installationSlots(directory)) {
|
||||
for (const entry of installationPublicSlots(directory)) {
|
||||
const state = loadSlotState(entry.setup, entry.path, entry.agent);
|
||||
if (state.phase !== 'stopped' || state.actual !== null) throw Error('Binding retirement requires every slot stopped');
|
||||
}
|
||||
@@ -262,7 +274,7 @@ function requireStoppedBindings(directory: string): void {
|
||||
export function retireHarnessBinding(directory: string, source: { id: string; fingerprint: string; confirmation?: string }): void {
|
||||
const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 });
|
||||
try {
|
||||
const i = readInstallation(directory);
|
||||
const i = readBindingInstallation(directory);
|
||||
if (!Object.hasOwn(i.setups, source.id) || semanticHash(i.setups[source.id]) !== source.fingerprint) throw Error('Binding definition changed; reopen local setup');
|
||||
if (i.retiredBindings?.[source.id]) throw Error('Binding already retired');
|
||||
if (source.confirmation !== undefined && source.confirmation !== bindingConfirmation(directory)) throw Error('Affected choices changed; reopen local setup');
|
||||
@@ -273,7 +285,7 @@ export function retireHarnessBinding(directory: string, source: { id: string; fi
|
||||
}
|
||||
|
||||
function bindingSnapshot(directory: string) {
|
||||
return installationSlots(directory).map(entry => ({ agent: entry.agent, setupId: entry.setupId, state: readPrivate(entry.path) }));
|
||||
return installationPublicSlots(directory).map(entry => ({ agent: entry.agent, setupId: entry.setupId, state: readPrivate(entry.path) }));
|
||||
}
|
||||
|
||||
/** Capture preview and fence together under the mutation lock, never during human input. */
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
136
beehive/test/v3-bindings.test.ts
Normal file
136
beehive/test/v3-bindings.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, rmdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { provisionCredentialSlot } from '../src/credential-slots.ts';
|
||||
import { installationPublicSlots, installationSlots, addHarnessBinding, addSlot, bindingPreview, retireHarnessBinding } from '../src/slots.ts';
|
||||
import { bindingFingerprint, host, validateSetup } from '../src/host.ts';
|
||||
import { createGenesis } from '../src/assignment.ts';
|
||||
import { newKey, publicKey, message, type Message } from '../src/protocol.ts';
|
||||
import { readPrivate, writePrivate } from '../src/storage.ts';
|
||||
import { isolatedFileCredentials } from './isolated-file-credentials.ts';
|
||||
|
||||
async function until(predicate: () => boolean) {
|
||||
for (let i = 0; i < 600; i++) { if (predicate()) return; await delay(20); }
|
||||
throw Error('Missing v3 binding evidence');
|
||||
}
|
||||
|
||||
test('v3 actual local wizard: immutable replacement, neutral normal conversion, retired history and changed-preview fence', { timeout: 60000 }, async () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-binding-')));
|
||||
const directory = join(root, 'host'), file = join(root, 'credentials.json'), credentials = isolatedFileCredentials(file);
|
||||
const owner = newKey(), secret = newKey(), agent = publicKey(secret);
|
||||
const setup = validateSetup({ host: 'v3-local', ownerPublic: publicKey(owner), mode: 'goose', runner: realpathSync(process.execPath), args: ['acp'], workspace: root, serviceHome: root, configDirectory: root, gooseProvider: 'fixture', gooseModels: ['a'] });
|
||||
async function cli(responses: [string, string, (() => void)?][], success = true) {
|
||||
const child = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), 'src/cli.ts', 'local-setup', directory], { env: { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: file }, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let output = '', cursor = 0; child.stdout.on('data', b => output += b); child.stderr.on('data', b => output += b);
|
||||
const exited = new Promise(resolve => child.on('close', resolve));
|
||||
try {
|
||||
for (const [prompt, answer, action] of responses) {
|
||||
await until(() => output.indexOf(prompt, cursor) >= 0 || child.exitCode !== null);
|
||||
assert.ok(output.indexOf(prompt, cursor) >= 0, output); cursor = output.length;
|
||||
action?.(); child.stdin.write(`${answer}\n`);
|
||||
}
|
||||
assert.equal((await exited) === 0, success, output);
|
||||
assert.ok(!output.includes(secret) && !output.includes(owner)); return output;
|
||||
} finally { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); await exited; }
|
||||
}
|
||||
try {
|
||||
provisionCredentialSlot(directory, setup, secret, createGenesis(publicKey(owner), agent, setup.host), credentials);
|
||||
const sibling = newKey(); addSlot(directory, sibling, createGenesis(publicKey(owner), publicKey(sibling), 'other-host'), 'default', undefined, credentials);
|
||||
const journal = join(directory, 'agents', agent, 'journal.json'), siblingJournal = join(directory, 'agents', publicKey(sibling), 'journal.json');
|
||||
const original = readFileSync(journal), siblingBytes = readFileSync(siblingJournal), keys = readFileSync(file);
|
||||
const initialManifest = readFileSync(join(directory, 'setup.json'));
|
||||
const normal = (answer: string): [string, string][] => [['Local action [', 'normal'], ['Existing agent public key:', agent], ['NEW immutable normal binding ID:', 'normal'], ['Absolute installed buzz-acp executable:', setup.runner], ['Buzz CONVERSATION relay URL', 'ws://127.0.0.1:19490'], ['Absolute installed buzz CLI executable:', setup.runner], ['Save NEW normal binding under installation lock', answer]];
|
||||
await cli(normal('no')); assert.deepEqual(readFileSync(join(directory, 'setup.json')), initialManifest);
|
||||
// A malformed synthetic credential file makes ANY read fail. Neutral wizard paths
|
||||
// must still work; the fixture loader prevents accidental native-store access.
|
||||
writeFileSync(file, 'invalid JSON');
|
||||
await cli(normal('yes'));
|
||||
const entries = installationPublicSlots(directory), fingerprint = bindingFingerprint(entries[0]!.bindings.default!);
|
||||
assert.equal(entries[0]!.bindings.default!.conversation, undefined);
|
||||
assert.ok(entries[0]!.bindings.normal!.conversation);
|
||||
await cli([['Local action [', 'replace-binding'], ['Existing binding ID to reuse:', 'default'], ['NEW immutable binding ID:', 'B'], ['Absolute compatible executable:', setup.runner], ['Allowed workspace', root], ['Retire default and replace', 'yes'], ['Save NEW binding only', 'yes']]);
|
||||
const current = installationPublicSlots(directory)[0]!;
|
||||
assert.equal(current.retiredBindings?.default, fingerprint);
|
||||
assert.equal(bindingFingerprint(current.bindings.default!), fingerprint);
|
||||
assert.equal(current.setupId, 'default');
|
||||
assert.deepEqual(readFileSync(journal), original); assert.deepEqual(readFileSync(siblingJournal), siblingBytes);
|
||||
const changed = await cli([['Local action [', 'retire-binding'], ['Existing binding ID to reuse:', 'B'], ['Retire B for new selection', 'yes', () => { const state = readPrivate(journal) as any; state.revision++; writePrivate(journal, state); }]], false);
|
||||
assert.match(changed, /Affected choices changed/);
|
||||
assert.equal(installationPublicSlots(directory)[0]!.retiredBindings?.B, undefined);
|
||||
const preview = bindingPreview(directory);
|
||||
mkdirSync(join(directory, 'host.lock'), { mode: 0o700 });
|
||||
assert.throws(() => retireHarnessBinding(directory, { id: 'B', fingerprint, confirmation: preview.confirmation }), /EEXIST/);
|
||||
rmdirSync(join(directory, 'host.lock'));
|
||||
// Restore only test fixture backend bytes; production never migrates secrets.
|
||||
writePrivate(file, JSON.parse(keys.toString()));
|
||||
assert.throws(() => addSlot(directory, newKey(), createGenesis(publicKey(owner), publicKey(newKey()), setup.host), 'default', undefined, credentials), /retired/);
|
||||
assert.equal(installationSlots(directory, credentials)[0]!.retiredBindings?.default, fingerprint);
|
||||
const manifest = readPrivate(join(directory, 'setup.json')) as any;
|
||||
manifest.retiredBindings.default = '0'.repeat(64); writePrivate(join(directory, 'setup.json'), manifest);
|
||||
assert.throws(() => installationPublicSlots(directory), /Invalid retired/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('v3 host retired binding retains history and neutral Stop; unreadable credential receipt hides local paths', async () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-binding-host-'))), file = join(root, 'credentials.json');
|
||||
const credentials = isolatedFileCredentials(file), owner = newKey(), secret = newKey(), agent = publicKey(secret);
|
||||
const setup = validateSetup({ host: 'v3-host', ownerPublic: publicKey(owner), mode: 'fixture', runner: realpathSync(process.execPath), args: [resolve('test/runner.ts')], workspace: root });
|
||||
let hydration = true, reads = 0, receive!: (m: Message) => void;
|
||||
const seen: Message[] = [];
|
||||
const backend = { ...credentials, read(ref: Parameters<typeof credentials.read>[0]) { reads++; if (!hydration) throw Object.assign(Error(`EACCES: ${root}/setup.json`), { code: 'EACCES' }); return credentials.read(ref); } };
|
||||
let h: Awaited<ReturnType<typeof host>> | undefined;
|
||||
const open = () => host(root, 'ws://fixture.invalid', undefined, { binding: { host: setup.host, owner: publicKey(owner) }, validate() {}, connect(_url, _secret, callback) { receive = callback; return { ready: Promise.resolve(), send(m) { seen.push(m); }, close() {} }; } }, backend);
|
||||
async function send(type: 'start' | 'stop', revision: number) { const m = message(type, setup.host, agent, revision); receive(m); await until(() => seen.some(r => r.body.operation === m.id)); return seen.find(r => r.body.operation === m.id)!.body.result; }
|
||||
try {
|
||||
provisionCredentialSlot(root, setup, secret, createGenesis(publicKey(owner), agent, setup.host), credentials);
|
||||
h = await open(); hydration = false;
|
||||
assert.match(String(await send('start', 0)), /unreadable/); assert.ok(!JSON.stringify(seen.filter(m => m.type === 'receipt')).includes(root));
|
||||
const before = reads; assert.equal(await send('stop', 0), 'accepted'); assert.equal(reads, before);
|
||||
await h.close(); h = undefined;
|
||||
const entry = installationPublicSlots(root)[0]!;
|
||||
retireHarnessBinding(root, { id: 'default', fingerprint: bindingFingerprint(entry.setup), confirmation: bindingPreview(root).confirmation });
|
||||
hydration = true; h = await open(); hydration = false;
|
||||
assert.match(String(await send('start', 1)), /retired/);
|
||||
assert.equal((readPrivate(entry.path) as any).actual, null);
|
||||
} finally { await h?.close(); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('v3 selected-next B does not change actual A until Restart; removed key Save/Stop stay neutral', async () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-binding-run-'))), file = join(root, 'credentials.json');
|
||||
const credentials = isolatedFileCredentials(file), owner = newKey(), secret = newKey(), agent = publicKey(secret);
|
||||
const setup = validateSetup({ host: 'v3-run', ownerPublic: publicKey(owner), mode: 'fixture', runner: realpathSync(process.execPath), args: [resolve('test/runner.ts')], workspace: root });
|
||||
let receive!: (m: Message) => void; const seen: Message[] = [];
|
||||
let h: Awaited<ReturnType<typeof host>> | undefined;
|
||||
try {
|
||||
provisionCredentialSlot(root, setup, secret, createGenesis(publicKey(owner), agent, setup.host), credentials);
|
||||
const { host: _h, ownerPublic: _o, ...harness } = setup;
|
||||
addHarnessBinding(root, 'B', { ...harness, args: [resolve('test/binding-runner.ts')] });
|
||||
const entry = installationPublicSlots(root)[0]!, manifest = readFileSync(join(root, 'setup.json'));
|
||||
let reads = 0; const backend = { ...credentials, read(ref: Parameters<typeof credentials.read>[0]) { reads++; return credentials.read(ref); } };
|
||||
h = await host(root, 'ws://fixture.invalid', undefined, { binding: { host: setup.host, owner: publicKey(owner) }, validate() {}, connect(_url, _secret, callback) { receive = callback; return { ready: Promise.resolve(), send(m) { seen.push(m); }, close() {} }; } }, backend);
|
||||
const state = () => readPrivate(entry.path) as any;
|
||||
async function send(type: 'start' | 'stop' | 'save' | 'restart', body = {}) { const m = message(type, setup.host, agent, state().revision, body); receive(m); await until(() => seen.some(r => r.body.operation === m.id)); return seen.find(r => r.body.operation === m.id)!.body.result; }
|
||||
assert.equal(await send('start'), 'accepted'); const actualA = state().actual;
|
||||
const selection = { ...state().selected, harnessSetup: { id: 'B', fingerprint: bindingFingerprint(entry.bindings.B!) } };
|
||||
const beforeSave = reads;
|
||||
assert.equal(await send('save', selection), 'saved; running configuration unchanged'); assert.equal(reads, beforeSave);
|
||||
assert.deepEqual(state().actual, actualA); assert.equal(state().selected.harnessSetup.id, 'B');
|
||||
assert.equal(await send('restart'), 'accepted');
|
||||
assert.equal(state().actual.selection.harnessSetup.id, 'B'); assert.notEqual(state().actual.run, actualA.run);
|
||||
assert.ok(Object.values(state().runs).some((r: any) => r.run === actualA.run));
|
||||
const actualB = state().actual;
|
||||
const ref = (readPrivate(join(root, 'setup.json')) as any).agents[agent].key;
|
||||
credentials.remove(ref); // fresh synthetic store only; bypass tests pre-spawn revalidation
|
||||
assert.match(String(await send('restart')), /missing or changed/); assert.deepEqual(state().actual, actualB);
|
||||
const beforeNeutral = reads;
|
||||
assert.equal(await send('save', selection), 'saved; running configuration unchanged');
|
||||
assert.equal(await send('stop'), 'accepted'); assert.equal(reads, beforeNeutral);
|
||||
assert.match(String(await send('start')), /missing or changed/);
|
||||
assert.equal(state().actual, null); assert.equal(credentials.read(ref), null);
|
||||
assert.deepEqual(readFileSync(join(root, 'setup.json')), manifest);
|
||||
} finally { await h?.close(); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in New Issue
Block a user