Files
orca/src/shared/tui-agent-launch-defaults.ts
T
Brennan Benson f06f737802 Unify Source Control action permissions and card layout (#30449)
* Source control actions start chats and terminals on their saved model

A "start an agent" action's model lived only in free-text CLI arguments, which a native chat
never reads, so AI Fix opened a chat on the default model. Each action now saves a structured
model and effort that both routes apply: chat create options, or the agent's own flags in a
terminal (winning over the same flag in CLI arguments). Desktop, dialog, fix-checks and mobile
launches carry it; Settings rows get Model/Effort selects and the CLI arguments field says it is
terminal-only.

Tracked in STA-11102.

* Name the model in the repository override note

* Type the dialog's model hand-off; keep Agent in English in Japanese copy

* fix: preserve action chat model choices through launch

* Preserve action selection ownership across chat launches

* Keep terminal host contract fixtures aligned with preflight

* fix: wait for action Settings agent inventory

* fix: consolidate direct agent routing imports

* fix: use standard action option labels

* fix: align action launch contracts with shared types

* fix: sync action option localization metadata

* test: provide action Settings inventory in mounted fixtures

* Keep action options row height stable while loading

* Let each Source Control action choose its chat permission mode

An action's saved chat choice may now carry a permission mode, with or without a model;
"Same as new chats" saves nothing. A model is replaced only when the action names one,
so a permission-only choice keeps the chat's remembered model and options.

* fix(native-chat): leave older hosts their own permission mode and keep action options readable

- An action's saved permission mode is sent only to hosts that advertise
  agentSession.create.options-mode.v1. Older hosts get no mode, in create
  options or a later setOption, so the chat starts on that host's new-chat
  default, as a phone launch to such a host already does. Hosts from
  1.4.225 to 1.4.227 refused a mode their tables lacked (OpenCode, Grok,
  OMP), leaving the action unable to start.
- Settings action rows give Model and Permissions full rows in the options
  column so saved values are readable; the loading placeholder reserves the
  same rows.
- Remove two unused locale keys; fix two stale comments.
- Tests: the older-host create carries no permission mode; a permission
  mode picked alone keeps the remembered model on an acquired create.

* fix(settings): reserve one options row while loading an action with nothing saved

Terminal and permissions-only rows no longer shrink when loading ends; a chat
agent with a model list may grow by its Model row once known.

* fix(native-chat): keep the create options mode a checked literal type

* fix(settings): share verified action option fields

* fix(settings): keep action selectors on standard typography

* Keep the launch files under their length limits after the main merge

The structured route imports its result types where they are defined, and the two older-host
action-launch tests share one setup helper.

* One action settings section for chat and terminal launches

A Source Control action's Model, model options and Permissions now apply whether it opens a
chat or a terminal; terminals receive them as the agent's own flags, and a repository's own
CLI arguments keep the flags it sets. Extra CLI arguments stay terminal-only.

* fix(source-control-ai): one terminal projection for saved action choices

Ask in a terminal now states its permission instead of leaving it to the
user's own config: Claude and Grok pass --permission-mode default, Codex
passes -a on-request -s read-only -c approvals_reviewer="user" (the
interactive CLI has no untrusted policy, so a read-only sandbox is what
asks before edits). Agents whose CLI has no flag for a mode show the
existing "Applies to chats only" hint.

The strict launch-preferences projection now requires the launch agent at
every caller, composes model ids through the resolver's own step, carries
a mode only where the agent's terminal has a flag for it, and the local
string wire keeps booleans as 'true'/'false', so Cursor Thinking/Fast reach
the terminal and an OpenCode permission-only choice is not refused.

The argument parser knows flag arity, so removing a boolean permission
flag keeps other options' values, following positionals and '--'.
Pre-admission and executor share one terminal admission check on the
options the terminal will receive. Customize with Save launches the recipe
as it will resolve once saved; legacy partial repository overrides are
edited as the complete recipe they resolve to. Empty Extra CLI arguments
start collapsed.

* fix(source-control-ai): an edited repository action saves complete

Every Settings edit of a repository action (agent, model, permission,
prompt or arguments) now starts from the complete recipe the action
resolves to, whether its stored override is owned or a legacy partial.
The resolved agent selection is kept (never an agent only detected to run
a default), an explicit null args clear stays null, and empty args stay
empty instead of being dropped. Clearing the last preference therefore
means the action agent's own defaults rather than re-inheriting Global.

The action text draft is lossless: unset or cleared prompt and args stay
so until the user types, and the inputs show them as empty, so saving a
prompt no longer writes empty args that suppress the agent's default
flags. Untouched sibling actions are not rewritten by the edit.

* fix(settings): style the Extra CLI arguments toggle through Button's own variant and size

CI's changed-code quality check refuses restyle classes on Button and
CollapsibleContent. Use the ghost variant at size xs with a margin, and
put the content spacing on a plain inner wrapper.

* fix: keep saved action and restart launch recipes consistent

* fix: describe inherited arguments and chat-only permissions accurately

* test: check repository action settings fixture type

* test: map saved action options into terminal compiler inputs

* fix: retain saved action controls during agent detection

* fix(i18n): retire obsolete Portuguese action labels

* test: align folder launch fallback mock with outcome export

* fix: carry resolved connector launch intent and mode together

* test: pin shared action launch branches to recorded agent output

* test: preserve saved source control choices through detection and migration

* test: align launch fixtures with integrated result contract

* test: retain recipe and restart contracts after main integration

* Unify Source Control action permission settings

* Keep native Ask copy with its owning change

* Preserve action drafts and old-host permission intent

* Consume rejected Source Control action saves

* Follow the moved Settings persistence fixture

* Keep permission integration contracts in the cross-version test project

* Include the action permission inheritance label in English catalogs

* Align launch contracts with shared permission recipes

* Keep Source Control action controls within their Settings columns

* Use primitive variants for compact action settings controls

* Show action arguments only for terminal defaults and widen recipe rows

* Apply terminal-only action arguments visibility to repository overrides

* Align action permission control with model controls

* Use shared settings label for action permissions

* Hide action model controls until argument migration ships
2026-10-26 16:01:56 -07:00

304 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
readLaunchPermissionChoice,
type AgentPermissionChoice,
agentPermissionEnv,
hasTypedAgentPermission,
PERMISSION_CHOICE_AGENTS,
resolveAgentPermission,
sanitizeAgentPermissionUpdates
} from './tui-agent-permission-choice'
import { agentArgsSetPermission } from './tui-agent-permission-lift'
import {
resolveStartupShell,
tokenizeStartupCommand,
type AgentStartupShell
} from './tui-agent-startup-shell'
import type { TuiAgent } from './tui-agent'
import { resolveLocalWindowsAgentStartupShell } from './windows-terminal-shell'
const UNSUPPORTED_TUI_AGENT_ARGS: Partial<Record<TuiAgent, readonly string[]>> = {
opencode: ['--dangerously-skip-permissions'],
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'
>
>
/** Launch intent overrides only Orca's injected permission, preserving the user's args and env. */
export function agentLaunchPermissionSettings<T extends AgentLaunchProfileSettings>(
agent: TuiAgent,
settings: T,
sessionOptions: Readonly<Record<string, unknown>> | undefined
) {
const choice = readLaunchPermissionChoice(sessionOptions)
return choice
? {
...settings,
agentPermissionDefault: settings?.agentPermissionDefault ?? 'own',
agentPermissionOverrides: { ...settings?.agentPermissionOverrides, [agent]: choice }
}
: settings
}
// 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
export const DEFAULT_TUI_AGENT_ENV: Partial<Record<string, Record<string, string>>> =
YOLO_TUI_AGENT_ENV
function argPattern(arg: string): RegExp {
return new RegExp(`(^|\\s)${arg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=\\s|$)`, 'g')
}
export function hasUnsupportedTuiAgentArgs(agent: TuiAgent, value: unknown): boolean {
if (typeof value !== 'string') {
return false
}
return (UNSUPPORTED_TUI_AGENT_ARGS[agent] ?? []).some((arg) => argPattern(arg).test(value))
}
/** Read legacy startup argv so quoted prompt text and operands after -- cannot masquerade as permission flags. */
export function tuiAgentArgsBypassPermissions(
agent: TuiAgent,
value: string | null | undefined,
shell: AgentStartupShell
): boolean {
const bypassArg = YOLO_TUI_AGENT_ARGS[agent]
if (typeof value !== 'string' || bypassArg === undefined) {
return false
}
const tokenized = tokenizeStartupCommand(value, shell)
const bypass = tokenizeStartupCommand(bypassArg, shell)
if (!tokenized.ok || !bypass.ok || bypass.tokens.length === 0) {
return false
}
const terminator = tokenized.tokens.indexOf('--')
const options = terminator === -1 ? tokenized.tokens : tokenized.tokens.slice(0, terminator)
// A token sequence: several agents' bypass is a flag and its value (`--permission-mode X`).
return options.some((_, start) =>
bypass.tokens.every((token, offset) => options[start + offset] === token)
)
}
function sanitizeTuiAgentLaunchArgs(agent: TuiAgent, args: string): string {
const unsupportedArgs = UNSUPPORTED_TUI_AGENT_ARGS[agent]
if (!unsupportedArgs) {
return args.trim()
}
// Some interactive agent commands removed, relocated or never supported these skip-permission flags.
return unsupportedArgs.reduce((next, arg) => next.replace(argPattern(arg), ' '), args).trim()
}
export function normalizeTuiAgentArgsRecord(value: unknown): Partial<Record<TuiAgent, string>> {
const normalized: Partial<Record<TuiAgent, string>> = {}
if (!value || typeof value !== 'object') {
return normalized
}
for (const [agent, args] of Object.entries(value)) {
if (!isTuiAgent(agent) || typeof args !== 'string') {
continue
}
normalized[agent] = sanitizeTuiAgentLaunchArgs(agent, args)
}
return normalized
}
export function normalizeTuiAgentEnvRecord(
value: unknown
): Partial<Record<TuiAgent, Record<string, string>>> {
const normalized: Partial<Record<TuiAgent, Record<string, string>>> = {}
if (!value || typeof value !== 'object') {
return normalized
}
for (const [agent, env] of Object.entries(value)) {
if (!isTuiAgent(agent) || !env || typeof env !== 'object') {
continue
}
const entries: [string, string][] = []
for (const [name, raw] of Object.entries(env)) {
const key = name.trim()
if (!key || typeof raw !== 'string') {
continue
}
entries.push([key, raw])
}
normalized[agent] = Object.fromEntries(entries)
}
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] ?? ''
}
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
}
/** Shared injection rule: CLI-owned permission text wins over an Orca choice. */
export function agentPermissionArgsForLaunch(
agent: TuiAgent,
choice: AgentPermissionChoice,
text: string
): string | null {
return choice === 'own'
? ''
: agentArgsSetPermission(agent, text)
? null
: agentPermissionArgs(agent, choice)
}
/** Compose stored permission flags with user arguments, letting explicit user permission text win. Older host text already includes its flags. */
export function resolveTuiAgentLaunchArgs(
agent: string,
settings: AgentLaunchProfileSettings | null | undefined,
permissionChoice?: AgentPermissionChoice
): string {
const own = ownArgs(agent, settings)
if (
(!hasTypedAgentPermission(settings) && permissionChoice === undefined) ||
!isTuiAgent(agent)
) {
return own ?? getTuiAgentDefaultArgs(agent)
}
const user = own?.trim() ?? ''
const flag = agentPermissionArgsForLaunch(
agent,
permissionChoice ?? resolveAgentPermission(agent, settings).choice,
user
)
if (!flag) {
return user
}
return user ? `${flag} ${user}` : flag
}
/** Legacy missing Arguments use Orca’s bypass default; Manual stores an explicit empty value that overrides it. */
export function resolvedTuiAgentArgsBypassPermissions(
agent: TuiAgent,
settings:
| (AgentLaunchProfileSettings & Partial<Pick<GlobalSettings, 'terminalWindowsShell'>>)
| null
| undefined,
platform: NodeJS.Platform
): boolean {
const shell = resolveStartupShell(
platform,
resolveLocalWindowsAgentStartupShell({
platform,
isRemote: false,
terminalWindowsShell: settings?.terminalWindowsShell
})
)
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,
settings: AgentLaunchProfileSettings | null | undefined,
platform: NodeJS.Platform | null | undefined = agentPermissionHostPlatform(),
visitBaseEntry?: (name: string, value: string) => true
): Record<string, string> {
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 ?? []))
}
/** Publish composed args/env for older clients that infer permission from text; explicit keys prevent fallback to their built-in defaults. */
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)])
)
}
}
/** Resume only the recorded recipe; defaults would grant bypass to unrecorded manual launches that never used it. */
export function resolveResumeLaunchRecipe(
launchConfig: SleepingAgentLaunchConfig | undefined
): SleepingAgentLaunchConfig {
if (!launchConfig) {
return { agentArgs: '', agentEnv: {} }
}
return {
agentArgs: launchConfig.agentArgs,
agentEnv: launchConfig.agentEnv,
...(launchConfig.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}),
...(launchConfig.ompResumeFilePath ? { ompResumeFilePath: launchConfig.ompResumeFilePath } : {})
}
}