mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 04:01:10 -04:00
fix(opencode): redact secrets from config responses
This commit is contained in:
@@ -114,6 +114,77 @@ type Info = ConfigV1.Info & {
|
||||
plugin_origins?: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
const redacted = "[redacted]"
|
||||
|
||||
export function toPublicInfo(info: Info): Info {
|
||||
return {
|
||||
...info,
|
||||
provider: info.provider
|
||||
? Object.fromEntries(
|
||||
Object.entries(info.provider).map(([id, provider]) => [
|
||||
id,
|
||||
{
|
||||
...provider,
|
||||
options: provider.options
|
||||
? (redactProviderOptions(provider.options) as typeof provider.options)
|
||||
: undefined,
|
||||
},
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
mcp: info.mcp
|
||||
? Object.fromEntries(
|
||||
Object.entries(info.mcp).map(([id, server]) => {
|
||||
if (!("type" in server)) return [id, server]
|
||||
if (server.type === "local") {
|
||||
return [
|
||||
id,
|
||||
{
|
||||
...server,
|
||||
environment: server.environment
|
||||
? Object.fromEntries(Object.keys(server.environment).map((key) => [key, redacted]))
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
id,
|
||||
{
|
||||
...server,
|
||||
headers: server.headers
|
||||
? Object.fromEntries(Object.keys(server.headers).map((key) => [key, redacted]))
|
||||
: undefined,
|
||||
oauth:
|
||||
server.oauth && server.oauth.clientSecret
|
||||
? { ...server.oauth, clientSecret: redacted }
|
||||
: server.oauth,
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function redactProviderOptions(value: unknown, key?: string): unknown {
|
||||
const normalized = key?.replaceAll(/[-_]/g, "").toLowerCase()
|
||||
if (
|
||||
normalized &&
|
||||
(normalized.endsWith("apikey") ||
|
||||
normalized.endsWith("token") ||
|
||||
normalized.includes("secret") ||
|
||||
normalized.includes("password") ||
|
||||
normalized.includes("credential") ||
|
||||
normalized === "accesskeyid" ||
|
||||
normalized === "authorization" ||
|
||||
normalized.endsWith("cookie"))
|
||||
)
|
||||
return redacted
|
||||
if (Array.isArray(value)) return value.map((item) => redactProviderOptions(item))
|
||||
if (!isRecord(value)) return value
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactProviderOptions(item, key)]))
|
||||
}
|
||||
|
||||
type State = {
|
||||
config: Info
|
||||
directories: string[]
|
||||
|
||||
@@ -12,7 +12,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
|
||||
const configSvc = yield* Config.Service
|
||||
|
||||
const get = Effect.fn("ConfigHttpApi.get")(function* () {
|
||||
return yield* configSvc.get()
|
||||
return Config.toPublicInfo(yield* configSvc.get())
|
||||
})
|
||||
|
||||
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
|
||||
|
||||
@@ -80,7 +80,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
|
||||
})
|
||||
|
||||
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
|
||||
return yield* config.getGlobal()
|
||||
return Config.toPublicInfo(yield* config.getGlobal())
|
||||
})
|
||||
|
||||
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
|
||||
|
||||
@@ -64,6 +64,93 @@ describe("config HttpApi", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"redacts resolved provider and MCP secrets",
|
||||
Effect.gen(function* () {
|
||||
const secrets = [
|
||||
"CANARY_PROVIDER_API_KEY",
|
||||
"CANARY_PROVIDER_CLIENT_SECRET",
|
||||
"CANARY_PROVIDER_NESTED_TOKEN",
|
||||
"CANARY_MCP_ENV",
|
||||
"CANARY_MCP_HEADER",
|
||||
"CANARY_MCP_CLIENT_SECRET",
|
||||
]
|
||||
const tmp = yield* tmpdirEffect({
|
||||
init: (dir) =>
|
||||
Promise.all(secrets.map((secret, index) => Bun.write(path.join(dir, `secret-${index}`), secret))),
|
||||
config: {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
provider: {
|
||||
canary: {
|
||||
name: "Canary Provider",
|
||||
options: {
|
||||
apiKey: "{file:secret-0}",
|
||||
clientSecret: "{file:secret-1}",
|
||||
nested: { accessToken: "{file:secret-2}", temperature: 0.5 },
|
||||
baseURL: "https://provider.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
local: {
|
||||
type: "local",
|
||||
command: ["canary-command"],
|
||||
environment: { TOKEN: "{file:secret-3}" },
|
||||
enabled: false,
|
||||
},
|
||||
remote: {
|
||||
type: "remote",
|
||||
url: "https://mcp.example.com",
|
||||
headers: { Authorization: "{file:secret-4}" },
|
||||
oauth: { clientId: "canary-client", clientSecret: "{file:secret-5}", scope: "read" },
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request("/config", {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const text = yield* Effect.promise(() => response.text())
|
||||
const body = JSON.parse(text)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
secrets.forEach((secret) => expect(text).not.toContain(secret))
|
||||
expect(body).toMatchObject({
|
||||
provider: {
|
||||
canary: {
|
||||
name: "Canary Provider",
|
||||
options: {
|
||||
apiKey: "[redacted]",
|
||||
clientSecret: "[redacted]",
|
||||
nested: { accessToken: "[redacted]", temperature: 0.5 },
|
||||
baseURL: "https://provider.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
local: {
|
||||
command: ["canary-command"],
|
||||
environment: { TOKEN: "[redacted]" },
|
||||
},
|
||||
remote: {
|
||||
url: "https://mcp.example.com",
|
||||
headers: { Authorization: "[redacted]" },
|
||||
oauth: { clientId: "canary-client", clientSecret: "[redacted]", scope: "read" },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"serves config with active provider model status",
|
||||
Effect.gen(function* () {
|
||||
|
||||
Reference in New Issue
Block a user