diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index 65e230b1bf..2e6ff6f684 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -1,14 +1,14 @@ import { EOL } from "os" import { Effect } from "effect" import { effectCmd } from "../../effect-cmd" +import { redactConfig } from "./redact" export const ConfigCommand = effectCmd({ command: "config", describe: "show resolved configuration", - builder: (yargs) => yargs, handler: Effect.fn("Cli.debug.config")(function* () { const { Config } = yield* Effect.promise(() => import("@/config/config")) const config = yield* Config.Service.use((cfg) => cfg.get()) - process.stdout.write(JSON.stringify(config, null, 2) + EOL) + process.stdout.write(JSON.stringify(redactConfig(config), null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/redact.ts b/packages/opencode/src/cli/cmd/debug/redact.ts new file mode 100644 index 0000000000..1b14da9994 --- /dev/null +++ b/packages/opencode/src/cli/cmd/debug/redact.ts @@ -0,0 +1,20 @@ +// Redact only the debug output; the resolved configuration must remain usable by providers. +const secretName = /(?:api.?key|secret|password|token$|authorization$|cookie$|credential|private.?key)/i + +export function redactConfig(value: unknown, headers = false): unknown { + if (Array.isArray(value)) return value.map((item) => redactConfig(item, headers)) + if (value === null || typeof value !== "object") return value + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => { + if (typeof item === "string" && (headers || secretName.test(key))) return [key, "***"] + if (typeof item === "string" && /^https?:\/\//i.test(item)) { + if (!URL.canParse(item)) return [key, "***"] + const url = new URL(item) + if (url.username || url.password || [...url.searchParams.keys()].some((name) => secretName.test(name))) + return [key, "***"] + } + return [key, redactConfig(item, headers || key.toLowerCase() === "headers")] + }), + ) +} diff --git a/packages/opencode/test/cli/debug-config.test.ts b/packages/opencode/test/cli/debug-config.test.ts new file mode 100644 index 0000000000..fe14270438 --- /dev/null +++ b/packages/opencode/test/cli/debug-config.test.ts @@ -0,0 +1,62 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { cliIt } from "../lib/cli-process" +import { redactConfig } from "@/cli/cmd/debug/redact" + +const config = { + provider: { + example: { + options: { + apiKey: "sk-example", + timeout: 1200, + headers: { Authorization: "Bearer example", "X-API-Key": "key" }, + }, + models: { demo: { variants: { fast: { api_key: "variant-secret" } } } }, + }, + }, + mcp: { + remote: { oauth: { clientSecret: "oauth-secret", clientId: "public" }, headers: { "x-custom": "opaque" } }, + local: { environment: { SERVICE_TOKEN: "env-secret", PATH: "/usr/bin" } }, + }, + url: "https://user:pass@example.com/path", + normal: { context_tokens: 200000, name: "example" }, +} + +describe("debug config redaction", () => { + cliIt.live("always masks resolved credentials", ({ opencode }) => + Effect.gen(function* () { + const content = JSON.stringify({ provider: config.provider }) + const env = { OPENCODE_CONFIG_CONTENT: content } + const result = yield* opencode.spawn(["debug", "config"], { env }) + opencode.expectExit(result, 0, "debug config") + expect(JSON.parse(result.stdout).provider.example.options).toMatchObject({ + apiKey: "***", + timeout: 1200, + headers: { Authorization: "***", "X-API-Key": "***" }, + }) + expect(result.stdout).not.toContain("sk-example") + expect(result.stdout).not.toContain("Bearer example") + }), + ) + + cliIt.live("does not mutate the input and preserves unrelated settings", () => + Effect.sync(() => { + const before = structuredClone(config) + expect(redactConfig(config)).toEqual({ + provider: { + example: { + options: { apiKey: "***", timeout: 1200, headers: { Authorization: "***", "X-API-Key": "***" } }, + models: { demo: { variants: { fast: { api_key: "***" } } } }, + }, + }, + mcp: { + remote: { oauth: { clientSecret: "***", clientId: "public" }, headers: { "x-custom": "***" } }, + local: { environment: { SERVICE_TOKEN: "***", PATH: "/usr/bin" } }, + }, + url: "***", + normal: config.normal, + }) + expect(config).toEqual(before) + }), + ) +})