From 87e5a80a262b4cfeb62f2a03392c60a3db1ae992 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 17:25:03 +0800 Subject: [PATCH 1/4] fix: isolate internal auth env from user commands --- packages/app/e2e/backend.test.ts | 24 ++++++++ packages/app/e2e/backend.ts | 42 +++++++++----- packages/opencode/src/pty/index.ts | 11 +++- packages/opencode/src/session/prompt.ts | 8 ++- packages/opencode/src/tool/bash.ts | 5 +- packages/opencode/src/util/env.ts | 8 +++ .../opencode/test/pty/pty-session.test.ts | 58 +++++++++++++++++++ .../test/session/prompt-effect.test.ts | 39 +++++++++++++ packages/opencode/test/tool/bash.test.ts | 46 +++++++++++++++ 9 files changed, 220 insertions(+), 21 deletions(-) create mode 100644 packages/app/e2e/backend.test.ts create mode 100644 packages/opencode/src/util/env.ts diff --git a/packages/app/e2e/backend.test.ts b/packages/app/e2e/backend.test.ts new file mode 100644 index 000000000..4400337b8 --- /dev/null +++ b/packages/app/e2e/backend.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { createBackendEnv } from "./backend" + +describe("createBackendEnv", () => { + test("does not pass inherited OpenCode server auth into isolated e2e backend", () => { + const env = createBackendEnv({ + base: { + PATH: "/usr/bin", + OPENCODE_SERVER_USERNAME: "PawWork", + OPENCODE_SERVER_PASSWORD: "secret", + opencode_server_password: "mixed-case-secret", + CUSTOM_VALUE: "kept", + }, + sandbox: "/tmp/pawwork-e2e", + }) + + expect(env.OPENCODE_SERVER_USERNAME).toBeUndefined() + expect(env.OPENCODE_SERVER_PASSWORD).toBeUndefined() + expect(env.opencode_server_password).toBeUndefined() + expect(env.PATH).toBe("/usr/bin") + expect(env.CUSTOM_VALUE).toBe("kept") + expect(env.OPENCODE_CLIENT).toBe("app") + }) +}) diff --git a/packages/app/e2e/backend.ts b/packages/app/e2e/backend.ts index a03d1d437..a67a16ea9 100644 --- a/packages/app/e2e/backend.ts +++ b/packages/app/e2e/backend.ts @@ -58,6 +58,8 @@ async function waitExit(proc: ReturnType, timeout = 10_000) { const LOG_CAP = 100 +const INTERNAL_SERVER_AUTH_ENV = new Set(["opencode_server_password", "opencode_server_username"]) + function cap(input: string[]) { if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP) } @@ -66,26 +68,38 @@ function tail(input: string[]) { return input.slice(-40).join("") } -export async function startBackend(label: string, input?: { llmUrl?: string }): Promise { - const port = await freePort() - const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`)) - const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") - const repoDir = path.resolve(appDir, "../..") - const opencodeDir = path.join(repoDir, "packages", "opencode") +export function createBackendEnv(input: { + base?: NodeJS.ProcessEnv + sandbox: string + llmUrl?: string +}): Record { const env = { - ...process.env, + ...(input.base ?? process.env), OPENCODE_DISABLE_LSP_DOWNLOAD: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", - OPENCODE_TEST_HOME: path.join(sandbox, "home"), - XDG_DATA_HOME: path.join(sandbox, "share"), - XDG_CACHE_HOME: path.join(sandbox, "cache"), - XDG_CONFIG_HOME: path.join(sandbox, "config"), - XDG_STATE_HOME: path.join(sandbox, "state"), + OPENCODE_TEST_HOME: path.join(input.sandbox, "home"), + XDG_DATA_HOME: path.join(input.sandbox, "share"), + XDG_CACHE_HOME: path.join(input.sandbox, "cache"), + XDG_CONFIG_HOME: path.join(input.sandbox, "config"), + XDG_STATE_HOME: path.join(input.sandbox, "state"), OPENCODE_CLIENT: "app", OPENCODE_STRICT_CONFIG_DEPS: "true", - OPENCODE_E2E_LLM_URL: input?.llmUrl, - } satisfies Record + OPENCODE_E2E_LLM_URL: input.llmUrl, + } + for (const key of Object.keys(env)) { + if (INTERNAL_SERVER_AUTH_ENV.has(key.toLowerCase())) delete env[key] + } + return env +} + +export async function startBackend(label: string, input?: { llmUrl?: string }): Promise { + const port = await freePort() + const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`)) + const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") + const repoDir = path.resolve(appDir, "../..") + const opencodeDir = path.join(repoDir, "packages", "opencode") + const env = createBackendEnv({ sandbox, llmUrl: input?.llmUrl }) const out: string[] = [] const err: string[] = [] const proc = spawn( diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index b70e986db..e2b0e0549 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -9,6 +9,7 @@ import { Log } from "@opencode-ai/core/util/log" import { lazy } from "@opencode-ai/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" +import { withoutInternalServerAuthEnv } from "@/util/env" import { PtyID } from "./schema" import { Effect, Layer, Context } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" @@ -183,13 +184,19 @@ export namespace Pty { const cwd = input.cwd || s.dir const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} }) - const env = { + const env = withoutInternalServerAuthEnv({ ...process.env, ...input.env, ...shell.env, TERM: "xterm-256color", OPENCODE_TERMINAL: "1", - } as Record + } as Record) + // bun-pty merges with the parent process environment internally, so + // deleting these keys is not enough for PTY sessions. Override with + // empty values to prevent PawWork's internal server credentials from + // being visible inside user terminals. + env.OPENCODE_SERVER_USERNAME = "" + env.OPENCODE_SERVER_PASSWORD = "" if (process.platform === "win32") { env.LC_ALL = "C.UTF-8" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9c7310bc9..f00762693 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -47,6 +47,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" +import { withoutInternalServerAuthEnv } from "@/util/env" import { Cause, Deferred, Effect, Exit, Layer, Option, Scope, Context } from "effect" import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" @@ -1004,15 +1005,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the ), ) - const env = { + const env = withoutInternalServerAuthEnv({ + ...process.env, ...shellEnv.env, TERM: "dumb", ...(shellName === "zsh" || shellName === "bash" ? { OPENCODE_SHELL_CWD: cwd } : {}), - } + }) const cmd = ChildProcess.make(sh, args, { cwd, - extendEnv: true, + extendEnv: false, env, stdin: "ignore", forceKillAfter: "3 seconds", diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 1cf455d4a..ecb859d94 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -20,6 +20,7 @@ import { Plugin } from "@/plugin" import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { withoutInternalServerAuthEnv } from "@/util/env" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -403,11 +404,11 @@ export const BashTool = Tool.define( const bundledToolsDir = resourcesPath ? path.join(resourcesPath, "tools") : "" const extraEnv = extra.env as Record const currentPath = extraEnv.PATH || process.env.PATH || "" - return { + return withoutInternalServerAuthEnv({ ...process.env, ...extraEnv, PATH: bundledToolsDir ? `${bundledToolsDir}${path.delimiter}${currentPath}` : currentPath, - } + }) }) const run = Effect.fn("BashTool.run")(function* ( diff --git a/packages/opencode/src/util/env.ts b/packages/opencode/src/util/env.ts new file mode 100644 index 000000000..8f8d9ea50 --- /dev/null +++ b/packages/opencode/src/util/env.ts @@ -0,0 +1,8 @@ +const INTERNAL_SERVER_AUTH_ENV = new Set(["opencode_server_password", "opencode_server_username"]) + +export function withoutInternalServerAuthEnv>(env: T): T { + for (const key of Object.keys(env)) { + if (INTERNAL_SERVER_AUTH_ENV.has(key.toLowerCase())) delete env[key] + } + return env +} diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index f7a949c92..8455d1cba 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -89,4 +89,62 @@ describe("pty", () => { }, }) }) + + test("does not expose internal server auth env to terminal sessions", async () => { + if (process.platform === "win32") return + + const previousUsername = process.env.OPENCODE_SERVER_USERNAME + const previousPassword = process.env.OPENCODE_SERVER_PASSWORD + const previousCustom = process.env.PAWWORK_E2E_CUSTOM_ENV + process.env.OPENCODE_SERVER_USERNAME = "PawWork" + process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.PAWWORK_E2E_CUSTOM_ENV = "kept" + + try { + await using dir = await tmpdir({ git: true }) + + await Instance.provide({ + directory: dir.path, + fn: async () => { + let id: PtyID | undefined + try { + const info = await Pty.create({ + command: "/bin/sh", + title: "env", + }) + id = info.id + + const output: string[] = [] + await Pty.connect(info.id, { + readyState: 1, + send: (data: unknown) => output.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")), + close: () => undefined, + } as any) + + await Pty.write( + info.id, + 'printf "username=%s\\n" "${OPENCODE_SERVER_USERNAME}" && printf "password=%s\\n" "${OPENCODE_SERVER_PASSWORD}" && printf "custom=%s\\n" "${PAWWORK_E2E_CUSTOM_ENV-unset}"\nexit\n', + ) + await wait(() => output.join("").includes("custom=")) + + const text = output.join("") + expect(text).toContain("username=") + expect(text).toContain("password=") + expect(text).toContain("custom=kept") + expect(text).not.toContain("secret") + expect(text).not.toContain("PawWork") + } finally { + if (id) await Pty.remove(id) + } + }, + }) + } finally { + if (previousUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME + else process.env.OPENCODE_SERVER_USERNAME = previousUsername + if (previousPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = previousPassword + if (previousCustom === undefined) delete process.env.PAWWORK_E2E_CUSTOM_ENV + else process.env.PAWWORK_E2E_CUSTOM_ENV = previousCustom + } + }) }) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 7a28f4e71..1acff08ba 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1249,6 +1249,45 @@ unix("shell captures stdout and stderr in completed tool output", () => ), ) +unix("shell does not expose internal server auth env", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const previousUsername = process.env.OPENCODE_SERVER_USERNAME + const previousPassword = process.env.OPENCODE_SERVER_PASSWORD + const previousCustom = process.env.PAWWORK_E2E_CUSTOM_ENV + process.env.OPENCODE_SERVER_USERNAME = "PawWork" + process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.PAWWORK_E2E_CUSTOM_ENV = "kept" + + try { + const { prompt, chat } = yield* boot() + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: + 'printf "username=%s\\n" "${OPENCODE_SERVER_USERNAME-unset}" && printf "password=%s\\n" "${OPENCODE_SERVER_PASSWORD-unset}" && printf "custom=%s\\n" "${PAWWORK_E2E_CUSTOM_ENV-unset}"', + }) + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.output).toContain("username=unset") + expect(tool.state.output).toContain("password=unset") + expect(tool.state.output).toContain("custom=kept") + expect(tool.state.output).not.toContain("secret") + } finally { + if (previousUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME + else process.env.OPENCODE_SERVER_USERNAME = previousUsername + if (previousPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = previousPassword + if (previousCustom === undefined) delete process.env.PAWWORK_E2E_CUSTOM_ENV + else process.env.PAWWORK_E2E_CUSTOM_ENV = previousCustom + } + }), + { git: true, config: cfg }, + ), +) + unix("shell completes a fast command on the preferred shell", () => provideTmpdirInstance( (dir) => diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 71046aeaf..a4d96aced 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -153,6 +153,52 @@ describe("tool.bash", () => { }, }) }) + + each("does not expose internal server auth env to user commands", async () => { + const previousUsername = process.env.OPENCODE_SERVER_USERNAME + const previousPassword = process.env.OPENCODE_SERVER_PASSWORD + const previousCustom = process.env.PAWWORK_E2E_CUSTOM_ENV + process.env.OPENCODE_SERVER_USERNAME = "PawWork" + process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.PAWWORK_E2E_CUSTOM_ENV = "kept" + const code = [ + 'console.log("username=" + (process.env.OPENCODE_SERVER_USERNAME ?? "unset"))', + 'console.log("password=" + (process.env.OPENCODE_SERVER_PASSWORD ?? "unset"))', + 'console.log("custom=" + (process.env.PAWWORK_E2E_CUSTOM_ENV ?? "unset"))', + ].join(";") + const command = `${PS.has(sh()) ? "& " : ""}${bin} -e ${evalarg(code)}` + + try { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initBash() + const result = await Effect.runPromise( + bash.execute( + { + command, + description: "Print selected environment variables", + }, + ctx, + ), + ) + + expect(result.metadata.exit).toBe(0) + expect(result.output).toContain("username=unset") + expect(result.output).toContain("password=unset") + expect(result.output).toContain("custom=kept") + expect(result.output).not.toContain("secret") + }, + }) + } finally { + if (previousUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME + else process.env.OPENCODE_SERVER_USERNAME = previousUsername + if (previousPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = previousPassword + if (previousCustom === undefined) delete process.env.PAWWORK_E2E_CUSTOM_ENV + else process.env.PAWWORK_E2E_CUSTOM_ENV = previousCustom + } + }) }) describe("tool.bash permissions", () => { From 42b032b73649fb9eb5aac86b72cc0393e1ec6d73 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 18:33:33 +0800 Subject: [PATCH 2/4] fix: preserve explicit pty auth env overrides --- .../e2e-backend-env.test.ts} | 4 +- packages/opencode/src/pty/index.ts | 6 +- packages/opencode/src/util/env.ts | 5 ++ .../opencode/test/pty/pty-session.test.ts | 57 +++++++++++++++++++ packages/opencode/test/tool/bash.test.ts | 14 +++++ 5 files changed, 82 insertions(+), 4 deletions(-) rename packages/app/{e2e/backend.test.ts => test/e2e-backend-env.test.ts} (82%) diff --git a/packages/app/e2e/backend.test.ts b/packages/app/test/e2e-backend-env.test.ts similarity index 82% rename from packages/app/e2e/backend.test.ts rename to packages/app/test/e2e-backend-env.test.ts index 4400337b8..c22f47276 100644 --- a/packages/app/e2e/backend.test.ts +++ b/packages/app/test/e2e-backend-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { createBackendEnv } from "./backend" +import { createBackendEnv } from "../e2e/backend" describe("createBackendEnv", () => { test("does not pass inherited OpenCode server auth into isolated e2e backend", () => { @@ -8,6 +8,7 @@ describe("createBackendEnv", () => { PATH: "/usr/bin", OPENCODE_SERVER_USERNAME: "PawWork", OPENCODE_SERVER_PASSWORD: "secret", + opencode_server_username: "mixed-case-user", opencode_server_password: "mixed-case-secret", CUSTOM_VALUE: "kept", }, @@ -16,6 +17,7 @@ describe("createBackendEnv", () => { expect(env.OPENCODE_SERVER_USERNAME).toBeUndefined() expect(env.OPENCODE_SERVER_PASSWORD).toBeUndefined() + expect(env.opencode_server_username).toBeUndefined() expect(env.opencode_server_password).toBeUndefined() expect(env.PATH).toBe("/usr/bin") expect(env.CUSTOM_VALUE).toBe("kept") diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index e2b0e0549..8a1af375b 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -9,7 +9,7 @@ import { Log } from "@opencode-ai/core/util/log" import { lazy } from "@opencode-ai/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" -import { withoutInternalServerAuthEnv } from "@/util/env" +import { envValueCaseInsensitive, withoutInternalServerAuthEnv } from "@/util/env" import { PtyID } from "./schema" import { Effect, Layer, Context } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" @@ -195,8 +195,8 @@ export namespace Pty { // deleting these keys is not enough for PTY sessions. Override with // empty values to prevent PawWork's internal server credentials from // being visible inside user terminals. - env.OPENCODE_SERVER_USERNAME = "" - env.OPENCODE_SERVER_PASSWORD = "" + env.OPENCODE_SERVER_USERNAME = envValueCaseInsensitive(input.env, "OPENCODE_SERVER_USERNAME") ?? "" + env.OPENCODE_SERVER_PASSWORD = envValueCaseInsensitive(input.env, "OPENCODE_SERVER_PASSWORD") ?? "" if (process.platform === "win32") { env.LC_ALL = "C.UTF-8" diff --git a/packages/opencode/src/util/env.ts b/packages/opencode/src/util/env.ts index 8f8d9ea50..c3f269dd0 100644 --- a/packages/opencode/src/util/env.ts +++ b/packages/opencode/src/util/env.ts @@ -6,3 +6,8 @@ export function withoutInternalServerAuthEnv | undefined, name: string) { + const normalized = name.toLowerCase() + return Object.entries(env ?? {}).find(([key]) => key.toLowerCase() === normalized)?.[1] +} diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 8455d1cba..1835ba213 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -147,4 +147,61 @@ describe("pty", () => { else process.env.PAWWORK_E2E_CUSTOM_ENV = previousCustom } }) + + test("preserves explicit terminal auth env overrides", async () => { + if (process.platform === "win32") return + + const previousUsername = process.env.OPENCODE_SERVER_USERNAME + const previousPassword = process.env.OPENCODE_SERVER_PASSWORD + process.env.OPENCODE_SERVER_USERNAME = "PawWork" + process.env.OPENCODE_SERVER_PASSWORD = "secret" + + try { + await using dir = await tmpdir({ git: true }) + + await Instance.provide({ + directory: dir.path, + fn: async () => { + let id: PtyID | undefined + try { + const info = await Pty.create({ + command: "/bin/sh", + title: "explicit-env", + env: { + OPENCODE_SERVER_USERNAME: "explicit-user", + OPENCODE_SERVER_PASSWORD: "explicit-password", + }, + }) + id = info.id + + const output: string[] = [] + await Pty.connect(info.id, { + readyState: 1, + send: (data: unknown) => output.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")), + close: () => undefined, + } as any) + + await Pty.write( + info.id, + 'printf "username=%s\\n" "${OPENCODE_SERVER_USERNAME}" && printf "password=%s\\n" "${OPENCODE_SERVER_PASSWORD}"\nexit\n', + ) + await wait(() => output.join("").includes("password=")) + + const text = output.join("") + expect(text).toContain("username=explicit-user") + expect(text).toContain("password=explicit-password") + expect(text).not.toContain("secret") + expect(text).not.toContain("PawWork") + } finally { + if (id) await Pty.remove(id) + } + }, + }) + } finally { + if (previousUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME + else process.env.OPENCODE_SERVER_USERNAME = previousUsername + if (previousPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = previousPassword + } + }) }) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index a4d96aced..4411652fc 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -156,14 +156,20 @@ describe("tool.bash", () => { each("does not expose internal server auth env to user commands", async () => { const previousUsername = process.env.OPENCODE_SERVER_USERNAME + const previousLowerUsername = process.env.opencode_server_username const previousPassword = process.env.OPENCODE_SERVER_PASSWORD + const previousLowerPassword = process.env.opencode_server_password const previousCustom = process.env.PAWWORK_E2E_CUSTOM_ENV process.env.OPENCODE_SERVER_USERNAME = "PawWork" + process.env.opencode_server_username = "lower-user" process.env.OPENCODE_SERVER_PASSWORD = "secret" + process.env.opencode_server_password = "lower-secret" process.env.PAWWORK_E2E_CUSTOM_ENV = "kept" const code = [ 'console.log("username=" + (process.env.OPENCODE_SERVER_USERNAME ?? "unset"))', + 'console.log("usernameLower=" + (process.env.opencode_server_username ?? "unset"))', 'console.log("password=" + (process.env.OPENCODE_SERVER_PASSWORD ?? "unset"))', + 'console.log("passwordLower=" + (process.env.opencode_server_password ?? "unset"))', 'console.log("custom=" + (process.env.PAWWORK_E2E_CUSTOM_ENV ?? "unset"))', ].join(";") const command = `${PS.has(sh()) ? "& " : ""}${bin} -e ${evalarg(code)}` @@ -185,16 +191,24 @@ describe("tool.bash", () => { expect(result.metadata.exit).toBe(0) expect(result.output).toContain("username=unset") + expect(result.output).toContain("usernameLower=unset") expect(result.output).toContain("password=unset") + expect(result.output).toContain("passwordLower=unset") expect(result.output).toContain("custom=kept") expect(result.output).not.toContain("secret") + expect(result.output).not.toContain("lower-user") + expect(result.output).not.toContain("lower-secret") }, }) } finally { if (previousUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME else process.env.OPENCODE_SERVER_USERNAME = previousUsername + if (previousLowerUsername === undefined) delete process.env.opencode_server_username + else process.env.opencode_server_username = previousLowerUsername if (previousPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD else process.env.OPENCODE_SERVER_PASSWORD = previousPassword + if (previousLowerPassword === undefined) delete process.env.opencode_server_password + else process.env.opencode_server_password = previousLowerPassword if (previousCustom === undefined) delete process.env.PAWWORK_E2E_CUSTOM_ENV else process.env.PAWWORK_E2E_CUSTOM_ENV = previousCustom } From f66aec898dd564c5424eddae8b3dde62febe2c90 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 18:56:56 +0800 Subject: [PATCH 3/4] fix: avoid mutating sanitized env input --- packages/opencode/src/util/env.ts | 7 ++++--- packages/opencode/test/util/env.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/util/env.test.ts diff --git a/packages/opencode/src/util/env.ts b/packages/opencode/src/util/env.ts index c3f269dd0..456d9eb3d 100644 --- a/packages/opencode/src/util/env.ts +++ b/packages/opencode/src/util/env.ts @@ -1,10 +1,11 @@ const INTERNAL_SERVER_AUTH_ENV = new Set(["opencode_server_password", "opencode_server_username"]) export function withoutInternalServerAuthEnv>(env: T): T { - for (const key of Object.keys(env)) { - if (INTERNAL_SERVER_AUTH_ENV.has(key.toLowerCase())) delete env[key] + const sanitized = { ...env } + for (const key of Object.keys(sanitized)) { + if (INTERNAL_SERVER_AUTH_ENV.has(key.toLowerCase())) delete sanitized[key] } - return env + return sanitized } export function envValueCaseInsensitive(env: Record | undefined, name: string) { diff --git a/packages/opencode/test/util/env.test.ts b/packages/opencode/test/util/env.test.ts new file mode 100644 index 000000000..bdfc47064 --- /dev/null +++ b/packages/opencode/test/util/env.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { withoutInternalServerAuthEnv } from "../../src/util/env" + +describe("util.env", () => { + test("does not mutate caller-owned env objects", () => { + const env: Record = { + OPENCODE_SERVER_USERNAME: "PawWork", + OPENCODE_SERVER_PASSWORD: "secret", + PAWWORK_E2E_CUSTOM_ENV: "kept", + } + + const sanitized = withoutInternalServerAuthEnv(env) + + expect(sanitized).toEqual({ PAWWORK_E2E_CUSTOM_ENV: "kept" }) + expect(env).toEqual({ + OPENCODE_SERVER_USERNAME: "PawWork", + OPENCODE_SERVER_PASSWORD: "secret", + PAWWORK_E2E_CUSTOM_ENV: "kept", + }) + expect(sanitized).not.toBe(env) + }) +}) From dd2659f2744cb8131d28d634ea6c3953e473c1de Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 19:05:33 +0800 Subject: [PATCH 4/4] test: cover mixed-case internal auth env --- packages/opencode/test/util/env.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/opencode/test/util/env.test.ts b/packages/opencode/test/util/env.test.ts index bdfc47064..d0b43db60 100644 --- a/packages/opencode/test/util/env.test.ts +++ b/packages/opencode/test/util/env.test.ts @@ -19,4 +19,22 @@ describe("util.env", () => { }) expect(sanitized).not.toBe(env) }) + + test("removes internal auth keys regardless of case", () => { + const env: Record = { + OpEnCoDe_Server_UserName: "PawWork", + opencode_server_password: "secret", + PAWWORK_E2E_CUSTOM_ENV: "kept", + } + + const sanitized = withoutInternalServerAuthEnv(env) + + expect(sanitized).toEqual({ PAWWORK_E2E_CUSTOM_ENV: "kept" }) + expect(env).toEqual({ + OpEnCoDe_Server_UserName: "PawWork", + opencode_server_password: "secret", + PAWWORK_E2E_CUSTOM_ENV: "kept", + }) + expect(sanitized).not.toBe(env) + }) })