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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<!-- markdownlint-disable MD013 -->

# PostHog Code Development Guide

`AGENTS.md` is the source of truth for architecture and development rules. `CLAUDE.md` is a symlink to this file. Edit this file only.
Expand Down Expand Up @@ -229,6 +231,7 @@ See [docs/conventions.md](./docs/conventions.md).
- Use SDK types from `@anthropic-ai/claude-agent-sdk` and `@agentclientprotocol/sdk`.
- Do not use Claude Code SDK `rawInput`. Use Zod-validated metadata.
- User approvals are tool calls with permissions. Do not model approvals as custom methods plus notifications.
- Keep browser and computer-use capabilities adapter-neutral unless an adapter has a documented technical constraint.

## Key Libraries

Expand Down
16 changes: 16 additions & 0 deletions apps/code/runtime-dependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
macOnlyNativeModules,
packagedFileGlobs,
requiredNativeModules,
runtimeJavaScriptModules,
runtimeNativeModules,
watcherPackageFor,
} from "./runtime-dependencies";
Expand Down Expand Up @@ -42,6 +43,21 @@ describe("native module globs", () => {
expect(packagedFileGlobs).toContain("node_modules/node-pty/**/*");
expect(packagedFileGlobs).toContain("node_modules/better-sqlite3/**/*");
});

it("packages browser automation runtime modules", () => {
expect(runtimeJavaScriptModules).toEqual([
"@playwright/mcp",
"playwright",
"playwright-core",
]);
expect(packagedFileGlobs).toEqual(
expect.arrayContaining([
"node_modules/@playwright/mcp/**/*",
"node_modules/playwright/**/*",
"node_modules/playwright-core/**/*",
]),
);
});
});

