Skip to content

Commit 54efcf0

Browse files
feat(agent): relay computer use to cloud tasks
Generated-By: PostHog Code Task-Id: c424306e-3221-4201-bbce-83723deb6a13
1 parent 42c0c0c commit 54efcf0

18 files changed

Lines changed: 252 additions & 61 deletions

File tree

docs/COMPUTER-USE.md

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

33
# Computer use
44

5-
PostHog Code can give local agent sessions tools to see and control the macOS desktop across supported agent adapters.
5+
PostHog Code can give local agent sessions and cloud tasks tools to see and control the macOS desktop across supported agent adapters.
66

77
## Enable it
88

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

14-
The setting applies when a session starts. Existing sessions are unchanged.
14+
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 only to local sessions on macOS.
19+
- It is available to local sessions on macOS and cloud tasks created from the desktop app.
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 sessions and unsupported operating systems do not receive the tools.
22+
- Cloud tasks relay computer actions back to the desktop app, which must remain open and connected for the run to keep using them.
23+
- Unsupported operating systems do not receive the tools.
2324
- Tool calls use the existing MCP tool-call and approval pipeline.
2425

2526
## Safety
2627

2728
- Review the screen before approving an action and verify the result after it runs.
2829
- Do not ask an agent to enter passwords, tokens, recovery codes, or other secrets.
29-
- Disable the setting and start a new session to remove the tools.
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.
3032
- Revoke access in **System Settings → Privacy & Security → Screen Recording** or **Accessibility** to prevent native control.
3133

3234
## Scope
3335

34-
The initial implementation uses macOS system utilities for screenshots, application launching, mouse input, and keyboard input. It does not provide computer control to cloud tasks because those tasks do not run on the user's computer. Remote computer use would require an explicit device connection, user-presence controls, and a separate security design.
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.
3537

3638
## Implementation
3739

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 its stdio MCP server. Session metadata gates the tools by environment, operating system, and the user's opt-in setting.
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.

docs/cloud-mcp-relay.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<!-- markdownlint-disable MD013 -->
2+
13
# Design: relaying local MCP servers into cloud task runs
24

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

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.
26+
1927
## Problem
2028

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

5462
## Architecture
5563

56-
```
64+
```text
5765
sandbox desktop
5866
┌──────────────────────────────┐ ┌──────────────────────────────┐
5967
│ Claude/Codex adapter │ │ McpRelayService │

