Skip to content

Commit 3baaabe

Browse files
authored
fix(core): resolve mcp header env placeholders (#35236)
1 parent afe3ebb commit 3baaabe

3 files changed

Lines changed: 154 additions & 1 deletion

File tree

packages/core/src/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { ConfigPlugin } from "./config/plugin"
2323
import { ConfigProvider } from "./config/provider"
2424
import { ConfigReference } from "./config/reference"
2525
import { ConfigToolOutput } from "./config/tool-output"
26+
import { ConfigVariable } from "./config/variable"
2627
import { ConfigWatcher } from "./config/watcher"
2728
import { ConfigV1 } from "./v1/config/config"
2829
import { ConfigMigrateV1 } from "./v1/config/migrate"
@@ -148,9 +149,10 @@ const layer = Layer.effect(
148149
const loadFile = Effect.fnUntraced(function* (filepath: string) {
149150
const text = yield* fs.readFileStringSafe(filepath)
150151
if (!text) return
152+
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
151153

152154
const errors: ParseError[] = []
153-
const input: unknown = parse(text, errors, { allowTrailingComma: true })
155+
const input: unknown = parse(substituted, errors, { allowTrailingComma: true })
154156
if (errors.length) return
155157

156158
const info = Option.getOrUndefined(
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
export * as ConfigVariable from "./variable"
2+
3+
import os from "os"
4+
import path from "path"
5+
import { Effect } from "effect"
6+
import { FSUtil } from "../fs-util"
7+
import { InvalidError } from "../v1/config/error"
8+
9+
type ParseSource =
10+
| {
11+
type: "path"
12+
path: string
13+
}
14+
| {
15+
type: "virtual"
16+
source: string
17+
dir: string
18+
}
19+
20+
type SubstituteInput = ParseSource & {
21+
text: string
22+
missing?: "error" | "empty"
23+
env?: Record<string, string>
24+
}
25+
26+
/** Apply {env:VAR} and {file:path} substitutions to config text. */
27+
export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) {
28+
const text = input.text.replace(
29+
/\{env:([^}]+)\}/g,
30+
(_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "",
31+
)
32+
if (!text.includes("{file:")) return text
33+
return yield* substituteFiles(input, text)
34+
})
35+
36+
const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) {
37+
const fs = yield* FSUtil.Service
38+
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
39+
const configSource = input.type === "path" ? input.path : input.source
40+
const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
41+
let out = ""
42+
let cursor = 0
43+
44+
for (const match of matches) {
45+
const token = match[0]
46+
const index = match.index
47+
out += text.slice(cursor, index)
48+
49+
const lineStart = text.lastIndexOf("\n", index - 1) + 1
50+
const prefix = text.slice(lineStart, index).trimStart()
51+
if (prefix.startsWith("//")) {
52+
out += token
53+
cursor = index + token.length
54+
continue
55+
}
56+
57+
const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
58+
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
59+
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
60+
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
61+
Effect.catch((error) => {
62+
if (input.missing === "empty") return Effect.succeed("")
63+
64+
const message = `bad file reference: "${token}"`
65+
return Effect.fail(
66+
new InvalidError(
67+
{
68+
path: configSource,
69+
message:
70+
error._tag === "PlatformError" && error.reason._tag === "NotFound"
71+
? `${message} ${resolvedPath} does not exist`
72+
: message,
73+
},
74+
{ cause: error },
75+
),
76+
)
77+
}),
78+
)
79+
80+
out += JSON.stringify(fileContent.trim()).slice(1, -1)
81+
cursor = index + token.length
82+
}
83+
84+
return out + text.slice(cursor)
85+
})

packages/core/test/config/config.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,72 @@ describe("Config", () => {
292292
),
293293
)
294294

295+
it.live("substitutes environment variables and relative file contents", () =>
296+
Effect.acquireUseRelease(
297+
Effect.sync(() => {
298+
const previous = {
299+
token: process.env.OPENCODE_TEST_MCP_TOKEN,
300+
missing: process.env.OPENCODE_TEST_MISSING,
301+
}
302+
process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
303+
delete process.env.OPENCODE_TEST_MISSING
304+
return previous
305+
}),
306+
() =>
307+
Effect.acquireUseRelease(
308+
Effect.promise(() => tmpdir()),
309+
(tmp) =>
310+
Effect.gen(function* () {
311+
yield* Effect.promise(() =>
312+
Promise.all([
313+
fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
314+
fs.writeFile(
315+
path.join(tmp.path, "opencode.jsonc"),
316+
`{
317+
// Ignored reference: {file:missing.txt}
318+
"username": "user-{env:OPENCODE_TEST_MISSING}",
319+
"mcp": {
320+
"servers": {
321+
"remote": {
322+
"type": "remote",
323+
"url": "https://example.com/mcp",
324+
"headers": {
325+
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
326+
"X-Token": "{file:token.txt}"
327+
}
328+
}
329+
}
330+
}
331+
}`,
332+
),
333+
]),
334+
)
335+
336+
return yield* Effect.gen(function* () {
337+
const config = yield* Config.Service
338+
const document = (yield* config.entries()).find((entry) => entry.type === "document")
339+
expect(document?.info.username).toBe("user-")
340+
const remote = document?.info.mcp?.servers?.remote
341+
expect(remote?.type).toBe("remote")
342+
if (remote?.type !== "remote") return
343+
expect(remote.headers).toEqual({
344+
Authorization: "Bearer secret",
345+
"X-Token": 'file\n"token"',
346+
})
347+
}).pipe(Effect.provide(testLayer(tmp.path)))
348+
}),
349+
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
350+
),
351+
(previous) =>
352+
Effect.sync(() => {
353+
if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
354+
else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
355+
if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
356+
else process.env.OPENCODE_TEST_MISSING = previous.missing
357+
}),
358+
),
359+
)
360+
295361
it.live("does not load legacy config.json files", () =>
296362
Effect.acquireRelease(
297363
Effect.promise(() => tmpdir()),

0 commit comments

Comments
 (0)