From f2152725ebb3899687e343a548fa04ca1b6d98f3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 16:08:34 -0400 Subject: [PATCH] fix(beehive): remove mechanical setup and exchange inputs Signed-off-by: Logan Johnson --- beehive/FIRST_DEMO.md | 89 +++++++++++++++++++++------ beehive/src/cli.ts | 44 +++++++------ beehive/src/enrollment-input.ts | 62 +++++++++++++------ beehive/src/host-identity.ts | 10 ++- beehive/src/setup-path.ts | 8 +++ beehive/test/catalog-tui.test.ts | 4 +- beehive/test/enrollment-input.test.ts | 79 +++++++++++++++++++----- beehive/test/launcher.test.ts | 24 ++++---- 8 files changed, 232 insertions(+), 88 deletions(-) create mode 100644 beehive/src/setup-path.ts diff --git a/beehive/FIRST_DEMO.md b/beehive/FIRST_DEMO.md index 79198e0af4..aea6acf7d5 100644 --- a/beehive/FIRST_DEMO.md +++ b/beehive/FIRST_DEMO.md @@ -1,10 +1,40 @@ -# First private demo (owner-run, not yet a live demonstration) +# First private demo: setup → approval → Start + +Start with **`beehive setup`** on the computer that will run agents. +It prepares that computer, not an agent and not a relay account. The wizard saves +its request automatically and prints where it went and what to do next. + +**Already stopped at the old “NEW private pairing file” prompt?** Cancel that old +wizard with Ctrl-C. After refreshing the launcher to the published version, run +`beehive setup` again (or `beehive setup ` if you chose +one). It recognizes the retained identity; press Enter to export it. This needs +no key read or new identity and never overwrites an earlier request. Do **not** +choose create, delete host state, or reinitialize Keychain. If you already have +the returned approval, choose `import` instead. Keep its original fingerprint. + +Quick route (details and prerequisites below): +1. Host: `beehive setup` → create, then privately transfer the printed request. +2. Trusted owner computer: `beehive setup ~/.beehive/owner-exchange` → approve, + choose the transferred request, compare the full fingerprint, choose validity + (e.g. `7d`), and enter the existing owner key at the hidden prompt. +3. Host: `beehive setup` → import the returned approval. +4. Host: `beehive provision-agent ~/.beehive/host `, + then `beehive auth-info ~/.beehive/host` for deliberate provider configuration. +5. Owner: `beehive catalog ` → follow the printed `beehive tui` command. +6. Host: `beehive host --owner-present`; owner TUI: `hosts`, `select 1`, `show`, `start`. + +Outputs use new private names, not passwords or authorization tokens. Request and +approval contain private infrastructure/ownership metadata but no private keys; +transfer them privately. Shell and wizard path inputs in this route support `~` +and `~/…` (not `~another-user`). Explicit output choices remain available with +wizard `export-to` / `approve-to` and `catalog `. 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. +compatibility. Workers use isolated fixtures, never frozen installations or real +owner state; owner-run continuation preserves the existing host identity. ## Prerequisites @@ -21,9 +51,11 @@ compatibility. Do not use frozen or existing installations for this demo. `~/.beehive/host`, resolved from your home (never the current directory) and displayed once before the wizard. An occupied default is never silently reinitialized; pass an explicit directory for additional hosts. Owner - approval/catalog files below stay explicit and never land in host state. -- A deliberately chosen **new** 0700 host directory, a dedicated host service OS - user with an interactive unlocked credential store, and a separate owner context. + approval files use a separate owner exchange folder; automatic catalogs save + beside the approval file supplied to catalog. +- The retained/default private host directory, a deliberately chosen host service OS + user with an interactive unlocked credential store, and a trusted owner computer. + A fresh setup creates the directory with mode 0700; never reset an existing identity. `@napi-rs/keyring` 2.0.0 may show OS approval/unlock prompts; it cannot suppress them. Only `beehive` service entries `host:` and `agent:` are used. Never approve access to someone else's account/cache. @@ -45,17 +77,23 @@ compatibility. Do not use frozen or existing installations for this demo. 1. **Host, owner present:** `beehive setup` (default host folder `~/.beehive/host`; `beehive setup ` - for an additional 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. + for an additional host). Choose `create`, accept the computer name or supply a label, then supply the + owner's **public** hex key (from the existing owner identity) and the management + relay URL (from your community operator). The request saves automatically under + `/exchange/` with a unique name. 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 - `beehive setup /new/owner-exchange` (an explicit directory keeps owner +2. Transfer the pairing file privately to the owner. **Trusted owner computer:** run + `beehive setup ~/.beehive/owner-exchange` (a separate directory keeps owner approval files outside host state), 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 + compare the full fingerprint with the host, confirm `yes`, choose approval validity such as `7d` + (future Unix seconds also work). The approval saves automatically under this + owner folder's `exchange/` with a unique name. Enter the existing owner key only at the hidden prompt. Transfer the approval file privately back to the host. 3. **Host:** rerun `beehive setup` on the same default host folder, choose `import`, select - that approval. This registers infrastructure only. Arrange the **already-member** + that approval. The wizard explains where to transfer it for an Enter default: + `/exchange/approval-.json`; otherwise supply its + actual path. Only this exact path is offered, never a search through your files, + and signature/request checks still apply. 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): @@ -91,18 +129,21 @@ compatibility. Do not use frozen or existing installations for this demo. 5. Run `beehive auth-info ~/.beehive/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:** `beehive catalog /new/private-catalog.json /private/approval.json`. +6. **Owner:** `beehive catalog /private/approval.json`. It saves a new private + catalog beside the approval and prints the exact next TUI command. The explicit + `catalog /new/private-catalog.json /private/approval.json` form still works. 7. **Host, owner present:** - `beehive host wss://relay-host --owner-present` - (default host folder; the explicit `beehive host + `beehive host --owner-present` + (default host folder and its retained registered relay; the explicit `beehive host --owner-present` form remains available). 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:** `beehive tui /private/private-catalog.json wss://relay-host`. +8. **Owner:** `beehive tui ` (uses the verified catalog relay; an + explicit matching relay argument remains supported). Enter owner key at the hidden prompt. A separate owner-signed membership check - precedes private transport. Use `hosts`, `select `, `show`, then + precedes private transport. Use `hosts`, `select 1` (the intended numbered host/agent row), `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. @@ -111,7 +152,19 @@ compatibility. Do not use frozen or existing installations for this demo. 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 +## Remaining deliberate inputs and first-demo limits + +The binding file is still necessary: Beehive cannot infer a trusted installed +provider/runtime, allowed workspace, dedicated service HOME/config, conversation +relay, or login consent. Its JSON paths are absolute paths, not shell expressions. +The owner-approved genesis still explicitly pins the intended agent and initial +host: it is not safe to synthesize a replacement root for an existing agent. +This PoC does not yet integrate owner-approved initial placement/provider setup +into a single wizard. Keep those two input files private (0600); registration +alone does not authorize an agent or grant conversation channel membership. +No internal revisions or CAS IDs are needed for first Start: the TUI supplies them +from the selected fresh host report. Do not use legacy two-argument setup to bypass +this private enrollment path. Membership proof is fresh and single-use for one connection (10s admission window), not indefinitely cached. After transport loss, no private automatic reconnect: diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index a80d6fe45a..9870c58b88 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -1,10 +1,12 @@ +import { setupPath } from './setup-path.ts'; +import { randomUUID } from 'node:crypto'; 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, readHostIdentityAsync } from './host-identity.ts'; +import { readHostIdentity, readHostIdentityPublic, readHostIdentityAsync } from './host-identity.ts'; import { verifyHostRegistration } from './host-registration.ts'; import { setupOwner } from './host.ts'; import { buzzProviderInput, buzzProviderGuidance, databricksOAuthGuidance } from './buzz-provider.ts'; @@ -37,8 +39,8 @@ 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); -// Default HOST state only, resolved from the current user's home (never cwd). Owner approval/catalog -// files stay explicit and never land in this folder. An occupied default is never reinitialized. +// Default HOST state only, resolved from the current user's home (never cwd). +// Owner approval uses a separately chosen owner exchange folder. An occupied default is never reinitialized. const defaultHostDirectory = () => join(homedir(), '.beehive', 'host'); const help = `Beehive — private host preview (explicit direct relay membership required) Command: beehive from any directory once installed (one-time user link, reversible: ln -s /bin/beehive.cjs /beehive, e.g. ~/.local/bin); source fallback inside the package: node src/cli.ts . @@ -48,6 +50,7 @@ Command: beehive from any directory once installed (one-time user link setup LEGACY loopback diagnostic setup (owner key copied) provision-agent Hidden matching agent import; OS credentials reconcile-provision Explicit exact first-provision recovery + catalog Automatic private catalog beside approval; prints tui command catalog Retain verified public host registrations presets Local process-free preset discovery/setup guidance local-setup Bindings; new/reuse/hidden standby/restore identity @@ -61,11 +64,12 @@ Command: beehive from any directory once installed (one-time user link auth-info [binding-id] Print local harness service context (no login) conversation-setup Legacy guidance; use local-setup action normal relay Dedicated ciphertext relay + host --owner-present Default host and retained registered relay host --owner-present Private foreground host on the default host folder; bounded OS key reads host --owner-present Private foreground host on an explicit folder; bounded OS key reads - tui Private owner UI; hidden existing owner signer + tui [relay] Private owner UI; hidden existing owner signer setup/add-agent accept optional for standby import. -The default host folder resolves from your home (~/.beehive/host), never the current directory; setup displays it once and never reinitializes an existing installation. Owner approval/catalog files remain explicit and separate from host state. +The default host folder resolves from your home (~/.beehive/host), never the current directory; setup displays it once and never reinitializes an existing installation. Owner approval files are automatic in the separately chosen owner exchange folder. catalog saves beside the approval. Enrollment, provision-agent, auth-info, catalog, host and tui paths accept ~ and ~/. Explicit relay overrides must match registration. reconcile-agent derives the interrupted binding and genesis from the retained journal; an optional public genesis file must match it. assignment-export accepts agent public key after filename when several slots exist. Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC. Private host/catalog requires fresh membership-enforced relay verification; no automatic private reconnect.`; @@ -73,13 +77,13 @@ 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.'); } else if (command === 'provision-agent' || command === 'reconcile-provision') { - const directory = resolve(text(args[0])); + const directory = setupPath(text(args[0])); const identity = readHostIdentity(directory); verifyHostRegistration(identity.registration, identity.pairing); - const binding = object(readPrivate(resolve(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'); const setup = validateSetup({ ...binding, host: identity.pairing.host, ownerPublic: identity.pairing.owner }); - const genesis = validateGenesis(readPrivate(resolve(text(args[2])))); + const genesis = validateGenesis(readPrivate(setupPath(text(args[2])))); const secret = await readAgentSecret(); if (command === 'reconcile-provision') reconcileCredentialProvision(directory, setup, secret, genesis); else provisionCredentialSlot(directory, setup, secret, genesis); @@ -92,7 +96,7 @@ async function main() { args[0] = defaultHostDirectory(); console.log(`Default host folder: ${args[0]} (pass an explicit directory for another host; existing installations are never reinitialized)`); } - const dir = resolve(text(args[0])); + const dir = setupPath(text(args[0])); if (args.length === 1 && !existsSync(join(dir, 'setup.json'))) { await enrollmentInput(dir); return; } @@ -296,7 +300,7 @@ async function main() { throw Error('Immutable bindings cannot be rewritten: use migrate-slots first for a legacy installation, then local-setup action normal to convert the selected ACP binding to a NEW reference'); } finally { rmdirSync(lock); } } else if (command === 'auth-info') { - const entries = installationPublicSlots(resolve(text(args[0]))); + const entries = installationPublicSlots(setupPath(text(args[0]))); const setup = args[1] ? entries[0]!.bindings[text(args[1])] : entries[0]!.setup; if (!setup) throw Error('Unknown local binding'); if (setup.mode === 'diagnostic-acp') { console.log('Setup/diagnostic only: authentication unverified, no model/native profile contract. Configure locally as the service user; no login/probe was run.'); return; } @@ -336,10 +340,11 @@ async function main() { // Either the classic form, or the default host // folder with the relay first (beehive host --owner-present). const relayFirst = args.length > 0 && /^(wss|ws):\/\//.test(args[0]); - const directory = args.length === 0 || relayFirst ? defaultHostDirectory() : resolve(text(args[0])); - const url = text(args[relayFirst ? 0 : 1]); + const directory = args.length === 0 || args[0] === '--owner-present' || relayFirst ? defaultHostDirectory() : setupPath(text(args[0])); + const suppliedRelay = args[relayFirst ? 0 : 1]; + const url = suppliedRelay && suppliedRelay !== '--owner-present' ? text(suppliedRelay) : readHostIdentityPublic(directory).pairing.relay; const privateInstallation = existsSync(join(directory, 'host-identity.json')); - if (privateInstallation && args[relayFirst ? 1 : 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.'); + if (privateInstallation && !args.includes('--owner-present')) throw Error('Private host requires --owner-present: OS credential access may prompt. Run deliberately as the host OS user; Stop cancels and awaits the bounded helper.'); const credentials = privateInstallation ? { ...systemCredentials, readAsync: credentialHelperReader({ operatorApproved: true }) } : systemCredentials; const identity = privateInstallation ? await readHostIdentityAsync(directory, credentials, controller.signal) : undefined; if (identity && identity.pairing.relay !== url) throw Error('Wrong registered host relay; no network request sent'); @@ -359,21 +364,24 @@ async function main() { // Transport ready rejection caused by intentional startup cancellation. } finally { process.off('SIGINT', stop); process.off('SIGTERM', stop); } } else if (command === 'catalog') { - if (args.length < 2) throw Error('Catalog requires private registration files'); - const registrations = args.slice(1).map(file => readPrivate(resolve(text(file)))); + if (args.length < 1) throw Error('Use beehive catalog , or catalog '); + const registrations = (args.length === 1 ? args : args.slice(1)).map(file => readPrivate(setupPath(text(file)))); 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); + const file = args.length === 1 ? join(dirname(setupPath(text(args[0]))), `catalog-${randomUUID()}.json`) : setupPath(text(args[0])); + writePrivate(file, catalog, true); + console.log(`Catalog saved: ${file}\nNext on owner computer: beehive tui ${shellQuote(file)}`); console.log(`Retained ${catalog.registrations.length} verified host registrations. Public keys only; fresh direct membership verification required when opening tui. Labels are not authority.`); } else if (command === 'tui') { - const identity = object(readPrivate(resolve(text(args[0])))); + const identity = object(readPrivate(setupPath(text(args[0])))); + if (args[1] === undefined && 'registrations' in identity) args[1] = text(identity.relay); const catalog: HostCatalog | undefined = 'registrations' in identity ? verifyHostCatalog(identity, text(identity.owner), text(args[1])) : undefined; const secret = catalog ? await readAgentSecret('Owner') : text(identity.secret); if (catalog && publicKey(secret) !== catalog.owner) throw Error('Wrong catalog owner signer'); const admission = catalog ? await verifyProductionAdmission(text(args[1]), secret) : undefined; const inventory = new Map(); const profiles = new Profiles(); - const client = managementClient(join(dirname(resolve(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); if (m.type === 'inventory') { const key = JSON.stringify([m.host, m.agent]); diff --git a/beehive/src/enrollment-input.ts b/beehive/src/enrollment-input.ts index e890497256..e37fde00f0 100644 --- a/beehive/src/enrollment-input.ts +++ b/beehive/src/enrollment-input.ts @@ -1,39 +1,63 @@ import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; -import { resolve } from 'node:path'; -import { bootstrapHostIdentity, enrollHostIdentity, readHostIdentity } from './host-identity.ts'; -import { hostPairing, pairingFingerprint, registerHost } from './host-registration.ts'; +import { join } from 'node:path'; +import { hostname } from 'node:os'; +import { existsSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { bootstrapHostIdentity, enrollHostIdentity, readHostIdentityPublic } from './host-identity.ts'; +import { hostPairing, pairingFingerprint, registerHost, verifyHostRegistration } from './host-registration.ts'; import { readPrivate, writePrivate } from './storage.ts'; import { readAgentSecret } from './key-input.ts'; import { text } from './protocol.ts'; +import { setupPath } from './setup-path.ts'; +const quote = (s: string) => `'${s.replaceAll("'", "'\"'\"'")}'`; /** Standalone offline enrollment UI. File exchange avoids circular relay bootstrap. */ export async function enrollmentInput(directory: string): Promise { + const retained = existsSync(join(directory, 'host-identity.json')) ? readHostIdentityPublic(directory) : undefined; + const exchange = join(directory, 'exchange'); const ui = createInterface({ input: stdin, output: stdout }); try { - console.log('Private infrastructure enrollment. No relay connection, agent authority, or runtime launch. Approval must run in your owner context, not on an untrusted host.'); - const action = await ui.question('Enrollment [create / export / approve / import / cancel]: '); + console.log('Prepare this computer → owner approval → import. No relay connection, agent authorization, or Start. Approve on the trusted owner computer, never enter the owner private key on the host.'); + if (retained) console.log(`Existing computer identity retained: ${retained.pairing.label}. Enter export to continue without creating or reading a key, or import when approval returns.`); + console.log('create/export save requests automatically; export-to/approve-to let you choose a new output path.'); + const action = (await ui.question(`Enrollment [${retained ? 'export (Enter) / import / export-to / cancel' : 'create / approve / approve-to / cancel'}]: `)) || (retained ? 'export' : ''); if (action === 'cancel') return; - if (action === 'create' || action === 'export') { - const identity = action === 'create' ? bootstrapHostIdentity(directory, text(await ui.question('Host label: ')), text(await ui.question('Owner PUBLIC key (hex): ')), text(await ui.question('Relay URL: '))) : readHostIdentity(directory); - console.log(`Pairing fingerprint: ${pairingFingerprint(identity.pairing)}`); - const file = resolve(text(await ui.question('NEW private pairing file: '))); + if (action === 'create' || action === 'export' || action === 'export-to') { + if (action === 'create' && retained) throw Error('Host identity already exists. Rerun setup and choose export or import; do not reset it.'); + const identity = action === 'create' ? bootstrapHostIdentity(directory, + (await ui.question(`Computer name [${hostname()}]: `)) || hostname(), + text(await ui.question('Owner PUBLIC key (hex, from your existing owner identity; never the private key): ')), + text(await ui.question('Management relay URL (wss://… from your community operator; membership is separate): '))) : readHostIdentityPublic(directory); + const fingerprint = pairingFingerprint(identity.pairing); + console.log(`Pairing fingerprint: ${fingerprint}`); + const file = action === 'export-to' ? setupPath(text(await ui.question('New private request output path (~ supported): '))) : join(exchange, `request-${fingerprint}-${randomUUID()}.json`); writePrivate(file, identity.pairing, true); - console.log('Pairing exported. Transfer privately to your standalone owner context. Pending/no network. Export can be repeated if interrupted.'); - } else if (action === 'approve') { - const request = hostPairing(readPrivate(resolve(text(await ui.question('Private pairing file: '))))); + console.log(`Request saved: ${file}\nTransfer this file privately to the owner computer (contains public keys and private infrastructure metadata, no secrets). There run beehive setup ~/.beehive/owner-exchange, choose approve and supply the transferred file. Compare the full fingerprint above. Re-export is safe; older files are never replaced.`); + } else if (action === 'approve' || action === 'approve-to') { + if (retained) throw Error('This folder belongs to a host. Approve on the trusted owner computer using beehive setup ~/.beehive/owner-exchange; no owner key requested here.'); + const request = hostPairing(readPrivate(setupPath(text(await ui.question('Private pairing file (transferred from host, ~ supported): '))))); console.log(`Host: ${request.host}\nLabel: ${request.label}\nOwner: ${request.owner}\nRelay: ${request.relay}\nFingerprint: ${pairingFingerprint(request)}`); if (await ui.question('Compare full fingerprint with host. Register ONLY this infrastructure (not agents or relay delegation)? [yes/no]: ') !== 'yes') return; - const expires = Number(await ui.question('Registration expiration (Unix seconds): ')); - if (!Number.isSafeInteger(expires) || expires <= Math.floor(Date.now() / 1000)) throw Error('Expiration must be in the future'); - const file = resolve(text(await ui.question('NEW private approval file: '))); + const duration = await ui.question('Approval validity (e.g. 7d for seven days, or future Unix seconds; choose how long to trust this host): '); + const expires = /^\d+d$/.test(duration) ? Math.floor(Date.now() / 1000) + Number(duration.slice(0, -1)) * 86400 : Number(duration); + if (!Number.isSafeInteger(expires) || expires <= Math.floor(Date.now() / 1000)) throw Error('Expiration must be in the future; use e.g. 7d or future Unix seconds'); + console.log(`Approval expires: ${new Date(expires * 1000).toISOString()}`); + const file = action === 'approve-to' ? setupPath(text(await ui.question('New private approval output path (~ supported): '))) : join(exchange, `approval-${pairingFingerprint(request)}-${randomUUID()}.json`); ui.close(); const secret = await readAgentSecret('Owner'); writePrivate(file, registerHost(request, secret, expires), true); - console.log('Registration signed. Owner secret not persisted. Transfer approval privately back to host. Relay admission remains pending.'); + console.log(`Approval saved: ${file}\nOwner secret not persisted. Transfer privately back to host, then run beehive setup and choose import. You may put it at the host's displayed default approval path.\nOn this owner computer next: beehive catalog ${quote(file)}\nRelay admission remains pending.`); } else if (action === 'import') { - enrollHostIdentity(directory, readPrivate(resolve(text(await ui.question('Private approval file: '))))); - console.log('Host registered offline. Pending/no network: relay admission remains pending. 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'); + if (!retained) throw Error('No host identity here. Run setup on the original host folder; do not create a replacement identity.'); + const expected = join(exchange, `approval-${pairingFingerprint(retained.pairing)}.json`); + console.log(`Transfer the owner approval here for an Enter default: ${expected}. Or enter its actual transferred path. Filename is not authorization; exact request and owner signature are checked.`); + const supplied = await ui.question(`Private approval file (~ supported${existsSync(expected) ? ', Enter for displayed path' : '; transferred from owner'}): `); + if (!supplied && !existsSync(expected)) throw Error('No approval at the displayed path. Transfer the owner-signed approval, then rerun setup and choose import.'); + const approval = readPrivate(supplied ? setupPath(supplied) : expected); + verifyHostRegistration(approval, retained.pairing); + enrollHostIdentity(directory, approval); + console.log(`Host registered offline. Pending/no network: relay admission remains pending. Ask your relay operator to enroll the host and owner as direct members. No agent authorized or started.\nNext: prepare binding and owner-approved agent genesis as in FIRST_DEMO.md, then beehive provision-agent ${quote(directory)} . Provider login is separate; beehive auth-info ${quote(directory)} prints its context. Then beehive host ${quote(directory)} --owner-present.`); + } else throw Error('Choose create to prepare a new computer, export to resume, approve on the owner computer, or import a returned approval'); } finally { ui.close(); } } diff --git a/beehive/src/host-identity.ts b/beehive/src/host-identity.ts index 467682d6a5..aa348f6771 100644 --- a/beehive/src/host-identity.ts +++ b/beehive/src/host-identity.ts @@ -28,13 +28,17 @@ export function bootstrapHostIdentity(directory: string, label: string, owner: s } /** Reject legacy broad OA records rather than silently migrating their authority. */ export function readHostIdentity(directory: string, credentials: CredentialBackend = systemCredentials): HostIdentity { + const value = readHostIdentityPublic(directory); + return { ...value, secret: readCredential(credentialReference('host', value.pairing.host), credentials) }; +} +/** Read only Beehive-owned public enrollment state; exporting never needs credential access. */ +export function readHostIdentityPublic(directory: string): Omit { 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'); - return { version: 3, pairing, secret: readCredential(key, credentials), registration: value.registration as HostRegistration | null }; + if (JSON.stringify(value.key) !== JSON.stringify(credentialReference('host', pairing.host))) throw Error('Invalid host credential reference'); + return { version: 3, pairing, registration: value.registration as HostRegistration | null }; } /** Live startup uses the owner-approved bounded helper, never a synchronous OS call. */ export async function readHostIdentityAsync(directory: string, credentials: CredentialBackend, signal?: AbortSignal): Promise { diff --git a/beehive/src/setup-path.ts b/beehive/src/setup-path.ts new file mode 100644 index 0000000000..93222f0b3c --- /dev/null +++ b/beehive/src/setup-path.ts @@ -0,0 +1,8 @@ +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; + +/** Resolve an explicitly supplied local path, including conventional ~/ spelling. */ +export function setupPath(value: string): string { + if (value.startsWith('~') && value !== '~' && !value.startsWith('~/')) throw Error('Use ~ or ~/ for your home; ~user paths are not supported'); + return resolve(value === '~' ? homedir() : value.startsWith('~/') ? `${homedir()}/${value.slice(2)}` : value); +} diff --git a/beehive/test/catalog-tui.test.ts b/beehive/test/catalog-tui.test.ts index f758ec247c..72b5e18408 100644 --- a/beehive/test/catalog-tui.test.ts +++ b/beehive/test/catalog-tui.test.ts @@ -85,7 +85,7 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran writeFileSync(helper, `import { readFileSync } from 'node:fs'; let input=''; for await (const b of process.stdin) input+=b; const secret=JSON.parse(readFileSync(${JSON.stringify(credentialFile)},'utf8'))[JSON.stringify(JSON.parse(input))]; process.stdout.write(JSON.stringify(secret ? {status:'present',secret} : {status:'missing'}));`); const loader = join(root, 'credential-helper-loader.mjs'); writeFileSync(loader, `import { registerHooks } from 'node:module'; registerHooks({load(url,ctx,next){const r=next(url,ctx); if(!url.endsWith('/src/credential-helper.ts'))return r;return {...r,source:String(r.source).replace("new URL('./credential-helper-child.ts', import.meta.url)", ${JSON.stringify(`new URL(${JSON.stringify('file://' + helper)})`)})};}});`); - const hostChild = spawn(process.execPath, ['--import', loader, 'src/cli.ts', 'host', directory, relay.url, '--owner-present'], { env: { PATH: '/usr/bin:/bin', HOME: root }, stdio: ['ignore','pipe','pipe'] }); + 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'] }); let hostOutput = ''; hostChild.stdout.on('data', b => hostOutput += b); hostChild.stderr.on('data', b => hostOutput += b); const hostExit = new Promise(resolve => hostChild.on('close', resolve)); const running = { async close() { if (hostChild.exitCode === null && hostChild.signalCode === null) hostChild.kill('SIGTERM'); assert.equal(await hostExit, 0, hostOutput); } }; @@ -96,7 +96,7 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran await foreign.ready; const path = join(root, 'catalog.json'); writePrivate(path, catalog); const env: NodeJS.ProcessEnv = { ...process.env }; delete env.BUZZ_PRIVATE_KEY; delete env.BUZZ_AUTH_TAG; delete env.BUZZ_RELAY_URL; - const child = spawn(process.execPath, ['src/cli.ts', 'tui', path, relay.url], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn(process.execPath, ['src/cli.ts', 'tui', path, ...(installed ? [relay.url] : [])], { env, stdio: ['pipe', 'pipe', 'pipe'] }); let output = ''; child.stdout.on('data', b => output += b.toString()); child.stderr.on('data', b => output += b.toString()); const exit = new Promise((done, reject) => { child.on('exit', done); child.on('error', reject); }); async function wait(predicate: () => boolean) { for (let n = 0; n < (installed ? 3000 : 400); n++) { if (predicate()) return; if (child.exitCode !== null) throw Error(output); await delay(20); } throw Error(`TUI observation timeout: ${output}`); } diff --git a/beehive/test/enrollment-input.test.ts b/beehive/test/enrollment-input.test.ts index 17efc37e02..b40e5a33e1 100644 --- a/beehive/test/enrollment-input.test.ts +++ b/beehive/test/enrollment-input.test.ts @@ -2,18 +2,22 @@ import { isolatedFileCredentials } from './isolated-file-credentials.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, copyFileSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { newKey, publicKey } from '../src/protocol.ts'; import { readHostIdentity, requireHostEnrollment } from '../src/host-identity.ts'; -import { pairingFingerprint } from '../src/host-registration.ts'; +import { setupPath } from '../src/setup-path.ts'; +import { homedir } from 'node:os'; +import { createGenesis } from '../src/assignment.ts'; +import { writePrivate } from '../src/storage.ts'; +import { pairingFingerprint, registerHost } from '../src/host-registration.ts'; -async function wizard(directory: string, steps: [string, string][], credentialFile: string): Promise { - const env: NodeJS.ProcessEnv = { ...process.env, BEEHIVE_TEST_CREDENTIAL_FILE: credentialFile }; +async function wizard(directory: string, steps: [string, string][], credentialFile: string, expectedCode = 0, home?: string, command = 'setup', extra: string[] = []): Promise { + const env: NodeJS.ProcessEnv = { ...process.env, ...(home ? { HOME: home } : {}), BEEHIVE_TEST_CREDENTIAL_FILE: credentialFile }; delete env.BUZZ_PRIVATE_KEY; delete env.BUZZ_AUTH_TAG; delete env.BUZZ_RELAY_URL; - const child = spawn(process.execPath, ['--import', fileURLToPath(new URL('./isolated-credentials-loader.ts', import.meta.url)), fileURLToPath(new URL('../src/cli.ts', import.meta.url)), 'setup', directory], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn(process.execPath, ['--import', fileURLToPath(new URL('./isolated-credentials-loader.ts', import.meta.url)), fileURLToPath(new URL('../src/cli.ts', import.meta.url)), command, directory, ...extra], { env, stdio: ['pipe', 'pipe', 'pipe'] }); let output = '', cursor = 0, next = 0; const timer = setTimeout(() => child.kill(), 10000); child.stdout.on('data', data => { @@ -27,7 +31,7 @@ async function wizard(directory: string, steps: [string, string][], credentialFi child.stderr.on('data', data => { output += data.toString(); }); try { const code = await new Promise((resolve, reject) => { child.on('error', reject); child.on('exit', resolve); }); - assert.equal(code, 0, output); assert.equal(next, steps.length, output); + assert.equal(code, expectedCode, output); assert.equal(next, steps.length, output); return output; } finally { clearTimeout(timer); child.stdin.end(); } } @@ -35,33 +39,78 @@ async function wizard(directory: string, steps: [string, string][], credentialFi test('actual standalone CLI private pairing, existing-owner approval and bootstrap return stay offline', async () => { const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-')); const host = join(root, 'host'), ownerContext = join(root, 'owner-context'); - const request = join(root, 'request.json'), approval = join(root, 'approval.json'); + let request: string, approval: string; const ownerSecret = newKey(); const credentialFile = join(root, 'isolated-fixture-secrets.json'); const credentials = isolatedFileCredentials(credentialFile); try { const created = await wizard(host, [ - ['Enrollment [', 'create'], ['Host label: ', 'desktop'], ['Owner PUBLIC key (hex): ', publicKey(ownerSecret)], - ['Relay URL: ', 'wss://example.invalid'], ['NEW private pairing file: ', request], + ['Enrollment [', 'create'], ['Computer name [', ''], ['Owner PUBLIC key (hex,', publicKey(ownerSecret)], + ['Management relay URL (', 'wss://example.invalid'], ], credentialFile); + request = join(host, 'exchange', readdirSync(join(host, 'exchange'))[0]!); + assert.match(created, /Request saved:/); + assert.ok(!created.includes('output path (~ supported):')); const original = readHostIdentity(host, credentials); + const identityBytes = readFileSync(join(host, 'host-identity.json')); + // Re-export reads only retained public state, not even the isolated credential store. + const poison = join(root, 'must-not-read.json'); writeFileSync(poison, 'invalid json'); + const resumed = await wizard(host, [['Enrollment [', '']], poison); + assert.match(resumed, /Existing computer identity retained/); + assert.equal(readdirSync(join(host, 'exchange')).length, 2); + assert.deepEqual(readFileSync(join(host, 'host-identity.json')), identityBytes); + assert.deepEqual(JSON.parse(readFileSync(request, 'utf8')), original.pairing); + const explicit = await wizard(host, [['Enrollment [', 'export-to'], ['output path (~ supported): ', '~/manual-request.json']], poison, 0, root); + assert.match(explicit, /manual-request.json/); + const manualBytes = readFileSync(join(root, 'manual-request.json')); + const refused = await wizard(host, [['Enrollment [', 'export-to'], ['output path (~ supported): ', '~/manual-request.json']], poison, 1, root); + assert.match(refused, /EEXIST/); + assert.deepEqual(readFileSync(join(root, 'manual-request.json')), manualBytes); + const missing = await wizard(host, [['Enrollment [', 'import'], ['Private approval file (', '']], poison, 1); + assert.match(missing, /Transfer the owner-signed approval/); assert.ok(created.includes(pairingFingerprint(original.pairing))); assert.equal(original.registration, null); const signed = await wizard(ownerContext, [ - ['Enrollment [', 'approve'], ['Private pairing file: ', request], ['[yes/no]: ', 'yes'], - ['Registration expiration (Unix seconds): ', String(Math.floor(Date.now() / 1000) + 300)], - ['NEW private approval file: ', approval], ['Owner private key (64 hex; hidden, never passed as an argument): ', ownerSecret], - ], credentialFile); + ['Enrollment [', 'approve'], ['Private pairing file (', '~/manual-request.json'], ['[yes/no]: ', 'yes'], + ['Approval validity (', '7d'], ['Owner private key (64 hex; hidden, never passed as an argument): ', ownerSecret], + ], credentialFile, 0, root); + approval = join(ownerContext, 'exchange', readdirSync(join(ownerContext, 'exchange'))[0]!); assert.ok(signed.includes(pairingFingerprint(original.pairing))); assert.ok(!signed.includes(ownerSecret)); - const imported = await wizard(host, [['Enrollment [', 'import'], ['Private approval file: ', approval]], credentialFile); + const catalogOutput = await wizard(approval, [], credentialFile, 0, undefined, 'catalog'); + assert.match(catalogOutput, /Next on owner computer: beehive tui/); + const defaultApproval = join(host, 'exchange', `approval-${pairingFingerprint(original.pairing)}.json`); + // A matching filename does not authorize anything. + writePrivate(defaultApproval, registerHost({ ...original.pairing, nonce: newKey() }, ownerSecret, Math.floor(Date.now() / 1000) + 300), true); + await wizard(host, [['Enrollment [', 'import'], ['Private approval file (', '']], credentialFile, 1); + assert.deepEqual(readFileSync(join(host, 'host-identity.json')), identityBytes); + copyFileSync(approval, defaultApproval); + const imported = await wizard('~/host', [['Enrollment [', 'import'], ['Private approval file (', '']], credentialFile, 0, root); assert.ok(imported.includes('Pending/no network')); const enrolled = readHostIdentity(host, credentials); assert.equal(enrolled.secret, original.secret); assert.ok(!readFileSync(join(host, 'host-identity.json'), 'utf8').includes(original.secret)); assert.deepEqual(enrolled.pairing, original.pairing); assert.throws(() => requireHostEnrollment(enrolled), /relay admission pending/); - assert.deepEqual(readdirSync(host), ['host-identity.json']); + assert.deepEqual(readdirSync(host), ['exchange', 'host-identity.json']); + assert.equal(statSync(join(host, 'exchange')).mode & 0o777, 0o700); + for (const file of [request, approval, defaultApproval]) assert.equal(statSync(file).mode & 0o777, 0o600); + const ownerOnHost = await wizard(host, [['Enrollment [', 'approve']], poison, 1); + assert.match(ownerOnHost, /Approve on the trusted owner computer/); + assert.ok(!ownerOnHost.includes('Owner private key')); + const agentSecret = newKey(); + writePrivate(join(root, 'binding.json'), { mode: 'fixture', runner: process.execPath, args: [fileURLToPath(new URL('./runner.ts', import.meta.url))], workspace: root }, true); + writePrivate(join(root, 'genesis.json'), createGenesis(publicKey(ownerSecret), publicKey(agentSecret), original.pairing.host), true); + const provisioned = await wizard('~/host', [['Agent private key (64 hex; hidden, never passed as an argument): ', agentSecret]], credentialFile, 0, root, 'provision-agent', ['~/binding.json', '~/genesis.json']); + assert.match(provisioned, /Provisioned STOPPED/); + assert.ok(!provisioned.includes(agentSecret)); + assert.ok(!readFileSync(join(host, 'setup.json'), 'utf8').includes(agentSecret)); for (const path of [request, approval, join(host, 'host-identity.json')]) assert.ok(!readFileSync(path, 'utf8').includes(ownerSecret)); } finally { rmSync(root, { recursive: true, force: true }); } }); + +test('setup paths expand only conventional home spelling', () => { + assert.equal(setupPath('~'), homedir()); + assert.equal(setupPath('~/a b'), join(homedir(), 'a b')); + assert.throws(() => setupPath('~someone/file'), /~user/); +}); diff --git a/beehive/test/launcher.test.ts b/beehive/test/launcher.test.ts index ea7915c828..c2141b2e88 100644 --- a/beehive/test/launcher.test.ts +++ b/beehive/test/launcher.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -38,9 +38,9 @@ async function runLauncher(installed: string, args: string[], env: NodeJS.Proces } finally { clearTimeout(timer); child.stdin.end(); } } -const createJourney = (request: string, owner: string): [string, string][] => [ - ['Enrollment [', 'create'], ['Host label: ', 'launcher-host'], ['Owner PUBLIC key (hex): ', publicKey(owner)], - ['Relay URL: ', 'wss://example.invalid'], ['NEW private pairing file: ', request], +const createJourney = (owner: string): [string, string][] => [ + ['Enrollment [', 'create'], ['Computer name [', 'launcher-host'], ['Owner PUBLIC key (hex,', publicKey(owner)], + ['Management relay URL (', 'wss://example.invalid'], ]; function installInto(root: string, name = 'user bin dir'): string { @@ -78,11 +78,10 @@ test('no-arg setup enrolls on the displayed default host folder via the isolated const cwd = join(root, 'unrelated cwd dir'); mkdirSync(cwd); const owner = newKey(); - const request = join(root, 'pairing request.json'); const credentials = join(root, 'isolated fixture secrets.json'); - const created = await runLauncher(installed, ['setup'], isolatedEnv(home, credentials), createJourney(request, owner), cwd); + const created = await runLauncher(installed, ['setup'], isolatedEnv(home, credentials), createJourney(owner), cwd); assert.equal(created.code, 0, created.output); - assert.equal(created.answered, 5, created.output); + assert.equal(created.answered, 4, created.output); const folder = join(home, '.beehive', 'host'); assert.ok(created.output.includes(`Default host folder: ${folder}`), created.output); const identity = JSON.parse(readFileSync(join(folder, 'host-identity.json'), 'utf8')) as { version: number; pairing: { host: string; owner: string; label: string }; registration: unknown }; @@ -94,15 +93,15 @@ test('no-arg setup enrolls on the displayed default host folder via the isolated const fixture = readFileSync(credentials, 'utf8'); assert.ok(fixture.includes(identity.pairing.host), 'host key stored in the isolated fixture'); assert.equal(Object.keys(JSON.parse(fixture)).length, 1); - const files = [join(folder, 'host-identity.json'), request, credentials]; - for (const file of [...files, created.output]) assert.ok(!file.includes(owner), 'owner secret never persisted or printed'); + const files = [join(folder, 'host-identity.json'), join(folder, 'exchange', readdirSync(join(folder, 'exchange'))[0]!), credentials]; + for (const value of [...files.map(file => readFileSync(file, 'utf8')), created.output]) assert.ok(!value.includes(owner), 'owner secret never persisted or printed'); // A second default setup must refuse to replace the retained identity. const before = readFileSync(join(folder, 'host-identity.json'), 'utf8'); const second = await runLauncher(installed, ['setup'], isolatedEnv(home, credentials), [ - ['Enrollment [', 'create'], ['Host label: ', 'other-host'], ['Owner PUBLIC key (hex): ', publicKey(newKey())], ['Relay URL: ', 'wss://other.example.invalid'], + ['Enrollment [', 'create'], ], cwd); assert.equal(second.code, 1, second.output); - assert.ok(second.output.includes('Host identity already exists; no replacement or automatic migration'), second.output); + assert.ok(second.output.includes('Host identity already exists. Rerun setup'), second.output); assert.equal(readFileSync(join(folder, 'host-identity.json'), 'utf8'), before, 'retained identity unchanged'); assert.ok(!existsSync(join(root, 'second request.json'))); } finally { rmSync(root, { recursive: true, force: true }); } @@ -118,9 +117,8 @@ test('explicit host directory setup is preserved through the launcher and leaves mkdirSync(cwd); const owner = newKey(); const explicit = join(root, 'explicit host dir'); - const request = join(root, 'pairing request.json'); const credentials = join(root, 'isolated fixture secrets.json'); - const created = await runLauncher(installed, ['setup', explicit], isolatedEnv(home, credentials), createJourney(request, owner), cwd); + const created = await runLauncher(installed, ['setup', explicit], isolatedEnv(home, credentials), createJourney(owner), cwd); assert.equal(created.code, 0, created.output); assert.ok(!created.output.includes('Default host folder:'), created.output); assert.ok(existsSync(join(explicit, 'host-identity.json')), 'identity lands in the explicit directory');