From 0dfe959f3a81804dde91361f6b7c8fbfff59fbb6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 12:04:58 -0400 Subject: [PATCH] feat(beehive): connect authenticated owner-local compute gateways Signed-off-by: Logan Johnson --- beehive/LOCAL_COMPUTE_SETUP.md | 56 ++++++++++++++++++++ beehive/src/buzz-provider.ts | 8 +-- beehive/test/buzz-provider.test.ts | 8 +++ beehive/test/conversation-harness-fixture.ts | 13 ++++- beehive/test/normal-conversation.test.ts | 33 +++++++++++- 5 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 beehive/LOCAL_COMPUTE_SETUP.md diff --git a/beehive/LOCAL_COMPUTE_SETUP.md b/beehive/LOCAL_COMPUTE_SETUP.md new file mode 100644 index 0000000000..c9091acb88 --- /dev/null +++ b/beehive/LOCAL_COMPUTE_SETUP.md @@ -0,0 +1,56 @@ +# Connect an already provisioned local compute gateway + +This is connection setup, **not mesh/backend provisioning**. Beehive neither +installs nor starts a compute service, opens a public listener, nor manages its +credentials. Use a gateway you have separately provisioned as the host service +operator, with a real required bearer token and OpenAI Chat Completions support. + +1. On the host, arrange for that gateway to listen on literal `127.0.0.1` or + `[::1]`. Its API base must be `/v1`. Keep its service configuration local. +2. Store its actual required bearer token in an absolute, canonical owner-only + (0600) file owned by the Beehive service user. Do not put credentials in a URL, + argv, profile, remote TUI or shared inventory. +3. In the existing local setup wizard select Buzz Agent provider setup, the + installed buzz-agent executable, provider `openai-compat`, that key file, + `http://127.0.0.1:PORT/v1`, wire `chat`, and exact served wire model IDs. + Existing `local-setup` → `add-buzz-provider` also creates a new immutable binding; + do not mutate a binding used by an existing run. HTTPS services remain supported. +4. Complete normal conversation setup with separate conversation relay and + installed buzz-acp/Buzz CLI. The model HTTP URL is **not** a management or + conversation relay. Existing workspace, identity and profile controls apply. +5. Select the advertised binding/model in a public named launch configuration. + Save changes selected-next only. Explicit Start/Restart gates prompts on the + exact session model/profile. Failed preflight preserves the previous actual + run. Endpoint availability or an approved model list alone is not readiness. + +## Source contract and unsupported direct mesh mode + +At Buzz source `051c3a270be9c73da9ab06700bcab7d5552fceaa`, +`desktop/src-tauri/src/managed_agents/relay_mesh.rs` derives OpenAI-compatible +Chat Completions at `http://127.0.0.1:9337/v1`, maps Desktop `auto` to wire model +`mesh`, and inserts the **placeholder** `buzz-mesh-local`. The frontend is not +thereby authenticated. `managed_agents/runtime.rs:778–789` removes ambient +`OPENAI_API_KEY`. `crates/buzz-agent/src/config.rs:631–691` requires nonempty +`OPENAI_COMPAT_API_KEY`; `llm.rs` appends `/chat/completions` and uses bearer auth. +Beehive retains this closed provider environment and does not insert a placeholder +or fall back to ambient credentials. **Direct unauthenticated Desktop mesh is not +supported by this slice.** Native optional-auth support would require separately +authorized consumer changes; do not make a fake key to appear configured. + +Use exact wire IDs, not the Desktop `auto` alias. `mesh` may route/degrade among +workers, so exact wire-model confirmation does not attest physical model/committee +membership. No mesh-specific long deadline, generation defaults or capacity promise +is imported. `commands/agent_providers.rs` describes discovered `buzz-backend-*` +JSON info invocations for compute deployment, not another LLM provider type; those +backend operations are not implemented here. + +## Evidence scope + +The reused installed normal OpenAI-compatible journey drives the actual local +wizard, host CLI subprocess and remote TUI; real installed buzz-acp and Buzz CLI +publish four verified same-agent signed replies. A source-shaped TypeScript ACP +consumer calls a bearer-protected loopback Chat Completions fixture. It checks +exact model/native profile, fresh Restart, selected-next versus actual, sibling and +history preservation, HTTP wrong-model/malformed-protocol/unreachable failures, +missing required credentials and no private inputs in inventory. This is not real +Buzz Agent inference, a deployed mesh network or production provider attestation. diff --git a/beehive/src/buzz-provider.ts b/beehive/src/buzz-provider.ts index 446bc10221..48ff59d746 100644 --- a/beehive/src/buzz-provider.ts +++ b/beehive/src/buzz-provider.ts @@ -16,7 +16,9 @@ export function validateBuzzProvider(value: BuzzProvider): void { if (value.auth === 'external-oauth' ? value.apiKeyFile !== undefined : typeof value.apiKeyFile !== 'string' || !isAbsolute(value.apiKeyFile)) throw Error('Provider requires owner-only key file; external OAuth forbids token fallback'); let url: URL; try { url = new URL(value.baseUrl); } catch { throw Error('Invalid local provider endpoint'); } - if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw Error('Provider endpoint requires HTTPS without credentials, query or fragment'); + // Literal loopback only: no DNS resolution, LAN exposure or alternative URL spellings. + const loopback = value.provider === 'openai-compat' && url.protocol === 'http:' && /^http:\/\/(127\.0\.0\.1|\[::1\])(?::[0-9]+)?\/v1\/?$/.test(value.baseUrl); + if ((!loopback && url.protocol !== 'https:') || url.username || url.password || url.search || url.hash) throw Error('Provider endpoint requires HTTPS or OpenAI-compatible literal loopback HTTP /v1, without credentials, query or fragment'); if (value.provider === 'databricks_v2' && (url.pathname !== '/' || url.port)) throw Error('Databricks workspace requires an HTTPS origin without path or custom port'); if (value.provider === 'openai-compat' ? !['auto', 'chat', 'responses'].includes(value.wire ?? '') : value.wire !== undefined) throw Error('Unsupported provider wire combination; only OpenAI-compatible accepts auto/chat/responses'); } @@ -38,7 +40,7 @@ export function buzzProviderEnvironment(value: BuzzProvider, home: string, confi return { ...base, [contract.key]: key, [contract.url]: value.provider === 'databricks_v2' ? new URL(value.baseUrl).origin : value.baseUrl, ...(value.wire ? { OPENAI_COMPAT_API: value.wire } : {}) }; } /** Operator guidance; no automatic install, login, refresh or cache harvesting. */ -export const buzzProviderGuidance = 'Buzz Agent provider binding: install buzz-agent locally as the dedicated service user. Anthropic uses ANTHROPIC_API_KEY; OpenAI-compatible uses OPENAI_COMPAT_API_KEY (NOT OPENAI_API_KEY), with explicit auto/chat/responses wire mode; OpenRouter uses OPENROUTER_API_KEY and Chat Completions only. Endpoint and owner-only key file stay local. Approved models are not an authenticated catalog. Start must prove the exact session model; Save never restarts. Databricks v2 uses DATABRICKS_HOST and either an owner-only DATABRICKS_TOKEN file or explicitly external native OAuth (no token fallback). Local configuration and key presence are not verified login, refresh or model access. No login or endpoint probe initiated.'; +export const buzzProviderGuidance = 'Buzz Agent provider binding: install buzz-agent locally as the dedicated service user. Anthropic uses ANTHROPIC_API_KEY; OpenAI-compatible uses OPENAI_COMPAT_API_KEY (NOT OPENAI_API_KEY), with explicit auto/chat/responses wire mode; OpenRouter uses OPENROUTER_API_KEY and Chat Completions only. Endpoint and owner-only key file stay local. Approved models are not an authenticated catalog. Start must prove the exact session model; Save never restarts. Databricks v2 uses DATABRICKS_HOST and either an owner-only DATABRICKS_TOKEN file or explicitly external native OAuth (no token fallback). Local configuration and key presence are not verified login, refresh or model access. No login or endpoint probe initiated. For an already provisioned owner-local compatible compute gateway, choose openai-compat, its literal loopback HTTP /v1 endpoint, chat wire and exact served model ID. Provision its real required bearer token in the owner-only key file. No service is downloaded, launched or exposed. This is connection setup, not backend provisioning. The pinned Desktop mesh uses a placeholder key for its unauthenticated frontend; native Buzz Agent still requires a nonempty key. That unauthenticated mesh contract is NOT supported here: do not create a fake key. Use a genuinely authenticated compatible local gateway or await native optional-auth support. Desktop auto maps to mesh; Beehive requires the exact wire model, with no auto alias or committee-capacity attestation. Management/conversation relay URLs are separate from this model endpoint.'; /** Shared normal/management wizard, not a generic remote environment form. */ export async function buzzProviderInput(ui: { question(prompt: string): Promise }): Promise { console.log(buzzProviderGuidance); @@ -47,7 +49,7 @@ export async function buzzProviderInput(ui: { question(prompt: string): Promise< const auth = provider === 'databricks_v2' ? await ui.question('Databricks authentication [token / external-oauth]: ') as BuzzProvider['auth'] : undefined; if (provider === 'databricks_v2' && !['token', 'external-oauth'].includes(auth ?? '')) throw Error('Choose explicit Databricks auth; no fallback'); const apiKeyFile = auth === 'external-oauth' ? undefined : await ui.question(`Absolute owner-only local ${contracts[provider].key} file (contents never relayed): `); - const baseUrl = await ui.question('Provider HTTPS base URL (local only): '); + const baseUrl = await ui.question('Provider HTTPS base URL (or OpenAI-compatible http://127.0.0.1:PORT/v1; local only): '); const wire = provider === 'openai-compat' ? await ui.question('OpenAI-compatible wire [auto / chat / responses]: ') as BuzzProvider['wire'] : undefined; const models = (await ui.question('Operator-approved compatible exact Buzz Agent model IDs (comma-separated): ')).split(',').map(m => m.trim()); const result = { provider, ...(apiKeyFile === undefined ? {} : { apiKeyFile }), ...(auth ? { auth } : {}), baseUrl, ...(wire ? { wire } : {}), models }; diff --git a/beehive/test/buzz-provider.test.ts b/beehive/test/buzz-provider.test.ts index e042ffa76f..aa5493b4b0 100644 --- a/beehive/test/buzz-provider.test.ts +++ b/beehive/test/buzz-provider.test.ts @@ -51,3 +51,11 @@ for (const auth of ['token', 'external-oauth'] as const) test(`Databricks v2 ${a if (auth === 'token') assert.throws(() => prepareAgent(launch), /API key prerequisite/); else assert.doesNotThrow(() => prepareAgent(launch), 'external OAuth does not inspect credential/cache files, including absent refresh stores'); }); + +test('owner-local compatible compute URL boundary remains credential-required', () => { + const binding: BuzzProvider = { provider: 'openai-compat', apiKeyFile: '/missing-local-key', baseUrl: 'http://127.0.0.1:9337/v1', wire: 'chat', models: ['mesh'] }; + for (const baseUrl of ['http://127.0.0.1:9337/v1', 'http://[::1]:9337/v1/']) assert.doesNotThrow(() => validateBuzzProvider({ ...binding, baseUrl })); + for (const baseUrl of ['http://localhost:9337/v1', 'http://127.1:9337/v1', 'http://2130706433:9337/v1', 'http://127.0.0.1:9337', 'http://192.168.1.2:9337/v1', 'http://127.0.0.1:9337/v1?key=secret', 'http://user:pass@127.0.0.1:9337/v1']) assert.throws(() => validateBuzzProvider({ ...binding, baseUrl })); + assert.throws(() => validateBuzzProvider({ ...binding, apiKeyFile: undefined })); + assert.throws(() => validateBuzzProvider({ ...binding, provider: 'anthropic', wire: undefined })); +}); diff --git a/beehive/test/conversation-harness-fixture.ts b/beehive/test/conversation-harness-fixture.ts index 05b575a29c..00ae9dc14e 100644 --- a/beehive/test/conversation-harness-fixture.ts +++ b/beehive/test/conversation-harness-fixture.ts @@ -6,11 +6,12 @@ import { once } from 'node:events'; import { publicKey } from '../src/protocol.ts'; // Native profile evidence is recorded from session/new input, never the env fallback. const buzzProvider = process.env.BUZZ_AGENT_PROVIDER; +const localCompute = buzzProvider === 'openai-compat' && /^http:\/\/127\.0\.0\.1:[0-9]+\/v1$/.test(process.env.OPENAI_COMPAT_BASE_URL ?? ''); if (['anthropic', 'openai-compat', 'openrouter'].includes(buzzProvider ?? '')) { const p = buzzProvider!; const key = p === 'anthropic' ? 'ANTHROPIC_API_KEY' : p === 'openai-compat' ? 'OPENAI_COMPAT_API_KEY' : 'OPENROUTER_API_KEY'; const endpoint = p === 'anthropic' ? 'ANTHROPIC_BASE_URL' : p === 'openai-compat' ? 'OPENAI_COMPAT_BASE_URL' : 'OPENROUTER_BASE_URL'; - if (process.env[key] !== `fixture-${p}-private-key` || process.env[endpoint] !== `https://${p}.fixture.invalid/v1` || process.env.OPENAI_API_KEY || process.env.DATABRICKS_HOST || process.env.GOOSE_PROVIDER || process.env.ANTHROPIC_MODEL || !process.env.HOME?.endsWith('service-home') || !process.env.BUZZ_AGENT_CONFIG_DIR?.endsWith('agent-config') || (p === 'openai-compat' ? process.env.OPENAI_COMPAT_API !== 'responses' : process.env.OPENAI_COMPAT_API !== undefined)) throw Error('Incorrect Buzz provider contract'); + if (process.env[key] !== `fixture-${p}-private-key` || (!localCompute && process.env[endpoint] !== `https://${p}.fixture.invalid/v1`) || process.env.OPENAI_API_KEY || process.env.DATABRICKS_HOST || process.env.GOOSE_PROVIDER || process.env.ANTHROPIC_MODEL || !process.env.HOME?.endsWith('service-home') || !process.env.BUZZ_AGENT_CONFIG_DIR?.endsWith('agent-config') || (p === 'openai-compat' ? process.env.OPENAI_COMPAT_API !== (localCompute ? 'chat' : 'responses') : process.env.OPENAI_COMPAT_API !== undefined)) throw Error('Incorrect Buzz provider contract'); for (const other of ['ANTHROPIC_API_KEY', 'OPENAI_COMPAT_API_KEY', 'OPENROUTER_API_KEY']) if (other !== key && process.env[other]) throw Error('Mixed provider credentials'); writeFileSync(`${p}-env-checked`, 'closed provider-specific contract'); } @@ -121,6 +122,16 @@ for await (const line of createInterface({ input: process.stdin })) { if ((goose || claude || codex || buzzProvider) && gooseSessions.has(m.params.sessionId)) sessionId = m.params.sessionId; appendFileSync('received-prompts.jsonl', JSON.stringify(m.params) + '\n'); if (selected !== model || m.params.sessionId !== sessionId) process.exit(8); + // Source-shaped OpenAI consumer: the loopback fixture is not a mesh network. + if (localCompute) { + const response = await fetch(`${process.env.OPENAI_COMPAT_BASE_URL}/chat/completions`, { + method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.OPENAI_COMPAT_API_KEY}` }, + body: JSON.stringify({ model: selected, stream: false, messages: [{ role: 'user', content: 'isolated fixture prompt' }] }), + signal: AbortSignal.timeout(2000), + }); + const body = await response.json() as any; + if (!response.ok || body.model !== selected || typeof body.choices?.[0]?.message?.content !== 'string') throw Error('Local compute protocol/model failure'); + } writeFileSync('prompt-started', selected); prompt = m.id; const drift = { jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: mode === 'config-drift' ? { sessionUpdate: 'config_option_update', configOptions: [{ category: 'model', currentValue: 'other' }] } : { sessionUpdate: 'current_model_update', currentModelId: 'other' } } }; if (['current-drift', 'config-drift'].includes(mode)) send(drift); diff --git a/beehive/test/normal-conversation.test.ts b/beehive/test/normal-conversation.test.ts index 6dee133522..1d5d35586c 100644 --- a/beehive/test/normal-conversation.test.ts +++ b/beehive/test/normal-conversation.test.ts @@ -25,7 +25,7 @@ for (const kind of ['goose', 'claude', 'codex', 'custom', 'anthropic', 'openai-c const databricks = kind.startsWith('databricks'), oauth = kind === 'databricks-oauth'; const buzz = databricks || ['anthropic', 'openai-compat', 'openrouter'].includes(kind); const provider = databricks ? 'databricks_v2' : kind; - const endpoint = databricks ? 'https://databricks.fixture.invalid' : `https://${kind}.fixture.invalid/v1`; + let endpoint = databricks ? 'https://databricks.fixture.invalid' : `https://${kind}.fixture.invalid/v1`; const keyName = databricks ? 'DATABRICKS_TOKEN' : kind === 'anthropic' ? 'ANTHROPIC_API_KEY' : kind === 'openai-compat' ? 'OPENAI_COMPAT_API_KEY' : 'OPENROUTER_API_KEY'; const title = codex ? 'Codex' : 'Claude'; const model = goose ? 'goose-model-a' : `${kind}-model-a`; @@ -47,6 +47,25 @@ for (const kind of ['goose', 'claude', 'codex', 'custom', 'anthropic', 'openai-c args: ['acp', literal], env: { CUSTOM_PRIVATE: 'owner-only-custom-value' }, installHint: 'Fixture only', installInstructionsUrl: '', contract: 'goose-native' }); writeFileSync(runner, `#!${realpathSync(process.execPath)}\nif (process.argv.length !== 4 || process.argv[2] !== 'acp' || process.argv[3] !== ${JSON.stringify(literal)} || process.env.CUSTOM_PRIVATE !== 'owner-only-custom-value') throw Error('custom argv/env mismatch');\nawait import(${JSON.stringify(pathToFileURL(resolve('test/conversation-harness-fixture.ts')).href)});\n`, { mode: 0o700 }); } + const computeRequests: string[] = []; + let computeFailure = ''; + const compute = kind === 'openai-compat' ? createServer(async (req, res) => { + let bytes = ''; for await (const chunk of req) bytes += chunk; + assert.equal(req.url, '/v1/chat/completions'); assert.equal(req.method, 'POST'); + assert.equal(req.headers.authorization, 'Bearer fixture-openai-compat-private-key'); + const body = JSON.parse(bytes); assert.equal(body.model, model); + assert.equal(body.stream, false); assert.ok(Array.isArray(body.messages)); + computeRequests.push(body.model); + res.setHeader('Content-Type', 'application/json'); + if (computeFailure === 'protocol') { res.end('{}'); return; } + res.end(JSON.stringify({ model: computeFailure === 'model' ? 'wrong' : model, choices: [{ message: { role: 'assistant', content: 'local fixture response' }, finish_reason: 'stop' }] })); + }) : undefined; + if (compute) { + await new Promise(resolve => compute.listen(0, '127.0.0.1', resolve)); + const address = compute.address(); assert.ok(address && typeof address !== 'string'); + endpoint = `http://127.0.0.1:${address.port}/v1`; + t.after(async () => { compute.closeAllConnections(); if (compute.listening) await new Promise(resolve => compute.close(() => resolve())); }); + } const http = createServer(); t.after(async () => { http.closeAllConnections(); if (http.listening) await new Promise(resolve => http.close(() => resolve())); rmSync(dir, { recursive: true, force: true }); }); await new Promise(resolve => http.listen(0, '127.0.0.1', resolve)); @@ -64,7 +83,7 @@ for (const kind of ['goose', 'claude', 'codex', 'custom', 'anthropic', 'openai-c ...(databricks ? [{ prompt: 'Databricks authentication [', answer: oauth ? 'external-oauth' : 'token' }] : []), ...(!oauth ? [{ prompt: `Absolute owner-only local ${converting ? 'ANTHROPIC_API_KEY' : keyName} file`, answer: converting ? initialKey : keyFile }] : []), { prompt: 'Provider HTTPS base URL', answer: converting ? 'https://anthropic.fixture.invalid/v1' : endpoint }, - ...(kind === 'openai-compat' ? [{ prompt: 'OpenAI-compatible wire [', answer: 'responses' }] : []), + ...(kind === 'openai-compat' ? [{ prompt: 'OpenAI-compatible wire [', answer: 'chat' }] : []), { prompt: 'Operator-approved compatible exact Buzz Agent model IDs', answer: model }, ] : []), { prompt: 'Allowed workspace (absolute directory): ', answer: dir }, @@ -367,6 +386,7 @@ for (const kind of ['goose', 'claude', 'codex', 'custom', 'anthropic', 'openai-c assert.deepEqual(readFileSync(join(installation, 'setup.json')), convertedManifest); assert.equal(state().actual.selection.behavior.revision, restartBehavior.revision); assert.equal(replies.length, 4, 'Restart must produce a fresh installed signed reply'); + if (compute) assert.ok(computeRequests.length >= 4, 'source-shaped local HTTP Chat Completions consumer crossed on actual prompts'); assert.match(readFileSync(join(dir, 'received-system-instructions'), 'utf8'), /NORMAL-PROFILE-BOUNDARY/); if (buzz) { assert.equal(readFileSync(join(dir, 'received-system-instructions'), 'utf8'), restartInstructions); @@ -399,6 +419,15 @@ for (const kind of ['goose', 'claude', 'codex', 'custom', 'anthropic', 'openai-c assert.deepEqual(state().actual, actual); } } + if (compute) { + writeFileSync(join(dir, 'mode'), 'ok'); + for (const failure of ['model', 'protocol', 'unreachable']) { + computeFailure = failure; + if (failure === 'unreachable') { compute.closeAllConnections(); await new Promise(resolve => compute.close(() => resolve())); } + assert.notEqual((await request(message('restart', 'journey', agent, state().revision, {}))).body.result, 'accepted'); + assert.deepEqual(state().actual, actual, `${failure} local service preflight preserves actual`); + } + } if (buzz && !oauth) { writeFileSync(join(dir, 'mode'), 'ok'); writeFileSync(keyFile, 'wrong-fixture-credential');