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.
This commit is contained in:
Okan YILDIRIM
2026-11-02 15:01:51 +00:00
committed by paseo-bot[bot]
parent 8018231a00
commit 2857aeb13d
14 changed files with 199 additions and 39 deletions
@@ -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");
@@ -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",
@@ -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(
@@ -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");
});
});
@@ -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;
@@ -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<string, string>;
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);
@@ -121,6 +121,38 @@ function withoutRequestId(command: Record<string, unknown>): Record<string, unkn
}
describe("OMP CLI runtime", () => {
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<string, unknown> | null = null;
@@ -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 = {
@@ -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,
@@ -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);
}
};
@@ -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) =>
@@ -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 = {
+1 -1
View File
@@ -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",