Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/code/src/main/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ import os from "node:os";
import path from "node:path";
import { app, crashReporter, protocol } from "electron";
import { fixPath } from "./utils/fixPath";
import { shouldRefuseInternalChildBoot } from "./utils/internal-child-guard";

// The internal-child marker means a workspace-server descendant stripped
// ELECTRON_RUN_AS_NODE and ran `node` or process.execPath; booting a full app
// here would race the single-instance lock and open phantom windows.
if (shouldRefuseInternalChildBoot(app.isPackaged, process.env)) {
process.stderr.write(
"[posthog-code] Refusing to start the desktop app from inside its own " +
"child process tree (expected ELECTRON_RUN_AS_NODE=1).\n",
);
process.exit(1);
}

const isDev = !app.isPackaged;

Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/main/services/workspace-server/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
import { createServer } from "node:net";
import path from "node:path";
import { TypedEventEmitter } from "@posthog/shared";
import { POSTHOG_CODE_INTERNAL_CHILD_ENV } from "@posthog/shared/constants";
import type { WorkspaceConnection } from "@posthog/workspace-client/client";
import { injectable } from "inversify";
import { logger } from "../../utils/logger.js";
Expand Down Expand Up @@ -191,6 +192,7 @@ export class WorkspaceServerService extends TypedEventEmitter<WorkspaceServerEve
env: {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
[POSTHOG_CODE_INTERNAL_CHILD_ENV]: "1",
WORKSPACE_SERVER_SECRET: secret,
WORKSPACE_SERVER_PORT: String(port),
WORKSPACE_SERVER_PARENT_PID: String(process.pid),
Expand Down
17 changes: 17 additions & 0 deletions apps/code/src/main/utils/internal-child-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { shouldRefuseInternalChildBoot } from "./internal-child-guard";

describe("shouldRefuseInternalChildBoot", () => {
it.each([
["packaged app inside the internal child tree", true, "1", true],
["packaged app with no marker", true, undefined, false],
["dev build inside the internal child tree", false, "1", false],
["packaged app with an empty marker", true, "", false],
] as const)("%s", (_name, isPackaged, marker, expected) => {
expect(
shouldRefuseInternalChildBoot(isPackaged, {
POSTHOG_CODE_INTERNAL_CHILD: marker,
}),
).toBe(expected);
});
});
10 changes: 10 additions & 0 deletions apps/code/src/main/utils/internal-child-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { POSTHOG_CODE_INTERNAL_CHILD_ENV } from "@posthog/shared/constants";

// Packaged-only: dev builds must stay launchable from dogfooding trees, where
// an app-spawned agent or terminal runs `pnpm dev` with the marker inherited.
export function shouldRefuseInternalChildBoot(
isPackaged: boolean,
env: NodeJS.ProcessEnv,
): boolean {
return isPackaged && Boolean(env[POSTHOG_CODE_INTERNAL_CHILD_ENV]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ describe("buildLocalToolsServer", () => {
// Token is forwarded to the child so its own git remote ops authenticate.
expect(envNames).toContain("GH_TOKEN");
expect(envNames).toContain("GITHUB_TOKEN");
// Codex strips ELECTRON_RUN_AS_NODE from its own env, and process.execPath
// is the app binary in packaged installs; without this the server boots
// the full desktop app instead of running the script.
expect(server?.env).toContainEqual({
name: "ELECTRON_RUN_AS_NODE",
value: "1",
});

const ctxEntry = server?.env.find(
(e) => e.name === "POSTHOG_LOCAL_TOOLS_CTX",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ function toMcpServerStdio(
const env = [
{ name: "POSTHOG_LOCAL_TOOLS_CTX", value: ctxBase64 },
{ name: "POSTHOG_LOCAL_TOOLS_ENABLED", value: enabledNames.join(",") },
// Codex spawns this command with ELECTRON_RUN_AS_NODE removed from its own
// env (spawn.ts). In packaged desktop installs process.execPath is the app
// binary, which boots the full app without this var. Inert on real node.
{ name: "ELECTRON_RUN_AS_NODE", value: "1" },
];
if (ctx.token) {
// Token also on the child env so its own git remote ops authenticate.
Expand Down
34 changes: 26 additions & 8 deletions packages/agent/src/utils/spawn-env.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,46 @@
import { mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { buildNodeShimScript } from "@posthog/shared/node-shim";
import { describe, expect, it } from "vitest";
import { stripElectronNodeShimFromPath } from "./spawn-env";

const EXEC_PATH = "/fake/Electron.app/Contents/MacOS/Electron";

function makeDir(kind: "shim" | "other-symlink" | "real-node" | "empty") {
function makeDir(
kind:
| "symlink-shim"
| "wrapper-shim"
| "foreign-wrapper"
| "other-symlink"
| "real-node"
| "empty",
) {
const dir = mkdtempSync(join(tmpdir(), "spawn-env-"));
if (kind === "shim") symlinkSync(EXEC_PATH, join(dir, "node"));
if (kind === "other-symlink") symlinkSync("/usr/bin/true", join(dir, "node"));
if (kind === "real-node") writeFileSync(join(dir, "node"), "");
const node = join(dir, "node");
if (kind === "symlink-shim") symlinkSync(EXEC_PATH, node);
if (kind === "wrapper-shim")
writeFileSync(node, buildNodeShimScript(EXEC_PATH));
if (kind === "foreign-wrapper")
writeFileSync(node, buildNodeShimScript("/some/other/app"));
if (kind === "other-symlink") symlinkSync("/usr/bin/true", node);
if (kind === "real-node") writeFileSync(node, "");
return dir;
}

describe("stripElectronNodeShimFromPath", () => {
it("removes only dirs whose node symlinks to the executable", () => {
const shim = makeDir("shim");
it("removes only dirs whose node aliases the executable", () => {
const symlinkShim = makeDir("symlink-shim");
const wrapperShim = makeDir("wrapper-shim");
const foreign = makeDir("foreign-wrapper");
const other = makeDir("other-symlink");
const real = makeDir("real-node");
const empty = makeDir("empty");
const input = [shim, other, real, empty].join(delimiter);
const input = [symlinkShim, wrapperShim, foreign, other, real, empty].join(
delimiter,
);
expect(stripElectronNodeShimFromPath(input, EXEC_PATH)).toBe(
[other, real, empty].join(delimiter),
[foreign, other, real, empty].join(delimiter),
);
});

Expand Down
36 changes: 19 additions & 17 deletions packages/agent/src/utils/spawn-env.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
import { readlinkSync } from "node:fs";
import { lstatSync, readFileSync, readlinkSync } from "node:fs";
import { delimiter, join } from "node:path";
import { buildNodeShimScript } from "@posthog/shared/node-shim";

/**
* Removes PATH entries that alias `node` to the current executable. The desktop
* host prepends a shim dir whose `node` symlinks to Electron so claude-code
* children (which inherit ELECTRON_RUN_AS_NODE=1) can resolve a node binary.
* Codex children have ELECTRON_RUN_AS_NODE deleted, so anything they spawn via
* that shim boots a full Electron app that never exits — wedging codex's
* plugin hooks (and with them every turn) until its 10-minute timeout.
*/
// Removes PATH entries that alias `node` to the current executable, whether a
// legacy symlink or the wrapper script written by ensureNodeShim. Codex
// children must not resolve `node` to Electron's bundled runtime: native
// modules they install target the real node ABI.
export function stripElectronNodeShimFromPath(
pathValue: string | undefined,
execPath: string = process.execPath,
): string | undefined {
if (!pathValue) return pathValue;
return pathValue
.split(delimiter)
.filter((dir) => {
if (!dir) return false;
try {
return readlinkSync(join(dir, "node")) !== execPath;
} catch {
return true;
}
})
.filter((dir) => dir && !isElectronNodeShimDir(dir, execPath))
.join(delimiter);
}

function isElectronNodeShimDir(dir: string, execPath: string): boolean {
const shimPath = join(dir, "node");
try {
if (lstatSync(shimPath).isSymbolicLink()) {
return readlinkSync(shimPath) === execPath;
}
return readFileSync(shimPath, "utf-8") === buildNodeShimScript(execPath);
} catch {
return false;
}
}
7 changes: 4 additions & 3 deletions packages/git/src/operation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { AsyncReaderWriterLock } from "./rw-lock";
* child processes spawned by git hooks (e.g. biome via lint-staged) don't
* crash trying to initialise GPU subsystems.
*
* The agent service symlinks `node → Electron binary` and prepends it to
* PATH. If ELECTRON_RUN_AS_NODE is missing, that binary starts as a full
* Chromium browser (GPU init → SIGTRAP crash). We strip most ELECTRON_/
* The agent service puts a `node` shim for the Electron binary on PATH (a
* wrapper script that sets ELECTRON_RUN_AS_NODE itself on POSIX, a symlink
* on win32). If the var is missing when the raw binary runs, it starts as a
* full Chromium browser (GPU init → SIGTRAP crash). We strip most ELECTRON_/
* CHROME_ vars but explicitly keep ELECTRON_RUN_AS_NODE=1 so any such
* shim still behaves as plain Node.js.
*
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
"types": "./dist/mcp-sandbox-proxy.d.ts",
"import": "./dist/mcp-sandbox-proxy.js"
},
"./node-shim": {
"types": "./dist/node-shim.d.ts",
"import": "./dist/node-shim.js"
},
"./posthog-property-headers": {
"types": "./dist/posthog-property-headers.d.ts",
"import": "./dist/posthog-property-headers.js"
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export {
export const SELF_DRIVING_SETUP_TASK_FLAG =
"posthog-code-self-driving-setup-task";
export const BRANCH_PREFIX = "posthog-code/";
export const POSTHOG_CODE_INTERNAL_CHILD_ENV = "POSTHOG_CODE_INTERNAL_CHILD";
21 changes: 21 additions & 0 deletions packages/shared/src/node-shim.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { buildNodeShimScript } from "./node-shim";

describe("buildNodeShimScript", () => {
it("sets ELECTRON_RUN_AS_NODE and execs the binary", () => {
const script = buildNodeShimScript(
"/Applications/PostHog Code.app/Contents/MacOS/PostHog Code",
);
expect(script).toContain("#!/bin/sh");
expect(script).toContain("export ELECTRON_RUN_AS_NODE=1");
expect(script).toContain(
'exec "/Applications/PostHog Code.app/Contents/MacOS/PostHog Code" "$@"',
);
});

it("escapes shell-special characters in the binary path", () => {
expect(buildNodeShimScript('/odd/pa"th/$app`bin\\x')).toContain(
'exec "/odd/pa\\"th/\\$app\\`bin\\\\x" "$@"',
);
});
});
14 changes: 14 additions & 0 deletions packages/shared/src/node-shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
function escapeForDoubleQuotes(path: string): string {
return path.replace(/([$`"\\])/g, "\\$1");
}

// Single source of truth for the PATH `node` shim format: workspace-server
// writes it and the codex spawn path detects it by exact content.
export function buildNodeShimScript(execPath: string): string {
return [
"#!/bin/sh",
"export ELECTRON_RUN_AS_NODE=1",
`exec "${escapeForDoubleQuotes(execPath)}" "$@"`,
"",
].join("\n");
}
1 change: 1 addition & 0 deletions packages/shared/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineConfig({
"src/dismissalReasons.ts",
"src/domain-types.ts",
"src/mcp-sandbox-proxy.ts",
"src/node-shim.ts",
"src/posthog-property-headers.ts",
"src/types.ts",
],
Expand Down
30 changes: 30 additions & 0 deletions packages/workspace-server/src/services/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ vi.mock("node:fs", async (importOriginal) => {
};
});

const mockNodeShim = vi.hoisted(() => ({
ensureNodeShim: vi.fn(),
}));

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

// --- Import after mocks ---
import type { RegisteredFolder } from "../folders/schemas";
import { AgentService, buildAutoApproveOutcome } from "./agent";
Expand Down Expand Up @@ -347,6 +353,30 @@ describe("AgentService", () => {
});
});

describe("node shim setup", () => {
it("writes the shim once per service and retries after a failure", async () => {
mockNodeShim.ensureNodeShim.mockImplementationOnce(() => {
throw new Error("read-only fs");
});

await service.startSession({ ...baseSessionParams, adapter: "codex" });
await service.startSession({
...baseSessionParams,
taskId: "task-2",
taskRunId: "run-2",
adapter: "codex",
});
await service.startSession({
...baseSessionParams,
taskId: "task-3",
taskRunId: "run-3",
adapter: "codex",
});

expect(mockNodeShim.ensureNodeShim).toHaveBeenCalledTimes(2);
});
});

describe("idle timeout", () => {
function injectSession(
svc: AgentService,
Expand Down
17 changes: 3 additions & 14 deletions packages/workspace-server/src/services/agent/agent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import fs, { mkdirSync, symlinkSync } from "node:fs";
import fs from "node:fs";
import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import {
Expand Down Expand Up @@ -88,6 +88,7 @@ import {
AGENT_REPO_FILES,
AGENT_SLEEP_COORDINATOR,
} from "./identifiers";
import { ensureNodeShim } from "./node-shim";
import type {
AgentLogger,
AgentMcpApps,
Expand Down Expand Up @@ -1593,19 +1594,7 @@ For git operations while detached:
const mockNodeDir = getMockNodeDir();
if (!this.mockNodeReady) {
try {
mkdirSync(mockNodeDir, { recursive: true });
const nodeSymlinkPath = join(mockNodeDir, "node");
try {
symlinkSync(process.execPath, nodeSymlinkPath);
} catch (err) {
if (
!(err instanceof Error) ||
!("code" in err) ||
err.code !== "EEXIST"
) {
throw err;
}
}
ensureNodeShim(mockNodeDir, process.execPath);
this.mockNodeReady = true;
} catch (err) {
this.log.warn("Failed to setup mock node environment", err);
Expand Down
Loading