mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
beehive: integrate private public-CLI Start reply Stop with fresh membership
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
111
beehive/FIRST_DEMO.md
Normal file
111
beehive/FIRST_DEMO.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
} };
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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())); } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user