From ec3a22f8ceb4d63d871287c8269a842184fc4ed9 Mon Sep 17 00:00:00 2001 From: Brother Darryl <7d7dea84f63f17682fa12720e0bb802a127c378d835b3ef416f80ed0ef0f6705@buzz.block.builderlab.xyz> Date: Fri, 11 Sep 2026 12:04:47 -0400 Subject: [PATCH] feat(beehive): separate local host and owner management Signed-off-by: Brother Darryl <7d7dea84f63f17682fa12720e0bb802a127c378d835b3ef416f80ed0ef0f6705@buzz.block.builderlab.xyz> --- beehive/README.md | 8 ++ beehive/src/cli.ts | 118 +++++++++++++++++-------- beehive/src/controller-config.ts | 29 ++++++ beehive/test/host-availability.test.ts | 33 +++++++ beehive/test/launcher.test.ts | 8 +- 5 files changed, 154 insertions(+), 42 deletions(-) create mode 100644 beehive/src/controller-config.ts diff --git a/beehive/README.md b/beehive/README.md index 165fa49735..0b7c1a34a4 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -12,6 +12,14 @@ can close while the host/runner continues. No direct-host HTTP API or controller service exists. This package intentionally lives alongside, not inside, the native clients: it is a separately authorized product experiment. +Configure and start the default host with `beehive host`. Running `beehive` +opens a two-part manager: **Local Host** works without an owner signer and routes +local runtime/agent-key provisioning through `manage-local`; **Agents** signs into +the relay-backed management view. The owner key is entered once, verified against +the configured owner npub and retained only in this managing computer's Beehive OS +credential entry. Other hosts retain only the owner npub, host key and explicitly +registered agent keys. + ## Command launcher (one-time, reversible) `bin/beehive.cjs` is a plain launcher for this checkout: it checks Node diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index eea64869e2..6fe69d656b 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -3,7 +3,7 @@ import { metadataRevision } from './public-metadata.ts'; import { setupPath } from './setup-path.ts'; import { randomUUID } from 'node:crypto'; import { credentialHelperReader } from './credential-helper.ts'; -import { systemCredentials } from './credential-store.ts'; +import { systemCredentials, createCredential, credentialReference, readCredential } from './credential-store.ts'; import { privateHostTransport } from './host-transport.ts'; import { installationPublicSlots } from './slots.ts'; import { provisionCredentialSlot, reconcileCredentialProvision, orphanCredentialSlots } from './credential-slots.ts'; @@ -19,7 +19,8 @@ import { claudeGuidance } from './claude.ts'; import { readAgentSecret } from './key-input.ts'; import { conversationInput } from './conversation-input.ts'; import { localSetup } from './local-setup.ts'; -import { hostSetup } from './host-setup.ts'; +import { hostSetup, ownerPublicInput } from './host-setup.ts'; +import { createControllerConfig, readControllerConfig } from './controller-config.ts'; import { enrollmentInput } from './enrollment-input.ts'; import { verifyHostCatalog, type HostCatalog } from './host-catalog.ts'; import { profileDrafts, editProfileDraft } from './profile-drafts.ts'; @@ -41,45 +42,81 @@ 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); +let [command, ...args] = process.argv.slice(2); // 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 -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 . - identity Disabled: use your existing owner signer - setup Configure/resume OWNER NPUB + RELAY on ~/.beehive/host; normal community join - setup Same configuration/join flow on an explicit retained host folder - 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 - legacy-enrollment Explicit historical approval exchange (not required) - presets Local process-free preset discovery/setup guidance - local-setup Bindings; new/reuse/hidden standby/restore identity - migrate-slots Explicit stopped upgrade, preserves journal - add-agent New identity using shared local harness - reconcile-agent [agent-public-key] [public-genesis-file] Recover interrupted added slot - remove-agent-key [agent-public-key] Remove ONE local key copy (public slot retained) - import-agent-key Restore retained identity via hidden local entry - assignment-export Export public pinned genesis locally - migrate-assignment Explicit stopped legacy enrollment - 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 configured 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 - drafts Offline public metadata + instruction drafts; no signer/network; same state directory as TUI - tui discover Private availability discovery; no approval file - 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. No approval files required. setup never starts the host or agents; host --owner-present is a deliberate foreground start. Legacy catalog remains available. Enrollment, provision-agent, auth-info, catalog, host and tui paths accept ~ and ~/. Explicit relay overrides must match retained local configuration. -reconcile-agent derives the interrupted binding and genesis from the retained journal; an optional public genesis file must match it. -assignment-export accepts agent public key after filename when several slots exist. -Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC. Private host/catalog uses authenticated encrypted relay transport. Relay refusals are transport errors.`; +const defaultControllerDirectory = () => join(homedir(), '.beehive', 'owner'); +let enteredOwnerSecret: string | undefined; +const help = `Beehive — run and manage your agents + +Usage: + beehive Open Local Host / Agents manager (implicit setup) + beehive host Configure if needed, then start this host + +The Local Host screen does not require owner sign-in. The Agents screen signs in +with the owner key once and retains it in this computer's Beehive OS credential +entry. Other hosts need only the configured owner npub.`; + +async function openManager(): Promise { + const hostDirectory = defaultHostDirectory(); + const controllerDirectory = defaultControllerDirectory(); + const ui = createInterface({ input: stdin, output: stdout }); + try { + console.log('Beehive | Local Host\nCommands: local, manage-local, agents, quit'); + for (;;) { + const line = (await ui.question('beehive> ')).trim(); + if (line === 'quit') return false; + if (line === 'local') { + if (!existsSync(join(hostDirectory, 'host-identity.json'))) { + console.log('This computer is not configured as a host. Run beehive host to configure and start it.'); + continue; + } + const identity = readHostIdentityPublic(hostDirectory); + const slots = existsSync(join(hostDirectory, 'setup.json')) || existsSync(join(hostDirectory, 'agents')) ? installationPublicSlots(hostDirectory) : []; + console.log(`Local Host ${identity.pairing.label} | ${identity.pairing.host}\nRelay ${identity.pairing.relay}\nOwner ${identity.pairing.owner}`); + if (slots.length === 0) console.log('No locally registered agents or runtimes.'); + for (const slot of slots) console.log(`Agent ${slot.agent} | runtimes ${Object.keys(slot.bindings).join(', ') || 'none'}`); + continue; + } + if (line === 'manage-local') { + ui.close(); + if (!existsSync(join(hostDirectory, 'host-identity.json'))) { + await hostSetup(hostDirectory); + return false; + } + command = 'local-setup'; args = [hostDirectory]; return true; + } + if (line === 'agents') { + const retained = readControllerConfig(controllerDirectory); + const local = existsSync(join(hostDirectory, 'host-identity.json')) ? readHostIdentityPublic(hostDirectory).pairing : undefined; + const owner = retained?.owner ?? ownerPublicInput(await ui.question(`Owner PUBLIC npub${local ? ` [${local.owner}]` : ''}: `) || String(local?.owner ?? '')); + const relay = retained?.relay ?? ((await ui.question(`Management relay URL${local ? ` [${local.relay}]` : ''}: `)).trim() || String(local?.relay ?? '')); + const reference = credentialReference('owner', owner); + let secret = systemCredentials.read(reference); + if (secret === null) { + ui.close(); + secret = await readAgentSecret('Owner'); + if (publicKey(secret) !== owner) throw Error('Owner key does not match the configured owner npub'); + createCredential('owner', secret); + } else { + secret = readCredential(reference); + } + if (!retained) createControllerConfig(controllerDirectory, owner, relay); + enteredOwnerSecret = secret; + command = 'tui'; + args = ['discover', relay, controllerDirectory]; + return true; + } + console.log('Use local, manage-local, agents, or quit. Local Host never requires owner sign-in; Agents does.'); + } + } finally { ui.close(); } +} + async function main() { + if (command === undefined) { + if (!await openManager()) return; + } if (command === 'drafts') { const drafts = profileDrafts(setupPath(text(args[0]))); const metadata = metadataDrafts(setupPath(text(args[0]))); @@ -377,6 +414,11 @@ async function main() { }; process.on('SIGINT', close); process.on('SIGTERM', close); } else if (command === 'host') { + if (args.length === 0 && !existsSync(join(defaultHostDirectory(), 'host-identity.json'))) { + await hostSetup(defaultHostDirectory()); + if (!existsSync(join(defaultHostDirectory(), 'host-identity.json'))) return; + } + if (args.length === 0) args.push('--owner-present'); const controller = new AbortController(); const stop = () => controller.abort(); process.on('SIGINT', stop); process.on('SIGTERM', stop); @@ -419,7 +461,7 @@ async function main() { const discovering = args[0] === 'discover'; const discoveryRelay = discovering ? text(args[1]) : undefined; if (discovering) { args[0] = join(setupPath(text(args[2])), 'discovery.json'); args[1] = discoveryRelay!; } - const discoverySecret = discovering ? await readAgentSecret('Owner') : undefined; + const discoverySecret = discovering ? enteredOwnerSecret ?? await readAgentSecret('Owner') : undefined; const identity = discovering ? { version: 1, owner: publicKey(discoverySecret!), relay: discoveryRelay!, registrations: [] } : object(readPrivate(setupPath(text(args[0])))); if (args[1] === undefined && 'registrations' in identity) args[1] = text(identity.relay); const catalog: HostCatalog | undefined = 'registrations' in identity ? verifyHostCatalog(identity, text(identity.owner), text(args[1])) : undefined; diff --git a/beehive/src/controller-config.ts b/beehive/src/controller-config.ts new file mode 100644 index 0000000000..5ac2b526ee --- /dev/null +++ b/beehive/src/controller-config.ts @@ -0,0 +1,29 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { fields, object } from './protocol.ts'; +import { readPrivate, writePrivate } from './storage.ts'; +import { ownerPublicInput } from './host-setup.ts'; + +export type ControllerConfig = { version: 1; owner: string; relay: string }; +const file = (directory: string) => join(directory, 'controller.json'); + +/** Read public controller routing only. The owner signer remains in OS credentials. */ +export function readControllerConfig(directory: string): ControllerConfig | undefined { + if (!existsSync(file(directory))) return undefined; + const value = object(readPrivate(file(directory))); + fields(value, ['version', 'owner', 'relay']); + if (value.version !== 1) throw Error('Unsupported Beehive controller configuration'); + const owner = ownerPublicInput(String(value.owner)); + const relay = String(value.relay); + if (!/^(wss|ws):\/\//.test(relay)) throw Error('Invalid retained management relay URL'); + return { version: 1, owner, relay }; +} + +/** First-use public routing configuration. Existing owner/relay authority is immutable here. */ +export function createControllerConfig(directory: string, owner: string, relay: string): ControllerConfig { + if (readControllerConfig(directory)) throw Error('Controller already configured; use Settings for deliberate changes'); + const value = { version: 1 as const, owner: ownerPublicInput(owner), relay: relay.trim() }; + if (!/^(wss|ws):\/\//.test(value.relay)) throw Error('Management relay requires wss:// (ws://127.0.0.1 for fixtures)'); + writePrivate(file(directory), value, true); + return value; +} diff --git a/beehive/test/host-availability.test.ts b/beehive/test/host-availability.test.ts index d427f6e5c3..21c8ec85a0 100644 --- a/beehive/test/host-availability.test.ts +++ b/beehive/test/host-availability.test.ts @@ -184,3 +184,36 @@ test('normal public owner inputs reject secrets', () => { assert.equal(ownerPublicInput(owner.toUpperCase()), owner); assert.throws(() => ownerPublicInput('nsec1invalid')); }); + +test('default manager partitions unsigned Local Host from persisted owner Agents sign-in', async () => { + const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-manager-partition-')); + const owner = newKey(); + const file = join(root, 'credentials.json'); + const directory = join(root, '.beehive', 'host'); + bootstrapHostIdentity(directory, 'Local machine', publicKey(owner), 'ws://127.0.0.1:1', isolatedFileCredentials(file)); + const child = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), 'src/cli.ts'], { + env: { ...process.env, HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: file }, stdio: ['pipe', 'pipe', 'pipe'], + }); + let output = ''; child.stdout!.on('data', b => output += b); child.stderr!.on('data', b => output += b); + const exit = new Promise(done => child.on('close', done)); + try { + await wait(() => output.includes('Beehive | Local Host'), () => output); + child.stdin!.write('local\n'); + await wait(() => output.includes('Local Host Local machine'), () => output); + assert.ok(!output.includes('Owner private key'), 'local host inspection never asks for the owner signer'); + child.stdin!.write('agents\n'); + await wait(() => output.includes('Owner PUBLIC npub'), () => output); + child.stdin!.write('\n'); + await wait(() => output.includes('Management relay URL'), () => output); + child.stdin!.write('\n'); + await wait(() => output.includes('Owner private key'), () => output); + child.stdin!.write(owner + '\n'); + await wait(() => existsSync(join(root, '.beehive', 'owner', 'controller.json'))); + await wait(() => JSON.parse(readFileSync(file, 'utf8'))[JSON.stringify(credentialReference('owner', publicKey(owner)))] === owner); + assert.deepEqual(JSON.parse(readFileSync(join(root, '.beehive', 'owner', 'controller.json'), 'utf8')), { + version: 1, owner: publicKey(owner), relay: 'ws://127.0.0.1:1', + }); + } finally { + child.kill('SIGKILL'); await exit; rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/beehive/test/launcher.test.ts b/beehive/test/launcher.test.ts index 4f225ea737..2701528c6f 100644 --- a/beehive/test/launcher.test.ts +++ b/beehive/test/launcher.test.ts @@ -61,11 +61,11 @@ test('installed launcher prints public help from an unrelated directory with spa const installed = installInto(root); const cwd = join(root, 'unrelated cwd dir'); mkdirSync(cwd); - const help = await runLauncher(installed, [], baseEnv(home), [], cwd); + const help = await runLauncher(installed, ['--help'], baseEnv(home), [], cwd); assert.equal(help.code, 0, help.output); - assert.match(help.output, /^Beehive — private host preview/, help.output); - assert.ok(help.output.includes('setup'), help.output); - assert.ok(help.output.includes('~/.beehive/host'), help.output); + assert.match(help.output, /^Beehive — run and manage your agents/, help.output); + assert.ok(help.output.includes('beehive host'), help.output); + assert.ok(help.output.includes('Local Host screen'), help.output); assert.ok(!help.output.includes('Enrollment ['), help.output); assert.ok(!existsSync(join(home, '.beehive')), 'help must not create state'); } finally { rmSync(root, { recursive: true, force: true }); }