diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 8180c075e17..2add36df4e6 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -26,7 +26,7 @@ import { type StructuredNativeChatBlocker } from '../../shared/structured-native-chat-launch-route' import type { TuiAgent } from '../../shared/tui-agent' -import { hasExplicitTuiLaunchCustomization } from '../../shared/tui-agent-launch-customization' +import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' import type { OrcaRuntimeService } from '../runtime/orca-runtime' export type AgentLaunchMode = 'structured' | 'terminal' @@ -36,7 +36,7 @@ export type AgentLaunchModeReason = | 'remote_execution_host' | 'reused_terminal' | 'agent_without_structured_session' - | 'tui_launch_customization' + | 'tui_launch_command' | 'structured_sessions_unavailable' | 'structured_support_unknown' | 'wsl_execution_runtime' @@ -71,8 +71,7 @@ export const DEFAULT_LAUNCH_VOCABULARY: AgentLaunchModeVocabulary = { } export type AgentLaunchModeSettings = Partial< - NativeChatDefaultSettings & - Pick + NativeChatDefaultSettings & Pick > /** The placement facts the decision reads. `worktree`, `model` and `effort` are deliberately not @@ -89,8 +88,7 @@ const DOWNGRADE_DETAIL: Record, s remote_execution_host: 'this launch runs on a remote execution host', reused_terminal: 'it reuses a running terminal agent', agent_without_structured_session: 'this agent has no structured session', - tui_launch_customization: - 'this agent has a custom launch command, arguments or environment that only a terminal applies', + tui_launch_command: 'this agent has a custom launch command that only a terminal runs', structured_sessions_unavailable: 'this runtime does not support structured agent sessions', structured_support_unknown: 'the execution host has not established structured session support', wsl_execution_runtime: 'this workspace runs under WSL', @@ -105,7 +103,7 @@ const BLOCKER_REASON: Record< 'reused-terminal': 'reused_terminal', 'agent-without-structured-session': 'agent_without_structured_session', 'floating-workspace': 'structured_unsupported_on_host', - 'tui-launch-customization': 'tui_launch_customization', + 'tui-launch-command': 'tui_launch_command', 'remote-execution-host': 'remote_execution_host', 'project-runtime': 'wsl_execution_runtime', 'runtime-capability': 'structured_sessions_unavailable', @@ -151,7 +149,7 @@ export function decideAgentLaunchMode(args: { // A resolved managed worktree or folder workspace is never a floating terminal. WSL is left to // the executing host's own create-support probe, which reads the resolved workspace rather // than guessing from a client-side project runtime. - requiresTuiLaunchCustomization: hasExplicitTuiLaunchCustomization(settings, agent) + requiresTuiLaunchCommand: hasExplicitTuiLaunchCommand(settings, agent) }) if (!support.supported) { return downgraded(BLOCKER_REASON[support.blocker], vocabulary) diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index 46bcb7219d3..e5456ecf4b9 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -6,6 +6,7 @@ import { query, type CanUseTool, type Options, + type PermissionMode, type SDKUserMessage, type SpawnedProcess as SdkSpawnedProcess, type SpawnOptions as SdkSpawnOptions @@ -143,7 +144,7 @@ function recordingSpawner(spawns: SpawnSeen[]) { } } -function resolvedLaunch(launchArgs: string[]) { +function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = []) { const record = { sessionId: 'contract-pin-session', provider: 'claude', @@ -161,7 +162,8 @@ function resolvedLaunch(launchArgs: string[]) { store: { getRecord: () => record } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async () => '/repos/workspace-1', resolveCommand: () => FAKE_CLI, - resolveAuthPolicy: () => ({ stripAuthEnv: true }) + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + resolvePermissionMode: () => permissionMode })({ identity: { sessionId: record.sessionId } as never }) } @@ -338,9 +340,9 @@ describe('Claude Agent SDK contract pins', () => { it('produces a matching CLI flag for every pre-SDK argv entry', async () => { const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) const spawns: SpawnSeen[] = [] - // Driven by the real resolver, so the argv walk covers the durable-launchArgs - // translation and its merge order, not a hand-written options literal. - const launch = await resolvedLaunch(['--model', 'claude-sonnet-4-5', '--effort', 'high']) + // Driven by the real resolver, so the argv walk covers its option set and merge order, + // not a hand-written options literal. + const launch = await resolvedLaunch('bypassPermissions', ['--model', 'claude-sonnet-4-5']) await drainQuery({ ...launch.options, pathToClaudeCodeExecutable: FAKE_CLI, @@ -352,15 +354,20 @@ describe('Claude Agent SDK contract pins', () => { expect(spawns).toHaveLength(1) const argv = normalizeArgv(spawns[0]!.args) - // Typed-first translation must not also spell the flag through extraArgs. - for (const flag of ['--model', '--effort']) { + // Agent Permissions reaches the child as the SDK's own typed pair, spelled exactly once each. + // `--allow-dangerously-skip-permissions` is what the SDK emits for the allow flag; the CLI + // refuses `bypassPermissions` without it, so a rename upstream must fail here rather than + // silently return a Yolo user to permission prompts. + for (const flag of ['--permission-mode', '--allow-dangerously-skip-permissions']) { expect( argv.filter((arg) => arg === flag), `${flag} occurrences` ).toHaveLength(1) } - expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5') - expect(argv[argv.indexOf('--effort') + 1]).toBe('high') + expect(argv[argv.indexOf('--permission-mode') + 1]).toBe('bypassPermissions') + // Configured CLI arguments are a terminal concern; a record written before they stopped + // being read must not smuggle one back into the child's argv. + expect(argv).not.toContain('--model') // Headless print mode is the SDK's only mode; `query()` never passes `-p`, // and if the SDK ever started passing it this pin would notice. const impliedByHeadlessQuery = new Set(['-p']) diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 650947cffa1..e3822aa4b45 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -11,10 +11,10 @@ import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-str import { CLAUDE_DEFAULT_SETTING_SOURCES, CLAUDE_STRUCTURED_BASE_OPTIONS, - claudeSdkOptionsForLaunchArgs, claudeSessionIdForOrcaSession, createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' +import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' const SESSION_ID = 'orca-session-1' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -55,13 +55,16 @@ function makeExecutable(path: string): void { function resolverFor( value: AgentSessionRecord | null, resolveEnv?: () => Record, - stripAuthEnv = false + stripAuthEnv = false, + // Manual by default so a test that is not about permissions is not silently about them. + agentDefaultArgs: Record = { claude: '' } ) { return createClaudeStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv }), + resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), ...(resolveEnv ? { resolveEnv } : {}) }) } @@ -119,6 +122,7 @@ describe('claude structured launch resolution', () => { supportedDialogKinds: [], extraArgs: { 'replay-user-messages': null }, systemPrompt: { type: 'preset', preset: 'claude_code' }, + permissionMode: 'default', sessionId: first.providerSessionId }) expect(first.options.resume).toBeUndefined() @@ -190,42 +194,55 @@ describe('claude structured launch resolution', () => { expect(launch.options.resumeSessionAt).toBeUndefined() }) - it('preserves durable Claude launch arguments as typed options and extraArgs', async () => { + // Agent Permissions is stored as the bypass flag inside the launch arguments, so presence of + // that flag — not the whole string — is what Yolo means, exactly as a terminal launch reads it. + it.each([ + ['--dangerously-skip-permissions'], + ['--dangerously-skip-permissions --model Opus'], + ['--model Opus --dangerously-skip-permissions'] + ])('starts a Yolo session in bypassPermissions for args %s', async (claude) => { + const launch = await resolverFor(record(), undefined, false, { claude })({ identity: IDENTITY }) + + expect(launch.options.permissionMode).toBe('bypassPermissions') + // The SDK refuses bypassPermissions unless the allow flag rides with it. + expect(launch.options.allowDangerouslySkipPermissions).toBe(true) + }) + + // The common profile: the toggle has never been used, so it has written nothing, and the + // default for the key it did not write is the bypass flag — the posture the terminal has + // always given these users. + it('starts a session that never opened Agent settings in bypassPermissions', async () => { + const launch = await resolverFor(record(), undefined, false, {})({ identity: IDENTITY }) + + expect(launch.options.permissionMode).toBe('bypassPermissions') + expect(launch.options.allowDangerouslySkipPermissions).toBe(true) + }) + + // Manual is stored as an empty string, which owns the key and so beats the shipped default. + it.each([[''], ['--model Opus']])( + 'leaves a Manual session prompting for args %s', + async (claude) => { + const launch = await resolverFor(record(), undefined, false, { claude })({ + identity: IDENTITY + }) + + expect(launch.options.permissionMode).toBe('default') + expect(launch.options.allowDangerouslySkipPermissions).toBeUndefined() + } + ) + + // The configured CLI arguments are a terminal concern: a durable record written before they + // stopped being read must not smuggle one back into the child. + it("ignores the record's durable launch arguments", async () => { const launch = await resolverFor( record({ - launchArgs: [ - '--model', - 'claude-sonnet-4-5', - '--effort', - 'high', - '--dangerously-skip-permissions' - ] + launchArgs: ['--model', 'claude-sonnet-4-5', '--dangerously-skip-permissions'] }) )({ identity: IDENTITY }) - expect(launch.options.model).toBe('claude-sonnet-4-5') - expect(launch.options.effort).toBe('high') - expect(launch.options.extraArgs).toEqual({ - 'dangerously-skip-permissions': null, - 'replay-user-messages': null - }) - }) - - it('routes durable launch arguments to a typed option first and refuses what neither can carry', () => { - // The catalog's own output: each flag lands in exactly one place, so the SDK - // cannot emit it twice with two different values. - expect(claudeSdkOptionsForLaunchArgs(['--model', 'opus', '--effort', 'xhigh'])).toEqual({ - model: 'opus', - effort: 'xhigh' - }) - // An effort the SDK's union does not name still reaches the CLI, unchanged. - expect(claudeSdkOptionsForLaunchArgs(['--effort', 'ultra'])).toEqual({ - extraArgs: { effort: 'ultra' } - }) - expect(claudeSdkOptionsForLaunchArgs(['--settings=/tmp/s.json'])).toEqual({ - extraArgs: { settings: '/tmp/s.json' } - }) - expect(() => claudeSdkOptionsForLaunchArgs(['-m', 'opus'])).toThrow(/no SDK option/) + expect(launch.options.model).toBeUndefined() + expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) + expect(launch.options.permissionMode).toBe('default') }) it('keeps the session launch environment pinned after account settings change', async () => { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 667ebfb8ddd..b157589637e 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,5 +1,8 @@ import { createHash } from 'node:crypto' -import type { EffortLevel, Options as ClaudeAgentSdkOptions } from '@anthropic-ai/claude-agent-sdk' +import type { + Options as ClaudeAgentSdkOptions, + PermissionMode +} from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -35,6 +38,8 @@ export type ClaudeStructuredSdkOptions = Pick< | 'extraArgs' | 'model' | 'effort' + | 'permissionMode' + | 'allowDangerouslySkipPermissions' | 'sessionId' | 'resume' | 'resumeSessionAt' @@ -58,8 +63,6 @@ export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = { extraArgs: { 'replay-user-messages': null } } -const EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max'] - function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Record { const next: Record = {} for (const [key, value] of Object.entries(env)) { @@ -71,47 +74,18 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Recor } /** - * Translate the record's durable launch arguments into SDK options. + * Agent Permissions as query-start options. * - * Typed option first so a flag is never emitted twice; `extraArgs` carries - * anything without one. A token expressible neither way is refused rather than - * dropped — a silent drop is how this lane loses launch flags. + * The SDK refuses `bypassPermissions` unless the allow flag rides with it, so the two are built + * here together and never emitted apart. The prompting mode is stated rather than left out: the + * SDK fills an absent mode with `default` anyway, and saying so keeps the launch readable. */ -export function claudeSdkOptionsForLaunchArgs( - args: readonly string[] -): Pick { - let model: string | undefined - let effort: EffortLevel | undefined - const extraArgs: Record = {} - for (let index = 0; index < args.length; index += 1) { - const token = args[index] ?? '' - if (!token.startsWith('--') || token.length <= 2) { - throw new Error( - `claude launch argument ${token} has no SDK option; refusing rather than dropping it` - ) - } - const equals = token.indexOf('=') - const flag = equals === -1 ? token : token.slice(0, equals) - let value = equals === -1 ? null : token.slice(equals + 1) - if (value === null) { - const next = args[index + 1] - if (next !== undefined && !next.startsWith('-')) { - value = next - index += 1 - } - } - if (flag === '--model' && value !== null) { - model = value - } else if (flag === '--effort' && value !== null && EFFORT_LEVELS.includes(value)) { - effort = value as EffortLevel - } else { - extraArgs[flag.slice(2)] = value - } - } +export function claudeStructuredPermissionOptions( + mode: PermissionMode +): Pick { return { - ...(model === undefined ? {} : { model }), - ...(effort === undefined ? {} : { effort }), - ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}) + permissionMode: mode, + ...(mode === 'bypassPermissions' ? { allowDangerouslySkipPermissions: true } : {}) } } @@ -142,6 +116,8 @@ export type ClaudeStructuredLaunchResolverDeps = { * inherit a guess. Build it with claudeStructuredAuthPolicyForSettings. */ resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + /** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */ + resolvePermissionMode?: () => Promise | PermissionMode /** How long an in-flight account switch may hold a launch before it is refused. */ authSwitchSettleTimeoutMs?: number /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ @@ -219,7 +195,11 @@ export function createClaudeStructuredLaunchResolver( head?.handle.provider === 'claude' ? head.handle.sessionId : claudeSessionIdForOrcaSession(identity.sessionId) - const durable = claudeSdkOptionsForLaunchArgs(record.launchArgs ?? []) + // `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal + // concern, and the permission mode they used to smuggle in is a typed option now. + const permission = claudeStructuredPermissionOptions( + (await deps.resolvePermissionMode?.()) ?? 'default' + ) const command = (deps.resolveCommand ?? resolveClaudeCommand)() const auth = await deps.resolveAuthPolicy() const overlay = await deps.resolveEnv?.() @@ -256,9 +236,8 @@ export function createClaudeStructuredLaunchResolver( return { pathToClaudeCodeExecutable: command, options: { - ...durable, ...CLAUDE_STRUCTURED_BASE_OPTIONS, - extraArgs: { ...durable.extraArgs, ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs }, + ...permission, ...(head?.handle.provider === 'claude' ? { resume: providerSessionId, diff --git a/src/main/claude/claude-structured-permission-mode.test.ts b/src/main/claude/claude-structured-permission-mode.test.ts new file mode 100644 index 00000000000..c09df8f5e05 --- /dev/null +++ b/src/main/claude/claude-structured-permission-mode.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' + +describe('claudeStructuredPermissionModeForSettings', () => { + // The three states the Agent Permissions toggle can leave behind. The untouched case is the + // common one and the easiest to get wrong: the toggle writes nothing until it is used, and the + // default Orca ships for the key it did not write is the bypass flag — which is what a terminal + // launch has always applied to an untouched profile. + it('bypasses when the user has never opened Agent settings', () => { + expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: {} })).toBe( + 'bypassPermissions' + ) + expect(claudeStructuredPermissionModeForSettings({})).toBe('bypassPermissions') + expect(claudeStructuredPermissionModeForSettings(null)).toBe('bypassPermissions') + expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { codex: '' } })).toBe( + 'bypassPermissions' + ) + }) + + it('bypasses when Yolo wrote the flag, alone or beside other tokens', () => { + for (const claude of [ + '--dangerously-skip-permissions', + '--dangerously-skip-permissions --model Opus', + '--model Opus --dangerously-skip-permissions' + ]) { + expect( + claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude } }), + claude + ).toBe('bypassPermissions') + } + }) + + // Manual is stored as an empty string, which owns the key and so beats the shipped default. + it('prompts when Manual cleared the flag', () => { + expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude: '' } })).toBe( + 'default' + ) + }) + + it('prompts when the user replaced the flag with something else', () => { + expect( + claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude: '--model Opus' } }) + ).toBe('default') + }) +}) diff --git a/src/main/claude/claude-structured-permission-mode.ts b/src/main/claude/claude-structured-permission-mode.ts new file mode 100644 index 00000000000..39161b50cf4 --- /dev/null +++ b/src/main/claude/claude-structured-permission-mode.ts @@ -0,0 +1,23 @@ +import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults' + +/** + * The Agent Permissions setting as the SDK's own permission mode. + * + * Read per acquisition — like the environment overlay and the auth policy beside it — rather than + * latched into the session record: the setting is the one copy of this fact, so nothing can + * disagree with it and a failed restore cannot silently downgrade a session to prompting. + * + * Yolo still stores itself as the agent's bypass flag inside the launch arguments, which is also + * what a terminal launch acts on, so presence of that flag is the fact to read — resolved through + * the same default fallback the terminal uses, which is why an untouched profile bypasses. The + * rest of the arguments string is a terminal concern this path does not interpret. + */ +export function claudeStructuredPermissionModeForSettings( + settings: Partial> | null | undefined +): PermissionMode { + return resolvedTuiAgentArgsBypassPermissions('claude', settings?.agentDefaultArgs) + ? 'bypassPermissions' + : 'default' +} diff --git a/src/main/codex/codex-structured-app-server-args.test.ts b/src/main/codex/codex-structured-app-server-args.test.ts deleted file mode 100644 index f76305cf7c1..00000000000 --- a/src/main/codex/codex-structured-app-server-args.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveCodexStructuredAppServerArgs } from './codex-structured-app-server-args' - -describe('structured Codex app-server arguments', () => { - it('keeps configuration flags and converts effort to the app-server config contract', () => { - expect( - resolveCodexStructuredAppServerArgs( - '--profile review -c approval_policy=never --model gpt-5.6 --effort high --search', - 'posix' - ) - ).toEqual([ - '--profile', - 'review', - '-c', - 'approval_policy=never', - '--model', - 'gpt-5.6', - '-c', - 'model_reasoning_effort=high', - '--search' - ]) - }) - - it.each(['--no-alt-screen', '--remote ws://host', '-C /tmp/elsewhere', 'resume thread-1'])( - 'reports an incompatible configured argument instead of dropping %s', - (configured) => { - expect(() => resolveCodexStructuredAppServerArgs(configured, 'posix')).toThrow( - /cannot apply the configured CLI arguments.*Settings or use terminal view/ - ) - } - ) -}) diff --git a/src/main/codex/codex-structured-app-server-args.ts b/src/main/codex/codex-structured-app-server-args.ts deleted file mode 100644 index af83c46c8a2..00000000000 --- a/src/main/codex/codex-structured-app-server-args.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - tokenizeStartupCommand, - type AgentStartupShell -} from '../../shared/tui-agent-startup-shell' - -const VALUE_FLAGS = new Set([ - '-a', - '--add-dir', - '--ask-for-approval', - '-c', - '--config', - '--disable', - '--effort', - '--enable', - '--local-provider', - '-m', - '--model', - '-p', - '--profile', - '--reasoning-effort', - '-s', - '--sandbox' -]) - -const BOOLEAN_FLAGS = new Set([ - '--approve-for-me', - '--dangerously-bypass-approvals-and-sandbox', - '--dangerously-bypass-hook-trust', - '--oss', - '--search', - '--strict-config' -]) - -const EFFORT_FLAGS = new Set(['--effort', '--reasoning-effort']) - -function configuredArgsError(detail: string): Error { - return new Error( - `Structured Codex chat cannot apply the configured CLI arguments to app-server: ${detail}. Update Codex CLI arguments in Settings or use terminal view.` - ) -} - -function splitOption(token: string): { flag: string; inlineValue?: string } { - const separator = token.indexOf('=') - return separator > 0 - ? { flag: token.slice(0, separator), inlineValue: token.slice(separator + 1) } - : { flag: token } -} - -/** Keeps config-affecting Codex flags and refuses every TUI-only or unknown token visibly. */ -export function resolveCodexStructuredAppServerArgs( - configuredArgs: string, - shell: AgentStartupShell -): string[] { - const parsed = tokenizeStartupCommand(configuredArgs.trim(), shell) - if (!parsed.ok) { - throw configuredArgsError(parsed.error) - } - const divergent = parsed.spans.find((span) => span.divergesFromShell) - if (divergent) { - throw configuredArgsError(configuredArgs.slice(divergent.start, divergent.end)) - } - const result: string[] = [] - for (let index = 0; index < parsed.tokens.length; index += 1) { - const token = parsed.tokens[index] - const { flag, inlineValue } = splitOption(token) - if (BOOLEAN_FLAGS.has(flag) && inlineValue === undefined) { - result.push(flag) - continue - } - if (!VALUE_FLAGS.has(flag)) { - throw configuredArgsError(token || 'an empty positional argument') - } - const value = inlineValue ?? parsed.tokens[++index] - if (value === undefined || value.length === 0) { - throw configuredArgsError(`${flag} requires a value`) - } - if (EFFORT_FLAGS.has(flag)) { - result.push('-c', `model_reasoning_effort=${value}`) - } else { - result.push(flag, value) - } - } - return result -} diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index de83e74c6c8..6b5bb958d4c 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -3,6 +3,7 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution' +import { codexStructuredPermissionArgsForSettings } from './codex-structured-permission-mode' const SESSION_ID = 'session-1' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -38,14 +39,16 @@ function record(overrides: Partial = {}): AgentSessionRecord function resolverFor( value: AgentSessionRecord | null, resolveWorkspacePath: (workspaceId: string) => Promise = async (id) => `/repos/${id}`, - resolveRollout: () => Promise = async () => null + resolveRollout: () => Promise = async () => null, + agentDefaultArgs: Record = { codex: '' } ) { return createCodexStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', resolveRollout, - isWindowsProcessStartTimeAvailable: () => true + isWindowsProcessStartTimeAvailable: () => true, + resolvePermissionArgs: () => codexStructuredPermissionArgsForSettings({ agentDefaultArgs }) }) } @@ -109,18 +112,36 @@ describe('codex structured launch resolution', () => { expect(launch.resumeThreadId).toBe('thread-current') }) - it('places the durable user configuration before the app-server subcommand', async () => { + // Agent Permissions is the only thing from the arguments field that reaches app-server, and it + // keeps the position the durable arguments used to hold: before the subcommand. + it('places the permission flag before the app-server subcommand', async () => { + const launch = await resolverFor(record(), undefined, undefined, { + codex: '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol' + })({ identity: IDENTITY }) + + expect(launch.args).toEqual(['--dangerously-bypass-approvals-and-sandbox', 'app-server']) + }) + + it('bypasses approvals for a profile that never opened Agent settings', async () => { + const launch = await resolverFor(record(), undefined, undefined, {})({ identity: IDENTITY }) + + expect(launch.args).toEqual(['--dangerously-bypass-approvals-and-sandbox', 'app-server']) + }) + + it('leaves the approval prompts on under Manual', async () => { + const launch = await resolverFor(record())({ identity: IDENTITY }) + + expect(launch.args).toEqual(['app-server']) + }) + + // The configured CLI arguments are a terminal concern: a durable record written before they + // stopped being read must not smuggle one back into app-server's argv. + it("ignores the record's durable launch arguments", async () => { const launch = await resolverFor( record({ launchArgs: ['--profile', 'review', '-c', 'model_reasoning_effort=high'] }) )({ identity: IDENTITY }) - expect(launch.args).toEqual([ - '--profile', - 'review', - '-c', - 'model_reasoning_effort=high', - 'app-server' - ]) + expect(launch.args).toEqual(['app-server']) }) it('pins resume to the rollout file that proved the durable thread', async () => { diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index d395ee87c12..68b3d03a98d 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -27,6 +27,9 @@ export type CodexStructuredLaunchResolverDeps = { resolveRollout?: typeof resolvePinnedCodexRolloutProof /** Test seam for the host capability; production uses the native process table. */ isWindowsProcessStartTimeAvailable?: () => boolean + /** The user's Agent Permissions setting as app-server argv, re-read per acquisition. + * Absent means the CLI's own approval prompts stay on. */ + resolvePermissionArgs?: () => string[] } export function createCodexStructuredLaunchResolver( @@ -66,7 +69,9 @@ export function createCodexStructuredLaunchResolver( pathEnv, ...(homePath ? { homePath } : {}) }) - const args = [...(record.launchArgs ?? []), 'app-server'] + // `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal + // concern, and the permission posture they used to smuggle in is derived per acquisition. + const args = [...(deps.resolvePermissionArgs?.() ?? []), 'app-server'] const head = agentSessionProviderHandleChainHead(record.providerHandleChain) const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null return { diff --git a/src/main/codex/codex-structured-permission-mode.test.ts b/src/main/codex/codex-structured-permission-mode.test.ts new file mode 100644 index 00000000000..8fd8b952a97 --- /dev/null +++ b/src/main/codex/codex-structured-permission-mode.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { codexStructuredPermissionArgsForSettings } from './codex-structured-permission-mode' + +const BYPASS = ['--dangerously-bypass-approvals-and-sandbox'] + +describe('codexStructuredPermissionArgsForSettings', () => { + it('bypasses when the user has never opened Agent settings', () => { + expect(codexStructuredPermissionArgsForSettings({ agentDefaultArgs: {} })).toEqual(BYPASS) + expect(codexStructuredPermissionArgsForSettings({})).toEqual(BYPASS) + expect(codexStructuredPermissionArgsForSettings(null)).toEqual(BYPASS) + expect(codexStructuredPermissionArgsForSettings({ agentDefaultArgs: { claude: '' } })).toEqual( + BYPASS + ) + }) + + it('bypasses when Yolo wrote the flag, alone or beside other tokens', () => { + for (const codex of [ + '--dangerously-bypass-approvals-and-sandbox', + '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol', + '--model gpt-5.6-sol --dangerously-bypass-approvals-and-sandbox' + ]) { + expect( + codexStructuredPermissionArgsForSettings({ agentDefaultArgs: { codex } }), + codex + ).toEqual(BYPASS) + } + }) + + it('leaves the approval prompts on when Manual cleared the flag', () => { + expect(codexStructuredPermissionArgsForSettings({ agentDefaultArgs: { codex: '' } })).toEqual( + [] + ) + }) + + // The passthrough that used to carry these to app-server is gone on purpose; only the + // permission posture is derived, and nothing else from the field reaches argv. + it('carries nothing but the permission posture out of the arguments field', () => { + expect( + codexStructuredPermissionArgsForSettings({ + agentDefaultArgs: { + codex: '--profile review --add-dir /repo -c model_reasoning_effort=high' + } + }) + ).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-permission-mode.ts b/src/main/codex/codex-structured-permission-mode.ts new file mode 100644 index 00000000000..fe1905e2117 --- /dev/null +++ b/src/main/codex/codex-structured-permission-mode.ts @@ -0,0 +1,21 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults' +import { YOLO_TUI_AGENT_ARGS } from '../../shared/tui-agent-permissions' + +/** + * The Agent Permissions setting as app-server argv. + * + * Derived per acquisition from the resolved launch arguments, never from the free-text Arguments + * field: app-server takes a narrower option set than the interactive CLI and the two are versioned + * apart, so the only thing read out of that field is the posture the toggle stores in it. An + * untouched profile resolves to the default Orca ships, which is the bypass flag. + */ +export function codexStructuredPermissionArgsForSettings( + settings: Partial> | null | undefined +): string[] { + const bypassArg = YOLO_TUI_AGENT_ARGS.codex + return bypassArg !== undefined && + resolvedTuiAgentArgsBypassPermissions('codex', settings?.agentDefaultArgs) + ? [bypassArg] + : [] +} diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 3eb7a6e9401..ff110a264cd 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -11,7 +11,6 @@ import { } from './runtime-worktree-ps-activity' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' import { compareWorktreePs } from './runtime-worktree-status-projection' -import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { Repo } from '../../shared/repo-types' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' @@ -20,13 +19,9 @@ import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' -import { - resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv -} from '../../shared/tui-agent-launch-defaults' -import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' -import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' -import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args' +import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' +import { codexStructuredPermissionArgsForSettings } from '../codex/codex-structured-permission-mode' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { hostname } from 'node:os' import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' @@ -153,13 +148,18 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent // in a plain folder lands in the folder rather than failing to resolve. resolveWorkspacePath: async (workspaceId) => (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, - resolveLaunchArgs: (provider) => this.resolveConfiguredStructuredLaunchArgs(provider), resolveLaunchEnvOverlay: () => resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), resolveClaudeLaunchEnv: () => resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv), resolveClaudeAuthPolicy: () => claudeStructuredAuthPolicyForSettings(this.requireStore().getSettings()), + // Re-read per acquisition, like the auth policy above it: the Agent Permissions setting is + // the one copy of this fact, and the configured CLI arguments never reach a structured launch. + resolveClaudePermissionMode: () => + claudeStructuredPermissionModeForSettings(this.requireStore().getSettings()), + resolveCodexPermissionArgs: () => + codexStructuredPermissionArgsForSettings(this.requireStore().getSettings()), // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), // Structured chat has no agent CLI hooks, so this projection is what the first-work @@ -176,47 +176,6 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent }) } - // Why the provider is honoured rather than assumed: Codex app-server flags are not - // Claude CLI flags, and prepending them to `claude` makes it exit on an unknown option. - protected resolveConfiguredStructuredLaunchArgs( - provider: AgentSessionRecord['provider'] - ): string[] { - if (provider === 'claude') { - return this.resolveConfiguredClaudeStructuredArgs() - } - return this.resolveConfiguredCodexStructuredArgs() - } - - protected resolveConfiguredClaudeStructuredArgs(): string[] { - const settings = this.requireStore().getSettings() - const shell = resolveStartupShell( - process.platform, - resolveLocalWindowsAgentStartupShell({ - platform: process.platform, - isRemote: false, - terminalWindowsShell: settings.terminalWindowsShell - }) - ) - const tokenized = tokenizeStartupCommand( - resolveTuiAgentLaunchArgs('claude', settings.agentDefaultArgs), - shell - ) - return tokenized.ok ? tokenized.tokens : [] - } - - protected resolveConfiguredCodexStructuredArgs(): string[] { - const settings = this.requireStore().getSettings() - const shell = resolveLocalWindowsAgentStartupShell({ - platform: process.platform, - isRemote: false, - terminalWindowsShell: settings.terminalWindowsShell - }) - return resolveCodexStructuredAppServerArgs( - resolveTuiAgentLaunchArgs('codex', settings.agentDefaultArgs), - shell ?? 'posix' - ) - } - protected createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { return { hostLabel: hostname(), diff --git a/src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts deleted file mode 100644 index c4333fd0a4d..00000000000 --- a/src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' - -type InstalledDeps = { - resolveLaunchArgs: (provider: 'claude' | 'codex') => Promise | string[] - resolveLaunchEnvOverlay: () => Record - resolveClaudeLaunchEnv?: () => Record -} - -const { installStructuredAgentSessionHost } = vi.hoisted(() => ({ - installStructuredAgentSessionHost: vi.fn(async (_deps: unknown) => ({}) as never) -})) - -vi.mock('./structured-agent-session-runtime', async (importOriginal) => ({ - ...(await importOriginal()), - ensureStructuredAgentSessionHost: installStructuredAgentSessionHost -})) - -function runtimeWith(settings: Record): OrcaRuntimeService { - return new OrcaRuntimeService({ getSettings: () => settings } as never) -} - -async function installedDeps(settings: Record): Promise { - installStructuredAgentSessionHost.mockClear() - await runtimeWith(settings).ensureStructuredAgentSessionHost() - return installStructuredAgentSessionHost.mock.calls[0]?.[0] as InstalledDeps -} - -describe('structured agent-session launch args wiring', () => { - it('resolves Claude launch args from the Claude agent defaults, not Codex flags', async () => { - const deps = await installedDeps({ - agentDefaultArgs: { - claude: '--dangerously-skip-permissions --model opus', - codex: '--dangerously-bypass-approvals-and-sandbox' - }, - agentDefaultEnv: {} - }) - - expect(await deps.resolveLaunchArgs('claude')).toEqual([ - '--dangerously-skip-permissions', - '--model', - 'opus' - ]) - }) - - it('still resolves Codex app-server args for a Codex session', async () => { - const deps = await installedDeps({ - agentDefaultArgs: { - claude: '--dangerously-skip-permissions', - codex: '--dangerously-bypass-approvals-and-sandbox' - }, - agentDefaultEnv: {} - }) - - const codexArgs = await deps.resolveLaunchArgs('codex') - expect(codexArgs).not.toContain('--dangerously-skip-permissions') - expect(codexArgs.length).toBeGreaterThan(0) - }) - - it('never lets a broken Codex args configuration block a Claude session', async () => { - const deps = await installedDeps({ - agentDefaultArgs: { claude: '--model opus', codex: '--not-a-real-codex-flag' }, - agentDefaultEnv: {} - }) - - expect(await deps.resolveLaunchArgs('claude')).toEqual(['--model', 'opus']) - expect(() => deps.resolveLaunchArgs('codex')).toThrow() - }) - - it('supplies the Claude env overlay so the launch resolver does not fall back to process.env', async () => { - const deps = await installedDeps({ - agentDefaultArgs: {}, - agentDefaultEnv: { - claude: { ORCA_CLAUDE_OVERLAY: 'claude-value' }, - codex: { ORCA_CODEX_OVERLAY: 'codex-value' } - } - }) - - expect(deps.resolveClaudeLaunchEnv).toBeTypeOf('function') - expect(deps.resolveClaudeLaunchEnv?.()).toMatchObject({ - ORCA_CLAUDE_OVERLAY: 'claude-value' - }) - expect(deps.resolveClaudeLaunchEnv?.()).not.toHaveProperty('ORCA_CODEX_OVERLAY') - expect(deps.resolveLaunchEnvOverlay()).toMatchObject({ ORCA_CODEX_OVERLAY: 'codex-value' }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts index b937f6febd2..ffdb87964bd 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts @@ -97,7 +97,7 @@ describe('a structured default this dispatch cannot honour', () => { decide({ settings: { ...STRUCTURED_DEFAULT, agentCmdOverrides: { claude: 'claude-wrapper' } } }) - ).toMatchObject({ mode: 'terminal', reason: 'tui_launch_customization' }) + ).toMatchObject({ mode: 'terminal', reason: 'tui_launch_command' }) }) // Neither provider is refused here on the client's platform: only the executing host knows diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts index 7e735fa65fb..5e84ea1fae7 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts @@ -87,19 +87,17 @@ describe('worker-start mode receipt wording', () => { }) }) - it('names a custom TUI launch as the downgrade', () => { + it('names a custom TUI launch command as the downgrade', () => { expect( decideWorkerStartMode({ params: { agent: 'claude' }, - settings: { ...STRUCTURED_PREFERENCE, agentDefaultArgs: { claude: '--custom' } } + settings: { ...STRUCTURED_PREFERENCE, agentCmdOverrides: { claude: 'claude-wrapper' } } }) ).toEqual({ mode: 'terminal', preferred: 'structured', - reason: 'tui_launch_customization', - detail: downgradeSentence( - 'this agent has a custom launch command, arguments or environment that only a terminal applies' - ) + reason: 'tui_launch_command', + detail: downgradeSentence('this agent has a custom launch command that only a terminal runs') }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 92643fcae57..4f2e75bd7b6 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -7,6 +7,7 @@ // reads is module-level for the same reason the registry is — the runtime // service is already far past its size budget. +import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -74,6 +75,10 @@ export type StructuredAgentSessionRuntimeDeps = { resolveClaudeLaunchEnv?: () => Promise> | Record /** Required, and asserted at install time — an absent policy must not degrade to a guess. */ resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + /** The user's Agent Permissions setting for Claude; absent means prompting. */ + resolveClaudePermissionMode?: () => Promise | PermissionMode + /** The same setting for Codex, as app-server argv; absent means its approval prompts stay on. */ + resolveCodexPermissionArgs?: () => string[] /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise @@ -261,6 +266,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 7e743220741..9a070db60af 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -1,3 +1,4 @@ +import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { proveClaudeTranscriptBranch } from '../claude/claude-transcript-branch-proof' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' @@ -27,6 +28,8 @@ export type StructuredClaudeRuntimeAdapterDeps = { /** Managed-account auth state for a Claude launch, mirroring the terminal preflight. * Required: an absent policy is what silently under-strips. */ resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + /** The user's Agent Permissions setting for Claude; absent means prompting. */ + resolveClaudePermissionMode?: () => Promise | PermissionMode readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] @@ -49,6 +52,9 @@ export function createStructuredClaudeRuntimeAdapter( resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), resolveAuthPolicy: deps.resolveClaudeAuthPolicy, + ...(deps.resolveClaudePermissionMode + ? { resolvePermissionMode: deps.resolveClaudePermissionMode } + : {}), ...(deps.readClaudeManagedAccountGate ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } : {}) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 7185f453351..62b29c8edb8 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -141,7 +141,6 @@ export async function submitFolderWorkspaceCreate({ }, prompt: launchDraftPrompt ?? note, promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit', - tuiCustomization: { agentArgs }, initialSessionOptions: startupPlan?.sessionOptions }) : null diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index 62eabe93ed4..2ea4c568243 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -106,7 +106,7 @@ describe('buildAgentLaunchRouteInput', () => { promptDelivery: 'auto-submit', launchText: 'fix the flaky test', nativeChatTranscriptIsLocalReadable: true, - requiresTuiLaunchCustomization: false, + requiresTuiLaunchCommand: false, initialSessionOptions: { model: 'gpt-5.4' } }) expect(mocks.getExecutionHostIdForWorktree).toHaveBeenCalledWith(appStore, 'wt-1') @@ -240,7 +240,6 @@ describe('buildAgentLaunchRouteInput', () => { it.each([ ['a cwd', { cwd: '/repo/sub' }, {}], - ['explicit agent args', { agentArgs: '--model gpt-5.4' }, {}], ['a settings command override', {}, { agentCmdOverrides: { codex: 'codex-nightly' } }] ] as const)('requires a terminal for %s', (_name, tuiCustomization, settingsOverride) => { const input = buildAgentLaunchRouteInput( @@ -251,7 +250,28 @@ describe('buildAgentLaunchRouteInput', () => { tuiCustomization } ) - expect(input.requiresTuiLaunchCustomization).toBe(true) + expect(input.requiresTuiLaunchCommand).toBe(true) + }) + + // The reported P0: `--dangerously-skip-permissions --model Opus` matched no blessed string, so + // every new Claude tab was silently demoted to the terminal-backed chat. The Arguments field is + // a terminal concern and no longer reaches this decision. + it.each([ + ['claude', '--dangerously-skip-permissions --model Opus'], + ['codex', '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol'], + ['claude', '--append-system-prompt "be brief"'] + ] as const)('keeps %s structured with configured arguments %s', (agent, agentArgs) => { + const appStore = store({ + ...STRUCTURED_SETTINGS, + agentDefaultArgs: { [agent]: agentArgs }, + agentDefaultEnv: { [agent]: { ORCA_QA: '1' } } + }) + const args = { + agent, + workspace: { kind: 'git-worktree' as const, worktreeId: 'wt-1' } + } + expect(routeFor(appStore, args)).toBe('structured-native-chat') + expect(buildAgentLaunchRouteInput(appStore, args).requiresTuiLaunchCommand).toBe(false) }) // Grok reads its transcript off local disk, so it is the agent the readability answer routes on. diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index fcda99dcf7e..85fbf2e8ca3 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -7,8 +7,7 @@ import { import type { TuiAgent } from '../../../shared/tui-agent' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { - hasExplicitTuiAgentArgs, - hasExplicitTuiLaunchCustomization, + hasExplicitTuiLaunchCommand, type AgentLaunchRoutingInput } from '@/lib/agent-launch-routing' // Why: the `connection-context` facade imports the store root; the resolver's own module keeps @@ -53,8 +52,8 @@ export type AgentLaunchRouteArgs = { workspace: ProspectiveWorkspace prompt?: string promptDelivery?: NativeChatLaunchPromptDelivery - /** A cwd or explicit CLI args only a terminal can apply. */ - tuiCustomization?: { cwd?: string | null; agentArgs?: string | null } + /** A working directory only a terminal can apply; a structured session runs in its workspace. */ + tuiCustomization?: { cwd?: string | null } initialSessionOptions?: Readonly> } @@ -130,10 +129,8 @@ export function buildAgentLaunchRouteInput( workspace, executionHostId ), - requiresTuiLaunchCustomization: - Boolean(tuiCustomization?.cwd?.trim()) || - hasExplicitTuiAgentArgs(agent, tuiCustomization?.agentArgs) || - hasExplicitTuiLaunchCustomization(store.settings, agent), + requiresTuiLaunchCommand: + Boolean(tuiCustomization?.cwd?.trim()) || hasExplicitTuiLaunchCommand(store.settings, agent), initialSessionOptions: args.initialSessionOptions } } diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index bb22ad6a815..941d184602b 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from 'vitest' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { - hasExplicitTuiAgentArgs, - hasExplicitTuiLaunchCustomization, - hasSemanticallyNonEmptyAgentArgs, + hasExplicitTuiLaunchCommand, resolveAgentLaunchRoute, structuredAgentLaunchSupported } from './agent-launch-routing' @@ -96,7 +94,7 @@ describe('resolveAgentLaunchRoute', () => { // openclaude and grok render native chat but have no structured adapter. expect(route({ agent: 'openclaude' })).toBe('legacy-native-chat') expect(route({ agent: 'grok' })).toBe('legacy-native-chat') - expect(route({ requiresTuiLaunchCustomization: true })).toBe('legacy-native-chat') + expect(route({ requiresTuiLaunchCommand: true })).toBe('legacy-native-chat') }) it.each([ @@ -147,21 +145,13 @@ describe('resolveAgentLaunchRoute', () => { ).toBe('legacy-native-chat') }) - it('normalizes semantically empty argument and settings customization', () => { - expect(hasSemanticallyNonEmptyAgentArgs(' \n\t')).toBe(false) - expect( - hasExplicitTuiLaunchCustomization( - { agentCmdOverrides: {}, agentDefaultArgs: { codex: ' ' }, agentDefaultEnv: {} }, - 'codex' - ) - ).toBe(false) - }) - - it('does not classify the resolved default TUI args as customization', () => { - expect(hasExplicitTuiAgentArgs('codex', '--dangerously-bypass-approvals-and-sandbox')).toBe( + it('treats a whitespace-only command override as no override', () => { + expect(hasExplicitTuiLaunchCommand({ agentCmdOverrides: { codex: ' ' } }, 'codex')).toBe( false ) - expect(hasExplicitTuiAgentArgs('codex', '--model gpt-5.6-sol')).toBe(true) + expect( + hasExplicitTuiLaunchCommand({ agentCmdOverrides: { codex: 'codex-nightly' } }, 'codex') + ).toBe(true) }) }) diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 5c4d8de9586..ba614053131 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -10,11 +10,7 @@ import { type NativeChatLaunchPromptDelivery } from '@/lib/native-chat-initial-view-mode' -export { - hasExplicitTuiAgentArgs, - hasExplicitTuiLaunchCustomization, - hasSemanticallyNonEmptyAgentArgs -} from '../../../shared/tui-agent-launch-customization' +export { hasExplicitTuiLaunchCommand } from '../../../shared/tui-agent-launch-command-override' export type AgentLaunchRoute = 'structured-native-chat' | 'legacy-native-chat' | 'terminal-tui' @@ -37,7 +33,7 @@ export type AgentLaunchRoutingInput = { promptDelivery?: NativeChatLaunchPromptDelivery launchText?: string nativeChatTranscriptIsLocalReadable?: boolean - requiresTuiLaunchCustomization?: boolean + requiresTuiLaunchCommand?: boolean initialSessionOptions?: Readonly> } @@ -77,7 +73,7 @@ export function structuredAgentLaunchSupported( hostCapabilities: input.hostCapabilities, workspaceKind: input.workspaceKind, projectRuntime: input.projectRuntime, - requiresTuiLaunchCustomization: input.requiresTuiLaunchCustomization + requiresTuiLaunchCommand: input.requiresTuiLaunchCommand }).supported ) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index d756316a6af..ff005550807 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -219,7 +219,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent workspace: { kind: workspaceKindForWorktreeId(worktreeId), worktreeId }, prompt: trimmedPrompt, promptDelivery: viewModePromptDelivery, - tuiCustomization: { cwd: initialCwd, agentArgs }, + tuiCustomization: { cwd: initialCwd }, initialSessionOptions: startupPlan.sessionOptions, onPromptDelivered }) diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 93cb9d146de..e81dbf4b411 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -98,7 +98,6 @@ export async function prepareDirectWorkItemAgentLaunch(args: { workspace: { kind: 'git-worktree', worktreeId: args.worktreeId, repoId: args.repoId }, prompt: args.draftContent, promptDelivery: args.promptDelivery, - tuiCustomization: { agentArgs: args.agentArgs }, initialSessionOptions: startupPlan?.sessionOptions }) const structuredLaunch = plan?.route === 'structured-native-chat' diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 02f202ae0b4..21b52a83b6e 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -56,21 +56,20 @@ describe('per-launch structured feasibility', () => { expect(support({ agent })).toEqual({ supported: true }) }) - it.each([ + const blockerCases: [string, Partial, string][] = [ ['a reused PTY agent', { reusesTerminal: true }, 'reused-terminal'], ['grok', { agent: 'grok' }, 'agent-without-structured-session'], ['openclaude', { agent: 'openclaude' }, 'agent-without-structured-session'], ['a floating workspace', { workspaceKind: 'floating' }, 'floating-workspace'], - ['a custom TUI launch', { requiresTuiLaunchCustomization: true }, 'tui-launch-customization'], + ['a custom TUI launch command', { requiresTuiLaunchCommand: true }, 'tui-launch-command'], ['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'], ['a missing capability', { hostCapabilities: [] }, 'runtime-capability'], ['an unanswered host', { hostCapabilities: null }, 'runtime-capability-unknown'] - ] as [string, Partial, string][])( - 'names %s as the blocker', - (_name, overrides, blocker) => { - expect(support(overrides)).toEqual({ supported: false, blocker }) - } - ) + ] + + it.each(blockerCases)('names %s as the blocker', (_name, overrides, blocker) => { + expect(support(overrides)).toEqual({ supported: false, blocker }) + }) // The client cannot see whether the host can read a provider child's start time, so neither // provider is refused here on platform; agentSession.createSupport answers that at create time. diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index df36494d24b..bde8de06c0a 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -24,7 +24,10 @@ export type StructuredNativeChatBlocker = | 'reused-terminal' | 'agent-without-structured-session' | 'floating-workspace' - | 'tui-launch-customization' + /** The agent's launch command is overridden, or the launch names its own working directory: + * a process shape only a PTY can produce. The configured *arguments* are not read here — + * they are a terminal concern the structured transports do not share a vocabulary with. */ + | 'tui-launch-command' | 'remote-execution-host' | 'project-runtime' | 'runtime-capability' @@ -43,7 +46,7 @@ export type StructuredNativeChatSupportInput = { hostCapabilities: readonly string[] | null workspaceKind?: 'git-worktree' | 'folder' | 'floating' projectRuntime?: ProjectExecutionRuntimeResolution | null - requiresTuiLaunchCustomization?: boolean + requiresTuiLaunchCommand?: boolean /** An existing PTY agent keeps its execution transport. */ reusesTerminal?: boolean } @@ -81,8 +84,8 @@ export function resolveStructuredNativeChatSupport( if (input.workspaceKind === 'floating') { return { supported: false, blocker: 'floating-workspace' } } - if (input.requiresTuiLaunchCustomization === true) { - return { supported: false, blocker: 'tui-launch-customization' } + if (input.requiresTuiLaunchCommand === true) { + return { supported: false, blocker: 'tui-launch-command' } } const projectRuntime = input.projectRuntime if (projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') { diff --git a/src/shared/tui-agent-launch-command-override.ts b/src/shared/tui-agent-launch-command-override.ts new file mode 100644 index 00000000000..0dea2c02754 --- /dev/null +++ b/src/shared/tui-agent-launch-command-override.ts @@ -0,0 +1,21 @@ +import type { GlobalSettings } from './global-settings-types' +import type { TuiAgent } from './tui-agent' + +/** + * Whether the user replaced this agent's launch command with one only a terminal can run. + * + * Shared rather than renderer-local because both launch surfaces have to answer it: the renderer + * routes such a launch back to the TUI, and orchestration falls a worker back to a PTY so the + * custom command still applies. + * + * Arguments and environment are deliberately not read here. Structured native chat applies the + * configured environment itself, and the Arguments field is a terminal/TUI concern: structured + * chat drives Claude through the Agent SDK and Codex through app-server, whose option sets are + * independently versioned and need not match the interactive CLI's. + */ +export function hasExplicitTuiLaunchCommand( + settings: Partial> | null | undefined, + agent: TuiAgent +): boolean { + return Boolean(settings?.agentCmdOverrides?.[agent]?.trim()) +} diff --git a/src/shared/tui-agent-launch-customization.ts b/src/shared/tui-agent-launch-customization.ts deleted file mode 100644 index b31edff8a01..00000000000 --- a/src/shared/tui-agent-launch-customization.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { GlobalSettings } from './global-settings-types' -import type { TuiAgent } from './tui-agent' -import { getTuiAgentDefaultArgs, getTuiAgentDefaultEnv } from './tui-agent-launch-defaults' - -/** - * Whether the user configured a TUI launch this agent would lose outside a terminal. - * - * Shared rather than renderer-local because both launch surfaces have to answer it: the renderer - * routes such a launch back to the TUI, and orchestration falls a worker back to a PTY so the - * custom command, arguments and environment still apply. - */ -export function hasExplicitTuiLaunchCustomization( - settings: - | Partial> - | null - | undefined, - agent: TuiAgent -): boolean { - const configuredArgs = settings?.agentDefaultArgs?.[agent] - const configuredEnv = settings?.agentDefaultEnv?.[agent] - const defaultEnv = getTuiAgentDefaultEnv(agent) - const envIsCustomized = - configuredEnv !== undefined && - (Object.keys(configuredEnv).length !== Object.keys(defaultEnv).length || - Object.entries(configuredEnv).some(([key, value]) => defaultEnv[key] !== value)) - return ( - Boolean(settings?.agentCmdOverrides?.[agent]?.trim()) || - hasExplicitTuiAgentArgs(agent, configuredArgs) || - envIsCustomized - ) -} - -export function hasSemanticallyNonEmptyAgentArgs(value: string | null | undefined): boolean { - return Boolean(value?.trim()) -} - -export function hasExplicitTuiAgentArgs( - agent: TuiAgent, - value: string | null | undefined -): boolean { - const trimmed = value?.trim() ?? '' - return trimmed.length > 0 && trimmed !== getTuiAgentDefaultArgs(agent).trim() -} diff --git a/src/shared/tui-agent-launch-defaults.test.ts b/src/shared/tui-agent-launch-defaults.test.ts new file mode 100644 index 00000000000..4a132144092 --- /dev/null +++ b/src/shared/tui-agent-launch-defaults.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + resolveTuiAgentLaunchArgs, + tuiAgentArgsBypassPermissions +} from './tui-agent-launch-defaults' + +describe('tuiAgentArgsBypassPermissions', () => { + // The Agent Permissions toggle has no storage of its own: Yolo is the presence of the agent's + // bypass flag in the arguments string, wherever the user has written the rest of the field. + it.each([ + ['claude', '--dangerously-skip-permissions', true], + ['claude', '--dangerously-skip-permissions --model Opus', true], + ['claude', '--model Opus --dangerously-skip-permissions', true], + ['claude', '', false], + ['claude', '--model Opus', false], + // A token boundary, so a longer flag that merely starts the same way is not a bypass. + ['claude', '--dangerously-skip-permissions-not-really', false], + ['codex', '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol', true], + ['codex', '--model gpt-5.6-sol', false] + ] as const)('reads %s args %s as %s', (agent, args, expected) => { + expect(tuiAgentArgsBypassPermissions(agent, args)).toBe(expected) + }) + + it('reads no bypass out of an absent or non-string value', () => { + expect(tuiAgentArgsBypassPermissions('claude', null)).toBe(false) + expect(tuiAgentArgsBypassPermissions('claude', undefined)).toBe(false) + }) +}) + +describe('resolveTuiAgentLaunchArgs', () => { + // A terminal launch still applies the whole configured string verbatim; only the structured + // route stopped reading it. + it('hands the configured arguments to a terminal launch unchanged', () => { + expect( + resolveTuiAgentLaunchArgs('claude', { + claude: '--dangerously-skip-permissions --model Opus' + }) + ).toBe('--dangerously-skip-permissions --model Opus') + }) + + it('falls back to the agent default when nothing is configured', () => { + expect(resolveTuiAgentLaunchArgs('claude', {})).toBe('--dangerously-skip-permissions') + expect(resolveTuiAgentLaunchArgs('claude', { claude: '' })).toBe('') + }) +}) diff --git a/src/shared/tui-agent-launch-defaults.ts b/src/shared/tui-agent-launch-defaults.ts index 6e0dc16f08d..5f23b7c2cb7 100644 --- a/src/shared/tui-agent-launch-defaults.ts +++ b/src/shared/tui-agent-launch-defaults.ts @@ -23,6 +23,21 @@ export function hasUnsupportedTuiAgentArgs(agent: TuiAgent, value: unknown): boo return (UNSUPPORTED_TUI_AGENT_ARGS[agent] ?? []).some((arg) => argPattern(arg).test(value)) } +/** + * Whether the configured arguments carry this agent's permission-bypass flag. + * + * The Agent Permissions toggle has no storage of its own — it writes and reads this flag inside + * the arguments string — so presence at a token boundary, not whole-string equality, is what + * "Yolo" means. A terminal launch applies the flag wherever else the user has written in the field. + */ +export function tuiAgentArgsBypassPermissions( + agent: TuiAgent, + value: string | null | undefined +): boolean { + const bypassArg = YOLO_TUI_AGENT_ARGS[agent] + return typeof value === 'string' && bypassArg !== undefined && argPattern(bypassArg).test(value) +} + function sanitizeTuiAgentLaunchArgs(agent: TuiAgent, args: string): string { const unsupportedArgs = UNSUPPORTED_TUI_AGENT_ARGS[agent] if (!unsupportedArgs) { @@ -93,6 +108,20 @@ export function resolveTuiAgentLaunchArgs( return getTuiAgentDefaultArgs(agent) } +/** + * Whether this agent's *resolved* launch arguments ask for a permission bypass. + * + * Resolved, not configured: an untouched Arguments field falls back to the default Orca ships, + * which is the bypass flag, so bypass is the posture a user gets until they choose otherwise. + * Choosing Manual stores an empty string, which owns the key and so beats that default. + */ +export function resolvedTuiAgentArgsBypassPermissions( + agent: TuiAgent, + configuredArgs: Partial> | null | undefined +): boolean { + return tuiAgentArgsBypassPermissions(agent, resolveTuiAgentLaunchArgs(agent, configuredArgs)) +} + export function resolveTuiAgentLaunchEnv( agent: TuiAgent, configuredEnv: Partial>> | null | undefined diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index e5e26f46802..97ee45ae425 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -45,6 +45,24 @@ describe('tui agent startup plans', () => { } ) + // Structured native chat stopped reading the configured arguments; a terminal launch must + // still spell every token of them, in order, exactly as the user wrote them. + it('passes the whole configured argument string to a terminal launch', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: '', + agentArgs: resolveTuiAgentLaunchArgs('claude', { + claude: '--dangerously-skip-permissions --model Opus' + }), + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true + }) + + // Every token, in order, shell-quoted as the terminal path has always quoted them. + expect(plan?.launchCommand).toBe("claude '--dangerously-skip-permissions' '--model' 'Opus'") + }) + it('uses POSIX quoting when the target shell is Linux', () => { const plan = buildAgentStartupPlan({ agent: 'claude', diff --git a/tests/e2e/structured-native-chat-routing-authority.unit.test.ts b/tests/e2e/structured-native-chat-routing-authority.unit.test.ts index 9dda8cbe6c7..84e64860ee6 100644 --- a/tests/e2e/structured-native-chat-routing-authority.unit.test.ts +++ b/tests/e2e/structured-native-chat-routing-authority.unit.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../src/shared/global-settings-types' import type * as SharedLaunchRoute from '../../src/shared/structured-native-chat-launch-route' import { decideWorkerStartMode } from '../../src/main/runtime/rpc/methods/orchestration-worker-start-mode' import { @@ -43,7 +44,7 @@ const blockers: StructuredNativeChatBlocker[] = [ 'reused-terminal', 'agent-without-structured-session', 'floating-workspace', - 'tui-launch-customization', + 'tui-launch-command', 'remote-execution-host', 'project-runtime', 'runtime-capability', @@ -54,13 +55,15 @@ describe('shared feasibility owns every caller decision', () => { it.each(placements)('orchestration cannot override the shared verdict for %j', (placement) => { for (const agent of ['claude', 'codex', 'grok', 'openclaude'] as const) { for (const customized of [false, true]) { - const input = { - params: { agent, ...placement }, - settings: { - ...settings, - ...(customized ? { agentDefaultArgs: { [agent]: '--custom' } } : {}) - } + // Arguments and environment are customized on BOTH passes, so the flag below tracks the + // launch command alone. A caller that resumed reading either one fails here. + const launchSettings: Partial & typeof settings = { + ...settings, + agentDefaultArgs: { [agent]: '--custom' }, + agentDefaultEnv: { [agent]: { ORCA_ROUTING_AUTHORITY: '1' } }, + ...(customized ? { agentCmdOverrides: { [agent]: `${agent}-wrapper` } } : {}) } + const input = { params: { agent, ...placement }, settings: launchSettings } predicate.mockReturnValue({ supported: true }) expect(decideWorkerStartMode(input).mode).toBe('structured') expect(predicate).toHaveBeenLastCalledWith( @@ -68,7 +71,7 @@ describe('shared feasibility owns every caller decision', () => { agent, executionHostId: placement.on ? `runtime:${placement.on}` : 'local', reusesTerminal: Boolean(placement.terminal), - requiresTuiLaunchCustomization: customized + requiresTuiLaunchCommand: customized }) ) for (const blocker of blockers) { @@ -96,7 +99,7 @@ describe('shared feasibility owns every caller decision', () => { executionHostId, promptDelivery, hostCapabilities: RUNTIME_CAPABILITIES, - requiresTuiLaunchCustomization: true, + requiresTuiLaunchCommand: true, workspaceKind: 'folder', initialSessionOptions: { model: 'model-1', effort: 'high' } } @@ -107,7 +110,7 @@ describe('shared feasibility owns every caller decision', () => { expect.objectContaining({ agent, executionHostId, - requiresTuiLaunchCustomization: true, + requiresTuiLaunchCommand: true, workspaceKind: 'folder' }) )