packages/agent/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@
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+
},
7579
"./execution-mode": {
7680
"types": "./dist/execution-mode.d.ts",
7781
"import": "./dist/execution-mode.js"

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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";
34
import { buildLocalToolsServer } from "./local-tools-mcp";
45

56
// The dist asset isn't on the walk-up path in unit tests, so make existsSync
@@ -10,6 +11,21 @@ vi.mock("node:fs", async (importActual) => {
1011
});
1112

1213
describe("buildLocalToolsServer", () => {
14+
it("builds the computer-only server for a macOS desktop relay", () => {
15+
const server = buildComputerUseStdioServer("/repo", "darwin");
16+
17+
expect(server?.env.POSTHOG_LOCAL_TOOLS_ENABLED).toBe(
18+
[
19+
"computer_screenshot",
20+
"computer_list_applications",
21+
"computer_open_application",
22+
"computer_click",
23+
"computer_type",
24+
"computer_key",
25+
].join(","),
26+
);
27+
});
28+
1329
const saved = {
1430
sandbox: process.env.IS_SANDBOX,
1531
ghToken: process.env.GH_TOKEN,

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

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

88
import type { McpServerStdio } from "@agentclientprotocol/sdk";
9-
import { ghTokenEnv } from "@posthog/git/signed-commit";
109
import { resolveGithubToken } from "../../utils/github-token";
11-
import { resolveBundledMcpScript } from "../../utils/resolve-bundled-script";
1210
import {
1311
enabledLocalTools,
14-
LOCAL_TOOLS_MCP_NAME,
1512
type LocalToolCtx,
1613
type LocalToolGateMeta,
1714
} from "../local-tools";
15+
import { buildLocalToolsStdioServer } from "../local-tools/stdio-server";
1816
import { resolveTaskId } from "../session-meta";
1917

2018
/**
@@ -29,39 +27,6 @@ export interface LocalToolsMeta extends LocalToolGateMeta {
2927
baseBranch?: string;
3028
}
3129

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 (ctx.token) {
49-
// Token also on the child env so its own git remote ops authenticate.
50-
env.push(
51-
...Object.entries(ghTokenEnv(ctx.token)).map(([name, value]) => ({
52-
name,
53-
value,
54-
})),
55-
);
56-
}
57-
return {
58-
name: LOCAL_TOOLS_MCP_NAME,
59-
command: process.execPath,
60-
args: [scriptPath],
61-
env,
62-
};
63-
}
64-
6530
/**
6631
* Returns the local-tools stdio server config to inject, or null when no tool's
6732
* gate passes (e.g. local/desktop run with no GH token). Tools self-gate via the
@@ -87,8 +52,12 @@ export function buildLocalToolsServer(
8752
if (tools.length === 0) {
8853
return null;
8954
}
90-
return toMcpServerStdio(
55+
const server = buildLocalToolsStdioServer(
9156
toolCtx,
9257
tools.map((t) => t.name),
9358
);
59+
return {
60+
...server,
61+
env: Object.entries(server.env).map(([name, value]) => ({ name, value })),
62+
};
9463
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { ghTokenEnv } from "@posthog/git/signed-commit";
2+
import { resolveBundledMcpScript } from "../../utils/resolve-bundled-script";
3+
import {
4+
LOCAL_TOOLS_MCP_NAME,
5+
type LocalToolCtx,
6+
type LocalToolGateMeta,
7+
} from "./registry";
8+
import { computerUseTools } from "./tools/computer-use";
9+
10+
export interface LocalToolsStdioServer {
11+
name: string;
12+
command: string;
13+
args: string[];
14+
env: Record<string, string>;
15+
}
16+
17+
export function buildLocalToolsStdioServer(
18+
ctx: LocalToolCtx,
19+
enabledNames: string[],
20+
): LocalToolsStdioServer {
21+
const scriptPath = resolveBundledMcpScript(
22+
"adapters/codex-app-server/local-tools-mcp-server.js",
23+
);
24+
const env: Record<string, string> = {
25+
POSTHOG_LOCAL_TOOLS_CTX: Buffer.from(JSON.stringify(ctx)).toString(
26+
"base64",
27+
),
28+
POSTHOG_LOCAL_TOOLS_ENABLED: enabledNames.join(","),
29+
ELECTRON_RUN_AS_NODE: "1",
30+
};
31+
if (ctx.token) {
32+
Object.assign(env, ghTokenEnv(ctx.token));
33+
}
34+
return {
35+
name: LOCAL_TOOLS_MCP_NAME,
36+
command: process.execPath,
37+
args: [scriptPath],
38+
env,
39+
};
40+
}
41+
42+
export function buildComputerUseStdioServer(
43+
cwd: string,
44+
platform: NodeJS.Platform = process.platform,
45+
): LocalToolsStdioServer | null {
46+
const ctx: LocalToolCtx = { cwd, platform };
47+
const meta: LocalToolGateMeta = {
48+
environment: "local",
49+
computerUse: true,
50+
};
51+
const tools = computerUseTools.filter((tool) => tool.isEnabled(ctx, meta));
52+
if (tools.length === 0) return null;
53+
return buildLocalToolsStdioServer(
54+
ctx,
55+
tools.map((tool) => tool.name),
56+
);
57+
}

packages/agent/tsup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export default defineConfig([
124124
"src/adapters/codex-app-server/models.ts",
125125
"src/adapters/codex-app-server/local-tools-mcp-server.ts",
126126
"src/adapters/claude/mcp/tool-metadata.ts",
127+
"src/adapters/local-tools/stdio-server.ts",
127128
"src/adapters/reasoning-effort.ts",
128129
"src/execution-mode.ts",
129130
"src/server/schemas.ts",

packages/core/src/local-mcp/localMcpImport.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import type { LocalMcpServerDescriptor } from "@posthog/shared";
1+
import {
2+
CLOUD_COMPUTER_USE_MCP_NAME,
3+
type LocalMcpServerDescriptor,
4+
} from "@posthog/shared";
25
import { describe, expect, it } from "vitest";
36
import {
7+
addCloudComputerUseRelay,
48
classifyLocalMcpServer,
59
isPrivateHostname,
610
type LocalMcpCloudClassification,
@@ -199,6 +203,21 @@ describe("LocalMcpImportService", () => {
199203
});
200204

201205
describe("partitionLocalMcpServersForRun", () => {
206+
it("puts built-in computer use first in the relay designation", () => {
207+
const result = addCloudComputerUseRelay([], true);
208+
209+
expect(result).toEqual([{ name: CLOUD_COMPUTER_USE_MCP_NAME }]);
210+
});
211+
212+
it("does not duplicate the built-in computer use relay name", () => {
213+
const result = addCloudComputerUseRelay(
214+
[{ name: CLOUD_COMPUTER_USE_MCP_NAME }],
215+
true,
216+
);
217+
218+
expect(result).toEqual([{ name: CLOUD_COMPUTER_USE_MCP_NAME }]);
219+
});
220+
202221
const importable = (name: string): LocalMcpCloudClassification => ({
203222
name,
204223
availability: "importable",

packages/core/src/local-mcp/localMcpImport.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import type {
44
CloudMcpServerRelayDesignation,
55
LocalMcpServerDescriptor,
66
} from "@posthog/shared";
7-
import { isPrivateIpv4Octets, isPrivateIpv6Literal } from "@posthog/shared";
7+
import {
8+
CLOUD_COMPUTER_USE_MCP_NAME,
9+
isPrivateIpv4Octets,
10+
isPrivateIpv6Literal,
11+
} from "@posthog/shared";
812
import { inject, injectable } from "inversify";
913
import { LOCAL_MCP_WORKSPACE_CLIENT } from "./identifiers";
1014

@@ -151,6 +155,17 @@ export interface LocalMcpServersForRun {
151155
relayed: CloudMcpServerRelayDesignation[];
152156
}
153157

158+
export function addCloudComputerUseRelay(
159+
servers: CloudMcpServerRelayDesignation[],
160+
enabled: boolean,
161+
): CloudMcpServerRelayDesignation[] {
162+
if (!enabled) return servers;
163+
return [
164+
{ name: CLOUD_COMPUTER_USE_MCP_NAME },
165+
...servers.filter((server) => server.name !== CLOUD_COMPUTER_USE_MCP_NAME),
166+
].slice(0, MAX_RELAYED_MCP_SERVERS);
167+
}
168+
154169
/**
155170
* Split classified local servers into the run-creation payload's imported and
156171
* relayed lists for the run's adapter.
@@ -170,7 +185,7 @@ export function partitionLocalMcpServersForRun(
170185
const imported = relayImportable
171186
? []
172187
: servers.flatMap((server) => (server.remote ? [server.remote] : []));
173-
const relayed = servers
188+
const relayedServers = servers
174189
.filter(
175190
(server) =>
176191
server.availability === "requires_desktop" ||
@@ -185,8 +200,8 @@ export function partitionLocalMcpServersForRun(
185200
? -1
186201
: 1,
187202
)
188-
.slice(0, MAX_RELAYED_MCP_SERVERS)
189203
.map((server) => ({ name: server.name }));
204+
const relayed = relayedServers.slice(0, MAX_RELAYED_MCP_SERVERS);
190205
return { imported, relayed };
191206
}
192207

packages/core/src/task-detail/taskCreationHost.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export interface RecordClaudeCliImportArgs {
7676
export interface ITaskCreationHost {
7777
getAuthenticatedClient(): Promise<TaskCreationApiClient | null>;
7878
assertCloudUsageAvailable(): Promise<void>;
79+
isComputerUseEnabled(): boolean;
7980
getTaskDirectory(taskId: string, repoKey?: string): Promise<string | null>;
8081
/**
8182
* Ensure a per-task scratch working directory exists for a repo-less channel

0 commit comments

Comments
 (0)