diff --git a/beehive/HOST_SETTINGS_BUILD.md b/beehive/HOST_SETTINGS_BUILD.md index 5b9f3358cf..0d60a5f7f2 100644 --- a/beehive/HOST_SETTINGS_BUILD.md +++ b/beehive/HOST_SETTINGS_BUILD.md @@ -162,3 +162,30 @@ merely sending BUZZ_AGENT_PROVIDER to an unrelated process. Evidence for this continuation, final suite and remaining work: `/Users/loganj/.buzz/artifacts/beehive-adapter-integration-9b0f8958/INTEGRATION_RESULT.md`. + +## Pi runtime continuation (supersedes Pi-disabled statements above) + +Pi now uses the Desktop `buzz-pi-acp` fork plus a separately resolved `pi` CLI +with saved OpenAI and Databricks providers. Desktop imposes no Pi semver floor: +execution requires fork identity, ACP protocol 1, and positive exact model and +thinking config-option evidence before prompts. Both probe and conversation broker +use `_meta.systemPrompt` and immutable spawn-time provider/model/effort settings. + +A run-owned wrapper creates an isolated Pi directory. Its public `models.json` +contains `$BEEHIVE_PI_KEY`, never a credential. OpenAI uses native Pi Responses; +Databricks uses shared-manifest Responses/Anthropic/MLflow routing, preserving raw +model/FQN and workspace. Its adapter receives only a private loopback capability; +every provider request enters the existing native workspace-bound token source. +No Desktop credentials, persistent Pi auth files, native OAuth copy or plugin +framework. Unknown/custom models remain exact and do not gain guessed effort. +Supported Pi thinking levels are the intersection of model capabilities and Pi's +native vocabulary. No selection is explicitly `off`, not retained user settings. + +Save/registration remain inert; existing runs keep their snapshots. Explicit +Start/Stop own wrapper, adapter, Pi descendants, proxy and temporary directory. +No native binary change; exports still require `src/*.ts`, `src/*.json` and the +existing `bin/beehive-databricks`. Tests use explicit synthetic module/process +seams (`pi-isolation-loader.mjs`), fake external ACP executables and loopback +provider servers, not installed harnesses or real OS stores. Targeted cases live +in `pi-host.test.ts`, `pi-conversation.test.ts`, and `pi.test.ts`. This slice does +not claim completion of Codex/Claude or erase prior combined-suite failures. diff --git a/beehive/TESTING.md b/beehive/TESTING.md index d7814cb8d8..0d21ae2f5c 100644 --- a/beehive/TESTING.md +++ b/beehive/TESTING.md @@ -686,3 +686,15 @@ The fixture reuses the private NIP42/59 relay via an optional HTTP handler; it d not change that relay's existing default admission behavior. No production relay, OS credential or installed-native public metadata evidence is claimed. See PUBLIC_METADATA.md for the editing and explicit publication path and limits. + +### Pi saved-provider runtime slice + +From package CWD, direct pinned Node: `node --test test/pi.test.ts + test/pi-host.test.ts test/pi-conversation.test.ts test/runtime-integration.test.ts`. +Pi host/conversation tests import `pi-isolation-loader.mjs` before dynamically +loading product modules. The hook explicitly injects itself into the owned Pi +wrapper and replaces only the native Databricks invocation with workspace/reference +checked synthetic refresh. HTTPS destinations are denied except the exact fixture +route, rewritten to a test-created loopback provider. Do not replace this with +HOME isolation or run these fixtures against an installed harness. The negative +Pi boundary tests use only saved synthetic OpenAI keys and make no provider call. diff --git a/beehive/src/acp.ts b/beehive/src/acp.ts index 12ed182adc..1bee4d8e62 100644 --- a/beehive/src/acp.ts +++ b/beehive/src/acp.ts @@ -1,3 +1,4 @@ +import { PI_ADAPTER, piEnvironment, piConfigEvidence } from './pi.ts'; import { fileURLToPath } from 'node:url'; import { buzzProviderEnvironment, type BuzzProvider } from './buzz-provider.ts'; import { validateCustom, type CustomAcp } from './custom-acp.ts'; @@ -10,7 +11,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'; codex?: CodexSetup; claude?: ClaudeSetup; provider?: string; model: string; instructions?: string }>; +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 }>; /** 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. */ @@ -36,6 +37,10 @@ export function prepareAgent(input: AgentLaunch) { const d = validateCustom(plan.custom); if (plan.harness !== 'goose' || d.contract !== 'goose-native' || d.executable !== plan.executable || JSON.stringify(d.args) !== JSON.stringify(plan.args)) throw Error('Unsupported custom launch contract'); } + 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)) }); + } 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'); @@ -89,6 +94,7 @@ export class AgentSession { private stoppingProbe = false; private session = ''; private codexModelVerified = false; + private piModelVerified = false; private response = ''; private prompting = false; private timeoutMs: number; @@ -162,10 +168,12 @@ export class AgentSession { // Only observations belonging to this owned session can invalidate its proof. // Model acknowledgement is not permanent: keep this guard active after completion. if (p.sessionId !== this.session) return; - if (update.sessionUpdate === 'current_model_update' && update.currentModelId !== this.prepared.plan.model) throw Error('ACP model changed'); + if (this.prepared.plan.harness === 'pi' && update.sessionUpdate === 'current_mode_update' && update.currentModeId !== (this.prepared.plan.buzzProvider?.effort ?? 'off')) throw Error('Pi effort changed'); + if (update.sessionUpdate === 'current_model_update' && update.currentModelId !== (this.prepared.plan.harness === 'pi' ? `beehive/${this.prepared.plan.model}` : this.prepared.plan.model)) throw Error('ACP model changed'); if (update.sessionUpdate === 'config_option_update') { + if (this.prepared.plan.harness === 'pi') piConfigEvidence(update, this.prepared.plan.model, this.prepared.plan.buzzProvider?.effort); if (!Array.isArray(update.configOptions)) throw Error('Invalid ACP model update'); - for (const option of update.configOptions) if (record(option).category === 'model' && record(option).currentValue !== this.prepared.plan.model) throw Error('ACP model changed'); + for (const option of update.configOptions) if (record(option).category === 'model' && record(option).currentValue !== (this.prepared.plan.harness === 'pi' ? `beehive/${this.prepared.plan.model}` : this.prepared.plan.model)) throw Error('ACP model changed'); } if (p.sessionId !== this.session || !this.prompting) return; if (update.sessionUpdate === 'agent_message_chunk') { @@ -202,9 +210,13 @@ export class AgentSession { const nativeBuzz = !this.prepared.plan.harness; const protocol = this.prepared.plan.harness === 'codex' || nativeBuzz ? 2 : 1; const init = record(await this.request('initialize', { protocolVersion: protocol, clientCapabilities: {}, clientInfo: { name: 'beehive', version: '0.0.1' } })); - if (init.protocolVersion !== protocol || record(init.agentInfo).name !== (this.prepared.plan.harness === 'codex' ? CODEX_ADAPTER : this.prepared.plan.harness === 'claude' ? CLAUDE_ADAPTER : this.prepared.plan.harness === 'goose' ? 'goose' : 'buzz-agent')) throw Error('Unsupported harness capabilities'); - const created = record(await this.request('session/new', { cwd: this.prepared.plan.workspace, mcpServers: [], ...((this.prepared.plan.harness === 'codex' || nativeBuzz) && this.prepared.plan.instructions !== undefined ? { systemPrompt: this.prepared.plan.instructions } : {}), ...(this.prepared.plan.harness === 'claude' && this.prepared.plan.instructions !== undefined ? { _meta: { systemPrompt: { append: this.prepared.plan.instructions } } } : {}) })); + if (init.protocolVersion !== protocol || record(init.agentInfo).name !== (this.prepared.plan.harness === 'pi' ? PI_ADAPTER : this.prepared.plan.harness === 'codex' ? CODEX_ADAPTER : this.prepared.plan.harness === 'claude' ? CLAUDE_ADAPTER : this.prepared.plan.harness === 'goose' ? 'goose' : 'buzz-agent')) throw Error('Unsupported harness capabilities'); + const created = record(await this.request('session/new', { cwd: this.prepared.plan.workspace, mcpServers: [], ...(this.prepared.plan.harness === 'pi' && this.prepared.plan.instructions !== undefined ? { _meta: { systemPrompt: this.prepared.plan.instructions } } : {}), ...((this.prepared.plan.harness === 'codex' || nativeBuzz) && this.prepared.plan.instructions !== undefined ? { systemPrompt: this.prepared.plan.instructions } : {}), ...(this.prepared.plan.harness === 'claude' && this.prepared.plan.instructions !== undefined ? { _meta: { systemPrompt: { append: this.prepared.plan.instructions } } } : {}) })); this.session = identifier(created.sessionId); + if (this.prepared.plan.harness === 'pi') { + const models = piConfigEvidence(created, this.prepared.plan.model, this.prepared.plan.buzzProvider?.effort); + this.piModelVerified = true; return { state: 'reported', models, authentication: 'unverified' }; + } if (this.prepared.plan.harness === 'codex') { const models = codexModels(created, this.prepared.plan.model); this.codexModelVerified = true; @@ -221,6 +233,7 @@ export class AgentSession { /** Require explicit exact-model acknowledgement, then a response in the SAME session/process. */ async verify(): Promise { if (!this.session) throw Error('Create ACP session first'); + if (this.prepared.plan.harness === 'pi' && !this.piModelVerified) throw Error('Pi exact model/effort evidence required'); if (this.prepared.plan.harness === 'codex' && !this.codexModelVerified) throw Error('Codex exact fresh model evidence required before prompts'); const model = this.prepared.plan.model; if (!this.prepared.plan.harness) { @@ -245,6 +258,7 @@ export class AgentSession { export function spawnAgent(prepared: ReturnType, extra: Record = {}) { const { plan } = prepared; const env = { ...prepared.env, ...extra }; + if (plan.harness === 'pi') return spawnOwned(process.execPath, [fileURLToPath(new URL('./pi-runtime-child.ts', import.meta.url)), plan.executable], plan.workspace, env); if (plan.buzzProvider?.provider === 'databricks_v2' && plan.buzzProvider.credential) { delete env.DATABRICKS_TOKEN; env.BEEHIVE_DATABRICKS_RUNTIME = JSON.stringify({ host: plan.buzzProvider.baseUrl, key: plan.buzzProvider.credential, model: plan.model }); diff --git a/beehive/src/broker.ts b/beehive/src/broker.ts index b520d88139..45cfbb056c 100644 --- a/beehive/src/broker.ts +++ b/beehive/src/broker.ts @@ -1,3 +1,4 @@ +import { PI_ADAPTER, piConfigEvidence } from './pi.ts'; import { CODEX_ADAPTER, codexModels } from './codex.ts'; import { CLAUDE_ADAPTER, claudeModels } from './claude.ts'; import { gooseModels } from './acp.ts'; @@ -158,6 +159,7 @@ export class ConversationSession { const pending = new Map(); const sessions = new Set(); const customProfiles = new Set(); + let piNative = false; const modelConfigs = new Set(['model']); const prompts = new Map; text: boolean; cancelled: boolean }>(); this.cancellations.push(() => { @@ -194,6 +196,15 @@ export class ConversationSession { if (p?.mode !== 'set' || p.key !== 'buzz' || typeof p.text !== 'string' || !p.text.includes(this.prepared.plan.instructions)) throw Error('Custom exact native profile missing'); } } + if (this.prepared.plan.harness === 'pi') { + if (msg.method === 'initialize') p.protocolVersion = 1; + if (['session/load','session/resume','session/set_model','session/set_mode','session/set_config_option'].includes(msg.method)) throw Error('Pi requires fresh session/Restart'); + if (msg.method === 'session/new') { + if (!piNative) throw Error('Pi initialize required'); + delete p.systemPrompt; + if (this.prepared.plan.instructions !== undefined) p._meta = { ...p._meta, systemPrompt: this.prepared.plan.instructions }; + } + } if (this.prepared.plan.harness === 'codex') { if (msg.method === 'initialize') p.protocolVersion = 2; if (['session/load', 'session/resume', 'session/set_model', 'session/set_config_option'].includes(msg.method)) throw Error('Codex requires fresh session/Restart'); @@ -260,6 +271,10 @@ export class ConversationSession { if (request.method === 'initialize' && this.prepared.plan.custom) { if (msg.result?.protocolVersion !== 1 || msg.result?.agentInfo?.name !== 'goose') throw Error('Custom Goose-native contract unavailable'); } + if (request.method === 'initialize' && this.prepared.plan.harness === 'pi') { + if (msg.result?.protocolVersion !== 1 || msg.result?.agentInfo?.name !== PI_ADAPTER) throw Error('Pi fork capability missing'); + piNative = true; + } if (request.method === 'initialize' && this.prepared.plan.harness === 'codex') { if (msg.result?.protocolVersion !== 2 || msg.result?.agentInfo?.name !== CODEX_ADAPTER) throw Error('Codex native profile capability unavailable'); codexNative = true; @@ -282,6 +297,10 @@ export class ConversationSession { } if (modelConfigs.size > 128) throw Error(); sessions.delete(session); + if (this.prepared.plan.harness === 'pi') { + piConfigEvidence(msg.result, this.prepared.plan.model, this.prepared.plan.buzzProvider?.effort); + sessions.add(session); send(socket, msg); return; + } if (this.prepared.plan.harness === 'codex') { codexModels(msg.result, this.prepared.plan.model); sessions.add(session); send(socket, msg); return; @@ -313,9 +332,13 @@ export class ConversationSession { } if (fromHarness && msg.method === 'session/update') { const p = msg.params; const u = p?.update; - if (u?.sessionUpdate === 'current_model_update' && u.currentModelId !== this.prepared.plan.model) throw Error(); + if (this.prepared.plan.harness === 'pi') { + if (u?.sessionUpdate === 'config_option_update') piConfigEvidence(u, this.prepared.plan.model, this.prepared.plan.buzzProvider?.effort); + if (u?.sessionUpdate === 'current_mode_update' && u.currentModeId !== (this.prepared.plan.buzzProvider?.effort ?? 'off')) throw Error('Pi effort changed'); + } + if (u?.sessionUpdate === 'current_model_update' && u.currentModelId !== (this.prepared.plan.harness === 'pi' ? `beehive/${this.prepared.plan.model}` : this.prepared.plan.model)) throw Error(); if (u?.sessionUpdate === 'config_option_update' && Array.isArray(u.configOptions)) { - for (const option of u.configOptions) if (option.category === 'model' && option.currentValue !== this.prepared.plan.model) throw Error(); + for (const option of u.configOptions) if (option.category === 'model' && option.currentValue !== (this.prepared.plan.harness === 'pi' ? `beehive/${this.prepared.plan.model}` : this.prepared.plan.model)) throw Error(); } const prompt = prompts.get(p?.sessionId); if (prompt && u?.sessionUpdate === 'agent_message_chunk' && u.content?.type === 'text' && typeof u.content.text === 'string') { diff --git a/beehive/src/conversation.ts b/beehive/src/conversation.ts index b515645c91..1c187dce3c 100644 --- a/beehive/src/conversation.ts +++ b/beehive/src/conversation.ts @@ -36,6 +36,7 @@ export function prepareConversationBinding(input: ConversationSetup, agent: Agen if (prepared.plan.args.some(a => a.includes('\0'))) throw Error('ACP arguments cannot contain NUL'); if (input.authTag !== undefined && (typeof input.authTag !== 'string' || input.authTag.length > 16384 || !input.authTag.length)) throw Error('Invalid local owner attestation'); const providerEnv = { ...prepared.env }; + if (agent.harness === 'pi') { delete providerEnv.BEEHIVE_PI_KEY; delete providerEnv.BEEHIVE_PI_RUNTIME; } // The relay transport/shim never needs the OS-backed Databricks bearer. // The actual model consumer obtains a run-local capability in spawnAgent. if (agent.buzzProvider?.provider === 'databricks_v2' && agent.buzzProvider.credential) delete providerEnv.DATABRICKS_TOKEN; @@ -53,7 +54,7 @@ export function prepareConversationBinding(input: ConversationSetup, agent: Agen BUZZ_RELAY_URL: url.href, BUZZ_ACP_AGENT_OWNER: owner, ...(input.authTag === undefined ? {} : { BUZZ_AUTH_TAG: input.authTag }), - ...((prepared.plan.harness === 'claude' || prepared.plan.harness === 'codex') ? {} : { BUZZ_ACP_MODEL: prepared.plan.model }), + ...((prepared.plan.harness === 'claude' || prepared.plan.harness === 'codex' || prepared.plan.harness === 'pi') ? {} : { BUZZ_ACP_MODEL: prepared.plan.model }), ...(prepared.plan.instructions === undefined ? {} : { BUZZ_ACP_SYSTEM_PROMPT: prepared.plan.instructions }), BUZZ_ACP_MCP_COMMAND: '', BUZZ_ACP_AGENTS: '1', diff --git a/beehive/src/databricks-runtime.ts b/beehive/src/databricks-runtime.ts index 1fb618c880..eab3313066 100644 --- a/beehive/src/databricks-runtime.ts +++ b/beehive/src/databricks-runtime.ts @@ -45,6 +45,7 @@ export async function databricksRuntime(input: { host: string; key: ProviderRefe controller.signal.throwIfAborted(); if (!token.secret || /\s/.test(token.secret)) throw Error(); const headers = new Headers({ authorization: `Bearer ${token.secret}`, 'content-type': 'application/json' }); + if (url.pathname === '/ai-gateway/anthropic/v1/messages') headers.set('anthropic-version', '2023-06-01'); token.secret = undefined; const upstream = await fetcher(host + url.pathname + url.search, { method: req.method, headers, ...(body ? { body: body.toString() } : {}), signal: controller.signal, redirect: 'error' }); if (upstream.status === 401 || upstream.status === 403) { denied = true; await upstream.body?.cancel(); error(res, 401); return; } @@ -56,7 +57,7 @@ export async function databricksRuntime(input: { host: string; key: ProviderRefe finally { await reader.cancel(); reader.releaseLock(); } controller.signal.throwIfAborted(); if (stopped || res.destroyed) return; - res.writeHead(200, { 'content-type': 'application/json' }); res.end(Buffer.concat(chunks)); + res.writeHead(200, { 'content-type': upstream.headers.get('content-type')?.startsWith('text/event-stream') ? 'text/event-stream' : 'application/json' }); res.end(Buffer.concat(chunks)); } catch { error(res, 503); } finally { clearTimeout(timer); req.removeListener('aborted', abort); res.removeListener('close', abort); active.delete(controller); } } diff --git a/beehive/src/harness-discovery.ts b/beehive/src/harness-discovery.ts index 2224c9fce9..c893f169e7 100644 --- a/beehive/src/harness-discovery.ts +++ b/beehive/src/harness-discovery.ts @@ -78,7 +78,7 @@ export async function discoverHarnesses(signal: AbortSignal, options: { home?: s let state: DetectedHarness['state'] = !binary ? 'not-installed' : row.underlyingCli && !cli ? 'cli-missing' : 'available'; if (state === 'available' && row.id === 'codex' && !compatibleCodex(await probe(binary!, ['--version'], [...managed, path].join(delimiter), home, signal) ?? '')) state = 'incompatible'; signal.throwIfAborted(); - const providers = state === 'available' && row.id === 'buzz-agent' ? ['openai', 'databricks_v2'] : state === 'available' && row.id === 'codex' ? ['openai'] : []; + const providers = state === 'available' && ['buzz-agent','pi'].includes(row.id) ? ['openai', 'databricks_v2'] : state === 'available' && row.id === 'codex' ? ['openai'] : []; const reason = state === 'not-installed' ? 'Not installed' : state === 'cli-missing' ? `Underlying ${row.underlyingCli} CLI not installed` : state === 'incompatible' ? 'Codex ACP 1.10.0 or newer required; version unknown/outdated' : providers.length ? 'Detected; provider/model access unverified' : 'Detected; this catalog’s OS-backed providers are not supported by this adapter. Use existing local setup for native authentication.'; result.push({ id: row.id, label: row.label!, executable: binary, cli, state, providers, reason }); } diff --git a/beehive/src/host.ts b/beehive/src/host.ts index 5e44045e4d..50cba5667a 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -1,3 +1,4 @@ +import { validatePi } from './pi.ts'; import { readSettings } from './settings.ts'; import { runtimeBindings } from './settings-runtime.ts'; import { managerCredential } from './manager-credential.ts'; @@ -25,7 +26,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'; codex?: CodexSetup; claude?: ClaudeSetup; gooseProvider?: string; gooseModels?: string[]; databricksHost?: string; serviceHome?: string; configDirectory?: string; conversation?: ConversationSetup }; +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 }; /** 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; @@ -45,16 +46,18 @@ export function setupOwner(setup: Pick): s return owner; } /** Operator-approved compatible models, not an authenticated provider catalog. */ -export function setupModels(s: Setup): string[] { return s.mode === 'buzz-agent-api-key' ? [...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 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); 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); if (s.agentSecret !== undefined) publicKey(String(s.agentSecret)); - if (!isAbsolute(String(s.runner)) || !isAbsolute(String(s.workspace)) || !Array.isArray(s.args) || s.args.some(a => typeof a !== 'string') || !['buzz-agent-api-key','diagnostic-acp','fixture','buzz-agent-databricks-v2','goose','claude','codex'].includes(String(s.mode))) throw Error('Invalid local setup'); - if (s.buzzProvider !== undefined && s.mode !== 'buzz-agent-api-key') throw Error('Buzz provider belongs only to Buzz Agent API-key bindings'); - if (s.mode === 'buzz-agent-api-key') { + if (!isAbsolute(String(s.runner)) || !isAbsolute(String(s.workspace)) || !Array.isArray(s.args) || s.args.some(a => typeof a !== 'string') || !['buzz-agent-api-key','diagnostic-acp','fixture','buzz-agent-databricks-v2','goose','claude','codex','pi'].includes(String(s.mode))) throw Error('Invalid local setup'); + if (s.piCli !== undefined && s.mode !== 'pi') throw Error('Pi CLI belongs only to Pi bindings'); + if (s.buzzProvider !== undefined && s.mode !== 'buzz-agent-api-key' && s.mode !== 'pi') throw Error('Buzz provider belongs only to Buzz Agent API-key bindings'); + if (s.mode === 'buzz-agent-api-key' || s.mode === 'pi') { + if (s.mode === 'pi') validatePi(String(s.piCli), s.buzzProvider as BuzzProvider); validateBuzzProvider(s.buzzProvider as BuzzProvider); if (s.args.length || typeof s.serviceHome !== 'string' || !isAbsolute(s.serviceHome) || typeof s.configDirectory !== 'string' || !isAbsolute(s.configDirectory) || s.databricksHost !== undefined || s.gooseProvider !== undefined || s.claude !== undefined || s.codex !== undefined || s.custom !== undefined) throw Error('Buzz Agent requires dedicated local context and zero args, not another harness auth contract'); } @@ -251,7 +254,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 === 'goose' || setup.mode === 'claude' || setup.mode === 'codex' ? '' : text(setup.databricksHost), ...(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 : { ...(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 0f6947c905..d33c186eac 100644 --- a/beehive/src/manager-controller.ts +++ b/beehive/src/manager-controller.ts @@ -276,8 +276,8 @@ A Stop result is not a recent host report that confirms the agent is stopped.` } const selected = this.harnesses?.find(h => h.id === (v.harness ?? 'buzz-agent')); 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'].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', executable: selected.executable, ...(selected.id === 'codex' ? { cli: selected.cli } : {}), providerId: v.provider ?? '', model: v.model ?? '', ...(v.effort ? { effort: v.effort } : {}) }] },previous.revision); + 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); 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.'; diff --git a/beehive/src/pi-runtime-child.ts b/beehive/src/pi-runtime-child.ts new file mode 100644 index 0000000000..8491d41cc0 --- /dev/null +++ b/beehive/src/pi-runtime-child.ts @@ -0,0 +1,30 @@ +import { spawn } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { databricksRuntime } from './databricks-runtime.ts'; +import { piModelConfig } from './pi.ts'; + +// Entire wrapper, adapter, Pi children and native helpers are in the existing +// supervisor group. No provider secret is persisted, including on forced exit. +let stopping = false; +let bridge: Awaited> | undefined; +let child: ReturnType | undefined; +let directory: string | undefined; +const stop = async () => { stopping = true; child?.kill('SIGTERM'); await bridge?.stop(); }; +process.on('SIGTERM', () => { void stop(); }); +process.on('SIGINT', () => { void stop(); }); +try { + const { provider, model } = JSON.parse(process.env.BEEHIVE_PI_RUNTIME ?? ''); + delete process.env.BEEHIVE_PI_RUNTIME; + if (provider.provider === 'databricks_v2') bridge = await databricksRuntime({ host: provider.baseUrl, key: provider.credential, model }); + if (!stopping) { + directory = mkdtempSync(join(tmpdir(), 'beehive-pi-')); + const agent = join(directory, 'agent'); mkdirSync(agent, { mode: 0o700 }); + writeFileSync(join(agent, 'models.json'), JSON.stringify(piModelConfig(provider, model, bridge?.endpoint)), { mode: 0o600 }); + const executable = process.argv[2]; if (!executable) throw Error(); + child = spawn(executable, ['--', '--provider', 'beehive', '--model', model, '--thinking', provider.effort ?? 'off', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'], { stdio: ['inherit','inherit','ignore'], env: { ...process.env, HOME: directory, PI_CODING_AGENT_DIR: agent, BEEHIVE_PI_KEY: bridge?.capability ?? process.env.BEEHIVE_PI_KEY! } }); + await new Promise((resolve,reject) => { child!.once('error',reject); child!.once('close', code => { process.exitCode = stopping ? 0 : code ?? 1; resolve(); }); }); + } +} catch { process.exitCode = 1; } +finally { await stop(); if (directory) rmSync(directory, { recursive: true, force: true }); } diff --git a/beehive/src/pi.ts b/beehive/src/pi.ts new file mode 100644 index 0000000000..505bf0ab51 --- /dev/null +++ b/beehive/src/pi.ts @@ -0,0 +1,45 @@ +import { accessSync, constants, realpathSync } from 'node:fs'; +import { dirname, isAbsolute } from 'node:path'; +import { validateBuzzProvider, type BuzzProvider } from './buzz-provider.ts'; +import { runtimeEfforts, runtimeCapabilities } from './runtime-effort.ts'; + +/** Desktop 78618804: fork identity (not upstream pi-acp) gates native metadata. + * The fork has no Desktop semver floor; positive session config evidence is required. */ +export const PI_ADAPTER = 'buzz-pi-acp'; +export function piEfforts(provider: string, model: string) { + return runtimeEfforts(provider, model).filter(e => ['minimal','low','medium','high','xhigh'].includes(e)); +} +export function validatePi(cli: string, provider: BuzzProvider) { + validateBuzzProvider(provider); + if (!isAbsolute(cli) || !provider.credential || !['openai-compat','databricks_v2'].includes(provider.provider) || provider.apiKeyFile !== undefined) throw Error('Pi requires a saved OS provider and explicit CLI'); + if (provider.effort !== undefined && provider.models.some(m => !piEfforts(provider.provider, m).includes(provider.effort!))) throw Error('Unsupported Pi effort'); +} +export function piEnvironment(cli: string, provider: BuzzProvider, home: string, model: string, secret?: string): Record { + validatePi(cli, provider); + if (realpathSync(cli) !== cli || !provider.models.includes(model)) throw Error('Pi CLI/model changed'); + accessSync(cli, constants.X_OK); + if (!secret || secret.length > 16384 || /[\s\0]/.test(secret)) throw Error('Pi OS provider admission required'); + return { PATH: `${dirname(cli)}:${dirname(process.execPath)}:/usr/bin:/bin`, HOME: home, PI_ACP_PI_COMMAND: cli, + BEEHIVE_PI_KEY: provider.provider === 'databricks_v2' ? 'native-refresh-owned' : secret, + BEEHIVE_PI_RUNTIME: JSON.stringify({ provider, model }) }; +} +/** Public-only models.json: apiKey names an environment variable, never a token. + * Provider name is private to this run; raw model/FQN remains unchanged on wire. */ +export function piModelConfig(provider: BuzzProvider, model: string, endpoint = provider.baseUrl) { + const db = provider.provider === 'databricks_v2'; + const route = runtimeCapabilities(provider.provider,model)?.databricks_v2_wire_route; + const anthropic = db && route === 'anthropic-messages'; + const responses = db && route === 'openai-responses'; + const api = anthropic ? 'anthropic-messages' : responses || !db ? 'openai-responses' : 'openai-completions'; + const baseUrl = endpoint + (db ? anthropic ? '/ai-gateway/anthropic' : responses ? '/ai-gateway/openai/v1' : '/ai-gateway/mlflow/v1' : ''); + return { providers: { beehive: { baseUrl, api, apiKey: '$BEEHIVE_PI_KEY', authHeader: true, models: [{ id: model, name: model, reasoning: piEfforts(provider.provider,model).length > 0, input: ['text'] }] } } }; +} +export function piConfigEvidence(value: any, model: string, effort?: string): string[] { + const options = value?.configOptions; + if (!Array.isArray(options) || options.length > 128) throw Error('Pi config capability missing'); + for (const [category, expected] of [['model', `beehive/${model}`], ['thought_level', effort ?? 'off']]) { + const matches = options.filter((o: any) => o.category === category); + if (matches.length !== 1 || matches[0].currentValue !== expected || !Array.isArray(matches[0].options) || !matches[0].options.some((o: any) => o.value === expected)) throw Error('Pi exact model/effort evidence mismatch'); + } + return [model]; +} diff --git a/beehive/src/runtime-effort.ts b/beehive/src/runtime-effort.ts index 6bc45bb997..1d86e70886 100644 --- a/beehive/src/runtime-effort.ts +++ b/beehive/src/runtime-effort.ts @@ -3,12 +3,25 @@ import manifest from './model-capabilities.json' with { type: 'json' }; /** Packaged verbatim from scripts/model-capabilities.json. Interpretation mirrors * Desktop modelCapabilities.ts exact/family precedence. No fallback effort picker: * unknown custom names and UC FQNs deliberately omit tuning rather than guessing. */ -export function runtimeEfforts(provider: string, model: string): readonly string[] { +export function runtimeCapabilities(provider: string, model: string) { const canon = provider === 'openai-compat' ? 'openai' : provider; - if (canon === 'databricks_v2' && model.split('.').length === 3 && model.split('.').every(v => v.length > 0 && !/[\s/]/.test(v))) return []; + if (canon === 'databricks_v2' && model.split('.').length === 3 && model.split('.').every(v => v.length > 0 && !/[\s/]/.test(v))) { + const service = model.split('.')[2]!.toLowerCase(); + const tokens = manifest.family_tokens.flatMap(token => { + let from = 0; + while (true) { + const i = service.indexOf(token, from); if (i < 0) return []; + if (i === 0 || !/[a-z0-9]/.test(service[i-1]!)) return [i]; + from = i + 1; + } + }); + const stripped = service.slice(tokens.length ? Math.min(...tokens) : 0); + const version = /^gpt-(\d+)(?=[^a-z0-9]|$)/.exec(stripped); + return { ...manifest.provider_fallbacks.databricks_v2.concrete_unknown, supported_efforts: [] as string[], databricks_v2_wire_route: version && Number(version[1]) >= 5 ? 'openai-responses' : 'mlflow-chat' }; + } const lower = model.toLowerCase(); const exact = manifest.exact_records.find(r => r.provider === canon && r.raw_model_id.toLowerCase() === lower); - if (exact) return exact.thinking_mode === 'omit-fields' ? [] : exact.supported_efforts; + if (exact) return exact; let start = lower.length; for (const token of manifest.family_tokens) { let from = 0; @@ -26,7 +39,14 @@ export function runtimeEfforts(provider: string, model: string): readonly string return lengths.length ? [{ rule, length: Math.max(...lengths) }] : []; }).sort((a, b) => b.length - a.length || (a.rule.id < b.rule.id ? -1 : 1)); const rule = matches[0]?.rule; - return !rule || rule.thinking_mode === 'omit-fields' ? [] : rule.supported_efforts; + if (rule) return rule; + const fallback = manifest.provider_fallbacks[canon as keyof typeof manifest.provider_fallbacks]?.concrete_unknown; + return fallback ? { ...fallback, supported_efforts: [] as string[] } : undefined; +} + +export function runtimeEfforts(provider: string, model: string): readonly string[] { + const result = runtimeCapabilities(provider, model); + return !result || result.thinking_mode === 'omit-fields' ? [] : result.supported_efforts; } /** Recheck at save AND actual launch; no caller can inject a universal level. */ diff --git a/beehive/src/runtime-form.ts b/beehive/src/runtime-form.ts index dbcdf5076b..4d7b9ceb9d 100644 --- a/beehive/src/runtime-form.ts +++ b/beehive/src/runtime-form.ts @@ -1,3 +1,4 @@ +import { piEfforts } from './pi.ts'; import { runtimeEfforts } from './runtime-effort.ts'; import type { ManagerSnapshot } from './manager-controller.ts'; @@ -23,7 +24,7 @@ export async function runtimeForm(screen: { 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) : []; + 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 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 } : {}) }); diff --git a/beehive/src/settings-runtime.ts b/beehive/src/settings-runtime.ts index 06467b37cc..48d65a25e0 100644 --- a/beehive/src/settings-runtime.ts +++ b/beehive/src/settings-runtime.ts @@ -14,7 +14,7 @@ export function runtimeBindings(base: Setup, settings: Settings): Record join(directory, 'settings.json'); const label = (s: unknown) => typeof s === 'string' && s.length > 0 && s.length <= 128 && !/[\x00-\x1f\x7f]/.test(s); @@ -26,9 +27,9 @@ export function validateSettings(value: Settings): Settings { if (a.profile && (typeof a.profile.relay !== 'string' || Object.values(a.profile).some(v => typeof v !== 'string' || v.length > 2048 || /[\x00-\x1f\x7f]/.test(v)))) throw Error('Invalid public profile'); } 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'].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 (!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.harness === 'codex' ? !r.cli || !isAbsolute(r.cli) || r.effort !== undefined || value.providers.find(p => p.id === r.providerId)?.type !== 'openai' : r.cli !== undefined) throw Error('Unsupported runtime provider/CLI/effort combination'); + 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); // Reject unknown fields, including accidental secret-bearing input. diff --git a/beehive/test/pi-acp-fixture.ts b/beehive/test/pi-acp-fixture.ts new file mode 100644 index 0000000000..e88c382e93 --- /dev/null +++ b/beehive/test/pi-acp-fixture.ts @@ -0,0 +1,44 @@ +import { createInterface } from 'node:readline'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import assert from 'node:assert/strict'; +const config = JSON.parse(readFileSync(join(process.cwd(),'pi-fixture.json'),'utf8')); +const agent = process.env.PI_CODING_AGENT_DIR!; +const document = readFileSync(join(agent,'models.json'),'utf8'); +const provider = JSON.parse(document).providers.beehive; +assert.equal(provider.apiKey,'$BEEHIVE_PI_KEY'); +assert.equal(provider.models[0].id,config.model); +assert.equal(provider.models[0].name,config.model); +assert.equal(document.includes('synthetic'),false); +const args = process.argv.slice(2); +assert.equal(args[args.indexOf('--model')+1],config.model); +assert.equal(args[args.indexOf('--thinking')+1],config.effort ?? 'off'); +assert.equal(process.env.PI_ACP_PI_COMMAND,process.execPath); +assert.equal(process.env.OPENAI_API_KEY,undefined); +assert.equal(process.env.DATABRICKS_TOKEN,undefined); +assert.equal(process.env.BEEHIVE_PI_RUNTIME,undefined); +const descendant = spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); +writeFileSync(join(process.cwd(),'pi-owned.json'),JSON.stringify({adapter:process.pid,descendant:descendant.pid,directory:process.env.HOME,endpoint:provider.baseUrl})); +const sessionId = 'pi-fixture-session'; +const configOptions = [{id:'model',category:'model',currentValue:`beehive/${config.model}`,options:[{value:`beehive/${config.model}`} ]},{id:'thought_level',category:'thought_level',currentValue:config.effort ?? 'off',options:[{value:config.effort ?? 'off'}]}]; +const send = (m: unknown) => console.log(JSON.stringify({jsonrpc:'2.0',...m as object})); +createInterface({input:process.stdin}).on('line',async line => { + const m=JSON.parse(line); let result; + if(m.method==='initialize') result={protocolVersion:config.mode==='wrong-protocol'?2:1,agentInfo:{name:config.mode==='wrong-fork'?'pi-acp':'buzz-pi-acp',version:'0.0.33'}}; + else if(m.method==='session/new') { if(config.instructions) assert.equal(m.params._meta?.systemPrompt,config.instructions); if(config.mode==='wrong-model') configOptions[0].currentValue='beehive/wrong'; if(config.mode==='wrong-effort') configOptions[1].currentValue='low'; result={sessionId,configOptions}; } + else if(m.method==='session/prompt') { + assert.equal(m.params.sessionId,sessionId); + if(config.mode==='drift-effort' || config.mode==='drift-model') { send({method:'session/update',params:{sessionId,update:config.mode==='drift-effort'?{sessionUpdate:'current_mode_update',currentModeId:'low'}:{sessionUpdate:'current_model_update',currentModelId:'beehive/wrong'}}});return; } + for(let n=0;n<(config.db?2:1);n++) { + const endpoint=config.db?provider.baseUrl+'/responses':config.endpoint+'/v1/responses'; + if(!config.db) assert.equal(provider.baseUrl,'https://api.openai.com/v1'); + const response=await fetch(endpoint,{method:'POST',headers:{authorization:'Bearer '+process.env[provider.apiKey.slice(1)]},body:JSON.stringify({model:provider.models[0].id, ...(config.effort?{reasoning:{effort:args[args.indexOf('--thinking')+1]}}:{}),input:'synthetic'})}); + assert.equal(response.status,200); await response.text(); + if(config.db) await new Promise(r=>setTimeout(r,75)); + } + send({method:'session/update',params:{sessionId,update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'Synthetic Pi complete'}}}}); result={stopReason:'end_turn'}; + } else if(m.method==='session/cancel') return; + else throw Error('Unexpected Pi RPC '+m.method); + send({id:m.id,result}); +}); diff --git a/beehive/test/pi-conversation.test.ts b/beehive/test/pi-conversation.test.ts new file mode 100644 index 0000000000..1fdaebd30e --- /dev/null +++ b/beehive/test/pi-conversation.test.ts @@ -0,0 +1,51 @@ +import './pi-isolation-loader.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +const {ConversationSession}=await import('../src/broker.ts'); +const {newKey,publicKey}=await import('../src/protocol.ts'); +for(const variant of ['openai','databricks','cancel']) test(`Pi ${variant} conversation broker proves model/effort, native metadata and owned Stop`,async t=>{ + const db=variant!=='openai',cancel=variant==='cancel'; + const root=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pi-conversation-')));t.after(()=>rmSync(root,{recursive:true,force:true})); + const model=db?'databricks-gpt-5-4':'gpt-5',effort='high',instructions='Pi exact native standing instructions'; + const key={service:'beehive' as const,account:'provider:00000000-0000-0000-0000-000000000000'}; + const observed:any[]=[];let requested!:()=>void;const requestStarted=new Promise(resolve=>{requested=resolve;}); + const server=createServer(async(req,res)=>{let body='';for await(const c of req)body+=c;observed.push({path:req.url,auth:req.headers.authorization,body:JSON.parse(body)});requested();if(!cancel)res.end('{}');}); + server.listen(0,'127.0.0.1');await once(server,'listening');t.after(()=>new Promise(r=>server.close(()=>r()))); + const endpoint=`http://127.0.0.1:${(server.address() as any).port}`,route=db?'/ai-gateway/openai/v1/responses':'/v1/responses'; + writeFileSync(join(root,'pi-fixture.json'),JSON.stringify({model,effort,db,endpoint,route,key,instructions})); + const executable=join(root,'buzz-pi-acp'),runtime=join(root,'fake-relay-runtime'); + writeFileSync(executable,`#!${process.execPath}\nawait import(${JSON.stringify(new URL('./pi-acp-fixture.ts',import.meta.url).href)});\n`,{mode:0o700}); + writeFileSync(runtime,`#!${process.execPath} +import {spawn} from 'node:child_process';import {createInterface} from 'node:readline';import {writeFileSync} from 'node:fs'; +if(process.env.BEEHIVE_PI_KEY || process.env.BEEHIVE_PI_RUNTIME || process.env.BUZZ_ACP_MODEL) throw Error('Relay runtime received provider control'); +const child=spawn(process.env.BUZZ_ACP_AGENT_COMMAND,process.env.BUZZ_ACP_AGENT_ARGS.split(','),{detached:true,stdio:['pipe','pipe','ignore'],env:{PATH:'/usr/bin:/bin'}}); +writeFileSync('pi-transport-pids',JSON.stringify([process.pid,child.pid])); +const pending=new Map();let seq=0; +createInterface({input:child.stdout}).on('line',line=>{const m=JSON.parse(line);pending.get(m.id)?.(m);}); +const request=(method,params)=>new Promise(resolve=>{const id=++seq;pending.set(id,resolve);child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\\n');}); +await request('initialize',{protocolVersion:2,clientCapabilities:{}}); +const created=await request('session/new',{cwd:process.cwd(),mcpServers:[]}); +await request('session/prompt',{sessionId:created.result.sessionId,prompt:[{type:'text',text:'Synthetic conversation'}]}); +`,{mode:0o700}); + const session=new ConversationSession({executable:runtime,relay:'ws://127.0.0.1:1'},{executable,args:[],workspace:root,home:root,configDirectory:root,databricksHost:'',harness:'pi',piCli:realpathSync(process.execPath),model,instructions,resolvedProviderKey:'synthetic-key',buzzProvider:{provider:db?'databricks_v2':'openai-compat',baseUrl:db?'https://pi-workspace.example':'https://api.openai.com/v1',credential:key,models:[model],effort,...(db?{auth:'token' as const}:{wire:'auto' as const})}},newKey(),publicKey(newKey())); + t.after(()=>session.stop()); + const proof=session.verify(); + if(cancel) { + const rejected=assert.rejects(proof);await Promise.race([requestStarted,proof]);session.cancel();await rejected; + } else { + const evidence=await proof;assert.equal(evidence.model,model);assert.equal(evidence.source,'external-buzz-conversation'); + assert.equal(observed.length,db?2:1); + for(const [i,row]of observed.entries()){assert.equal(row.path,route);assert.equal(row.body.model,model);assert.equal(row.body.reasoning.effort,effort);assert.equal(row.auth,db?`Bearer synthetic-pi-generation-${i+1}`:'Bearer synthetic-key');} + } + await session.stop(); + const owned=JSON.parse(readFileSync(join(root,'pi-owned.json'),'utf8')); + const transport=JSON.parse(readFileSync(join(root,'pi-transport-pids'),'utf8')); + for(const pid of [...transport,owned.adapter,owned.descendant])assert.throws(()=>process.kill(pid,0),(e:any)=>e.code==='ESRCH'); + assert.equal(existsSync(owned.directory),false); + if(db)await assert.rejects(fetch(owned.endpoint+'/responses')); +}); diff --git a/beehive/test/pi-host.test.ts b/beehive/test/pi-host.test.ts new file mode 100644 index 0000000000..eac6f1c6db --- /dev/null +++ b/beehive/test/pi-host.test.ts @@ -0,0 +1,98 @@ +import './pi-isolation-loader.mjs'; +import type { Setup } from '../src/host.ts'; +import type { Message } from '../src/protocol.ts'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// Load product modules only AFTER the explicit subprocess isolation hooks. +const { addDatabricks } = await import('../src/databricks.ts'); +const { addOpenAI } = await import('../src/settings-credentials.ts'); +const { readSettings, saveSettings, settingsId } = await import('../src/settings.ts'); +const { newKey, publicKey, message } = await import('../src/protocol.ts'); +const { host, provision } = await import('../src/host.ts'); +const { createGenesis } = await import('../src/assignment.ts'); +const { writePrivate } = await import('../src/storage.ts'); + +function fixture(t: test.TestContext) { const root = realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-settings-'))); t.after(() => rmSync(root,{ recursive: true, force: true })); return root; } +const sleep = (ms: number) => new Promise(r => setTimeout(r,ms)); +async function wait(fn: () => boolean) { for(let n=0;n<150;n++) { if(fn()) return; await sleep(20); } throw Error('Fixture wait expired'); } + +for (const providerType of ['pi-openai','pi-databricks','pi-custom','pi-databricks-custom'] as const) test(`${providerType} saved runtime reaches host Save/new Start while the prior active run remains immutable`, async t => { + const root = fixture(t), owner = newKey(), agent = newKey(), hostKey = publicKey(newKey()); + const db = providerType.startsWith('pi-databricks'); + const home = root, config = root; + const setup: Setup = { host: hostKey, ownerSecret: owner, agentSecret: agent, runner: realpathSync(process.execPath), args: ['-e','setInterval(()=>{},1000)'], workspace: root, serviceHome: home, configDirectory: config, mode: 'fixture' }; + provision(root,setup,createGenesis(publicKey(owner),publicKey(agent),hostKey)); + let receive: (m: Message) => void = () => {}; const reports: Message[] = []; + const transport = { binding: { host: hostKey, owner: publicKey(owner) }, validate() {}, connect(_url: string,_secret: string,handle: (m: Message) => void) { receive = handle; return { ready: Promise.resolve(), send(m: Message) { reports.push(m); }, close() {} }; } }; + let providerReads = 0; + const running = await host(root,'ws://127.0.0.1',undefined,transport,undefined,async (input,signal) => { signal.throwIfAborted(); assert.equal((input as any).provider,!db ? 'openai-compat' : 'databricks_v2'); if (db) assert.equal((input as any).host,'https://pi-workspace.example'); providerReads++; return {ok:true,secret:'synthetic-provider-key'}; }); + t.after(() => running.close()); + const start = message('start',hostKey,publicKey(agent),0); receive(start); + await wait(() => reports.some(m => m.type === 'inventory' && m.body.phase === 'running')); + const active = reports.filter(m => m.type === 'inventory').at(-1)!.body.actualRun; + let stored: string | null = null; + if (!db) addOpenAI(root,'Fixture','synthetic',{ read: () => stored, create(_r,v) { stored = v; } }); + else await addDatabricks(root,'Workspace','https://pi-workspace.example',new AbortController().signal,async input => { assert.equal(input.action,'login'); return {ok:true}; }); + const prior = readSettings(root), id = settingsId(); + const model = providerType === 'pi-databricks-custom' ? 'tenant.schema.gpt-6-service' : providerType === 'pi-custom' ? 'retained-custom-model' : db ? 'databricks-gpt-5-4' : 'gpt-5'; + const effort = providerType.endsWith('custom') ? undefined : 'high'; + const executable = join(root,'buzz-pi-acp'); + const observations: any[] = []; + const server = createServer(async (req,res) => { + let body=''; for await(const chunk of req) body+=chunk; + observations.push({path:req.url,authorization:req.headers.authorization,body:JSON.parse(body)}); + res.setHeader('content-type','text/event-stream');res.end('data: {"type":"response.completed"}\n\n'); + }); + server.listen(0,'127.0.0.1');await once(server,'listening'); + t.after(() => new Promise(resolve=>server.close(()=>resolve()))); + const endpoint=`http://127.0.0.1:${(server.address() as any).port}`; + const route=db?'/ai-gateway/openai/v1/responses':'/v1/responses'; + writeFileSync(join(root,'pi-fixture.json'),JSON.stringify({model,effort,db,endpoint,route,key:prior.providers[0]!.key})); + writeFileSync(executable,`#!${process.execPath}\nawait import(${JSON.stringify(new URL('./pi-acp-fixture.ts',import.meta.url).href)});\n`,{mode:0o700}); + saveSettings(root,{ ...prior, runtimes: [{ id, name: 'Pi custom name', harness: 'pi', executable, providerId: prior.providers[0]!.id, model, cli:realpathSync(process.execPath), ...(effort?{effort}:{}) }] },prior.revision); + assert.equal(readSettings(root).runtimes[0]!.name,'Pi custom name'); + assert.equal(providerReads,0,'Save does not accept secrets or start Pi'); + await wait(() => running.settingsRevision === 2); + await wait(() => reports.some(m => m.type === 'inventory' && (m.body.harnessSetups as any[]).some(r => r.id === `runtime:${id}`))); + const inventory = reports.filter(m => m.type === 'inventory').at(-1)!; + assert.deepEqual(inventory.body.actualRun,active); + const binding = (inventory.body.harnessSetups as any[]).find(r => r.id === `runtime:${id}`); + const save = message('save',hostKey,publicKey(agent),inventory.revision,{ model, workspace: root, profile: 'default', harnessSetup: { id: binding.id, fingerprint: binding.fingerprint } }); + receive(save); await wait(() => reports.some(m => m.body.operation === save.id)); + assert.equal(reports.find(m => m.body.operation === save.id)?.body.result,'saved; running configuration unchanged'); + assert.deepEqual(reports.filter(m => m.type === 'inventory').at(-1)!.body.actualRun,active); + const current = reports.filter(m => m.type === 'inventory').at(-1)!; + const stop = message('stop',hostKey,publicKey(agent),current.revision); receive(stop); await wait(() => reports.some(m => m.body.operation === stop.id)); + const stopped = reports.filter(m => m.type === 'inventory').at(-1)!; + assert.equal(stopped.body.phase,'stopped'); + const nextStart = message('start',hostKey,publicKey(agent),stopped.revision); receive(nextStart); await wait(() => reports.some(m => m.body.operation === nextStart.id)); + assert.equal(reports.find(m => m.body.operation === nextStart.id)?.body.result,'accepted'); + assert.equal((reports.filter(m => m.type === 'inventory').at(-1)!.body.actualRun as any).selection.model,model); + assert.equal(providerReads,1); + assert.equal(observations.length,db?2:1); + for(const [i,observed] of observations.entries()) { + assert.equal(observed.path,route); + assert.equal(observed.body.model,model); + assert.equal(observed.body.reasoning?.effort,effort); + assert.equal(observed.authorization,db?`Bearer synthetic-pi-generation-${i+1}`:'Bearer synthetic-provider-key'); + } + const owned=JSON.parse(readFileSync(join(root,'pi-owned.json'),'utf8')); + const beforeStop=reports.filter(m=>m.type==='inventory').at(-1)!; + const piStop=message('stop',hostKey,publicKey(agent),beforeStop.revision);receive(piStop); + await wait(()=>reports.some(m=>m.body.operation===piStop.id)); + assert.equal(reports.filter(m=>m.type==='inventory').at(-1)!.body.phase,'stopped'); + for(const pid of [owned.adapter,owned.descendant]) assert.throws(()=>process.kill(pid,0),(e:any)=>e.code==='ESRCH'); + assert.equal(existsSync(owned.directory),false); + if(db) { await assert.rejects(fetch(owned.endpoint+'/responses')); const native=readFileSync(join(root,'native-calls'),'utf8').trim().split('\n').map(v=>JSON.parse(v)); assert.equal(native.length,2); assert.deepEqual(native.map(v=>v.input.key),[prior.providers[0]!.key,prior.providers[0]!.key]); } + assert.ok(!JSON.stringify(reports).includes('synthetic-provider-key')); + assert.ok(!readFileSync(join(root,'journal.json'),'utf8').includes('synthetic-provider-key')); + const invalid = { ...readSettings(root), revision: 3, runtimes: [] }; writePrivate(join(root,'settings.json'),invalid); + await sleep(2100); assert.equal(running.settingsRevision,2,'invalid replacement retains effective catalog'); + await running.close(); +}); + diff --git a/beehive/test/pi-isolation-loader.mjs b/beehive/test/pi-isolation-loader.mjs new file mode 100644 index 0000000000..2444c7affb --- /dev/null +++ b/beehive/test/pi-isolation-loader.mjs @@ -0,0 +1,33 @@ +// Explicit test-only subprocess seam: never installed or imported by production. +import { registerHooks } from 'node:module'; +import { readFileSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +const loader = import.meta.url; +registerHooks({ load(url,context,next) { + const result = next(url,context); + if (url.endsWith('/src/acp.ts')) return { ...result, source: String(result.source).replace("[fileURLToPath(new URL('./pi-runtime-child.ts', import.meta.url)),", `[ '--import', ${JSON.stringify(loader)}, fileURLToPath(new URL('./pi-runtime-child.ts', import.meta.url)),`) }; + if (url.endsWith('/src/databricks.ts')) { + const source = String(result.source); + const start = source.indexOf('export async function databricksNative('); + const end = source.indexOf('/** Native grant',start); + if(start<0 || end<0) throw Error('Native isolation seam changed'); + return {...result,source:source.slice(0,start)+`export async function databricksNative(input,signal) { return globalThis.piFixtureNative(input,signal); }\n`+source.slice(end)}; + } + return result; +}}); +let expires = 0, generation = 0; +globalThis.piFixtureNative = async (input,signal) => { + signal.throwIfAborted(); + const fixture = JSON.parse(readFileSync(join(process.cwd(),'pi-fixture.json'),'utf8')); + if (input.action !== 'token' || input.host !== 'https://pi-workspace.example' || JSON.stringify(input.key) !== JSON.stringify(fixture.key)) throw Error('Fixture native boundary mismatch'); + if (Date.now() >= expires) { generation++; expires=Date.now()+50; } + appendFileSync(join(process.cwd(),'native-calls'),JSON.stringify({input,generation})+'\n'); + return {ok:true,secret:`synthetic-pi-generation-${generation}`}; +}; +const originalFetch = globalThis.fetch; +globalThis.fetch = async (url,options) => { + if (!String(url).startsWith('https://')) return originalFetch(url,options); + const fixture = JSON.parse(readFileSync(join(process.cwd(),'pi-fixture.json'),'utf8')); + if (url !== 'https://pi-workspace.example'+fixture.route) throw Error('Nonfixture network forbidden'); + return originalFetch(fixture.endpoint+fixture.route,options); +}; diff --git a/beehive/test/pi.test.ts b/beehive/test/pi.test.ts new file mode 100644 index 0000000000..cf3e808f61 --- /dev/null +++ b/beehive/test/pi.test.ts @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, realpathSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { AgentSession, prepareAgent, type AgentLaunch } from '../src/acp.ts'; +import { piEfforts, piModelConfig } from '../src/pi.ts'; +import { runtimeForm } from '../src/runtime-form.ts'; +import type { ManagerSnapshot } from '../src/manager-controller.ts'; +import { runtimeCapabilities } from '../src/runtime-effort.ts'; +const key={service:'beehive' as const,account:'provider:00000000-0000-0000-0000-000000000000'}; +const provider={provider:'openai-compat' as const,baseUrl:'https://api.openai.com/v1',wire:'auto' as const,credential:key,models:['gpt-5'],effort:'high'}; +for(const mode of ['wrong-model','wrong-effort','wrong-fork','wrong-protocol','drift-effort','drift-model']) test(`Pi rejects ${mode} before any provider request, Stop owns children`,async t=>{ + const root=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pi-negative-')));t.after(()=>rmSync(root,{recursive:true,force:true})); + const executable=join(root,'buzz-pi-acp'); + writeFileSync(executable,`#!${process.execPath}\nawait import(${JSON.stringify(new URL('./pi-acp-fixture.ts',import.meta.url).href)});\n`,{mode:0o700}); + writeFileSync(join(root,'pi-fixture.json'),JSON.stringify({mode,model:'gpt-5',effort:'high',db:false,endpoint:'http://127.0.0.1:1'})); + const launch:AgentLaunch={executable,args:[],workspace:root,home:root,configDirectory:root,databricksHost:'',harness:'pi',piCli:realpathSync(process.execPath),buzzProvider:provider,model:'gpt-5',resolvedProviderKey:'synthetic-secret'}; + assert.throws(()=>prepareAgent({...launch,resolvedProviderKey:undefined}),/admission/); + const session=new AgentSession(launch);let stopped=false;t.after(()=>stopped?undefined:session.owned.stop()); + if(mode.startsWith('drift-')) await session.catalog(); else await assert.rejects(session.catalog()); + await assert.rejects(session.verify()); + await session.owned.stop();stopped=true; + const owned=JSON.parse(readFileSync(join(root,'pi-owned.json'),'utf8')); + for(const pid of [owned.adapter,owned.descendant]) assert.throws(()=>process.kill(pid,0),(e:any)=>e.code==='ESRCH'); + assert.equal(existsSync(owned.directory),false); +}); + +test('Pi model routes use the shared capability owner; UC tenant components never choose protocol or effort',()=>{ + const db={...provider,provider:'databricks_v2' as const,wire:undefined,auth:'token' as const,baseUrl:'https://pi-workspace.example'}; + for(const [model,api,suffix] of [['databricks-gpt-5-4','openai-responses','/ai-gateway/openai/v1'],['databricks-claude-sonnet-4-5','anthropic-messages','/ai-gateway/anthropic'],['gpt-5.schema.unknown','openai-completions','/ai-gateway/mlflow/v1'],['catalog.schema.gpt-6-service','openai-responses','/ai-gateway/openai/v1'],['catalog.schema.agpt-5-gpt-6','openai-responses','/ai-gateway/openai/v1'],['catalog.schema.claude-gpt-5','openai-completions','/ai-gateway/mlflow/v1']]) { + const p=piModelConfig(db,model).providers.beehive; + assert.equal(p.api,api);assert.equal(p.baseUrl,db.baseUrl+suffix);assert.equal(p.models[0].id,model); + } + assert.deepEqual(piEfforts('databricks_v2','catalog.schema.gpt-6-service'),[]); + assert.deepEqual(piEfforts('openai','unknown-custom'),[]); + assert.equal(runtimeCapabilities('databricks_v2','gpt-5.schema.unknown')!.supported_efforts.length,0); +}); + +test('actual runtime form offers Pi both providers, retains custom names, submits only supported model effort',async()=>{ + for(const type of ['openai','databricks_v2'] as const) { + 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'}}); + } +}); diff --git a/beehive/test/runtime-integration.test.ts b/beehive/test/runtime-integration.test.ts index 28fa3f9023..1bbd5c3118 100644 --- a/beehive/test/runtime-integration.test.ts +++ b/beehive/test/runtime-integration.test.ts @@ -101,10 +101,11 @@ test('Desktop contract detects fake adapter/CLI independently, strict Codex vers const rows = await discoverHarnesses(new AbortController().signal, {home:root,path:root,bundled:[],common:[],loginShells:[]}); assert.equal(rows.find(r => r.id === 'pi')?.executable,join(root,'buzz-pi-acp')); assert.equal(rows.find(r => r.id === 'pi')?.state,'available'); + assert.deepEqual(rows.find(r => r.id === 'pi')?.providers,['openai','databricks_v2']); assert.equal(rows.find(r => r.id === 'codex')?.state,'available'); assert.equal(rows.find(r => r.id === 'claude')?.state,'cli-missing'); assert.equal(rows.find(r => r.id === 'goose')?.state,'not-installed'); - assert.deepEqual(rows.filter(r => r.providers.length).map(r => r.id),['codex','buzz-agent']); + assert.deepEqual(rows.filter(r => r.providers.length).map(r => r.id),['codex','buzz-agent','pi']); for (const value of ['1.9.99','1.10','1.10.0-rc1','not-version']) assert.equal(compatibleCodex(value),false); for (const value of ['1.10.0','pkg 2.0.0']) assert.equal(compatibleCodex(value),true); const slow = join(root,'slow'); writeFileSync(slow,'#!/bin/sh\nexec /bin/sleep 60\n',{mode:0o700});