diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 8110d96ca..c6a4e2fc1 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -15,6 +15,7 @@ import { monoFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { terminalAttr, terminalProbe } from "@/testing/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" +import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" import { terminalWriter } from "@/utils/terminal-writer" const TOGGLE_TERMINAL_ID = "terminal.toggle" @@ -523,18 +524,18 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return drop?.() - const next = new URL(url + `/pty/${id}/connect`) - next.searchParams.set("directory", directory) - next.searchParams.set("cursor", String(seek)) - next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (!sameOrigin && password) { - next.searchParams.set("auth_token", btoa(`${username}:${password}`)) - // For same-origin requests, let the browser reuse the page's existing auth. - next.username = username - next.password = password - } - - const socket = new WebSocket(next) + const socket = new WebSocket( + terminalWebSocketURL({ + url, + id, + directory, + cursor: seek, + sameOrigin, + username, + password, + authToken: server.current?.type === "http" ? server.current.authToken : false, + }), + ) socket.binaryType = "arraybuffer" ws = socket diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts new file mode 100644 index 000000000..23c0b06b6 --- /dev/null +++ b/packages/app/src/context/server.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test" +import { resolveServerList, ServerConnection } from "./server" + +describe("resolveServerList", () => { + test("lets startup auth_token credentials override a persisted same-url server", () => { + const list = resolveServerList({ + stored: [{ url: "https://server.example.test" }], + props: [ + { + type: "http", + authToken: true, + http: { + url: "https://server.example.test", + username: "opencode", + password: "secret", + }, + }, + ], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "secret", + }) + expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true) + expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test") + }) + + test("keeps persisted credentials when startup has no auth_token", () => { + const list = resolveServerList({ + stored: [ + { + url: "https://server.example.test", + username: "opencode", + password: "saved", + }, + ], + props: [{ type: "http", http: { url: "https://server.example.test" } }], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "saved", + }) + expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined() + }) + + test("keeps persisted display name when startup auth_token credentials override a same-url server", () => { + const list = resolveServerList({ + stored: [ + { + type: "http", + displayName: "Team server", + http: { url: "https://server.example.test" }, + }, + ], + props: [ + { + type: "http", + authToken: true, + http: { + url: "https://server.example.test", + username: "opencode", + password: "secret", + }, + }, + ], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.displayName).toBe("Team server") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "secret", + }) + expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true) + }) +}) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1204fba55..eb8e3a361 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -5,7 +5,6 @@ import { Persist, persisted } from "@/utils/persist" import { useCheckServerHealth } from "@/utils/server-health" type StoredProject = { worktree: string; expanded: boolean } -type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http const HEALTH_POLL_INTERVAL_MS = 10_000 export function normalizeServerUrl(input: string) { @@ -46,6 +45,7 @@ export namespace ServerConnection { export type Http = { type: "http" http: HttpBase + authToken?: boolean } & Base export type Sidecar = { @@ -92,6 +92,37 @@ export namespace ServerConnection { export const Key = { make: (v: string) => v as Key } } +type StoredHttp = Omit & { authToken?: never } +export type StoredServer = string | ServerConnection.HttpBase | StoredHttp + +export function resolveServerList(input: { + props?: Array + stored: StoredServer[] +}): Array { + const servers = [ + ...input.stored.map((value) => + typeof value === "string" + ? { + type: "http" as const, + http: { url: value }, + } + : value, + ), + ...(input.props ?? []), + ] + + const deduped = new Map() + for (const value of servers) { + const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value } + const key = ServerConnection.key(conn) + if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue + const existing = deduped.get(key) + deduped.set(key, existing ? { ...existing, ...conn, displayName: conn.displayName ?? existing.displayName } : conn) + } + + return [...deduped.values()] +} + export const { use: useServer, provider: ServerProvider } = createSimpleContext({ name: "Server", init: (props: { @@ -113,26 +144,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url) const allServers = createMemo((): Array => { - const servers = [ - ...(props.servers ?? []), - ...store.list.map((value) => - typeof value === "string" - ? { - type: "http" as const, - http: { url: value }, - } - : value, - ), - ] - - const deduped = new Map( - servers.map((value) => { - const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value } - return [ServerConnection.key(conn), conn] - }), - ) - - return [...deduped.values()] + return resolveServerList({ stored: store.list, props: props.servers }) }) const [state, setState] = createStore({ @@ -174,7 +186,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( function add(input: ServerConnection.Http) { const url_ = normalizeServerUrl(input.http.url) if (!url_) return - const conn = { ...input, http: { ...input.http, url: url_ } } + const { authToken: _authToken, ...persisted } = input + const conn: StoredHttp = { ...persisted, http: { ...input.http, url: url_ } } return batch(() => { const existing = store.list.findIndex((x) => url(x) === url_) if (existing !== -1) { diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 72a413871..dd213ff97 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -9,6 +9,7 @@ import { handleNotificationClick } from "@/utils/notification-click" import { showToast } from "@opencode-ai/ui/toast" import pkg from "../package.json" import { ServerConnection } from "./context/server" +import { authFromToken } from "@/utils/server" const DEFAULT_SERVER_URL_KEY = "pawwork.settings.dat:defaultServerUrl" @@ -146,6 +147,13 @@ const getDefaultUrl = () => { return getCurrentUrl() } +const clearAuthToken = () => { + const params = new URLSearchParams(location.search) + if (!params.has("auth_token")) return + params.delete("auth_token") + history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash) +} + const platform: Platform = { platform: "web", shell: { kind: "desktop", os: detectShellOs() }, @@ -163,7 +171,17 @@ const platform: Platform = { } if (root instanceof HTMLElement) { - const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } + const authToken = new URLSearchParams(location.search).get("auth_token") + const auth = authFromToken(authToken) + clearAuthToken() + const server: ServerConnection.Http = { + type: "http", + authToken: !!auth, + http: { + url: getCurrentUrl(), + ...auth, + }, + } render( () => ( diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts new file mode 100644 index 000000000..4666b7d6d --- /dev/null +++ b/packages/app/src/utils/server.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { authFromToken, authTokenFromCredentials } from "./server" + +describe("authFromToken", () => { + test("decodes basic auth credentials from auth_token", () => { + expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" }) + }) + + test("defaults blank username to opencode", () => { + expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" }) + }) + + test("ignores malformed tokens", () => { + expect(authFromToken("not base64")).toBeUndefined() + expect(authFromToken(btoa("missing-separator"))).toBeUndefined() + }) +}) + +describe("authTokenFromCredentials", () => { + test("encodes credentials with the default username", () => { + expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret")) + }) +}) diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 17f4a3adc..f6657da6b 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,5 +1,21 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { ServerConnection } from "@/context/server" +import { decode64 } from "@/utils/base64" + +export function authTokenFromCredentials(input: { username?: string; password: string }) { + return btoa(`${input.username ?? "opencode"}:${input.password}`) +} + +export function authFromToken(token: string | null) { + const decoded = decode64(token ?? undefined) + if (!decoded) return + const separator = decoded.indexOf(":") + if (separator === -1) return + return { + username: decoded.slice(0, separator) || "opencode", + password: decoded.slice(separator + 1), + } +} export function createSdkForServer({ server, @@ -10,7 +26,7 @@ export function createSdkForServer({ const auth = (() => { if (!server.password) return return { - Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`, + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, } })() diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts new file mode 100644 index 000000000..9ed6fc9e3 --- /dev/null +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { terminalWebSocketURL } from "./terminal-websocket-url" + +describe("terminalWebSocketURL", () => { + test("uses query auth for non-same-origin saved credentials", () => { + const url = terminalWebSocketURL({ + url: "https://server.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: false, + username: "opencode", + password: "secret", + }) + + expect(url.protocol).toBe("wss:") + expect(url.pathname).toBe("/pty/pty_test/connect") + expect(url.searchParams.get("directory")).toBe("/tmp/project") + expect(url.searchParams.get("cursor")).toBe("10") + expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) + }) + + test("omits query auth for same-origin saved credentials", () => { + const url = terminalWebSocketURL({ + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "opencode", + password: "secret", + }) + + expect(url.protocol).toBe("wss:") + expect(url.searchParams.has("auth_token")).toBe(false) + }) + + test("uses query auth for same-origin credentials from auth_token", () => { + const url = terminalWebSocketURL({ + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "opencode", + password: "secret", + authToken: true, + }) + + expect(url.protocol).toBe("wss:") + expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) + }) +}) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts new file mode 100644 index 000000000..3998adfa3 --- /dev/null +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -0,0 +1,24 @@ +import { authTokenFromCredentials } from "@/utils/server" + +export function terminalWebSocketURL(input: { + url: string + id: string + directory: string + cursor: number + sameOrigin: boolean + username: string + password?: string + authToken?: boolean +}) { + const next = new URL(input.url + "/pty/" + input.id + "/connect") + next.searchParams.set("directory", input.directory) + next.searchParams.set("cursor", String(input.cursor)) + next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + if (input.password && (!input.sameOrigin || input.authToken)) { + next.searchParams.set( + "auth_token", + authTokenFromCredentials({ username: input.username, password: input.password }), + ) + } + return next +} diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 8141adc4f..7d9f7aa1b 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -4,6 +4,7 @@ import { cmd } from "./cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" import { Server } from "@/server/server" +import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" @@ -27,6 +28,7 @@ export const AcpCommand = cmd({ const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, + headers: ServerAuth.headers(), }) const input = new WritableStream({ diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index beb6a4363..4362279cb 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -303,6 +303,7 @@ export const ProvidersLoginCommand = cmd({ prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", + stderr: "inherit", }) if (!proc.stdout) { prompts.log.error("Failed") diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 4d934f75a..878707060 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -27,6 +27,7 @@ import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" import { AppRuntime } from "@/effect/app-runtime" +import { ServerAuth } from "../../server/auth" type ToolProps = { input: Tool.InferParameters @@ -284,6 +285,11 @@ export const RunCommand = cmd({ type: "string", describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", + }) .option("dir", { type: "string", describe: "directory to run in, path on remote server if attaching", @@ -670,13 +676,7 @@ export const RunCommand = cmd({ } if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() + const headers = ServerAuth.headers({ password: args.password, username: args.username }) const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) return await execute(sdk) } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 259772b2f..b53d17a43 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,7 +11,6 @@ import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" -import { Flag } from "@opencode-ai/core/flag/flag" import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" @@ -29,6 +28,8 @@ import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" import { installAdaptor, ownerKey, uninstallAdaptor } from "@/control-plane/adaptors" import type { Adaptor } from "@/control-plane/types" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -137,11 +138,7 @@ export namespace Plugin { const client = createOpencodeClient({ baseUrl: "http://localhost:4096", directory: ctx.directory, - headers: Flag.OPENCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, + headers: ServerAuth.headers(), fetch: async (...args) => (await Server.Default()).app.fetch(...args), }) const cfg = yield* config.get() diff --git a/packages/opencode/src/server/auth.ts b/packages/opencode/src/server/auth.ts new file mode 100644 index 000000000..ab115bae4 --- /dev/null +++ b/packages/opencode/src/server/auth.ts @@ -0,0 +1,45 @@ +export * as ServerAuth from "./auth" + +import { Flag } from "@opencode-ai/core/flag/flag" +import { Option, Redacted } from "effect" + +export type Credentials = { + password?: string + username?: string +} + +export type ConfigInfo = { + password: Option.Option + username: string +} + +export type DecodedCredentials = { + readonly username: string + readonly password: Redacted.Redacted +} + +export function required(config: ConfigInfo) { + return Option.isSome(config.password) && config.password.value !== "" +} + +export function authorized(credentials: DecodedCredentials, config: ConfigInfo) { + return ( + Option.isSome(config.password) && + credentials.username === config.username && + Redacted.value(credentials.password) === config.password.value + ) +} + +export function header(credentials?: Credentials) { + const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + + const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +export function headers(credentials?: Credentials) { + const authorization = header(credentials) + if (!authorization) return undefined + return { Authorization: authorization } +} diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index dc2320d2a..12ea20dfd 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -7,9 +7,10 @@ import type { ErrorHandler, MiddlewareHandler } from "hono" import { HTTPException } from "hono/http-exception" import { Log } from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" -import { basicAuth } from "hono/basic-auth" import { cors } from "hono/cors" import { compress } from "hono/compress" +import { Option, Redacted } from "effect" +import { ServerAuth } from "./auth" const log = Log.create({ service: "server" }) @@ -36,17 +37,41 @@ export const ErrorMiddleware: ErrorHandler = (err, c) => { }) } -export const AuthMiddleware: MiddlewareHandler = (c, next) => { +export const AuthMiddleware: MiddlewareHandler = async (c, next) => { + const unauthorized = () => { + c.header("WWW-Authenticate", 'Basic realm="opencode"') + return c.text("Unauthorized", 401) + } + // Allow CORS preflight requests to succeed without auth. // Browser clients sending Authorization headers will preflight with OPTIONS. if (c.req.method === "OPTIONS") return next() + const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() - const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" - if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) + const queryToken = c.req.query("auth_token") + const authHeader = c.req.header("authorization") + const header = queryToken ? "Basic " + queryToken : authHeader + + const match = header?.match(/^Basic\s+(.+)$/i) + if (!match) return unauthorized() - return basicAuth({ username, password })(c, next) + const credentialsPart = match[1] + const decoded = Buffer.from(credentialsPart, "base64").toString("utf8") + const separator = decoded.indexOf(":") + if (separator === -1) return unauthorized() + + const config = { + password: Option.some(password), + username: Flag.OPENCODE_SERVER_USERNAME ?? "opencode", + } + const credentials = { + username: decoded.slice(0, separator), + password: Redacted.make(decoded.slice(separator + 1)), + } + if (!ServerAuth.authorized(credentials, config)) return unauthorized() + return next() } export const LoggerMiddleware: MiddlewareHandler = async (c, next) => { diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index ea1c79178..e8f725222 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -7,7 +7,16 @@ export function errorFormat(error: unknown): string { if (typeof error === "object" && error !== null) { try { - return JSON.stringify(error, null, 2) + const json = JSON.stringify(error, null, 2) + if (json === "{}") { + const str = String(error) + if (str && str !== "[object Object]") return str + const ctor = error.constructor?.name + const prefix = ctor && ctor !== "Object" ? ctor : "Error" + const names = Object.getOwnPropertyNames(error) + return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }` + } + return json } catch { return "Unexpected error (unserializable)" } @@ -30,7 +39,7 @@ export function errorMessage(error: unknown): string { if (text && text !== "[object Object]") return text const formatted = errorFormat(error) - if (formatted && formatted !== "{}") return formatted + if (formatted) return formatted return "unknown error" } @@ -41,7 +50,7 @@ export function errorData(error: unknown) { message: errorMessage(error), stack: error.stack, cause: error.cause === undefined ? undefined : errorFormat(error.cause), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -49,7 +58,7 @@ export function errorData(error: unknown) { return { type: typeof error, message: errorMessage(error), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -66,12 +75,6 @@ export function errorData(error: unknown) { if (typeof data.message !== "string") data.message = errorMessage(error) if (typeof data.type !== "string") data.type = error.constructor?.name - data.formatted = errorFormatted(error) + data.formatted = errorFormat(error) return data } - -function errorFormatted(error: unknown) { - const formatted = errorFormat(error) - if (formatted !== "{}") return formatted - return String(error) -} diff --git a/packages/opencode/test/server/auth.test.ts b/packages/opencode/test/server/auth.test.ts new file mode 100644 index 000000000..fb5586088 --- /dev/null +++ b/packages/opencode/test/server/auth.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Option, Redacted } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "../../src/server/auth" +import { AuthMiddleware } from "../../src/server/middleware" +import { Hono } from "hono" + +const mutableFlag = Flag as { + OPENCODE_SERVER_PASSWORD?: string + OPENCODE_SERVER_USERNAME?: string +} + +const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +afterEach(() => { + mutableFlag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + mutableFlag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME +}) + +describe("ServerAuth", () => { + test("does not emit auth headers without a password", () => { + mutableFlag.OPENCODE_SERVER_PASSWORD = undefined + mutableFlag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.header()).toBeUndefined() + expect(ServerAuth.headers()).toBeUndefined() + }) + + test("defaults to the opencode username", () => { + mutableFlag.OPENCODE_SERVER_PASSWORD = "secret" + mutableFlag.OPENCODE_SERVER_USERNAME = undefined + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`, + }) + }) + + test("uses the configured username", () => { + mutableFlag.OPENCODE_SERVER_PASSWORD = "secret" + mutableFlag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`, + }) + }) + + test("prefers explicit credentials", () => { + mutableFlag.OPENCODE_SERVER_PASSWORD = "secret" + mutableFlag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({ + Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`, + }) + }) + + test("validates decoded credentials against config", () => { + const config = { password: Option.some("secret"), username: "alice" } + + expect(ServerAuth.required(config)).toBe(true) + expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true) + expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false) + }) +}) + +describe("AuthMiddleware", () => { + const app = () => { + const app = new Hono() + app.use(AuthMiddleware) + app.get("/", (c) => c.text("ok")) + return app + } + + test("authorizes auth_token query credentials without requiring a mutable request header", async () => { + mutableFlag.OPENCODE_SERVER_PASSWORD = "secret" + mutableFlag.OPENCODE_SERVER_USERNAME = "alice" + + const token = Buffer.from("alice:secret").toString("base64") + const response = await app().request(`/?auth_token=${encodeURIComponent(token)}`) + + expect(response.status).toBe(200) + expect(await response.text()).toBe("ok") + }) + + test("accepts case-insensitive Basic auth with flexible spacing", async () => { + mutableFlag.OPENCODE_SERVER_PASSWORD = "secret" + mutableFlag.OPENCODE_SERVER_USERNAME = "alice" + + const response = await app().request("/", { + headers: { + authorization: `basic ${Buffer.from("alice:secret").toString("base64")}`, + }, + }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe("ok") + }) + + test("returns a Basic challenge when credentials are missing", async () => { + mutableFlag.OPENCODE_SERVER_PASSWORD = "secret" + mutableFlag.OPENCODE_SERVER_USERNAME = "alice" + + const response = await app().request("/") + + expect(response.status).toBe(401) + expect(response.headers.get("www-authenticate")).toBe('Basic realm="opencode"') + }) + + test("returns a Basic challenge when credentials are invalid", async () => { + mutableFlag.OPENCODE_SERVER_PASSWORD = "secret" + mutableFlag.OPENCODE_SERVER_USERNAME = "alice" + + const response = await app().request("/", { + headers: { + authorization: `Basic ${Buffer.from("alice:wrong").toString("base64")}`, + }, + }) + + expect(response.status).toBe(401) + expect(response.headers.get("www-authenticate")).toBe('Basic realm="opencode"') + }) +}) diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index e536f3c4e..5a9e730bb 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -35,4 +35,16 @@ describe("util.error", () => { expect(data.message).toBe("ResolveMessage: Cannot resolve module") expect(String(data.formatted)).toContain("ResolveMessage") }) + + test("never returns bare {} for opaque object errors", () => { + expect(errorFormat({})).not.toBe("{}") + expect(errorFormat({})).toContain("no message") + + class OpaqueError {} + const opaque = new OpaqueError() + Object.defineProperty(opaque, "secret", { value: "hidden", enumerable: false }) + + expect(errorFormat(opaque)).not.toBe("{}") + expect(errorFormat(opaque)).toContain("OpaqueError") + }) }) diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 2d71d8446..1a5c89b93 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -84,5 +84,21 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp return response }) + client.interceptors.error.use((error, response, request) => { + const isEmpty = + error === undefined || + error === null || + error === "" || + (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) + + if (!isEmpty) return error + + const method = request?.method ?? "?" + const url = request?.url ?? "?" + if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) + + const statusText = response.statusText ? " " + response.statusText : "" + return new Error(`opencode server ${method} ${url} -> ${response.status}${statusText}: (empty response body)`) + }) return new OpencodeClient({ client }) }