diff --git a/beehive/src/catalog-transport.ts b/beehive/src/catalog-transport.ts index 723e3ea858..66f957dd12 100644 --- a/beehive/src/catalog-transport.ts +++ b/beehive/src/catalog-transport.ts @@ -1,5 +1,5 @@ import { connectNostr } from './nostr-client.ts'; -import { catalogHost, catalogResponse, verifyHostCatalog, type HostCatalog } from './host-catalog.ts'; +import { HostOffers, catalogHost, catalogResponse, verifyHostCatalog, type HostCatalog } from './host-catalog.ts'; import { productionAdmission, type ScopedRelayAdmission } from './relay-admission.ts'; import { open, publicKey, type Envelope, type Message } from './protocol.ts'; @@ -9,6 +9,7 @@ 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)); + const offers = new HostOffers(); let closed = false; let generation = 0; function dial() { @@ -17,7 +18,7 @@ export function catalogTransport(catalog: HostCatalog, admission: ScopedRelayAdm return connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => { if (closed || current !== generation) return; let m: Message; - try { m = catalogResponse(retained, input); } catch { return; } + try { m = catalogResponse(retained, input, Math.floor(Date.now() / 1000), offers); } catch { return; } receive(m); }, () => { if (!closed && current === generation) disconnected?.(1008); }); } @@ -26,7 +27,7 @@ export function catalogTransport(catalog: HostCatalog, admission: ScopedRelayAdm if (closed) throw Error('UI closed'); // Profiles need explicit per-host distribution and receipts before enabling. if (!['inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) throw Error('Operation not integrated with private host transport'); - const host = catalogHost(retained, m.host); + const host = catalogHost(retained, m.host, Math.floor(Date.now() / 1000), offers); const current = generation; void wire.publish(m, host.request.host).catch(() => { if (!closed && current === generation) disconnected?.(1008); }); } diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index 9870c58b88..4a8a28d0e7 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -64,12 +64,13 @@ Command: beehive from any directory once installed (one-time user link 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 --owner-present Default host and retained registered relay + host --owner-present Default host and retained configured relay host --owner-present Private foreground host on the default host folder; bounded OS key reads host --owner-present Private foreground host on an explicit folder; bounded OS key reads + tui discover Private availability discovery; no approval file tui [relay] Private owner UI; hidden existing owner signer setup/add-agent accept optional for standby import. -The default host folder resolves from your home (~/.beehive/host), never the current directory; setup displays it once and never reinitializes an existing installation. Owner approval files are automatic in the separately chosen owner exchange folder. catalog saves beside the approval. Enrollment, provision-agent, auth-info, catalog, host and tui paths accept ~ and ~/. Explicit relay overrides must match registration. +The default host folder resolves from your home (~/.beehive/host), never the current directory; setup displays it once and never reinitializes an existing installation. Owner approval files are automatic in the separately chosen owner exchange folder. catalog saves beside the approval. Enrollment, provision-agent, auth-info, catalog, host and tui paths accept ~ and ~/. Explicit relay overrides must match retained local configuration. 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. Private host/catalog requires fresh membership-enforced relay verification; no automatic private reconnect.`; @@ -79,7 +80,6 @@ async function main() { } else if (command === 'provision-agent' || command === 'reconcile-provision') { const directory = setupPath(text(args[0])); const identity = readHostIdentity(directory); - verifyHostRegistration(identity.registration, identity.pairing); const binding = object(readPrivate(setupPath(text(args[1])))); if (['host', 'ownerSecret', 'ownerPublic', 'agentSecret'].some(key => Object.hasOwn(binding, key))) throw Error('Local binding file must not carry identity'); const setup = validateSetup({ ...binding, host: identity.pairing.host, ownerPublic: identity.pairing.owner }); @@ -347,9 +347,9 @@ async function main() { if (privateInstallation && !args.includes('--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'); + if (identity && identity.pairing.relay !== url) throw Error('Wrong configured 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 transport = identity ? privateHostTransport(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 @@ -373,16 +373,26 @@ async function main() { console.log(`Catalog saved: ${file}\nNext on owner computer: beehive tui ${shellQuote(file)}`); 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(setupPath(text(args[0])))); + const discovering = args[0] === 'discover'; + const discoveryRelay = discovering ? text(args[1]) : undefined; + if (discovering) { args[0] = join(setupPath(text(args[2])), 'discovery.json'); args[1] = discoveryRelay!; } + const discoverySecret = discovering ? await readAgentSecret('Owner') : undefined; + const identity = discovering ? { version: 1, owner: publicKey(discoverySecret!), relay: discoveryRelay!, registrations: [] } : object(readPrivate(setupPath(text(args[0])))); if (args[1] === undefined && 'registrations' in identity) args[1] = text(identity.relay); 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); + const secret = discoverySecret ?? (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 offers = new Map(); + const hostFresh = (host: string) => !catalog || (offers.has(host) && Date.now() - Number(offers.get(host)!.body.observedAt) <= 6000) || catalog.registrations.some(r => r.request.host === host && Date.now() < r.expires * 1000); const profiles = new Profiles(); const client = managementClient(join(dirname(setupPath(text(args[0]))), 'management-intents'),text(args[1]),secret,m => { profiles.receive(m); + if (m.type === 'availability') { + const prior = offers.get(m.host); + if ((prior || offers.size < 256) && (!prior || Number(m.body.observedAt) > Number(prior.body.observedAt))) offers.set(m.host, m); + } if (m.type === 'inventory') { const key = JSON.stringify([m.host, m.agent]); const prior = inventory.get(key); @@ -419,11 +429,13 @@ async function main() { continue; } if (line === 'hosts') { + for (const offer of offers.values()) console.log(`${object(offer.body.configuration).label} | host ${offer.host} | ${Date.now() - Number(offer.body.observedAt) > 6000 ? 'STALE/UNKNOWN' : 'available infrastructure (not agent authorization)'}`); if (catalog) for (const r of catalog.registrations) { + if (offers.has(r.request.host)) continue; const expired = Date.now() >= r.expires * 1000; console.log(`${r.request.label} | host ${r.request.host} | ${expired ? 'REGISTRATION EXPIRED / UNKNOWN' : [...inventory.values()].some(m => m.host === r.request.host) ? 'registered infrastructure' : 'UNREACHABLE / UNKNOWN (no host report)'}`); } - [...inventory.values()].forEach((m, index) => console.log(`${index + 1}. ${m.host} | agent ${m.agent} | assigned ${m.body.assignedHost} | ${m.body.phase} | ${m.body.readiness} | ${Date.now()-Number(m.body.observedAt) > 6000 || (catalog && !catalog.registrations.some(r => r.request.host === m.host && Date.now() < r.expires * 1000)) ? 'STALE/UNKNOWN' : 'recent host report'}`)); continue; + [...inventory.values()].forEach((m, index) => console.log(`${index + 1}. ${m.host} | agent ${m.agent} | assigned ${m.body.assignedHost} | ${m.body.phase} | ${m.body.readiness} | ${Date.now()-Number(m.body.observedAt) > 6000 || !hostFresh(m.host) ? 'STALE/UNKNOWN' : 'recent host report'}`)); continue; } if (line === 'agents') { const agents = new Set([...inventory.values()].map(m => m.agent)); @@ -460,7 +472,7 @@ async function main() { const current = inventory.get(selected); if (!current) { console.log('Select an advertised host first.'); continue; } if (line === 'show') { - if (catalog && (Date.now() - Number(current.body.observedAt) > 6000 || !catalog.registrations.some(r => r.request.host === current.host && Date.now() < r.expires * 1000))) console.log('STALE/UNKNOWN: the following is a historical host report, not current actual state.'); + if (catalog && (Date.now() - Number(current.body.observedAt) > 6000 || !hostFresh(current.host))) console.log('STALE/UNKNOWN: the following is a historical host report, not current actual state.'); const next = current.body.selectedNext as any, actual = current.body.actualRun as any; const label = (s: any) => `${s?.configuration ? `${s.configuration.name} @ ${s.configuration.revision}` : 'default (legacy/unversioned)'} | behavior ${s?.behavior ? `${s.behavior.name} @ ${s.behavior.revision.slice(0, 12)}` : 'upstream default'}`; console.log(`Agent ${current.agent} | host ${current.host} | assigned ${current.body.assignedHost}\nCurrent: ${actual ? label(actual.selection) : 'no actual run'}\nSelected next: ${label(next)} (explicit Restart to apply)\n${JSON.stringify(current,null,2)}`); continue; } diff --git a/beehive/src/host-catalog.ts b/beehive/src/host-catalog.ts index d35506d379..7f63632e15 100644 --- a/beehive/src/host-catalog.ts +++ b/beehive/src/host-catalog.ts @@ -1,9 +1,31 @@ import { fields, object, parseMessage, type Message } from './protocol.ts'; -import { hostPairing, verifyHostRegistration, type HostRegistration } from './host-registration.ts'; +import { hostPairing, verifyHostRegistration, type HostPairing, type HostRegistration } from './host-registration.ts'; import type { AuthenticatedMessage } from './nostr-codec.ts'; /** Public-key-only retained owner catalog. Registration is not admission or placement. */ export type HostCatalog = { version: 1; owner: string; relay: string; registrations: HostRegistration[] }; +/** Session-only, bounded self-signed availability. Never persisted as owner approval. */ +export class HostOffers { + readonly entries = new Map(); + receive(catalog: HostCatalog, input: AuthenticatedMessage, now: number): Message { + const m = parseMessage(input.message); + if (m.type !== 'availability' || input.sender !== m.host) throw Error('Untrusted availability signer'); + fields(m.body, ['configuration', 'observedAt']); + const request = hostPairing(m.body.configuration); + const observedAt = m.body.observedAt; + if (request.host !== input.sender || request.owner !== catalog.owner || request.relay !== catalog.relay || + typeof observedAt !== 'number' || !Number.isSafeInteger(observedAt) || observedAt > now + 1000 || observedAt < now - 6000) throw Error('Invalid availability scope/freshness'); + const prior = this.entries.get(m.host); + if (!prior && this.entries.size >= 256) throw Error('Availability capacity reached'); + if (!prior || observedAt > prior.observedAt) this.entries.set(m.host, { request, observedAt }); + return m; + } + /** Availability grants routing only; command execution still requires agent authority. */ + host(host: string, now: number) { + const offer = this.entries.get(host); + return offer && now - offer.observedAt <= 6000 ? offer : undefined; + } +} /** Reverify persisted input, including signatures, scope and expiry, on every use. */ export function verifyHostCatalog(value: unknown, owner: string, relay: string, now = Math.floor(Date.now() / 1000)): HostCatalog { const c = object(value); @@ -21,46 +43,61 @@ export function verifyHostCatalog(value: unknown, owner: string, relay: string, return { version: 1, owner, relay, registrations }; } /** Host PUBLIC KEY is the route. Duplicate display labels never confer authority. */ -export function catalogHost(catalog: HostCatalog, host: string, now = Math.floor(Date.now() / 1000)): HostRegistration { +export function catalogHost(catalog: HostCatalog, host: string, now = Math.floor(Date.now() / 1000), offers?: HostOffers): { request: HostPairing } { + const offered = offers?.host(host, now * 1000); + if (offered && offered.request.owner === catalog.owner && offered.request.relay === catalog.relay) return offered; const current = verifyHostCatalog(catalog, catalog.owner, catalog.relay, now); const registration = current.registrations.find(r => r.request.host === host); if (!registration) throw Error('Host is not registered in owner catalog'); return registration; } -/** Only the exact registered infrastructure signer may supply this host's reports. */ -export function catalogResponse(catalog: HostCatalog, input: AuthenticatedMessage, now = Math.floor(Date.now() / 1000)): Message { +/** Reports bind the exact infrastructure signer, never an owner endorsement. */ +export function catalogResponse(catalog: HostCatalog, input: AuthenticatedMessage, now = Math.floor(Date.now() / 1000), offers?: HostOffers): Message { const m = parseMessage(input.message); - const registration = catalogHost(catalog, m.host, now); + if (m.type === 'availability' && offers) return offers.receive(catalog, input, now * 1000); + const registration = catalogHost(catalog, m.host, now, offers); if (input.sender !== registration.request.host || !['inventory', 'receipt'].includes(m.type)) throw Error('Untrusted host response'); if (m.type === 'inventory' && (typeof m.body.observedAt !== 'number' || !Number.isSafeInteger(m.body.observedAt) || m.body.observedAt < 0 || m.body.observedAt > now * 1000 + 1999)) throw Error('Invalid host observation time'); return m; } -/** Existing inventory consumer with registration and freshness fences, not a second client. */ +/** Infrastructure availability and per-agent observations are separate rows. */ export class HostInventory { readonly #catalog: HostCatalog; + readonly #offers = new HostOffers(); readonly #reports = new Map(); constructor(catalog: HostCatalog) { this.#catalog = structuredClone(verifyHostCatalog(catalog, catalog.owner, catalog.relay)); } receive(input: AuthenticatedMessage, now = Date.now()): Message { - const m = catalogResponse(this.#catalog, input, Math.floor(now / 1000)); + const m = catalogResponse(this.#catalog, input, Math.floor(now / 1000), this.#offers); if (m.type === 'inventory') { const observed = m.body.observedAt; if (typeof observed !== 'number' || !Number.isSafeInteger(observed) || observed < 0 || observed > now + 1000) throw Error('Invalid host observation time'); const key = JSON.stringify([m.host, m.agent]); const previous = this.#reports.get(key); + if (!previous && this.#reports.size >= 1000) throw Error('Inventory capacity reached'); if (!previous || observed > Number(previous.body.observedAt)) this.#reports.set(key, structuredClone(m)); } return m; } - /** Missing/stale reports never mean stopped. Expired registration never means trusted. */ + /** Missing/stale reports never mean stopped. Availability is never agent consent. */ rows(now = Date.now()) { - return this.#catalog.registrations.flatMap(r => { - const reports = [...this.#reports.values()].filter(m => m.host === r.request.host); - const trusted = now < r.expires * 1000; - return (reports.length ? reports : [undefined]).map(report => ({ - host: r.request.host, label: r.request.label, agent: report?.agent, - status: !trusted ? 'registration-expired' : !report || now - Number(report.body.observedAt) > 6000 ? 'unreachable/unknown' : 'recent-host-report', - report: trusted && report ? structuredClone(report) : undefined, + const hosts = new Map(this.#catalog.registrations.map(r => [r.request.host, r.request])); + for (const offer of this.#offers.entries.values()) hosts.set(offer.request.host, offer.request); + return [...hosts.values()].flatMap(request => { + const offered = this.#offers.host(request.host, now); + const legacy = this.#catalog.registrations.find(r => r.request.host === request.host); + const trusted = !!offered || !!legacy && now < legacy.expires * 1000; + const reports = [...this.#reports.values()].filter(m => m.host === request.host); + const infrastructure = { + host: request.host, label: request.label, agent: undefined as string | undefined, + status: offered ? 'available infrastructure (no agent authorization)' : legacy && now >= legacy.expires * 1000 ? 'registration-expired' : 'unreachable/unknown', + report: undefined as Message | undefined, + }; + const rows = reports.map(report => ({ + host: request.host, label: request.label, agent: report.agent, + status: !trusted ? legacy ? 'registration-expired' : 'unreachable/unknown' : now - Number(report.body.observedAt) > 6000 ? 'unreachable/unknown' : 'recent-host-report', + report: trusted ? structuredClone(report) : undefined, })); + return offered || !rows.length ? [infrastructure, ...rows] : rows; }); } } diff --git a/beehive/src/host-identity.ts b/beehive/src/host-identity.ts index aa348f6771..416b5ccb54 100644 --- a/beehive/src/host-identity.ts +++ b/beehive/src/host-identity.ts @@ -40,7 +40,8 @@ export function readHostIdentityPublic(directory: string): Omit { signal?.throwIfAborted(); const value = object(readPrivate(join(directory, 'host-identity.json'))); @@ -49,13 +50,12 @@ export async function readHostIdentityAsync(directory: string, credentials: Cred 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 }; + return { version: 3, pairing, secret, registration: value.registration as HostRegistration | null }; } /** 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 1f82ce9b8f..ee5020fd3d 100644 --- a/beehive/src/host-transport.ts +++ b/beehive/src/host-transport.ts @@ -1,25 +1,25 @@ import { connectNostr } from './nostr-client.ts'; -import { verifyHostRegistration, type HostRegistration } from './host-registration.ts'; +import { hostPairing, type HostPairing, type HostRegistration } from './host-registration.ts'; import { productionAdmission, type ScopedRelayAdmission } from './relay-admission.ts'; -import { publicKey, type Message } from './protocol.ts'; +import { message, publicKey, type Message } from './protocol.ts'; -/** Private infrastructure transport for the existing host executor. No agent grant - * is manufactured here; its retained per-agent journal remains the authority owner. +/** Local host configuration pins the command signer, not an owner endorsement. + * Legacy registration wrappers are compatibility input only (public history). + * No agent grant is manufactured; retained per-agent journals remain authoritative. */ -export function privateHostTransport(registration: HostRegistration, secret: string, admission: ScopedRelayAdmission = productionAdmission) { - const retained = structuredClone(verifyHostRegistration(registration, registration.request)); +export function privateHostTransport(configuration: HostPairing | HostRegistration, secret: string, admission: ScopedRelayAdmission = productionAdmission) { + const retained = { request: hostPairing('request' in configuration ? configuration.request : configuration) }; if (publicKey(secret) !== retained.request.host) throw Error('Wrong infrastructure signer'); return { + availability: () => message('availability', retained.request.host, '', 0, { configuration: retained.request, observedAt: Date.now() }), binding: { host: retained.request.host, owner: retained.request.owner }, validate(url: string) { - verifyHostRegistration(retained, retained.request); if (url !== retained.request.relay) throw Error('Wrong host relay'); }, 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; if (input.sender !== retained.request.owner || m.host !== retained.request.host || !['inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) return; receive(m); @@ -28,8 +28,7 @@ export function privateHostTransport(registration: HostRegistration, secret: str ready: wire.ready, close: () => wire.close(), send(m: Message) { - verifyHostRegistration(retained, retained.request); - if (m.host !== retained.request.host || !['inventory', 'receipt'].includes(m.type)) throw Error('Invalid private host report'); + if (m.host !== retained.request.host || !['availability', 'inventory', 'receipt'].includes(m.type)) throw Error('Invalid private host report'); void wire.publish(m, retained.request.owner).catch(() => {}); // Host outbox/heartbeat retains retry state. }, }; diff --git a/beehive/src/host.ts b/beehive/src/host.ts index fc686193ee..f55d65436e 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -559,7 +559,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri } /** One installation owns every slot, lock and management transport. */ -export async function host(directory: string, url: string, signal?: AbortSignal, transport?: { binding: { host: string; owner: string }; validate(url: string): void; connect(url: string, secret: string, receive: (m: Message) => void, recovered?: () => void): { ready: Promise; send(m: Message): void; close(): void } }, credentials: CredentialBackend = systemCredentials) { +export async function host(directory: string, url: string, signal?: AbortSignal, transport?: { availability?(): Message; binding: { host: string; owner: string }; validate(url: string): void; connect(url: string, secret: string, receive: (m: Message) => void, recovered?: () => void): { ready: Promise; send(m: Message): void; close(): void } }, credentials: CredentialBackend = systemCredentials) { signal?.throwIfAborted(); (transport?.validate ?? validateRelayURL)(url); const lock = join(directory, 'host.lock'); @@ -581,7 +581,7 @@ export async function host(directory: string, url: string, signal?: AbortSignal, // Abort uses the same owner as ready shutdown, including quarantine fences. const abort = () => { void close().catch(() => {}); }; try { - const entries = await installationSlotsAsync(directory, credentials, signal); + const entries = transport && !existsSync(join(directory, 'setup.json')) ? [] : await installationSlotsAsync(directory, credentials, signal); signal?.throwIfAborted(); signal?.addEventListener('abort', abort, { once: true }); for (const entry of entries) { @@ -596,18 +596,19 @@ export async function host(directory: string, url: string, signal?: AbortSignal, return value; }, publish, profiles, entry.setupId, entry.bindings, entry.retiredBindings)); } - const setup = entries[0]!.setup; + const setup = entries[0]?.setup; + const hostKey = transport?.binding.host ?? setup!.host; // Every slot is hydrated before dialing. Initial WS history may arrive in the // same event-loop turn as open, before the ready promise continuation. initialized = true; - client = (transport ? transport.connect.bind(transport) : connect)(url, transport ? '' : text(setup.ownerSecret), m => { + client = (transport ? transport.connect.bind(transport) : connect)(url, transport ? '' : text(setup!.ownerSecret), m => { if (!initialized) return; profiles.receive(m); - if (m.host !== setup.host) return; + if (m.host !== hostKey) return; const selected = slots.get(m.agent); trace('host.route', { id: m.id, type: m.type, selected: !!selected }); if (selected) selected.receive(m); else if (m.type === 'inspect') { for (const s of slots.values()) s.replay(); } - else if (['save','start','restart','stop','move'].includes(m.type)) publish(message('receipt',setup.host,m.agent,0,{ operation: m.id, fingerprint: digest(JSON.stringify(m)).toString('hex'), result: 'not-authority' })); + else if (['save','start','restart','stop','move'].includes(m.type)) publish(message('receipt',hostKey,m.agent,0,{ operation: m.id, fingerprint: digest(JSON.stringify(m)).toString('hex'), result: 'not-authority' })); }, () => { if (initialized) for (const s of slots.values()) s.replay(); }); await client.ready; signal?.throwIfAborted(); @@ -617,9 +618,10 @@ export async function host(directory: string, url: string, signal?: AbortSignal, throw e; } initialized = true; + if (transport?.availability) publish(transport.availability()); for (const s of slots.values()) s.replay(); - heartbeat = setInterval(() => { for (const s of slots.values()) s.heartbeat(); }, 2000); - return { agent: [...slots.keys()][0]!, agents: [...slots.keys()], close() { + heartbeat = setInterval(() => { if (transport?.availability) publish(transport.availability()); for (const s of slots.values()) s.heartbeat(); }, 2000); + return { agent: [...slots.keys()][0], agents: [...slots.keys()], close() { signal?.removeEventListener('abort', abort); return close(); } }; diff --git a/beehive/src/protocol.ts b/beehive/src/protocol.ts index 16680fdd90..e377d48086 100644 --- a/beehive/src/protocol.ts +++ b/beehive/src/protocol.ts @@ -1,8 +1,9 @@ import { createHash, createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; import { schnorr } from '@noble/curves/secp256k1'; -/** Experimental private wire protocol; deliberately not a Nostr kind allocation. */ -export type Message = { v: 1; id: string; host: string; agent: string; type: 'profile' | 'inspect' | 'save' | 'start' | 'restart' | 'stop' | 'move' | 'prepare' | 'prepared' | 'grant' | 'inventory' | 'receipt'; revision: number; body: Record }; +/** Experimental private wire protocol; deliberately not a Nostr kind allocation. + * Host-level availability has agent='' and revision=0, never an agent inventory row. */ +export type Message = { v: 1; id: string; host: string; agent: string; type: 'availability' | 'profile' | 'inspect' | 'save' | 'start' | 'restart' | 'stop' | 'move' | 'prepare' | 'prepared' | 'grant' | 'inventory' | 'receipt'; revision: number; body: Record }; export type Envelope = { v: 1; owner: string; nonce: string; ciphertext: string; tag: string; signature: string }; /** Reject unknown envelope/message fields, oversized values and ambiguous revisions. */ export function object(value: unknown): Record { @@ -19,9 +20,12 @@ export function fields(value: Record, names: string[]) { export function parseMessage(value: unknown): Message { const m = object(value); fields(m, ['v','id','host','agent','type','revision','body']); - if (m.v !== 1 || !['profile','inspect','save','start','restart','stop','move','prepare','prepared','grant','inventory','receipt'].includes(String(m.type)) || !Number.isSafeInteger(m.revision) || Number(m.revision) < 0) throw Error('Invalid message'); + if (m.v !== 1 || !['availability','profile','inspect','save','start','restart','stop','move','prepare','prepared','grant','inventory','receipt'].includes(String(m.type)) || !Number.isSafeInteger(m.revision) || Number(m.revision) < 0) throw Error('Invalid message'); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(text(m.id))) throw Error('Invalid operation ID'); - text(m.host); text(m.agent); object(m.body); + text(m.host); + if (m.type === 'availability') { if (m.agent !== '' || m.revision !== 0) throw Error('Availability is not an agent report'); } + else text(m.agent); + object(m.body); return m as Message; } export function digest(value: string): Buffer { return createHash('sha256').update(value).digest(); } diff --git a/beehive/test/catalog-tui.test.ts b/beehive/test/catalog-tui.test.ts index 72b5e18408..858fbdc97a 100644 --- a/beehive/test/catalog-tui.test.ts +++ b/beehive/test/catalog-tui.test.ts @@ -46,20 +46,20 @@ 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 scenario of ['external', 'installed', 'v3-conversion']) { -const installed = scenario !== 'external', converting = scenario === 'v3-conversion'; +for (const scenario of ['external', 'configured', 'installed', 'v3-conversion']) { +const installed = scenario === 'installed' || scenario === 'v3-conversion', 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); 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 catalog = verifyHostCatalog({ version: 1, owner: publicKey(owner), relay: relay.url, registrations: scenario === 'configured' ? [two] : [one, two] }, publicKey(owner), relay.url); const directory = join(root, 'host'); mkdirSync(directory); 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 }); + writePrivate(join(directory, 'host-identity.json'), { version: 3, pairing: one.request, key: credentialReference('host', publicKey(a)), registration: scenario === 'configured' ? null : 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'); @@ -85,7 +85,7 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran 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, ...(installed ? [relay.url] : []), '--owner-present'], { env: { PATH: '/usr/bin:/bin', HOME: root }, stdio: ['ignore','pipe','pipe'] }); + const hostChild = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), '--import', loader, 'src/cli.ts', 'host', directory, ...(installed ? [relay.url] : []), '--owner-present'], { env: { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: credentialFile }, 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); } }; @@ -95,8 +95,8 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran 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); - 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, ['src/cli.ts', 'tui', path, ...(installed ? [relay.url] : [])], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + const env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: credentialFile }; + const child = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), 'src/cli.ts', 'tui', path, ...(installed ? [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}`); } diff --git a/beehive/test/host-availability.test.ts b/beehive/test/host-availability.test.ts new file mode 100644 index 0000000000..cb2febfd07 --- /dev/null +++ b/beehive/test/host-availability.test.ts @@ -0,0 +1,145 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { newKey, publicKey, message, parseMessage } from '../src/protocol.ts'; +import { bootstrapHostIdentity } from '../src/host-identity.ts'; +import { credentialReference } from '../src/credential-store.ts'; +import { isolatedFileCredentials } from './isolated-file-credentials.ts'; +import { nostrFixture } from './nostr-fixture.ts'; +import { connectNostr } from '../src/nostr-client.ts'; +import { catalogTransport } from '../src/catalog-transport.ts'; +import { HostOffers, HostInventory, catalogResponse, type HostCatalog } from '../src/host-catalog.ts'; +import { verifyProductionAdmission } from '../src/relay-admission.ts'; +import { createGenesis } from '../src/assignment.ts'; +import { writePrivate } from '../src/storage.ts'; + +async function wait(predicate: () => boolean, detail: () => string = () => '') { + for (let n = 0; n < 300; n++) { if (predicate()) return; await delay(20); } + throw Error(`Timed out: ${detail()}`); +} + +test('actual zero-agent host CLI advertises privately; file-free owner TUI discovery is not Start authority', async () => { + const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-availability-')); + const owner = newKey(), stranger = newKey(); + const file = join(root, 'credentials.json'), credentials = isolatedFileCredentials(file); + const directory = join(root, 'host'); + // Retained identity is created once, with no approval or agent slot. + const identity = bootstrapHostIdentity(directory, 'Empty laptop', publicKey(owner), 'wss://example.invalid', credentials); + const relay = await nostrFixture(publicKey(owner), new Set([publicKey(owner), identity.pairing.host, publicKey(stranger)])); + // Fixture-only initial URL assignment before any runtime; never a production migration. + identity.pairing.relay = relay.url; + writePrivate(join(directory, 'host-identity.json'), { version: 3, pairing: identity.pairing, key: credentialReference('host', identity.pairing.host), registration: null }); + const retained = readFileSync(join(directory, 'host-identity.json')); + const reads = join(root, 'reads.jsonl'); + const helper = join(root, 'credential-child.mjs'); + writeFileSync(helper, `import {readFileSync,appendFileSync} from 'node:fs';let input='';for await(const b of process.stdin)input+=b;appendFileSync(${JSON.stringify(reads)},input+'\\n');const secret=JSON.parse(readFileSync(${JSON.stringify(file)},'utf8'))[JSON.stringify(JSON.parse(input))];process.stdout.write(JSON.stringify(secret?{status:'present',secret}:{status:'missing'}));`); + const loader = join(root, '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;const s=String(r.source);if(!s.includes("new URL('./credential-helper-child.ts', import.meta.url)"))throw Error('fixture injection failed');return {...r,source:s.replace("new URL('./credential-helper-child.ts', import.meta.url)",${JSON.stringify(`new URL(${JSON.stringify('file://' + helper)})`)})};}});`); + const children: ReturnType[] = []; + function cli(args: string[]) { + const child = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), '--import', loader, 'src/cli.ts', ...args], { env: { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: file }, stdio: ['pipe', 'pipe', 'pipe'] }); + children.push(child); + let output = ''; child.stdout!.on('data', b => output += b); child.stderr!.on('data', b => output += b); + const exit = new Promise(done => child.on('close', done)); + return { child, output: () => output, exit }; + } + const host = cli(['host', directory, '--owner-present']); + let ownerWire: ReturnType> | undefined; + let wrong: ReturnType | undefined; + try { + await wait(() => host.output().includes('Host online'), host.output); + assert.ok(!existsSync(join(directory, 'setup.json'))); + const catalog: HostCatalog = { version: 1, owner: publicKey(owner), relay: relay.url, registrations: [] }; + const reports: ReturnType[] = []; + ownerWire = catalogTransport(catalog, await verifyProductionAdmission(relay.url, owner))(relay.url, owner, m => reports.push(m)); + await ownerWire.ready; + await wait(() => reports.some(m => m.type === 'availability')); + assert.equal(reports.filter(m => m.type === 'inventory').length, 0); + assert.ok(relay.liveChecks.includes(identity.pairing.host)); + assert.ok(relay.history.every(e => JSON.stringify(e.tags) === JSON.stringify([['p', publicKey(owner)]]))); + assert.ok(relay.history.every(e => !e.content.includes('Empty laptop') && !e.content.includes(identity.pairing.host))); + const leaked: unknown[] = []; + wrong = connectNostr(relay.url, Buffer.from(stranger, 'hex'), undefined, m => leaked.push(m), () => {}); + await wrong.ready; + const invalid = message('start', identity.pairing.host, publicKey(newKey())); + await wrong.publish(invalid, identity.pairing.host); + const start = message('start', identity.pairing.host, invalid.agent); + ownerWire.send(start); + await wait(() => reports.some(m => m.type === 'receipt' && m.body.operation === start.id)); + assert.equal(reports.find(m => m.body.operation === start.id)!.body.result, 'not-authority'); + await delay(150); + assert.ok(!reports.some(m => m.body.operation === invalid.id)); + assert.equal(leaked.length, 0); + const ui = cli(['tui', 'discover', relay.url, join(root, 'owner-state')]); + await wait(() => ui.output().includes('Owner private key'), ui.output); + ui.child.stdin!.write(owner + '\n'); + await wait(() => ui.output().includes('beehive> '), ui.output); + await delay(150); + ui.child.stdin!.write('hosts\n'); + await wait(() => ui.output().includes('available infrastructure (not agent authorization)'), ui.output); + assert.ok(ui.output().includes(identity.pairing.host)); + ui.child.stdin!.write('agents\n'); + await wait(() => ui.output().includes('Per-host reports'), ui.output); + assert.ok(!ui.output().includes(`Agent ${invalid.agent}`)); + ui.child.stdin!.write('quit\n'); assert.equal(await ui.exit, 0, ui.output()); + host.child.kill('SIGTERM'); assert.equal(await host.exit, 0, host.output()); + assert.deepEqual(readFileSync(join(directory, 'host-identity.json')), retained); + assert.deepEqual(readFileSync(reads, 'utf8').trim().split('\n').map(s => JSON.parse(s)), [credentialReference('host', identity.pairing.host)]); + assert.ok(!existsSync(join(directory, 'agents'))); + // Availability cannot repair a missing placement grant. The actual CLI must + // reject a forged assignment before it asks the credential helper for an agent. + const agent = newKey(); + const bindingFile = join(root, 'binding.json'), genesisFile = join(root, 'genesis.json'); + writePrivate(bindingFile, { runner: process.execPath, args: [resolve('test/runner.ts')], workspace: root, mode: 'fixture' }); + writePrivate(genesisFile, createGenesis(publicKey(owner), publicKey(agent), 'source-host')); + const provision = cli(['provision-agent', directory, bindingFile, genesisFile]); + await wait(() => provision.output().includes('Agent private key'), provision.output); + provision.child.stdin!.write(agent + '\n'); + assert.equal(await provision.exit, 0, provision.output()); + assert.match(provision.output(), /Provisioned STOPPED public slot/); + const journalFile = join(directory, 'agents', publicKey(agent), 'journal.json'); + const forged = JSON.parse(readFileSync(journalFile, 'utf8')); + forged.assignment.assignedHost = identity.pairing.host; // No owner-authorized transfer chain. + writePrivate(journalFile, forged); + const rejected = cli(['host', directory, '--owner-present']); + assert.notEqual(await rejected.exit, 0); + assert.ok(!rejected.output().includes('Host online')); + assert.deepEqual(JSON.parse(readFileSync(journalFile, 'utf8')), forged); + assert.ok(readFileSync(reads, 'utf8').trim().split('\n').every(s => JSON.parse(s).role === 'host')); + // A configured npub alone cannot pass the ordinary fresh member gate. + const pending = bootstrapHostIdentity(join(root, 'pending'), 'Pending', publicKey(owner), relay.url, credentials); + const denied = cli(['host', join(root, 'pending'), '--owner-present']); + assert.notEqual(await denied.exit, 0); + assert.match(denied.output(), /Direct membership not verified/); + assert.ok(!denied.output().includes('Host online')); + assert.ok(!relay.liveChecks.includes(pending.pairing.host)); + } finally { + ownerWire?.close(); wrong?.close(); + for (const child of children) if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await Promise.all(children.map(c => c.exitCode !== null || c.signalCode !== null ? Promise.resolve() : new Promise(done => c.on('close', done)))); + await relay.close(); rmSync(root, { recursive: true, force: true }); + } +}); + +test('availability schema and catalog fences: scope, signer, freshness, no fabricated approval', () => { + const owner = publicKey(newKey()), host = publicKey(newKey()), relay = 'wss://example.invalid'; + const catalog: HostCatalog = { version: 1, owner, relay, registrations: [] }; + const now = Math.floor(Date.now() / 1000) * 1000; + const configuration = { version: 1, purpose: 'beehive-host-registration', owner, host, relay, label: 'host', nonce: newKey() }; + const offer = message('availability', host, '', 0, { configuration, observedAt: now }); + const offers = new HostOffers(); + assert.throws(() => parseMessage({ ...offer, agent: 'fake-agent' })); + assert.throws(() => catalogResponse(catalog, { sender: owner, message: offer }, now / 1000, offers)); + assert.throws(() => catalogResponse(catalog, { sender: host, message: { ...offer, body: { ...offer.body, configuration: { ...configuration, owner: host } } } }, now / 1000, offers)); + assert.throws(() => catalogResponse(catalog, { sender: host, message: offer }, now / 1000 + 7, offers)); + catalogResponse(catalog, { sender: host, message: offer }, now / 1000, offers); + assert.equal(catalog.registrations.length, 0); + const view = new HostInventory(catalog); view.receive({ sender: host, message: offer }, now); + assert.equal(view.rows(now)[0]!.agent, undefined); + assert.match(view.rows(now)[0]!.status, /available infrastructure/); + assert.equal(view.rows(now + 7000)[0]!.status, 'unreachable/unknown'); +});