refactor(process): extract command env resolution helper

This commit is contained in:
Peter Steinberger
2026-03-02 14:02:35 +00:00
parent 9eb70d2725
commit 234e07fcc0
2 changed files with 46 additions and 30 deletions

View File

@@ -2,9 +2,8 @@ import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import process from "node:process";
import { describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { attachChildProcessBridge } from "./child-process-bridge.js";
import { runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js";
import { resolveCommandEnv, runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js";
describe("runCommandWithTimeout", () => {
it("never enables shell execution (Windows cmd.exe injection hardening)", () => {
@@ -16,24 +15,31 @@ describe("runCommandWithTimeout", () => {
).toBe(false);
});
it("merges custom env with process.env", async () => {
await withEnvAsync({ OPENCLAW_BASE_ENV: "base" }, async () => {
const result = await runCommandWithTimeout(
[
process.execPath,
"-e",
'process.stdout.write((process.env.OPENCLAW_BASE_ENV ?? "") + "|" + (process.env.OPENCLAW_TEST_ENV ?? ""))',
],
{
timeoutMs: 80,
env: { OPENCLAW_TEST_ENV: "ok" },
},
);
expect(result.code).toBe(0);
expect(result.stdout).toBe("base|ok");
expect(result.termination).toBe("exit");
it("merges custom env with base env and drops undefined values", async () => {
const resolved = resolveCommandEnv({
argv: ["node", "script.js"],
baseEnv: {
OPENCLAW_BASE_ENV: "base",
OPENCLAW_TO_REMOVE: undefined,
},
env: {
OPENCLAW_TEST_ENV: "ok",
},
});
expect(resolved.OPENCLAW_BASE_ENV).toBe("base");
expect(resolved.OPENCLAW_TEST_ENV).toBe("ok");
expect(resolved.OPENCLAW_TO_REMOVE).toBeUndefined();
});
it("suppresses npm fund prompts for npm argv", async () => {
const resolved = resolveCommandEnv({
argv: ["npm", "--version"],
baseEnv: {},
});
expect(resolved.NPM_CONFIG_FUND).toBe("false");
expect(resolved.npm_config_fund).toBe("false");
});
it("kills command when no output timeout elapses", async () => {

View File

@@ -174,16 +174,13 @@ export type CommandOptions = {
noOutputTimeoutMs?: number;
};
export async function runCommandWithTimeout(
argv: string[],
optionsOrTimeout: number | CommandOptions,
): Promise<SpawnResult> {
const options: CommandOptions =
typeof optionsOrTimeout === "number" ? { timeoutMs: optionsOrTimeout } : optionsOrTimeout;
const { timeoutMs, cwd, input, env, noOutputTimeoutMs } = options;
const { windowsVerbatimArguments } = options;
const hasInput = input !== undefined;
export function resolveCommandEnv(params: {
argv: string[];
env?: NodeJS.ProcessEnv;
baseEnv?: NodeJS.ProcessEnv;
}): NodeJS.ProcessEnv {
const baseEnv = params.baseEnv ?? process.env;
const argv = params.argv;
const shouldSuppressNpmFund = (() => {
const cmd = path.basename(argv[0] ?? "");
if (cmd === "npm" || cmd === "npm.cmd" || cmd === "npm.exe") {
@@ -196,7 +193,7 @@ export async function runCommandWithTimeout(
return false;
})();
const mergedEnv = env ? { ...process.env, ...env } : { ...process.env };
const mergedEnv = params.env ? { ...baseEnv, ...params.env } : { ...baseEnv };
const resolvedEnv = Object.fromEntries(
Object.entries(mergedEnv)
.filter(([, value]) => value !== undefined)
@@ -210,6 +207,19 @@ export async function runCommandWithTimeout(
resolvedEnv.npm_config_fund = "false";
}
}
return resolvedEnv;
}
export async function runCommandWithTimeout(
argv: string[],
optionsOrTimeout: number | CommandOptions,
): Promise<SpawnResult> {
const options: CommandOptions =
typeof optionsOrTimeout === "number" ? { timeoutMs: optionsOrTimeout } : optionsOrTimeout;
const { timeoutMs, cwd, input, env, noOutputTimeoutMs } = options;
const { windowsVerbatimArguments } = options;
const hasInput = input !== undefined;
const resolvedEnv = resolveCommandEnv({ argv, env });
const stdio = resolveCommandStdio({ hasInput, preferInherit: true });
const finalArgv = process.platform === "win32" ? (resolveNpmArgvForWindows(argv) ?? argv) : argv;