Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions packages/app/e2e/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ async function waitExit(proc: ReturnType<typeof spawn>, 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)
}
Expand All @@ -66,26 +68,38 @@ function tail(input: string[]) {
return input.slice(-40).join("")
}

export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
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<string, string | undefined> {
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<string, string | undefined>
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<Handle> {
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(
Expand Down
26 changes: 26 additions & 0 deletions packages/app/test/e2e-backend-env.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
11 changes: 9 additions & 2 deletions packages/opencode/src/pty/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, string>
} as Record<string, string>)
// 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"
Expand Down
8 changes: 5 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -403,11 +404,11 @@ export const BashTool = Tool.define(
const bundledToolsDir = resourcesPath ? path.join(resourcesPath, "tools") : ""
const extraEnv = extra.env as Record<string, string>
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* (
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/util/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const INTERNAL_SERVER_AUTH_ENV = new Set(["opencode_server_password", "opencode_server_username"])

export function withoutInternalServerAuthEnv<T extends Record<string, string | undefined>>(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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function envValueCaseInsensitive(env: Record<string, string | undefined> | undefined, name: string) {
const normalized = name.toLowerCase()
return Object.entries(env ?? {}).find(([key]) => key.toLowerCase() === normalized)?.[1]
}
115 changes: 115 additions & 0 deletions packages/opencode/test/pty/pty-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
Astro-Han marked this conversation as resolved.
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
}
})
})
39 changes: 39 additions & 0 deletions packages/opencode/test/session/prompt-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading
Loading