beehive: integrate private public-CLI Start reply Stop with fresh membership

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 14:39:55 -04:00
parent 7176028b7c
commit ac32ae0d24
9 changed files with 231 additions and 33 deletions

111
beehive/FIRST_DEMO.md Normal file
View File

@@ -0,0 +1,111 @@
# First private demo (owner-run, not yet a live demonstration)
The tested path is **public `host` CLI → private owner `tui` → Start → agent-signed
reply → Stop**. Automated evidence uses a loopback membership-enforced relay model,
a synthetic credential helper, the installed Buzz ACP/CLI and a deterministic
provider harness. It proves neither macOS Keychain readiness nor deployment/provider
compatibility. Do not use frozen or existing installations for this demo.
## Prerequisites
- Node 24.15.0 and this package's frozen standalone dependencies. In `beehive`,
invoke `/absolute/path/to/node src/cli.ts ...`; no root bootstrap is needed.
- A deliberately chosen **new** 0700 host directory, a dedicated host service OS
user with an interactive unlocked credential store, and a separate owner context.
`@napi-rs/keyring` 2.0.0 may show OS approval/unlock prompts; it cannot suppress
them. Only `beehive` service entries `host:<public-key>` and `agent:<public-key>`
are used. Never approve access to someone else's account/cache.
- Existing owner signer and an explicitly supplied demo agent key/authorization.
Agent enrollment/channel membership on the conversation relay is a separate
prerequisite: host registration is not an agent-owner grant.
- Relay operator has already enrolled the host and owner public keys as ordinary
direct members. This CLI currently supports **already-member verification**;
it does not mint invites or expose invite claims. The relay must advertise its
`self` key and NIP-43, with membership-gated signed `POST /query`, NIP-42 and
NIP-59 giftwraps. Open-relay AUTH/ACK and a saved roster are not admission.
- One installed supported provider configuration. The example below chooses Buzz
Agent / Databricks v2, dedicated service HOME/config, plus installed buzz-acp and
sibling Buzz CLI. Real provider/native protocol compatibility remains unverified.
Do not substitute a different provider until its configuration is intentionally
supplied. No login, inference, invite or production enrollment was run by workers.
## Manual setup → Start → reply → Stop
1. **Host, owner present:** `node src/cli.ts setup /new/private-host`
Choose `create`, supply label, owner's **public** hex key and `wss://relay-host`;
choose a new private pairing file. Expect OS key creation/readback approval.
If interrupted, use `export` on the same directory; never regenerate identity.
2. Transfer the pairing file privately to the owner. **Owner context:** run
`node src/cli.ts setup /new/owner-exchange`, choose `approve`, select the pairing,
compare the full fingerprint with the host, confirm `yes`, enter a future Unix
expiration and a new private approval file. Enter the existing owner key only
at the hidden prompt. Transfer the approval file privately back to the host.
3. **Host:** rerun `node src/cli.ts setup /new/private-host`, choose `import`, select
that approval. This registers infrastructure only. Arrange the **already-member**
prerequisite above out of band; there is no administrative operation here.
4. Prepare a private local `binding.json` (absolute paths, no identity keys):
```json
{
"mode": "buzz-agent-databricks-v2",
"runner": "/absolute/supported/buzz-agent",
"args": [],
"workspace": "/absolute/demo-workspace",
"serviceHome": "/absolute/dedicated-service-home",
"configDirectory": "/absolute/dedicated-service-config",
"databricksHost": "https://your-workspace.example",
"conversation": {
"executable": "/Applications/Buzz.app/Contents/MacOS/buzz-acp",
"relay": "wss://your-conversation-relay",
"replyTool": { "executable": "/Applications/Buzz.app/Contents/MacOS/buzz" }
}
}
```
Supply the owner's deliberately approved **initial demo placement** public
genesis file: `{ "v":1, "id":"<fresh UUID v4>", "owner":"<owner hex>",
"agent":"<demo agent hex>", "initialHost":"<paired host hex>" }`.
This is a locally pinned root, not a signed bearer grant. It must match the
intended agent and host. **Never replace an existing agent's retained genesis
or invent a new root to bypass its assignment**; use a separately authorized
initial demo identity/placement, not migration or Move.
Run `node src/cli.ts provision-agent /new/private-host /private/binding.json /private/genesis.json`.
Enter the matching agent key at the hidden prompt. Expect OS create/readback
prompts. The owner key is never placed on the host. If interrupted, retain all
artifacts and use `reconcile-provision` with these exact same inputs, not a new key.
5. Run `node src/cli.ts auth-info /new/private-host` for the exact service-user
native login context. Deliberately configure/login the supported provider as
that user only. No borrowed Desktop cache. Presence of files is not login proof.
6. **Owner:** `node src/cli.ts catalog /new/private-catalog.json /private/approval.json`.
7. **Host, owner present:**
`node src/cli.ts host /new/private-host wss://relay-host --owner-present`
The flag consents to live OS reads, not relay admin or provider login. Each key
read uses an owned helper with a 10s deadline; SIGINT/SIGTERM cancels and awaits
its actual close. An OS-owned dialog may remain after helper termination;
inspect/dismiss it deliberately. Denied/timeout is not a missing key. Expect a
fresh host-signed HTTP membership check, then `Host online`.
8. **Owner:** `node src/cli.ts tui /private/private-catalog.json wss://relay-host`.
Enter owner key at the hidden prompt. A separate owner-signed membership check
precedes private transport. Use `hosts`, `select <full host hex>`, `show`, then
`start`. Wait for `completed | accepted` and running actual state; publication
alone does not prove Start. From the owner's usual Buzz client, mention the demo
agent in its authorized conversation channel and observe its signed reply.
9. In the TUI, `stop`; wait for accepted receipt and `show` stopped/no actual run.
`quit` closes only the owner UI. Ctrl-C the foreground host for owned teardown.
Missing provider credentials do not prevent Stop. Preserve journals if teardown
reports uncertainty; do not clear locks or kill unrelated PIDs.
## Honest first-demo limits
Membership proof is fresh and single-use for one connection (10s admission window),
not indefinitely cached. After transport loss, no private automatic reconnect:
close/reopen the command to verify membership again. `reconcile` explains that
requirement instead of silently reusing proof. A live socket's revocation visibility
is governed by relay policy; this is not continuous membership monitoring.
Roster timestamps are not compared with local claim time. A fresh membership-gated
row check can pass with an old or lagging roster. Failed admission does not fall back
to public management, owner OA, another signer, or plaintext storage. Registrations,
catalogs, management inventory and lifecycle commands remain private. Move, profile
distribution and generalized recovery are outside this first demo.

