fix(connection): resolve ${secret:...} refs in stdio env before spawn (closes #65)#66
Merged
Merged
Conversation
…closes #65) For stdio services, ${secret:...} references in `env` (and `args`) reached the spawned child UNRESOLVED on any connect path that did not resolve upstream. transport.ts spreads `service.env` raw into the child env and only runs ${VAR} expansion on args, never the secret resolver -- so a literal `${secret:...}` (or a blanked `${secret:...}` arg) landed in the child. Observed on `n8n`: n8n-mcp's zod `.url()` validation of N8N_API_URL rejected the literal ref and all 15 management tools reported the misleading "n8n API not configured". The direct CLI path (resolveDirectServiceConfig) and the daemon pool (pool.ts) already resolve, but the pool's HTTP/WebSocket stdio *fallback* constructors (connectFallback / connectWsFallback) build a stdio config from raw fb.env / fb.args and call connectToService with no resolution -- a proven leak. Rather than patch each caller, resolve at the single universal stdio chokepoint: connectToService, which every stdio spawn (direct, pool, and both fallbacks) passes through. Resolution is idempotent -- resolveServiceSecretRefs short-circuits via hasSecretRefs when no ${secret:} remains -- so callers that already resolve upstream incur no extra Vaultwarden lookups. The resolver is injectable (options.resolver) for testing; default stays VaultwardenSecretResolver. Log labels intentionally keep the UNRESOLVED command/args so resolved secret values never reach logs. Test: new env-echo fixture MCP server + client.test.ts cases assert a ${secret:...} env value and a ${secret:...} arg both reach the child RESOLVED (mocked resolver), and that a ref-free config triggers zero resolver calls. Mutation-verified: reverting the fix makes the child read back the literal `${secret:...}` env and a stripped `--token=` arg, failing both. Verified: bun run typecheck clean; full suite 1080 pass / 0 fail across three repeated runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ct TS)
Top-level `for await` requires the file be a module. Adds `export {}` so
strict-context typecheck accepts the fixture; repo tsc was already lax.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
For stdio services,
${secret:...}references in the service'senv(andargs) reached the spawned child unresolved on any connect path that did not resolve them upstream.src/connection/transport.ts:82spreadsservice.envraw into the childmergedEnv.src/connection/transport.ts:83runs only${VAR}expansion, and only on args -- never the secret resolver.So a literal
${secret:...}env value (or a${secret:...}arg thatexpandEnvVarsblanks to empty, since no such shell var exists) landed in the child. Observed on then8nservice: n8n-mcp'sgetN8nApiConfig()validatesN8N_API_URLwith zod.url(), the literal ref fails validation, and all 15 n8n management tools report the misleading"n8n API not configured". (This is the true root cause behind rtech-mcps#19;source: remote->localwas a red herring --sourceis daemon routing, not env-source.)Why some paths worked and some didn't
resolveServiceSecretRefswas already called on the direct CLI path (resolveDirectServiceConfig) and in the daemon pool (src/daemon/pool.ts:140). But the pool's HTTP/WebSocket stdio fallback constructors --connectFallback(pool.ts:435) andconnectWsFallback(pool.ts:359) -- build a stdio config from rawfb.env/fb.argsand callconnectToServicewith no resolution. That is a proven leak in the current tree, and it means resolution was caller-dependent rather than guaranteed.The fix
Resolve at the single universal stdio chokepoint --
connectToServiceinsrc/connection/client.ts-- which every stdio spawn passes through (direct path, daemon pool, and both fallbacks). One surgical change closes every path instead of patching each caller.resolveServiceSecretRefs(name, config, resolver)+VaultwardenSecretResolverpattern the working paths use.resolveServiceSecretRefsshort-circuits viahasSecretRefswhen no${secret:remains, so callers that already resolve upstream incur zero extra Vaultwarden lookups -- no double resolution.options.resolver) for testing; default staysVaultwardenSecretResolver.Test Plan
New fixture
tests/fixtures/env-echo-mcp-server.ts(an MCP server whoseecho_envtool returns its ownprocess.env+argv) plustests/connection/client.test.tscases:${secret:...}env value reaches the child resolved (mocked resolver) -- child never sees${secret:.${secret:...}arg is resolved before spawn.Mutation-verified: reverting the fix (
resolved.*->service.*) makes the child read back the literal${secret:RTech MCPs - foo#fields.N8N_API_URL}env and a stripped--token=arg -- both new tests fail. This reproduces #65 exactly.Full suite:
bun test-> 1080 pass / 0 fail across three repeated runs.bun run typecheckclean.Verifying n8n lights up after
With this merged, on a host where the
n8nstdio service uses${secret:...}in itsenv, the child receives the realN8N_API_URL/N8N_API_KEY, zod.url()validation passes, and the 15 n8n management tools stop reporting"n8n API not configured":Closes #65.
🤖 Generated with Claude Code