Skip to content

fix: ${VAR} env-var interpolation for configs (closes #635)#655

Merged
anandgupta42 merged 5 commits intomainfrom
fix/env-var-shell-syntax
Apr 5, 2026
Merged

fix: ${VAR} env-var interpolation for configs (closes #635)#655
anandgupta42 merged 5 commits intomainfrom
fix/env-var-shell-syntax

Conversation

@anandgupta42
Copy link
Copy Markdown
Contributor

@anandgupta42 anandgupta42 commented Apr 5, 2026

What does this PR do?

Fixes #635 by adding shell/dotenv-style ${VAR} interpolation to config parsing. Users arriving from Claude Code, VS Code, dotenv, or docker-compose naturally write ${VAR} and previously hit silent failures — the literal string passed through to MCP servers, shadowing the forwarded parent env.

Syntax supported (after full PR):

Syntax Mode Example
${VAR} String-safe (JSON-escaped) "API_KEY": "${MY_API_KEY}"
${VAR:-default} String-safe with fallback "MODE": "${APP_MODE:-production}"
{env:VAR} Raw text (unchanged for backward compat) "count": {env:NUM}
$${VAR} Escape hatch → literal ${VAR} "template": "$${VAR}"

Bare $VAR (without braces) is intentionally NOT interpolated — too collision-prone with prices, JSON pointers, etc.

Commits:

  1. Initial ${VAR} support with POSIX-identifier regex
  2. Consensus-review fixes: JSON-escape values (security), $${VAR} escape hatch, stale TUI tip
  3. ${VAR:-default} fallback syntax (POSIX / docker-compose convention)
  4. config_env_interpolation telemetry event to measure adoption and footguns

Type of change

  • Bug fix
  • New feature (${VAR:-default}, telemetry)
  • Test coverage
  • Documentation
  • Refactoring
  • Infrastructure

Issue for this PR

Closes #635

Research

Surveyed 10 other MCP clients + docker-compose + dotenv. Full findings in PR comments. Key takeaways:

  • ${VAR} + ${VAR:-default} is the de facto standard (POSIX shell, docker-compose, dotenv, GitHub Actions, Terraform, Claude Code, Cursor)
  • Every surveyed tool does raw text substitution — none JSON-escape. This PR is the first MCP client to implement string-safe substitution, eliminating the injection risk documented in OpenCode issue #5299.
  • Docker-compose convention for escape hatch is $${VAR} — matched here.

Security

${VAR} values are JSON-escaped via JSON.stringify(value).slice(1, -1). Env values containing quotes, commas, braces, or newlines cannot break out of the enclosing JSON string. Tested with injection-attempt values. Existing {env:VAR} keeps raw-injection semantics for backward compat (documented).

Telemetry

New event config_env_interpolation emitted per config load (only when interpolation actually runs). Fields:

  • dollar_refs, dollar_unresolved, dollar_defaulted, dollar_escaped (new syntax)
  • legacy_brace_refs, legacy_brace_unresolved (old syntax)

Lets us measure adoption of ${VAR:-default} defaults, detect users writing fragile configs without defaults, and plan future deprecation of {env:VAR}.

How did you verify your code works?

  • 32 new unit tests in test/config/paths-parsetext.test.ts:
    • ${VAR} happy path + unset → empty string
    • Mixed ${VAR} and {env:VAR} in same config
    • Invalid identifier names pass through (${foo bar}, ${foo-bar}, ${foo.bar})
    • Bare $VAR NOT interpolated
    • MCP env config regression test for MCP server fails to resolve environment variables in config #635
    • JSON injection attempt blocked (security)
    • Multiline/backslash values escaped correctly
    • $${VAR} escape hatch preserves literal
    • ${VAR:-default} unset/set/empty/empty-default/special-chars cases
    • Default value JSON-escaped
    • $${VAR:-default} escape hatch with defaults
  • All 162 config + telemetry tests pass
  • Typecheck passes
  • Marker guard clean
  • ALL_EVENT_TYPES completeness list updated (43 → 44)

Checklist

  • Changes wrapped in altimate_change markers (diverges from upstream)
  • Existing {env:VAR} syntax untouched (backward compat)
  • Security: values JSON-escaped (first MCP client to do so)
  • Tests pass
  • Typecheck passes
  • Marker guard passes
  • Docs updated (mcp-servers.md + TUI tip)

Summary by CodeRabbit

  • New Features

    • Added support for multiple environment variable interpolation syntaxes in MCP server configuration: ${VAR}, ${VAR:-default}, {env:VAR}, and $${VAR} escape sequences.
  • Documentation

    • Added "Environment variable interpolation" section documenting all supported placeholder syntaxes, injection behaviors, and default value handling.
  • Updates

    • CLI tips updated to reflect new environment variable syntax options.

Config substitution previously only accepted {env:VAR}. Users arriving
from Claude Code, VS Code, dotenv, or docker-compose naturally write
${VAR} and hit silent failures — the literal string passes through to
MCP servers as the env value, shadowing the forwarded parent env.

This adds ${VAR} as an alias for {env:VAR}. Regex matches POSIX
identifier names only ([A-Za-z_][A-Za-z0-9_]*) to avoid collisions
with random ${...} content in URLs or paths. Bare $VAR (without
braces) is intentionally NOT interpolated — too collision-prone.

- paths.ts: add second regex replace after the existing {env:VAR} pass
- paths-parsetext.test.ts: 6 new tests covering shell syntax, mixed
  use, invalid identifier names, bare $VAR rejection, and MCP env
  regression scenario
- mcp-servers.md: document both syntaxes with a table

Closes #635
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 5, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4cad505e-a1fe-4c07-8ef8-08b79190493c

📥 Commits

Reviewing files that changed from the base of the PR and between 31fdbcf and 45dd7bd.

📒 Files selected for processing (5)
  • docs/docs/configure/mcp-servers.md
  • packages/opencode/src/altimate/telemetry/index.ts
  • packages/opencode/src/config/paths.ts
  • packages/opencode/test/config/paths-parsetext.test.ts
  • packages/opencode/test/telemetry/telemetry.test.ts
✅ Files skipped from review due to trivial changes (2)
  • docs/docs/configure/mcp-servers.md
  • packages/opencode/test/config/paths-parsetext.test.ts

📝 Walkthrough

Walkthrough

Added ${VAR}-style environment-variable interpolation to config parsing (with ${VAR:-default} and $${VAR} escape), updated substitution logic and telemetry emission, added documentation and UI tip text, and expanded tests to cover new interpolation behaviors and edge cases.

Changes

Cohort / File(s) Summary
Documentation
docs/docs/configure/mcp-servers.md
Added "Environment variable interpolation" section describing syntaxes: ${VAR}, ${VAR:-default}, {env:VAR}, and $${VAR}, their injection behaviors, unset/default semantics, and that bare $VAR is not interpolated.
Config substitution logic
packages/opencode/src/config/paths.ts
Rewrote ConfigPaths.substitute() to single-pass regex for multiple env syntaxes: handles escaped $${...} → literal, ${VAR} / ${VAR:-default} → JSON-escaped substitution using process.env (with default semantics), and {env:VAR} → legacy raw injection. Emits telemetry via dynamic import when interpolations occur; preserves {file:...} and JSONC parsing flow.
Telemetry types
packages/opencode/src/altimate/telemetry/index.ts
Added new Telemetry.Event variant type: "config_env_interpolation" with counters for ${...} usages, unresolved/default/escaped counts, and legacy {env:...} counts.
UI tip text
packages/opencode/src/cli/cmd/tui/component/tips.tsx
Updated user-facing tip to mention ${VAR_NAME} syntax alongside {env:VAR_NAME}.
Tests
packages/opencode/test/config/paths-parsetext.test.ts, packages/opencode/test/telemetry/telemetry.test.ts
Added comprehensive tests for ${...} substitution (defaults, escaping, JSON-escaping, non-identifiers, coexistence with {env:...}, single-pass behavior, nested structures) and added the new telemetry event type to the test suite.

Sequence Diagram(s)

sequenceDiagram
  participant Caller as ConfigPaths.substitute()
  participant Parser as Pattern Matcher
  participant Env as process.env
  participant Telemetry as telemetry module
  participant Output as Result (JSONC parse)

  Caller->>Parser: Scan input for patterns (${...}, $${...}, {env:...}, {file:...})
  alt Found $${VAR}
    Parser->>Caller: Unescape to literal `${VAR}` (no env lookup)
  else Found ${VAR} or ${VAR:-default}
    Parser->>Env: Lookup VAR
    Env-->>Parser: value or undefined
    Parser->>Parser: Apply default if `${...:-default}` and JSON-escape value
    Parser->>Telemetry: dynamic import emit counts (dollar_refs / defaulted / unresolved / escaped)
    Parser->>Caller: Inject JSON-escaped string value
  else Found {env:VAR}
    Parser->>Env: Lookup VAR
    Env-->>Parser: value or undefined
    Parser->>Telemetry: dynamic import emit counts (legacy_brace_refs / unresolved)
    Parser->>Caller: Inject raw text (or "")
  end
  Caller->>Output: Continue `{file:...}` substitutions and JSONC parse
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • mdesmet

Poem

🐰
I nibbled bytes and found the key,
Curly dollars set configs free.
Escape with double-dollar’s art,
Defaults and quotes won't fall apart.
Hoppy builds and logs that sing!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is comprehensive and well-structured, covering what changed, why, syntax examples, security considerations, telemetry, testing, and verification. However, it does not include the required 'PINEAPPLE' marker at the top for AI-generated contributions. Add 'PINEAPPLE' at the very top of the PR description before any other content, as required by the template for AI-generated contributions.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding ${VAR} env-var interpolation for configs and references the closed issue #635.
Linked Issues check ✅ Passed The PR successfully implements ${VAR} and ${VAR:-default} interpolation for environment variables in configs, addressing the core requirement from #635. All objectives including backward compatibility, security, and preventing silent failures are met.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the #635 objective: environment variable interpolation in configs. Documentation updates, telemetry addition, and test coverage are all in-scope supporting changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/env-var-shell-syntax

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/config/paths.ts">

<violation number="1" location="packages/opencode/src/config/paths.ts:96">
P1: Double-substitution: the `${VAR}` regex runs on the output of the `{env:VAR}` pass, so an env value containing a literal `${X}` will have `X` resolved as a second variable. Combine both patterns into a single-pass replacement to avoid this injection vector.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

Applies fixes from multi-model consensus review (Claude + GPT 5.4 + Gemini 3.1 Pro).

- C1 (CRITICAL — JSON injection): ${VAR} substitution is now JSON-safe via
  JSON.stringify(value).slice(1, -1). Env values with quotes/commas/newlines
  can no longer break out of the enclosing JSON string. Existing {env:VAR}
  keeps raw-injection semantics for backward compat (documented as the
  opt-in power-user syntax).
- M2 (MAJOR — escape hatch): $${VAR} now preserves a literal ${VAR} in
  the output (docker-compose convention). Negative lookbehind in the match
  regex prevents substitution when preceded by $.
- m3 (MINOR — documentation): docs expanded with a 3-row syntax comparison
  table explaining string-safe vs raw-injection modes.
- m4 (MINOR — tests): added 3 edge-case tests covering JSON injection
  attempt, multiline/backslash values, and the new $${VAR} escape hatch.
- n5 (NIT — stale tip): TUI tip at tips.tsx:150 now mentions both
  ${VAR} and {env:VAR} syntaxes.

Deferred (larger refactor, scope discipline):
- M1 (comment handling): substitutions still run inside // comments. Same
  pre-existing behavior for {env:VAR}. Would require unified substitution
  pass. Can be a follow-up PR.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/opencode/src/config/paths.ts`:
- Around line 92-104: Reorder the interpolation passes so the POSIX-style ${VAR}
alias runs before the {env:VAR} injection to preserve raw semantics: move the
block using text.replace(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, ...) and the
unescape /\$\$(\{[A-Za-z_][A-Za-z0-9_]*\})/g, "$$$1" to execute prior to the
{env:...} replacement, keeping the same code and variable names (text, varName,
process.env). Also add a regression test that sets process.env.A = '${B}', calls
the code path that processes "{env:A}", and asserts the resulting string
contains the literal "${B}" (i.e., no second-pass expansion).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f4d1a170-7778-47eb-b366-217cf35d5ae0

📥 Commits

Reviewing files that changed from the base of the PR and between 478d893 and 31fdbcf.

📒 Files selected for processing (4)
  • docs/docs/configure/mcp-servers.md
  • packages/opencode/src/cli/cmd/tui/component/tips.tsx
  • packages/opencode/src/config/paths.ts
  • packages/opencode/test/config/paths-parsetext.test.ts

Comment on lines +92 to +104
// altimate_change start — accept ${VAR} shell/dotenv syntax as alias for {env:VAR}
// Users arriving from Claude Code / VS Code / dotenv / docker-compose expect this
// convention. Only matches POSIX identifier names to avoid collisions with random
// ${...} content. Value is JSON-escaped so it can't break out of the enclosing
// string — use {env:VAR} for raw unquoted injection. Docker-compose convention:
// $${VAR} escapes to literal ${VAR}. See issue #635.
text = text.replace(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, varName) => {
const value = process.env[varName] || ""
return JSON.stringify(value).slice(1, -1)
})
// Unescape: $${VAR} → ${VAR} (user-authored literal preservation, docker-compose style)
text = text.replace(/\$\$(\{[A-Za-z_][A-Za-z0-9_]*\})/g, "$$$1")
// altimate_change end
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve {env:VAR} raw semantics by reordering interpolation passes.

Right now, ${VAR} replacement runs after {env:VAR} replacement, so any ${...} that appears inside an env value injected by {env:...} is expanded in a second pass. That changes backward-compatible raw behavior unexpectedly.

Suggested fix
-    text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
-      return process.env[varName] || ""
-    })
-    // altimate_change start — accept ${VAR} shell/dotenv syntax as alias for {env:VAR}
+    // altimate_change start — accept ${VAR} shell/dotenv syntax as alias for {env:VAR}
     // Users arriving from Claude Code / VS Code / dotenv / docker-compose expect this
     // convention. Only matches POSIX identifier names to avoid collisions with random
     // ${...} content. Value is JSON-escaped so it can't break out of the enclosing
     // string — use {env:VAR} for raw unquoted injection. Docker-compose convention:
     // $${VAR} escapes to literal ${VAR}. See issue `#635`.
     text = text.replace(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, varName) => {
       const value = process.env[varName] || ""
       return JSON.stringify(value).slice(1, -1)
     })
     // Unescape: $${VAR} → ${VAR} (user-authored literal preservation, docker-compose style)
     text = text.replace(/\$\$(\{[A-Za-z_][A-Za-z0-9_]*\})/g, "$$$1")
+    // Keep legacy raw semantics for {env:VAR} without cascading ${...} expansion
+    text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
+      return process.env[varName] || ""
+    })
     // altimate_change end

