From 0380a03a64eaff5461a3245cb90e85ebde32eb9d Mon Sep 17 00:00:00 2001 From: 0xDevNinja Date: Thu, 18 Jun 2026 12:03:10 +0530 Subject: [PATCH] fix(config): json-escape env/file substitutions into config text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{env:VAR}` was substituted into the raw config document before JSON parsing, so a value containing backslashes — e.g. a native Windows path like `C:\Users\me\AppData\Roaming\MyApp` set via `{env:MYDIR}` — produced invalid JSON escape sequences and failed startup with `InvalidEscapeCharacter`. Add a `jsonEscape` option to `substitute()` and pass it from the two callers whose output is handed to a JSON parser (`config.ts`, `tui.ts`), so substituted values are escaped to stay valid inside a JSON string. The remote-config url/header callers leave it off, since they substitute into already-parsed strings. `{file:}` substitution now follows the same flag (it was previously always escaped, which was wrong for the url/header case). Closes #32695 --- packages/opencode/src/config/config.ts | 4 ++-- packages/opencode/src/config/tui.ts | 2 +- packages/opencode/src/config/variable.ts | 13 +++++++++++-- packages/opencode/test/config/config.test.ts | 16 ++++++++++++++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7f568f492073..7f3cdc260fd7 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -219,8 +219,8 @@ export const layer = Layer.effect( const expanded = yield* Effect.promise(() => ConfigVariable.substitute( "path" in options - ? { text, type: "path", path: options.path, env } - : { text, type: "virtual", ...options, env }, + ? { text, type: "path", path: options.path, env, jsonEscape: true } + : { text, type: "virtual", ...options, env, jsonEscape: true }, ), ) const parsed = ConfigParse.jsonc(expanded, source) diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index edc7674a9309..b8fc3a90901b 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -97,7 +97,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const load = (text: string, configFilepath: string): Effect.Effect => Effect.gen(function* () { const expanded = yield* Effect.promise(() => - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", jsonEscape: true }), ) const data = ConfigParse.jsonc(expanded, configFilepath) if (!isRecord(data)) return {} as Info diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 52c449538fa1..21ab19f5008a 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -20,6 +20,14 @@ type SubstituteInput = ParseSource & { text: string missing?: "error" | "empty" env?: Record + /** + * Escape substituted values so they remain valid inside a JSON string when + * the result is fed to a JSON parser. Without this, a value containing + * backslashes (e.g. a native Windows path `C:\Users\me`) or quotes breaks + * the surrounding document. Leave off when substituting into an already + * parsed string (e.g. a remote-config url or header value). + */ + jsonEscape?: boolean } function source(input: ParseSource) { @@ -33,8 +41,9 @@ function dir(input: ParseSource) { /** Apply {env:VAR} and {file:path} substitutions to config text. */ export async function substitute(input: SubstituteInput) { const missing = input.missing ?? "error" + const escape = input.jsonEscape ? (value: string) => JSON.stringify(value).slice(1, -1) : (value: string) => value let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { - return (input.env?.[varName] ?? process.env[varName]) || "" + return escape((input.env?.[varName] ?? process.env[varName]) || "") }) const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) @@ -82,7 +91,7 @@ export async function substitute(input: SubstituteInput) { }) ).trim() - out += JSON.stringify(fileContent).slice(1, -1) + out += escape(fileContent) cursor = index + token.length } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 02ace5366880..39727dd3e994 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -515,6 +515,22 @@ it.instance("handles environment variable substitution", () => ), ) +it.instance("escapes backslashes in env substitution so Windows paths stay valid JSON", () => + withProcessEnv( + "WIN_PATH_VAR", + "C:\\Users\\me\\AppData\\Roaming\\MyApp", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeConfigEffect(test.directory, { + $schema: "https://opencode.ai/config.json", + username: "{env:WIN_PATH_VAR}", + }) + const config = yield* Config.use.get() + expect(config.username).toBe("C:\\Users\\me\\AppData\\Roaming\\MyApp") + }), + ), +) + it.instance("preserves env variables when adding $schema to config", () => withProcessEnv( "PRESERVE_VAR",