Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/secrets/refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ async function fetchVaultwardenCredential(query: string): Promise<unknown> {
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",
},
});
Expand Down
17 changes: 17 additions & 0 deletions tests/fixtures/mock-vaultwarden-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand Down
24 changes: 24 additions & 0 deletions tests/secrets/refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down