mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-17 02:40:44 +00:00
144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
import fs from "node:fs";
|
|
|
|
/**
|
|
* On Linux, children spawned by a long-lived parent (e.g., the gateway) inherit
|
|
* the parent's `oom_score_adj`. Under cgroup memory pressure the kernel tends
|
|
* to pick the largest-RSS process as the OOM victim, which is usually the
|
|
* gateway rather than its transient workers. See issue #70404.
|
|
*
|
|
* Since Linux 2.6.20 any unprivileged process may voluntarily *raise* its own
|
|
* `oom_score_adj` without `CAP_SYS_RESOURCE`. We exploit that by wrapping the
|
|
* child argv in a tiny `/bin/sh` shim that raises the score in the post-fork
|
|
* child and then `exec`s the real command, so there is no extra long-lived
|
|
* shell process and no change to the final process identity.
|
|
*
|
|
* Opt out per-process by setting `OPENCLAW_CHILD_OOM_SCORE_ADJ=0` (also
|
|
* accepts `false`/`no`/`off`). Callers may also provide the key via
|
|
* `params.env` for per-child overrides.
|
|
*/
|
|
|
|
const CHILD_OOM_SCORE_ADJ_ENV_KEY = "OPENCLAW_CHILD_OOM_SCORE_ADJ";
|
|
const OOM_SCORE_WRAP_SHELL = "/bin/sh";
|
|
const OOM_SCORE_WRAP_SCRIPT = 'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"';
|
|
|
|
// Env keys that can cause /bin/sh (especially bash invoked as sh) to source
|
|
// caller-influenced startup files before the final `exec`. Stripped when we
|
|
// wrap so the shim can't become an env-controlled code-exec primitive.
|
|
const SHELL_INIT_ENV_KEYS = ["BASH_ENV", "ENV", "CDPATH"] as const;
|
|
|
|
function isDisabled(value: string | undefined): boolean {
|
|
switch (value?.trim().toLowerCase()) {
|
|
case "0":
|
|
case "false":
|
|
case "no":
|
|
case "off":
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
let cachedShellAvailable: boolean | null = null;
|
|
function defaultShellAvailable(): boolean {
|
|
if (cachedShellAvailable !== null) {
|
|
return cachedShellAvailable;
|
|
}
|
|
try {
|
|
cachedShellAvailable = fs.statSync(OOM_SCORE_WRAP_SHELL).isFile();
|
|
} catch {
|
|
cachedShellAvailable = false;
|
|
}
|
|
return cachedShellAvailable;
|
|
}
|
|
|
|
export type OomWrapOptions = {
|
|
platform?: NodeJS.Platform;
|
|
env?: NodeJS.ProcessEnv;
|
|
shellAvailable?: () => boolean;
|
|
};
|
|
|
|
export type OomScoreAdjustedSpawn = {
|
|
command: string;
|
|
args: string[];
|
|
env: NodeJS.ProcessEnv | undefined;
|
|
wrapped: boolean;
|
|
};
|
|
|
|
function shouldWrapChildForOomScore(options: OomWrapOptions | undefined): boolean {
|
|
const platform = options?.platform ?? process.platform;
|
|
if (platform !== "linux") {
|
|
return false;
|
|
}
|
|
const env = options?.env ?? process.env;
|
|
if (isDisabled(env[CHILD_OOM_SCORE_ADJ_ENV_KEY])) {
|
|
return false;
|
|
}
|
|
return (options?.shellAvailable ?? defaultShellAvailable)();
|
|
}
|
|
|
|
function isWrapped(command: string, args: readonly string[]): boolean {
|
|
return command === OOM_SCORE_WRAP_SHELL && args[0] === "-c" && args[1] === OOM_SCORE_WRAP_SCRIPT;
|
|
}
|
|
|
|
function canUseShellExecCommand(command: string): boolean {
|
|
// POSIX sh implementations such as dash do not support `exec --`. A command
|
|
// starting with "-" could be parsed as an exec option, so keep that rare
|
|
// shape on the original direct-spawn path instead of wrapping it.
|
|
return !command.startsWith("-");
|
|
}
|
|
|
|
function hardenShellEnv(baseEnv: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
|
|
const next: NodeJS.ProcessEnv = { ...(baseEnv ?? process.env) };
|
|
for (const key of SHELL_INIT_ENV_KEYS) {
|
|
delete next[key];
|
|
}
|
|
return next;
|
|
}
|
|
|
|
export function prepareOomScoreAdjustedSpawn(
|
|
command: string,
|
|
args: readonly string[] = [],
|
|
options?: OomWrapOptions,
|
|
): OomScoreAdjustedSpawn {
|
|
const copy = [...args];
|
|
if (!command || !canUseShellExecCommand(command) || !shouldWrapChildForOomScore(options)) {
|
|
return { command, args: copy, env: options?.env, wrapped: false };
|
|
}
|
|
if (isWrapped(command, copy)) {
|
|
return { command, args: copy, env: hardenShellEnv(options?.env), wrapped: true };
|
|
}
|
|
return {
|
|
command: OOM_SCORE_WRAP_SHELL,
|
|
args: ["-c", OOM_SCORE_WRAP_SCRIPT, command, ...copy],
|
|
env: hardenShellEnv(options?.env),
|
|
wrapped: true,
|
|
};
|
|
}
|
|
|
|
export function wrapArgvForChildOomScoreRaise(
|
|
argv: readonly string[],
|
|
options?: OomWrapOptions,
|
|
): string[] {
|
|
const copy = [...argv];
|
|
if (copy.length === 0) {
|
|
return copy;
|
|
}
|
|
const spawn = prepareOomScoreAdjustedSpawn(copy[0] ?? "", copy.slice(1), options);
|
|
return [spawn.command, ...spawn.args];
|
|
}
|
|
|
|
/**
|
|
* Returns `baseEnv` with shell-init keys stripped when argv will be wrapped.
|
|
* Unchanged (including `undefined`) when no wrap applies, so non-Linux and
|
|
* opted-out paths keep exact inherited-env semantics.
|
|
*/
|
|
export function hardenedEnvForChildOomWrap(
|
|
baseEnv: NodeJS.ProcessEnv | undefined,
|
|
options?: OomWrapOptions,
|
|
): NodeJS.ProcessEnv | undefined {
|
|
if (!shouldWrapChildForOomScore(options)) {
|
|
return baseEnv;
|
|
}
|
|
return hardenShellEnv(baseEnv);
|
|
}
|