From ac32ae0d248f61aaec1e8730b98544a93ccfb023 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 14:39:55 -0400 Subject: [PATCH] beehive: integrate private public-CLI Start reply Stop with fresh membership Signed-off-by: Logan Johnson --- beehive/FIRST_DEMO.md | 111 +++++++++++++++++++++++++++++++ beehive/src/catalog-transport.ts | 2 +- beehive/src/cli.ts | 31 ++++++--- beehive/src/enrollment-input.ts | 2 +- beehive/src/host-identity.ts | 20 +++++- beehive/src/host-transport.ts | 2 +- beehive/src/relay-admission.ts | 26 ++++++++ beehive/test/catalog-tui.test.ts | 41 ++++++++---- beehive/test/nostr-fixture.ts | 29 ++++++-- 9 files changed, 231 insertions(+), 33 deletions(-) create mode 100644 beehive/FIRST_DEMO.md diff --git a/beehive/FIRST_DEMO.md b/beehive/FIRST_DEMO.md new file mode 100644 index 0000000000..9e60f02c8e --- /dev/null +++ b/beehive/FIRST_DEMO.md @@ -0,0 +1,111 @@ +# First private demo (owner-run, not yet a live demonstration) + +The tested path is **public `host` CLI → private owner `tui` → Start → agent-signed +reply → Stop**. Automated evidence uses a loopback membership-enforced relay model, +a synthetic credential helper, the installed Buzz ACP/CLI and a deterministic +provider harness. It proves neither macOS Keychain readiness nor deployment/provider +compatibility. Do not use frozen or existing installations for this demo. + +## Prerequisites + +- Node 24.15.0 and this package's frozen standalone dependencies. In `beehive`, + invoke `/absolute/path/to/node src/cli.ts ...`; no root bootstrap is needed. +- A deliberately chosen **new** 0700 host directory, a dedicated host service OS + user with an interactive unlocked credential store, and a separate owner context. + `@napi-rs/keyring` 2.0.0 may show OS approval/unlock prompts; it cannot suppress + them. Only `beehive` service entries `host:` and `agent:` + are used. Never approve access to someone else's account/cache. +- Existing owner signer and an explicitly supplied demo agent key/authorization. + Agent enrollment/channel membership on the conversation relay is a separate + prerequisite: host registration is not an agent-owner grant. +- Relay operator has already enrolled the host and owner public keys as ordinary + direct members. This CLI currently supports **already-member verification**; + it does not mint invites or expose invite claims. The relay must advertise its + `self` key and NIP-43, with membership-gated signed `POST /query`, NIP-42 and + NIP-59 giftwraps. Open-relay AUTH/ACK and a saved roster are not admission. +- One installed supported provider configuration. The example below chooses Buzz + Agent / Databricks v2, dedicated service HOME/config, plus installed buzz-acp and + sibling Buzz CLI. Real provider/native protocol compatibility remains unverified. + Do not substitute a different provider until its configuration is intentionally + supplied. No login, inference, invite or production enrollment was run by workers. + +## Manual setup → Start → reply → Stop + +1. **Host, owner present:** `node src/cli.ts setup /new/private-host` + Choose `create`, supply label, owner's **public** hex key and `wss://relay-host`; + choose a new private pairing file. Expect OS key creation/readback approval. + If interrupted, use `export` on the same directory; never regenerate identity. +2. Transfer the pairing file privately to the owner. **Owner context:** run + `node src/cli.ts setup /new/owner-exchange`, choose `approve`, select the pairing, + compare the full fingerprint with the host, confirm `yes`, enter a future Unix + expiration and a new private approval file. Enter the existing owner key only + at the hidden prompt. Transfer the approval file privately back to the host. +3. **Host:** rerun `node src/cli.ts setup /new/private-host`, choose `import`, select + that approval. This registers infrastructure only. Arrange the **already-member** + prerequisite above out of band; there is no administrative operation here. +4. Prepare a private local `binding.json` (absolute paths, no identity keys): + + ```json + { + "mode": "buzz-agent-databricks-v2", + "runner": "/absolute/supported/buzz-agent", + "args": [], + "workspace": "/absolute/demo-workspace", + "serviceHome": "/absolute/dedicated-service-home", + "configDirectory": "/absolute/dedicated-service-config", + "databricksHost": "https://your-workspace.example", + "conversation": { + "executable": "/Applications/Buzz.app/Contents/MacOS/buzz-acp", + "relay": "wss://your-conversation-relay", + "replyTool": { "executable": "/Applications/Buzz.app/Contents/MacOS/buzz" } + } + } + ``` + + Supply the owner's deliberately approved **initial demo placement** public + genesis file: `{ "v":1, "id":"", "owner":"", + "agent":"", "initialHost":"" }`. + This is a locally pinned root, not a signed bearer grant. It must match the + intended agent and host. **Never replace an existing agent's retained genesis + or invent a new root to bypass its assignment**; use a separately authorized + initial demo identity/placement, not migration or Move. + + Run `node src/cli.ts provision-agent /new/private-host /private/binding.json /private/genesis.json`. + Enter the matching agent key at the hidden prompt. Expect OS create/readback + prompts. The owner key is never placed on the host. If interrupted, retain all + artifacts and use `reconcile-provision` with these exact same inputs, not a new key. +5. Run `node src/cli.ts auth-info /new/private-host` for the exact service-user + native login context. Deliberately configure/login the supported provider as + that user only. No borrowed Desktop cache. Presence of files is not login proof. +6. **Owner:** `node src/cli.ts catalog /new/private-catalog.json /private/approval.json`. +7. **Host, owner present:** + `node src/cli.ts host /new/private-host wss://relay-host --owner-present` + The flag consents to live OS reads, not relay admin or provider login. Each key + read uses an owned helper with a 10s deadline; SIGINT/SIGTERM cancels and awaits + its actual close. An OS-owned dialog may remain after helper termination; + inspect/dismiss it deliberately. Denied/timeout is not a missing key. Expect a + fresh host-signed HTTP membership check, then `Host online`. +8. **Owner:** `node src/cli.ts tui /private/private-catalog.json wss://relay-host`. + Enter owner key at the hidden prompt. A separate owner-signed membership check + precedes private transport. Use `hosts`, `select `, `show`, then + `start`. Wait for `completed | accepted` and running actual state; publication + alone does not prove Start. From the owner's usual Buzz client, mention the demo + agent in its authorized conversation channel and observe its signed reply. +9. In the TUI, `stop`; wait for accepted receipt and `show` stopped/no actual run. + `quit` closes only the owner UI. Ctrl-C the foreground host for owned teardown. + Missing provider credentials do not prevent Stop. Preserve journals if teardown + reports uncertainty; do not clear locks or kill unrelated PIDs. + +## Honest first-demo limits + +Membership proof is fresh and single-use for one connection (10s admission window), +not indefinitely cached. After transport loss, no private automatic reconnect: +close/reopen the command to verify membership again. `reconcile` explains that +requirement instead of silently reusing proof. A live socket's revocation visibility +is governed by relay policy; this is not continuous membership monitoring. + +Roster timestamps are not compared with local claim time. A fresh membership-gated +row check can pass with an old or lagging roster. Failed admission does not fall back +to public management, owner OA, another signer, or plaintext storage. Registrations, +catalogs, management inventory and lifecycle commands remain private. Move, profile +distribution and generalized recovery are outside this first demo. diff --git a/beehive/src/catalog-transport.ts b/beehive/src/catalog-transport.ts index 7b99f08083..723e3ea858 100644 --- a/beehive/src/catalog-transport.ts +++ b/beehive/src/catalog-transport.ts @@ -9,10 +9,10 @@ import { open, publicKey, type Envelope, type Message } from './protocol.ts'; export function catalogTransport(catalog: HostCatalog, admission: ScopedRelayAdmission = productionAdmission) { return (url: string, secret: string, receive: (m: Message) => void, recovered?: () => void, disconnected?: (code: number) => void) => { const retained = structuredClone(verifyHostCatalog(catalog, publicKey(secret), url)); - admission.admit({ relay: url, publicKey: retained.owner, transport: 'nip42-nip59', ownerDelegation: false }); let closed = false; let generation = 0; function dial() { + admission.admit({ relay: url, publicKey: retained.owner, transport: 'nip42-nip59', ownerDelegation: false }); const current = ++generation; return connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => { if (closed || current !== generation) return; diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index 638402f4bf..7d8b1c1168 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -1,6 +1,10 @@ +import { credentialHelperReader } from './credential-helper.ts'; +import { systemCredentials } from './credential-store.ts'; +import { privateHostTransport } from './host-transport.ts'; +import { verifyProductionAdmission } from './relay-admission.ts'; import { installationPublicSlots } from './slots.ts'; import { provisionCredentialSlot, reconcileCredentialProvision, orphanCredentialSlots } from './credential-slots.ts'; -import { readHostIdentity } from './host-identity.ts'; +import { readHostIdentity, readHostIdentityAsync } from './host-identity.ts'; import { verifyHostRegistration } from './host-registration.ts'; import { setupOwner } from './host.ts'; import { buzzProviderInput, buzzProviderGuidance, databricksOAuthGuidance } from './buzz-provider.ts'; @@ -32,7 +36,7 @@ import { prepareConversation } from './conversation.ts'; const operationLabel = (m: Message) => `${m.host} ${m.type}${m.type === 'move' ? ` → ${String(m.body.target)}` : ''} | agent ${m.agent} | operation ${m.id}`; const shellQuote = (s: string) => `'${s.replaceAll("'", "'\"'\"'")}'`; const [command, ...args] = process.argv.slice(2); -const help = `Beehive — isolated development preview (loopback relay only) +const help = `Beehive — private host preview (explicit direct relay membership required) identity Disabled: use your existing owner signer setup Offline host pairing/owner approval/import setup LEGACY loopback diagnostic setup (owner key copied) @@ -51,12 +55,12 @@ const help = `Beehive — isolated development preview (loopback relay only) auth-info [binding-id] Print local harness service context (no login) conversation-setup Legacy guidance; use local-setup action normal relay Dedicated ciphertext relay - host Persistent foreground host - tui Relay-connected terminal UI + host --owner-present Private foreground host; bounded OS key reads + tui Private owner UI; hidden existing owner signer setup/add-agent accept optional for standby import. reconcile-agent derives the interrupted binding and genesis from the retained journal; an optional public genesis file must match it. assignment-export accepts agent public key after filename when several slots exist. -Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC or production relay support.`; +Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC. Private host/catalog requires fresh membership-enforced relay verification; no automatic private reconnect.`; async function main() { if (command === 'identity') { throw Error('Standalone Beehive uses your existing owner identity through explicit secure input. Creating a parallel controller identity or persisting a plaintext owner key is disabled.'); @@ -316,7 +320,15 @@ async function main() { const stop = () => controller.abort(); process.on('SIGINT', stop); process.on('SIGTERM', stop); try { - const running = await host(resolve(text(args[0])), text(args[1]), controller.signal); + const directory = resolve(text(args[0])), url = text(args[1]); + const privateInstallation = existsSync(join(directory, 'host-identity.json')); + if (privateInstallation && args[2] !== '--owner-present') throw Error('Private host requires --owner-present: OS credential access may prompt. Run deliberately as the host OS user; Stop cancels and awaits the bounded helper.'); + const credentials = privateInstallation ? { ...systemCredentials, readAsync: credentialHelperReader({ operatorApproved: true }) } : systemCredentials; + const identity = privateInstallation ? await readHostIdentityAsync(directory, credentials, controller.signal) : undefined; + if (identity && identity.pairing.relay !== url) throw Error('Wrong registered host relay; no network request sent'); + const admission = identity ? await verifyProductionAdmission(url, identity.secret, controller.signal) : undefined; + const transport = identity ? privateHostTransport(verifyHostRegistration(identity.registration, identity.pairing), identity.secret, admission) : undefined; + const running = await host(directory, url, controller.signal, transport, credentials); console.log(`Host online; agents ${running.agents.join(', ')}. Ctrl-C stops owned runner before host exits.`); // The host owns teardown both before and after ready. Observe its one // completion promise so uncertain teardown cannot disappear as a rejection. @@ -335,12 +347,13 @@ async function main() { const first = object(object(registrations[0]).request); const catalog = verifyHostCatalog({ version: 1, owner: first.owner, relay: first.relay, registrations }, text(first.owner), text(first.relay)); writePrivate(resolve(text(args[0])), catalog, true); - console.log(`Retained ${catalog.registrations.length} verified host registrations. Public keys only; admission pending. Labels are not authority.`); + console.log(`Retained ${catalog.registrations.length} verified host registrations. Public keys only; fresh direct membership verification required when opening tui. Labels are not authority.`); } else if (command === 'tui') { const identity = object(readPrivate(resolve(text(args[0])))); const catalog: HostCatalog | undefined = 'registrations' in identity ? verifyHostCatalog(identity, text(identity.owner), text(args[1])) : undefined; const secret = catalog ? await readAgentSecret('Owner') : text(identity.secret); if (catalog && publicKey(secret) !== catalog.owner) throw Error('Wrong catalog owner signer'); + const admission = catalog ? await verifyProductionAdmission(text(args[1]), secret) : undefined; const inventory = new Map(); const profiles = new Profiles(); const client = managementClient(join(dirname(resolve(text(args[0]))), 'management-intents'),text(args[1]),secret,m => { @@ -354,7 +367,7 @@ async function main() { }, () => { console.log(client.connected ? '\nManagement relay connected.' : '\nRelay disconnected: pending results UNKNOWN; automatic reconnect is bounded (disabled on policy refusal). Use reconcile after checking relay policy.'); for (const operation of client.status()) console.log(`${operationLabel(operation.request)}: ${operation.state} | ${operation.result ?? operation.publication}`); - }, catalog ? { catalog } : undefined); + }, catalog ? { catalog, admission } : undefined); try { await client.ready; } catch (error) { client.close(); throw error; } const ui = createInterface({ input: stdin, output: stdout }); console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: binding , configurations, config-new, config-select , config-rename, config-remove , profiles, profile-new, profile-edit , apply , operations, reconcile, retry , hosts, agents, select , show, save, start, restart, stop, move, quit. Closing this UI does not stop hosts.'); @@ -368,7 +381,7 @@ async function main() { client.status().forEach((o, index) => console.log(`${index + 1}. ${operationLabel(o.request)} at revision ${o.request.revision}: ${o.state} | ${o.result ?? o.publication}${o.retryAvailable ? ` | reconcile, then retry ${index + 1}` : ''}`)); console.log('Historical results are not current host state. Reconcile queries receipts without retrying blocked work.'); continue; } - if (line === 'reconcile') { client.reconcile(); console.log('Reconnecting/querying host results; blocked work stays blocked. Connection alone proves neither policy repair nor completion.'); continue; } + if (line === 'reconcile') { try { client.reconcile(); console.log('Reconnecting/querying host results; blocked work stays blocked. Connection alone proves neither policy repair nor completion.'); } catch (error) { console.log(error instanceof Error ? error.message : 'Reconnect refused'); } continue; } if (line.startsWith('retry ')) { const number = line.slice(6); const operation = /^[1-9][0-9]*$/.test(number) ? client.status()[Number(number) - 1] : undefined; diff --git a/beehive/src/enrollment-input.ts b/beehive/src/enrollment-input.ts index c113af8196..7ca725b2f8 100644 --- a/beehive/src/enrollment-input.ts +++ b/beehive/src/enrollment-input.ts @@ -33,7 +33,7 @@ export async function enrollmentInput(directory: string): Promise { console.log('Registration signed. Owner secret not persisted. Transfer approval privately back to host. Relay admission remains pending.'); } else if (action === 'import') { enrollHostIdentity(directory, readPrivate(resolve(text(await ui.question('Private approval file: '))))); - console.log('Host registered. Pending/no network: narrow relay admission is not implemented. No agent authorized or started.'); + console.log('Host registered offline. Host startup requires an already-enrolled direct relay member and a fresh live row check. No agent authorized or started.'); } else throw Error('Unknown enrollment action'); } finally { ui.close(); } } diff --git a/beehive/src/host-identity.ts b/beehive/src/host-identity.ts index c3828a31ff..467682d6a5 100644 --- a/beehive/src/host-identity.ts +++ b/beehive/src/host-identity.ts @@ -34,8 +34,24 @@ export function readHostIdentity(directory: string, credentials: CredentialBacke const pairing = hostPairing(value.pairing); const key = credentialReference('host', pairing.host); if (JSON.stringify(value.key) !== JSON.stringify(key)) throw Error('Invalid host credential reference'); - const secret = readCredential(key, credentials); - return { version: 3, pairing, secret, registration: value.registration as HostRegistration | null }; + return { version: 3, pairing, secret: readCredential(key, credentials), registration: value.registration as HostRegistration | null }; +} +/** Live startup uses the owner-approved bounded helper, never a synchronous OS call. */ +export async function readHostIdentityAsync(directory: string, credentials: CredentialBackend, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const value = object(readPrivate(join(directory, 'host-identity.json'))); + fields(value, ['version', 'pairing', 'key', 'registration']); + if (value.version !== 3) throw Error('Legacy identity retained; explicit consent required for migration'); + const pairing = hostPairing(value.pairing); + const key = credentialReference('host', pairing.host); + if (JSON.stringify(value.key) !== JSON.stringify(key)) throw Error('Invalid host credential reference'); + verifyHostRegistration(value.registration, pairing); + if (!credentials.readAsync) throw Error('Bounded asynchronous host credential reader required'); + const secret = await credentials.readAsync(key, signal); + signal?.throwIfAborted(); + if (secret === null) throw Error('Local host key missing; explicit matching import required'); + if (publicKey(secret) !== pairing.host) throw Error('Credential identity mismatch'); + return { version: 3, pairing, secret, registration: value.registration as HostRegistration }; } /** Atomically import only an owner registration matching the exact retained request. */ export function enrollHostIdentity(directory: string, registration: unknown, now = Math.floor(Date.now() / 1000), credentials: CredentialBackend = systemCredentials): void { diff --git a/beehive/src/host-transport.ts b/beehive/src/host-transport.ts index 6e4e6ab365..1f82ce9b8f 100644 --- a/beehive/src/host-transport.ts +++ b/beehive/src/host-transport.ts @@ -14,10 +14,10 @@ export function privateHostTransport(registration: HostRegistration, secret: str validate(url: string) { verifyHostRegistration(retained, retained.request); if (url !== retained.request.relay) throw Error('Wrong host relay'); - admission.admit({ relay: url, publicKey: retained.request.host, transport: 'nip42-nip59', ownerDelegation: false }); }, connect(url: string, _legacyOwnerSecret: string, receive: (m: Message) => void, _recovered?: () => void) { this.validate(url); + admission.admit({ relay: url, publicKey: retained.request.host, transport: 'nip42-nip59', ownerDelegation: false }); const wire = connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => { try { verifyHostRegistration(retained, retained.request); } catch { return; } const m = input.message; diff --git a/beehive/src/relay-admission.ts b/beehive/src/relay-admission.ts index 923f5dab3b..3a904eb115 100644 --- a/beehive/src/relay-admission.ts +++ b/beehive/src/relay-admission.ts @@ -1,3 +1,6 @@ +import { finalizeEvent } from 'nostr-tools/pure'; +import { publicKey } from './protocol.ts'; +import { verifyDirectMembershipEvidence, type HostAuthorizationSigner } from './direct-membership.ts'; /** Admission proof supplied by a reviewed integration, never loaded from user JSON. * Owner-approved route: ordinary direct relay membership, without NIP-OA. * Registration alone or successful open-relay AUTH is not membership evidence. @@ -9,3 +12,26 @@ export type ScopedRelayAdmission = { export const productionAdmission: ScopedRelayAdmission = { admit() { throw Error('Host relay admission pending: verified ordinary direct membership required (relay admin: buzz-admin add-member --pubkey --role member); NIP-OA prohibited'); }, }; + +/** Infrastructure or owner signs only its own fresh HTTP authentication; no OA. */ +export function membershipSigner(secret: string): HostAuthorizationSigner { + return { hostPublicKey: publicKey(secret), async signHttpAuthentication(request) { + return JSON.stringify(finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', + tags: [['u', request.url], ['method', request.method], ['payload', request.payloadSha256Hex]] }, Buffer.from(secret, 'hex'))); + } }; +} +/** One fresh live row check authorizes ONE immediate connection. Reopen the command + * for another check after loss; a saved claim/roster/ACK is never an admission token. */ +export async function verifyProductionAdmission(relay: string, secret: string, signal?: AbortSignal): Promise { + const signer = membershipSigner(secret); + const evidence = await verifyDirectMembershipEvidence({ relay, signer, signal }); + signal?.throwIfAborted(); + if (evidence.status !== 'live-verified-member') throw Error(`Direct membership not verified (${evidence.status}); require membership-enforced relay and a fresh live row check`); + const deadline = Date.now() + 10_000; + let consumed = false; + return { admit(scope) { + if (consumed || Date.now() > deadline) throw Error('Fresh membership required; close and manually reopen command'); + if (scope.relay !== relay || scope.publicKey !== signer.hostPublicKey || scope.transport !== 'nip42-nip59' || scope.ownerDelegation !== false) throw Error('Wrong direct membership scope'); + consumed = true; + } }; +} diff --git a/beehive/test/catalog-tui.test.ts b/beehive/test/catalog-tui.test.ts index 27b6117636..f758ec247c 100644 --- a/beehive/test/catalog-tui.test.ts +++ b/beehive/test/catalog-tui.test.ts @@ -1,3 +1,5 @@ +import { isolatedFileCredentials } from './isolated-file-credentials.ts'; +import { credentialReference } from '../src/credential-store.ts'; import { importSlotKey, installationSlots } from '../src/slots.ts'; import { conversationRelayFixture } from './conversation-relay-fixture.ts'; import test from 'node:test'; @@ -10,15 +12,12 @@ import { setTimeout as delay } from 'node:timers/promises'; import { newKey, publicKey, message } from '../src/protocol.ts'; import { hostPairing, registerHost } from '../src/host-registration.ts'; import { verifyHostCatalog, catalogResponse, HostInventory } from '../src/host-catalog.ts'; -import { privateHostTransport } from '../src/host-transport.ts'; -import { host } from '../src/host.ts'; import { writePrivate } from '../src/storage.ts'; import { provisionCredentialSlot } from '../src/credential-slots.ts'; import { createGenesis } from '../src/assignment.ts'; -import { memoryCredentials } from './credential-fixture.ts'; import { nostrFixture } from './nostr-fixture.ts'; import { connectNostr } from '../src/nostr-client.ts'; -import { productionAdmission, type ScopedRelayAdmission } from '../src/relay-admission.ts'; +import { productionAdmission } from '../src/relay-admission.ts'; function registration(owner: string, key: string, relay: string, label = 'same label') { return registerHost(hostPairing({ version: 1, purpose: 'beehive-host-registration', host: publicKey(key), owner: publicKey(owner), label, relay, nonce: newKey() }), owner, Math.floor(Date.now() / 1000) + 600); @@ -56,9 +55,11 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran const relay = await nostrFixture(publicKey(owner), members); const one = registration(owner, a, relay.url), two = registration(owner, b, relay.url); const catalog = verifyHostCatalog({ version: 1, owner: publicKey(owner), relay: relay.url, registrations: [one, two] }, publicKey(owner), relay.url); - const admission: ScopedRelayAdmission = { admit(scope) { assert.equal(scope.relay, relay.url); assert.equal(scope.ownerDelegation, false); assert.ok(members.has(scope.publicKey)); } }; const directory = join(root, 'host'); mkdirSync(directory); - const credentials = memoryCredentials(); + const credentialFile = join(root, 'credentials.json'); + const credentials = isolatedFileCredentials(credentialFile); + credentials.create(credentialReference('host', publicKey(a)), a); + writePrivate(join(directory, 'host-identity.json'), { version: 3, pairing: one.request, key: credentialReference('host', publicKey(a)), registration: one }); 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', ...(!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'); @@ -80,16 +81,22 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran } 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); + const helper = join(root, 'credential-child.mjs'); + writeFileSync(helper, `import { readFileSync } from 'node:fs'; let input=''; for await (const b of process.stdin) input+=b; const secret=JSON.parse(readFileSync(${JSON.stringify(credentialFile)},'utf8'))[JSON.stringify(JSON.parse(input))]; process.stdout.write(JSON.stringify(secret ? {status:'present',secret} : {status:'missing'}));`); + const loader = join(root, 'credential-helper-loader.mjs'); + writeFileSync(loader, `import { registerHooks } from 'node:module'; registerHooks({load(url,ctx,next){const r=next(url,ctx); if(!url.endsWith('/src/credential-helper.ts'))return r;return {...r,source:String(r.source).replace("new URL('./credential-helper-child.ts', import.meta.url)", ${JSON.stringify(`new URL(${JSON.stringify('file://' + helper)})`)})};}});`); + const hostChild = spawn(process.execPath, ['--import', loader, 'src/cli.ts', 'host', directory, relay.url, '--owner-present'], { env: { PATH: '/usr/bin:/bin', HOME: root }, stdio: ['ignore','pipe','pipe'] }); + let hostOutput = ''; hostChild.stdout.on('data', b => hostOutput += b); hostChild.stderr.on('data', b => hostOutput += b); + const hostExit = new Promise(resolve => hostChild.on('close', resolve)); + const running = { async close() { if (hostChild.exitCode === null && hostChild.signalCode === null) hostChild.kill('SIGTERM'); assert.equal(await hostExit, 0, hostOutput); } }; + for (let n=0; n<400 && !hostOutput.includes('Host online'); n++) { if (hostChild.exitCode !== null) break; await delay(20); } + assert.match(hostOutput, /Host online/); const foreignMessages: unknown[] = []; const foreign = connectNostr(relay.url, Buffer.from(b, 'hex'), undefined, m => foreignMessages.push(m), () => {}); await foreign.ready; const path = join(root, 'catalog.json'); writePrivate(path, catalog); - // Replace only the admission dependency, not CLI, catalog, transport, journal or executor. - const loader = join(root, 'admission-loader.mjs'); - writeFileSync(loader, `import { registerHooks } from 'node:module'; registerHooks({load(url,ctx,next){const r=next(url,ctx); if(!url.endsWith('/src/relay-admission.ts'))return r;return {...r,source:'export const productionAdmission = { admit(s) { if(s.relay !== '+${JSON.stringify(JSON.stringify(relay.url))}+' || s.publicKey !== '+${JSON.stringify(JSON.stringify(publicKey(owner)))}+' || s.ownerDelegation !== false) throw Error("fixture admission scope"); } };'};}});`); const env: NodeJS.ProcessEnv = { ...process.env }; delete env.BUZZ_PRIVATE_KEY; delete env.BUZZ_AUTH_TAG; delete env.BUZZ_RELAY_URL; - const child = spawn(process.execPath, ['--import', loader, 'src/cli.ts', 'tui', path, relay.url], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn(process.execPath, ['src/cli.ts', 'tui', path, relay.url], { env, stdio: ['pipe', 'pipe', 'pipe'] }); let output = ''; child.stdout.on('data', b => output += b.toString()); child.stderr.on('data', b => output += b.toString()); const exit = new Promise((done, reject) => { child.on('exit', done); child.on('error', reject); }); async function wait(predicate: () => boolean) { for (let n = 0; n < (installed ? 3000 : 400); n++) { if (predicate()) return; if (child.exitCode !== null) throw Error(output); await delay(20); } throw Error(`TUI observation timeout: ${output}`); } @@ -106,8 +113,8 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran 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/); + for (let n = 0; n < 80; n++) { selected = await command('show'); if (selected.includes('"revision": 1,\n "body"')) break; await delay(25); } + assert.ok(selected.includes('"revision": 1,\n "body"'), selected); const saved = JSON.parse(readFileSync(journalPath, 'utf8')); assert.equal(saved.selected.harnessSetup.id, 'normal'); assert.equal(saved.actual, null); } @@ -130,6 +137,11 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran await wait(() => output.slice(stopAt).includes('accepted')); for (let n = 0; n < 80; n++) { shown = await command('show'); if (shown.includes('"phase": "stopped"')) break; await delay(25); } assert.match(shown, /"phase": "stopped"/); + if (conversation) for (const file of ['harness-pid', 'descendant-pid', 'tool-shim-pid']) { + const pid = Number(readFileSync(join(root, file), 'utf8')); + assert.ok(Number.isSafeInteger(pid) && pid > 0); + assert.throws(() => process.kill(pid, 0), (error: NodeJS.ErrnoException) => error.code === 'ESRCH', `${file} survives accepted Stop`); + } const beforeReplay = readFileSync(journalPath, 'utf8'); await foreign.publish(message('start', publicKey(a), publicKey(agent), 2), publicKey(a)); await foreign.publish(message('inventory', publicKey(a), publicKey(agent), 999, { phase: 'FORGED', observedAt: Date.now() + 500 }), publicKey(owner)); @@ -144,7 +156,7 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran const scope = join(intentDirectory, readdirSync(intentDirectory)[0]!); const intent = readdirSync(scope).filter(n => n.endsWith('.intent')).map(n => JSON.parse(readFileSync(join(scope, n), 'utf8'))).map(v => open(v.envelope, owner)).find(m => m.type === 'start')!; await ownerWire.publish(intent, publicKey(a)); - await command('reconcile'); + assert.match(await command('reconcile'), /Fresh membership required/); await delay(100); shown = await command('show'); assert.ok(!shown.includes('FORGED')); @@ -153,6 +165,7 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran 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.deepEqual(relay.liveChecks.sort(), [publicKey(a), publicKey(owner)].sort()); 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')); diff --git a/beehive/test/nostr-fixture.ts b/beehive/test/nostr-fixture.ts index 6990ddbc8e..84255530db 100644 --- a/beehive/test/nostr-fixture.ts +++ b/beehive/test/nostr-fixture.ts @@ -1,6 +1,8 @@ +import { createServer } from 'node:http'; +import { newKey, publicKey, digest } from '../src/protocol.ts'; import { randomUUID } from 'node:crypto'; import { WebSocketServer, type WebSocket } from 'ws'; -import { verifyEvent, type Event } from 'nostr-tools/pure'; +import { finalizeEvent, verifyEvent, type Event } from 'nostr-tools/pure'; import { verifyHostAttestation } from '../src/host-attestation.ts'; /** Narrow executable model of Buzz 051c3a2 admission, NOT a deployed/Rust relay. @@ -13,9 +15,26 @@ export async function nostrFixture(owner: string, independentMembers?: ReadonlyS // api/mod.rs direct membership is distinct from ViaOwner. This is fixture // policy only, NOT evidence that deployed membership has narrow permissions. const members = independentMembers ? new Set(independentMembers) : undefined; - const server = new WebSocketServer({ host: '127.0.0.1', port: 0, maxPayload: 300000 }); - await new Promise(resolve => server.once('listening', resolve)); - const address = server.address(); + const self = newKey(); + const liveChecks: string[] = []; + const http = createServer(async (req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/') { res.end(JSON.stringify({ self: publicKey(self), supported_nips: members ? [43] : [] })); return; } + let body = ''; for await (const chunk of req) { body += chunk; if (body.length > 4096) { res.writeHead(413).end(); return; } } + try { + const auth = JSON.parse(Buffer.from((req.headers.authorization ?? '').replace(/^Nostr /, ''), 'base64').toString()); + if (!verifyEvent(auth) || auth.kind !== 27235 || auth.content !== '' || Math.abs(auth.created_at - Math.floor(Date.now()/1000)) > 60 || + JSON.stringify(auth.tags) !== JSON.stringify([['u', url.replace('ws:', 'http:') + req.url], ['method', 'POST'], ['payload', digest(body).toString('hex')]])) throw Error('auth'); + if (!members?.has(auth.pubkey)) { res.writeHead(403).end(JSON.stringify({ error: 'relay_membership_required' })); return; } + if (req.url !== '/query' || req.method !== 'POST') throw Error('route'); + liveChecks.push(auth.pubkey); + // Deliberately lagging signed roster: fresh row proof does not depend on it. + res.end(JSON.stringify([finalizeEvent({ kind: 13534, created_at: 1, content: '', tags: [['-']] }, Buffer.from(self, 'hex'))])); + } catch { res.writeHead(401).end('{}'); } + }); + const server = new WebSocketServer({ server: http, maxPayload: 300000 }); + await new Promise(resolve => http.listen(0, '127.0.0.1', resolve)); + const address = http.address(); if (!address || typeof address === 'string') throw Error('Missing fixture address'); const url = `ws://127.0.0.1:${address.port}`; const history: Event[] = []; @@ -59,5 +78,5 @@ export async function nostrFixture(owner: string, independentMembers?: ReadonlyS } }); }); - return { url, history, get independentWraps() { return independentWraps; }, async close() { for (const peer of connections.keys()) peer.terminate(); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } }; + return { url, history, liveChecks, get independentWraps() { return independentWraps; }, async close() { for (const peer of connections.keys()) peer.terminate(); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); await new Promise((resolve, reject) => http.close(error => error ? reject(error) : resolve())); } }; }