From 540e71090f3a2a5ebd7a3baa180fdf4059f165a4 Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Mon, 29 Jun 2026 10:55:42 -0400 Subject: [PATCH] fix(secrets): clear MCP2CLI_DAEMON when resolving ${secret:} refs in-daemon The secret-ref resolver spawns `mcp2cli vaultwarden-secrets get_credential` to resolve `${secret:...}` references. It inherited the parent env via ...process.env and only set MCP2CLI_NO_DAEMON. When the resolver runs INSIDE the daemon, the daemon's own MCP2CLI_DAEMON=1 is inherited, so the spawned child BOOTS A SECOND DAEMON instead of running the CLI command -- and every ${secret:...} ref in a stdio service's env fails with "Vaultwarden lookup failed". This bit gitingest on CT216 (GITHUB_TOKEN secret-ref unresolvable) while standalone lookups worked. Fix: explicitly clear MCP2CLI_DAEMON in the spawn env. Verified live on the box (clearing MCP2CLI_DAEMON resolves; leaving it boots a daemon) and with a mutation-tested regression test. Full suite 1072 pass / 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/secrets/refs.ts | 8 ++++++++ tests/fixtures/mock-vaultwarden-command.ts | 17 +++++++++++++++ tests/secrets/refs.test.ts | 24 ++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/src/secrets/refs.ts b/src/secrets/refs.ts index 34393c1..4cddc05 100644 --- a/src/secrets/refs.ts +++ b/src/secrets/refs.ts @@ -119,6 +119,14 @@ async function fetchVaultwardenCredential(query: string): Promise { stderr: "pipe", env: { ...process.env, + // Clear MCP2CLI_DAEMON so this child runs the CLI command, NOT a daemon. + // When the resolver runs INSIDE the daemon, the daemon's own + // MCP2CLI_DAEMON=1 is inherited via ...process.env; without clearing it the + // spawned `mcp2cli vaultwarden-secrets get_credential` boots a second daemon + // instead of resolving the secret, so every `${secret:...}` ref in a stdio + // service's env (e.g. gitingest's GITHUB_TOKEN) fails with "Vaultwarden + // lookup failed". + MCP2CLI_DAEMON: "", MCP2CLI_NO_DAEMON: process.env.MCP2CLI_VAULTWARDEN_USE_DAEMON === "1" ? "" : "1", }, }); diff --git a/tests/fixtures/mock-vaultwarden-command.ts b/tests/fixtures/mock-vaultwarden-command.ts index f866d3d..ff4bf96 100644 --- a/tests/fixtures/mock-vaultwarden-command.ts +++ b/tests/fixtures/mock-vaultwarden-command.ts @@ -14,6 +14,23 @@ if (params.query === "fixture") { process.exit(0); } +// Resolve to a sentinel token ONLY if this spawned child saw MCP2CLI_DAEMON +// cleared. If it is still "1" (the resolver failed to clear the daemon's env), +// return a value that makes the assertion fail -- mirroring the real bug where +// the child would boot a daemon instead of resolving. See refs.ts. +if (params.query === "daemon-env-check") { + const cleared = process.env.MCP2CLI_DAEMON !== "1"; + console.log(JSON.stringify({ + success: true, + result: { + fields: { + token: cleared ? "resolved-not-as-daemon" : "BOOTED-AS-DAEMON", + }, + }, + })); + process.exit(0); +} + if (params.query === "slow") { await new Promise((resolve) => setTimeout(resolve, 5_000)); console.log(JSON.stringify({ success: true, result: "too-late" })); diff --git a/tests/secrets/refs.test.ts b/tests/secrets/refs.test.ts index da6fd92..9c178b1 100644 --- a/tests/secrets/refs.test.ts +++ b/tests/secrets/refs.test.ts @@ -104,6 +104,30 @@ describe("VaultwardenSecretResolver", () => { expect(value).toBe("fixture-token"); }); + test("clears MCP2CLI_DAEMON in the resolver subprocess (runs CLI, not a daemon)", async () => { + // Regression: when the resolver runs INSIDE the daemon, the daemon's + // MCP2CLI_DAEMON=1 is inherited via process.env; without clearing it the + // spawned `mcp2cli vaultwarden-secrets` boots a daemon instead of resolving, + // breaking every ${secret:...} ref in a stdio service's env. + const originalDaemon = process.env.MCP2CLI_DAEMON; + process.env.MCP2CLI_DAEMON = "1"; // simulate running inside the daemon + process.env.MCP2CLI_VAULTWARDEN_COMMAND = Bun.argv[0]!; + process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS = JSON.stringify([ + "run", + resolve(import.meta.dir, "../fixtures/mock-vaultwarden-command.ts"), + ]); + try { + const resolver = new VaultwardenSecretResolver(); + const value = await resolver.resolve("daemon-env-check#fields.token"); + // The child must have seen MCP2CLI_DAEMON cleared, so it resolved normally + // instead of booting a daemon. + expect(value).toBe("resolved-not-as-daemon"); + } finally { + if (originalDaemon !== undefined) process.env.MCP2CLI_DAEMON = originalDaemon; + else delete process.env.MCP2CLI_DAEMON; + } + }); + test("times out stalled Vaultwarden lookups", async () => { process.env.MCP2CLI_VAULTWARDEN_COMMAND = Bun.argv[0]!; process.env.MCP2CLI_VAULTWARDEN_COMMAND_ARGS = JSON.stringify([