#12822 · @jerome-benoit · opened Feb 9, 2026 at 1:35 PM UTC · last updated Mar 21, 2026 at 11:31 AM UTC

fix(env): remove Env namespace, use direct process.env access

appfix
61
+28829620 files

Score breakdown

Impact

9.0

Clarity

9.0

Urgency

8.0

Ease Of Review

6.0

Guidelines

9.0

Readiness

7.0

Size

0.0

Trust

10.0

Traction

2.0

Summary

This PR removes the problematic Env namespace, which caused environment variables to become stale and broke dynamic provider detection. It replaces all usages with direct process.env access, fixing a long-standing issue and simplifying the codebase. This is a critical bug fix for dynamic configurations.

Open in GitHub

Description

Summary

Removes the Env namespace entirely. Fixes #12698.

Why Remove Instead of Conditional Caching?

The suggested fix proposed conditional caching. However, the Env namespace has caused repeated issues:

  • #11481: Env.set() mutates internal copy, not process.env — breaks provider SDKs
  • #12698: Env.all() returns stale snapshot
  • 7ebe352af (#11482): workaround bypassing Env in provider code

The API semantics are misleading: Env.set(key, value) appears to set an env var but external code (AWS SDK, child processes) never sees the change. Removal eliminates this leaky and not thread-safe abstraction.

Changes

| Area | Change | |------|--------| | src/env/index.ts | Deleted | | src/**/*.ts | Env.*process.env[key] | | test/preload.ts | Env snapshot/restore per test | | test/provider/*.test.ts | Remove Env imports |

Linked Issues

#12698 Env.all() caches process.env snapshot, preventing detection of env vars set after initialization

View issue

Comments

No comments.

Changed Files

.gitignore

+10
@@ -14,6 +14,7 @@ ts-dist
.turbo
**/.serena
.serena/
.sisyphus/
/result
refs
Session.vim

packages/opencode/src/cli/cmd/github.ts

+99
@@ -453,7 +453,7 @@ export const GithubRunCommand = cmd({
const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch"
const { providerID, modelID } = normalizeModel()
const variant = process.env["VARIANT"] || undefined
const variant = process.env.VARIANT || undefined
const runId = normalizeRunId()
const share = normalizeShare()
const oidcBaseUrl = normalizeOidcBaseUrl()
@@ -518,7 +518,7 @@ export const GithubRunCommand = cmd({
try {
if (useGithubToken) {
const githubToken = process.env["GITHUB_TOKEN"]
const githubToken = process.env.GITHUB_TOKEN
if (!githubToken) {
throw new Error(
"GITHUB_TOKEN environment variable is not set. When using use_github_token, you must provide GITHUB_TOKEN.",
@@ -701,7 +701,7 @@ export const GithubRunCommand = cmd({
process.exit(exitCode)
function normalizeModel() {
const value = process.env["MODEL"]
const value = process.env.MODEL
if (!value) throw new Error(`Environment variable "MODEL" is not set`)
const { providerID, modelID } = Provider.parseModel(value

packages/opencode/src/cli/cmd/tui/context/route.tsx

+22
@@ -20,8 +20,8 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: () => {
const [store, setStore] = createStore<Route>(
process.env["OPENCODE_ROUTE"]
? JSON.parse(process.env["OPENCODE_ROUTE"])
process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: {
type: "home",
},

packages/opencode/src/cli/cmd/tui/util/clipboard.ts

+22
@@ -17,7 +17,7 @@ function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const passthrough = process.env["TMUX"] || process.env["STY"]
const passthrough = process.env.TMUX || process.env.STY
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
@@ -103,7 +103,7 @@ export namespace Clipboard {
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
if (process.env.WAYLAND_DISPLAY && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })

packages/opencode/src/cli/cmd/tui/util/editor.ts

+11
@@ -8,7 +8,7 @@ import { Process } from "@/util/process"
export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
const editor = process.env.VISUAL || process.env.EDITOR
if (!editor) return
const filepath = join(tmpdir(), `${Date.now()}.md`)

packages/opencode/src/config/config.ts

+02
@@ -12,7 +12,6 @@ import { lazy } from "../util/lazy"
import { NamedError } from "@opencode-ai/util/error"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { Env } from "../env"
import {
type ParseError as JsoncParseError,
applyEdits,
@@ -186,7 +185,6 @@ export namespace Config {
])
if (token) {
process.env["OPENCODE_CONSOLE_TOKEN"] = token
Env.set("OPENCODE_CONSOLE_TOKEN", token)
}
if (config) {

packages/opencode/src/env/index.ts

+028
@@ -1,28 +0,0 @@
import { Instance } from "../project/instance"
export namespace Env {
const state = Instance.state(() => {
// Create a shallow copy to isolate environment per instance
// Prevents parallel tests from interfering with each other's env vars
return { ...process.env } as Record<string, string | undefined>
})
export function get(key: string) {
const env = state()
return env[key]
}
export function all() {
return state()
}
export function set(key: string, value: string) {
const env = state()
env[key] = value
}
export function remove(key: string) {
const env = state()
delete env[key]
}
}

packages/opencode/src/flag/flag.ts

+1414
@@ -12,15 +12,15 @@ function falsy(key: string) {
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export const OPENCODE_GIT_BASH_PATH = process.env.OPENCODE_GIT_BASH_PATH
export const OPENCODE_CONFIG = process.env.OPENCODE_CONFIG
export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_CONFIG_CONTENT = process.env.OPENCODE_CONFIG_CONTENT
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
export const OPENCODE_PERMISSION = process.env.OPENCODE_PERMISSION
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")

packages/opencode/src/ide/index.ts

+33
@@ -35,8 +35,8 @@ export namespace Ide {
)
export function ide() {
if (process.env["TERM_PROGRAM"] === "vscode") {
const v = process.env["GIT_ASKPASS"]
if (process.env.TERM_PROGRAM === "vscode") {
const v = process.env.GIT_ASKPASS
for (const ide of SUPPORTED_IDES) {
if (v?.includes(ide.name)) return ide.name
}
@@ -45,7 +45,7 @@ export namespace Ide {
}
export function alreadyInstalled() {
return process.env["OPENCODE_CALLER"] === "vscode" || process.env["OPENCODE_CALLER"] === "vscode-insiders"
return process.env.OPENCODE_CALLER === "vscode" || process.env.OPENCODE_CALLER === "vscode-insiders"
}
export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) {

packages/opencode/src/lsp/server.ts

+1111
@@ -373,7 +373,7 @@ export namespace LSPServer {
extensions: [".go"],
async spawn(root) {
let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
PATH: process.env.PATH + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!which("go")) return
@@ -410,7 +410,7 @@ export namespace LSPServer {
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
PATH: process.env.PATH + path.delimiter + Global.Path.bin,
})
if (!bin) {
const ruby = which("ruby")
@@ -465,7 +465,7 @@ export namespace LSPServer {
const initialization: Record<string, string> = {}
const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter(
const potentialVenvPaths = [process.env.VIRTUAL_ENV, path.join(root, ".venv"), path.join(root, "venv")].filter(
(p): p is string => p !== undefined,
)
for (const venvPath of potentialVenvPaths) {
@@ -534,7 +534,7 @@ export namespace L

packages/opencode/src/provider/provider.ts

+2429
@@ -11,7 +11,6 @@ import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error"
import { ModelsDev } from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
@@ -162,7 +161,7 @@ export namespace Provider {
},
async opencode(input) {
const hasKey = await (async () => {
const env = Env.all()
const env = process.env
if (input.env.some((item) => env[item])) return true
if (await Auth.get(input.id)) return true
const config = await Config.get()
@@ -214,7 +213,7 @@ export namespace Provider {
const resource = iife(() => {
const name = provider.options?.resourceName
if (typeof name === "string" && name.trim() !== "") return name
return Env.get("AZURE_RESOURCE_NAME")
return process.env.AZURE_RESOURCE_NAME
})
return {
@@ -236,7 +235,7 @@ export namespace Provider {
}
},
"azure-cognitive-services": async () => {
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")

packages/opencode/src/share/share-next.ts

+11
@@ -61,7 +61,7 @@ export namespace ShareNext {
return { headers, api: consoleApi, baseUrl: active.url }
}
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
const disabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
export async function init() {
if (disabled) return

packages/opencode/src/util/proxied.ts

+61
@@ -1,3 +1,8 @@
export function proxied() {
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
return !!(
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.https_proxy
)
}

packages/opencode/test/config/config.test.ts

+4848
@@ -179,8 +179,8 @@ test("merges multiple config files with correct precedence", async () => {
})
test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"]
process.env["TEST_VAR"] = "test-user"
const originalEnv = process.env.TEST_VAR
process.env.TEST_VAR = "test-user"
try {
await using tmp = await tmpdir({
@@ -200,16 +200,16 @@ test("handles environment variable substitution", async () => {
})
} finally {
if (originalEnv !== undefined) {
process.env["TEST_VAR"] = originalEnv
process.env.TEST_VAR = originalEnv
} else {
delete process.env["TEST_VAR"]
delete process.env.TEST_VAR
}
}
})
test("preserves env variables when adding $schema to config", async () => {
const originalEnv = process.env["PRESERVE_VAR"]
process.env["PRESERVE_VAR"] = "secret_value"
const originalEnv = process.env.PRESERVE_VAR
process.env.PRESERVE_VAR = "secret_value"
try {
await using tmp = await tmpdir({
@@ -238,9 +238,9 @@ test("preserves env variables when adding $schema to config", async () => {
})
} finally {
if (originalEnv !== undefined) {

packages/opencode/test/ide/ide.test.ts

+1717
@@ -12,70 +12,70 @@ describe("ide", () => {
})
test("should detect Visual Studio Code", () => {
process.env["TERM_PROGRAM"] = "vscode"
process.env["GIT_ASKPASS"] = "/path/to/Visual Studio Code.app/Contents/Resources/app/extensions/git/dist/askpass.sh"
process.env.TERM_PROGRAM = "vscode"
process.env.GIT_ASKPASS = "/path/to/Visual Studio Code.app/Contents/Resources/app/extensions/git/dist/askpass.sh"
expect(Ide.ide()).toBe("Visual Studio Code")
})
test("should detect Visual Studio Code Insiders", () => {
process.env["TERM_PROGRAM"] = "vscode"
process.env["GIT_ASKPASS"] =
process.env.TERM_PROGRAM = "vscode"
process.env.GIT_ASKPASS =
"/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/extensions/git/dist/askpass.sh"
expect(Ide.ide()).toBe("Visual Studio Code - Insiders")
})
test("should detect Cursor", () => {
process.env["TERM_PROGRAM"] = "vscode"
process.env["GIT_ASKPASS"] = "/path/to/Cursor.app/Contents/Resources/app/extensions/git/dist/askpass.sh"
process.env.TERM_PROGRAM = "vscode"
process.env.GIT_ASKPASS = "/path/to/Cursor.app/Contents/Resources/app/extensi

packages/opencode/test/preload.ts

+5430
@@ -4,7 +4,7 @@ import os from "os"
import path from "path"
import fs from "fs/promises"
import { setTimeout as sleep } from "node:timers/promises"
import { afterAll } from "bun:test"
import { afterAll, beforeEach, afterEach } from "bun:test"
// Set XDG env vars FIRST, before any src/ imports
const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid)
@@ -29,50 +29,50 @@ afterAll(async () => {
await rm(30)
})
process.env["XDG_DATA_HOME"] = path.join(dir, "share")
process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
process.env["XDG_STATE_HOME"] = path.join(dir, "state")
process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
process.env.XDG_DATA_HOME = path.join(dir, "share")
process.env.XDG_CACHE_HOME = path.join(dir, "cache")
process.env.XDG_CONFIG_HOME = path.join(dir, "config")
process.env.XDG_STATE_HOME = path.join(dir, "state")
process.env.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
// Set test home directory to isolate tests from user's actual home directory
// This prevents tests fr

packages/opencode/test/provider/amazon-bedrock.test.ts

+1819
@@ -6,7 +6,6 @@ import { ProviderID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { Env } from "../../src/env"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem"
@@ -31,8 +30,8 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("AWS_REGION", "us-east-1")
Env.set("AWS_PROFILE", "default")
process.env.AWS_REGION = "us-east-1"
process.env.AWS_PROFILE = "default"
},
fn: async () => {
const providers = await Provider.list()
@@ -56,8 +55,8 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("AWS_REGION", "eu-west-1")
Env.set("AWS_PROFILE", "default")
process.env.AWS_REGION = "eu-west-1"
process.env.AWS_PROFILE = "default"
},
fn: async () => {
const providers = await

packages/opencode/test/provider/gitlab-duo.test.ts

+1415
@@ -5,7 +5,6 @@ import { ProviderID, ModelID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { Env } from "../../src/env"
import { Global } from "../../src/global"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
@@ -23,7 +22,7 @@ test("GitLab Duo: loads provider with API key from environment", async () => {
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-gitlab-token")
process.env.GITLAB_TOKEN = "test-gitlab-token"
},
fn: async () => {
const providers = await Provider.list()
@@ -54,8 +53,8 @@ test("GitLab Duo: config instanceUrl option sets baseURL", async () => {
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com")
process.env.GITLAB_TOKEN = "test-token"
process.env.GITLAB_INSTANCE_URL = "https://gitlab.example.com"
},
fn: async () => {
const providers =

packages/opencode/test/provider/provider.test.ts

+5758
@@ -5,7 +5,6 @@ import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Env } from "../../src/env"
test("provider loaded from env variable", async () => {
await using tmp = await tmpdir({
@@ -21,7 +20,7 @@ test("provider loaded from env variable", async () => {
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
process.env.ANTHROPIC_API_KEY = "test-api-key"
},
fn: async () => {
const providers = await Provider.list()
@@ -76,7 +75,7 @@ test("disabled_providers excludes provider", async () => {
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
process.env.ANTHROPIC_API_KEY = "test-api-key"
},
fn: async () => {
const providers = await Provider.list()
@@ -100,8 +99,8 @@ test("enabled_providers restricts to only listed providers", async () => {
await Instance.provide({
directory: tmp.path,
in

packages/opencode/test/session/instruction.test.ts

+66
@@ -74,14 +74,14 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
let originalConfigDir: string | undefined
beforeEach(() => {
originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
originalConfigDir = process.env.OPENCODE_CONFIG_DIR
})
afterEach(() => {
if (originalConfigDir === undefined) {
delete process.env["OPENCODE_CONFIG_DIR"]
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
process.env.OPENCODE_CONFIG_DIR = originalConfigDir
}
})
@@ -98,7 +98,7 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
})
await using projectTmp = await tmpdir()
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
process.env.OPENCODE_CONFIG_DIR = profileTmp.path
const originalGlobalConfig = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
@@ -125,7 +125,7 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
})
await using projectTmp = await tmpdir()
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
process.env.OPENCODE_