fix(opencode): redact credentials in debug config (#50956)

Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-09-23 17:35:46 -05:00 committed by GitHub
parent 1d6c3c0e29
commit 82d4c89031
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 84 additions and 2 deletions

View file

@ -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)
}),
})

View file

@ -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")]
}),
)
}

View file

@ -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)
}),
)
})