File size: 6,796 Bytes
3144483 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | import { AsyncLocalStorage } from "node:async_hooks";
import path from "node:path";
import process from "node:process";
import { extractErrorCode } from "@openclaw/normalization-core/error-coercion";
import { execa, type Options as ExecaOptions, type ResultPromise } from "execa";
import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js";
import { mergeProcessEnv } from "../infra/process-env.js";
import { getFileLockProcessStartTime } from "../shared/pid-alive.js";
import { killProcessTree } from "./kill-tree.js";
import { resolveSafeChildProcessInvocation } from "./windows-command.js";
export const COMMAND_PROCESS_TREE_KILL_GRACE_MS = 300;
type CommandProcessScope = {
signal: AbortSignal;
children: Set<() => void>;
};
const commandProcessScope = new AsyncLocalStorage<CommandProcessScope>();
export function resolveCommandProcessSignal(signal?: AbortSignal): AbortSignal | undefined {
const inherited = commandProcessScope.getStore()?.signal;
return inherited ? AbortSignal.any(signal ? [inherited, signal] : [inherited]) : signal;
}
/** Cleanup helpers must outlive cancellation of the commands they are settling. */
export function runOutsideCommandProcessScope<T>(run: () => T): T {
return commandProcessScope.exit(run);
}
/** Terminal command deadlines stop their children before the caller permits rollback. */
export async function withCommandProcessScope<T>(
run: (stop: () => void) => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const controller = new AbortController();
const inherited = resolveCommandProcessSignal(signal);
const scope: CommandProcessScope = {
signal: inherited ? AbortSignal.any([inherited, controller.signal]) : controller.signal,
children: new Set(),
};
const stop = () => {
controller.abort();
for (const stopChild of scope.children) {
stopChild();
}
scope.children.clear();
};
return await commandProcessScope.run(scope, async () => {
try {
return await run(stop);
} finally {
stop();
}
});
}
function retainCommandProcess<OptionsType extends ExecaOptions>(
scope: CommandProcessScope,
child: ResultPromise<OptionsType>,
): void {
const pid = child.pid;
// Windows executable finalizers retain a Job until process exit; dead launcher
// PIDs cannot safely identify their surviving descendants through taskkill.
if (pid === undefined || process.platform === "win32") {
return;
}
const startedAt = getFileLockProcessStartTime(pid);
const stop = () => {
const nativeChild = child.nodeChildProcess;
// A live direct child holds PID custody even when its optional timestamp probe failed.
if (nativeChild.exitCode !== null || nativeChild.signalCode !== null) {
const currentStart = getFileLockProcessStartTime(pid);
if (currentStart !== null && currentStart !== startedAt) {
return;
}
}
killProcessTree(pid, { detached: true, force: true });
};
scope.children.add(stop);
const release = () => {
try {
// A direct child can exit while descendants retain its pipes or mutate
// installed files. Keep that group owned until it actually disappears.
process.kill(-pid, 0);
return;
} catch (error) {
if (extractErrorCode(error) !== "ESRCH") {
return;
}
}
scope.children.delete(stop);
};
void child.then(release, release);
}
export function shouldSpawnWithShell(params: {
resolvedCommand: string;
platform: NodeJS.Platform;
}): boolean {
// SECURITY: never enable `shell` for argv-based execution.
// `shell` routes through cmd.exe on Windows, which turns untrusted argv values
// (like chat prompts passed as CLI args) into command-injection primitives.
// If you need a shell, use an explicit shell-wrapper argv (e.g. `cmd.exe /c ...`)
// and validate/escape at the call site.
void params;
return false;
}
type SpawnCommandOptions = ExecaOptions & {
baseEnv?: NodeJS.ProcessEnv;
/** The command runner routes scope cancellation through its termination owner. */
inheritScopeCancellation?: boolean;
};
export function spawnCommandWithInvocation<
OptionsType extends SpawnCommandOptions = SpawnCommandOptions,
>(
argv: string[],
options: OptionsType = {} as OptionsType,
): {
child: ResultPromise<OptionsType>;
invocation: ReturnType<typeof resolveSafeChildProcessInvocation>;
} {
const scope = commandProcessScope.getStore();
if (scope?.signal.aborted) {
throw new Error("Command process scope is closed");
}
const {
baseEnv,
env,
windowsVerbatimArguments,
cancelSignal,
inheritScopeCancellation = true,
...execaOptions
} = options;
const commandEnv = resolveCommandEnv({ argv, baseEnv, env });
const invocation = resolveSafeChildProcessInvocation({
argv,
cwd: execaOptions.cwd,
env: commandEnv,
windowsVerbatimArguments,
});
const child = execa(invocation.command, invocation.args, {
...execaOptions,
cancelSignal: inheritScopeCancellation
? resolveCommandProcessSignal(cancelSignal)
: cancelSignal,
...(scope ? { killDescendants: true } : {}),
env: commandEnv,
extendEnv: false,
shell: false,
windowsHide: invocation.windowsHide,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
} as ExecaOptions) as unknown as ResultPromise<OptionsType>;
if (scope) {
retainCommandProcess(scope, child);
}
return { child, invocation };
}
/** Spawn through the canonical argv, environment, and Windows safety boundary. */
export function spawnCommand<OptionsType extends SpawnCommandOptions = SpawnCommandOptions>(
argv: string[],
options: OptionsType = {} as OptionsType,
): ResultPromise<OptionsType> {
return spawnCommandWithInvocation(argv, options).child;
}
export function resolveCommandEnv(params: {
argv: string[];
env?: NodeJS.ProcessEnv;
baseEnv?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
}): NodeJS.ProcessEnv {
const baseEnv = params.baseEnv ?? process.env;
const platform = params.platform ?? process.platform;
const argv = params.argv;
const shouldSuppressNpmFund = (() => {
const cmd = path.basename(argv[0] ?? "");
if (cmd === "npm" || cmd === "npm.cmd" || cmd === "npm.exe") {
return true;
}
if (cmd === "node" || cmd === "node.exe") {
const script = argv[1] ?? "";
return script.includes("npm-cli.js");
}
return false;
})();
const resolvedEnv = mergeProcessEnv([baseEnv, params.env], platform);
if (shouldSuppressNpmFund) {
if (resolvedEnv.NPM_CONFIG_FUND == null) {
resolvedEnv.NPM_CONFIG_FUND = "false";
}
if (resolvedEnv.npm_config_fund == null) {
resolvedEnv.npm_config_fund = "false";
}
}
return markOpenClawExecEnv(resolvedEnv);
}
|