Skip to content

Commit 45dd7bd

Browse files
committed
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 a9367e1 commit 45dd7bd

File tree

2 files changed

+70
-33
lines changed

2 files changed

+70
-33
lines changed

packages/opencode/src/config/paths.ts

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -86,43 +86,49 @@ 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-
// altimate_change start — track interpolation stats for telemetry
90-
let legacyBraceRefs = 0
91-
let legacyBraceUnresolved = 0
92-
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
93-
legacyBraceRefs++
94-
const v = process.env[varName]
95-
if (v === undefined || v === "") legacyBraceUnresolved++
96-
return v || ""
97-
})
98-
// altimate_change end
99-
// altimate_change start — accept ${VAR} shell/dotenv syntax as alias for {env:VAR}
100-
// Users arriving from Claude Code / VS Code / dotenv / docker-compose expect this
101-
// convention. Only matches POSIX identifier names to avoid collisions with random
102-
// ${...} content. Value is JSON-escaped so it can't break out of the enclosing
103-
// string — use {env:VAR} for raw unquoted injection. Supports ${VAR:-default}
104-
// for fallback values (docker-compose / POSIX shell convention: default used when
105-
// the variable is unset OR empty). Docker-compose convention: $${VAR} escapes to
106-
// literal ${VAR}. See issue #635.
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.
10798
let dollarRefs = 0
10899
let dollarUnresolved = 0
109100
let dollarDefaulted = 0
110-
text = text.replace(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, varName, fallback) => {
111-
dollarRefs++
112-
const envValue = process.env[varName]
113-
const resolved = envValue !== undefined && envValue !== ""
114-
if (!resolved && fallback !== undefined) dollarDefaulted++
115-
if (!resolved && fallback === undefined) dollarUnresolved++
116-
const value = resolved ? envValue : (fallback ?? "")
117-
return JSON.stringify(value).slice(1, -1)
118-
})
119-
// Unescape: $${VAR} → ${VAR} (user-authored literal preservation, docker-compose style)
120-
// Handles both ${VAR} and ${VAR:-default} forms.
121101
let dollarEscaped = 0
122-
text = text.replace(/\$\$(\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\})/g, (_, rest) => {
123-
dollarEscaped++
124-
return "$" + rest
125-
})
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+
)
126132
// Emit telemetry if any env interpolation happened. Dynamic import avoids a
127133
// circular dep with @/altimate/telemetry (which imports @/config/config).
128134
if (dollarRefs > 0 || legacyBraceRefs > 0 || dollarEscaped > 0) {

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,37 @@ describe("ConfigPaths.parseText: ${VAR} substitution (shell/dotenv alias)", () =
250250
}
251251
})
252252

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+
253284
test("works inside MCP environment config (issue #635 regression)", async () => {
254285
process.env.OPENCODE_TEST_GITLAB_TOKEN = "glpat-xxxxx"
255286
try {

0 commit comments

Comments
 (0)