diff --git a/packages/core/src/config/variable.ts b/packages/core/src/config/variable.ts index e6d7ad4d266a..afbb3259bbe8 100644 --- a/packages/core/src/config/variable.ts +++ b/packages/core/src/config/variable.ts @@ -23,7 +23,12 @@ type SubstituteInput = ParseSource & { env?: Record } -/** Apply {env:VAR} and {file:path} substitutions to config text. */ +/** + * Apply {env:VAR} and {file:path} substitutions to config text. + * + * `text` is raw JSON(C) source: `{file:...}` bodies are JSON string fragments, + * so their escapes are decoded before filesystem access. + */ export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) { const text = input.text.replace( /\{env:([^}]+)\}/g, @@ -53,7 +58,14 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex continue } - const filePath = token.slice("{file:".length, -1) + let filePath = token.slice("{file:".length, -1) + try { + // Bodies sit inside JSON(C) string values, so encoded escapes (e.g. the + // doubled backslashes of Windows paths) must be decoded first. + filePath = JSON.parse(`"${filePath}"`) + } catch { + // Not valid JSON string content; keep the raw path. + } 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( diff --git a/packages/core/test/config/variable.test.ts b/packages/core/test/config/variable.test.ts new file mode 100644 index 000000000000..0fd38eb9636b --- /dev/null +++ b/packages/core/test/config/variable.test.ts @@ -0,0 +1,99 @@ +import os from "os" +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { ConfigVariable } from "@opencode/core/config/variable" +import { FSUtil } from "@opencode/util/fs-util" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.empty) + +// substitute() only reads files through FSUtil, so a spy layer records the exact +// paths the substitution asks for while serving canned content. +function spyFileSystemLayer(spy: { paths: string[]; content: string }) { + return Layer.effect( + FSUtil.Service, + FSUtil.Service.use((fs) => + Effect.succeed( + FSUtil.Service.of({ + ...fs, + readFileString: (target: string) => { + spy.paths.push(target) + return Effect.succeed(spy.content) + }, + }), + ), + ), + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) +} + +describe("ConfigVariable.substitute", () => { + // Reproduction: a UNC path inside a JSON string value must be written with + // doubled backslashes, so the raw token body cannot be used as a filesystem + // path — it has to be JSON-decoded first. + it.effect("decodes JSON string escapes in {file:...} bodies before filesystem access", () => + Effect.gen(function* () { + const spy = { paths: [] as string[], content: "decoded" } + const text = String.raw`{"shell":"{file:\\\\server\\share\\mcp.json}"}` + const substituted = yield* ConfigVariable.substitute({ type: "path", path: "/project/opencode.json", text }).pipe( + Effect.provide(spyFileSystemLayer(spy)), + ) + expect(spy.paths).toEqual([String.raw`\\server\share\mcp.json`]) + expect(substituted).toBe(`{"shell":"decoded"}`) + }), + ) + + // A plain Windows path written as JSON (doubled backslashes) must decode to + // the same path the user meant; on POSIX the body has no escapes and passes + // through unchanged, so this holds on every platform. + it.effect("decodes escaped Windows paths in {file:...} bodies", () => + Effect.gen(function* () { + const spy = { paths: [] as string[], content: "decoded" } + const target = path.join("C:", "Users", "dev", "mcp.json") + const body = JSON.stringify(target).slice(1, -1) + const text = `{"shell":"{file:${body}}"}` + const substituted = yield* ConfigVariable.substitute({ type: "path", path: "/project/opencode.json", text }).pipe( + Effect.provide(spyFileSystemLayer(spy)), + ) + expect(spy.paths).toEqual([target]) + expect(substituted).toBe(`{"shell":"decoded"}`) + }), + ) + + it.effect("resolves plain relative {file:...} paths against the config directory", () => + Effect.gen(function* () { + const spy = { paths: [] as string[], content: "decoded" } + const text = String.raw`{"shell":"{file:sub/mcp.json}"}` + const substituted = yield* ConfigVariable.substitute({ type: "path", path: "/project/opencode.json", text }).pipe( + Effect.provide(spyFileSystemLayer(spy)), + ) + expect(spy.paths).toEqual([path.resolve("/project", "sub/mcp.json")]) + expect(substituted).toBe(`{"shell":"decoded"}`) + }), + ) + + it.effect("keeps the raw path when a {file:...} body is not valid JSON string content", () => + Effect.gen(function* () { + const spy = { paths: [] as string[], content: "decoded" } + const text = String.raw`{"shell":"{file:\q}"}` + const substituted = yield* ConfigVariable.substitute({ type: "path", path: "/project/opencode.json", text }).pipe( + Effect.provide(spyFileSystemLayer(spy)), + ) + expect(spy.paths).toHaveLength(1) + expect(substituted).toBe(`{"shell":"decoded"}`) + }), + ) + + it.effect("expands ~ after decoding escapes in {file:...} bodies", () => + Effect.gen(function* () { + const spy = { paths: [] as string[], content: "decoded" } + const text = String.raw`{"shell":"{file:~\/config/mcp.json}"}` + const substituted = yield* ConfigVariable.substitute({ type: "path", path: "/project/opencode.json", text }).pipe( + Effect.provide(spyFileSystemLayer(spy)), + ) + expect(spy.paths).toEqual([path.join(os.homedir(), path.join("config", "mcp.json"))]) + expect(substituted).toBe(`{"shell":"decoded"}`) + }), + ) +})