mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
feat(beehive): discover configured hosts without infrastructure approval
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { connectNostr } from './nostr-client.ts';
|
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 { productionAdmission, type ScopedRelayAdmission } from './relay-admission.ts';
|
||||||
import { open, publicKey, type Envelope, type Message } from './protocol.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) {
|
export function catalogTransport(catalog: HostCatalog, admission: ScopedRelayAdmission = productionAdmission) {
|
||||||
return (url: string, secret: string, receive: (m: Message) => void, recovered?: () => void, disconnected?: (code: number) => void) => {
|
return (url: string, secret: string, receive: (m: Message) => void, recovered?: () => void, disconnected?: (code: number) => void) => {
|
||||||
const retained = structuredClone(verifyHostCatalog(catalog, publicKey(secret), url));
|
const retained = structuredClone(verifyHostCatalog(catalog, publicKey(secret), url));
|
||||||
|
const offers = new HostOffers();
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
function dial() {
|
function dial() {
|
||||||
@@ -17,7 +18,7 @@ export function catalogTransport(catalog: HostCatalog, admission: ScopedRelayAdm
|
|||||||
return connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => {
|
return connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => {
|
||||||
if (closed || current !== generation) return;
|
if (closed || current !== generation) return;
|
||||||
let m: Message;
|
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);
|
receive(m);
|
||||||
}, () => { if (!closed && current === generation) disconnected?.(1008); });
|
}, () => { if (!closed && current === generation) disconnected?.(1008); });
|
||||||
}
|
}
|
||||||
@@ -26,7 +27,7 @@ export function catalogTransport(catalog: HostCatalog, admission: ScopedRelayAdm
|
|||||||
if (closed) throw Error('UI closed');
|
if (closed) throw Error('UI closed');
|
||||||
// Profiles need explicit per-host distribution and receipts before enabling.
|
// 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');
|
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;
|
const current = generation;
|
||||||
void wire.publish(m, host.request.host).catch(() => { if (!closed && current === generation) disconnected?.(1008); });
|
void wire.publish(m, host.request.host).catch(() => { if (!closed && current === generation) disconnected?.(1008); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
auth-info <host-directory> [binding-id] Print local harness service context (no login)
|
||||||
conversation-setup <host-directory> Legacy guidance; use local-setup action normal
|
conversation-setup <host-directory> Legacy guidance; use local-setup action normal
|
||||||
relay <port> <owner-public-key> <log-file> Dedicated ciphertext relay
|
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 <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
|
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
|
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.
|
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.
|
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.
|
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.`;
|
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') {
|
} else if (command === 'provision-agent' || command === 'reconcile-provision') {
|
||||||
const directory = setupPath(text(args[0]));
|
const directory = setupPath(text(args[0]));
|
||||||
const identity = readHostIdentity(directory);
|
const identity = readHostIdentity(directory);
|
||||||
verifyHostRegistration(identity.registration, identity.pairing);
|
|
||||||
const binding = object(readPrivate(setupPath(text(args[1]))));
|
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');
|
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 });
|
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.');
|
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 credentials = privateInstallation ? { ...systemCredentials, readAsync: credentialHelperReader({ operatorApproved: true }) } : systemCredentials;
|
||||||
const identity = privateInstallation ? await readHostIdentityAsync(directory, credentials, controller.signal) : undefined;
|
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 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);
|
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.`);
|
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
|
// 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(`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.`);
|
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') {
|
} 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);
|
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 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');
|
if (catalog && publicKey(secret) !== catalog.owner) throw Error('Wrong catalog owner signer');
|
||||||
const admission = catalog ? await verifyProductionAdmission(text(args[1]), secret) : undefined;
|
const admission = catalog ? await verifyProductionAdmission(text(args[1]), secret) : undefined;
|
||||||
const inventory = new Map<string,Message>();
|
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 profiles = new Profiles();
|
||||||
const client = managementClient(join(dirname(setupPath(text(args[0]))), 'management-intents'),text(args[1]),secret,m => {
|
const client = managementClient(join(dirname(setupPath(text(args[0]))), 'management-intents'),text(args[1]),secret,m => {
|
||||||
profiles.receive(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') {
|
if (m.type === 'inventory') {
|
||||||
const key = JSON.stringify([m.host, m.agent]);
|
const key = JSON.stringify([m.host, m.agent]);
|
||||||
const prior = inventory.get(key);
|
const prior = inventory.get(key);
|
||||||
@@ -419,11 +429,13 @@ async function main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (line === 'hosts') {
|
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 (catalog) for (const r of catalog.registrations) {
|
||||||
|
if (offers.has(r.request.host)) continue;
|
||||||
const expired = Date.now() >= r.expires * 1000;
|
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)'}`);
|
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') {
|
if (line === 'agents') {
|
||||||
const agents = new Set([...inventory.values()].map(m => m.agent));
|
const agents = new Set([...inventory.values()].map(m => m.agent));
|
||||||
@@ -460,7 +472,7 @@ async function main() {
|
|||||||
const current = inventory.get(selected);
|
const current = inventory.get(selected);
|
||||||
if (!current) { console.log('Select an advertised host first.'); continue; }
|
if (!current) { console.log('Select an advertised host first.'); continue; }
|
||||||
if (line === 'show') {
|
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 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'}`;
|
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; }
|
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; }
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
import { fields, object, parseMessage, type Message } from './protocol.ts';
|
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';
|
import type { AuthenticatedMessage } from './nostr-codec.ts';
|
||||||
|
|
||||||
/** Public-key-only retained owner catalog. Registration is not admission or placement. */
|
/** Public-key-only retained owner catalog. Registration is not admission or placement. */
|
||||||
export type HostCatalog = { version: 1; owner: string; relay: string; registrations: HostRegistration[] };
|
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. */
|
/** 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 {
|
export function verifyHostCatalog(value: unknown, owner: string, relay: string, now = Math.floor(Date.now() / 1000)): HostCatalog {
|
||||||
const c = object(value);
|
const c = object(value);
|
||||||
@@ -21,46 +43,61 @@ export function verifyHostCatalog(value: unknown, owner: string, relay: string,
|
|||||||
return { version: 1, owner, relay, registrations };
|
return { version: 1, owner, relay, registrations };
|
||||||
}
|
}
|
||||||
/** Host PUBLIC KEY is the route. Duplicate display labels never confer authority. */
|
/** 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 current = verifyHostCatalog(catalog, catalog.owner, catalog.relay, now);
|
||||||
const registration = current.registrations.find(r => r.request.host === host);
|
const registration = current.registrations.find(r => r.request.host === host);
|
||||||
if (!registration) throw Error('Host is not registered in owner catalog');
|
if (!registration) throw Error('Host is not registered in owner catalog');
|
||||||
return registration;
|
return registration;
|
||||||
}
|
}
|
||||||
/** Only the exact registered infrastructure signer may supply this host's reports. */
|
/** Reports bind the exact infrastructure signer, never an owner endorsement. */
|
||||||
export function catalogResponse(catalog: HostCatalog, input: AuthenticatedMessage, now = Math.floor(Date.now() / 1000)): Message {
|
export function catalogResponse(catalog: HostCatalog, input: AuthenticatedMessage, now = Math.floor(Date.now() / 1000), offers?: HostOffers): Message {
|
||||||
const m = parseMessage(input.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 (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');
|
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;
|
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 {
|
export class HostInventory {
|
||||||
readonly #catalog: HostCatalog;
|
readonly #catalog: HostCatalog;
|
||||||
|
readonly #offers = new HostOffers();
|
||||||
readonly #reports = new Map<string, Message>();
|
readonly #reports = new Map<string, Message>();
|
||||||
constructor(catalog: HostCatalog) { this.#catalog = structuredClone(verifyHostCatalog(catalog, catalog.owner, catalog.relay)); }
|
constructor(catalog: HostCatalog) { this.#catalog = structuredClone(verifyHostCatalog(catalog, catalog.owner, catalog.relay)); }
|
||||||
receive(input: AuthenticatedMessage, now = Date.now()): Message {
|
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') {
|
if (m.type === 'inventory') {
|
||||||
const observed = m.body.observedAt;
|
const observed = m.body.observedAt;
|
||||||
if (typeof observed !== 'number' || !Number.isSafeInteger(observed) || observed < 0 || observed > now + 1000) throw Error('Invalid host observation time');
|
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 key = JSON.stringify([m.host, m.agent]);
|
||||||
const previous = this.#reports.get(key);
|
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));
|
if (!previous || observed > Number(previous.body.observedAt)) this.#reports.set(key, structuredClone(m));
|
||||||
}
|
}
|
||||||
return 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()) {
|
rows(now = Date.now()) {
|
||||||
return this.#catalog.registrations.flatMap(r => {
|
const hosts = new Map(this.#catalog.registrations.map(r => [r.request.host, r.request]));
|
||||||
const reports = [...this.#reports.values()].filter(m => m.host === r.request.host);
|
for (const offer of this.#offers.entries.values()) hosts.set(offer.request.host, offer.request);
|
||||||
const trusted = now < r.expires * 1000;
|
return [...hosts.values()].flatMap(request => {
|
||||||
return (reports.length ? reports : [undefined]).map(report => ({
|
const offered = this.#offers.host(request.host, now);
|
||||||
host: r.request.host, label: r.request.label, agent: report?.agent,
|
const legacy = this.#catalog.registrations.find(r => r.request.host === request.host);
|
||||||
status: !trusted ? 'registration-expired' : !report || now - Number(report.body.observedAt) > 6000 ? 'unreachable/unknown' : 'recent-host-report',
|
const trusted = !!offered || !!legacy && now < legacy.expires * 1000;
|
||||||
report: trusted && report ? structuredClone(report) : undefined,
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
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 };
|
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> {
|
export async function readHostIdentityAsync(directory: string, credentials: CredentialBackend, signal?: AbortSignal): Promise<HostIdentity> {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const value = object(readPrivate(join(directory, 'host-identity.json')));
|
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 pairing = hostPairing(value.pairing);
|
||||||
const key = credentialReference('host', pairing.host);
|
const key = credentialReference('host', pairing.host);
|
||||||
if (JSON.stringify(value.key) !== JSON.stringify(key)) throw Error('Invalid host credential reference');
|
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');
|
if (!credentials.readAsync) throw Error('Bounded asynchronous host credential reader required');
|
||||||
const secret = await credentials.readAsync(key, signal);
|
const secret = await credentials.readAsync(key, signal);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (secret === null) throw Error('Local host key missing; explicit matching import required');
|
if (secret === null) throw Error('Local host key missing; explicit matching import required');
|
||||||
if (publicKey(secret) !== pairing.host) throw Error('Credential identity mismatch');
|
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. */
|
/** 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 {
|
export function enrollHostIdentity(directory: string, registration: unknown, now = Math.floor(Date.now() / 1000), credentials: CredentialBackend = systemCredentials): void {
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { connectNostr } from './nostr-client.ts';
|
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 { 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
|
/** Local host configuration pins the command signer, not an owner endorsement.
|
||||||
* is manufactured here; its retained per-agent journal remains the authority owner.
|
* 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) {
|
export function privateHostTransport(configuration: HostPairing | HostRegistration, secret: string, admission: ScopedRelayAdmission = productionAdmission) {
|
||||||
const retained = structuredClone(verifyHostRegistration(registration, registration.request));
|
const retained = { request: hostPairing('request' in configuration ? configuration.request : configuration) };
|
||||||
if (publicKey(secret) !== retained.request.host) throw Error('Wrong infrastructure signer');
|
if (publicKey(secret) !== retained.request.host) throw Error('Wrong infrastructure signer');
|
||||||
return {
|
return {
|
||||||
|
availability: () => message('availability', retained.request.host, '', 0, { configuration: retained.request, observedAt: Date.now() }),
|
||||||
binding: { host: retained.request.host, owner: retained.request.owner },
|
binding: { host: retained.request.host, owner: retained.request.owner },
|
||||||
validate(url: string) {
|
validate(url: string) {
|
||||||
verifyHostRegistration(retained, retained.request);
|
|
||||||
if (url !== retained.request.relay) throw Error('Wrong host relay');
|
if (url !== retained.request.relay) throw Error('Wrong host relay');
|
||||||
},
|
},
|
||||||
connect(url: string, _legacyOwnerSecret: string, receive: (m: Message) => void, _recovered?: () => void) {
|
connect(url: string, _legacyOwnerSecret: string, receive: (m: Message) => void, _recovered?: () => void) {
|
||||||
this.validate(url);
|
this.validate(url);
|
||||||
admission.admit({ relay: url, publicKey: retained.request.host, transport: 'nip42-nip59', ownerDelegation: false });
|
admission.admit({ relay: url, publicKey: retained.request.host, transport: 'nip42-nip59', ownerDelegation: false });
|
||||||
const wire = connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => {
|
const wire = connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => {
|
||||||
try { verifyHostRegistration(retained, retained.request); } catch { return; }
|
|
||||||
const m = input.message;
|
const m = input.message;
|
||||||
if (input.sender !== retained.request.owner || m.host !== retained.request.host || !['inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) return;
|
if (input.sender !== retained.request.owner || m.host !== retained.request.host || !['inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) return;
|
||||||
receive(m);
|
receive(m);
|
||||||
@@ -28,8 +28,7 @@ export function privateHostTransport(registration: HostRegistration, secret: str
|
|||||||
ready: wire.ready,
|
ready: wire.ready,
|
||||||
close: () => wire.close(),
|
close: () => wire.close(),
|
||||||
send(m: Message) {
|
send(m: Message) {
|
||||||
verifyHostRegistration(retained, retained.request);
|
if (m.host !== retained.request.host || !['availability', 'inventory', 'receipt'].includes(m.type)) throw Error('Invalid private host report');
|
||||||
if (m.host !== retained.request.host || !['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.
|
void wire.publish(m, retained.request.owner).catch(() => {}); // Host outbox/heartbeat retains retry state.
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One installation owns every slot, lock and management transport. */
|
/** 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();
|
signal?.throwIfAborted();
|
||||||
(transport?.validate ?? validateRelayURL)(url);
|
(transport?.validate ?? validateRelayURL)(url);
|
||||||
const lock = join(directory, 'host.lock');
|
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.
|
// Abort uses the same owner as ready shutdown, including quarantine fences.
|
||||||
const abort = () => { void close().catch(() => {}); };
|
const abort = () => { void close().catch(() => {}); };
|
||||||
try {
|
try {
|
||||||
const entries = await installationSlotsAsync(directory, credentials, signal);
|
const entries = transport && !existsSync(join(directory, 'setup.json')) ? [] : await installationSlotsAsync(directory, credentials, signal);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
signal?.addEventListener('abort', abort, { once: true });
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -596,18 +596,19 @@ export async function host(directory: string, url: string, signal?: AbortSignal,
|
|||||||
return value;
|
return value;
|
||||||
}, publish, profiles, entry.setupId, entry.bindings, entry.retiredBindings));
|
}, 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
|
// Every slot is hydrated before dialing. Initial WS history may arrive in the
|
||||||
// same event-loop turn as open, before the ready promise continuation.
|
// same event-loop turn as open, before the ready promise continuation.
|
||||||
initialized = true;
|
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;
|
if (!initialized) return;
|
||||||
profiles.receive(m);
|
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 });
|
const selected = slots.get(m.agent); trace('host.route', { id: m.id, type: m.type, selected: !!selected });
|
||||||
if (selected) selected.receive(m);
|
if (selected) selected.receive(m);
|
||||||
else if (m.type === 'inspect') { for (const s of slots.values()) s.replay(); }
|
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(); });
|
}, () => { if (initialized) for (const s of slots.values()) s.replay(); });
|
||||||
await client.ready;
|
await client.ready;
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
@@ -617,9 +618,10 @@ export async function host(directory: string, url: string, signal?: AbortSignal,
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
if (transport?.availability) publish(transport.availability());
|
||||||
for (const s of slots.values()) s.replay();
|
for (const s of slots.values()) s.replay();
|
||||||
heartbeat = setInterval(() => { for (const s of slots.values()) s.heartbeat(); }, 2000);
|
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() {
|
return { agent: [...slots.keys()][0], agents: [...slots.keys()], close() {
|
||||||
signal?.removeEventListener('abort', abort);
|
signal?.removeEventListener('abort', abort);
|
||||||
return close();
|
return close();
|
||||||
} };
|
} };
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createHash, createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
import { createHash, createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||||
import { schnorr } from '@noble/curves/secp256k1';
|
import { schnorr } from '@noble/curves/secp256k1';
|
||||||
|
|
||||||
/** Experimental private wire protocol; deliberately not a Nostr kind allocation. */
|
/** 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> };
|
* 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 };
|
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. */
|
/** Reject unknown envelope/message fields, oversized values and ambiguous revisions. */
|
||||||
export function object(value: unknown): Record<string, unknown> {
|
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 {
|
export function parseMessage(value: unknown): Message {
|
||||||
const m = object(value);
|
const m = object(value);
|
||||||
fields(m, ['v','id','host','agent','type','revision','body']);
|
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');
|
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;
|
return m as Message;
|
||||||
}
|
}
|
||||||
export function digest(value: string): Buffer { return createHash('sha256').update(value).digest(); }
|
export function digest(value: string): Buffer { return createHash('sha256').update(value).digest(); }
|
||||||
|
|||||||
@@ -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/);
|
assert.throws(() => productionAdmission.admit({ relay, publicKey: publicKey(a), transport: 'nip42-nip59', ownerDelegation: false }), /pending/);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const scenario of ['external', 'installed', 'v3-conversion']) {
|
for (const scenario of ['external', 'configured', 'installed', 'v3-conversion']) {
|
||||||
const installed = scenario !== 'external', converting = scenario === '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 () => {
|
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 root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-catalog-tui-')));
|
||||||
const owner = newKey(), a = newKey(), b = newKey(), agent = newKey();
|
const owner = newKey(), a = newKey(), b = newKey(), agent = newKey();
|
||||||
const members = new Set([owner, a, b].map(publicKey));
|
const members = new Set([owner, a, b].map(publicKey));
|
||||||
const relay = await nostrFixture(publicKey(owner), members);
|
const relay = await nostrFixture(publicKey(owner), members);
|
||||||
const one = registration(owner, a, relay.url), two = registration(owner, b, relay.url);
|
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 directory = join(root, 'host'); mkdirSync(directory);
|
||||||
const credentialFile = join(root, 'credentials.json');
|
const credentialFile = join(root, 'credentials.json');
|
||||||
const credentials = isolatedFileCredentials(credentialFile);
|
const credentials = isolatedFileCredentials(credentialFile);
|
||||||
credentials.create(credentialReference('host', publicKey(a)), a);
|
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;
|
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);
|
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');
|
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'}));`);
|
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');
|
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)})`)})};}});`);
|
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);
|
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 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); } };
|
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), () => {});
|
const foreign = connectNostr(relay.url, Buffer.from(b, 'hex'), undefined, m => foreignMessages.push(m), () => {});
|
||||||
await foreign.ready;
|
await foreign.ready;
|
||||||
const path = join(root, 'catalog.json'); writePrivate(path, catalog);
|
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 env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: credentialFile };
|
||||||
const child = spawn(process.execPath, ['src/cli.ts', 'tui', path, ...(installed ? [relay.url] : [])], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
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());
|
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); });
|
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}`); }
|
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}`); }
|
||||||
|
|||||||
145
beehive/test/host-availability.test.ts
Normal file
145
beehive/test/host-availability.test.ts
Normal 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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user