fix(gateway): prefer linux child OOM victims

Raise eligible Linux child processes own oom_score_adj from a child-side /bin/sh exec shim so cgroup memory pressure prefers transient workers over the long-lived gateway. Cover supervisor children, PTY shells, MCP stdio servers, and OpenClaw-launched browser processes through the shared process runtime seam.

Harden the wrapper for distroless images, shell startup env, per-child and process-level opt-outs, dash-compatible exec, and leading-dash command names. Document Linux verification and OOM behavior.

Fixes #70404.

Co-authored-by: Neerav Makwana <261249544+neeravmakwana@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-04-23 05:10:30 +01:00
parent d3a2e993a8
commit cc9dcd3d69
14 changed files with 451 additions and 25 deletions

View File

@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
- Agents/TTS: preserve spoken text in TTS tool results while defusing reply directives in transcript content, so future turns remember voice replies without treating spoken `MEDIA:` or voice tags as delivery metadata. (#68869) Thanks @zqchris.
- Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT.
- Agents/Pi embedded runs: suppress the "⚠️ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (`stopReason=stop`). Real failure modes (tool errors, provider `stopReason=error`, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana.
- Gateway/Linux: wrap gateway-managed supervisor, PTY, MCP stdio, and browser child processes in a tiny `/bin/sh` shim that raises the child's own `oom_score_adj` on Linux, so under cgroup memory pressure the kernel prefers transient workers over the long-lived gateway. Opt out with `OPENCLAW_CHILD_OOM_SCORE_ADJ=0`. Fixes #70404. (#70419) Thanks @neeravmakwana.
- Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like `functions.<name>:<index>`) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a `sanitizeToolCallIds` opt-out to the shared `openai-compatible` replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314.
- Dependencies/security: override transitive `uuid` to `14.0.0`, clearing the runtime advisory across dependencies.
- Codex harness: ignore dynamic tool descriptions when deciding whether to reuse a native app-server thread while still fingerprinting tool schemas, so channel-specific copy changes no longer reset otherwise compatible Codex conversations. (#69976) Thanks @chen-zhang-cs-code.

View File

@@ -1,2 +1,2 @@
e10f01ce10a381ecb098b805cee95b7278d16de42e02c7873f54448eb2b6c5cc plugin-sdk-api-baseline.json
918b646ff2e0849c4feba5ef930a08187a7bdad3a2d35ba4e1dd456fe3ea2cea plugin-sdk-api-baseline.jsonl
6297ca54fecbf277f3ed2e76410cc79aef95cf7dd887ab2383858a2132f81777 plugin-sdk-api-baseline.json
aa3343fda656a0034f9dd5ec7e28fcf45d49b15c1ed64329673ac1629285730c plugin-sdk-api-baseline.jsonl

View File

@@ -3,6 +3,7 @@ summary: "Linux support + companion app status"
read_when:
- Looking for Linux companion app status
- Planning platform coverage or contributions
- Debugging Linux OOM kills or exit 137 on a VPS or container
title: "Linux App"
---
@@ -98,3 +99,39 @@ Enable it:
```
systemctl --user enable --now openclaw-gateway[-<profile>].service
```
## Memory pressure and OOM kills
On Linux, the kernel chooses an OOM victim when a host, VM, or container cgroup
runs out of memory. The Gateway can be a poor victim because it owns long-lived
sessions and channel connections. OpenClaw therefore biases transient child
processes to be killed before the Gateway when possible.
For eligible Linux child spawns, OpenClaw starts the child through a short
`/bin/sh` wrapper that raises the child's own `oom_score_adj` to `1000`, then
`exec`s the real command. This is an unprivileged operation because the child is
only increasing its own OOM kill likelihood.
Covered child process surfaces include:
- supervisor-managed command children,
- PTY shell children,
- MCP stdio server children,
- OpenClaw-launched browser/Chrome processes.
The wrapper is Linux-only and is skipped when `/bin/sh` is unavailable. It is
also skipped if the child env sets `OPENCLAW_CHILD_OOM_SCORE_ADJ=0`, `false`,
`no`, or `off`.
To verify a child process:
```bash
cat /proc/<child-pid>/oom_score_adj
```
Expected value for covered children is `1000`. The Gateway process should keep
its normal score, usually `0`.
This does not replace normal memory tuning. If a VPS or container repeatedly
kills children, increase the memory limit, reduce concurrency, or add stronger
resource controls such as systemd `MemoryMax=` or container-level memory limits.

View File

@@ -114,3 +114,6 @@ If you deliberately installed a system unit instead, edit
How `Restart=` policies help automated recovery:
[systemd can automate service recovery](https://www.redhat.com/en/blog/systemd-automate-recovery).
For Linux OOM behavior, child process victim selection, and `exit 137`
diagnostics, see [Linux memory pressure and OOM kills](/platforms/linux#memory-pressure-and-oom-kills).

View File

@@ -2,6 +2,7 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams, spawn } from "n
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { prepareOomScoreAdjustedSpawn } from "openclaw/plugin-sdk/process-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { ensurePortAvailable } from "../infra/ports.js";
@@ -290,13 +291,16 @@ export async function launchOpenClawChrome(
// environments (e.g. Docker), while keeping stderr piped for diagnostics.
// Cast to ChildProcessWithoutNullStreams so callers can use .stderr safely;
// the tuple overload resolution varies across @types/node versions.
return spawn(exe.path, args, {
stdio: ["ignore", "ignore", "pipe"],
const preparedSpawn = prepareOomScoreAdjustedSpawn(exe.path, args, {
env: {
...process.env,
// Reduce accidental sharing with the user's env.
HOME: os.homedir(),
},
});
return spawn(preparedSpawn.command, preparedSpawn.args, {
stdio: ["ignore", "ignore", "pipe"],
env: preparedSpawn.env,
}) as unknown as ChildProcessWithoutNullStreams;
};

View File

@@ -45,9 +45,25 @@ describe("OpenClawStdioClientTransport", () => {
child.emit("spawn");
await started;
expect(spawnMock).toHaveBeenCalledWith(
"npx",
["-y", "example-mcp"],
const [command, args, options] = spawnMock.mock.calls[0] as [
string,
string[],
{ env?: NodeJS.ProcessEnv },
];
if (process.platform === "linux") {
expect(command).toBe("/bin/sh");
expect(args).toEqual([
"-c",
'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"',
"npx",
"-y",
"example-mcp",
]);
} else {
expect(command).toBe("npx");
expect(args).toEqual(["-y", "example-mcp"]);
}
expect(options).toEqual(
expect.objectContaining({
cwd: "/tmp/example",
detached: process.platform !== "win32",
@@ -55,6 +71,7 @@ describe("OpenClawStdioClientTransport", () => {
stdio: ["pipe", "pipe", "pipe"],
}),
);
expect(options.env).toEqual(expect.objectContaining({ EXAMPLE: "1" }));
expect(transport.pid).toBe(4321);
expect(transport.stderr).toBeInstanceOf(PassThrough);
});

View File

@@ -6,6 +6,7 @@ import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/s
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { killProcessTree } from "../process/kill-tree.js";
import { prepareOomScoreAdjustedSpawn } from "../process/linux-oom-score.js";
export type OpenClawStdioServerParameters = {
command: string;
@@ -46,13 +47,19 @@ export class OpenClawStdioClientTransport implements Transport {
}
await new Promise<void>((resolve, reject) => {
const child = spawn(this.serverParams.command, this.serverParams.args ?? [], {
const baseEnv = {
...getDefaultEnvironment(),
...this.serverParams.env,
};
const preparedSpawn = prepareOomScoreAdjustedSpawn(
this.serverParams.command,
this.serverParams.args ?? [],
{ env: baseEnv },
);
const child = spawn(preparedSpawn.command, preparedSpawn.args, {
cwd: this.serverParams.cwd,
detached: process.platform !== "win32",
env: {
...getDefaultEnvironment(),
...this.serverParams.env,
},
env: preparedSpawn.env,
shell: false,
stdio: ["pipe", "pipe", this.serverParams.stderr ?? "inherit"],
windowsHide: process.platform === "win32",

View File

@@ -1,3 +1,5 @@
// Public process helpers for plugins that spawn or probe local commands.
export * from "../process/exec.js";
export { prepareOomScoreAdjustedSpawn } from "../process/linux-oom-score.js";
export type { OomScoreAdjustedSpawn, OomWrapOptions } from "../process/linux-oom-score.js";

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import {
hardenedEnvForChildOomWrap,
prepareOomScoreAdjustedSpawn,
wrapArgvForChildOomScoreRaise,
} from "./linux-oom-score.js";
const argv = ["/usr/bin/node", "--max-old-space-size=256", "run.js", "arg with spaces"];
const wrapScript = 'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"';
const linux = { platform: "linux", env: {}, shellAvailable: () => true } as const;
const linuxNoShell = { platform: "linux", env: {}, shellAvailable: () => false } as const;
describe("wrapArgvForChildOomScoreRaise", () => {
it("wraps argv on linux with default env", () => {
const result = wrapArgvForChildOomScoreRaise(argv, linux);
expect(result.slice(0, 3)).toEqual(["/bin/sh", "-c", wrapScript]);
expect(result.slice(3)).toEqual(argv);
});
it("returns argv unchanged on non-linux platforms", () => {
for (const platform of ["darwin", "win32", "freebsd"] as const) {
expect(
wrapArgvForChildOomScoreRaise(argv, { platform, env: {}, shellAvailable: () => true }),
).toEqual(argv);
}
});
it("respects the OPENCLAW_CHILD_OOM_SCORE_ADJ opt-out", () => {
for (const value of ["0", "false", "FALSE", "no", "off"]) {
expect(
wrapArgvForChildOomScoreRaise(argv, {
...linux,
env: { OPENCLAW_CHILD_OOM_SCORE_ADJ: value },
}),
).toEqual(argv);
}
});
it("skips wrap when /bin/sh is unavailable (distroless/scratch)", () => {
expect(wrapArgvForChildOomScoreRaise(argv, linuxNoShell)).toEqual(argv);
});
it("does not double-wrap already-wrapped argv", () => {
const once = wrapArgvForChildOomScoreRaise(argv, linux);
const twice = wrapArgvForChildOomScoreRaise(once, linux);
expect(twice).toEqual(once);
});
it("returns empty argv unchanged", () => {
expect(wrapArgvForChildOomScoreRaise([], linux)).toEqual([]);
});
it("skips wrap for command names that exec could parse as options", () => {
expect(wrapArgvForChildOomScoreRaise(["-p", "node"], linux)).toEqual(["-p", "node"]);
});
});
describe("prepareOomScoreAdjustedSpawn", () => {
it("returns command, args, and hardened env when wrap applies", () => {
const result = prepareOomScoreAdjustedSpawn("/usr/bin/node", ["run.js"], {
...linux,
env: { PATH: "/usr/bin", BASH_ENV: "/tmp/bashenv", ENV: "/tmp/env", CDPATH: "/tmp" },
});
expect(result).toEqual({
command: "/bin/sh",
args: ["-c", wrapScript, "/usr/bin/node", "run.js"],
env: { PATH: "/usr/bin" },
wrapped: true,
});
});
it("preserves the spawn shape when wrap does not apply", () => {
const env = { PATH: "/usr/bin" };
expect(
prepareOomScoreAdjustedSpawn("/usr/bin/node", ["run.js"], {
platform: "darwin",
env,
shellAvailable: () => true,
}),
).toEqual({
command: "/usr/bin/node",
args: ["run.js"],
env,
wrapped: false,
});
});
});
describe("hardenedEnvForChildOomWrap", () => {
const tainted = { PATH: "/usr/bin", BASH_ENV: "/tmp/evil.sh", ENV: "/tmp/evil", CDPATH: "/tmp" };
it("strips shell-init keys when wrap applies", () => {
expect(hardenedEnvForChildOomWrap(tainted, linux)).toEqual({ PATH: "/usr/bin" });
});
it("preserves baseEnv (including undefined) when wrap does not apply", () => {
expect(hardenedEnvForChildOomWrap(tainted, linuxNoShell)).toBe(tainted);
expect(
hardenedEnvForChildOomWrap(undefined, { platform: "darwin", shellAvailable: () => true }),
).toBeUndefined();
expect(
hardenedEnvForChildOomWrap(tainted, { ...linux, env: { OPENCLAW_CHILD_OOM_SCORE_ADJ: "0" } }),
).toBe(tainted);
});
});

View File

@@ -0,0 +1,143 @@
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.
*/
export 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);
}

View File

@@ -215,18 +215,70 @@ describe("createChildAdapter", () => {
expect(spawnArgs.fallbacks ?? []).toEqual([]);
});
it("keeps inherited env when no override env is provided", async () => {
it("keeps inherited env when no override env is provided on non-Linux", async () => {
setPlatform("darwin");
await createAdapterHarness({
pid: 3333,
argv: ["node", "-e", "process.exit(0)"],
});
const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as {
argv?: string[];
options?: { env?: NodeJS.ProcessEnv };
};
expect(spawnArgs.argv).toEqual(["node", "-e", "process.exit(0)"]);
expect(spawnArgs.options?.env).toBeUndefined();
});
it("wraps Linux child spawns and strips shell-init env", async () => {
const originalBashEnv = process.env.BASH_ENV;
const originalEnv = process.env.ENV;
const originalCdpath = process.env.CDPATH;
setPlatform("linux");
process.env.BASH_ENV = "/tmp/bashenv";
process.env.ENV = "/tmp/env";
process.env.CDPATH = "/tmp";
try {
await createAdapterHarness({
pid: 3334,
argv: ["/usr/bin/node", "-e", "process.exit(0)"],
});
} finally {
if (originalBashEnv === undefined) {
delete process.env.BASH_ENV;
} else {
process.env.BASH_ENV = originalBashEnv;
}
if (originalEnv === undefined) {
delete process.env.ENV;
} else {
process.env.ENV = originalEnv;
}
if (originalCdpath === undefined) {
delete process.env.CDPATH;
} else {
process.env.CDPATH = originalCdpath;
}
}
const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as {
argv?: string[];
options?: { env?: NodeJS.ProcessEnv };
};
expect(spawnArgs.argv?.slice(0, 4)).toEqual([
"/bin/sh",
"-c",
'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"',
"/usr/bin/node",
]);
expect(spawnArgs.argv?.slice(4)).toEqual(["-e", "process.exit(0)"]);
expect(spawnArgs.options?.env).toBeDefined();
expect(spawnArgs.options?.env?.BASH_ENV).toBeUndefined();
expect(spawnArgs.options?.env?.ENV).toBeUndefined();
expect(spawnArgs.options?.env?.CDPATH).toBeUndefined();
});
it("passes explicit env overrides as strings", async () => {
await createAdapterHarness({
pid: 4444,

View File

@@ -1,5 +1,6 @@
import type { ChildProcessWithoutNullStreams, SpawnOptions } from "node:child_process";
import { killProcessTree } from "../../kill-tree.js";
import { prepareOomScoreAdjustedSpawn } from "../../linux-oom-score.js";
import { spawnWithFallback } from "../../spawn-utils.js";
import { resolveWindowsCommandShim } from "../../windows-command.js";
import type { ManagedRunStdin, SpawnProcessAdapter } from "../types.js";
@@ -31,6 +32,10 @@ export async function createChildAdapter(params: {
}): Promise<ChildAdapter> {
const resolvedArgv = [...params.argv];
resolvedArgv[0] = resolveCommand(resolvedArgv[0] ?? "");
const baseEnv = params.env ? toStringEnv(params.env) : undefined;
const preparedSpawn = prepareOomScoreAdjustedSpawn(resolvedArgv[0] ?? "", resolvedArgv.slice(1), {
env: baseEnv,
});
const stdinMode = params.stdinMode ?? (params.input !== undefined ? "pipe-closed" : "inherit");
@@ -41,7 +46,7 @@ export async function createChildAdapter(params: {
const options: SpawnOptions = {
cwd: params.cwd,
env: params.env ? toStringEnv(params.env) : undefined,
env: preparedSpawn.env,
stdio: ["pipe", "pipe", "pipe"],
detached: useDetached,
windowsHide: true,
@@ -54,7 +59,7 @@ export async function createChildAdapter(params: {
}
const spawned = await spawnWithFallback({
argv: resolvedArgv,
argv: [preparedSpawn.command, ...preparedSpawn.args],
options,
fallbacks: useDetached
? [

View File

@@ -40,6 +40,14 @@ function expectSpawnEnv() {
return spawnOptions?.env;
}
function expectSpawnCommand() {
return spawnMock.mock.calls[0]?.[0] as string | undefined;
}
function expectSpawnArgs() {
return spawnMock.mock.calls[0]?.[1] as string[] | undefined;
}
describe("createPtyAdapter", () => {
let createPtyAdapter: typeof import("./pty.js").createPtyAdapter;
@@ -143,16 +151,55 @@ describe("createPtyAdapter", () => {
await expect(adapter.wait()).resolves.toEqual({ code: 3, signal: null });
});
it("keeps inherited env when no override env is provided", async () => {
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
it("keeps inherited env when no override env is provided on non-Linux", async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
try {
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
await createPtyAdapter({
shell: "bash",
args: ["-lc", "env"],
});
await createPtyAdapter({
shell: "bash",
args: ["-lc", "env"],
});
expect(expectSpawnEnv()).toBeUndefined();
expect(expectSpawnCommand()).toBe("bash");
expect(expectSpawnArgs()).toEqual(["-lc", "env"]);
expect(expectSpawnEnv()).toBeUndefined();
} finally {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
}
});
it("wraps Linux PTY spawns so shell children inherit higher OOM score", async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
try {
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
await createPtyAdapter({
shell: "bash",
args: ["-lc", "env"],
env: { PATH: "/usr/bin", BASH_ENV: "/tmp/bashenv" },
});
} finally {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
}
expect(expectSpawnCommand()).toBe("/bin/sh");
expect(expectSpawnArgs()).toEqual([
"-c",
'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"',
"bash",
"-lc",
"env",
]);
expect(expectSpawnEnv()).toEqual({ PATH: "/usr/bin" });
});
it("passes explicit env overrides as strings", async () => {

View File

@@ -1,4 +1,5 @@
import { killProcessTree } from "../../kill-tree.js";
import { prepareOomScoreAdjustedSpawn } from "../../linux-oom-score.js";
import type { ManagedRunStdin, SpawnProcessAdapter } from "../types.js";
import { toStringEnv } from "./env.js";
@@ -55,9 +56,11 @@ export async function createPtyAdapter(params: {
if (!spawn) {
throw new Error("PTY support is unavailable (node-pty spawn not found).");
}
const pty = spawn(params.shell, params.args, {
const baseEnv = params.env ? toStringEnv(params.env) : undefined;
const preparedSpawn = prepareOomScoreAdjustedSpawn(params.shell, params.args, { env: baseEnv });
const pty = spawn(preparedSpawn.command, preparedSpawn.args, {
cwd: params.cwd,
env: params.env ? toStringEnv(params.env) : undefined,
env: preparedSpawn.env ? toStringEnv(preparedSpawn.env) : undefined,
name: params.name ?? process.env.TERM ?? "xterm-256color",
cols: params.cols ?? 120,
rows: params.rows ?? 30,