Also add a regression test where {env:A} resolves to a value containing ${B} and remains literal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/src/config/paths.ts` around lines 92 - 104, Reorder the
interpolation passes so the POSIX-style ${VAR} alias runs before the {env:VAR}
injection to preserve raw semantics: move the block using
text.replace(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, ...) and the unescape
/\$\$(\{[A-Za-z_][A-Za-z0-9_]*\})/g, "$$$1" to execute prior to the {env:...}
replacement, keeping the same code and variable names (text, varName,
process.env). Also add a regression test that sets process.env.A = '${B}', calls
the code path that processes "{env:A}", and asserts the resulting string
contains the literal "${B}" (i.e., no second-pass expansion).

Extends the ${VAR} substitution to support POSIX/docker-compose-style
defaults. Matches user expectations from dotenv, docker-compose, and
shell: default value is used when the variable is unset OR empty
(matching :- semantics rather than bare -).

- ${VAR:-default} uses 'default' when VAR is unset or empty string
- ${VAR:-} (empty default) resolves to empty string
- Defaults with spaces/special chars supported (${VAR:-Hello World})
- Default values are JSON-escaped (same security properties as ${VAR})
- $${VAR:-default} escape hatch works for literal preservation

Per research, ${VAR:-default} is the de facto standard across
docker-compose, dotenv, POSIX shell, GitHub Actions, and Terraform —
users arrive from these tools expecting this syntax.

Added 7 tests covering unset/set/empty cases, empty default, spaces,
JSON-injection attempt, and escape hatch.
Collect signals on env-var interpolation usage to detect footguns and
guide future improvements. Tracks per config-load:

- dollar_refs: count of ${VAR} / ${VAR:-default} references
- dollar_unresolved: ${VAR} with no value and no default → empty string
  (signal: users writing fragile configs without defaults)
- dollar_defaulted: ${VAR:-default} where the fallback was used
  (signal: whether defaults syntax is actually being used)
- dollar_escaped: $${VAR} literal escapes used
- legacy_brace_refs: {env:VAR} references (raw-injection syntax)
- legacy_brace_unresolved: {env:VAR} with no value

Emitted via dynamic import to avoid circular dep with @/altimate/telemetry
(which imports @/config/config). Event fires only when interpolation
actually happens, so no-env-ref configs don't generate noise.

What this lets us answer after shipping:
- How many users hit 'unresolved' unintentionally? → consider failing loud
- Is ${VAR:-default} getting adopted? → iterate on docs if not
- Are users still writing the legacy {env:VAR}? → plan deprecation
- Is the $${VAR} escape rare? → simplify docs if so

Adds event type to ALL_EVENT_TYPES completeness list (43 → 44).
@anandgupta42 anandgupta42 changed the title fix: accept ${VAR} shell/dotenv syntax for env interpolation fix: ${VAR} env-var interpolation for configs (closes #635) Apr 5, 2026
Both bots flagged a backward-compat regression: ${VAR} pass runs after
{env:VAR} pass, so an env value containing literal ${X} got expanded in
the second pass. Reordering only fixed one direction (reverse cascade
still possible when ${VAR}'s output contains {env:Y}).

Correct fix: single-pass substitution with one regex alternation that
evaluates all three patterns against the ORIGINAL text. Output of any
pattern cannot be re-matched by another.

Single regex handles:
  $${VAR} or $${VAR:-default}  → literal escape
  (?<!$)${VAR}[:-default]       → JSON-safe substitution
  {env:VAR}                         → raw text injection

Regression tests added for both cascade directions:
  - env.A="${B}" + {env:A} → literal ${B} stays
  - env.A="{env:B}" + ${A} → literal {env:B} stays

All 34 tests pass. Typecheck + marker guard clean.
@anandgupta42 anandgupta42 merged commit b724e1c into main Apr 5, 2026
16 checks passed
@anandgupta42
Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai Already addressed in commit 45dd7bddd0 — refactored to single-pass substitution using one regex alternation that matches all three patterns against the ORIGINAL text. Output of any pattern cannot be re-matched by another.

Regex: /\$\$(\{VAR[:-default]\})|(?<!\$)\$\{VAR[:-default]\}|\{env:VAR\}/g

Added 2 regression tests covering both cascade directions:

  1. env.A="\${B}" + {env:A} in config → literal \${B} stays (original coderabbit scenario)
  2. env.A="{env:B}" + \${A} in config → literal {env:B} stays (reverse direction)

Both pass.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai bot commented Apr 5, 2026

@cubic-dev-ai Already addressed in commit 45dd7bddd0 — refactored to single-pass substitution using one regex alternation that matches all three patterns against the ORIGINAL text. Output of any pattern cannot be re-matched by another.

Regex: /\$\$(\{VAR[:-default]\})|(?<!\$)\$\{VAR[:-default]\}|\{env:VAR\}/g

Added 2 regression tests covering both cascade directions:
...

@anandgupta42 That's a solid fix for the double-substitution issue. The single-pass approach with the unified regex alternation correctly prevents cascading interpolations where an environment variable's value could inadvertently trigger a second replacement pass.

I've verified the implementation and the two new regression tests in paths-parsetext.test.ts:

  • Legacy-to-New: {env:A} containing ${B} now correctly remains ${B}.
  • New-to-Legacy: ${A} containing {env:B} now correctly remains {env:B}.

The negative lookbehind (?<!\$) combined with the first alternation group for \$\$(\{...\}) is a clean way to handle the docker-compose style escape hatch while allowing the rest of the string to be processed in one go.

I also like that you maintained the security properties we discussed:

  • ${VAR} continues to use JSON.stringify(value).slice(1, -1) for string-safe escaping.
  • {env:VAR} remains raw for backward compatibility with structural injections.

The telemetry tracking inside the replacement callback is also well-handled, ensuring we still get signals on syntax adoption despite the unified pass.

For more details on how we track these patterns, you can check the Learnings page.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP server fails to resolve environment variables in config

1 participant