From d1adfdba160d0f7f0eeca8f60aae5bd08e9fdaf7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 24 Jun 2026 16:24:48 -0500 Subject: [PATCH 1/2] fix(opencode): restrict local mcp environment --- packages/opencode/src/mcp/index.ts | 68 +++++++++++++++++-- .../opencode/test/fixture/mcp-environment.ts | 21 ++++++ .../opencode/test/mcp/environment.test.ts | 62 +++++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/test/fixture/mcp-environment.ts create mode 100644 packages/opencode/test/mcp/environment.test.ts diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index db673244b3..585e339a23 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -117,6 +117,53 @@ type ResourceInfo = Awaited>["resources"] type ResourceTemplateInfo = Awaited>["resourceTemplates"][number] type McpEntry = NonNullable[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,21 @@ function remoteURL(value: string) { if (URL.canParse(value)) return new URL(value) } +function localMcpEnvironment(command: string, environment?: Record) { + 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] + }), + ) + return { + ...inherited, + ...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}), + ...environment, + } +} + interface CreateResult { mcpClient?: MCPClient status: Status @@ -338,11 +400,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 diff --git a/packages/opencode/test/fixture/mcp-environment.ts b/packages/opencode/test/fixture/mcp-environment.ts new file mode 100644 index 0000000000..f8a6e2f5c3 --- /dev/null +++ b/packages/opencode/test/fixture/mcp-environment.ts @@ -0,0 +1,21 @@ +import readline from "node:readline" + +await Bun.write(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) as { id?: number; method: string; params?: { protocolVersion?: string } } + 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`, + ) +}) diff --git a/packages/opencode/test/mcp/environment.test.ts b/packages/opencode/test/mcp/environment.test.ts new file mode 100644 index 0000000000..68fdffecfd --- /dev/null +++ b/packages/opencode/test/mcp/environment.test.ts @@ -0,0 +1,62 @@ +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) + +it.instance( + "local subprocess receives only baseline and configured environment", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const values = { + APPDATA: path.join(test.directory, "appdata"), + LC_TIME: "C", + OPENCODE_MCP_PARENT_SECRET: "parent-secret", + PATHEXT: ".EXE;.CMD", + SYSTEMROOT: path.join(test.directory, "windows"), + TMPDIR: test.directory, + } + const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]])) + Object.assign(process.env, values) + + 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.ts")], + environment: { + MCP_ENV_OUTPUT: output, + MCP_EXPLICIT_TOKEN: "configured-token", + }, + }) + + expect(result.status.environment).toEqual({ status: "connected" }) + const env = (yield* Effect.promise(() => Bun.file(output).json())) as Record + expect(env.OPENCODE_MCP_PARENT_SECRET).toBeUndefined() + expect(env.MCP_EXPLICIT_TOKEN).toBe("configured-token") + expect(env.PATH).toBe(process.env.PATH!) + expect(env.HOME).toBe(process.env.HOME!) + expect(env.TMPDIR).toBe(values.TMPDIR) + expect(env.LC_TIME).toBe(values.LC_TIME) + expect(env.APPDATA).toBe(values.APPDATA) + expect(env.PATHEXT).toBe(values.PATHEXT) + expect(env.SYSTEMROOT).toBe(values.SYSTEMROOT) + }).pipe(Effect.ensuring(mcp.disconnect("environment").pipe(Effect.ignore))), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ), + ) + }), + { config: { mcp: {} } }, +) From 8f62645677a919e4ac318091c526f7ced983c2d4 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 24 Jun 2026 16:59:07 -0500 Subject: [PATCH 2/2] fix(opencode): make mcp env handling portable --- packages/opencode/src/mcp/index.ts | 8 ++- ...{mcp-environment.ts => mcp-environment.js} | 5 +- .../opencode/test/mcp/environment.test.ts | 50 +++++++++++-------- 3 files changed, 38 insertions(+), 25 deletions(-) rename packages/opencode/test/fixture/{mcp-environment.ts => mcp-environment.js} (73%) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 585e339a23..6df9d4a20b 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -180,9 +180,15 @@ function localMcpEnvironment(command: string, environment?: Record key.toUpperCase())) + return { + ...Object.fromEntries(Object.entries(defaults).filter(([key]) => !configured.has(key.toUpperCase()))), ...environment, } } diff --git a/packages/opencode/test/fixture/mcp-environment.ts b/packages/opencode/test/fixture/mcp-environment.js similarity index 73% rename from packages/opencode/test/fixture/mcp-environment.ts rename to packages/opencode/test/fixture/mcp-environment.js index f8a6e2f5c3..cd3a228440 100644 --- a/packages/opencode/test/fixture/mcp-environment.ts +++ b/packages/opencode/test/fixture/mcp-environment.js @@ -1,11 +1,12 @@ import readline from "node:readline" +import { writeFile } from "node:fs/promises" -await Bun.write(process.env.MCP_ENV_OUTPUT!, JSON.stringify(process.env)) +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) as { id?: number; method: string; params?: { protocolVersion?: string } } + const request = JSON.parse(line) if (request.method !== "initialize") return process.stdout.write( `${JSON.stringify({ diff --git a/packages/opencode/test/mcp/environment.test.ts b/packages/opencode/test/mcp/environment.test.ts index 68fdffecfd..bb57967a64 100644 --- a/packages/opencode/test/mcp/environment.test.ts +++ b/packages/opencode/test/mcp/environment.test.ts @@ -6,54 +6,60 @@ 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 values = { - APPDATA: path.join(test.directory, "appdata"), - LC_TIME: "C", - OPENCODE_MCP_PARENT_SECRET: "parent-secret", - PATHEXT: ".EXE;.CMD", - SYSTEMROOT: path.join(test.directory, "windows"), - TMPDIR: test.directory, - } - const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]])) - Object.assign(process.env, values) + 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.ts")], + 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 expect(env.OPENCODE_MCP_PARENT_SECRET).toBeUndefined() expect(env.MCP_EXPLICIT_TOKEN).toBe("configured-token") - expect(env.PATH).toBe(process.env.PATH!) - expect(env.HOME).toBe(process.env.HOME!) - expect(env.TMPDIR).toBe(values.TMPDIR) - expect(env.LC_TIME).toBe(values.LC_TIME) - expect(env.APPDATA).toBe(values.APPDATA) - expect(env.PATHEXT).toBe(values.PATHEXT) - expect(env.SYSTEMROOT).toBe(values.SYSTEMROOT) + 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(() => { - Object.entries(previous).forEach(([key, value]) => { - if (value === undefined) delete process.env[key] - else process.env[key] = value - }) + if (previous === undefined) delete process.env.OPENCODE_MCP_PARENT_SECRET + else process.env.OPENCODE_MCP_PARENT_SECRET = previous }), ), )