Skip to content

Commit 7372184

Browse files
oski646juliusmarmingecodex
authored
fix: map runtime modes to correct permission levels (#1587)
Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>
1 parent d9ded65 commit 7372184

File tree

9 files changed

+189
-105
lines changed

9 files changed

+189
-105
lines changed

apps/server/src/codexAppServerManager.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the
290290
</collaboration_mode>`;
291291

292292
function mapCodexRuntimeMode(runtimeMode: RuntimeMode): {
293-
readonly approvalPolicy: "on-request" | "never";
294-
readonly sandbox: "workspace-write" | "danger-full-access";
293+
readonly approvalPolicy: "untrusted" | "on-request" | "never";
294+
readonly sandbox: "read-only" | "workspace-write" | "danger-full-access";
295295
} {
296-
if (runtimeMode === "approval-required") {
297-
return {
298-
approvalPolicy: "on-request",
299-
sandbox: "workspace-write",
300-
};
296+
switch (runtimeMode) {
297+
case "approval-required":
298+
return {
299+
approvalPolicy: "untrusted",
300+
sandbox: "read-only",
301+
};
302+
case "auto-accept-edits":
303+
return {
304+
approvalPolicy: "on-request",
305+
sandbox: "workspace-write",
306+
};
307+
case "full-access":
308+
return {
309+
approvalPolicy: "never",
310+
sandbox: "danger-full-access",
311+
};
301312
}
302-
303-
return {
304-
approvalPolicy: "never",
305-
sandbox: "danger-full-access",
306-
};
307313
}
308314

309315
/**

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ApprovalRequestId,
1515
ProviderItemId,
1616
ProviderRuntimeEvent,
17+
type RuntimeMode,
1718
ThreadId,
1819
} from "@t3tools/contracts";
1920
import { assert, describe, it } from "@effect/vitest";
@@ -2496,57 +2497,63 @@ describe("ClaudeAdapterLive", () => {
24962497
);
24972498
});
24982499

2499-
it.effect("restores base permission mode on sendTurn when interactionMode is default", () => {
2500-
const harness = makeHarness();
2501-
return Effect.gen(function* () {
2502-
const adapter = yield* ClaudeAdapter;
2500+
it.effect.each<{ runtimeMode: RuntimeMode; expectedBase: PermissionMode }>([
2501+
{ runtimeMode: "full-access", expectedBase: "bypassPermissions" },
2502+
{ runtimeMode: "approval-required", expectedBase: "default" },
2503+
{ runtimeMode: "auto-accept-edits", expectedBase: "acceptEdits" },
2504+
])(
2505+
"restores $expectedBase permission mode after plan turn ($runtimeMode)",
2506+
({ runtimeMode, expectedBase }) => {
2507+
const harness = makeHarness();
2508+
return Effect.gen(function* () {
2509+
const adapter = yield* ClaudeAdapter;
25032510

2504-
const session = yield* adapter.startSession({
2505-
threadId: THREAD_ID,
2506-
provider: "claudeAgent",
2507-
runtimeMode: "full-access",
2508-
});
2511+
const session = yield* adapter.startSession({
2512+
threadId: THREAD_ID,
2513+
provider: "claudeAgent",
2514+
runtimeMode,
2515+
});
25092516

2510-
// First turn in plan mode
2511-
yield* adapter.sendTurn({
2512-
threadId: session.threadId,
2513-
input: "plan this",
2514-
interactionMode: "plan",
2515-
attachments: [],
2516-
});
2517+
// First turn in plan mode
2518+
yield* adapter.sendTurn({
2519+
threadId: session.threadId,
2520+
input: "plan this",
2521+
interactionMode: "plan",
2522+
attachments: [],
2523+
});
25172524

2518-
// Complete the turn so we can send another
2519-
const turnCompletedFiber = yield* Stream.filter(
2520-
adapter.streamEvents,
2521-
(event) => event.type === "turn.completed",
2522-
).pipe(Stream.runHead, Effect.forkChild);
2525+
// Complete the turn so we can send another
2526+
const turnCompletedFiber = yield* Stream.filter(
2527+
adapter.streamEvents,
2528+
(event) => event.type === "turn.completed",
2529+
).pipe(Stream.runHead, Effect.forkChild);
25232530

2524-
harness.query.emit({
2525-
type: "result",
2526-
subtype: "success",
2527-
is_error: false,
2528-
errors: [],
2529-
session_id: "sdk-session-plan-restore",
2530-
uuid: "result-plan",
2531-
} as unknown as SDKMessage);
2531+
harness.query.emit({
2532+
type: "result",
2533+
subtype: "success",
2534+
is_error: false,
2535+
errors: [],
2536+
session_id: `sdk-session-${runtimeMode}`,
2537+
uuid: `result-${runtimeMode}`,
2538+
} as unknown as SDKMessage);
25322539

2533-
yield* Fiber.join(turnCompletedFiber);
2540+
yield* Fiber.join(turnCompletedFiber);
25342541

2535-
// Second turn back to default
2536-
yield* adapter.sendTurn({
2537-
threadId: session.threadId,
2538-
input: "now do it",
2539-
interactionMode: "default",
2540-
attachments: [],
2541-
});
2542+
// Second turn back to default
2543+
yield* adapter.sendTurn({
2544+
threadId: session.threadId,
2545+
input: "now do it",
2546+
interactionMode: "default",
2547+
attachments: [],
2548+
});
25422549

2543-
// First call sets "plan", second call restores "bypassPermissions" (the base for full-access)
2544-
assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", "bypassPermissions"]);
2545-
}).pipe(
2546-
Effect.provideService(Random.Random, makeDeterministicRandomService()),
2547-
Effect.provide(harness.layer),
2548-
);
2549-
});
2550+
assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", expectedBase]);
2551+
}).pipe(
2552+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
2553+
Effect.provide(harness.layer),
2554+
);
2555+
},
2556+
);
25502557

25512558
it.effect("does not call setPermissionMode when interactionMode is absent", () => {
25522559
const harness = makeHarness();

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2693,7 +2693,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
26932693
? modelSelection.options.thinking
26942694
: undefined;
26952695
const effectiveEffort = getEffectiveClaudeCodeEffort(effort);
2696-
const permissionMode = input.runtimeMode === "full-access" ? "bypassPermissions" : undefined;
2696+
const runtimeModeToPermission: Record<string, PermissionMode> = {
2697+
"auto-accept-edits": "acceptEdits",
2698+
"full-access": "bypassPermissions",
2699+
};
2700+
const permissionMode = runtimeModeToPermission[input.runtimeMode];
26972701
const settings = {
26982702
...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
26992703
...(fastMode ? { fastMode: true } : {}),
@@ -2881,8 +2885,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
28812885
});
28822886
} else if (input.interactionMode === "default") {
28832887
yield* Effect.tryPromise({
2884-
try: () =>
2885-
context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"),
2888+
try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"),
28862889
catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause),
28872890
});
28882891
}

apps/web/src/components/ChatView.browser.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,16 @@ async function waitForButtonContainingText(text: string): Promise<HTMLButtonElem
956956
);
957957
}
958958

959+
async function waitForSelectItemContainingText(text: string): Promise<HTMLElement> {
960+
return waitForElement(
961+
() =>
962+
Array.from(document.querySelectorAll<HTMLElement>('[data-slot="select-item"]')).find((item) =>
963+
item.textContent?.includes(text),
964+
) ?? null,
965+
`Unable to find select item containing "${text}".`,
966+
);
967+
}
968+
959969
async function expectComposerActionsContained(): Promise<void> {
960970
const footer = await waitForElement(
961971
() => document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'),
@@ -2326,6 +2336,32 @@ describe("ChatView timeline estimator parity (full app)", () => {
23262336
}
23272337
});
23282338

2339+
it("shows runtime mode descriptions in the desktop composer access select", async () => {
2340+
setDraftThreadWithoutWorktree();
2341+
2342+
const mounted = await mountChatView({
2343+
viewport: WIDE_FOOTER_VIEWPORT,
2344+
snapshot: createDraftOnlySnapshot(),
2345+
});
2346+
2347+
try {
2348+
const runtimeModeSelect = await waitForButtonByText("Full access");
2349+
runtimeModeSelect.click();
2350+
2351+
expect((await waitForSelectItemContainingText("Supervised")).textContent).toContain(
2352+
"Ask before commands and file changes",
2353+
);
2354+
2355+
const autoAcceptItem = await waitForSelectItemContainingText("Auto-accept edits");
2356+
expect(autoAcceptItem.textContent).toContain("Auto-approve edits");
2357+
expect((await waitForSelectItemContainingText("Full access")).textContent).toContain(
2358+
"Allow commands and edits without prompts",
2359+
);
2360+
} finally {
2361+
await mounted.cleanup();
2362+
}
2363+
});
2364+
23292365
it("keeps removed terminal context pills removed when a new one is added", async () => {
23302366
const removedLabel = "Terminal 1 lines 1-2";
23312367
const addedLabel = "Terminal 2 lines 9-10";

apps/web/src/components/ChatView.tsx

Lines changed: 61 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,13 @@ import {
115115
ListTodoIcon,
116116
LockIcon,
117117
LockOpenIcon,
118+
type LucideIcon,
119+
PenLineIcon,
118120
XIcon,
119121
} from "lucide-react";
120122
import { Button } from "./ui/button";
121123
import { Separator } from "./ui/separator";
124+
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
122125
import { cn, randomUUID } from "~/lib/utils";
123126
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
124127
import { toastManager } from "./ui/toast";
@@ -410,6 +413,29 @@ interface TerminalLaunchContext {
410413
worktreePath: string | null;
411414
}
412415

416+
const runtimeModeConfig: Record<
417+
RuntimeMode,
418+
{ label: string; description: string; icon: LucideIcon }
419+
> = {
420+
"approval-required": {
421+
label: "Supervised",
422+
description: "Ask before commands and file changes.",
423+
icon: LockIcon,
424+
},
425+
"auto-accept-edits": {
426+
label: "Auto-accept edits",
427+
description: "Auto-approve edits, ask before other actions.",
428+
icon: PenLineIcon,
429+
},
430+
"full-access": {
431+
label: "Full access",
432+
description: "Allow commands and edits without prompts.",
433+
icon: LockOpenIcon,
434+
},
435+
};
436+
437+
const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
438+
413439
type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "worktreePath">;
414440

415441
function useLocalDispatchState(input: {
@@ -960,6 +986,8 @@ export default function ChatView(props: ChatViewProps) {
960986
composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
961987
const interactionMode =
962988
composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
989+
const runtimeModeOption = runtimeModeConfig[runtimeMode];
990+
const RuntimeModeIcon = runtimeModeOption.icon;
963991
const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
964992
const canCheckoutPullRequestIntoThread = isLocalDraftThread;
965993
const diffOpen = rawSearch.diff === "1";
@@ -2350,11 +2378,6 @@ export default function ChatView(props: ChatViewProps) {
23502378
const toggleInteractionMode = useCallback(() => {
23512379
handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
23522380
}, [handleInteractionModeChange, interactionMode]);
2353-
const toggleRuntimeMode = useCallback(() => {
2354-
void handleRuntimeModeChange(
2355-
runtimeMode === "full-access" ? "approval-required" : "full-access",
2356-
);
2357-
}, [handleRuntimeModeChange, runtimeMode]);
23582381
const togglePlanSidebar = useCallback(() => {
23592382
setPlanSidebarOpen((open) => {
23602383
if (open) {
@@ -4651,7 +4674,7 @@ export default function ChatView(props: ChatViewProps) {
46514674
traitsMenuContent={providerTraitsMenuContent}
46524675
onToggleInteractionMode={toggleInteractionMode}
46534676
onTogglePlanSidebar={togglePlanSidebar}
4654-
onToggleRuntimeMode={toggleRuntimeMode}
4677+
onRuntimeModeChange={handleRuntimeModeChange}
46554678
/>
46564679
) : (
46574680
<>
@@ -4693,29 +4716,39 @@ export default function ChatView(props: ChatViewProps) {
46934716
className="mx-0.5 hidden h-4 sm:block"
46944717
/>
46954718

4696-
<Button
4697-
variant="ghost"
4698-
className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
4699-
size="sm"
4700-
type="button"
4701-
onClick={() =>
4702-
void handleRuntimeModeChange(
4703-
runtimeMode === "full-access"
4704-
? "approval-required"
4705-
: "full-access",
4706-
)
4707-
}
4708-
title={
4709-
runtimeMode === "full-access"
4710-
? "Full access — click to require approvals"
4711-
: "Approval required — click for full access"
4712-
}
4719+
<Select
4720+
value={runtimeMode}
4721+
onValueChange={(value) => handleRuntimeModeChange(value!)}
47134722
>
4714-
{runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
4715-
<span className="sr-only sm:not-sr-only">
4716-
{runtimeMode === "full-access" ? "Full access" : "Supervised"}
4717-
</span>
4718-
</Button>
4723+
<SelectTrigger
4724+
variant="ghost"
4725+
size="sm"
4726+
aria-label="Runtime mode"
4727+
title={runtimeModeOption.description}
4728+
>
4729+
<RuntimeModeIcon className="size-4" />
4730+
<SelectValue>{runtimeModeOption.label}</SelectValue>
4731+
</SelectTrigger>
4732+
<SelectPopup alignItemWithTrigger={false}>
4733+
{runtimeModeOptions.map((mode) => {
4734+
const option = runtimeModeConfig[mode];
4735+
const OptionIcon = option.icon;
4736+
return (
4737+
<SelectItem key={mode} value={mode} className="min-w-64 py-2">
4738+
<div className="grid min-w-0 gap-0.5">
4739+
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
4740+
<OptionIcon className="size-3.5 shrink-0 text-muted-foreground" />
4741+
{option.label}
4742+
</span>
4743+
<span className="text-muted-foreground text-xs leading-4">
4744+
{option.description}
4745+
</span>
4746+
</div>
4747+
</SelectItem>
4748+
);
4749+
})}
4750+
</SelectPopup>
4751+
</Select>
47194752

47204753
{activePlan || sidebarProposedPlan || planSidebarOpen ? (
47214754
<>

apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
138138
}
139139
onToggleInteractionMode={vi.fn()}
140140
onTogglePlanSidebar={vi.fn()}
141-
onToggleRuntimeMode={vi.fn()}
141+
onRuntimeModeChange={vi.fn()}
142142
/>,
143143
{ container: host },
144144
);

apps/web/src/components/chat/CompactComposerControlsMenu.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
2020
traitsMenuContent?: ReactNode;
2121
onToggleInteractionMode: () => void;
2222
onTogglePlanSidebar: () => void;
23-
onToggleRuntimeMode: () => void;
23+
onRuntimeModeChange: (mode: RuntimeMode) => void;
2424
}) {
2525
return (
2626
<Menu>
@@ -60,10 +60,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
6060
value={props.runtimeMode}
6161
onValueChange={(value) => {
6262
if (!value || value === props.runtimeMode) return;
63-
props.onToggleRuntimeMode();
63+
props.onRuntimeModeChange(value as RuntimeMode);
6464
}}
6565
>
6666
<MenuRadioItem value="approval-required">Supervised</MenuRadioItem>
67+
<MenuRadioItem value="auto-accept-edits">Auto-accept edits</MenuRadioItem>
6768
<MenuRadioItem value="full-access">Full access</MenuRadioItem>
6869
</MenuRadioGroup>
6970
{props.activePlan ? (

0 commit comments

Comments
 (0)