Skip to content

Commit 44fe21e

Browse files
committed
make node shim set ELECTRON_RUN_AS_NODE itself
1 parent e802179 commit 44fe21e

3 files changed

Lines changed: 183 additions & 14 deletions

File tree

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

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import fs, { mkdirSync, symlinkSync } from "node:fs";
1+
import fs from "node:fs";
22
import { homedir, tmpdir } from "node:os";
33
import { isAbsolute, join, relative, resolve, sep } from "node:path";
44
import {
@@ -87,6 +87,7 @@ import {
8787
AGENT_REPO_FILES,
8888
AGENT_SLEEP_COORDINATOR,
8989
} from "./identifiers";
90+
import { ensureNodeShim } from "./node-shim";
9091
import type {
9192
AgentLogger,
9293
AgentMcpApps,
@@ -1560,19 +1561,7 @@ For git operations while detached:
15601561
const mockNodeDir = getMockNodeDir();
15611562
if (!this.mockNodeReady) {
15621563
try {
1563-
mkdirSync(mockNodeDir, { recursive: true });
1564-
const nodeSymlinkPath = join(mockNodeDir, "node");
1565-
try {
1566-
symlinkSync(process.execPath, nodeSymlinkPath);
1567-
} catch (err) {
1568-
if (
1569-
!(err instanceof Error) ||
1570-
!("code" in err) ||
1571-
err.code !== "EEXIST"
1572-
) {
1573-
throw err;
1574-
}
1575-
}
1564+
ensureNodeShim(mockNodeDir, process.execPath);
15761565
this.mockNodeReady = true;
15771566
} catch (err) {
15781567
this.log.warn("Failed to setup mock node environment", err);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
lstatSync,
3+
mkdtempSync,
4+
readFileSync,
5+
readlinkSync,
6+
rmSync,
7+
statSync,
8+
symlinkSync,
9+
utimesSync,
10+
writeFileSync,
11+
} from "node:fs";
12+
import { tmpdir } from "node:os";
13+
import { join } from "node:path";
14+
import { afterEach, describe, expect, it } from "vitest";
15+
import { buildNodeShimScript, ensureNodeShim } from "./node-shim";
16+
17+
const EXEC_PATH = "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code";
18+
19+
describe("ensureNodeShim", () => {
20+
const dirs: string[] = [];
21+
22+
function makeDir(): string {
23+
const dir = mkdtempSync(join(tmpdir(), "node-shim-test-"));
24+
dirs.push(dir);
25+
return join(dir, "shim");
26+
}
27+
28+
afterEach(() => {
29+
while (dirs.length > 0) {
30+
const dir = dirs.pop();
31+
if (dir) rmSync(dir, { recursive: true, force: true });
32+
}
33+
});
34+
35+
it("writes an executable wrapper that sets ELECTRON_RUN_AS_NODE itself", () => {
36+
const dir = makeDir();
37+
ensureNodeShim(dir, EXEC_PATH, "darwin");
38+
39+
const shim = join(dir, "node");
40+
const content = readFileSync(shim, "utf-8");
41+
expect(content).toContain("#!/bin/sh");
42+
expect(content).toContain("export ELECTRON_RUN_AS_NODE=1");
43+
expect(content).toContain(`exec "${EXEC_PATH}" "$@"`);
44+
expect(statSync(shim).mode & 0o111).not.toBe(0);
45+
});
46+
47+
it("escapes shell-special characters in the binary path", () => {
48+
expect(buildNodeShimScript('/odd/pa"th/$app`bin\\x')).toContain(
49+
'exec "/odd/pa\\"th/\\$app\\`bin\\\\x" "$@"',
50+
);
51+
});
52+
53+
it("replaces a legacy symlink shim with the wrapper script", () => {
54+
const dir = makeDir();
55+
ensureNodeShim(dir, EXEC_PATH, "darwin");
56+
const shim = join(dir, "node");
57+
rmSync(shim);
58+
symlinkSync(EXEC_PATH, shim);
59+
60+
ensureNodeShim(dir, EXEC_PATH, "darwin");
61+
62+
expect(lstatSync(shim).isSymbolicLink()).toBe(false);
63+
expect(readFileSync(shim, "utf-8")).toBe(buildNodeShimScript(EXEC_PATH));
64+
});
65+
66+
it("rewrites the wrapper when the binary path changes", () => {
67+
const dir = makeDir();
68+
ensureNodeShim(dir, "/old/location/App", "darwin");
69+
70+
ensureNodeShim(dir, EXEC_PATH, "darwin");
71+
72+
expect(readFileSync(join(dir, "node"), "utf-8")).toBe(
73+
buildNodeShimScript(EXEC_PATH),
74+
);
75+
});
76+
77+
it("leaves an up-to-date wrapper untouched", () => {
78+
const dir = makeDir();
79+
ensureNodeShim(dir, EXEC_PATH, "darwin");
80+
const shim = join(dir, "node");
81+
const past = new Date("2020-01-01T00:00:00Z");
82+
utimesSync(shim, past, past);
83+
84+
ensureNodeShim(dir, EXEC_PATH, "darwin");
85+
86+
expect(statSync(shim).mtimeMs).toBe(past.getTime());
87+
});
88+
89+
it("replaces a corrupt shim that is not the expected script", () => {
90+
const dir = makeDir();
91+
ensureNodeShim(dir, EXEC_PATH, "darwin");
92+
const shim = join(dir, "node");
93+
writeFileSync(shim, "not a shim");
94+
95+
ensureNodeShim(dir, EXEC_PATH, "darwin");
96+
97+
expect(readFileSync(shim, "utf-8")).toBe(buildNodeShimScript(EXEC_PATH));
98+
});
99+
100+
it("keeps the symlink behavior on windows and tolerates an existing link", () => {
101+
const dir = makeDir();
102+
ensureNodeShim(dir, EXEC_PATH, "win32");
103+
ensureNodeShim(dir, EXEC_PATH, "win32");
104+
105+
const shim = join(dir, "node");
106+
expect(lstatSync(shim).isSymbolicLink()).toBe(true);
107+
expect(readlinkSync(shim)).toBe(EXEC_PATH);
108+
});
109+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
chmodSync,
3+
lstatSync,
4+
mkdirSync,
5+
readFileSync,
6+
renameSync,
7+
symlinkSync,
8+
writeFileSync,
9+
} from "node:fs";
10+
import { join } from "node:path";
11+
12+
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
13+
return err instanceof Error && "code" in err;
14+
}
15+
16+
function escapeForDoubleQuotes(path: string): string {
17+
return path.replace(/([$`"\\])/g, "\\$1");
18+
}
19+
20+
export function buildNodeShimScript(execPath: string): string {
21+
return [
22+
"#!/bin/sh",
23+
"export ELECTRON_RUN_AS_NODE=1",
24+
`exec "${escapeForDoubleQuotes(execPath)}" "$@"`,
25+
"",
26+
].join("\n");
27+
}
28+
29+
/**
30+
* Writes the `node` shim agents resolve via PATH. On POSIX this is a wrapper
31+
* script that sets ELECTRON_RUN_AS_NODE itself before exec'ing the app binary,
32+
* so the shim stays a node runtime even when a layer in between (user dotfiles,
33+
* direnv/flox, shell snapshots, MCP clients with cleaned envs) strips the var.
34+
* A bare symlink relied on every descendant preserving the env and booted the
35+
* full desktop app whenever one didn't. Replaces a stale legacy symlink or a
36+
* wrapper pointing at a moved binary; no-op when already current.
37+
*/
38+
export function ensureNodeShim(
39+
mockNodeDir: string,
40+
execPath: string,
41+
platform: NodeJS.Platform = process.platform,
42+
): void {
43+
mkdirSync(mockNodeDir, { recursive: true });
44+
const shimPath = join(mockNodeDir, "node");
45+
46+
if (platform === "win32") {
47+
try {
48+
symlinkSync(execPath, shimPath);
49+
} catch (err) {
50+
if (!isErrnoException(err) || err.code !== "EEXIST") throw err;
51+
}
52+
return;
53+
}
54+
55+
const script = buildNodeShimScript(execPath);
56+
if (currentShimContent(shimPath) === script) return;
57+
58+
const tmpPath = `${shimPath}.${process.pid}.tmp`;
59+
writeFileSync(tmpPath, script);
60+
chmodSync(tmpPath, 0o755);
61+
renameSync(tmpPath, shimPath);
62+
}
63+
64+
function currentShimContent(shimPath: string): string | null {
65+
try {
66+
if (lstatSync(shimPath).isSymbolicLink()) return null;
67+
return readFileSync(shimPath, "utf-8");
68+
} catch {
69+
return null;
70+
}
71+
}

0 commit comments

Comments
 (0)