Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion bin/opencode-acp-adapter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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')}`;
}
Expand Down
98 changes: 88 additions & 10 deletions bin/opencode-acp-adapter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
});
Loading