Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the
</collaboration_mode>`;

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",
};
}

/**
Expand Down
93 changes: 49 additions & 44 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`, () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it.each or similar

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 🙌

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();
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, PermissionMode> = {
"auto-accept-edits": "acceptEdits",
"full-access": "bypassPermissions",
};
const permissionMode = runtimeModeToPermission[input.runtimeMode];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implicit undefined for approval-required lacks exhaustiveness safety

Low Severity

The runtimeModeToPermission map is typed as Record<string, PermissionMode> and intentionally omits "approval-required", relying on undefined to propagate through truthy checks and ?? "default" fallbacks. Unlike the Codex adapter's mapCodexRuntimeMode switch statement which gets TypeScript exhaustiveness checking, this string-keyed Record provides no compile-time guarantee that all RuntimeMode values are accounted for. If a new runtime mode is ever added, it would silently fall through to undefined / "default" behavior without any compiler warning. Making this a complete map over RuntimeMode (mapping "approval-required" explicitly to "default") would make the intent clear and get exhaustiveness checking from TypeScript.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6707b61. Configure here.

const settings = {
...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
...(fastMode ? { fastMode: true } : {}),
Expand Down Expand Up @@ -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),
});
}
Expand Down
51 changes: 32 additions & 19 deletions apps/web/src/components/ChatView.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
ListTodoIcon,
LockIcon,
LockOpenIcon,
PenLineIcon,
XIcon,
} from "lucide-react";
import { Button } from "./ui/button";
Expand Down Expand Up @@ -332,6 +333,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: <LockIcon />,
next: "auto-accept-edits",
},
"auto-accept-edits": {
label: "Auto-accept edits",
title: "Auto-accept edits - click for full access",
icon: <PenLineIcon />,
next: "full-access",
},
"full-access": {
label: "Full access",
title: "Full access - click for supervised",
icon: <LockOpenIcon />,
next: "approval-required",
},
};

function useLocalDispatchState(input: {
activeThread: Thread | undefined;
activeLatestTurn: Thread["latestTurn"] | null;
Expand Down Expand Up @@ -1811,10 +1836,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(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is a toggle button the best way? A select with a short description might be better?

https://coss.com/ui/docs/components/select#with-object-values

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah maybe, let me check how it would look and I'll post a screenshot here so we can decide.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CleanShot 2026-04-06 at 21 01 02
CleanShot.2026-04-06.at.21.02.31.mp4

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could work, wdyt?

void handleRuntimeModeChange(runtimeModeConfig[runtimeMode].next);
}, [handleRuntimeModeChange, runtimeMode]);
const togglePlanSidebar = useCallback(() => {
setPlanSidebarOpen((open) => {
Expand Down Expand Up @@ -4015,7 +4038,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
traitsMenuContent={providerTraitsMenuContent}
onToggleInteractionMode={toggleInteractionMode}
onTogglePlanSidebar={togglePlanSidebar}
onToggleRuntimeMode={toggleRuntimeMode}
onRuntimeModeChange={handleRuntimeModeChange}
/>
) : (
<>
Expand Down Expand Up @@ -4062,22 +4085,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" ? <LockOpenIcon /> : <LockIcon />}
{runtimeModeConfig[runtimeMode].icon}
<span className="sr-only sm:not-sr-only">
{runtimeMode === "full-access" ? "Full access" : "Supervised"}
{runtimeModeConfig[runtimeMode].label}
</span>
</Button>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
traitsMenuContent?: ReactNode;
onToggleInteractionMode: () => void;
onTogglePlanSidebar: () => void;
onToggleRuntimeMode: () => void;
onRuntimeModeChange: (mode: RuntimeMode) => void;
}) {
return (
<Menu>
Expand Down Expand Up @@ -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);
}}
>
<MenuRadioItem value="approval-required">Supervised</MenuRadioItem>
<MenuRadioItem value="auto-accept-edits">Auto-accept edits</MenuRadioItem>
<MenuRadioItem value="full-access">Full access</MenuRadioItem>
</MenuRadioGroup>
{props.activePlan ? (
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1801,8 +1801,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
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) {
Expand Down
6 changes: 5 additions & 1 deletion packages/contracts/src/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
Comment thread
cursor[bot] marked this conversation as resolved.
export type RuntimeMode = typeof RuntimeMode.Type;
export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
export const ProviderInteractionMode = Schema.Literals(["default", "plan"]);
Expand Down
Loading