From 060c918faa6df7d6d04cf3d4c65a0780af720442 Mon Sep 17 00:00:00 2001 From: Rahul Sharma Date: Sun, 18 Oct 2026 19:28:35 +0530 Subject: [PATCH 1/2] feat: support bearer tokens and OpenChamber sign-in Add OPENCODE_SERVER_TOKEN and OPENCODE_OPENCHAMBER_PASSWORD/URL so opencode-mcp can reach OpenCode behind OpenChamber, which rejects Basic auth. The client token is cached in memory and renewed once on 401. Closes #33 --- CHANGELOG.md | 7 ++ README.md | 2 + docs/configuration.md | 17 ++++ src/auth.ts | 123 +++++++++++++++++++++++++ src/client.ts | 36 ++++---- src/index.ts | 11 ++- src/server-manager.ts | 7 +- tests/openchamber-auth.test.ts | 161 +++++++++++++++++++++++++++++++++ tests/server-manager.test.ts | 8 ++ 9 files changed, 350 insertions(+), 22 deletions(-) create mode 100644 src/auth.ts create mode 100644 tests/openchamber-auth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8020c61..71ab363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Bearer-token authentication with `OPENCODE_SERVER_TOKEN`. +- OpenChamber sign-in with `OPENCODE_OPENCHAMBER_PASSWORD` (and optional `OPENCODE_OPENCHAMBER_URL`): exchanges the UI password for a client token, keeps it in memory, and renews it once on 401. Applies to API requests, event streams and the health probe. + ## [3.0.0] - 2026-09-16 ### Breaking changes diff --git a/README.md b/README.md index f974ba9..cfad349 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ All settings are optional; an OpenCode server must already be running by default |---|---| | `OPENCODE_BASE_URL` | Server endpoint; defaults to `http://127.0.0.1:4096` | | `OPENCODE_SERVER_USERNAME`, `OPENCODE_SERVER_PASSWORD` | Optional server HTTP authentication | +| `OPENCODE_SERVER_TOKEN` | Optional bearer token, e.g. an OpenChamber client token | +| `OPENCODE_OPENCHAMBER_PASSWORD`, `OPENCODE_OPENCHAMBER_URL` | Sign in to an [OpenChamber](https://github.com/openchamber/openchamber)-fronted server | | `OPENCODE_AUTO_SERVE` | Set to `true` to opt in to launching a local server | | `OPENCODE_DEFAULT_PROVIDER`, `OPENCODE_DEFAULT_MODEL` | Default prompt provider/model pair | | `OPENCODE_TOOL_PROFILE` | `full` (default) or a smaller `essential` tool set | diff --git a/docs/configuration.md b/docs/configuration.md index a271a91..a56998f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,6 +9,9 @@ An OpenCode server must already be running unless automatic startup is explicitl | `OPENCODE_BASE_URL` | `http://127.0.0.1:4096` | OpenCode HTTP endpoint | | `OPENCODE_SERVER_USERNAME` | `opencode` | Username when HTTP authentication is enabled | | `OPENCODE_SERVER_PASSWORD` | unset | HTTP password; set matching credentials on OpenCode and MCP | +| `OPENCODE_SERVER_TOKEN` | unset | Bearer token sent as `Authorization: Bearer `; replaces Basic auth | +| `OPENCODE_OPENCHAMBER_PASSWORD` | unset | OpenChamber UI password, exchanged for a client token; see below | +| `OPENCODE_OPENCHAMBER_URL` | origin of `OPENCODE_BASE_URL` | OpenChamber origin that serves `/auth/session` | | `OPENCODE_AUTO_SERVE` | `false` | Set exactly `true` to allow a local child server to start | | `OPENCODE_DEFAULT_PROVIDER` | unset | Default prompt provider ID | | `OPENCODE_DEFAULT_MODEL` | unset | Default prompt model ID; configure both defaults together | @@ -21,6 +24,20 @@ The `essential` profile keeps the common delegation, observation, and required-i Job records hold local operational metadata and results, independent of OpenCode's session storage. The default root is `$XDG_STATE_HOME/opencode-mcp/tasks` (falling back to `~/.local/state/opencode-mcp/tasks`); Windows uses `%LOCALAPPDATA%/opencode-mcp/tasks`. Records are partitioned by server and caller scope. Records expire 24 hours after creation; expiry removes the handle and does not abort the OpenCode session. Keep the task store private to your user account and persist it if the MCP process is ephemeral. See [architecture](architecture.md) for recovery behavior and retention. +## OpenChamber + +[OpenChamber](https://github.com/openchamber/openchamber) proxies OpenCode under `/api` and rejects Basic auth. Point `OPENCODE_BASE_URL` at the proxy and choose one credential: + +- `OPENCODE_OPENCHAMBER_PASSWORD`: on first use, opencode-mcp posts the UI password to `/auth/session` and receives a trusted-device client token (7 days by default). The token is kept in memory only. When a request is rejected with 401, it signs in once more and resends that request; the rejected request never reached OpenCode, so writes are not duplicated. Repeated sign-ins reuse one client entry per host in OpenChamber's client list. +- `OPENCODE_SERVER_TOKEN`: a client token you minted yourself. It is not renewed; replace it when it expires or is revoked. + +```bash +OPENCODE_BASE_URL=https://openchamber.example.com/api +OPENCODE_OPENCHAMBER_PASSWORD=... # keep in your MCP client's secret store +``` + +OpenChamber rate-limits sign-ins (10 attempts per 5 minutes by default, then a lockout); a wrong password reports an error instead of retrying. Treat the password and tokens as secrets: they grant the same access as the OpenChamber UI. + ## Project Scope For project-scoped tools, `directory` must be an absolute path on the OpenCode server. POSIX, Windows drive, and UNC paths are preserved on every client OS; relative paths and NUL/CR/LF are rejected. OpenCode resolves existence, access, and symlinks. Global authentication tools omit `directory`. diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..10bd135 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,123 @@ +import { hostname } from "node:os"; + +export interface AuthOptions { + /** HTTP Basic credentials for an OpenCode server started with OPENCODE_SERVER_PASSWORD. */ + username?: string; + password?: string; + /** Static bearer token, sent as `Authorization: Bearer `. */ + token?: string; + /** OpenChamber UI password, exchanged for a bearer client token. */ + openchamberPassword?: string; + /** OpenChamber origin hosting `/auth/session`; defaults to the base URL's origin. */ + openchamberUrl?: string; +} + +const LOGIN_TIMEOUT_MS = 15_000; + +/** + * Resolves the `Authorization` header for OpenCode requests. + * + * Precedence: OpenChamber login, then a static bearer token, then Basic auth. + * An OpenChamber client token is obtained lazily, cached in memory only, and + * renewed once when a request is rejected with 401. + */ +export class AuthProvider { + private token?: string; + private login?: Promise; + + constructor(private readonly options: AuthOptions, private readonly baseUrl: string) { + this.token = options.openchamberPassword ? undefined : options.token || undefined; + } + + /** True when a rejected token can be renewed without user action. */ + get canRefresh(): boolean { + return Boolean(this.options.openchamberPassword); + } + + async getHeader(): Promise { + if (this.options.openchamberPassword) { + return `Bearer ${this.token ?? await this.signIn()}`; + } + if (this.token) return `Bearer ${this.token}`; + const { username, password } = this.options; + if (!password) return undefined; + return "Basic " + Buffer.from(`${username ?? "opencode"}:${password}`).toString("base64"); + } + + /** + * Discard the rejected header and sign in again. Concurrent callers that + * observed the same rejected header share one login. + */ + async refresh(rejected: string | undefined): Promise { + if (!this.canRefresh) return undefined; + if (rejected && rejected !== `Bearer ${this.token}` && this.token) return `Bearer ${this.token}`; + this.token = undefined; + return `Bearer ${await this.signIn()}`; + } + + private signIn(): Promise { + this.login ??= this.requestClientToken().then( + (token) => { this.token = token; return token; }, + ).finally(() => { this.login = undefined; }); + return this.login; + } + + private async requestClientToken(): Promise { + const origin = new URL(this.options.openchamberUrl || this.baseUrl).origin; + const host = hostname(); + let res: Response; + try { + res = await fetch(`${origin}/auth/session`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + password: this.options.openchamberPassword, + issueClientToken: true, + trustDevice: true, + clientLabel: `opencode-mcp (${host})`, + clientKind: "desktop", + dedupeKey: `opencode-mcp:${host}`, + }), + signal: AbortSignal.timeout(LOGIN_TIMEOUT_MS), + }); + } catch (error) { + throw new Error(`OpenChamber login to ${origin} failed: ${error instanceof Error ? error.message : String(error)}`); + } + if (res.status === 429) { + const retryAfter = res.headers.get("retry-after"); + throw new Error(`OpenChamber login to ${origin} is rate limited${retryAfter ? `; retry after ${retryAfter}s` : ""}`); + } + if (res.status === 401) { + throw new Error(`OpenChamber login to ${origin} was rejected; check OPENCODE_OPENCHAMBER_PASSWORD`); + } + if (!res.ok) throw new Error(`OpenChamber login to ${origin} failed (${res.status})`); + const body = await res.json().catch(() => undefined) as { clientToken?: unknown } | undefined; + if (typeof body?.clientToken !== "string" || !body.clientToken) { + throw new Error(`OpenChamber at ${origin} did not issue a client token; upgrade OpenChamber or set OPENCODE_SERVER_TOKEN`); + } + return body.clientToken; + } + + /** + * `fetch` that applies the current header and, for renewable credentials, + * retries once after a 401. The rejected request never reached OpenCode, so + * resending it cannot duplicate a write. + */ + async fetch(request: Request): Promise { + const header = await this.getHeader(); + const retry = this.canRefresh ? request.clone() : undefined; + const response = await fetch(withAuthorization(request, header)); + if (response.status !== 401 || !retry) return response; + const renewed = await this.refresh(header); + if (!renewed) return response; + await response.body?.cancel().catch(() => {}); + return fetch(withAuthorization(retry, renewed)); + } +} + +function withAuthorization(request: Request, header: string | undefined): Request { + if (!header) return request; + const headers = new Headers(request.headers); + headers.set("Authorization", header); + return new Request(request, { headers }); +} diff --git a/src/client.ts b/src/client.ts index 29104e2..1542c81 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import { createOpencodeClient, OpencodeClient as NativeClient } from "@opencode-ai/sdk"; import { normalizeDirectory } from "./helpers.js"; import { ensureServer, isServerRunning } from "./server-manager.js"; +import { AuthProvider } from "./auth.js"; import { createRequestContext, abortableSleep, throwIfAborted, withAbort, type RequestOptions } from "./async.js"; export type { RequestOptions } from "./async.js"; @@ -8,6 +9,12 @@ export interface OpenCodeClientOptions { baseUrl: string; username?: string; password?: string; + /** Static bearer token (`OPENCODE_SERVER_TOKEN`). */ + token?: string; + /** OpenChamber UI password (`OPENCODE_OPENCHAMBER_PASSWORD`). */ + openchamberPassword?: string; + /** OpenChamber origin (`OPENCODE_OPENCHAMBER_URL`). */ + openchamberUrl?: string; autoServe?: boolean; } @@ -66,12 +73,6 @@ function isConnectionError(err: Error): boolean { ); } -function buildBasicAuthHeader(username?: string, password?: string): string | undefined { - if (!password) return undefined; - const user = username ?? "opencode"; - return "Basic " + Buffer.from(`${user}:${password}`).toString("base64"); -} - export class OpenCodeClient { public api: NativeClient; private baseUrl: string; @@ -79,12 +80,14 @@ export class OpenCodeClient { private reconnectAttempts = 0; private username?: string; private password?: string; + private auth: AuthProvider; constructor(options: OpenCodeClientOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, ""); this.autoServe = options.autoServe ?? false; this.username = options.username; this.password = options.password; + this.auth = new AuthProvider(options, this.baseUrl); this.api = this.buildSdkClient(); } @@ -95,18 +98,18 @@ export class OpenCodeClient { * surfaces a different URL than the one we were aiming at. */ private buildSdkClient(): NativeClient { - const headers: Record = {}; - const authHeader = buildBasicAuthHeader(this.username, this.password); - if (authHeader) { - headers["Authorization"] = authHeader; - } - return createOpencodeClient({ baseUrl: this.baseUrl, headers }); + return createOpencodeClient({ baseUrl: this.baseUrl, fetch: (request: Request) => this.auth.fetch(request) }); } getBaseUrl(): string { return this.baseUrl; } + /** Current `Authorization` header value; signs in to OpenChamber when configured. */ + getAuthorization(): Promise { + return this.auth.getHeader(); + } + private async request( method: string, path: string, @@ -159,11 +162,12 @@ export class OpenCodeClient { this.reconnectAttempts++; console.error(`Connection failed (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}), attempting server reconnection...`); try { - const status = await withAbort(isServerRunning(this.baseUrl, this.username, this.password), context.signal); + const authorization = await withAbort(this.auth.getHeader(), context.signal); + const status = await withAbort(isServerRunning(this.baseUrl, this.username, this.password, authorization), context.signal); if (!status.healthy) { throwIfAborted(context.signal); const ensured = await withAbort(ensureServer({ - baseUrl: this.baseUrl, autoServe: true, username: this.username, password: this.password, + baseUrl: this.baseUrl, autoServe: true, username: this.username, password: this.password, authorization, }), context.signal); if (ensured.url) { const nextUrl = ensured.url.replace(/\/$/, ""); @@ -216,14 +220,12 @@ export class OpenCodeClient { const headers: Record = { Accept: "text/event-stream", "Cache-Control": "no-cache" }; const normalized = normalizeDirectory(opts.directory); if (normalized) headers["x-opencode-directory"] = normalized; - const authHeader = buildBasicAuthHeader(this.username, this.password); - if (authHeader) headers["Authorization"] = authHeader; const context = createRequestContext(opts, 30_000); let reader: ReadableStreamDefaultReader | undefined; const cancelReader = () => { void reader?.cancel().catch(() => {}); }; try { throwIfAborted(context.signal); - const res = await withAbort(fetch(url, { method: "GET", headers, signal: context.signal }), context.signal); + const res = await withAbort(this.auth.fetch(new Request(url, { method: "GET", headers, signal: context.signal })), context.signal); if (!res.ok) { const text = await withAbort(res.text(), context.signal); throw new OpenCodeError(`SSE ${path} failed (${res.status}): ${text}`, res.status, "GET", path, text); diff --git a/src/index.ts b/src/index.ts index 9b23128..9284e1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,15 +12,20 @@ async function main() { const baseUrl = process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096"; const username = process.env.OPENCODE_SERVER_USERNAME; const password = process.env.OPENCODE_SERVER_PASSWORD; + const token = process.env.OPENCODE_SERVER_TOKEN || undefined; + const openchamberPassword = process.env.OPENCODE_OPENCHAMBER_PASSWORD || undefined; + const openchamberUrl = process.env.OPENCODE_OPENCHAMBER_URL || undefined; const autoServe = process.env.OPENCODE_AUTO_SERVE === "true"; const profile = process.env.OPENCODE_TOOL_PROFILE ?? "full"; if (profile !== "full" && profile !== "essential") throw new Error("OPENCODE_TOOL_PROFILE must be full or essential"); setModelDefaults(process.env.OPENCODE_DEFAULT_PROVIDER, process.env.OPENCODE_DEFAULT_MODEL); registerShutdownHandlers(); - const client = new OpenCodeClient({ baseUrl, username, password, autoServe }); - const scope = createHash("sha256").update(JSON.stringify([baseUrl, username ?? "opencode", password ?? ""])).digest("hex"); + const client = new OpenCodeClient({ baseUrl, username, password, token, openchamberPassword, openchamberUrl, autoServe }); + const identity: unknown[] = [baseUrl, username ?? "opencode", password ?? ""]; + if (token || openchamberPassword) identity.push(token ?? "", openchamberPassword ?? "", openchamberUrl ?? ""); + const scope = createHash("sha256").update(JSON.stringify(identity)).digest("hex"); const jobs = new JobService(client, { storeRoot: process.env.OPENCODE_TASK_STORE, scope }); - try { await ensureServer({ baseUrl, autoServe, username, password }); } + try { await ensureServer({ baseUrl, autoServe, username, password, authorization: await client.getAuthorization() }); } catch (error) { console.error(`Warning: ${error instanceof Error ? error.message : String(error)}`); } serveStdio(() => createServer(client, jobs, profile), { legacy: "serve", transport: new TaskTransport(new StdioServerTransport(), jobs), diff --git a/src/server-manager.ts b/src/server-manager.ts index 6902968..f941b65 100644 --- a/src/server-manager.ts +++ b/src/server-manager.ts @@ -11,6 +11,8 @@ export interface ServerManagerOptions { */ username?: string; password?: string; + /** Full `Authorization` header value; takes precedence over Basic credentials. */ + authorization?: string; } export interface ServerStatus { @@ -97,10 +99,11 @@ export async function isServerRunning( baseUrl: string, username?: string, password?: string, + authorization?: string, ): Promise<{ healthy: boolean; version?: string }> { try { const headers: Record = {}; - const authHeader = buildBasicAuthHeader(username, password); + const authHeader = authorization ?? buildBasicAuthHeader(username, password); if (authHeader) { headers["Authorization"] = authHeader; } @@ -169,7 +172,7 @@ export async function ensureServer( const baseUrl = opts.baseUrl; const autoServe = opts.autoServe === true; - const existing = await isServerRunning(baseUrl, opts.username, opts.password); + const existing = await isServerRunning(baseUrl, opts.username, opts.password, opts.authorization); if (existing.healthy) { console.error( `OpenCode server already running at ${baseUrl} (v${existing.version ?? "unknown"})`, diff --git a/tests/openchamber-auth.test.ts b/tests/openchamber-auth.test.ts new file mode 100644 index 0000000..d6d6c04 --- /dev/null +++ b/tests/openchamber-auth.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createServer, type Server } from "node:http"; +import { OpenCodeClient, OpenCodeError } from "../src/client.js"; +import { ensureServer } from "../src/server-manager.js"; + +const PASSWORD = "fixture-ui-password"; + +// Minimal OpenChamber stand-in: password login issues bearer client tokens, +// and the OpenCode API is proxied under /api and rejects anything else. +describe("OpenChamber authentication", () => { + let server: Server; + let origin: string; + let logins: any[]; + let valid: Set; + let issued: number; + let loginStatus: number; + let requests: Array<{ method: string; path: string; authorization?: string; body?: any }>; + + beforeEach(async () => { + logins = []; + valid = new Set(); + issued = 0; + loginStatus = 200; + requests = []; + server = createServer(async (req, res) => { + let raw = ""; + for await (const chunk of req) raw += chunk; + const url = new URL(req.url!, "http://localhost"); + const send = (status: number, body: unknown, headers: Record = {}) => { + res.writeHead(status, { "content-type": "application/json", ...headers }).end(JSON.stringify(body)); + }; + if (url.pathname === "/auth/session" && req.method === "POST") { + const body = JSON.parse(raw); + logins.push(body); + if (loginStatus === 429) return send(429, { error: "Too many login attempts" }, { "retry-after": "900" }); + if (body.password !== PASSWORD) return send(401, { error: "Invalid credentials" }); + const clientToken = `oc_client_${++issued}`; + valid.add(clientToken); + return send(200, { authenticated: true, clientToken, client: { id: String(issued) } }); + } + const authorization = req.headers.authorization; + requests.push({ method: req.method!, path: url.pathname, authorization, body: raw ? JSON.parse(raw) : undefined }); + const bearer = authorization?.match(/^Bearer (.+)$/)?.[1]; + if (!url.pathname.startsWith("/api/") || !bearer || !valid.has(bearer)) { + return send(401, { error: "UI authentication required", locked: true }); + } + if (url.pathname === "/api/global/health") return send(200, { healthy: true, version: "fixture" }); + if (url.pathname === "/api/event") { + res.writeHead(200, { "content-type": "text/event-stream" }); + return res.end("event: ping\ndata: {}\n\n"); + } + return send(200, { ok: true, path: url.pathname }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + }); + + afterEach(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }); + + function openchamberClient(options: Partial[0]> = {}) { + return new OpenCodeClient({ baseUrl: `${origin}/api`, openchamberPassword: PASSWORD, ...options }); + } + + it("signs in lazily and sends the client token as a bearer header", async () => { + const client = openchamberClient({ username: "ignored", password: "ignored" }); + expect(logins).toHaveLength(0); + await client.get("/session"); + await client.post("/session", { title: "x" }); + expect(logins).toHaveLength(1); + expect(logins[0]).toMatchObject({ password: PASSWORD, issueClientToken: true, trustDevice: true, clientKind: "desktop" }); + expect(logins[0].clientLabel).toMatch(/^opencode-mcp/); + expect(logins[0].dedupeKey).toMatch(/^opencode-mcp:/); + expect(requests.map((r) => r.authorization)).toEqual(["Bearer oc_client_1", "Bearer oc_client_1"]); + }); + + it("signs in again once after a 401 and replays the request body", async () => { + const client = openchamberClient(); + await client.get("/session"); + valid.clear(); // token expired or revoked + const result = await client.post("/session", { title: "replayed" }); + expect(result).toMatchObject({ ok: true }); + expect(logins).toHaveLength(2); + expect(requests.slice(1).map((r) => [r.authorization, r.body])).toEqual([ + ["Bearer oc_client_1", { title: "replayed" }], + ["Bearer oc_client_2", { title: "replayed" }], + ]); + }); + + it("shares one renewal between concurrent rejected requests", async () => { + const client = openchamberClient(); + await client.get("/session"); + valid.clear(); + await Promise.all([client.get("/a"), client.get("/b"), client.get("/c")]); + expect(logins).toHaveLength(2); + }); + + it("surfaces a 401 when the renewed token is rejected too, without looping", async () => { + const client = openchamberClient(); + await client.get("/session"); + valid.clear(); + const add = valid.add.bind(valid); + valid.add = () => valid; // the server stops accepting new tokens + await expect(client.get("/session")).rejects.toMatchObject({ status: 401 }); + valid.add = add; + expect(logins).toHaveLength(2); + }); + + it("reports a rejected password without echoing it", async () => { + const client = openchamberClient({ openchamberPassword: "wrong-secret" }); + const error = await client.get("/session").catch((e) => e); + expect(String(error.message)).toContain("OPENCODE_OPENCHAMBER_PASSWORD"); + expect(String(error.message)).not.toContain("wrong-secret"); + expect(requests).toHaveLength(0); + }); + + it("reports rate limiting", async () => { + loginStatus = 429; + await expect(openchamberClient().get("/session")).rejects.toThrow(/rate limited; retry after 900s/); + }); + + it("uses OPENCODE_OPENCHAMBER_URL for login when the API lives elsewhere", async () => { + const client = new OpenCodeClient({ baseUrl: `${origin}/api`, openchamberPassword: PASSWORD, openchamberUrl: `${origin}/ignored/path` }); + await client.get("/session"); + expect(logins).toHaveLength(1); + }); + + it("authenticates event streams", async () => { + const client = openchamberClient(); + const events = []; + for await (const event of client.subscribeSSE("/api/event")) events.push(event); + expect(events).toEqual([{ event: "ping", data: "{}" }]); + expect(requests.at(-1)?.authorization).toBe("Bearer oc_client_1"); + }); + + it("authenticates the health probe", async () => { + const client = openchamberClient(); + const status = await ensureServer({ baseUrl: `${origin}/api`, authorization: await client.getAuthorization() }); + expect(status).toMatchObject({ running: true, version: "fixture", managedByUs: false }); + }); + + describe("static bearer token", () => { + it("sends the token and never signs in", async () => { + valid.add("oc_client_static"); + const client = new OpenCodeClient({ baseUrl: `${origin}/api`, token: "oc_client_static", password: "basic-ignored" }); + await client.get("/session"); + expect(requests[0].authorization).toBe("Bearer oc_client_static"); + expect(logins).toHaveLength(0); + }); + + it("does not retry a rejected static token", async () => { + const client = new OpenCodeClient({ baseUrl: `${origin}/api`, token: "oc_client_revoked" }); + const error = await client.get("/session").catch((e) => e); + expect(error).toBeInstanceOf(OpenCodeError); + expect(error.status).toBe(401); + expect(requests).toHaveLength(1); + }); + }); +}); diff --git a/tests/server-manager.test.ts b/tests/server-manager.test.ts index 98dc1c6..4691c0d 100644 --- a/tests/server-manager.test.ts +++ b/tests/server-manager.test.ts @@ -164,6 +164,14 @@ describe("isServerRunning", () => { // base64("opencode:secret123") === "b3BlbmNvZGU6c2VjcmV0MTIz" expect(headers.Authorization).toBe("Basic b3BlbmNvZGU6c2VjcmV0MTIz"); }); + + it("prefers an explicit Authorization header over Basic credentials", async () => { + mockFetchHealthy(); + await isServerRunning("http://127.0.0.1:4096", "admin", "secret123", "Bearer oc_client_x"); + const init = fetchMock.mock.calls[0][1] as RequestInit; + const headers = (init.headers ?? {}) as Record; + expect(headers.Authorization).toBe("Bearer oc_client_x"); + }); }); // ─── startServer ───────────────────────────────────────────────────────── From 3d62cee7dfdaf6fcba7fe4be4f2ff59771e27931 Mon Sep 17 00:00:00 2001 From: Rahul Sharma Date: Wed, 21 Oct 2026 21:11:47 +0530 Subject: [PATCH 2/2] fix: address review feedback for OpenChamber auth - Scope the OpenChamber dedupeKey to a per-process instance ID (OPENCODE_OPENCHAMBER_CLIENT_ID override) instead of hostname alone, so independent MCP instances on the same host no longer replace each other's client token in OpenChamber v2.4.0's client store. - Preserve the OPENCODE_BASE_URL path prefix (e.g. OpenChamber's /api proxy) when building SSE URLs in subscribeSSE, fixing event streaming through opencode_events_poll. - Forward OPENCODE_SERVER_TOKEN, OPENCODE_OPENCHAMBER_PASSWORD, OPENCODE_OPENCHAMBER_URL and OPENCODE_OPENCHAMBER_CLIENT_ID through the smoke-test runner's child environment allowlist. - Add regression tests: two independent OpenChamber clients with distinct dedupeKeys, concurrent renewal of both without spurious 401s, event-poll tool exercised end-to-end for project and global scope, and smoke-environment forwarding of the new credentials. - Update README, docs/configuration.md and CHANGELOG for the new OPENCODE_OPENCHAMBER_CLIENT_ID variable. --- CHANGELOG.md | 7 +++- README.md | 1 + docs/configuration.md | 3 +- scripts/mcp-smoke-test.mjs | 9 ++++- src/auth.ts | 27 ++++++++++++++- src/client.ts | 8 ++++- src/index.ts | 3 +- tests/openchamber-auth.test.ts | 62 ++++++++++++++++++++++++++++++++-- tests/smoke-runner.test.ts | 18 ++++++++++ 9 files changed, 130 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ab363..1370a8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Bearer-token authentication with `OPENCODE_SERVER_TOKEN`. -- OpenChamber sign-in with `OPENCODE_OPENCHAMBER_PASSWORD` (and optional `OPENCODE_OPENCHAMBER_URL`): exchanges the UI password for a client token, keeps it in memory, and renews it once on 401. Applies to API requests, event streams and the health probe. +- OpenChamber sign-in with `OPENCODE_OPENCHAMBER_PASSWORD` (and optional `OPENCODE_OPENCHAMBER_URL`): exchanges the UI password for a client token, keeps it in memory, and renews it once on 401. Applies to API requests, event streams and the health probe. Each process gets a distinct client identifier by default (override with `OPENCODE_OPENCHAMBER_CLIENT_ID`), so independent MCP instances on the same host do not replace each other's token. + +### Fixed + +- Event streams (`opencode_events_poll`) preserved the `OPENCODE_BASE_URL` path prefix (e.g. OpenChamber's `/api` proxy) instead of resolving against the origin alone. +- The smoke-test runner now forwards `OPENCODE_SERVER_TOKEN`, `OPENCODE_OPENCHAMBER_PASSWORD`, `OPENCODE_OPENCHAMBER_URL` and `OPENCODE_OPENCHAMBER_CLIENT_ID` to the smoke child. ## [3.0.0] - 2026-09-16 diff --git a/README.md b/README.md index cfad349..abc6309 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ All settings are optional; an OpenCode server must already be running by default | `OPENCODE_SERVER_USERNAME`, `OPENCODE_SERVER_PASSWORD` | Optional server HTTP authentication | | `OPENCODE_SERVER_TOKEN` | Optional bearer token, e.g. an OpenChamber client token | | `OPENCODE_OPENCHAMBER_PASSWORD`, `OPENCODE_OPENCHAMBER_URL` | Sign in to an [OpenChamber](https://github.com/openchamber/openchamber)-fronted server | +| `OPENCODE_OPENCHAMBER_CLIENT_ID` | Overrides the per-process OpenChamber client identifier; see [configuration](docs/configuration.md#openchamber) | | `OPENCODE_AUTO_SERVE` | Set to `true` to opt in to launching a local server | | `OPENCODE_DEFAULT_PROVIDER`, `OPENCODE_DEFAULT_MODEL` | Default prompt provider/model pair | | `OPENCODE_TOOL_PROFILE` | `full` (default) or a smaller `essential` tool set | diff --git a/docs/configuration.md b/docs/configuration.md index a56998f..0999d86 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,6 +12,7 @@ An OpenCode server must already be running unless automatic startup is explicitl | `OPENCODE_SERVER_TOKEN` | unset | Bearer token sent as `Authorization: Bearer `; replaces Basic auth | | `OPENCODE_OPENCHAMBER_PASSWORD` | unset | OpenChamber UI password, exchanged for a client token; see below | | `OPENCODE_OPENCHAMBER_URL` | origin of `OPENCODE_BASE_URL` | OpenChamber origin that serves `/auth/session` | +| `OPENCODE_OPENCHAMBER_CLIENT_ID` | random per process | Overrides the identifier that scopes this instance's OpenChamber client record; see below | | `OPENCODE_AUTO_SERVE` | `false` | Set exactly `true` to allow a local child server to start | | `OPENCODE_DEFAULT_PROVIDER` | unset | Default prompt provider ID | | `OPENCODE_DEFAULT_MODEL` | unset | Default prompt model ID; configure both defaults together | @@ -28,7 +29,7 @@ Job records hold local operational metadata and results, independent of OpenCode [OpenChamber](https://github.com/openchamber/openchamber) proxies OpenCode under `/api` and rejects Basic auth. Point `OPENCODE_BASE_URL` at the proxy and choose one credential: -- `OPENCODE_OPENCHAMBER_PASSWORD`: on first use, opencode-mcp posts the UI password to `/auth/session` and receives a trusted-device client token (7 days by default). The token is kept in memory only. When a request is rejected with 401, it signs in once more and resends that request; the rejected request never reached OpenCode, so writes are not duplicated. Repeated sign-ins reuse one client entry per host in OpenChamber's client list. +- `OPENCODE_OPENCHAMBER_PASSWORD`: on first use, opencode-mcp posts the UI password to `/auth/session` and receives a trusted-device client token (7 days by default). The token is kept in memory only. When a request is rejected with 401, it signs in once more and resends that request; the rejected request never reached OpenCode, so writes are not duplicated. Each MCP process gets its own client entry in OpenChamber's client list, keyed by a random identifier generated at startup, so independent MCP instances on the same host (e.g. two editor windows) never replace each other's token. Set `OPENCODE_OPENCHAMBER_CLIENT_ID` to a stable value only if you deliberately want multiple processes to share one client entry. - `OPENCODE_SERVER_TOKEN`: a client token you minted yourself. It is not renewed; replace it when it expires or is revoked. ```bash diff --git a/scripts/mcp-smoke-test.mjs b/scripts/mcp-smoke-test.mjs index c02018b..bb40ca4 100644 --- a/scripts/mcp-smoke-test.mjs +++ b/scripts/mcp-smoke-test.mjs @@ -43,7 +43,14 @@ export function smokeOptions(argv = [], env = process.env) { export function smokeEnvironment(options, env = process.env) { const childEnv = { OPENCODE_BASE_URL: options.baseUrl, OPENCODE_AUTO_SERVE: "false" }; - for (const key of ["OPENCODE_SERVER_USERNAME", "OPENCODE_SERVER_PASSWORD"]) { + for (const key of [ + "OPENCODE_SERVER_USERNAME", + "OPENCODE_SERVER_PASSWORD", + "OPENCODE_SERVER_TOKEN", + "OPENCODE_OPENCHAMBER_PASSWORD", + "OPENCODE_OPENCHAMBER_URL", + "OPENCODE_OPENCHAMBER_CLIENT_ID", + ]) { if (env[key] !== undefined) childEnv[key] = env[key]; } return childEnv; diff --git a/src/auth.ts b/src/auth.ts index 10bd135..077d28e 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,4 +1,5 @@ import { hostname } from "node:os"; +import { randomUUID } from "node:crypto"; export interface AuthOptions { /** HTTP Basic credentials for an OpenCode server started with OPENCODE_SERVER_PASSWORD. */ @@ -10,6 +11,13 @@ export interface AuthOptions { openchamberPassword?: string; /** OpenChamber origin hosting `/auth/session`; defaults to the base URL's origin. */ openchamberUrl?: string; + /** + * Overrides the identifier that scopes this instance's OpenChamber + * `dedupeKey` (`OPENCODE_OPENCHAMBER_CLIENT_ID`). Set this when two MCP + * instances must intentionally share one client record, e.g. across + * restarts; otherwise a random one is generated per process. + */ + clientId?: string; } const LOGIN_TIMEOUT_MS = 15_000; @@ -25,8 +33,24 @@ export class AuthProvider { private token?: string; private login?: Promise; + /** + * Identifies this MCP instance to OpenChamber. Stable for the lifetime of + * the provider (and thus reused for every renewal), but unique per + * instance so a second independent MCP instance on the same host cannot + * replace this one's client record: OpenChamber v2.4.0 revokes the + * previous client record whenever a login reuses a `dedupeKey` (see + * https://github.com/openchamber/openchamber/blob/v2.4.0/packages/web/ + * server/lib/client-auth/remote-clients.js#L188-L200), so a key derived + * only from `hostname()` let a second MCP process silently invalidate the + * first one's cached token. Override with `OPENCODE_OPENCHAMBER_CLIENT_ID` + * when two instances must intentionally share one client record, e.g. + * across restarts of a supervised process that should keep one slot. + */ + private readonly instanceId: string; + constructor(private readonly options: AuthOptions, private readonly baseUrl: string) { this.token = options.openchamberPassword ? undefined : options.token || undefined; + this.instanceId = options.clientId || `${process.pid}-${randomUUID()}`; } /** True when a rejected token can be renewed without user action. */ @@ -76,7 +100,8 @@ export class AuthProvider { trustDevice: true, clientLabel: `opencode-mcp (${host})`, clientKind: "desktop", - dedupeKey: `opencode-mcp:${host}`, + // Scoped to this instance, not just this host: see `instanceId` above. + dedupeKey: `opencode-mcp:${host}:${this.instanceId}`, }), signal: AbortSignal.timeout(LOGIN_TIMEOUT_MS), }); diff --git a/src/client.ts b/src/client.ts index 1542c81..6d55c35 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,6 +15,8 @@ export interface OpenCodeClientOptions { openchamberPassword?: string; /** OpenChamber origin (`OPENCODE_OPENCHAMBER_URL`). */ openchamberUrl?: string; + /** Overrides the per-process OpenChamber dedupe scope (`OPENCODE_OPENCHAMBER_CLIENT_ID`). */ + clientId?: string; autoServe?: boolean; } @@ -216,7 +218,11 @@ export class OpenCodeClient { } async *subscribeSSE(path: string, opts: RequestOptions & { directory?: string } = {}): AsyncGenerator<{ event: string; data: string }, void, undefined> { - const url = new URL(path, this.baseUrl).toString(); + // `new URL(path, this.baseUrl)` resolves an absolute `path` against the + // origin, discarding any base path (e.g. OpenChamber's `/api` proxy + // prefix). Join on the string form of baseUrl instead, which already has + // its trailing slash stripped in the constructor. + const url = `${this.baseUrl}${path}`; const headers: Record = { Accept: "text/event-stream", "Cache-Control": "no-cache" }; const normalized = normalizeDirectory(opts.directory); if (normalized) headers["x-opencode-directory"] = normalized; diff --git a/src/index.ts b/src/index.ts index 9284e1c..57ba949 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,12 +15,13 @@ async function main() { const token = process.env.OPENCODE_SERVER_TOKEN || undefined; const openchamberPassword = process.env.OPENCODE_OPENCHAMBER_PASSWORD || undefined; const openchamberUrl = process.env.OPENCODE_OPENCHAMBER_URL || undefined; + const clientId = process.env.OPENCODE_OPENCHAMBER_CLIENT_ID || undefined; const autoServe = process.env.OPENCODE_AUTO_SERVE === "true"; const profile = process.env.OPENCODE_TOOL_PROFILE ?? "full"; if (profile !== "full" && profile !== "essential") throw new Error("OPENCODE_TOOL_PROFILE must be full or essential"); setModelDefaults(process.env.OPENCODE_DEFAULT_PROVIDER, process.env.OPENCODE_DEFAULT_MODEL); registerShutdownHandlers(); - const client = new OpenCodeClient({ baseUrl, username, password, token, openchamberPassword, openchamberUrl, autoServe }); + const client = new OpenCodeClient({ baseUrl, username, password, token, openchamberPassword, openchamberUrl, clientId, autoServe }); const identity: unknown[] = [baseUrl, username ?? "opencode", password ?? ""]; if (token || openchamberPassword) identity.push(token ?? "", openchamberPassword ?? "", openchamberUrl ?? ""); const scope = createHash("sha256").update(JSON.stringify(identity)).digest("hex"); diff --git a/tests/openchamber-auth.test.ts b/tests/openchamber-auth.test.ts index d6d6c04..10047cf 100644 --- a/tests/openchamber-auth.test.ts +++ b/tests/openchamber-auth.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createServer, type Server } from "node:http"; import { OpenCodeClient, OpenCodeError } from "../src/client.js"; import { ensureServer } from "../src/server-manager.js"; +import { registerEventTools } from "../src/tools/events.js"; const PASSWORD = "fixture-ui-password"; @@ -12,6 +13,13 @@ describe("OpenChamber authentication", () => { let origin: string; let logins: any[]; let valid: Set; + // OpenChamber v2.4.0's actual remote-client store: minting a token for a + // dedupeKey that already has one revokes the previous token outright + // (see https://github.com/openchamber/openchamber/blob/v2.4.0/packages/web/ + // server/lib/client-auth/remote-clients.js#L188-L200). A fixture that only + // adds tokens to a set (as this one used to) cannot catch a dedupeKey + // collision between two independent clients. + let tokenByDedupeKey: Map; let issued: number; let loginStatus: number; let requests: Array<{ method: string; path: string; authorization?: string; body?: any }>; @@ -19,6 +27,7 @@ describe("OpenChamber authentication", () => { beforeEach(async () => { logins = []; valid = new Set(); + tokenByDedupeKey = new Map(); issued = 0; loginStatus = 200; requests = []; @@ -34,7 +43,10 @@ describe("OpenChamber authentication", () => { logins.push(body); if (loginStatus === 429) return send(429, { error: "Too many login attempts" }, { "retry-after": "900" }); if (body.password !== PASSWORD) return send(401, { error: "Invalid credentials" }); + const previous = tokenByDedupeKey.get(body.dedupeKey); + if (previous) valid.delete(previous); const clientToken = `oc_client_${++issued}`; + tokenByDedupeKey.set(body.dedupeKey, clientToken); valid.add(clientToken); return send(200, { authenticated: true, clientToken, client: { id: String(issued) } }); } @@ -45,7 +57,7 @@ describe("OpenChamber authentication", () => { return send(401, { error: "UI authentication required", locked: true }); } if (url.pathname === "/api/global/health") return send(200, { healthy: true, version: "fixture" }); - if (url.pathname === "/api/event") { + if (url.pathname === "/api/event" || url.pathname === "/api/global/event") { res.writeHead(200, { "content-type": "text/event-stream" }); return res.end("event: ping\ndata: {}\n\n"); } @@ -97,6 +109,31 @@ describe("OpenChamber authentication", () => { expect(logins).toHaveLength(2); }); + it("gives two independent clients distinct dedupeKeys so neither invalidates the other", async () => { + const a = openchamberClient(); + const b = openchamberClient(); + await a.get("/session"); + await b.get("/session"); // against the real OpenChamber store, this used to revoke a's token + expect(a).not.toBe(b); + expect(logins).toHaveLength(2); + expect(logins[0].dedupeKey).not.toBe(logins[1].dedupeKey); + // a's token must still be valid: logging in as b did not evict it. + await expect(a.get("/still-valid")).resolves.toMatchObject({ ok: true }); + expect(logins).toHaveLength(2); + }); + + it("renews both independent clients concurrently without a spurious 401", async () => { + const a = openchamberClient(); + const b = openchamberClient(); + await a.get("/session"); + await b.get("/session"); + valid.clear(); // both tokens expired or were revoked out of band + const results = await Promise.allSettled([a.get("/a"), b.get("/b")]); + expect(results.every((r) => r.status === "fulfilled")).toBe(true); + // One renewal per client; neither client's renewal evicted the other's. + expect(logins).toHaveLength(4); + }); + it("surfaces a 401 when the renewed token is rejected too, without looping", async () => { const client = openchamberClient(); await client.get("/session"); @@ -129,12 +166,33 @@ describe("OpenChamber authentication", () => { it("authenticates event streams", async () => { const client = openchamberClient(); + // Exercise subscribeSSE the same way opencode_events_poll does: an + // unprefixed path that relies on the client preserving `baseUrl`'s `/api` + // prefix, not a path with the prefix already baked in. const events = []; - for await (const event of client.subscribeSSE("/api/event")) events.push(event); + for await (const event of client.subscribeSSE("/event")) events.push(event); expect(events).toEqual([{ event: "ping", data: "{}" }]); expect(requests.at(-1)?.authorization).toBe("Bearer oc_client_1"); }); + it("authenticates the events-poll tool through the OpenChamber proxy prefix", async () => { + const client = openchamberClient(); + let handler: Function = () => {}; + registerEventTools({ tool: (...args: any[]) => { handler = args.at(-1); } } as any, client); + const result = await handler({ durationMs: 200 }); + expect(result.isError).toBeFalsy(); + expect(requests.some((r) => r.path === "/api/event")).toBe(true); + }); + + it("authenticates the global events-poll tool through the OpenChamber proxy prefix", async () => { + const client = openchamberClient(); + let handler: Function = () => {}; + registerEventTools({ tool: (...args: any[]) => { handler = args.at(-1); } } as any, client); + const result = await handler({ durationMs: 200, scope: "global" }); + expect(result.isError).toBeFalsy(); + expect(requests.some((r) => r.path === "/api/global/event")).toBe(true); + }); + it("authenticates the health probe", async () => { const client = openchamberClient(); const status = await ensureServer({ baseUrl: `${origin}/api`, authorization: await client.getAuthorization() }); diff --git a/tests/smoke-runner.test.ts b/tests/smoke-runner.test.ts index 553373f..51d2506 100644 --- a/tests/smoke-runner.test.ts +++ b/tests/smoke-runner.test.ts @@ -138,6 +138,24 @@ describe("smoke options", () => { expect(env).toEqual({ OPENCODE_BASE_URL: "http://localhost:4096", OPENCODE_SERVER_USERNAME: "fixture-user", OPENCODE_SERVER_PASSWORD: "fixture-password", OPENCODE_AUTO_SERVE: "false" }); }); + it("forwards bearer-token and OpenChamber credentials to the smoke child", () => { + const env = smokeEnvironment({ baseUrl: "http://localhost:4096" }, { + OPENCODE_SERVER_TOKEN: "fixture-token", + OPENCODE_OPENCHAMBER_PASSWORD: "fixture-ui-password", + OPENCODE_OPENCHAMBER_URL: "http://localhost:4096", + OPENCODE_OPENCHAMBER_CLIENT_ID: "fixture-client-id", + UNRELATED_SECRET: "not-forwarded", + }); + expect(env).toEqual({ + OPENCODE_BASE_URL: "http://localhost:4096", + OPENCODE_AUTO_SERVE: "false", + OPENCODE_SERVER_TOKEN: "fixture-token", + OPENCODE_OPENCHAMBER_PASSWORD: "fixture-ui-password", + OPENCODE_OPENCHAMBER_URL: "http://localhost:4096", + OPENCODE_OPENCHAMBER_CLIENT_ID: "fixture-client-id", + }); + }); + it("rejects embedded credentials, remote fixture ambiguity, and unknown options", () => { expect(() => smokeOptions(["--base-url", "http://user:fixture@localhost:4096"], {})).toThrow("credentials"); expect(() => smokeOptions(["--base-url", "https://example.com"], {})).toThrow("disposable");