mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Stop reading the terminal arguments field on the structured chat route (#20944)
* fix(native-chat): stop reading the terminal arguments field on the structured chat route Setting Claude's Arguments to "--dangerously-skip-permissions --model Opus" made every new Claude tab open in the old terminal-backed chat instead of the new structured one, with nothing on screen to explain why. Removing "--model Opus" fixed it. The cause was a whole-string comparison: the configured arguments were checked against a single blessed value per agent, so any added token at all — including one the agent supports — stopped the string matching and the launch was demoted. Structured chat does not run the interactive CLI. It drives Claude through the Agent SDK and Codex through app-server, and those take narrower option sets that are versioned separately from the CLI's, so one free-text field cannot have a guaranteed meaning for all three. The structured route now reads only what it can actually honour: a replaced launch command, or a launch that names its own working directory. Terminal launches still apply the field exactly as before. Permission posture no longer travels as a raw flag. It is derived from the resolved launch arguments, which is the same fact a terminal launch acts on and which falls back to the default Orca ships when the field was never touched, so bypass stays on by default and Manual is still honoured. Claude gets the SDK's typed permissionMode and allowDangerouslySkipPermissions at query start; Codex gets its bypass flag placed before the app-server subcommand. Both are re-derived per acquisition beside the auth policy and environment overlay rather than stored in the session record, so nothing can disagree with the setting. Codex also loses the --profile, --add-dir and -c passthrough that reached app-server through that field. Only the permission posture comes back. * test(native-chat): pin routing authority on the narrowed feasibility input The routing-authority pin still named the old bundled blocker and built its "customized" fixture out of the arguments field, which is no longer a feasibility input. Both are now the launch command, and arguments and environment are customized on both passes of the loop, so the flag handed to the shared resolver tracks the command alone — a caller that resumed reading either one fails here. No case is dropped and no assertion is relaxed: the blocker list is still exhaustive and every caller must still honour a refusal from the shared resolver.
This commit is contained in:
@@ -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<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv'>
|
||||
NativeChatDefaultSettings & Pick<GlobalSettings, 'agentCmdOverrides'>
|
||||
>
|
||||
|
||||
/** The placement facts the decision reads. `worktree`, `model` and `effort` are deliberately not
|
||||
@@ -89,8 +88,7 @@ const DOWNGRADE_DETAIL: Record<Exclude<AgentLaunchModeReason, 'user_default'>, 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)
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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<string, string>,
|
||||
stripAuthEnv = false
|
||||
stripAuthEnv = false,
|
||||
// Manual by default so a test that is not about permissions is not silently about them.
|
||||
agentDefaultArgs: Record<string, string> = { 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 () => {
|
||||
|
||||
@@ -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<string, string>): Record<string, string> {
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
@@ -71,47 +74,18 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): 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<ClaudeStructuredSdkOptions, 'model' | 'effort' | 'extraArgs'> {
|
||||
let model: string | undefined
|
||||
let effort: EffortLevel | undefined
|
||||
const extraArgs: Record<string, string | null> = {}
|
||||
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<ClaudeStructuredSdkOptions, 'permissionMode' | 'allowDangerouslySkipPermissions'> {
|
||||
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> | ClaudeStructuredAuthPolicy
|
||||
/** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */
|
||||
resolvePermissionMode?: () => Promise<PermissionMode> | 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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<Pick<GlobalSettings, 'agentDefaultArgs'>> | null | undefined
|
||||
): PermissionMode {
|
||||
return resolvedTuiAgentArgsBypassPermissions('claude', settings?.agentDefaultArgs)
|
||||
? 'bypassPermissions'
|
||||
: 'default'
|
||||
}
|
||||
@@ -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/
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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> = {}): AgentSessionRecord
|
||||
function resolverFor(
|
||||
value: AgentSessionRecord | null,
|
||||
resolveWorkspacePath: (workspaceId: string) => Promise<string> = async (id) => `/repos/${id}`,
|
||||
resolveRollout: () => Promise<string | null> = async () => null
|
||||
resolveRollout: () => Promise<string | null> = async () => null,
|
||||
agentDefaultArgs: Record<string, string> = { 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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<Pick<GlobalSettings, 'agentDefaultArgs'>> | null | undefined
|
||||
): string[] {
|
||||
const bypassArg = YOLO_TUI_AGENT_ARGS.codex
|
||||
return bypassArg !== undefined &&
|
||||
resolvedTuiAgentArgsBypassPermissions('codex', settings?.agentDefaultArgs)
|
||||
? [bypassArg]
|
||||
: []
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
type InstalledDeps = {
|
||||
resolveLaunchArgs: (provider: 'claude' | 'codex') => Promise<string[]> | string[]
|
||||
resolveLaunchEnvOverlay: () => Record<string, string>
|
||||
resolveClaudeLaunchEnv?: () => Record<string, string>
|
||||
}
|
||||
|
||||
const { installStructuredAgentSessionHost } = vi.hoisted(() => ({
|
||||
installStructuredAgentSessionHost: vi.fn(async (_deps: unknown) => ({}) as never)
|
||||
}))
|
||||
|
||||
vi.mock('./structured-agent-session-runtime', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
ensureStructuredAgentSessionHost: installStructuredAgentSessionHost
|
||||
}))
|
||||
|
||||
function runtimeWith(settings: Record<string, unknown>): OrcaRuntimeService {
|
||||
return new OrcaRuntimeService({ getSettings: () => settings } as never)
|
||||
}
|
||||
|
||||
async function installedDeps(settings: Record<string, unknown>): Promise<InstalledDeps> {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, string>> | Record<string, string>
|
||||
/** Required, and asserted at install time — an absent policy must not degrade to a guess. */
|
||||
resolveClaudeAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
|
||||
/** The user's Agent Permissions setting for Claude; absent means prompting. */
|
||||
resolveClaudePermissionMode?: () => Promise<PermissionMode> | 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<NodeJS.ProcessEnv>
|
||||
@@ -261,6 +266,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
|
||||
store,
|
||||
resolveWorkspacePath: deps.resolveWorkspacePath,
|
||||
resolveEnvironment: resolveCodexEnvironment,
|
||||
...(deps.resolveCodexPermissionArgs
|
||||
? { resolvePermissionArgs: deps.resolveCodexPermissionArgs }
|
||||
: {}),
|
||||
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
|
||||
}),
|
||||
...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}),
|
||||
@@ -292,6 +300,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
|
||||
? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv }
|
||||
: {}),
|
||||
resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy,
|
||||
...(deps.resolveClaudePermissionMode
|
||||
? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode }
|
||||
: {}),
|
||||
...(deps.getClaudeManagedAccountGateSettings
|
||||
? {
|
||||
readClaudeManagedAccountGate: () =>
|
||||
|
||||
@@ -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> | ClaudeStructuredAuthPolicy
|
||||
/** The user's Agent Permissions setting for Claude; absent means prompting. */
|
||||
resolveClaudePermissionMode?: () => Promise<PermissionMode> | 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 }
|
||||
: {})
|
||||
|
||||
@@ -141,7 +141,6 @@ export async function submitFolderWorkspaceCreate({
|
||||
},
|
||||
prompt: launchDraftPrompt ?? note,
|
||||
promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit',
|
||||
tuiCustomization: { agentArgs },
|
||||
initialSessionOptions: startupPlan?.sessionOptions
|
||||
})
|
||||
: null
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Record<string, unknown>>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<Record<string, unknown>>
|
||||
}
|
||||
|
||||
@@ -77,7 +73,7 @@ export function structuredAgentLaunchSupported(
|
||||
hostCapabilities: input.hostCapabilities,
|
||||
workspaceKind: input.workspaceKind,
|
||||
projectRuntime: input.projectRuntime,
|
||||
requiresTuiLaunchCustomization: input.requiresTuiLaunchCustomization
|
||||
requiresTuiLaunchCommand: input.requiresTuiLaunchCommand
|
||||
}).supported
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -56,21 +56,20 @@ describe('per-launch structured feasibility', () => {
|
||||
expect(support({ agent })).toEqual({ supported: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
const blockerCases: [string, Partial<StructuredNativeChatSupportInput>, 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<StructuredNativeChatSupportInput>, 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.
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<Pick<GlobalSettings, 'agentCmdOverrides'>> | null | undefined,
|
||||
agent: TuiAgent
|
||||
): boolean {
|
||||
return Boolean(settings?.agentCmdOverrides?.[agent]?.trim())
|
||||
}
|
||||
@@ -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<Pick<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv'>>
|
||||
| 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()
|
||||
}
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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<Record<TuiAgent, string>> | null | undefined
|
||||
): boolean {
|
||||
return tuiAgentArgsBypassPermissions(agent, resolveTuiAgentLaunchArgs(agent, configuredArgs))
|
||||
}
|
||||
|
||||
export function resolveTuiAgentLaunchEnv(
|
||||
agent: TuiAgent,
|
||||
configuredEnv: Partial<Record<TuiAgent, Record<string, string>>> | null | undefined
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<GlobalSettings> & 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'
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user