From 6c13bf7fa8247c0a193d336cc3a2a7db92b2c5dd Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:16:56 +0200 Subject: [PATCH 1/4] fix(mcp): stop handing stdio servers every secret in executor's environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP SDK ships a sudo-style safe-list for exactly this — HOME, LOGNAME, PATH, SHELL, TERM, USER — and merges whatever env you pass on top of it. Spreading process.env into that did not extend the safe-list, it defeated it. So any stdio MCP server received every variable this process holds: EXECUTOR_SECRET_KEY, which decrypts the whole secret store, plus EXECUTOR_AUTH_TOKEN, DATABASE_URL and anything else the operator exported. Adding one third-party server went from "it sees its own API key" to "it holds the key to everyone else's". The leak was on the declared-env branch only. With no env configured the SDK's safe-list already applied, so the branch a credential-bearing integration takes was the unsafe one. Pass only what the integration declared and let the SDK apply its own base. --- .../plugins/mcp/src/sdk/stdio-connector.ts | 13 ++- .../mcp/src/sdk/stdio-env-isolation.test.ts | 101 ++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts diff --git a/packages/plugins/mcp/src/sdk/stdio-connector.ts b/packages/plugins/mcp/src/sdk/stdio-connector.ts index 99a0f72e3..3a431e6ed 100644 --- a/packages/plugins/mcp/src/sdk/stdio-connector.ts +++ b/packages/plugins/mcp/src/sdk/stdio-connector.ts @@ -26,6 +26,17 @@ export const createStdioTransport = (config: StdioTransportConfig) => new StdioClientTransport({ command: config.command, args: config.args ? [...config.args] : undefined, - env: config.env ? ({ ...process.env, ...config.env } as Record) : undefined, + // Pass only what the integration declared. The SDK already merges this + // over `getDefaultEnvironment()`, a sudo-style safe-list (HOME, LOGNAME, + // PATH, SHELL, TERM, USER) that deliberately excludes everything else and + // skips function-shaped values as a security risk. + // + // Spreading `process.env` here did not add to that safe-list, it defeated + // it: the child received every variable this process holds, which for a + // server that spawns one includes `EXECUTOR_SECRET_KEY` (the key that + // decrypts the secret store), `EXECUTOR_AUTH_TOKEN`, `DATABASE_URL` and + // whatever else the operator exported. A stdio server needing one of + // those declares it in the integration's `env` like any other value. + env: config.env, cwd: config.cwd, }); diff --git a/packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts b/packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts new file mode 100644 index 000000000..0c254e6aa --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts @@ -0,0 +1,101 @@ +// What environment does a stdio MCP server actually receive? +// +// This spawns a real subprocess through the real transport and reads back the +// environment that process was handed. Asserting on the arguments we pass to +// the SDK would not answer the question — the SDK merges its own safe-list +// underneath ours, so the only honest answer comes from the child itself. +// +// The child is a plain node script rather than an MCP server: it is spawned by +// the same code path either way, and speaking the protocol would add nothing +// to what is being measured. It never completes a handshake, so the transport +// is closed once the file has been written. + +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "@effect/vitest"; + +import { createStdioTransport } from "./stdio-connector"; + +/** A variable only this test sets, standing in for a real one like + * `EXECUTOR_SECRET_KEY`. Using a fake keeps the test honest on a machine + * where the real one happens not to be set. */ +const HOST_ONLY_SECRET = "EXECUTOR_TEST_HOST_ONLY_SECRET"; +const HOST_ONLY_VALUE = "host-secret-that-must-not-reach-a-child"; + +const dirs: string[] = []; + +afterEach(() => { + delete process.env[HOST_ONLY_SECRET]; + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** Spawn a child through the transport and return the environment it saw. */ +const envSeenByChild = async ( + declared: Record | undefined, +): Promise> => { + const dir = mkdtempSync(join(tmpdir(), "executor-stdio-env-")); + dirs.push(dir); + const out = join(dir, "env.json"); + + const transport = createStdioTransport({ + command: process.execPath, + args: [ + "-e", + "require('node:fs').writeFileSync(process.argv[1], JSON.stringify(process.env))", + out, + ], + env: declared, + }); + + await transport.start(); + // The child writes and exits; poll briefly rather than assuming timing. + for (let i = 0; i < 100 && !existsSync(out); i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await transport.close(); + + // oxlint-disable-next-line executor/no-json-parse -- boundary: reading back the raw env dump this test's own child process just wrote; the value is only key-checked, never decoded into domain types + return JSON.parse(readFileSync(out, "utf8")) as Record; +}; + +describe("environment handed to a stdio MCP subprocess", () => { + it("does not leak a host secret to a server that declares its own env", async () => { + // The declared-env branch is the one that matters: it is the branch a + // credential-bearing integration takes, and it was the leaking one. + process.env[HOST_ONLY_SECRET] = HOST_ONLY_VALUE; + + const childEnv = await envSeenByChild({ DECLARED_TOKEN: "declared-value" }); + + expect(childEnv[HOST_ONLY_SECRET]).toBeUndefined(); + // ...and the thing the integration actually asked for still arrives. + expect(childEnv.DECLARED_TOKEN).toBe("declared-value"); + }); + + it("does not leak a host secret to a server that declares no env", async () => { + process.env[HOST_ONLY_SECRET] = HOST_ONLY_VALUE; + + const childEnv = await envSeenByChild(undefined); + + expect(childEnv[HOST_ONLY_SECRET]).toBeUndefined(); + }); + + it("still provides the SDK's safe-list, so servers keep working", async () => { + // The fix must not strand servers that legitimately need PATH to find + // their own interpreter. The SDK's list is what supplies it. + const childEnv = await envSeenByChild({ DECLARED_TOKEN: "declared-value" }); + + expect(childEnv.PATH).toBeDefined(); + expect(childEnv.HOME).toBeDefined(); + }); + + it("POSITIVE CONTROL: the child does report a variable when it is passed one", async () => { + // Proves the measurement works. Without this, a child that failed to + // write, or wrote an empty object, would satisfy every assertion above. + process.env[HOST_ONLY_SECRET] = HOST_ONLY_VALUE; + + const childEnv = await envSeenByChild({ [HOST_ONLY_SECRET]: HOST_ONLY_VALUE }); + + expect(childEnv[HOST_ONLY_SECRET]).toBe(HOST_ONLY_VALUE); + }); +}); From b1e651c476cb30d5509ef7b12aed7f477500e882 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:15:51 -0700 Subject: [PATCH 2/4] Inherit proxy and CA variables into stdio MCP servers The env fix drops process.env from the spawn, which also drops the host's proxy and TLS trust configuration. No source config declares those and a server behind a corporate proxy or an intercepting CA cannot reach anything without them, so pass a fixed allowlist beneath the declared env: HTTP_PROXY, HTTPS_PROXY, NO_PROXY (both spellings), NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, SSL_CERT_DIR. Same list and same reasoning as the pass-through in apps/cli/src/service.ts. The declared env still wins on a key collision. Tests cover the allowlist in both directions and the child now reports only the keys under test instead of dumping the whole environment to a temp file. Adds the changeset. --- .changeset/mcp-stdio-env-isolation.md | 11 +++ .../plugins/mcp/src/sdk/stdio-connector.ts | 52 +++++++++-- .../mcp/src/sdk/stdio-env-isolation.test.ts | 86 ++++++++++++++++--- 3 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 .changeset/mcp-stdio-env-isolation.md diff --git a/.changeset/mcp-stdio-env-isolation.md b/.changeset/mcp-stdio-env-isolation.md new file mode 100644 index 000000000..c57015f50 --- /dev/null +++ b/.changeset/mcp-stdio-env-isolation.md @@ -0,0 +1,11 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +**Stdio MCP servers no longer inherit executor's full environment** + +A stdio MCP server that declared any `env` at all was spawned with every environment variable this process holds. The MCP SDK already guards against that: it spawns with `{ ...getDefaultEnvironment(), ...serverParams.env }`, where `getDefaultEnvironment()` is a sudo-style safe-list of `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM` and `USER`. Passing `{ ...process.env, ...config.env }` did not add to that safe-list, it overwrote it. In practice, adding one third-party `npx` server went from "this server can see the API key I gave it" to "this server also holds `EXECUTOR_SECRET_KEY`, the key that decrypts every other stored credential, plus `EXECUTOR_AUTH_TOKEN` and `DATABASE_URL`". The leak sat on the `config.env` branch — the branch a credential-bearing integration takes. + +A stdio server now receives the SDK's safe-list, the variables declared on the source config, and one short allowlist of infrastructure variables read from the host: `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` (both spellings), `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE` and `SSL_CERT_DIR`. Those carry no credential, no source config declares them, and a server behind a corporate proxy or an intercepting CA cannot reach anything without them — the same reasoning and the same list `service install` already uses when it bakes a supervised unit's minimal environment. The declared `env` wins on a key collision. + +If a stdio server relied on some other variable arriving from the host, set it explicitly on the source's `env`. That is now the only way anything beyond the lists above reaches a server, and it is the mechanism that already existed for it. diff --git a/packages/plugins/mcp/src/sdk/stdio-connector.ts b/packages/plugins/mcp/src/sdk/stdio-connector.ts index 6c2e47b9a..3cd854a8c 100644 --- a/packages/plugins/mcp/src/sdk/stdio-connector.ts +++ b/packages/plugins/mcp/src/sdk/stdio-connector.ts @@ -23,21 +23,59 @@ export type StdioTransportConfig = { readonly cwd?: string; }; +/** + * Host variables a stdio server inherits, on top of the SDK's own safe-list. + * + * Same reasoning and same list as the TLS pass-through `service install` bakes + * into a supervised unit's minimal environment (`apps/cli/src/service.ts`): a + * stdio server sits behind the same corporate proxy and the same intercepting + * CA as the process that spawned it, and those paths commonly live outside the + * OS trust store. Dropping them makes every HTTPS call from every stdio server + * fail on such a network. None of them carries a credential. + * + * Deliberately short and closed. Anything else a server needs — an API key + * above all — is declared on the source config's `env`, which is the mechanism + * that already exists for exactly that, and which wins on a key collision. + */ +const inheritedEnvKeys = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + // Lowercase spellings are not aliases: libcurl and most Unix tooling read + // these, while Node reads the uppercase ones. Both are in real use. + "http_proxy", + "https_proxy", + "no_proxy", + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "SSL_CERT_DIR", +] as const; + +/** Read at spawn time, not module load: the host's proxy configuration can be + * set after this module is first imported. */ +const inheritedEnv = (): Record => + Object.fromEntries( + inheritedEnvKeys.flatMap((key) => { + const value = process.env[key]; + return value ? [[key, value] as const] : []; + }), + ); + export const createStdioTransport = (config: StdioTransportConfig) => new StdioClientTransport({ command: config.command, args: config.args ? [...config.args] : undefined, - // Pass only what the integration declared. The SDK already merges this - // over `getDefaultEnvironment()`, a sudo-style safe-list (HOME, LOGNAME, - // PATH, SHELL, TERM, USER) that deliberately excludes everything else and - // skips function-shaped values as a security risk. + // Pass the declared env plus the infrastructure allowlist above, and + // nothing else. The SDK merges this over `getDefaultEnvironment()`, a + // sudo-style safe-list (HOME, LOGNAME, PATH, SHELL, TERM, USER) that + // deliberately excludes everything else and skips function-shaped values + // as a security risk. // // Spreading `process.env` here did not add to that safe-list, it defeated // it: the child received every variable this process holds, which for a // server that spawns one includes `EXECUTOR_SECRET_KEY` (the key that // decrypts the secret store), `EXECUTOR_AUTH_TOKEN`, `DATABASE_URL` and - // whatever else the operator exported. A stdio server needing one of - // those declares it in the integration's `env` like any other value. - env: config.env, + // whatever else the operator exported. + env: { ...inheritedEnv(), ...config.env }, cwd: config.cwd, }); diff --git a/packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts b/packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts index 0c254e6aa..61439929c 100644 --- a/packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts +++ b/packages/plugins/mcp/src/sdk/stdio-env-isolation.test.ts @@ -9,6 +9,10 @@ // the same code path either way, and speaking the protocol would add nothing // to what is being measured. It never completes a handshake, so the transport // is closed once the file has been written. +// +// The child reports only the keys a test names. Dumping the whole environment +// would write the runner's own secrets to a temp file to answer a question +// about a handful of variables. import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -24,15 +28,31 @@ const HOST_ONLY_SECRET = "EXECUTOR_TEST_HOST_ONLY_SECRET"; const HOST_ONLY_VALUE = "host-secret-that-must-not-reach-a-child"; const dirs: string[] = []; +/** Host variables a test set, restored rather than deleted: the machine + * running this may legitimately be behind a proxy. */ +const restore = new Map(); + +const setHostEnv = (key: string, value: string): void => { + if (!restore.has(key)) restore.set(key, process.env[key]); + process.env[key] = value; +}; afterEach(() => { - delete process.env[HOST_ONLY_SECRET]; + for (const [key, value] of restore) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + restore.clear(); for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); -/** Spawn a child through the transport and return the environment it saw. */ +/** + * Spawn a child through the transport and return which of `probe` it saw. + * Keys the child did not receive are absent from the result. + */ const envSeenByChild = async ( declared: Record | undefined, + probe: ReadonlyArray, ): Promise> => { const dir = mkdtempSync(join(tmpdir(), "executor-stdio-env-")); dirs.push(dir); @@ -42,8 +62,12 @@ const envSeenByChild = async ( command: process.execPath, args: [ "-e", - "require('node:fs').writeFileSync(process.argv[1], JSON.stringify(process.env))", + "const [out, ...keys] = process.argv.slice(1);" + + "require('node:fs').writeFileSync(out, JSON.stringify(Object.fromEntries(" + + "keys.flatMap((k) => (process.env[k] === undefined ? [] : [[k, process.env[k]]]))" + + ")))", out, + ...probe, ], env: declared, }); @@ -63,9 +87,12 @@ describe("environment handed to a stdio MCP subprocess", () => { it("does not leak a host secret to a server that declares its own env", async () => { // The declared-env branch is the one that matters: it is the branch a // credential-bearing integration takes, and it was the leaking one. - process.env[HOST_ONLY_SECRET] = HOST_ONLY_VALUE; + setHostEnv(HOST_ONLY_SECRET, HOST_ONLY_VALUE); - const childEnv = await envSeenByChild({ DECLARED_TOKEN: "declared-value" }); + const childEnv = await envSeenByChild({ DECLARED_TOKEN: "declared-value" }, [ + HOST_ONLY_SECRET, + "DECLARED_TOKEN", + ]); expect(childEnv[HOST_ONLY_SECRET]).toBeUndefined(); // ...and the thing the integration actually asked for still arrives. @@ -73,9 +100,9 @@ describe("environment handed to a stdio MCP subprocess", () => { }); it("does not leak a host secret to a server that declares no env", async () => { - process.env[HOST_ONLY_SECRET] = HOST_ONLY_VALUE; + setHostEnv(HOST_ONLY_SECRET, HOST_ONLY_VALUE); - const childEnv = await envSeenByChild(undefined); + const childEnv = await envSeenByChild(undefined, [HOST_ONLY_SECRET]); expect(childEnv[HOST_ONLY_SECRET]).toBeUndefined(); }); @@ -83,18 +110,57 @@ describe("environment handed to a stdio MCP subprocess", () => { it("still provides the SDK's safe-list, so servers keep working", async () => { // The fix must not strand servers that legitimately need PATH to find // their own interpreter. The SDK's list is what supplies it. - const childEnv = await envSeenByChild({ DECLARED_TOKEN: "declared-value" }); + const childEnv = await envSeenByChild({ DECLARED_TOKEN: "declared-value" }, ["PATH", "HOME"]); expect(childEnv.PATH).toBeDefined(); expect(childEnv.HOME).toBeDefined(); }); + it("passes the host's proxy and CA configuration through, but nothing beside it", async () => { + // A server behind a corporate proxy or an intercepting CA cannot reach + // anything without these, and no source config declares them. Inheriting + // this short list is the whole difference between the fix landing and the + // fix breaking every install on such a network. + setHostEnv("HTTPS_PROXY", "http://proxy.invalid:3128"); + setHostEnv("no_proxy", "localhost,127.0.0.1"); + setHostEnv("NODE_EXTRA_CA_CERTS", "/etc/ssl/certs/corporate.pem"); + setHostEnv(HOST_ONLY_SECRET, HOST_ONLY_VALUE); + + const childEnv = await envSeenByChild(undefined, [ + "HTTPS_PROXY", + "no_proxy", + "NODE_EXTRA_CA_CERTS", + HOST_ONLY_SECRET, + ]); + + expect(childEnv.HTTPS_PROXY).toBe("http://proxy.invalid:3128"); + expect(childEnv.no_proxy).toBe("localhost,127.0.0.1"); + expect(childEnv.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/corporate.pem"); + // The allowlist is an allowlist: a secret sitting beside those in the same + // environment still does not travel. + expect(childEnv[HOST_ONLY_SECRET]).toBeUndefined(); + }); + + it("lets the source config override an inherited proxy value", async () => { + // The allowlist is merged underneath the declared env, so a source that + // needs its own egress route is not overruled by the host's. + setHostEnv("HTTPS_PROXY", "http://host-proxy.invalid:3128"); + + const childEnv = await envSeenByChild({ HTTPS_PROXY: "http://declared-proxy.invalid:8080" }, [ + "HTTPS_PROXY", + ]); + + expect(childEnv.HTTPS_PROXY).toBe("http://declared-proxy.invalid:8080"); + }); + it("POSITIVE CONTROL: the child does report a variable when it is passed one", async () => { // Proves the measurement works. Without this, a child that failed to // write, or wrote an empty object, would satisfy every assertion above. - process.env[HOST_ONLY_SECRET] = HOST_ONLY_VALUE; + setHostEnv(HOST_ONLY_SECRET, HOST_ONLY_VALUE); - const childEnv = await envSeenByChild({ [HOST_ONLY_SECRET]: HOST_ONLY_VALUE }); + const childEnv = await envSeenByChild({ [HOST_ONLY_SECRET]: HOST_ONLY_VALUE }, [ + HOST_ONLY_SECRET, + ]); expect(childEnv[HOST_ONLY_SECRET]).toBe(HOST_ONLY_VALUE); }); From 1f0a66cf3e37fbfa5aeba71418173332b02a1805 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:52:01 -0700 Subject: [PATCH 3/4] Cover stdio env isolation in the local e2e scenario --- e2e/local/fixtures/stdio-mcp-server.mjs | 25 ++ e2e/local/stdio-mcp.test.ts | 301 ++++++++++++++---------- 2 files changed, 199 insertions(+), 127 deletions(-) diff --git a/e2e/local/fixtures/stdio-mcp-server.mjs b/e2e/local/fixtures/stdio-mcp-server.mjs index 9f57c79a2..cef83abc5 100644 --- a/e2e/local/fixtures/stdio-mcp-server.mjs +++ b/e2e/local/fixtures/stdio-mcp-server.mjs @@ -11,6 +11,12 @@ // It exposes one tool, `echo_tool`, and (when EXECUTOR_E2E_SECRET is set in the // child env) a second `whoami` tool that returns that env value — so a scenario // can prove a per-connection secret env var actually reached the subprocess. +// +// It also reports its own environment the same way: for each variable in +// ENV_PROBES below it advertises a tool ONLY when that variable is present in +// the child's env. Gating the tool NAME rather than returning a value means the +// answer survives the whole discovery path unaltered — a scenario reads it off +// the tools API, and "absent" cannot be confused with "empty string". import { createInterface } from "node:readline"; @@ -38,6 +44,25 @@ if (process.env.EXECUTOR_E2E_SECRET) { }); } +// Each entry advertises `saw_` when its variable reached this process. +// The three cover the three ways a variable can be handed to a stdio server: +// declared on the source, allowlisted infrastructure inherited from the host, +// and — the leak — an unrelated host variable that must never travel. +const ENV_PROBES = [ + { tool: "saw_declared_env", key: "EXECUTOR_E2E_SECRET" }, + { tool: "saw_proxy_env", key: "NO_PROXY" }, + { tool: "saw_host_secret", key: "EXECUTOR_E2E_HOST_ONLY_SECRET" }, +]; + +for (const probe of ENV_PROBES) { + if (process.env[probe.key] === undefined) continue; + TOOLS.push({ + name: probe.tool, + description: `Present only because ${probe.key} is set in this server's environment`, + inputSchema: { type: "object", properties: {} }, + }); +} + const handle = (msg) => { if (msg.method === "initialize") { send({ diff --git a/e2e/local/stdio-mcp.test.ts b/e2e/local/stdio-mcp.test.ts index a8ece4caa..c7b55bdf3 100644 --- a/e2e/local/stdio-mcp.test.ts +++ b/e2e/local/stdio-mcp.test.ts @@ -14,6 +14,14 @@ // stdio add never created one, so the integration landed with zero connections // and zero tools. The fix auto-creates the default connection on add and routes // the env values into the connection's secret store. +// +// The scenario also covers what ELSE reaches that subprocess. A stdio server +// that declared any `env` used to be spawned with `{ ...process.env, ...env }`, +// so it received every variable the daemon holds — including the key that +// decrypts the secret store. `withLocalServer`'s `env` option plants two +// variables on the real daemon process (see DAEMON_ENV) and the fixture reports +// which of them survived the spawn, so the boundary is measured on the product +// path rather than at the call site of the transport. import { fileURLToPath } from "node:url"; import { expect } from "@effect/vitest"; @@ -37,8 +45,26 @@ const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import. // the connection's secret env reached the spawned subprocess. const SECRET = "s3cr3t-from-the-vault"; +// Planted on the `executor web` process itself, so the only way either value +// can reach a stdio subprocess is by being inherited across the spawn. +// +// NO_PROXY stands in for the infrastructure allowlist: it is one of the +// variables a stdio server must keep inheriting, and it is the inert member of +// that list — with no proxy configured alongside it, it changes nothing about +// the daemon's own egress, so planting it cannot perturb the rest of the run. +// +// The sentinel stands in for a real host secret such as EXECUTOR_SECRET_KEY. +// A variable only this scenario sets keeps the assertion honest on a machine +// where the real one happens not to be exported. +const PROXY_VALUE = "stdio-env-e2e.invalid"; +const HOST_ONLY_SENTINEL = "host-secret-that-must-not-reach-a-stdio-server"; +const DAEMON_ENV = { + NO_PROXY: PROXY_VALUE, + EXECUTOR_E2E_HOST_ONLY_SECRET: HOST_ONLY_SENTINEL, +}; + scenario( - "Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret", + "Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret and the daemon's own environment withheld", // Must stay STRICTLY greater than the boot-URL wait in `withLocalServer` // (currently 240s). When this CI job runs `stdio-mcp.test.ts` alone it always // pays a cold `vite optimizeDeps` boot (no prior file to warm the cache), the @@ -51,132 +77,153 @@ scenario( const cli = yield* Cli; const runDir = yield* RunDir; - yield* withLocalServer(cli, runDir, (server) => - Effect.gen(function* () { - const client = yield* HttpApiClient.make(api, { - baseUrl: new URL("/api", server.origin).toString(), - transformClient: HttpClient.mapRequest((request) => - HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), - ), - }).pipe(Effect.provide(FetchHttpClient.layer)); - - const slug = "e2e-stdio"; - - // Add the stdio server exactly as the desktop/local "Add MCP" flow does, - // including a secret env var the server needs. - yield* client.mcp.addServer({ - payload: { - transport: "stdio", - name: "E2E Stdio", - command: "node", - args: [FIXTURE], - env: { EXECUTOR_E2E_SECRET: SECRET }, - slug, - }, - }); - - // The integration lands in the catalog — the add itself works. - const integrations = yield* client.integrations.list(); - expect( - integrations.map((i) => String(i.slug)), - "the stdio MCP integration is registered", - ).toContain(slug); - - // The add auto-creates the default connection (the v1.5 split makes this - // the thing that drives tool discovery). Pre-fix there were zero. - const connections = yield* client.connections.list({ query: { integration: slug } }); - expect( - connections.map((c) => String(c.name)), - "a default connection was auto-created for the stdio server", - ).toContain("default"); - - // THE SYMPTOM, fixed: the stdio server's tools are detected. `whoami` - // appearing proves the connection's secret env reached the subprocess. - const tools = yield* client.tools.list({ query: { integration: slug } }); - const names = tools.map((t) => t.name); - expect(names, "the stdio server's base tool is detected").toContain("echo_tool"); - expect( - names, - "the secret env var reached the spawned subprocess (whoami is gated on it)", - ).toContain("whoami"); - - // "Properly store auth": the secret value is NOT in the integration's - // config blob — only the var NAME is declared there; the value lives on - // the connection (the secret store). - const stored = yield* client.mcp.getServer({ params: { slug } }); - expect( - JSON.stringify(stored?.config ?? {}), - "the secret value is not persisted in the integration config", - ).not.toContain(SECRET); - - // --- The UI path: DECLARE env var names, then provide the secret value - // as a connection credential (what the add form now does). --- - const declSlug = "e2e-stdio-decl"; - yield* client.mcp.addServer({ - payload: { - transport: "stdio", - name: "E2E Stdio Declared", - command: "node", - args: [FIXTURE], - envVars: ["EXECUTOR_E2E_SECRET"], - slug: declSlug, - }, - }); - - // Declaring a secret env var (no value) does NOT auto-connect: the - // secret is still missing, so there are no tools until you connect. - const beforeConns = yield* client.connections.list({ query: { integration: declSlug } }); - expect(beforeConns, "no connection until the secret is provided").toHaveLength(0); - const beforeTools = yield* client.tools.list({ query: { integration: declSlug } }); - expect(beforeTools, "no tools until the secret is provided").toHaveLength(0); - - // Provide the secret as the connection credential (the connect step). - yield* client.connections.create({ - payload: { - owner: "org", - name: ConnectionName.make("default"), - integration: IntegrationSlug.make(declSlug), - template: AuthTemplateSlug.make("env"), - values: { EXECUTOR_E2E_SECRET: SECRET }, - }, - }); - - const declTools = yield* client.tools.list({ query: { integration: declSlug } }); - expect( - declTools.map((t) => t.name), - "connecting with the secret discovers the env-gated tool", - ).toContain("whoami"); - - // --- versionNegotiation "auto" survives the API → config → connector - // path and still reaches a legacy server: the probe gets the fixture's - // method-not-found for `server/discover` (a definitive legacy verdict) - // and falls back to `initialize`. Modern-era acceptance against a real - // legacy-disabled SDK v2 server lives in the plugin's - // stdio-negotiation.test.ts. --- - const autoSlug = "e2e-stdio-auto"; - yield* client.mcp.addServer({ - payload: { - transport: "stdio", - name: "E2E Stdio Auto", - command: "node", - args: [FIXTURE], - versionNegotiation: "auto", - slug: autoSlug, - }, - }); - - const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } }); - expect( - JSON.stringify(autoStored?.config ?? {}), - "the negotiation mode is persisted on the integration config", - ).toContain('"versionNegotiation":"auto"'); - - const autoTools = yield* client.tools.list({ query: { integration: autoSlug } }); - expect( - autoTools.map((t) => t.name), - "auto negotiation falls back to legacy and still discovers tools", - ).toContain("echo_tool"); - }), + yield* withLocalServer( + cli, + runDir, + (server) => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(api, { + baseUrl: new URL("/api", server.origin).toString(), + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + + const slug = "e2e-stdio"; + + // Add the stdio server exactly as the desktop/local "Add MCP" flow does, + // including a secret env var the server needs. + yield* client.mcp.addServer({ + payload: { + transport: "stdio", + name: "E2E Stdio", + command: "node", + args: [FIXTURE], + env: { EXECUTOR_E2E_SECRET: SECRET }, + slug, + }, + }); + + // The integration lands in the catalog — the add itself works. + const integrations = yield* client.integrations.list(); + expect( + integrations.map((i) => String(i.slug)), + "the stdio MCP integration is registered", + ).toContain(slug); + + // The add auto-creates the default connection (the v1.5 split makes this + // the thing that drives tool discovery). Pre-fix there were zero. + const connections = yield* client.connections.list({ query: { integration: slug } }); + expect( + connections.map((c) => String(c.name)), + "a default connection was auto-created for the stdio server", + ).toContain("default"); + + // THE SYMPTOM, fixed: the stdio server's tools are detected. `whoami` + // appearing proves the connection's secret env reached the subprocess. + const tools = yield* client.tools.list({ query: { integration: slug } }); + const names = tools.map((t) => t.name); + expect(names, "the stdio server's base tool is detected").toContain("echo_tool"); + expect( + names, + "the secret env var reached the spawned subprocess (whoami is gated on it)", + ).toContain("whoami"); + + // --- What the daemon's own environment does, and does not, hand to a + // stdio server. This is the branch that leaked: `e2e-stdio` declares an + // `env`, and the declared-env branch used to spawn with the whole of + // `process.env` merged underneath. Each `saw_*` tool exists only because + // the matching variable was present in the CHILD's environment. --- + expect(names, "the declared env var is visible to the server").toContain( + "saw_declared_env", + ); + expect( + names, + "the host's proxy configuration still reaches the server, or every stdio server behind a corporate proxy breaks", + ).toContain("saw_proxy_env"); + expect( + names, + "a host variable the source never declared (EXECUTOR_E2E_HOST_ONLY_SECRET) must not reach the server", + ).not.toContain("saw_host_secret"); + + // "Properly store auth": the secret value is NOT in the integration's + // config blob — only the var NAME is declared there; the value lives on + // the connection (the secret store). + const stored = yield* client.mcp.getServer({ params: { slug } }); + expect( + JSON.stringify(stored?.config ?? {}), + "the secret value is not persisted in the integration config", + ).not.toContain(SECRET); + + // --- The UI path: DECLARE env var names, then provide the secret value + // as a connection credential (what the add form now does). --- + const declSlug = "e2e-stdio-decl"; + yield* client.mcp.addServer({ + payload: { + transport: "stdio", + name: "E2E Stdio Declared", + command: "node", + args: [FIXTURE], + envVars: ["EXECUTOR_E2E_SECRET"], + slug: declSlug, + }, + }); + + // Declaring a secret env var (no value) does NOT auto-connect: the + // secret is still missing, so there are no tools until you connect. + const beforeConns = yield* client.connections.list({ query: { integration: declSlug } }); + expect(beforeConns, "no connection until the secret is provided").toHaveLength(0); + const beforeTools = yield* client.tools.list({ query: { integration: declSlug } }); + expect(beforeTools, "no tools until the secret is provided").toHaveLength(0); + + // Provide the secret as the connection credential (the connect step). + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("default"), + integration: IntegrationSlug.make(declSlug), + template: AuthTemplateSlug.make("env"), + values: { EXECUTOR_E2E_SECRET: SECRET }, + }, + }); + + const declTools = yield* client.tools.list({ query: { integration: declSlug } }); + expect( + declTools.map((t) => t.name), + "connecting with the secret discovers the env-gated tool", + ).toContain("whoami"); + + // --- versionNegotiation "auto" survives the API → config → connector + // path and still reaches a legacy server: the probe gets the fixture's + // method-not-found for `server/discover` (a definitive legacy verdict) + // and falls back to `initialize`. Modern-era acceptance against a real + // legacy-disabled SDK v2 server lives in the plugin's + // stdio-negotiation.test.ts. --- + const autoSlug = "e2e-stdio-auto"; + yield* client.mcp.addServer({ + payload: { + transport: "stdio", + name: "E2E Stdio Auto", + command: "node", + args: [FIXTURE], + versionNegotiation: "auto", + slug: autoSlug, + }, + }); + + const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } }); + expect( + JSON.stringify(autoStored?.config ?? {}), + "the negotiation mode is persisted on the integration config", + ).toContain('"versionNegotiation":"auto"'); + + const autoTools = yield* client.tools.list({ query: { integration: autoSlug } }); + expect( + autoTools.map((t) => t.name), + "auto negotiation falls back to legacy and still discovers tools", + ).toContain("echo_tool"); + }), + { env: DAEMON_ENV }, ); }), ); From f1f718df13b613ccd1fb23cad7d991a82825f2b5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:23:40 -0700 Subject: [PATCH 4/4] Resolve stdio env key collisions case-insensitively on Windows Windows environment keys are case-insensitive, but a JavaScript spread is not. An inherited HTTP_PROXY beside a declared http_proxy, or the SDK's PATH beside a declared Path, both reached the child, which then read whichever spelling Windows resolved first rather than the declared one. Merge by case-insensitive key identity on win32: the declared env still wins and the losing spelling is dropped. A key the SDK's own safe-list also sets is emitted with the SDK's spelling, so it replaces that entry instead of aliasing it. Also correct the README, which still described stdio children as inheriting process.env. --- .changeset/mcp-stdio-env-isolation.md | 2 +- packages/plugins/mcp/README.md | 8 +- .../plugins/mcp/src/sdk/stdio-connector.ts | 60 +++++++- .../mcp/src/sdk/stdio-env-merge.test.ts | 133 ++++++++++++++++++ 4 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 packages/plugins/mcp/src/sdk/stdio-env-merge.test.ts diff --git a/.changeset/mcp-stdio-env-isolation.md b/.changeset/mcp-stdio-env-isolation.md index c57015f50..f194fbf8c 100644 --- a/.changeset/mcp-stdio-env-isolation.md +++ b/.changeset/mcp-stdio-env-isolation.md @@ -6,6 +6,6 @@ A stdio MCP server that declared any `env` at all was spawned with every environment variable this process holds. The MCP SDK already guards against that: it spawns with `{ ...getDefaultEnvironment(), ...serverParams.env }`, where `getDefaultEnvironment()` is a sudo-style safe-list of `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM` and `USER`. Passing `{ ...process.env, ...config.env }` did not add to that safe-list, it overwrote it. In practice, adding one third-party `npx` server went from "this server can see the API key I gave it" to "this server also holds `EXECUTOR_SECRET_KEY`, the key that decrypts every other stored credential, plus `EXECUTOR_AUTH_TOKEN` and `DATABASE_URL`". The leak sat on the `config.env` branch — the branch a credential-bearing integration takes. -A stdio server now receives the SDK's safe-list, the variables declared on the source config, and one short allowlist of infrastructure variables read from the host: `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` (both spellings), `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE` and `SSL_CERT_DIR`. Those carry no credential, no source config declares them, and a server behind a corporate proxy or an intercepting CA cannot reach anything without them — the same reasoning and the same list `service install` already uses when it bakes a supervised unit's minimal environment. The declared `env` wins on a key collision. +A stdio server now receives the SDK's safe-list, the variables declared on the source config, and one short allowlist of infrastructure variables read from the host: `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` (both spellings), `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE` and `SSL_CERT_DIR`. Those carry no credential, no source config declares them, and a server behind a corporate proxy or an intercepting CA cannot reach anything without them — the same reasoning and the same list `service install` already uses when it bakes a supervised unit's minimal environment. The declared `env` wins on a key collision. On Windows that collision is resolved case-insensitively, because the OS treats `Path` and `PATH` as one variable while a JavaScript spread does not: a declared `http_proxy` now replaces an inherited `HTTP_PROXY` instead of travelling beside it, which would have left the child reading whichever spelling Windows resolved first. If a stdio server relied on some other variable arriving from the host, set it explicitly on the source's `env`. That is now the only way anything beyond the lists above reaches a server, and it is the mechanism that already existed for it. diff --git a/packages/plugins/mcp/README.md b/packages/plugins/mcp/README.md index 20c0bb74f..aabbbfc2a 100644 --- a/packages/plugins/mcp/README.md +++ b/packages/plugins/mcp/README.md @@ -18,8 +18,8 @@ import { mcpPlugin } from "@executor-js/plugin-mcp"; const executor = await createExecutor({ onElicitation: "accept-all", - // Stdio integrations spawn a local subprocess and inherit `process.env` — - // only enable for trusted single-user contexts. + // Stdio integrations spawn a local subprocess — only enable for trusted + // single-user contexts. See "Stdio environment" below for what they receive. plugins: [mcpPlugin({ dangerouslyAllowStdioMCP: true })] as const, }); @@ -50,6 +50,10 @@ const result = await executor.tools.invoke("context7.searchLibraries", { }); ``` +## Stdio environment + +A stdio server does not inherit `process.env`. It receives the MCP SDK's safe-list (`HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, `USER`; the Windows equivalents on Windows), plus a short allowlist of proxy and CA variables — `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, their lowercase spellings, `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, and `SSL_CERT_DIR` — so a server behind a corporate proxy can still reach the network. Nothing else travels, so a secret in the host environment stays there. Give the server anything more, an API key above all, through the integration's own `env`, which wins on a key collision. On Windows that collision is resolved case-insensitively, because the OS treats `Path` and `PATH` as one variable. + ## Using with Effect If you're building on `@executor-js/sdk/core` (the raw Effect entry), import this plugin from its `/core` subpath instead — it returns the Effect-shaped plugin with `Effect.Effect<...>`-returning methods rather than promisified wrappers: diff --git a/packages/plugins/mcp/src/sdk/stdio-connector.ts b/packages/plugins/mcp/src/sdk/stdio-connector.ts index 3cd854a8c..70bb84b0c 100644 --- a/packages/plugins/mcp/src/sdk/stdio-connector.ts +++ b/packages/plugins/mcp/src/sdk/stdio-connector.ts @@ -14,7 +14,7 @@ // the import and therefore never touch `node:child_process`. // --------------------------------------------------------------------------- -import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/client/stdio"; export type StdioTransportConfig = { readonly command: string; @@ -61,6 +61,57 @@ const inheritedEnv = (): Record => }), ); +/** + * Combine the inherited allowlist with the source config's declared `env`, + * using the key identity the target platform actually has. + * + * On Windows an environment key is case-insensitive: `Path` and `PATH` name one + * variable. A JavaScript spread is case-sensitive on every platform, so a plain + * merge can emit two spellings of the same variable — inherited `HTTP_PROXY` + * beside a declared `http_proxy`, or the SDK's `PATH` beside a declared `Path`. + * Both then reach the child's environment block, and the child reads whichever + * Windows resolves first rather than the one the config declared. Precedence + * that reads correctly in the source is silently lost at the boundary. + * + * So on `win32` this merges by case-insensitive key identity. The declared env + * still wins, and the losing spelling is dropped instead of travelling beside + * the winner. A key that collides with one the SDK sets is emitted with the + * SDK's spelling, because the SDK spreads its own safe-list underneath this + * result: matching its spelling replaces that entry, while a different spelling + * would only add an alias next to it. + * + * On every other platform the keys are genuinely case-sensitive, and this is a + * plain merge. + */ +export const mergeStdioEnv = ({ + platform, + inherited, + declared, + sdkKeys = [], +}: { + readonly platform: NodeJS.Platform; + readonly inherited: Record; + readonly declared?: Record; + /** Keys the SDK's own safe-list will place underneath this result. */ + readonly sdkKeys?: ReadonlyArray; +}): Record => { + if (platform !== "win32") return { ...inherited, ...declared }; + + const sdkSpelling = new Map(sdkKeys.map((key) => [key.toLowerCase(), key] as const)); + // Keyed by the case-insensitive identity; insertion order follows first + // sight of a variable, and a later source overwrites the entry in place. + const merged = new Map(); + + for (const source of [inherited, declared]) { + for (const [key, value] of Object.entries(source ?? {})) { + const identity = key.toLowerCase(); + merged.set(identity, [sdkSpelling.get(identity) ?? key, value]); + } + } + + return Object.fromEntries(merged.values()); +}; + export const createStdioTransport = (config: StdioTransportConfig) => new StdioClientTransport({ command: config.command, @@ -76,6 +127,11 @@ export const createStdioTransport = (config: StdioTransportConfig) => // server that spawns one includes `EXECUTOR_SECRET_KEY` (the key that // decrypts the secret store), `EXECUTOR_AUTH_TOKEN`, `DATABASE_URL` and // whatever else the operator exported. - env: { ...inheritedEnv(), ...config.env }, + env: mergeStdioEnv({ + platform: process.platform, + inherited: inheritedEnv(), + declared: config.env, + sdkKeys: Object.keys(getDefaultEnvironment()), + }), cwd: config.cwd, }); diff --git a/packages/plugins/mcp/src/sdk/stdio-env-merge.test.ts b/packages/plugins/mcp/src/sdk/stdio-env-merge.test.ts new file mode 100644 index 000000000..8bbd9a75e --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-env-merge.test.ts @@ -0,0 +1,133 @@ +// How the inherited allowlist and the declared env combine, per platform. +// +// The sibling `stdio-env-isolation` suite spawns a real child and asks what it +// received. That answers the security question, but it can only ever measure +// the platform it runs on, and the defect here is Windows-only: a case-variant +// key collision. So this suite exercises the merge directly and passes the +// platform in, which lets the Windows behaviour be asserted from any host. + +import { describe, expect, it } from "@effect/vitest"; + +import { mergeStdioEnv } from "./stdio-connector"; + +/** The keys the SDK's own safe-list places underneath our result on Windows. */ +const windowsSdkKeys = ["APPDATA", "PATH", "SYSTEMROOT", "TEMP", "USERPROFILE"]; + +describe("mergeStdioEnv on a case-sensitive platform", () => { + it("keeps both spellings, because they are two real variables", () => { + // On Unix `HTTP_PROXY` and `http_proxy` are unrelated variables that + // different tooling reads, and the allowlist inherits both on purpose. + // Collapsing them here would drop configuration the host meant to pass. + const merged = mergeStdioEnv({ + platform: "linux", + inherited: { HTTP_PROXY: "http://host.invalid:3128" }, + declared: { http_proxy: "http://declared.invalid:8080" }, + }); + + expect(merged).toStrictEqual({ + HTTP_PROXY: "http://host.invalid:3128", + http_proxy: "http://declared.invalid:8080", + }); + }); + + it("lets the declared env win an exact key collision", () => { + const merged = mergeStdioEnv({ + platform: "darwin", + inherited: { HTTPS_PROXY: "http://host.invalid:3128" }, + declared: { HTTPS_PROXY: "http://declared.invalid:8080" }, + }); + + expect(merged).toStrictEqual({ HTTPS_PROXY: "http://declared.invalid:8080" }); + }); +}); + +describe("mergeStdioEnv on win32", () => { + it("drops the inherited alias so the declared value is the only one", () => { + // The defect: a case-sensitive spread emits `HTTP_PROXY` beside + // `http_proxy`, both reach the child's environment block, and Windows + // resolves the case-insensitive name to whichever comes first — not + // necessarily the declared one. One entry can only resolve one way. + const merged = mergeStdioEnv({ + platform: "win32", + inherited: { HTTP_PROXY: "http://host.invalid:3128" }, + declared: { http_proxy: "http://declared.invalid:8080" }, + }); + + expect(Object.keys(merged)).toStrictEqual(["http_proxy"]); + expect(merged.http_proxy).toBe("http://declared.invalid:8080"); + expect(merged.HTTP_PROXY).toBeUndefined(); + }); + + it("collapses aliases the allowlist itself produces", () => { + // `process.env` is case-insensitive on Windows, so reading the allowlist's + // uppercase and lowercase proxy spellings returns the same variable twice. + // The child should be handed it once. + const merged = mergeStdioEnv({ + platform: "win32", + inherited: { + HTTPS_PROXY: "http://host.invalid:3128", + https_proxy: "http://host.invalid:3128", + }, + }); + + expect(Object.keys(merged)).toStrictEqual(["https_proxy"]); + expect(merged.https_proxy).toBe("http://host.invalid:3128"); + }); + + it("emits a declared key with the SDK's spelling when the SDK also sets it", () => { + // The SDK spreads `getDefaultEnvironment()` underneath this result with a + // case-sensitive spread, and its Windows list spells the variable `PATH`. + // Returning `Path` would leave `PATH` standing beside it; returning `PATH` + // replaces it, which is what a source that overrides its interpreter + // lookup is asking for. + const merged = mergeStdioEnv({ + platform: "win32", + inherited: {}, + declared: { Path: "C:\\server\\bin" }, + sdkKeys: windowsSdkKeys, + }); + + expect(Object.keys(merged)).toStrictEqual(["PATH"]); + expect(merged.PATH).toBe("C:\\server\\bin"); + }); + + it("leaves a key the SDK does not set under its declared spelling", () => { + const merged = mergeStdioEnv({ + platform: "win32", + inherited: {}, + declared: { My_Api_Token: "declared-value" }, + sdkKeys: windowsSdkKeys, + }); + + expect(merged).toStrictEqual({ My_Api_Token: "declared-value" }); + }); + + it("still passes an inherited variable the source does not declare", () => { + // Deduplication must not turn into dropping: the proxy and CA settings are + // the reason the allowlist exists. + const merged = mergeStdioEnv({ + platform: "win32", + inherited: { NODE_EXTRA_CA_CERTS: "C:\\certs\\corporate.pem" }, + declared: { DECLARED_TOKEN: "declared-value" }, + sdkKeys: windowsSdkKeys, + }); + + expect(merged).toStrictEqual({ + NODE_EXTRA_CA_CERTS: "C:\\certs\\corporate.pem", + DECLARED_TOKEN: "declared-value", + }); + }); + + it("does not invent an entry for an SDK key nobody declared", () => { + // The SDK supplies its own safe-list; this merge only decides spelling for + // keys that are actually being passed. + const merged = mergeStdioEnv({ + platform: "win32", + inherited: {}, + declared: {}, + sdkKeys: windowsSdkKeys, + }); + + expect(merged).toStrictEqual({}); + }); +});