Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 0757f01

Browse files
authored
fix(tasks): map plan to read-only for codex cloud runs (#3322)
1 parent 78ef004 commit 0757f01

6 files changed

Lines changed: 266 additions & 7 deletions

File tree

packages/agent/src/execution-mode.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CODEX_MODE_PRESETS } from "@posthog/shared";
1+
import { CODEX_MODE_PRESETS, type ExecutionMode } from "@posthog/shared";
22
import { ALLOW_BYPASS } from "./utils/common";
33

44
export interface ModeInfo {
@@ -68,7 +68,7 @@ export const CODEX_NATIVE_MODES = ["auto", "read-only", "full-access"] as const;
6868
export type CodexNativeMode = (typeof CODEX_NATIVE_MODES)[number];
6969

7070
/** Union of all permission mode IDs across adapters */
71-
export type PermissionMode = CodeExecutionMode | CodexNativeMode;
71+
export type PermissionMode = ExecutionMode;
7272

7373
export function isCodexNativeMode(mode: string): mode is CodexNativeMode {
7474
return (CODEX_NATIVE_MODES as readonly string[]).includes(mode);

packages/api-client/src/posthog-client.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,74 @@ describe("PostHogAPIClient", () => {
7777
);
7878
});
7979

80+
it("maps plan to read-only for cloud Codex runs", async () => {
81+
const client = new PostHogAPIClient(
82+
"http://localhost:8000",
83+
async () => "token",
84+
async () => "token",
85+
123,
86+
);
87+
88+
const post = vi.fn().mockResolvedValue({
89+
id: "task-123",
90+
title: "Task",
91+
description: "Task",
92+
created_at: "2026-04-14T00:00:00Z",
93+
updated_at: "2026-04-14T00:00:00Z",
94+
origin_product: "user_created",
95+
});
96+
97+
(client as unknown as { api: { post: typeof post } }).api = { post };
98+
99+
await client.runTaskInCloud("task-123", "feature/codex-plan", {
100+
adapter: "codex",
101+
model: "gpt-5.4",
102+
initialPermissionMode: "plan",
103+
});
104+
105+
expect(post).toHaveBeenCalledWith(
106+
"/api/projects/{project_id}/tasks/{id}/run/",
107+
expect.objectContaining({
108+
body: expect.objectContaining({
109+
initial_permission_mode: "read-only",
110+
}),
111+
}),
112+
);
113+
});
114+
115+
it("omits the permission mode when no adapter is set", async () => {
116+
const client = new PostHogAPIClient(
117+
"http://localhost:8000",
118+
async () => "token",
119+
async () => "token",
120+
123,
121+
);
122+
123+
const post = vi.fn().mockResolvedValue({
124+
id: "task-123",
125+
title: "Task",
126+
description: "Task",
127+
created_at: "2026-04-14T00:00:00Z",
128+
updated_at: "2026-04-14T00:00:00Z",
129+
origin_product: "user_created",
130+
});
131+
132+
(client as unknown as { api: { post: typeof post } }).api = { post };
133+
134+
await client.runTaskInCloud("task-123", "feature/no-adapter", {
135+
initialPermissionMode: "plan",
136+
});
137+
138+
expect(post).toHaveBeenCalledWith(
139+
"/api/projects/{project_id}/tasks/{id}/run/",
140+
expect.objectContaining({
141+
body: expect.not.objectContaining({
142+
initial_permission_mode: expect.anything(),
143+
}),
144+
}),
145+
);
146+
});
147+
80148
it("rejects unsupported reasoning effort for cloud Codex runs", async () => {
81149
const client = new PostHogAPIClient(
82150
"http://localhost:8000",
@@ -177,6 +245,102 @@ describe("PostHogAPIClient", () => {
177245
);
178246
});
179247

248+
it("maps the permission mode per adapter when creating task runs", async () => {
249+
const fetch = vi.fn().mockResolvedValue({
250+
ok: true,
251+
json: async () => ({ id: "run-123", environment: "cloud" }),
252+
});
253+
const client = new PostHogAPIClient(
254+
"http://localhost:8000",
255+
async () => "token",
256+
async () => "token",
257+
123,
258+
);
259+
260+
(
261+
client as unknown as {
262+
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
263+
}
264+
).api = {
265+
baseUrl: "http://localhost:8000",
266+
fetcher: { fetch },
267+
};
268+
269+
await client.createTaskRun("task-123", {
270+
environment: "cloud",
271+
adapter: "claude",
272+
model: "claude-opus-4-8",
273+
initialPermissionMode: "read-only",
274+
});
275+
276+
const body = JSON.parse(fetch.mock.calls[0][0].overrides.body as string);
277+
expect(body.initial_permission_mode).toBe("plan");
278+
});
279+
280+
it("omits the permission mode from created task runs without an adapter", async () => {
281+
const fetch = vi.fn().mockResolvedValue({
282+
ok: true,
283+
json: async () => ({ id: "run-123", environment: "cloud" }),
284+
});
285+
const client = new PostHogAPIClient(
286+
"http://localhost:8000",
287+
async () => "token",
288+
async () => "token",
289+
123,
290+
);
291+
292+
(
293+
client as unknown as {
294+
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
295+
}
296+
).api = {
297+
baseUrl: "http://localhost:8000",
298+
fetcher: { fetch },
299+
};
300+
301+
await client.createTaskRun("task-123", {
302+
environment: "cloud",
303+
initialPermissionMode: "plan",
304+
});
305+
306+
const body = JSON.parse(fetch.mock.calls[0][0].overrides.body as string);
307+
expect(body).not.toHaveProperty("initial_permission_mode");
308+
});
309+
310+
it("omits the permission mode when none is selected", async () => {
311+
const client = new PostHogAPIClient(
312+
"http://localhost:8000",
313+
async () => "token",
314+
async () => "token",
315+
123,
316+
);
317+
318+
const post = vi.fn().mockResolvedValue({
319+
id: "task-123",
320+
title: "Task",
321+
description: "Task",
322+
created_at: "2026-04-14T00:00:00Z",
323+
updated_at: "2026-04-14T00:00:00Z",
324+
origin_product: "user_created",
325+
});
326+
327+
(client as unknown as { api: { post: typeof post } }).api = { post };
328+
329+
await client.runTaskInCloud("task-123", "feature/no-mode", {
330+
adapter: "codex",
331+
model: "gpt-5.4",
332+
});
333+
334+
expect(post).toHaveBeenCalledWith(
335+
"/api/projects/{project_id}/tasks/{id}/run/",
336+
expect.objectContaining({
337+
body: expect.not.objectContaining({
338+
initial_permission_mode: expect.anything(),
339+
}),
340+
}),
341+
);
342+
});
343+
180344
it("starts an existing cloud task run with run-scoped artifact ids", async () => {
181345
const fetch = vi.fn().mockResolvedValue({
182346
ok: true,

packages/api-client/src/posthog-client.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import "./generated.augment";
22
import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort";
3-
import type { PermissionMode } from "@posthog/agent/execution-mode";
43
import type {
54
Adapter,
65
CloudRunSource,
6+
ExecutionMode,
77
PrAuthorshipMode,
88
SeatData,
99
StoredLogEntry,
@@ -12,6 +12,7 @@ import type {
1212
import {
1313
DISMISSAL_REASON_OPTIONS,
1414
type DismissalReasonOptionValue,
15+
resolveCloudInitialPermissionMode,
1516
SEAT_PRODUCT_KEY,
1617
} from "@posthog/shared";
1718
import type {
@@ -491,7 +492,7 @@ interface CloudRunOptions {
491492
autoPublish?: boolean;
492493
runSource?: CloudRunSource;
493494
signalReportId?: string;
494-
initialPermissionMode?: PermissionMode;
495+
initialPermissionMode?: ExecutionMode;
495496
homeQuickAction?: string;
496497
}
497498

@@ -546,6 +547,13 @@ function buildCloudRunRequestBody(
546547
}
547548
body.reasoning_effort = options.reasoningLevel;
548549
}
550+
// The API rejects initial_permission_mode without runtime_adapter and validates it per adapter.
551+
if (options.initialPermissionMode) {
552+
body.initial_permission_mode = resolveCloudInitialPermissionMode(
553+
options.adapter,
554+
options.initialPermissionMode,
555+
);
556+
}
549557
}
550558
if (options?.resumeFromRunId) {
551559
body.resume_from_run_id = options.resumeFromRunId;
@@ -574,9 +582,6 @@ function buildCloudRunRequestBody(
574582
if (options?.signalReportId) {
575583
body.signal_report_id = options.signalReportId;
576584
}
577-
if (options?.initialPermissionMode) {
578-
body.initial_permission_mode = options.initialPermissionMode;
579-
}
580585
if (options?.homeQuickAction) {
581586
body.home_quick_action = options.homeQuickAction;
582587
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveCloudInitialPermissionMode } from "./execution-modes";
3+
4+
describe("resolveCloudInitialPermissionMode", () => {
5+
it.each([
6+
["codex", "auto", "auto"],
7+
["codex", "read-only", "read-only"],
8+
["codex", "full-access", "full-access"],
9+
["codex", "plan", "read-only"],
10+
["codex", "default", "auto"],
11+
["codex", "acceptEdits", "auto"],
12+
["codex", "bypassPermissions", "full-access"],
13+
["claude", "default", "default"],
14+
["claude", "acceptEdits", "acceptEdits"],
15+
["claude", "plan", "plan"],
16+
["claude", "bypassPermissions", "bypassPermissions"],
17+
["claude", "auto", "auto"],
18+
["claude", "read-only", "plan"],
19+
["claude", "full-access", "bypassPermissions"],
20+
] as const)(
21+
"resolves %s adapter mode %s to %s",
22+
(adapter, mode, expected) => {
23+
expect(resolveCloudInitialPermissionMode(adapter, mode)).toBe(expected);
24+
},
25+
);
26+
});

packages/shared/src/execution-modes.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { Adapter } from "./adapter";
2+
import type { ExecutionMode } from "./exec-types";
3+
14
export interface CodexModePreset {
25
id: "plan" | "read-only" | "auto" | "full-access";
36
name: string;
@@ -32,3 +35,63 @@ export const CODEX_MODE_PRESETS: readonly CodexModePreset[] = [
3235
description: "Auto-approves all operations",
3336
},
3437
];
38+
39+
const CLAUDE_CLOUD_PERMISSION_MODES = [
40+
"default",
41+
"acceptEdits",
42+
"plan",
43+
"bypassPermissions",
44+
"auto",
45+
] as const;
46+
47+
const CODEX_CLOUD_PERMISSION_MODES = [
48+
"auto",
49+
"read-only",
50+
"full-access",
51+
] as const;
52+
53+
type ClaudeCloudPermissionMode = (typeof CLAUDE_CLOUD_PERMISSION_MODES)[number];
54+
type CodexCloudPermissionMode = (typeof CODEX_CLOUD_PERMISSION_MODES)[number];
55+
56+
function isClaudeCloudPermissionMode(
57+
mode: ExecutionMode,
58+
): mode is ClaudeCloudPermissionMode {
59+
return (CLAUDE_CLOUD_PERMISSION_MODES as readonly string[]).includes(mode);
60+
}
61+
62+
function isCodexCloudPermissionMode(
63+
mode: ExecutionMode,
64+
): mode is CodexCloudPermissionMode {
65+
return (CODEX_CLOUD_PERMISSION_MODES as readonly string[]).includes(mode);
66+
}
67+
68+
// The cloud API's per-adapter mode enums are narrower than the local presets (no "plan" for codex); degrade to the nearest permission ceiling.
69+
const CODEX_CLOUD_MODE_FALLBACKS: Record<
70+
Exclude<ClaudeCloudPermissionMode, "auto">,
71+
CodexCloudPermissionMode
72+
> = {
73+
default: "auto",
74+
acceptEdits: "auto",
75+
plan: "read-only",
76+
bypassPermissions: "full-access",
77+
};
78+
79+
const CLAUDE_CLOUD_MODE_FALLBACKS: Record<
80+
Exclude<CodexCloudPermissionMode, "auto">,
81+
ClaudeCloudPermissionMode
82+
> = {
83+
"read-only": "plan",
84+
"full-access": "bypassPermissions",
85+
};
86+
87+
export function resolveCloudInitialPermissionMode(
88+
adapter: Adapter,
89+
mode: ExecutionMode,
90+
): ExecutionMode {
91+
if (adapter === "codex") {
92+
if (isCodexCloudPermissionMode(mode)) return mode;
93+
return CODEX_CLOUD_MODE_FALLBACKS[mode] ?? "auto";
94+
}
95+
if (isClaudeCloudPermissionMode(mode)) return mode;
96+
return CLAUDE_CLOUD_MODE_FALLBACKS[mode] ?? "default";
97+
}

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export type { ExecutionMode } from "./exec-types";
8585
export {
8686
CODEX_MODE_PRESETS,
8787
type CodexModePreset,
88+
resolveCloudInitialPermissionMode,
8889
} from "./execution-modes";
8990
export * from "./flags";
9091
export * from "./git-domain";

0 commit comments

Comments
 (0)