Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/core/src/config/variable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ type SubstituteInput = ParseSource & {
env?: Record<string, string>
}

/** 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,
Expand Down Expand Up @@ -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(
Expand Down
99 changes: 99 additions & 0 deletions packages/core/test/config/variable.test.ts
Original file line number Diff line number Diff line change
@@ -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"}`)
}),
)
})
Loading