View File

@@ -9,10 +9,10 @@ import { open, publicKey, type Envelope, type Message } from './protocol.ts';
export function catalogTransport(catalog: HostCatalog, admission: ScopedRelayAdmission = productionAdmission) {
return (url: string, secret: string, receive: (m: Message) => void, recovered?: () => void, disconnected?: (code: number) => void) => {
const retained = structuredClone(verifyHostCatalog(catalog, publicKey(secret), url));
admission.admit({ relay: url, publicKey: retained.owner, transport: 'nip42-nip59', ownerDelegation: false });
let closed = false;
let generation = 0;
function dial() {
admission.admit({ relay: url, publicKey: retained.owner, transport: 'nip42-nip59', ownerDelegation: false });
const current = ++generation;
return connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => {
if (closed || current !== generation) return;

View File

@@ -1,6 +1,10 @@
import { credentialHelperReader } from './credential-helper.ts';
import { systemCredentials } from './credential-store.ts';
import { privateHostTransport } from './host-transport.ts';
import { verifyProductionAdmission } from './relay-admission.ts';
import { installationPublicSlots } from './slots.ts';
import { provisionCredentialSlot, reconcileCredentialProvision, orphanCredentialSlots } from './credential-slots.ts';
import { readHostIdentity } from './host-identity.ts';
import { readHostIdentity, readHostIdentityAsync } from './host-identity.ts';
import { verifyHostRegistration } from './host-registration.ts';
import { setupOwner } from './host.ts';
import { buzzProviderInput, buzzProviderGuidance, databricksOAuthGuidance } from './buzz-provider.ts';
@@ -32,7 +36,7 @@ import { prepareConversation } from './conversation.ts';
const operationLabel = (m: Message) => `${m.host} ${m.type}${m.type === 'move' ? ` → ${String(m.body.target)}` : ''} | agent ${m.agent} | operation ${m.id}`;
const shellQuote = (s: string) => `'${s.replaceAll("'", "'\"'\"'")}'`;
const [command, ...args] = process.argv.slice(2);
const help = `Beehive — isolated development preview (loopback relay only)
const help = `Beehive — private host preview (explicit direct relay membership required)
identity Disabled: use your existing owner signer
setup <host-directory> Offline host pairing/owner approval/import
setup <host-directory> <identity-file> LEGACY loopback diagnostic setup (owner key copied)
@@ -51,12 +55,12 @@ const help = `Beehive — isolated development preview (loopback relay only)
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 <host-directory> <ws://127.0.0.1:port> Persistent foreground host
tui <identity-file> <ws://127.0.0.1:port> Relay-connected terminal UI
host <host-directory> <relay> --owner-present Private foreground host; bounded OS key reads
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.
reconcile-agent derives the interrupted binding and genesis from the retained journal; an optional public genesis file must match it.
assignment-export accepts agent public key after filename when several slots exist.
Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC or production relay support.`;
Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC. Private host/catalog requires fresh membership-enforced relay verification; no automatic private reconnect.`;
async function main() {
if (command === 'identity') {
throw Error('Standalone Beehive uses your existing owner identity through explicit secure input. Creating a parallel controller identity or persisting a plaintext owner key is disabled.');
@@ -316,7 +320,15 @@ async function main() {
const stop = () => controller.abort();
process.on('SIGINT', stop); process.on('SIGTERM', stop);
try {
const running = await host(resolve(text(args[0])), text(args[1]), controller.signal);
const directory = resolve(text(args[0])), url = text(args[1]);
const privateInstallation = existsSync(join(directory, 'host-identity.json'));
if (privateInstallation && args[2] !== '--owner-present') throw Error('Private host requires --owner-present: OS credential access may prompt. Run deliberately as the host OS user; Stop cancels and awaits the bounded helper.');
const credentials = privateInstallation ? { ...systemCredentials, readAsync: credentialHelperReader({ operatorApproved: true }) } : systemCredentials;
const identity = privateInstallation ? await readHostIdentityAsync(directory, credentials, controller.signal) : undefined;
if (identity && identity.pairing.relay !== url) throw Error('Wrong registered host relay; no network request sent');
const admission = identity ? await verifyProductionAdmission(url, identity.secret, controller.signal) : undefined;
const transport = identity ? privateHostTransport(verifyHostRegistration(identity.registration, identity.pairing), identity.secret, admission) : undefined;
const running = await host(directory, url, controller.signal, transport, credentials);
console.log(`Host online; agents ${running.agents.join(', ')}. Ctrl-C stops owned runner before host exits.`);
// The host owns teardown both before and after ready. Observe its one
// completion promise so uncertain teardown cannot disappear as a rejection.
@@ -335,12 +347,13 @@ async function main() {
const first = object(object(registrations[0]).request);
const catalog = verifyHostCatalog({ version: 1, owner: first.owner, relay: first.relay, registrations }, text(first.owner), text(first.relay));
writePrivate(resolve(text(args[0])), catalog, true);
console.log(`Retained ${catalog.registrations.length} verified host registrations. Public keys only; admission pending. Labels are not authority.`);
console.log(`Retained ${catalog.registrations.length} verified host registrations. Public keys only; fresh direct membership verification required when opening tui. Labels are not authority.`);
} else if (command === 'tui') {
const identity = object(readPrivate(resolve(text(args[0]))));
const catalog: HostCatalog | undefined = 'registrations' in identity ? verifyHostCatalog(identity, text(identity.owner), text(args[1])) : undefined;
const secret = catalog ? await readAgentSecret('Owner') : text(identity.secret);
if (catalog && publicKey(secret) !== catalog.owner) throw Error('Wrong catalog owner signer');
const admission = catalog ? await verifyProductionAdmission(text(args[1]), secret) : undefined;
const inventory = new Map<string,Message>();
const profiles = new Profiles();
const client = managementClient(join(dirname(resolve(text(args[0]))), 'management-intents'),text(args[1]),secret,m => {
@@ -354,7 +367,7 @@ async function main() {
}, () => {
console.log(client.connected ? '\nManagement relay connected.' : '\nRelay disconnected: pending results UNKNOWN; automatic reconnect is bounded (disabled on policy refusal). Use reconcile after checking relay policy.');
for (const operation of client.status()) console.log(`${operationLabel(operation.request)}: ${operation.state} | ${operation.result ?? operation.publication}`);
}, catalog ? { catalog } : undefined);
}, catalog ? { catalog, admission } : undefined);
try { await client.ready; } catch (error) { client.close(); throw error; }
const ui = createInterface({ input: stdin, output: stdout });
console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: binding <local-id>, configurations, config-new, config-select <name>, config-rename, config-remove <name>, profiles, profile-new, profile-edit <number>, apply <number|default>, operations, reconcile, retry <number>, hosts, agents, select <number or unique host>, show, save, start, restart, stop, move, quit. Closing this UI does not stop hosts.');
@@ -368,7 +381,7 @@ async function main() {
client.status().forEach((o, index) => console.log(`${index + 1}. ${operationLabel(o.request)} at revision ${o.request.revision}: ${o.state} | ${o.result ?? o.publication}${o.retryAvailable ? ` | reconcile, then retry ${index + 1}` : ''}`));
console.log('Historical results are not current host state. Reconcile queries receipts without retrying blocked work.'); continue;
}
if (line === 'reconcile') { client.reconcile(); console.log('Reconnecting/querying host results; blocked work stays blocked. Connection alone proves neither policy repair nor completion.'); continue; }
if (line === 'reconcile') { try { client.reconcile(); console.log('Reconnecting/querying host results; blocked work stays blocked. Connection alone proves neither policy repair nor completion.'); } catch (error) { console.log(error instanceof Error ? error.message : 'Reconnect refused'); } continue; }
if (line.startsWith('retry ')) {
const number = line.slice(6);
const operation = /^[1-9][0-9]*$/.test(number) ? client.status()[Number(number) - 1] : undefined;

View File

@@ -33,7 +33,7 @@ export async function enrollmentInput(directory: string): Promise<void> {
console.log('Registration signed. Owner secret not persisted. Transfer approval privately back to host. Relay admission remains pending.');
} else if (action === 'import') {
enrollHostIdentity(directory, readPrivate(resolve(text(await ui.question('Private approval file: ')))));
console.log('Host registered. Pending/no network: narrow relay admission is not implemented. No agent authorized or started.');
console.log('Host registered offline. Host startup requires an already-enrolled direct relay member and a fresh live row check. No agent authorized or started.');
} else throw Error('Unknown enrollment action');
} finally { ui.close(); }
}

View File

@@ -34,8 +34,24 @@ export function readHostIdentity(directory: string, credentials: CredentialBacke
const pairing = hostPairing(value.pairing);
const key = credentialReference('host', pairing.host);
if (JSON.stringify(value.key) !== JSON.stringify(key)) throw Error('Invalid host credential reference');
const secret = readCredential(key, credentials);
return { version: 3, pairing, secret, registration: value.registration as HostRegistration | null };
return { version: 3, pairing, secret: readCredential(key, credentials), registration: value.registration as HostRegistration | null };
}
/** Live startup uses the owner-approved bounded helper, never a synchronous OS call. */
export async function readHostIdentityAsync(directory: string, credentials: CredentialBackend, signal?: AbortSignal): Promise<HostIdentity> {
signal?.throwIfAborted();
const value = object(readPrivate(join(directory, 'host-identity.json')));
fields(value, ['version', 'pairing', 'key', 'registration']);
if (value.version !== 3) throw Error('Legacy identity retained; explicit consent required for migration');
const pairing = hostPairing(value.pairing);
const key = credentialReference('host', pairing.host);
if (JSON.stringify(value.key) !== JSON.stringify(key)) throw Error('Invalid host credential reference');
verifyHostRegistration(value.registration, pairing);
if (!credentials.readAsync) throw Error('Bounded asynchronous host credential reader required');
const secret = await credentials.readAsync(key, signal);
signal?.throwIfAborted();
if (secret === null) throw Error('Local host key missing; explicit matching import required');
if (publicKey(secret) !== pairing.host) throw Error('Credential identity mismatch');
return { version: 3, pairing, secret, registration: value.registration as HostRegistration };
}
/** Atomically import only an owner registration matching the exact retained request. */
export function enrollHostIdentity(directory: string, registration: unknown, now = Math.floor(Date.now() / 1000), credentials: CredentialBackend = systemCredentials): void {

View File

@@ -14,10 +14,10 @@ export function privateHostTransport(registration: HostRegistration, secret: str
validate(url: string) {
verifyHostRegistration(retained, retained.request);
if (url !== retained.request.relay) throw Error('Wrong host relay');
admission.admit({ relay: url, publicKey: retained.request.host, transport: 'nip42-nip59', ownerDelegation: false });
},
connect(url: string, _legacyOwnerSecret: string, receive: (m: Message) => void, _recovered?: () => void) {
this.validate(url);
admission.admit({ relay: url, publicKey: retained.request.host, transport: 'nip42-nip59', ownerDelegation: false });
const wire = connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => {
try { verifyHostRegistration(retained, retained.request); } catch { return; }
const m = input.message;

View File

@@ -1,3 +1,6 @@
import { finalizeEvent } from 'nostr-tools/pure';
import { publicKey } from './protocol.ts';
import { verifyDirectMembershipEvidence, type HostAuthorizationSigner } from './direct-membership.ts';
/** Admission proof supplied by a reviewed integration, never loaded from user JSON.
* Owner-approved route: ordinary direct relay membership, without NIP-OA.
* Registration alone or successful open-relay AUTH is not membership evidence.
@@ -9,3 +12,26 @@ export type ScopedRelayAdmission = {
export const productionAdmission: ScopedRelayAdmission = {
admit() { throw Error('Host relay admission pending: verified ordinary direct membership required (relay admin: buzz-admin add-member --pubkey <host-public-key> --role member); NIP-OA prohibited'); },
};
/** Infrastructure or owner signs only its own fresh HTTP authentication; no OA. */
export function membershipSigner(secret: string): HostAuthorizationSigner {
return { hostPublicKey: publicKey(secret), async signHttpAuthentication(request) {
return JSON.stringify(finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '',
tags: [['u', request.url], ['method', request.method], ['payload', request.payloadSha256Hex]] }, Buffer.from(secret, 'hex')));
} };
}
/** One fresh live row check authorizes ONE immediate connection. Reopen the command
* for another check after loss; a saved claim/roster/ACK is never an admission token. */
export async function verifyProductionAdmission(relay: string, secret: string, signal?: AbortSignal): Promise<ScopedRelayAdmission> {
const signer = membershipSigner(secret);
const evidence = await verifyDirectMembershipEvidence({ relay, signer, signal });
signal?.throwIfAborted();
if (evidence.status !== 'live-verified-member') throw Error(`Direct membership not verified (${evidence.status}); require membership-enforced relay and a fresh live row check`);
const deadline = Date.now() + 10_000;
let consumed = false;
return { admit(scope) {
if (consumed || Date.now() > deadline) throw Error('Fresh membership required; close and manually reopen command');
if (scope.relay !== relay || scope.publicKey !== signer.hostPublicKey || scope.transport !== 'nip42-nip59' || scope.ownerDelegation !== false) throw Error('Wrong direct membership scope');
consumed = true;
} };
}

View File

@@ -1,3 +1,5 @@
import { isolatedFileCredentials } from './isolated-file-credentials.ts';
import { credentialReference } from '../src/credential-store.ts';
import { importSlotKey, installationSlots } from '../src/slots.ts';
import { conversationRelayFixture } from './conversation-relay-fixture.ts';
import test from 'node:test';
@@ -10,15 +12,12 @@ import { setTimeout as delay } from 'node:timers/promises';
import { newKey, publicKey, message } from '../src/protocol.ts';
import { hostPairing, registerHost } from '../src/host-registration.ts';
import { verifyHostCatalog, catalogResponse, HostInventory } from '../src/host-catalog.ts';
import { privateHostTransport } from '../src/host-transport.ts';
import { host } from '../src/host.ts';
import { writePrivate } from '../src/storage.ts';
import { provisionCredentialSlot } from '../src/credential-slots.ts';
import { createGenesis } from '../src/assignment.ts';
import { memoryCredentials } from './credential-fixture.ts';
import { nostrFixture } from './nostr-fixture.ts';
import { connectNostr } from '../src/nostr-client.ts';
import { productionAdmission, type ScopedRelayAdmission } from '../src/relay-admission.ts';
import { productionAdmission } from '../src/relay-admission.ts';
function registration(owner: string, key: string, relay: string, label = 'same label') {
return registerHost(hostPairing({ version: 1, purpose: 'beehive-host-registration', host: publicKey(key), owner: publicKey(owner), label, relay, nonce: newKey() }), owner, Math.floor(Date.now() / 1000) + 600);
@@ -56,9 +55,11 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran
const relay = await nostrFixture(publicKey(owner), members);
const one = registration(owner, a, relay.url), two = registration(owner, b, relay.url);
const catalog = verifyHostCatalog({ version: 1, owner: publicKey(owner), relay: relay.url, registrations: [one, two] }, publicKey(owner), relay.url);
const admission: ScopedRelayAdmission = { admit(scope) { assert.equal(scope.relay, relay.url); assert.equal(scope.ownerDelegation, false); assert.ok(members.has(scope.publicKey)); } };
const directory = join(root, 'host'); mkdirSync(directory);
const credentials = memoryCredentials();
const credentialFile = join(root, 'credentials.json');
const credentials = isolatedFileCredentials(credentialFile);
credentials.create(credentialReference('host', publicKey(a)), a);
writePrivate(join(directory, 'host-identity.json'), { version: 3, pairing: one.request, key: credentialReference('host', publicKey(a)), registration: one });
const conversation = installed ? await conversationRelayFixture(root, owner, publicKey(agent)) : undefined;
provisionCredentialSlot(directory, { host: publicKey(a), ownerPublic: publicKey(owner), runner: realpathSync(process.execPath), args: [resolve(installed ? 'test/conversation-harness-fixture.ts' : 'test/runner.ts')], workspace: root, mode: installed ? 'buzz-agent-databricks-v2' : 'fixture', ...(conversation ? { serviceHome: root, configDirectory: root, databricksHost: 'https://fixture.invalid', ...(!converting ? { conversation: { executable: realpathSync(process.env.BEEHIVE_REAL_BUZZ_ACP!), relay: conversation.url, replyTool: { executable: realpathSync(join(process.env.BEEHIVE_REAL_BUZZ_ACP!, '..', 'buzz')) } } } : {}) } : {}) }, agent, createGenesis(publicKey(owner), publicKey(agent), publicKey(a)), credentials);
const journalPath = join(directory, 'agents', publicKey(agent), 'journal.json');
@@ -80,16 +81,22 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran
}
const manifest = readFileSync(join(directory, 'setup.json'), 'utf8');
assert.ok(!manifest.includes(owner) && !manifest.includes(agent));
const running = await host(directory, relay.url, undefined, privateHostTransport(one, a, admission), credentials);
const helper = join(root, 'credential-child.mjs');
writeFileSync(helper, `import { readFileSync } from 'node:fs'; let input=''; for await (const b of process.stdin) input+=b; const secret=JSON.parse(readFileSync(${JSON.stringify(credentialFile)},'utf8'))[JSON.stringify(JSON.parse(input))]; process.stdout.write(JSON.stringify(secret ? {status:'present',secret} : {status:'missing'}));`);
const loader = join(root, 'credential-helper-loader.mjs');
writeFileSync(loader, `import { registerHooks } from 'node:module'; registerHooks({load(url,ctx,next){const r=next(url,ctx); if(!url.endsWith('/src/credential-helper.ts'))return r;return {...r,source:String(r.source).replace("new URL('./credential-helper-child.ts', import.meta.url)", ${JSON.stringify(`new URL(${JSON.stringify('file://' + helper)})`)})};}});`);
const hostChild = spawn(process.execPath, ['--import', loader, 'src/cli.ts', 'host', directory, relay.url, '--owner-present'], { env: { PATH: '/usr/bin:/bin', HOME: root }, stdio: ['ignore','pipe','pipe'] });
let hostOutput = ''; hostChild.stdout.on('data', b => hostOutput += b); hostChild.stderr.on('data', b => hostOutput += b);
const hostExit = new Promise<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); } };
for (let n=0; n<400 && !hostOutput.includes('Host online'); n++) { if (hostChild.exitCode !== null) break; await delay(20); }
assert.match(hostOutput, /Host online/);
const foreignMessages: unknown[] = [];
const foreign = connectNostr(relay.url, Buffer.from(b, 'hex'), undefined, m => foreignMessages.push(m), () => {});
await foreign.ready;
const path = join(root, 'catalog.json'); writePrivate(path, catalog);
// Replace only the admission dependency, not CLI, catalog, transport, journal or executor.
const loader = join(root, 'admission-loader.mjs');
writeFileSync(loader, `import { registerHooks } from 'node:module'; registerHooks({load(url,ctx,next){const r=next(url,ctx); if(!url.endsWith('/src/relay-admission.ts'))return r;return {...r,source:'export const productionAdmission = { admit(s) { if(s.relay !== '+${JSON.stringify(JSON.stringify(relay.url))}+' || s.publicKey !== '+${JSON.stringify(JSON.stringify(publicKey(owner)))}+' || s.ownerDelegation !== false) throw Error("fixture admission scope"); } };'};}});`);
const env: NodeJS.ProcessEnv = { ...process.env }; delete env.BUZZ_PRIVATE_KEY; delete env.BUZZ_AUTH_TAG; delete env.BUZZ_RELAY_URL;
const child = spawn(process.execPath, ['--import', loader, 'src/cli.ts', 'tui', path, relay.url], { env, stdio: ['pipe', 'pipe', 'pipe'] });
const child = spawn(process.execPath, ['src/cli.ts', 'tui', path, relay.url], { env, stdio: ['pipe', 'pipe', 'pipe'] });
let output = ''; child.stdout.on('data', b => output += b.toString()); child.stderr.on('data', b => output += b.toString());
const exit = new Promise<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}`); }
@@ -106,8 +113,8 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran
if (converting) {
await command('binding normal');
let selected = '';
for (let n = 0; n < 80; n++) { selected = await command('show'); if (selected.includes('"revision": 1')) break; await delay(25); }
assert.match(selected, /"revision": 1/);
for (let n = 0; n < 80; n++) { selected = await command('show'); if (selected.includes('"revision": 1,\n "body"')) break; await delay(25); }
assert.ok(selected.includes('"revision": 1,\n "body"'), selected);
const saved = JSON.parse(readFileSync(journalPath, 'utf8'));
assert.equal(saved.selected.harnessSetup.id, 'normal'); assert.equal(saved.actual, null);
}
@@ -130,6 +137,11 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran
await wait(() => output.slice(stopAt).includes('accepted'));
for (let n = 0; n < 80; n++) { shown = await command('show'); if (shown.includes('"phase": "stopped"')) break; await delay(25); }
assert.match(shown, /"phase": "stopped"/);
if (conversation) for (const file of ['harness-pid', 'descendant-pid', 'tool-shim-pid']) {
const pid = Number(readFileSync(join(root, file), 'utf8'));
assert.ok(Number.isSafeInteger(pid) && pid > 0);
assert.throws(() => process.kill(pid, 0), (error: NodeJS.ErrnoException) => error.code === 'ESRCH', `${file} survives accepted Stop`);
}
const beforeReplay = readFileSync(journalPath, 'utf8');
await foreign.publish(message('start', publicKey(a), publicKey(agent), 2), publicKey(a));
await foreign.publish(message('inventory', publicKey(a), publicKey(agent), 999, { phase: 'FORGED', observedAt: Date.now() + 500 }), publicKey(owner));
@@ -144,7 +156,7 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran
const scope = join(intentDirectory, readdirSync(intentDirectory)[0]!);
const intent = readdirSync(scope).filter(n => n.endsWith('.intent')).map(n => JSON.parse(readFileSync(join(scope, n), 'utf8'))).map(v => open(v.envelope, owner)).find(m => m.type === 'start')!;
await ownerWire.publish(intent, publicKey(a));
await command('reconcile');
assert.match(await command('reconcile'), /Fresh membership required/);
await delay(100);
shown = await command('show');
assert.ok(!shown.includes('FORGED'));
@@ -153,6 +165,7 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran
assert.equal(Object.keys(journal.operations).length, converting ? 3 : 2);
} finally { ownerWire.close(); }
assert.equal(foreignMessages.length, 0, 'another admitted host cannot read owner inventory or commands');
assert.deepEqual(relay.liveChecks.sort(), [publicKey(a), publicKey(owner)].sort());
assert.ok(relay.history.every(e => e.kind === 1059 && e.tags.length === 1));
assert.ok(!output.includes(owner)); assert.ok(!output.includes(a)); assert.ok(!output.includes(agent));
const state = JSON.parse(readFileSync(journalPath, 'utf8'));

View File

@@ -1,6 +1,8 @@
import { createServer } from 'node:http';
import { newKey, publicKey, digest } from '../src/protocol.ts';
import { randomUUID } from 'node:crypto';
import { WebSocketServer, type WebSocket } from 'ws';
import { verifyEvent, type Event } from 'nostr-tools/pure';
import { finalizeEvent, verifyEvent, type Event } from 'nostr-tools/pure';
import { verifyHostAttestation } from '../src/host-attestation.ts';
/** Narrow executable model of Buzz 051c3a2 admission, NOT a deployed/Rust relay.
@@ -13,9 +15,26 @@ export async function nostrFixture(owner: string, independentMembers?: ReadonlyS
// api/mod.rs direct membership is distinct from ViaOwner. This is fixture
// policy only, NOT evidence that deployed membership has narrow permissions.
const members = independentMembers ? new Set(independentMembers) : undefined;
const server = new WebSocketServer({ host: '127.0.0.1', port: 0, maxPayload: 300000 });
await new Promise<void>(resolve => server.once('listening', resolve));
const address = server.address();
const self = newKey();
const liveChecks: string[] = [];
const http = createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json');
if (req.method === 'GET' && req.url === '/') { res.end(JSON.stringify({ self: publicKey(self), supported_nips: members ? [43] : [] })); return; }
let body = ''; for await (const chunk of req) { body += chunk; if (body.length > 4096) { res.writeHead(413).end(); return; } }
try {
const auth = JSON.parse(Buffer.from((req.headers.authorization ?? '').replace(/^Nostr /, ''), 'base64').toString());
if (!verifyEvent(auth) || auth.kind !== 27235 || auth.content !== '' || Math.abs(auth.created_at - Math.floor(Date.now()/1000)) > 60 ||
JSON.stringify(auth.tags) !== JSON.stringify([['u', url.replace('ws:', 'http:') + req.url], ['method', 'POST'], ['payload', digest(body).toString('hex')]])) throw Error('auth');
if (!members?.has(auth.pubkey)) { res.writeHead(403).end(JSON.stringify({ error: 'relay_membership_required' })); return; }
if (req.url !== '/query' || req.method !== 'POST') throw Error('route');
liveChecks.push(auth.pubkey);
// Deliberately lagging signed roster: fresh row proof does not depend on it.
res.end(JSON.stringify([finalizeEvent({ kind: 13534, created_at: 1, content: '', tags: [['-']] }, Buffer.from(self, 'hex'))]));
} catch { res.writeHead(401).end('{}'); }
});
const server = new WebSocketServer({ server: http, maxPayload: 300000 });
await new Promise<void>(resolve => http.listen(0, '127.0.0.1', resolve));
const address = http.address();
if (!address || typeof address === 'string') throw Error('Missing fixture address');
const url = `ws://127.0.0.1:${address.port}`;
const history: Event[] = [];
@@ -59,5 +78,5 @@ export async function nostrFixture(owner: string, independentMembers?: ReadonlyS
}
});
});
return { url, history, get independentWraps() { return independentWraps; }, async close() { for (const peer of connections.keys()) peer.terminate(); await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } };
return { url, history, liveChecks, get independentWraps() { return independentWraps; }, async close() { for (const peer of connections.keys()) peer.terminate(); await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())); await new Promise<void>((resolve, reject) => http.close(error => error ? reject(error) : resolve())); } };
}