anomalyco / anomalyco/opencode

# OpenCode {env:USERPROFILE} bug - reference and analysis

Open
#43,691 1 comment 1 reaction 1 assignee View on GitHub

@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:

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:

  1. 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().

  2. 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.

  3. 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"
  • \U is not a valid JSON escape sequence (only \u lowercase 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.