Skip to content

fix: allow operator env vars in MCP config substitution; warn on refused references - #811

Merged
mrubens merged 3 commits into
developfrom
fix/warn-unresolvable-mcp-env-refs
Jul 27, 2026
Merged

fix: allow operator env vars in MCP config substitution; warn on refused references#811
mrubens merged 3 commits into
developfrom
fix/warn-unresolvable-mcp-env-refs

Conversation

@mrubens

@mrubens mrubens commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Problem

Environment-config MCP servers can reference deployment env vars with ${VAR} syntax, but the substitution guard filtered by name shape: any variable ending in _SECRET or _PRIVATE_KEY was silently refused, and the literal ${VAR} text was passed to the MCP server with no warning at any layer. So this natural config just doesn't authenticate, with nothing to go on:

mcpServers:
  reddit:
    command: npx
    args: [-y, reddit-mcp-buddy]
    env:
      REDDIT_CLIENT_SECRET: ${REDDIT_CLIENT_SECRET}   # silently left literal

The name-shape filter also protected nothing for these vars: operator-defined deployment env vars are already injected into the sandbox runtime env, readable by the agent and every process in it. Refusing to substitute them into MCP config was friction without security.

Fix

Key the guard off source instead of name shape:

  • Operator-defined deployment env vars always substitute, whatever they're named. On a name collision with a reserved runtime name (e.g. an operator's own DATABASE_URL), the operator's value wins — internal values are never reachable.
  • Reserved Roomote runtime names (ROOMOTE_*, AUTH_TOKEN, JOB_AUTH_*, PREVIEW_AUTH_*, DATABASE_URL, REDIS_URL, BASH_ENV) remain non-injectable. This is the boundary that matters: MCP config substitution runs worker-side and could otherwise re-expose values the sandbox-secrets scrubbing deliberately keeps away from tasks.
  • The _SECRET/_PRIVATE_KEY suffix heuristics are gone.

And make any refusal loud instead of silent: a reference to a reserved name warns with guidance, and a reference to an undefined name warns as a likely typo. Unresolved references are still passed through literally (streamable-http headers rely on this for the OpenCode-level {env:...} conversion).

Also extracts the reference-scanning regex into collectEnvVarReferences next to substituteEnvVars so the two can't drift.

Tests

Covers: operator var with _SECRET name substitutes; non-reserved secret-like task env var substitutes; reserved name refused + warned; operator value wins reserved-name collisions; undefined reference warns; fully-resolved config does not warn. Full worker run-task + commands suites pass; lint/typecheck/knip pass.

Operator-configured MCP servers can reference deployment env vars with
${VAR} syntax, but the lookup deliberately excludes restricted names
(_SECRET/_PRIVATE_KEY suffixes and reserved runtime names) so MCP config
cannot pull control-plane secrets. Unresolvable references were passed
through as literal ${VAR} text with no trace anywhere, which reads as
"env MCPs don't work" and is painful to diagnose.

Keep the guard, make it loud: warn once per referenced name when a
reference names a restricted variable (with a rename suggestion) or is
not defined in the task environment.
@roomote-roomote

roomote-roomote Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

No code issues found. See task

  • apps/worker/src/run-task/run-task.ts:1158 The new operatorEnvVars argument is derived from envVars after injectEnvVars() and workspace setup mutate that object. This can reclassify internal values as operator values: for example, ROOMOTE_AUTH_BYPASS_VALUE is added at env-vars.ts:238-248, then a custom MCP header ${ROOMOTE_AUTH_BYPASS_VALUE} resolves through the overlay and is sent to the MCP server, despite being a reserved runtime name. Preserve a deployment-only snapshot (including intended environment config vars) before runtime injection, or filter the overlay to exclude internally added values.

Reviewed c4773d5

…of name

Drop the _SECRET/_PRIVATE_KEY suffix heuristic from the MCP config
substitution guard. Operator-defined deployment env vars are already
present in the sandbox environment, so refusing to substitute them into
MCP server config protected nothing while breaking legitimate setups
(e.g. REDDIT_CLIENT_SECRET for an authenticated Reddit MCP).

The guard now keys off source instead of name shape: reserved Roomote
runtime names (ROOMOTE_*, AUTH_TOKEN, JOB_AUTH_*, PREVIEW_AUTH_*,
DATABASE_URL, REDIS_URL, BASH_ENV) remain non-injectable, while
operator-defined vars always substitute — and win on collision, so an
operator's own DATABASE_URL resolves to their value, never an internal
one. Refused reserved-name references still warn loudly.
@mrubens mrubens changed the title fix: warn when custom MCP env references cannot be substituted fix: allow operator env vars in MCP config substitution; warn on refused references Jul 27, 2026
Review follow-up: envVars is mutated by injectEnvVars() (auth bypass
values, BASH_ENV, PREVIEW_DOMAINS, ...) before runTask reads it, so
deriving the operator overlay from it could reclassify runtime-internal
values as operator-provided and let ${ROOMOTE_AUTH_BYPASS_VALUE} resolve
past the reserved-name guard.

Thread the existing pre-injection snapshot (userEnvVars) from
executeTaskRun through runFn into runTask and build the overlay from
that. As defense in depth, Roomote-namespaced names (ROOMOTE_*,
AUTH_TOKEN, BASH_ENV, JOB_AUTH_*, PREVIEW_AUTH_*) are dropped from the
overlay even if present in the operator map, while generic reserved
names (DATABASE_URL, REDIS_URL) can still be shadowed by an operator's
own values.
@mrubens

mrubens commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in c4773d5: the operator overlay is now built from the pre-injection userEnvVars snapshot (taken in executeTaskRun before injectEnvVars mutates the env map) and threaded through runFnrunTaskcreateHarness. As defense in depth, Roomote-namespaced names (ROOMOTE_*, AUTH_TOKEN, BASH_ENV, JOB_AUTH_*, PREVIEW_AUTH_*) are excluded from the overlay even if they appear in the operator map, so a runtime-injected entry can never be reclassified as operator-provided — while generic reserved names (DATABASE_URL, REDIS_URL) can still be shadowed by an operator's own values. Added a regression test for the ${ROOMOTE_AUTH_BYPASS_VALUE} case.

@mrubens
mrubens merged commit 9f6e8bf into develop Jul 27, 2026
17 checks passed
@mrubens
mrubens deleted the fix/warn-unresolvable-mcp-env-refs branch July 27, 2026 05:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant