From d9ec9647a9935ceda29a72d440b9fb1765479b18 Mon Sep 17 00:00:00 2001 From: oski646 Date: Mon, 30 Mar 2026 21:24:48 +0200 Subject: [PATCH 1/2] fix: map runtime modes to correct permission levels and add auto-accept-edits mode Sessions launched without --dangerously-skip-permissions crashed when switching away from full-access because the fallback always tried to restore bypassPermissions. Map each runtime mode to its proper Codex approval policy / sandbox pair and Claude SDK permission mode, and expose the new auto-accept-edits option in the UI. Fixes #1437 Fixes #1241 --- apps/server/src/codexAppServerManager.ts | 30 +++--- .../src/provider/Layers/ClaudeAdapter.test.ts | 93 ++++++++++--------- .../src/provider/Layers/ClaudeAdapter.ts | 9 +- apps/web/src/components/ChatView.tsx | 51 ++++++---- .../CompactComposerControlsMenu.browser.tsx | 2 +- .../chat/CompactComposerControlsMenu.tsx | 5 +- packages/contracts/src/orchestration.ts | 6 +- 7 files changed, 114 insertions(+), 82 deletions(-) diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3145038647..bea28168a6 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the `; function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { - readonly approvalPolicy: "on-request" | "never"; - readonly sandbox: "workspace-write" | "danger-full-access"; + readonly approvalPolicy: "untrusted" | "on-request" | "never"; + readonly sandbox: "read-only" | "workspace-write" | "danger-full-access"; } { - if (runtimeMode === "approval-required") { - return { - approvalPolicy: "on-request", - sandbox: "workspace-write", - }; + switch (runtimeMode) { + case "approval-required": + return { + approvalPolicy: "untrusted", + sandbox: "read-only", + }; + case "auto-accept-edits": + return { + approvalPolicy: "on-request", + sandbox: "workspace-write", + }; + case "full-access": + return { + approvalPolicy: "never", + sandbox: "danger-full-access", + }; } - - return { - approvalPolicy: "never", - sandbox: "danger-full-access", - }; } /** diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d064a8239f..65eeef9797 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2493,57 +2493,62 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("restores base permission mode on sendTurn when interactionMode is default", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; + for (const [runtimeMode, expectedBase] of [ + ["full-access", "bypassPermissions"], + ["approval-required", "default"], + ["auto-accept-edits", "acceptEdits"], + ] as const) { + it.effect(`restores ${expectedBase} permission mode after plan turn (${runtimeMode})`, () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeAgent", - runtimeMode: "full-access", - }); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode, + }); - // First turn in plan mode - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "plan this", - interactionMode: "plan", - attachments: [], - }); + // First turn in plan mode + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); - // Complete the turn so we can send another - const turnCompletedFiber = yield* Stream.filter( - adapter.streamEvents, - (event) => event.type === "turn.completed", - ).pipe(Stream.runHead, Effect.forkChild); + // Complete the turn so we can send another + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-plan-restore", - uuid: "result-plan", - } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: `sdk-session-${runtimeMode}`, + uuid: `result-${runtimeMode}`, + } as unknown as SDKMessage); - yield* Fiber.join(turnCompletedFiber); + yield* Fiber.join(turnCompletedFiber); - // Second turn back to default - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "now do it", - interactionMode: "default", - attachments: [], - }); + // Second turn back to default + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "now do it", + interactionMode: "default", + attachments: [], + }); - // First call sets "plan", second call restores "bypassPermissions" (the base for full-access) - assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", "bypassPermissions"]); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); + assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", expectedBase]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + } it.effect("does not call setPermissionMode when interactionMode is absent", () => { const harness = makeHarness(); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6b50bd4fbb..6cbf090d32 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2693,7 +2693,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ? modelSelection.options.thinking : undefined; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); - const permissionMode = input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; + const runtimeModeToPermission: Record = { + "auto-accept-edits": "acceptEdits", + "full-access": "bypassPermissions", + }; + const permissionMode = runtimeModeToPermission[input.runtimeMode]; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -2881,8 +2885,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } else if (input.interactionMode === "default") { yield* Effect.tryPromise({ - try: () => - context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"), + try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..68efba00b8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -94,6 +94,7 @@ import { ListTodoIcon, LockIcon, LockOpenIcon, + PenLineIcon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; @@ -245,6 +246,30 @@ interface PendingPullRequestSetupRequest { scriptId: string; } +const runtimeModeConfig: Record< + RuntimeMode, + { label: string; title: string; icon: React.ReactNode; next: RuntimeMode } +> = { + "approval-required": { + label: "Supervised", + title: "Supervised - click for auto-accept edits", + icon: , + next: "auto-accept-edits", + }, + "auto-accept-edits": { + label: "Auto-accept edits", + title: "Auto-accept edits - click for full access", + icon: , + next: "full-access", + }, + "full-access": { + label: "Full access", + title: "Full access - click for supervised", + icon: , + next: "approval-required", + }, +}; + export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); @@ -1665,10 +1690,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const toggleRuntimeMode = useCallback(() => { - void handleRuntimeModeChange( - runtimeMode === "full-access" ? "approval-required" : "full-access", - ); + const cycleRuntimeMode = useCallback(() => { + void handleRuntimeModeChange(runtimeModeConfig[runtimeMode].next); }, [handleRuntimeModeChange, runtimeMode]); const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { @@ -3907,7 +3930,7 @@ export default function ChatView({ threadId }: ChatViewProps) { traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} - onToggleRuntimeMode={toggleRuntimeMode} + onRuntimeModeChange={handleRuntimeModeChange} /> ) : ( <> @@ -3954,22 +3977,12 @@ export default function ChatView({ threadId }: ChatViewProps) { className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" size="sm" type="button" - onClick={() => - void handleRuntimeModeChange( - runtimeMode === "full-access" - ? "approval-required" - : "full-access", - ) - } - title={ - runtimeMode === "full-access" - ? "Full access — click to require approvals" - : "Approval required — click for full access" - } + onClick={cycleRuntimeMode} + title={runtimeModeConfig[runtimeMode].title} > - {runtimeMode === "full-access" ? : } + {runtimeModeConfig[runtimeMode].icon} - {runtimeMode === "full-access" ? "Full access" : "Supervised"} + {runtimeModeConfig[runtimeMode].label} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index eee6f885e9..a17651e657 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -130,7 +130,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str } onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} - onToggleRuntimeMode={vi.fn()} + onRuntimeModeChange={vi.fn()} />, { container: host }, ); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index db38ed8c1e..84e0953440 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -20,7 +20,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; - onToggleRuntimeMode: () => void; + onRuntimeModeChange: (mode: RuntimeMode) => void; }) { return ( @@ -60,10 +60,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls value={props.runtimeMode} onValueChange={(value) => { if (!value || value === props.runtimeMode) return; - props.onToggleRuntimeMode(); + props.onRuntimeModeChange(value as RuntimeMode); }} > Supervised + Auto-accept edits Full access {props.activePlan ? ( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a780a55c78..a954d1e2dc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -62,7 +62,11 @@ export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); export type ModelSelection = typeof ModelSelection.Type; -export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); +export const RuntimeMode = Schema.Literals([ + "approval-required", + "auto-accept-edits", + "full-access", +]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); From 7c6a95812601d4e37dc3df1cf9ea56f3dcfbec84 Mon Sep 17 00:00:00 2001 From: oski646 Date: Mon, 30 Mar 2026 21:50:54 +0200 Subject: [PATCH 2/2] fix: stop silently discarding auto-accept-edits runtime mode setRuntimeMode had an explicit allowlist that missed "auto-accept-edits", converting it to null. Replace with nullish coalescing since TypeScript already constrains the parameter to RuntimeMode | null | undefined. --- apps/web/src/composerDraftStore.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 17b06e7bd1..df057e74c2 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1801,8 +1801,7 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const nextRuntimeMode = - runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null; + const nextRuntimeMode = runtimeMode ?? null; set((state) => { const existing = state.draftsByThreadId[threadId]; if (!existing && nextRuntimeMode === null) {