Skip to content

Commit 98441cb

Browse files
feat(agent): give cloud tasks isolated computer use
Generated-By: PostHog Code Task-Id: c6d78416-85c8-4304-bd69-e309562468c4
1 parent 7ebf612 commit 98441cb

28 files changed

Lines changed: 326 additions & 332 deletions

docs/COMPUTER-USE.md

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,37 @@
22

33
# Computer use
44

5-
PostHog Code can give local agent sessions and cloud tasks tools to see and control the macOS desktop across supported agent adapters.
5+
PostHog Code can give local agent sessions tools to control the Mac and cloud tasks tools to control an isolated Linux desktop in their sandbox.
66

77
## Enable it
88

99
1. Open **Settings → Advanced**.
1010
2. Enable **Computer use**.
1111
3. Start a new local session or cloud task.
12-
4. Grant Screen Recording and Accessibility access when macOS requests it.
12+
4. For local sessions, grant Screen Recording and Accessibility access when macOS requests it.
1313

1414
The setting applies when a session or cloud task starts. Existing runs are unchanged.
1515

1616
## Behavior
1717

1818
- Computer use is opt-in and disabled by default.
19-
- It is available to local sessions on macOS and cloud tasks created from the desktop app.
19+
- Local sessions control the user's Mac. Cloud tasks control a virtual Linux desktop inside their own sandbox.
2020
- Agents can capture the desktop, list visible applications, open or focus applications, click coordinates, type text, and press keys.
2121
- Claude, Codex, and future adapters receive the same PostHog-owned MCP tools rather than adapter-specific computer-control implementations.
22-
- Cloud tasks relay computer actions back to the desktop app, which must remain open and connected for the run to keep using them.
2322
- Unsupported operating systems do not receive the tools.
2423
- Tool calls use the existing MCP tool-call and approval pipeline.
2524

2625
## Safety
2726

2827
- Review the screen before approving an action and verify the result after it runs.
2928
- Do not ask an agent to enter passwords, tokens, recovery codes, or other secrets.
30-
- Cloud computer actions require approval on the connected desktop unless the action was allowed for the rest of that run.
31-
- Disable the setting before starting a new session or cloud task to omit the tools. Closing the desktop app disconnects computer use from active cloud tasks.
29+
- Disable the setting before starting a new session or cloud task to omit the tools.
3230
- Revoke access in **System Settings → Privacy & Security → Screen Recording** or **Accessibility** to prevent native control.
3331

3432
## Scope
3533

36-
The implementation uses macOS system utilities for screenshots, application launching, mouse input, and keyboard input. Cloud workers do not access the computer directly: PostHog Code designates a built-in MCP server for the cloud run, relays each request through the existing task command channel, and executes approved actions on the connected desktop. Relay designations are held in memory, so restarting the app disconnects active runs rather than silently reconnecting them.
34+
Local sessions use macOS system utilities for screenshots, application launching, mouse input, and keyboard input. Cloud tasks start a virtual X display and lightweight window manager in the sandbox, then use Linux desktop utilities for the same actions. The cloud desktop contains only the task sandbox and cannot access the user's computer.
3735

3836
## Implementation
3937

40-
Computer actions are registered in the shared local-tools MCP registry. The Claude adapter exposes that registry through its in-process MCP server, while the Codex adapter and cloud relay expose the same registry through the packaged stdio MCP server. Local session metadata and cloud run relay designations gate the tools by operating system and the user's opt-in setting.
38+
Computer actions are registered in the shared local-tools MCP registry. The Claude adapter exposes that registry through its in-process MCP server, while the Codex adapter exposes the same registry through the packaged stdio MCP server. Session metadata gates the tools by environment, operating system, and the user's opt-in setting.

docs/cloud-mcp-relay.md

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
<!-- markdownlint-disable MD013 -->
2-
31
# Design: relaying local MCP servers into cloud task runs
42

53
Status: **implemented** (same PR as the import work), behind the same
@@ -18,14 +16,6 @@ them, and relayed servers stop working for in-flight runs (they 503 after the
1816
liveness window) rather than surviving a handoff. Tool allowlisting for
1917
relayed servers persists for the session lifetime, not to the backend.
2018

