feat(beehive): discover configured hosts without infrastructure approval

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 17:21:00 -04:00
parent f2152725eb
commit 91d2246c5e
9 changed files with 259 additions and 59 deletions

View File

@@ -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); });
}

View File

@@ -64,12 +64,13 @@ Command: beehive <command> from any directory once installed (one-time user link
auth-info <host-directory> [binding-id] Print local harness service context (no login)
conversation-setup <host-directory> Legacy guidance; use local-setup action normal
relay <port> <owner-public-key> <log-file> Dedicated ciphertext relay
host --owner-present Default host and retained registered relay
host --owner-present Default host and retained configured relay
host <relay> --owner-present Private foreground host on the default host folder; bounded OS key reads
host <host-directory> <relay> --owner-present Private foreground host on an explicit folder; bounded OS key reads
tui discover <relay> <state-directory> Private availability discovery; no approval file
tui <catalog-file> [relay] Private owner UI; hidden existing owner signer
setup/add-agent accept optional <local-key-file> <public-genesis-file> 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 <approval-file> 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 <approval-file> 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<string,Message>();
const offers = new Map<string,Message>();
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; }

View File

@@ -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<string, { request: HostPairing; observedAt: number }>();
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<string, Message>();
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;
});
}
}

View File

@@ -40,7 +40,8 @@ export function readHostIdentityPublic(directory: string): Omit<HostIdentity, 's
if (JSON.stringify(value.key) !== JSON.stringify(credentialReference('host', pairing.host))) throw Error('Invalid host credential reference');
return { version: 3, pairing, registration: value.registration as HostRegistration | null };
}
/** Live startup uses the owner-approved bounded helper, never a synchronous OS call. */
/** Local configuration is the management trust root. Retain legacy approval as
* public history only. Live startup uses the operator-approved bounded helper. */
export async function readHostIdentityAsync(directory: string, credentials: CredentialBackend, signal?: AbortSignal): Promise<HostIdentity> {
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 {

View File

@@ -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.
},
};

View File

@@ -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<void>; 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<void>; 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();
} };

View File

@@ -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<string, unknown> };
/** 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<string, unknown> };
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<string, unknown> {
@@ -19,9 +20,12 @@ export function fields(value: Record<string, unknown>, 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(); }

View File

@@ -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<number | null>(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<number | null>((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}`); }

View File

@@ -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<typeof spawn>[] = [];
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<number | null>(done => child.on('close', done));
return { child, output: () => output, exit };
}
const host = cli(['host', directory, '--owner-present']);
let ownerWire: ReturnType<ReturnType<typeof catalogTransport>> | undefined;
let wrong: ReturnType<typeof connectNostr> | 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<typeof message>[] = [];
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');
});