diff --git a/packages/agent/src/execution-mode.ts b/packages/agent/src/execution-mode.ts index d1a82a793c..7ebf75e86b 100644 --- a/packages/agent/src/execution-mode.ts +++ b/packages/agent/src/execution-mode.ts @@ -1,4 +1,4 @@ -import { CODEX_MODE_PRESETS } from "@posthog/shared"; +import { CODEX_MODE_PRESETS, type ExecutionMode } from "@posthog/shared"; import { ALLOW_BYPASS } from "./utils/common"; export interface ModeInfo { @@ -68,7 +68,7 @@ export const CODEX_NATIVE_MODES = ["auto", "read-only", "full-access"] as const; export type CodexNativeMode = (typeof CODEX_NATIVE_MODES)[number]; /** Union of all permission mode IDs across adapters */ -export type PermissionMode = CodeExecutionMode | CodexNativeMode; +export type PermissionMode = ExecutionMode; export function isCodexNativeMode(mode: string): mode is CodexNativeMode { return (CODEX_NATIVE_MODES as readonly string[]).includes(mode); diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index ffb2a9f962..2e9a96b991 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -77,6 +77,74 @@ describe("PostHogAPIClient", () => { ); }); + it("maps plan to read-only for cloud Codex runs", async () => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + const post = vi.fn().mockResolvedValue({ + id: "task-123", + title: "Task", + description: "Task", + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + origin_product: "user_created", + }); + + (client as unknown as { api: { post: typeof post } }).api = { post }; + + await client.runTaskInCloud("task-123", "feature/codex-plan", { + adapter: "codex", + model: "gpt-5.4", + initialPermissionMode: "plan", + }); + + expect(post).toHaveBeenCalledWith( + "/api/projects/{project_id}/tasks/{id}/run/", + expect.objectContaining({ + body: expect.objectContaining({ + initial_permission_mode: "read-only", + }), + }), + ); + }); + + it("omits the permission mode when no adapter is set", async () => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + const post = vi.fn().mockResolvedValue({ + id: "task-123", + title: "Task", + description: "Task", + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + origin_product: "user_created", + }); + + (client as unknown as { api: { post: typeof post } }).api = { post }; + + await client.runTaskInCloud("task-123", "feature/no-adapter", { + initialPermissionMode: "plan", + }); + + expect(post).toHaveBeenCalledWith( + "/api/projects/{project_id}/tasks/{id}/run/", + expect.objectContaining({ + body: expect.not.objectContaining({ + initial_permission_mode: expect.anything(), + }), + }), + ); + }); + it("rejects unsupported reasoning effort for cloud Codex runs", async () => { const client = new PostHogAPIClient( "http://localhost:8000", @@ -177,6 +245,102 @@ describe("PostHogAPIClient", () => { ); }); + it("maps the permission mode per adapter when creating task runs", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: "run-123", environment: "cloud" }), + }); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { + baseUrl: "http://localhost:8000", + fetcher: { fetch }, + }; + + await client.createTaskRun("task-123", { + environment: "cloud", + adapter: "claude", + model: "claude-opus-4-8", + initialPermissionMode: "read-only", + }); + + const body = JSON.parse(fetch.mock.calls[0][0].overrides.body as string); + expect(body.initial_permission_mode).toBe("plan"); + }); + + it("omits the permission mode from created task runs without an adapter", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: "run-123", environment: "cloud" }), + }); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { + baseUrl: "http://localhost:8000", + fetcher: { fetch }, + }; + + await client.createTaskRun("task-123", { + environment: "cloud", + initialPermissionMode: "plan", + }); + + const body = JSON.parse(fetch.mock.calls[0][0].overrides.body as string); + expect(body).not.toHaveProperty("initial_permission_mode"); + }); + + it("omits the permission mode when none is selected", async () => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + const post = vi.fn().mockResolvedValue({ + id: "task-123", + title: "Task", + description: "Task", + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + origin_product: "user_created", + }); + + (client as unknown as { api: { post: typeof post } }).api = { post }; + + await client.runTaskInCloud("task-123", "feature/no-mode", { + adapter: "codex", + model: "gpt-5.4", + }); + + expect(post).toHaveBeenCalledWith( + "/api/projects/{project_id}/tasks/{id}/run/", + expect.objectContaining({ + body: expect.not.objectContaining({ + initial_permission_mode: expect.anything(), + }), + }), + ); + }); + it("starts an existing cloud task run with run-scoped artifact ids", async () => { const fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 9d74ce0d2b..a929819858 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1,9 +1,9 @@ import "./generated.augment"; import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; -import type { PermissionMode } from "@posthog/agent/execution-mode"; import type { Adapter, CloudRunSource, + ExecutionMode, PrAuthorshipMode, SeatData, StoredLogEntry, @@ -12,6 +12,7 @@ import type { import { DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, + resolveCloudInitialPermissionMode, SEAT_PRODUCT_KEY, } from "@posthog/shared"; import type { @@ -491,7 +492,7 @@ interface CloudRunOptions { autoPublish?: boolean; runSource?: CloudRunSource; signalReportId?: string; - initialPermissionMode?: PermissionMode; + initialPermissionMode?: ExecutionMode; homeQuickAction?: string; } @@ -546,6 +547,13 @@ function buildCloudRunRequestBody( } body.reasoning_effort = options.reasoningLevel; } + // The API rejects initial_permission_mode without runtime_adapter and validates it per adapter. + if (options.initialPermissionMode) { + body.initial_permission_mode = resolveCloudInitialPermissionMode( + options.adapter, + options.initialPermissionMode, + ); + } } if (options?.resumeFromRunId) { body.resume_from_run_id = options.resumeFromRunId; @@ -574,9 +582,6 @@ function buildCloudRunRequestBody( if (options?.signalReportId) { body.signal_report_id = options.signalReportId; } - if (options?.initialPermissionMode) { - body.initial_permission_mode = options.initialPermissionMode; - } if (options?.homeQuickAction) { body.home_quick_action = options.homeQuickAction; } diff --git a/packages/shared/src/execution-modes.test.ts b/packages/shared/src/execution-modes.test.ts new file mode 100644 index 0000000000..6a765b56ad --- /dev/null +++ b/packages/shared/src/execution-modes.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { resolveCloudInitialPermissionMode } from "./execution-modes"; + +describe("resolveCloudInitialPermissionMode", () => { + it.each([ + ["codex", "auto", "auto"], + ["codex", "read-only", "read-only"], + ["codex", "full-access", "full-access"], + ["codex", "plan", "read-only"], + ["codex", "default", "auto"], + ["codex", "acceptEdits", "auto"], + ["codex", "bypassPermissions", "full-access"], + ["claude", "default", "default"], + ["claude", "acceptEdits", "acceptEdits"], + ["claude", "plan", "plan"], + ["claude", "bypassPermissions", "bypassPermissions"], + ["claude", "auto", "auto"], + ["claude", "read-only", "plan"], + ["claude", "full-access", "bypassPermissions"], + ] as const)( + "resolves %s adapter mode %s to %s", + (adapter, mode, expected) => { + expect(resolveCloudInitialPermissionMode(adapter, mode)).toBe(expected); + }, + ); +}); diff --git a/packages/shared/src/execution-modes.ts b/packages/shared/src/execution-modes.ts index 909fcf80ee..e7d7fde243 100644 --- a/packages/shared/src/execution-modes.ts +++ b/packages/shared/src/execution-modes.ts @@ -1,3 +1,6 @@ +import type { Adapter } from "./adapter"; +import type { ExecutionMode } from "./exec-types"; + export interface CodexModePreset { id: "plan" | "read-only" | "auto" | "full-access"; name: string; @@ -32,3 +35,63 @@ export const CODEX_MODE_PRESETS: readonly CodexModePreset[] = [ description: "Auto-approves all operations", }, ]; + +const CLAUDE_CLOUD_PERMISSION_MODES = [ + "default", + "acceptEdits", + "plan", + "bypassPermissions", + "auto", +] as const; + +const CODEX_CLOUD_PERMISSION_MODES = [ + "auto", + "read-only", + "full-access", +] as const; + +type ClaudeCloudPermissionMode = (typeof CLAUDE_CLOUD_PERMISSION_MODES)[number]; +type CodexCloudPermissionMode = (typeof CODEX_CLOUD_PERMISSION_MODES)[number]; + +function isClaudeCloudPermissionMode( + mode: ExecutionMode, +): mode is ClaudeCloudPermissionMode { + return (CLAUDE_CLOUD_PERMISSION_MODES as readonly string[]).includes(mode); +} + +function isCodexCloudPermissionMode( + mode: ExecutionMode, +): mode is CodexCloudPermissionMode { + return (CODEX_CLOUD_PERMISSION_MODES as readonly string[]).includes(mode); +} + +// The cloud API's per-adapter mode enums are narrower than the local presets (no "plan" for codex); degrade to the nearest permission ceiling. +const CODEX_CLOUD_MODE_FALLBACKS: Record< + Exclude, + CodexCloudPermissionMode +> = { + default: "auto", + acceptEdits: "auto", + plan: "read-only", + bypassPermissions: "full-access", +}; + +const CLAUDE_CLOUD_MODE_FALLBACKS: Record< + Exclude, + ClaudeCloudPermissionMode +> = { + "read-only": "plan", + "full-access": "bypassPermissions", +}; + +export function resolveCloudInitialPermissionMode( + adapter: Adapter, + mode: ExecutionMode, +): ExecutionMode { + if (adapter === "codex") { + if (isCodexCloudPermissionMode(mode)) return mode; + return CODEX_CLOUD_MODE_FALLBACKS[mode] ?? "auto"; + } + if (isClaudeCloudPermissionMode(mode)) return mode; + return CLAUDE_CLOUD_MODE_FALLBACKS[mode] ?? "default"; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6089a52c29..2fb5a06129 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -85,6 +85,7 @@ export type { ExecutionMode } from "./exec-types"; export { CODEX_MODE_PRESETS, type CodexModePreset, + resolveCloudInitialPermissionMode, } from "./execution-modes"; export * from "./flags"; export * from "./git-domain";