Skip to content

Commit b724e1c

Browse files
authored
fix: ${VAR} env-var interpolation for configs (closes #635) (#655)
* fix: [#635] accept ${VAR} shell/dotenv syntax for env interpolation 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 * fix: address consensus review findings for PR #655 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. * feat: add ${VAR:-default} syntax for fallback values 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. * feat: add config_env_interpolation telemetry event 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). * fix: address P1 double-substitution from cubic/coderabbit review 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.
1 parent 478d893 commit b724e1c

File tree

6 files changed

+304
-5
lines changed

6 files changed

+304
-5
lines changed

docs/docs/configure/mcp-servers.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,21 @@ Run an MCP server as a local subprocess:
2020
}
2121
```
2222

23+
### Environment variable interpolation
24+
25+
Both syntaxes work anywhere in the config:
26+
27+
| Syntax | Injection mode | Example |
28+
|--------|----------------|---------|
29+
| `${VAR}` | String-safe (JSON-escaped) | `"API_KEY": "${MY_API_KEY}"` — shell / dotenv style |
30+
| `${VAR:-default}` | String-safe with fallback | `"MODE": "${APP_MODE:-production}"` — used when `VAR` is unset or empty |
31+
| `{env:VAR}` | Raw text | `"count": {env:NUM}` — use for unquoted structural injection |
32+
| `$${VAR}` | Escape hatch | `"template": "$${VAR}"` — preserves literal `${VAR}` (docker-compose style) |
33+
34+
If the variable is not set and no default is given, it resolves to an empty string. Bare `$VAR` (without braces) is **not** interpolated — use `${VAR}` or `{env:VAR}`.
35+
36+
**Why two syntaxes?** `${VAR}` JSON-escapes the value so tokens containing quotes or braces can't break the config structure — the safe default for secrets. `{env:VAR}` does raw text injection for the rare case where you need to inject numbers or structure into unquoted JSON positions.
37+
2338
| Field | Type | Description |
2439
|-------|------|-------------|
2540
| `type` | `"local"` | Local subprocess server |

packages/opencode/src/altimate/telemetry/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,25 @@ export namespace Telemetry {
659659
error_message?: string
660660
}
661661
// altimate_change end
662+
// altimate_change start — config env-var interpolation telemetry
663+
| {
664+
type: "config_env_interpolation"
665+
timestamp: number
666+
session_id: string
667+
/** ${VAR} / ${VAR:-default} references encountered */
668+
dollar_refs: number
669+
/** ${VAR} with no value and no default → resolved to empty string (footgun signal) */
670+
dollar_unresolved: number
671+
/** ${VAR:-default} where default was used */
672+
dollar_defaulted: number
673+
/** $${VAR} literal escape sequences found */
674+
dollar_escaped: number
675+
/** legacy {env:VAR} references (raw injection syntax) */
676+
legacy_brace_refs: number
677+
/** {env:VAR} with no value → empty string */
678+
legacy_brace_unresolved: number
679+
}
680+
// altimate_change end
662681
// altimate_change start — plan-agent model tool-call refusal detection
663682
| {
664683
type: "plan_no_tool_generation"

packages/opencode/src/cli/cmd/tui/component/tips.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ const TIPS = [
147147
"Create JSON theme files in {highlight}.altimate-code/themes/{/highlight} directory",
148148
"Themes support dark/light variants for both modes",
149149
"Reference ANSI colors 0-255 in custom themes",
150-
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config",
150+
"Use {highlight}${VAR_NAME}{/highlight} or {highlight}{env:VAR_NAME}{/highlight} to reference environment variables in config",
151151
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
152152
"Use {highlight}instructions{/highlight} in config to load additional rules files",
153153
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",

packages/opencode/src/config/paths.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,69 @@ export namespace ConfigPaths {
8686

8787
/** Apply {env:VAR} and {file:path} substitutions to config text. */
8888
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
89-
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
90-
return process.env[varName] || ""
91-
})
89+
// altimate_change start — unified env-var interpolation
90+
// Single-pass substitution against the ORIGINAL text prevents output of one
91+
// pattern being re-matched by another (e.g. {env:A}="${B}" expanding B).
92+
// Syntaxes (order tried, in one regex via alternation):
93+
// 1. $${VAR} or $${VAR:-default} — literal escape (docker-compose style)
94+
// 2. ${VAR} or ${VAR:-default} — string-safe, JSON-escaped (shell/dotenv)
95+
// 3. {env:VAR} — raw text injection (backward compat)
96+
// Users arriving from Claude Code / VS Code / dotenv / docker-compose expect
97+
// ${VAR}. Use {env:VAR} for raw unquoted injection. See issue #635.
98+
let dollarRefs = 0
99+
let dollarUnresolved = 0
100+
let dollarDefaulted = 0
101+
let dollarEscaped = 0
102+
let legacyBraceRefs = 0
103+
let legacyBraceUnresolved = 0
104+
text = text.replace(
105+
/\$\$(\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\})|(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}|\{env:([^}]+)\}/g,
106+
(match, escaped, dollarVar, dollarDefault, braceVar) => {
107+
if (escaped !== undefined) {
108+
// $${VAR} → literal ${VAR}
109+
dollarEscaped++
110+
return "$" + escaped
111+
}
112+
if (dollarVar !== undefined) {
113+
// ${VAR} / ${VAR:-default} → JSON-escaped string-safe substitution
114+
dollarRefs++
115+
const envValue = process.env[dollarVar]
116+
const resolved = envValue !== undefined && envValue !== ""
117+
if (!resolved && dollarDefault !== undefined) dollarDefaulted++
118+
if (!resolved && dollarDefault === undefined) dollarUnresolved++
119+
const value = resolved ? envValue : (dollarDefault ?? "")
120+
return JSON.stringify(value).slice(1, -1)
121+
}
122+
if (braceVar !== undefined) {
123+
// {env:VAR} → raw text injection
124+
legacyBraceRefs++
125+
const v = process.env[braceVar]
126+
if (v === undefined || v === "") legacyBraceUnresolved++
127+
return v || ""
128+
}
129+
return match
130+
},
131+
)
132+
// Emit telemetry if any env interpolation happened. Dynamic import avoids a
133+
// circular dep with @/altimate/telemetry (which imports @/config/config).
134+
if (dollarRefs > 0 || legacyBraceRefs > 0 || dollarEscaped > 0) {
135+
import("@/altimate/telemetry")
136+
.then(({ Telemetry }) => {
137+
Telemetry.track({
138+
type: "config_env_interpolation",
139+
timestamp: Date.now(),
140+
session_id: Telemetry.getContext().sessionId,
141+
dollar_refs: dollarRefs,
142+
dollar_unresolved: dollarUnresolved,
143+
dollar_defaulted: dollarDefaulted,
144+
dollar_escaped: dollarEscaped,
145+
legacy_brace_refs: legacyBraceRefs,
146+
legacy_brace_unresolved: legacyBraceUnresolved,
147+
})
148+
})
149+
.catch(() => {})
150+
}
151+
// altimate_change end
92152

93153
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
94154
if (!fileMatches.length) return text

packages/opencode/test/config/paths-parsetext.test.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,210 @@ describe("ConfigPaths.parseText: {env:VAR} substitution", () => {
9797
})
9898
})
9999

100+
describe("ConfigPaths.parseText: ${VAR} substitution (shell/dotenv alias)", () => {
101+
const envKey = "OPENCODE_TEST_SHELL_SYNTAX_KEY"
102+
103+
beforeEach(() => {
104+
process.env[envKey] = "shell-style-value"
105+
})
106+
107+
afterEach(() => {
108+
delete process.env[envKey]
109+
})
110+
111+
test("substitutes ${VAR} with environment variable value", async () => {
112+
const text = `{"apiKey": "\${${envKey}}"}`
113+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
114+
expect(result).toEqual({ apiKey: "shell-style-value" })
115+
})
116+
117+
test("substitutes to empty string when env var is not set", async () => {
118+
const text = '{"apiKey": "${OPENCODE_TEST_SHELL_NONEXISTENT_XYZ}"}'
119+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
120+
expect(result).toEqual({ apiKey: "" })
121+
})
122+
123+
test("${VAR} and {env:VAR} both work in same config", async () => {
124+
process.env.OPENCODE_TEST_MIXED_A = "alpha"
125+
process.env.OPENCODE_TEST_MIXED_B = "beta"
126+
try {
127+
const text = '{"a": "${OPENCODE_TEST_MIXED_A}", "b": "{env:OPENCODE_TEST_MIXED_B}"}'
128+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
129+
expect(result).toEqual({ a: "alpha", b: "beta" })
130+
} finally {
131+
delete process.env.OPENCODE_TEST_MIXED_A
132+
delete process.env.OPENCODE_TEST_MIXED_B
133+
}
134+
})
135+
136+
test("ignores ${...} with non-identifier names (spaces, special chars)", async () => {
137+
// These should pass through unmodified — not valid POSIX identifiers
138+
const text = '{"a": "${FOO BAR}", "b": "${foo-bar}", "c": "${foo.bar}"}'
139+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
140+
expect(result).toEqual({ a: "${FOO BAR}", b: "${foo-bar}", c: "${foo.bar}" })
141+
})
142+
143+
test("does not match bare $VAR (without braces)", async () => {
144+
process.env.OPENCODE_TEST_BARE = "should-not-match"
145+
try {
146+
const text = '{"value": "$OPENCODE_TEST_BARE"}'
147+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
148+
// Bare $VAR stays literal — only ${VAR} is interpolated
149+
expect(result).toEqual({ value: "$OPENCODE_TEST_BARE" })
150+
} finally {
151+
delete process.env.OPENCODE_TEST_BARE
152+
}
153+
})
154+
155+
test("JSON-safe: env value with quotes cannot inject JSON structure", async () => {
156+
// Security regression test for C1 in consensus review of PR #655.
157+
// {env:VAR} is raw injection (backward compat); ${VAR} is string-safe.
158+
process.env.OPENCODE_TEST_INJECT = 'pwned", "isAdmin": true, "x": "y'
159+
try {
160+
const text = '{"token": "${OPENCODE_TEST_INJECT}"}'
161+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
162+
// Value stays inside the "token" string — no injection into sibling keys
163+
expect(result).toEqual({ token: 'pwned", "isAdmin": true, "x": "y' })
164+
expect(result.isAdmin).toBeUndefined()
165+
} finally {
166+
delete process.env.OPENCODE_TEST_INJECT
167+
}
168+
})
169+
170+
test("JSON-safe: env value with backslash and newline escaped properly", async () => {
171+
process.env.OPENCODE_TEST_MULTILINE = 'line1\nline2\tpath\\to\\file'
172+
try {
173+
const text = '{"value": "${OPENCODE_TEST_MULTILINE}"}'
174+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
175+
expect(result).toEqual({ value: "line1\nline2\tpath\\to\\file" })
176+
} finally {
177+
delete process.env.OPENCODE_TEST_MULTILINE
178+
}
179+
})
180+
181+
test("default: ${VAR:-default} uses default when var unset", async () => {
182+
// Variable is not set — default value should be used
183+
const text = '{"mode": "${OPENCODE_TEST_UNSET_VAR:-production}"}'
184+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
185+
expect(result).toEqual({ mode: "production" })
186+
})
187+
188+
test("default: ${VAR:-default} uses env value when var set", async () => {
189+
process.env.OPENCODE_TEST_DEFAULT_OVERRIDE = "staging"
190+
try {
191+
const text = '{"mode": "${OPENCODE_TEST_DEFAULT_OVERRIDE:-production}"}'
192+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
193+
expect(result).toEqual({ mode: "staging" })
194+
} finally {
195+
delete process.env.OPENCODE_TEST_DEFAULT_OVERRIDE
196+
}
197+
})
198+
199+
test("default: ${VAR:-default} uses default when var is empty string", async () => {
200+
// POSIX :- uses default for both unset AND empty (matches docker-compose)
201+
process.env.OPENCODE_TEST_EMPTY_VAR = ""
202+
try {
203+
const text = '{"mode": "${OPENCODE_TEST_EMPTY_VAR:-fallback}"}'
204+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
205+
expect(result).toEqual({ mode: "fallback" })
206+
} finally {
207+
delete process.env.OPENCODE_TEST_EMPTY_VAR
208+
}
209+
})
210+
211+
test("default: empty default ${VAR:-} resolves to empty string", async () => {
212+
const text = '{"value": "${OPENCODE_TEST_EMPTY_DEFAULT:-}"}'
213+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
214+
expect(result).toEqual({ value: "" })
215+
})
216+
217+
test("default: default value with spaces and special chars", async () => {
218+
const text = '{"msg": "${OPENCODE_TEST_MISSING:-Hello World 123}"}'
219+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
220+
expect(result).toEqual({ msg: "Hello World 123" })
221+
})
222+
223+
test("default: default value is JSON-escaped (security)", async () => {
224+
const text = '{"token": "${OPENCODE_TEST_MISSING:-pwned\\", \\"isAdmin\\": true, \\"x\\": \\"y}"}'
225+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
226+
expect(result.token).toContain("pwned")
227+
expect(result.isAdmin).toBeUndefined()
228+
})
229+
230+
test("escape hatch: $${VAR:-default} stays literal", async () => {
231+
process.env.OPENCODE_TEST_ESCAPED_DEFAULT = "should-not-be-used"
232+
try {
233+
const text = '{"template": "$${OPENCODE_TEST_ESCAPED_DEFAULT:-my-default}"}'
234+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
235+
expect(result).toEqual({ template: "${OPENCODE_TEST_ESCAPED_DEFAULT:-my-default}" })
236+
} finally {
237+
delete process.env.OPENCODE_TEST_ESCAPED_DEFAULT
238+
}
239+
})
240+
241+
test("escape hatch: $${VAR} stays literal (docker-compose convention)", async () => {
242+
process.env.OPENCODE_TEST_SHOULD_NOT_SUB = "interpolated"
243+
try {
244+
const text = '{"template": "$${OPENCODE_TEST_SHOULD_NOT_SUB}"}'
245+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
246+
// $${VAR} → literal ${VAR}, env value is NOT substituted
247+
expect(result).toEqual({ template: "${OPENCODE_TEST_SHOULD_NOT_SUB}" })
248+
} finally {
249+
delete process.env.OPENCODE_TEST_SHOULD_NOT_SUB
250+
}
251+
})
252+
253+
test("single-pass: {env:A} value containing ${B} stays literal (no cascade)", async () => {
254+
// Regression test for cubic/coderabbit P1: previously the {env:VAR} pass ran
255+
// first, then the ${VAR} pass expanded any ${...} in its output. Single-pass
256+
// substitution evaluates both patterns against the ORIGINAL text only.
257+
process.env.OPENCODE_TEST_CASCADE_A = "${OPENCODE_TEST_CASCADE_B}"
258+
process.env.OPENCODE_TEST_CASCADE_B = "should-not-expand"
259+
try {
260+
const text = '{"value": "{env:OPENCODE_TEST_CASCADE_A}"}'
261+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
262+
// {env:VAR} is raw injection — its output is NOT re-interpolated
263+
expect(result.value).toBe("${OPENCODE_TEST_CASCADE_B}")
264+
} finally {
265+
delete process.env.OPENCODE_TEST_CASCADE_A
266+
delete process.env.OPENCODE_TEST_CASCADE_B
267+
}
268+
})
269+
270+
test("single-pass: ${A} value containing {env:B} stays literal (no cascade)", async () => {
271+
// Reverse direction: ${VAR} output must not be matched by {env:VAR} pass.
272+
process.env.OPENCODE_TEST_CASCADE_C = "{env:OPENCODE_TEST_CASCADE_D}"
273+
process.env.OPENCODE_TEST_CASCADE_D = "should-not-expand"
274+
try {
275+
const text = '{"value": "${OPENCODE_TEST_CASCADE_C}"}'
276+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
277+
expect(result.value).toBe("{env:OPENCODE_TEST_CASCADE_D}")
278+
} finally {
279+
delete process.env.OPENCODE_TEST_CASCADE_C
280+
delete process.env.OPENCODE_TEST_CASCADE_D
281+
}
282+
})
283+
284+
test("works inside MCP environment config (issue #635 regression)", async () => {
285+
process.env.OPENCODE_TEST_GITLAB_TOKEN = "glpat-xxxxx"
286+
try {
287+
const text = `{
288+
"mcp": {
289+
"gitlab": {
290+
"type": "local",
291+
"command": ["npx", "-y", "@modelcontextprotocol/server-gitlab"],
292+
"environment": { "GITLAB_TOKEN": "\${OPENCODE_TEST_GITLAB_TOKEN}" }
293+
}
294+
}
295+
}`
296+
const result = await ConfigPaths.parseText(text, "/fake/config.json")
297+
expect(result.mcp.gitlab.environment.GITLAB_TOKEN).toBe("glpat-xxxxx")
298+
} finally {
299+
delete process.env.OPENCODE_TEST_GITLAB_TOKEN
300+
}
301+
})
302+
})
303+
100304
describe("ConfigPaths.parseText: {file:path} substitution", () => {
101305
test("substitutes {file:path} with file contents (trimmed)", async () => {
102306
await using tmp = await tmpdir()

packages/opencode/test/telemetry/telemetry.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,12 @@ const ALL_EVENT_TYPES: Telemetry.Event["type"][] = [
246246
"feature_suggestion",
247247
"core_failure",
248248
"sql_pre_validation",
249+
"config_env_interpolation",
249250
]
250251

251252
describe("telemetry.event-types", () => {
252253
test("all event types are valid", () => {
253-
expect(ALL_EVENT_TYPES.length).toBe(43)
254+
expect(ALL_EVENT_TYPES.length).toBe(44)
254255
})
255256
})
256257

0 commit comments

Comments
 (0)