anomalyco / anomalyco/opencode
# OpenCode {env:USERPROFILE} bug - reference and analysis
@nexxeln is already working on this.
Since Aug 20, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- Avg merge
- 7h 2m
- Merged PRs (30d)
- 384
Description
Description
Existing issue
This bug is already reported and closed as duplicate:
-
#35536 — {env:} substitution breaks on Windows paths due to unescaped backslashes in JSON
- Closed as duplicate
- Reported against OpenCode 1.17.13 (Jul 6, 2026)
- Assigned to @Hona, no fix shipped as of 1.18.19
-
#20640 — earlier report, closed as "incorrect root cause attribution", corrected
issue was never filed (per the #35536 reporter)
No fix has landed as of OpenCode 1.18.19.
The bug
OpenCode's {env:VAR} substitution replaces placeholders in the raw config
text before JSONC parsing, without JSON-escaping the substituted value. On
Windows, environment variables like USERPROFILE contain backslashes
(C:\Users\<name>), which produce invalid JSON escape sequences (\U) and make
the entire config file unparseable.
Why substitute before parse (the design rationale)
The substitute-before-parse order appears intentional. The substitute function
handles two kinds of placeholders, and one of them requires raw-text injection:
{env:VAR}— inline an environment variable value{file:path}— read an entire file and inline its raw contents
The {file:...} case is the reason substitution must happen before JSONC
parsing. A common pattern is:
"agent": {
"<harness>": {
"prompt": "{file:./<harness>/prompts/primary.md}"
}
}
The file primary.md is a multi-paragraph agent prompt containing newlines,
quotes, backticks, and other characters that are not valid inside a JSON string
literal. The file's contents are meant to become part of the JSON structure
itself — not a JSON-escaped string value. If you parsed the JSON first and then
substituted into the parsed string, you would either have to JSON-encode the
file contents (which would deliver escaped strings to downstream consumers
instead of raw text, breaking the prompt), or the file would have to be valid
JSON itself (which defeats the purpose of {file:...}).
So the design is: substitute first, parse second, because {file:...} can
inject arbitrary content that is itself meant to be part of the JSON.
Why it breaks for {env:VAR}
The two placeholders have opposite needs:
| Placeholder | Content | Should be JSON-escaped? |
|---|---|---|
{file:path} |
arbitrary, may be JSON structure | No — meant to become part of the JSON |
{env:VAR} |
a string value (path, key, etc.) | Yes — it's a value inside a JSON string |
The bug is that both placeholders go through the same replace call with the
same lack of escaping. The env value is spliced into the raw text as-is, and if
it contains backslashes (as Windows paths do), the resulting text is no longer
valid JSON.
Notably, {file:...} already does the right thing — it wraps the file contents
with JSON.stringify(), which escapes backslashes. {env:VAR} does not.
Suggested fix
The fix is not "move substitution after parse" — that would break {file:...}.
Instead, {env:VAR} and {file:path} need different treatment:
-
JSON-escape
{env:VAR}substitutions only — after replacing an env
placeholder, escape backslashes (\->\\) and other JSON-special
characters in the substituted value before it lands in the raw text. Leave
{file:...}substitutions unescaped. This is the minimal fix and respects
the existing design. This is essentially what{file:...}already does via
JSON.stringify(). -
Detect context — only escape the env value if the placeholder appears
inside a JSON string literal (between double quotes). If it appears in a
position where{file:...}would (replacing a value that is itself JSON),
leave it raw. This is more correct but harder to implement with regex
replacement on raw text, which is likely why the escaping was omitted. -
At minimum, document the limitation — if the current behaviour is kept,
note that{env:...}values containing backslashes will break the config on
Windows, and recommend forward-slash paths or{env:HOME}as a workaround.
Plugins
own harness
OpenCode version
1.18.19
Steps to reproduce
Reproduction
1. Config file
~/.config/opencode/opencode.jsonc contains:
{
"instructions": [
"{env:USERPROFILE}/.config/opencode/<harness>/AGENTS.md"
]
}
2. Error
ConfigJsonError: InvalidEscapeCharacter at line 34, column 5
Line 34: "C:\Users\<user>/.config/opencode/<harness>/AGENTS.md",
^
The full error includes the substituted config content showing
"C:\Users\<user>/.config/..." where \U is an invalid JSON escape.
3. Root cause
The substitute function does a raw string replacement on the config text
before it is parsed as JSONC:
// From the binary (minified):
async function d(o) {
let t = o.missing ?? "error",
r = o.text.replace(/\{env:([^}]+)\}/g, (D, n) => {
return (o.env?.[n] ?? process.env[n]) || ""
}),
// ... then {file:...} handling ...
return r;
}
// Caller:
A = yield* W.promise(() => B2.substitute({text: Y, type: "path", path: j.path, env: Q}));
G = f.jsonc(A, z); // parses substituted text as JSONC
The replacement (o.env?.[n] ?? process.env[n]) || "" inserts the raw
environment variable value into the text. On Windows:
process.env.USERPROFILE=C:\Users\<user>- After substitution:
"C:\Users\<user>/.config/opencode/<harness>/AGENTS.md" \Uis not a valid JSON escape sequence (only\ulowercase is)- JSONC parse fails
The #35536 reporter identified the same root cause in the source:
substitute() in packages/opencode/src/config/variable.ts does a plain
replace() for {env:} values, unlike {file:} which wraps with
JSON.stringify() to escape backslashes.
4. Why this matters
Any {env:...} placeholder whose value contains a backslash will break the
config. This affects at minimum:
{env:USERPROFILE}—C:\Users\<name>(Windows){env:HOMEDRIVE}+{env:HOMEPATH}—C:\+\Users\<name>- Any custom env var containing a Windows path
The {env:HOME} placeholder works on Linux/macOS because /home/<user> has no
backslashes, but on native Windows HOME is typically unset and USERPROFILE
is the correct variable.
Screenshot and/or share link
No response
Operating System
Windows 11
Terminal
Window Terminal
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.