Skip to content
Draft
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
2 changes: 2 additions & 0 deletions apps/code/electron-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const config: Configuration = {
".vite/build/grammars/**",
".vite/build/rpc-host.js",
".vite/build/rpc-host.js.map",
// Spawned by Codex (an external binary that can't read inside asar).
".vite/build/adapters/**",
...asarUnpackGlobs,
],

Expand Down
2 changes: 2 additions & 0 deletions apps/code/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
copyCodexAcpBinaries,
copyDrizzleMigrations,
copyEnricherGrammars,
copyLocalToolsMcpServer,
copyPiRpcHost,
copyPosthogPlugin,
fixFilenameCircularRef,
Expand Down Expand Up @@ -95,6 +96,7 @@ export default defineConfig(({ mode }) => {
fixFilenameCircularRef(),
copyClaudeExecutable(),
copyPiRpcHost(),
copyLocalToolsMcpServer(),
copyPosthogPlugin(isDev),
copyDrizzleMigrations(),
copyCodexAcpBinaries(),
Expand Down
25 changes: 25 additions & 0 deletions apps/code/vite-main-plugins.mts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,31 @@ export function copyPiRpcHost(): Plugin {
};
}

export function copyLocalToolsMcpServer(): Plugin {
return {
name: "copy-local-tools-mcp-server",
writeBundle() {
const rel = "adapters/codex-app-server/local-tools-mcp-server.js";
const candidates = [
join(__dirname, "node_modules/@posthog/agent/dist", rel),
join(__dirname, "../../node_modules/@posthog/agent/dist", rel),
join(__dirname, "../../packages/agent/dist", rel),
];
const source = candidates.find((candidate) => existsSync(candidate));
if (!source) {
throw new Error(
`[copy-local-tools-mcp-server] Unable to find the Codex local-tools MCP server, spawned at runtime via resolveBundledMcpScript. Build @posthog/agent first. Checked:\n ${candidates.join("\n ")}`,
);
}
// Keep the dist-relative layout: resolveBundledMcpScript resolves this
// exact relative path against the running bundle's directory.
const dest = join(__dirname, ".vite/build", rel);
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(source, dest);
},
};
}

export function copyClaudeExecutable(): Plugin {
return {
name: "copy-claude-executable",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import type {
} from "@anthropic-ai/claude-agent-sdk";
import { text } from "../../../utils/acp-content";
import type { Logger } from "../../../utils/logger";
import { qualifiedLocalToolName } from "../../local-tools";
import { SPEAK_TOOL_NAME } from "../../local-tools/tools/speak";
import { AUTO_APPROVED_LOCAL_TOOL_IDS } from "../../local-tools";
import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp";
import {
getMcpToolApprovalState,
Expand Down Expand Up @@ -40,8 +39,6 @@ import {
isPostHogExecTool,
} from "./posthog-exec-gate";

const SPEAK_TOOL_ID = qualifiedLocalToolName(SPEAK_TOOL_NAME);

export type ToolPermissionResult =
| {
behavior: "allow";
Expand Down Expand Up @@ -761,10 +758,10 @@ export async function canUseTool(
return { behavior: "deny", message, interrupt: false };
}

// Narration is a fire-and-forget no-op on the agent side; a permission
// prompt for it interrupts the user to approve a line they may never hear.
// An explicit do_not_use block above still wins.
if (toolName === SPEAK_TOOL_ID) {
// Auto-approve tools flagged `autoApprove` — read-only or fire-and-forget
// (canvas checkout, speak narration) where a permission prompt is pure
// friction. An explicit do_not_use block above still wins.
if (AUTO_APPROVED_LOCAL_TOOL_IDS.has(toolName)) {
return {
behavior: "allow",
updatedInput: toolInput as Record<string, unknown>,
Expand Down
21 changes: 20 additions & 1 deletion packages/agent/src/adapters/local-tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry";
import {
type LocalTool,
type LocalToolCtx,
type LocalToolGateMeta,
qualifiedLocalToolName,
} from "./registry";
import { canvasCheckoutTool, canvasPublishTool } from "./tools/canvas";
import { cloneRepoTool } from "./tools/clone-repo";
import { listReposTool } from "./tools/list-repos";
import { signedCommitTool } from "./tools/signed-commit";
Expand All @@ -23,8 +29,21 @@ export const LOCAL_TOOLS: LocalTool[] = [
listReposTool,
cloneRepoTool,
speakTool,
canvasCheckoutTool,
canvasPublishTool,
];

/**
* Qualified ids of local tools flagged `autoApprove` — the Claude permission
* handler allows these without a prompt. Codex does not prompt for MCP tool
* calls, so it does not consult this.
*/
export const AUTO_APPROVED_LOCAL_TOOL_IDS: ReadonlySet<string> = new Set(
LOCAL_TOOLS.filter((t) => t.autoApprove).map((t) =>
qualifiedLocalToolName(t.name),
),
);

/** Tools whose gate passes for the given context — the set to actually expose. */
export function enabledLocalTools(
ctx: LocalToolCtx,
Expand Down
9 changes: 9 additions & 0 deletions packages/agent/src/adapters/local-tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export interface LocalToolDef<S extends z.ZodRawShape> {
* by default in the Claude adapter (ENABLE_TOOL_SEARCH). Ignored by Codex.
*/
alwaysLoad?: boolean;
/**
* Auto-approve this tool's calls without a permission prompt — for read-only
* or fire-and-forget tools where prompting is pure friction (an explicit
* do_not_use block still wins). Honored by the Claude permission handler;
* Codex does not prompt for MCP tool calls, so it is a no-op there.
*/
autoApprove?: boolean;
isEnabled(ctx: LocalToolCtx, meta: LocalToolGateMeta | undefined): boolean;
handler(
ctx: LocalToolCtx,
Expand All @@ -66,6 +73,8 @@ export interface LocalTool {
description: string;
schema: z.ZodRawShape;
alwaysLoad?: boolean;
/** See {@link LocalToolDef.autoApprove}. */
autoApprove?: boolean;
isEnabled(ctx: LocalToolCtx, meta: LocalToolGateMeta | undefined): boolean;
handler(
ctx: LocalToolCtx,
Expand Down
183 changes: 183 additions & 0 deletions packages/agent/src/adapters/local-tools/tools/canvas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AUTO_APPROVED_LOCAL_TOOL_IDS } from "../index";
import {
canvasCheckoutTool,
canvasScratchDir,
canvasScratchFile,
} from "./canvas";

// Version composition and the stale-base rejection live server-side in the
// desktop-fs canvas action (and are tested there); the tool's own logic is
// the scratch-file plumbing and create-time channel placement.
describe("canvas scratch paths", () => {
it("keys the scratch dir and file by canvas id, outside any workspace", () => {
expect(canvasScratchDir("dash-1")).toBe("/tmp/posthog-canvas/dash-1");
expect(canvasScratchFile("dash-1")).toBe(
"/tmp/posthog-canvas/dash-1/canvas.tsx",
);
});
});

describe("canvas tool permissions", () => {
it("auto-approves canvas_checkout (read-only) but not canvas_publish (write)", () => {
expect(
AUTO_APPROVED_LOCAL_TOOL_IDS.has(
"mcp__posthog-code-tools__canvas_checkout",
),
).toBe(true);
expect(
AUTO_APPROVED_LOCAL_TOOL_IDS.has(
"mcp__posthog-code-tools__canvas_publish",
),
).toBe(false);
});
});

// Create-if-missing placement: the channel is resolved tool-side from the
// task's desktop-fs filing row (`type=task&ref=<taskId>`), never from a
// model-relayed channel name. Requests are matched on their query string, so
// the credential source (env vs a live /tmp/agent-env) doesn't matter.
describe("canvas_checkout create-if-missing placement", () => {
const ctx = { cwd: "/tmp", taskId: "task-1" };
let requests: { url: string; method: string; body?: unknown }[];
let routes: ((url: string, method: string) => unknown | undefined)[];

beforeEach(() => {
vi.stubEnv("POSTHOG_API_URL", "http://posthog.test");
vi.stubEnv("POSTHOG_PERSONAL_API_KEY", "phx_test");
vi.stubEnv("POSTHOG_PROJECT_ID", "1");
requests = [];
routes = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const method = init?.method ?? "GET";
const body = init?.body ? JSON.parse(init.body as string) : undefined;
requests.push({ url, method, body });
for (const route of routes) {
const result = route(url, method);
if (result !== undefined) {
return new Response(JSON.stringify(result), { status: 200 });
}
}
return new Response("{}", { status: 404 });
}),
);
});

afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

it("places the canvas in the task's channel folder with the app's meta shape", async () => {
routes.push((url, method) => {
if (url.includes("type=task&ref=task-1")) {
return {
results: [
{ id: "fs-home", path: "Unfiled/Tasks/My task", type: "task" },
{ id: "fs-filed", path: "demo-channel/My task", type: "task" },
],
};
}
if (
url.includes(`type=folder&path=${encodeURIComponent("demo-channel")}`)
) {
return {
results: [{ id: "folder-1", path: "demo-channel", type: "folder" }],
};
}
if (method === "POST" && url.endsWith("/desktop_file_system/")) {
return {
id: "canvas-1",
path: "demo-channel/My Canvas",
type: "dashboard",
meta: {},
};
}
return undefined;
});

const result = await canvasCheckoutTool.handler(ctx, {
name: "My Canvas",
});

expect(result.isError).toBeUndefined();
const create = requests.find((r) => r.method === "POST");
expect(create?.body).toMatchObject({
path: "demo-channel/My Canvas",
type: "dashboard",
meta: { channelId: "folder-1", templateId: "freeform" },
});
});

it("refuses (naming existing channels) when the task isn't filed in one", async () => {
routes.push((url) => {
if (url.includes("type=task&ref=task-1")) return { results: [] };
if (url.includes("type=folder&depth=1")) {
return { results: [{ id: "f1", path: "demo-channel" }] };
}
return undefined;
});

const result = await canvasCheckoutTool.handler(ctx, { name: "Orphan" });

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('"demo-channel"');
expect(requests.some((r) => r.method === "POST")).toBe(false);
});

it("refuses a parentPath that matches no existing folder instead of minting one", async () => {
routes.push((url) => {
if (
url.includes(`type=folder&path=${encodeURIComponent("Demo Channel")}`)
) {
return { results: [] };
}
if (url.includes("type=folder&depth=1")) {
return { results: [{ id: "f1", path: "demo-channel" }] };
}
return undefined;
});

const result = await canvasCheckoutTool.handler(ctx, {
name: "My Canvas",
parentPath: "Demo Channel",
});

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Demo Channel");
expect(requests.some((r) => r.method === "POST")).toBe(false);
});

it("honors an explicit parentPath that resolves to a real folder", async () => {
routes.push((url, method) => {
if (url.includes(`type=folder&path=${encodeURIComponent("other")}`)) {
return { results: [{ id: "folder-2", path: "other", type: "folder" }] };
}
if (method === "POST" && url.endsWith("/desktop_file_system/")) {
return {
id: "canvas-2",
path: "other/Board",
type: "dashboard",
meta: {},
};
}
return undefined;
});

const result = await canvasCheckoutTool.handler(ctx, {
name: "Board",
parentPath: "other",
});

expect(result.isError).toBeUndefined();
const create = requests.find((r) => r.method === "POST");
expect(create?.body).toMatchObject({
path: "other/Board",
meta: { channelId: "folder-2" },
});
// The task-filing lookup is skipped entirely on an explicit override.
expect(requests.some((r) => r.url.includes("type=task"))).toBe(false);
});
});
Loading
Loading