Skip to content

Commit 53ae2df

Browse files
fix(agent): honor Codex full access permissions (#3471)
1 parent 6306e29 commit 53ae2df

11 files changed

Lines changed: 570 additions & 117 deletions

File tree

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -864,7 +864,7 @@ describe("CodexAppServerAgent", () => {
864864
).toBe("untrusted");
865865
});
866866

867-
it("falls back to auto for a non-codex initial permissionMode", async () => {
867+
it("maps bypassPermissions to Codex full-access", async () => {
868868
const stub = makeStubRpc({
869869
"thread/start": { thread: { id: "t" } },
870870
"turn/start": { turn: { id: "turn_1" } },
@@ -874,7 +874,6 @@ describe("CodexAppServerAgent", () => {
874874
processOptions: { binaryPath: "/x/codex" },
875875
rpcFactory: stub.factory,
876876
});
877-
// "bypassPermissions" is a Claude mode, not a codex mode → default "auto".
878877
await agent.newSession({
879878
cwd: "/r",
880879
_meta: { permissionMode: "bypassPermissions" },
@@ -889,7 +888,10 @@ describe("CodexAppServerAgent", () => {
889888
const turnStart = stub.requests.find((r) => r.method === "turn/start");
890889
expect(
891890
(turnStart?.params as { approvalPolicy?: string }).approvalPolicy,
892-
).toBe("on-request");
891+
).toBe("never");
892+
expect(
893+
(turnStart?.params as { sandboxPolicy?: unknown }).sandboxPolicy,
894+
).toEqual({ type: "dangerFullAccess" });
893895
});
894896

895897
it("applies a read-only sandboxPolicy + approvalPolicy when the picker is Plan", async () => {

packages/agent/src/adapters/codex-app-server/session-config.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
collaborationModeFor,
77
DEFAULT_EFFORTS,
88
modeApprovalPolicy,
9+
resolveCodexMode,
10+
SessionConfigState,
911
sandboxPolicyFor,
1012
} from "./session-config";
1113

@@ -106,6 +108,34 @@ describe("collaborationModeFor", () => {
106108
});
107109
});
108110

111+
describe("resolveCodexMode", () => {
112+
it.each([
113+
["read-only", "read-only"],
114+
["auto", "auto"],
115+
["full-access", "full-access"],
116+
["bypassPermissions", "full-access"],
117+
["default", "auto"],
118+
[undefined, "auto"],
119+
])("maps host mode %s to codex mode %s", (mode, expected) => {
120+
expect(resolveCodexMode(mode)).toBe(expected);
121+
});
122+
});
123+
124+
describe("SessionConfigState", () => {
125+
it("canonicalizes bypassPermissions during a live mode update", () => {
126+
const config = new SessionConfigState("gpt-5.5");
127+
128+
config.setOption("mode", "bypassPermissions");
129+
130+
expect(config.mode).toBe("full-access");
131+
expect(config.approvalPolicy()).toBe("never");
132+
expect(config.sandboxPolicy()).toEqual({ type: "dangerFullAccess" });
133+
expect(
134+
config.options.find((option) => option.category === "mode")?.currentValue,
135+
).toBe("full-access");
136+
});
137+
});
138+
109139
describe("buildConfigOptions", () => {
110140
const byCategory = (
111141
opts: ReturnType<typeof buildConfigOptions>,

packages/agent/src/adapters/codex-app-server/session-config.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
2-
import { CODEX_MODE_PRESETS, type CodexModePreset } from "@posthog/shared";
2+
import {
3+
CODEX_MODE_PRESETS,
4+
type CodexModePreset,
5+
type ExecutionMode,
6+
resolveCloudInitialPermissionMode,
7+
} from "@posthog/shared";
38
import { type GatewayModel, isOpenAIModel } from "../../gateway-models";
49
import { getReasoningEffortOptions } from "./models";
510

@@ -121,13 +126,13 @@ export function collaborationModeFor(
121126
}
122127

123128
/**
124-
* Resolve the host's initial `_meta.permissionMode` to a codex mode. A recognized
125-
* mode is honored; anything else (e.g. "bypassPermissions") falls back to default.
129+
* Resolve a host permission mode or live picker value to a codex mode. A
130+
* recognized mode is honored; Claude's bypass mode maps to Codex full access.
131+
* Other unknown modes fall back to default.
126132
*/
127-
export function resolveInitialMode(permissionMode: string | undefined): string {
128-
return permissionMode && CODEX_MODES.some((m) => m.id === permissionMode)
129-
? permissionMode
130-
: DEFAULT_MODE;
133+
export function resolveCodexMode(mode: string | undefined): string {
134+
if (!mode) return DEFAULT_MODE;
135+
return resolveCloudInitialPermissionMode("codex", mode as ExecutionMode);
131136
}
132137

133138
/** Codex's standard reasoning efforts; used when model/list doesn't expose them. */
@@ -246,7 +251,7 @@ export class SessionConfigState {
246251

247252
/** Apply the host's initial approval mode (from `_meta.permissionMode`). */
248253
setInitialMode(permissionMode: string | undefined): void {
249-
this._mode = resolveInitialMode(permissionMode);
254+
this._mode = resolveCodexMode(permissionMode);
250255
this.rebuild();
251256
}
252257

@@ -260,7 +265,7 @@ export class SessionConfigState {
260265
if (configId === "model") this._model = value;
261266
else if (configId === "effort") this._effort = value;
262267
else if (configId === "mode") {
263-
this._mode = value;
268+
this._mode = resolveCodexMode(value);
264269
modeChanged = true;
265270
}
266271
}

packages/core/src/sessions/cloudTaskUpdateNotifications.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,11 @@ function createHarness() {
143143
taskViewedApi: { markActivity },
144144
getPersistedConfigOptions: () => undefined,
145145
setPersistedConfigOptions: vi.fn(),
146+
adapterStore: {
147+
getAdapter: () => undefined,
148+
setAdapter: vi.fn(),
149+
removeAdapter: vi.fn(),
150+
},
146151
trpc: {
147152
agent: {
148153
onSessionIdleKilled: {

packages/core/src/sessions/sessionService.ts

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,6 @@ export interface SessionServiceDeps {
324324
options: SessionConfigOption[],
325325
) => void;
326326
removePersistedConfigOptions: (taskRunId: string) => void;
327-
updatePersistedConfigOptionValue: (...args: any[]) => any;
328327
adapterStore: {
329328
getAdapter(taskRunId: string): Adapter | undefined;
330329
setAdapter(taskRunId: string, adapter: Adapter): void;
@@ -4029,7 +4028,7 @@ export class SessionService {
40294028
this.d.store.updateSession(session.taskRunId, {
40304029
configOptions: updatedOptions,
40314030
});
4032-
this.d.updatePersistedConfigOptionValue(session.taskRunId, configId, value);
4031+
this.d.setPersistedConfigOptions(session.taskRunId, updatedOptions);
40334032

40344033
if (
40354034
!session.isCloud &&
@@ -4054,20 +4053,25 @@ export class SessionService {
40544053
});
40554054
}
40564055
} catch (error) {
4057-
// Rollback on error
4058-
const rolledBackOptions = configOptions.map((opt) =>
4059-
opt.id === configId
4060-
? ({ ...opt, currentValue: previousValue } as SessionConfigOption)
4061-
: opt,
4062-
);
4063-
this.d.store.updateSession(session.taskRunId, {
4064-
configOptions: rolledBackOptions,
4065-
});
4066-
this.d.updatePersistedConfigOptionValue(
4067-
session.taskRunId,
4068-
configId,
4069-
String(previousValue),
4056+
const latestConfigOptions =
4057+
this.d.store.getSessionByTaskId(taskId)?.configOptions ?? [];
4058+
const latestOption = latestConfigOptions.find(
4059+
(option) => option.id === configId,
40704060
);
4061+
if (latestOption?.currentValue === value) {
4062+
const rolledBackOptions = latestConfigOptions.map((option) =>
4063+
option.id === configId
4064+
? ({
4065+
...option,
4066+
currentValue: previousValue,
4067+
} as SessionConfigOption)
4068+
: option,
4069+
);
4070+
this.d.store.updateSession(session.taskRunId, {
4071+
configOptions: rolledBackOptions,
4072+
});
4073+
this.d.setPersistedConfigOptions(session.taskRunId, rolledBackOptions);
4074+
}
40714075
this.d.log.error("Failed to set session config option", {
40724076
taskId,
40734077
configId,
@@ -4483,8 +4487,33 @@ export class SessionService {
44834487
runState?: Record<string, unknown>,
44844488
): () => void {
44854489
const taskRunId = runId;
4490+
const persistedConfigOptions = this.d.getPersistedConfigOptions(taskRunId);
4491+
const persistedAdapter = this.d.adapterStore.getAdapter(taskRunId);
4492+
const buildInitialConfigOptions = (
4493+
mode: string | undefined,
4494+
configAdapter: Adapter | undefined = persistedAdapter,
4495+
): SessionConfigOption[] => {
4496+
const defaults = addMissingCloudRuntimeConfigOptions(
4497+
buildCloudDefaultConfigOptions(mode, adapter),
4498+
adapter,
4499+
initialModel,
4500+
initialReasoningEffort,
4501+
);
4502+
if (!persistedConfigOptions?.length) return defaults;
4503+
if (configAdapter && configAdapter !== adapter) return defaults;
4504+
4505+
const defaultIds = new Set(defaults.map((option) => option.id));
4506+
const completeOptions = [
4507+
...defaults,
4508+
...persistedConfigOptions.filter(
4509+
(option) => !defaultIds.has(option.id),
4510+
),
4511+
];
4512+
return mergeConfigOptions(completeOptions, persistedConfigOptions);
4513+
};
44864514

44874515
if (this.supersededRunIds.has(runId)) return () => {};
4516+
this.d.adapterStore.setAdapter(taskRunId, adapter);
44884517

44894518
const existingWatcher = this.cloudTaskWatchers.get(taskId);
44904519

@@ -4512,11 +4541,9 @@ export class SessionService {
45124541
if (shouldRefreshConfigOptions) {
45134542
this.d.store.updateSession(existing.taskRunId, {
45144543
adapter,
4515-
configOptions: addMissingCloudRuntimeConfigOptions(
4516-
buildCloudDefaultConfigOptions(currentMode, adapter),
4517-
adapter,
4518-
initialModel,
4519-
initialReasoningEffort,
4544+
configOptions: buildInitialConfigOptions(
4545+
currentMode,
4546+
existing.adapter,
45204547
),
45214548
});
45224549
} else {
@@ -4613,11 +4640,9 @@ export class SessionService {
46134640
session.status = "disconnected";
46144641
session.isCloud = true;
46154642
session.adapter = adapter;
4616-
session.configOptions = addMissingCloudRuntimeConfigOptions(
4617-
buildCloudDefaultConfigOptions(initialMode, adapter),
4618-
adapter,
4619-
initialModel,
4620-
initialReasoningEffort,
4643+
session.configOptions = buildInitialConfigOptions(
4644+
initialMode,
4645+
existing?.taskRunId === taskRunId ? existing.adapter : persistedAdapter,
46214646
);
46224647
this.d.store.setSession(session);
46234648
// Optimistic seeding for the initial task description is deferred
@@ -4636,11 +4661,9 @@ export class SessionService {
46364661
)?.currentValue;
46374662
const currentMode =
46384663
typeof existingMode === "string" ? existingMode : initialMode;
4639-
updates.configOptions = addMissingCloudRuntimeConfigOptions(
4640-
buildCloudDefaultConfigOptions(currentMode, adapter),
4641-
adapter,
4642-
initialModel,
4643-
initialReasoningEffort,
4664+
updates.configOptions = buildInitialConfigOptions(
4665+
currentMode,
4666+
existing.adapter,
46444667
);
46454668
} else {
46464669
const configOptions = addMissingCloudRuntimeConfigOptions(

packages/ui/src/features/sessions/sessionConfigStore.ts

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,6 @@ interface SessionConfigActions {
1515
getConfigOptions: (taskRunId: string) => SessionConfigOption[] | undefined;
1616
/** Remove config options for a task run */
1717
removeConfigOptions: (taskRunId: string) => void;
18-
/** Update a single config option value */
19-
updateConfigOptionValue: (
20-
taskRunId: string,
21-
configId: string,
22-
value: string,
23-
) => void;
2418
}
2519

2620
type SessionConfigStore = SessionConfigState & SessionConfigActions;
@@ -42,22 +36,6 @@ export const useSessionConfigStore = create<SessionConfigStore>()(
4236
const { [taskRunId]: _removed, ...rest } = state.configsByRunId;
4337
return { configsByRunId: rest };
4438
}),
45-
46-
updateConfigOptionValue: (taskRunId, configId, value) =>
47-
set((state) => {
48-
const existing = state.configsByRunId[taskRunId];
49-
if (!existing) return state;
50-
51-
const updated = existing.map((opt) =>
52-
opt.id === configId
53-
? ({ ...opt, currentValue: value } as SessionConfigOption)
54-
: opt,
55-
);
56-
57-
return {
58-
configsByRunId: { ...state.configsByRunId, [taskRunId]: updated },
59-
};
60-
}),
6139
}),
6240
{
6341
name: "session-config-storage",
@@ -86,14 +64,3 @@ export function setPersistedConfigOptions(
8664
export function removePersistedConfigOptions(taskRunId: string): void {
8765
useSessionConfigStore.getState().removeConfigOptions(taskRunId);
8866
}
89-
90-
/** Non-hook accessor for updating a single config option value */
91-
export function updatePersistedConfigOptionValue(
92-
taskRunId: string,
93-
configId: string,
94-
value: string,
95-
): void {
96-
useSessionConfigStore
97-
.getState()
98-
.updateConfigOptionValue(taskRunId, configId, value);
99-
}

packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,6 @@ const mockSessionConfigStore = vi.hoisted(() => ({
127127
getPersistedConfigOptions: vi.fn(() => undefined),
128128
setPersistedConfigOptions: vi.fn(),
129129
removePersistedConfigOptions: vi.fn(),
130-
updatePersistedConfigOptionValue: vi.fn(),
131130
}));
132131

133132
vi.mock(

0 commit comments

Comments
 (0)