21-
The built-in `posthog-code-computer-use` designation resolves to the packaged
22-
PostHog local-tools MCP server instead of `~/.claude.json`. It is added to new
23-
cloud runs when the desktop **Computer use** setting is enabled and exposes
24-
only the macOS computer tools. The same relay approval policy applies, so tool
25-
calls execute only while the creating desktop remains connected. Runs with
26-
imported or relayed MCP servers skip pre-warmed sandbox reuse because the
27-
server list is not known when the warm run starts.
28-
2919
## Problem
3020

3121
Two classes of local MCP servers cannot be imported into a cloud sandbox by
@@ -63,7 +53,7 @@ The MCP relay mirrors this with a new event/command pair.
6353

6454
## Architecture
6555

66-
```text
56+
```
6757
sandbox desktop
6858
┌──────────────────────────────┐ ┌──────────────────────────────┐
6959
│ Claude/Codex adapter │ │ McpRelayService │

packages/agent/package.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,6 @@
7272
"types": "./dist/adapters/claude/mcp/tool-metadata.d.ts",
7373
"import": "./dist/adapters/claude/mcp/tool-metadata.js"
7474
},
75-
"./adapters/local-tools/stdio-server": {
76-
"types": "./dist/adapters/local-tools/stdio-server.d.ts",
77-
"import": "./dist/adapters/local-tools/stdio-server.js"
78-
},
7975
"./execution-mode": {
8076
"types": "./dist/execution-mode.d.ts",
8177
"import": "./dist/execution-mode.js"

packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,20 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
3-
import { buildComputerUseStdioServer } from "../local-tools/stdio-server";
43
import { buildLocalToolsServer } from "./local-tools-mcp";
54

6-
// Isolate bundled-script resolution and the sandbox env file; the test only
7-
// inspects the generated stdio config.
5+
// The dist asset isn't on the walk-up path in unit tests, so make existsSync
6+
// succeed; nothing spawns the script — we only inspect the path.
87
vi.mock("node:fs", async (importActual) => {
98
const actual = await importActual<typeof import("node:fs")>();
10-
return {
11-
...actual,
12-
existsSync: vi.fn().mockReturnValue(true),
13-
readFileSync: vi.fn(() => {
14-
throw new Error("sandbox env file unavailable");
15-
}),
16-
};
9+
return { ...actual, existsSync: vi.fn().mockReturnValue(true) };
1710
});
1811

1912
describe("buildLocalToolsServer", () => {
20-
it("builds the computer-only server for a macOS desktop relay", () => {
21-
const server = buildComputerUseStdioServer("/repo", "darwin");
22-
23-
expect(server?.env.POSTHOG_LOCAL_TOOLS_ENABLED).toBe(
24-
[
25-
"computer_screenshot",
26-
"computer_list_applications",
27-
"computer_open_application",
28-
"computer_click",
29-
"computer_type",
30-
"computer_key",
31-
].join(","),
32-
);
33-
});
34-
3513
const saved = {
3614
sandbox: process.env.IS_SANDBOX,
3715
ghToken: process.env.GH_TOKEN,
3816
githubToken: process.env.GITHUB_TOKEN,
17+
display: process.env.DISPLAY,
3918
};
4019

4120
beforeEach(() => {
@@ -44,12 +23,14 @@ describe("buildLocalToolsServer", () => {
4423
delete process.env.IS_SANDBOX;
4524
delete process.env.GH_TOKEN;
4625
delete process.env.GITHUB_TOKEN;
26+
delete process.env.DISPLAY;
4727
});
4828

4929
afterEach(() => {
5030
restore("IS_SANDBOX", saved.sandbox);
5131
restore("GH_TOKEN", saved.ghToken);
5232
restore("GITHUB_TOKEN", saved.githubToken);
33+
restore("DISPLAY", saved.display);
5334
});
5435

5536
function restore(key: string, value: string | undefined): void {
@@ -174,6 +155,20 @@ describe("buildLocalToolsServer", () => {
174155
);
175156
});
176157

158+
it("exposes computer tools for opted-in cloud Linux sessions", () => {
159+
process.env.DISPLAY = ":99";
160+
const server = buildLocalToolsServer(
161+
{ cwd: "/repo", platform: "linux" },
162+
{ environment: "cloud", computerUse: true },
163+
);
164+
165+
const enabled =
166+
server?.env.find((entry) => entry.name === "POSTHOG_LOCAL_TOOLS_ENABLED")
167+
?.value ?? "";
168+
expect(enabled.split(",")).toContain("computer_screenshot");
169+
expect(server?.env).toContainEqual({ name: "DISPLAY", value: ":99" });
170+
});
171+
177172
it.each([
178173
{
179174
platform: "linux" as const,

packages/agent/src/adapters/codex-app-server/local-tools-mcp.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
*/
77

88
import type { McpServerStdio } from "@agentclientprotocol/sdk";
9+
import { ghTokenEnv } from "@posthog/git/signed-commit";
910
import { resolveGithubToken } from "../../utils/github-token";
11+
import { resolveBundledMcpScript } from "../../utils/resolve-bundled-script";
1012
import {
1113
enabledLocalTools,
14+
LOCAL_TOOLS_MCP_NAME,
1215
type LocalToolCtx,
1316
type LocalToolGateMeta,
1417
} from "../local-tools";
15-
import { buildLocalToolsStdioServer } from "../local-tools/stdio-server";
1618
import { resolveTaskId } from "../session-meta";
1719

1820
/**
@@ -27,6 +29,42 @@ export interface LocalToolsMeta extends LocalToolGateMeta {
2729
baseBranch?: string;
2830
}
2931

32+
function toMcpServerStdio(
33+
ctx: LocalToolCtx,
34+
enabledNames: string[],
35+
): McpServerStdio {
36+
const scriptPath = resolveBundledMcpScript(
37+
"adapters/codex-app-server/local-tools-mcp-server.js",
38+
);
39+
const ctxBase64 = Buffer.from(JSON.stringify(ctx)).toString("base64");
40+
const env = [
41+
{ name: "POSTHOG_LOCAL_TOOLS_CTX", value: ctxBase64 },
42+
{ name: "POSTHOG_LOCAL_TOOLS_ENABLED", value: enabledNames.join(",") },
43+
// Codex spawns this command with ELECTRON_RUN_AS_NODE removed from its own
44+
// env (spawn.ts). In packaged desktop installs process.execPath is the app
45+
// binary, which boots the full app without this var. Inert on real node.
46+
{ name: "ELECTRON_RUN_AS_NODE", value: "1" },
47+
];
48+
if (process.env.DISPLAY) {
49+
env.push({ name: "DISPLAY", value: process.env.DISPLAY });
50+
}
51+
if (ctx.token) {
52+
// Token also on the child env so its own git remote ops authenticate.
53+
env.push(
54+
...Object.entries(ghTokenEnv(ctx.token)).map(([name, value]) => ({
55+
name,
56+
value,
57+
})),
58+
);
59+
}
60+
return {
61+
name: LOCAL_TOOLS_MCP_NAME,
62+
command: process.execPath,
63+
args: [scriptPath],
64+
env,
65+
};
66+
}
67+
3068
/**
3169
* Returns the local-tools stdio server config to inject, or null when no tool's
3270
* gate passes (e.g. local/desktop run with no GH token). Tools self-gate via the
@@ -52,12 +90,8 @@ export function buildLocalToolsServer(
5290
if (tools.length === 0) {
5391
return null;
5492
}
55-
const server = buildLocalToolsStdioServer(
93+
return toMcpServerStdio(
5694
toolCtx,
5795
tools.map((t) => t.name),
5896
);
59-
return {
60-
...server,
61-
env: Object.entries(server.env).map(([name, value]) => ({ name, value })),
62-
};
6397
}

packages/agent/src/adapters/local-tools/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface LocalToolGateMeta {
3131
channelMode?: boolean;
3232
/** Spoken narration is on for this session: enables the speak tool. */
3333
spokenNarration?: boolean;
34-
/** Desktop computer control is enabled for this local session. */
34+
/** Computer control is enabled for this session. */
3535
computerUse?: boolean;
3636
}
3737

packages/agent/src/adapters/local-tools/stdio-server.ts

Lines changed: 0 additions & 57 deletions
This file was deleted.

packages/agent/src/adapters/local-tools/tools/computer-use.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execFile } from "node:child_process";
1+
import { execFile, spawn } from "node:child_process";
22
import { readFile, unlink } from "node:fs/promises";
33
import { beforeEach, describe, expect, it, vi } from "vitest";
44
import {
@@ -10,18 +10,21 @@ import {
1010
computerUseTools,
1111
} from "./computer-use";
1212

13-
vi.mock("node:child_process", () => ({ execFile: vi.fn() }));
13+
vi.mock("node:child_process", () => ({ execFile: vi.fn(), spawn: vi.fn() }));
1414
vi.mock("node:fs/promises", () => ({
1515
readFile: vi.fn(),
1616
unlink: vi.fn().mockResolvedValue(undefined),
1717
}));
1818

1919
const mockedExecFile = vi.mocked(execFile);
20+
const mockedSpawn = vi.mocked(spawn);
2021
const mockedReadFile = vi.mocked(readFile);
2122
const mockedUnlink = vi.mocked(unlink);
2223

2324
const context = { cwd: "/repo", platform: "darwin" as const };
2425
const enabledMeta = { environment: "local" as const, computerUse: true };
26+
const cloudContext = { cwd: "/repo", platform: "linux" as const };
27+
const cloudMeta = { environment: "cloud" as const, computerUse: true };
2528

2629
describe("computer use tools", () => {
2730
beforeEach(() => {
@@ -33,6 +36,7 @@ describe("computer use tools", () => {
3336
}
3437
return undefined;
3538
}) as unknown as typeof execFile);
39+
mockedSpawn.mockReturnValue({ unref: vi.fn() } as never);
3640
mockedReadFile.mockResolvedValue(Buffer.from("png"));
3741
mockedUnlink.mockResolvedValue(undefined);
3842
});
@@ -71,6 +75,12 @@ describe("computer use tools", () => {
7175
expect(computerScreenshotTool.isEnabled(context, enabledMeta)).toBe(true);
7276
});
7377

78+
it("is enabled for opted-in cloud Linux sessions", () => {
79+
expect(computerScreenshotTool.isEnabled(cloudContext, cloudMeta)).toBe(
80+
true,
81+
);
82+
});
83+
7484
it("returns a screenshot image and removes the temporary file", async () => {
7585
const result = await computerScreenshotTool.handler(context, {});
7686

@@ -130,6 +140,33 @@ describe("computer use tools", () => {
130140
);
131141
});
132142

143+
it("opens the cloud browser without invoking a shell", async () => {
144+
const unref = vi.fn();
145+
mockedSpawn.mockReturnValueOnce({ unref } as never);
146+
147+
await computerOpenApplicationTool.handler(cloudContext, {
148+
application: "Browser",
149+
});
150+
151+
expect(mockedSpawn).toHaveBeenCalledWith(
152+
"/usr/bin/epiphany",
153+
["--new-window"],
154+
{ detached: true, stdio: "ignore" },
155+
);
156+
expect(unref).toHaveBeenCalledOnce();
157+
});
158+
159+
it("uses Linux desktop utilities in cloud sessions", async () => {
160+
await computerClickTool.handler(cloudContext, { x: 100, y: 200 });
161+
162+
expect(mockedExecFile).toHaveBeenCalledWith(
163+
"/usr/bin/xdotool",
164+
["mousemove", "100", "200", "click", "1"],
165+
{ encoding: "utf8" },
166+
expect.any(Function),
167+
);
168+
});
169+
133170
it.each([
134171
[computerClickTool, { x: 100, y: 200 }],
135172
[computerTypeTool, { text: "hello" }],

0 commit comments

Comments
 (0)