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
1 change: 1 addition & 0 deletions apps/code/electron-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const config: Configuration = {
".vite/build/grammars/**",
".vite/build/rpc-host.js",
".vite/build/rpc-host.js.map",
".vite/build/mcp-server.js",
...asarUnpackGlobs,
],

Expand Down
4 changes: 0 additions & 4 deletions apps/code/src/main/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,7 @@ import type {
} from "@posthog/workspace-server/services/mcp-relay/identifiers";
import type {
PI_RPC_CLIENT_FACTORY,
PI_RUNTIME_FACTORY,
PiRpcClientFactory,
PiRuntimeFactory,
} from "@posthog/workspace-server/services/pi-session/identifiers";
import type { PosthogPluginService } from "@posthog/workspace-server/services/posthog-plugin/posthog-plugin";
import type { ProcessTrackingService } from "@posthog/workspace-server/services/process-tracking/process-tracking";
Expand Down Expand Up @@ -358,8 +356,6 @@ export interface MainBindings {
[AGENT_LOGGER]: RootLogger;
[PI_RPC_CLIENT_FACTORY]: PiRpcClientFactory;

[PI_RUNTIME_FACTORY]: PiRuntimeFactory;

// Logger
[ROOT_LOGGER]: RootLogger;

Expand Down
3 changes: 0 additions & 3 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,6 @@ import { onboardingImportModule } from "@posthog/workspace-server/services/onboa
import { osModule } from "@posthog/workspace-server/services/os/os.module";
import {
PI_RPC_CLIENT_FACTORY,
PI_RUNTIME_FACTORY,
PI_SESSION_SERVICE,
} from "@posthog/workspace-server/services/pi-session/identifiers";
import type { PiSessionService } from "@posthog/workspace-server/services/pi-session/pi-session";
Expand Down Expand Up @@ -231,7 +230,6 @@ import { workspaceMetadataModule } from "@posthog/workspace-server/services/work
import ExternalAppsStoreImpl from "electron-store";
import type { FileWatcherBridge } from "../index";
import { DesktopPiRpcClientFactory } from "../platform-adapters/desktop-pi-rpc-client-factory";
import { DesktopPiRuntimeFactory } from "../platform-adapters/desktop-pi-runtime-factory";
import { ElectronAppLifecycle } from "../platform-adapters/electron-app-lifecycle";
import { ElectronAppMeta } from "../platform-adapters/electron-app-meta";
import { ElectronAppMetrics } from "../platform-adapters/electron-app-metrics";
Expand Down Expand Up @@ -379,7 +377,6 @@ container
.bind(MAIN_DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY)
.toService(DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY);
container.load(agentModule);
container.bind(PI_RUNTIME_FACTORY).to(DesktopPiRuntimeFactory);
container.load(piSessionModule);
container.bind(AGENT_SLEEP_COORDINATOR).toService(MAIN_SLEEP_SERVICE);
container.bind(AGENT_MCP_APPS).toService(MCP_APPS_SERVICE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe("DesktopPiRpcClientFactory", () => {
);
expect(createPiRpcClient).toHaveBeenCalledWith({
cwd: "/workspace",
capabilities: { environment: "local" },
providerOptions: {
region: "eu",
baseUrl: "http://127.0.0.1:1234",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class DesktopPiRpcClientFactory implements PiRpcClientFactory {

return createPiRpcClient({
...input,
capabilities: { environment: "local" },
providerOptions: {
region: credentials.region,
baseUrl,
Expand Down

This file was deleted.

24 changes: 0 additions & 24 deletions apps/code/src/main/platform-adapters/desktop-pi-runtime-factory.ts

This file was deleted.

41 changes: 28 additions & 13 deletions apps/code/vite-main-plugins.mts
Original file line number Diff line number Diff line change
Expand Up @@ -167,21 +167,36 @@ export function copyPiRpcHost(): Plugin {
return {
name: "copy-pi-rpc-host",
writeBundle() {
const candidates = [
join(__dirname, "node_modules/@posthog/agent/dist/pi/rpc-host.js"),
join(
__dirname,
"../../node_modules/@posthog/agent/dist/pi/rpc-host.js",
),
join(__dirname, "../../packages/agent/dist/pi/rpc-host.js"),
const assets = [
{
name: "Pi RPC host",
source: "pi/rpc-host.js",
destination: "rpc-host.js",
},
{
name: "Pi local-tools MCP server",
source: "adapters/local-tools/mcp-server.js",
destination: "mcp-server.js",
Comment on lines +175 to +179

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 MCP child remains inside ASAR

When a packaged desktop Pi session enables a first-party local tool, the app launches .vite/build/mcp-server.js as a standalone Node script, but this new asset is not included in asarUnpack alongside rpc-host.js, causing the MCP child and its tools to fail at startup.

Knowledge Base Used: PostHog Code Desktop App (apps/code)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/vite-main-plugins.mts
Line: 175-179

Comment:
**MCP child remains inside ASAR**

When a packaged desktop Pi session enables a first-party local tool, the app launches `.vite/build/mcp-server.js` as a standalone Node script, but this new asset is not included in `asarUnpack` alongside `rpc-host.js`, causing the MCP child and its tools to fail at startup.

**Knowledge Base Used:** [PostHog Code Desktop App (`apps/code`)](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/code/-/docs/apps-code-desktop.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

},
];
const source = candidates.find((candidate) => existsSync(candidate));
if (!source) {
throw new Error(
`[copy-pi-rpc-host] Unable to find Pi RPC host, required at runtime by createPiRpcClient. Build @posthog/agent first. Checked:\n ${candidates.join("\n ")}`,
);

for (const asset of assets) {
const candidates = [
join(__dirname, `node_modules/@posthog/agent/dist/${asset.source}`),
join(
__dirname,
`../../node_modules/@posthog/agent/dist/${asset.source}`,
),
join(__dirname, `../../packages/agent/dist/${asset.source}`),
];
const source = candidates.find((candidate) => existsSync(candidate));
if (!source) {
throw new Error(
`[copy-pi-rpc-host] Unable to find ${asset.name}. Build @posthog/agent first. Checked:\n ${candidates.join("\n ")}`,
);
}
copyFileSync(source, join(__dirname, ".vite/build", asset.destination));
}
copyFileSync(source, join(__dirname, ".vite/build/rpc-host.js"));
},
};
}
Expand Down
6 changes: 3 additions & 3 deletions packages/agent/build/verify-local-tools-mcp-server.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Post-build smoke check: boot the bundled local-tools MCP server exactly the
// way Codex spawns it in a cloud sandbox (a bare `node dist/...js` process) and
// way an agent runtime spawns it in a cloud sandbox (a bare `node dist/...js` process) and
// assert it answers `tools/list`. Catches bundling regressions — e.g. inlined
// CJS deps whose dynamic `require()` throws in ESM output — that unit tests
// running from src can never see, and that otherwise fail silently in cloud
Expand All @@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";

const script = resolve(
fileURLToPath(new URL(".", import.meta.url)),
"../dist/adapters/codex-app-server/local-tools-mcp-server.js",
"../dist/adapters/local-tools/mcp-server.js",
);

const ctx = Buffer.from(
Expand All @@ -20,7 +20,7 @@ const ctx = Buffer.from(
const child = spawn(process.execPath, [script], {
env: {
...process.env,
// Mirror the production Codex spawn: if this ever runs from an
// Mirror the production child spawn: if this ever runs from an
// Electron-hosted process, execPath is the app binary, not node.
ELECTRON_RUN_AS_NODE: "1",
POSTHOG_LOCAL_TOOLS_CTX: ctx,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ import {
estimateTokens,
} from "../claude/context-breakdown";
import { isLocalSkillCommandChunk } from "../local-skill";
import {
buildLocalToolsServer,
type LocalToolsMeta,
} from "../local-tools/mcp-server-config";
import { resolveSpokenNarration } from "../session-meta";
import {
AppServerClient,
Expand All @@ -67,7 +71,6 @@ import {
buildUsageBreakdownParams,
} from "./ext-notifications";
import { type CodexUserInput, toCodexInput } from "./input";
import { buildLocalToolsServer, type LocalToolsMeta } from "./local-tools-mcp";
import {
type AppServerItem,
changePaths,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
import { buildLocalToolsServer } from "./local-tools-mcp";
import { buildLocalToolsServer } from "./mcp-server-config";

// The dist asset isn't on the walk-up path in unit tests, so make existsSync
// succeed; nothing spawns the script — we only inspect the path.
Expand All @@ -17,8 +17,7 @@ describe("buildLocalToolsServer", () => {
};

beforeEach(() => {
// The signed-git gate reads IS_SANDBOX and the token vars; clear them so each
// case controls the cloud signal (meta.environment) and token explicitly.
// Clear credential state so each case controls the cloud signal explicitly.
delete process.env.IS_SANDBOX;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
Expand Down Expand Up @@ -55,14 +54,13 @@ describe("buildLocalToolsServer", () => {
expect(server?.name).toBe(LOCAL_TOOLS_MCP_NAME);
expect(server?.command).toBe(process.execPath);
expect(server?.args).toHaveLength(1);
expect(server?.args[0]).toMatch(/local-tools-mcp-server\.js$/);
expect(server?.args[0]).toMatch(/mcp-server\.js$/);

const envNames = server?.env.map((e) => e.name) ?? [];
expect(envNames).toContain("POSTHOG_LOCAL_TOOLS_CTX");
expect(envNames).toContain("POSTHOG_LOCAL_TOOLS_ENABLED");
// Token is forwarded to the child so its own git remote ops authenticate.
expect(envNames).toContain("GH_TOKEN");
expect(envNames).toContain("GITHUB_TOKEN");
expect(envNames).not.toContain("GH_TOKEN");
expect(envNames).not.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.
Expand All @@ -78,13 +76,14 @@ describe("buildLocalToolsServer", () => {
Buffer.from(ctxEntry?.value ?? "", "base64").toString("utf-8"),
);
expect(ctx.cwd).toBe("/repo");
expect(ctx.token).toBe("ghs_test");
expect(ctx.token).toBeUndefined();
expect(ctx.taskId).toBe("task-1");
expect(ctx.taskRunId).toBe("run-1");
expect(ctx.baseBranch).toBe("master");
});

it("returns a server but omits token env vars when no token is present", () => {
it("does not serialize a parent GitHub token into the child config", () => {
process.env.GH_TOKEN = "ghs_test";
const server = buildLocalToolsServer(
{ cwd: "/repo" },
{ environment: "cloud" },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
/**
* Builds the stdio local-tools MCP server config to inject into a Codex
* app-server thread's `config.mcp_servers`.
* Returns the ACP `McpServerStdio` shape so the existing translation layer stays
* the single owner of the ACP→Codex map.
* Builds the stdio local-tools MCP server config for agent runtimes.
* Returns the ACP `McpServerStdio` shape used by the Codex adapter and Pi.
*/

import type { McpServerStdio } from "@agentclientprotocol/sdk";
import { ghTokenEnv } from "@posthog/git/signed-commit";
import { resolveGithubToken } from "../../utils/github-token";
import { resolveBundledMcpScript } from "../../utils/resolve-bundled-script";
import { resolveTaskId } from "../session-meta";
import {
enabledLocalTools,
LOCAL_TOOLS_MCP_NAME,
type LocalToolCtx,
type LocalToolGateMeta,
} from "../local-tools";
import { resolveTaskId } from "../session-meta";
} from "./index";

/**
* Gate inputs the local-tools server needs beyond `LocalToolGateMeta`: the task id
Expand All @@ -34,7 +30,7 @@ function toMcpServerStdio(
enabledNames: string[],
): McpServerStdio {
const scriptPath = resolveBundledMcpScript(
"adapters/codex-app-server/local-tools-mcp-server.js",
"adapters/local-tools/mcp-server.js",
);
const ctxBase64 = Buffer.from(JSON.stringify(ctx)).toString("base64");
const env = [
Expand All @@ -45,15 +41,6 @@ function toMcpServerStdio(
// 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.
env.push(
...Object.entries(ghTokenEnv(ctx.token)).map(([name, value]) => ({
name,
value,
})),
);
}
return {
name: LOCAL_TOOLS_MCP_NAME,
command: process.execPath,
Expand All @@ -77,7 +64,6 @@ export function buildLocalToolsServer(
}
const toolCtx: LocalToolCtx = {
cwd,
token: resolveGithubToken(),
taskId: resolveTaskId(meta),
taskRunId: meta?.taskRunId,
baseBranch: meta?.baseBranch,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { afterEach, describe, expect, it } from "vitest";

const directories: string[] = [];

async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "local-tools-mcp-server-"));
directories.push(directory);
return directory;
}

afterEach(async () => {
await Promise.all(
directories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});

describe("local-tools MCP server", () => {
it("starts the bundled server and executes an enabled tool", async () => {
const cwd = await temporaryDirectory();
const client = new Client({ name: "local-tools-test", version: "1.0.0" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [
join(process.cwd(), "../../node_modules/tsx/dist/cli.mjs"),
join(process.cwd(), "src/adapters/local-tools/mcp-server.ts"),
],
cwd,
env: {
PATH: process.env.PATH ?? "",
POSTHOG_LOCAL_TOOLS_CTX: Buffer.from(
JSON.stringify({ cwd, taskId: "task-1", taskRunId: "run-1" }),
).toString("base64"),
POSTHOG_LOCAL_TOOLS_ENABLED: "speak",
},
});

try {
await client.connect(transport);
const tools = await client.listTools();
const result = await client.callTool({
name: "speak",
arguments: { text: "Completed the task", kind: "done" },
});

expect(tools.tools.map((tool) => tool.name)).toEqual(["speak"]);
expect(result.content).toEqual([{ type: "text", text: "ok" }]);
} finally {
await client.close();
}
});
});
Loading
Loading