mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
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:
committed by
paseo-bot[bot]
parent
8018231a00
commit
2857aeb13d
+2
-1
@@ -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.<id>.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.
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user