From f635c34bd9bf3236adbed693ebdd9de7c5873ab9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 21 Sep 2026 15:31:39 -0400 Subject: [PATCH] feat(beehive): add volatile owner session and protected section gates Signed-off-by: Logan Johnson --- beehive/OPENTUI_WIP.md | 23 +++ beehive/native/README.md | 24 +++ beehive/src/manager-view.ts | 3 +- beehive/src/opentui-shell.ts | 21 ++- beehive/src/owner-child.ts | 20 +++ beehive/src/owner-client.ts | 40 +++++ beehive/src/owner-key.ts | 31 ++++ beehive/src/owner-panel.ts | 153 ++++++++++++++++++ beehive/src/owner-protocol.ts | 15 ++ beehive/src/owner-service.ts | 102 ++++++++++++ beehive/src/provider-dialog.ts | 2 +- beehive/src/shell-state.ts | 1 + beehive/test/owner-ipc.test.ts | 29 ++++ beehive/test/owner-key.test.ts | 24 +++ beehive/test/owner-service.test.ts | 115 +++++++++++++ beehive/test/owner-tui-fixture-child.ts | 21 +++ beehive/test/owner-tui-fixture.ts | 11 ++ beehive/test/shell-owner-ipc.test.ts | 49 ++++++ beehive/test/shell-owner.test.ts | 61 +++++++ crates/buzz-agent/Cargo.toml | 4 + .../buzz-agent/src/bin/beehive_owner_key.rs | 95 +++++++++++ 21 files changed, 836 insertions(+), 8 deletions(-) create mode 100644 beehive/src/owner-child.ts create mode 100644 beehive/src/owner-client.ts create mode 100644 beehive/src/owner-key.ts create mode 100644 beehive/src/owner-panel.ts create mode 100644 beehive/src/owner-protocol.ts create mode 100644 beehive/src/owner-service.ts create mode 100644 beehive/test/owner-ipc.test.ts create mode 100644 beehive/test/owner-key.test.ts create mode 100644 beehive/test/owner-service.test.ts create mode 100644 beehive/test/owner-tui-fixture-child.ts create mode 100644 beehive/test/owner-tui-fixture.ts create mode 100644 beehive/test/shell-owner-ipc.test.ts create mode 100644 beehive/test/shell-owner.test.ts create mode 100644 crates/buzz-agent/src/bin/beehive_owner_key.rs diff --git a/beehive/OPENTUI_WIP.md b/beehive/OPENTUI_WIP.md index a5e68a0c28..01f8331a69 100644 --- a/beehive/OPENTUI_WIP.md +++ b/beehive/OPENTUI_WIP.md @@ -174,3 +174,26 @@ credentials, relay, provider, host lifecycle or installed-package change was use Narrow renderer checks cover the 40-column footer, input and pane routing; this is not an exhaustive screenshot matrix for every prompt or launch error. Publication and installed desktop/laptop smoke belong to the next continuation. + +### Owner sign-in slice + +The current OpenTUI manager uses a separate offline Node owner authority, not the +legacy saved-owner-key manager. Its header is SIGN IN / SIGNED IN. Signed-out +Host/Agents show the access gate. The full-width owner screen offers Desktop +identity first, then a masked nsec form. True first use chooses public relay +routing before sign-in. Existing controller/Host owner and relay bindings cannot +be changed by signing in; a different owner is refused. + +Desktop availability is a prompt-free metadata check of one exact entry. Explicit +selection performs the session-only read and validates the identity. Keychain +presence alone cannot prove the blob is valid or access will be granted. See +`native/README.md` for the helper packaging and evidence boundary. + +Manual nsec and Desktop session keys stay in the Node authority's memory. Only +public state and input length return to the renderer. First use saves the public +owner/relay binding only. Sign-out and quit discard the session; relaunch starts +signed out. No relay is contacted. Contextual success requires explicit Continue; +standalone success stays on the owner screen. Host/Agents content remains an +explicit unimplemented placeholder after sign-in. Host setup, relay discovery, +registration, lifecycle and agent controls are not part of this slice. Existing +advanced CLI and legacy manager flows are unchanged by this implementation. diff --git a/beehive/native/README.md b/beehive/native/README.md index bbc5a16cc9..c93e1ced0b 100644 --- a/beehive/native/README.md +++ b/beehive/native/README.md @@ -86,3 +86,27 @@ contract explicitly with `python3 beehive/tools/export-harnesses.py shell.close(); process.once('SIGINT', stop); diff --git a/beehive/src/opentui-shell.ts b/beehive/src/opentui-shell.ts index 5d4ede774d..8f672fbab8 100644 --- a/beehive/src/opentui-shell.ts +++ b/beehive/src/opentui-shell.ts @@ -1,4 +1,6 @@ import { BoxRenderable, ScrollBoxRenderable, TextRenderable, type CliRenderer, type KeyEvent } from '@opentui/core'; +import { OwnerPanel } from './owner-panel.ts'; +import type { OwnerClient } from './owner-protocol.ts'; import { ProviderPanel } from './provider-panel.ts'; import type { ProviderClient } from './provider-protocol.ts'; import { brand, destinations, listWidth, ownerLabel, ShellState, type FocusRegion } from './shell-state.ts'; @@ -72,6 +74,7 @@ export class OpenTuiShell { private unsubscribeInventory: () => void; private screenGeneration = 0; private providers?: ProviderPanel; + private owner?: OwnerPanel; private readonly footerRule: TextRenderable; private readonly footer: TextRenderable; private helpScrim?: BoxRenderable; @@ -81,7 +84,7 @@ export class OpenTuiShell { private helpTitle?: TextRenderable; private helpActions?: TextRenderable; - constructor(readonly renderer: CliRenderer, private readonly inventory: HarnessInventoryController, private readonly helpContent = helpBody, providers?: ProviderClient) { + constructor(readonly renderer: CliRenderer, private readonly inventory: HarnessInventoryController, private readonly helpContent = helpBody, providers?: ProviderClient, owner?: OwnerClient) { this.inventorySnapshot = inventory.snapshot(); this.done = new Promise(resolve => { this.finish = resolve; }); this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: palette.surface }); @@ -109,6 +112,7 @@ export class OpenTuiShell { this.divider = new TextRenderable(renderer, { width: 1, height: '100%', fg: palette.divider, content: '│' }); this.focusPerimeter = new BoxRenderable(renderer, { position: 'absolute', visible: false, border: true, borderColor: palette.focus, backgroundColor: 'transparent', onMouseDown: event => { + if (this.state.ownerActive || this.state.protectedSection) { this.owner?.pointer(event.y); return; } if (this.state.activeSection === 3) { this.providers?.pointer(event.y); return; } if (this.state.focus === 'list') this.clickListSlot(event.y - 6); else if (this.state.focus === 'detail' && event.y === 15) this.activateSelectedRow(); @@ -137,7 +141,8 @@ export class OpenTuiShell { this.footer = new TextRenderable(renderer, { height: 1, fg: palette.muted, content: wideFooter }); this.root.add(this.footer); if (providers) this.providers = new ProviderPanel(renderer, this.listPane, this.detailPane, this.state, providers, () => this.paint()); - renderer.keyInput.on('paste', event => { if (this.providers?.paste(new TextDecoder().decode(event.bytes))) event.preventDefault(); }); + if (owner) this.owner = new OwnerPanel(renderer, this.detailPane, this.state, owner, () => this.paint()); + renderer.keyInput.on('paste', event => { if (this.owner?.paste(new TextDecoder().decode(event.bytes)) || this.providers?.paste(new TextDecoder().decode(event.bytes))) event.preventDefault(); }); renderer.keyInput.on('keypress', key => this.key(key)); renderer.on('resize', () => this.resize()); renderer.on('destroy', () => this.close(false)); @@ -167,7 +172,7 @@ export class OpenTuiShell { } private key(key: KeyEvent) { - if (!(key.ctrl && ['q', 'c'].includes(key.name)) && !this.state.belowMinimum && !this.state.helpOpen && this.providers?.key(key)) { key.preventDefault(); return; } + if (!(key.ctrl && ['q', 'c'].includes(key.name)) && !this.state.belowMinimum && !this.state.helpOpen && (this.owner?.key(key) || this.providers?.key(key))) { key.preventDefault(); return; } const wasHarnesses = this.state.mode === 'section' && this.state.activeSection === 2; const effect = this.state.key(key.name, { ctrl: key.ctrl, shift: key.shift }); if (wasHarnesses && !(this.state.mode === 'section' && this.state.activeSection === 2)) { this.screenGeneration++; this.inventory.cancel(); } @@ -178,7 +183,7 @@ export class OpenTuiShell { } private activate(index: number) { - if (this.state.belowMinimum || this.state.helpOpen) return; + if (this.state.belowMinimum || this.state.helpOpen || this.owner?.modal) return; const wasHarnesses = this.state.mode === 'section' && this.state.activeSection === 2; this.state.activateHeader(index); if (wasHarnesses && !(this.state.mode === 'section' && this.state.activeSection === 2)) { this.screenGeneration++; this.inventory.cancel(); } @@ -209,7 +214,9 @@ export class OpenTuiShell { private paint() { if (this.closed) return; const width = this.renderer.width; - this.ownerText.left = Math.max(0, width - ownerLabel.length - 1); + const label = this.owner ? this.state.signedIn ? 'SIGNED IN' : 'SIGN IN' : ownerLabel; + this.ownerText.content = label; + this.ownerText.left = Math.max(0, width - label.length - 1); this.brandText.width = Math.max(0, this.ownerText.left - 1); const rule = '─'.repeat(Math.max(1, width)); this.headerRule.content = rule; this.footerRule.content = rule; @@ -219,7 +226,7 @@ export class OpenTuiShell { const focused = this.state.focus === 'header' && this.state.headerIndex === index; const active = this.state.mode === 'section' && this.state.activeSection === index; item.bg = focused ? palette.selected : palette.surface; - item.fg = focused ? palette.selectedText : active ? palette.focus : index < 2 ? palette.muted : palette.text; + item.fg = focused ? palette.selectedText : active ? palette.focus : index < 2 && !this.state.signedIn ? palette.muted : palette.text; }); const ownerFocused = this.state.focus === 'header' && this.state.headerIndex === destinations.length; this.ownerText.bg = ownerFocused ? palette.selected : palette.surface; @@ -235,6 +242,7 @@ export class OpenTuiShell { } this.paintHarnesses(); this.providers?.setActive(!this.state.belowMinimum && this.state.mode === 'section' && this.state.activeSection === 3); + this.owner?.paint(); const focusedPane = this.state.focus === 'list' || this.state.focus === 'detail' ? this.state.focus : undefined; this.focusPerimeter.visible = Boolean(focusedPane); if (focusedPane) { @@ -336,6 +344,7 @@ export class OpenTuiShell { this.unsubscribeInventory(); this.inventory.dispose(); this.providers?.dispose(); + this.owner?.dispose(); if (destroy) this.renderer.destroy(); this.finish(); } diff --git a/beehive/src/owner-child.ts b/beehive/src/owner-child.ts new file mode 100644 index 0000000000..d83c92430c --- /dev/null +++ b/beehive/src/owner-child.ts @@ -0,0 +1,20 @@ +import { OwnerService } from './owner-service.ts'; +const service = new OwnerService(process.env.BEEHIVE_HOME); +service.subscribe(snapshot => { if (process.connected) process.send?.({ type: 'snapshot', snapshot }); }); +let pending: Promise | undefined; +let exiting = false; +process.on('message', async (message: any) => { + if (message.type === 'secret') service.secret(message.action, message.value); + else if (message.type === 'cancel') service.cancel(); + else if (message.type === 'request') { + pending = service.request(message.request); + const ok = await pending; + if (process.connected && !exiting) process.send?.({ type: 'result', id: message.id, ok }); + } +}); +const stop = () => { + if (exiting) return; exiting = true; service.dispose(); + void Promise.resolve(pending).finally(() => process.exit()); +}; +process.on('disconnect', stop); +process.on('SIGTERM', stop); diff --git a/beehive/src/owner-client.ts b/beehive/src/owner-client.ts new file mode 100644 index 0000000000..0af97c74dd --- /dev/null +++ b/beehive/src/owner-client.ts @@ -0,0 +1,40 @@ +import { fork } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { existsSync } from 'node:fs'; +import type { OwnerClient, OwnerRequest, OwnerSnapshot } from './owner-protocol.ts'; + +/** Renderer transport: only public projections return from the Node authority. */ +export function localOwner(options: { helper?: URL; environment?: NodeJS.ProcessEnv } = {}): OwnerClient { + const environment = options.environment ?? process.env; + const packagedNode = fileURLToPath(new URL('../runtime/node', import.meta.url)); + const child = fork(fileURLToPath(options.helper ?? new URL('./owner-child.ts', import.meta.url)), [], { + execPath: existsSync(packagedNode) ? packagedNode : process.env.BEEHIVE_NODE || 'node', + execArgv: ['--experimental-transform-types'], silent: true, + env: { PATH: environment.PATH ?? '/usr/bin:/bin', ...(environment.HOME ? { HOME: environment.HOME } : {}), ...(environment.BEEHIVE_HOME ? { BEEHIVE_HOME: environment.BEEHIVE_HOME } : {}), ...(environment.DATABRICKS_HOST ? { DATABRICKS_HOST: environment.DATABRICKS_HOST } : {}) }, + }); + let snapshot: OwnerSnapshot = { signedIn: false, desktop: 'unknown', desktopReason: 'Checking identity availability.', phase: 'idle', message: 'Loading owner session…', secretLength: 0 }; + let serial = 0, disposed = false; + const listeners = new Set<(snapshot: OwnerSnapshot) => void>(); + const pending = new Map void>(); + const emit = () => { for (const listener of listeners) listener(structuredClone(snapshot)); }; + const unavailable = () => { + for (const resolve of pending.values()) resolve(false); pending.clear(); + if (disposed) return; + snapshot = { ...snapshot, signedIn: false, owner: undefined, phase: 'error', secretLength: 0, message: 'Owner controller stopped. Reopen Beehive. Saved data was not reset.' }; emit(); + }; + child.stdout?.resume(); child.stderr?.resume(); child.on('error', unavailable); child.on('close', unavailable); + child.on('message', (message: any) => { + if (disposed) return; + if (message.type === 'snapshot') { snapshot = message.snapshot; emit(); } + else if (message.type === 'result') { pending.get(message.id)?.(message.ok === true); pending.delete(message.id); } + }); + const send = (message: object) => { if (!disposed && child.connected) child.send(message, error => { if (error) unavailable(); }); else unavailable(); }; + return { + snapshot: () => structuredClone(snapshot), + subscribe(listener) { listeners.add(listener); listener(structuredClone(snapshot)); return () => { listeners.delete(listener); }; }, + request(request: OwnerRequest) { return new Promise(resolve => { const id = ++serial; pending.set(id, resolve); send({ type: 'request', id, request }); }); }, + secret(action, value) { send({ type: 'secret', action, value }); }, + cancel() { send({ type: 'cancel' }); }, + dispose() { if (disposed) return; disposed = true; for (const resolve of pending.values()) resolve(false); pending.clear(); listeners.clear(); if (child.connected) child.disconnect(); else child.kill('SIGTERM'); }, + }; +} diff --git a/beehive/src/owner-key.ts b/beehive/src/owner-key.ts new file mode 100644 index 0000000000..adc0f00983 --- /dev/null +++ b/beehive/src/owner-key.ts @@ -0,0 +1,31 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { readBuzzOwnerKey } from './buzz-owner-key.ts'; + +/** Bounded exact-entry read. The no-UI native probe and explicit read never write Keychain. */ +export async function desktopOwnerKey(probe: boolean, signal: AbortSignal, executable = fileURLToPath(new URL('../bin/beehive-owner-key', import.meta.url))): Promise<{ available?: boolean; secret?: string; reason?: string }> { + signal.throwIfAborted(); + return new Promise(resolve => { + const child = spawn(executable, [], { + stdio: ['pipe', 'pipe', 'ignore'], env: { PATH: '/usr/bin:/bin', ...(process.env.HOME ? { HOME: process.env.HOME } : {}) }, + }); + let output = '', failed = false; + const stop = () => { failed = true; output = ''; child.kill('SIGKILL'); }; + const timer = setTimeout(stop, 10000); signal.addEventListener('abort', stop, { once: true }); + child.on('error', () => { failed = true; }); child.stdin.on('error', stop); + child.stdout.on('data', chunk => { if (failed) return; if (output.length + chunk.length > 4096) stop(); else output += chunk.toString(); }); + child.on('close', code => { + clearTimeout(timer); signal.removeEventListener('abort', stop); + if (failed || signal.aborted || code !== 0) { resolve({ reason: 'access' }); return; } + try { + const value = JSON.parse(output); output = ''; + if (value.reason) { resolve({ reason: value.reason }); return; } + if (probe) { resolve({ available: value.available === true }); return; } + const result = readBuzzOwnerKey(null, () => JSON.stringify({ identity: value.identity })); + resolve(result.ok ? { secret: result.secret } : { reason: result.reason }); + } catch { resolve({ reason: 'malformed' }); } + }); + child.stdin.end(probe ? 'probe' : 'read'); + if (signal.aborted) stop(); + }); +} diff --git a/beehive/src/owner-panel.ts b/beehive/src/owner-panel.ts new file mode 100644 index 0000000000..cf19372819 --- /dev/null +++ b/beehive/src/owner-panel.ts @@ -0,0 +1,153 @@ +import { BoxRenderable, TextRenderable, type CliRenderer, type KeyEvent } from '@opentui/core'; +import { palette } from './opentui-shell.ts'; +import { ProviderDialog } from './provider-dialog.ts'; +import type { OwnerClient, OwnerSnapshot } from './owner-protocol.ts'; +import { destinations, type ShellState } from './shell-state.ts'; + +/** Full-width owner screen and access gate. Only public session facts enter Bun. */ +export class OwnerPanel { + private snapshot: OwnerSnapshot; + private unsubscribe: () => void; + private box: BoxRenderable; + private title: TextRenderable; + private facts: TextRenderable; + private labels: TextRenderable; + private heading: TextRenderable; + private actions: TextRenderable[] = []; + private message: TextRenderable; + private note: TextRenderable; + private dialog?: ProviderDialog; + private index = 0; + private actionTop = 0; + private relay = ''; + private continuation?: number; + private prior?: { mode: ShellState['mode']; activeSection: number; headerIndex: number; focus: ShellState['focus'] }; + private shown = false; + private generation = 0; + private wasOwner = false; + constructor(private renderer: CliRenderer, pane: BoxRenderable, private state: ShellState, private client: OwnerClient, private repaint: () => void) { + this.snapshot = client.snapshot(); + this.box = new BoxRenderable(renderer, { width: '100%', height: '100%', backgroundColor: palette.surface }); pane.add(this.box); + const text = (fg: string) => { const t = new TextRenderable(renderer, { position: 'absolute', left: 2, fg, wrapMode: 'word' }); this.box.add(t); return t; }; + this.title = text(palette.muted); this.facts = text(palette.text); this.labels = text(palette.muted); this.heading = text(palette.focus); + for (let i = 0; i < 4; i++) { + const action = text(palette.text); action.onMouseDown = () => { if (!this.shown || this.dialog || this.state.helpOpen) return; this.index = i; this.state.focus = 'detail'; this.repaint(); void this.run(); }; this.actions.push(action); + } + this.message = text(palette.text); this.note = text(palette.muted); + this.unsubscribe = client.subscribe(snapshot => { this.snapshot = snapshot; this.state.signedIn = snapshot.signedIn; this.dialog?.updateSecretLength(snapshot.secretLength); this.repaint(); }); + } + get modal() { return !!this.dialog; } + private commands() { + if (!this.state.ownerActive) return this.snapshot.signedIn ? [] : ['Sign in']; + if (this.snapshot.signedIn) return [...(this.continuation === undefined ? [] : [`Continue to ${this.continuation === 0 ? 'Host' : 'Agents'}`]), 'Sign out']; + if (!this.snapshot.relay && !this.relay) return ['Choose a relay', 'Use Buzz Desktop identity', 'Provide owner nsec']; + return ['Use Buzz Desktop identity', 'Provide owner nsec', ...(this.snapshot.relay ? [] : ['Change relay']), 'Check Desktop identity']; + } + private disabled(command: string) { return command === 'Use Buzz Desktop identity' && (this.snapshot.desktop !== 'available' || !(this.snapshot.relay || this.relay)) || command === 'Provide owner nsec' && !(this.snapshot.relay || this.relay); } + private selectionNote(command: string) { + if (command === 'Use Buzz Desktop identity') return !(this.snapshot.relay || this.relay) ? 'Choose a relay first. No sign-in or connection happens yet.' : this.snapshot.desktopReason; + if (command === 'Provide owner nsec') return !(this.snapshot.relay || this.relay) ? 'Choose a relay first.' : 'Your nsec stays in memory until sign-out or quit.'; + if (command === 'Sign out') return 'Signs out of Beehive. Host and agents keep running.'; + if (command.startsWith('Continue')) return 'Opens the requested section. No Host or agent operation runs.'; + if (command.includes('relay')) return 'Sets public routing on first sign-in. Does not connect or configure a Host.'; + return 'Host and Agents require owner sign-in. Harnesses and Providers do not.'; + } + private openOwner() { + this.prior = { mode: this.state.mode, activeSection: this.state.activeSection, headerIndex: this.state.headerIndex, focus: this.state.focus }; + this.continuation = this.state.activeSection; + this.state.mode = 'owner'; this.state.headerIndex = destinations.length; this.state.focus = 'detail'; this.index = 0; this.repaint(); + } + private back() { + this.generation++; this.client.cancel(); this.continuation = undefined; + if (this.prior) { Object.assign(this.state, this.prior); this.prior = undefined; } + else { this.state.mode = 'section'; this.state.headerIndex = this.state.activeSection; this.state.focus = 'header'; } + this.repaint(); + } + key(key: KeyEvent) { + if (!this.shown) return false; + if (this.dialog) { this.dialog.key(key); return true; } + if (key.name === 'escape' && this.snapshot.phase === 'busy') { this.client.cancel(); return true; } + if (key.name === 'escape' && this.state.ownerActive) { this.back(); return true; } + if (key.name === 'a' && this.commands().length) { this.state.focus = 'detail'; this.repaint(); return true; } + if (this.state.focus !== 'detail') return false; + if (key.name === 'up') { if (this.index > 0) this.index--; else { this.state.focus = 'header'; this.state.headerIndex = this.state.ownerActive ? destinations.length : this.state.activeSection; } this.repaint(); return true; } + if (key.name === 'down') { this.index = Math.min(this.commands().length - 1, this.index + 1); this.repaint(); return true; } + if (key.name === 'left') { if (this.state.ownerActive) this.back(); else { this.state.focus = 'header'; this.repaint(); } return true; } + if (key.name === 'return') { void this.run(); return true; } + return false; + } + paste(value: string) { if (!this.dialog) return false; this.dialog.paste(value); return true; } + pointer(y: number) { + if (!this.shown || this.dialog || this.state.helpOpen) return; + const index = y - 3 - this.actionTop; + if (index < 0 || index >= this.commands().length) return; + this.index = index; this.state.focus = 'detail'; this.repaint(); void this.run(); + } + private async run() { + if (this.dialog || !this.shown || this.snapshot.phase === 'busy') return; + const command = this.commands()[this.index]; if (!command || this.disabled(command)) return; + const generation = this.generation; + if (command === 'Sign in') { this.openOwner(); return; } + if (command.startsWith('Continue')) { + const target = this.continuation; this.continuation = undefined; this.prior = undefined; + if (target !== undefined && this.snapshot.signedIn) this.state.activateHeader(target); + this.repaint(); return; + } + if (command === 'Sign out') { this.continuation = undefined; this.prior = undefined; this.index = 0; await this.client.request({ action: 'signout' }); this.repaint(); return; } + if (command === 'Check Desktop identity') { await this.client.request({ action: 'probe' }); return; } + if (command.includes('relay')) { + const dialog = new ProviderDialog(this.renderer, 'CHOOSE A RELAY', [{ label: 'Relay URL', value: this.relay || 'wss://' }], () => {}, 'Use relay'); + this.dialog = dialog; const result = await dialog.done; if (this.dialog === dialog) this.dialog = undefined; + if (result && generation === this.generation) { this.relay = result['Relay URL']!.trim(); this.index = 0; } + this.repaint(); return; + } + if (command === 'Provide owner nsec') { + this.client.secret('clear'); + const dialog = new ProviderDialog(this.renderer, 'PROVIDE OWNER NSEC', [{ label: 'Owner nsec', secret: true }], (action, value) => this.client.secret(action, value), 'Sign in'); + this.dialog = dialog; const result = await dialog.done; if (this.dialog === dialog) this.dialog = undefined; + if (!result || generation !== this.generation) { this.repaint(); return; } + } + const ok = await this.client.request({ action: command === 'Use Buzz Desktop identity' ? 'signin-desktop' : 'signin-nsec', relay: this.relay }); + if (ok && generation === this.generation) { this.index = 0; this.state.focus = 'header'; this.state.headerIndex = destinations.length; } + this.repaint(); + } + paint() { + const owner = this.state.ownerActive; + const shown = !this.state.belowMinimum && (owner || this.state.protectedSection); + const left = (this.wasOwner && !owner) || (this.shown && !shown); + const entered = owner && !this.wasOwner; + this.wasOwner = owner; this.shown = shown; this.box.visible = shown; + if (left) { + this.generation++; this.dialog?.cancel(); this.dialog = undefined; + if (this.snapshot.phase === 'busy') this.client.cancel(); + this.continuation = undefined; this.prior = undefined; this.index = 0; + } + if (!shown) return; + if (entered && !this.snapshot.signedIn && this.snapshot.desktop === 'unknown') void this.client.request({ action: 'probe' }); + const width = this.renderer.width - 4, height = this.renderer.height - 5; + const wrap = (value: string) => { const rows: string[] = []; for (const line of value.split('\n')) { let rest = line; while (rest.length > width) { const at = rest.lastIndexOf(' ', width); const end = at > 0 ? at : width; rows.push(rest.slice(0, end)); rest = rest.slice(end).trimStart(); } rows.push(rest); } return rows; }; + const truncate = (value: string) => value.length > width - 8 ? value.slice(0, Math.ceil((width - 9) / 2)) + '…' + value.slice(-Math.floor((width - 9) / 2)) : value; + const commands = this.commands(); this.index = Math.max(0, Math.min(this.index, commands.length - 1)); + this.title.top = 1; this.title.width = width; this.title.height = 1; + this.title.content = owner ? this.snapshot.signedIn ? 'SIGNED IN' : this.continuation === undefined ? 'OWNER SIGN-IN' : 'SIGN-IN REQUIRED' : destinations[this.state.activeSection]!; + this.facts.top = 2; this.facts.left = owner ? 10 : 2; this.facts.width = owner ? width - 8 : width; + this.labels.top = 2; this.labels.width = 8; this.labels.height = 2; this.labels.visible = owner; this.labels.content = 'Relay\nOwner'; + this.facts.content = owner ? `${truncate(this.snapshot.relay || this.relay || 'Not configured')}\n${truncate(this.snapshot.owner || this.snapshot.boundOwner || 'Not configured')}` : this.snapshot.signedIn ? 'Owner access granted.\nThis section is not implemented in this slice.' : `Sign in to view and manage ${this.state.activeSection === 0 ? 'Host' : 'Agents'}.`; + this.facts.height = owner || this.snapshot.signedIn ? 2 : 1; + this.heading.top = 4; this.heading.width = width; this.heading.height = 1; this.heading.visible = commands.length > 0; + this.heading.content = 'AVAILABLE ACTIONS ' + '─'.repeat(Math.max(1, width - 18)); + this.actionTop = 5; + this.actions.forEach((action, i) => { action.visible = i < commands.length; action.top = this.actionTop + i; action.width = width; action.height = 1; + action.content = `${this.state.focus === 'detail' && i === this.index ? '›' : ' '} ${commands[i] ?? ''}`; + action.fg = this.disabled(commands[i] ?? '') ? palette.muted : this.state.focus === 'detail' && i === this.index ? palette.focus : palette.text; + }); + const messageTop = this.actionTop + commands.length; + this.message.top = messageTop; this.message.width = width; this.message.height = Math.max(1, height - messageTop - 3); + this.message.content = owner ? this.snapshot.message : ''; + this.message.fg = this.snapshot.phase === 'error' ? palette.failure : palette.text; + this.note.top = height - 3; this.note.height = 2; this.note.width = width; + this.note.content = wrap(this.selectionNote(commands[this.index] ?? '')).join('\n'); + this.dialog?.paint(); + } + dispose() { this.generation++; this.dialog?.cancel(); this.unsubscribe(); this.client.dispose(); } +} diff --git a/beehive/src/owner-protocol.ts b/beehive/src/owner-protocol.ts new file mode 100644 index 0000000000..9f81817016 --- /dev/null +++ b/beehive/src/owner-protocol.ts @@ -0,0 +1,15 @@ +/** Public owner-session projection. No credential, signer or secret is serializable here. */ +export type OwnerSnapshot = { + signedIn: boolean; owner?: string; boundOwner?: string; relay?: string; + desktop: 'unknown' | 'available' | 'unavailable'; desktopReason: string; + phase: 'idle' | 'busy' | 'error'; message: string; secretLength: number; +}; +export type OwnerRequest = { action: 'probe' | 'signin-nsec' | 'signin-desktop' | 'signout'; relay?: string }; +export interface OwnerClient { + snapshot(): OwnerSnapshot; + subscribe(listener: (snapshot: OwnerSnapshot) => void): () => void; + request(request: OwnerRequest): Promise; + secret(action: 'append' | 'backspace' | 'clear', value?: string): void; + cancel(): void; + dispose(): void; +} diff --git a/beehive/src/owner-service.ts b/beehive/src/owner-service.ts new file mode 100644 index 0000000000..eb7a843316 --- /dev/null +++ b/beehive/src/owner-service.ts @@ -0,0 +1,102 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { nip19 } from 'nostr-tools'; +import { readControllerConfig, createControllerConfig } from './controller-config.ts'; +import { readHostIdentityPublic } from './host-identity.ts'; +import { agentNsec } from './settings-credentials.ts'; +import { publicKey } from './protocol.ts'; +import { desktopOwnerKey } from './owner-key.ts'; +import type { OwnerClient, OwnerRequest, OwnerSnapshot } from './owner-protocol.ts'; + +const missing = 'Desktop identity unavailable. Open and unlock Buzz, then check again or provide an owner nsec.'; +/** Offline Node authority. Session keys have no persistence, snapshot, or relay path. */ +export class OwnerService implements OwnerClient { + private current: OwnerSnapshot = { signedIn: false, desktop: 'unknown', desktopReason: 'Identity availability has not been checked.', phase: 'idle', message: 'Sign in to manage Host and Agents.', secretLength: 0 }; + private listeners = new Set<(snapshot: OwnerSnapshot) => void>(); + private input = ''; + private session?: string; + private active?: AbortController; + private generation = 0; + private disposed = false; + constructor(private home = homedir(), private desktop = desktopOwnerKey) { + try { this.projectBinding(); } catch { this.current.phase = 'error'; this.current.message = 'Saved owner routing is invalid or inconsistent. Repair the retained configuration; nothing was reset.'; } + } + private binding() { + const controller = readControllerConfig(join(this.home, '.beehive', 'owner')); + const directory = join(this.home, '.beehive', 'host'); + const host = existsSync(join(directory, 'host-identity.json')) ? readHostIdentityPublic(directory).pairing : undefined; + if (existsSync(join(directory, 'setup.json')) && !host) throw Error('Legacy Host needs explicit migration'); + if (controller && host && (controller.owner !== host.owner || controller.relay !== host.relay)) throw Error('Conflicting bindings'); + return controller ?? (host ? { owner: host.owner, relay: host.relay } : undefined); + } + private projectBinding() { const b = this.binding(); this.current.boundOwner = b ? nip19.npubEncode(b.owner) : undefined; this.current.relay = b?.relay; } + snapshot() { return structuredClone(this.current); } + subscribe(listener: (snapshot: OwnerSnapshot) => void) { this.listeners.add(listener); listener(this.snapshot()); return () => { this.listeners.delete(listener); }; } + private publish() { if (!this.disposed) for (const listener of this.listeners) listener(this.snapshot()); } + secret(action: 'append' | 'backspace' | 'clear', value = '') { + if (this.disposed || this.active || this.current.signedIn) return; + if (action === 'clear') this.input = ''; + else if (action === 'backspace') this.input = this.input.slice(0, -1); + else if (action === 'append') this.input = (this.input + value.replace(/[\x00-\x1f\x7f]/g, '')).slice(0, 256); + this.current.secretLength = this.input.length; this.publish(); + } + async request(request: OwnerRequest) { + if (this.disposed) return false; + if (request.action === 'signout') { + this.cancel(); this.session = undefined; this.current.signedIn = false; this.current.owner = undefined; + this.current.message = 'Signed out. Host and agents keep running.'; this.publish(); return true; + } + if (this.active || this.current.signedIn) return false; + const abort = new AbortController(), generation = ++this.generation; + this.active = abort; this.current.phase = 'busy'; this.current.message = request.action === 'probe' ? 'Checking Desktop identity…' : 'Signing in… Esc cancels.'; this.publish(); + let error = 'Sign-in failed. No session was opened. Check the retained owner configuration and try again.'; + const check = () => { abort.signal.throwIfAborted(); if (generation !== this.generation || this.disposed) throw Error('Cancelled'); }; + try { + if (request.action === 'probe') { + const result = await this.desktop(true, abort.signal); check(); + this.current.desktop = result.available ? 'available' : 'unavailable'; + this.current.desktopReason = result.available ? 'Desktop entry detected. Sign-in checks identity and access.' : missing; + this.current.message = result.available ? 'Buzz Desktop identity is available.' : missing; + } else { + const before = this.binding(); + const relay = before?.relay ?? request.relay?.trim(); + error = 'Enter a relay URL using wss:// (or ws://localhost for local testing). Nothing was changed.'; + if (!relay) throw Error(); + const url = new URL(relay); + if (url.username || url.password || url.hash || !(url.protocol === 'wss:' || (url.protocol === 'ws:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw Error(); + let secret: string; + if (request.action === 'signin-desktop') { + error = 'Buzz Desktop key access failed or was cancelled. Unlock Keychain and check again, or provide an owner nsec. No session was opened.'; + const result = await this.desktop(false, abort.signal); check(); + if (!result.secret) throw Error(); + secret = result.secret; + } else if (request.action === 'signin-nsec') { + error = 'Enter a valid owner nsec. No session was opened and nothing was saved.'; + secret = agentNsec(this.input); + } else throw Error('Unsupported action'); + const owner = publicKey(secret); check(); + error = 'This identity does not match the configured owner. Use the matching identity. Owner and relay were not changed.'; + const now = this.binding(); + if (now && (now.owner !== owner || now.relay !== relay)) throw Error(); + error = 'Owner routing changed or could not be saved. Reopen sign-in and check the retained configuration. No session was opened.'; + if (!now) createControllerConfig(join(this.home, '.beehive', 'owner'), owner, relay); + // Read back the sole public authority after first-use activation. + const saved = this.binding(); + if (saved?.owner !== owner || saved.relay !== relay) throw Error(); + check(); this.session = secret; this.current.signedIn = true; this.current.owner = nip19.npubEncode(owner); this.projectBinding(); + this.current.message = 'Signed in. No Host, agent, or relay operation was started.'; + } + this.current.phase = 'idle'; return true; + } catch { + if (generation === this.generation && !this.disposed) { this.current.phase = 'error'; this.current.message = error; } + return false; + } finally { + if (generation === this.generation) { this.input = ''; this.current.secretLength = 0; } + if (this.active === abort) this.active = undefined; + this.publish(); + } + } + cancel() { this.generation++; this.active?.abort(); this.active = undefined; this.input = ''; this.current.secretLength = 0; this.current.phase = 'idle'; this.current.message = 'Sign-in cancelled. No requested action was run.'; this.publish(); } + dispose() { this.cancel(); this.session = undefined; this.current.owner = undefined; this.current.signedIn = false; this.disposed = true; this.listeners.clear(); } +} diff --git a/beehive/src/provider-dialog.ts b/beehive/src/provider-dialog.ts index 91fd87318d..1ee77c7012 100644 --- a/beehive/src/provider-dialog.ts +++ b/beehive/src/provider-dialog.ts @@ -107,7 +107,7 @@ export class ProviderDialog { // First input replaces it; the existing empty-secret save contract retains it. const value = field.secret ? '•'.repeat(Math.min(field.savedSecret ? 12 : this.secretLength, width - 10)) : field.value ?? ''; const focused = this.index === i; - const prompt = field.secret ? 'Enter API key' : `Enter ${field.label.toLowerCase()}`; + const prompt = field.secret && field.label === 'API key' ? 'Enter API key' : `Enter ${field.label.toLowerCase()}`; const suffix = field.choices ? ' ← →' : ''; row.content = (value || prompt).slice(-(width - 10 - suffix.length)).padEnd(width - 10 - suffix.length) + suffix; row.fg = value ? palette.text : palette.muted; diff --git a/beehive/src/shell-state.ts b/beehive/src/shell-state.ts index 31035c1186..db40fa8a4c 100644 --- a/beehive/src/shell-state.ts +++ b/beehive/src/shell-state.ts @@ -10,6 +10,7 @@ export type HarnessListRow = Readonly<{ id: string; kind: 'harness' | 'command' /** Framework-independent state for the never-signed-in shell tracer. */ export class ShellState { + signedIn = false; headerIndex = 0; activeSection = 0; focus: FocusRegion = 'header'; diff --git a/beehive/test/owner-ipc.test.ts b/beehive/test/owner-ipc.test.ts new file mode 100644 index 0000000000..cfece0c5d8 --- /dev/null +++ b/beehive/test/owner-ipc.test.ts @@ -0,0 +1,29 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { nip19 } from 'nostr-tools'; +import { localOwner } from '../src/owner-client.ts'; +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +test('real private IPC binds public snapshots to session, cancel, signout and restart', async () => { + const home = mkdtempSync(join(tmpdir(), 'owner-tui-fixture-')); + writeFileSync(join(home, 'desktop-mode'), 'available'); + const open = () => localOwner({ helper: new URL('./owner-tui-fixture-child.ts', import.meta.url), environment: { PATH: process.env.PATH, BEEHIVE_HOME: home } }); + let client = open(); const snapshots: unknown[] = []; client.subscribe(s => snapshots.push(s)); + try { + assert.equal(await client.request({ action: 'probe' }), true); assert.equal(client.snapshot().desktop, 'available'); + const nsec = nip19.nsecEncode(Buffer.from('1'.repeat(64), 'hex')); + client.secret('append', nsec); + assert.equal(await client.request({ action: 'signin-nsec', relay: 'wss://relay.example' }), true); + assert.equal(client.snapshot().signedIn, true); + assert.doesNotMatch(JSON.stringify(snapshots), new RegExp(nsec)); + await client.request({ action: 'signout' }); assert.equal(client.snapshot().signedIn, false); + writeFileSync(join(home, 'desktop-mode'), 'delayed'); + const pending = client.request({ action: 'signin-desktop' }); await sleep(80); client.cancel(); + assert.equal(await pending, false); assert.equal(client.snapshot().signedIn, false); + client.dispose(); client = open(); + await client.request({ action: 'probe' }); assert.equal(client.snapshot().signedIn, false); + assert.equal(existsSync(join(home, '.beehive/owner/controller.json')), true); + } finally { client.dispose(); rmSync(home, { recursive: true, force: true }); } +}); diff --git a/beehive/test/owner-key.test.ts b/beehive/test/owner-key.test.ts new file mode 100644 index 0000000000..3bf895b743 --- /dev/null +++ b/beehive/test/owner-key.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { desktopOwnerKey } from '../src/owner-key.ts'; +import { nip19 } from 'nostr-tools'; +test('native adapter handles metadata, explicit identity, malformed/denied output and abort without a real Keychain', async () => { + const home = mkdtempSync('/tmp/owner-key-adapter-'), executable = home+'/helper'; + const nsec = nip19.nsecEncode(Buffer.from('1'.repeat(64),'hex')); + const helper = (body: string) => writeFileSync(executable, `#!${process.execPath}\n${body}`, {mode:0o700}); + try { + helper(`let input='';process.stdin.on('data',v=>input+=v);process.stdin.on('end',()=>console.log(JSON.stringify(input==='probe'?{available:true}:{identity:${JSON.stringify(nsec)}})));`); + assert.deepEqual(await desktopOwnerKey(true,new AbortController().signal,executable),{available:true}); + assert.deepEqual(await desktopOwnerKey(false,new AbortController().signal,executable),{secret:'1'.repeat(64)}); + for(const value of [{reason:'access'},{identity:'malformed'},{agent:nsec}]) { + helper(`console.log(${JSON.stringify(JSON.stringify(value))})`); + assert.equal((await desktopOwnerKey(false,new AbortController().signal,executable)).secret,undefined); + } + helper(`process.stdin.resume();setTimeout(()=>console.log(JSON.stringify({identity:${JSON.stringify(nsec)}})),30000);`); + const abort = new AbortController(), pending=desktopOwnerKey(false,abort.signal,executable); + abort.abort(); assert.deepEqual(await pending,{reason:'access'}); + helper(`console.log('x'.repeat(5000))`); + assert.equal((await desktopOwnerKey(false,new AbortController().signal,executable)).secret,undefined); + } finally {rmSync(home,{recursive:true,force:true});} +}); diff --git a/beehive/test/owner-service.test.ts b/beehive/test/owner-service.test.ts new file mode 100644 index 0000000000..57b128fe15 --- /dev/null +++ b/beehive/test/owner-service.test.ts @@ -0,0 +1,115 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { nip19 } from 'nostr-tools'; +import { OwnerService } from '../src/owner-service.ts'; +import { publicKey } from '../src/protocol.ts'; +import { createControllerConfig } from '../src/controller-config.ts'; +import { bootstrapHostIdentity } from '../src/host-identity.ts'; +const secret = '1'.repeat(64), other = '2'.repeat(64); +const nsec = nip19.nsecEncode(Buffer.from(secret, 'hex')); +const relay = 'wss://relay.example'; +function fixture(desktop: ConstructorParameters[1] = async probe => probe ? ({ available: true }) : ({ secret })) { + const home = mkdtempSync(join(tmpdir(), 'owner-session-')); + const service = new OwnerService(home, desktop); + return { home, service, close() { service.dispose(); rmSync(home, { recursive: true, force: true }); } }; +} +test('nsec session persists only public binding; signout and reopen never restore a signer', async () => { + const f = fixture(); + try { + f.service.secret('append', nsec); + assert.equal(await f.service.request({ action: 'signin-nsec', relay }), true); + assert.equal(f.service.snapshot().signedIn, true); + const file = join(f.home, '.beehive/owner/controller.json'); + const saved = readFileSync(file, 'utf8'); + assert.deepEqual(JSON.parse(saved), { version: 1, owner: publicKey(secret), relay }); + assert.ok(!saved.includes(nsec) && !saved.includes(secret)); + assert.doesNotMatch(JSON.stringify(f.service.snapshot()), new RegExp(nsec+'|'+secret)); + assert.equal(f.service.snapshot().secretLength, 0); + assert.equal(existsSync(join(f.home, '.beehive/host')), false); + await f.service.request({ action: 'signout' }); + assert.equal(f.service.snapshot().signedIn, false); assert.equal(f.service.snapshot().owner, undefined); + const reopened = new OwnerService(f.home, async () => { throw Error('No automatic Keychain read'); }); + assert.equal(reopened.snapshot().signedIn, false); reopened.dispose(); + assert.equal(readFileSync(file, 'utf8'), saved); + } finally { f.close(); } +}); +test('Desktop probe never creates session or files; explicit Desktop sign-in derives matching owner', async () => { + const calls: boolean[] = [], f = fixture(async probe => { calls.push(probe); return probe ? { available: true } : { secret }; }); + try { + await f.service.request({ action: 'probe' }); + assert.deepEqual(calls, [true]); assert.equal(f.service.snapshot().desktop, 'available'); + assert.equal(f.service.snapshot().signedIn, false); assert.equal(existsSync(join(f.home, '.beehive')), false); + assert.equal(await f.service.request({ action: 'signin-desktop', relay }), true); + assert.deepEqual(calls, [true, false]); assert.equal(f.service.snapshot().owner, nip19.npubEncode(publicKey(secret))); + } finally { f.close(); } +}); +test('invalid nsec, invalid relay and missing/denied Desktop never write routing or leak failure text', async () => { + const f = fixture(async () => ({ reason: 'access secret-never-print' })); + try { + await f.service.request({ action: 'probe' }); assert.equal(f.service.snapshot().desktop, 'unavailable'); + for (const action of ['signin-nsec', 'signin-desktop'] as const) { + f.service.secret('append', 'invalid-secret-never-print'); + assert.equal(await f.service.request({ action, relay }), false); + assert.equal(f.service.snapshot().signedIn, false); + assert.doesNotMatch(JSON.stringify(f.service.snapshot()), /secret-never-print/); + } + for (const bad of ['', 'https://relay.example', 'ws://remote.example', 'wss://name:password@relay.example']) { + f.service.secret('append', nsec); assert.equal(await f.service.request({ action: 'signin-nsec', relay: bad }), false); + } + assert.equal(existsSync(join(f.home, '.beehive')), false); + } finally { f.close(); } +}); +test('existing Host owner and relay are authority; mismatch leaves all public state unchanged', async () => { + const f = fixture(); const keys = new Map(); + try { + bootstrapHostIdentity(join(f.home, '.beehive/host'), 'Fixture Host', publicKey(other), relay, { + read: ref => keys.get(ref.publicKey) ?? null, create: (ref, value) => { keys.set(ref.publicKey, value); }, remove: ref => { keys.delete(ref.publicKey); }, + }); + const file = join(f.home, '.beehive/host/host-identity.json'), before = readFileSync(file, 'utf8'); + assert.equal(await f.service.request({ action: 'signin-desktop', relay }), false); + assert.match(f.service.snapshot().message, /does not match/); + assert.equal(readFileSync(file, 'utf8'), before); + assert.equal(existsSync(join(f.home, '.beehive/owner')), false); + f.service.secret('append', nip19.nsecEncode(Buffer.from(other, 'hex'))); + assert.equal(await f.service.request({ action: 'signin-nsec', relay: 'wss://ignored.example' }), true); + assert.equal(f.service.snapshot().relay, relay); + } finally { f.close(); } +}); +test('concurrent first-use winner is rechecked after credential completion', async () => { + let finish!: (value: { secret: string }) => void; + const f = fixture(() => new Promise(resolve => finish = resolve)); + try { + const work = f.service.request({ action: 'signin-desktop', relay }); + createControllerConfig(join(f.home, '.beehive/owner'), publicKey(other), relay); + finish({ secret }); assert.equal(await work, false); + assert.equal(f.service.snapshot().signedIn, false); + assert.equal(JSON.parse(readFileSync(join(f.home, '.beehive/owner/controller.json'), 'utf8')).owner, publicKey(other)); + } finally { f.close(); } +}); +for (const stop of ['cancel', 'signout', 'dispose'] as const) test(`${stop} fences late Desktop completion and clears input`, async () => { + let finish!: (value: { secret: string }) => void; + const f = fixture(() => new Promise(resolve => finish = resolve)); + try { + const work = f.service.request({ action: 'signin-desktop', relay }); + if (stop === 'signout') await f.service.request({ action: 'signout' }); else f.service[stop](); + finish({ secret }); assert.equal(await work, false); + assert.equal(f.service.snapshot().signedIn, false); assert.equal(f.service.snapshot().secretLength, 0); + assert.equal(existsSync(join(f.home, '.beehive')), false); + } finally { f.close(); } +}); + +test('cancelled read cannot block or overwrite a replacement session', async () => { + let finish!: (value: { secret: string }) => void; + const f = fixture(() => new Promise(resolve => finish = resolve)); + try { + const old = f.service.request({ action: 'signin-desktop', relay }); + f.service.cancel(); f.service.secret('append', nsec); + assert.equal(await f.service.request({ action: 'signin-nsec', relay }), true); + finish({ secret: other }); assert.equal(await old, false); + assert.equal(f.service.snapshot().owner, nip19.npubEncode(publicKey(secret))); + assert.equal(f.service.snapshot().signedIn, true); + } finally { f.close(); } +}); diff --git a/beehive/test/owner-tui-fixture-child.ts b/beehive/test/owner-tui-fixture-child.ts new file mode 100644 index 0000000000..1e7f41a7b8 --- /dev/null +++ b/beehive/test/owner-tui-fixture-child.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { OwnerService } from '../src/owner-service.ts'; +const home = process.env.BEEHIVE_HOME!; +if (!home?.includes('owner-tui-fixture-')) throw Error('Isolated owner fixture required'); +const service = new OwnerService(home, async (probe, signal) => { + const mode = readFileSync(join(home, 'desktop-mode'), 'utf8').trim(); + if (mode === 'delayed') await new Promise(resolve => setTimeout(resolve, 1200)); + signal.throwIfAborted(); + return mode === 'missing' || mode === 'denied' ? { reason: mode === 'missing' ? 'missing' : 'access' } : probe ? { available: true } : { secret: mode === 'mismatch' ? '2'.repeat(64) : '1'.repeat(64) }; +}); +service.subscribe(snapshot => { if (process.connected) process.send?.({ type: 'snapshot', snapshot }); }); +process.on('message', async (message: any) => { + if (message.type === 'secret') service.secret(message.action, message.value); + else if (message.type === 'cancel') service.cancel(); + else if (message.type === 'request') { + const ok = await service.request(message.request); + if (process.connected) process.send?.({ type: 'result', id: message.id, ok }); + } +}); +process.on('disconnect', () => { service.dispose(); process.exit(); }); diff --git a/beehive/test/owner-tui-fixture.ts b/beehive/test/owner-tui-fixture.ts new file mode 100644 index 0000000000..1f799c7275 --- /dev/null +++ b/beehive/test/owner-tui-fixture.ts @@ -0,0 +1,11 @@ +import { createCliRenderer } from '@opentui/core'; +import { OpenTuiShell } from '../src/opentui-shell.ts'; +import { localOwner } from '../src/owner-client.ts'; +import { HarnessInventoryController } from '../src/harness-inventory.ts'; +if (!process.env.BEEHIVE_HOME?.includes('owner-tui-fixture-')) throw Error('Isolated owner fixture required'); +const renderer = await createCliRenderer({ exitOnCtrlC: false, screenMode: 'alternate-screen', consoleMode: 'disabled', openConsoleOnError: false }); +const inventory = new HarnessInventoryController({ read: () => ({ version: 1, revision: 0, agents: [], providers: [], runtimes: [] }), save: candidate => candidate }, async () => []); +const client = localOwner({ helper: new URL('./owner-tui-fixture-child.ts', import.meta.url) }); +const shell = new OpenTuiShell(renderer, inventory, undefined, undefined, client); +process.once('SIGTERM', () => shell.close()); process.once('SIGINT', () => shell.close()); +await shell.done; diff --git a/beehive/test/shell-owner-ipc.test.ts b/beehive/test/shell-owner-ipc.test.ts new file mode 100644 index 0000000000..a1e2bee348 --- /dev/null +++ b/beehive/test/shell-owner-ipc.test.ts @@ -0,0 +1,49 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { createTestRenderer } from '@opentui/core/testing'; +import { OpenTuiShell } from '../src/opentui-shell.ts'; +import { HarnessInventoryController } from '../src/harness-inventory.ts'; +import { localOwner } from '../src/owner-client.ts'; +import { nip19 } from 'nostr-tools'; +for (const [width, height] of [[120,40],[60,20]]) test(`real owner IPC: form failure/recovery, Desktop denial/mismatch, navigation fencing at ${width}x${height}`, async () => { + const home = mkdtempSync('/tmp/owner-tui-fixture-shell-'); + const mode = (value: string) => writeFileSync(home+'/desktop-mode', value); + mode('available'); + const ui = await createTestRenderer({width,height,exitOnCtrlC:false}); + const client = localOwner({helper:new URL('./owner-tui-fixture-child.ts',import.meta.url),environment:{HOME:home,BEEHIVE_HOME:home,PATH:process.env.PATH}}); + const inventory = new HarnessInventoryController({read:()=>({version:1,revision:0,agents:[],providers:[],runtimes:[]}),save:c=>c},async()=>[]); + const shell = new OpenTuiShell(ui.renderer,inventory,undefined,undefined,client); + const wait = async (predicate:()=>boolean) => { for(let i=0;i<200;i++) { if(predicate()) { await ui.renderOnce(); return; } await new Promise(r=>setTimeout(r,10)); } assert.fail('Owner state did not settle'); }; + const input = ui.mockInput; + const enter = () => input.pressEnter(); + const submit = () => { input.pressTab(); enter(); }; + try { + await wait(()=>!client.snapshot().message.includes('Loading')); + for(let i=0;i<4;i++) input.pressArrow('right'); + await wait(()=>client.snapshot().desktop==='available'); + enter(); enter(); await ui.renderOnce(); + input.typeText('relay.example'); submit(); await new Promise(r=>setTimeout(r,0)); await ui.renderOnce(); assert.doesNotMatch(ui.captureCharFrame(),/CHOOSE A RELAY/); + input.pressArrow('down'); enter(); input.typeText('invalid'); submit(); + await wait(()=>client.snapshot().phase==='error'); + assert.match(ui.captureCharFrame(),/valid owner nsec/); + enter(); input.typeText(nip19.nsecEncode(Buffer.from('1'.repeat(64),'hex'))); + await wait(()=>client.snapshot().secretLength>0); + assert.doesNotMatch(ui.captureCharFrame(),/nsec1/); assert.match(ui.captureCharFrame(),/••/); + submit(); await wait(()=>client.snapshot().signedIn && shell.state.focus==='header'); + assert.doesNotMatch(ui.captureCharFrame(),/Continue to/); + enter(); enter(); await wait(()=>!client.snapshot().signedIn); + mode('denied'); enter(); await wait(()=>client.snapshot().phase==='error'); + assert.match(ui.captureCharFrame(),/key access failed/); + mode('mismatch'); enter(); await wait(()=>client.snapshot().phase==='error' && client.snapshot().message.includes('does not match')); + assert.match(ui.captureCharFrame(),/does not match/); + mode('delayed'); enter(); await wait(()=>client.snapshot().phase==='busy'); + input.pressArrow('up'); input.pressArrow('left'); + assert.equal(shell.state.activeSection,3); + await new Promise(r=>setTimeout(r,1400)); + assert.equal(client.snapshot().signedIn,false); assert.equal(shell.state.activeSection,3); + input.pressArrow('right'); mode('available'); enter(); enter(); + await wait(()=>client.snapshot().signedIn && shell.state.focus==='header'); + assert.match(ui.captureCharFrame(),/SIGNED IN/); + } finally {shell.close();rmSync(home,{recursive:true,force:true});} +}); diff --git a/beehive/test/shell-owner.test.ts b/beehive/test/shell-owner.test.ts new file mode 100644 index 0000000000..626f44e77f --- /dev/null +++ b/beehive/test/shell-owner.test.ts @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTestRenderer } from '@opentui/core/testing'; +import { OpenTuiShell } from '../src/opentui-shell.ts'; +import { HarnessInventoryController } from '../src/harness-inventory.ts'; +import type { OwnerClient, OwnerRequest, OwnerSnapshot } from '../src/owner-protocol.ts'; +function fixture() { + const snapshot: OwnerSnapshot = { signedIn: false, desktop: 'available', desktopReason: 'Uses the Buzz Desktop identity in Keychain.', phase: 'idle', message: 'Sign in to manage Host and Agents.', secretLength: 0, relay: 'wss://relay.example' }; + const requests: OwnerRequest[] = [], listeners = new Set<(s: OwnerSnapshot) => void>(); + const emit = () => { for (const fn of listeners) fn(structuredClone(snapshot)); }; + const client: OwnerClient = { + snapshot: () => structuredClone(snapshot), subscribe(fn) { listeners.add(fn); fn(structuredClone(snapshot)); return () => { listeners.delete(fn); }; }, + async request(r) { requests.push(r); if (r.action.startsWith('signin')) { snapshot.signedIn = true; snapshot.owner = 'npub1fixture'; snapshot.secretLength = 0; snapshot.message = 'Signed in. No operation was started.'; } else if (r.action === 'signout') { snapshot.signedIn = false; snapshot.owner = undefined; snapshot.message = 'Signed out. Host and agents keep running.'; } emit(); return true; }, + secret(action, value) { snapshot.secretLength = action === 'clear' ? 0 : action === 'backspace' ? Math.max(0, snapshot.secretLength - 1) : snapshot.secretLength + (value?.length ?? 0); emit(); }, + cancel() {}, dispose() {}, + }; + const inventory = new HarnessInventoryController({ read: () => ({ version: 1, revision: 0, agents: [], providers: [], runtimes: [] }), save: candidate => candidate }, async () => []); + return { snapshot, requests, client, inventory, emit }; +} +for (const [width, height] of [[120,40],[60,20]]) test(`owner standalone sign-in, explicit contextual continuation, and signout at ${width}x${height}`, async () => { + const ui = await createTestRenderer({ width, height, exitOnCtrlC: false }), f = fixture(); + const shell = new OpenTuiShell(ui.renderer, f.inventory, undefined, undefined, f.client); + try { + await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Sign in to view and manage Host/); + assert.equal(f.requests.length, 0); + ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /SIGN-IN REQUIRED/); assert.match(ui.captureCharFrame(), /Use Buzz Desktop identity/); + ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.equal(shell.state.focus, 'header'); assert.match(ui.captureCharFrame(), /SIGNED IN/); + assert.match(ui.captureCharFrame(), /Continue to Host/); assert.equal(shell.state.mode, 'owner'); + ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /not implemented/); assert.equal(shell.state.activeSection, 0); + ui.mockInput.pressKey('\x1b'); await new Promise(r => setTimeout(r, 50)); + for (let i=0;i<4;i++) ui.mockInput.pressArrow('right'); + ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.equal(shell.state.signedIn, false); assert.match(ui.captureCharFrame(), /Use Buzz Desktop identity/); + ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.equal(shell.state.signedIn, true); assert.equal(shell.state.mode, 'owner'); + assert.doesNotMatch(ui.captureCharFrame(), /Continue to/); + assert.deepEqual(f.requests.map(r=>r.action), ['signin-desktop','signout','signin-desktop']); + } finally { shell.close(); } +}); +for (const [width,height] of [[120,40],[60,20]]) test(`owner unavailable identity, masked input, cancellation and pointer at ${width}x${height}`, async () => { + const ui=await createTestRenderer({width,height,exitOnCtrlC:false}),f=fixture(); + f.snapshot.desktop='unavailable'; f.snapshot.desktopReason='No usable Buzz Desktop identity. Open Buzz or use nsec.'; + const shell=new OpenTuiShell(ui.renderer,f.inventory,undefined,undefined,f.client); + try { + for(let i=0;i<4;i++) ui.mockInput.pressArrow('right'); + ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.equal(f.requests.length,0); assert.match(ui.captureCharFrame(),/No usable Buzz/); + const lines=ui.captureCharFrame().split('\n'),y=lines.findIndex(l=>l.includes('Provide owner nsec')); + await ui.mockMouse.click(lines[y]!.indexOf('Provide owner nsec'),y);await ui.renderOnce(); + assert.match(ui.captureCharFrame(),/PROVIDE OWNER NSEC/); assert.match(ui.captureCharFrame(),/Enter owner nsec/); + ui.mockInput.typeText('nsec1never-show');await ui.renderOnce(); + assert.doesNotMatch(ui.captureCharFrame(),/nsec1never-show/); assert.match(ui.captureCharFrame(),/••/); + ui.mockInput.pressEscape();await new Promise(r=>setTimeout(r,50)); await ui.renderOnce(); + assert.equal(f.snapshot.secretLength,0);assert.equal(f.requests.length,0); + ui.mockInput.pressEnter();await ui.renderOnce();ui.mockInput.typeText('fixture');ui.mockInput.pressTab();ui.mockInput.pressEnter();await ui.renderOnce(); + assert.equal(f.requests.at(-1)?.action,'signin-nsec');assert.equal(shell.state.signedIn,true); + }finally{shell.close();} +}); diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index 5ea5e629e2..c09c1f873d 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -87,3 +87,7 @@ path = "src/bin/beehive_databricks.rs" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "=3.7.0" + +[[bin]] +name = "beehive-owner-key" +path = "src/bin/beehive_owner_key.rs" diff --git a/crates/buzz-agent/src/bin/beehive_owner_key.rs b/crates/buzz-agent/src/bin/beehive_owner_key.rs new file mode 100644 index 0000000000..af592bf1a8 --- /dev/null +++ b/crates/buzz-agent/src/bin/beehive_owner_key.rs @@ -0,0 +1,95 @@ +//! Exact, read-only Desktop identity access. Probe never opens Keychain UI. +use serde_json::{json, Value}; +use std::io::{IsTerminal, Read}; + +fn identity(raw: &[u8]) -> Result { + if raw.len() > 1024 * 1024 { + return Err("malformed"); + } + let value: Value = serde_json::from_slice(raw).map_err(|_| "malformed")?; + value + .get("identity") + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or("missing") +} + +#[cfg(target_os = "macos")] +fn read(probe: bool) -> Result { + use security_framework::{ + item::{ItemClass, ItemSearchOptions, SearchResult}, + os::macos::keychain::SecKeychain, + }; + let _no_ui = if probe { + Some(SecKeychain::disable_user_interaction().map_err(|_| "access")?) + } else { + None + }; + let rows = ItemSearchOptions::new() + .class(ItemClass::generic_password()) + .service("buzz-desktop") + .account("secrets") + .load_data(!probe) + .load_attributes(probe) + .skip_authenticated_items(probe) + .search() + .map_err(|error| { + if error.code() == -25300 { + "missing" + } else { + "access" + } + })?; + match rows.first() { + Some(SearchResult::Dict(_)) if probe => Ok(json!({"available": true})), + Some(SearchResult::Data(data)) if !probe => { + identity(data).map(|identity| json!({"identity": identity})) + } + _ => Err("missing"), + } +} + +#[cfg(not(target_os = "macos"))] +fn read(_: bool) -> Result { + Err("unsupported") +} + +fn main() { + if std::io::stdin().is_terminal() || std::io::stdout().is_terminal() { + std::process::exit(2); + } + let mut input = String::new(); + let result = if std::io::stdin() + .take(128) + .read_to_string(&mut input) + .is_err() + { + Err("input") + } else { + match input.trim() { + "probe" => read(true), + "read" => read(false), + _ => Err("input"), + } + }; + let output = match result { + Ok(value) => value, + Err(reason) => json!({"reason": reason}), + }; + println!("{output}"); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn extracts_only_identity() { + assert_eq!( + identity(br#"{"identity":"nsec1fixture","agent":"never-return"}"#), + Ok("nsec1fixture".into()) + ); + assert_eq!(identity(br#"{"agent":"never-return"}"#), Err("missing")); + assert_eq!(identity(b"bad"), Err("malformed")); + assert_eq!(identity(&vec![b' '; 1024 * 1024 + 1]), Err("malformed")); + } +}