Skip to content
Closed
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
27 changes: 25 additions & 2 deletions apps/code/forge.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process";
import { execSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { chmodSync, cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { MakerDMG } from "@electron-forge/maker-dmg";
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
Expand Down Expand Up @@ -139,11 +139,33 @@ function copySync(dependency: string, destinationRoot: string, source: string) {
);
}

function copyBundledNodeRuntime(destinationRoot: string): void {
const source = process.execPath;
const extension = process.platform === "win32" ? ".exe" : "";
const binDir = path.join(destinationRoot, "bin");
const destination = path.join(binDir, `node${extension}`);

mkdirSync(binDir, { recursive: true });
rmSync(destination, { force: true });
cpSync(source, destination, { dereference: true });

if (process.platform !== "win32") {
chmodSync(destination, 0o755);
}

console.log(
`[forge] Copied bundled Node runtime into ${path.relative(
process.cwd(),
destination,
)}`,
);
}

const config: ForgeConfig = {
packagerConfig: {
asar: {
unpack:
"{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/.vite/build/plugins/posthog/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**,**/node_modules/better-sqlite3/**,**/node_modules/bindings/**,**/node_modules/file-uri-to-path/**}",
"{**/*.node,**/spawn-helper,**/bin/node,**/bin/node.exe,**/.vite/build/claude-cli/**,**/.vite/build/plugins/posthog/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**,**/node_modules/better-sqlite3/**,**/node_modules/bindings/**,**/node_modules/file-uri-to-path/**}",
},
prune: false,
name: "PostHog Code",
Expand Down Expand Up @@ -226,6 +248,7 @@ const config: ForgeConfig = {
electronChild = child;
},
packageAfterCopy: async (_forgeConfig, buildPath) => {
copyBundledNodeRuntime(buildPath);
copyNativeDependency("node-pty", buildPath);
copyNativeDependency("node-addon-api", buildPath);
copyNativeDependency("@parcel/watcher", buildPath);
Expand Down
70 changes: 68 additions & 2 deletions apps/code/src/main/services/agent/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const mockAgentConstructor = vi.hoisted(() =>
);

const mockFetch = vi.hoisted(() => vi.fn());
const mockExecFileSync = vi.hoisted(() => vi.fn(() => "/usr/local/bin/node\n"));
const mockExistsSync = vi.hoisted(() => vi.fn((..._args: unknown[]) => false));

// --- Module mocks ---

Expand Down Expand Up @@ -111,16 +113,28 @@ vi.mock("@shared/errors.js", () => ({
isAuthError: vi.fn(() => false),
}));

vi.mock("node:child_process", async (importOriginal) => {
const original = await importOriginal<typeof import("node:child_process")>();
return {
...original,
default: {
...original,
execFileSync: mockExecFileSync,
},
execFileSync: mockExecFileSync,
};
});

vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs")>();
return {
...original,
default: {
...original,
existsSync: vi.fn(() => false),
existsSync: mockExistsSync,
realpathSync: vi.fn((p: string) => p),
},
existsSync: vi.fn(() => false),
existsSync: mockExistsSync,
mkdirSync: vi.fn(),
symlinkSync: vi.fn(),
realpathSync: vi.fn((p: string) => p),
Expand Down Expand Up @@ -175,9 +189,19 @@ const baseSessionParams = {

describe("AgentService", () => {
let service: AgentService;
let originalPath: string | undefined;
let originalOverride: string | undefined;
let originalNodeBinary: string | undefined;

beforeEach(() => {
vi.clearAllMocks();
originalPath = process.env.PATH;
originalOverride = process.env.POSTHOG_AGENT_NODE_PATH;
originalNodeBinary = process.env.NODE_BINARY;
delete process.env.POSTHOG_AGENT_NODE_PATH;
delete process.env.NODE_BINARY;
process.env.PATH = "/usr/bin:/bin";
mockApp.isPackaged = false;

// MCP installations endpoint returns empty
mockFetch.mockResolvedValue({
Expand All @@ -204,6 +228,17 @@ describe("AgentService", () => {
});

afterEach(() => {
process.env.PATH = originalPath;
if (originalOverride === undefined) {
delete process.env.POSTHOG_AGENT_NODE_PATH;
} else {
process.env.POSTHOG_AGENT_NODE_PATH = originalOverride;
}
if (originalNodeBinary === undefined) {
delete process.env.NODE_BINARY;
} else {
process.env.NODE_BINARY = originalNodeBinary;
}
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -304,6 +339,37 @@ describe("AgentService", () => {
});
});

describe("node runtime resolution", () => {
it("uses env override for node and prepends its bin dir to PATH", async () => {
process.env.POSTHOG_AGENT_NODE_PATH = "/custom/node/bin/node";

await service.startSession({
...baseSessionParams,
adapter: "claude",
});

expect(mockExecFileSync).not.toHaveBeenCalled();
expect(process.env.PATH?.startsWith("/custom/node/bin:")).toBe(true);
});

it("prefers bundled node runtime when packaged", async () => {
mockApp.isPackaged = true;
mockExistsSync.mockImplementation(
(...args: unknown[]) => args[0] === "/mock/appPath.unpacked/bin/node",
);

await service.startSession({
...baseSessionParams,
adapter: "claude",
});

expect(mockExecFileSync).not.toHaveBeenCalled();
expect(process.env.PATH?.startsWith("/mock/appPath.unpacked/bin:")).toBe(
true,
);
});
});

describe("idle timeout", () => {
function injectSession(
svc: AgentService,
Expand Down
94 changes: 54 additions & 40 deletions apps/code/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import fs, { mkdirSync, symlinkSync } from "node:fs";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
import {
delimiter,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from "node:path";
import {
type Client,
ClientSideConnection,
Expand Down Expand Up @@ -53,13 +62,6 @@ export type { InterruptReason };

const log = logger.scope("agent-service");

const MOCK_NODE_DIR_PREFIX = "agent-node";

function getMockNodeDir(): string {
const suffix = isDevBuild() ? "dev" : "prod";
return join(tmpdir(), `${MOCK_NODE_DIR_PREFIX}-${suffix}`);
}

/** Mark all content blocks as hidden so the renderer doesn't show a duplicate user message on retry */
type MessageCallback = (message: unknown) => void;

Expand Down Expand Up @@ -239,6 +241,43 @@ function getCodexBinaryPath(): string {
: join(appPath, ".vite/build/codex-acp/codex-acp");
}

function resolveNodeExecutablePath(): string {
const envOverride =
process.env.POSTHOG_AGENT_NODE_PATH || process.env.NODE_BINARY;
if (envOverride) {
return envOverride;
}

if (app.isPackaged) {
const extension = process.platform === "win32" ? ".exe" : "";
const bundledNodePath = join(
`${app.getAppPath()}.unpacked`,
"bin",
`node${extension}`,
);
if (fs.existsSync(bundledNodePath)) {
return bundledNodePath;
}

log.warn("Bundled node runtime not found in packaged app", {
bundledNodePath,
});
}

try {
const locator = process.platform === "win32" ? "where" : "which";
return execFileSync(locator, ["node"], {
encoding: "utf8",
env: process.env,
}).trim();
} catch (err) {
log.warn("Failed to resolve node executable from PATH", {
error: err instanceof Error ? err.message : String(err),
});
return "node";
}
}

interface PendingPermission {
resolve: (response: RequestPermissionResponse) => void;
reject: (error: Error) => void;
Expand All @@ -253,7 +292,6 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
private sessions = new Map<string, ManagedSession>();
private currentToken: string | null = null;
private pendingPermissions = new Map<string, PendingPermission>();
private mockNodeReady = false;
private idleTimeouts = new Map<
string,
{ handle: ReturnType<typeof setTimeout>; deadline: number }
Expand Down Expand Up @@ -647,9 +685,9 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
}

const channel = `agent-event:${taskRunId}`;
const mockNodeDir = this.setupMockNodeEnvironment();
const nodeExecutablePath = resolveNodeExecutablePath();
const proxyUrl = await this.ensureAuthProxy(credentials);
this.setupEnvironment(credentials, mockNodeDir, proxyUrl);
this.setupEnvironment(credentials, proxyUrl, nodeExecutablePath);

const isPreview = taskId === "__preview__";

Expand Down Expand Up @@ -1170,13 +1208,14 @@ For git operations while detached:

private setupEnvironment(
credentials: Credentials,
mockNodeDir: string,
proxyUrl: string,
nodeExecutablePath: string,
): void {
const token = this.getToken(credentials.apiKey);
const currentPath = process.env.PATH || "";
if (!currentPath.split(delimiter).includes(mockNodeDir)) {
process.env.PATH = `${mockNodeDir}${delimiter}${currentPath}`;
const nodeBinDir = dirname(nodeExecutablePath);
if (!currentPath.split(delimiter).includes(nodeBinDir)) {
process.env.PATH = `${nodeBinDir}${delimiter}${currentPath}`;
}
process.env.POSTHOG_AUTH_HEADER = `Bearer ${token}`;
process.env.ANTHROPIC_API_KEY = token;
Expand All @@ -1198,31 +1237,6 @@ For git operations while detached:
process.env.POSTHOG_PROJECT_ID = String(credentials.projectId);
}

private setupMockNodeEnvironment(): string {
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;
}
}
this.mockNodeReady = true;
} catch (err) {
log.warn("Failed to setup mock node environment", err);
}
}
return mockNodeDir;
}

private cancelInFlightMcpToolCalls(session: ManagedSession): void {
for (const [toolCallId, toolKey] of session.inFlightMcpToolCalls) {
this.mcpAppsService.notifyToolCancelled(toolKey, toolCallId);
Expand Down
10 changes: 2 additions & 8 deletions apps/code/src/main/services/notification/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { inject, injectable, postConstruct } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
import { getMainWindow } from "../../trpc/context";
import { logger } from "../../utils/logger";
import { focusMainWindow } from "../../window";
import { TaskLinkEvent, type TaskLinkService } from "../task-link/service";

const log = logger.scope("notification");
Expand Down Expand Up @@ -31,14 +32,7 @@ export class NotificationService {
const notification = new Notification({ title, body, silent });

notification.on("click", () => {
log.info("Notification clicked, focusing window", { title, taskId });
const mainWindow = getMainWindow();
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.focus();
}
focusMainWindow("notification click");

if (taskId) {
this.taskLinkService.emit(TaskLinkEvent.OpenTask, { taskId });
Expand Down
12 changes: 2 additions & 10 deletions apps/code/src/main/services/task-link/service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
import { getMainWindow } from "../../trpc/context";
import { logger } from "../../utils/logger";
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
import { focusMainWindow } from "../../window";
import type { DeepLinkService } from "../deep-link/service";

const log = logger.scope("task-link-service");
Expand Down Expand Up @@ -69,15 +69,7 @@ export class TaskLinkService extends TypedEventEmitter<TaskLinkEvents> {
this.pendingDeepLink = { taskId, taskRunId };
}

// Focus the window
log.info("Deep link focusing window", { taskId, taskRunId });
const mainWindow = getMainWindow();
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.focus();
}
focusMainWindow("task deep link");

return true;
}
Expand Down
1 change: 0 additions & 1 deletion packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ function buildMcpServers(
function buildEnvironment(): Record<string, string> {
return {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: "true",
// Offload all MCP tools by default
ENABLE_TOOL_SEARCH: "auto:0",
Expand Down
Loading