Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline 8f62645677 fix(opencode): make mcp env handling portable 2026-06-24 16:59:07 -05:00
Aiden Cline d1adfdba16 fix(opencode): restrict local mcp environment 2026-06-24 16:24:48 -05:00
7 changed files with 161 additions and 205 deletions
-90
View File
@@ -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[]
+69 -5
View File
@@ -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* () {