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/app/test/e2e-backend-env.test.ts b/packages/app/test/e2e-backend-env.test.ts new file mode 100644 index 000000000..c22f47276 --- /dev/null +++ b/packages/app/test/e2e-backend-env.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { createBackendEnv } from "../e2e/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_username: "mixed-case-user", + 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_username).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/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index b70e986db..8a1af375b 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 { envValueCaseInsensitive, 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 = 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/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..456d9eb3d --- /dev/null +++ b/packages/opencode/src/util/env.ts @@ -0,0 +1,14 @@ +const INTERNAL_SERVER_AUTH_ENV = new Set(["opencode_server_password", "opencode_server_username"]) + +export function withoutInternalServerAuthEnv>(env: T): T { + const sanitized = { ...env } + for (const key of Object.keys(sanitized)) { + if (INTERNAL_SERVER_AUTH_ENV.has(key.toLowerCase())) delete sanitized[key] + } + return sanitized +} + +export function envValueCaseInsensitive(env: Record | 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 f7a949c92..1835ba213 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -89,4 +89,119 @@ 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 + } + }) + + 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/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..4411652fc 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -153,6 +153,66 @@ 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)}` + + 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("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 + } + }) }) describe("tool.bash permissions", () => { diff --git a/packages/opencode/test/util/env.test.ts b/packages/opencode/test/util/env.test.ts new file mode 100644 index 000000000..d0b43db60 --- /dev/null +++ b/packages/opencode/test/util/env.test.ts @@ -0,0 +1,40 @@ +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) + }) + + 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) + }) +})