mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f62645677 | |||
| d1adfdba16 |
@@ -114,96 +114,6 @@ 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,
|
||||
models: provider.models
|
||||
? Object.fromEntries(
|
||||
Object.entries(provider.models).map(([id, model]) => [
|
||||
id,
|
||||
{
|
||||
...model,
|
||||
options: model.options
|
||||
? (redactProviderOptions(model.options) as typeof model.options)
|
||||
: undefined,
|
||||
headers: model.headers
|
||||
? Object.fromEntries(Object.keys(model.headers).map((key) => [key, redacted]))
|
||||
: undefined,
|
||||
},
|
||||
]),
|
||||
)
|
||||
: 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 === "headers" && isRecord(value)) {
|
||||
return Object.fromEntries(Object.keys(value).map((key) => [key, redacted]))
|
||||
}
|
||||
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[]
|
||||
|
||||
@@ -117,6 +117,53 @@ type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"]
|
||||
type ResourceTemplateInfo = Awaited<ReturnType<MCPClient["listResourceTemplates"]>>["resourceTemplates"][number]
|
||||
type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
|
||||
|
||||
const LOCAL_MCP_INHERITED_ENV = [
|
||||
"APPDATA",
|
||||
"COMSPEC",
|
||||
"HOME",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ADDRESS",
|
||||
"LC_ALL",
|
||||
"LC_COLLATE",
|
||||
"LC_CTYPE",
|
||||
"LC_IDENTIFICATION",
|
||||
"LC_MEASUREMENT",
|
||||
"LC_MESSAGES",
|
||||
"LC_MONETARY",
|
||||
"LC_NAME",
|
||||
"LC_NUMERIC",
|
||||
"LC_PAPER",
|
||||
"LC_TELEPHONE",
|
||||
"LC_TIME",
|
||||
"LOCALAPPDATA",
|
||||
"LOGNAME",
|
||||
"PATH",
|
||||
"PATHEXT",
|
||||
"PROCESSOR_ARCHITECTURE",
|
||||
"PROGRAMDATA",
|
||||
"PROGRAMFILES",
|
||||
"PROGRAMFILES(X86)",
|
||||
"SHELL",
|
||||
"SYSTEMDRIVE",
|
||||
"SYSTEMROOT",
|
||||
"TEMP",
|
||||
"TERM",
|
||||
"TMP",
|
||||
"TMPDIR",
|
||||
"USER",
|
||||
"USERNAME",
|
||||
"USERPROFILE",
|
||||
"WINDIR",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"XDG_STATE_HOME",
|
||||
] as const
|
||||
|
||||
function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info {
|
||||
return typeof entry === "object" && entry !== null && "type" in entry
|
||||
}
|
||||
@@ -125,6 +172,27 @@ function remoteURL(value: string) {
|
||||
if (URL.canParse(value)) return new URL(value)
|
||||
}
|
||||
|
||||
function localMcpEnvironment(command: string, environment?: Record<string, string>) {
|
||||
const inherited = Object.fromEntries(
|
||||
LOCAL_MCP_INHERITED_ENV.flatMap((key) => {
|
||||
const value = process.env[key]
|
||||
if (value === undefined || value.startsWith("()")) return []
|
||||
return [[key, value] as const]
|
||||
}),
|
||||
)
|
||||
const defaults = {
|
||||
...inherited,
|
||||
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
}
|
||||
if (process.platform !== "win32" || !environment) return { ...defaults, ...environment }
|
||||
|
||||
const configured = new Set(Object.keys(environment).map((key) => key.toUpperCase()))
|
||||
return {
|
||||
...Object.fromEntries(Object.entries(defaults).filter(([key]) => !configured.has(key.toUpperCase()))),
|
||||
...environment,
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateResult {
|
||||
mcpClient?: MCPClient
|
||||
status: Status
|
||||
@@ -338,11 +406,7 @@ export const layer = Layer.effect(
|
||||
command: cmd,
|
||||
args,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...mcp.environment,
|
||||
},
|
||||
env: localMcpEnvironment(cmd, mcp.environment),
|
||||
})
|
||||
|
||||
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
||||
|
||||
@@ -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 Config.toPublicInfo(yield* configSvc.get())
|
||||
return 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 Config.toPublicInfo(yield* config.getGlobal())
|
||||
return yield* config.getGlobal()
|
||||
})
|
||||
|
||||
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import readline from "node:readline"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
|
||||
await writeFile(process.env.MCP_ENV_OUTPUT, JSON.stringify(process.env))
|
||||
|
||||
const lines = readline.createInterface({ input: process.stdin })
|
||||
lines.on("close", () => process.exit(0))
|
||||
lines.on("line", (line) => {
|
||||
const request = JSON.parse(line)
|
||||
if (request.method !== "initialize") return
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id,
|
||||
result: {
|
||||
protocolVersion: request.params?.protocolVersion,
|
||||
capabilities: {},
|
||||
serverInfo: { name: "environment-test", version: "1" },
|
||||
},
|
||||
})}\n`,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { MCP } from "../../src/mcp/index"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(MCP.defaultLayer)
|
||||
const inherited = [
|
||||
"APPDATA",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LOCALAPPDATA",
|
||||
"PATH",
|
||||
"PATHEXT",
|
||||
"SYSTEMROOT",
|
||||
"TEMP",
|
||||
"TMPDIR",
|
||||
"USERPROFILE",
|
||||
] as const
|
||||
|
||||
it.instance(
|
||||
"local subprocess receives only baseline and configured environment",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const previous = process.env.OPENCODE_MCP_PARENT_SECRET
|
||||
process.env.OPENCODE_MCP_PARENT_SECRET = "parent-secret"
|
||||
|
||||
yield* MCP.Service.use((mcp) =>
|
||||
Effect.gen(function* () {
|
||||
const output = path.join(test.directory, "environment.json")
|
||||
const result = yield* mcp.add("environment", {
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "../fixture/mcp-environment.js")],
|
||||
environment: {
|
||||
MCP_ENV_OUTPUT: output,
|
||||
MCP_EXPLICIT_TOKEN: "configured-token",
|
||||
...(process.platform === "win32" ? { Path: path.dirname(process.execPath) } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!("environment" in result.status)) throw new Error("Expected MCP status map")
|
||||
expect(result.status.environment).toEqual({ status: "connected" })
|
||||
const env = (yield* Effect.promise(() => Bun.file(output).json())) as Record<string, string>
|
||||
expect(env.OPENCODE_MCP_PARENT_SECRET).toBeUndefined()
|
||||
expect(env.MCP_EXPLICIT_TOKEN).toBe("configured-token")
|
||||
inherited.forEach((key) => {
|
||||
if (process.platform === "win32" && key === "PATH") return
|
||||
if (process.env[key] !== undefined) expect(env[key]).toBe(process.env[key])
|
||||
})
|
||||
if (process.platform === "win32") {
|
||||
expect(Object.entries(env).find(([key]) => key.toUpperCase() === "PATH")?.[1]).toBe(
|
||||
path.dirname(process.execPath),
|
||||
)
|
||||
}
|
||||
}).pipe(Effect.ensuring(mcp.disconnect("environment").pipe(Effect.ignore))),
|
||||
).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_MCP_PARENT_SECRET
|
||||
else process.env.OPENCODE_MCP_PARENT_SECRET = previous
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ config: { mcp: {} } },
|
||||
)
|
||||
@@ -64,114 +64,6 @@ 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_PROVIDER_HEADER",
|
||||
"CANARY_MODEL_API_KEY",
|
||||
"CANARY_MODEL_HEADER",
|
||||
"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 },
|
||||
headers: { "x-custom-auth": "{file:secret-3}" },
|
||||
baseURL: "https://provider.example.com",
|
||||
},
|
||||
models: {
|
||||
canary: {
|
||||
name: "Canary Model",
|
||||
status: "active",
|
||||
options: { apiKey: "{file:secret-4}", temperature: 0.7 },
|
||||
headers: { "x-custom-auth": "{file:secret-5}" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
local: {
|
||||
type: "local",
|
||||
command: ["canary-command"],
|
||||
environment: { TOKEN: "{file:secret-6}" },
|
||||
enabled: false,
|
||||
},
|
||||
remote: {
|
||||
type: "remote",
|
||||
url: "https://mcp.example.com",
|
||||
headers: { Authorization: "{file:secret-7}" },
|
||||
oauth: { clientId: "canary-client", clientSecret: "{file:secret-8}", 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 },
|
||||
headers: { "x-custom-auth": "[redacted]" },
|
||||
baseURL: "https://provider.example.com",
|
||||
},
|
||||
models: {
|
||||
canary: {
|
||||
name: "Canary Model",
|
||||
status: "active",
|
||||
options: { apiKey: "[redacted]", temperature: 0.7 },
|
||||
headers: { "x-custom-auth": "[redacted]" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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