authorgravatar for me.oski646@gmail.comOskar Sekutowicz <me.oski646@gmail.com> 2026-04-09 20:10:05+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 18:10:05+00:00
log7372184d06646dde603e5fd92bc23d3f35ae8d10
tree2e2679de6bf398fac4139df12d8126e043ec796f
parentd9ded65dfef01156056d99dedfe2265d0cb5ea59
signature Signed by PGP key B5690EEEBB952194

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>

9 files changed, 189 insertions(+), 105 deletions(-)

apps/server/src/codexAppServerManager.ts+18-12
......@@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the
290290</collaboration_mode>`;
291291
292292function 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+51-44
......@@ -14,6 +14,7 @@ import {
1414 ApprovalRequestId,
1515 ProviderItemId,
1616 ProviderRuntimeEvent,
17 type RuntimeMode,
1718 ThreadId,
1819} from "@t3tools/contracts";
1920import { 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+6-3
......@@ -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+36
......@@ -956,6 +956,16 @@ async function waitForButtonContainingText(text: string): Promise<HTMLButtonElem
956956 );
957957}
958958
959async 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
959969async 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+61-28
......@@ -115,10 +115,13 @@ import {
115115 ListTodoIcon,
116116 LockIcon,
117117 LockOpenIcon,
118 type LucideIcon,
119 PenLineIcon,
118120 XIcon,
119121} from "lucide-react";
120122import { Button } from "./ui/button";
121123import { Separator } from "./ui/separator";
124import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
122125import { cn, randomUUID } from "~/lib/utils";
123126import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
124127import { toastManager } from "./ui/toast";
......@@ -410,6 +413,29 @@ interface TerminalLaunchContext {
410413 worktreePath: string | null;
411414}
412415
416const 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
437const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
438
413439type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "worktreePath">;
414440
415441function 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+1-1
......@@ -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+3-2
......@@ -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 ? (
apps/web/src/composerDraftStore.ts+8-14
......@@ -45,6 +45,7 @@ import { UnifiedSettings } from "@t3tools/contracts/settings";
4545export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
4646const COMPOSER_DRAFT_STORAGE_VERSION = 5;
4747const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
48const isRuntimeMode = Schema.is(RuntimeMode);
4849export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
4950
5051export const DraftId = Schema.String.pipe(Schema.brand("DraftId"));
......@@ -1195,11 +1196,9 @@ function normalizePersistedDraftThreads(
11951196 typeof createdAt === "string" && createdAt.length > 0
11961197 ? createdAt
11971198 : new Date().toISOString(),
1198 runtimeMode:
1199 candidateDraftThread.runtimeMode === "approval-required" ||
1200 candidateDraftThread.runtimeMode === "full-access"
1201 ? candidateDraftThread.runtimeMode
1202 : DEFAULT_RUNTIME_MODE,
1199 runtimeMode: isRuntimeMode(candidateDraftThread.runtimeMode)
1200 ? candidateDraftThread.runtimeMode
1201 : DEFAULT_RUNTIME_MODE,
12031202 interactionMode:
12041203 candidateDraftThread.interactionMode === "plan" ||
12051204 candidateDraftThread.interactionMode === "default"
......@@ -1318,11 +1317,9 @@ function normalizePersistedDraftsByThreadId(
13181317 return normalized ? [normalized] : [];
13191318 })
13201319 : [];
1321 const runtimeMode =
1322 draftCandidate.runtimeMode === "approval-required" ||
1323 draftCandidate.runtimeMode === "full-access"
1324 ? draftCandidate.runtimeMode
1325 : null;
1320 const runtimeMode = isRuntimeMode(draftCandidate.runtimeMode)
1321 ? draftCandidate.runtimeMode
1322 : null;
13261323 const interactionMode =
13271324 draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default"
13281325 ? draftCandidate.interactionMode
......@@ -2373,10 +2370,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23732370 if (threadKey.length === 0) {
23742371 return;
23752372 }
2376 const nextRuntimeMode =
2377 runtimeMode === "approval-required" || runtimeMode === "full-access"
2378 ? runtimeMode
2379 : null;
2373 const nextRuntimeMode = isRuntimeMode(runtimeMode) ? runtimeMode : null;
23802374 set((state) => {
23812375 const existing = state.draftsByThreadKey[threadKey];
23822376 if (!existing && nextRuntimeMode === null) {
packages/contracts/src/orchestration.ts+5-1
......@@ -59,7 +59,11 @@ export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
5959export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]);
6060export type ModelSelection = typeof ModelSelection.Type;
6161
62export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
62export const RuntimeMode = Schema.Literals([
63 "approval-required",
64 "auto-accept-edits",
65 "full-access",
66]);
6367export type RuntimeMode = typeof RuntimeMode.Type;
6468export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6569export const ProviderInteractionMode = Schema.Literals(["default", "plan"]);