Skip to content

Commit 8a18ab5

Browse files
authored
feat(agent): prefer a detected real node runtime in the PATH shim
The node shim now probes PATH for a real Node >= 20 at session start and execs it directly when found, keeping the ELECTRON_RUN_AS_NODE fallback only for when that node later disappears. Candidates identifying as Electron (process.versions.electron) are rejected; our own shim dirs are skipped; POSTHOG_CODE_NODE_PATH overrides detection. The codex PATH strip recognizes both wrapper variants via a shared isNodeShimScript predicate instead of exact-content equality. Generated-By: PostHog Code Task-Id: ced8de81-260a-443e-823e-eb8828b89d07
1 parent 170b4b3 commit 8a18ab5

10 files changed

Lines changed: 664 additions & 48 deletions

File tree

packages/agent/src/utils/spawn-env.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ function makeDir(
1111
kind:
1212
| "symlink-shim"
1313
| "wrapper-shim"
14+
| "real-node-wrapper-shim"
1415
| "foreign-wrapper"
1516
| "other-symlink"
1617
| "real-node"
@@ -21,6 +22,8 @@ function makeDir(
2122
if (kind === "symlink-shim") symlinkSync(EXEC_PATH, node);
2223
if (kind === "wrapper-shim")
2324
writeFileSync(node, buildNodeShimScript(EXEC_PATH));
25+
if (kind === "real-node-wrapper-shim")
26+
writeFileSync(node, buildNodeShimScript(EXEC_PATH, "/usr/local/bin/node"));
2427
if (kind === "foreign-wrapper")
2528
writeFileSync(node, buildNodeShimScript("/some/other/app"));
2629
if (kind === "other-symlink") symlinkSync("/usr/bin/true", node);
@@ -32,13 +35,20 @@ describe("stripElectronNodeShimFromPath", () => {
3235
it("removes only dirs whose node aliases the executable", () => {
3336
const symlinkShim = makeDir("symlink-shim");
3437
const wrapperShim = makeDir("wrapper-shim");
38+
const realNodeWrapperShim = makeDir("real-node-wrapper-shim");
3539
const foreign = makeDir("foreign-wrapper");
3640
const other = makeDir("other-symlink");
3741
const real = makeDir("real-node");
3842
const empty = makeDir("empty");
39-
const input = [symlinkShim, wrapperShim, foreign, other, real, empty].join(
40-
delimiter,
41-
);
43+
const input = [
44+
symlinkShim,
45+
wrapperShim,
46+
realNodeWrapperShim,
47+
foreign,
48+
other,
49+
real,
50+
empty,
51+
].join(delimiter);
4252
expect(stripElectronNodeShimFromPath(input, EXEC_PATH)).toBe(
4353
[foreign, other, real, empty].join(delimiter),
4454
);

packages/agent/src/utils/spawn-env.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { lstatSync, readFileSync, readlinkSync } from "node:fs";
22
import { delimiter, join } from "node:path";
3-
import { buildNodeShimScript } from "@posthog/shared/node-shim";
3+
import { isNodeShimScript } from "@posthog/shared/node-shim";
44

55
// Removes PATH entries that alias `node` to the current executable, whether a
6-
// legacy symlink or the wrapper script written by ensureNodeShim. Codex
7-
// children must not resolve `node` to Electron's bundled runtime: native
8-
// modules they install target the real node ABI.
6+
// legacy symlink or either wrapper-script variant written by ensureNodeShim.
7+
// Codex children must not resolve `node` through the shim: with the Electron
8+
// fallback engaged, native modules they install target the wrong ABI. A
9+
// real-node-backed shim would be harmless, but stripping it too keeps codex
10+
// on the user's own PATH resolution (detection only ever picks a node that is
11+
// independently reachable there).
912
export function stripElectronNodeShimFromPath(
1013
pathValue: string | undefined,
1114
execPath: string = process.execPath,
@@ -23,7 +26,7 @@ function isElectronNodeShimDir(dir: string, execPath: string): boolean {
2326
if (lstatSync(shimPath).isSymbolicLink()) {
2427
return readlinkSync(shimPath) === execPath;
2528
}
26-
return readFileSync(shimPath, "utf-8") === buildNodeShimScript(execPath);
29+
return isNodeShimScript(readFileSync(shimPath, "utf-8"), execPath);
2730
} catch {
2831
return false;
2932
}
Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,61 @@
11
import { describe, expect, it } from "vitest";
2-
import { buildNodeShimScript } from "./node-shim";
2+
import { buildNodeShimScript, isNodeShimScript } from "./node-shim";
3+
4+
const EXEC_PATH = "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code";
35

46
describe("buildNodeShimScript", () => {
57
it("sets ELECTRON_RUN_AS_NODE and execs the binary", () => {
6-
const script = buildNodeShimScript(
7-
"/Applications/PostHog Code.app/Contents/MacOS/PostHog Code",
8-
);
8+
const script = buildNodeShimScript(EXEC_PATH);
99
expect(script).toContain("#!/bin/sh");
1010
expect(script).toContain("export ELECTRON_RUN_AS_NODE=1");
11-
expect(script).toContain(
12-
'exec "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code" "$@"',
13-
);
11+
expect(script).toContain(`exec "${EXEC_PATH}" "$@"`);
1412
});
1513

16-
it("escapes shell-special characters in the binary path", () => {
14+
it("prefers the real node when given one, keeping the run-as-node fallback", () => {
15+
const script = buildNodeShimScript(EXEC_PATH, "/usr/local/bin/node");
16+
const lines = script.split("\n");
17+
expect(lines[0]).toBe("#!/bin/sh");
18+
expect(lines[1]).toBe('if [ -x "/usr/local/bin/node" ]; then');
19+
expect(lines[2]).toBe(' exec "/usr/local/bin/node" "$@"');
20+
expect(lines[3]).toBe("fi");
21+
expect(script).toContain("export ELECTRON_RUN_AS_NODE=1");
22+
expect(script).toContain(`exec "${EXEC_PATH}" "$@"`);
23+
});
24+
25+
it("escapes shell-special characters in both paths", () => {
1726
expect(buildNodeShimScript('/odd/pa"th/$app`bin\\x')).toContain(
1827
'exec "/odd/pa\\"th/\\$app\\`bin\\\\x" "$@"',
1928
);
29+
expect(buildNodeShimScript(EXEC_PATH, '/odd/no"de/$v')).toContain(
30+
'exec "/odd/no\\"de/\\$v" "$@"',
31+
);
32+
});
33+
});
34+
35+
describe("isNodeShimScript", () => {
36+
it("recognizes both variants written for the same binary", () => {
37+
expect(isNodeShimScript(buildNodeShimScript(EXEC_PATH), EXEC_PATH)).toBe(
38+
true,
39+
);
40+
expect(
41+
isNodeShimScript(
42+
buildNodeShimScript(EXEC_PATH, "/usr/local/bin/node"),
43+
EXEC_PATH,
44+
),
45+
).toBe(true);
46+
});
47+
48+
it("rejects shims written for a different binary and non-shim content", () => {
49+
expect(
50+
isNodeShimScript(buildNodeShimScript("/some/other/app"), EXEC_PATH),
51+
).toBe(false);
52+
expect(
53+
isNodeShimScript(
54+
buildNodeShimScript("/some/other/app", "/usr/local/bin/node"),
55+
EXEC_PATH,
56+
),
57+
).toBe(false);
58+
expect(isNodeShimScript("not a shim", EXEC_PATH)).toBe(false);
59+
expect(isNodeShimScript("", EXEC_PATH)).toBe(false);
2060
});
2161
});

packages/shared/src/node-shim.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,44 @@ function escapeForDoubleQuotes(path: string): string {
22
return path.replace(/([$`"\\])/g, "\\$1");
33
}
44

5-
// Single source of truth for the PATH `node` shim format: workspace-server
6-
// writes it and the codex spawn path detects it by exact content.
7-
export function buildNodeShimScript(execPath: string): string {
5+
function fallbackBlock(execPath: string): string {
86
return [
9-
"#!/bin/sh",
107
"export ELECTRON_RUN_AS_NODE=1",
118
`exec "${escapeForDoubleQuotes(execPath)}" "$@"`,
129
"",
1310
].join("\n");
1411
}
12+
13+
// Single source of truth for the PATH `node` shim format: workspace-server
14+
// writes it and the codex spawn path detects it via isNodeShimScript.
15+
//
16+
// With a realNodePath the shim prefers that binary and only falls back to
17+
// running the app binary as node when it has gone missing — e.g. an nvm
18+
// prune deleted the version between sessions.
19+
export function buildNodeShimScript(
20+
execPath: string,
21+
realNodePath?: string,
22+
): string {
23+
if (!realNodePath) {
24+
return `#!/bin/sh\n${fallbackBlock(execPath)}`;
25+
}
26+
const real = escapeForDoubleQuotes(realNodePath);
27+
return [
28+
"#!/bin/sh",
29+
`if [ -x "${real}" ]; then`,
30+
` exec "${real}" "$@"`,
31+
"fi",
32+
fallbackBlock(execPath),
33+
].join("\n");
34+
}
35+
36+
// True for any shim variant written for the given app binary, with or
37+
// without an embedded preferred real node — detectors (the codex PATH strip,
38+
// real-node discovery) can't know which real node path a shim embeds, so
39+
// they match on the fallback block every variant ends with.
40+
export function isNodeShimScript(content: string, execPath: string): boolean {
41+
return (
42+
content.startsWith("#!/bin/sh\n") &&
43+
content.endsWith(fallbackBlock(execPath))
44+
);
45+
}

packages/workspace-server/src/services/agent/agent.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ const mockNodeShim = vi.hoisted(() => ({
104104

105105
vi.mock("./node-shim", () => mockNodeShim);
106106

107+
const mockRealNode = vi.hoisted(() => ({
108+
findRealNode: vi.fn(
109+
async (): Promise<{ path: string; version: string } | null> => null,
110+
),
111+
}));
112+
113+
vi.mock("./real-node", () => mockRealNode);
114+
107115
// --- Import after mocks ---
108116
import type { RegisteredFolder } from "../folders/schemas";
109117
import { AgentService, buildAutoApproveOutcome } from "./agent";
@@ -375,6 +383,21 @@ describe("AgentService", () => {
375383

376384
expect(mockNodeShim.ensureNodeShim).toHaveBeenCalledTimes(2);
377385
});
386+
387+
it("passes the detected real node through to the shim writer", async () => {
388+
mockRealNode.findRealNode.mockResolvedValueOnce({
389+
path: "/usr/local/bin/node",
390+
version: "v22.1.0",
391+
});
392+
393+
await service.startSession({ ...baseSessionParams, adapter: "codex" });
394+
395+
expect(mockNodeShim.ensureNodeShim).toHaveBeenCalledWith(
396+
expect.any(String),
397+
process.execPath,
398+
{ realNodePath: "/usr/local/bin/node" },
399+
);
400+
});
378401
});
379402

380403
describe("idle timeout", () => {

packages/workspace-server/src/services/agent/agent.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import type {
100100
AgentScopedLogger,
101101
AgentSleepCoordinator,
102102
} from "./ports";
103+
import { findRealNode } from "./real-node";
103104
import {
104105
AgentServiceEvent,
105106
type AgentServiceEvents,
@@ -370,6 +371,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
370371
private sessions = new Map<string, ManagedSession>();
371372
private pendingPermissions = new Map<string, PendingPermission>();
372373
private mockNodeReady = false;
374+
private nodeShimSetup: Promise<void> | null = null;
373375
private idleTimeouts = new Map<
374376
string,
375377
{ handle: ReturnType<typeof setTimeout>; deadline: number }
@@ -761,7 +763,7 @@ If a repository IS genuinely required, attach one in this priority order:
761763
}
762764

763765
const channel = `agent-event:${taskRunId}`;
764-
const mockNodeDir = this.setupMockNodeEnvironment();
766+
const mockNodeDir = await this.setupMockNodeEnvironment();
765767
const proxyUrl = await this.agentAuthAdapter.ensureGatewayProxy(
766768
credentials.apiHost,
767769
);
@@ -1596,19 +1598,42 @@ For git operations while detached:
15961598
this.log.info("All agent sessions cleaned up");
15971599
}
15981600

1599-
private setupMockNodeEnvironment(): string {
1601+
private async setupMockNodeEnvironment(): Promise<string> {
16001602
const mockNodeDir = getMockNodeDir();
16011603
if (!this.mockNodeReady) {
1602-
try {
1603-
ensureNodeShim(mockNodeDir, process.execPath);
1604-
this.mockNodeReady = true;
1605-
} catch (err) {
1606-
this.log.warn("Failed to setup mock node environment", err);
1607-
}
1604+
// Concurrent session starts share one setup; a failed run clears the
1605+
// memo so the next session retries.
1606+
this.nodeShimSetup ??= this.runNodeShimSetup(mockNodeDir);
1607+
await this.nodeShimSetup;
1608+
this.nodeShimSetup = null;
16081609
}
16091610
return mockNodeDir;
16101611
}
16111612

1613+
private async runNodeShimSetup(mockNodeDir: string): Promise<void> {
1614+
// findRealNode absorbs its own failures (null → Electron fallback), so
1615+
// only the shim write can throw, leaving mockNodeReady false to retry.
1616+
const realNode = await findRealNode({
1617+
warn: (message, data) => this.log.warn(message, data),
1618+
});
1619+
try {
1620+
ensureNodeShim(mockNodeDir, process.execPath, {
1621+
realNodePath: realNode?.path,
1622+
});
1623+
this.mockNodeReady = true;
1624+
if (realNode) {
1625+
this.log.info("node shim prefers detected real node", {
1626+
path: realNode.path,
1627+
version: realNode.version,
1628+
});
1629+
} else {
1630+
this.log.info("node shim using Electron run-as-node fallback");
1631+
}
1632+
} catch (err) {
1633+
this.log.warn("Failed to setup mock node environment", err);
1634+
}
1635+
}
1636+
16121637
private cancelInFlightMcpToolCalls(session: ManagedSession): void {
16131638
for (const [toolCallId, toolKey] of session.inFlightMcpToolCalls) {
16141639
this.mcpAppsService.notifyToolCancelled(toolKey, toolCallId);

0 commit comments

Comments
 (0)