Files
orca/src/main/runtime/runtime-client-settings.ts
T
Brennan Benson fb1f4b3e6f 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
2026-10-23 16:20:55 -07:00

293 lines
12 KiB
TypeScript

import { getAppEnvironment } from '../../shared/app-environment'
import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate'
import { isAgentSkillSharingEnabled } from '../../shared/agent-skill-sharing-gate'
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
import { TASK_PROVIDERS } from '../../shared/task-providers'
import {
applyTerminalQuickCommandMutation,
MAX_QUICK_COMMANDS,
type TerminalQuickCommandMutation
} from '../../shared/terminal-quick-commands'
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
import { normalizeSourceControlAiSettings } from '../../shared/source-control-ai'
import { SOURCE_CONTROL_LAUNCH_ACTION_IDS } from '../../shared/source-control-ai-actions'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { applyNativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-option-defaults'
import type { NativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-options'
import { getHostDisplayLabelOverrides } from '../../shared/host-setting-overrides'
import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting'
import type { RuntimeStore } from './runtime-store-contract'
import {
agentLaunchSettingsMutationUpdates,
projectAgentLaunchSettings,
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,
RuntimeClientSettingsUpdate,
RuntimeClientSourceControlAi,
RuntimeHostDisplayLabelOverrides
} from './runtime-client-settings-shape'
export class RuntimeClientSettingsController {
private reconciliationGeneration = 0
private reconciliationTail: Promise<void> = Promise.resolve()
constructor(
private readonly store: Pick<
RuntimeStore,
'getSettings' | 'updateSettings' | 'runDurableMutation'
> | null,
private readonly notifyReposChanged: (() => void) | undefined = undefined
) {}
get(): RuntimeClientSettings {
if (!this.store?.getSettings) {
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: 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).
terminalCopyTrimsGutter: settings.terminalCopyTrimsGutter !== false,
defaultTaskSource: settings.defaultTaskSource ?? 'github',
defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues',
visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS],
defaultRepoSelection: settings.defaultRepoSelection ?? null,
// Persisted settings can violate the paired client's string-array contract.
defaultLinearTeamSelection: Array.isArray(settings.defaultLinearTeamSelection)
? settings.defaultLinearTeamSelection.filter((id): id is string => typeof id === 'string')
: null,
githubProjects: settings.githubProjects,
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true,
experimentalNativeChat: settings.experimentalNativeChat === true,
// Older clients use this key to fall back to terminal-backed chat. Keep their default terminal.
openAgentTabsInChatByDefault: false,
experimentalStructuredNativeChat: settings.experimentalNativeChat === true,
compactWorktreeCards: settings.compactWorktreeCards === true,
minimaxGroupId: settings.minimaxGroupId ?? '',
minimaxUsageModels: settings.minimaxUsageModels ?? 'general',
minimaxEndpoint: settings.minimaxEndpoint ?? 'overseas',
zcodePlanSite: settings.zcodePlanSite ?? 'zai',
prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [],
artifactSharingEnabled: isArtifactSharingEnabled(settings),
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings),
machineName: settings.machineName ?? '',
// Why projected: a web client's Settings → Chat edits this host's new-chat default.
...(settings.nativeChatPermissionMode
? { nativeChatPermissionMode: settings.nativeChatPermissionMode }
: {}),
...(settings.agentNativeChatPermissionModes
? { agentNativeChatPermissionModes: settings.agentNativeChatPermissionModes }
: {}),
// Why projected: a paired client's AI buttons start these actions' agents, and must honour
// the agent saved for each one as the desktop does. Absent on older hosts.
sourceControlAi: projectSourceControlLaunchRecipes(settings),
hostSettingOverrides: Object.fromEntries(
[
...getHostDisplayLabelOverrides({ hostSettingOverrides: settings.hostSettingOverrides })
].map(([hostId, displayLabel]) => [hostId, { displayLabel }])
) as RuntimeHostDisplayLabelOverrides
}
}
async update(updates: RuntimeClientSettingsUpdate): Promise<RuntimeClientSettings> {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const beforeSettings = this.store.getSettings()
const before = beforeSettings.agentStatusHooksEnabled !== false
// Legacy clients can select an agent or a shell, but cannot select None.
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: {} }
: {}),
...(updates.defaultTuiAgent !== undefined ? { newWorkspaceSetupOnly: false } : {})
},
{ notifyListeners: true }
)
const settings = this.store.getSettings()
if (updates.worktreeVisibilityDefaults !== undefined) {
this.notifyReposChanged?.()
}
if (
(typeof updates.agentStatusHooksEnabled === 'boolean' &&
before !== updates.agentStatusHooksEnabled) ||
(updates.disabledTuiAgents !== undefined &&
!haveSameDisabledTuiAgents(beforeSettings.disabledTuiAgents, settings.disabledTuiAgents))
) {
await this.reconcileManagedAgentHooks()
}
return this.get()
}
getTerminalQuickCommands(): TerminalQuickCommand[] {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
}
return this.store.getSettings().terminalQuickCommands ?? []
}
getAgentLaunch(): AgentLaunchSettings {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
}
return projectAgentLaunchSettings(this.store.getSettings(), process.platform)
}
async mutateAgentLaunch(mutation: AgentLaunchSettingsMutation): Promise<AgentLaunchSettings> {
const store = this.store
if (!store?.getSettings || !store.updateSettings || !store.runDurableMutation) {
throw new Error('runtime_unavailable')
}
const applySettings = store.updateSettings.bind(store)
const committed = await store.runDurableMutation(() => {
const before = store.getSettings()
const updates = agentLaunchSettingsMutationUpdates(before, mutation, process.platform)
applySettings(updates, { notifyListeners: true })
const applied = store.getSettings()
return {
value: {
settings: projectAgentLaunchSettings(applied, process.platform),
reconcileHooks:
updates.disabledTuiAgents !== undefined &&
!haveSameDisabledTuiAgents(before.disabledTuiAgents, applied.disabledTuiAgents)
},
rollback: () => {
const rollback = agentLaunchSettingsRollbackUpdates(
before,
applied,
store.getSettings(),
updates
)
if (Object.keys(rollback).length > 0) {
applySettings(rollback, { notifyListeners: true })
}
}
}
})
if (committed.reconcileHooks) {
void this.reconcileManagedAgentHooks().catch((error) => {
console.error(
'[agent-hooks] Failed to reconcile managed hooks after saving launch settings:',
error
)
})
}
return committed.settings
}
updateTerminalQuickCommands(mutation: TerminalQuickCommandMutation): TerminalQuickCommand[] {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const current = this.getTerminalQuickCommands()
if (
mutation.type === 'upsert' &&
!current.some((command) => command.id === mutation.command.id) &&
current.length >= MAX_QUICK_COMMANDS
) {
throw new Error('Quick command limit reached')
}
const next = applyTerminalQuickCommandMutation(current, mutation)
this.store.updateSettings({ terminalQuickCommands: next }, { notifyListeners: true })
return this.getTerminalQuickCommands()
}
updatePRBotAuthorOverride(args: { author: string; isBot: boolean }): RuntimeClientSettings {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const current = this.store.getSettings().prBotAuthorOverrides
this.store.updateSettings(
{ prBotAuthorOverrides: applyPRBotAuthorOverride(current, args.author, args.isBot) },
{ notifyListeners: true }
)
return this.get()
}
updateNativeChatSessionOptions(mutation: NativeChatSessionOptionSettingsMutation): void {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const next = applyNativeChatSessionOptionSettingsMutation(
this.store.getSettings().nativeChatSessionOptions,
mutation
)
if (next) {
this.store.updateSettings({ nativeChatSessionOptions: next }, { notifyListeners: true })
}
}
private reconcileManagedAgentHooks(): Promise<void> {
const generation = ++this.reconciliationGeneration
const reconciliation = this.reconciliationTail.then(async () => {
if (generation !== this.reconciliationGeneration) {
return
}
const settings = this.store?.getSettings()
if (!settings) {
return
}
await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, {
shouldHydrateShellPath: getAppEnvironment().isPackaged(),
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const current = this.store?.getSettings()
return current !== undefined && isAgentStatusHooksEnabledForAgent(current, agent)
}
})
})
this.reconciliationTail = reconciliation.catch(() => {})
return reconciliation
}
}
function projectSourceControlLaunchRecipes(
settings: Partial<Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'>>
): RuntimeClientSourceControlAi {
const { actions } = normalizeSourceControlAiSettings(
settings.sourceControlAi,
settings.commitMessageAi
)
return {
actions: Object.fromEntries(
SOURCE_CONTROL_LAUNCH_ACTION_IDS.flatMap((actionId) => {
const recipe = actions?.[actionId]
return recipe ? [[actionId, recipe]] : []
})
)
}
}