History

fix: map runtime modes to correct permission levels (#1587)

Oskar Sekutowicz committed 7 months ago

Commit 73721841 parent d9ded659 files · +189 −105Browse files

Unified · Split

9 files
  1. apps/server/src/codexAppServerManager.ts +18 −12
  2. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +51 −44
  3. apps/server/src/provider/Layers/ClaudeAdapter.ts +6 −3
  4. apps/web/src/components/ChatView.browser.tsx +36 −0
  5. apps/web/src/components/ChatView.tsx +61 −28
  6. apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +1 −1
  7. apps/web/src/components/chat/CompactComposerControlsMenu.tsx +3 −2
  8. apps/web/src/composerDraftStore.ts +8 −14
  9. packages/contracts/src/orchestration.ts +5 −1
apps/server/src/codexAppServerManager.ts+18−12
@@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the · 289 unmodified lines
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+51−44
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
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", () => { · 2476 unmodified lines
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* ( · 2692 unmodified lines
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* ( · 181 unmodified lines
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−0
@@ -956,6 +956,16 @@ async function waitForButtonContainingText(text: string): Promise<HTMLButtonElem · 955 unmodified lines
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)", () => { · 1364 unmodified lines
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 { · 114 unmodified lines
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 { · 285 unmodified lines
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) { · 544 unmodified lines
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) { · 1384 unmodified lines
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) { · 2290 unmodified lines
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) { · 35 unmodified lines
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 · 137 unmodified lines
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 · 19 unmodified lines
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 · 33 unmodified lines
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"; · 44 unmodified lines
4545 export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
4646 const COMPOSER_DRAFT_STORAGE_VERSION = 5;
4747 const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
48+const isRuntimeMode = Schema.is(RuntimeMode);
4849 export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
4950
5051 export const DraftId = Schema.String.pipe(Schema.brand("DraftId"));
@@ -1195,11 +1196,9 @@ function normalizePersistedDraftThreads( · 1144 unmodified lines
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( · 112 unmodified lines
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>()( · 1044 unmodified lines
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; · 58 unmodified lines
5959 export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]);
6060 export type ModelSelection = typeof ModelSelection.Type;
6161
62−export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
62+export const RuntimeMode = Schema.Literals([
63+ "approval-required",
64+ "auto-accept-edits",
65+ "full-access",
66+]);
6367 export type RuntimeMode = typeof RuntimeMode.Type;
6468 export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6569 export const ProviderInteractionMode = Schema.Literals(["default", "plan"]);