diff --git a/src/renderer/components/providers/claude/index.tsx b/src/renderer/components/providers/claude/index.tsx index 83942b9..d0fd4ad 100644 --- a/src/renderer/components/providers/claude/index.tsx +++ b/src/renderer/components/providers/claude/index.tsx @@ -52,7 +52,7 @@ registerComposerControls("claude", ({ capabilities, config, isDisabled, onConfig hideLabelOnWrap: true, value: config.approvalPolicy ?? - capabilities.bypassApprovalPolicy ?? + capabilities.bypassPermissions?.approvalPolicy ?? capabilities.approvalPolicies[0]?.id ?? "default", isDisabled, diff --git a/src/renderer/components/providers/codex/index.tsx b/src/renderer/components/providers/codex/index.tsx index f9700c1..506b074 100644 --- a/src/renderer/components/providers/codex/index.tsx +++ b/src/renderer/components/providers/codex/index.tsx @@ -2,7 +2,8 @@ export * from "./CodexStatusIcon"; import type { ComposerControl } from "@/renderer/components/thread/ThreadComposer"; import { CodexStatusIcon } from "./CodexStatusIcon"; -import { fullAccessToggle, planWorkToggle } from "../composerControlBuilders"; +import { planWorkToggle } from "../composerControlBuilders"; +import type { AgentCapability, ThreadConfig } from "@/shared/contracts"; import { registerCommitGenDefaults, registerComposerControls, @@ -96,8 +97,8 @@ const CODEX_PERMISSION_PRESETS = [ { id: "auto-review", label: "Auto-review", - hint: "Review failures", - approvalPolicies: ["on-failure"], + hint: "Review on request", + approvalPolicies: ["on-request"], sandboxModes: ["workspace-write"], }, { @@ -137,8 +138,45 @@ function isCodexPermissionPresetSelected( ); } +function buildCodexPermissionControl( + capabilities: AgentCapability, + config: ThreadConfig, + isDisabled: boolean, + onConfigChange: (patch: Partial) => void, +): ComposerControl | null { + const approvalIds = new Set(capabilities.approvalPolicies.map((policy) => policy.id)); + const sandboxIds = new Set(capabilities.sandboxModes.map((mode) => mode.id)); + const permissionPresets = CODEX_PERMISSION_PRESETS.flatMap((preset) => { + const resolved = resolveCodexPermissionPreset(preset, approvalIds, sandboxIds); + return resolved ? [{ ...preset, ...resolved }] : []; + }); + if (permissionPresets.length === 0) return null; + const current = + permissionPresets.find((preset) => isCodexPermissionPresetSelected(preset, config)) ?? + permissionPresets[0]!; + return { + iconKind: "permission", + options: permissionPresets.map((preset) => ({ + id: preset.id, + label: preset.label, + hint: preset.hint, + })), + hideLabelOnWrap: true, + value: current.id, + isDisabled, + onChange: (value: string) => { + const preset = permissionPresets.find((option) => option.id === value); + if (!preset) return; + onConfigChange({ + approvalPolicy: preset.approvalPolicy, + sandboxMode: preset.sandboxMode, + }); + }, + }; +} + registerComposerControls("codex", { - // ACP exposes plan mode and the coupled approval/sandbox preset selector. + // ACP exposes plan mode in addition to the preset selector. gui: ({ capabilities, config, isDisabled, onConfigChange }) => { const isPlanMode = (config.mode ?? "agent") !== "agent"; const controls: ComposerControl[] = [ @@ -148,59 +186,24 @@ registerComposerControls("codex", { onChange: (isSelected) => onConfigChange({ mode: isSelected ? "plan" : "agent" }), }), ]; - - const approvalIds = new Set(capabilities.approvalPolicies.map((policy) => policy.id)); - const sandboxIds = new Set(capabilities.sandboxModes.map((mode) => mode.id)); - const permissionPresets = CODEX_PERMISSION_PRESETS.flatMap((preset) => { - const resolved = resolveCodexPermissionPreset(preset, approvalIds, sandboxIds); - return resolved ? [{ ...preset, ...resolved }] : []; - }); - if (permissionPresets.length > 0) { - const currentPermissionPreset = - permissionPresets.find((preset) => isCodexPermissionPresetSelected(preset, config)) ?? - permissionPresets[0]!; - controls.push({ - iconKind: "permission", - options: permissionPresets.map((preset) => ({ - id: preset.id, - label: preset.label, - hint: preset.hint, - })), - hideLabelOnWrap: true, - value: currentPermissionPreset.id, - isDisabled, - onChange: (value) => { - const preset = permissionPresets.find((option) => option.id === value); - if (!preset) return; - onConfigChange({ - approvalPolicy: preset.approvalPolicy, - sandboxMode: preset.sandboxMode, - }); - }, - }); - } + const permission = buildCodexPermissionControl( + capabilities, + config, + isDisabled, + onConfigChange, + ); + if (permission) controls.push(permission); return controls; }, - // Terminal CLI ignores `mode: "plan"` and exposes a single Full Access - // toggle instead of the paired approval/sandbox selector. + // Terminal CLI ignores `mode: "plan"` but uses the same preset selector + // as GUI for permissions so both surfaces stay in lockstep. terminal: ({ capabilities, config, isDisabled, onConfigChange }) => { - const hasPermissions = - capabilities.approvalPolicies.length > 0 || capabilities.sandboxModes.length > 0; - if (!hasPermissions) return []; - const isFullAccess = - config.approvalPolicy === "never" && config.sandboxMode === "danger-full-access"; - return [ - fullAccessToggle({ - isFullAccess, - isDisabled, - onChange: (selected) => { - if (selected) { - onConfigChange({ approvalPolicy: "never", sandboxMode: "danger-full-access" }); - } else { - onConfigChange({ approvalPolicy: "", sandboxMode: "" }); - } - }, - }), - ]; + const permission = buildCodexPermissionControl( + capabilities, + config, + isDisabled, + onConfigChange, + ); + return permission ? [permission] : []; }, }); diff --git a/src/renderer/components/thread/ContinueInProviderDialog.tsx b/src/renderer/components/thread/ContinueInProviderDialog.tsx index fc7ad45..4f8d9ef 100644 --- a/src/renderer/components/thread/ContinueInProviderDialog.tsx +++ b/src/renderer/components/thread/ContinueInProviderDialog.tsx @@ -98,16 +98,18 @@ function resolveModeValue( : (capabilities.modes[0] ?? undefined); } -function resolveApprovalPolicyValue(capabilities: AgentCapability, preferred?: string): string { - const policies = capabilities.approvalPolicies; - return preferred && policies.some((p) => p.id === preferred) - ? preferred - : (capabilities.bypassApprovalPolicy ?? policies[0]?.id ?? ""); -} - -function resolveSandboxModeValue(capabilities: AgentCapability, preferred?: string): string { - const modes = capabilities.sandboxModes; - return preferred && modes.some((m) => m.id === preferred) ? preferred : (modes[0]?.id ?? ""); +function resolveLabeledOptionValue( + options: ReadonlyArray<{ id: string }>, + preferred: string | undefined, + bypass: string | undefined, +): string { + if (preferred !== undefined) { + return options.some((o) => o.id === preferred) ? preferred : ""; + } + if (bypass && options.some((o) => o.id === bypass)) { + return bypass; + } + return options[0]?.id ?? ""; } function resolveDefaultConfig( @@ -124,8 +126,16 @@ function resolveDefaultConfig( ? preferred?.thinking === true : false; const mode = resolveModeValue(capabilities, preferred?.mode); - const approvalPolicy = resolveApprovalPolicyValue(capabilities, preferred?.approvalPolicy); - const sandboxMode = resolveSandboxModeValue(capabilities, preferred?.sandboxMode); + const approvalPolicy = resolveLabeledOptionValue( + capabilities.approvalPolicies, + preferred?.approvalPolicy, + capabilities.bypassPermissions?.approvalPolicy, + ); + const sandboxMode = resolveLabeledOptionValue( + capabilities.sandboxModes, + preferred?.sandboxMode, + capabilities.bypassPermissions?.sandboxMode, + ); return { model, diff --git a/src/renderer/components/thread/ThreadDraftView.test.tsx b/src/renderer/components/thread/ThreadDraftView.test.tsx index a8733dc..88a3e63 100644 --- a/src/renderer/components/thread/ThreadDraftView.test.tsx +++ b/src/renderer/components/thread/ThreadDraftView.test.tsx @@ -88,6 +88,8 @@ const codexStatus: AgentStatus = { { id: "read-only", label: "Read Only" }, { id: "danger-full-access", label: "Full Access" }, ], + defaultApprovalPolicy: "on-request", + defaultSandboxMode: "workspace-write", supportsResume: true, supportsDirectInput: true, liveInputMode: "server", @@ -127,6 +129,7 @@ const geminiStatus: AgentStatus = { supportsDirectInput: true, liveInputMode: "terminal", presentationMode: "terminal", + defaultApprovalPolicy: "never", settingDefs: [], }, }; @@ -151,7 +154,7 @@ const antigravityStatus: AgentStatus = { liveInputMode: "terminal", presentationMode: "terminal", presentationModes: ["terminal"], - bypassApprovalPolicy: "yolo", + bypassPermissions: { approvalPolicy: "yolo" }, settingDefs: [], }, }; @@ -178,7 +181,8 @@ const claudeStatus: AgentStatus = { supportsDirectInput: true, liveInputMode: "terminal", presentationMode: "terminal", - bypassApprovalPolicy: "auto", + defaultApprovalPolicy: "auto", + bypassPermissions: { approvalPolicy: "auto" }, settingDefs: [], }, }; @@ -402,7 +406,7 @@ describe("ThreadDraftView", () => { expect(providerModel?.currentModel).toBe("gpt-5.4"); const effortContext = props.controls.find((c) => c.kind === "effort-context"); expect(effortContext?.effortValue).toBe("high"); - expect(props.controls.some((control) => control.value === "default-permissions")).toBe(true); + expect(props.controls.some((control) => control.value === "auto-review")).toBe(true); }); fireEvent.click(screen.getByText("set-prompt")); @@ -414,13 +418,15 @@ describe("ThreadDraftView", () => { model: "gpt-5.4", effort: "high", mode: "agent", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", }, presentationMode: "gui", prompt: "hello world", }); }); - it("defaults Home Codex drafts to full access without changing project defaults", async () => { + it("defaults Home Codex drafts to provider defaults, same as any other project", async () => { const onStart = vi.fn<(input: unknown) => void>(); useSharedSettings.setState({ providerConfigs: { @@ -454,7 +460,7 @@ describe("ThreadDraftView", () => { const providerModel = props.controls.find((c) => c.kind === "provider-model"); expect(providerModel?.currentAgentKind).toBe("codex"); expect(providerModel?.currentModel).toBe("gpt-5.4"); - expect(props.controls.some((control) => control.value === "full-access")).toBe(true); + expect(props.controls.some((control) => control.value === "auto-review")).toBe(true); }); fireEvent.click(screen.getByText("set-prompt")); @@ -466,14 +472,12 @@ describe("ThreadDraftView", () => { model: "gpt-5.4", effort: "high", mode: "agent", - approvalPolicy: "never", - sandboxMode: "danger-full-access", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", }, presentationMode: "gui", prompt: "hello world", }); - expect(useSharedSettings.getState().providerConfigs.codex?.approvalPolicy).toBe(""); - expect(useSharedSettings.getState().providerConfigs.codex?.sandboxMode).toBe(""); }); it("defaults synthetic generic ACP permissions to supervised", async () => { diff --git a/src/renderer/components/thread/ThreadDraftView.tsx b/src/renderer/components/thread/ThreadDraftView.tsx index 0da3bda..5596dd8 100644 --- a/src/renderer/components/thread/ThreadDraftView.tsx +++ b/src/renderer/components/thread/ThreadDraftView.tsx @@ -260,9 +260,7 @@ export function ThreadDraftView(props: { lastDraftConfig, isHomeScope ? {} : providerConfigsRef.current, ); - const resolved = resolveProviderDraftConfig(selectedAgentForConfig, saved, { - preferUnrestrictedPermissions: isHomeScope, - }); + const resolved = resolveProviderDraftConfig(selectedAgentForConfig, saved); const nextModel = resolved.model; const nextEffort = resolved.effort ?? ""; const nextContext = resolved.contextSize; @@ -527,20 +525,16 @@ export function ThreadDraftView(props: { } if (!selectedAgentForConfig) return; hasLocalConfigEditRef.current = true; - const resolved = resolveProviderDraftConfig( - selectedAgentForConfig, - { - model: patch.model ?? model, - effort: patch.effort ?? effort, - ...(patch.contextSize !== undefined ? { contextSize: patch.contextSize } : { contextSize }), - ...(patch.fast !== undefined ? { fast: patch.fast } : { fast }), - ...(patch.thinking !== undefined ? { thinking: patch.thinking } : { thinking }), - mode: patch.mode ?? mode, - approvalPolicy: patch.approvalPolicy ?? approvalPolicy, - sandboxMode: patch.sandboxMode ?? sandboxMode, - }, - { preferUnrestrictedPermissions: isHomeScope }, - ); + const resolved = resolveProviderDraftConfig(selectedAgentForConfig, { + model: patch.model ?? model, + effort: patch.effort ?? effort, + ...(patch.contextSize !== undefined ? { contextSize: patch.contextSize } : { contextSize }), + ...(patch.fast !== undefined ? { fast: patch.fast } : { fast }), + ...(patch.thinking !== undefined ? { thinking: patch.thinking } : { thinking }), + mode: patch.mode ?? mode, + approvalPolicy: patch.approvalPolicy ?? approvalPolicy, + sandboxMode: patch.sandboxMode ?? sandboxMode, + }); setModel(resolved.model); setEffort(resolved.effort ?? ""); @@ -604,14 +598,10 @@ export function ThreadDraftView(props: { persistProviderConfig(effectiveAgentKind, snapshot); } const targetSaved = isHomeScope ? undefined : providerConfigsRef.current[nextKind]; - const resolved = resolveProviderDraftConfig( - targetAgentForConfig, - { - ...(targetSaved ?? {}), - model: nextModel, - }, - { preferUnrestrictedPermissions: isHomeScope }, - ); + const resolved = resolveProviderDraftConfig(targetAgentForConfig, { + ...(targetSaved ?? {}), + model: nextModel, + }); persistProviderConfig(nextKind, resolved); setModel(resolved.model); setEffort(resolved.effort ?? ""); diff --git a/src/renderer/components/thread/ThreadHeaderStatus.tsx b/src/renderer/components/thread/ThreadHeaderStatus.tsx index 944a083..6ae5c42 100644 --- a/src/renderer/components/thread/ThreadHeaderStatus.tsx +++ b/src/renderer/components/thread/ThreadHeaderStatus.tsx @@ -60,6 +60,7 @@ export function ThreadHeaderStatusTooltipBody(props: { thread: Thread }) { const runtime = threadRuntimeStatusLabel(thread); const source = thread.threadStatusSource; const isServer = source === "server"; + const errorMessage = thread.status === "error" ? thread.errorMessage?.trim() : undefined; return (
@@ -79,9 +80,15 @@ export function ThreadHeaderStatusTooltipBody(props: { thread: Thread }) {

)}
-

- {threadStatusSupportDetail(source)} -

+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : ( +

+ {threadStatusSupportDetail(source)} +

+ )} ); } diff --git a/src/renderer/components/thread/threadDraftViewHelpers.ts b/src/renderer/components/thread/threadDraftViewHelpers.ts index 80a2247..8c3f70f 100644 --- a/src/renderer/components/thread/threadDraftViewHelpers.ts +++ b/src/renderer/components/thread/threadDraftViewHelpers.ts @@ -84,101 +84,33 @@ export function resolveModeValue(agent: AgentStatus, preferred?: string): string : (modes[0] ?? "agent"); } -function normalizeOptionName(value: string): string { - return value.trim().toLowerCase(); -} - export function formatEffortLabel(id: string): string { if (id === "xhigh") return "Extra High"; return id.charAt(0).toUpperCase() + id.slice(1); } -function findUnrestrictedApprovalPolicy(agent: AgentStatus): string | undefined { - const policies = agent.capabilities.approvalPolicies; - const configuredBypass = agent.capabilities.bypassApprovalPolicy; - if (configuredBypass && policies.some((policy) => policy.id === configuredBypass)) { - return configuredBypass; - } - - const preferredIds = new Set(["never", "yolo", "auto", "bypassPermissions", "dontAsk"]); - const byId = policies.find((policy) => preferredIds.has(policy.id)); - if (byId) { - return byId.id; - } - - const preferredLabels = new Set([ - "full access", - "yolo", - "bypass permissions", - "don't ask", - "dont ask", - ]); - const byLabel = policies.find((policy) => preferredLabels.has(normalizeOptionName(policy.label))); - return byLabel?.id; -} - -function findDefaultApprovalPolicy(agent: AgentStatus): string | undefined { - const policies = agent.capabilities.approvalPolicies; - if ( - agent.kind.startsWith("acp-generic:") && - policies.some((policy) => policy.id === "default") && - policies.some((policy) => policy.id === "never") - ) { - return "default"; - } - - return findUnrestrictedApprovalPolicy(agent); -} - -interface ResolveProviderDraftConfigOptions { - preferUnrestrictedPermissions?: boolean; -} - -export function resolveApprovalPolicyValue( - agent: AgentStatus, - preferred?: string, - options: ResolveProviderDraftConfigOptions = {}, -): string { +export function resolveApprovalPolicyValue(agent: AgentStatus, preferred?: string): string { const policies = agent.capabilities.approvalPolicies; if (preferred !== undefined) { return policies.some((p) => p.id === preferred) ? preferred : ""; } - if (agent.kind === "codex" && !options.preferUnrestrictedPermissions) { - return ""; + const explicit = agent.capabilities.defaultApprovalPolicy; + if (explicit && policies.some((p) => p.id === explicit)) { + return explicit; } - - const fallback = options.preferUnrestrictedPermissions - ? findUnrestrictedApprovalPolicy(agent) - : findDefaultApprovalPolicy(agent); - return fallback ?? policies[0]?.id ?? ""; + return policies[0]?.id ?? ""; } -function findDefaultSandboxMode(agent: AgentStatus): string | undefined { - const modes = agent.capabilities.sandboxModes; - const preferredIds = new Set(["danger-full-access", "full-access"]); - const byId = modes.find((mode) => preferredIds.has(mode.id)); - if (byId) { - return byId.id; - } - - const byLabel = modes.find((mode) => normalizeOptionName(mode.label) === "full access"); - return byLabel?.id; -} - -export function resolveSandboxModeValue( - agent: AgentStatus, - preferred?: string, - options: ResolveProviderDraftConfigOptions = {}, -): string { +export function resolveSandboxModeValue(agent: AgentStatus, preferred?: string): string { const modes = agent.capabilities.sandboxModes; if (preferred !== undefined) { return modes.some((m) => m.id === preferred) ? preferred : ""; } - if (agent.kind === "codex" && !options.preferUnrestrictedPermissions) { - return ""; + const explicit = agent.capabilities.defaultSandboxMode; + if (explicit && modes.some((m) => m.id === explicit)) { + return explicit; } - - return findDefaultSandboxMode(agent) ?? modes[0]?.id ?? ""; + return modes[0]?.id ?? ""; } export function resolveInitialPresentationMode( @@ -222,7 +154,6 @@ function normalizeCursorPreferredDraft( export function resolveProviderDraftConfig( agent: AgentStatus, preferred?: Partial, - options: ResolveProviderDraftConfigOptions = {}, ): ProviderDraftConfig { const normalizedPreferred = normalizeCursorPreferredDraft(agent, preferred); const nextModel = resolveModelValue(agent, normalizedPreferred?.model); @@ -234,12 +165,8 @@ export function resolveProviderDraftConfig( | "agent" | "plan" | "autopilot"; - const nextApproval = resolveApprovalPolicyValue( - agent, - normalizedPreferred?.approvalPolicy, - options, - ); - const nextSandbox = resolveSandboxModeValue(agent, normalizedPreferred?.sandboxMode, options); + const nextApproval = resolveApprovalPolicyValue(agent, normalizedPreferred?.approvalPolicy); + const nextSandbox = resolveSandboxModeValue(agent, normalizedPreferred?.sandboxMode); return { model: nextModel, diff --git a/src/renderer/state/slices/threadSlice.ts b/src/renderer/state/slices/threadSlice.ts index 0e7a6c3..2bfc410 100644 --- a/src/renderer/state/slices/threadSlice.ts +++ b/src/renderer/state/slices/threadSlice.ts @@ -732,6 +732,7 @@ export const createThreadSlice: SliceCreator = (set) => ({ isThreadConfigEqual(thread.config, nextConfig) && thread.canResumeWithConfig === snapshot.canResumeWithConfig && thread.threadStatusSource === snapshot.threadStatusSource && + thread.errorMessage === snapshot.errorMessage && thread.activeTurnStartedAt === nextTurnTiming.activeTurnStartedAt && thread.lastTurnStartedAt === nextTurnTiming.lastTurnStartedAt && thread.lastTurnEndedAt === nextTurnTiming.lastTurnEndedAt && @@ -758,6 +759,7 @@ export const createThreadSlice: SliceCreator = (set) => ({ ...(snapshot.threadStatusSource !== undefined ? { threadStatusSource: snapshot.threadStatusSource } : {}), + ...(snapshot.errorMessage !== undefined ? { errorMessage: snapshot.errorMessage } : {}), ...(snapshot.sessionRef ? { sessionRef: snapshot.sessionRef } : {}), ...(snapshot.slashCommands !== undefined ? { slashCommands: snapshot.slashCommands } diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts index dd9bbbe..0fe3558 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts @@ -77,6 +77,7 @@ export function useConflictResolver(params: { provider.capabilities, ); + const bypass = provider.capabilities.bypassPermissions; const store = useAppStore.getState(); const thread = store.createThread({ projectId: project.id, @@ -84,7 +85,8 @@ export function useConflictResolver(params: { config: { model, ...(effort ? { effort } : {}), - approvalPolicy: provider.capabilities.bypassApprovalPolicy ?? "bypassPermissions", + approvalPolicy: bypass?.approvalPolicy ?? "bypassPermissions", + ...(bypass?.sandboxMode ? { sandboxMode: bypass.sandboxMode } : {}), }, prompt, presentationMode, diff --git a/src/renderer/views/MainView/parts/AppContent/draftConfig.test.ts b/src/renderer/views/MainView/parts/AppContent/draftConfig.test.ts index 188358b..6afd994 100644 --- a/src/renderer/views/MainView/parts/AppContent/draftConfig.test.ts +++ b/src/renderer/views/MainView/parts/AppContent/draftConfig.test.ts @@ -2,6 +2,20 @@ import { describe, expect, it } from "vitest"; import { buildProjectDraftConfig } from "./draftConfig"; describe("buildProjectDraftConfig", () => { + it("preserves explicit empty approvalPolicy and sandboxMode so 'Default permissions' survives reload", () => { + const built = buildProjectDraftConfig({ + agentKind: "codex", + config: { + model: "gpt-5.5", + approvalPolicy: "", + sandboxMode: "", + }, + worktreeMode: false, + }); + expect(built.approvalPolicy).toBe(""); + expect(built.sandboxMode).toBe(""); + }); + it("preserves thread creation config fields used by later drafts", () => { expect( buildProjectDraftConfig({ diff --git a/src/renderer/views/MainView/parts/AppContent/draftConfig.ts b/src/renderer/views/MainView/parts/AppContent/draftConfig.ts index ec7e5fa..9a418d8 100644 --- a/src/renderer/views/MainView/parts/AppContent/draftConfig.ts +++ b/src/renderer/views/MainView/parts/AppContent/draftConfig.ts @@ -15,8 +15,11 @@ export function buildProjectDraftConfig(input: { ...(config.fast === true ? { fast: true } : {}), ...(config.thinking === true ? { thinking: true } : {}), ...(config.mode ? { mode: config.mode } : {}), - ...(config.approvalPolicy ? { approvalPolicy: config.approvalPolicy } : {}), - ...(config.sandboxMode ? { sandboxMode: config.sandboxMode } : {}), + // Preserve explicit empty strings — "" means "use provider defaults" + // (e.g. Codex "Default permissions"). Stripping it would make reload + // fall through to the bypass fallback and silently flip to Full access. + ...(config.approvalPolicy !== undefined ? { approvalPolicy: config.approvalPolicy } : {}), + ...(config.sandboxMode !== undefined ? { sandboxMode: config.sandboxMode } : {}), worktreeMode, }; } diff --git a/src/shared/contracts/agent.ts b/src/shared/contracts/agent.ts index b67f114..06bd8c0 100644 --- a/src/shared/contracts/agent.ts +++ b/src/shared/contracts/agent.ts @@ -113,6 +113,16 @@ export const agentSettingDefSchema = z.discriminatedUnion("type", [ ]); export type AgentSettingDef = z.infer; +/** + * Provider-agnostic "Full Access" preset. Shared UI (conflict resolver, + * handoff dialog, bypass toggles) applies these fields together so codex + * and similar providers that need a sandbox override are bypassed correctly. + */ +const bypassPermissionsSchema = z.object({ + approvalPolicy: z.string().optional(), + sandboxMode: z.string().optional(), +}); + const agentPresentationCapabilityOverrideSchema = z .object({ models: z.array(labeledOptionSchema), @@ -129,13 +139,15 @@ const agentPresentationCapabilityOverrideSchema = z modes: z.array(threadModeSchema), approvalPolicies: z.array(labeledOptionSchema), sandboxModes: z.array(labeledOptionSchema), + defaultApprovalPolicy: z.string().optional(), + defaultSandboxMode: z.string().optional(), supportsResume: z.boolean(), supportsDirectInput: z.boolean(), liveInputMode: liveInputModeSchema, presentationMode: threadPresentationModeSchema, presentationModes: z.array(threadPresentationModeSchema).optional(), requiresTerminalFocusBeforeInput: z.boolean().optional(), - bypassApprovalPolicy: z.string().optional(), + bypassPermissions: bypassPermissionsSchema.optional(), settingDefs: z.array(agentSettingDefSchema), slashCommands: z.array(agentSlashCommandSchema).optional(), }) @@ -163,6 +175,10 @@ export const agentCapabilitySchema = z.object({ modes: z.array(threadModeSchema).default([]), approvalPolicies: z.array(labeledOptionSchema).default([]), sandboxModes: z.array(labeledOptionSchema).default([]), + /** First-draft approval policy when the user has no saved preference. Falls back to the first entry in `approvalPolicies` if unset. */ + defaultApprovalPolicy: z.string().optional(), + /** First-draft sandbox mode when the user has no saved preference. Falls back to the first entry in `sandboxModes` if unset. */ + defaultSandboxMode: z.string().optional(), supportsResume: z.boolean().default(false), supportsDirectInput: z.boolean().default(true), liveInputMode: liveInputModeSchema.default("terminal"), @@ -174,7 +190,7 @@ export const agentCapabilitySchema = z.object({ */ presentationModes: z.array(threadPresentationModeSchema).optional(), requiresTerminalFocusBeforeInput: z.boolean().optional(), - bypassApprovalPolicy: z.string().optional(), + bypassPermissions: bypassPermissionsSchema.optional(), settingDefs: z.array(agentSettingDefSchema).default([]), /** Populated when the Claude Agent SDK init probe succeeds (install detection). */ slashCommands: z.array(agentSlashCommandSchema).optional(), diff --git a/src/shared/contracts/thread.ts b/src/shared/contracts/thread.ts index 84150f2..2e0275b 100644 --- a/src/shared/contracts/thread.ts +++ b/src/shared/contracts/thread.ts @@ -48,6 +48,8 @@ export const threadSchema = z.object({ lastTurnEndedAt: z.string().min(1).optional(), /** Set by supervisor `thread-state`; not user-editable. */ threadStatusSource: threadStatusSourceSchema.optional(), + /** Latest error reason from the runtime, present when `status === "error"`. */ + errorMessage: z.string().optional(), slashCommands: z.array(agentSlashCommandSchema).optional(), }); export type Thread = z.infer; diff --git a/src/supervisor/agents/acp-generic/index.ts b/src/supervisor/agents/acp-generic/index.ts index 7e02ceb..cb1b051 100644 --- a/src/supervisor/agents/acp-generic/index.ts +++ b/src/supervisor/agents/acp-generic/index.ts @@ -290,6 +290,9 @@ function mergeAcpProbeCapabilities( { id: "default", label: "Supervised" }, { id: "never", label: "Auto Approve" }, ]; + // Synthetic UI: start in the conservative "Supervised" tier; user can flip + // to Auto Approve via the composer toggle. + merged.defaultApprovalPolicy = "default"; } return merged; } diff --git a/src/supervisor/agents/antigravity/detection.ts b/src/supervisor/agents/antigravity/detection.ts index 0f2c3da..546314d 100644 --- a/src/supervisor/agents/antigravity/detection.ts +++ b/src/supervisor/agents/antigravity/detection.ts @@ -28,7 +28,8 @@ export const defaultAntigravityCapabilities: AgentCapability = { liveInputMode: "terminal", presentationMode: "terminal", presentationModes: ["terminal"], - bypassApprovalPolicy: "yolo", + defaultApprovalPolicy: "yolo", + bypassPermissions: { approvalPolicy: "yolo" }, settingDefs: [], }; diff --git a/src/supervisor/agents/claude/detection.ts b/src/supervisor/agents/claude/detection.ts index f0000c8..c4a7d66 100644 --- a/src/supervisor/agents/claude/detection.ts +++ b/src/supervisor/agents/claude/detection.ts @@ -57,7 +57,8 @@ export const claudeCapabilities: AgentCapability = { liveInputMode: "terminal", presentationMode: "terminal", presentationModes: ["terminal", "gui"], - bypassApprovalPolicy: CLAUDE_DEFAULT_APPROVAL_POLICY, + defaultApprovalPolicy: CLAUDE_DEFAULT_APPROVAL_POLICY, + bypassPermissions: { approvalPolicy: CLAUDE_DEFAULT_APPROVAL_POLICY }, settingDefs: [ { key: "usePowershellTool", diff --git a/src/supervisor/agents/codex/detection.ts b/src/supervisor/agents/codex/detection.ts index 1fba5c1..e9dd2b1 100644 --- a/src/supervisor/agents/codex/detection.ts +++ b/src/supervisor/agents/codex/detection.ts @@ -194,7 +194,9 @@ export const codexDefaultCapabilities: AgentCapability = { liveInputMode: "terminal", presentationMode: "terminal", presentationModes: ["terminal", "gui"], - bypassApprovalPolicy: "full-auto", + defaultApprovalPolicy: "on-request", + defaultSandboxMode: "workspace-write", + bypassPermissions: { approvalPolicy: "never", sandboxMode: "danger-full-access" }, settingDefs: [], slashCommands: CODEX_BUILT_IN_SLASH_COMMANDS, }; diff --git a/src/supervisor/agents/codex/index.ts b/src/supervisor/agents/codex/index.ts index aa6a0b2..6363c69 100644 --- a/src/supervisor/agents/codex/index.ts +++ b/src/supervisor/agents/codex/index.ts @@ -259,7 +259,11 @@ export function createCodexAdapter(): AgentAdapter { }, defaultOneShotModel: "gpt-5.5", buildOneShotCommand(model, effort) { - const args = ["exec", "-m", model]; + // `--skip-git-repo-check` lets `codex exec` run from worktrees or other + // directories not on codex's trust list. Title generation only reads + // the user's prompt from stdin and emits a short string — it never + // touches the repo, so the trust gate is just noise here. + const args = ["exec", "--skip-git-repo-check", "-m", model]; if (effort) { args.push("-c", `model_reasoning_effort="${effort}"`); } diff --git a/src/supervisor/agents/commandBuilders.test.ts b/src/supervisor/agents/commandBuilders.test.ts index ffa7688..517275c 100644 --- a/src/supervisor/agents/commandBuilders.test.ts +++ b/src/supervisor/agents/commandBuilders.test.ts @@ -305,7 +305,15 @@ describe("agent command builders", () => { it("passes reasoning effort through one-shot commit generation commands", () => { expect(createCodexAdapter().buildOneShotCommand?.("gpt-5.4-mini", "low")).toEqual({ command: "codex", - args: ["exec", "-m", "gpt-5.4-mini", "-c", 'model_reasoning_effort="low"', "-"], + args: [ + "exec", + "--skip-git-repo-check", + "-m", + "gpt-5.4-mini", + "-c", + 'model_reasoning_effort="low"', + "-", + ], }); expect( diff --git a/src/supervisor/agents/copilot/detection.ts b/src/supervisor/agents/copilot/detection.ts index a9fc988..5d777d2 100644 --- a/src/supervisor/agents/copilot/detection.ts +++ b/src/supervisor/agents/copilot/detection.ts @@ -33,7 +33,8 @@ export const copilotDefaultCapabilities: AgentCapability = { presentationMode: "terminal", presentationModes: ["terminal", "gui"], requiresTerminalFocusBeforeInput: true, - bypassApprovalPolicy: "never", + defaultApprovalPolicy: "never", + bypassPermissions: { approvalPolicy: "never" }, settingDefs: [], }; diff --git a/src/supervisor/agents/cursor/detection.ts b/src/supervisor/agents/cursor/detection.ts index 0d4dae5..4ef0284 100644 --- a/src/supervisor/agents/cursor/detection.ts +++ b/src/supervisor/agents/cursor/detection.ts @@ -37,7 +37,8 @@ export const cursorDefaultCapabilities: AgentCapability = { liveInputMode: "terminal", presentationMode: "terminal", presentationModes: ["terminal", "gui"], - bypassApprovalPolicy: "never", + defaultApprovalPolicy: "never", + bypassPermissions: { approvalPolicy: "never" }, settingDefs: [], }; diff --git a/src/supervisor/agents/gemini/detection.ts b/src/supervisor/agents/gemini/detection.ts index fac1ec1..f6c6cda 100644 --- a/src/supervisor/agents/gemini/detection.ts +++ b/src/supervisor/agents/gemini/detection.ts @@ -47,7 +47,8 @@ export const defaultGeminiCapabilities: AgentCapability = { liveInputMode: "terminal", presentationMode: "terminal", presentationModes: ["terminal", "gui"], - bypassApprovalPolicy: "yolo", + defaultApprovalPolicy: "never", + bypassPermissions: { approvalPolicy: "never" }, settingDefs: [], }; diff --git a/src/supervisor/agents/opencode/detection.ts b/src/supervisor/agents/opencode/detection.ts index d972030..5aa0c49 100644 --- a/src/supervisor/agents/opencode/detection.ts +++ b/src/supervisor/agents/opencode/detection.ts @@ -59,7 +59,8 @@ export const opencodeDefaultCapabilities: AgentCapability = { // `opencode serve` + SDK SSE stream); terminal stays the default and uses // the same SDK helper for one-shot session-id allocation. presentationModes: ["terminal", "gui"], - bypassApprovalPolicy: "yolo", + defaultApprovalPolicy: "yolo", + bypassPermissions: { approvalPolicy: "yolo" }, settingDefs: [ { key: OPENCODE_BROWSER_MCP_SETTING_KEY, diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index 52129ab..4529024 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -706,7 +706,9 @@ export class ThreadSessionManager { env: shellEnv, }); } catch (error) { - throw new Error(describeShellSpawnFailure(shellCommand, shellEnv, error), { cause: error }); + throw new Error(describeSpawnFailure("shell", shellCommand, shellEnv, error), { + cause: error, + }); } const session: ShellSessionRuntime = { @@ -1366,20 +1368,38 @@ export class ThreadSessionManager { const command = input.command ? injectWslEnv(input.command, input.projectLocation, terminalAgentEnv) : undefined; - const pty = command - ? spawn(command.command, command.args, { + let pty; + if (command) { + const ptyEnv = { + ...process.env, + ...(command.env ?? {}), + ...agentEnv, + ...terminalEnv, + }; + try { + pty = spawn(command.command, command.args, { name: process.platform === "win32" ? "xterm-color" : terminalEnv.TERM, cols: input.initialSize.cols, rows: input.initialSize.rows, cwd: command.cwd ?? process.cwd(), - env: { - ...process.env, - ...(command.env ?? {}), - ...agentEnv, - ...terminalEnv, - }, - }) - : undefined; + env: ptyEnv, + }); + } catch (error) { + throw new Error( + describeSpawnFailure( + "agent", + { + command: command.command, + args: command.args, + ...(command.cwd ? { cwd: command.cwd } : {}), + }, + sanitizeEnv(ptyEnv), + error, + ), + { cause: error }, + ); + } + } const session: SessionRuntime = { instanceId: randomUUID(), threadId: input.threadId, @@ -2017,35 +2037,58 @@ export class ThreadSessionManager { } } -function describeShellSpawnFailure( - shellCommand: { command: string; args: string[]; cwd?: string }, +function describeSpawnFailure( + kind: "shell" | "agent", + cmd: { command: string; args: string[]; cwd?: string }, env: Record, error: unknown, ): string { const base = error instanceof Error ? error.message : String(error); + const prefix = `Failed to spawn ${kind} (${cmd.command})`; - if (shellCommand.cwd) { - const cwdDiagnosis = diagnoseCwd(shellCommand.cwd); + if (cmd.cwd) { + const cwdDiagnosis = diagnoseCwd(cmd.cwd); if (cwdDiagnosis) return cwdDiagnosis; } - // Absolute shell paths (the common case for $SHELL on macOS/Linux) can be - // probed; relative names like "bash" rely on PATH lookup and we leave the - // raw error in that case. - if (shellCommand.command.startsWith("/")) { - const shellDiagnosis = diagnoseShellBinary(shellCommand.command); - if (shellDiagnosis) return shellDiagnosis; + if (cmd.command.startsWith("/")) { + const binaryDiagnosis = diagnoseShellBinary(cmd.command); + if (binaryDiagnosis) return binaryDiagnosis; + } else { + // node-pty surfaces a bare "posix_spawnp failed." for PATH-lookup misses. + // Do the lookup ourselves against the env actually handed to the child so + // the user sees whether the binary was missing vs found-but-unspawnable. + const lookup = diagnoseRelativeBinary(cmd.command, env); + if (lookup) return `${prefix}: ${lookup}`; } // posix_spawn returns E2BIG when env+argv exceed ARG_MAX (~256KB on macOS). const envBytes = measureEnvBytes(env); - const argvBytes = measureArgvBytes(shellCommand.command, shellCommand.args); + const argvBytes = measureArgvBytes(cmd.command, cmd.args); // Leave headroom — ARG_MAX includes pointer overhead and string terminators. if (envBytes + argvBytes > 200_000) { - return `Failed to spawn shell (${shellCommand.command}): environment is too large (${Math.round((envBytes + argvBytes) / 1024)} KB). This usually means a parent process leaked variables into the launch env.`; + return `${prefix}: environment is too large (${Math.round((envBytes + argvBytes) / 1024)} KB). This usually means a parent process leaked variables into the launch env.`; } - return `Failed to spawn shell (${shellCommand.command}): ${base}`; + return `${prefix}: ${base}`; +} + +function diagnoseRelativeBinary(command: string, env: Record): string | undefined { + const pathValue = env.PATH ?? ""; + const entries = pathValue.split(":").filter((entry) => entry.length > 0); + for (const entry of entries) { + const candidate = resolvePath(entry, command); + try { + const stat = statSync(candidate); + if (stat.isFile() && (stat.mode & 0o111) !== 0) return undefined; + } catch { + // continue + } + } + if (entries.length === 0) { + return `'${command}' could not be resolved — PATH is empty.`; + } + return `'${command}' was not found on PATH (${entries.length} entries searched). Check that the binary is installed and visible to the app's environment.`; } let spawnHelperChmodAttempted = false;