From c73978a090d6e2ad5a0a91c0295076ca15f2313e Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 09:05:30 +0200 Subject: [PATCH] Authenticate plugin-owned OpenCode servers; support secured external servers (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin-owned `opencode serve` processes ran unsecured: any local process that could reach the loopback port had full API access (file content, sessions, lifecycle). Conversely, a user who exported OPENCODE_SERVER_PASSWORD got a server the plugin's header-less client could no longer talk to. Every owned server now gets a per-server random password, passed only via the child environment (never argv) with the username pinned, and recorded in server.json written owner-only (0600). The client sends HTTP Basic auth — matching OpenCode's own convention, username defaulting to "opencode" — on all four paths: normal requests, fresh-connection requests, health checks, and the SSE event stream. On 1.17.15 every route including /global/health and /event requires the header, verified live. External servers configured via OPENCODE_COMPANION_SERVER_URL read the same OPENCODE_SERVER_PASSWORD/OPENCODE_SERVER_USERNAME variables the user already exports for a secured server; an unauthenticated 401 now produces a precise diagnostic instead of a generic health failure. Cancel processes re-derive credentials from the persisted server session or the ambient variables, and teardown authenticates its dispose call (dispose only cleans instance state — the PID termination remains the actual shutdown). The fake fixture now enforces Basic auth on every route whenever a password is present in its environment, so the whole existing suite exercises authenticated paths; new tests cover unauthenticated HTTP/SSE rejection, owner-only server.json, credential redaction in status output, and password-protected external servers (missing, wrong, and correct credentials). Co-Authored-By: Claude Fable 5 --- README.md | 2 + .../opencode/scripts/lib/opencode-server.mjs | 28 ++++- plugins/opencode/scripts/lib/opencode.mjs | 37 +++++-- .../opencode/scripts/lib/server-lifecycle.mjs | 102 +++++++++++++++--- plugins/opencode/scripts/lib/state.mjs | 8 +- tests/fake-opencode-fixture.mjs | 12 +++ tests/opencode-server.test.mjs | 64 ++++++++++- tests/runtime.test.mjs | 50 +++++++++ tests/server-lifecycle.test.mjs | 52 +++++++++ 9 files changed, 327 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2f0f09d..c5be33e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ they already have. - Usage is billed by whichever OpenCode provider/model you select. - **Node.js 18.18 or later** +The plugin starts its own local `opencode serve` on demand, bound to `127.0.0.1` and protected with a per-server random password (HTTP Basic auth), so other local processes cannot reach its API. If you point the plugin at your own server via `OPENCODE_COMPANION_SERVER_URL` and that server is password-protected, also export `OPENCODE_SERVER_PASSWORD` (and `OPENCODE_SERVER_USERNAME` if you customized it). + ## Install Add the marketplace in Claude Code: diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index ab80d0d..fa4dece 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -17,6 +17,20 @@ function trimBaseUrl(url) { return String(url ?? "").replace(/\/+$/, ""); } +// Matches OpenCode's own client convention: HTTP Basic with the password from +// OPENCODE_SERVER_PASSWORD and a username defaulting to "opencode". On a +// password-protected server (v1.17.15) every route requires this header, +// including /global/health and the /event stream. +export function buildBasicAuthHeader(credentials = {}) { + const password = typeof credentials.password === "string" && credentials.password ? credentials.password : null; + if (!password) { + return null; + } + const username = + typeof credentials.username === "string" && credentials.username ? credentials.username : "opencode"; + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +} + function encodePathSegment(value) { return encodeURIComponent(String(value)); } @@ -210,6 +224,7 @@ export class OpencodeServerClient { constructor(baseUrl, options = {}) { this.baseUrl = trimBaseUrl(baseUrl); this.fetch = options.fetch ?? globalThis.fetch; + this.authorization = buildBasicAuthHeader(options); if (!this.baseUrl) { throw new Error("OpenCode server URL is required."); } @@ -223,13 +238,20 @@ export class OpencodeServerClient { return `${this.baseUrl}${suffix}`; } + authHeaders() { + return this.authorization ? { authorization: this.authorization } : {}; + } + async request(method, path, options = {}) { const url = this.url(path); if (options.freshConnection) { return requestWithFreshConnection(new URL(url), { method, path, - headers: options.headers, + headers: { + ...this.authHeaders(), + ...(options.headers ?? {}) + }, body: options.body, signal: options.signal, requestTimeoutMs: options.requestTimeoutMs @@ -238,6 +260,7 @@ export class OpencodeServerClient { const headers = { ...(options.body == null ? {} : { "content-type": "application/json" }), + ...this.authHeaders(), ...(options.headers ?? {}) }; const response = await this.fetch(url, { @@ -333,7 +356,8 @@ export class OpencodeServerClient { const response = await this.fetch(this.url("/event"), { method: "GET", headers: { - accept: "text/event-stream" + accept: "text/event-stream", + ...this.authHeaders() }, signal: options.signal }); diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index e80a337..659c24e 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -4,7 +4,14 @@ import path from "node:path"; import { buildOpenCodeImportDocumentFromClaudeJsonl } from "./claude-session-transfer.mjs"; import { createTempDir, readJsonFile, writeJsonFile } from "./fs.mjs"; import { OpencodeHttpError, OpencodeServerClient } from "./opencode-server.mjs"; -import { SERVER_URL_ENV, ensureServer, loadServerSession } from "./server-lifecycle.mjs"; +import { + SERVER_PASSWORD_ENV, + SERVER_URL_ENV, + SERVER_USERNAME_ENV, + ensureServer, + loadServerSession, + serverSessionCredentials +} from "./server-lifecycle.mjs"; import { binaryAvailable, runCommandChecked } from "./process.mjs"; const TASK_SESSION_PREFIX = "OpenCode Companion Task"; @@ -844,14 +851,32 @@ async function withServer(cwd, fn) { if (!server?.url) { throw new Error("OpenCode server did not become ready."); } - return fn(new OpencodeServerClient(server.url), server); + return fn(new OpencodeServerClient(server.url, serverSessionCredentials(server)), server); } -async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000) { +// A job record only stores the server URL, so a later cancel process must +// re-derive credentials: the workspace's persisted owned-server session first, +// then the ambient external-server variables. +function resolveCredentialsForServerUrl(cwd, serverUrl) { + const normalized = normalizeServerUrlForCompare(serverUrl); + const session = loadServerSession(cwd); + if (session?.url && normalizeServerUrlForCompare(session.url) === normalized) { + return serverSessionCredentials(session); + } + if (normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]) === normalized) { + return { + password: process.env[SERVER_PASSWORD_ENV] || null, + username: process.env[SERVER_USERNAME_ENV] || undefined + }; + } + return { password: null, username: undefined }; +} + +async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000, credentials = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const client = new OpencodeServerClient(serverUrl); + const client = new OpencodeServerClient(serverUrl, credentials); await client.abort(threadId, { signal: controller.signal }); } finally { clearTimeout(timeout); @@ -1002,7 +1027,7 @@ export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) { if (serverUrl) { try { - await abortSessionAtUrl(serverUrl, threadId); + await abortSessionAtUrl(serverUrl, threadId, 1000, resolveCredentialsForServerUrl(cwd, serverUrl)); const serverExternal = normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]); return { attempted: true, @@ -1044,7 +1069,7 @@ export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) { if (!usedServerUrl) { throw new Error("OpenCode server did not become ready."); } - const client = new OpencodeServerClient(usedServerUrl); + const client = new OpencodeServerClient(usedServerUrl, serverSessionCredentials(server)); await client.abort(threadId); return { attempted: true, diff --git a/plugins/opencode/scripts/lib/server-lifecycle.mjs b/plugins/opencode/scripts/lib/server-lifecycle.mjs index c730a3f..e01992b 100644 --- a/plugins/opencode/scripts/lib/server-lifecycle.mjs +++ b/plugins/opencode/scripts/lib/server-lifecycle.mjs @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import net from "node:net"; import os from "node:os"; @@ -11,6 +12,12 @@ import { atomicWriteFile, resolveStateDir } from "./state.mjs"; export const SERVER_URL_ENV = "OPENCODE_COMPANION_SERVER_URL"; export const PID_FILE_ENV = "OPENCODE_COMPANION_SERVER_PID_FILE"; export const LOG_FILE_ENV = "OPENCODE_COMPANION_SERVER_LOG_FILE"; +// OpenCode's own server-auth variables (not plugin-specific): when the +// password is set, `opencode serve` requires HTTP Basic auth on every route. +export const SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD"; +export const SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME"; + +const OWNED_SERVER_USERNAME = "opencode"; const SERVER_STATE_FILE = "server.json"; const SERVER_LOCK_DIR = "server.lock"; @@ -57,7 +64,19 @@ export function loadServerSession(cwd) { export function saveServerSession(cwd, session) { const stateDir = resolveStateDir(cwd); fs.mkdirSync(stateDir, { recursive: true }); - atomicWriteFile(resolveServerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`); + // server.json carries the owned server's password; keep it owner-only. + atomicWriteFile(resolveServerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 }); +} + +export function serverSessionCredentials(session) { + return { + password: typeof session?.password === "string" && session.password ? session.password : null, + username: typeof session?.username === "string" && session.username ? session.username : undefined + }; +} + +function generateServerPassword() { + return crypto.randomBytes(24).toString("base64url"); } export function clearServerSession(cwd) { @@ -77,14 +96,14 @@ async function withTimeout(fn, timeoutMs) { } } -export async function isServerHealthy(url, timeoutMs = 500) { +export async function isServerHealthy(url, timeoutMs = 500, credentials = {}) { const normalized = normalizeUrl(url); if (!normalized) { return false; } try { - const client = new OpencodeServerClient(normalized); + const client = new OpencodeServerClient(normalized, credentials); await withTimeout((signal) => client.health({ signal }), timeoutMs); return true; } catch { @@ -92,10 +111,10 @@ export async function isServerHealthy(url, timeoutMs = 500) { } } -async function waitForServerHealth(url, timeoutMs = 10000) { +async function waitForServerHealth(url, timeoutMs = 10000, credentials = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (await isServerHealthy(url, 500)) { + if (await isServerHealthy(url, 500, credentials)) { return true; } await sleep(100); @@ -189,7 +208,7 @@ function releaseServerLock(lockDir, token) { async function loadHealthyServerSession(cwd, healthTimeoutMs) { const existing = loadServerSession(cwd); - if (existing?.url && (await isServerHealthy(existing.url, healthTimeoutMs))) { + if (existing?.url && (await isServerHealthy(existing.url, healthTimeoutMs, serverSessionCredentials(existing)))) { return existing; } return null; @@ -315,11 +334,17 @@ function findOpenPort(hostname = DEFAULT_HOSTNAME) { }); } -export function spawnServerProcess({ cwd, port, hostname = DEFAULT_HOSTNAME, pidFile, logFile, env = process.env }) { +export function spawnServerProcess({ cwd, port, hostname = DEFAULT_HOSTNAME, pidFile, logFile, env = process.env, password = null }) { + // Pass the generated password via the child environment only — never argv, + // which any local user could read from the process list. Pin the username so + // an ambient OPENCODE_SERVER_USERNAME cannot desynchronize server and client. + const childEnv = password + ? { ...env, [SERVER_PASSWORD_ENV]: password, [SERVER_USERNAME_ENV]: OWNED_SERVER_USERNAME } + : env; const logFd = fs.openSync(logFile, "a"); const child = spawn("opencode", ["serve", "--hostname", hostname, "--port", String(port)], { cwd, - env, + env: childEnv, detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true, @@ -360,15 +385,35 @@ function killServerPid(pid, killProcess = null) { } export async function ensureServer(cwd, options = {}) { - const overrideUrl = normalizeUrl(options.serverUrl ?? options.env?.[SERVER_URL_ENV] ?? process.env[SERVER_URL_ENV]); + const envSource = options.env ?? process.env; + const overrideUrl = normalizeUrl(options.serverUrl ?? envSource[SERVER_URL_ENV] ?? process.env[SERVER_URL_ENV]); if (overrideUrl) { - if (!(await isServerHealthy(overrideUrl, options.healthTimeoutMs ?? 1000))) { + // A password-protected external server uses the same variables OpenCode's + // own tooling reads, so a user who secured their server has already + // exported them. + const credentials = { + password: envSource[SERVER_PASSWORD_ENV] || null, + username: envSource[SERVER_USERNAME_ENV] || undefined + }; + try { + const client = new OpencodeServerClient(overrideUrl, credentials); + await withTimeout((signal) => client.health({ signal }), options.healthTimeoutMs ?? 1000); + } catch (error) { + if (error?.status === 401) { + throw new Error( + credentials.password + ? `Configured OpenCode server rejected the provided credentials (HTTP 401): ${overrideUrl}. Check ${SERVER_PASSWORD_ENV} and ${SERVER_USERNAME_ENV}.` + : `Configured OpenCode server requires authentication: ${overrideUrl}. Export ${SERVER_PASSWORD_ENV} (and ${SERVER_USERNAME_ENV} unless it is "opencode") so the plugin can connect.` + ); + } throw new Error(`Configured OpenCode server is not healthy: ${overrideUrl}`); } return { url: overrideUrl, pid: null, - external: true + external: true, + password: credentials.password, + username: credentials.username ?? null }; } @@ -387,7 +432,7 @@ export async function ensureServer(cwd, options = {}) { const staleExisting = loadServerSession(cwd); if (staleExisting) { - const { url, pidFile, logFile, sessionDir, pid, external } = staleExisting; + const { url, pidFile, logFile, sessionDir, pid, external, password, username } = staleExisting; // The server lock is already held here; intentionally omit cwd so teardown // uses the unlocked path even if the persisted session schema grows. await teardownServerSession({ @@ -397,6 +442,8 @@ export async function ensureServer(cwd, options = {}) { sessionDir, pid, external, + password, + username, killProcess: options.killProcess ?? null }); clearServerSession(cwd); @@ -408,16 +455,23 @@ export async function ensureServer(cwd, options = {}) { const sessionDir = createServerSessionDir(); const pidFile = path.join(sessionDir, "opencode-server.pid"); const logFile = path.join(sessionDir, "opencode-server.log"); + // Every plugin-owned server gets its own random password so no other local + // process can reach the API on the loopback port (issue #27). + const password = generateServerPassword(); const child = spawnServerProcess({ cwd, hostname, port, pidFile, logFile, - env: options.env ?? process.env + env: options.env ?? process.env, + password }); - const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000); + const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000, { + password, + username: OWNED_SERVER_USERNAME + }); if (!ready) { await teardownServerSession({ url, @@ -425,6 +479,8 @@ export async function ensureServer(cwd, options = {}) { logFile, sessionDir, pid: child.pid ?? null, + password, + username: OWNED_SERVER_USERNAME, killProcess: options.killProcess ?? null }); return null; @@ -436,7 +492,9 @@ export async function ensureServer(cwd, options = {}) { pidFile, logFile, sessionDir, - external: false + external: false, + password, + username: OWNED_SERVER_USERNAME }; const leasedSession = addServerLease(session, options); saveServerSession(cwd, leasedSession); @@ -453,14 +511,18 @@ async function teardownServerSessionUnlocked({ sessionDir = null, pid = null, external = false, + password = null, + username = null, killProcess = null } = {}) { if (url && !external) { try { - const client = new OpencodeServerClient(url); + const client = new OpencodeServerClient(url, { password, username: username ?? undefined }); + // Dispose only cleans up instance state; on 1.17.15 it does NOT stop the + // HTTP listener, so the PID termination below is the actual shutdown. await withTimeout((signal) => client.dispose({ signal }), 1000); } catch { - // Fall back to process termination below. + // Instance cleanup is best-effort; process termination below still runs. } } @@ -504,6 +566,8 @@ export async function teardownServerSession({ sessionDir = null, pid = null, external = false, + password = null, + username = null, killProcess = null } = {}) { if (!cwd) { @@ -514,6 +578,8 @@ export async function teardownServerSession({ sessionDir, pid, external, + password, + username, killProcess }); } @@ -547,6 +613,8 @@ export async function teardownServerSession({ sessionDir: session?.sessionDir ?? sessionDir, pid: session?.pid ?? pid, external: Boolean(session?.external ?? external), + password: session?.password ?? password, + username: session?.username ?? username, killProcess }; const result = await teardownServerSessionUnlocked(teardownTarget); diff --git a/plugins/opencode/scripts/lib/state.mjs b/plugins/opencode/scripts/lib/state.mjs index e7484cc..b0bf492 100644 --- a/plugins/opencode/scripts/lib/state.mjs +++ b/plugins/opencode/scripts/lib/state.mjs @@ -282,10 +282,14 @@ function removeFileIfExists(filePath) { } } -export function atomicWriteFile(filePath, contents) { +export function atomicWriteFile(filePath, contents, options = {}) { const tempFile = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; try { - fs.writeFileSync(tempFile, contents, "utf8"); + fs.writeFileSync( + tempFile, + contents, + options.mode == null ? "utf8" : { encoding: "utf8", mode: options.mode } + ); fs.renameSync(tempFile, filePath); } catch (error) { try { diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index d4d8af6..38294bf 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -340,8 +340,20 @@ try { } catch {} const bootStartedAt = Date.now(); const healthDelayMs = Math.max(0, Number(process.env.FAKE_OPENCODE_HEALTH_DELAY_MS || 0)); +// Mirror the real server's auth: when OPENCODE_SERVER_PASSWORD is set, every +// route (including /global/health and /event) requires HTTP Basic auth. +const serverPassword = process.env.OPENCODE_SERVER_PASSWORD || ""; +const serverUsername = process.env.OPENCODE_SERVER_USERNAME || "opencode"; +const expectedAuthorization = serverPassword + ? "Basic " + Buffer.from(serverUsername + ":" + serverPassword).toString("base64") + : null; const server = http.createServer(async (req, res) => { + if (expectedAuthorization && req.headers.authorization !== expectedAuthorization) { + res.writeHead(401, { "www-authenticate": 'Basic realm="opencode"' }); + res.end(); + return; + } const url = new URL(req.url, "http://127.0.0.1"); if (req.method === "GET" && url.pathname === "/global/health") { diff --git a/tests/opencode-server.test.mjs b/tests/opencode-server.test.mjs index 1da8636..29693d6 100644 --- a/tests/opencode-server.test.mjs +++ b/tests/opencode-server.test.mjs @@ -1,14 +1,76 @@ import http from "node:http"; +import net from "node:net"; import { EventEmitter } from "node:events"; import test from "node:test"; import assert from "node:assert/strict"; -import { OpencodeServerClient } from "../plugins/opencode/scripts/lib/opencode-server.mjs"; +import { buildBasicAuthHeader, OpencodeServerClient } from "../plugins/opencode/scripts/lib/opencode-server.mjs"; + +async function canListenLocalhost() { + return new Promise((resolve) => { + const server = net.createServer(); + let settled = false; + function finish(value) { + if (settled) { + return; + } + settled = true; + resolve(value); + } + server.once("error", () => finish(false)); + server.listen(0, "127.0.0.1", () => { + server.close(() => finish(true)); + }); + }); +} + +const LOCAL_LISTEN_AVAILABLE = await canListenLocalhost(); +const LOCAL_LISTEN_SKIP = LOCAL_LISTEN_AVAILABLE ? false : "local 127.0.0.1 listen is unavailable in this sandbox"; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +test("buildBasicAuthHeader follows OpenCode's basic-auth convention", () => { + assert.equal(buildBasicAuthHeader({ password: "pw" }), `Basic ${Buffer.from("opencode:pw").toString("base64")}`); + assert.equal( + buildBasicAuthHeader({ username: "custom", password: "pw" }), + `Basic ${Buffer.from("custom:pw").toString("base64")}` + ); + assert.equal(buildBasicAuthHeader({}), null); + assert.equal(buildBasicAuthHeader({ password: "" }), null); +}); + +test("client sends Basic auth on requests, fresh connections, and the event stream", { skip: LOCAL_LISTEN_SKIP }, async () => { + const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`; + const seen = []; + const server = http.createServer((req, res) => { + seen.push({ url: req.url, authorization: req.headers.authorization }); + if (req.url === "/event") { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end("data: {\"type\":\"noop\"}\n\n"); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const client = new OpencodeServerClient(`http://127.0.0.1:${server.address().port}`, { password: "secret" }); + + try { + await client.health(); + await client.listMessages("ses_auth", { freshConnection: true, requestTimeoutMs: 2000 }); + await client.subscribeEvents(() => {}); + + assert.equal(seen.length, 3); + for (const request of seen) { + assert.equal(request.authorization, authorization, `missing auth on ${request.url}`); + } + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + function createRequestStub(onRequest) { const req = new EventEmitter(); req.write = () => {}; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 12fc5ad..d7a6f4d 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -691,6 +691,56 @@ test("write task denies gated permission asks and keeps stock agent guards (issu } }); +test("plugin-owned server requires auth for HTTP and SSE and never leaks the password (issue #27)", { skip: LOCAL_LISTEN_SKIP }, async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + const pluginDataDir = makeTempDir("opencode-plugin-data-"); + const env = buildTestEnv(binDir, { CLAUDE_PLUGIN_DATA: pluginDataDir }); + + try { + // The task completing at all proves the authenticated health, HTTP, and + // SSE paths work: the fixture 401s every unauthenticated route once the + // lifecycle passes it a generated password. + const result = run("node", [SCRIPT, "task", "--json", "auth roundtrip"], { cwd: repo, env }); + assert.equal(result.status, 0, result.stderr); + + await withProcessEnv({ CLAUDE_PLUGIN_DATA: pluginDataDir }, async () => { + const session = loadServerSession(repo); + assert.ok(session?.url, "server session persisted"); + assert.ok( + typeof session.password === "string" && session.password.length >= 24, + "owned server records a generated password" + ); + assert.equal(session.username, "opencode"); + + if (process.platform !== "win32") { + const stateFile = path.join(resolveStateDir(repo), "server.json"); + assert.equal(fs.statSync(stateFile).mode & 0o777, 0o600, "server.json must be owner-only"); + } + + const unauthorizedHttp = await fetch(`${session.url}/session`); + assert.equal(unauthorizedHttp.status, 401); + await unauthorizedHttp.text().catch(() => {}); + const unauthorizedSse = await fetch(`${session.url}/event`, { headers: { accept: "text/event-stream" } }); + assert.equal(unauthorizedSse.status, 401); + await unauthorizedSse.body?.cancel().catch(() => {}); + + const authorization = `Basic ${Buffer.from(`${session.username}:${session.password}`).toString("base64")}`; + const authorized = await fetch(`${session.url}/session`, { headers: { authorization } }); + assert.equal(authorized.status, 200); + await authorized.text().catch(() => {}); + + const status = run("node", [SCRIPT, "status", "--json"], { cwd: repo, env }); + assert.equal(status.status, 0, status.stderr); + assert.ok(!status.stdout.includes(session.password), "status output must not contain the server password"); + }); + } finally { + cleanupServer(repo, env); + } +}); + test("task forwards spark model alias and effort as OpenCode variant", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); diff --git a/tests/server-lifecycle.test.mjs b/tests/server-lifecycle.test.mjs index 41e0e42..1d77341 100644 --- a/tests/server-lifecycle.test.mjs +++ b/tests/server-lifecycle.test.mjs @@ -57,6 +57,58 @@ test( } ); +test( + "ensureServer handles password-protected external servers via OpenCode's auth variables (issue #27)", + { skip: LOCAL_LISTEN_AVAILABLE ? false : "local 127.0.0.1 listen is unavailable in this sandbox" }, + async () => { + const password = "external-secret"; + const expectedAuthorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`; + let authorizedRequests = 0; + const server = http.createServer((req, res) => { + if (req.headers.authorization !== expectedAuthorization) { + res.writeHead(401, { "www-authenticate": 'Basic realm="opencode"' }); + res.end(); + return; + } + authorizedRequests += 1; + if (req.method === "GET" && req.url === "/global/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${server.address().port}`; + const workspace = makeTempDir(); + + try { + await assert.rejects( + () => ensureServer(workspace, { env: { OPENCODE_COMPANION_SERVER_URL: url } }), + /requires authentication.*OPENCODE_SERVER_PASSWORD/s + ); + await assert.rejects( + () => + ensureServer(workspace, { + env: { OPENCODE_COMPANION_SERVER_URL: url, OPENCODE_SERVER_PASSWORD: "wrong" } + }), + /rejected the provided credentials/ + ); + + const result = await ensureServer(workspace, { + env: { OPENCODE_COMPANION_SERVER_URL: url, OPENCODE_SERVER_PASSWORD: password } + }); + assert.equal(result.external, true); + assert.equal(result.password, password); + assert.ok(authorizedRequests >= 1, "the health check must send Basic auth"); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + } +); + test("ensureServer keeps a single lease for repeated calls from the same process", async () => { const url = "http://127.0.0.1:1"; const workspace = makeTempDir();