mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Agents: store each agent's permission itself; terminals and new chats read it (#31315)
* feat(agents): store each agent's permission itself instead of typing flags into Arguments Terminals and new chats read one stored choice per agent (Use the agent's own settings, Ask for approval, Accept edits, Approve for me, Full access). Orca adds the flag at launch; Arguments hold only the user's own text. A one-time move lifts Orca's flags out of saved Arguments. Older clients still see and write the flag as part of Arguments; the host translates. * fix(agents): say the all-agents switch resets agents set separately; keep primitives unstyled * test(agents): the one-time move returns every launch-profile field * Fix permission carrier ownership and surface-specific defaults * Preserve uncertain permission operands and name the option inventory * Check env entries without indexing typed agent maps * Combine Chat permission capability imports * Use folder execution-host platform for permission environment * Preserve launch map replacement and acknowledge permission saves * Keep permission feedback fallbacks statically discoverable * Provide preload platform to Agents settings render tests * Preserve Gemini approval arguments under Full access * fix(settings): retain complete environment edits through save outcomes * fix(settings): narrow local failure scope and preserve host messages * fix(settings): retire argument drafts after acknowledged saves * fix(settings): keep Goose environment editable after reset * fix(settings): show resolved arguments for legacy hosts * Preserve pending environment resets and bound launch settings work * Keep optional settings fallback in repository metadata reads * Read repository visibility defaults through the raw settings fixtures * Prepare whole Environment edits and retire unchanged drafts * Keep Environment settings transitions in an acyclic module * Update raw settings reader test fixtures * Place test fixture safety notes on assertion starts * test: pin permission settings work and recovery contracts * test: type permission settings work fixtures * test: cover migrated permission inputs and captured CLI grammar * test: preserve typed permissions in WSL Claude restarts
This commit is contained in:
@@ -5,6 +5,10 @@ import baseConfig from './vitest.config'
|
||||
import { nodeRuntimePool } from './scripts/vitest-node-runtime-pool'
|
||||
|
||||
const contracts = [
|
||||
'src/shared/tui-agent-permission-choice.test.ts',
|
||||
'src/renderer/src/components/settings/agent-permission-conversion-persistence.test.tsx',
|
||||
'src/main/runtime/rpc/methods/repo.test.ts',
|
||||
'src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx',
|
||||
'src/main/sqlite/sync-database.test.ts',
|
||||
'src/main/runtime/orchestration/db/row-column-lists.test.ts',
|
||||
'src/shared/native-chat-tool-attribution-allocation.test.ts',
|
||||
|
||||
@@ -104,8 +104,8 @@ export function buildMobileAiVaultResumeLaunch(args: {
|
||||
cmdOverrides,
|
||||
platform: args.hostPlatform,
|
||||
shell,
|
||||
agentArgs: resolveTuiAgentLaunchArgs(args.session.agent, args.settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings?.agentDefaultEnv),
|
||||
agentArgs: resolveTuiAgentLaunchArgs(args.session.agent, args.settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings, args.hostPlatform),
|
||||
...(args.session.agent === 'omp' && resumeFilePath
|
||||
? { ompResumeFilePath: resumeFilePath }
|
||||
: {})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CODEX_PERMISSION_CONFIG_ROOTS } from '../../shared/agent-permission-args'
|
||||
import { parseTomlKeyPath } from './config-toml-key-path'
|
||||
import { StructuredAgentArgumentsError } from '../native-chat/structured-agent-arguments-error'
|
||||
|
||||
@@ -60,12 +61,7 @@ function inlineValue(token: string, option: string): string | undefined {
|
||||
|
||||
function isPermissionConfig(value: string): boolean {
|
||||
const key = parseTomlKeyPath(value)?.segments[0]
|
||||
return (
|
||||
key === 'approval_policy' ||
|
||||
key === 'sandbox_mode' ||
|
||||
key === 'approvals_reviewer' ||
|
||||
key === 'sandbox_workspace_write'
|
||||
)
|
||||
return CODEX_PERMISSION_CONFIG_ROOTS.some((root) => root === key)
|
||||
}
|
||||
|
||||
/** Accept only root CLI options; Orca sets permissions on the thread RPC. */
|
||||
|
||||
@@ -6,6 +6,8 @@ const launchSettings = new Set([
|
||||
'agentCmdOverrides',
|
||||
'agentDefaultArgs',
|
||||
'agentDefaultEnv',
|
||||
'agentPermissionDefault',
|
||||
'agentPermissionOverrides',
|
||||
'nativeChatInheritShellEnvironment',
|
||||
'nativeChatShellEnvironmentVariables',
|
||||
'terminalWindowsShell',
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import { resolveTuiAgentLaunchArgs } from '../../shared/tui-agent-launch-defaults'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
type AgentLaunchProfileSettings
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
|
||||
|
||||
/** Use the same grouping rules as terminal launches before the provider filters its owned flags. */
|
||||
export function structuredAgentConfiguredArgs(
|
||||
agent: string,
|
||||
settings: Partial<Pick<GlobalSettings, 'agentDefaultArgs' | 'terminalWindowsShell'>>,
|
||||
settings: AgentLaunchProfileSettings & Partial<Pick<GlobalSettings, 'terminalWindowsShell'>>,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string[] {
|
||||
const shell =
|
||||
@@ -15,10 +18,7 @@ export function structuredAgentConfiguredArgs(
|
||||
isRemote: false,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
}) ?? 'posix'
|
||||
const parsed = tokenizeStartupCommand(
|
||||
resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
shell
|
||||
)
|
||||
const parsed = tokenizeStartupCommand(resolveTuiAgentLaunchArgs(agent, settings), shell)
|
||||
if (!parsed.ok) {
|
||||
throw new Error(`${agent} Arguments are invalid: ${parsed.error}`)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export function createOrcadAccountServices(args: {
|
||||
prepareCodexCatalogProbeHome: (homePath) =>
|
||||
codexRuntimeHome.prepareHostCodexHomeForReadOnlyAppServer(
|
||||
homePath,
|
||||
resolveTuiAgentLaunchEnv('codex', store.getSettings().agentDefaultEnv)
|
||||
resolveTuiAgentLaunchEnv('codex', store.getSettings())
|
||||
),
|
||||
prepareAiVaultSessionResume: (resumeArgs) =>
|
||||
prepareCodexAiVaultSessionResume(resumeArgs, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { composeAgentLaunchProfile } from '../shared/tui-agent-launch-defaults'
|
||||
import {
|
||||
closeTestStores,
|
||||
testState,
|
||||
@@ -459,12 +460,12 @@ describe('Store', () => {
|
||||
)
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getSettings().agentDefaultArgs).toMatchObject({
|
||||
expect(composeAgentLaunchProfile(store.getSettings()).agentDefaultArgs).toMatchObject({
|
||||
claude: '--dangerously-skip-permissions',
|
||||
codex: '--dangerously-bypass-approvals-and-sandbox',
|
||||
cursor: '--yolo'
|
||||
})
|
||||
expect(store.getSettings().agentDefaultEnv).toMatchObject({
|
||||
expect(composeAgentLaunchProfile(store.getSettings()).agentDefaultEnv).toMatchObject({
|
||||
goose: { GOOSE_MODE: 'auto' }
|
||||
})
|
||||
expect(store.getSettings().agentYoloDefaultsMigrated).toBe(true)
|
||||
@@ -484,9 +485,10 @@ describe('Store', () => {
|
||||
)
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getSettings().agentDefaultArgs?.codex).toBe('')
|
||||
expect(store.getSettings().agentDefaultEnv?.goose).toEqual({})
|
||||
expect(store.getSettings().agentDefaultArgs?.claude).toBe('--dangerously-skip-permissions')
|
||||
const launched = composeAgentLaunchProfile(store.getSettings())
|
||||
expect(launched.agentDefaultArgs.codex).toBe('')
|
||||
expect(launched.agentDefaultEnv.goose).toEqual({})
|
||||
expect(launched.agentDefaultArgs.claude).toBe('--dangerously-skip-permissions')
|
||||
})
|
||||
|
||||
it('removes unsupported TUI skip-permissions args from migrated profiles', async () => {
|
||||
@@ -508,7 +510,7 @@ describe('Store', () => {
|
||||
|
||||
expect(store.getSettings().agentDefaultArgs?.opencode).toBe('--model opencode/gpt-5')
|
||||
expect(store.getSettings().agentDefaultArgs?.kilo).toBe('')
|
||||
expect(store.getSettings().agentDefaultArgs?.codex).toBe(
|
||||
expect(composeAgentLaunchProfile(store.getSettings()).agentDefaultArgs.codex).toBe(
|
||||
'--dangerously-bypass-approvals-and-sandbox'
|
||||
)
|
||||
expect((readDataFile() as PersistedState).settings.agentDefaultArgs?.opencode).toBe(
|
||||
|
||||
@@ -5,10 +5,7 @@ import { retireAtRestCommandSettings } from '../../native-chat/agent-session-wir
|
||||
import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection'
|
||||
import { normalizeNativeChatProviderChoices } from '../../../shared/native-chat-providers'
|
||||
import { resolveNestedWorkerMaxDepth } from '../../../shared/nested-worker-depth'
|
||||
import {
|
||||
normalizeTuiAgentArgsRecord,
|
||||
normalizeTuiAgentEnvRecord
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import { sanitizeAgentLaunchProfileUpdates } from '../../../shared/tui-agent-launch-defaults'
|
||||
import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands'
|
||||
import { assertTerminalQuickCommandsStorable } from '../../../shared/terminal-quick-command-prompt-limit'
|
||||
import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes'
|
||||
@@ -111,14 +108,7 @@ export function updateSettings(
|
||||
})
|
||||
}
|
||||
}
|
||||
if ('agentDefaultArgs' in updates) {
|
||||
sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs)
|
||||
sanitizedUpdates.agentYoloDefaultsMigrated = true
|
||||
}
|
||||
if ('agentDefaultEnv' in updates) {
|
||||
sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv)
|
||||
sanitizedUpdates.agentYoloDefaultsMigrated = true
|
||||
}
|
||||
sanitizeAgentLaunchProfileUpdates(sanitizedUpdates, updates)
|
||||
if ('terminalQuickCommands' in updates) {
|
||||
const terminalQuickCommands = normalizeTerminalQuickCommands(updates.terminalQuickCommands)
|
||||
// Every local and paired save lands here, so this is where an unstorable list is refused.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultPersistedState } from '../../../shared/constants'
|
||||
import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings'
|
||||
import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings'
|
||||
@@ -7,6 +7,8 @@ import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { applyAgentPermissionMode } from '../../../shared/tui-agent-permissions'
|
||||
import { resolveAgentPermission } from '../../../shared/tui-agent-permission-choice'
|
||||
import { resolveTuiAgentLaunchArgs } from '../../../shared/tui-agent-launch-defaults'
|
||||
|
||||
// Simulates a profile created before the dedicated Experimental switch was persisted.
|
||||
function normalizeLegacyProfile(overrides: Record<string, unknown>): PersistedState['settings'] {
|
||||
@@ -142,6 +144,112 @@ describe('chat appearance settings', () => {
|
||||
})
|
||||
|
||||
describe('the new-chat permission setting', () => {
|
||||
it.each<{
|
||||
name: string
|
||||
saved: Partial<GlobalSettings>
|
||||
raw: string[]
|
||||
choices: string[]
|
||||
chat: string
|
||||
terminal: string[]
|
||||
}>([
|
||||
{
|
||||
name: 'mixed terminal permissions before the Chat seed exists',
|
||||
saved: {
|
||||
agentYoloDefaultsMigrated: true,
|
||||
agentDefaultArgs: {
|
||||
claude: '--dangerously-skip-permissions --model sonnet',
|
||||
codex: '--approve-for-me --config tui.animations=false'
|
||||
}
|
||||
},
|
||||
raw: ['--model sonnet', '--config tui.animations=false'],
|
||||
choices: ['full-access', 'auto'],
|
||||
chat: 'ask',
|
||||
terminal: [
|
||||
'--dangerously-skip-permissions --model sonnet',
|
||||
'--approve-for-me --config tui.animations=false'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'explicit empty Arguments with saved Chat defaults',
|
||||
saved: {
|
||||
agentYoloDefaultsMigrated: true,
|
||||
agentDefaultArgs: { claude: '', codex: '' },
|
||||
nativeChatPermissionMode: 'auto'
|
||||
},
|
||||
raw: ['', ''],
|
||||
choices: ['own', 'own'],
|
||||
chat: 'auto',
|
||||
terminal: ['', '']
|
||||
},
|
||||
{
|
||||
name: 'Claude plan text owned by the user',
|
||||
saved: {
|
||||
agentYoloDefaultsMigrated: true,
|
||||
agentDefaultArgs: { claude: '--permission-mode plan --model sonnet', codex: '' },
|
||||
nativeChatPermissionMode: 'accept-edits'
|
||||
},
|
||||
raw: ['--permission-mode plan --model sonnet', ''],
|
||||
choices: ['own', 'own'],
|
||||
chat: 'accept-edits',
|
||||
terminal: ['--permission-mode plan --model sonnet', '']
|
||||
},
|
||||
{
|
||||
name: 'legacy yolo defaults before either migration',
|
||||
saved: { agentYoloDefaultsMigrated: false, agentDefaultArgs: {} },
|
||||
raw: ['', ''],
|
||||
choices: ['full-access', 'full-access'],
|
||||
chat: 'full-access',
|
||||
terminal: ['--dangerously-skip-permissions', '--dangerously-bypass-approvals-and-sandbox']
|
||||
}
|
||||
])(
|
||||
'loads and reloads $name through the real profile pipeline',
|
||||
({ saved, raw, choices, chat, terminal }) => {
|
||||
const defaults = getDefaultPersistedState(homedir())
|
||||
const parsed: PersistedState = {
|
||||
...defaults,
|
||||
settings: {
|
||||
...defaults.settings,
|
||||
agentPermissionDefault: undefined,
|
||||
agentPermissionOverrides: undefined,
|
||||
nativeChatPermissionMode: undefined,
|
||||
agentNativeChatPermissionModes: { claude: 'ask', codex: 'auto' },
|
||||
agentDefaultEnv: { claude: { QA_LOADER_NOTE: 'preserved' } },
|
||||
...saved
|
||||
}
|
||||
}
|
||||
const needsSave = vi.fn()
|
||||
const profile = prepareLoadedProfileSettings(parsed, defaults, needsSave)
|
||||
const loaded = normalizeLoadedGlobalSettings(
|
||||
parsed,
|
||||
prepareLoadedTerminalSettings(parsed, needsSave),
|
||||
profile
|
||||
)
|
||||
expect(needsSave).toHaveBeenCalled()
|
||||
expect(loaded.agentYoloDefaultsMigrated).toBe(true)
|
||||
expect(loaded.agentPermissionDefault).toBeDefined()
|
||||
expect(loaded.nativeChatPermissionMode).toBe(chat)
|
||||
expect(loaded.agentNativeChatPermissionModes).toEqual({ claude: 'ask', codex: 'auto' })
|
||||
expect(loaded.agentDefaultEnv?.claude).toEqual({ QA_LOADER_NOTE: 'preserved' })
|
||||
expect(
|
||||
(['claude', 'codex'] as const).map((agent) => loaded.agentDefaultArgs?.[agent])
|
||||
).toEqual(raw)
|
||||
expect(
|
||||
(['claude', 'codex'] as const).map((agent) => resolveAgentPermission(agent, loaded).choice)
|
||||
).toEqual(choices)
|
||||
expect(
|
||||
(['claude', 'codex'] as const).map((agent) => resolveTuiAgentLaunchArgs(agent, loaded))
|
||||
).toEqual(terminal)
|
||||
const reloaded: PersistedState = { ...parsed, settings: JSON.parse(JSON.stringify(loaded)) }
|
||||
expect(
|
||||
normalizeLoadedGlobalSettings(
|
||||
reloaded,
|
||||
prepareLoadedTerminalSettings(reloaded, needsSave),
|
||||
prepareLoadedProfileSettings(reloaded, defaults, needsSave)
|
||||
)
|
||||
).toEqual(loaded)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['manual', 'ask'],
|
||||
['yolo', 'full-access']
|
||||
|
||||
@@ -54,6 +54,7 @@ export function normalizeLoadedGlobalSettings(
|
||||
stampPrimarySelectionTerminalDefaults,
|
||||
migratedDisabledTuiAgents,
|
||||
migratedAgentYoloDefaults,
|
||||
migratedAgentPermission,
|
||||
migratedWindowsRuntimeDefault,
|
||||
migratedLocalAccountRuntime,
|
||||
loadedCompactWorktreeCards,
|
||||
@@ -139,7 +140,8 @@ export function normalizeLoadedGlobalSettings(
|
||||
nativeChatShellEnvironmentVariables: normalizeNativeChatShellEnvironmentVariables(
|
||||
parsed.settings?.nativeChatShellEnvironmentVariables
|
||||
),
|
||||
// Why: the next settings save stores the seed; until then a reload derives the same one.
|
||||
// Why: the next settings save stores the seed; until then a reload derives the same one. Read
|
||||
// from the Arguments as saved, before their permission flags moved into the stored choice.
|
||||
agentNativeChatPermissionModes: normalizeAgentNativeChatPermissionModes(
|
||||
parsed.settings?.agentNativeChatPermissionModes
|
||||
),
|
||||
@@ -159,6 +161,7 @@ export function normalizeLoadedGlobalSettings(
|
||||
),
|
||||
disabledTuiAgents: migratedDisabledTuiAgents,
|
||||
...migratedAgentYoloDefaults,
|
||||
...migratedAgentPermission,
|
||||
claudeAgentTeamsDefaultDisabledMigrated: true,
|
||||
sshRemoteCliControlExistingHostsMigrated: true,
|
||||
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cu
|
||||
import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings'
|
||||
import { migratePortugueseUiLanguageOnLoad } from '../../../shared/portuguese-ui-language-migration'
|
||||
import { migrateAgentYoloDefaults } from '../applying-settings/terminal-settings-migrations'
|
||||
import { hasTypedAgentPermission } from '../../../shared/tui-agent-permission-choice'
|
||||
import { migrateAgentPermissionChoice } from '../../../shared/tui-agent-permission-lift'
|
||||
import {
|
||||
normalizeLoadedOnboardingState,
|
||||
normalizeNotificationSettings,
|
||||
@@ -49,6 +51,8 @@ export type PreparedLoadedProfileSettings = {
|
||||
GlobalSettings,
|
||||
'agentDefaultArgs' | 'agentDefaultEnv' | 'agentYoloDefaultsMigrated'
|
||||
>
|
||||
/** The same launch profile with permissions moved into the stored choice; this is what's saved. */
|
||||
migratedAgentPermission: ReturnType<typeof migrateAgentPermissionChoice>
|
||||
migratedWindowsRuntimeDefault: GlobalSettings['localWindowsRuntimeDefault']
|
||||
migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime']
|
||||
loadedCompactWorktreeCards: boolean
|
||||
@@ -146,8 +150,17 @@ export function prepareLoadedProfileSettings(
|
||||
}
|
||||
const migratedDisabledTuiAgents = normalizeDisabledTuiAgents(parsed.settings?.disabledTuiAgents)
|
||||
const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings)
|
||||
const migratedAgentPermission = migrateAgentPermissionChoice(
|
||||
{
|
||||
...migratedAgentYoloDefaults,
|
||||
agentPermissionDefault: parsed.settings?.agentPermissionDefault,
|
||||
agentPermissionOverrides: parsed.settings?.agentPermissionOverrides
|
||||
},
|
||||
process.platform
|
||||
)
|
||||
if (
|
||||
parsed.settings?.agentYoloDefaultsMigrated !== true ||
|
||||
!hasTypedAgentPermission(parsed.settings) ||
|
||||
parsed.settings?.agentDefaultArgs?.devin !==
|
||||
migratedAgentYoloDefaults.agentDefaultArgs?.devin ||
|
||||
hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) ||
|
||||
@@ -257,6 +270,7 @@ export function prepareLoadedProfileSettings(
|
||||
stampPrimarySelectionTerminalDefaults,
|
||||
migratedDisabledTuiAgents,
|
||||
migratedAgentYoloDefaults,
|
||||
migratedAgentPermission,
|
||||
migratedWindowsRuntimeDefault,
|
||||
migratedLocalAccountRuntime,
|
||||
loadedCompactWorktreeCards,
|
||||
|
||||
@@ -58,7 +58,7 @@ describe.skipIf(process.platform === 'win32')('workspace-relative Codex selectio
|
||||
const sources = {
|
||||
resolveCommand: (options?: { pathEnv?: string | null; homePath?: string }) =>
|
||||
resolveStructuredAgentCommand('codex', settings, options),
|
||||
resolveAgentLaunchEnv: () => resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv)
|
||||
resolveAgentLaunchEnv: () => resolveTuiAgentLaunchEnv('codex', settings)
|
||||
}
|
||||
const environment = createStructuredAgentEnvironmentResolvers(sources)
|
||||
const record: AgentSessionRecord = {
|
||||
|
||||
@@ -35,6 +35,14 @@ export function agentLaunchSettingsRollbackUpdates(
|
||||
Object.is(current.agentDefaultEnv, applied.agentDefaultEnv)
|
||||
? { agentDefaultEnv: before.agentDefaultEnv ?? {} }
|
||||
: {}),
|
||||
...(Object.hasOwn(updates, 'agentPermissionDefault') &&
|
||||
Object.is(current.agentPermissionDefault, applied.agentPermissionDefault)
|
||||
? { agentPermissionDefault: before.agentPermissionDefault }
|
||||
: {}),
|
||||
...(Object.hasOwn(updates, 'agentPermissionOverrides') &&
|
||||
Object.is(current.agentPermissionOverrides, applied.agentPermissionOverrides)
|
||||
? { agentPermissionOverrides: before.agentPermissionOverrides ?? {} }
|
||||
: {}),
|
||||
...(Object.hasOwn(updates, 'agentNativeChatPermissionModes') &&
|
||||
Object.is(current.agentNativeChatPermissionModes, applied.agentNativeChatPermissionModes)
|
||||
? { agentNativeChatPermissionModes: before.agentNativeChatPermissionModes ?? {} }
|
||||
|
||||
@@ -72,7 +72,7 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase {
|
||||
(this.authoritativeWindowId !== null &&
|
||||
this.authoritativeWindowId !== HEADLESS_RUNTIME_WINDOW_ID &&
|
||||
this.store?.getUI?.().sidebarOpen !== false),
|
||||
hostLabelOverrides: () => getHostDisplayLabelOverrides(this.getClientSettings())
|
||||
hostLabelOverrides: () => getHostDisplayLabelOverrides(this.requireStore().getSettings())
|
||||
})
|
||||
: null
|
||||
// Switch point: the phone's icon source.
|
||||
|
||||
@@ -33,11 +33,14 @@ function prepareMethod() {
|
||||
}
|
||||
|
||||
function runtimeWithSettings(enabled = true, disabledTuiAgents: string[] = []): OrcaRuntimeService {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook handler reads only requireStore/getSettings from this runtime fixture.
|
||||
return {
|
||||
getClientSettings: vi.fn(() => ({
|
||||
agentStatusHooksEnabled: enabled,
|
||||
disabledTuiAgents
|
||||
}))
|
||||
requireStore: () => ({
|
||||
getSettings: () => ({
|
||||
agentStatusHooksEnabled: enabled,
|
||||
disabledTuiAgents
|
||||
})
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ export const AGENT_HOOK_METHODS = [
|
||||
ORCA_CODEX_HOME: params.orcaCodexHome,
|
||||
WSL_DISTRO_NAME: params.wslDistro
|
||||
},
|
||||
hooksEnabled: isAgentStatusHooksEnabledForAgent(runtime.getClientSettings(), 'codex')
|
||||
hooksEnabled: isAgentStatusHooksEnabledForAgent(
|
||||
runtime.requireStore().getSettings(),
|
||||
'codex'
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { RuntimeStore } from '../../runtime-store-contract'
|
||||
import { RuntimeRpcPairing } from '../../runtime-rpc/runtime-rpc-pairing'
|
||||
import { REPO_METHODS } from './repo'
|
||||
import { AGENT_HOOK_METHODS } from './agent-hooks'
|
||||
import { CLIENT_UI_METHODS } from './client-ui'
|
||||
import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import * as updatesModule from '../../../../shared/agent-launch-permission-updates'
|
||||
import * as launchProfile from '../../../../shared/tui-agent-launch-defaults'
|
||||
import * as shell from '../../../../shared/tui-agent-startup-shell'
|
||||
import {
|
||||
measureSettingsWork,
|
||||
expectSettingsWorkScale,
|
||||
createGlobalSettingsFixture
|
||||
} from '../../../../shared/global-settings-test-fixture'
|
||||
|
||||
vi.mock('../../../codex/managed-wsl-home-shell-preflight', () => ({
|
||||
prepareManagedWslCodexHomeBeforeShellLaunch: async ({
|
||||
hooksEnabled
|
||||
}: {
|
||||
hooksEnabled: boolean
|
||||
}) => (hooksEnabled ? { agent: 'codex', state: 'installed' } : null)
|
||||
}))
|
||||
vi.mock('../../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
it.each([20, 200])('projects %s repos without launch work', async (n) => {
|
||||
const settings = createGlobalSettingsFixture({
|
||||
hostSettingOverrides: { 'runtime:alpha': { displayLabel: ' Label ' } },
|
||||
worktreeVisibilityDefaults: { external: 'show' },
|
||||
agentPermissionDefault: 'ask',
|
||||
agentDefaultArgs: { claude: '--model opus '.repeat(n) },
|
||||
agentDefaultEnv: {
|
||||
claude: Object.fromEntries(Array.from({ length: n }, (_, i) => [`K${i}`, 'private']))
|
||||
}
|
||||
})
|
||||
const ingressReceipts: ReturnType<typeof measureSettingsWork>['work'][] = []
|
||||
const legacy = updatesModule.legacyLaunchTextUpdates
|
||||
vi.spyOn(updatesModule, 'legacyLaunchTextUpdates').mockImplementation((...args) => {
|
||||
const receipt = measureSettingsWork(() => legacy(...args))
|
||||
ingressReceipts.push(receipt.work)
|
||||
return receipt.value
|
||||
})
|
||||
const composed = vi.spyOn(launchProfile, 'composeAgentLaunchProfile')
|
||||
const tokenized = vi.spyOn(shell, 'tokenizeStartupCommand')
|
||||
const store: Pick<RuntimeStore, 'getSettings' | 'updateSettings' | 'runDurableMutation'> = {
|
||||
getSettings: () => settings,
|
||||
updateSettings: (updates) => Object.assign(settings, updates),
|
||||
runDurableMutation: async (mutate) => mutate().value
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these settings and offline-label paths use only the supplied store methods.
|
||||
const host = new OrcaRuntimeService(store as unknown as RuntimeStore)
|
||||
vi.spyOn(host, 'configureNotificationDismissalStore').mockImplementation(() => {})
|
||||
vi.spyOn(host.pairedServerRows!, 'attach').mockReturnValue(() => {})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: invoking captured label callbacks; only relay retirement subscribes, no transport is used.
|
||||
const relayHosts = { onEnvironmentRetired: () => () => {} } as unknown as NonNullable<
|
||||
Parameters<RuntimeRpcPairing['setMobileDesktopRelayHosts']>[0]
|
||||
>
|
||||
const labels = new RuntimeRpcPairing({
|
||||
runtime: host,
|
||||
userDataPath: '/unused/pr31315',
|
||||
methods: []
|
||||
})
|
||||
labels.setMobileDesktopRelayHosts(relayHosts)
|
||||
expect(host.pairedServerRows?.['options'].hostLabelOverrides().get('runtime:alpha')).toBe('Label')
|
||||
expect(
|
||||
labels['mobileRelayHostCatalog']?.['options'].hostLabelOverrides().get('runtime:alpha')
|
||||
).toBe('Label')
|
||||
labels.setMobileDesktopRelayHosts(null)
|
||||
vi.spyOn(host, 'enrichMissingRepoGitRemoteIdentities').mockResolvedValue(undefined)
|
||||
vi.spyOn(host, 'listVisibleRepos').mockReturnValue(
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: `repo-${i}`,
|
||||
path: '/repo',
|
||||
displayName: `Repo ${i}`,
|
||||
badgeColor: 'gray',
|
||||
addedAt: 0,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}))
|
||||
)
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: host,
|
||||
methods: [...REPO_METHODS, ...CLIENT_UI_METHODS, ...AGENT_HOOK_METHODS]
|
||||
})
|
||||
const legacyReplies: string[] = []
|
||||
const currentReplies: string[] = []
|
||||
|
||||
await dispatcher.dispatchStreaming(makeRequest('repo.list'), (reply) => legacyReplies.push(reply))
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('repo.list'),
|
||||
(reply) => currentReplies.push(reply),
|
||||
{ clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] }
|
||||
)
|
||||
|
||||
expect(JSON.parse(legacyReplies[0]!).result.repos).toHaveLength(n)
|
||||
expect(JSON.parse(legacyReplies[0]!).result.repos[0].externalWorktreeVisibility).toBe('show')
|
||||
delete settings.worktreeVisibilityDefaults
|
||||
legacyReplies.length = 0
|
||||
await dispatcher.dispatchStreaming(makeRequest('repo.list'), (reply) => legacyReplies.push(reply))
|
||||
expect(
|
||||
JSON.parse(legacyReplies[0]!).result.repos.map(
|
||||
(repo: { externalWorktreeVisibility: string }) => repo.externalWorktreeVisibility
|
||||
)
|
||||
).toEqual(Array(n).fill('hide'))
|
||||
settings.agentStatusHooksEnabled = true
|
||||
settings.disabledTuiAgents = []
|
||||
expect(
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('agentHooks.prepareCodexForWslPane', {
|
||||
codexHome: '/home/qa/codex',
|
||||
orcaCodexHome: '/home/qa/codex',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: true, result: { state: 'installed' } })
|
||||
expect(composed).not.toHaveBeenCalled()
|
||||
expect(tokenized).not.toHaveBeenCalled()
|
||||
const originalArgs = settings.agentDefaultArgs?.claude
|
||||
if (originalArgs === undefined) {
|
||||
throw new Error('Missing Claude Arguments fixture')
|
||||
}
|
||||
const published = launchProfile.composeAgentLaunchProfile(settings)
|
||||
for (const [row, params] of Object.entries({
|
||||
'settings.get': undefined,
|
||||
'settings.update': { machineName: 'Host' },
|
||||
'settings.updatePRBotAuthorOverride': { author: 'test', isBot: true },
|
||||
'settings.getAgentLaunch': undefined,
|
||||
'settings.mutateAgentLaunch': { type: 'permission', agent: 'claude', choice: 'ask' },
|
||||
'settings.update legacy': {
|
||||
agentDefaultArgs: {
|
||||
...published.agentDefaultArgs,
|
||||
claude: `--permission-mode auto ${originalArgs}`
|
||||
},
|
||||
agentDefaultEnv: { ...published.agentDefaultEnv, goose: { KEEP: 'new' } }
|
||||
}
|
||||
})) {
|
||||
const [method, ingress] = row.split(' ')
|
||||
composed.mockClear()
|
||||
tokenized.mockClear()
|
||||
ingressReceipts.length = 0
|
||||
const { value, work } = measureSettingsWork(() =>
|
||||
dispatcher.dispatch(makeRequest(method, params))
|
||||
)
|
||||
const response = await value
|
||||
const ingressWork = ingressReceipts.at(-1)
|
||||
const currentArgs = settings.agentDefaultArgs?.claude
|
||||
if (currentArgs === undefined) {
|
||||
throw new Error('Missing Claude Arguments after dispatch')
|
||||
}
|
||||
expect(response.ok).toBe(true)
|
||||
const text = JSON.stringify(response)
|
||||
expect(text).toContain(ingress ? '--permission-mode auto' : '--permission-mode default')
|
||||
expect(composed).toHaveBeenCalledTimes(ingress ? 2 : 1)
|
||||
expect(
|
||||
tokenized.mock.calls.reduce((bytes, [text]) => bytes + text.length, 0)
|
||||
).toBeLessThanOrEqual((ingress ? 60 : 6) * currentArgs.length + 12000)
|
||||
// Each profile gets14n; three recipes with W<=6 get2(W+1)B construction bytes each.
|
||||
const bytes =
|
||||
text.length +
|
||||
work.bytes +
|
||||
(ingressWork ? 42 * Math.max(0, ...ingressWork.inputs.map((input) => input.length)) : 0),
|
||||
visits = work.entries + work.visits + 14 * n * (ingress ? 2 : 1),
|
||||
comparisons = work.comparisons
|
||||
expect(work.entries - (ingressWork?.entries ?? 0) + 14 * n).toBeLessThanOrEqual(40 * n + 8000)
|
||||
if (ingressWork) {
|
||||
// One original profile plus the three declared Claude recipes' bounded proofs.
|
||||
const proof = 3 * 3 * (2 + 10 * (6 + 1)) * (2 * n + 6) + 3 * 4096
|
||||
expect(ingressWork.entries + ingressWork.visits + 14 * n).toBeLessThanOrEqual(
|
||||
40 * n + 8000 + proof
|
||||
)
|
||||
}
|
||||
expect(comparisons).toBeLessThanOrEqual(4 * (n + 1) * Math.ceil(Math.log2(n + 1)))
|
||||
expectSettingsWorkScale(`response-${method}-${!!ingress}`, n, [visits, bytes])
|
||||
const namesOnly = method.includes('AgentLaunch')
|
||||
const env = settings.agentDefaultEnv
|
||||
if (!env?.claude) {
|
||||
throw new Error('Missing Claude Environment after dispatch')
|
||||
}
|
||||
const result = JSON.parse(text).result.settings
|
||||
expect(result[namesOnly ? 'environmentNames' : 'agentDefaultEnv'].claude).toEqual(
|
||||
namesOnly ? Object.keys(env.claude).sort() : env.claude
|
||||
)
|
||||
expect(text.includes('private')).toBe(!namesOnly)
|
||||
if (ingress) {
|
||||
expect(settings.agentPermissionOverrides?.claude).toBe('auto')
|
||||
expect(env.goose).toEqual({ KEEP: 'new' })
|
||||
}
|
||||
}
|
||||
expect(JSON.parse(currentReplies[0]!).result.repos[0].externalWorktreeVisibility).toBeUndefined()
|
||||
})
|
||||
@@ -110,35 +110,6 @@ describe('repo RPC methods', () => {
|
||||
expect(inProcessResponse).toMatchObject({ ok: true, result })
|
||||
})
|
||||
|
||||
it('projects inherited visibility for old clients but preserves inheritance for capable clients', async () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: repo.list only calls the fixture methods defined below.
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
enrichMissingRepoGitRemoteIdentities: vi.fn(),
|
||||
listVisibleRepos: () => [
|
||||
{ id: 'repo-1', path: '/repo', externalWorktreeVisibilityLegacy: false }
|
||||
],
|
||||
getClientSettings: () => ({ worktreeVisibilityDefaults: { external: 'show' } })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
const legacyReplies: string[] = []
|
||||
const currentReplies: string[] = []
|
||||
|
||||
await dispatcher.dispatchStreaming(makeRequest('repo.list'), (reply) =>
|
||||
legacyReplies.push(reply)
|
||||
)
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('repo.list'),
|
||||
(reply) => currentReplies.push(reply),
|
||||
{ clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] }
|
||||
)
|
||||
|
||||
expect(JSON.parse(legacyReplies[0]!).result.repos[0].externalWorktreeVisibility).toBe('show')
|
||||
expect(
|
||||
JSON.parse(currentReplies[0]!).result.repos[0].externalWorktreeVisibility
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('projects inherited visibility on repo mutation responses for old clients', async () => {
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
@@ -146,10 +117,13 @@ describe('repo RPC methods', () => {
|
||||
kind: 'git' as const,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: repo.add, visibility projection and response envelope use only these supplied runtime methods, including raw settings.
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
addRepo: vi.fn().mockResolvedValue(repo),
|
||||
getClientSettings: () => ({ worktreeVisibilityDefaults: { external: 'show' } })
|
||||
requireStore: () => ({
|
||||
getSettings: () => ({ worktreeVisibilityDefaults: { external: 'show' } })
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@ export function projectRepoVisibilityForClient(repo: Repo, context: RepoProjecti
|
||||
) {
|
||||
return repo
|
||||
}
|
||||
const settings = context.runtime.requireStore?.().getSettings()
|
||||
return {
|
||||
...repo,
|
||||
externalWorktreeVisibility: effectiveExternalWorktreeVisibility(
|
||||
repo,
|
||||
isLegacyRepoForExternalWorktreeVisibility(repo),
|
||||
context.runtime.getClientSettings?.().worktreeVisibilityDefaults
|
||||
settings ? (settings.worktreeVisibilityDefaults ?? { external: 'hide' }) : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { Store } from '../persistence/loading-store/store'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { newChatPermissionModeForSettings } from '../../shared/agent-chat-permission-mode'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
deferred,
|
||||
@@ -24,6 +31,98 @@ vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({
|
||||
}))
|
||||
|
||||
describe('durable execution-host launch settings', () => {
|
||||
it('persists host carrier edits and reloads the same terminal and new-chat intent', async () => {
|
||||
const { store, readState } = await fixture()
|
||||
const controller = new RuntimeClientSettingsController(store)
|
||||
await controller.mutateAgentLaunch({ type: 'permission', choice: 'ask' })
|
||||
await controller.mutateAgentLaunch({ type: 'permission', choice: 'auto' })
|
||||
await controller.mutateAgentLaunch({
|
||||
type: 'user-arguments',
|
||||
agent: 'claude',
|
||||
value: '--append-system-prompt --dangerously-skip-permissions'
|
||||
})
|
||||
await controller.mutateAgentLaunch({
|
||||
type: 'user-arguments',
|
||||
agent: 'codex',
|
||||
value: '--config=sandbox_mode=read-only'
|
||||
})
|
||||
await controller.mutateAgentLaunch({
|
||||
type: 'permission',
|
||||
agent: 'goose',
|
||||
choice: 'full-access'
|
||||
})
|
||||
await controller.update({
|
||||
agentDefaultArgs: { ...controller.get().agentDefaultArgs, goose: '--model foo' }
|
||||
})
|
||||
await store.flushPendingOrThrowAsync()
|
||||
const saved = readState()
|
||||
const reloaded = new Store({
|
||||
serializedState: JSON.stringify(saved),
|
||||
dataFile: join(saved.settings.workspaceDir, 'permission-reload.json')
|
||||
})
|
||||
try {
|
||||
const settings = reloaded.getSettings()
|
||||
expect(resolveTuiAgentLaunchArgs('claude', settings)).toBe(
|
||||
'--permission-mode auto --append-system-prompt --dangerously-skip-permissions'
|
||||
)
|
||||
expect(resolveTuiAgentLaunchArgs('codex', settings)).toBe('--config=sandbox_mode=read-only')
|
||||
expect(resolveTuiAgentLaunchEnv('goose', settings)).toMatchObject({ GOOSE_MODE: 'auto' })
|
||||
expect(newChatPermissionModeForSettings('claude', settings)).toBe('auto')
|
||||
const loadedController = new RuntimeClientSettingsController(reloaded)
|
||||
expect(loadedController.get().agentDefaultArgs?.codex).toBe('--config=sandbox_mode=read-only')
|
||||
for (const agent of ['antigravity', 'opencode', 'opencode2', 'omp']) {
|
||||
expect(newChatPermissionModeForSettings(agent, reloaded.getSettings())).toBe('ask')
|
||||
expect(resolveTuiAgentLaunchArgs(agent, reloaded.getSettings())).toBe('')
|
||||
}
|
||||
} finally {
|
||||
await reloaded.freezeWritesAsync()
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces legacy maps, preserving absent carriers and clearing omitted user values', async () => {
|
||||
const { store, readState } = await fixture()
|
||||
store.updateSettings({
|
||||
agentPermissionDefault: 'own',
|
||||
agentPermissionOverrides: { claude: 'full-access', goose: 'full-access', opencode: 'ask' },
|
||||
agentDefaultArgs: { claude: '--model opus', codex: '--model old' },
|
||||
agentDefaultEnv: {
|
||||
claude: { ANTHROPIC_BASE_URL: 'http://old-proxy' },
|
||||
goose: { KEEP: 'custom' }
|
||||
}
|
||||
})
|
||||
const controller = new RuntimeClientSettingsController(store)
|
||||
await controller.update({ agentDefaultArgs: { codex: '--model new' } })
|
||||
expect(store.getSettings()).toMatchObject({
|
||||
agentDefaultArgs: { codex: '--model new' },
|
||||
agentDefaultEnv: {
|
||||
claude: { ANTHROPIC_BASE_URL: 'http://old-proxy' },
|
||||
goose: { KEEP: 'custom' }
|
||||
},
|
||||
agentPermissionOverrides: { claude: 'own', goose: 'full-access', opencode: 'ask' }
|
||||
})
|
||||
expect(store.getSettings().agentDefaultArgs).not.toHaveProperty('claude')
|
||||
await controller.update({ agentDefaultEnv: { goose: { GOOSE_MODE: 'auto', KEEP: 'new' } } })
|
||||
expect(store.getSettings().agentDefaultEnv).toEqual({ goose: { KEEP: 'new' } })
|
||||
expect(store.getSettings().agentDefaultArgs).toEqual({ codex: '--model new' })
|
||||
expect(resolveTuiAgentLaunchEnv('goose', store.getSettings())).toEqual({
|
||||
GOOSE_MODE: 'auto',
|
||||
KEEP: 'new'
|
||||
})
|
||||
await controller.update({ agentDefaultArgs: {}, agentDefaultEnv: {} })
|
||||
await store.flushPendingOrThrowAsync()
|
||||
const saved = readState().settings
|
||||
expect(saved.agentDefaultArgs).toEqual({})
|
||||
expect(saved.agentDefaultEnv).toEqual({})
|
||||
expect(saved.agentPermissionOverrides).toMatchObject({
|
||||
claude: 'own',
|
||||
goose: 'own',
|
||||
opencode: 'ask'
|
||||
})
|
||||
expect(controller.get().agentDefaultArgs?.claude).toBe('')
|
||||
expect(controller.get().agentDefaultEnv?.goose).toEqual({})
|
||||
expect(newChatPermissionModeForSettings('opencode', store.getSettings())).toBe('ask')
|
||||
})
|
||||
|
||||
it('withdraws cached plugin skills when saved launch arguments disable plugins', async () => {
|
||||
const { store } = await fixture()
|
||||
const controller = new RuntimeClientSettingsController(store)
|
||||
@@ -280,7 +379,7 @@ describe('durable execution-host launch settings', () => {
|
||||
})
|
||||
expect(readState().settings.agentNativeChatPermissionModes).toEqual({ codex: 'auto' })
|
||||
expect(readState().settings.nativeChatPermissionMode).toBe(beforeNative)
|
||||
expect(readState().settings.agentDefaultArgs.codex).toBe('--approve-for-me')
|
||||
expect(readState().settings.agentPermissionOverrides).toMatchObject({ codex: 'auto' })
|
||||
})
|
||||
|
||||
it('recomputes queued intents after a failed write instead of persisting its stale map', async () => {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { RuntimeClientSettingsController } from './runtime-client-settings'
|
||||
import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import { structuredAgentConfiguredArgs } from '../native-chat/structured-agent-configured-args'
|
||||
import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { agentLaunchSettingsMutationUpdates } from '../../shared/agent-launch-settings'
|
||||
import { AgentLaunchSettingsMutation } from '../../shared/rpc-contract/agent-launch-settings-params'
|
||||
import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc/runtime-rpc-mobile-method-allowlist'
|
||||
@@ -71,7 +74,7 @@ describe('execution-host agent launch settings', () => {
|
||||
|
||||
expect(host.agentCmdOverrides?.claude).toBe('/host/claude-wrapper')
|
||||
expect(structuredAgentConfiguredArgs('claude', host)).toEqual(['--host-marker'])
|
||||
expect(resolveTuiAgentLaunchEnv('claude', host.agentDefaultEnv)).toEqual({
|
||||
expect(resolveTuiAgentLaunchEnv('claude', host)).toEqual({
|
||||
API_KEY: 'host-secret',
|
||||
KEEP: 'host-only',
|
||||
MARKER: 'host-marker'
|
||||
@@ -112,13 +115,13 @@ describe('execution-host agent launch settings', () => {
|
||||
|
||||
it('changes terminal permissions on the host without roundtripping unrelated secrets', async () => {
|
||||
const { controller, host } = fixture()
|
||||
host.agentDefaultArgs = { claude: '--dangerously-skip-permissions' }
|
||||
host.agentDefaultArgs = { claude: '' }
|
||||
await controller.mutateAgentLaunch({
|
||||
type: 'permissions',
|
||||
syncNativeChat: true,
|
||||
mode: 'manual'
|
||||
})
|
||||
expect(host.agentDefaultArgs?.claude).toBe('')
|
||||
expect(resolveTuiAgentLaunchArgs('claude', host)).toBe('')
|
||||
expect(host.nativeChatPermissionMode).toBe('ask')
|
||||
expect(host.agentDefaultEnv?.claude).toEqual({ API_KEY: 'host-secret', KEEP: 'host-only' })
|
||||
expect(JSON.stringify(controller.getAgentLaunch())).not.toContain('host-only')
|
||||
@@ -129,7 +132,7 @@ describe('execution-host agent launch settings', () => {
|
||||
const beforeNative = host.nativeChatPermissionMode
|
||||
host.agentDefaultArgs = { claude: '', codex: '' }
|
||||
await controller.mutateAgentLaunch({ type: 'permissions', syncNativeChat: true, mode: 'auto' })
|
||||
expect(host.agentDefaultArgs).toMatchObject({
|
||||
expect(controller.get().agentDefaultArgs).toMatchObject({
|
||||
claude: '--permission-mode auto',
|
||||
codex: '--approve-for-me'
|
||||
})
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
type AgentLaunchSettings,
|
||||
type AgentLaunchSettingsMutation
|
||||
} from '../../shared/agent-launch-settings'
|
||||
import { legacyLaunchTextUpdates } from '../../shared/agent-launch-permission-updates'
|
||||
import { composeAgentLaunchProfile } from '../../shared/tui-agent-launch-defaults'
|
||||
import { agentLaunchSettingsRollbackUpdates } from './agent-launch-settings-rollback'
|
||||
import type {
|
||||
RuntimeClientSettings,
|
||||
@@ -51,13 +53,16 @@ export class RuntimeClientSettingsController {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const settings = this.store.getSettings()
|
||||
// Why composed: paired clients (the phone's resume, legacy-host chat checks) read the flag in
|
||||
// this text as the permission, as every client before the stored choice did.
|
||||
const launchProfile = composeAgentLaunchProfile(settings, process.platform)
|
||||
return {
|
||||
defaultTuiAgent: settings.defaultTuiAgent ?? null,
|
||||
newWorkspaceSetupOnly: false,
|
||||
disabledTuiAgents: settings.disabledTuiAgents ?? [],
|
||||
agentCmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentDefaultArgs: settings.agentDefaultArgs ?? {},
|
||||
agentDefaultEnv: settings.agentDefaultEnv ?? {},
|
||||
agentDefaultArgs: launchProfile.agentDefaultArgs,
|
||||
agentDefaultEnv: launchProfile.agentDefaultEnv,
|
||||
agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false,
|
||||
// Why projected: mobile's terminal Copy honours this, and a host predating
|
||||
// the setting sends no key, which the client reads as on (#19770).
|
||||
@@ -114,6 +119,17 @@ export class RuntimeClientSettingsController {
|
||||
this.store.updateSettings(
|
||||
{
|
||||
...updates,
|
||||
// An older client writes Arguments as it reads them, flag included.
|
||||
...(updates.agentDefaultArgs !== undefined || updates.agentDefaultEnv !== undefined
|
||||
? legacyLaunchTextUpdates(
|
||||
beforeSettings,
|
||||
{
|
||||
agentDefaultArgs: updates.agentDefaultArgs,
|
||||
agentDefaultEnv: updates.agentDefaultEnv
|
||||
},
|
||||
process.platform
|
||||
)
|
||||
: {}),
|
||||
...(updates.nativeChatPermissionMode !== undefined
|
||||
? { agentNativeChatPermissionModes: {} }
|
||||
: {}),
|
||||
@@ -147,7 +163,7 @@ export class RuntimeClientSettingsController {
|
||||
if (!this.store?.getSettings) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return projectAgentLaunchSettings(this.store.getSettings())
|
||||
return projectAgentLaunchSettings(this.store.getSettings(), process.platform)
|
||||
}
|
||||
|
||||
async mutateAgentLaunch(mutation: AgentLaunchSettingsMutation): Promise<AgentLaunchSettings> {
|
||||
@@ -163,7 +179,7 @@ export class RuntimeClientSettingsController {
|
||||
const applied = store.getSettings()
|
||||
return {
|
||||
value: {
|
||||
settings: projectAgentLaunchSettings(applied),
|
||||
settings: projectAgentLaunchSettings(applied, process.platform),
|
||||
reconcileHooks:
|
||||
updates.disabledTuiAgents !== undefined &&
|
||||
!haveSameDisabledTuiAgents(before.disabledTuiAgents, applied.disabledTuiAgents)
|
||||
|
||||
@@ -109,7 +109,8 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure {
|
||||
? new MobileRelayHostCatalog({
|
||||
hosts,
|
||||
serverRows: pairedServerRows ?? new OnDemandServerWorktreeRows(hosts),
|
||||
hostLabelOverrides: () => getHostDisplayLabelOverrides(this.runtime.getClientSettings()),
|
||||
hostLabelOverrides: () =>
|
||||
getHostDisplayLabelOverrides(this.runtime.requireStore().getSettings()),
|
||||
getWorkspaceSession: (hostId) => this.runtime.getWorkspaceSessionForHost(hostId)
|
||||
})
|
||||
: null
|
||||
|
||||
@@ -127,6 +127,8 @@ export type RuntimeStore = {
|
||||
agentCmdOverrides?: GlobalSettings['agentCmdOverrides']
|
||||
agentDefaultArgs?: GlobalSettings['agentDefaultArgs']
|
||||
agentDefaultEnv?: GlobalSettings['agentDefaultEnv']
|
||||
agentPermissionDefault?: GlobalSettings['agentPermissionDefault']
|
||||
agentPermissionOverrides?: GlobalSettings['agentPermissionOverrides']
|
||||
terminalWindowsShell?: GlobalSettings['terminalWindowsShell']
|
||||
// Read by the launch-line carry rule to name the shell a local line is typed into.
|
||||
terminalDefaultShell?: GlobalSettings['terminalDefaultShell']
|
||||
|
||||
+2
-2
@@ -141,8 +141,8 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O
|
||||
agentArgs:
|
||||
request.agentArgs !== undefined
|
||||
? request.agentArgs
|
||||
: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv),
|
||||
: resolveTuiAgentLaunchArgs(request.agent, settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings),
|
||||
ompResumeFilePath: request.ompResumeFilePath,
|
||||
sessionOptions: this.toAgentSessionOptions(request.launchPreferences),
|
||||
sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences),
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function resolveHostStructuredAgentLaunchEnvironment(
|
||||
): Promise<Record<string, string>> {
|
||||
return {
|
||||
...(await resolveHostAgentBaseEnvironment(settings)),
|
||||
...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {})
|
||||
...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings) : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr
|
||||
settings: Pick<GlobalSettings, 'agentDefaultEnv'>
|
||||
) {
|
||||
return {
|
||||
...resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
...resolveTuiAgentLaunchEnv(agent, settings),
|
||||
...this.accounts.hostLaunchEnvFor(agent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
|
||||
prepareCodexCatalogProbeHome: (homePath) =>
|
||||
state.codexRuntimeHome?.prepareHostCodexHomeForReadOnlyAppServer(
|
||||
homePath,
|
||||
resolveTuiAgentLaunchEnv('codex', store.getSettings().agentDefaultEnv)
|
||||
resolveTuiAgentLaunchEnv('codex', store.getSettings())
|
||||
),
|
||||
orchestrationEnvironmentTransport,
|
||||
workspaceSleepPlatform: {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { EventProps } from '../../../../shared/telemetry-events'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { OnboardingState } from '../../../../shared/onboarding-state-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import { applyAgentPermissionMode } from '../../../../shared/tui-agent-permissions'
|
||||
import { applyAllAgentsPermission } from '../../../../shared/tui-agent-permission-choice'
|
||||
import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode'
|
||||
import type { StepId, StepNumber } from './use-onboarding-flow-types'
|
||||
|
||||
@@ -184,11 +184,10 @@ export function usePersistCurrentStep({
|
||||
const defaultTuiAgent = selectedAgentOrBlank(selectedAgent)
|
||||
await updateSettings({
|
||||
defaultTuiAgent,
|
||||
...applyAgentPermissionMode({
|
||||
mode: yoloPermissions ? 'yolo' : 'manual',
|
||||
agentDefaultArgs: settings.agentDefaultArgs,
|
||||
agentDefaultEnv: settings.agentDefaultEnv
|
||||
}),
|
||||
// Off adds no flag, as it always has: each agent's own settings apply.
|
||||
...(yoloPermissionsChosen.current
|
||||
? applyAllAgentsPermission(settings, yoloPermissions ? 'full-access' : 'own')
|
||||
: {}),
|
||||
...onboardingChatPermissionSetting(
|
||||
settings.nativeChatPermissionMode,
|
||||
yoloPermissions,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { resolveAgentPermissionModeSummary } from '../../../../shared/tui-agent-permissions'
|
||||
|
||||
function savedYoloPermissions(settings: GlobalSettings | null | undefined): boolean {
|
||||
return (
|
||||
resolveAgentPermissionModeSummary({
|
||||
agentDefaultArgs: settings?.agentDefaultArgs,
|
||||
agentDefaultEnv: settings?.agentDefaultEnv
|
||||
}) !== 'manual'
|
||||
)
|
||||
const choice = settings?.agentPermissionDefault ?? 'full-access'
|
||||
return choice === 'full-access' || choice === 'auto'
|
||||
}
|
||||
|
||||
/** Onboarding's Yolo switch: follows saved settings as they hydrate, until the person flips it. */
|
||||
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
AgentDefaultArgsInput,
|
||||
AgentDefaultEnvInput
|
||||
} from './AgentLaunchDefaultsEditor'
|
||||
import {
|
||||
AgentPermissionArgsChip,
|
||||
agentPermissionChoiceLabel,
|
||||
AgentPermissionMenu,
|
||||
type AgentPermissionChoiceRow
|
||||
} from './AgentPermissionChoiceControls'
|
||||
|
||||
type AgentAvailability = 'enabled' | 'disabled'
|
||||
|
||||
@@ -68,6 +74,7 @@ export type AgentCatalogRowProps = {
|
||||
defaultCmd: string
|
||||
defaultArgs: string
|
||||
defaultEnv: Record<string, string>
|
||||
hasDefaultEnv?: boolean
|
||||
isDetected: boolean
|
||||
isEnabled: boolean
|
||||
isDefault: boolean
|
||||
@@ -78,11 +85,15 @@ export type AgentCatalogRowProps = {
|
||||
onSetEnabled: (enabled: boolean) => void
|
||||
onSaveOverride: (value: string) => void | Promise<void>
|
||||
onSaveArgs: (value: string) => void | Promise<void>
|
||||
onSaveEnv: (value: Record<string, string>) => void
|
||||
onSaveEnv: (value: Record<string, string>) => void | Promise<void>
|
||||
sessionSourceHome?: AgentSessionSourceHomeControl
|
||||
environmentNames?: readonly string[]
|
||||
/** Absent for agents without a configurable permission mode on this host. */
|
||||
permission?: AgentRowPermission
|
||||
/** On hosts that store the permission apart from Arguments; replaces `permission`. */
|
||||
permissionChoice?: AgentPermissionChoiceRow
|
||||
/** The text a terminal launch gets, when it differs from the editable Arguments. */
|
||||
launchArgs?: string
|
||||
environmentEditor?: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -93,6 +104,7 @@ export function AgentCatalogRow({
|
||||
defaultCmd,
|
||||
defaultArgs,
|
||||
defaultEnv,
|
||||
hasDefaultEnv,
|
||||
isDetected,
|
||||
isEnabled,
|
||||
isDefault,
|
||||
@@ -107,7 +119,9 @@ export function AgentCatalogRow({
|
||||
sessionSourceHome,
|
||||
environmentNames,
|
||||
environmentEditor,
|
||||
permission
|
||||
permission,
|
||||
permissionChoice,
|
||||
launchArgs = argsOverride
|
||||
}: AgentCatalogRowProps): React.JSX.Element {
|
||||
const envSummary = environmentNames?.join(' ') ?? stringifyAgentDefaultEnvDraft(envOverride)
|
||||
const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv)
|
||||
@@ -132,7 +146,7 @@ export function AgentCatalogRow({
|
||||
)?.focus()
|
||||
})
|
||||
}
|
||||
const expandable = isDetected || permission !== undefined
|
||||
const expandable = isDetected || permission !== undefined || permissionChoice !== undefined
|
||||
|
||||
return (
|
||||
<div className={cn('py-3', !isDetected && 'opacity-70')}>
|
||||
@@ -158,12 +172,25 @@ export function AgentCatalogRow({
|
||||
) : (
|
||||
defaultCmd
|
||||
)}
|
||||
{argsOverride && <span className="ml-1.5 text-foreground/70">{argsOverride}</span>}
|
||||
{launchArgs && <span className="ml-1.5 text-foreground/70">{launchArgs}</span>}
|
||||
{envSummary && <span className="ml-1.5 text-foreground/60">{envSummary}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{permissionChoice?.source === 'agent' && (
|
||||
<div className="mr-3 flex h-7 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPermission}
|
||||
className="rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<SettingsBadge tone="accent">
|
||||
{agentPermissionChoiceLabel(permissionChoice.choice, label)}
|
||||
</SettingsBadge>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{permission && permission.mode !== permission.expectedMode && (
|
||||
// Right-aligned against the controls so badges line up without reserving width on every row.
|
||||
<div className="mr-3 flex h-7 items-center">
|
||||
@@ -246,6 +273,11 @@ export function AgentCatalogRow({
|
||||
|
||||
{expandable && cmdOpen && (
|
||||
<div className="mt-3 space-y-3 pl-10">
|
||||
{permissionChoice && (
|
||||
<div ref={permissionRef}>
|
||||
<AgentPermissionMenu agentId={agentId} label={label} permission={permissionChoice} />
|
||||
</div>
|
||||
)}
|
||||
{permission && (
|
||||
<AgentRowPermissionControl
|
||||
label={label}
|
||||
@@ -263,23 +295,41 @@ export function AgentCatalogRow({
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<AgentDefaultArgsInput
|
||||
key={`${agentId}:${argsOverride}`}
|
||||
key={agentId}
|
||||
defaultArgs={defaultArgs}
|
||||
argsOverride={argsOverride}
|
||||
onSaveArgs={onSaveArgs}
|
||||
permissionChip={
|
||||
permissionChoice?.flag && !permissionChoice.argsSetPermission ? (
|
||||
<AgentPermissionArgsChip
|
||||
label={label}
|
||||
flag={permissionChoice.flag}
|
||||
onRemove={() => permissionChoice.onSelect('own')}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
note={
|
||||
permissionChoice?.argsSetPermission
|
||||
? translate(
|
||||
'settings.agents.permissionArgsTerminalOnly',
|
||||
'These arguments may control {{agent}}’s terminal permissions, so Orca adds no flag. Chats use the Permissions choice above.',
|
||||
{ agent: label }
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{environmentEditor ??
|
||||
((defaultEnvSummary || envSummary) && (
|
||||
<div className="mt-2">
|
||||
<AgentDefaultEnvInput
|
||||
key={`${agentId}:${envSummary}`}
|
||||
defaultEnv={defaultEnv}
|
||||
envOverride={envOverride}
|
||||
onSaveEnv={onSaveEnv}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{environmentEditor ?? (
|
||||
<div className="mt-2">
|
||||
<AgentDefaultEnvInput
|
||||
key={agentId}
|
||||
defaultEnv={defaultEnv}
|
||||
showEmpty={hasDefaultEnv}
|
||||
envOverride={envOverride}
|
||||
onSaveEnv={onSaveEnv}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{sessionSourceHome && (
|
||||
<div className="mt-2">
|
||||
<AgentSessionSourceHomeInput
|
||||
|
||||
@@ -16,13 +16,10 @@ describe('launch field reset ordering', () => {
|
||||
})
|
||||
const save = vi.fn().mockResolvedValue(undefined).mockReturnValueOnce(blurSave)
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
const defaults = { defaultArgs: '--default', argsOverride: '--default', onSaveArgs: save }
|
||||
const view = render(
|
||||
field === 'arguments' ? (
|
||||
<AgentDefaultArgsInput
|
||||
defaultArgs="--default"
|
||||
argsOverride="--custom"
|
||||
onSaveArgs={save}
|
||||
/>
|
||||
<AgentDefaultArgsInput {...defaults} argsOverride="--custom" />
|
||||
) : (
|
||||
<AgentCommandOverrideInput
|
||||
defaultCmd="claude"
|
||||
@@ -33,12 +30,19 @@ describe('launch field reset ordering', () => {
|
||||
)
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.click(input)
|
||||
await user.type(input, ' dirty')
|
||||
await user.click(screen.getByRole('button', { name: 'Reset' }))
|
||||
expect(save).toHaveBeenNthCalledWith(1, field === 'arguments' ? '--custom' : '/host/custom')
|
||||
expect(save).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
field === 'arguments' ? '--custom dirty' : '/host/custom dirty'
|
||||
)
|
||||
finishBlur()
|
||||
await waitFor(() =>
|
||||
expect(save).toHaveBeenLastCalledWith(field === 'arguments' ? '--default' : '')
|
||||
)
|
||||
if (field === 'arguments') {
|
||||
view.rerender(<AgentDefaultArgsInput {...defaults} />)
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(input).toHaveProperty('value', field === 'arguments' ? '--default' : 'claude')
|
||||
)
|
||||
@@ -51,22 +55,26 @@ describe('launch field reset ordering', () => {
|
||||
async (result) => {
|
||||
let finishBlur!: () => void
|
||||
let rejectBlur!: (error: Error) => void
|
||||
let rejectReset!: (error: Error) => void
|
||||
let finishReset!: () => void
|
||||
const blurSave = new Promise<void>((resolve, reject) => {
|
||||
finishBlur = resolve
|
||||
rejectBlur = reject
|
||||
})
|
||||
const resetSave = new Promise<void>((resolve) => {
|
||||
const resetSave = new Promise<void>((resolve, reject) => {
|
||||
rejectReset = reject
|
||||
finishReset = resolve
|
||||
})
|
||||
const save = vi.fn().mockReturnValueOnce(blurSave).mockReturnValueOnce(resetSave)
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<AgentDefaultArgsInput defaultArgs="--default" argsOverride="--custom" onSaveArgs={save} />
|
||||
const defaults = { defaultArgs: '--default', onSaveArgs: save }
|
||||
const editor = (argsOverride: string) => (
|
||||
<AgentDefaultArgsInput {...defaults} argsOverride={argsOverride} />
|
||||
)
|
||||
const view = render(editor('--custom'))
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.click(input)
|
||||
await user.keyboard('{Enter}')
|
||||
await user.type(input, ' dirty')
|
||||
await user.click(screen.getByRole('button', { name: 'Reset' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Reset' }))
|
||||
expect(save).toHaveBeenCalledTimes(1)
|
||||
@@ -77,10 +85,22 @@ describe('launch field reset ordering', () => {
|
||||
}
|
||||
await waitFor(() => expect(save).toHaveBeenNthCalledWith(2, '--default'))
|
||||
expect(input).toHaveProperty('readOnly', true)
|
||||
finishReset()
|
||||
view.rerender(editor('--custom dirty'))
|
||||
await user.click(input)
|
||||
await user.tab()
|
||||
view.rerender(editor('--default'))
|
||||
expect(input).toHaveProperty('value', '--custom dirty')
|
||||
if (result === 'rejected') {
|
||||
rejectReset(new Error('unknown acknowledgement'))
|
||||
await waitFor(() => expect(input).toHaveProperty('readOnly', false))
|
||||
expect(input).toHaveProperty('value', '--custom dirty')
|
||||
await user.click(screen.getByRole('button', { name: 'Reset' }))
|
||||
} else {
|
||||
finishReset()
|
||||
}
|
||||
await waitFor(() => expect(input).toHaveProperty('readOnly', false))
|
||||
expect(input).toHaveProperty('value', '--default')
|
||||
expect(save).toHaveBeenCalledTimes(2)
|
||||
expect(save).toHaveBeenCalledTimes(result === 'rejected' ? 3 : 2)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -84,17 +84,34 @@ export function AgentCommandOverrideInput({
|
||||
export function AgentDefaultArgsInput({
|
||||
defaultArgs,
|
||||
argsOverride,
|
||||
onSaveArgs
|
||||
onSaveArgs,
|
||||
permissionChip,
|
||||
note
|
||||
}: {
|
||||
defaultArgs: string
|
||||
argsOverride: string
|
||||
onSaveArgs: (value: string) => void | Promise<void>
|
||||
/** The permission flag terminals add ahead of the text, drawn just before the box. */
|
||||
permissionChip?: React.ReactNode
|
||||
note?: string
|
||||
}): React.JSX.Element {
|
||||
const [argsDraft, setArgsDraft] = useState(argsOverride)
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const argsDraft = draft ?? argsOverride
|
||||
const { pending, write } = useAgentLaunchFieldWrite()
|
||||
const commitArgs = (): void => {
|
||||
const value = argsDraft.trim()
|
||||
write(value, () => onSaveArgs(value))
|
||||
if (pending || draft === null) {
|
||||
return
|
||||
}
|
||||
const value = draft.trim()
|
||||
if (value === argsOverride) {
|
||||
setDraft(null)
|
||||
return
|
||||
}
|
||||
write(
|
||||
value,
|
||||
() => onSaveArgs(value),
|
||||
() => setDraft((current) => (current === draft ? null : current))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -103,10 +120,11 @@ export function AgentDefaultArgsInput({
|
||||
{translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissionChip}
|
||||
<Input
|
||||
value={argsDraft}
|
||||
readOnly={pending}
|
||||
onChange={(event) => setArgsDraft(event.target.value)}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={commitArgs}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
@@ -114,8 +132,7 @@ export function AgentDefaultArgsInput({
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setArgsDraft(argsOverride)
|
||||
event.currentTarget.blur()
|
||||
setDraft(null)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
@@ -125,16 +142,17 @@ export function AgentDefaultArgsInput({
|
||||
spellCheck={false}
|
||||
className="h-7 flex-1 font-mono text-xs"
|
||||
/>
|
||||
{argsOverride !== defaultArgs && (
|
||||
{(argsOverride !== defaultArgs || argsDraft !== defaultArgs) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setDraft(argsDraft)
|
||||
write(
|
||||
defaultArgs,
|
||||
() => onSaveArgs(defaultArgs),
|
||||
() => setArgsDraft(defaultArgs)
|
||||
() => setDraft((current) => (current === argsDraft ? null : current))
|
||||
)
|
||||
}}
|
||||
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
|
||||
@@ -143,35 +161,52 @@ export function AgentDefaultArgsInput({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{note ? <p className="text-[11px] text-muted-foreground">{note}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentDefaultEnvInput({
|
||||
showEmpty = false,
|
||||
defaultEnv,
|
||||
envOverride,
|
||||
onSaveEnv
|
||||
}: {
|
||||
showEmpty?: boolean
|
||||
defaultEnv: Record<string, string>
|
||||
envOverride: Record<string, string>
|
||||
onSaveEnv: (value: Record<string, string>) => void
|
||||
}): React.JSX.Element {
|
||||
onSaveEnv: (value: Record<string, string>) => void | Promise<void>
|
||||
}): React.JSX.Element | null {
|
||||
const defaultEnvText = stringifyAgentDefaultEnvDraft(defaultEnv)
|
||||
const draftSeed = stringifyAgentDefaultEnvDraft(envOverride)
|
||||
const [envDraft, setEnvDraft] = useState(draftSeed)
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const envDraft = draft ?? draftSeed
|
||||
const { pending, write } = useAgentLaunchFieldWrite()
|
||||
const [envDraftError, setEnvDraftError] = useState<AgentDefaultEnvDraftInvalidReason | null>(null)
|
||||
const envDraftErrorId = useId()
|
||||
const commitEnv = (): void => {
|
||||
if (pending || draft === null) {
|
||||
return
|
||||
}
|
||||
if (envDraft === draftSeed) {
|
||||
setDraft(null)
|
||||
return
|
||||
}
|
||||
const parsedDraft = parseAgentDefaultEnvDraft(envDraft)
|
||||
setEnvDraftError(parsedDraft.invalidReason)
|
||||
if (!parsedDraft.invalidReason) {
|
||||
onSaveEnv(parsedDraft.env)
|
||||
setDraft(envDraft)
|
||||
write(
|
||||
stringifyAgentDefaultEnvDraft(parsedDraft.env),
|
||||
() => onSaveEnv(parsedDraft.env),
|
||||
() => setDraft((current) => (current === envDraft ? null : current))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!showEmpty && !defaultEnvText && !draftSeed && draft === null && !pending) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -180,8 +215,9 @@ export function AgentDefaultEnvInput({
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={envDraft}
|
||||
readOnly={pending}
|
||||
onChange={(event) => {
|
||||
setEnvDraft(event.target.value)
|
||||
setDraft(event.target.value)
|
||||
if (envDraftError) {
|
||||
setEnvDraftError(null)
|
||||
}
|
||||
@@ -193,9 +229,8 @@ export function AgentDefaultEnvInput({
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setEnvDraft(draftSeed)
|
||||
setDraft(null)
|
||||
setEnvDraftError(null)
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
@@ -207,15 +242,21 @@ export function AgentDefaultEnvInput({
|
||||
aria-describedby={envDraftError ? envDraftErrorId : undefined}
|
||||
className="h-7 flex-1 font-mono text-xs"
|
||||
/>
|
||||
{draftSeed !== defaultEnvText && (
|
||||
{(draftSeed !== defaultEnvText || envDraft !== defaultEnvText) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onSaveEnv(defaultEnv)
|
||||
setEnvDraft(defaultEnvText)
|
||||
setEnvDraftError(null)
|
||||
setDraft(envDraft)
|
||||
write(
|
||||
defaultEnvText,
|
||||
() => onSaveEnv(defaultEnv),
|
||||
() => {
|
||||
setDraft((current) => (current === envDraft ? null : current))
|
||||
setEnvDraftError(null)
|
||||
}
|
||||
)
|
||||
}}
|
||||
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import {
|
||||
agentChatPermissionModes,
|
||||
savedChatPermissionMode
|
||||
} from '../../../../shared/agent-chat-permission-mode'
|
||||
import { useChatProgramFacts } from '@/lib/host-chat-program-acquisition'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type {
|
||||
AgentLaunchSettings,
|
||||
AgentLaunchSettingsMutation
|
||||
} from '../../../../shared/agent-launch-settings'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { AUTO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import {
|
||||
agentPermissionArgs,
|
||||
agentPermissionEnv,
|
||||
agentPermissionChoices,
|
||||
PERMISSION_CHOICE_AGENTS,
|
||||
resolveAgentPermission,
|
||||
type AgentPermissionChoice,
|
||||
type AgentPermissionSettings
|
||||
} from '../../../../shared/tui-agent-permission-choice'
|
||||
import {
|
||||
agentArgsSetPermission,
|
||||
liftAgentPermissionArgs
|
||||
} from '../../../../shared/tui-agent-permission-lift'
|
||||
import {
|
||||
agentPermissionChoiceLabel,
|
||||
AllAgentsPermissionSetting,
|
||||
type AgentPermissionChoiceRow
|
||||
} from './AgentPermissionChoiceControls'
|
||||
import type { AgentPermissionsSettingProps } from './AgentPermissionsSetting'
|
||||
import { buildAgentRowPermissions } from './agent-row-permission-model'
|
||||
import {
|
||||
@@ -52,6 +76,7 @@ export function AgentLaunchSettingsCatalog({
|
||||
nativeChatPermissionsSupported?: boolean
|
||||
local?: {
|
||||
settings: GlobalSettings
|
||||
saveEnvironment: (agent: TuiAgent, value: Record<string, string>) => Promise<void>
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
|
||||
}
|
||||
}): React.JSX.Element {
|
||||
@@ -89,6 +114,58 @@ export function AgentLaunchSettingsCatalog({
|
||||
: mutation
|
||||
void mutate(scopedMutation).catch(() => {})
|
||||
}
|
||||
const typed = settings.agentPermission
|
||||
const permissionSettings: AgentPermissionSettings | null = typed
|
||||
? { agentPermissionDefault: typed.default, agentPermissionOverrides: typed.overrides }
|
||||
: null
|
||||
const choiceRow = (agent: TuiAgent): AgentPermissionChoiceRow | undefined => {
|
||||
const choices = agentPermissionChoices(agent)
|
||||
if (!permissionSettings || choices.length < 2) {
|
||||
return undefined
|
||||
}
|
||||
const resolved = resolveAgentPermission(agent, permissionSettings)
|
||||
const chatSupport = agentChatPermissionModes(agent)
|
||||
return {
|
||||
choices,
|
||||
...resolved,
|
||||
terminalChoice:
|
||||
agentPermissionArgs(agent, resolved.choice) ||
|
||||
Object.keys(agentPermissionEnv(agent, resolved.choice)).length
|
||||
? resolved.choice
|
||||
: 'own',
|
||||
chatChoice:
|
||||
chatSupport && resolved.choice !== 'own'
|
||||
? savedChatPermissionMode(chatSupport, resolved.choice)
|
||||
: null,
|
||||
defaultChoice: resolveAgentPermission(agent, {
|
||||
agentPermissionDefault: permissionSettings.agentPermissionDefault
|
||||
}).choice,
|
||||
onSelect: (choice) => save({ type: 'permission', agent, choice }),
|
||||
flag: agentPermissionArgs(agent, resolved.choice),
|
||||
argsSetPermission: agentArgsSetPermission(agent, typed?.userArgs[agent])
|
||||
}
|
||||
}
|
||||
const saveUserArgs = async (agent: TuiAgent, label: string, value: string): Promise<void> => {
|
||||
const lifted = liftAgentPermissionArgs(agent, value)
|
||||
await mutate({ type: 'user-arguments', agent, value })
|
||||
if (lifted.choice) {
|
||||
const chatSupported = nativeChatPermissionsSupported && agentChatPermissionModes(agent)
|
||||
const values = { agent: label, mode: agentPermissionChoiceLabel(lifted.choice, label) }
|
||||
toast.success(
|
||||
chatSupported
|
||||
? translate(
|
||||
'settings.agents.permissionFromArgs',
|
||||
'{{agent}} set to {{mode}} in terminals and new chats.',
|
||||
values
|
||||
)
|
||||
: translate(
|
||||
'settings.agents.permissionFromArgsTerminals',
|
||||
'{{agent}} set to {{mode}} in terminals.',
|
||||
values
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
const permissions = buildAgentRowPermissions({
|
||||
agents: catalog.map((agent) => agent.id),
|
||||
settings,
|
||||
@@ -104,15 +181,24 @@ export function AgentLaunchSettingsCatalog({
|
||||
label: agent.label,
|
||||
homepageUrl: agent.homepageUrl,
|
||||
defaultCmd: agent.cmd,
|
||||
defaultArgs: getTuiAgentDefaultArgs(agent.id),
|
||||
defaultEnv: getTuiAgentDefaultEnv(agent.id),
|
||||
defaultArgs: typed ? '' : getTuiAgentDefaultArgs(agent.id),
|
||||
defaultEnv: typed ? {} : getTuiAgentDefaultEnv(agent.id),
|
||||
hasDefaultEnv: Object.keys(getTuiAgentDefaultEnv(agent.id)).length > 0,
|
||||
isDetected,
|
||||
isEnabled: isTuiAgentEnabled(agent.id, settings.disabledTuiAgents),
|
||||
isDefault: isDetected && settings.defaultTuiAgent === agent.id,
|
||||
cmdOverride: isDetected ? settings.agentCmdOverrides[agent.id] : undefined,
|
||||
argsOverride: resolveTuiAgentLaunchArgs(agent.id, settings.agentDefaultArgs),
|
||||
envOverride: local ? resolveTuiAgentLaunchEnv(agent.id, local.settings.agentDefaultEnv) : {},
|
||||
permission: permissions.rows.get(agent.id),
|
||||
argsOverride: typed
|
||||
? (typed.userArgs[agent.id] ?? '')
|
||||
: resolveTuiAgentLaunchArgs(agent.id, settings),
|
||||
launchArgs: typed ? (settings.agentDefaultArgs[agent.id] ?? '') : undefined,
|
||||
envOverride: !local
|
||||
? {}
|
||||
: typed
|
||||
? resolveTuiAgentLaunchEnv(agent.id, local.settings, window.api.platform.get().platform)
|
||||
: resolveTuiAgentLaunchEnv(agent.id, { agentDefaultEnv: local.settings.agentDefaultEnv }),
|
||||
permission: typed ? undefined : permissions.rows.get(agent.id),
|
||||
permissionChoice: choiceRow(agent.id),
|
||||
onSetDefault: () =>
|
||||
save({
|
||||
type: 'default',
|
||||
@@ -120,14 +206,11 @@ export function AgentLaunchSettingsCatalog({
|
||||
}),
|
||||
onSetEnabled: (enabled) => save({ type: 'availability', agent: agent.id, enabled }),
|
||||
onSaveOverride: (value) => mutate({ type: 'command', agent: agent.id, value }),
|
||||
onSaveArgs: (value) => mutate({ type: 'arguments', agent: agent.id, value }),
|
||||
onSaveEnv: (value) => {
|
||||
if (local) {
|
||||
local.updateSettings({
|
||||
agentDefaultEnv: { ...local.settings.agentDefaultEnv, [agent.id]: value }
|
||||
})
|
||||
}
|
||||
},
|
||||
onSaveArgs: (value) =>
|
||||
typed
|
||||
? saveUserArgs(agent.id, agent.label, value)
|
||||
: mutate({ type: 'arguments', agent: agent.id, value }),
|
||||
onSaveEnv: (value) => local?.saveEnvironment(agent.id, value),
|
||||
...(!local
|
||||
? {
|
||||
environmentNames: settings.environmentNames[agent.id] ?? [],
|
||||
@@ -172,17 +255,32 @@ export function AgentLaunchSettingsCatalog({
|
||||
onSetDefault={(agent) => save({ type: 'default', agent })}
|
||||
defaultOpensAsChat={defaultOpensAsChat}
|
||||
/>
|
||||
{renderPermissions({
|
||||
mode: permissions.globalMode,
|
||||
nonAutoMode: settings.nonAutoPermissionMode,
|
||||
majorityMode: permissions.majorityMode,
|
||||
autoAgentLabels: catalog
|
||||
.filter((agent) => agent.id in AUTO_TUI_AGENT_ARGS)
|
||||
.map((agent) => agent.label),
|
||||
onChange: (mode) => save({ type: 'permissions', mode }),
|
||||
onChangeNonAuto: (mode) => save({ type: 'permissions', mode, exceptAuto: true }),
|
||||
autoSupported
|
||||
})}
|
||||
{permissionSettings ? (
|
||||
<AllAgentsPermissionSetting
|
||||
choice={permissionSettings.agentPermissionDefault ?? 'own'}
|
||||
withoutAutoChoice={sharedChoice(
|
||||
PERMISSION_CHOICE_AGENTS.filter(
|
||||
(agent) => !agentPermissionChoices(agent).includes('auto')
|
||||
).map((agent) => resolveAgentPermission(agent, permissionSettings).choice)
|
||||
)}
|
||||
ownChoiceCount={Object.keys(permissionSettings.agentPermissionOverrides ?? {}).length}
|
||||
onChange={(choice) => save({ type: 'permission', choice })}
|
||||
onChangeWithoutAuto={(choice) => save({ type: 'permission', choice, withoutAuto: true })}
|
||||
/>
|
||||
) : null}
|
||||
{permissionSettings
|
||||
? null
|
||||
: renderPermissions({
|
||||
mode: permissions.globalMode,
|
||||
nonAutoMode: settings.nonAutoPermissionMode,
|
||||
majorityMode: permissions.majorityMode,
|
||||
autoAgentLabels: catalog
|
||||
.filter((agent) => agent.id in AUTO_TUI_AGENT_ARGS)
|
||||
.map((agent) => agent.label),
|
||||
onChange: (mode) => save({ type: 'permissions', mode }),
|
||||
onChangeNonAuto: (mode) => save({ type: 'permissions', mode, exceptAuto: true }),
|
||||
autoSupported
|
||||
})}
|
||||
<AgentDetectionCatalog
|
||||
detectedAgents={detectedAgents}
|
||||
undetectedAgents={undetectedAgents}
|
||||
@@ -202,3 +300,7 @@ export function AgentLaunchSettingsCatalog({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function sharedChoice(choices: readonly AgentPermissionChoice[]): AgentPermissionChoice | null {
|
||||
return choices.length > 0 && choices.every((choice) => choice === choices[0]) ? choices[0] : null
|
||||
}
|
||||
|
||||
@@ -34,8 +34,12 @@ vi.mock('./AgentLaunchSettingsCatalog', () => ({
|
||||
return <p>Local launch settings</p>
|
||||
}
|
||||
}))
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('api', { platform: { get: () => ({ platform: 'darwin' }) } })
|
||||
state.runtimeEnvironments = [
|
||||
{ id: 'ssh-host', name: 'SSH host', createdAt: 1, pairingRevision: 17 }
|
||||
]
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { toast } from 'sonner'
|
||||
import { getAgentLabel } from '@/lib/agent-catalog'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import { wholeAgentEnvironmentUpdates } from '../../../../shared/agent-launch-permission-updates'
|
||||
import { useRef } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
@@ -69,26 +73,56 @@ function LocalAgentLaunchSettings({
|
||||
renderPermissions: AgentPermissionsRenderer
|
||||
}): React.JSX.Element {
|
||||
const tail = useRef<Promise<void> | null>(null)
|
||||
const mutate = (mutation: AgentLaunchSettingsMutation): Promise<void> => {
|
||||
const apply = (
|
||||
agent: TuiAgent | undefined,
|
||||
compute: (current: GlobalSettings, platform: NodeJS.Platform) => Partial<GlobalSettings>
|
||||
): Promise<void> => {
|
||||
const write = (tail.current ?? Promise.resolve())
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
const current = useAppStore.getState().settings ?? settings
|
||||
const platform = window.api.platform.get().platform
|
||||
await updateSettings(agentLaunchSettingsMutationUpdates(current, mutation, platform))
|
||||
const updates = compute(current, window.api.platform.get().platform)
|
||||
if (Object.keys(updates).length) {
|
||||
await useAppStore.getState().updateSettingsOrThrow(updates)
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
toast.error(
|
||||
agent
|
||||
? translate(
|
||||
'settings.agents.localSaveFailedAgent',
|
||||
'{{agent}} settings could not be saved. Try again.',
|
||||
{ agent: getAgentLabel(agent) }
|
||||
)
|
||||
: translate(
|
||||
'settings.agents.localSaveFailed',
|
||||
'Agent settings could not be saved. Try again.'
|
||||
)
|
||||
)
|
||||
throw error
|
||||
})
|
||||
tail.current = write
|
||||
return write
|
||||
}
|
||||
const mutate = (mutation: AgentLaunchSettingsMutation): Promise<void> =>
|
||||
apply(
|
||||
mutation.type !== 'default' && 'agent' in mutation ? mutation.agent : undefined,
|
||||
(current, platform) => agentLaunchSettingsMutationUpdates(current, mutation, platform)
|
||||
)
|
||||
const saveEnvironment = (agent: TuiAgent, requested: Record<string, string>): Promise<void> =>
|
||||
apply(agent, (current, platform) =>
|
||||
wholeAgentEnvironmentUpdates(current, agent, requested, platform)
|
||||
)
|
||||
|
||||
return (
|
||||
<AgentLaunchSettingsCatalog
|
||||
settings={projectAgentLaunchSettings(settings)}
|
||||
settings={projectAgentLaunchSettings(settings, window.api.platform.get().platform)}
|
||||
mutate={mutate}
|
||||
detectionTarget={LOCAL_DETECTION_TARGET}
|
||||
hostName={null}
|
||||
renderPermissions={renderPermissions}
|
||||
nativeChatPermissionsSupported
|
||||
local={{ settings, updateSettings }}
|
||||
local={{ settings, updateSettings, saveEnvironment }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { ChevronDown, X } from 'lucide-react'
|
||||
import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentPermissionChoice } from '../../../../shared/tui-agent-permission-choice'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '../ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import {
|
||||
nativeChatPermissionModeDescription,
|
||||
nativeChatPermissionModeLabel
|
||||
} from '../native-chat/native-chat-permission-mode-labels'
|
||||
import { SettingsSegmentedControl } from './SettingsFormControls'
|
||||
|
||||
/** The chat's wording for a mode; `own` names the agent when there is one. */
|
||||
export function agentPermissionChoiceLabel(
|
||||
choice: AgentPermissionChoice,
|
||||
agentLabel: string | null
|
||||
): string {
|
||||
if (choice !== 'own') {
|
||||
return nativeChatPermissionModeLabel(choice)
|
||||
}
|
||||
return agentLabel
|
||||
? translate('settings.agents.permissionOwnAgent', "Use {{agent}}'s own settings", {
|
||||
agent: agentLabel
|
||||
})
|
||||
: translate('settings.agents.permissionOwnAll', "Agents' own settings")
|
||||
}
|
||||
|
||||
// Temporary copy until chats can run an agent's own settings: until then they keep Settings → Chat.
|
||||
function ownDescription(): string {
|
||||
return translate(
|
||||
'settings.agents.permissionOwnDescription',
|
||||
'Orca adds no permission flag in terminals; chats use Settings → Chat.'
|
||||
)
|
||||
}
|
||||
|
||||
export function AllAgentsPermissionSetting({
|
||||
choice,
|
||||
withoutAutoChoice,
|
||||
ownChoiceCount,
|
||||
onChange,
|
||||
onChangeWithoutAuto
|
||||
}: {
|
||||
choice: AgentPermissionChoice
|
||||
/** What the agents without Approve for me share; null when they differ. */
|
||||
withoutAutoChoice: AgentPermissionChoice | null
|
||||
/** Agents set separately, which choosing a mode here resets. */
|
||||
ownChoiceCount: number
|
||||
onChange: (choice: AgentPermissionChoice) => void
|
||||
onChangeWithoutAuto: (choice: AgentPermissionChoice) => void
|
||||
}): React.JSX.Element {
|
||||
const title = translate(
|
||||
'auto.components.settings.AgentsPane.agentPermissions',
|
||||
'Agent Permissions'
|
||||
)
|
||||
return (
|
||||
<section className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{choice === 'own'
|
||||
? ownDescription()
|
||||
: translate(
|
||||
'settings.agents.permissionChoiceDescription',
|
||||
'Applies where each agent supports this mode. Change one agent below.'
|
||||
)}
|
||||
</p>
|
||||
{ownChoiceCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'settings.agents.permissionResetsOwnChoices',
|
||||
'Choosing a mode here resets the agents marked as set separately.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<SettingsSegmentedControl<AgentPermissionChoice>
|
||||
value={choice}
|
||||
onChange={onChange}
|
||||
ariaLabel={title}
|
||||
size="sm"
|
||||
options={(['own', 'ask', 'auto', 'full-access'] as const).map((value) => ({
|
||||
value,
|
||||
label:
|
||||
value === 'own'
|
||||
? translate('settings.agents.permissionOwnShort', 'Own settings')
|
||||
: nativeChatPermissionModeLabel(value)
|
||||
}))}
|
||||
/>
|
||||
{choice === 'auto' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate('settings.agents.permissionWithoutAuto', 'Agents without Approve for me')}
|
||||
</span>
|
||||
<SettingsSegmentedControl<AgentPermissionChoice | 'mixed'>
|
||||
value={withoutAutoChoice ?? 'mixed'}
|
||||
onChange={(next) => {
|
||||
if (next !== 'mixed') {
|
||||
onChangeWithoutAuto(next)
|
||||
}
|
||||
}}
|
||||
ariaLabel={translate(
|
||||
'settings.agents.otherAgentsPermissionsLabel',
|
||||
'Permissions for other agents'
|
||||
)}
|
||||
size="sm"
|
||||
options={(['own', 'full-access'] as const).map((value) => ({
|
||||
value,
|
||||
label:
|
||||
value === 'own'
|
||||
? translate('settings.agents.permissionOwnShort', 'Own settings')
|
||||
: nativeChatPermissionModeLabel(value)
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export type AgentPermissionChoiceRow = {
|
||||
choices: readonly AgentPermissionChoice[]
|
||||
choice: AgentPermissionChoice
|
||||
source: 'agent' | 'all'
|
||||
terminalChoice?: AgentPermissionChoice
|
||||
chatChoice?: AgentChatPermissionMode | null
|
||||
/** What this agent gets from the all-agents choice. */
|
||||
defaultChoice: AgentPermissionChoice
|
||||
onSelect: (choice: AgentPermissionChoice | 'default') => void
|
||||
/** The flag terminals add, shown as a chip in Arguments; empty when none. */
|
||||
flag: string
|
||||
/** The agent's own Arguments set permissions, so terminals use those instead. */
|
||||
argsSetPermission: boolean
|
||||
}
|
||||
|
||||
export function AgentPermissionMenu({
|
||||
agentId,
|
||||
label,
|
||||
permission
|
||||
}: {
|
||||
agentId: string
|
||||
label: string
|
||||
permission: AgentPermissionChoiceRow
|
||||
}): React.JSX.Element {
|
||||
const followAll = translate('settings.agents.permissionSameAsAll', 'Same as all agents')
|
||||
const current = agentPermissionChoiceLabel(permission.choice, label)
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="text-xs font-medium">
|
||||
{translate('settings.agents.rowPermissions', 'Permissions')}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
aria-label={translate('settings.agents.rowPermissionsLabel', '{{agent}} permissions', {
|
||||
agent: label
|
||||
})}
|
||||
className="max-w-full"
|
||||
>
|
||||
<span className="truncate">
|
||||
{permission.source === 'all' ? `${followAll} · ${current}` : current}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" collisionPadding={8} className="w-80">
|
||||
<DropdownMenuRadioGroup
|
||||
value={permission.source === 'all' ? 'default' : permission.choice}
|
||||
>
|
||||
<DropdownMenuRadioItem value="default" onSelect={() => permission.onSelect('default')}>
|
||||
<span className="truncate">
|
||||
{followAll} · {agentPermissionChoiceLabel(permission.defaultChoice, label)}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuSeparator />
|
||||
{permission.choices.map((choice) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={choice}
|
||||
value={choice}
|
||||
onSelect={() => permission.onSelect(choice)}
|
||||
>
|
||||
<div className="min-w-0 py-0.5">
|
||||
<div className={cn(choice === 'full-access' && 'text-status-warning')}>
|
||||
{agentPermissionChoiceLabel(choice, label)}
|
||||
</div>
|
||||
<div className="text-xs font-normal text-muted-foreground">
|
||||
{choice === 'own'
|
||||
? ownDescription()
|
||||
: nativeChatPermissionModeDescription(choice, agentId)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{permission.terminalChoice === 'own' && permission.chatChoice ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'settings.agents.permissionSurfaceDifference',
|
||||
'Terminals use the agent’s own settings; new chats use {{mode}}.',
|
||||
{ mode: nativeChatPermissionModeLabel(permission.chatChoice) }
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The flag Orca adds, drawn inside the Arguments box; removing it means the agent's own settings. */
|
||||
export function AgentPermissionArgsChip({
|
||||
label,
|
||||
flag,
|
||||
onRemove
|
||||
}: {
|
||||
label: string
|
||||
flag: string
|
||||
onRemove: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-5 max-w-[60%] shrink-0 items-center gap-1 rounded bg-muted px-1.5 font-mono text-[11px] text-foreground/80">
|
||||
<span className="truncate">{flag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={translate(
|
||||
'settings.agents.permissionChipRemove',
|
||||
"Remove {{flag}} and use {{agent}}'s own settings",
|
||||
{ flag, agent: label }
|
||||
)}
|
||||
className="rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate(
|
||||
'settings.agents.permissionChipTooltip',
|
||||
'Added by Permissions. Remove it to use {{agent}}’s own settings.',
|
||||
{ agent: label }
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
@@ -166,6 +166,11 @@ function findSegmentedControl(node: unknown, ariaLabel: string): ReactElementLik
|
||||
return found
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', { api: { platform: { get: () => ({ platform: 'darwin' }) } } })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('AgentsPane', () => {
|
||||
beforeEach(() => {
|
||||
detectedAgentsMock.detectedIds = ['claude']
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability'
|
||||
import { getChatPermissionSearchEntry } from './chat-permission-search'
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
@@ -238,3 +240,18 @@ describe('ChatBehaviorSetting permissions', () => {
|
||||
expect(container.querySelector('#chat-permissions')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'matches Chat copy and search to its API owner typed capability %s',
|
||||
(typed) => {
|
||||
setLocalRuntimeCapabilitiesForTests(typed ? [AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY] : [])
|
||||
const { container } = renderSetting({
|
||||
nativeChatPermissionMode: 'ask',
|
||||
agentPermissionDefault: 'full-access',
|
||||
activeRuntimeEnvironmentId: 'unrelated-agents-host'
|
||||
})
|
||||
const description = getChatPermissionSearchEntry(typed).description
|
||||
expect(container.querySelector('#chat-permissions')?.textContent).toContain(description)
|
||||
expect(description?.includes('own settings')).toBe(typed)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import { useStructuredAgentSessionHostQueuesMessages } from '@/runtime/structured-agent-session-host-capability'
|
||||
import {
|
||||
useChatSettingsTypedPermissions,
|
||||
useStructuredAgentSessionHostQueuesMessages
|
||||
} from '@/runtime/structured-agent-session-host-capability'
|
||||
import { useAppStore } from '../../store'
|
||||
import { Card, CardContent } from '../ui/card'
|
||||
import { ChatPermissionSetting } from './ChatPermissionSetting'
|
||||
@@ -27,12 +30,15 @@ export function ChatBehaviorSetting({
|
||||
updateSettings,
|
||||
forceVisible = false
|
||||
}: ChatBehaviorSettingProps): React.JSX.Element | null {
|
||||
const typedPermissions = useChatSettingsTypedPermissions()
|
||||
const query = useAppStore((state) => state.settingsSearchQuery)
|
||||
// A host without the capability ignores the preference; the switch would do nothing.
|
||||
const hostQueuesMessages = useStructuredAgentSessionHostQueuesMessages(STRUCTURED_CHAT_HOST)
|
||||
const entries = getChatBehaviorEntriesByKey()
|
||||
const rows = [
|
||||
...(settings.nativeChatPermissionMode !== undefined ? [getChatPermissionSearchEntry()] : []),
|
||||
...(settings.nativeChatPermissionMode !== undefined
|
||||
? [getChatPermissionSearchEntry(typedPermissions)]
|
||||
: []),
|
||||
entries.resumeOnRestart,
|
||||
...(hostQueuesMessages ? [entries.queueFollowUps] : []),
|
||||
entries.shellEnvironment
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useChatSettingsTypedPermissions } from '@/runtime/structured-agent-session-host-capability'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { AGENT_CHAT_PERMISSION_MODES } from '../../../../shared/agent-chat-permission-mode'
|
||||
import { NativeChatPermissionModePicker } from '../native-chat/NativeChatPermissionModePicker'
|
||||
@@ -15,11 +16,12 @@ export function ChatPermissionSetting({
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
forceVisible: boolean
|
||||
}): React.JSX.Element | null {
|
||||
const typedPermissions = useChatSettingsTypedPermissions()
|
||||
const current = settings.nativeChatPermissionMode
|
||||
if (current === undefined) {
|
||||
return null
|
||||
}
|
||||
const entry = getChatPermissionSearchEntry()
|
||||
const entry = getChatPermissionSearchEntry(typedPermissions)
|
||||
return (
|
||||
<SearchableSetting
|
||||
{...entry}
|
||||
|
||||
@@ -68,6 +68,17 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('host launch settings form', () => {
|
||||
it('shows resolved launch defaults in a collapsed legacy host row', async () => {
|
||||
transport.read.mockResolvedValue(projectAgentLaunchSettings({ agentDefaultArgs: {} }))
|
||||
render(pane(17))
|
||||
const installed = (await screen.findByText('Installed')).closest('section')
|
||||
if (!installed) {
|
||||
throw new Error('missing installed agents section')
|
||||
}
|
||||
expect(within(installed).getByText('--dangerously-skip-permissions')).toBeTruthy()
|
||||
expect(transport.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'offers automatic permissions only when supported: %s',
|
||||
async (supported) => {
|
||||
@@ -405,6 +416,38 @@ describe('host launch settings form', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'retains failed Arguments and follows acknowledged host text (typed=%s)',
|
||||
async (typed) => {
|
||||
const settings = (value: string) =>
|
||||
projectAgentLaunchSettings({
|
||||
...(typed ? { agentPermissionDefault: 'ask' as const } : {}),
|
||||
agentDefaultArgs: { claude: value }
|
||||
})
|
||||
transport.read.mockResolvedValue(settings('--host-marker'))
|
||||
transport.mutate
|
||||
.mockRejectedValueOnce(new Error('unknown acknowledgement'))
|
||||
.mockResolvedValue(settings('--host-normalized'))
|
||||
const view = render(pane(17))
|
||||
const args = await screen.findByDisplayValue('--host-marker')
|
||||
fireEvent.change(args, { target: { value: '--next-marker' } })
|
||||
fireEvent.blur(args)
|
||||
await screen.findByRole('alert')
|
||||
expect(args).toHaveProperty('value', '--next-marker')
|
||||
fireEvent.blur(args)
|
||||
await screen.findByDisplayValue('--host-normalized')
|
||||
expect(transport.mutate.mock.calls[1]?.[1]).toEqual({
|
||||
type: typed ? 'user-arguments' : 'arguments',
|
||||
agent: 'claude',
|
||||
value: '--next-marker'
|
||||
})
|
||||
fireEvent.change(args, { target: { value: '--unsubmitted' } })
|
||||
view.rerender(pane(18))
|
||||
await screen.findByDisplayValue('--host-marker')
|
||||
expect(screen.queryByDisplayValue('--unsubmitted')).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not display launch controls or local defaults for an old server', async () => {
|
||||
transport.read.mockResolvedValue(null)
|
||||
render(pane(17))
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import {
|
||||
AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY,
|
||||
AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY,
|
||||
AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY,
|
||||
AGENT_LAUNCH_SETTINGS_CAPABILITY
|
||||
} from '../../../../shared/agent-launch-runtime-capability'
|
||||
import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client'
|
||||
@@ -82,6 +83,15 @@ export async function mutateHostAgentLaunchSettings(
|
||||
) {
|
||||
throw new Error('agent_launch_native_chat_permissions_unsupported')
|
||||
}
|
||||
if (
|
||||
(mutation.type === 'permission' || mutation.type === 'user-arguments') &&
|
||||
!(await runtimeEnvironmentSupportsCapability(
|
||||
owner.environmentId,
|
||||
AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY
|
||||
))
|
||||
) {
|
||||
throw new Error('agent_launch_permission_choice_unsupported')
|
||||
}
|
||||
const result = await callRuntimeRpc<{ settings: AgentLaunchSettings }>(
|
||||
{ kind: 'environment', environmentId: owner.environmentId },
|
||||
'settings.mutateAgentLaunch',
|
||||
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
import * as updatesModule from '../../../../shared/agent-launch-permission-updates'
|
||||
import * as liftModule from '../../../../shared/tui-agent-permission-lift'
|
||||
import * as envDraftModule from './agent-default-env-draft'
|
||||
// @vitest-environment happy-dom
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '../../store'
|
||||
import { AgentsPane } from './AgentsPane'
|
||||
import { persist } from './settings-persistence-test-fixture'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import {
|
||||
measureSettingsWork,
|
||||
expectSettingsWorkScale
|
||||
} from '../../../../shared/global-settings-test-fixture'
|
||||
import { resolveTuiAgentLaunchArgs } from '../../../../shared/tui-agent-launch-defaults'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }))
|
||||
vi.mock('@/hooks/useDetectedAgents', () => ({
|
||||
useDetectedAgents: () => ({
|
||||
detectedIds: ['claude', 'gemini', 'goose'],
|
||||
detectionFailed: false,
|
||||
isRefreshing: false,
|
||||
refresh: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('./AgentAwakeSetting', () => ({ AgentAwakeSetting: () => null }))
|
||||
vi.mock('./AgentCacheTimerSection', () => ({ AgentCacheTimerSection: () => null }))
|
||||
|
||||
const feedback: (ReturnType<typeof measureSettingsWork>['work'] & { text?: string | null })[] = []
|
||||
const stringifyFixture = envDraftModule.stringifyAgentDefaultEnvDraft
|
||||
beforeEach(() => {
|
||||
feedback.length = 0
|
||||
Object.assign(uiWork, { entries: 0, bytes: 0 })
|
||||
envWork.entries = envWork.bytes = 0
|
||||
const prepare = updatesModule.wholeAgentEnvironmentUpdates
|
||||
vi.spyOn(updatesModule, 'wholeAgentEnvironmentUpdates').mockImplementation((...args) => {
|
||||
const [settings, agent, requested] = args
|
||||
const size =
|
||||
Object.keys(settings.agentDefaultEnv?.[agent] ?? {}).length + Object.keys(requested).length
|
||||
const result = measureSettingsWork(() => prepare(...args))
|
||||
const entries = result.work.entries + 12 * size
|
||||
if (size >= 20) {
|
||||
expect(entries).toBeLessThanOrEqual(36 * size + 64)
|
||||
}
|
||||
envWork.entries += entries
|
||||
envWork.bytes += result.work.bytes
|
||||
return result.value
|
||||
})
|
||||
const importer = liftModule.liftAgentPermissionArgs
|
||||
vi.spyOn(liftModule, 'liftAgentPermissionArgs').mockImplementation((...args) => {
|
||||
const receipt = measureSettingsWork(() => importer(...args))
|
||||
feedback.push({ ...receipt.work, text: args[1] })
|
||||
return receipt.value
|
||||
})
|
||||
const parse = envDraftModule.parseAgentDefaultEnvDraft
|
||||
vi.spyOn(envDraftModule, 'parseAgentDefaultEnvDraft').mockImplementation((input) => {
|
||||
const receipt = measureSettingsWork(() => parse(input))
|
||||
// Parse reads/writes each pair; stringify maps and joins each output entry.
|
||||
uiWork.entries += receipt.work.entries + 3 * Object.keys(receipt.value.env).length
|
||||
uiWork.bytes += receipt.work.bytes + 4 * input.length
|
||||
return receipt.value
|
||||
})
|
||||
const stringify = envDraftModule.stringifyAgentDefaultEnvDraft
|
||||
vi.spyOn(envDraftModule, 'stringifyAgentDefaultEnvDraft').mockImplementation((input) => {
|
||||
const receipt = measureSettingsWork(() => stringify(input))
|
||||
uiWork.entries += receipt.work.entries + 3 * Object.keys(input).length
|
||||
uiWork.bytes += receipt.work.bytes + 4 * JSON.stringify(input).length
|
||||
return receipt.value
|
||||
})
|
||||
Object.assign(window.api, { platform: { get: () => ({ platform: 'darwin' }) } })
|
||||
const settings = useAppStore.getState().settings
|
||||
if (!settings) {
|
||||
throw new Error('Missing settings fixture')
|
||||
}
|
||||
useAppStore.setState({ settings: { ...settings, agentPermissionDefault: 'ask' } })
|
||||
})
|
||||
|
||||
function AgentsHarness() {
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const updateSettings = useAppStore((state) => state.updateSettings)
|
||||
if (!settings) {
|
||||
throw new Error('Missing settings fixture')
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<AgentsPane
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
hostScope={{ target: { kind: 'local' }, available: true }}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function argumentsInput(label: string): HTMLInputElement {
|
||||
const row = screen.getByText(label, { selector: 'span' }).closest('div.py-3')
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
throw new Error('Missing agent row')
|
||||
}
|
||||
const expand = within(row).queryByRole('button', { name: 'Expand agent settings' })
|
||||
if (expand) {
|
||||
fireEvent.click(expand)
|
||||
}
|
||||
const input = within(row).getByPlaceholderText('No default arguments')
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
throw new Error('Missing Arguments input')
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
it.each(
|
||||
[20, 200].flatMap((n) => [
|
||||
`${'--model opus '.repeat(n)}--dangerously-skip-permissions`,
|
||||
`--dangerously-skip-permissions ${'--model opus '.repeat(n).trim()}`
|
||||
])
|
||||
)('retires the acknowledged recipe %s before chip removal and neutral events', async (recipe) => {
|
||||
const models = recipe.replace('--dangerously-skip-permissions', '').trim()
|
||||
savedArguments(models, 'full-access')
|
||||
render(<AgentsHarness />)
|
||||
const input = argumentsInput('Claude')
|
||||
const remove = () =>
|
||||
screen.getByRole('button', { name: /Remove .* and use Claude's own settings/ })
|
||||
fireEvent.click(remove())
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(1))
|
||||
const user = userEvent.setup()
|
||||
await user.click(input)
|
||||
await user.clear(input)
|
||||
fireEvent.change(input, { target: { value: recipe } })
|
||||
const before = feedback.length
|
||||
await user.keyboard('{Enter}')
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1))
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
const imports = feedback.slice(before)
|
||||
expect(imports.map((work) => work.text)).toEqual([recipe, recipe])
|
||||
for (const work of imports) {
|
||||
expect(work.chars).toBeLessThanOrEqual(36 * recipe.length + 12000)
|
||||
}
|
||||
const key = recipe.startsWith('--dangerously') ? 'leading' : 'tail',
|
||||
total = imports.reduce(
|
||||
(sum, work) =>
|
||||
sum + work.chars + work.entries + work.visits + work.bytes + 42 * recipe.length,
|
||||
0
|
||||
)
|
||||
expectSettingsWorkScale(`feedback-${key}`, recipe.length > 1000 ? 200 : 20, [total])
|
||||
expect(useAppStore.getState().settings?.agentPermissionOverrides?.claude).toBe('full-access')
|
||||
expect(resolveTuiAgentLaunchArgs('claude', useAppStore.getState().settings ?? {})).toBe(
|
||||
`--dangerously-skip-permissions ${models}`
|
||||
)
|
||||
expect(input.value).toBe(models)
|
||||
fireEvent.click(remove())
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(3))
|
||||
await user.click(input)
|
||||
await user.tab()
|
||||
await user.click(input)
|
||||
await user.keyboard('{Enter}')
|
||||
expect(persist).toHaveBeenCalledTimes(3)
|
||||
expect(toast.success).toHaveBeenCalledTimes(1)
|
||||
expect(useAppStore.getState().settings?.agentPermissionOverrides?.claude).toBe('own')
|
||||
await user.click(input)
|
||||
await user.type(input, ' --dangerously-skip-permissions')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(input.value).toBe(models)
|
||||
await user.tab()
|
||||
fireEvent.change(input, { target: { value: ` ${models} ` } })
|
||||
fireEvent.blur(input)
|
||||
expect(input.value).toBe(models)
|
||||
expect(persist).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('retains failed conversion through the ordinary callback and announces only the saved retry', async () => {
|
||||
savedArguments('--model opus')
|
||||
const save = deferredSave()
|
||||
render(<AgentsHarness />)
|
||||
const input = argumentsInput('Claude')
|
||||
fireEvent.change(input, { target: { value: '--permission-mode auto --model opus' } })
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(1))
|
||||
act(() => savedArguments(''))
|
||||
expect(input.isConnected).toBe(true)
|
||||
await act(async () => save.reject(new Error('Settings disk is unavailable')))
|
||||
await waitFor(() => expect(input.readOnly).toBe(false))
|
||||
expect(toast.error).toHaveBeenCalledExactlyOnceWith(
|
||||
'Claude settings could not be saved. Try again.'
|
||||
)
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().settings?.agentPermissionDefault).toBe('ask')
|
||||
expect(useAppStore.getState().settings?.agentPermissionOverrides?.claude).toBeUndefined()
|
||||
expect(input.value).toBe('--permission-mode auto --model opus')
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'Claude set to Approve for me in terminals and new chats.'
|
||||
)
|
||||
)
|
||||
expect(useAppStore.getState().settings?.agentPermissionOverrides?.claude).toBe('auto')
|
||||
expect(useAppStore.getState().settings?.agentDefaultArgs?.claude).toBe('--model opus')
|
||||
await waitFor(() => expect(input.value).toBe('--model opus'))
|
||||
act(() => savedArguments('--later'))
|
||||
expect(input.value).toBe('--later')
|
||||
fireEvent.change(input, { target: { value: '--dirty' } })
|
||||
act(() => savedArguments(''))
|
||||
expect(input.value).toBe('--dirty')
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
it('describes terminal-only conversion without claiming new native chats', async () => {
|
||||
render(<AgentsHarness />)
|
||||
const input = argumentsInput('Gemini')
|
||||
fireEvent.change(input, { target: { value: '--yolo' } })
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith('Gemini set to Full access in terminals.')
|
||||
)
|
||||
expect(useAppStore.getState().settings?.agentPermissionOverrides?.gemini).toBe('full-access')
|
||||
expect(useAppStore.getState().settings?.agentDefaultArgs?.gemini).toBe('')
|
||||
})
|
||||
|
||||
function savedArguments(value: string, choice: 'ask' | 'full-access' = 'ask') {
|
||||
useAppStore.setState(({ settings }) => ({
|
||||
settings: settings && {
|
||||
...settings,
|
||||
agentPermissionDefault: choice,
|
||||
agentDefaultArgs: { claude: value }
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function savedEnvironment(env: Record<string, string>, agent = 'claude') {
|
||||
const settings = useAppStore.getState().settings
|
||||
if (!settings) {
|
||||
throw new Error('Missing settings fixture')
|
||||
}
|
||||
useAppStore.setState({
|
||||
settings: { ...settings, agentDefaultEnv: { ...settings.agentDefaultEnv, [agent]: env } }
|
||||
})
|
||||
}
|
||||
function environmentInput(value: string): HTMLInputElement {
|
||||
const input = screen.getByDisplayValue(value)
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
throw new Error('Missing Environment input')
|
||||
}
|
||||
return input
|
||||
}
|
||||
function deferredSave() {
|
||||
let resolve!: () => void
|
||||
let reject!: (error: Error) => void
|
||||
const done = new Promise<void>((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
persist.mockImplementationOnce(async (updates) => {
|
||||
await done
|
||||
const settings = useAppStore.getState().settings
|
||||
if (!settings) {
|
||||
throw new Error('Missing settings fixture')
|
||||
}
|
||||
return { ...settings, ...updates }
|
||||
})
|
||||
return { resolve, reject }
|
||||
}
|
||||
|
||||
it('retains a whole failed Environment edit across empty publication and retries once', async () => {
|
||||
savedEnvironment({ A: 'old', B: 'old' })
|
||||
const save = deferredSave()
|
||||
render(<AgentsHarness />)
|
||||
const input = environmentInput('A=old B=old')
|
||||
fireEvent.change(input, { target: { value: 'A=new B=new' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(1))
|
||||
expect(persist.mock.calls[0][0].agentDefaultEnv?.claude).toEqual({ A: 'new', B: 'new' })
|
||||
act(() => savedEnvironment({}))
|
||||
expect(input.isConnected).toBe(true)
|
||||
await act(async () => save.reject(new Error('secret provider details')))
|
||||
await waitFor(() => expect(input.readOnly).toBe(false))
|
||||
expect(input.value).toBe('A=new B=new')
|
||||
expect(toast.error).toHaveBeenCalledExactlyOnceWith(
|
||||
'Claude settings could not be saved. Try again.'
|
||||
)
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() =>
|
||||
expect(useAppStore.getState().settings?.agentDefaultEnv?.claude).toEqual({ A: 'new', B: 'new' })
|
||||
)
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
act(() => savedEnvironment({ A: 'external' }))
|
||||
expect(input.value).toBe('A=external')
|
||||
const user = userEvent.setup()
|
||||
await user.click(input)
|
||||
await user.type(input, 'discard')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(input.value).toBe('A=external')
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() => expect(input.readOnly).toBe(false))
|
||||
fireEvent.change(input, { target: { value: 'A=two' } })
|
||||
fireEvent.change(input, { target: { value: 'A=external' } })
|
||||
fireEvent.blur(input)
|
||||
act(() => savedEnvironment({ A: 'two' }))
|
||||
await user.click(input)
|
||||
await user.tab()
|
||||
expect(input.value).toBe('A=two')
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each(['blur', 'reset'])('retains ordered Environment work when %s rejects', async (failure) => {
|
||||
savedEnvironment({ A: 'old' })
|
||||
const blur = deferredSave()
|
||||
const reset = deferredSave()
|
||||
const user = userEvent.setup()
|
||||
render(<AgentsHarness />)
|
||||
const input = environmentInput('A=old')
|
||||
await user.click(input)
|
||||
await user.clear(input)
|
||||
await user.type(input, 'A=draft')
|
||||
const row = input.closest('div.py-3')
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
throw new Error('Missing row')
|
||||
}
|
||||
const resetButton = within(row).getByRole('button', { name: 'Reset' })
|
||||
await user.click(resetButton)
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(1))
|
||||
await act(async () => (failure === 'blur' ? blur.reject(new Error('offline')) : blur.resolve()))
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(2))
|
||||
expect(input.readOnly).toBe(true)
|
||||
expect(input.value).toBe('A=draft')
|
||||
await user.click(input)
|
||||
await user.tab()
|
||||
await user.click(input)
|
||||
await user.keyboard('{Enter}')
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
if (failure === 'reset') {
|
||||
act(() => savedEnvironment({}))
|
||||
expect(input.isConnected).toBe(true)
|
||||
await act(async () => reset.reject(new Error('offline')))
|
||||
await waitFor(() => expect(input.readOnly).toBe(false))
|
||||
expect(input.value).toBe('A=draft')
|
||||
await user.click(resetButton)
|
||||
} else {
|
||||
await act(async () => reset.resolve())
|
||||
}
|
||||
await waitFor(() => expect(input.isConnected).toBe(false))
|
||||
expect(useAppStore.getState().settings?.agentDefaultEnv?.claude).toEqual({})
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
expect(toast.error).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['darwin', 'win32'].flatMap((platform) => [20, 200].map((n) => [platform, n] as const)))(
|
||||
'bounds whole Environment on %s with %i entries',
|
||||
async (platform, n) => {
|
||||
Object.assign(window.api, { platform: { get: () => ({ platform }) } })
|
||||
savedEnvironment({ Path: 'old' })
|
||||
savedEnvironment({ TOKEN: 'keep' }, 'gemini')
|
||||
const original = useAppStore.getState().settings?.agentDefaultEnv
|
||||
render(<AgentsHarness />)
|
||||
const input = environmentInput('Path=old')
|
||||
const names = Array.from({ length: n }, (_, i) => `K${String(i).padStart(3, '0')}`)
|
||||
for (const value of ['new', 'last']) {
|
||||
const extra = Object.fromEntries(names.map((k) => [k, value]))
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
value: (value === 'new' ? 'Path=old PATH=new ' : 'PATH=new ') + stringifyFixture(extra)
|
||||
}
|
||||
})
|
||||
act(() => savedEnvironment({ TOKEN: 'latest' }, 'gemini'))
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => expect(input.readOnly).toBe(false))
|
||||
expect(useAppStore.getState().settings?.agentDefaultEnv).toEqual({
|
||||
...original,
|
||||
claude: {
|
||||
...extra,
|
||||
...(platform === 'win32' || value === 'last'
|
||||
? { PATH: 'new' }
|
||||
: { Path: 'old', PATH: 'new' })
|
||||
},
|
||||
gemini: { TOKEN: 'latest' }
|
||||
})
|
||||
}
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
const calls = vi.mocked(updatesModule.wholeAgentEnvironmentUpdates).mock.calls.length
|
||||
fireEvent.change(input, { target: { value: `${input.value} ` } })
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() => expect(input.readOnly).toBe(false))
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
expect(updatesModule.wholeAgentEnvironmentUpdates).toHaveBeenCalledTimes(calls + 1)
|
||||
const counts = [envWork.entries, envWork.bytes, uiWork.entries, uiWork.bytes]
|
||||
expectSettingsWorkScale(`renderer-env-${platform}`, n, counts)
|
||||
}
|
||||
)
|
||||
|
||||
it('imports then removes Goose carrier through one whole edit per acknowledgement', async () => {
|
||||
savedEnvironment({ OTHER: 'keep' }, 'goose')
|
||||
render(<AgentsHarness />)
|
||||
const input = environmentInput('OTHER=keep')
|
||||
fireEvent.change(input, { target: { value: 'OTHER=next GOOSE_MODE=auto' } })
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() =>
|
||||
expect(useAppStore.getState().settings?.agentPermissionOverrides?.goose).toBe('full-access')
|
||||
)
|
||||
expect(useAppStore.getState().settings?.agentDefaultEnv?.goose).toEqual({ OTHER: 'next' })
|
||||
fireEvent.change(input, { target: { value: 'OTHER=last' } })
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() =>
|
||||
expect(useAppStore.getState().settings?.agentPermissionOverrides?.goose).toBe('own')
|
||||
)
|
||||
expect(useAppStore.getState().settings?.agentDefaultEnv?.goose).toEqual({ OTHER: 'last' })
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([20, 200])('keeps Goose Environment editable after Reset/reopen (%i)', async (n) => {
|
||||
const extra = Object.fromEntries(
|
||||
Array.from({ length: n }, (_, i) => [`K${String(i).padStart(3, '0')}`, 'keep'])
|
||||
)
|
||||
savedEnvironment(
|
||||
{ goose_mode: 'lowercase-is-distinct', QA_GOOS_KEEP: 'keep', QA_EDITOR_ONE: 'one', ...extra },
|
||||
'goose'
|
||||
)
|
||||
useAppStore.setState(({ settings }) => ({
|
||||
settings: settings && {
|
||||
...settings,
|
||||
agentPermissionOverrides: { goose: 'own' }
|
||||
}
|
||||
}))
|
||||
render(<AgentsHarness />)
|
||||
const input = environmentInput(
|
||||
`goose_mode=lowercase-is-distinct QA_GOOS_KEEP=keep QA_EDITOR_ONE=one ${stringifyFixture(extra)}`
|
||||
)
|
||||
const row = input.closest('div.py-3')
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
throw new Error('Missing Goose row')
|
||||
}
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Reset' }))
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(useAppStore.getState().settings?.agentDefaultEnv?.goose).toEqual({}))
|
||||
await waitFor(() =>
|
||||
expect(within(row).getByPlaceholderText('No default environment')).toHaveProperty('value', '')
|
||||
)
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Collapse agent settings' }))
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Expand agent settings' }))
|
||||
const empty = within(row).getByPlaceholderText('No default environment')
|
||||
fireEvent.change(empty, {
|
||||
target: { value: `QA_AFTER_RESET=yes ${stringifyFixture(extra)}` }
|
||||
})
|
||||
fireEvent.blur(empty)
|
||||
await waitFor(() =>
|
||||
expect(useAppStore.getState().settings?.agentDefaultEnv?.goose).toEqual({
|
||||
QA_AFTER_RESET: 'yes',
|
||||
...extra
|
||||
})
|
||||
)
|
||||
expect(useAppStore.getState().settings?.agentPermissionOverrides?.goose).toBe('own')
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
const counts = [envWork.entries, envWork.bytes, uiWork.entries, uiWork.bytes]
|
||||
expectSettingsWorkScale('renderer-Reset', n, counts)
|
||||
})
|
||||
|
||||
it('reports one direct local permission action failure without changing saved intent', async () => {
|
||||
persist.mockRejectedValueOnce(new Error('private error'))
|
||||
render(<AgentsHarness />)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full access' }))
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledExactlyOnceWith(
|
||||
'Agent settings could not be saved. Try again.'
|
||||
)
|
||||
)
|
||||
expect(useAppStore.getState().settings?.agentPermissionDefault).toBe('ask')
|
||||
})
|
||||
|
||||
const uiWork = { entries: 0, bytes: 0 }
|
||||
const envWork = { entries: 0, bytes: 0 }
|
||||
@@ -49,8 +49,10 @@ export function buildAgentRowPermissions({
|
||||
agent,
|
||||
resolveTuiAgentPermissionMode({
|
||||
agent,
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: localEnv ? resolveTuiAgentLaunchEnv(agent, localEnv) : undefined
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings),
|
||||
agentEnv: localEnv
|
||||
? resolveTuiAgentLaunchEnv(agent, { agentDefaultEnv: localEnv })
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,17 @@ import { translate } from '@/i18n/i18n'
|
||||
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const getChatPermissionSearchEntry = createLocalizedCatalog((): SettingsSearchEntry => ({
|
||||
const getTypedEntry = createLocalizedCatalog((): SettingsSearchEntry => ({
|
||||
targetSectionId: 'chat-permissions',
|
||||
title: translate('settings.chat.permissionsTitle', 'Permissions for new chats'),
|
||||
// Temporary until chats can run an agent's own settings.
|
||||
description: translate(
|
||||
'settings.chat.permissionsDescriptionOwnAgents',
|
||||
'New chats of agents set to their own settings in Settings → Agents. Other agents start chats in their permission there.'
|
||||
)
|
||||
}))
|
||||
|
||||
const getLegacyEntry = createLocalizedCatalog((): SettingsSearchEntry => ({
|
||||
targetSectionId: 'chat-permissions',
|
||||
title: translate('settings.chat.permissionsTitle', 'Permissions for new chats'),
|
||||
description: translate(
|
||||
@@ -10,3 +20,7 @@ export const getChatPermissionSearchEntry = createLocalizedCatalog((): SettingsS
|
||||
'New chats only. Agent-specific defaults are in Settings → Agents.'
|
||||
)
|
||||
}))
|
||||
|
||||
export function getChatPermissionSearchEntry(typedPermissions = false): SettingsSearchEntry {
|
||||
return typedPermissions ? getTypedEntry() : getLegacyEntry()
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ export function useAgentLaunchFieldWrite(): {
|
||||
queuedValues.current.add(value)
|
||||
setPending(true)
|
||||
const completion: Promise<void> = result
|
||||
.then(onSaved)
|
||||
.then(() => {
|
||||
if (tail.current === completion) {
|
||||
onSaved?.()
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
queuedValues.current.delete(value)
|
||||
|
||||
+58
-21
@@ -5,6 +5,11 @@ import { useAppStore } from '@/store'
|
||||
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
|
||||
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import {
|
||||
measureSettingsWork,
|
||||
expectSettingsWorkScale
|
||||
} from '../../../../shared/global-settings-test-fixture'
|
||||
import * as profile from '../../../../shared/tui-agent-launch-defaults'
|
||||
import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings'
|
||||
import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status'
|
||||
import {
|
||||
@@ -36,6 +41,28 @@ const server = projectAgentLaunchSettings({
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['opencode']
|
||||
})
|
||||
const raw = {
|
||||
...server,
|
||||
agentPermissionDefault: 'ask' as const,
|
||||
agentDefaultEnv: {
|
||||
claude: Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`K${i}`, 'private']))
|
||||
}
|
||||
}
|
||||
const payloadWork = { entries: 0, bytes: 0, count: 0 }
|
||||
const compositions = vi.spyOn(profile, 'composeAgentLaunchProfile')
|
||||
function payload(source: Parameters<typeof projectAgentLaunchSettings>[0] = raw) {
|
||||
const before = compositions.mock.calls.length,
|
||||
{ value: settings, work } = measureSettingsWork(() => projectAgentLaunchSettings(source))
|
||||
expect(compositions.mock.calls.length - before).toBe(1)
|
||||
const result = { settings },
|
||||
text = JSON.stringify(result)
|
||||
expect(text).not.toContain('private')
|
||||
payloadWork.count++
|
||||
payloadWork.entries += work.entries + work.visits + 14 * 20
|
||||
payloadWork.bytes += text.length + work.bytes
|
||||
return result
|
||||
}
|
||||
|
||||
function status(runtimeId = 'runtime-1'): RuntimeEnvironmentStatus {
|
||||
return {
|
||||
status: {
|
||||
@@ -51,7 +78,8 @@ function status(runtimeId = 'runtime-1'): RuntimeEnvironmentStatus {
|
||||
}
|
||||
}
|
||||
beforeEach(() => {
|
||||
rpc.call.mockReset().mockResolvedValue({ settings: server })
|
||||
Object.assign(payloadWork, { entries: 0, bytes: 0, count: 0 })
|
||||
rpc.call.mockReset().mockImplementation(() => payload())
|
||||
rpc.supports.mockReset().mockResolvedValue(true)
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'alpha', createdAt: 7 }])
|
||||
setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT])
|
||||
@@ -171,38 +199,47 @@ it('ignores old reads across host switches, restarts, and re-pairing', async ()
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes after a save, fences the earlier read, and refreshes on reopening', async () => {
|
||||
const old = Promise.withResolvers<{ settings: typeof server }>()
|
||||
rpc.call.mockReturnValueOnce(old.promise)
|
||||
const hook = renderHook(
|
||||
({ open }) => useNewTabAgentMenuRows('wt', open, { kind: 'runtime', environmentId: 'alpha' }),
|
||||
{ initialProps: { open: true } }
|
||||
)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
await mutateHostAgentLaunchSettings(
|
||||
it.each([20, 200])('bounds %s menu owners across save/open', async (n) => {
|
||||
const notify = () =>
|
||||
mutateHostAgentLaunchSettings(
|
||||
{ environmentId: 'alpha', pairingRevision: 7 },
|
||||
{ type: 'availability', agent: 'opencode', enabled: false }
|
||||
)
|
||||
const old = Promise.withResolvers<void>()
|
||||
rpc.call.mockImplementationOnce(() => {
|
||||
const earlier = payload({ ...raw, disabledTuiAgents: ['codex'] })
|
||||
return old.promise.then(() => earlier)
|
||||
})
|
||||
await act(async () =>
|
||||
old.resolve({ settings: projectAgentLaunchSettings({ disabledTuiAgents: ['codex'] }) })
|
||||
const hooks = Array.from({ length: n }, () =>
|
||||
renderHook(
|
||||
({ open }) => useNewTabAgentMenuRows('wt', open, { kind: 'runtime', environmentId: 'alpha' }),
|
||||
{ initialProps: { open: true } }
|
||||
)
|
||||
)
|
||||
const hook = hooks[0]
|
||||
await act(async () => {})
|
||||
await act(notify)
|
||||
await act(async () => old.resolve())
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe(
|
||||
'codex'
|
||||
)
|
||||
hook.rerender({ open: false })
|
||||
rpc.call.mockResolvedValue({
|
||||
settings: projectAgentLaunchSettings({
|
||||
defaultTuiAgent: 'claude',
|
||||
disabledTuiAgents: ['codex']
|
||||
})
|
||||
})
|
||||
hook.rerender({ open: true })
|
||||
expect(rpc.call).toHaveBeenCalledTimes(2 * n + 1)
|
||||
hooks.forEach((h) => h.rerender({ open: false }))
|
||||
await act(notify)
|
||||
expect(rpc.call).toHaveBeenCalledTimes(2 * n + 2)
|
||||
rpc.call.mockImplementation(() =>
|
||||
payload({ ...raw, defaultTuiAgent: 'claude', disabledTuiAgents: ['codex'] })
|
||||
)
|
||||
hooks.forEach((h) => h.rerender({ open: true }))
|
||||
await act(async () => {})
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe(
|
||||
'claude'
|
||||
)
|
||||
hooks.forEach((h) => h.unmount())
|
||||
await act(notify)
|
||||
expect(rpc.call).toHaveBeenCalledTimes(3 * n + 3)
|
||||
expect(payloadWork.count).toBe(3 * n + 3)
|
||||
expectSettingsWorkScale('menu', n, [payloadWork.entries, payloadWork.bytes])
|
||||
})
|
||||
|
||||
it('keeps this runtime’s last preferences through a failed reconnect refresh', async () => {
|
||||
|
||||
@@ -31,6 +31,8 @@ import { useCallback } from 'react'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import { settleComposerSubmit } from '@/lib/composer-submit-cancellation'
|
||||
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getFolderWorkspaceAgentLaunchFact } from '@/components/sidebar/folder-workspace-agent-startup'
|
||||
import { submitFolderWorkspaceCreate } from '@/components/sidebar/folder-workspace-composer-submit'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
@@ -101,6 +103,10 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp
|
||||
if (isSubmissionCancelled()) {
|
||||
return
|
||||
}
|
||||
const launchHost = getFolderWorkspaceAgentLaunchFact(
|
||||
useAppStore.getState(),
|
||||
selectedProjectGroup
|
||||
)
|
||||
const folderWorkspaceCreated = await submitFolderWorkspaceCreate({
|
||||
projectGroup: selectedProjectGroup,
|
||||
name: smartGitHubMetadata?.workspaceName ?? name,
|
||||
@@ -112,10 +118,14 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp
|
||||
launchChoice: options?.launchChoice,
|
||||
autoRenameBranchFromWork: settings?.autoRenameBranchFromWork,
|
||||
agentCmdOverrides: settings?.agentCmdOverrides,
|
||||
agentArgs: agent
|
||||
? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs)
|
||||
agentArgs: agent ? resolveTuiAgentLaunchArgs(agent, settings) : undefined,
|
||||
agentEnv: agent
|
||||
? resolveTuiAgentLaunchEnv(
|
||||
agent,
|
||||
settings,
|
||||
launchHost.kind === 'known' ? launchHost.platform : null
|
||||
)
|
||||
: undefined,
|
||||
agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined,
|
||||
terminalWindowsShell: settings?.terminalWindowsShell,
|
||||
isRemote: folderTargetIsRemote,
|
||||
launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
|
||||
|
||||
@@ -42,8 +42,12 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui
|
||||
agent,
|
||||
draft: draftPrompt,
|
||||
cmdOverrides: settings?.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(
|
||||
agent,
|
||||
settings,
|
||||
requireExecutionHostPlatform(input.launchHost)
|
||||
),
|
||||
platform: requireExecutionHostPlatform(input.launchHost),
|
||||
shell: input.shell ?? undefined,
|
||||
isRemote: input.isRemote
|
||||
@@ -66,8 +70,12 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui
|
||||
agent,
|
||||
prompt,
|
||||
cmdOverrides: settings?.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(
|
||||
agent,
|
||||
settings,
|
||||
requireExecutionHostPlatform(input.launchHost)
|
||||
),
|
||||
platform: requireExecutionHostPlatform(input.launchHost),
|
||||
shell: input.shell ?? undefined,
|
||||
isRemote: input.isRemote,
|
||||
|
||||
@@ -9,6 +9,7 @@ export type SettingsNavigationBuildOptions = {
|
||||
isWebClient: boolean
|
||||
nativeChatEnabled: boolean
|
||||
hostQueuesMessages: boolean
|
||||
typedChatPermissions?: boolean
|
||||
managedBrowserCreationEnabled: boolean
|
||||
mobileEmulatorCreationEnabled: boolean
|
||||
isDev: boolean
|
||||
|
||||
@@ -20,6 +20,7 @@ export function buildInterfaceSettingsSections({
|
||||
isWebClient,
|
||||
nativeChatEnabled,
|
||||
hostQueuesMessages,
|
||||
typedChatPermissions,
|
||||
managedBrowserCreationEnabled,
|
||||
mobileEmulatorCreationEnabled
|
||||
}: SettingsNavigationBuildOptions): SettingsNavSection[] {
|
||||
@@ -51,7 +52,7 @@ export function buildInterfaceSettingsSections({
|
||||
),
|
||||
icon: MessageSquare,
|
||||
searchEntries: [
|
||||
getChatPermissionSearchEntry(),
|
||||
getChatPermissionSearchEntry(typedChatPermissions),
|
||||
...getChatAppearanceSearchEntries(),
|
||||
...getChatBehaviorSearchEntries().filter(
|
||||
(entry) => hostQueuesMessages || entry.targetSectionId !== 'chat-queue-follow-ups'
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
import { defaultScopeHost } from '@/lib/default-creation-host'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useProjectHostSetupProjection } from '@/store/selectors'
|
||||
import { useStructuredAgentSessionHostQueuesMessages } from '@/runtime/structured-agent-session-host-capability'
|
||||
import {
|
||||
useChatSettingsTypedPermissions,
|
||||
useStructuredAgentSessionHostQueuesMessages
|
||||
} from '@/runtime/structured-agent-session-host-capability'
|
||||
import type { ProjectGroupingModel } from '../../../shared/project-section-grouping'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import {
|
||||
@@ -44,6 +47,7 @@ export function buildSettingsNavigationMetadata({
|
||||
isWebClient,
|
||||
nativeChatEnabled = false,
|
||||
hostQueuesMessages = false,
|
||||
typedChatPermissions = false,
|
||||
managedBrowserCreationEnabled = !isWebClient,
|
||||
mobileEmulatorCreationEnabled = !isWebClient,
|
||||
isDev = import.meta.env.DEV,
|
||||
@@ -58,6 +62,7 @@ export function buildSettingsNavigationMetadata({
|
||||
isWebClient: boolean
|
||||
nativeChatEnabled?: boolean
|
||||
hostQueuesMessages?: boolean
|
||||
typedChatPermissions?: boolean
|
||||
managedBrowserCreationEnabled?: boolean
|
||||
mobileEmulatorCreationEnabled?: boolean
|
||||
isDev?: boolean
|
||||
@@ -87,6 +92,7 @@ export function buildSettingsNavigationMetadata({
|
||||
isWebClient,
|
||||
nativeChatEnabled,
|
||||
hostQueuesMessages,
|
||||
typedChatPermissions,
|
||||
managedBrowserCreationEnabled,
|
||||
mobileEmulatorCreationEnabled,
|
||||
isDev,
|
||||
@@ -127,6 +133,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
|
||||
})
|
||||
)
|
||||
const nativeChatEnabled = settings?.experimentalNativeChat === true
|
||||
const typedChatPermissions = useChatSettingsTypedPermissions()
|
||||
const hostQueuesMessages = useStructuredAgentSessionHostQueuesMessages({ kind: 'local' })
|
||||
const isMac = isMacUserAgent()
|
||||
const isWindows = isWindowsUserAgent()
|
||||
@@ -173,6 +180,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
|
||||
isWebClient,
|
||||
nativeChatEnabled,
|
||||
hostQueuesMessages,
|
||||
typedChatPermissions,
|
||||
managedBrowserCreationEnabled,
|
||||
mobileEmulatorCreationEnabled,
|
||||
isDev: import.meta.env.DEV,
|
||||
@@ -192,6 +200,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
|
||||
isWebClient,
|
||||
nativeChatEnabled,
|
||||
hostQueuesMessages,
|
||||
typedChatPermissions,
|
||||
managedBrowserCreationEnabled,
|
||||
mobileEmulatorCreationEnabled,
|
||||
isLinearConnected,
|
||||
|
||||
@@ -256,7 +256,8 @@
|
||||
"antigravityNotSignedIn": "Not signed in. Chats need an Antigravity sign-in, separate from your agy terminal account.",
|
||||
"antigravityAccount": "Account",
|
||||
"antigravitySignInNeeded": "Chats need an Antigravity sign-in, separate from your agy terminal account."
|
||||
}
|
||||
},
|
||||
"permissionsDescriptionOwnAgents": "New chats of agents set to their own settings in Settings → Agents. Other agents start chats in their permission there."
|
||||
},
|
||||
"terminal": {
|
||||
"optionLayoutAlt": "ABC or U.S. — Option sends Alt/Esc sequences",
|
||||
@@ -479,7 +480,23 @@
|
||||
"customPermissions": "Custom arguments",
|
||||
"replaceWithPermissionMode": "Replace with {{mode}}",
|
||||
"expandAgentSettings": "Expand agent settings",
|
||||
"collapseAgentSettings": "Collapse agent settings"
|
||||
"collapseAgentSettings": "Collapse agent settings",
|
||||
"permissionArgsTerminalOnly": "These arguments may control {{agent}}’s terminal permissions, so Orca adds no flag. Chats use the Permissions choice above.",
|
||||
"localSaveFailed": "Agent settings could not be saved. Try again.",
|
||||
"localSaveFailedAgent": "{{agent}} settings could not be saved. Try again.",
|
||||
"permissionFromArgs": "{{agent}} set to {{mode}} in terminals and new chats.",
|
||||
"permissionFromArgsTerminals": "{{agent}} set to {{mode}} in terminals.",
|
||||
"permissionOwnAgent": "Use {{agent}}'s own settings",
|
||||
"permissionOwnAll": "Agents' own settings",
|
||||
"permissionOwnDescription": "Orca adds no permission flag in terminals; chats use Settings → Chat.",
|
||||
"permissionChoiceDescription": "Applies where each agent supports this mode. Change one agent below.",
|
||||
"permissionOwnShort": "Own settings",
|
||||
"permissionWithoutAuto": "Agents without Approve for me",
|
||||
"permissionSurfaceDifference": "Terminals use the agent’s own settings; new chats use {{mode}}.",
|
||||
"permissionSameAsAll": "Same as all agents",
|
||||
"permissionChipRemove": "Remove {{flag}} and use {{agent}}'s own settings",
|
||||
"permissionChipTooltip": "Added by Permissions. Remove it to use {{agent}}’s own settings.",
|
||||
"permissionResetsOwnChoices": "Choosing a mode here resets the agents marked as set separately."
|
||||
},
|
||||
"followSymlinkedDirectoriesKeywords": {
|
||||
"symlink": "symlink",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import { resolveTuiAgentLaunchArgs } from '../../../shared/tui-agent-launch-defaults'
|
||||
import {
|
||||
getWorkspaceRuntimePreference,
|
||||
resolveProjectExecutionRuntime,
|
||||
@@ -290,11 +291,20 @@ describe('buildPaneAccountRestartStartup for Claude', () => {
|
||||
availableWslDistros: ['Ubuntu-24.04']
|
||||
})
|
||||
expect(projectRuntimeContext).toMatchObject({ status: 'resolved', runtime: { kind: 'wsl' } })
|
||||
useAppStore.getState().registerAgentLaunchConfig(makePaneKey(TAB_ID, LEAF_ID), {
|
||||
agentArgs: resolveTuiAgentLaunchArgs('claude', {
|
||||
agentPermissionDefault: 'full-access',
|
||||
agentDefaultArgs: { claude: '--tools Read' }
|
||||
}),
|
||||
agentEnv: {}
|
||||
})
|
||||
|
||||
const startup = buildPaneAccountRestartStartup('claude', pane)
|
||||
|
||||
expect(startup?.command).toBe(`claude '--resume' '${SESSION_ID}'`)
|
||||
expect(startup?.resumeProviderSession?.id).toBe(SESSION_ID)
|
||||
expect(startup?.command).toBe(
|
||||
`claude '--dangerously-skip-permissions' '--tools' 'Read' '--resume' '${SESSION_ID}'`
|
||||
)
|
||||
expect(startup?.resumeProviderSession).toEqual({ key: 'session_id', id: SESSION_ID })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -96,13 +96,13 @@ export function resolveAgentAccountRestartResumeStartup(
|
||||
launchConfig !== undefined
|
||||
? launchConfig.agentArgs
|
||||
: unrecordedLaunchUsesDefaults
|
||||
? resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs)
|
||||
? resolveTuiAgentLaunchArgs(agent, state.settings)
|
||||
: null,
|
||||
agentEnv:
|
||||
launchConfig !== undefined
|
||||
? launchConfig.agentEnv
|
||||
: unrecordedLaunchUsesDefaults
|
||||
? resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv)
|
||||
? resolveTuiAgentLaunchEnv(agent, state.settings, resumeTarget.platform)
|
||||
: null,
|
||||
...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}),
|
||||
...(launchConfig?.ompResumeFilePath
|
||||
|
||||
@@ -9,7 +9,8 @@ import { buildAiVaultResumeCommand } from '../../../shared/ai-vault-resume-comma
|
||||
import { buildAgentStartupPlan } from '../../../shared/tui-agent-startup'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
resolveTuiAgentLaunchEnv,
|
||||
type AgentLaunchProfileSettings
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
@@ -25,9 +26,9 @@ export function buildAntigravityReferenceStartup(args: {
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
commandOverride?: string | null
|
||||
settings?: Partial<
|
||||
Pick<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv'>
|
||||
> | null
|
||||
settings?:
|
||||
| (Partial<Pick<GlobalSettings, 'agentCmdOverrides'>> & AgentLaunchProfileSettings)
|
||||
| null
|
||||
}): AiVaultResumeStartup | null {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'antigravity',
|
||||
@@ -39,8 +40,8 @@ export function buildAntigravityReferenceStartup(args: {
|
||||
...args.settings?.agentCmdOverrides,
|
||||
...(args.commandOverride?.trim() ? { antigravity: args.commandOverride } : {})
|
||||
},
|
||||
agentArgs: resolveTuiAgentLaunchArgs('antigravity', args.settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv('antigravity', args.settings?.agentDefaultEnv)
|
||||
agentArgs: resolveTuiAgentLaunchArgs('antigravity', args.settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv('antigravity', args.settings, args.platform)
|
||||
})
|
||||
if (!plan) {
|
||||
return null
|
||||
|
||||
@@ -147,10 +147,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
|
||||
}
|
||||
const { resolvedLaunchPlatform, isRemote, queuedShell } = executionContext
|
||||
const effectiveAgentArgs =
|
||||
agentArgs !== undefined
|
||||
? agentArgs
|
||||
: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs)
|
||||
const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv)
|
||||
agentArgs !== undefined ? agentArgs : resolveTuiAgentLaunchArgs(agent, store.settings)
|
||||
const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings, resolvedLaunchPlatform)
|
||||
const trimmedPrompt = prompt?.trim() ?? ''
|
||||
const hasPrompt = trimmedPrompt.length > 0
|
||||
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
|
||||
|
||||
@@ -94,8 +94,8 @@ export async function startWorkspaceAgent(
|
||||
autoRenameBranchFromWork: false,
|
||||
// As the composer's folder submit passes them.
|
||||
agentCmdOverrides: settings?.agentCmdOverrides,
|
||||
agentArgs: resolveTuiAgentLaunchArgs(producer.agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(producer.agent, settings?.agentDefaultEnv),
|
||||
agentArgs: resolveTuiAgentLaunchArgs(producer.agent, settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(producer.agent, settings),
|
||||
terminalWindowsShell: settings?.terminalWindowsShell,
|
||||
launchSource: 'new_workspace_composer',
|
||||
createFolderWorkspace: async () => folderWorkspace ?? null,
|
||||
|
||||
@@ -13,7 +13,8 @@ import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
resolveTuiAgentLaunchEnv,
|
||||
type AgentLaunchProfileSettings
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -23,11 +24,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
draftContent: string
|
||||
promptDelivery: 'draft' | 'submit-after-ready'
|
||||
settings:
|
||||
| {
|
||||
agentCmdOverrides?: Partial<Record<TuiAgent, string>>
|
||||
agentDefaultArgs?: Partial<Record<TuiAgent, string>>
|
||||
agentDefaultEnv?: Partial<Record<TuiAgent, Record<string, string>>>
|
||||
}
|
||||
| ({ agentCmdOverrides?: Partial<Record<TuiAgent, string>> } & AgentLaunchProfileSettings)
|
||||
| null
|
||||
| undefined
|
||||
launchPlatform: NodeJS.Platform
|
||||
@@ -47,9 +44,9 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
|
||||
|
||||
const effectiveAgentArgs =
|
||||
args.agentArgs === undefined
|
||||
? resolveTuiAgentLaunchArgs(args.agent, args.settings?.agentDefaultArgs)
|
||||
? resolveTuiAgentLaunchArgs(args.agent, args.settings)
|
||||
: args.agentArgs
|
||||
const effectiveAgentEnv = resolveTuiAgentLaunchEnv(args.agent, args.settings?.agentDefaultEnv)
|
||||
const effectiveAgentEnv = resolveTuiAgentLaunchEnv(args.agent, args.settings, args.launchPlatform)
|
||||
const draftLaunchPlan =
|
||||
args.promptDelivery === 'submit-after-ready'
|
||||
? null
|
||||
|
||||
@@ -48,8 +48,8 @@ export function buildOnboardingFolderAgentStartup(
|
||||
agent,
|
||||
prompt: '',
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings, host.platform),
|
||||
...host,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY } from '../../../shared/agent-launch-runtime-capability'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
|
||||
@@ -156,3 +157,11 @@ export function useStructuredAgentSessionHostEditsQueuedImages(
|
||||
AGENT_SESSION_QUEUED_MESSAGE_IMAGE_EDIT_RUNTIME_CAPABILITY
|
||||
)
|
||||
}
|
||||
|
||||
/** The API runtime owns Chat settings; on web the local API forwards to the active paired host. */
|
||||
export function useChatSettingsTypedPermissions(): boolean {
|
||||
return useStructuredAgentSessionHostCapability(
|
||||
{ kind: 'local' },
|
||||
AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,7 +125,8 @@ it.each(['paired-linux', 'local-bash', 'withheld', 'structured-withheld'] as con
|
||||
experimentalNativeChat: true,
|
||||
nativeChatProviders: { claude: target === 'structured-withheld' },
|
||||
terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
agentDefaultArgs: { claude: '--append-system-prompt "don\'t edit"' }
|
||||
agentDefaultArgs: { claude: '--append-system-prompt "don\'t edit"' },
|
||||
agentPermissionOverrides: { claude: 'own' }
|
||||
},
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[
|
||||
|
||||
@@ -18,10 +18,7 @@ import { normalizeOpenInApplications } from '../../../../shared/open-in-applicat
|
||||
import { createSettingsSearchState, type SettingsSearchState } from './settings-search-state'
|
||||
import { isRuntimeCatalogListingStale } from './runtime-status-hydration'
|
||||
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
|
||||
import {
|
||||
normalizeTuiAgentArgsRecord,
|
||||
normalizeTuiAgentEnvRecord
|
||||
} from '../../../../shared/tui-agent-launch-defaults'
|
||||
import { sanitizeAgentLaunchProfileUpdates } from '../../../../shared/tui-agent-launch-defaults'
|
||||
import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context'
|
||||
import { normalizeUiLanguage } from '../../../../shared/ui-language'
|
||||
import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy'
|
||||
@@ -106,14 +103,7 @@ function normalizeSettingsUpdates(
|
||||
if ('disabledTuiAgents' in updates) {
|
||||
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
|
||||
}
|
||||
if ('agentDefaultArgs' in updates) {
|
||||
sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs)
|
||||
sanitizedUpdates.agentYoloDefaultsMigrated = true
|
||||
}
|
||||
if ('agentDefaultEnv' in updates) {
|
||||
sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv)
|
||||
sanitizedUpdates.agentYoloDefaultsMigrated = true
|
||||
}
|
||||
sanitizeAgentLaunchProfileUpdates(sanitizedUpdates, updates)
|
||||
if ('uiLanguage' in updates) {
|
||||
sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { runtimeChatPermissionSetting } from './web-preference-normalization'
|
||||
import {
|
||||
migrateStoredWebSettings,
|
||||
runtimeChatPermissionSetting
|
||||
} from './web-preference-normalization'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { resolveAgentPermission } from '../../../../shared/tui-agent-permission-choice'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../../shared/tui-agent-launch-defaults'
|
||||
|
||||
it.each([
|
||||
['mixed', '--dangerously-skip-permissions --model sonnet', '--model sonnet', 'full-access'],
|
||||
['explicit empty', '', '', 'own'],
|
||||
[
|
||||
'Claude plan',
|
||||
'--permission-mode plan --model sonnet',
|
||||
'--permission-mode plan --model sonnet',
|
||||
'own'
|
||||
]
|
||||
] as const)(
|
||||
'migrates saved browser %s Arguments once while retaining Chat',
|
||||
(_name, args, raw, choice) => {
|
||||
// Recorded Goose process environment: ticket 1447, PID 20979, creation validated around capture.
|
||||
const recordedGooseEnv = { GOOSE_MODE: 'auto', QA_GOOSE_NOTE: 'ordinary70a4' }
|
||||
const stored: Partial<GlobalSettings> = {
|
||||
agentDefaultArgs: { claude: args, codex: '' },
|
||||
agentDefaultEnv: { goose: recordedGooseEnv },
|
||||
nativeChatPermissionMode: 'ask',
|
||||
agentNativeChatPermissionModes: { claude: 'auto', codex: 'ask' }
|
||||
}
|
||||
const loaded = migrateStoredWebSettings(stored)
|
||||
expect(loaded.agentPermissionDefault).toBeDefined()
|
||||
expect(resolveAgentPermission('claude', loaded).choice).toBe(choice)
|
||||
expect(resolveAgentPermission('codex', loaded).choice).toBe('own')
|
||||
expect(loaded.agentDefaultArgs?.claude).toBe(raw)
|
||||
expect(loaded.agentDefaultArgs?.codex).toBe('')
|
||||
expect(resolveTuiAgentLaunchArgs('claude', loaded)).toBe(args)
|
||||
expect(loaded.nativeChatPermissionMode).toBe('ask')
|
||||
expect(loaded.agentNativeChatPermissionModes).toEqual(stored.agentNativeChatPermissionModes)
|
||||
expect(resolveAgentPermission('goose', loaded).choice).toBe('full-access')
|
||||
expect(loaded.agentDefaultEnv?.goose).toEqual({ QA_GOOSE_NOTE: 'ordinary70a4' })
|
||||
expect(resolveTuiAgentLaunchEnv('goose', loaded)).toEqual(recordedGooseEnv)
|
||||
expect(migrateStoredWebSettings(JSON.parse(JSON.stringify(loaded)))).toEqual(loaded)
|
||||
}
|
||||
)
|
||||
|
||||
it('clears a previous host scoped default when reading settings from a pre-native-sync host', () => {
|
||||
const cached = {
|
||||
|
||||
@@ -22,11 +22,18 @@ import type { PersistedUIState } from '../../../../shared/persisted-ui-state-typ
|
||||
import { normalizeStatusBarUsageMode } from '../../../../shared/status-bar-usage-mode'
|
||||
import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes'
|
||||
import {
|
||||
DEFAULT_TUI_AGENT_ARGS,
|
||||
DEFAULT_TUI_AGENT_ENV,
|
||||
normalizeTuiAgentArgsRecord,
|
||||
normalizeTuiAgentEnvRecord
|
||||
} from '../../../../shared/tui-agent-launch-defaults'
|
||||
import { hasTypedAgentPermission } from '../../../../shared/tui-agent-permission-choice'
|
||||
import { migrateAgentPermissionChoice } from '../../../../shared/tui-agent-permission-lift'
|
||||
import { normalizeOsc52ClipboardDefaultOn } from '../../../../shared/osc52-clipboard-settings'
|
||||
import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings'
|
||||
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
|
||||
import { normalizeUiLanguage } from '../../../../shared/ui-language'
|
||||
import { migratePortugueseUiLanguageOnLoad } from '../../../../shared/portuguese-ui-language-migration'
|
||||
import { normalizeUsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
|
||||
import { mergeWorkspaceCleanupUIState } from '../../../../shared/workspace-cleanup-ui-state'
|
||||
|
||||
@@ -143,6 +150,34 @@ export function mergeOsc52ClipboardNoticePending(
|
||||
)
|
||||
}
|
||||
|
||||
/** A browser-saved settings blob with each load-time migration applied. */
|
||||
export function migrateStoredWebSettings(stored: Partial<GlobalSettings>): Partial<GlobalSettings> {
|
||||
return {
|
||||
...stored,
|
||||
...migrateBrowserSavedAgentPermission(stored),
|
||||
...normalizeAutoRenameBranchFromWorkDefaultOn(stored),
|
||||
...normalizeTerminalCursorStyleDefault(stored),
|
||||
...normalizeOsc52ClipboardDefaultOn(stored),
|
||||
terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes),
|
||||
...migratePortugueseUiLanguageOnLoad(stored)
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser-saved Arguments from before the stored permission carry their flags; moved so the
|
||||
* stored default can't add a second one. A missing key launched with the shipped default. */
|
||||
function migrateBrowserSavedAgentPermission(stored: Partial<GlobalSettings>) {
|
||||
if (hasTypedAgentPermission(stored) || (!stored.agentDefaultArgs && !stored.agentDefaultEnv)) {
|
||||
return {}
|
||||
}
|
||||
return migrateAgentPermissionChoice(
|
||||
{
|
||||
agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS, ...stored.agentDefaultArgs },
|
||||
agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV, ...stored.agentDefaultEnv }
|
||||
},
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
export function mergeSettings(
|
||||
base: GlobalSettings,
|
||||
updates: Partial<GlobalSettings>,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PreloadApi } from '../../../../preload/api-types'
|
||||
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings'
|
||||
import {
|
||||
getDefaultSettings,
|
||||
getDefaultUIState,
|
||||
@@ -7,22 +6,17 @@ import {
|
||||
} from '../../../../shared/constants'
|
||||
import { normalizeWorktreeVisibilityDefaults } from '../../../../shared/external-worktree-visibility'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import {
|
||||
normalizeOsc52ClipboardDefaultOn,
|
||||
osc52ClipboardDefaultOnOverridesPersistedOff
|
||||
} from '../../../../shared/osc52-clipboard-settings'
|
||||
import { osc52ClipboardDefaultOnOverridesPersistedOff } from '../../../../shared/osc52-clipboard-settings'
|
||||
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
|
||||
import {
|
||||
applyPRBotAuthorOverride,
|
||||
normalizePRBotAuthorOverrides
|
||||
} from '../../../../shared/pr-bot-author-overrides'
|
||||
import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings'
|
||||
import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes'
|
||||
import { migratePortugueseUiLanguageOnLoad } from '../../../../shared/portuguese-ui-language-migration'
|
||||
import { readStoredWebRuntimeEnvironment } from '../web-runtime-environment'
|
||||
import {
|
||||
mergeSettings,
|
||||
mergeWebUIState,
|
||||
migrateStoredWebSettings,
|
||||
runtimeChatPermissionSetting
|
||||
} from './web-preference-normalization'
|
||||
import { callRuntimeResult } from './web-runtime-calls'
|
||||
@@ -37,14 +31,7 @@ export function getStoredSettings(): GlobalSettings {
|
||||
const defaults = getDefaultSettings('~')
|
||||
const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY)
|
||||
const stored = readJson<Partial<GlobalSettings>>(SETTINGS_STORAGE_KEY, {})
|
||||
const migratedStored = {
|
||||
...stored,
|
||||
...normalizeAutoRenameBranchFromWorkDefaultOn(stored),
|
||||
...normalizeTerminalCursorStyleDefault(stored),
|
||||
...normalizeOsc52ClipboardDefaultOn(stored),
|
||||
terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes),
|
||||
...migratePortugueseUiLanguageOnLoad(stored)
|
||||
}
|
||||
const migratedStored = migrateStoredWebSettings(stored)
|
||||
if (
|
||||
rawStoredSettings &&
|
||||
(stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork ||
|
||||
|
||||
@@ -43,7 +43,9 @@ export function legacyHostChatPermissionMode(
|
||||
typeof configured === 'object' && configured !== null && agent in configured
|
||||
? Object.getOwnPropertyDescriptor(configured, agent)?.value
|
||||
: undefined
|
||||
const args = resolveTuiAgentLaunchArgs(agent, typeof raw === 'string' ? { [agent]: raw } : {})
|
||||
const args = resolveTuiAgentLaunchArgs(agent, {
|
||||
agentDefaultArgs: typeof raw === 'string' ? { [agent]: raw } : {}
|
||||
})
|
||||
return HOST_STARTUP_SHELLS.some((shell) => tuiAgentArgsBypassPermissions(agent, args, shell))
|
||||
? 'full-access'
|
||||
: 'ask'
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
resolveTuiAgentLaunchArgs
|
||||
} from './tui-agent-launch-defaults'
|
||||
import { AUTO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions'
|
||||
import {
|
||||
hasTypedAgentPermission,
|
||||
resolveAgentPermission,
|
||||
type AgentPermissionSettings
|
||||
} from './tui-agent-permission-choice'
|
||||
import { isTuiAgent } from './tui-agent-config'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
|
||||
/** A chat's own permission choice. Saved in chat records and settings: never rename a value. */
|
||||
@@ -35,46 +41,14 @@ export function isAgentChatPermissionMode(value: unknown): value is AgentChatPer
|
||||
return AGENT_CHAT_PERMISSION_MODES.some((mode) => mode === value)
|
||||
}
|
||||
|
||||
/** The modes one agent's chats offer. */
|
||||
export type AgentChatPermissionModeSupport = {
|
||||
modes: readonly AgentChatPermissionMode[]
|
||||
/** `auto` runs only on a model the agent's listing says supports it. */
|
||||
autoNeedsModelSupport?: true
|
||||
}
|
||||
|
||||
const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = {
|
||||
modes: AGENT_CHAT_PERMISSION_MODES,
|
||||
autoNeedsModelSupport: true
|
||||
}
|
||||
|
||||
const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = {
|
||||
modes: ['ask', 'auto', 'full-access']
|
||||
}
|
||||
|
||||
// ACP chats: Orca answers the agent's permission requests yes under Full access, else the person does.
|
||||
const ACP_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = {
|
||||
modes: ['ask', 'full-access']
|
||||
}
|
||||
|
||||
// No Auto: the server has no reviewer that answers approvals for the person.
|
||||
const ANTIGRAVITY_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = {
|
||||
modes: ['ask', 'accept-edits', 'full-access']
|
||||
}
|
||||
|
||||
const AGENT_CHAT_PERMISSION_MODES_BY_AGENT = new Map<string, AgentChatPermissionModeSupport>([
|
||||
['claude', CLAUDE_CHAT_PERMISSION_MODES],
|
||||
['codex', CODEX_CHAT_PERMISSION_MODES],
|
||||
['grok', ACP_CHAT_PERMISSION_MODES],
|
||||
['opencode', ACP_CHAT_PERMISSION_MODES],
|
||||
['opencode2', ACP_CHAT_PERMISSION_MODES],
|
||||
['omp', ACP_CHAT_PERMISSION_MODES],
|
||||
['antigravity', ANTIGRAVITY_CHAT_PERMISSION_MODES]
|
||||
])
|
||||
|
||||
/** The one table of which agents' chats offer which modes; null keeps following Agent Permissions. */
|
||||
export function agentChatPermissionModes(agent: string): AgentChatPermissionModeSupport | null {
|
||||
return AGENT_CHAT_PERMISSION_MODES_BY_AGENT.get(agent) ?? null
|
||||
}
|
||||
import {
|
||||
agentChatPermissionModes,
|
||||
type AgentChatPermissionModeSupport
|
||||
} from './agent-chat-permission-support'
|
||||
export {
|
||||
agentChatPermissionModes,
|
||||
type AgentChatPermissionModeSupport
|
||||
} from './agent-chat-permission-support'
|
||||
|
||||
/** The saved choice if this agent offers it; anything else asks, never grants more access. */
|
||||
export function savedChatPermissionMode(
|
||||
@@ -136,14 +110,25 @@ export function newChatPermissionMode(
|
||||
return support ? savedChatPermissionMode(support, setting) : null
|
||||
}
|
||||
|
||||
/** A scoped agent choice takes precedence over the shared new-chat default. */
|
||||
export type NewChatPermissionSettings = AgentPermissionSettings &
|
||||
Partial<Pick<GlobalSettings, 'nativeChatPermissionMode' | 'agentNativeChatPermissionModes'>>
|
||||
|
||||
/**
|
||||
* An agent's stored permission starts its new chats, as it starts its terminals. On its own
|
||||
* settings, chats keep the Chat page default (or its per-agent copy) until chats can run the
|
||||
* agent's own config.
|
||||
*/
|
||||
export function newChatPermissionModeForSettings(
|
||||
agent: string,
|
||||
settings:
|
||||
| Partial<Pick<GlobalSettings, 'nativeChatPermissionMode' | 'agentNativeChatPermissionModes'>>
|
||||
| null
|
||||
| undefined
|
||||
settings: NewChatPermissionSettings | null | undefined
|
||||
): AgentChatPermissionMode | null {
|
||||
const support = agentChatPermissionModes(agent)
|
||||
if (support && isTuiAgent(agent) && hasTypedAgentPermission(settings)) {
|
||||
const { choice } = resolveAgentPermission(agent, settings)
|
||||
if (choice !== 'own') {
|
||||
return savedChatPermissionMode(support, choice)
|
||||
}
|
||||
}
|
||||
const overrides = settings?.agentNativeChatPermissionModes
|
||||
const override = overrides && Object.getOwnPropertyDescriptor(overrides, agent)?.value
|
||||
return newChatPermissionMode(agent, override ?? settings?.nativeChatPermissionMode)
|
||||
@@ -172,8 +157,7 @@ export function loadedChatPermissionSetting(
|
||||
return isAgentChatPermissionMode(saved) ? saved : 'ask'
|
||||
}
|
||||
const automatic = (agent: 'claude' | 'codex'): boolean =>
|
||||
resolveTuiAgentLaunchArgs(agent, terminal.agentDefaultArgs).trim() ===
|
||||
AUTO_TUI_AGENT_ARGS[agent]
|
||||
resolveTuiAgentLaunchArgs(agent, terminal).trim() === AUTO_TUI_AGENT_ARGS[agent]
|
||||
if (automatic('claude') && automatic('codex')) {
|
||||
return 'auto'
|
||||
}
|
||||
@@ -188,9 +172,10 @@ export function loadedChatPermissionSetting(
|
||||
* the new-chat setting. */
|
||||
export function preModeChatGetsFullAccess(
|
||||
agent: TuiAgent,
|
||||
settings: Partial<
|
||||
Pick<GlobalSettings, 'agentDefaultArgs' | 'terminalWindowsShell' | 'nativeChatPermissionMode'>
|
||||
>,
|
||||
settings: AgentPermissionSettings &
|
||||
Partial<
|
||||
Pick<GlobalSettings, 'agentDefaultArgs' | 'terminalWindowsShell' | 'nativeChatPermissionMode'>
|
||||
>,
|
||||
platform: NodeJS.Platform
|
||||
): boolean {
|
||||
return agent in YOLO_TUI_AGENT_ARGS
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AgentChatPermissionMode } from './agent-chat-permission-mode'
|
||||
/** The modes one agent's chats offer. */
|
||||
export type AgentChatPermissionModeSupport = {
|
||||
modes: readonly AgentChatPermissionMode[]
|
||||
/** `auto` runs only on a model the agent's listing says supports it. */
|
||||
autoNeedsModelSupport?: true
|
||||
}
|
||||
|
||||
const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = {
|
||||
modes: ['ask', 'accept-edits', 'auto', 'full-access'],
|
||||
autoNeedsModelSupport: true
|
||||
}
|
||||
|
||||
const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = {
|
||||
modes: ['ask', 'auto', 'full-access']
|
||||
}
|
||||
|
||||
// ACP chats: Orca answers the agent's permission requests yes under Full access, else the person does.
|
||||
const ACP_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = {
|
||||
modes: ['ask', 'full-access']
|
||||
}
|
||||
|
||||
// No Auto: the server has no reviewer that answers approvals for the person.
|
||||
const ANTIGRAVITY_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = {
|
||||
modes: ['ask', 'accept-edits', 'full-access']
|
||||
}
|
||||
|
||||
const AGENT_CHAT_PERMISSION_MODES_BY_AGENT = new Map<string, AgentChatPermissionModeSupport>([
|
||||
['claude', CLAUDE_CHAT_PERMISSION_MODES],
|
||||
['codex', CODEX_CHAT_PERMISSION_MODES],
|
||||
['grok', ACP_CHAT_PERMISSION_MODES],
|
||||
['opencode', ACP_CHAT_PERMISSION_MODES],
|
||||
['opencode2', ACP_CHAT_PERMISSION_MODES],
|
||||
['omp', ACP_CHAT_PERMISSION_MODES],
|
||||
['antigravity', ANTIGRAVITY_CHAT_PERMISSION_MODES]
|
||||
])
|
||||
|
||||
/** The one table of which agents' chats offer which modes; null keeps following Agent Permissions. */
|
||||
export function agentChatPermissionModes(agent: string): AgentChatPermissionModeSupport | null {
|
||||
return AGENT_CHAT_PERMISSION_MODES_BY_AGENT.get(agent) ?? null
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { agentPermissionHostPlatform, permissionEnvNameMatches } from './agent-permission-env'
|
||||
import type {
|
||||
AgentLaunchSettingsMutation,
|
||||
AgentLaunchSettingsSource
|
||||
} from './agent-launch-settings'
|
||||
import { resolveTuiAgentLaunchEnv } from './tui-agent-launch-defaults'
|
||||
import {
|
||||
agentPermissionEnv,
|
||||
applyAgentPermissionOverrides,
|
||||
hasTypedAgentPermission,
|
||||
resolveAgentPermission,
|
||||
type AgentPermissionChoice
|
||||
} from './tui-agent-permission-choice'
|
||||
import { liftAgentPermissionEnv } from './tui-agent-permission-lift'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
|
||||
/** An agent's choice changed only when it differs from what it resolves to now. */
|
||||
export function choiceUpdate(
|
||||
settings: AgentLaunchSettingsSource,
|
||||
agent: TuiAgent,
|
||||
choice: AgentPermissionChoice
|
||||
): AgentLaunchSettingsSource {
|
||||
return resolveAgentPermission(agent, settings).choice === choice
|
||||
? {}
|
||||
: applyAgentPermissionOverrides(settings, [agent], choice)
|
||||
}
|
||||
|
||||
function hasEnvPermission(agent: TuiAgent): boolean {
|
||||
return Object.keys(agentPermissionEnv(agent, 'full-access')).length > 0
|
||||
}
|
||||
|
||||
/** A whole env record as its terminal gets it; for Goose, the bypass entry is the choice. */
|
||||
export function legacyEnvUpdates(
|
||||
settings: AgentLaunchSettingsSource,
|
||||
agent: TuiAgent,
|
||||
env: Record<string, string>,
|
||||
platform: NodeJS.Platform | undefined = agentPermissionHostPlatform()
|
||||
): AgentLaunchSettingsSource {
|
||||
if (!hasEnvPermission(agent)) {
|
||||
return { agentDefaultEnv: { ...settings.agentDefaultEnv, [agent]: env } }
|
||||
}
|
||||
const previous = resolveTuiAgentLaunchEnv(agent, settings, platform)
|
||||
const names = Object.keys(agentPermissionEnv(agent, 'full-access'))
|
||||
const carrier = (value: Record<string, string>) =>
|
||||
Object.entries(value)
|
||||
.filter(([key]) => names.some((name) => permissionEnvNameMatches(key, name, platform)))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
if (JSON.stringify(carrier(previous)) === JSON.stringify(carrier(env))) {
|
||||
const user = { ...env }
|
||||
for (const [key] of carrier(env)) {
|
||||
delete user[key]
|
||||
}
|
||||
return {
|
||||
agentDefaultEnv: {
|
||||
...settings.agentDefaultEnv,
|
||||
[agent]: {
|
||||
...user,
|
||||
...Object.fromEntries(carrier(settings.agentDefaultEnv?.[agent] ?? {}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const lifted = liftAgentPermissionEnv(agent, env, platform)
|
||||
return {
|
||||
agentDefaultEnv: { ...settings.agentDefaultEnv, [agent]: lifted.env },
|
||||
...choiceUpdate(settings, agent, lifted.choice ?? 'own')
|
||||
}
|
||||
}
|
||||
|
||||
export function agentEnvironmentMutationUpdates(
|
||||
settings: AgentLaunchSettingsSource,
|
||||
mutation: Extract<
|
||||
AgentLaunchSettingsMutation,
|
||||
{ type: 'environment-set' | 'environment-remove' }
|
||||
>,
|
||||
platform: NodeJS.Platform
|
||||
): AgentLaunchSettingsSource {
|
||||
// Edits the env the terminal gets, so removing Goose's published bypass entry means own.
|
||||
const env = { ...resolveTuiAgentLaunchEnv(mutation.agent, settings, platform) }
|
||||
// Windows environment names are case insensitive on the execution host.
|
||||
for (const name of Object.keys(env)) {
|
||||
if (
|
||||
name === mutation.name ||
|
||||
(platform === 'win32' && name.toLowerCase() === mutation.name.toLowerCase())
|
||||
) {
|
||||
delete env[name]
|
||||
}
|
||||
}
|
||||
const nextEnv =
|
||||
mutation.type === 'environment-set' ? { ...env, [mutation.name]: mutation.value } : env
|
||||
if (hasTypedAgentPermission(settings)) {
|
||||
return legacyEnvUpdates(settings, mutation.agent, nextEnv, platform)
|
||||
}
|
||||
return { agentDefaultEnv: { ...settings.agentDefaultEnv, [mutation.agent]: nextEnv } }
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { agentPermissionHostPlatform, permissionEnvNameMatches } from './agent-permission-env'
|
||||
import {
|
||||
agentEnvironmentMutationUpdates,
|
||||
choiceUpdate,
|
||||
legacyEnvUpdates
|
||||
} from './agent-launch-environment-updates'
|
||||
import type {
|
||||
AgentLaunchSettingsMutation,
|
||||
AgentLaunchSettingsSource
|
||||
} from './agent-launch-settings'
|
||||
import {
|
||||
composeAgentLaunchProfile,
|
||||
normalizeTuiAgentArgsRecord,
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from './tui-agent-launch-defaults'
|
||||
import {
|
||||
agentPermissionArgs,
|
||||
agentPermissionChoices,
|
||||
agentPermissionEnv,
|
||||
applyAgentPermissionOverrides,
|
||||
applyAllAgentsPermission,
|
||||
hasTypedAgentPermission,
|
||||
PERMISSION_CHOICE_AGENTS,
|
||||
resolveAgentPermission,
|
||||
type AgentPermissionChoice
|
||||
} from './tui-agent-permission-choice'
|
||||
import { liftAgentPermissionArgs } from './tui-agent-permission-lift'
|
||||
import { isTuiAgent } from './tui-agent-config'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
|
||||
// Agent launch settings writes against the stored permission, including the shapes older clients
|
||||
// send, which treat the permission flag as part of the Arguments text.
|
||||
|
||||
export function permissionUpdates(
|
||||
settings: AgentLaunchSettingsSource,
|
||||
mutation: Extract<AgentLaunchSettingsMutation, { type: 'permission' }>
|
||||
): AgentLaunchSettingsSource {
|
||||
const { agent, choice } = mutation
|
||||
if (agent) {
|
||||
if (choice !== 'default' && !agentPermissionChoices(agent).includes(choice)) {
|
||||
throw new Error('This agent does not offer that permission')
|
||||
}
|
||||
return applyAgentPermissionOverrides(settings, [agent], choice)
|
||||
}
|
||||
if (choice === 'default') {
|
||||
throw new Error('Choose a permission for all agents')
|
||||
}
|
||||
if (mutation.withoutAuto) {
|
||||
const agents = PERMISSION_CHOICE_AGENTS.filter(
|
||||
(candidate) => !agentPermissionChoices(candidate).includes('auto')
|
||||
)
|
||||
return applyAgentPermissionOverrides(settings, agents, choice)
|
||||
}
|
||||
return applyAllAgentsPermission(settings, choice)
|
||||
}
|
||||
|
||||
/** An older client's Auto/Yolo/Manual switch, as the choice it means. Manual passed no flag. */
|
||||
export function legacyPermissionsUpdates(
|
||||
settings: AgentLaunchSettingsSource,
|
||||
mutation: Extract<AgentLaunchSettingsMutation, { type: 'permissions' }>
|
||||
): AgentLaunchSettingsSource {
|
||||
const choice: AgentPermissionChoice =
|
||||
mutation.mode === 'yolo' ? 'full-access' : mutation.mode === 'auto' ? 'auto' : 'own'
|
||||
if (mutation.agent) {
|
||||
return applyAgentPermissionOverrides(settings, [mutation.agent], choice)
|
||||
}
|
||||
if (mutation.exceptAuto) {
|
||||
return permissionUpdates(settings, { type: 'permission', choice, withoutAuto: true })
|
||||
}
|
||||
return applyAllAgentsPermission(settings, choice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Arguments text from a client that sees the flag as part of it. Unchanged text changes nothing;
|
||||
* otherwise Orca's flag in it becomes the choice, and text without one means the agent's own.
|
||||
*/
|
||||
export function legacyArgumentsUpdates(
|
||||
settings: AgentLaunchSettingsSource,
|
||||
agent: TuiAgent,
|
||||
value: string
|
||||
): AgentLaunchSettingsSource {
|
||||
if (value.trim() === resolveTuiAgentLaunchArgs(agent, settings).trim()) {
|
||||
return {}
|
||||
}
|
||||
const lifted = liftAgentPermissionArgs(agent, value)
|
||||
return {
|
||||
agentDefaultArgs: normalizeTuiAgentArgsRecord({
|
||||
...settings.agentDefaultArgs,
|
||||
[agent]: lifted.args
|
||||
}),
|
||||
...(lifted.choice
|
||||
? choiceUpdate(settings, agent, lifted.choice)
|
||||
: agentPermissionArgs(agent, resolveAgentPermission(agent, settings).choice) &&
|
||||
resolveTuiAgentLaunchArgs(agent, settings).trim() !==
|
||||
(settings.agentDefaultArgs?.[agent]?.trim() ?? '')
|
||||
? choiceUpdate(settings, agent, 'own')
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed-store updates for a client that writes whole Arguments or env records (an older paired
|
||||
* client's settings update): each changed agent is read as `legacyArgumentsUpdates` does.
|
||||
*/
|
||||
export function legacyLaunchTextUpdates(
|
||||
settings: AgentLaunchSettingsSource,
|
||||
updates: Pick<AgentLaunchSettingsSource, 'agentDefaultArgs' | 'agentDefaultEnv'>,
|
||||
platform: NodeJS.Platform | undefined = agentPermissionHostPlatform()
|
||||
): AgentLaunchSettingsSource {
|
||||
if (!hasTypedAgentPermission(settings)) {
|
||||
return updates
|
||||
}
|
||||
const published = composeAgentLaunchProfile(settings, platform)
|
||||
const args: Partial<Record<TuiAgent, string>> = {}
|
||||
const envs: Partial<Record<TuiAgent, Record<string, string>>> = {}
|
||||
let overrides = settings.agentPermissionOverrides
|
||||
if (updates.agentDefaultArgs !== undefined) {
|
||||
for (const agent of new Set([
|
||||
...Object.keys(published.agentDefaultArgs),
|
||||
...Object.keys(updates.agentDefaultArgs)
|
||||
])) {
|
||||
if (!isTuiAgent(agent)) {
|
||||
continue
|
||||
}
|
||||
const change = legacyArgumentsUpdates(settings, agent, updates.agentDefaultArgs[agent] ?? '')
|
||||
if (Object.hasOwn(updates.agentDefaultArgs, agent)) {
|
||||
args[agent] = change.agentDefaultArgs?.[agent] ?? settings.agentDefaultArgs?.[agent] ?? ''
|
||||
}
|
||||
if (change.agentPermissionOverrides?.[agent]) {
|
||||
overrides = { ...overrides, [agent]: change.agentPermissionOverrides[agent] }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updates.agentDefaultEnv !== undefined) {
|
||||
for (const agent of new Set([
|
||||
...Object.keys(published.agentDefaultEnv),
|
||||
...Object.keys(updates.agentDefaultEnv)
|
||||
])) {
|
||||
if (!isTuiAgent(agent)) {
|
||||
continue
|
||||
}
|
||||
const change = legacyEnvUpdates(
|
||||
settings,
|
||||
agent,
|
||||
updates.agentDefaultEnv[agent] ?? {},
|
||||
platform
|
||||
)
|
||||
if (Object.hasOwn(updates.agentDefaultEnv, agent)) {
|
||||
envs[agent] = change.agentDefaultEnv?.[agent] ?? settings.agentDefaultEnv?.[agent] ?? {}
|
||||
}
|
||||
if (change.agentPermissionOverrides?.[agent]) {
|
||||
overrides = { ...overrides, [agent]: change.agentPermissionOverrides[agent] }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(updates.agentDefaultArgs !== undefined ? { agentDefaultArgs: args } : {}),
|
||||
...(updates.agentDefaultEnv !== undefined ? { agentDefaultEnv: envs } : {}),
|
||||
agentPermissionOverrides: overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function wholeAgentEnvironmentUpdates(
|
||||
settings: AgentLaunchSettingsSource,
|
||||
agent: TuiAgent,
|
||||
requested: Record<string, string>,
|
||||
platform: NodeJS.Platform
|
||||
): AgentLaunchSettingsSource {
|
||||
const carrierNames = hasTypedAgentPermission(settings)
|
||||
? Object.keys(agentPermissionEnv(agent, 'full-access'))
|
||||
: []
|
||||
const isCarrier = (name: string) =>
|
||||
carrierNames.some((carrier) => permissionEnvNameMatches(name, carrier, platform))
|
||||
const ordinary = new Map<string, string>()
|
||||
const groups = new Map<string, Set<string>>()
|
||||
const identity = (name: string) => (platform === 'win32' ? name.toLowerCase() : name)
|
||||
const carrierEntries: [string, string][] = []
|
||||
const previous = resolveTuiAgentLaunchEnv(agent, settings, platform, (name, value) => {
|
||||
if (isCarrier(name)) {
|
||||
carrierEntries.push([name, value])
|
||||
return true
|
||||
}
|
||||
ordinary.set(name, value)
|
||||
const key = identity(name)
|
||||
groups.set(key, (groups.get(key) ?? new Set<string>()).add(name))
|
||||
return true
|
||||
})
|
||||
const carrier: AgentLaunchSettingsSource = {
|
||||
agentPermissionDefault: settings.agentPermissionDefault,
|
||||
agentPermissionOverrides: settings.agentPermissionOverrides?.[agent]
|
||||
? { [agent]: settings.agentPermissionOverrides[agent] }
|
||||
: {},
|
||||
agentDefaultEnv: { [agent]: Object.fromEntries(carrierEntries) }
|
||||
}
|
||||
let changed = false
|
||||
const change = (name: string, present: boolean): void => {
|
||||
changed = true
|
||||
if (isCarrier(name)) {
|
||||
const mutation: AgentLaunchSettingsMutation = present
|
||||
? { type: 'environment-set', agent, name, value: requested[name] }
|
||||
: { type: 'environment-remove', agent, name }
|
||||
Object.assign(carrier, agentEnvironmentMutationUpdates(carrier, mutation, platform))
|
||||
return
|
||||
}
|
||||
const key = identity(name)
|
||||
const group = groups.get(key) ?? new Set<string>()
|
||||
for (const spelling of group) {
|
||||
ordinary.delete(spelling)
|
||||
}
|
||||
if (present) {
|
||||
ordinary.set(name, requested[name])
|
||||
if (group.size !== 1 || !group.has(name)) {
|
||||
groups.set(key, new Set<string>().add(name))
|
||||
}
|
||||
} else {
|
||||
groups.delete(key)
|
||||
}
|
||||
}
|
||||
for (const name in previous) {
|
||||
if (Object.hasOwn(previous, name) && previous[name] !== requested[name]) {
|
||||
change(name, Object.hasOwn(requested, name))
|
||||
}
|
||||
}
|
||||
for (const name in requested) {
|
||||
if (Object.hasOwn(requested, name) && !Object.hasOwn(previous, name)) {
|
||||
change(name, true)
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return {}
|
||||
}
|
||||
Object.entries(carrier.agentDefaultEnv?.[agent] ?? {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.forEach(([name, value]) => ordinary.set(name, value))
|
||||
const override = carrier.agentPermissionOverrides?.[agent]
|
||||
return {
|
||||
agentDefaultEnv: { ...settings.agentDefaultEnv, [agent]: Object.fromEntries(ordinary) },
|
||||
...(override !== settings.agentPermissionOverrides?.[agent]
|
||||
? applyAgentPermissionOverrides(settings, [agent], override ?? 'default')
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ export const AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY =
|
||||
export const AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY =
|
||||
'settings.agent-launch.native-chat-permissions.v1' as const
|
||||
|
||||
// Host stores each agent's permission apart from its Arguments: accepts `permission` and
|
||||
// `user-arguments` and publishes `agentPermission`. Older hosts keep the permission in the text.
|
||||
export const AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY = 'settings.agent-permission.v1' as const
|
||||
|
||||
// Host-advertised: reads `surface`, the user's chat or terminal pick for one launch, and refuses an
|
||||
// explicit chat it cannot open before creating anything. Older hosts strip the field.
|
||||
export const AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY = 'agent.launch.surface.v1' as const
|
||||
@@ -77,6 +81,7 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [
|
||||
AGENT_LAUNCH_SETTINGS_CAPABILITY,
|
||||
AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY,
|
||||
AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY,
|
||||
AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY,
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentEnvironmentMutationUpdates, choiceUpdate } from './agent-launch-environment-updates'
|
||||
import type { GlobalSettings } from './global-settings-types'
|
||||
import {
|
||||
agentChatPermissionModes,
|
||||
@@ -11,10 +12,18 @@ import {
|
||||
normalizeDisabledTuiAgents
|
||||
} from './tui-agent-selection'
|
||||
import {
|
||||
composeAgentLaunchProfile,
|
||||
normalizeTuiAgentArgsRecord,
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from './tui-agent-launch-defaults'
|
||||
import { hasTypedAgentPermission, type AgentPermissionChoice } from './tui-agent-permission-choice'
|
||||
import { liftAgentPermissionArgs } from './tui-agent-permission-lift'
|
||||
import {
|
||||
legacyArgumentsUpdates,
|
||||
legacyPermissionsUpdates,
|
||||
permissionUpdates
|
||||
} from './agent-launch-permission-updates'
|
||||
import {
|
||||
applyAgentPermissionMode,
|
||||
AUTO_TUI_AGENT_ARGS,
|
||||
@@ -35,6 +44,8 @@ export type AgentLaunchSettingsSource = Partial<
|
||||
| 'agentCmdOverrides'
|
||||
| 'agentDefaultArgs'
|
||||
| 'agentDefaultEnv'
|
||||
| 'agentPermissionDefault'
|
||||
| 'agentPermissionOverrides'
|
||||
| 'nativeChatPermissionMode'
|
||||
| 'agentNativeChatPermissionModes'
|
||||
>
|
||||
@@ -51,6 +62,15 @@ export type AgentLaunchSettings = {
|
||||
autoPermissionMode?: true
|
||||
/** With Auto on: the combined mode of agents Auto doesn't cover. Absent from older hosts. */
|
||||
nonAutoPermissionMode?: 'manual' | 'yolo' | 'mixed'
|
||||
/**
|
||||
* The stored permission and each agent's own Arguments without Orca's flag. `agentDefaultArgs`
|
||||
* keeps the flag for older clients. Absent from hosts that store the permission as text.
|
||||
*/
|
||||
agentPermission?: {
|
||||
default: AgentPermissionChoice
|
||||
overrides: Partial<Record<TuiAgent, AgentPermissionChoice>>
|
||||
userArgs: Partial<Record<TuiAgent, string>>
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentLaunchSettingsMutation =
|
||||
@@ -64,23 +84,35 @@ export type AgentLaunchSettingsMutation =
|
||||
exceptAuto?: true
|
||||
syncNativeChat?: true
|
||||
}
|
||||
/** Hosts with the stored permission. No agent: all agents; `withoutAuto`: agents without Auto. */
|
||||
| {
|
||||
type: 'permission'
|
||||
choice: AgentPermissionChoice | 'default'
|
||||
agent?: TuiAgent
|
||||
withoutAuto?: true
|
||||
}
|
||||
| { type: 'command'; agent: TuiAgent; value: string }
|
||||
/** The whole terminal text, flag included, as older clients see it. */
|
||||
| { type: 'arguments'; agent: TuiAgent; value: string }
|
||||
/** Only the user's own text; a permission flag Orca recognizes in it becomes the choice. */
|
||||
| { type: 'user-arguments'; agent: TuiAgent; value: string }
|
||||
| { type: 'environment-set'; agent: TuiAgent; name: string; value: string }
|
||||
| { type: 'environment-remove'; agent: TuiAgent; name: string }
|
||||
|
||||
export function projectAgentLaunchSettings(
|
||||
settings: AgentLaunchSettingsSource
|
||||
settings: AgentLaunchSettingsSource,
|
||||
platform?: NodeJS.Platform
|
||||
): AgentLaunchSettings {
|
||||
const permissionMode = resolveAgentPermissionModeSummary(settings)
|
||||
const composed = composeAgentLaunchProfile(settings, platform)
|
||||
const permissionMode = resolveAgentPermissionModeSummary(composed)
|
||||
return {
|
||||
defaultTuiAgent: settings.defaultTuiAgent ?? null,
|
||||
newWorkspaceSetupOnly: false,
|
||||
disabledTuiAgents: normalizeDisabledTuiAgents(settings.disabledTuiAgents),
|
||||
agentCmdOverrides: { ...settings.agentCmdOverrides },
|
||||
agentDefaultArgs: normalizeTuiAgentArgsRecord(settings.agentDefaultArgs),
|
||||
agentDefaultArgs: normalizeTuiAgentArgsRecord(composed.agentDefaultArgs),
|
||||
environmentNames: Object.fromEntries(
|
||||
Object.entries(settings.agentDefaultEnv ?? {}).map(([agent, env]) => [
|
||||
Object.entries(composed.agentDefaultEnv).map(([agent, env]) => [
|
||||
agent,
|
||||
Object.keys(env ?? {}).sort()
|
||||
])
|
||||
@@ -90,7 +122,16 @@ export function projectAgentLaunchSettings(
|
||||
...(permissionMode === 'auto'
|
||||
? {
|
||||
autoPermissionMode: true,
|
||||
nonAutoPermissionMode: resolveNonAutoAgentPermissionMode(settings)
|
||||
nonAutoPermissionMode: resolveNonAutoAgentPermissionMode(composed)
|
||||
}
|
||||
: {}),
|
||||
...(hasTypedAgentPermission(settings)
|
||||
? {
|
||||
agentPermission: {
|
||||
default: settings.agentPermissionDefault ?? 'own',
|
||||
overrides: { ...settings.agentPermissionOverrides },
|
||||
userArgs: normalizeTuiAgentArgsRecord(settings.agentDefaultArgs)
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
@@ -110,8 +151,8 @@ function nativeChatModesForPreset(
|
||||
agentChatPermissionModes(agent) &&
|
||||
resolveTuiAgentPermissionMode({
|
||||
agent,
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv)
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings)
|
||||
}) !== 'mixed'
|
||||
)
|
||||
.map((agent) => [agent, mode])
|
||||
@@ -135,9 +176,17 @@ export function agentLaunchSettingsMutationUpdates(
|
||||
mutation.agent,
|
||||
mutation.enabled
|
||||
)
|
||||
case 'permission':
|
||||
if (!hasTypedAgentPermission(settings)) {
|
||||
throw new Error('This host stores permissions in launch arguments')
|
||||
}
|
||||
return permissionUpdates(settings, mutation)
|
||||
case 'permissions': {
|
||||
const nativeMode =
|
||||
mutation.mode === 'yolo' ? 'full-access' : mutation.mode === 'auto' ? 'auto' : 'ask'
|
||||
const typed = hasTypedAgentPermission(settings)
|
||||
const terminal = (args: Parameters<typeof applyAgentPermissionMode>[0]) =>
|
||||
typed ? legacyPermissionsUpdates(settings, mutation) : applyAgentPermissionMode(args)
|
||||
if (mutation.agent) {
|
||||
const agent = mutation.agent
|
||||
if (!(agent in YOLO_TUI_AGENT_ARGS) && !(agent in YOLO_TUI_AGENT_ENV)) {
|
||||
@@ -147,16 +196,17 @@ export function agentLaunchSettingsMutationUpdates(
|
||||
throw new Error('This agent does not support automatic permission review')
|
||||
}
|
||||
if (
|
||||
!typed &&
|
||||
resolveTuiAgentPermissionMode({
|
||||
agent,
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv)
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings)
|
||||
}) === 'mixed'
|
||||
) {
|
||||
throw new Error('Edit custom launch arguments before choosing a permission mode')
|
||||
}
|
||||
return {
|
||||
...applyAgentPermissionMode({ ...settings, mode: mutation.mode, agent }),
|
||||
...terminal({ ...settings, mode: mutation.mode, agent }),
|
||||
...(mutation.syncNativeChat && agentChatPermissionModes(agent)
|
||||
? {
|
||||
agentNativeChatPermissionModes: {
|
||||
@@ -172,7 +222,7 @@ export function agentLaunchSettingsMutationUpdates(
|
||||
throw new Error('Auto cannot apply to agents without automatic permission review')
|
||||
}
|
||||
return {
|
||||
...applyAgentPermissionMode({ ...settings, mode: mutation.mode, exceptAuto: true }),
|
||||
...terminal({ ...settings, mode: mutation.mode, exceptAuto: true }),
|
||||
...(mutation.syncNativeChat
|
||||
? {
|
||||
agentNativeChatPermissionModes: {
|
||||
@@ -185,7 +235,7 @@ export function agentLaunchSettingsMutationUpdates(
|
||||
}
|
||||
if (mutation.mode === 'auto') {
|
||||
return {
|
||||
...applyAgentPermissionMode({ ...settings, mode: mutation.mode }),
|
||||
...terminal({ ...settings, mode: mutation.mode }),
|
||||
...(mutation.syncNativeChat
|
||||
? {
|
||||
agentNativeChatPermissionModes: {
|
||||
@@ -197,7 +247,7 @@ export function agentLaunchSettingsMutationUpdates(
|
||||
}
|
||||
}
|
||||
return {
|
||||
...applyAgentPermissionMode({ ...settings, mode: mutation.mode }),
|
||||
...terminal({ ...settings, mode: mutation.mode }),
|
||||
...(mutation.syncNativeChat
|
||||
? { nativeChatPermissionMode: nativeMode, agentNativeChatPermissionModes: {} }
|
||||
: {})
|
||||
@@ -213,27 +263,30 @@ export function agentLaunchSettingsMutationUpdates(
|
||||
return { agentCmdOverrides: commands }
|
||||
}
|
||||
case 'arguments':
|
||||
if (hasTypedAgentPermission(settings)) {
|
||||
return legacyArgumentsUpdates(settings, mutation.agent, mutation.value)
|
||||
}
|
||||
return {
|
||||
agentDefaultArgs: normalizeTuiAgentArgsRecord({
|
||||
...settings.agentDefaultArgs,
|
||||
[mutation.agent]: mutation.value
|
||||
})
|
||||
}
|
||||
case 'environment-set':
|
||||
case 'environment-remove': {
|
||||
const env = { ...resolveTuiAgentLaunchEnv(mutation.agent, settings.agentDefaultEnv) }
|
||||
// Windows environment names are case insensitive on the execution host.
|
||||
for (const name of Object.keys(env)) {
|
||||
if (
|
||||
name === mutation.name ||
|
||||
(platform === 'win32' && name.toLowerCase() === mutation.name.toLowerCase())
|
||||
) {
|
||||
delete env[name]
|
||||
}
|
||||
case 'user-arguments': {
|
||||
if (!hasTypedAgentPermission(settings)) {
|
||||
throw new Error('This host stores permissions in launch arguments')
|
||||
}
|
||||
const lifted = liftAgentPermissionArgs(mutation.agent, mutation.value)
|
||||
return {
|
||||
agentDefaultArgs: normalizeTuiAgentArgsRecord({
|
||||
...settings.agentDefaultArgs,
|
||||
[mutation.agent]: lifted.args
|
||||
}),
|
||||
...(lifted.choice ? choiceUpdate(settings, mutation.agent, lifted.choice) : {})
|
||||
}
|
||||
const nextEnv =
|
||||
mutation.type === 'environment-set' ? { ...env, [mutation.name]: mutation.value } : env
|
||||
return { agentDefaultEnv: { ...settings.agentDefaultEnv, [mutation.agent]: nextEnv } }
|
||||
}
|
||||
case 'environment-set':
|
||||
case 'environment-remove':
|
||||
return agentEnvironmentMutationUpdates(settings, mutation, platform)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { agentRequiredValueOptions } from './agent-session-option-agent-args'
|
||||
import { agentPermissionArgs, agentPermissionChoices } from './tui-agent-permission-choice'
|
||||
import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
|
||||
export const CODEX_PERMISSION_CONFIG_ROOTS = [
|
||||
'approval_policy',
|
||||
'sandbox_mode',
|
||||
'approvals_reviewer',
|
||||
'sandbox_workspace_write'
|
||||
] as const
|
||||
|
||||
const CLAUDE_PERMISSION_OPTIONS = [
|
||||
'--permission-mode',
|
||||
'--dangerously-skip-permissions',
|
||||
'--allow-dangerously-skip-permissions',
|
||||
'--permission-prompt-tool',
|
||||
'--permission-prompts',
|
||||
'--allowedTools',
|
||||
'--allowed-tools',
|
||||
'--disallowedTools',
|
||||
'--disallowed-tools'
|
||||
]
|
||||
const EXTRA_CARRIERS: Partial<Record<TuiAgent, readonly string[]>> = {
|
||||
claude: CLAUDE_PERMISSION_OPTIONS,
|
||||
'claude-agent-teams': CLAUDE_PERMISSION_OPTIONS,
|
||||
codex: ['-a', '--ask-for-approval', '-s', '--sandbox', '--yolo', '--not-so-yolo', '--full-auto'],
|
||||
grok: ['--permission-mode', '--always-approve', '--yolo', '--dangerously-skip-permissions'],
|
||||
gemini: ['--approval-mode', '-y']
|
||||
}
|
||||
const CODEX_VALUES = [
|
||||
'-a',
|
||||
'--ask-for-approval',
|
||||
'-s',
|
||||
'--sandbox',
|
||||
'-c',
|
||||
'--config',
|
||||
'-m',
|
||||
'--model',
|
||||
'--local-provider',
|
||||
'--enable',
|
||||
'--disable'
|
||||
]
|
||||
const MODEL_VALUES = ['--model', '--effort']
|
||||
const CLAUDE_VARIADIC = [
|
||||
'--tools',
|
||||
'--allowedTools',
|
||||
'--allowed-tools',
|
||||
'--disallowedTools',
|
||||
'--disallowed-tools',
|
||||
'--add-dir',
|
||||
'--mcp-config',
|
||||
'--betas',
|
||||
'--plugin-dir'
|
||||
]
|
||||
const PERMISSION_LAUNCH_GRAMMARS: readonly AgentStartupShell[] = ['posix', 'powershell', 'cmd']
|
||||
|
||||
function words(text: string): string[] {
|
||||
const parsed = tokenizeStartupCommand(text, 'posix')
|
||||
return parsed.ok ? parsed.tokens : []
|
||||
}
|
||||
|
||||
function optionName(agent: TuiAgent, token: string): string {
|
||||
if (agent === 'codex' && /^-[acms].+/.test(token)) {
|
||||
return token.slice(0, 2)
|
||||
}
|
||||
return token.split('=', 1)[0]
|
||||
}
|
||||
|
||||
/** Literal CLI roots: shell quoting is already removed; TOML quoted keys are not decoded. */
|
||||
function isPermissionConfig(value: string | undefined): boolean {
|
||||
if (!value?.includes('=')) {
|
||||
return false
|
||||
}
|
||||
const root = value.slice(0, value.indexOf('=')).trim().split('.')[0]
|
||||
return CODEX_PERMISSION_CONFIG_ROOTS.some((key) => key === root)
|
||||
}
|
||||
|
||||
type PermissionOptionInventory = {
|
||||
values: Set<string>
|
||||
carriers: Set<string>
|
||||
switches: Set<string>
|
||||
}
|
||||
function permissionOptionInventory(agent: TuiAgent): PermissionOptionInventory {
|
||||
const recipes = agentPermissionChoices(agent).flatMap((choice) => {
|
||||
const tokens = words(agentPermissionArgs(agent, choice))
|
||||
return tokens.map((token, index) => ({ token, next: tokens[index + 1] }))
|
||||
})
|
||||
const carriers = new Set(EXTRA_CARRIERS[agent])
|
||||
const values = new Set(agent === 'codex' ? CODEX_VALUES : [])
|
||||
const switches = new Set<string>()
|
||||
for (const { token, next } of recipes) {
|
||||
if (!token.startsWith('-')) {
|
||||
continue
|
||||
}
|
||||
const name = optionName(agent, token)
|
||||
if (name !== '-c' && name !== '--config') {
|
||||
carriers.add(name)
|
||||
}
|
||||
if (token === name && next && !next.startsWith('-')) {
|
||||
values.add(name)
|
||||
} else {
|
||||
switches.add(name)
|
||||
}
|
||||
}
|
||||
return { carriers, values, switches }
|
||||
}
|
||||
|
||||
/** Bounded root-option ownership. Unknown regions stay uncertain, never rejected. */
|
||||
function analyzeAgentPermissionWords(
|
||||
agent: TuiAgent,
|
||||
tokens: readonly string[],
|
||||
inventory = permissionOptionInventory(agent)
|
||||
) {
|
||||
const claude = agent === 'claude' || agent === 'claude-agent-teams'
|
||||
const required = agentRequiredValueOptions(agent)
|
||||
const roles: string[] = []
|
||||
const optionIndices = new Set<number>()
|
||||
let uncertain = false
|
||||
let terminated = false
|
||||
const carrierIndices: number[] = []
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index]
|
||||
if (terminated) {
|
||||
roles[index] = 'positional'
|
||||
continue
|
||||
}
|
||||
if (token === '--' && !uncertain) {
|
||||
roles[index] = 'terminator'
|
||||
terminated = true
|
||||
continue
|
||||
}
|
||||
const name = optionName(agent, token)
|
||||
const inline = token !== name
|
||||
const value = inline
|
||||
? token.slice(name.length + (token[name.length] === '=' ? 1 : 0))
|
||||
: tokens[index + 1]
|
||||
const policy =
|
||||
inventory.carriers.has(name) ||
|
||||
(agent === 'codex' && ['-c', '--config'].includes(name) && isPermissionConfig(value))
|
||||
if (policy) {
|
||||
carrierIndices.push(index)
|
||||
}
|
||||
const knownValue =
|
||||
inventory.values.has(name) ||
|
||||
required.includes(name) ||
|
||||
(['claude', 'claude-agent-teams', 'grok', 'codex'].includes(agent) &&
|
||||
MODEL_VALUES.includes(name))
|
||||
const known =
|
||||
knownValue ||
|
||||
inventory.carriers.has(name) ||
|
||||
inventory.switches.has(name) ||
|
||||
(agent === 'codex' && ['--search', '--oss', '--no-alt-screen'].includes(name))
|
||||
roles[index] = uncertain || !known ? 'uncertain' : `option:${name}`
|
||||
if (!uncertain && known) {
|
||||
optionIndices.add(index)
|
||||
}
|
||||
if (!known) {
|
||||
uncertain = true
|
||||
}
|
||||
if (!knownValue || inline || uncertain) {
|
||||
continue
|
||||
}
|
||||
const consumesDash = required.includes(name)
|
||||
if (value === undefined || (!consumesDash && value.startsWith('-'))) {
|
||||
continue
|
||||
}
|
||||
roles[++index] = uncertain ? 'uncertain' : `value:${name}`
|
||||
if (claude && CLAUDE_VARIADIC.includes(name)) {
|
||||
while (tokens[index + 1] !== undefined && !tokens[index + 1].startsWith('-')) {
|
||||
roles[++index] = uncertain ? 'uncertain' : `value:${name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
return { roles, optionIndices, carrier: carrierIndices.length > 0, carrierIndices }
|
||||
}
|
||||
|
||||
export function agentArgsSetPermission(agent: TuiAgent, text: string | null | undefined): boolean {
|
||||
return PERMISSION_LAUNCH_GRAMMARS.some((grammar) => {
|
||||
const parsed = tokenizeStartupCommand(text ?? '', grammar)
|
||||
return parsed.ok && analyzeAgentPermissionWords(agent, parsed.tokens).carrier
|
||||
})
|
||||
}
|
||||
|
||||
/** Extraction must preserve both shell words and their CLI owners after prefix composition. */
|
||||
export function permissionRecipeCut(
|
||||
agent: TuiAgent,
|
||||
text: string,
|
||||
recipe: string
|
||||
): { args: string } | null {
|
||||
const recipeTokens = tokenizeStartupCommand(recipe, 'posix')
|
||||
const wanted = recipeTokens.ok ? recipeTokens.tokens : []
|
||||
const rawRecipe = recipeTokens.ok
|
||||
? recipeTokens.spans.map((span) => recipe.slice(span.start, span.end))
|
||||
: []
|
||||
if (!wanted.length) {
|
||||
return null
|
||||
}
|
||||
const inventory = permissionOptionInventory(agent)
|
||||
const originals = PERMISSION_LAUNCH_GRAMMARS.map((grammar) => {
|
||||
const parsed = tokenizeStartupCommand(text, grammar)
|
||||
return parsed.ok
|
||||
? {
|
||||
parsed,
|
||||
analysis: analyzeAgentPermissionWords(agent, parsed.tokens, inventory),
|
||||
offsets: new Map(parsed.spans.map((span, index) => [span.start, index]))
|
||||
}
|
||||
: null
|
||||
})
|
||||
const firstOriginal = originals[0]
|
||||
if (!firstOriginal || originals.some((original) => !original)) {
|
||||
return null
|
||||
}
|
||||
const { parsed: first, analysis } = firstOriginal
|
||||
const candidates = new Set([0])
|
||||
const firstCarrier = analysis.carrierIndices[0]
|
||||
const lastCarrier = analysis.carrierIndices.at(-1)
|
||||
if (
|
||||
!analysis.roles.includes('uncertain') &&
|
||||
firstCarrier !== undefined &&
|
||||
lastCarrier !== undefined
|
||||
) {
|
||||
for (
|
||||
let index = Math.max(1, firstCarrier - wanted.length + 1);
|
||||
index <= firstCarrier;
|
||||
index += 1
|
||||
) {
|
||||
if (index + wanted.length > lastCarrier && analysis.optionIndices.has(index)) {
|
||||
candidates.add(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const index of candidates) {
|
||||
const run = first.spans.slice(index, index + wanted.length)
|
||||
const last = run.at(-1)
|
||||
if (
|
||||
!last ||
|
||||
!run.every((span, offset) => text.slice(span.start, span.end) === rawRecipe[offset])
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const start = run[0].start
|
||||
const end = last.end
|
||||
const before = text.slice(0, start).trimEnd()
|
||||
const after = text.slice(end).trimStart()
|
||||
const args = before && after ? `${before} ${after}` : before || after
|
||||
const proven = originals.every((original, grammarIndex) => {
|
||||
if (!original) {
|
||||
return false
|
||||
}
|
||||
const { parsed, analysis: originalAnalysis, offsets } = original
|
||||
const cut = tokenizeStartupCommand(args, PERMISSION_LAUNCH_GRAMMARS[grammarIndex])
|
||||
if (!cut.ok) {
|
||||
return false
|
||||
}
|
||||
const offset = offsets.get(start) ?? -1
|
||||
if (
|
||||
offset === -1 ||
|
||||
!wanted.every(
|
||||
(word, n) =>
|
||||
parsed.tokens[offset + n] === word &&
|
||||
text.slice(parsed.spans[offset + n].start, parsed.spans[offset + n].end) ===
|
||||
rawRecipe[n]
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const kept = parsed.tokens.filter((_, n) => n < offset || n >= offset + wanted.length)
|
||||
if (kept.length !== cut.tokens.length || !kept.every((word, n) => word === cut.tokens[n])) {
|
||||
return false
|
||||
}
|
||||
if (analyzeAgentPermissionWords(agent, cut.tokens, inventory).carrier) {
|
||||
return false
|
||||
}
|
||||
// A leading exact recipe reconstructs the original argv, even with an unknown remainder.
|
||||
if (offset === 0) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
!originalAnalysis.optionIndices.has(offset) ||
|
||||
originalAnalysis.roles.includes('uncertain')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const composed = analyzeAgentPermissionWords(agent, [...wanted, ...cut.tokens], inventory)
|
||||
const keptRoles = originalAnalysis.roles.filter(
|
||||
(_, n) => n < offset || n >= offset + wanted.length
|
||||
)
|
||||
return keptRoles.every((role, n) => role === composed.roles[wanted.length + n])
|
||||
})
|
||||
if (proven) {
|
||||
return { args }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** The execution host supplies this; browser caches without a host use exact identity. */
|
||||
export function agentPermissionHostPlatform(): NodeJS.Platform | undefined {
|
||||
return typeof process !== 'undefined' ? process.platform : undefined
|
||||
}
|
||||
|
||||
export function permissionEnvNameMatches(
|
||||
left: string,
|
||||
right: string,
|
||||
platform: NodeJS.Platform | null | undefined
|
||||
): boolean {
|
||||
return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { AgentType } from './agent-status-types'
|
||||
|
||||
// Required values consume dash-leading text; boolean and optional-valued options are excluded.
|
||||
// Codex (clap) and OpenCode (yargs) never take a dash-leading value, so they have no entry.
|
||||
const VALUE_OPTIONS: Partial<Record<AgentType, readonly string[]>> = {
|
||||
const AGENT_REQUIRED_VALUE_OPTIONS: Partial<Record<AgentType, readonly string[]>> = {
|
||||
claude: [
|
||||
'--add-dir',
|
||||
'--agent',
|
||||
@@ -31,6 +31,7 @@ const VALUE_OPTIONS: Partial<Record<AgentType, readonly string[]>> = {
|
||||
'--output-format',
|
||||
'--permission-mode',
|
||||
'--permission-prompts',
|
||||
'--permission-prompt-tool',
|
||||
'--plugin-dir',
|
||||
'--plugin-url',
|
||||
'--remote-control-session-name-prefix',
|
||||
@@ -60,7 +61,10 @@ const VALUE_OPTIONS: Partial<Record<AgentType, readonly string[]>> = {
|
||||
}
|
||||
|
||||
export function agentArgTerminatorIndex(agent: AgentType, tokens: readonly string[]): number {
|
||||
return findOptionOccurrence(tokens, ['--'], false, VALUE_OPTIONS[agent])?.index ?? tokens.length
|
||||
return (
|
||||
findOptionOccurrence(tokens, ['--'], false, agentRequiredValueOptions(agent))?.index ??
|
||||
tokens.length
|
||||
)
|
||||
}
|
||||
|
||||
export function agentArgOptionTokens(tokens: readonly string[]): readonly string[] {
|
||||
@@ -76,13 +80,31 @@ export function removeAgentArgOption(
|
||||
matchesValue: (value: string | undefined) => boolean = () => true
|
||||
): string[] {
|
||||
const kept: string[] = []
|
||||
let rest = tokens
|
||||
let found = findOptionOccurrence(rest, aliases, true, VALUE_OPTIONS[agent])
|
||||
const required = agentRequiredValueOptions(agent)
|
||||
let cursor = 0
|
||||
let found = findOptionOccurrence(tokens, aliases, true, required, cursor)
|
||||
while (found) {
|
||||
const end = found.index + found.consumed
|
||||
kept.push(...rest.slice(0, matchesValue(found.value) ? found.index : end))
|
||||
rest = rest.slice(end)
|
||||
found = findOptionOccurrence(rest, aliases, true, VALUE_OPTIONS[agent])
|
||||
const keptEnd = matchesValue(found.value) ? found.index : end
|
||||
for (let index = cursor; index < keptEnd; index += 1) {
|
||||
kept.push(tokens[index])
|
||||
}
|
||||
cursor = end
|
||||
found = findOptionOccurrence(tokens, aliases, true, required, cursor)
|
||||
}
|
||||
return [...kept, ...rest]
|
||||
for (let index = cursor; index < tokens.length; index += 1) {
|
||||
kept.push(tokens[index])
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/** Shared required-operand facts for terminal parsing, including CLI-sharing teams. */
|
||||
export function agentRequiredValueOptions(agent: string): readonly string[] {
|
||||
const value: unknown = Object.getOwnPropertyDescriptor(
|
||||
AGENT_REQUIRED_VALUE_OPTIONS,
|
||||
agent === 'claude-agent-teams' ? 'claude' : agent
|
||||
)?.value
|
||||
return Array.isArray(value) && value.every((item): item is string => typeof item === 'string')
|
||||
? value
|
||||
: []
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('resolveAgentStartupPlanInputs with extra agent args', () => {
|
||||
})
|
||||
const desktopArgs = mergeExtraAgentArgs({
|
||||
agent: 'claude',
|
||||
defaultArgs: resolveTuiAgentLaunchArgs('claude', SETTINGS.agentDefaultArgs),
|
||||
defaultArgs: resolveTuiAgentLaunchArgs('claude', SETTINGS),
|
||||
extraAgentArgs: '--effort high',
|
||||
shell: 'posix'
|
||||
})
|
||||
|
||||
@@ -10,7 +10,12 @@ import { resolveLocalWindowsAgentStartupShell } from './windows-terminal-shell'
|
||||
export type AgentStartupSettings = Partial<
|
||||
Pick<
|
||||
GlobalSettings,
|
||||
'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv' | 'terminalWindowsShell'
|
||||
| 'agentCmdOverrides'
|
||||
| 'agentDefaultArgs'
|
||||
| 'agentDefaultEnv'
|
||||
| 'agentPermissionDefault'
|
||||
| 'agentPermissionOverrides'
|
||||
| 'terminalWindowsShell'
|
||||
>
|
||||
>
|
||||
|
||||
@@ -55,9 +60,7 @@ export function resolveAgentStartupPlanInputs(args: {
|
||||
// A per-launch override wins over the Settings default; `null` is "no arguments", so this
|
||||
// tests for absence rather than falsiness.
|
||||
let agentArgs =
|
||||
args.agentArgs !== undefined
|
||||
? args.agentArgs
|
||||
: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs)
|
||||
args.agentArgs !== undefined ? args.agentArgs : resolveTuiAgentLaunchArgs(agent, settings)
|
||||
const shell =
|
||||
args.shell ??
|
||||
resolveLocalWindowsAgentStartupShell({
|
||||
@@ -81,7 +84,7 @@ export function resolveAgentStartupPlanInputs(args: {
|
||||
agent,
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs,
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings, platform),
|
||||
platform,
|
||||
shell,
|
||||
isRemote,
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
type SleepingAgentLaunchConfig
|
||||
} from './agent-session-resume'
|
||||
import { normalizeAiVaultResumeFilePath } from './ai-vault-resume-path'
|
||||
import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from './tui-agent-launch-defaults'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv,
|
||||
type AgentLaunchProfileSettings
|
||||
} from './tui-agent-launch-defaults'
|
||||
import { buildAgentResumeStartupPlan } from './tui-agent-startup'
|
||||
import {
|
||||
quoteStartupArg,
|
||||
@@ -47,9 +51,8 @@ export type AiVaultResumeStartup = {
|
||||
providerSession?: AgentProviderSessionMetadata
|
||||
}
|
||||
|
||||
export type AiVaultResumeLaunchSettings = Partial<
|
||||
Pick<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv'>
|
||||
>
|
||||
export type AiVaultResumeLaunchSettings = Partial<Pick<GlobalSettings, 'agentCmdOverrides'>> &
|
||||
AgentLaunchProfileSettings
|
||||
|
||||
/** The platform and live shell a resume line is written for, and the Codex home it pins. */
|
||||
export type AiVaultResumeHostShell = {
|
||||
@@ -133,9 +136,8 @@ export function buildAiVaultLocalResumeStartup(
|
||||
cmdOverrides: resumeCommandOverrides(args),
|
||||
platform,
|
||||
shell: liveShell,
|
||||
agentArgs:
|
||||
args.agentArgs ?? resolveTuiAgentLaunchArgs(session.agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(session.agent, settings?.agentDefaultEnv),
|
||||
agentArgs: args.agentArgs ?? resolveTuiAgentLaunchArgs(session.agent, settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(session.agent, settings, platform),
|
||||
resumeInLaunchCwd: true,
|
||||
...(session.agent === 'omp' && resumeFilePath ? { ompResumeFilePath: resumeFilePath } : {})
|
||||
})
|
||||
@@ -218,8 +220,8 @@ export function buildAiVaultForkStartup(
|
||||
cmdOverrides: resumeCommandOverrides(args),
|
||||
platform,
|
||||
shell: liveShell,
|
||||
agentArgs: resolveTuiAgentLaunchArgs(session.agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(session.agent, settings?.agentDefaultEnv),
|
||||
agentArgs: resolveTuiAgentLaunchArgs(session.agent, settings),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(session.agent, settings, platform),
|
||||
resumeInLaunchCwd: true,
|
||||
fork: true
|
||||
})
|
||||
@@ -250,7 +252,7 @@ export function resolveAiVaultCopiedSessionAgentArgs(
|
||||
const { platform, liveShell } = args.host
|
||||
const shell = resolveStartupShell(platform, liveShell)
|
||||
const configuredArgs = isTuiAgent(session.agent)
|
||||
? resolveTuiAgentLaunchArgs(session.agent, settings?.agentDefaultArgs)
|
||||
? resolveTuiAgentLaunchArgs(session.agent, settings)
|
||||
: ''
|
||||
const configured = tokenizeStartupCommand(configuredArgs, shell)
|
||||
if (!configured.ok) {
|
||||
@@ -298,7 +300,7 @@ export function assertAiVaultCopiedSessionStoreAllowed(args: AiVaultResumeStartu
|
||||
args.settings?.agentCmdOverrides?.[agent] ||
|
||||
getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[agent], platform)
|
||||
const parsed = tokenizeStartupCommand(
|
||||
`${command} ${resolveTuiAgentLaunchArgs(agent, args.settings?.agentDefaultArgs)}`,
|
||||
`${command} ${resolveTuiAgentLaunchArgs(agent, args.settings)}`,
|
||||
resolveStartupShell(platform, liveShell)
|
||||
)
|
||||
if (!parsed.ok) {
|
||||
|
||||
@@ -14,9 +14,10 @@ export function findOptionOccurrence(
|
||||
tokens: readonly string[],
|
||||
aliases: readonly string[],
|
||||
stopAtTerminator: boolean,
|
||||
valueOptions: readonly string[] = []
|
||||
valueOptions: readonly string[] = [],
|
||||
startIndex = 0
|
||||
): { index: number; consumed: number; value?: string } | null {
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
for (let index = startIndex; index < tokens.length; index += 1) {
|
||||
const token = tokens[index]
|
||||
if (stopAtTerminator && token === '--') {
|
||||
break
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getDefaultSourceControlAiSettings } from './source-control-ai-settings'
|
||||
import { DEFAULT_APP_ICON_ID } from './app-icon'
|
||||
import { DEFAULT_OPEN_IN_APPLICATIONS } from './open-in-applications'
|
||||
import { DEFAULT_DISABLED_TUI_AGENTS } from './tui-agent-selection'
|
||||
import { DEFAULT_TUI_AGENT_ARGS, DEFAULT_TUI_AGENT_ENV } from './tui-agent-launch-defaults'
|
||||
import { UI_LANGUAGE_SYSTEM } from './ui-language'
|
||||
import {
|
||||
DEFAULT_LEFT_SIDEBAR_TINT_COLOR,
|
||||
@@ -142,7 +141,7 @@ export function buildDefaultSettings(args: {
|
||||
nativeChatInheritShellEnvironment: true,
|
||||
nativeChatShellEnvironmentVariables: [],
|
||||
nativeChatSessionOptions: {},
|
||||
// The shipped Claude and Codex arguments carry their bypass flags.
|
||||
// New chats of agents on their own settings; others start in their agent permission.
|
||||
nativeChatPermissionMode: 'full-access',
|
||||
openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS],
|
||||
rightSidebarOpenByDefault: true,
|
||||
@@ -225,9 +224,12 @@ export function buildDefaultSettings(args: {
|
||||
zcodePlanSite: 'zai',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS },
|
||||
agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV },
|
||||
// Arguments hold only the user's own text; the permission flag comes from the choice below.
|
||||
agentDefaultArgs: {},
|
||||
agentDefaultEnv: {},
|
||||
agentYoloDefaultsMigrated: true,
|
||||
agentPermissionDefault: 'full-access',
|
||||
agentPermissionOverrides: {},
|
||||
agentStatusHooksEnabled: true,
|
||||
agentWorkspaceTrustEnabled: true,
|
||||
codexTerminalServerIsolation: true,
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { ClaudeAccountSwitchSettings } from './claude-account-switch-settin
|
||||
import type { ExternalConversationSettings } from './external-conversation-settings'
|
||||
import type { NativeChatGlobalSettings } from './native-chat-appearance-settings'
|
||||
import type { WorktreeDeletionPreferences } from './worktree-deletion-preferences'
|
||||
import type { AgentPermissionGlobalSettings } from './tui-agent-permission-choice'
|
||||
|
||||
/** Settings groups owned by their feature modules and composed into GlobalSettings. */
|
||||
export type GlobalSettingsBase = NativeChatGlobalSettings &
|
||||
WorktreeDeletionPreferences &
|
||||
ClaudeAccountSwitchSettings &
|
||||
AgentPermissionGlobalSettings &
|
||||
ExternalConversationSettings &
|
||||
BrowserLinkOpeningSettings
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { expect, vi } from 'vitest'
|
||||
import * as shell from './tui-agent-startup-shell'
|
||||
import type { GlobalSettings } from './global-settings-types'
|
||||
import { getDefaultVoiceSettings } from './constants'
|
||||
import { getDefaultNotificationSettings } from './notification-settings-defaults'
|
||||
@@ -25,3 +27,195 @@ export function createGlobalSettingsFixture(
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const originalTokenizer = shell.tokenizeStartupCommand
|
||||
|
||||
// Native materializations and string outputs; callers add the finite semantic/property passes.
|
||||
export function measureSettingsWork<T>(call: () => T) {
|
||||
const work = {
|
||||
entries: 0,
|
||||
assignments: 0,
|
||||
comparisons: 0,
|
||||
bytes: 0,
|
||||
chars: 0,
|
||||
visits: 0,
|
||||
inputs: Array<string>()
|
||||
}
|
||||
const currentTokenizer = shell.tokenizeStartupCommand
|
||||
const existingTokenizerSpy = vi.isMockFunction(currentTokenizer)
|
||||
const tokenize = vi.isMockFunction(currentTokenizer)
|
||||
? (currentTokenizer.getMockImplementation() ?? originalTokenizer)
|
||||
: currentTokenizer
|
||||
const tokenizerSpy = vi.spyOn(shell, 'tokenizeStartupCommand')
|
||||
tokenizerSpy.mockImplementation((...args) => {
|
||||
work.inputs.push(args[0])
|
||||
work.chars += args[0].length
|
||||
const parsed = tokenize(...args)
|
||||
// Fixed token/span/owner passes; this is not a dynamic index-read counter.
|
||||
if (parsed.ok) {
|
||||
work.visits += 10 * parsed.tokens.length
|
||||
}
|
||||
return parsed
|
||||
})
|
||||
const restores: (() => void)[] = []
|
||||
function replace<T, K extends keyof T>(target: T, name: K, wrap: (original: T[K]) => T[K]) {
|
||||
const original = target[name]
|
||||
target[name] = wrap(original)
|
||||
restores.push(() => {
|
||||
target[name] = original
|
||||
})
|
||||
}
|
||||
replace(Object, 'entries', (original) => (value) => {
|
||||
const result = original(value)
|
||||
work.entries += 2 * result.length
|
||||
return result
|
||||
})
|
||||
replace(Object, 'keys', (original) => (value) => {
|
||||
const result = original(value)
|
||||
work.entries += 2 * result.length
|
||||
return result
|
||||
})
|
||||
replace(Object, 'fromEntries', (original) => (value) => {
|
||||
const result = original(value)
|
||||
work.entries += Reflect.ownKeys(result).length
|
||||
return result
|
||||
})
|
||||
replace(Object, 'hasOwn', (original) => (value, key) => {
|
||||
const result = original(value, key)
|
||||
work.entries++
|
||||
return result
|
||||
})
|
||||
replace(Object, 'assign', (original) => (target, ...sources) => {
|
||||
const result = original(target, ...sources)
|
||||
work.assignments++
|
||||
for (const source of sources) {
|
||||
work.entries += 2 * Reflect.ownKeys(Object(source)).length
|
||||
}
|
||||
return result
|
||||
})
|
||||
replace(
|
||||
Map.prototype,
|
||||
'get',
|
||||
(original) =>
|
||||
function (this: Map<unknown, unknown>, key: unknown) {
|
||||
const result: unknown = original.call(this, key)
|
||||
work.entries++
|
||||
return result
|
||||
}
|
||||
)
|
||||
replace(
|
||||
Map.prototype,
|
||||
'set',
|
||||
(original) =>
|
||||
function (this: Map<unknown, unknown>, key: unknown, value: unknown) {
|
||||
const result = original.call(this, key, value)
|
||||
work.entries++
|
||||
return result
|
||||
}
|
||||
)
|
||||
replace(
|
||||
Map.prototype,
|
||||
'delete',
|
||||
(original) =>
|
||||
function (this: Map<unknown, unknown>, key: unknown) {
|
||||
const result = original.call(this, key)
|
||||
work.entries++
|
||||
return result
|
||||
}
|
||||
)
|
||||
replace(
|
||||
Set.prototype,
|
||||
'add',
|
||||
(original) =>
|
||||
function (this: Set<unknown>, value: unknown) {
|
||||
const result = original.call(this, value)
|
||||
work.entries++
|
||||
return result
|
||||
}
|
||||
)
|
||||
replace(
|
||||
Set.prototype,
|
||||
'has',
|
||||
(original) =>
|
||||
function (this: Set<unknown>, value: unknown) {
|
||||
const result = original.call(this, value)
|
||||
work.entries++
|
||||
return result
|
||||
}
|
||||
)
|
||||
replace(
|
||||
Array.prototype,
|
||||
'sort',
|
||||
(original) =>
|
||||
function (this: unknown[], compare?: (a: unknown, b: unknown) => number) {
|
||||
return original.call(this, (a, b) => {
|
||||
work.comparisons++
|
||||
return typeof compare === 'function'
|
||||
? compare(a, b)
|
||||
: +(String(a) > String(b)) - +(String(a) < String(b))
|
||||
})
|
||||
}
|
||||
)
|
||||
replace(
|
||||
Array.prototype,
|
||||
'slice',
|
||||
(original) =>
|
||||
function (this: unknown[], start?: number, end?: number) {
|
||||
const result = original.call(this, start, end)
|
||||
work.visits += 2 * result.length
|
||||
return result
|
||||
}
|
||||
)
|
||||
replace(
|
||||
String.prototype,
|
||||
'slice',
|
||||
(original) =>
|
||||
function (this: string, start?: number, end?: number) {
|
||||
const result = original.call(this, start, end)
|
||||
work.bytes += result.length
|
||||
return result
|
||||
}
|
||||
)
|
||||
replace(
|
||||
String.prototype,
|
||||
'toLowerCase',
|
||||
(original) =>
|
||||
function (this: string) {
|
||||
const result = original.call(this)
|
||||
work.bytes += result.length
|
||||
return result
|
||||
}
|
||||
)
|
||||
work.entries = 0
|
||||
const restore = () => {
|
||||
restores.toReversed().forEach((reset) => reset())
|
||||
if (existingTokenizerSpy) {
|
||||
tokenizerSpy.mockImplementation(tokenize)
|
||||
} else {
|
||||
tokenizerSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
let pending = false
|
||||
try {
|
||||
const value = call()
|
||||
pending = value instanceof Promise
|
||||
if (value instanceof Promise) {
|
||||
void value.then(restore, restore)
|
||||
}
|
||||
return { value, work }
|
||||
} finally {
|
||||
if (!pending) {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workScales = new Map<string, number[]>()
|
||||
export function expectSettingsWorkScale(key: string, size: number, counts: number[]) {
|
||||
if (size === 200) {
|
||||
const baseline = workScales.get(key)
|
||||
expect(baseline, key).toBeDefined()
|
||||
counts.forEach((value, i) => expect(value, key).toBeLessThanOrEqual(12 * baseline![i]))
|
||||
}
|
||||
workScales.set(key, counts)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function plus(
|
||||
producer: { kind: 'plus', agent: 'claude', via },
|
||||
request,
|
||||
provider,
|
||||
settings: { agentDefaultArgs: { claude: ARGS } }
|
||||
settings: { agentDefaultArgs: { claude: ARGS }, agentPermissionOverrides: { claude: 'own' } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { AgentType } from './agent-status-types'
|
||||
import {
|
||||
AGENT_CHAT_PERMISSION_MODE_OPTION_ID,
|
||||
isAgentChatPermissionMode,
|
||||
newChatPermissionModeForSettings
|
||||
newChatPermissionModeForSettings,
|
||||
type NewChatPermissionSettings
|
||||
} from './agent-chat-permission-mode'
|
||||
import type { GlobalSettings } from './global-settings-types'
|
||||
import { sessionOptionValueIsValid } from './agent-session-option-catalog'
|
||||
@@ -118,10 +119,11 @@ export function resolveStructuredLaunchSeedOptions(
|
||||
/** A new chat's seed: the saved selection plus the permission default narrowed to the agent. */
|
||||
export function structuredLaunchSeedOptions(
|
||||
settings:
|
||||
| Pick<
|
||||
| (Pick<
|
||||
GlobalSettings,
|
||||
'nativeChatSessionOptions' | 'nativeChatPermissionMode' | 'agentNativeChatPermissionModes'
|
||||
>
|
||||
> &
|
||||
NewChatPermissionSettings)
|
||||
| null
|
||||
| undefined,
|
||||
agent: AgentType
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { isTuiAgent } from '../tui-agent-config'
|
||||
import { AGENT_PERMISSION_CHOICES } from '../tui-agent-permission-choice'
|
||||
import type { TuiAgent } from '../tui-agent'
|
||||
|
||||
const Agent = z.custom<TuiAgent>((value) => typeof value === 'string' && isTuiAgent(value))
|
||||
@@ -31,8 +32,17 @@ export const AgentLaunchSettingsMutation = z.discriminatedUnion('type', [
|
||||
syncNativeChat: z.literal(true).optional()
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('permission'),
|
||||
choice: z.enum([...AGENT_PERMISSION_CHOICES, 'default']),
|
||||
agent: Agent.optional(),
|
||||
withoutAuto: z.literal(true).optional()
|
||||
})
|
||||
.strict(),
|
||||
z.object({ type: z.literal('command'), agent: Agent, value: Value }).strict(),
|
||||
z.object({ type: z.literal('arguments'), agent: Agent, value: Value }).strict(),
|
||||
z.object({ type: z.literal('user-arguments'), agent: Agent, value: Value }).strict(),
|
||||
z.object({ type: z.literal('environment-set'), agent: Agent, name: Name, value: Value }).strict(),
|
||||
z.object({ type: z.literal('environment-remove'), agent: Agent, name: Name }).strict()
|
||||
])
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('automatic terminal permissions', () => {
|
||||
const settings = agentLaunchSettingsMutationUpdates({}, mutation, platform)
|
||||
|
||||
for (const agent of ['claude', 'claude-agent-teams', 'codex'] as const) {
|
||||
const agentArgs = resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs)
|
||||
const agentArgs = resolveTuiAgentLaunchArgs(agent, settings)
|
||||
const startup = buildAgentStartupPlan({
|
||||
agent,
|
||||
agentArgs,
|
||||
|
||||
@@ -121,14 +121,14 @@ describe('resolveTuiAgentLaunchArgs', () => {
|
||||
it('hands the configured arguments to a terminal launch unchanged', () => {
|
||||
expect(
|
||||
resolveTuiAgentLaunchArgs('claude', {
|
||||
claude: '--dangerously-skip-permissions --model Opus'
|
||||
agentDefaultArgs: { 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('')
|
||||
expect(resolveTuiAgentLaunchArgs('claude', { agentDefaultArgs: { claude: '' } })).toBe('')
|
||||
})
|
||||
|
||||
// Their chats run their own permission mode, so terminals get no permission argument for them.
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { agentPermissionHostPlatform, permissionEnvNameMatches } from './agent-permission-env'
|
||||
import type { SleepingAgentLaunchConfig } from './agent-session-resume'
|
||||
import type { GlobalSettings } from './global-settings-types'
|
||||
import { isTuiAgent } from './tui-agent-config'
|
||||
import { YOLO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ENV } from './tui-agent-permissions'
|
||||
import {
|
||||
agentPermissionArgs,
|
||||
agentPermissionEnv,
|
||||
hasTypedAgentPermission,
|
||||
PERMISSION_CHOICE_AGENTS,
|
||||
resolveAgentPermission,
|
||||
sanitizeAgentPermissionUpdates
|
||||
} from './tui-agent-permission-choice'
|
||||
import { agentArgsSetPermission } from './tui-agent-permission-lift'
|
||||
import {
|
||||
resolveStartupShell,
|
||||
tokenizeStartupCommand,
|
||||
@@ -15,6 +25,15 @@ const UNSUPPORTED_TUI_AGENT_ARGS: Partial<Record<TuiAgent, readonly string[]>> =
|
||||
kilo: ['--dangerously-skip-permissions']
|
||||
}
|
||||
|
||||
/** What a launch reads: the user's Arguments and env plus the stored permission choice. */
|
||||
export type AgentLaunchProfileSettings = Partial<
|
||||
Pick<
|
||||
GlobalSettings,
|
||||
'agentDefaultArgs' | 'agentDefaultEnv' | 'agentPermissionDefault' | 'agentPermissionOverrides'
|
||||
>
|
||||
>
|
||||
|
||||
// What an untouched agent ran with before permissions were stored as a choice; legacy text only.
|
||||
export const DEFAULT_TUI_AGENT_ARGS: Partial<Record<TuiAgent, string>> &
|
||||
Readonly<Record<string, string | undefined>> = YOLO_TUI_AGENT_ARGS
|
||||
|
||||
@@ -35,8 +54,7 @@ export function hasUnsupportedTuiAgentArgs(agent: TuiAgent, value: unknown): boo
|
||||
/**
|
||||
* 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. Read the same argv the startup path builds so quoted prompt text and
|
||||
* Legacy profiles carry the permission in Arguments. Read the startup argv so quoted text and
|
||||
* operands after `--` cannot authorize a structured session.
|
||||
*/
|
||||
export function tuiAgentArgsBypassPermissions(
|
||||
@@ -109,6 +127,22 @@ export function normalizeTuiAgentEnvRecord(
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** Normalizes a settings write's launch profile in place, wherever the write lands. */
|
||||
export function sanitizeAgentLaunchProfileUpdates(
|
||||
target: Partial<GlobalSettings>,
|
||||
updates: Partial<GlobalSettings>
|
||||
): void {
|
||||
if ('agentDefaultArgs' in updates) {
|
||||
target.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs)
|
||||
target.agentYoloDefaultsMigrated = true
|
||||
}
|
||||
if ('agentDefaultEnv' in updates) {
|
||||
target.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv)
|
||||
target.agentYoloDefaultsMigrated = true
|
||||
}
|
||||
sanitizeAgentPermissionUpdates(target, updates)
|
||||
}
|
||||
|
||||
export function getTuiAgentDefaultArgs(agent: string): string {
|
||||
return DEFAULT_TUI_AGENT_ARGS[agent] ?? ''
|
||||
}
|
||||
@@ -117,18 +151,31 @@ export function getTuiAgentDefaultEnv(agent: string): Record<string, string> {
|
||||
return { ...DEFAULT_TUI_AGENT_ENV[agent] }
|
||||
}
|
||||
|
||||
function ownArgs(agent: string, settings: AgentLaunchProfileSettings | null | undefined) {
|
||||
const configured = settings?.agentDefaultArgs
|
||||
const value: unknown = configured && Object.getOwnPropertyDescriptor(configured, agent)?.value
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The Arguments a terminal launch of this agent gets: Orca's flag for the agent's stored
|
||||
* permission, then the user's own text. Text that sets permissions itself wins, so Orca adds none.
|
||||
* Settings without a stored choice (an older host's published text) already carry the flag.
|
||||
*/
|
||||
export function resolveTuiAgentLaunchArgs(
|
||||
agent: string,
|
||||
configuredArgs: Readonly<Record<string, string | undefined>> | null | undefined
|
||||
settings: AgentLaunchProfileSettings | null | undefined
|
||||
): string {
|
||||
if (
|
||||
configuredArgs &&
|
||||
Object.hasOwn(configuredArgs, agent) &&
|
||||
typeof configuredArgs[agent] === 'string'
|
||||
) {
|
||||
return configuredArgs[agent] ?? ''
|
||||
const own = ownArgs(agent, settings)
|
||||
if (!hasTypedAgentPermission(settings) || !isTuiAgent(agent)) {
|
||||
return own ?? getTuiAgentDefaultArgs(agent)
|
||||
}
|
||||
return getTuiAgentDefaultArgs(agent)
|
||||
const user = own?.trim() ?? ''
|
||||
const flag = agentPermissionArgs(agent, resolveAgentPermission(agent, settings).choice)
|
||||
if (!flag || agentArgsSetPermission(agent, user)) {
|
||||
return user
|
||||
}
|
||||
return user ? `${flag} ${user}` : flag
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,7 +188,7 @@ export function resolveTuiAgentLaunchArgs(
|
||||
export function resolvedTuiAgentArgsBypassPermissions(
|
||||
agent: TuiAgent,
|
||||
settings:
|
||||
| Partial<Pick<GlobalSettings, 'agentDefaultArgs' | 'terminalWindowsShell'>>
|
||||
| (AgentLaunchProfileSettings & Partial<Pick<GlobalSettings, 'terminalWindowsShell'>>)
|
||||
| null
|
||||
| undefined,
|
||||
platform: NodeJS.Platform
|
||||
@@ -154,21 +201,71 @@ export function resolvedTuiAgentArgsBypassPermissions(
|
||||
terminalWindowsShell: settings?.terminalWindowsShell
|
||||
})
|
||||
)
|
||||
return tuiAgentArgsBypassPermissions(
|
||||
agent,
|
||||
resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
|
||||
shell
|
||||
)
|
||||
return tuiAgentArgsBypassPermissions(agent, resolveTuiAgentLaunchArgs(agent, settings), shell)
|
||||
}
|
||||
|
||||
/** The env a terminal launch of this agent gets; the user's own entries win over Orca's. */
|
||||
export function resolveTuiAgentLaunchEnv(
|
||||
agent: string,
|
||||
configuredEnv: Partial<Record<string, Record<string, string>>> | null | undefined
|
||||
settings: AgentLaunchProfileSettings | null | undefined,
|
||||
platform: NodeJS.Platform | null | undefined = agentPermissionHostPlatform(),
|
||||
visitBaseEntry?: (name: string, value: string) => true
|
||||
): Record<string, string> {
|
||||
if (configuredEnv && Object.hasOwn(configuredEnv, agent)) {
|
||||
return { ...configuredEnv[agent] }
|
||||
const configured = settings?.agentDefaultEnv
|
||||
const value: unknown = configured && Object.getOwnPropertyDescriptor(configured, agent)?.value
|
||||
const own =
|
||||
value && typeof value === 'object'
|
||||
? Object.entries(value).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[1] === 'string' && (visitBaseEntry?.(entry[0], entry[1]) ?? true)
|
||||
)
|
||||
: undefined
|
||||
if (!hasTypedAgentPermission(settings) || !isTuiAgent(agent)) {
|
||||
const entries = own ?? Object.entries(getTuiAgentDefaultEnv(agent))
|
||||
if (!own) {
|
||||
entries.forEach(([name, value]) => visitBaseEntry?.(name, value))
|
||||
}
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
const added = Object.entries(
|
||||
agentPermissionEnv(agent, resolveAgentPermission(agent, settings).choice)
|
||||
).filter(([name]) => !own?.some(([key]) => permissionEnvNameMatches(key, name, platform)))
|
||||
return Object.fromEntries(added.length ? [...added, ...(own ?? [])] : (own ?? []))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every agent's Arguments and env as its terminal launch gets them, flags included: what older
|
||||
* clients read, since they treat that text as the permission. Each permission agent gets a key so
|
||||
* an older client never falls back to its own built-in default.
|
||||
*/
|
||||
export function composeAgentLaunchProfile(
|
||||
settings: AgentLaunchProfileSettings,
|
||||
platform: NodeJS.Platform | null | undefined = agentPermissionHostPlatform()
|
||||
): Required<Pick<AgentLaunchProfileSettings, 'agentDefaultArgs' | 'agentDefaultEnv'>> {
|
||||
if (!hasTypedAgentPermission(settings)) {
|
||||
return {
|
||||
agentDefaultArgs: { ...settings.agentDefaultArgs },
|
||||
agentDefaultEnv: { ...settings.agentDefaultEnv }
|
||||
}
|
||||
}
|
||||
const argAgents = new Set([
|
||||
...Object.keys(settings.agentDefaultArgs ?? {}),
|
||||
...PERMISSION_CHOICE_AGENTS
|
||||
])
|
||||
const envAgents = new Set([
|
||||
...Object.keys(settings.agentDefaultEnv ?? {}),
|
||||
...PERMISSION_CHOICE_AGENTS.filter(
|
||||
(agent) => Object.keys(agentPermissionEnv(agent, 'full-access')).length > 0
|
||||
)
|
||||
])
|
||||
return {
|
||||
agentDefaultArgs: Object.fromEntries(
|
||||
[...argAgents].map((agent) => [agent, resolveTuiAgentLaunchArgs(agent, settings)])
|
||||
),
|
||||
agentDefaultEnv: Object.fromEntries(
|
||||
[...envAgents].map((agent) => [agent, resolveTuiAgentLaunchEnv(agent, settings, platform)])
|
||||
)
|
||||
}
|
||||
return getTuiAgentDefaultEnv(agent)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
import * as permissionArgs from './agent-permission-args'
|
||||
import { measureSettingsWork, expectSettingsWorkScale } from './global-settings-test-fixture'
|
||||
import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from './tui-agent-startup'
|
||||
import {
|
||||
wholeAgentEnvironmentUpdates,
|
||||
legacyLaunchTextUpdates
|
||||
} from './agent-launch-permission-updates'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
agentLaunchSettingsMutationUpdates,
|
||||
projectAgentLaunchSettings,
|
||||
type AgentLaunchSettingsSource
|
||||
} from './agent-launch-settings'
|
||||
import { newChatPermissionModeForSettings } from './agent-chat-permission-mode'
|
||||
import {
|
||||
DEFAULT_TUI_AGENT_ARGS,
|
||||
DEFAULT_TUI_AGENT_ENV,
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from './tui-agent-launch-defaults'
|
||||
import { applyAllAgentsPermission, resolveAgentPermission } from './tui-agent-permission-choice'
|
||||
import { agentArgsSetPermission, migrateAgentPermissionChoice } from './tui-agent-permission-lift'
|
||||
|
||||
const legacy = (
|
||||
args: Record<string, string>,
|
||||
env: Record<string, Record<string, string>> = {}
|
||||
): AgentLaunchSettingsSource => ({
|
||||
agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS, ...args },
|
||||
agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV, ...env }
|
||||
})
|
||||
|
||||
describe('one-time move of permission flags out of Arguments', () => {
|
||||
it('imports Grok modes from recorded 1.0.62 help without taking ownership of other modes', () => {
|
||||
// Installed 1.0.62 (1474f9cbdd91) --help, permission-mode excerpt; not captured argv.
|
||||
const recordedHelp = ` --permission-mode <MODE>
|
||||
Permission mode
|
||||
|
||||
[possible values: default, acceptEdits, auto, dontAsk, bypassPermissions, plan]`
|
||||
const option = recordedHelp.trim().split(' ')[0]
|
||||
const modes = recordedHelp.match(/possible values: ([^\]]+)/)?.[1].split(', ')
|
||||
expect(modes).toBeDefined()
|
||||
for (const mode of modes ?? []) {
|
||||
const text = `${option} ${mode} --model grok-code-fast-1`
|
||||
const migrated = migrateAgentPermissionChoice({ agentDefaultArgs: { grok: text } })
|
||||
const choice =
|
||||
mode === 'default' ? 'ask' : mode === 'bypassPermissions' ? 'full-access' : 'own'
|
||||
expect(resolveAgentPermission('grok', migrated).choice).toBe(choice)
|
||||
expect(migrated.agentDefaultArgs.grok).toBe(
|
||||
choice === 'own' ? text : '--model grok-code-fast-1'
|
||||
)
|
||||
expect(resolveTuiAgentLaunchArgs('grok', migrated)).toBe(text)
|
||||
}
|
||||
})
|
||||
|
||||
it('turns saved shapes into a stored choice and leaves the rest of the text', () => {
|
||||
const migrated = migrateAgentPermissionChoice(
|
||||
legacy(
|
||||
{
|
||||
claude: '--model opus --dangerously-skip-permissions',
|
||||
codex: '--approve-for-me -c model_reasoning_effort="high"',
|
||||
gemini: '',
|
||||
// Not one of Orca's flags: left whole, and Orca adds nothing.
|
||||
grok: '--permission-mode plan'
|
||||
},
|
||||
{ goose: { goose_mode: 'auto', OTHER: '1' } }
|
||||
),
|
||||
'win32'
|
||||
)
|
||||
expect(migrated.agentPermissionDefault).toBe('full-access')
|
||||
expect(migrated.agentDefaultArgs).toMatchObject({
|
||||
claude: '--model opus',
|
||||
codex: '-c model_reasoning_effort="high"',
|
||||
gemini: '',
|
||||
grok: '--permission-mode plan',
|
||||
cursor: ''
|
||||
})
|
||||
// Windows reads env names case-insensitively; unrelated entries stay.
|
||||
expect(migrated.agentDefaultEnv.goose).toEqual({ OTHER: '1' })
|
||||
expect(migrated.agentPermissionOverrides).toEqual({
|
||||
codex: 'auto',
|
||||
gemini: 'own',
|
||||
grok: 'own',
|
||||
opencode: 'own',
|
||||
opencode2: 'own',
|
||||
omp: 'own'
|
||||
})
|
||||
// Terminals launch what they launched before the move.
|
||||
expect(resolveTuiAgentLaunchArgs('claude', migrated)).toBe(
|
||||
'--dangerously-skip-permissions --model opus'
|
||||
)
|
||||
expect(resolveTuiAgentLaunchArgs('grok', migrated)).toBe('--permission-mode plan')
|
||||
expect(resolveTuiAgentLaunchEnv('goose', migrated)).toEqual({ GOOSE_MODE: 'auto', OTHER: '1' })
|
||||
})
|
||||
|
||||
it('cuts a flag beside quoted text, and leaves a quoted flag as the user’s own text', () => {
|
||||
const migrated = migrateAgentPermissionChoice(
|
||||
legacy({ claude: '--name "a b" --dangerously-skip-permissions' })
|
||||
)
|
||||
expect(migrated.agentDefaultArgs.claude).toBe('--name "a b"')
|
||||
const quoted = migrateAgentPermissionChoice(
|
||||
legacy({ claude: '"--dangerously-skip-permissions"' })
|
||||
)
|
||||
expect(quoted.agentPermissionOverrides.claude).toBe('own')
|
||||
expect(quoted.agentDefaultArgs.claude).toBe('"--dangerously-skip-permissions"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal launch composes the stored choice', () => {
|
||||
const typed = {
|
||||
agentPermissionDefault: 'full-access',
|
||||
agentPermissionOverrides: { claude: 'ask', codex: 'own' },
|
||||
agentDefaultArgs: { claude: '--model opus', codex: '--search', grok: '--permission-mode plan' }
|
||||
} as const
|
||||
|
||||
it('adds Orca’s flag ahead of the user’s text, nothing for own or text that sets permissions', () => {
|
||||
expect(resolveTuiAgentLaunchArgs('claude', typed)).toBe(
|
||||
'--permission-mode default --model opus'
|
||||
)
|
||||
expect(resolveTuiAgentLaunchArgs('codex', typed)).toBe('--search')
|
||||
expect(resolveTuiAgentLaunchArgs('grok', typed)).toBe('--permission-mode plan')
|
||||
expect(resolveTuiAgentLaunchArgs('gemini', typed)).toBe('--yolo')
|
||||
// Stored intent survives; the terminal composes no unsupported recipe.
|
||||
expect(resolveTuiAgentLaunchArgs('gemini', { agentPermissionDefault: 'ask' })).toBe('')
|
||||
})
|
||||
|
||||
it('passes an older host’s published text through unchanged', () => {
|
||||
expect(
|
||||
resolveTuiAgentLaunchArgs('claude', {
|
||||
agentDefaultArgs: { claude: '--permission-mode auto' }
|
||||
})
|
||||
).toBe('--permission-mode auto')
|
||||
})
|
||||
|
||||
it('Auto for all agents keeps the others on what they had', () => {
|
||||
const next = applyAllAgentsPermission({ agentPermissionDefault: 'full-access' }, 'auto')
|
||||
expect(resolveAgentPermission('claude', next).choice).toBe('auto')
|
||||
expect(resolveAgentPermission('gemini', next).choice).toBe('full-access')
|
||||
})
|
||||
})
|
||||
|
||||
describe('older clients against a host with the stored choice', () => {
|
||||
const host = {
|
||||
...migrateAgentPermissionChoice(legacy({ codex: '--approve-for-me --search' })),
|
||||
nativeChatPermissionMode: 'ask'
|
||||
} satisfies AgentLaunchSettingsSource
|
||||
|
||||
it('see Arguments with the flag included', () => {
|
||||
const projected = projectAgentLaunchSettings(host)
|
||||
expect(projected.agentDefaultArgs.codex).toBe('--approve-for-me --search')
|
||||
expect(projected.agentPermission?.userArgs.codex).toBe('--search')
|
||||
})
|
||||
|
||||
it('deleting the published flag means the agent’s own settings; another flag becomes the choice', () => {
|
||||
const deleted = agentLaunchSettingsMutationUpdates(
|
||||
host,
|
||||
{ type: 'arguments', agent: 'codex', value: '--search' },
|
||||
'linux'
|
||||
)
|
||||
expect(resolveAgentPermission('codex', { ...host, ...deleted }).choice).toBe('own')
|
||||
const swapped = agentLaunchSettingsMutationUpdates(
|
||||
host,
|
||||
{
|
||||
type: 'arguments',
|
||||
agent: 'codex',
|
||||
value: '--dangerously-bypass-approvals-and-sandbox --search'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
expect({ ...host, ...swapped }.agentDefaultArgs?.codex).toBe('--search')
|
||||
expect(resolveAgentPermission('codex', { ...host, ...swapped }).choice).toBe('full-access')
|
||||
const manual = agentLaunchSettingsMutationUpdates(
|
||||
host,
|
||||
{ type: 'permissions', mode: 'manual', agent: 'claude' },
|
||||
'linux'
|
||||
)
|
||||
expect(resolveTuiAgentLaunchArgs('claude', { ...host, ...manual })).toBe('')
|
||||
})
|
||||
|
||||
it('new chats start in the stored choice; agents on their own settings keep the Chat default', () => {
|
||||
expect(newChatPermissionModeForSettings('codex', host)).toBe('auto')
|
||||
const own = { ...host, agentPermissionOverrides: { codex: 'own' as const } }
|
||||
expect(newChatPermissionModeForSettings('codex', own)).toBe('ask')
|
||||
})
|
||||
})
|
||||
|
||||
describe('permission carrier ownership across save, reload and consumers', () => {
|
||||
const disagreement = '--model op\\ us --permission-mode auto'
|
||||
const repeated = '--dangerously-skip-permissions '.repeat(20).trim()
|
||||
const ownershipCases = [
|
||||
[
|
||||
'claude',
|
||||
'--model opus --permission-mode auto',
|
||||
'--model opus',
|
||||
'--permission-mode auto --model opus',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
'--permission-mode auto --model opus',
|
||||
'--model opus',
|
||||
'--permission-mode auto --model opus',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
'--append-system-prompt --dangerously-skip-permissions',
|
||||
'--append-system-prompt --dangerously-skip-permissions',
|
||||
'--permission-mode auto --append-system-prompt --dangerously-skip-permissions',
|
||||
'auto',
|
||||
false
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
'--tools Bash --dangerously-skip-permissions Read',
|
||||
'--tools Bash --dangerously-skip-permissions Read',
|
||||
'--tools Bash --dangerously-skip-permissions Read',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
'--unknown value --permission-mode auto',
|
||||
'--unknown value --permission-mode auto',
|
||||
'--unknown value --permission-mode auto',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
'--unknown value',
|
||||
'--unknown value',
|
||||
'--permission-mode auto --unknown value',
|
||||
'auto',
|
||||
false
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
'-- --dangerously-skip-permissions',
|
||||
'-- --dangerously-skip-permissions',
|
||||
'--permission-mode auto -- --dangerously-skip-permissions',
|
||||
'auto',
|
||||
false
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
'--append-system-prompt -- --permission-mode auto',
|
||||
'--append-system-prompt --',
|
||||
'--permission-mode auto --append-system-prompt --',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
'resume --permission-mode auto',
|
||||
'resume --permission-mode auto',
|
||||
'resume --permission-mode auto',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
'--unknown --append-system-prompt --dangerously-skip-permissions',
|
||||
'--unknown --append-system-prompt --dangerously-skip-permissions',
|
||||
'--unknown --append-system-prompt --dangerously-skip-permissions',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
'--unknown -- --dangerously-skip-permissions',
|
||||
'--unknown -- --dangerously-skip-permissions',
|
||||
'--unknown -- --dangerously-skip-permissions',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
['codex', '--yolo', '--yolo', '--yolo', 'auto', true],
|
||||
['codex', '--not-so-yolo', '--not-so-yolo', '--not-so-yolo', 'auto', true],
|
||||
['codex', '--approve-for-me', '', '--approve-for-me', 'auto', true],
|
||||
['codex', '-sread-only', '-sread-only', '-sread-only', 'auto', true],
|
||||
['codex', '-aon-request', '-aon-request', '-aon-request', 'auto', true],
|
||||
[
|
||||
'codex',
|
||||
'-c sandbox_mode="read-only"',
|
||||
'-c sandbox_mode="read-only"',
|
||||
'-c sandbox_mode="read-only"',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'codex',
|
||||
'--config approval_policy=on-request',
|
||||
'--config approval_policy=on-request',
|
||||
'--config approval_policy=on-request',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'codex',
|
||||
'-capprovals_reviewer=auto_review',
|
||||
'-capprovals_reviewer=auto_review',
|
||||
'-capprovals_reviewer=auto_review',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'codex',
|
||||
'--config=sandbox_workspace_write.network_access=true',
|
||||
'--config=sandbox_workspace_write.network_access=true',
|
||||
'--config=sandbox_workspace_write.network_access=true',
|
||||
'auto',
|
||||
true
|
||||
],
|
||||
[
|
||||
'codex',
|
||||
'-c model_reasoning_effort=high',
|
||||
'-c model_reasoning_effort=high',
|
||||
'--approve-for-me -c model_reasoning_effort=high',
|
||||
'auto',
|
||||
false
|
||||
],
|
||||
[
|
||||
'codex',
|
||||
`-c '"sandbox_mode"=read-only'`,
|
||||
`-c '"sandbox_mode"=read-only'`,
|
||||
`--approve-for-me -c '"sandbox_mode"=read-only'`,
|
||||
'auto',
|
||||
false
|
||||
],
|
||||
['claude', repeated, repeated, repeated, 'auto', true],
|
||||
['claude', disagreement, disagreement, disagreement, 'auto', true],
|
||||
['grok', '--yolo', '--yolo', '--yolo', 'ask', true]
|
||||
] as const
|
||||
it.each(
|
||||
ownershipCases.flatMap(([agent, value, raw, launch, chat, carrier]) =>
|
||||
(value.includes('--model opus') || value === repeated ? [20, 200] : [1]).map((n) => {
|
||||
const scale = (text: string) =>
|
||||
text === repeated
|
||||
? '--dangerously-skip-permissions '.repeat(n).trim()
|
||||
: text.replace('--model opus', '--model opus '.repeat(n).trim())
|
||||
return [agent, scale(value), scale(raw), scale(launch), chat, carrier, n] as const
|
||||
})
|
||||
)
|
||||
)('%s preserves ownership for %s', (agent, value, raw, launch, chat, carrier, n) => {
|
||||
const before: AgentLaunchSettingsSource = {
|
||||
agentPermissionDefault: agent === 'grok' ? 'ask' : 'auto'
|
||||
}
|
||||
const start = [importWork.chars, importWork.visits]
|
||||
expect(
|
||||
migrateAgentPermissionChoice({ agentDefaultArgs: { [agent]: value } }, 'linux')
|
||||
.agentDefaultArgs[agent]
|
||||
).toBe(raw)
|
||||
const updates = agentLaunchSettingsMutationUpdates(
|
||||
before,
|
||||
{ type: 'user-arguments', agent, value },
|
||||
'linux'
|
||||
)
|
||||
const beforeReload = importWork.chars
|
||||
const loaded = migrateAgentPermissionChoice(
|
||||
JSON.parse(JSON.stringify({ ...before, ...updates })),
|
||||
'linux'
|
||||
)
|
||||
expect(importWork.chars).toBe(beforeReload)
|
||||
if (value.length > 100) {
|
||||
const key = value.startsWith('--permission-mode')
|
||||
? 'leading'
|
||||
: raw === value
|
||||
? 'rejected'
|
||||
: 'tail'
|
||||
const size = [importWork.chars - start[0], importWork.visits - start[1]]
|
||||
expectSettingsWorkScale(`import-${key}`, n, size)
|
||||
}
|
||||
expect(loaded.agentDefaultArgs[agent]).toBe(raw)
|
||||
expect(resolveTuiAgentLaunchArgs(agent, loaded)).toBe(launch)
|
||||
expect(projectAgentLaunchSettings(loaded).agentDefaultArgs[agent]).toBe(launch)
|
||||
expect(newChatPermissionModeForSettings(agent, loaded)).toBe(chat)
|
||||
expect(agentArgsSetPermission(agent, value)).toBe(carrier)
|
||||
})
|
||||
|
||||
it('keeps a Gemini mode from recorded 0.63.0 help user-owned through import and composition', () => {
|
||||
// Installed 0.63.0 --help, ticket 1916: syntax evidence, without a provider turn.
|
||||
const recordedHelp = ` --approval-mode Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode) [string] [choices: "default", "auto_edit", "yolo", "plan"]`
|
||||
const option = recordedHelp.trim().split(/\s+/)[0]
|
||||
const mode = recordedHelp.match(/choices: "([^"]+)"/)?.[1]
|
||||
const text = `${option} ${mode}`
|
||||
expect(text).toBe('--approval-mode default')
|
||||
const imported = migrateAgentPermissionChoice({ agentDefaultArgs: { gemini: text } })
|
||||
expect(imported.agentDefaultArgs.gemini).toBe(text)
|
||||
expect(resolveAgentPermission('gemini', imported).choice).toBe('own')
|
||||
const full = { ...imported, agentPermissionDefault: 'full-access' as const }
|
||||
expect(resolveAgentPermission('gemini', full).choice).toBe('full-access')
|
||||
expect(resolveTuiAgentLaunchArgs('gemini', full)).toBe(text)
|
||||
})
|
||||
|
||||
it.each(['--approval-mode auto_edit', '--approval-mode=plan', '-y'])(
|
||||
'preserves Gemini literal %s under Full through save and reload',
|
||||
(value) => {
|
||||
const before: AgentLaunchSettingsSource = { agentPermissionDefault: 'full-access' }
|
||||
const updates = agentLaunchSettingsMutationUpdates(
|
||||
before,
|
||||
{ type: 'user-arguments', agent: 'gemini', value },
|
||||
'linux'
|
||||
)
|
||||
const loaded = migrateAgentPermissionChoice(
|
||||
JSON.parse(JSON.stringify({ ...before, ...updates })),
|
||||
'linux'
|
||||
)
|
||||
expect(loaded.agentDefaultArgs.gemini).toBe(value)
|
||||
expect(loaded.agentPermissionOverrides).toEqual({})
|
||||
expect(resolveAgentPermission('gemini', loaded).choice).toBe('full-access')
|
||||
expect(resolveTuiAgentLaunchArgs('gemini', loaded)).toBe(value)
|
||||
expect(projectAgentLaunchSettings(loaded).agentDefaultArgs.gemini).toBe(value)
|
||||
expect(agentArgsSetPermission('gemini', value)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(
|
||||
(['linux', 'win32'] as const).flatMap((platform) =>
|
||||
[20, 200].map((n) => [platform, n] as const)
|
||||
)
|
||||
)('prepares Goose with ordered semantics on %s (%i)', (platform, n) => {
|
||||
const carrierName = (key: string) =>
|
||||
(platform === 'win32' ? key.toUpperCase() : key) === 'GOOSE_MODE'
|
||||
const aliases = Object.fromEntries(
|
||||
Array.from({ length: n }, (_, i) => {
|
||||
let bit = 0
|
||||
return [
|
||||
'GOOSE_MODE'.replace(/[A-Z]/g, (c) => (i & (1 << bit++) ? c.toLowerCase() : c)),
|
||||
'custom'
|
||||
]
|
||||
})
|
||||
)
|
||||
const ordinary = Object.fromEntries(
|
||||
Array.from({ length: n }, (_, i) => [
|
||||
[...'ORDINARYKEY'].map((c, bit) => (i & (1 << bit) ? c.toLowerCase() : c)).join(''),
|
||||
'old'
|
||||
])
|
||||
)
|
||||
const changed = Object.fromEntries(Object.keys(ordinary).map((k) => [k, 'new']))
|
||||
const proto = (value: string) => Object.fromEntries([['__proto__', value]])
|
||||
for (const [row, [raw, requested, mode = 'full-access']] of (
|
||||
[
|
||||
[{}, { goose_mode: 'auto' }],
|
||||
[
|
||||
{ GOOSE_MODE: 'a', goose_mode: 'b' },
|
||||
{ GOOSE_MODE: 'a', goose_mode: 'b', X: 'c' }
|
||||
],
|
||||
[{ GOOSE_MODE: 'custom', goose_mode: 'auto', OTHER: 'keep' }, {}],
|
||||
[proto('own'), proto('next')],
|
||||
[aliases, { ...aliases, X: 'new' }],
|
||||
[aliases, {}],
|
||||
[aliases, aliases],
|
||||
[ordinary, changed, 'ask'],
|
||||
[ordinary, { ...changed, GOOSE_MODE: 'auto' }]
|
||||
] as const
|
||||
).entries()) {
|
||||
const extras = Object.fromEntries(Array.from({ length: n }, (_, i) => [`Other${i}`, {}]))
|
||||
const initial: AgentLaunchSettingsSource = {
|
||||
agentPermissionDefault: mode === 'ask' ? 'ask' : 'full-access',
|
||||
agentDefaultEnv: { ...extras, goose: raw, gemini: { LATEST: 'keep' } },
|
||||
agentPermissionOverrides: Object.fromEntries(
|
||||
Object.keys(extras).map((k) => [k, 'own' as const])
|
||||
)
|
||||
}
|
||||
const previous = resolveTuiAgentLaunchEnv('goose', initial, platform)
|
||||
const changedNames = [
|
||||
...new Set([...Object.keys(previous), ...Object.keys(requested)])
|
||||
].filter((name) => previous[name] !== requested[name])
|
||||
const expectedActions = changedNames.filter(carrierName).length
|
||||
const sequential = changedNames.reduce<AgentLaunchSettingsSource>(
|
||||
(state, name) => ({
|
||||
...state,
|
||||
...agentLaunchSettingsMutationUpdates(
|
||||
state,
|
||||
Object.hasOwn(requested, name)
|
||||
? { type: 'environment-set', agent: 'goose', name, value: requested[name] }
|
||||
: { type: 'environment-remove', agent: 'goose', name },
|
||||
platform
|
||||
)
|
||||
}),
|
||||
initial
|
||||
)
|
||||
let outer = 0
|
||||
type AgentSettingsMap = Record<
|
||||
string,
|
||||
| Record<string, string>
|
||||
| NonNullable<AgentLaunchSettingsSource['agentPermissionDefault']>
|
||||
| undefined
|
||||
>
|
||||
const observe = <T extends AgentSettingsMap>(target: T) =>
|
||||
new Proxy(target, {
|
||||
ownKeys(value) {
|
||||
const keys = Reflect.ownKeys(value)
|
||||
outer += 2 * keys.length
|
||||
return keys
|
||||
},
|
||||
get(value, key) {
|
||||
outer++
|
||||
return typeof key === 'string' ? value[key] : undefined
|
||||
}
|
||||
})
|
||||
initial.agentDefaultEnv = observe(initial.agentDefaultEnv!)
|
||||
initial.agentPermissionOverrides = observe(initial.agentPermissionOverrides!)
|
||||
const receipt = measureSettingsWork(() =>
|
||||
wholeAgentEnvironmentUpdates(initial, 'goose', requested, platform)
|
||||
)
|
||||
const d = Object.keys(raw).filter(carrierName).length
|
||||
expect(outer).toBeLessThanOrEqual(4 * (2 * n + 2))
|
||||
const ordinary =
|
||||
Object.keys(raw).filter((k) => !carrierName(k)).length +
|
||||
Object.keys(requested).filter((k) => !carrierName(k)).length
|
||||
expect(receipt.work.assignments).toBe(expectedActions)
|
||||
// Removal/rename rows empty the raw carrier group on the first action; later raw groups stay empty.
|
||||
const complete =
|
||||
row >= 7
|
||||
? 13 * n + 1 + (row === 8 ? 3 * n + 24 : 0)
|
||||
: (expectedActions ? 20 * (d + 2 * expectedActions) : 0) + 12 * (d + ordinary + 1)
|
||||
const entries = receipt.work.entries + complete
|
||||
expect(entries).toBeLessThanOrEqual(
|
||||
row >= 7
|
||||
? 36 * (n + (row === 8 ? 1 : 0)) + 64
|
||||
: 64 * (d + expectedActions + 1) + 36 * ordinary
|
||||
)
|
||||
if (row >= 7 && platform === 'win32') {
|
||||
expect(receipt.value.agentDefaultEnv?.goose).toEqual({
|
||||
[Object.keys(changed).at(-1)!]: 'new'
|
||||
})
|
||||
}
|
||||
const key = `env-${platform}-${row}`,
|
||||
counts = [entries + outer, receipt.work.bytes]
|
||||
expectSettingsWorkScale(key, n, counts)
|
||||
if (row === 6) {
|
||||
expect(receipt.value).toEqual({})
|
||||
}
|
||||
expect(receipt.work.comparisons).toBeLessThanOrEqual(
|
||||
16 * (d + 1) * Math.ceil(Math.log2(d + 1))
|
||||
)
|
||||
const prepared = { ...initial, ...receipt.value }
|
||||
expect(prepared).toEqual(sequential)
|
||||
expect(Object.keys(prepared.agentDefaultEnv?.goose ?? {})).toEqual(
|
||||
Object.keys(sequential.agentDefaultEnv?.goose ?? {})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('applies combined old-client carrier edits against the original projection', () => {
|
||||
const host: AgentLaunchSettingsSource = {
|
||||
agentPermissionDefault: 'full-access',
|
||||
agentDefaultEnv: { goose: { KEEP: '1' } }
|
||||
}
|
||||
const updated = {
|
||||
...host,
|
||||
...legacyLaunchTextUpdates(
|
||||
host,
|
||||
{
|
||||
agentDefaultArgs: { goose: '--model foo', claude: '--permission-mode auto --model opus' },
|
||||
agentDefaultEnv: { goose: { GOOSE_MODE: 'auto', KEEP: '2' } }
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
}
|
||||
expect(resolveTuiAgentLaunchEnv('goose', updated, 'linux')).toEqual({
|
||||
GOOSE_MODE: 'auto',
|
||||
KEEP: '2'
|
||||
})
|
||||
expect(resolveTuiAgentLaunchArgs('goose', updated)).toBe('--model foo')
|
||||
expect(newChatPermissionModeForSettings('claude', updated)).toBe('auto')
|
||||
expect(updated.agentDefaultArgs?.claude).toBe('--model opus')
|
||||
})
|
||||
|
||||
it('uses host environment identity without guessing duplicate Windows values', () => {
|
||||
const raw = legacy({}, { goose: { goose_mode: 'auto', KEEP: '1' } })
|
||||
const posix = migrateAgentPermissionChoice(raw, 'linux')
|
||||
expect(resolveTuiAgentLaunchEnv('goose', posix, 'linux')).toEqual({
|
||||
goose_mode: 'auto',
|
||||
KEEP: '1'
|
||||
})
|
||||
const explicit = { ...posix, agentPermissionOverrides: { goose: 'full-access' as const } }
|
||||
expect(resolveTuiAgentLaunchEnv('goose', explicit, 'linux')).toEqual({
|
||||
GOOSE_MODE: 'auto',
|
||||
goose_mode: 'auto',
|
||||
KEEP: '1'
|
||||
})
|
||||
const unknown = migrateAgentPermissionChoice(raw, null)
|
||||
expect(unknown.agentDefaultEnv.goose).toEqual({ goose_mode: 'auto', KEEP: '1' })
|
||||
const windows = migrateAgentPermissionChoice(raw, 'win32')
|
||||
expect(resolveTuiAgentLaunchEnv('goose', windows, 'win32')).toEqual({
|
||||
GOOSE_MODE: 'auto',
|
||||
KEEP: '1'
|
||||
})
|
||||
const duplicate = migrateAgentPermissionChoice(
|
||||
legacy({}, { goose: { GOOSE_MODE: 'auto', goose_mode: 'approve' } }),
|
||||
'win32'
|
||||
)
|
||||
expect(resolveAgentPermission('goose', duplicate).choice).toBe('own')
|
||||
expect(resolveTuiAgentLaunchEnv('goose', duplicate, 'win32')).toEqual({
|
||||
GOOSE_MODE: 'auto',
|
||||
goose_mode: 'approve'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves chat-only migration defaults, then seeds Ask and retains raw choices through Auto', () => {
|
||||
const agents = ['antigravity', 'opencode', 'opencode2', 'omp'] as const
|
||||
const upgraded = {
|
||||
...migrateAgentPermissionChoice(legacy({})),
|
||||
nativeChatPermissionMode: 'ask' as const
|
||||
}
|
||||
for (const agent of ['opencode', 'opencode2', 'omp'] as const) {
|
||||
expect(newChatPermissionModeForSettings(agent, upgraded)).toBe('ask')
|
||||
}
|
||||
const ask = { ...upgraded, ...applyAllAgentsPermission(upgraded, 'ask') }
|
||||
for (const agent of agents) {
|
||||
expect(resolveTuiAgentLaunchArgs(agent, ask)).toBe('')
|
||||
expect(newChatPermissionModeForSettings(agent, ask)).toBe('ask')
|
||||
const edited = {
|
||||
...ask,
|
||||
...agentLaunchSettingsMutationUpdates(
|
||||
ask,
|
||||
{ type: 'arguments', agent, value: '--model foo' },
|
||||
'linux'
|
||||
)
|
||||
}
|
||||
expect(newChatPermissionModeForSettings(agent, edited)).toBe('ask')
|
||||
}
|
||||
const own = { ...ask, agentPermissionOverrides: { omp: 'own' as const } }
|
||||
const auto = { ...own, ...applyAllAgentsPermission(own, 'auto') }
|
||||
expect(resolveAgentPermission('omp', auto).choice).toBe('own')
|
||||
for (const agent of ['antigravity', 'opencode', 'opencode2'] as const) {
|
||||
expect(newChatPermissionModeForSettings(agent, auto)).toBe('ask')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const importWork = { chars: 0, visits: 0 }
|
||||
const cut = permissionArgs.permissionRecipeCut
|
||||
vi.spyOn(permissionArgs, 'permissionRecipeCut').mockImplementation((agent, text, recipe) => {
|
||||
const { value, work } = measureSettingsWork(() => cut(agent, text, recipe))
|
||||
importWork.chars += work.chars
|
||||
importWork.visits += work.visits
|
||||
if (text.length < 100) {
|
||||
return value
|
||||
}
|
||||
const calls = work.inputs,
|
||||
w = recipe.split(' ').length,
|
||||
t = text.split(' ').length
|
||||
expect(calls.filter((v) => v === text)).toHaveLength(3)
|
||||
// Fixed token/span/owner allowance plus native slices and two B construction passes per candidate.
|
||||
for (const [actual, bound] of [
|
||||
[calls.filter((v) => v.length > 100 && v !== text).length, 3 * (w + 1)],
|
||||
[work.chars, 3 * (w + 2) * text.length + 4096],
|
||||
[work.entries + work.visits, 3 * (2 + 10 * (w + 1)) * (t + w) + 4096],
|
||||
[work.bytes + 2 * (w + 1) * text.length, 12 * (w + 1) * text.length + 4096]
|
||||
]) {
|
||||
expect(actual).toBeLessThanOrEqual(bound)
|
||||
}
|
||||
return value
|
||||
})
|
||||
it.each([false, true])('bounds actual startup/raw resume (%s)', (resume) => {
|
||||
for (const n of [20, 200]) {
|
||||
const models = '--model opus '.repeat(n).trim(),
|
||||
raw = Array(n).fill("'--model' 'opus'").join(' ')
|
||||
const { value: plan, work } = measureSettingsWork(() =>
|
||||
(resume ? buildAgentResumeStartupPlan : buildAgentStartupPlan)({
|
||||
agent: 'claude',
|
||||
cmdOverrides: {},
|
||||
platform: 'linux',
|
||||
agentArgs: models,
|
||||
sessionOptions: { model: 'opus' },
|
||||
sessionOptionsOverrideAgentArgs: resume,
|
||||
prompt: '',
|
||||
allowEmptyPromptLaunch: true,
|
||||
providerSession: { key: 'session_id', id: 's' }
|
||||
})
|
||||
)
|
||||
expect(plan?.launchConfig.agentCommand).toBe(`claude ${raw}`)
|
||||
expect(plan?.launchCommand).toBe(`claude ${resume ? "'--model' 'opus' '--resume' 's'" : raw}`)
|
||||
expect(work.visits).toBeLessThanOrEqual(40 * (2 * n + 1))
|
||||
expectSettingsWorkScale(`startup-${resume}`, n, [work.visits])
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { agentChatPermissionModes } from './agent-chat-permission-support'
|
||||
import type { AgentChatPermissionMode } from './agent-chat-permission-mode'
|
||||
import { isTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import {
|
||||
AUTO_TUI_AGENT_ARGS,
|
||||
YOLO_TUI_AGENT_ARGS,
|
||||
YOLO_TUI_AGENT_ENV
|
||||
} from './tui-agent-permissions'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
|
||||
/**
|
||||
* An agent's stored permission. `own`: Orca adds no permission flag and the agent's own config
|
||||
* applies. The rest use the chat's four modes. Saved in settings: never rename a value.
|
||||
*/
|
||||
export type AgentPermissionChoice = 'own' | AgentChatPermissionMode
|
||||
|
||||
export const AGENT_PERMISSION_CHOICES = [
|
||||
'own',
|
||||
'ask',
|
||||
'accept-edits',
|
||||
'auto',
|
||||
'full-access'
|
||||
] as const satisfies readonly AgentPermissionChoice[]
|
||||
|
||||
export type AgentPermissionGlobalSettings = {
|
||||
/** Every agent's permission in terminals and new chats, unless it has its own below. Absent on
|
||||
* profiles saved before permissions were stored apart from Arguments. */
|
||||
agentPermissionDefault?: AgentPermissionChoice
|
||||
/** Agents given their own permission instead of the all-agents one. */
|
||||
agentPermissionOverrides?: Partial<Record<TuiAgent, AgentPermissionChoice>>
|
||||
}
|
||||
|
||||
export function isAgentPermissionChoice(value: unknown): value is AgentPermissionChoice {
|
||||
return AGENT_PERMISSION_CHOICES.some((choice) => choice === value)
|
||||
}
|
||||
|
||||
// Why only these: each recipe is verified against its CLI. Ask states every field Orca's own Ask
|
||||
// chat states (Codex: approvals, sandbox and reviewer), so an agent's config can't widen it.
|
||||
const ASK_TUI_AGENT_ARGS: Partial<Record<TuiAgent, string>> = {
|
||||
claude: '--permission-mode default',
|
||||
'claude-agent-teams': '--permission-mode default',
|
||||
grok: '--permission-mode default',
|
||||
codex: '-a on-request -s workspace-write -c approvals_reviewer=user'
|
||||
}
|
||||
|
||||
const ACCEPT_EDITS_TUI_AGENT_ARGS: Partial<Record<TuiAgent, string>> = {
|
||||
claude: '--permission-mode acceptEdits',
|
||||
'claude-agent-teams': '--permission-mode acceptEdits'
|
||||
}
|
||||
|
||||
const ARGS_BY_CHOICE: Record<AgentChatPermissionMode, Partial<Record<TuiAgent, string>>> = {
|
||||
ask: ASK_TUI_AGENT_ARGS,
|
||||
'accept-edits': ACCEPT_EDITS_TUI_AGENT_ARGS,
|
||||
auto: AUTO_TUI_AGENT_ARGS,
|
||||
'full-access': YOLO_TUI_AGENT_ARGS
|
||||
}
|
||||
|
||||
/** The flag text Orca adds for this choice; empty for `own` or a choice the agent lacks. */
|
||||
export function agentPermissionArgs(agent: TuiAgent, choice: AgentPermissionChoice): string {
|
||||
return choice === 'own' ? '' : (ARGS_BY_CHOICE[choice][agent] ?? '')
|
||||
}
|
||||
|
||||
/** The environment Orca adds for this choice (Goose's bypass is an env var). */
|
||||
export function agentPermissionEnv(
|
||||
agent: TuiAgent,
|
||||
choice: AgentPermissionChoice
|
||||
): Record<string, string> {
|
||||
return choice === 'full-access' ? { ...YOLO_TUI_AGENT_ENV[agent] } : {}
|
||||
}
|
||||
|
||||
function supportsChoice(agent: TuiAgent, choice: AgentPermissionChoice): boolean {
|
||||
return (
|
||||
choice === 'own' ||
|
||||
agentPermissionArgs(agent, choice) !== '' ||
|
||||
Object.keys(agentPermissionEnv(agent, choice)).length > 0 ||
|
||||
agentChatPermissionModes(agent)?.modes.includes(choice) === true
|
||||
)
|
||||
}
|
||||
|
||||
/** Modes supported by at least one of this agent's terminal or chat surfaces. */
|
||||
export function agentPermissionChoices(agent: TuiAgent): readonly AgentPermissionChoice[] {
|
||||
return AGENT_PERMISSION_CHOICES.filter((choice) => supportsChoice(agent, choice))
|
||||
}
|
||||
|
||||
/** Agents with at least one terminal or chat mode beyond `own`, in catalog order. */
|
||||
export const PERMISSION_CHOICE_AGENTS: readonly TuiAgent[] = Object.keys(TUI_AGENT_CONFIG).filter(
|
||||
(agent): agent is TuiAgent => isTuiAgent(agent) && agentPermissionChoices(agent).length > 1
|
||||
)
|
||||
|
||||
export type AgentPermissionSettings = AgentPermissionGlobalSettings
|
||||
|
||||
/** Settings that store the permission as a choice rather than as flag text in Arguments. */
|
||||
export function hasTypedAgentPermission(settings: AgentPermissionSettings | null | undefined) {
|
||||
return isAgentPermissionChoice(settings?.agentPermissionDefault)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads stored intent. Each consuming surface applies its own supported-mode rules.
|
||||
*/
|
||||
export function resolveAgentPermission(
|
||||
agent: TuiAgent,
|
||||
settings: AgentPermissionSettings | null | undefined
|
||||
): { choice: AgentPermissionChoice; source: 'agent' | 'all' } {
|
||||
const override = settings?.agentPermissionOverrides?.[agent]
|
||||
if (isAgentPermissionChoice(override)) {
|
||||
return { choice: override, source: 'agent' }
|
||||
}
|
||||
const fallback = settings?.agentPermissionDefault
|
||||
const choice = isAgentPermissionChoice(fallback) ? fallback : 'own'
|
||||
return { choice, source: 'all' }
|
||||
}
|
||||
|
||||
export function normalizeAgentPermissionOverrides(
|
||||
value: unknown
|
||||
): NonNullable<AgentPermissionGlobalSettings['agentPermissionOverrides']> {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
([agent, choice]) => isTuiAgent(agent) && isAgentPermissionChoice(choice)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The all-agents choice applied: every agent follows it, except that Auto leaves agents without
|
||||
* it on what they had, as the Auto switch always has (falling to `own` could widen or narrow them).
|
||||
*/
|
||||
export function applyAllAgentsPermission(
|
||||
settings: AgentPermissionSettings,
|
||||
choice: AgentPermissionChoice
|
||||
): Required<AgentPermissionSettings> {
|
||||
const overrides: Partial<Record<TuiAgent, AgentPermissionChoice>> = {}
|
||||
if (choice === 'auto') {
|
||||
for (const agent of PERMISSION_CHOICE_AGENTS) {
|
||||
const previous = resolveAgentPermission(agent, settings).choice
|
||||
if (!supportsChoice(agent, 'auto')) {
|
||||
overrides[agent] = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
return { agentPermissionDefault: choice, agentPermissionOverrides: overrides }
|
||||
}
|
||||
|
||||
/** Sets agents' own choices; `'default'` makes them follow the all-agents choice again. */
|
||||
export function applyAgentPermissionOverrides(
|
||||
settings: AgentPermissionSettings,
|
||||
agents: readonly TuiAgent[],
|
||||
choice: AgentPermissionChoice | 'default'
|
||||
): Pick<AgentPermissionGlobalSettings, 'agentPermissionOverrides'> {
|
||||
const overrides = { ...settings.agentPermissionOverrides }
|
||||
for (const agent of agents) {
|
||||
if (choice === 'default') {
|
||||
delete overrides[agent]
|
||||
} else {
|
||||
overrides[agent] = choice
|
||||
}
|
||||
}
|
||||
return { agentPermissionOverrides: overrides }
|
||||
}
|
||||
|
||||
/** Normalizes the stored permission in a settings write. An unknown all-agents choice is dropped:
|
||||
* saved, it would read as legacy text and lose every agent's stored permission. */
|
||||
export function sanitizeAgentPermissionUpdates(
|
||||
target: AgentPermissionGlobalSettings,
|
||||
updates: AgentPermissionGlobalSettings
|
||||
): void {
|
||||
if (
|
||||
'agentPermissionDefault' in updates &&
|
||||
!isAgentPermissionChoice(updates.agentPermissionDefault)
|
||||
) {
|
||||
delete target.agentPermissionDefault
|
||||
}
|
||||
if ('agentPermissionOverrides' in updates) {
|
||||
target.agentPermissionOverrides = normalizeAgentPermissionOverrides(
|
||||
updates.agentPermissionOverrides
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { agentPermissionHostPlatform, permissionEnvNameMatches } from './agent-permission-env'
|
||||
import type { AgentChatPermissionMode } from './agent-chat-permission-mode'
|
||||
import type { GlobalSettings } from './global-settings-types'
|
||||
import {
|
||||
agentPermissionArgs,
|
||||
agentPermissionChoices,
|
||||
agentPermissionEnv,
|
||||
hasTypedAgentPermission,
|
||||
normalizeAgentPermissionOverrides,
|
||||
PERMISSION_CHOICE_AGENTS,
|
||||
resolveAgentPermission,
|
||||
type AgentPermissionChoice
|
||||
} from './tui-agent-permission-choice'
|
||||
import { permissionRecipeCut } from './agent-permission-args'
|
||||
export { agentArgsSetPermission } from './agent-permission-args'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
|
||||
/** Only exact recipes with preserved shell words and CLI ownership become stored intent. */
|
||||
export function liftAgentPermissionArgs(
|
||||
agent: TuiAgent,
|
||||
text: string | null | undefined
|
||||
): { choice: AgentChatPermissionMode | null; args: string } {
|
||||
const original = text?.trim() ?? ''
|
||||
for (const choice of agentPermissionChoices(agent)) {
|
||||
if (choice === 'own') {
|
||||
continue
|
||||
}
|
||||
const cut = permissionRecipeCut(agent, original, agentPermissionArgs(agent, choice))
|
||||
if (cut) {
|
||||
return { choice, args: cut.args }
|
||||
}
|
||||
}
|
||||
return { choice: null, args: original }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an env-driven bypass (Goose's `GOOSE_MODE=auto`) out of an environment record. Names
|
||||
* follow the execution host's identity; unknown browser caches preserve ambiguous spellings.
|
||||
*/
|
||||
export function liftAgentPermissionEnv(
|
||||
agent: TuiAgent,
|
||||
env: Record<string, string> | null | undefined,
|
||||
platform: NodeJS.Platform | null | undefined = agentPermissionHostPlatform()
|
||||
): { choice: 'full-access' | null; env: Record<string, string> } {
|
||||
const rest = { ...env }
|
||||
const bypass = Object.entries(agentPermissionEnv(agent, 'full-access'))
|
||||
const matches = (name: string, wanted: string): boolean =>
|
||||
permissionEnvNameMatches(name, wanted, platform)
|
||||
const setsBypass = ([wanted, value]: [string, string]): boolean => {
|
||||
const spellings = Object.entries(rest).filter(([name]) => matches(name, wanted))
|
||||
return (
|
||||
spellings.length > 0 &&
|
||||
spellings.every(([, current]) => current === value) &&
|
||||
(platform != null ||
|
||||
!Object.keys(rest).some(
|
||||
(name) => name !== wanted && name.toLowerCase() === wanted.toLowerCase()
|
||||
))
|
||||
)
|
||||
}
|
||||
if (bypass.length === 0 || !bypass.every(setsBypass)) {
|
||||
return { choice: null, env: rest }
|
||||
}
|
||||
for (const name of Object.keys(rest)) {
|
||||
if (bypass.some(([wanted]) => matches(name, wanted))) {
|
||||
delete rest[name]
|
||||
}
|
||||
}
|
||||
return { choice: 'full-access', env: rest }
|
||||
}
|
||||
|
||||
type AgentPermissionProfile = Required<
|
||||
Pick<
|
||||
GlobalSettings,
|
||||
'agentDefaultArgs' | 'agentDefaultEnv' | 'agentPermissionDefault' | 'agentPermissionOverrides'
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* Moves each agent's permission out of its Arguments into a stored choice, once per profile.
|
||||
* Orca's own flags become the choice and leave the text; no flag means the agent's own settings
|
||||
* (those terminals passed none). Text that sets permissions some other way is left whole, on own.
|
||||
*/
|
||||
export function migrateAgentPermissionChoice(
|
||||
settings: Partial<AgentPermissionProfile>,
|
||||
platform: NodeJS.Platform | null | undefined = agentPermissionHostPlatform()
|
||||
): AgentPermissionProfile {
|
||||
const args = { ...settings.agentDefaultArgs }
|
||||
const env = { ...settings.agentDefaultEnv }
|
||||
if (hasTypedAgentPermission(settings)) {
|
||||
return {
|
||||
agentDefaultArgs: args,
|
||||
agentDefaultEnv: env,
|
||||
agentPermissionDefault: settings.agentPermissionDefault ?? 'own',
|
||||
agentPermissionOverrides: normalizeAgentPermissionOverrides(settings.agentPermissionOverrides)
|
||||
}
|
||||
}
|
||||
const choices = new Map<TuiAgent, AgentPermissionChoice>()
|
||||
const counts = new Map<AgentPermissionChoice, number>()
|
||||
for (const agent of PERMISSION_CHOICE_AGENTS) {
|
||||
const fromArgs = liftAgentPermissionArgs(agent, args[agent])
|
||||
const fromEnv = fromArgs.choice ? null : liftAgentPermissionEnv(agent, env[agent], platform)
|
||||
if (fromArgs.choice) {
|
||||
args[agent] = fromArgs.args
|
||||
} else if (fromEnv?.choice && env[agent]) {
|
||||
env[agent] = fromEnv.env
|
||||
}
|
||||
const choice = fromArgs.choice ?? fromEnv?.choice ?? 'own'
|
||||
choices.set(agent, choice)
|
||||
counts.set(choice, (counts.get(choice) ?? 0) + 1)
|
||||
}
|
||||
// The most common choice becomes the all-agents one; ties keep the first counted.
|
||||
let agentPermissionDefault: AgentPermissionChoice = 'own'
|
||||
for (const [choice, count] of counts) {
|
||||
if (count > (counts.get(agentPermissionDefault) ?? 0)) {
|
||||
agentPermissionDefault = choice
|
||||
}
|
||||
}
|
||||
const agentPermissionOverrides: Partial<Record<TuiAgent, AgentPermissionChoice>> = {}
|
||||
for (const [agent, choice] of choices) {
|
||||
if (resolveAgentPermission(agent, { agentPermissionDefault }).choice !== choice) {
|
||||
agentPermissionOverrides[agent] = choice
|
||||
}
|
||||
}
|
||||
return {
|
||||
agentDefaultArgs: args,
|
||||
agentDefaultEnv: env,
|
||||
agentPermissionDefault,
|
||||
agentPermissionOverrides
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ describe('tui agent startup plans', () => {
|
||||
agent: 'claude',
|
||||
prompt: '',
|
||||
agentArgs: resolveTuiAgentLaunchArgs('claude', {
|
||||
claude: '--dangerously-skip-permissions --model Opus'
|
||||
agentDefaultArgs: { claude: '--dangerously-skip-permissions --model Opus' }
|
||||
}),
|
||||
cmdOverrides: {},
|
||||
platform: 'linux',
|
||||
@@ -400,7 +400,7 @@ describe('tui agent startup plans', () => {
|
||||
cmdOverrides: {},
|
||||
platform,
|
||||
shell,
|
||||
agentArgs: resolveTuiAgentLaunchArgs('muse', defaults)
|
||||
agentArgs: resolveTuiAgentLaunchArgs('muse', { agentDefaultArgs: defaults })
|
||||
})
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
@@ -607,7 +607,7 @@ describe('tui agent startup plans', () => {
|
||||
agent: 'opencode',
|
||||
prompt: 'fix it',
|
||||
cmdOverrides: {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs('opencode', agentDefaultArgs),
|
||||
agentArgs: resolveTuiAgentLaunchArgs('opencode', { agentDefaultArgs }),
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user