Skip to content

Commit 8318948

Browse files
committed
feat(agent): refactor permission capabilities and track thread errors
* Restructure agent capabilities to use a unified `bypassPermissions` schema supporting both approval policy and sandbox mode bypasses. * Update detection capabilities and defaults across all supervisor agents (Claude, Codex, Gemini, Copilot, Cursor, Opencode, Antigravity). * Update UI draft configurations and helpers to support the new capability system and preserve explicit user overrides. * Add an `errorMessage` field to the thread schema and thread slice state to capture and display runtime execution errors in the status header. * Add `--skip-git-repo-check` flags to Codex one-shot commands to support non-trust listed directories. * Enhance supervisor runtime error reporting using unified spawn failure descriptions.
1 parent 95786ca commit 8318948

24 files changed

Lines changed: 274 additions & 228 deletions

File tree

src/renderer/components/providers/claude/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ registerComposerControls("claude", ({ capabilities, config, isDisabled, onConfig
5252
hideLabelOnWrap: true,
5353
value:
5454
config.approvalPolicy ??
55-
capabilities.bypassApprovalPolicy ??
55+
capabilities.bypassPermissions?.approvalPolicy ??
5656
capabilities.approvalPolicies[0]?.id ??
5757
"default",
5858
isDisabled,

src/renderer/components/providers/codex/index.tsx

Lines changed: 58 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ export * from "./CodexStatusIcon";
22

33
import type { ComposerControl } from "@/renderer/components/thread/ThreadComposer";
44
import { CodexStatusIcon } from "./CodexStatusIcon";
5-
import { fullAccessToggle, planWorkToggle } from "../composerControlBuilders";
5+
import { planWorkToggle } from "../composerControlBuilders";
6+
import type { AgentCapability, ThreadConfig } from "@/shared/contracts";
67
import {
78
registerCommitGenDefaults,
89
registerComposerControls,
@@ -96,8 +97,8 @@ const CODEX_PERMISSION_PRESETS = [
9697
{
9798
id: "auto-review",
9899
label: "Auto-review",
99-
hint: "Review failures",
100-
approvalPolicies: ["on-failure"],
100+
hint: "Review on request",
101+
approvalPolicies: ["on-request"],
101102
sandboxModes: ["workspace-write"],
102103
},
103104
{
@@ -137,8 +138,45 @@ function isCodexPermissionPresetSelected(
137138
);
138139
}
139140

141+
function buildCodexPermissionControl(
142+
capabilities: AgentCapability,
143+
config: ThreadConfig,
144+
isDisabled: boolean,
145+
onConfigChange: (patch: Partial<ThreadConfig>) => void,
146+
): ComposerControl | null {
147+
const approvalIds = new Set(capabilities.approvalPolicies.map((policy) => policy.id));
148+
const sandboxIds = new Set(capabilities.sandboxModes.map((mode) => mode.id));
149+
const permissionPresets = CODEX_PERMISSION_PRESETS.flatMap((preset) => {
150+
const resolved = resolveCodexPermissionPreset(preset, approvalIds, sandboxIds);
151+
return resolved ? [{ ...preset, ...resolved }] : [];
152+
});
153+
if (permissionPresets.length === 0) return null;
154+
const current =
155+
permissionPresets.find((preset) => isCodexPermissionPresetSelected(preset, config)) ??
156+
permissionPresets[0]!;
157+
return {
158+
iconKind: "permission",
159+
options: permissionPresets.map((preset) => ({
160+
id: preset.id,
161+
label: preset.label,
162+
hint: preset.hint,
163+
})),
164+
hideLabelOnWrap: true,
165+
value: current.id,
166+
isDisabled,
167+
onChange: (value: string) => {
168+
const preset = permissionPresets.find((option) => option.id === value);
169+
if (!preset) return;
170+
onConfigChange({
171+
approvalPolicy: preset.approvalPolicy,
172+
sandboxMode: preset.sandboxMode,
173+
});
174+
},
175+
};
176+
}
177+
140178
registerComposerControls("codex", {
141-
// ACP exposes plan mode and the coupled approval/sandbox preset selector.
179+
// ACP exposes plan mode in addition to the preset selector.
142180
gui: ({ capabilities, config, isDisabled, onConfigChange }) => {
143181
const isPlanMode = (config.mode ?? "agent") !== "agent";
144182
const controls: ComposerControl[] = [
@@ -148,59 +186,24 @@ registerComposerControls("codex", {
148186
onChange: (isSelected) => onConfigChange({ mode: isSelected ? "plan" : "agent" }),
149187
}),
150188
];
151-
152-
const approvalIds = new Set(capabilities.approvalPolicies.map((policy) => policy.id));
153-
const sandboxIds = new Set(capabilities.sandboxModes.map((mode) => mode.id));
154-
const permissionPresets = CODEX_PERMISSION_PRESETS.flatMap((preset) => {
155-
const resolved = resolveCodexPermissionPreset(preset, approvalIds, sandboxIds);
156-
return resolved ? [{ ...preset, ...resolved }] : [];
157-
});
158-
if (permissionPresets.length > 0) {
159-
const currentPermissionPreset =
160-
permissionPresets.find((preset) => isCodexPermissionPresetSelected(preset, config)) ??
161-
permissionPresets[0]!;
162-
controls.push({
163-
iconKind: "permission",
164-
options: permissionPresets.map((preset) => ({
165-
id: preset.id,
166-
label: preset.label,
167-
hint: preset.hint,
168-
})),
169-
hideLabelOnWrap: true,
170-
value: currentPermissionPreset.id,
171-
isDisabled,
172-
onChange: (value) => {
173-
const preset = permissionPresets.find((option) => option.id === value);
174-
if (!preset) return;
175-
onConfigChange({
176-
approvalPolicy: preset.approvalPolicy,
177-
sandboxMode: preset.sandboxMode,
178-
});
179-
},
180-
});
181-
}
189+
const permission = buildCodexPermissionControl(
190+
capabilities,
191+
config,
192+
isDisabled,
193+
onConfigChange,
194+
);
195+
if (permission) controls.push(permission);
182196
return controls;
183197
},
184-
// Terminal CLI ignores `mode: "plan"` and exposes a single Full Access
185-
// toggle instead of the paired approval/sandbox selector.
198+
// Terminal CLI ignores `mode: "plan"` but uses the same preset selector
199+
// as GUI for permissions so both surfaces stay in lockstep.
186200
terminal: ({ capabilities, config, isDisabled, onConfigChange }) => {
187-
const hasPermissions =
188-
capabilities.approvalPolicies.length > 0 || capabilities.sandboxModes.length > 0;
189-
if (!hasPermissions) return [];
190-
const isFullAccess =
191-
config.approvalPolicy === "never" && config.sandboxMode === "danger-full-access";
192-
return [
193-
fullAccessToggle({
194-
isFullAccess,
195-
isDisabled,
196-
onChange: (selected) => {
197-
if (selected) {
198-
onConfigChange({ approvalPolicy: "never", sandboxMode: "danger-full-access" });
199-
} else {
200-
onConfigChange({ approvalPolicy: "", sandboxMode: "" });
201-
}
202-
},
203-
}),
204-
];
201+
const permission = buildCodexPermissionControl(
202+
capabilities,
203+
config,
204+
isDisabled,
205+
onConfigChange,
206+
);
207+
return permission ? [permission] : [];
205208
},
206209
});

src/renderer/components/thread/ContinueInProviderDialog.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,18 @@ function resolveModeValue(
9898
: (capabilities.modes[0] ?? undefined);
9999
}
100100

101-
function resolveApprovalPolicyValue(capabilities: AgentCapability, preferred?: string): string {
102-
const policies = capabilities.approvalPolicies;
103-
return preferred && policies.some((p) => p.id === preferred)
104-
? preferred
105-
: (capabilities.bypassApprovalPolicy ?? policies[0]?.id ?? "");
106-
}
107-
108-
function resolveSandboxModeValue(capabilities: AgentCapability, preferred?: string): string {
109-
const modes = capabilities.sandboxModes;
110-
return preferred && modes.some((m) => m.id === preferred) ? preferred : (modes[0]?.id ?? "");
101+
function resolveLabeledOptionValue(
102+
options: ReadonlyArray<{ id: string }>,
103+
preferred: string | undefined,
104+
bypass: string | undefined,
105+
): string {
106+
if (preferred !== undefined) {
107+
return options.some((o) => o.id === preferred) ? preferred : "";
108+
}
109+
if (bypass && options.some((o) => o.id === bypass)) {
110+
return bypass;
111+
}
112+
return options[0]?.id ?? "";
111113
}
112114

113115
function resolveDefaultConfig(
@@ -124,8 +126,16 @@ function resolveDefaultConfig(
124126
? preferred?.thinking === true
125127
: false;
126128
const mode = resolveModeValue(capabilities, preferred?.mode);
127-
const approvalPolicy = resolveApprovalPolicyValue(capabilities, preferred?.approvalPolicy);
128-
const sandboxMode = resolveSandboxModeValue(capabilities, preferred?.sandboxMode);
129+
const approvalPolicy = resolveLabeledOptionValue(
130+
capabilities.approvalPolicies,
131+
preferred?.approvalPolicy,
132+
capabilities.bypassPermissions?.approvalPolicy,
133+
);
134+
const sandboxMode = resolveLabeledOptionValue(
135+
capabilities.sandboxModes,
136+
preferred?.sandboxMode,
137+
capabilities.bypassPermissions?.sandboxMode,
138+
);
129139

130140
return {
131141
model,

src/renderer/components/thread/ThreadDraftView.test.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ const codexStatus: AgentStatus = {
8888
{ id: "read-only", label: "Read Only" },
8989
{ id: "danger-full-access", label: "Full Access" },
9090
],
91+
defaultApprovalPolicy: "on-request",
92+
defaultSandboxMode: "workspace-write",
9193
supportsResume: true,
9294
supportsDirectInput: true,
9395
liveInputMode: "server",
@@ -127,6 +129,7 @@ const geminiStatus: AgentStatus = {
127129
supportsDirectInput: true,
128130
liveInputMode: "terminal",
129131
presentationMode: "terminal",
132+
defaultApprovalPolicy: "never",
130133
settingDefs: [],
131134
},
132135
};
@@ -151,7 +154,7 @@ const antigravityStatus: AgentStatus = {
151154
liveInputMode: "terminal",
152155
presentationMode: "terminal",
153156
presentationModes: ["terminal"],
154-
bypassApprovalPolicy: "yolo",
157+
bypassPermissions: { approvalPolicy: "yolo" },
155158
settingDefs: [],
156159
},
157160
};
@@ -178,7 +181,8 @@ const claudeStatus: AgentStatus = {
178181
supportsDirectInput: true,
179182
liveInputMode: "terminal",
180183
presentationMode: "terminal",
181-
bypassApprovalPolicy: "auto",
184+
defaultApprovalPolicy: "auto",
185+
bypassPermissions: { approvalPolicy: "auto" },
182186
settingDefs: [],
183187
},
184188
};
@@ -402,7 +406,7 @@ describe("ThreadDraftView", () => {
402406
expect(providerModel?.currentModel).toBe("gpt-5.4");
403407
const effortContext = props.controls.find((c) => c.kind === "effort-context");
404408
expect(effortContext?.effortValue).toBe("high");
405-
expect(props.controls.some((control) => control.value === "default-permissions")).toBe(true);
409+
expect(props.controls.some((control) => control.value === "auto-review")).toBe(true);
406410
});
407411

408412
fireEvent.click(screen.getByText("set-prompt"));
@@ -414,13 +418,15 @@ describe("ThreadDraftView", () => {
414418
model: "gpt-5.4",
415419
effort: "high",
416420
mode: "agent",
421+
approvalPolicy: "on-request",
422+
sandboxMode: "workspace-write",
417423
},
418424
presentationMode: "gui",
419425
prompt: "hello world",
420426
});
421427
});
422428

423-
it("defaults Home Codex drafts to full access without changing project defaults", async () => {
429+
it("defaults Home Codex drafts to provider defaults, same as any other project", async () => {
424430
const onStart = vi.fn<(input: unknown) => void>();
425431
useSharedSettings.setState({
426432
providerConfigs: {
@@ -454,7 +460,7 @@ describe("ThreadDraftView", () => {
454460
const providerModel = props.controls.find((c) => c.kind === "provider-model");
455461
expect(providerModel?.currentAgentKind).toBe("codex");
456462
expect(providerModel?.currentModel).toBe("gpt-5.4");
457-
expect(props.controls.some((control) => control.value === "full-access")).toBe(true);
463+
expect(props.controls.some((control) => control.value === "auto-review")).toBe(true);
458464
});
459465

460466
fireEvent.click(screen.getByText("set-prompt"));
@@ -466,14 +472,12 @@ describe("ThreadDraftView", () => {
466472
model: "gpt-5.4",
467473
effort: "high",
468474
mode: "agent",
469-
approvalPolicy: "never",
470-
sandboxMode: "danger-full-access",
475+
approvalPolicy: "on-request",
476+
sandboxMode: "workspace-write",
471477
},
472478
presentationMode: "gui",
473479
prompt: "hello world",
474480
});
475-
expect(useSharedSettings.getState().providerConfigs.codex?.approvalPolicy).toBe("");
476-
expect(useSharedSettings.getState().providerConfigs.codex?.sandboxMode).toBe("");
477481
});
478482

479483
it("defaults synthetic generic ACP permissions to supervised", async () => {

src/renderer/components/thread/ThreadDraftView.tsx

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,7 @@ export function ThreadDraftView(props: {
260260
lastDraftConfig,
261261
isHomeScope ? {} : providerConfigsRef.current,
262262
);
263-
const resolved = resolveProviderDraftConfig(selectedAgentForConfig, saved, {
264-
preferUnrestrictedPermissions: isHomeScope,
265-
});
263+
const resolved = resolveProviderDraftConfig(selectedAgentForConfig, saved);
266264
const nextModel = resolved.model;
267265
const nextEffort = resolved.effort ?? "";
268266
const nextContext = resolved.contextSize;
@@ -527,20 +525,16 @@ export function ThreadDraftView(props: {
527525
}
528526
if (!selectedAgentForConfig) return;
529527
hasLocalConfigEditRef.current = true;
530-
const resolved = resolveProviderDraftConfig(
531-
selectedAgentForConfig,
532-
{
533-
model: patch.model ?? model,
534-
effort: patch.effort ?? effort,
535-
...(patch.contextSize !== undefined ? { contextSize: patch.contextSize } : { contextSize }),
536-
...(patch.fast !== undefined ? { fast: patch.fast } : { fast }),
537-
...(patch.thinking !== undefined ? { thinking: patch.thinking } : { thinking }),
538-
mode: patch.mode ?? mode,
539-
approvalPolicy: patch.approvalPolicy ?? approvalPolicy,
540-
sandboxMode: patch.sandboxMode ?? sandboxMode,
541-
},
542-
{ preferUnrestrictedPermissions: isHomeScope },
543-
);
528+
const resolved = resolveProviderDraftConfig(selectedAgentForConfig, {
529+
model: patch.model ?? model,
530+
effort: patch.effort ?? effort,
531+
...(patch.contextSize !== undefined ? { contextSize: patch.contextSize } : { contextSize }),
532+
...(patch.fast !== undefined ? { fast: patch.fast } : { fast }),
533+
...(patch.thinking !== undefined ? { thinking: patch.thinking } : { thinking }),
534+
mode: patch.mode ?? mode,
535+
approvalPolicy: patch.approvalPolicy ?? approvalPolicy,
536+
sandboxMode: patch.sandboxMode ?? sandboxMode,
537+
});
544538

545539
setModel(resolved.model);
546540
setEffort(resolved.effort ?? "");
@@ -604,14 +598,10 @@ export function ThreadDraftView(props: {
604598
persistProviderConfig(effectiveAgentKind, snapshot);
605599
}
606600
const targetSaved = isHomeScope ? undefined : providerConfigsRef.current[nextKind];
607-
const resolved = resolveProviderDraftConfig(
608-
targetAgentForConfig,
609-
{
610-
...(targetSaved ?? {}),
611-
model: nextModel,
612-
},
613-
{ preferUnrestrictedPermissions: isHomeScope },
614-
);
601+
const resolved = resolveProviderDraftConfig(targetAgentForConfig, {
602+
...(targetSaved ?? {}),
603+
model: nextModel,
604+
});
615605
persistProviderConfig(nextKind, resolved);
616606
setModel(resolved.model);
617607
setEffort(resolved.effort ?? "");

src/renderer/components/thread/ThreadHeaderStatus.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export function ThreadHeaderStatusTooltipBody(props: { thread: Thread }) {
6060
const runtime = threadRuntimeStatusLabel(thread);
6161
const source = thread.threadStatusSource;
6262
const isServer = source === "server";
63+
const errorMessage = thread.status === "error" ? thread.errorMessage?.trim() : undefined;
6364

6465
return (
6566
<div className="w-[min(22rem,calc(100vw-2rem))] space-y-3 py-3 pl-2 pr-5 [overflow-wrap:break-word] [word-break:normal] hyphens-none">
@@ -79,9 +80,15 @@ export function ThreadHeaderStatusTooltipBody(props: { thread: Thread }) {
7980
</p>
8081
)}
8182
</div>
82-
<p className="border-t border-border/60 pt-2.5 text-xs leading-snug text-muted [overflow-wrap:break-word] [word-break:normal] hyphens-none">
83-
{threadStatusSupportDetail(source)}
84-
</p>
83+
{errorMessage ? (
84+
<p className="max-h-32 overflow-y-auto whitespace-pre-wrap break-words border-t border-border/60 pt-2.5 text-xs leading-snug text-danger">
85+
{errorMessage}
86+
</p>
87+
) : (
88+
<p className="border-t border-border/60 pt-2.5 text-xs leading-snug text-muted [overflow-wrap:break-word] [word-break:normal] hyphens-none">
89+
{threadStatusSupportDetail(source)}
90+
</p>
91+
)}
8592
</div>
8693
);
8794
}

0 commit comments

Comments
 (0)