From 2857aeb13d04f751a67a3a8e63e344c47289ec78 Mon Sep 17 00:00:00 2001 From: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:30:34 +0000 Subject: [PATCH] fix(server): honor explicit provider runtime env overrides Restore runtime controls only from configured provider env, while preserving inherited and session filtering. Carry finalized provider environments through spawn boundaries so a second scrub cannot drop explicit settings. Cover configured controls across ACP launches and OpenCode V2 reconnect/resume, including both environment-builder consumer paths. Keep parent-session and inherited control filtering checks, and identify Pi/OMP Node RPC fixture coverage accurately. --- docs/plugins.md | 3 +- .../src/server/agent/plugin-provider.test.ts | 13 +++- .../agent/provider-launch-config.test.ts | 59 +++++++++++++++++++ .../server/agent/provider-launch-config.ts | 31 ++++++---- .../server/agent/providers/acp-agent.test.ts | 15 +++-- .../agent/providers/codex-app-server-agent.ts | 1 + .../agent/providers/jsonl-rpc-process.ts | 7 ++- .../agent/providers/omp/cli-runtime.test.ts | 32 ++++++++++ .../server/agent/providers/omp/cli-runtime.ts | 14 +++-- .../providers/opencode/server-manager.ts | 1 + .../agent/providers/opencode/v2/agent.test.ts | 15 +++-- .../agent/providers/pi/cli-runtime.test.ts | 31 ++++++++++ .../server/agent/providers/pi/cli-runtime.ts | 14 +++-- packages/server/src/server/paseo-env.ts | 2 +- 14 files changed, 199 insertions(+), 39 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index db6151f9de..547314256b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -493,7 +493,8 @@ export default function contribute(server: PluginServerContext) { Declare `command: ["agent", "serve"]` when your provider launches an executable. The daemon applies `agents.providers..command` and `env`, resolves the executable against the effective PATH, and -removes parent-session and daemon-control environment variables. `connect({ launch })` receives +removes parent-session variables and inherited/session daemon-control values; explicitly configured +provider env survives. `connect({ launch })` receives `{ command, args, env }`: an executable path, effective arguments, and the complete sanitized environment. Spawn with those values; do not merge the plugin process's environment back in. The launch travels as data through the same provider protocol for built-in and subprocess plugins. diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index a9cf3b0ed5..2a0cf36b54 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1975,7 +1975,12 @@ describe("plugin provider launch and status", () => { try { const client = registry.definitions()[registration.id]!.createClient(logger, { command, - env: { PLUGIN_SETTING: "custom", CLAUDECODE: "parent" }, + env: { + PLUGIN_SETTING: "custom", + CLAUDECODE: "parent", + ELECTRON_RUN_AS_NODE: "1", + ESBUILD_BINARY_PATH: "/provider/esbuild", + }, }); expect(await client.isAvailable()).toBe(true); expect(await client.getCatalogCacheKey!({ scope: "global" })).toBe("shared"); @@ -1988,7 +1993,11 @@ describe("plugin provider launch and status", () => { expect(launch).toMatchObject({ command: process.execPath, args: expectedArgs, - env: { PLUGIN_SETTING: "custom" }, + env: { + PLUGIN_SETTING: "custom", + ELECTRON_RUN_AS_NODE: "1", + ESBUILD_BINARY_PATH: "/provider/esbuild", + }, }); expect(launch!.env).not.toHaveProperty("CLAUDECODE"); expect(launch!.env).not.toHaveProperty("PASEO_NODE_ENV"); diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 311a160aae..31a0c42cbb 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -2,10 +2,12 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; +import { execCommand } from "../../utils/spawn.js"; import { checkProviderLaunchAvailable, createProviderEnv, + createProviderEnvSpec, migrateProviderSettings, ProviderOverrideSchema, resolveProviderLaunch, @@ -217,6 +219,63 @@ describe("checkProviderLaunchAvailable", () => { }); describe("createProviderEnv", () => { + test("passes configured runtime controls through command execution", async () => { + const result = await execCommand( + process.execPath, + [ + "-e", + "console.log(JSON.stringify({ ELECTRON_RUN_AS_NODE: process.env.ELECTRON_RUN_AS_NODE ?? null, PASEO_NODE_ENV: process.env.PASEO_NODE_ENV ?? null }))", + ], + createProviderEnvSpec({ + baseEnv: { ELECTRON_RUN_AS_NODE: "inherited", PASEO_NODE_ENV: "production" }, + runtimeSettings: { env: { ELECTRON_RUN_AS_NODE: "1" } }, + overlays: [{ PASEO_NODE_ENV: "development" }], + }), + ); + + expect(result).toEqual({ + stdout: '{"ELECTRON_RUN_AS_NODE":"1","PASEO_NODE_ENV":null}\n', + stderr: "", + }); + }); + + test.each([ + "ELECTRON_RUN_AS_NODE", + "ELECTRON_NO_ATTACH_CONSOLE", + "PASEO_NODE_ENV", + "PASEO_DESKTOP_MANAGED", + "PASEO_SUPERVISED", + "ESBUILD_BINARY_PATH", + ])("preserves explicitly configured %s while scrubbing inherited and session controls", (key) => { + const baseEnv = { + PATH: "/usr/bin", + ELECTRON_RUN_AS_NODE: "inherited", + PASEO_NODE_ENV: "inherited", + ESBUILD_BINARY_PATH: "/inherited/esbuild", + }; + const overlays = [ + { + [key]: "session", + PASEO_SUPERVISED: "session", + CUSTOM: "session", + REMOVED: undefined, + }, + ]; + const runtimeSettings = { + env: { [key]: "configured", CUSTOM: "configured", REMOVED: "configured", CLAUDECODE: "1" }, + }; + + expect(createProviderEnv({ baseEnv, runtimeSettings, overlays })).toEqual({ + PATH: "/usr/bin", + CUSTOM: "session", + [key]: "configured", + }); + expect(createProviderEnv({ baseEnv, overlays })).toEqual({ + PATH: "/usr/bin", + CUSTOM: "session", + }); + }); + test("merges provider env overrides", () => { const base = { PATH: "/usr/bin", diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index 29554fd2b2..88d8c59119 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -5,6 +5,7 @@ import { } from "../../executable-resolution/executable-resolution.js"; import { createExternalProcessEnv, + RUNTIME_CONTROL_ENV_KEYS, type ProcessEnvRecord, type ExternalProcessEnv, } from "../paseo-env.js"; @@ -222,8 +223,8 @@ export interface ProviderEnvOptions { } export interface ProviderEnvSpec { - baseEnv?: ProcessEnvRecord; - envOverlay: ProcessEnvRecord; + env: ExternalProcessEnv; + envMode: "internal"; } function collectProviderEnvOverlays( @@ -236,20 +237,24 @@ function collectProviderEnvOverlays( } export function createProviderEnvSpec(options: ProviderEnvOptions = {}): ProviderEnvSpec { - const overlays = collectProviderEnvOverlays(options.runtimeSettings, options.overlays ?? []); - const envOverlay: ProcessEnvRecord = Object.assign({}, ...overlays); - for (const key of PARENT_SESSION_ENV_VARS) { - envOverlay[key] = undefined; - } - return { - ...(options.baseEnv ? { baseEnv: options.baseEnv } : {}), - envOverlay, - }; + // Provider env is finalized here so spawning cannot scrub explicit user settings again. + return { env: createProviderEnv(options), envMode: "internal" }; } export function createProviderEnv(options: ProviderEnvOptions = {}): ExternalProcessEnv { - const spec = createProviderEnvSpec(options); - return createExternalProcessEnv(spec.baseEnv ?? process.env, spec.envOverlay); + const overlays = collectProviderEnvOverlays(options.runtimeSettings, options.overlays ?? []); + const envOverlay: ProcessEnvRecord = Object.assign({}, ...overlays); + const env = createExternalProcessEnv(options.baseEnv ?? process.env, envOverlay); + for (const key of RUNTIME_CONTROL_ENV_KEYS) { + const configured = options.runtimeSettings?.env?.[key]; + if (configured !== undefined) { + env[key] = configured; + } + } + for (const key of PARENT_SESSION_ENV_VARS) { + delete env[key]; + } + return env; } export async function isProviderCommandAvailable( diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 7e50b32cf5..2a58d0ca9e 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -595,9 +595,11 @@ describe("createLoggedNdJsonStream", () => { describe("ACPAgentSession terminal tools", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); test("runs single-string terminal commands through the platform shell", async () => { + vi.stubEnv("BASH_ENV", "/unexpected/bash-env"); const child = createTerminalChildStub(); const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); const session = createSession(); @@ -617,13 +619,15 @@ describe("ACPAgentSession terminal tools", () => { shell.args, expect.objectContaining({ cwd: "/repo", - envOverlay: expect.objectContaining({ BASH_ENV: undefined }), + envMode: "internal", shell: false, }), ); + expect(spawn.mock.calls[0]?.[2]?.env).not.toHaveProperty("BASH_ENV"); }); test("preserves cmd semantics for single-string terminal commands on Windows", async () => { + vi.stubEnv("BASH_ENV", "/unexpected/bash-env"); const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32", @@ -645,10 +649,11 @@ describe("ACPAgentSession terminal tools", () => { ["/c", "echo %TEMP% && echo ok"], expect.objectContaining({ cwd: "C:\\repo", - envOverlay: expect.objectContaining({ BASH_ENV: undefined }), + envMode: "internal", shell: false, }), ); + expect(spawn.mock.calls[0]?.[2]?.env).not.toHaveProperty("BASH_ENV"); } finally { Object.defineProperty(process, "platform", { value: originalPlatform, @@ -761,6 +766,7 @@ describe("ACPAgentSession terminal tools", () => { }); test("carries the agent's identity into single-string shell commands", async () => { + vi.stubEnv("BASH_ENV", "/unexpected/bash-env"); const child = createTerminalChildStub(); const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); const session = createSession({ launchEnv: { PASEO_AGENT_ID: "agent-1" } }); @@ -775,12 +781,13 @@ describe("ACPAgentSession terminal tools", () => { expect.any(String), expect.any(Array), expect.objectContaining({ - envOverlay: expect.objectContaining({ + env: expect.objectContaining({ PASEO_AGENT_ID: "agent-1", - BASH_ENV: undefined, }), + envMode: "internal", }), ); + expect(spawn.mock.calls[0]?.[2]?.env).not.toHaveProperty("BASH_ENV"); }); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index cc667b2494..291c82da97 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -7488,6 +7488,7 @@ export class CodexAppServerAgentClient implements AgentClient { detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv), + envMode: "internal", }); assertChildWithPipes(child); return child; diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts index ddf85eb316..6c986dcd11 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -1,7 +1,7 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; -import { spawnProcess } from "../../../utils/spawn.js"; +import { spawnProcess, type SpawnProcessOptions } from "../../../utils/spawn.js"; import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; import { JsonlFrameDecoder, ProviderStreamLimitError } from "./jsonl-frame-decoder.js"; export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js"; @@ -23,6 +23,7 @@ export interface JsonlRpcLaunch { args: string[]; cwd: string; env?: Record; + envMode?: SpawnProcessOptions["envMode"]; } interface JsonlRpcResponse { @@ -65,7 +66,9 @@ function assertChildWithPipes( function spawnJsonlRpcProcess(launch: JsonlRpcLaunch): ChildProcessWithoutNullStreams { const child = spawnProcess(launch.command, launch.args, { cwd: launch.cwd, - envOverlay: launch.env, + ...(launch.envMode === "internal" + ? { env: launch.env, envMode: launch.envMode } + : { envOverlay: launch.env }), stdio: ["pipe", "pipe", "pipe"], }); assertChildWithPipes(child); diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index 4c8a81fe46..60c518c13e 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -121,6 +121,38 @@ function withoutRequestId(command: Record): Record { + test("passes configured runtime controls to the spawned RPC process", async () => { + const source = ` + const readline = require("node:readline"); + console.log(JSON.stringify({ type: "ready" })); + readline.createInterface({ input: process.stdin }).on("line", (line) => { + const command = JSON.parse(line); + const env = { + ELECTRON_RUN_AS_NODE: process.env.ELECTRON_RUN_AS_NODE ?? null, + PASEO_NODE_ENV: process.env.PASEO_NODE_ENV ?? null, + }; + console.log(JSON.stringify({ type: "response", id: command.id, success: true, + data: { commands: [{ name: "env", description: JSON.stringify(env) }] } })); + }); + `; + const runtime = new OmpCliRuntime({ + logger: pino({ level: "silent" }), + command: [process.execPath, "-e", source, "--"], + runtimeSettings: { env: { ELECTRON_RUN_AS_NODE: "1" } }, + }); + const session = await runtime.startSession({ + cwd: process.cwd(), + env: { ELECTRON_RUN_AS_NODE: "session", PASEO_NODE_ENV: "development" }, + }); + try { + expect(await session.getCommands()).toEqual([ + { name: "env", description: '{"ELECTRON_RUN_AS_NODE":"1","PASEO_NODE_ENV":null}' }, + ]); + } finally { + await session.close(); + } + }); + test("steer waits for OMP's response and surfaces rejection", async () => { const child = createOmpChild(); let pending: Record | null = null; diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index 5ae9d8f9c4..98a75b37ab 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -1,9 +1,11 @@ -import { createExternalProcessEnv } from "../../../paseo-env.js"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; import { z } from "zod"; -import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import { + createProviderEnvSpec, + type ProviderRuntimeSettings, +} from "../../provider-launch-config.js"; import { JSONL_RPC_NO_TIMEOUT, JsonlRpcProcess, @@ -76,13 +78,17 @@ export class OmpCliRuntime implements OmpRuntime { runtimeSettings: this.options.runtimeSettings, session: input, }); - launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {}); + const envSpec = createProviderEnvSpec({ + runtimeSettings: this.options.runtimeSettings, + overlays: [input.env], + }); + launch.env = envSpec.env; const [command, ...args] = launch.argv; const processLaunch: JsonlRpcLaunch = { command, args, cwd: launch.cwd, - env: launch.env, + ...envSpec, }; const spawn = this.spawnProcess; const processOptions = { diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index 9ff5dc6c96..e7cf6c1516 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -345,6 +345,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], baseEnv: environment, + envMode: "internal", }); const managedProcessRecord = this.recordManagedServerProcess({ process: serverProcess, diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index e8188730f5..162d75c22c 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -593,15 +593,14 @@ describe("OpenCode v2 session lifecycle", () => { const assertEnvironment = (index: number) => { const env = environments.get([first, second][index].info.id); expect(env?.PATH).toBe(process.env.PATH); - expect(env).toMatchObject({ ...launches[index].env, PASEO_ENV_TEST: "configured" }); + expect(env).toMatchObject({ + ...launches[index].env, + PASEO_ENV_TEST: "configured", + ELECTRON_RUN_AS_NODE: "1", + PASEO_SUPERVISED: "1", + }); expect(env?.USER).toBe(process.env.USER); - for (const key of [ - "ELECTRON_RUN_AS_NODE", - "PASEO_SUPERVISED", - "CLAUDECODE", - "ESBUILD_BINARY_PATH", - "PASEO_NODE_ENV", - ]) { + for (const key of ["CLAUDECODE", "ESBUILD_BINARY_PATH", "PASEO_NODE_ENV"]) { expect(env).not.toHaveProperty(key); } }; diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts index 463870760f..2e99b49866 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts @@ -129,6 +129,37 @@ function writePiResponse( } describe("PiCliRuntime", () => { + test("passes configured runtime controls to the spawned RPC process", async () => { + const source = ` + const readline = require("node:readline"); + readline.createInterface({ input: process.stdin }).on("line", (line) => { + const command = JSON.parse(line); + const env = { + ELECTRON_RUN_AS_NODE: process.env.ELECTRON_RUN_AS_NODE ?? null, + PASEO_NODE_ENV: process.env.PASEO_NODE_ENV ?? null, + }; + console.log(JSON.stringify({ type: "response", id: command.id, success: true, + data: { commands: [{ name: "env", description: JSON.stringify(env) }] } })); + }); + `; + const runtime = new PiCliRuntime({ + logger: pino({ level: "silent" }), + command: [process.execPath, "-e", source, "--"], + runtimeSettings: { env: { ELECTRON_RUN_AS_NODE: "1" } }, + }); + const session = await runtime.startSession({ + cwd: process.cwd(), + env: { ELECTRON_RUN_AS_NODE: "session", PASEO_NODE_ENV: "development" }, + }); + try { + expect(await session.getCommands()).toEqual([ + { name: "env", description: '{"ELECTRON_RUN_AS_NODE":"1","PASEO_NODE_ENV":null}' }, + ]); + } finally { + await session.close(); + } + }); + test("starts pi in rpc mode and resolves command responses", async () => { const child = createPiChild(); replyToCommands(child, (command) => diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 62ee01c503..bb74b0942f 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -1,8 +1,10 @@ -import { createExternalProcessEnv } from "../../../paseo-env.js"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; -import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import { + createProviderEnvSpec, + type ProviderRuntimeSettings, +} from "../../provider-launch-config.js"; import { JSONL_RPC_NO_TIMEOUT, JsonlRpcProcess, @@ -57,13 +59,17 @@ export class PiCliRuntime implements PiRuntime { runtimeSettings: this.options.runtimeSettings, session: input, }); - launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {}); + const envSpec = createProviderEnvSpec({ + runtimeSettings: this.options.runtimeSettings, + overlays: [input.env], + }); + launch.env = envSpec.env; const [command, ...args] = launch.argv; const processLaunch: JsonlRpcLaunch = { command, args, cwd: launch.cwd, - env: launch.env, + ...envSpec, }; const spawn = this.spawnProcess; const processOptions = { diff --git a/packages/server/src/server/paseo-env.ts b/packages/server/src/server/paseo-env.ts index 4df58e11db..192de99f1f 100644 --- a/packages/server/src/server/paseo-env.ts +++ b/packages/server/src/server/paseo-env.ts @@ -1,7 +1,7 @@ const PASEO_NODE_ENV = "PASEO_NODE_ENV"; const ELECTRON_RUN_AS_NODE = "ELECTRON_RUN_AS_NODE"; -const RUNTIME_CONTROL_ENV_KEYS = [ +export const RUNTIME_CONTROL_ENV_KEYS = [ PASEO_NODE_ENV, "PASEO_DESKTOP_MANAGED", "PASEO_SUPERVISED",