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
18 changes: 9 additions & 9 deletions docs/COMPUTER-USE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,37 @@

# Computer use

PostHog Code can give local agent sessions tools to see and control the macOS desktop across supported agent adapters.
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.

## Enable it

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

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

## Behavior

- Computer use is opt-in and disabled by default.
- It is available only to local sessions on macOS.
- Local sessions control the user's Mac. Cloud tasks control a virtual Linux desktop inside their own sandbox.
- Agents can capture the desktop, list visible applications, open or focus applications, click coordinates, type text, and press keys.
- Claude, Codex, and future adapters receive the same PostHog-owned MCP tools rather than adapter-specific computer-control implementations.
- Cloud sessions and unsupported operating systems do not receive the tools.
- Unsupported operating systems do not receive the tools.
- Tool calls use the existing MCP tool-call and approval pipeline.

## Safety

- Review the screen before approving an action and verify the result after it runs.
- Do not ask an agent to enter passwords, tokens, recovery codes, or other secrets.
- Disable the setting and start a new session to remove the tools.
- Disable the setting before starting a new session or cloud task to omit the tools.
- Revoke access in **System Settings → Privacy & Security → Screen Recording** or **Accessibility** to prevent native control.

## Scope

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.
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.

## Implementation

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.
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.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe("buildLocalToolsServer", () => {
sandbox: process.env.IS_SANDBOX,
ghToken: process.env.GH_TOKEN,
githubToken: process.env.GITHUB_TOKEN,
display: process.env.DISPLAY,
};

beforeEach(() => {
Expand All @@ -22,12 +23,14 @@ describe("buildLocalToolsServer", () => {
delete process.env.IS_SANDBOX;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.DISPLAY;
});

afterEach(() => {
restore("IS_SANDBOX", saved.sandbox);
restore("GH_TOKEN", saved.ghToken);
restore("GITHUB_TOKEN", saved.githubToken);
restore("DISPLAY", saved.display);
});

function restore(key: string, value: string | undefined): void {
Expand Down Expand Up @@ -152,6 +155,20 @@ describe("buildLocalToolsServer", () => {
);
});

it("exposes computer tools for opted-in cloud Linux sessions", () => {
process.env.DISPLAY = ":99";
const server = buildLocalToolsServer(
{ cwd: "/repo", platform: "linux" },
{ environment: "cloud", computerUse: true },
);

const enabled =
server?.env.find((entry) => entry.name === "POSTHOG_LOCAL_TOOLS_ENABLED")
?.value ?? "";
expect(enabled.split(",")).toContain("computer_screenshot");
expect(server?.env).toContainEqual({ name: "DISPLAY", value: ":99" });
});

it.each([
{
platform: "linux" as const,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ function toMcpServerStdio(
// binary, which boots the full app without this var. Inert on real node.
{ name: "ELECTRON_RUN_AS_NODE", value: "1" },
];
if (process.env.DISPLAY) {
env.push({ name: "DISPLAY", value: process.env.DISPLAY });
}
if (ctx.token) {
// Token also on the child env so its own git remote ops authenticate.
env.push(
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/adapters/local-tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface LocalToolGateMeta {
channelMode?: boolean;
/** Spoken narration is on for this session: enables the speak tool. */
spokenNarration?: boolean;
/** Desktop computer control is enabled for this local session. */
/** Computer control is enabled for this session. */
computerUse?: boolean;
}

Expand Down
41 changes: 39 additions & 2 deletions packages/agent/src/adapters/local-tools/tools/computer-use.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execFile } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import { readFile, unlink } from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
Expand All @@ -10,18 +10,21 @@ import {
computerUseTools,
} from "./computer-use";

vi.mock("node:child_process", () => ({ execFile: vi.fn() }));
vi.mock("node:child_process", () => ({ execFile: vi.fn(), spawn: vi.fn() }));
vi.mock("node:fs/promises", () => ({
readFile: vi.fn(),
unlink: vi.fn().mockResolvedValue(undefined),
}));

const mockedExecFile = vi.mocked(execFile);
const mockedSpawn = vi.mocked(spawn);
const mockedReadFile = vi.mocked(readFile);
const mockedUnlink = vi.mocked(unlink);

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

describe("computer use tools", () => {
beforeEach(() => {
Expand All @@ -33,6 +36,7 @@ describe("computer use tools", () => {
}
return undefined;
}) as unknown as typeof execFile);
mockedSpawn.mockReturnValue({ unref: vi.fn() } as never);
mockedReadFile.mockResolvedValue(Buffer.from("png"));
mockedUnlink.mockResolvedValue(undefined);
});
Expand Down Expand Up @@ -71,6 +75,12 @@ describe("computer use tools", () => {
expect(computerScreenshotTool.isEnabled(context, enabledMeta)).toBe(true);
});

it("is enabled for opted-in cloud Linux sessions", () => {
expect(computerScreenshotTool.isEnabled(cloudContext, cloudMeta)).toBe(
true,
);
});

it("returns a screenshot image and removes the temporary file", async () => {
const result = await computerScreenshotTool.handler(context, {});

Expand Down Expand Up @@ -130,6 +140,33 @@ describe("computer use tools", () => {
);
});

it("opens the cloud browser without invoking a shell", async () => {
const unref = vi.fn();
mockedSpawn.mockReturnValueOnce({ unref } as never);

await computerOpenApplicationTool.handler(cloudContext, {
application: "Browser",
});

expect(mockedSpawn).toHaveBeenCalledWith(
"/usr/bin/epiphany",
["--new-window"],
{ detached: true, stdio: "ignore" },
);
expect(unref).toHaveBeenCalledOnce();
});

it("uses Linux desktop utilities in cloud sessions", async () => {
await computerClickTool.handler(cloudContext, { x: 100, y: 200 });

expect(mockedExecFile).toHaveBeenCalledWith(
"/usr/bin/xdotool",
["mousemove", "100", "200", "click", "1"],
{ encoding: "utf8" },
expect.any(Function),
);
});

it.each([
[computerClickTool, { x: 100, y: 200 }],
[computerTypeTool, { text: "hello" }],
Expand Down
Loading
Loading