diff --git a/bin/opencode-acp-adapter.mjs b/bin/opencode-acp-adapter.mjs index 159388ed4..162d99bfc 100755 --- a/bin/opencode-acp-adapter.mjs +++ b/bin/opencode-acp-adapter.mjs @@ -26,12 +26,22 @@ // shared server OpenChamber attaches to) // OPENCODE_SERVER_PASSWORD when set, sent as HTTP basic auth // (user "opencode"), matching serve's auth +// OPENCODE_AUTH_FILE JSON state file carrying the standing +// server's "password" field, read at +// runtime when the env var is unset +// (default ~/.local/state/opencode/ +// service-prod.json — the server's own +// state file, #4615). The value is never +// logged or baked into tracked config. // OPENCODE_ACP_ADAPTER_DEBUG=1 -> diagnostics on stderr (stdout is // the protocol channel and stays clean) // // The adapter spawns no server of its own; if the shared serve is // unreachable, session/new fails with the connection error. +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { createInterface } from 'node:readline'; import process from 'node:process'; @@ -60,9 +70,61 @@ function parseServerUrl(argv) { const serverBase = parseServerUrl(process.argv.slice(2)); +// Resolve the standing server's password (#4615), mirroring the fleet +// pattern (pm-stack dispatch's OpenCodeClient, bin/openchamber-start.sh): +// the password lives in the server's own state file and is read at +// runtime — never baked into tracked config or the Nix store, never +// logged. Precedence: the OPENCODE_SERVER_PASSWORD env var, then the +// JSON state file named by OPENCODE_AUTH_FILE (default +// ~/.local/state/opencode/service-prod.json, field "password"). +// Resolved lazily on first use and cached for the process lifetime +// (acpx launches a fresh adapter per gateway session). +// +// A missing state file just means no auth (a passwordless dev serve). +// A state file that exists but is malformed, or carries no password, +// warns once on stderr — the file path and the problem, never any +// credential value — and requests go out unauthenticated, so the +// serve's own 401 names the real problem instead of a silent hang. +let resolvedPassword; +let credentialResolved = false; +function serverPassword() { + if (credentialResolved) return resolvedPassword; + credentialResolved = true; + const fromEnv = process.env.OPENCODE_SERVER_PASSWORD; + if (fromEnv) { + resolvedPassword = fromEnv; + return resolvedPassword; + } + const authFile = + process.env.OPENCODE_AUTH_FILE || + join(homedir(), '.local', 'state', 'opencode', 'service-prod.json'); + let raw; + try { + raw = readFileSync(authFile, 'utf8'); + } catch { + debug(`no readable credential state file at ${authFile}; requests go unauthenticated`); + return undefined; + } + try { + const parsed = JSON.parse(raw); + if (typeof parsed?.password === 'string' && parsed.password) { + resolvedPassword = parsed.password; + return resolvedPassword; + } + process.stderr.write( + `[${AGENT_NAME}] credential state file ${authFile} carries no password; requests go unauthenticated\n`, + ); + } catch { + process.stderr.write( + `[${AGENT_NAME}] credential state file ${authFile} is not valid JSON; requests go unauthenticated\n`, + ); + } + return undefined; +} + function authHeaders(extra = {}) { const headers = { ...extra }; - const password = process.env.OPENCODE_SERVER_PASSWORD; + const password = serverPassword(); if (password) { headers.Authorization = `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`; } diff --git a/bin/opencode-acp-adapter.test.mjs b/bin/opencode-acp-adapter.test.mjs index 746572f56..bf917f40b 100644 --- a/bin/opencode-acp-adapter.test.mjs +++ b/bin/opencode-acp-adapter.test.mjs @@ -10,7 +10,9 @@ // (session/cancel -> POST abort, prompt resolves "cancelled"). import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import http from 'node:http'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -39,7 +41,13 @@ function startStubServe(script) { }); req.on('end', () => { const body = raw ? JSON.parse(raw) : null; - calls.push({ method: req.method, pathname: url.pathname, query: url.searchParams, body }); + calls.push({ + method: req.method, + pathname: url.pathname, + query: url.searchParams, + body, + authorization: req.headers.authorization ?? null, + }); if (req.method === 'GET' && url.pathname === '/event') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); res.flushHeaders(); @@ -86,12 +94,26 @@ function startStubServe(script) { // --------------------------------------------------------------------------- // Scripted ACP client over the adapter's stdio. // --------------------------------------------------------------------------- -function startAdapter(serverPort) { - const child = spawn(process.execPath, [ - path.join(binDir, 'opencode-acp-adapter.mjs'), - '--server', - `http://127.0.0.1:${serverPort}`, - ]); +function startAdapter(serverPort, env = {}) { + const child = spawn( + process.execPath, + [path.join(binDir, 'opencode-acp-adapter.mjs'), '--server', `http://127.0.0.1:${serverPort}`], + { + env: { + ...process.env, + // Hermetic credential resolution: never the developer's real + // env password or real state file unless a test opts in. + OPENCODE_SERVER_PASSWORD: '', + OPENCODE_AUTH_FILE: path.join(binDir, 'nonexistent-auth-fixture.json'), + ...env, + }, + }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); const pending = new Map(); const notifications = []; const clientRequests = []; @@ -142,12 +164,12 @@ function startAdapter(serverPort) { } throw new Error(`timed out waiting for ${what}`); }; - return { child, request, notify, respond, notifications, clientRequests, waitFor }; + return { child, request, notify, respond, notifications, clientRequests, waitFor, stderr: () => stderr }; } -async function setup(t, script) { +async function setup(t, script, env) { const stub = await startStubServe(script); - const acp = startAdapter(stub.port); + const acp = startAdapter(stub.port, env); t.after(() => { acp.child.kill('SIGKILL'); stub.server.close(); @@ -260,3 +282,59 @@ test('session/cancel aborts on the serve and the prompt resolves cancelled', asy 'stub serve saw POST abort', ); }); + +// --------------------------------------------------------------------------- +// #4615: the configured gateway launch path (acpx spawns the adapter +// with no OPENCODE_SERVER_PASSWORD) must still authenticate, by +// runtime-loading the standing server's own state file. Fixtures use +// synthetic passwords only. +// --------------------------------------------------------------------------- + +const basicFor = (password) => `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`; + +test('gateway launch path: adapter loads the credential from the default state file under HOME', async (t) => { + const home = mkdtempSync(path.join(tmpdir(), 'acp-adapter-home-')); + const stateDir = path.join(home, '.local', 'state', 'opencode'); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(path.join(stateDir, 'service-prod.json'), JSON.stringify({ password: 'synthetic-gateway-password' })); + const { stub, acp } = await setup(t, textEvents(), { + HOME: home, + OPENCODE_AUTH_FILE: '', + }); + await acp.request('session/new', { cwd: '/home/user/code', mcpServers: [] }); + const create = stub.calls.find((c) => c.method === 'POST' && c.pathname === '/session'); + assert.equal(create.authorization, basicFor('synthetic-gateway-password')); + assert.ok(!acp.stderr().includes('synthetic-gateway-password'), 'credential never reaches stderr'); +}); + +test('OPENCODE_AUTH_FILE fixture authenticates; the env password wins over the file', async (t) => { + const dir = mkdtempSync(path.join(tmpdir(), 'acp-adapter-auth-')); + const authFile = path.join(dir, 'service-prod.json'); + writeFileSync(authFile, JSON.stringify({ password: 'synthetic-file-password' })); + + const fromFile = await setup(t, textEvents(), { OPENCODE_AUTH_FILE: authFile }); + await fromFile.acp.request('session/new', { cwd: '/home/user/code', mcpServers: [] }); + const fileCreate = fromFile.stub.calls.find((c) => c.method === 'POST' && c.pathname === '/session'); + assert.equal(fileCreate.authorization, basicFor('synthetic-file-password')); + + const fromEnv = await setup(t, textEvents(), { + OPENCODE_AUTH_FILE: authFile, + OPENCODE_SERVER_PASSWORD: 'synthetic-env-password', + }); + await fromEnv.acp.request('session/new', { cwd: '/home/user/code', mcpServers: [] }); + const envCreate = fromEnv.stub.calls.find((c) => c.method === 'POST' && c.pathname === '/session'); + assert.equal(envCreate.authorization, basicFor('synthetic-env-password')); +}); + +test('malformed credential state file warns on stderr and sends no auth header', async (t) => { + const dir = mkdtempSync(path.join(tmpdir(), 'acp-adapter-bad-')); + const authFile = path.join(dir, 'service-prod.json'); + writeFileSync(authFile, 'this is not json'); + const { stub, acp } = await setup(t, textEvents(), { OPENCODE_AUTH_FILE: authFile }); + const result = await acp.request('session/new', { cwd: '/home/user/code', mcpServers: [] }); + assert.equal(result.sessionId, SESSION_ID); + const create = stub.calls.find((c) => c.method === 'POST' && c.pathname === '/session'); + assert.equal(create.authorization, null); + await acp.waitFor(() => acp.stderr().includes('not valid JSON') || null, 'stderr warning'); + assert.ok(acp.stderr().includes(authFile), 'warning names the state file'); +});