mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
feat(beehive): add volatile owner session and protected section gates
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -86,3 +86,27 @@ contract explicitly with `python3 beehive/tools/export-harnesses.py <reviewed-SH
|
||||
from the repository root; its current source is Desktop main
|
||||
`78618804ec86a014524ad7d1fb55928e8f5c3edf`. Keep the existing helper binary in
|
||||
`bin/beehive-databricks`; do not build/install anything at runtime.
|
||||
|
||||
## Owner session helper
|
||||
|
||||
The OpenTUI manager also packages `beehive-owner-key`. Build it with the normal
|
||||
repository toolchain and default features, scoped to this binary:
|
||||
|
||||
```sh
|
||||
cargo build --locked --release -p buzz-agent --bin beehive-owner-key
|
||||
install -m 700 target/release/beehive-owner-key "$PACKAGE/beehive/bin/beehive-owner-key"
|
||||
```
|
||||
|
||||
This helper belongs only in the Beehive package. It makes no credential writes.
|
||||
On macOS its exact query is generic password service `buzz-desktop`, account
|
||||
`secrets`. The `probe` request returns metadata availability only, with Keychain
|
||||
interaction disabled and authenticated items skipped. Presence is not proof that
|
||||
the blob contains a valid owner identity; explicit `read` checks that next. Only
|
||||
explicit Desktop sign-in reads the `identity` member. No fallback, enumeration,
|
||||
agent-key selection or migration is permitted. Other platforms report unsupported;
|
||||
manual owner nsec remains available. The Node authority bounds output, owns the
|
||||
helper process and kills it on cancel or timeout. No secret returns to Bun.
|
||||
|
||||
Tests inject a synthetic executable or Desktop adapter. A fresh HOME is **not**
|
||||
Keychain isolation. Do not run the native read against real Desktop secrets as a
|
||||
smoke test. OS access/denial needs a separately authorized isolated Keychain test.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createCliRenderer } from '@opentui/core';
|
||||
import { OpenTuiShell } from './opentui-shell.ts';
|
||||
import { localOwner } from './owner-client.ts';
|
||||
import { localProviders } from './provider-client.ts';
|
||||
import { localHarnessInventory } from './harness-inventory.ts';
|
||||
|
||||
@@ -10,7 +11,7 @@ const renderer = await createCliRenderer({
|
||||
openConsoleOnError: false,
|
||||
});
|
||||
const inventory = localHarnessInventory();
|
||||
const shell = new OpenTuiShell(renderer, inventory, undefined, localProviders());
|
||||
const shell = new OpenTuiShell(renderer, inventory, undefined, localProviders(), localOwner());
|
||||
void inventory.refresh();
|
||||
const stop = () => shell.close();
|
||||
process.once('SIGINT', stop);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
20
beehive/src/owner-child.ts
Normal file
20
beehive/src/owner-child.ts
Normal file
@@ -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<boolean> | 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);
|
||||
40
beehive/src/owner-client.ts
Normal file
40
beehive/src/owner-client.ts
Normal file
@@ -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<number, (ok: boolean) => 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'); },
|
||||
};
|
||||
}
|
||||
31
beehive/src/owner-key.ts
Normal file
31
beehive/src/owner-key.ts
Normal file
@@ -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();
|
||||
});
|
||||
}
|
||||
153
beehive/src/owner-panel.ts
Normal file
153
beehive/src/owner-panel.ts
Normal file
@@ -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(); }
|
||||
}
|
||||
15
beehive/src/owner-protocol.ts
Normal file
15
beehive/src/owner-protocol.ts
Normal file
@@ -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<boolean>;
|
||||
secret(action: 'append' | 'backspace' | 'clear', value?: string): void;
|
||||
cancel(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
102
beehive/src/owner-service.ts
Normal file
102
beehive/src/owner-service.ts
Normal file
@@ -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(); }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
29
beehive/test/owner-ipc.test.ts
Normal file
29
beehive/test/owner-ipc.test.ts
Normal file
@@ -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 }); }
|
||||
});
|
||||
24
beehive/test/owner-key.test.ts
Normal file
24
beehive/test/owner-key.test.ts
Normal file
@@ -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});}
|
||||
});
|
||||
115
beehive/test/owner-service.test.ts
Normal file
115
beehive/test/owner-service.test.ts
Normal file
@@ -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<typeof OwnerService>[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<string, string>();
|
||||
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(); }
|
||||
});
|
||||
21
beehive/test/owner-tui-fixture-child.ts
Normal file
21
beehive/test/owner-tui-fixture-child.ts
Normal file
@@ -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(); });
|
||||
11
beehive/test/owner-tui-fixture.ts
Normal file
11
beehive/test/owner-tui-fixture.ts
Normal file
@@ -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;
|
||||
49
beehive/test/shell-owner-ipc.test.ts
Normal file
49
beehive/test/shell-owner-ipc.test.ts
Normal file
@@ -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});}
|
||||
});
|
||||
61
beehive/test/shell-owner.test.ts
Normal file
61
beehive/test/shell-owner.test.ts
Normal file
@@ -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();}
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
95
crates/buzz-agent/src/bin/beehive_owner_key.rs
Normal file
95
crates/buzz-agent/src/bin/beehive_owner_key.rs
Normal file
@@ -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<String, &'static str> {
|
||||
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<Value, &'static str> {
|
||||
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<Value, &'static str> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user