describe("native module list invariants", () => {
Expand Down
7 changes: 7 additions & 0 deletions apps/code/runtime-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export const runtimeNativeModules = [
"is-number",
];

export const runtimeJavaScriptModules = [
"@playwright/mcp",
"playwright",
"playwright-core",
];

// The base native modules that must exist when packaging; a missing one is a
// broken build, not a warning. before-pack stages these with copyRequiredDep.
export const requiredNativeModules = [
Expand Down Expand Up @@ -65,6 +71,7 @@ const scopeOf = (name: string) =>

export const packagedFileGlobs = [
...runtimeNativeModules,
...runtimeJavaScriptModules,
...macOnlyNativeModules,
].map((name) => `node_modules/${scopeOf(name)}/**/*`);

Expand Down
5 changes: 5 additions & 0 deletions apps/code/scripts/before-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "node:path";
import {
macOnlyNativeModules,
requiredNativeModules,
runtimeJavaScriptModules,
runtimeNativeModules,
watcherPackageFor,
} from "../runtime-dependencies";
Expand Down Expand Up @@ -72,6 +73,10 @@ export default async function beforePack(context: BeforePackContext) {
}
}

for (const dep of runtimeJavaScriptModules) {
copyRequiredDep(dep, rootNodeModules, localNodeModules);
}

const watcherPkg = watcherPackageFor(platformName, arch);
if (watcherPkg) {
copyRequiredDep(watcherPkg, rootNodeModules, localNodeModules);
Expand Down
30 changes: 30 additions & 0 deletions docs/BROWSER-USE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!-- markdownlint-disable MD013 -->

# Browser use

PostHog Code can give local agent sessions browser automation tools through an isolated Playwright MCP server.

## Enable it

1. Install Google Chrome.
2. Open **Settings → Advanced**.
3. Enable **Browser use**.
4. Start a new local session.

The setting applies when a session starts. Existing sessions are unchanged.

## Behavior

- Browser use is opt-in and disabled by default.
- It is available only to local sessions.
- Each session launches an isolated Chrome profile, so it does not inherit cookies or logins from the user's normal browser profile.
- Tool calls and screenshots use the existing MCP tool-call pipeline.
- Cloud sessions do not receive the local browser server.

## Scope

This feature automates websites in a dedicated Chrome window. It does not control arbitrary desktop applications or the user's existing browser windows.

## Implementation

The workspace session layer injects a pinned `@playwright/mcp` stdio server for every supported local agent adapter instead of maintaining a bespoke browser-control protocol. This keeps browser lifecycle, accessibility snapshots, input actions, and image responses on Playwright's supported MCP implementation while reusing PostHog Code's existing MCP tool-call and approval surfaces.
12 changes: 10 additions & 2 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ export interface SessionServiceDeps {
rtkEnabledCloud?: boolean;
spokenNotifications?: boolean;
spokenNarrationEnabled?: boolean;
browserUse?: boolean;
};
usageLimit: { show: (...args: any[]) => any };
readonly addDirectoryDialog: { open: boolean };
Expand Down Expand Up @@ -1088,14 +1089,19 @@ export class SessionService {
this.d.log.warn("Failed to verify workspace", { taskId, err });
});

const { customInstructions, rtkEnabledLocal, spokenNarrationEnabled } =
this.d.settings;
const {
customInstructions,
rtkEnabledLocal,
spokenNarrationEnabled,
browserUse,
} = this.d.settings;
const result = await this.d.trpc.agent.reconnect.mutate({
taskId,
taskRunId,
repoPath,
rtkEnabled: rtkEnabledLocal,
spokenNarration: spokenNarrationEnabled === true,
browserUse: browserUse === true,
apiHost: auth.apiHost,
projectId: auth.projectId,
logUrl,
Expand Down Expand Up @@ -1419,6 +1425,7 @@ export class SessionService {
customInstructions: startCustomInstructions,
rtkEnabledLocal,
spokenNarrationEnabled,
browserUse,
} = this.d.settings;
const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL;
const result = await this.d.trpc.agent.start.mutate({
Expand All @@ -1432,6 +1439,7 @@ export class SessionService {
customInstructions: startCustomInstructions || undefined,
rtkEnabled: rtkEnabledLocal,
spokenNarration: spokenNarrationEnabled === true,
browserUse: browserUse === true,
effort: effortLevelSchema.safeParse(reasoningLevel).success
? (reasoningLevel as EffortLevel)
: undefined,
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/features/sessions/sessionServiceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ function buildSessionServiceDeps(): SessionServiceDeps {
),
import.meta.env.DEV,
),
browserUse: state.browserUse,
};
},
usageLimit: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export function AdvancedSettings() {
const setRtkEnabledLocal = useSettingsStore((s) => s.setRtkEnabledLocal);
const rtkEnabledCloud = useSettingsStore((s) => s.rtkEnabledCloud);
const setRtkEnabledCloud = useSettingsStore((s) => s.setRtkEnabledCloud);
const browserUse = useSettingsStore((s) => s.browserUse);
const setBrowserUse = useSettingsStore((s) => s.setBrowserUse);
const hostTRPC = useHostTRPC();
const { data: rtkStatus } = useQuery(hostTRPC.agent.rtkStatus.queryOptions());
const devModeClient = useServiceOptional<DevModeClient>(DEV_MODE_CLIENT);
Expand Down Expand Up @@ -88,6 +90,12 @@ export function AdvancedSettings() {
)}
</Flex>
</SettingRow>
<SettingRow
label="Browser use"
description="Let local agent sessions launch an isolated Google Chrome window and interact with websites. Experimental; requires Chrome to be installed"
>
<Switch checked={browserUse} onCheckedChange={setBrowserUse} size="1" />
</SettingRow>
<SettingRow
label="Reset onboarding and tours"
description="Re-run the onboarding tutorial and product tours on next app restart"
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/features/settings/settingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe("feature settingsStore defaults", () => {
expect(useSettingsStore.getState().lastUsedLocalWorkspaceMode).toBe(
"local",
);
expect(useSettingsStore.getState().browserUse).toBe(false);
});
});

Expand Down Expand Up @@ -220,6 +221,7 @@ describe("feature settingsStore cloud selections", () => {
["slotMachineMode", false, true],
["dismissibleUpdateBanners", false, true],
["showSidebarWorktrees", false, true],
["browserUse", false, true],
] as const)("rehydrates %s", async (field, initial, persisted) => {
getItem.mockResolvedValue(
JSON.stringify({ state: { [field]: persisted }, version: 0 }),
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/features/settings/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,14 @@ interface SettingsStore {
// sessions, cloud covers cloud runs.
rtkEnabledLocal: boolean;
rtkEnabledCloud: boolean;
browserUse: boolean;
setAllowBypassPermissions: (enabled: boolean) => void;
setPreventSleepWhileRunning: (enabled: boolean) => void;
setDebugLogsCloudRuns: (enabled: boolean) => void;
setAutoPublishCloudRuns: (enabled: boolean) => void;
setRtkEnabledLocal: (enabled: boolean) => void;
setRtkEnabledCloud: (enabled: boolean) => void;
setBrowserUse: (enabled: boolean) => void;

// Terminal
terminalFont: TerminalFont;
Expand Down Expand Up @@ -428,6 +430,7 @@ export const useSettingsStore = create<SettingsStore>()(
autoPublishCloudRuns: true,
rtkEnabledLocal: true,
rtkEnabledCloud: true,
browserUse: false,
setAllowBypassPermissions: (enabled) =>
set({ allowBypassPermissions: enabled }),
setPreventSleepWhileRunning: (enabled) =>
Expand All @@ -437,6 +440,7 @@ export const useSettingsStore = create<SettingsStore>()(
set({ autoPublishCloudRuns: enabled }),
setRtkEnabledLocal: (enabled) => set({ rtkEnabledLocal: enabled }),
setRtkEnabledCloud: (enabled) => set({ rtkEnabledCloud: enabled }),
setBrowserUse: (enabled) => set({ browserUse: enabled }),

// Terminal
terminalFont: "berkeley-mono",
Expand Down Expand Up @@ -566,6 +570,7 @@ export const useSettingsStore = create<SettingsStore>()(
autoPublishCloudRuns: state.autoPublishCloudRuns,
rtkEnabledLocal: state.rtkEnabledLocal,
rtkEnabledCloud: state.rtkEnabledCloud,
browserUse: state.browserUse,

// Terminal
terminalFont: state.terminalFont,
Expand Down
1 change: 1 addition & 0 deletions packages/workspace-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@hono/trpc-server": "catalog:",
"@modelcontextprotocol/sdk": "^1.29.0",
"@parcel/watcher": "catalog:",
"@playwright/mcp": "0.0.78",
"@posthog/agent": "workspace:*",
"@posthog/di": "workspace:*",
"@posthog/enricher": "workspace:*",
Expand Down
68 changes: 68 additions & 0 deletions packages/workspace-server/src/services/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ vi.mock("@posthog/agent/adapters/claude/session/jsonl-hydration", () => ({
hydrateSessionJsonl: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("./browser-use-mcp", () => ({
buildBrowserUseServer: vi.fn((enabled: boolean | undefined) =>
enabled
? {
name: "playwright",
command: process.execPath,
args: ["/mock/playwright-mcp/cli.js", "--isolated"],
env: [{ name: "ELECTRON_RUN_AS_NODE", value: "1" }],
}
: null,
),
}));

vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs")>();
return {
Expand Down Expand Up @@ -345,6 +358,48 @@ describe("AgentService", () => {
expect(codexMcp).toEqual(claudeMcp);
});

it("passes the same browser MCP server to Claude and Codex", async () => {
await service.startSession({
...baseSessionParams,
taskRunId: "run-claude",
adapter: "claude",
browserUse: true,
});
await service.startSession({
...baseSessionParams,
taskRunId: "run-codex",
adapter: "codex",
browserUse: true,
});

const claudeBrowserServer =
mockNewSession.mock.calls[0][0].mcpServers.at(-1);
const codexBrowserServer =
mockNewSession.mock.calls[1][0].mcpServers.at(-1);
expect(claudeBrowserServer).toMatchObject({
name: "playwright",
args: expect.arrayContaining(["--isolated"]),
});
expect(codexBrowserServer).toEqual(claudeBrowserServer);
});

it.each([undefined, false])(
"does not pass browser MCP when browserUse is %s",
async (browserUse) => {
await service.startSession({
...baseSessionParams,
adapter: "claude",
browserUse,
});

expect(mockNewSession.mock.calls[0][0].mcpServers).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "playwright" }),
]),
);
},
);

it("drops unreachable MCP servers for codex but keeps them for claude", async () => {
vi.stubGlobal(
"fetch",
Expand Down Expand Up @@ -414,6 +469,19 @@ describe("AgentService", () => {
"spokenNarration",
);
});

it("keeps browser configuration out of adapter-specific session meta", async () => {
await service.startSession({
...baseSessionParams,
adapter: "claude",
browserUse: true,
});

expect(mockNewSession).toHaveBeenCalledTimes(1);
expect(mockNewSession.mock.calls[0][0]._meta).not.toHaveProperty(
"browserUse",
);
});
});

describe("permission requests", () => {
Expand Down
18 changes: 17 additions & 1 deletion packages/workspace-server/src/services/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import type { ProcessTrackingService } from "../process-tracking/process-trackin
import { loadSessionEnvOverrides } from "../session-env/loader";
import { isScratchPath } from "../workspace/scratch";
import type { AgentAuthAdapter, McpToolInstallations } from "./auth-adapter";
import { buildBrowserUseServer } from "./browser-use-mcp";
import { cleanupCodexHome, prepareCodexHome } from "./codex-home";
import { discoverExternalPlugins } from "./discover-plugins";
import {
Expand Down Expand Up @@ -290,6 +291,8 @@ interface SessionConfig {
rtkEnabled?: boolean;
/** The user's spoken-narration setting at session start. */
spokenNarration?: boolean;
/** Whether local sessions may launch the isolated browser-use tools. */
browserUse?: boolean;
}

/** Pull the adapter's `agentCapabilities._meta.posthog.steering` from initialize. */
Expand Down Expand Up @@ -935,10 +938,22 @@ If a repository IS genuinely required, attach one in this priority order:
// ("ACP connection closed") and makes the host silently fall back to a
// Claude/Opus session. Claude connects lazily and is unaffected, so only
// the Codex server list is pruned to the reachable ones.
const sessionMcpServers =
const reachableMcpServers =
adapter === "codex"
? await this.filterReachableMcpServers(mcpServers, taskRunId)
: mcpServers;
let browserUseServer: ReturnType<typeof buildBrowserUseServer> = null;
try {
browserUseServer = buildBrowserUseServer(config.browserUse, "local");
} catch (err) {
this.log.warn("Browser-use server unavailable; continuing without it", {
error: err instanceof Error ? err.message : String(err),
});
}
const sessionMcpServers = [
...reachableMcpServers,
...(browserUseServer ? [browserUseServer] : []),
];

let externalPlugins: Awaited<ReturnType<typeof discoverExternalPlugins>> =
[];
Expand Down Expand Up @@ -2028,6 +2043,7 @@ For git operations while detached:
rtkEnabled: "rtkEnabled" in params ? params.rtkEnabled : undefined,
spokenNarration:
"spokenNarration" in params ? params.spokenNarration : undefined,
browserUse: "browserUse" in params ? params.browserUse : undefined,
};
}

Expand Down
Loading
Loading