diff --git a/beehive/UX_NAVIGATION_CHECKPOINT.md b/beehive/UX_NAVIGATION_CHECKPOINT.md index 49d4c684c8..0cbb2a04f2 100644 --- a/beehive/UX_NAVIGATION_CHECKPOINT.md +++ b/beehive/UX_NAVIGATION_CHECKPOINT.md @@ -157,3 +157,107 @@ cd beehive "$BUN" test ./test/opentui-screen.bun.ts "$NODE" --test test/*.test.ts ``` + +## Continuation b3d1e450 — environment and typed completion slice + +This continuation remains **incomplete**, not a functional UX handback. No push, +export, installation or native change. The earlier missing-work list still applies +except that runtime environment configuration is now implemented. The earlier +navigation and Restart evidence remains valid for its stated scope. + +Implemented in this slice: + +- Node `ManagerController.request` returns `ManagerResult`: completed, submitted + (with operation ID), failed, cancelled or ignored. `manager-entry.ts` carries + that exact completion across IPC; `manager-view.ts` cannot treat a failed or + cancelled profile lookup as success. Old preview data is cleared before nsec + validation. Runtime discovery failure and model-query cancellation exit the + form. Submitted is **not** a host receipt or successful execution. +- Runtime setup includes a JSON name/value environment field before the name and + confirmation. Validation is shared between form, controller, settings, local + setup and launch. It permits at most 32 entries, 2048 UTF-8 bytes/value and 8192 + serialized bytes; blocks controls, identity/provider/harness configuration, + loader hooks and secret-bearing names. These are **nonsecret user-supplied + values**, not an OS credential editor; naming rules cannot detect a secret + disguised as an ordinary value. Provider/model/effort remain separate fields. +- The append-only settings snapshot includes environment; binding fingerprints + therefore include it. `settings-runtime.ts` projects it only into the selected + local binding; `host.ts` passes it into `prepareAgent`. `acp.ts` copies/freezes + it and overlays authoritative managed environment. Save never mutates a prior + definition or active run. Selected runtime detail lists variable names only. + +Source-grounded limits / next edits (do not repeat broad discovery): + +1. **Private Move is not an available existing manager operation.** There are + three separate refusals: `intents.ts` submit's `privateHosts` allowlist, + `catalog-transport.ts` send allowlist, and `host-transport.ts` authenticated + receive allowlist. Host Move prepare/grant messages also currently have no + private host-to-host authenticated routing. Legacy `move.test.ts` is necessary + but insufficient. Implement authenticated source/destination grant transport + and ownership verification before enabling the manager action. Do not merely + remove these allowlists or chain Stop/Start. Native sources were not needed + or changed in this slice. +2. **Directory still missing.** Current Desktop + `commands/agent_discovery/relay_directory.rs:list_relay_agents_for_selection` + first obtains relay-self identity, paginates kind39002 authored by that relay + and scoped to viewer `#p`, and owner kind30177 coordinates; then exact-agent + kind10100/kind0, verified profile owners and exact-owner `#d=agent` policy. + It retains membership-visible or viewer-owned agents. This is not kind0 + enumeration and is not signed Beehive assignment authority. Add a bounded, + cancellable Node adapter and explicit synthetic query transport; wire rows + separately from `ManagerController.inventory` (still private reports). +3. **Registration-first continuation still missing.** `ManagerResult` is now the + executable completion seam, not the requested governing registration/lifecycle + state machine. `registerAgent` remains identity-only. Next introduce one + captured operation state with exact agent/target/revisions and saved runtime; + extend the credential operation atomically with that runtime association; + continue only after verified registration and fresh assignment authority. + Retained key read-back and public-only assignment journals remain unchanged. + Do not manufacture genesis from nsec possession. Cancellation must discard + the captured continuation without claiming OS rollback. +4. **Desktop provider/discovery parity still missing.** Bounded current consumer + inspection: `AgentConfigFields.tsx:advancedEditorBlock` passes `config.env_vars` + into `EnvVarsEditor`, excluding API key and structured keys; the latter keeps + required/file-satisfied/hidden rows separate. `agentConfigCore.ts` owns runtime + descriptors and dependent resets. Beehive's new nonsecret local JSON subset + is intentionally more restrictive, not full Desktop parity; no historical + harness-contract pin was updated. Current provider catalog/auth/model consumer + alignment and remaining concise-copy cleanup still need implementation. + +ACP failure investigation: saved prior `full-suite.log:266–272` identifies the +external ACP boundary test and `AgentSession`'s per-RPC timeout, but contains no +fixture mode or method. The a83a47b diff changed only manager/navigation/rendering, +their tests/docs and walkthrough; it did not change ACP/host launch paths. This +excludes a direct ACP edit in that commit, **not** contention or an unrelated bug. +The cause remains unestablished. This slice adds a test-only wrapper identifying +fixture mode while preserving the original error as `cause`, timeout, assertions +and production behavior. Focused ACP passed; that is not a diagnosis or waiver. + +Evidence: `/Users/loganj/.buzz/artifacts/beehive-ux-b3d1e450/` + +- `focused.log`: 34/34 controller, runtime integration, Pi, environment and ACP. +- `host-environment.log`: 11/11. Actual host Save/Start/Stop through synthetic + transport and owned Buzz/OpenAI, Buzz/Databricks and Codex-shaped processes + assert environment received, old active run unchanged, OS provider seams + synthetic, and exact wire/inventory limits unchanged. Environment test also + observes an actual owned ACP child's environment and immutable launch copy. +- `typecheck-final.log`: strict passed. `renderer.log`: Bun 1.4.2 renderer 4/4. +- `pty-final.log`, `walkthrough.json`: real 100x30 Node/Bun UI. Registration and + environment cancellation preserve settings; environment Save persists exact + `REVIEW_MODE=synthetic`, selecting runtime shows corresponding details; provider + cancellation/save/models and four sections work; hidden keys absent; exit 0. + `11-environment-entered`, `12-runtime-confirmation`, `14-runtime-selected` + have both ANSI streams and reconstructed plain frames. `pty-walkthrough.py` + contains exact key interactions. First successful PTY evidence is retained + separately in sibling `beehive-ux-b3d1e450-first-pty/`. +- Package-wide suite was **not rerun**: requested functional scope is unfinished. + Prior 202 pass / 4 fail / 19 skip remains the last full-suite result, including + all four unclassified failures. No timeout/concurrency/assertion relaxation. + Native unchanged: reuse historical 700/0/1 evidence, not a new run. + +Reviewer entry for this slice only: `sh beehive/tools/ux-walkthrough.sh --help`, +then run without arguments. Add runtime -> harness -> provider -> model -> +Environment `{ "REVIEW_MODE": "synthetic" }` -> name -> yes. Select that runtime. +The fixture still deliberately blocks external service, relay and OS operations; +it does not establish registration-first or Move. Final independent UX acceptance +should wait for those implementation tasks, not treat this as completion. diff --git a/beehive/src/acp.ts b/beehive/src/acp.ts index f9e7c2f5ff..2fac6dbeb8 100644 --- a/beehive/src/acp.ts +++ b/beehive/src/acp.ts @@ -1,3 +1,4 @@ +import { runtimeEnvironment } from './runtime-environment.ts'; import { prepareCodexHome } from './codex-home.ts'; import { PI_ADAPTER, piEnvironment, piConfigEvidence } from './pi.ts'; import { fileURLToPath } from 'node:url'; @@ -12,7 +13,7 @@ import { createHash } from 'node:crypto'; import { spawnOwned, type OwnedProcess } from './owned.ts'; /** Host-prepared launch: executable/provider binding stays local; instructions are a validated public snapshot. Never accept remote env/argv. */ -export type AgentLaunch = Readonly<{ resolvedProviderKey?: string; buzzProvider?: BuzzProvider; custom?: CustomAcp; executable: string; args: readonly string[]; workspace: string; home: string; configDirectory: string; databricksHost: string; harness?: 'goose' | 'claude' | 'codex' | 'pi'; piCli?: string; codex?: CodexSetup; claude?: ClaudeSetup; provider?: string; model: string; instructions?: string }>; +export type AgentLaunch = Readonly<{ environment?: Record; resolvedProviderKey?: string; buzzProvider?: BuzzProvider; custom?: CustomAcp; executable: string; args: readonly string[]; workspace: string; home: string; configDirectory: string; databricksHost: string; harness?: 'goose' | 'claude' | 'codex' | 'pi'; piCli?: string; codex?: CodexSetup; claude?: ClaudeSetup; provider?: string; model: string; instructions?: string }>; /** ACP catalogs can be fallback data; even a nonempty result is NOT auth evidence. */ export type Catalog = { state: 'reported' | 'empty' | 'filtered'; models: string[]; authentication: 'unverified' }; /** Same child/session acknowledgement plus completed text response, not provider attestation. */ @@ -28,7 +29,8 @@ const identifier = (v: unknown): string => { }; /** Validate and snapshot local launch settings. No ambient provider credentials inherited. */ export function prepareAgent(input: AgentLaunch) { - const plan = Object.freeze({ ...input, args: Object.freeze([...input.args]) }); + const environment = Object.freeze(runtimeEnvironment(input.environment ?? {})); + const plan = Object.freeze({ ...input, ...(input.environment ? { environment } : {}), args: Object.freeze([...input.args]) }); if (plan.harness === 'codex' && plan.codex?.managedHome) prepareCodexHome(plan.codex.managedHome, plan.home, plan.configDirectory); for (const p of [plan.executable, plan.workspace, plan.home, plan.configDirectory]) { if (!isAbsolute(p) || realpathSync(p) !== p) throw Error('Harness setup requires canonical absolute paths'); @@ -41,31 +43,31 @@ export function prepareAgent(input: AgentLaunch) { } if (plan.harness === 'pi') { if (plan.args.length || !plan.piCli || !plan.buzzProvider) throw Error('Invalid Pi launch'); - return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze(piEnvironment(plan.piCli, plan.buzzProvider, plan.home, plan.model, plan.resolvedProviderKey)), cliExecutableHash: hash(readFileSync(plan.piCli)) }); + return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze({ ...environment, ...piEnvironment(plan.piCli, plan.buzzProvider, plan.home, plan.model, plan.resolvedProviderKey) }), cliExecutableHash: hash(readFileSync(plan.piCli)) }); } if (plan.harness === 'codex') { identifier(plan.model); if (plan.args.length || !plan.codex || !plan.codex.models.includes(plan.model) || plan.home === plan.configDirectory) throw Error('Codex requires zero-argument adapter, approved model and distinct HOME/CODEX_HOME'); - return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze(codexEnvironment(plan.codex, plan.home, plan.configDirectory, plan.model, plan.resolvedProviderKey)), cliExecutableHash: hash(readFileSync(plan.codex.cli)) }); + return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze({ ...environment, ...codexEnvironment(plan.codex, plan.home, plan.configDirectory, plan.model, plan.resolvedProviderKey) }), cliExecutableHash: hash(readFileSync(plan.codex.cli)) }); } if (plan.harness === 'claude') { identifier(plan.model); if (plan.args.length || !plan.claude) throw Error('Claude requires a zero-argument ACP adapter and local CLI/key binding'); - return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze(claudeEnvironment(plan.claude, plan.home, plan.model)), cliExecutableHash: hash(readFileSync(plan.claude.cli)) }); + return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze({ ...environment, ...claudeEnvironment(plan.claude, plan.home, plan.model) }), cliExecutableHash: hash(readFileSync(plan.claude.cli)) }); } if (plan.harness === 'goose') { identifier(plan.provider); identifier(plan.model); - return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze>({ ...plan.custom?.env, PATH: '/usr/bin:/bin', HOME: plan.home, GOOSE_PROVIDER: plan.provider!, GOOSE_MODEL: plan.model, GOOSE_MODE: 'auto' }) }); + return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze>({ ...environment, ...plan.custom?.env, PATH: '/usr/bin:/bin', HOME: plan.home, GOOSE_PROVIDER: plan.provider!, GOOSE_MODEL: plan.model, GOOSE_MODE: 'auto' }) }); } if (plan.buzzProvider) { if (plan.args.length || plan.harness || plan.databricksHost) throw Error('Mixed Buzz Agent provider launch contract'); - return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze>({ ...buzzProviderEnvironment(plan.buzzProvider, plan.home, plan.configDirectory, plan.model, plan.resolvedProviderKey), ...(plan.instructions === undefined ? {} : { BUZZ_AGENT_SYSTEM_PROMPT: plan.instructions }) }) }); + return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze>({ ...environment, ...buzzProviderEnvironment(plan.buzzProvider, plan.home, plan.configDirectory, plan.model, plan.resolvedProviderKey), ...(plan.instructions === undefined ? {} : { BUZZ_AGENT_SYSTEM_PROMPT: plan.instructions }) }) }); } const url = new URL(plan.databricksHost); if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw Error('Databricks workspace must be an HTTPS origin'); identifier(plan.model); - return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze>({ PATH: '/usr/bin:/bin', HOME: plan.home, BUZZ_AGENT_CONFIG_DIR: plan.configDirectory, BUZZ_AGENT_PROVIDER: 'databricks_v2', DATABRICKS_HOST: url.origin, BUZZ_AGENT_MODEL: plan.model, ...(plan.instructions === undefined ? {} : { BUZZ_AGENT_SYSTEM_PROMPT: plan.instructions }) }) }); + return Object.freeze({ plan, executableHash: hash(readFileSync(plan.executable)), env: Object.freeze>({ ...environment, PATH: '/usr/bin:/bin', HOME: plan.home, BUZZ_AGENT_CONFIG_DIR: plan.configDirectory, BUZZ_AGENT_PROVIDER: 'databricks_v2', DATABRICKS_HOST: url.origin, BUZZ_AGENT_MODEL: plan.model, ...(plan.instructions === undefined ? {} : { BUZZ_AGENT_SYSTEM_PROMPT: plan.instructions }) }) }); } /** Spawn-fixed Goose model evidence from native ACP configOptions, not set_model. diff --git a/beehive/src/host.ts b/beehive/src/host.ts index 15ef467e69..f78d3c5218 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -1,3 +1,4 @@ +import { runtimeEnvironment } from './runtime-environment.ts'; import { validatePi } from './pi.ts'; import { readSettings } from './settings.ts'; import { runtimeBindings } from './settings-runtime.ts'; @@ -26,7 +27,7 @@ import { spawnOwned, type OwnedProcess } from './owned.ts'; import { AgentSession, prepareAgent, type AgentLaunch, type Catalog, type Evidence } from './acp.ts'; import { prepareConversation, conversationSummary, type ConversationSetup } from './conversation.ts'; -export type Setup = { host: string; ownerSecret?: string; ownerPublic?: string; agentSecret?: string; runner: string; args: string[]; workspace: string; allowedWorkspaces?: string[]; custom?: CustomAcp; buzzProvider?: BuzzProvider; mode: 'buzz-agent-api-key' | 'diagnostic-acp' | 'fixture' | 'buzz-agent-databricks-v2' | 'goose' | 'claude' | 'codex' | 'pi'; piCli?: string; codex?: CodexSetup; claude?: ClaudeSetup; gooseProvider?: string; gooseModels?: string[]; databricksHost?: string; serviceHome?: string; configDirectory?: string; conversation?: ConversationSetup }; +export type Setup = { environment?: Record; host: string; ownerSecret?: string; ownerPublic?: string; agentSecret?: string; runner: string; args: string[]; workspace: string; allowedWorkspaces?: string[]; custom?: CustomAcp; buzzProvider?: BuzzProvider; mode: 'buzz-agent-api-key' | 'diagnostic-acp' | 'fixture' | 'buzz-agent-databricks-v2' | 'goose' | 'claude' | 'codex' | 'pi'; piCli?: string; codex?: CodexSetup; claude?: ClaudeSetup; gooseProvider?: string; gooseModels?: string[]; databricksHost?: string; serviceHome?: string; configDirectory?: string; conversation?: ConversationSetup }; /** Definition-only fingerprint: common host authority and agent key are excluded. */ export function bindingFingerprint(setup: Setup): string { const { host: _host, ownerSecret: _owner, ownerPublic: _ownerPublic, agentSecret: _agent, ...harness } = setup; @@ -49,6 +50,7 @@ export function setupOwner(setup: Pick): s export function setupModels(s: Setup): string[] { return (s.mode === 'buzz-agent-api-key' || s.mode === 'pi') ? [...s.buzzProvider!.models] : s.mode === 'diagnostic-acp' ? [] : s.mode === 'codex' ? [...s.codex!.models] : s.mode === 'claude' ? [...s.claude!.models] : s.mode === 'goose' ? [...s.gooseModels!] : [s.mode === 'fixture' ? 'fixture-model' : 'databricks-claude-haiku-4-5']; } export function validateSetup(value: unknown): Setup { const s = object(value); + if (s.environment !== undefined) runtimeEnvironment(s.environment); for (const key of ['host','runner','workspace']) text(s[key]); if (s.allowedWorkspaces !== undefined && (!Array.isArray(s.allowedWorkspaces) || s.allowedWorkspaces.length > 32 || s.allowedWorkspaces.some(w => typeof w !== 'string' || !isAbsolute(w)))) throw Error('Invalid allowed workspaces'); setupOwner(s as Setup); @@ -254,7 +256,7 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri resolvedProviderKey = result.secret; result.secret = undefined; } finally { if (credentialRead === controller) credentialRead = undefined; } } - const launch: AgentLaunch | undefined = setup.mode === 'fixture' ? undefined : { ...(resolvedProviderKey ? { resolvedProviderKey } : {}), executable: setup.runner, args: setup.args, workspace: selected.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), ...(setup.buzzProvider ? { buzzProvider: setup.buzzProvider } : {}), databricksHost: setup.mode === 'buzz-agent-api-key' || setup.mode === 'pi' || setup.mode === 'goose' || setup.mode === 'claude' || setup.mode === 'codex' ? '' : text(setup.databricksHost), ...(setup.mode === 'pi' ? { harness: 'pi' as const, piCli: setup.piCli } : {}), ...(setup.mode === 'codex' ? { harness: 'codex' as const, codex: setup.codex } : {}), ...(setup.mode === 'claude' ? { harness: 'claude' as const, claude: setup.claude } : {}), ...(setup.mode === 'goose' ? { harness: 'goose' as const, provider: setup.gooseProvider, ...(setup.custom ? { custom: setup.custom } : {}) } : {}), model: selected.model, ...(selected.behavior ? { instructions: selected.behavior.instructions } : {}) }; + const launch: AgentLaunch | undefined = setup.mode === 'fixture' ? undefined : { ...(setup.environment ? { environment: runtimeEnvironment(setup.environment) } : {}), ...(resolvedProviderKey ? { resolvedProviderKey } : {}), executable: setup.runner, args: setup.args, workspace: selected.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), ...(setup.buzzProvider ? { buzzProvider: setup.buzzProvider } : {}), databricksHost: setup.mode === 'buzz-agent-api-key' || setup.mode === 'pi' || setup.mode === 'goose' || setup.mode === 'claude' || setup.mode === 'codex' ? '' : text(setup.databricksHost), ...(setup.mode === 'pi' ? { harness: 'pi' as const, piCli: setup.piCli } : {}), ...(setup.mode === 'codex' ? { harness: 'codex' as const, codex: setup.codex } : {}), ...(setup.mode === 'claude' ? { harness: 'claude' as const, claude: setup.claude } : {}), ...(setup.mode === 'goose' ? { harness: 'goose' as const, provider: setup.gooseProvider, ...(setup.custom ? { custom: setup.custom } : {}) } : {}), model: selected.model, ...(selected.behavior ? { instructions: selected.behavior.instructions } : {}) }; const prepared = launch ? prepareAgent(launch) : undefined; const conversation = setup.conversation && launch ? prepareConversation(setup.conversation, launch, executionSecret, state.binding.owner) : undefined; // Local hashes only; no setup or secret values leave the host. Include existing diff --git a/beehive/src/manager-controller.ts b/beehive/src/manager-controller.ts index 007efdeb22..b782d71095 100644 --- a/beehive/src/manager-controller.ts +++ b/beehive/src/manager-controller.ts @@ -1,3 +1,4 @@ +import { runtimeEnvironment } from './runtime-environment.ts'; import { addDatabricks, databricksNative } from './databricks.ts'; import { existsSync } from 'node:fs'; import { readSettings, saveSettings, settingsId, type Settings, type RegisteredAgent } from './settings.ts'; @@ -18,6 +19,12 @@ import { fetchRelayName } from './relay-name.ts'; import { message, type Message } from './protocol.ts'; import { profileDrafts, editProfileDraft } from './profile-drafts.ts'; +/** Controller completion is not a host receipt: submitted work remains pending. */ +export type ManagerResult = + | { state: 'completed' } + | { state: 'submitted'; operationId: string } + | { state: 'failed' | 'cancelled' | 'ignored'; reason: string }; + export type ManagerRequest = { id: number; action: string; values?: Record; target?: string; revision?: number }; export type ManagerItem = { id: string; label: string; detail: string; evidence?: string; disabled?: Record }; export type ManagerSnapshot = { local: ManagerItem[]; agents: (ManagerItem & { revision: number; configurations: string[] })[]; routing?: { owner: string; relay: string }; owner?: string; status: string; settings?: Settings; service?: ServiceStatus; hostRelay?: string; relayName?: string; profilePreview?: RegisteredAgent; models?: string[]; runtimeExecutable?: string; harnesses?: DetectedHarness[]; databricksHost?: string }; @@ -236,18 +243,19 @@ A Stop result is not a recent host report that confirms the agent is stopped.` } } cancel() { this.generation++; this.active?.abort(); } close() { this.closed = true; this.cancel(); this.relayNamePending?.abort.abort(); this.client?.close(); this.client = undefined; this.owner = undefined; } - async request(request: ManagerRequest) { - if (this.closed || request.id <= this.lastId || this.active) return; + async request(request: ManagerRequest): Promise { + if (this.closed || request.id <= this.lastId || this.active) return { state: 'ignored', reason: 'Request is closed, duplicate, or busy.' }; this.lastId = request.id; const abort = new AbortController(); this.active = abort; const generation = ++this.generation; const check = () => { abort.signal.throwIfAborted(); if (this.closed || generation !== this.generation) throw plain('Stopped waiting. Changes may already be saved or submitted. Inspect before you try again.'); }; const v = request.values ?? {}; + let result: ManagerResult = { state: 'completed' }; try { if (request.action === 'profile-preview') { + this.profilePreview = undefined; const identity = readHostIdentityPublic(this.hostDirectory); const key = publicKey(agentNsec(v.secret ?? '')); - this.profilePreview = undefined; const profile = await fetchAgentProfile(identity.pairing.relay,key,abort.signal); check(); this.profilePreview = { publicKey: key, key: { service: 'beehive', role: 'agent', publicKey: key }, ...profile }; this.status = profile.profileState === 'found' ? 'Signed public profile found.' : profile.profileState === 'none' ? 'No profile found. You can register with the public key.' : 'Profile lookup unavailable. You can register without a profile.'; @@ -277,7 +285,7 @@ A Stop result is not a recent host report that confirms the agent is stopped.` } const previous = readSettings(this.hostDirectory); const provider = previous.providers.find(p => p.id === v.provider); if (!selected?.executable || !provider || !selected.providers.includes(provider.type) || !['buzz-agent','codex','pi'].includes(selected.id)) throw plain('No supported executable/provider combination found.'); - saveSettings(this.hostDirectory,{ ...previous, runtimes: [...previous.runtimes,{ id: settingsId(), name: v.name ?? '', harness: selected.id as 'buzz-agent' | 'codex' | 'pi', executable: selected.executable, ...(['codex','pi'].includes(selected.id) ? { cli: selected.cli } : {}), providerId: v.provider ?? '', model: v.model ?? '', ...(v.effort ? { effort: v.effort } : {}) }] },previous.revision); + saveSettings(this.hostDirectory,{ ...previous, runtimes: [...previous.runtimes,{ id: settingsId(), name: v.name ?? '', harness: selected.id as 'buzz-agent' | 'codex' | 'pi', executable: selected.executable, ...(['codex','pi'].includes(selected.id) ? { cli: selected.cli } : {}), providerId: v.provider ?? '', model: v.model ?? '', ...(v.environment ? { environment: runtimeEnvironment(JSON.parse(v.environment)) } : {}), ...(v.effort ? { effort: v.effort } : {}) }] },previous.revision); this.status = 'Runtime saved for new runs. Running agents did not change. Host loading is reported separately.'; } else if (request.action === 'host-start') { this.service = await startService(this.hostDirectory); check(); this.status = 'Host running. Registration and settings do not start agents.'; @@ -334,9 +342,11 @@ A Stop result is not a recent host report that confirms the agent is stopped.` } if (this.client.status().some(o => o.request.host === current.host && o.request.agent === current.agent && !['completed','failed'].includes(o.state))) throw plain('An operation has no confirmed result. Inspect operations and check operation results before you act again.'); const operation = message(request.action === 'select-config' ? 'save' : request.action as 'start' | 'stop' | 'restart', current.host, current.agent, current.revision, request.action === 'select-config' ? { configurationAction: 'select', name: v.name } : {}); this.client.submit(operation); + result = { state: 'submitted', operationId: operation.id }; this.status = `Operation ${operation.id}: ${plainOperationType(operation)} saved and pending.\nHost: ${current.host}\nAgent: ${current.agent}\nRelay publication does not confirm host acceptance. Inspect operations. Unknown does not mean stopped.`; } else if (request.action !== 'refresh') throw plain('Not available in this build'); - } catch (error) { if (!this.closed) this.status = error instanceof PlainStatus ? error.message : abort.signal.aborted && error === abort.signal.reason ? 'Stopped waiting. Changes may already be saved or submitted. Inspect before you try again.' : error instanceof Error ? plainBackendMessage(error.message) : `Could not complete the operation: ${String(error)}`; } + } catch (error) { result = { state: abort.signal.aborted || generation !== this.generation ? 'cancelled' : 'failed', reason: 'Operation did not complete; inspect status before retrying.' }; if (!this.closed) this.status = error instanceof PlainStatus ? error.message : abort.signal.aborted && error === abort.signal.reason ? 'Stopped waiting. Changes may already be saved or submitted. Inspect before you try again.' : error instanceof Error ? plainBackendMessage(error.message) : `Could not complete the operation: ${String(error)}`; } finally { delete v.secret; this.active = undefined; this.refresh(); } + return result; } } diff --git a/beehive/src/manager-entry.ts b/beehive/src/manager-entry.ts index 0e29f734c0..116817d83f 100644 --- a/beehive/src/manager-entry.ts +++ b/beehive/src/manager-entry.ts @@ -23,7 +23,7 @@ input.on('line', line => { const value = JSON.parse(line); if (value.quit) controller.close(); else if (value.cancel) controller.cancel(); - else void controller.request(value).then(() => send({ complete: value.id })); + else void controller.request(value).then(result => send({ complete: value.id, result })); } catch { controller.cancel(); } }); const refresh = setInterval(() => controller.refresh(), 2000); diff --git a/beehive/src/manager-navigation.ts b/beehive/src/manager-navigation.ts index d4c850f18d..043e61812f 100644 --- a/beehive/src/manager-navigation.ts +++ b/beehive/src/manager-navigation.ts @@ -10,7 +10,7 @@ export function sectionRows(snapshot: ManagerSnapshot, section: number): Manager if (section === 1) return snapshot.agents; if (section === 2) return [ ...(snapshot.harnesses ?? []).map(h => ({ id: `harness:${h.id}`, label: `${h.label} · ${h.state}`, detail: `Harness: ${h.label}\nState: ${h.state}\nExecutable: ${h.executable ?? '—'}\nCLI: ${h.cli ?? '—'}` })), - ...(settings?.runtimes ?? []).map(r => ({ id: `runtime:${r.id}`, label: r.name, detail: `Runtime: ${r.name}\nHarness: ${r.harness}\nProvider: ${settings?.providers.find(p => p.id === r.providerId)?.name ?? '—'}\nModel: ${r.model}\nEffort: ${r.effort ?? 'Inherit'}` })), + ...(settings?.runtimes ?? []).map(r => ({ id: `runtime:${r.id}`, label: r.name, detail: `Runtime: ${r.name}\nHarness: ${r.harness}\nProvider: ${settings?.providers.find(p => p.id === r.providerId)?.name ?? '—'}\nModel: ${r.model}\nEffort: ${r.effort ?? 'Inherit'}\nEnvironment: ${Object.keys(r.environment ?? {}).join(', ') || 'None'}` })), ]; if (section === 3) return (settings?.providers ?? []).map(p => ({ id: p.id, label: p.name, detail: `Provider: ${p.name}\nType: ${p.type}\nEndpoint: ${p.endpoint}` })); return []; diff --git a/beehive/src/manager-view.ts b/beehive/src/manager-view.ts index c54aea0e98..61cb2e67c9 100644 --- a/beehive/src/manager-view.ts +++ b/beehive/src/manager-view.ts @@ -4,7 +4,7 @@ import { createCliRenderer } from '@opentui/core'; import { createReadStream, createWriteStream } from 'node:fs'; import { createInterface } from 'node:readline'; import { OpenTuiScreen, type ManagerAction } from './opentui-screen.ts'; -import type { ManagerRequest, ManagerSnapshot } from './manager-controller.ts'; +import type { ManagerRequest, ManagerSnapshot, ManagerResult } from './manager-controller.ts'; const renderer = await createCliRenderer({ exitOnCtrlC: false }); const screen = new OpenTuiScreen(renderer); @@ -12,11 +12,11 @@ const output = createWriteStream('', { fd: 4 }); let snapshot: ManagerSnapshot = { local: [], agents: [], status: 'Connecting to Beehive…' }; const navigation = new ManagerNavigation(); let scope = 0, selected = '', sequence = 0, lastStatus = ''; -let pending: { id: number; resolve: () => void } | undefined; +let pending: { id: number; resolve: (result: ManagerResult) => void } | undefined; function request(action: string, values?: Record, target?: string, revision?: number) { - if (pending) return Promise.resolve(); + if (pending) return Promise.resolve({ state: 'ignored', reason: 'An operation is pending.' }); const id = ++sequence; - const completion = new Promise(resolve => { pending = { id, resolve }; }); + const completion = new Promise(resolve => { pending = { id, resolve }; }); const message: ManagerRequest = { id, action, values, target, revision }; screen.setPending(true); screen.notice(action === 'add-databricks' ? 'Signing in through your browser… Esc cancels the native helper. An OS credential write or browser window may remain; cancelled sign-in will not save a provider.' : 'Working… Esc stops waiting. Remote work and saved changes are not cancelled. Inspect before you try again.'); output.write(JSON.stringify(message) + '\n'); @@ -62,7 +62,8 @@ function render() { if (!configured) { await routing('configure'); return; } let secret = await screen.input('Register agent\nAgent nsec private key. Hidden. Saved only in Beehive’s OS credential store.', '', true); if (secret === undefined) return; - await request('profile-preview', { secret }); + const result = await request('profile-preview', { secret }); + if (result.state !== 'completed') { secret = ''; return; } const preview = snapshot.profilePreview; if (!preview) { secret = ''; return; } if (await screen.confirm(`Register agent\n${preview.profile?.name ?? 'Name unavailable'}\n${preview.publicKey}\n${preview.profileState === 'unavailable' ? 'Relay unavailable. Register without a profile?' : preview.profileState === 'none' ? 'No profile found.' : preview.profile?.about ?? ''}\nThis does not authorize or start an agent. Save?`)) await request('register-agent',{ secret }); @@ -139,7 +140,7 @@ input.on('line', line => { try { const value = JSON.parse(line); if (value.snapshot) { snapshot = value.snapshot; render(); } - if (value.complete === pending?.id) { const old = pending; pending = undefined; screen.setPending(false); old?.resolve(); } + if (value.complete === pending?.id) { const old = pending; pending = undefined; screen.setPending(false); old?.resolve(value.result ?? { state: 'failed', reason: 'Missing controller result.' }); } } catch { screen.notice('Beehive could not read the response. Quit and inspect saved operation records before you try again.'); } }); input.on('close', () => screen.close()); diff --git a/beehive/src/opentui-screen.ts b/beehive/src/opentui-screen.ts index a033279195..a2fc6c7a48 100644 --- a/beehive/src/opentui-screen.ts +++ b/beehive/src/opentui-screen.ts @@ -1,9 +1,10 @@ import { RGBA, BoxRenderable, InputRenderable, ScrollBoxRenderable, SelectRenderable, TextRenderable, TextareaRenderable, type CliRenderer, type KeyEvent } from '@opentui/core'; +import type { ManagerResult } from './manager-controller.ts'; import { managerSections } from './manager-navigation.ts'; import { stripVTControlCharacters } from 'node:util'; export type ManagerRow = { id: string; label: string; detail: string; evidence?: string }; -export type ManagerAction = { label: string; disabled?: string; run: () => void | Promise }; +export type ManagerAction = { label: string; disabled?: string; run: () => void | Promise }; const fg = RGBA.defaultForeground(), bg = RGBA.defaultBackground(); const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ''); diff --git a/beehive/src/runtime-environment.ts b/beehive/src/runtime-environment.ts new file mode 100644 index 0000000000..a4d6969381 --- /dev/null +++ b/beehive/src/runtime-environment.ts @@ -0,0 +1,13 @@ +/** Local nonsecret runtime variables. Authority, credentials, loader hooks and + * structured harness fields are never configurable through this map. */ +export function runtimeEnvironment(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length > 32) throw Error('Environment requires at most 32 name/value entries'); + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (!/^[A-Z_][A-Z0-9_]{0,99}$/.test(key) || /^(BUZZ_|BEEHIVE_|GOOSE_|CODEX_|ANTHROPIC_|CLAUDE_|OPENAI_|OPENROUTER_|DATABRICKS_|PI_|NODE_|LD_|DYLD_|XDG_|NPM_|PNPM_|BUN_)/.test(key) || /(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/.test(key) || ['HOME','PATH','SHELL','ENV','BASH_ENV','ZDOTDIR','USER','LOGNAME','TMPDIR','TMP','TEMP'].includes(key)) throw Error('Environment name is reserved or secret-bearing'); + if (typeof entry !== 'string' || Buffer.byteLength(entry) > 2048 || /[\x00-\x1f\x7f-\x9f]/.test(entry)) throw Error('Invalid environment value'); + result[key] = entry; + } + if (Buffer.byteLength(JSON.stringify(result)) > 8192) throw Error('Environment exceeds 8192 bytes'); + return result; +} diff --git a/beehive/src/runtime-form.ts b/beehive/src/runtime-form.ts index 4d7b9ceb9d..fc80426bee 100644 --- a/beehive/src/runtime-form.ts +++ b/beehive/src/runtime-form.ts @@ -1,6 +1,7 @@ +import { runtimeEnvironment } from './runtime-environment.ts'; import { piEfforts } from './pi.ts'; import { runtimeEfforts } from './runtime-effort.ts'; -import type { ManagerSnapshot } from './manager-controller.ts'; +import type { ManagerSnapshot, ManagerResult } from './manager-controller.ts'; /** Same short form used by the actual renderer and synthetic key/form fixtures. */ export async function runtimeForm(screen: { @@ -8,8 +9,8 @@ export async function runtimeForm(screen: { input(label: string, initial?: string): Promise; confirm(label: string): Promise; notice(text: string): void; -}, snapshot: () => ManagerSnapshot, request: (action: string, values?: Record) => Promise) { - await request('runtime-form'); +}, snapshot: () => ManagerSnapshot, request: (action: string, values?: Record) => Promise) { + if ((await request('runtime-form')).state !== 'completed') return; const harnesses = snapshot().harnesses ?? []; if (!harnesses.length) { screen.notice(snapshot().status); return; } const labels = harnesses.map(h => `${h.label} · ${h.providers.length ? 'Available' : h.state === 'available' ? 'Provider integration unavailable' : h.state}`); @@ -20,12 +21,17 @@ export async function runtimeForm(screen: { if (!providers.length) { screen.notice('Add a supported provider first.'); return; } const choice = await screen.choose('Provider', providers.map(p => `${p.name} · ${p.id}`)); if (!choice) return; const provider = providers.find(p => choice === `${p.name} · ${p.id}`)!; - await request('models', { provider: provider.id }); + const modelResult = await request('models', { provider: provider.id }); + if (modelResult.state === 'cancelled' || modelResult.state === 'ignored') return; const models = snapshot().models; const choiceModel = await screen.choose(models ? 'Model' : 'Model listing unavailable · Custom model allowed', [...(models ?? []), 'Custom model']); if (!choiceModel) return; const model = choiceModel === 'Custom model' ? await screen.input('Exact model ID. Custom does not verify provider access.') : choiceModel; if (!model) return; const efforts = selected.id === 'buzz-agent' ? runtimeEfforts(provider.type, model) : selected.id === 'pi' ? piEfforts(provider.type, model) : []; const effort = efforts.length ? await screen.choose('Effort · new runs only', ['Inherit', ...efforts]) : 'Inherit'; if (!effort) return; + const entry = await screen.input('Environment · JSON name/value map · no secrets', '{}'); if (entry === undefined) return; + let environment: string; + try { environment = JSON.stringify(runtimeEnvironment(JSON.parse(entry))); } + catch { screen.notice('Invalid environment. Use nonsecret name/value pairs; identity, provider and loader variables are reserved. Nothing saved.'); return; } const name = await screen.input('Runtime name', model); if (!name) return; - if (await screen.confirm(`Save ${name}?\n${selected.label} · ${provider.name} · ${model}\n${efforts.length ? `Effort: ${effort}` : 'No supported effort control for this model.'}\nSaved for new runs. Running agents do not change.`)) await request('add-runtime', { name, model, provider: provider.id, harness: selected.id, ...(effort !== 'Inherit' ? { effort } : {}) }); + if (await screen.confirm(`Save ${name}?\n${selected.label} · ${provider.name} · ${model}\n${efforts.length ? `Effort: ${effort}` : 'No supported effort control for this model.'}\nEnvironment: ${Object.keys(JSON.parse(environment)).join(', ') || 'None'}\nSaved for new runs. Running agents do not change.`)) await request('add-runtime', { name, model, environment, provider: provider.id, harness: selected.id, ...(effort !== 'Inherit' ? { effort } : {}) }); } diff --git a/beehive/src/settings-runtime.ts b/beehive/src/settings-runtime.ts index e213490ec8..41c456e5f4 100644 --- a/beehive/src/settings-runtime.ts +++ b/beehive/src/settings-runtime.ts @@ -1,3 +1,4 @@ +import { runtimeEnvironment } from './runtime-environment.ts'; import { codexHomeIdentity, codexHomePaths } from './codex-home.ts'; import { publicKey } from './protocol.ts'; import { readSettings, validateSettings, type Settings } from './settings.ts'; @@ -17,6 +18,7 @@ export function runtimeBindings(base: Setup, settings: Settings, agent = base.ag const paths = managedHome ? codexHomePaths(managedHome) : undefined; return [`runtime:${runtime.id}`, validateSetup({ host: base.host, ownerPublic: base.ownerPublic, ...(base.ownerSecret ? { ownerSecret: base.ownerSecret } : {}), ...(base.agentSecret ? { agentSecret: base.agentSecret } : {}), + ...(runtime.environment ? { environment: runtimeEnvironment(runtime.environment) } : {}), runner: runtime.executable, args: [], workspace: base.workspace, allowedWorkspaces: base.allowedWorkspaces, serviceHome: paths?.home ?? base.serviceHome, configDirectory: paths?.config ?? base.configDirectory, ...(base.conversation ? { conversation: base.conversation } : {}), ...(runtime.harness === 'codex' ? { mode: 'codex', codex: { ...(managedHome ? { managedHome } : {}), cli: runtime.cli!, credential: provider.key, models: [runtime.model] } } : { mode: runtime.harness === 'pi' ? 'pi' : 'buzz-agent-api-key', ...(runtime.harness === 'pi' ? { piCli: runtime.cli } : {}), diff --git a/beehive/src/settings.ts b/beehive/src/settings.ts index dc52231f7f..a8a40ec292 100644 --- a/beehive/src/settings.ts +++ b/beehive/src/settings.ts @@ -1,3 +1,4 @@ +import { runtimeEnvironment } from './runtime-environment.ts'; import { piEfforts } from './pi.ts'; import { validateRuntimeEffort } from './runtime-effort.ts'; import { databricksHost } from './databricks.ts'; @@ -12,7 +13,7 @@ export type RegisteredAgent = { publicKey: string; key: CredentialReference; pro /** Each provider version owns an exact immutable OS credential entry. */ export type ProviderReference = { service: 'beehive'; account: string }; export type SavedProvider = { id: string; name: string; type: 'openai' | 'databricks_v2'; endpoint: string; key: ProviderReference }; -export type SavedRuntime = { id: string; name: string; harness: 'buzz-agent' | 'codex' | 'pi'; cli?: string; executable: string; providerId: string; model: string; effort?: string }; +export type SavedRuntime = { environment?: Record; id: string; name: string; harness: 'buzz-agent' | 'codex' | 'pi'; cli?: string; executable: string; providerId: string; model: string; effort?: string }; export type Settings = { version: 1; revision: number; agents: RegisteredAgent[]; providers: SavedProvider[]; runtimes: SavedRuntime[] }; const path = (directory: string) => join(directory, 'settings.json'); const label = (s: unknown) => typeof s === 'string' && s.length > 0 && s.length <= 128 && !/[\x00-\x1f\x7f]/.test(s); @@ -29,6 +30,7 @@ export function validateSettings(value: Settings): Settings { for (const p of value.providers) if (!label(p.name) || !label(p.id) || !['openai','databricks_v2'].includes(p.type) || (p.type === 'openai' ? p.endpoint !== 'https://api.openai.com/v1' : databricksHost(p.endpoint) !== p.endpoint) || p.key?.service !== 'beehive' || !/^provider:[0-9a-f-]{36}$/.test(p.key.account)) throw Error('Invalid saved provider'); for (const r of value.runtimes) if (!label(r.id) || !label(r.name) || !['buzz-agent','codex','pi'].includes(r.harness) || !isAbsolute(r.executable) || !/^[a-zA-Z0-9_.:/-]{1,200}$/.test(r.model) || !value.providers.some(p => p.id === r.providerId)) throw Error('Invalid saved runtime'); for (const r of value.runtimes) { + if (r.environment !== undefined) runtimeEnvironment(r.environment); if (r.harness === 'codex' ? !r.cli || !isAbsolute(r.cli) || r.effort !== undefined || value.providers.find(p => p.id === r.providerId)?.type !== 'openai' : r.harness === 'pi' ? !r.cli || !isAbsolute(r.cli) || r.effort !== undefined && !piEfforts(value.providers.find(p => p.id === r.providerId)!.type, r.model).includes(r.effort) : r.cli !== undefined) throw Error('Unsupported runtime provider/CLI/effort combination'); } for (const r of value.runtimes) validateRuntimeEffort(value.providers.find(p => p.id === r.providerId)!.type, r.model, r.effort); @@ -37,7 +39,7 @@ export function validateSettings(value: Settings): Settings { fields(value, ['version','revision','agents','providers','runtimes']); for (const a of value.agents) { fields(a, ['publicKey','key','profile','profileState']); if (a.profile) fields(a.profile, ['relay','name','picture','about']); } for (const p of value.providers) { fields(p, ['id','name','type','endpoint','key']); fields(p.key, ['service','account']); } - for (const r of value.runtimes) fields(r, ['id','name','harness','executable','providerId','model','effort','cli']); + for (const r of value.runtimes) fields(r, ['id','name','harness','executable','providerId','model','effort','cli','environment']); return structuredClone(value); } /** Absent settings means an empty catalog, never reconstructed credentials. */ diff --git a/beehive/test/acp.test.ts b/beehive/test/acp.test.ts index 3571c135d6..dfe09e0629 100644 --- a/beehive/test/acp.test.ts +++ b/beehive/test/acp.test.ts @@ -44,7 +44,7 @@ test('external ACP boundary: snapshot, catalog provenance, exact-model same-sess assert.match(evidence.responseHash, /^[0-9a-f]{64}$/); assert.equal(evidence.executableHash, s.prepared.executableHash); } - } finally { await cleanup(s); } + } catch (error) { throw new Error(`ACP fixture mode ${mode} failed`, { cause: error }); } finally { await cleanup(s); } } } finally { rmSync(dir, { recursive: true, force: true }); } }); diff --git a/beehive/test/host-settings.test.ts b/beehive/test/host-settings.test.ts index 632b962acd..f892446758 100644 --- a/beehive/test/host-settings.test.ts +++ b/beehive/test/host-settings.test.ts @@ -95,7 +95,7 @@ for (const providerType of ['openai','databricks_v2','codex-openai'] as const) t const prior = readSettings(root), id = settingsId(); const model = codex ? 'custom-codex-model' : providerType === 'openai' ? 'gpt-5' : 'databricks-gpt-5-4'; const executable = join(root,'fixture-buzz-agent'); - writeFileSync(executable,`#!/bin/sh\n[ \"$BUZZ_AGENT_THINKING_EFFORT\" = high ] || exit 9\nexec '${process.execPath}' '${fileURLToPath(new URL('./acp-fixture.ts',import.meta.url))}' ${providerType === 'openai' ? 'openai' : 'databricks-os'}\n`,{mode:0o700}); + writeFileSync(executable,`#!/bin/sh\n[ \"$REVIEW_MODE\" = synthetic ] || exit 10\n[ \"$BUZZ_AGENT_THINKING_EFFORT\" = high ] || exit 9\nexec '${process.execPath}' '${fileURLToPath(new URL('./acp-fixture.ts',import.meta.url))}' ${providerType === 'openai' ? 'openai' : 'databricks-os'}\n`,{mode:0o700}); let requests = 0; if (codex) { const server = createServer(async (req,res) => { @@ -110,12 +110,13 @@ for (const providerType of ['openai','databricks_v2','codex-openai'] as const) t const endpoint = `http://127.0.0.1:${(server.address() as any).port}/v1/responses`; writeFileSync(join(root,'mode'),'ok'); writeFileSync(executable,`#!${process.execPath} +if (process.env.REVIEW_MODE !== 'synthetic') throw Error('Missing selected runtime environment'); const response = await fetch(${JSON.stringify(endpoint)}, {method:'POST',headers:{authorization:'Bearer '+process.env.OPENAI_API_KEY},body:JSON.stringify({model:JSON.parse(process.env.CODEX_CONFIG).model})}); if (!response.ok) throw Error('Synthetic provider rejected'); await import(${JSON.stringify(new URL('./conversation-harness-fixture.ts',import.meta.url).href)}); `,{mode:0o700}); } - saveSettings(root,{ ...prior, runtimes: [{ id, name: 'Future', harness: codex ? 'codex' : 'buzz-agent', executable, providerId: prior.providers[0]!.id, model, ...(codex ? {cli:realpathSync(process.execPath)} : {effort:'high'}) }] },prior.revision); + saveSettings(root,{ ...prior, runtimes: [{ id, name: 'Future', environment: {REVIEW_MODE:'synthetic'}, harness: codex ? 'codex' : 'buzz-agent', executable, providerId: prior.providers[0]!.id, model, ...(codex ? {cli:realpathSync(process.execPath)} : {effort:'high'}) }] },prior.revision); const projected = runtimeBindings(setup,readSettings(root))[`runtime:${id}`]!; if (codex) { assert.notEqual(projected.serviceHome,root); assert.equal(existsSync(projected.serviceHome!),false); } await wait(() => running.settingsRevision === 2); diff --git a/beehive/test/manager-controller.test.ts b/beehive/test/manager-controller.test.ts index 97a85576a8..c428923d24 100644 --- a/beehive/test/manager-controller.test.ts +++ b/beehive/test/manager-controller.test.ts @@ -149,3 +149,26 @@ test('manager presentation preserves raw evidence, unknown states and literal co assert.match(f.snapshot.agents.find(r => r.id === 'host:host-b')!.detail, /Host configuration: Host name: Host B/); } finally { f.cleanup(); } }); + +test('typed completion separates submitted intent from failures, cancellation and ignored requests', async () => { + const f = fixture(); + try { + assert.equal((await f.controller.request({id:1,action:'start'})).state,'failed'); + assert.equal((await f.controller.request({id:1,action:'start'})).state,'ignored'); + assert.equal((await f.controller.request({id:2,action:'signin',values:{owner,relay:'wss://example.invalid',secret}})).state,'completed'); + f.receive(message('inventory','host-a','agent-a',3,{observedAt:Date.now(),phase:'stopped',actualRun:null,assignedHost:'host-a'})); + const result = await f.controller.request({id:3,action:'start',target:JSON.stringify(['host-a','agent-a']),revision:3}); + assert.equal(result.state,'submitted'); + if (result.state === 'submitted') assert.equal(result.operationId,f.submitted[0]!.id); + assert.equal((await f.controller.request({id:4,action:'start',target:JSON.stringify(['host-a','agent-a']),revision:2})).state,'failed'); + } finally {f.cleanup();} + const home = mkdtempSync(join(tmpdir(),'beehive-manager-')); + let finish!: (v:any)=>void; + const c = new ManagerController(home,()=>{},()=>new Promise(resolve=>{finish=resolve;}),undefined,async()=>undefined); + try { + const pending = c.request({id:1,action:'signin',values:{owner,relay:'wss://example.invalid'}}); + c.cancel(); finish({ok:true,secret}); + assert.equal((await pending).state,'cancelled'); + assert.equal(c.snapshot().owner,undefined); + } finally {c.close();rmSync(home,{recursive:true,force:true});} +}); diff --git a/beehive/test/pi.test.ts b/beehive/test/pi.test.ts index cf3e808f61..2acb5d3122 100644 --- a/beehive/test/pi.test.ts +++ b/beehive/test/pi.test.ts @@ -42,7 +42,7 @@ test('actual runtime form offers Pi both providers, retains custom names, submit const model=type==='openai'?'gpt-5':'databricks-gpt-5-4'; const snapshot:ManagerSnapshot={local:[],agents:[],status:'fixture',harnesses:[{id:'pi',label:'Pi',executable:'/fake',cli:'/fake-pi',state:'available',providers:['openai','databricks_v2'],reason:'synthetic'}],models:[model],settings:{version:1,revision:1,agents:[],runtimes:[],providers:[{id:'p',name:'Saved',type,endpoint:type==='openai'?'https://api.openai.com/v1':'https://pi-workspace.example',key}]}}; const choices=['Pi · Available','Saved · p',model,'high'];const calls:any[]=[]; - await runtimeForm({async choose(_label,options){const value=choices.shift();assert.ok(options.includes(value!));return value;},async input(){return 'My Pi';},async confirm(){return true;},notice(){throw Error('unexpected notice');}},()=>snapshot,async(action,values)=>{calls.push({action,values});}); - assert.deepEqual(calls.at(-1),{action:'add-runtime',values:{name:'My Pi',model,provider:'p',harness:'pi',effort:'high'}}); + await runtimeForm({async choose(_label,options){const value=choices.shift();assert.ok(options.includes(value!));return value;},async input(label){return label.startsWith('Environment') ? '{}' : 'My Pi';},async confirm(){return true;},notice(){throw Error('unexpected notice');}},()=>snapshot,async(action,values)=>{calls.push({action,values});return {state:"completed"};}); + assert.deepEqual(calls.at(-1),{action:'add-runtime',values:{name:'My Pi',model,environment:'{}',provider:'p',harness:'pi',effort:'high'}}); } }); diff --git a/beehive/test/runtime-environment.test.ts b/beehive/test/runtime-environment.test.ts new file mode 100644 index 0000000000..43e82e89a5 --- /dev/null +++ b/beehive/test/runtime-environment.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runtimeEnvironment } from '../src/runtime-environment.ts'; +import { AgentSession, prepareAgent } from '../src/acp.ts'; +import { runtimeBindings } from '../src/settings-runtime.ts'; +import { saveSettings, readSettings, type Settings } from '../src/settings.ts'; +import { bindingFingerprint, type Setup } from '../src/host.ts'; +import { publicKey } from '../src/protocol.ts'; + +const key = { service: 'beehive' as const, account: 'provider:00000000-0000-0000-0000-000000000000' }; +test('runtime environment validates names, bytes and bounds; catalog Save retains immutable prior definitions', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-env-'))); + try { + for (const name of ['HOME','PATH','BUZZ_PRIVATE_KEY','BEEHIVE_PI_RUNTIME','NODE_OPTIONS','LD_PRELOAD','OPENAI_API_KEY','PI_ACP_PI_COMMAND','MY_SECRET','DATABRICKS_HOST','BUZZ_AGENT_MODEL','BUZZ_AGENT_THINKING_EFFORT']) assert.throws(() => runtimeEnvironment({[name]:'x'})); + for (const input of [null, [], {REVIEW: 1}, {REVIEW: 'x\n'}, {REVIEW: '界'.repeat(700)}, Object.fromEntries(Array.from({length:33},(_,i)=>[`VAR_${i}`,'x'])), Object.fromEntries(Array.from({length:5},(_,i)=>[`VAR_${i}`,'x'.repeat(2048)]))]) assert.throws(() => runtimeEnvironment(input)); + const settings: Settings = {version:1, revision:0, agents:[], providers:[{id:'p',name:'Synthetic',type:'openai',endpoint:'https://api.openai.com/v1',key}], runtimes:[{id:'a',name:'A',harness:'buzz-agent',executable:realpathSync(process.execPath),providerId:'p',model:'gpt-5',environment:{REVIEW_MODE:'first'}}]}; + const saved = saveSettings(root,settings,0); + const base: Setup = {host:'fixture',ownerPublic:publicKey('1'.repeat(64)),runner:realpathSync(process.execPath),args:[],workspace:root,serviceHome:root,configDirectory:root,mode:'fixture'}; + const first = runtimeBindings(base,saved)['runtime:a']!; + const next = saveSettings(root,{...saved,runtimes:[...saved.runtimes,{...saved.runtimes[0]!,id:'b',name:'B',environment:{REVIEW_MODE:'second'}}]},1); + assert.deepEqual(first.environment,{REVIEW_MODE:'first'}); + assert.notEqual(bindingFingerprint(first),bindingFingerprint(runtimeBindings(base,next)['runtime:b']!)); + assert.throws(()=>saveSettings(root,{...next,runtimes:[{...next.runtimes[0]!,environment:{REVIEW_MODE:'changed'}},next.runtimes[1]!]},2),/Retained/); + assert.equal(readSettings(root).revision,2); + } finally { rmSync(root,{recursive:true,force:true}); } +}); + +test('actual owned ACP child receives snapshotted local environment, not ambient identity or credentials', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-env-'))); + let session: AgentSession | undefined; + try { + const script = join(root,'fixture.ts'), evidence = join(root,'environment.json'); + writeFileSync(script,`import {writeFileSync} from 'node:fs'; writeFileSync(${JSON.stringify(evidence)},JSON.stringify({value:process.env.REVIEW_MODE, identity:process.env.BUZZ_PRIVATE_KEY, token:process.env.DATABRICKS_TOKEN})); await import(${JSON.stringify(new URL('./acp-fixture.ts',import.meta.url).href)});`); + const plan = {executable:realpathSync(process.execPath),args:[script],workspace:root,home:root,configDirectory:root,databricksHost:'https://fixture.invalid',model:'databricks-claude-haiku-4-5',environment:{REVIEW_MODE:'first'}}; + const prepared = prepareAgent(plan); + plan.environment.REVIEW_MODE = 'later'; + assert.equal(prepared.env.REVIEW_MODE,'first'); + assert.equal(prepared.plan.environment?.REVIEW_MODE,'first'); + session = new AgentSession(prepared.plan,1500); + await session.catalog(); await session.verify(); + assert.deepEqual(JSON.parse(readFileSync(evidence,'utf8')),{value:'first'}); + } finally { await session?.owned.stop(); rmSync(root,{recursive:true,force:true}); } +}); + +test('runtime form stops on failed discovery and cancelled models rather than continuing stale choices', async () => { + const {runtimeForm} = await import('../src/runtime-form.ts'); + const snapshot = {local:[],agents:[],status:'synthetic',harnesses:[{id:'buzz-agent',label:'Buzz Agent',executable:'/fixture',state:'available' as const,providers:['openai' as const],reason:'fixture'}],settings:{version:1 as const,revision:1,agents:[],runtimes:[],providers:[{id:'p',name:'Provider',type:'openai' as const,endpoint:'https://api.openai.com/v1',key}]}}; + let choices = 0; + const screen = {choose:async (_label:string,options:string[])=>{choices++;return options[0];},input:async()=>{throw Error('must not enter configuration');},confirm:async()=>{throw Error('must not save');},notice:()=>{}}; + await runtimeForm(screen,()=>snapshot,async()=>({state:'failed',reason:'fixture'})); + assert.equal(choices,0); + const calls:string[]=[]; + await runtimeForm(screen,()=>snapshot,async action=>{calls.push(action);return action === 'models' ? {state:'cancelled',reason:'fixture'} : {state:'completed'};}); + assert.equal(choices,2); + assert.deepEqual(calls,['runtime-form','models']); +}); diff --git a/beehive/test/runtime-integration.test.ts b/beehive/test/runtime-integration.test.ts index 1bbd5c3118..487950c18f 100644 --- a/beehive/test/runtime-integration.test.ts +++ b/beehive/test/runtime-integration.test.ts @@ -132,7 +132,7 @@ test('effort is manifest-backed, validates persisted settings and reaches actual test('short runtime form selects supported model-specific effort; unknown custom omits it and unsupported harness cannot save', async () => { const snapshot: ManagerSnapshot = { local:[], agents:[], status:'synthetic', models:['gpt-5'], harnesses:[{id:'buzz-agent',label:'Buzz Agent',executable:'/fixture',state:'available',providers:['openai'],reason:'fixture'},{id:'pi',label:'Pi',state:'available',providers:[],reason:'Provider integration unavailable'}], settings:{version:1,revision:1,agents:[],runtimes:[],providers:[{id:'p',name:'OpenAI',type:'openai',endpoint:'https://api.openai.com/v1',key}]} }; const submitted: {action:string;values?:Record}[] = []; - const run = async (choices: string[], inputs: string[]) => runtimeForm({choose:async (_label,options) => {const value=choices.shift(); assert.ok(value === undefined || options.includes(value)); return value;},input:async () => inputs.shift(),confirm:async label => {assert.match(label,/Running agents do not change/);return true;},notice:() => {}}, () => snapshot, async (action,values) => {submitted.push({action,values});}); + const run = async (choices: string[], inputs: string[]) => runtimeForm({choose:async (_label,options) => {const value=choices.shift(); assert.ok(value === undefined || options.includes(value)); return value;},input:async label => label.startsWith('Environment') ? '{}' : inputs.shift(),confirm:async label => {assert.match(label,/Running agents do not change/);return true;},notice:() => {}}, () => snapshot, async (action,values) => {submitted.push({action,values});return {state:"completed"};}); await run(['Buzz Agent · Available','OpenAI · p','gpt-5','high'],['Reasoning']); assert.equal(submitted.at(-1)?.values?.effort,'high'); assert.equal(submitted.at(-1)?.values?.model,'gpt-5'); diff --git a/beehive/tools/ux-walkthrough.sh b/beehive/tools/ux-walkthrough.sh index 2880afb762..a0d588aa9d 100644 --- a/beehive/tools/ux-walkthrough.sh +++ b/beehive/tools/ux-walkthrough.sh @@ -1,6 +1,27 @@ #!/bin/sh # Synthetic UI only: fresh file credentials and explicitly blocked external seams. set -eu +if [ "${1:-}" = --help ]; then + cat <<'HELP' +Synthetic manager walkthrough (100x30 terminal recommended). +Run without arguments; every invocation creates a fresh isolated HOME. + +Right/Left: Host / Agents / Harnesses / Providers. a: controls. Tab: list. +Harnesses: Add runtime -> Buzz Agent -> Review OpenAI -> fixture-model -> + Environment JSON (e.g. {"REVIEW_MODE":"synthetic"}) -> Runtime name -> yes. + Escape at Environment cancels without saving. Select the saved runtime to + inspect harness/provider/model/effort/environment names. Save affects new runs. +Providers: Add provider or Models; synthetic keys only. Escape cancels. +Agents: Register opens hidden nsec entry; Escape cancels. +Ctrl-Q: exit even inside a form. + +This fixture blocks external credentials, network, browser and service actions. +It does NOT demonstrate registration-first execution or authenticated Move. +Use the committed runtime-environment and host-settings tests for synthetic +owned-process environment and Save/Start/Stop evidence, not this UI fixture. +HELP + exit 0 +fi ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) NODE=${BEEHIVE_REVIEW_NODE:-/Users/loganj/Library/Caches/hermit/pkg/node-24.15.0/bin/node} BUN=${BEEHIVE_REVIEW_BUN:-/Users/loganj/.buzz/artifacts/beehive-opentui-57f9c8fa/runtime/bun-darwin-aarch64/bun}