diff --git a/packages/desktop/src/main/wsl/runtime.ts b/packages/desktop/src/main/wsl/runtime.ts index 458443731b..ce79262bc4 100644 --- a/packages/desktop/src/main/wsl/runtime.ts +++ b/packages/desktop/src/main/wsl/runtime.ts @@ -3,6 +3,7 @@ import { existsSync } from "node:fs" import { join } from "node:path" import * as pty from "@lydell/node-pty" import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types" +import { parseShellEnv } from "../shell-env" import { wslTerminalArgs } from "./policy" export type WslCommandLine = { @@ -32,6 +33,7 @@ export type RunWslOptions = { const DEFAULT_WSL_TIMEOUT_MS = 20_000 const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000 +const WSL_SHELL_ENV_TIMEOUT_MS = 5_000 export function wslArgs(args: string[], distro?: string | null, user?: string | null) { return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args] @@ -202,6 +204,31 @@ export function runWslSh(script: string, distro?: string | null, opts?: RunWslOp return runWslInDistro(["sh", "-lc", script], distro, opts) } +export function wslShellEnvProbeArgs(mode: "-il" | "-l") { + return ["bash", mode, "-c", "env -0"] +} + +export function wslShellEnvExports(env: Record) { + return Object.entries(env) + .filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) + .map(([key, value]) => `export ${key}=${shellEscape(value)}`) +} + +export async function loadWslShellEnv(distro: string) { + // Probe separately so the long-running server stays non-interactive and does not emit shell prompts or job-control warnings. + const probe = async (mode: "-il" | "-l") => { + const result = await runWslInDistro(wslShellEnvProbeArgs(mode), distro, { + timeoutMs: WSL_SHELL_ENV_TIMEOUT_MS, + }).catch(() => undefined) + if (!result || result.code !== 0) return undefined + const env = parseShellEnv(Buffer.from(result.stdout)) + if (Object.keys(env).length === 0) return undefined + return env + } + + return (await probe("-il")) ?? (await probe("-l")) ?? {} +} + export async function probeWslRuntime(opts?: RunWslOptions): Promise { const version = await runWsl(["--version"], opts).catch((error) => ({ code: 1, diff --git a/packages/desktop/src/main/wsl/sidecar.test.ts b/packages/desktop/src/main/wsl/sidecar.test.ts new file mode 100644 index 0000000000..ca78d6e96d --- /dev/null +++ b/packages/desktop/src/main/wsl/sidecar.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { spawnSync } from "node:child_process" +import { parseShellEnv } from "../shell-env" +import { wslShellEnvExports, wslShellEnvProbeArgs } from "./runtime" + +test("probes the interactive login environment used by a WSL terminal", () => { + expect(wslShellEnvProbeArgs("-il")).toEqual(["bash", "-il", "-c", "env -0"]) +}) + +test.skipIf(process.platform === "win32")("loads the login environment before starting the server", () => { + const home = mkdtempSync(join(tmpdir(), "opencode-wsl-env-")) + try { + writeFileSync(join(home, ".bash_profile"), '[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"\n') + writeFileSync(join(home, ".bashrc"), "export OPENCODE_WSL_ENV_TEST='https://company.test/v1?x=$HOME&y=one two'\n") + const command = wslShellEnvProbeArgs("-il") + const probe = spawnSync(command[0], command.slice(1), { + encoding: "utf8", + env: { ...process.env, HOME: home }, + }) + const env = parseShellEnv(Buffer.from(probe.stdout)) + const result = spawnSync("bash", ["-se"], { + input: [...wslShellEnvExports(env), 'printf "%s" "${OPENCODE_WSL_ENV_TEST:-missing}"'].join("\n"), + encoding: "utf8", + }) + + expect(probe.status).toBe(0) + expect(result.status).toBe(0) + expect(result.stdout).toBe("https://company.test/v1?x=$HOME&y=one two") + } finally { + rmSync(home, { recursive: true, force: true }) + } +}) diff --git a/packages/desktop/src/main/wsl/sidecar.ts b/packages/desktop/src/main/wsl/sidecar.ts index 522e575a47..71a0e3c9d7 100644 --- a/packages/desktop/src/main/wsl/sidecar.ts +++ b/packages/desktop/src/main/wsl/sidecar.ts @@ -3,7 +3,14 @@ import { randomUUID } from "node:crypto" import { createServer } from "node:net" import { app } from "electron" import { checkHealth } from "../server" -import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime" +import { + loadWslShellEnv, + type WslCommandLine, + resolveWslOpencode, + shellEscape, + wslArgs, + wslShellEnvExports, +} from "./runtime" import { pollWslHealth } from "./startup" export type WslSidecar = { @@ -23,7 +30,9 @@ export async function spawnWslSidecar( const port = await allocatePort() const password = randomUUID() const username = "opencode" + const env = await loadWslShellEnv(distro) const script = [ + ...wslShellEnvExports(env), "set -euo pipefail", 'cd "$HOME" || cd /', 'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',