fix(core): resolve mcp header env placeholders (#35236)
This commit is contained in:
@@ -23,6 +23,7 @@ import { ConfigPlugin } from "./config/plugin"
|
|||||||
import { ConfigProvider } from "./config/provider"
|
import { ConfigProvider } from "./config/provider"
|
||||||
import { ConfigReference } from "./config/reference"
|
import { ConfigReference } from "./config/reference"
|
||||||
import { ConfigToolOutput } from "./config/tool-output"
|
import { ConfigToolOutput } from "./config/tool-output"
|
||||||
|
import { ConfigVariable } from "./config/variable"
|
||||||
import { ConfigWatcher } from "./config/watcher"
|
import { ConfigWatcher } from "./config/watcher"
|
||||||
import { ConfigV1 } from "./v1/config/config"
|
import { ConfigV1 } from "./v1/config/config"
|
||||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||||
@@ -148,9 +149,10 @@ const layer = Layer.effect(
|
|||||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||||
const text = yield* fs.readFileStringSafe(filepath)
|
const text = yield* fs.readFileStringSafe(filepath)
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||||
|
|
||||||
const errors: ParseError[] = []
|
const errors: ParseError[] = []
|
||||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
const input: unknown = parse(substituted, errors, { allowTrailingComma: true })
|
||||||
if (errors.length) return
|
if (errors.length) return
|
||||||
|
|
||||||
const info = Option.getOrUndefined(
|
const info = Option.getOrUndefined(
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
export * as ConfigVariable from "./variable"
|
||||||
|
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { InvalidError } from "../v1/config/error"
|
||||||
|
|
||||||
|
type ParseSource =
|
||||||
|
| {
|
||||||
|
type: "path"
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "virtual"
|
||||||
|
source: string
|
||||||
|
dir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubstituteInput = ParseSource & {
|
||||||
|
text: string
|
||||||
|
missing?: "error" | "empty"
|
||||||
|
env?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply {env:VAR} and {file:path} substitutions to config text. */
|
||||||
|
export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) {
|
||||||
|
const text = input.text.replace(
|
||||||
|
/\{env:([^}]+)\}/g,
|
||||||
|
(_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "",
|
||||||
|
)
|
||||||
|
if (!text.includes("{file:")) return text
|
||||||
|
return yield* substituteFiles(input, text)
|
||||||
|
})
|
||||||
|
|
||||||
|
const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
|
||||||
|
const configSource = input.type === "path" ? input.path : input.source
|
||||||
|
const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||||
|
let out = ""
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const token = match[0]
|
||||||
|
const index = match.index
|
||||||
|
out += text.slice(cursor, index)
|
||||||
|
|
||||||
|
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||||
|
const prefix = text.slice(lineStart, index).trimStart()
|
||||||
|
if (prefix.startsWith("//")) {
|
||||||
|
out += token
|
||||||
|
cursor = index + token.length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||||
|
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
|
||||||
|
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
|
||||||
|
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
|
||||||
|
Effect.catch((error) => {
|
||||||
|
if (input.missing === "empty") return Effect.succeed("")
|
||||||
|
|
||||||
|
const message = `bad file reference: "${token}"`
|
||||||
|
return Effect.fail(
|
||||||
|
new InvalidError(
|
||||||
|
{
|
||||||
|
path: configSource,
|
||||||
|
message:
|
||||||
|
error._tag === "PlatformError" && error.reason._tag === "NotFound"
|
||||||
|
? `${message} ${resolvedPath} does not exist`
|
||||||
|
: message,
|
||||||
|
},
|
||||||
|
{ cause: error },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
out += JSON.stringify(fileContent.trim()).slice(1, -1)
|
||||||
|
cursor = index + token.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return out + text.slice(cursor)
|
||||||
|
})
|
||||||
@@ -292,6 +292,72 @@ describe("Config", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("substitutes environment variables and relative file contents", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = {
|
||||||
|
token: process.env.OPENCODE_TEST_MCP_TOKEN,
|
||||||
|
missing: process.env.OPENCODE_TEST_MISSING,
|
||||||
|
}
|
||||||
|
process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
|
||||||
|
delete process.env.OPENCODE_TEST_MISSING
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Promise.all([
|
||||||
|
fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
|
||||||
|
fs.writeFile(
|
||||||
|
path.join(tmp.path, "opencode.jsonc"),
|
||||||
|
`{
|
||||||
|
// Ignored reference: {file:missing.txt}
|
||||||
|
"username": "user-{env:OPENCODE_TEST_MISSING}",
|
||||||
|
"mcp": {
|
||||||
|
"servers": {
|
||||||
|
"remote": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://example.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
|
||||||
|
"X-Token": "{file:token.txt}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return yield* Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const document = (yield* config.entries()).find((entry) => entry.type === "document")
|
||||||
|
expect(document?.info.username).toBe("user-")
|
||||||
|
const remote = document?.info.mcp?.servers?.remote
|
||||||
|
expect(remote?.type).toBe("remote")
|
||||||
|
if (remote?.type !== "remote") return
|
||||||
|
expect(remote.headers).toEqual({
|
||||||
|
Authorization: "Bearer secret",
|
||||||
|
"X-Token": 'file\n"token"',
|
||||||
|
})
|
||||||
|
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||||
|
}),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
|
||||||
|
else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
|
||||||
|
if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
|
||||||
|
else process.env.OPENCODE_TEST_MISSING = previous.missing
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("does not load legacy config.json files", () =>
|
it.live("does not load legacy config.json files", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
Reference in New Issue
Block a user