Skip to content

Commit daaaceb

Browse files
authored
fix: [AI-656] single-pass env-var resolution for MCP configs (#697)
Follow-up to #666. The two-layer design (parse-time `ConfigPaths.parseText` in `readJsonSafe` + launch-time `resolveEnvVars` in `mcp/index.ts`) created two correctness bugs: 1. `$${VAR}` escape broken end-to-end: Layer 1 turned `$${VAR}` into literal `${VAR}`; Layer 2 then re-resolved it to the live env value. No syntax remained for passing a literal `${SOMETHING}` to an MCP server. 2. Variable-chain injection: with `EVIL_VAR="${SECRET}"` in the shell and a config referencing `${EVIL_VAR}`, Layer 1 produced the literal `"${SECRET}"` which Layer 2 then resolved to the actual secret — exfiltrating a variable the config never mentioned. The fix collapses to a single resolution point at config load time, scoped to the fields that need it. Changes: - `config/paths.ts`: export shared `ENV_VAR_PATTERN`, `resolveEnvVarsInString()`, and `newEnvSubstitutionStats()`. Internal `substitute()` reuses the same regex grammar — no more duplicate implementation in `mcp/index.ts`. - `mcp/discover.ts`: revert the `ConfigPaths.parseText` call in `readJsonSafe` (was substituting across the whole JSON text, not just env blocks). Add `resolveServerEnvVars()` helper called from `transform()`, scoped to `env` and `headers` values only. Emits `log.warn` for unresolved vars with server and source context. - `mcp/index.ts`: remove `resolveEnvVars()` and `ENV_VAR_PATTERN` entirely. The stdio launch site now uses `mcp.environment` directly — single resolution point already ran at config load time. - `test/mcp/env-var-interpolation.test.ts`: switch to `ConfigPaths.resolveEnvVarsInString`. Add 5 regression tests: single-pass no-chain; `$${VAR}` survives end-to-end; chain injection blocked; `${VAR}` resolved in remote server `headers`; `${VAR}` in command args / URL is NOT resolved (scope check). Also fixes fragile `process.env = {...}` teardown. Addresses the PR #666 consensus code review: - Critical: double interpolation (resolution collapsed to one pass) - Major: regex duplication (single source in `config/paths.ts`) - Major: silent empty-string on unresolved vars (now `log.warn` with context) - Major: whole-JSON-text scope (narrowed to `env` and `headers` only) - Major: swallowed `catch {}` in `readJsonSafe` (removed with the whole block) - Minor: `resolveEnvVars` top-level export (removed) - Minor: `mcp.headers` not resolved (now resolved) Test results: 36/36 tests pass in `test/mcp/env-var-interpolation.test.ts` + `test/mcp/discover.test.ts`. Marker guard clean. No new TS errors in touched files. Relates to #656 and #666.
1 parent 350001d commit daaaceb

File tree

4 files changed

+363
-141
lines changed

4 files changed

+363
-141
lines changed

packages/opencode/src/config/paths.ts

Lines changed: 114 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -84,51 +84,127 @@ export namespace ConfigPaths {
8484
return typeof input === "string" ? path.dirname(input) : input.dir
8585
}
8686

87+
// altimate_change start — shared env-var interpolation primitives
88+
// Unified regex for env-var interpolation, single source of truth.
89+
// Syntaxes (alternation, left-to-right):
90+
// 1. $${VAR} or $${VAR:-default} — literal escape (docker-compose style)
91+
// 2. ${VAR} or ${VAR:-default} — shell/dotenv substitution
92+
// 3. {env:VAR} — raw text injection (backward compat)
93+
// Exported so other modules (e.g. mcp/discover) can reuse the exact same grammar
94+
// without forking the regex. See issue #635, #656.
95+
export const ENV_VAR_PATTERN =
96+
/\$\$(\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\})|(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}|\{env:([^}]+)\}/g
97+
98+
export interface EnvSubstitutionStats {
99+
dollarRefs: number
100+
dollarUnresolved: number
101+
dollarDefaulted: number
102+
dollarEscaped: number
103+
legacyBraceRefs: number
104+
legacyBraceUnresolved: number
105+
unresolvedNames: string[]
106+
}
107+
108+
/**
109+
* Resolve ${VAR}, ${VAR:-default}, {env:VAR}, and $${VAR} patterns in a raw
110+
* string value (i.e. a value that is already a parsed JS string, NOT JSON text).
111+
* Returns the resolved string without any JSON escaping — safe for direct use in
112+
* process environments, HTTP headers, or anywhere a plain string is needed.
113+
*
114+
* Does NOT JSON-escape — use the internal `substitute()` wrapper below if you
115+
* need that (substitute() operates on raw JSON text pre-parse).
116+
*/
117+
export function resolveEnvVarsInString(
118+
value: string,
119+
stats?: EnvSubstitutionStats,
120+
): string {
121+
return value.replace(ENV_VAR_PATTERN, (match, escaped, dollarVar, dollarDefault, braceVar) => {
122+
if (escaped !== undefined) {
123+
// $${VAR} → literal ${VAR}
124+
if (stats) stats.dollarEscaped++
125+
return "$" + escaped
126+
}
127+
if (dollarVar !== undefined) {
128+
// ${VAR} / ${VAR:-default}
129+
if (stats) stats.dollarRefs++
130+
const envValue = process.env[dollarVar]
131+
const resolved = envValue !== undefined && envValue !== ""
132+
if (!resolved && dollarDefault !== undefined && stats) stats.dollarDefaulted++
133+
if (!resolved && dollarDefault === undefined) {
134+
if (stats) {
135+
stats.dollarUnresolved++
136+
stats.unresolvedNames.push(dollarVar)
137+
}
138+
}
139+
return resolved ? envValue : (dollarDefault ?? "")
140+
}
141+
if (braceVar !== undefined) {
142+
// {env:VAR} → raw text injection
143+
if (stats) stats.legacyBraceRefs++
144+
const v = process.env[braceVar]
145+
if ((v === undefined || v === "") && stats) {
146+
stats.legacyBraceUnresolved++
147+
stats.unresolvedNames.push(braceVar)
148+
}
149+
return v || ""
150+
}
151+
return match
152+
})
153+
}
154+
155+
export function newEnvSubstitutionStats(): EnvSubstitutionStats {
156+
return {
157+
dollarRefs: 0,
158+
dollarUnresolved: 0,
159+
dollarDefaulted: 0,
160+
dollarEscaped: 0,
161+
legacyBraceRefs: 0,
162+
legacyBraceUnresolved: 0,
163+
unresolvedNames: [],
164+
}
165+
}
166+
// altimate_change end
167+
87168
/** Apply {env:VAR} and {file:path} substitutions to config text. */
88169
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
89170
// altimate_change start — unified env-var interpolation
90171
// Single-pass substitution against the ORIGINAL text prevents output of one
91172
// 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)
173+
// Uses the shared ENV_VAR_PATTERN grammar, adding JSON-escaping for values
174+
// substituted into raw JSON text (which is then parsed). This is the ONLY
175+
// call site that applies JSON escaping; raw-string callers should use
176+
// `resolveEnvVarsInString` instead.
177+
const stats = newEnvSubstitutionStats()
178+
text = text.replace(ENV_VAR_PATTERN, (match, escaped, dollarVar, dollarDefault, braceVar) => {
179+
if (escaped !== undefined) {
180+
stats.dollarEscaped++
181+
return "$" + escaped
182+
}
183+
if (dollarVar !== undefined) {
184+
stats.dollarRefs++
185+
const envValue = process.env[dollarVar]
186+
const resolved = envValue !== undefined && envValue !== ""
187+
if (!resolved && dollarDefault !== undefined) stats.dollarDefaulted++
188+
if (!resolved && dollarDefault === undefined) {
189+
stats.dollarUnresolved++
190+
stats.unresolvedNames.push(dollarVar)
121191
}
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 || ""
192+
const value = resolved ? envValue : (dollarDefault ?? "")
193+
// JSON-escape because this substitution happens against raw JSON text.
194+
return JSON.stringify(value).slice(1, -1)
195+
}
196+
if (braceVar !== undefined) {
197+
stats.legacyBraceRefs++
198+
const v = process.env[braceVar]
199+
if (v === undefined || v === "") {
200+
stats.legacyBraceUnresolved++
201+
stats.unresolvedNames.push(braceVar)
128202
}
129-
return match
130-
},
131-
)
203+
return v || ""
204+
}
205+
return match
206+
})
207+
const { dollarRefs, dollarUnresolved, dollarDefaulted, dollarEscaped, legacyBraceRefs, legacyBraceUnresolved } = stats
132208
// Emit telemetry if any env interpolation happened. Dynamic import avoids a
133209
// circular dep with @/altimate/telemetry (which imports @/config/config).
134210
if (dollarRefs > 0 || legacyBraceRefs > 0 || dollarEscaped > 0) {

packages/opencode/src/mcp/discover.ts

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,41 @@ import path from "path"
33
import { parse as parseJsonc } from "jsonc-parser"
44
import { Log } from "../util/log"
55
import { Filesystem } from "../util/filesystem"
6+
import { ConfigPaths } from "../config/paths"
67
import type { Config } from "../config/config"
78

89
const log = Log.create({ service: "mcp.discover" })
910

11+
// altimate_change start — per-field env-var resolution for discovered MCP configs
12+
// Discovered configs (.vscode/mcp.json, .cursor/mcp.json, ~/.claude.json, etc.)
13+
// are parsed with plain parseJsonc and thus never pass through ConfigPaths.substitute.
14+
// Resolve ${VAR} / {env:VAR} patterns only on the env and headers fields so that
15+
// scoping is narrow (we don't touch command args, URLs, or server names) and so
16+
// that the launch site does NOT need a second resolution pass.
17+
// See PR #666 review — double-interpolation regression fixed by doing this once,
18+
// here, rather than twice.
19+
function resolveServerEnvVars(
20+
obj: Record<string, unknown>,
21+
context: { server: string; source: string; field: "env" | "headers" },
22+
): Record<string, string> {
23+
const out: Record<string, string> = {}
24+
const stats = ConfigPaths.newEnvSubstitutionStats()
25+
for (const [key, raw] of Object.entries(obj)) {
26+
if (typeof raw !== "string") continue
27+
out[key] = ConfigPaths.resolveEnvVarsInString(raw, stats)
28+
}
29+
if (stats.unresolvedNames.length > 0) {
30+
log.warn("unresolved env var references in MCP config — substituting empty string", {
31+
server: context.server,
32+
source: context.source,
33+
field: context.field,
34+
unresolved: stats.unresolvedNames.join(", "),
35+
})
36+
}
37+
return out
38+
}
39+
// altimate_change end
40+
1041
interface ExternalMcpSource {
1142
/** Relative path from base directory */
1243
file: string
@@ -32,16 +63,29 @@ const SOURCES: ExternalMcpSource[] = [
3263
* Transform a single external MCP entry into our Config.Mcp shape.
3364
* Returns undefined if the entry is invalid (no command or url).
3465
* Preserves recognized fields: timeout, enabled.
66+
*
67+
* altimate_change — `context` is used to scope env-var resolution to the
68+
* `env` and `headers` fields and to tag warnings with the source + server name.
3569
*/
36-
function transform(entry: Record<string, any>): Config.Mcp | undefined {
70+
function transform(
71+
entry: Record<string, any>,
72+
// altimate_change start — context for env-var resolution warnings
73+
context: { server: string; source: string },
74+
// altimate_change end
75+
): Config.Mcp | undefined {
3776
// Remote server — handle both "url" and Claude Code's "type: http" format
3877
if (entry.url && typeof entry.url === "string") {
3978
const result: Record<string, any> = {
4079
type: "remote" as const,
4180
url: entry.url,
4281
}
4382
if (entry.headers && typeof entry.headers === "object") {
44-
result.headers = entry.headers
83+
// altimate_change start — resolve env vars in headers (e.g. Authorization: Bearer ${TOKEN})
84+
result.headers = resolveServerEnvVars(entry.headers as Record<string, unknown>, {
85+
...context,
86+
field: "headers",
87+
})
88+
// altimate_change end
4589
}
4690
if (typeof entry.timeout === "number") result.timeout = entry.timeout
4791
if (typeof entry.enabled === "boolean") result.enabled = entry.enabled
@@ -63,7 +107,12 @@ function transform(entry: Record<string, any>): Config.Mcp | undefined {
63107
command: cmd,
64108
}
65109
if (entry.env && typeof entry.env === "object") {
66-
result.environment = entry.env
110+
// altimate_change start — resolve env vars in environment block
111+
result.environment = resolveServerEnvVars(entry.env as Record<string, unknown>, {
112+
...context,
113+
field: "env",
114+
})
115+
// altimate_change end
67116
}
68117
if (typeof entry.timeout === "number") result.timeout = entry.timeout
69118
if (typeof entry.enabled === "boolean") result.enabled = entry.enabled
@@ -93,7 +142,10 @@ function addServersFromFile(
93142
if (Object.prototype.hasOwnProperty.call(result, name)) continue // first source wins
94143
if (!entry || typeof entry !== "object") continue
95144

96-
const transformed = transform(entry as Record<string, any>)
145+
const transformed = transform(entry as Record<string, any>, {
146+
server: name,
147+
source: sourceLabel,
148+
})
97149
if (transformed) {
98150
// Project-scoped servers are discovered but disabled by default for security.
99151
// User-owned home-directory configs are auto-enabled.
@@ -117,18 +169,6 @@ async function readJsonSafe(filePath: string): Promise<any | undefined> {
117169
} catch {
118170
return undefined
119171
}
120-
// altimate_change start — apply env-var interpolation to external MCP configs
121-
// External configs (Claude Code, Cursor, Copilot, Gemini) may contain ${VAR}
122-
// or {env:VAR} references that need resolving before JSON parsing.
123-
// Uses ConfigPaths.parseText which runs substitute() then parses JSONC.
124-
try {
125-
const { ConfigPaths } = await import("../config/paths")
126-
return await ConfigPaths.parseText(text, filePath, "empty")
127-
} catch {
128-
// Substitution or parse failure — fall back to direct parse without interpolation
129-
log.debug("env-var interpolation failed for external MCP config, falling back to direct parse", { file: filePath })
130-
}
131-
// altimate_change end
132172
const errors: any[] = []
133173
const result = parseJsonc(text, errors, { allowTrailingComma: true })
134174
if (errors.length > 0) {

packages/opencode/src/mcp/index.ts

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,29 +25,6 @@ import { TuiEvent } from "@/cli/cmd/tui/event"
2525
import open from "open"
2626
import { Telemetry } from "@/telemetry"
2727

28-
// altimate_change start — resolve env-var references in MCP environment values
29-
// Handles ${VAR}, ${VAR:-default}, and {env:VAR} patterns that may have survived
30-
// config parsing (e.g. discovered external configs, config updates via updateGlobal).
31-
const ENV_VAR_PATTERN =
32-
/\$\$(\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\})|(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}|\{env:([^}]+)\}/g
33-
34-
export function resolveEnvVars(environment: Record<string, string>): Record<string, string> {
35-
const resolved: Record<string, string> = {}
36-
for (const [key, value] of Object.entries(environment)) {
37-
resolved[key] = value.replace(ENV_VAR_PATTERN, (match, escaped, dollarVar, dollarDefault, braceVar) => {
38-
if (escaped !== undefined) return "$" + escaped
39-
if (dollarVar !== undefined) {
40-
const envValue = process.env[dollarVar]
41-
return envValue !== undefined && envValue !== "" ? envValue : (dollarDefault ?? "")
42-
}
43-
if (braceVar !== undefined) return process.env[braceVar] || ""
44-
return match
45-
})
46-
}
47-
return resolved
48-
}
49-
// altimate_change end
50-
5128
export namespace MCP {
5229
const log = Log.create({ service: "mcp" })
5330
const DEFAULT_TIMEOUT = 30_000
@@ -532,8 +509,12 @@ export namespace MCP {
532509
env: {
533510
...process.env,
534511
...(cmd === "altimate" || cmd === "altimate-code" ? { BUN_BE_BUN: "1" } : {}),
535-
// altimate_change start — resolve ${VAR} / {env:VAR} patterns that survived config parsing
536-
...(mcp.environment ? resolveEnvVars(mcp.environment) : {}),
512+
// altimate_change start — env-var references in mcp.environment are resolved once
513+
// at config load time: `ConfigPaths.substitute()` for `opencode.json`, and
514+
// `resolveServerEnvVars()` for discovered external configs (`.vscode/mcp.json`,
515+
// `.cursor/mcp.json`, etc.). A second pass here would re-expand already-resolved
516+
// values and break the `$${VAR}` escape convention — see PR #666 review.
517+
...(mcp.environment ?? {}),
537518
// altimate_change end
538519
},
539520
})

0 commit comments

Comments
 (0)