Merge branch 'main' into brennanb2025/agent-launch-mobile

#19850 was retargeted to main after #19849 landed as a SQUASH, so this branch
held #19849's original commits while main held the squashed equivalent - the
same content under different SHAs, which git reported as dirty.

Five files conflicted add/add, all of them files this PR also edits. Neither
side was takeable wholesale: theirs would have discarded this PR's work, ours
would have discarded anything main gained. Verified first that main's blob for
each is byte-identical to 539e283c0f - the #19849 head this work builds on - so
ours is a strict superset and the conflict is purely the squash artifact. Then
took ours on all five.

Two things auto-merged WRONG, outside any conflict region, and neither showed a
marker:

  - rpc-params-type-parity.ts silently reverted to main's copy, putting
    'agent.launch' back in UncataloguedMethod while the catalog still holds the
    entry. Typecheck caught it: TS2344 at :45. Re-applied.
  - the generated params catalog was stale against main's new methods.
    Regenerated; agent.launch stays catalogued and the uncatalogued list is
    back to three.

Audited every file this branch authored against main's copy afterwards: none
other reverted. The only file matching main verbatim is src/relay/pty-handler.ts,
which this branch never touched.
This commit is contained in:
Brennan Benson
2026-09-15 17:02:39 -07:00
34 changed files with 1129 additions and 142 deletions
+1
View File
@@ -130,6 +130,7 @@ docs/**
!docs/reference/ssh-host-key-verification.md
!docs/reference/ssh-reconnect-source-recovery.md
!docs/reference/windows-setup-shell.md
!docs/reference/windows-terminal-shell-selection.md
!docs/reference/worktree-scan-fingerprint.md
!docs/reference/wsl-command-execution.md
!docs/reference/wsl-probe-failure-semantics.md
+1
View File
@@ -61,6 +61,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Windows terminal shells**: `--shell` picks the shell a terminal *is*; `--command` is typed into whatever shell the host spawned, so a shell choice routed through `command` silently becomes a child process. See [`docs/reference/windows-terminal-shell-selection.md`](./docs/reference/windows-terminal-shell-selection.md).
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one.
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
@@ -40,8 +40,12 @@ const repoPath = (absolute) => posix(path.relative(REPO_ROOT, absolute))
// Every module the catalog may import from: the extracted params modules plus the
// pre-existing src/shared schemas the RPC methods already bind directly.
function indexableModules() {
// Tests are excluded here for the same reason as the RPC_DIR walk below: bundling one pulls
// vitest into the CJS catalog build, which throws on require().
const modules = new Set(
globSync('*.ts', { cwd: CONTRACT_DIR }).map((name) => path.join(CONTRACT_DIR, name))
globSync('*.ts', { cwd: CONTRACT_DIR })
.filter((name) => !name.endsWith('.test.ts'))
.map((name) => path.join(CONTRACT_DIR, name))
)
modules.delete(OUTPUT_PATH)
for (const file of globSync('**/*.ts', { cwd: RPC_DIR })) {
@@ -0,0 +1,54 @@
# Windows terminal shell selection
Two different things can put `cmd.exe` on a Windows terminal, and only one of them makes the
terminal _be_ cmd.
- **`--shell` / `shellOverride`** names the executable the PTY is spawned as. The terminal's own
process is that shell for its whole life.
- **`--command` / `startupCommand`** is text the provider types into whatever shell it spawned.
`--command cmd.exe` therefore starts cmd as a **child** of the host's default shell.
The difference is invisible until the child exits. Leaving that cmd returns the caller's handle to
a Git Bash or PowerShell prompt it never asked for, and anything that keyed off "this terminal is
cmd" is now wrong — while `terminal list` still shows one connected, healthy terminal, because the
PTY never changed.
## Why the runtime path needed its own fix
There are two spawn preflights, and they are twins:
- `src/main/ipc/pty/ipc/spawn-preflight.ts` — renderer/IPC spawns (a terminal tab in the app).
- `src/main/ipc/pty/runtime/spawn-preflight.ts` — runtime spawns: `terminal.create` from the CLI,
headless `orca serve`, and every paired remote environment.
Only the IPC twin read the caller's requested shell. The runtime twin passed a literal `undefined`,
so a runtime-created terminal could only ever be the host's default shell. `orca terminal create
--command cmd.exe` against a Windows environment had no way to say "be cmd" — it could only type
`cmd.exe` into Git Bash. `src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts` pins the pair.
## Rules
- A caller choosing a shell passes `--shell`; a caller running a program passes `--command`. Do not
route a shell choice through `command` — it looks like it worked.
- The allowlist is `isSupportedWindowsShellOverride` in `src/shared/windows-terminal-shell.ts`, and
it is the reason `--shell` cannot name an arbitrary executable. The CLI, the `terminal.create`
RPC schema, and the relay all check the same set; add a shell in one place only.
- Bare shell names only. A path or anything with arguments is refused, so `--shell` can never carry
a command line into `pty.spawn`.
- A host that predates `--shell` STRIPS it (`terminal.create` params are a zod object, which drops
unknown keys) and answers with a healthy terminal running its default shell — a reply that reads
as success. So the CLI gates on `TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY` and refuses
before creating anything, rather than creating the wrong shell quietly.
- `--shell` is Windows-only, and a host that cannot apply it REFUSES the create
(`terminalShellOverrideRefusal`). macOS and Linux execution hosts spawn the login shell, and a
terminal routed over SSH resolves its shell on the SSH host, whose platform and installed shells
this runtime cannot see. Refusing is the point: spawning the default shell and reporting success
is the failure `--shell` exists to remove.
- A project's execution runtime decides which MACHINE the shell runs on, so it outranks a
per-terminal pick — but it outranks it by REFUSING, not by rewriting. A `--shell` that
contradicts the project runtime (a Windows shell on a WSL project, or a WSL name on a
Windows-host project) is refused. `resolveLocalWindowsTerminalRuntimeOptions` would otherwise
rewrite the value — a WSL project forces `wsl.exe`, a Windows-host project discards a WSL name in
favour of `COMSPEC` — and hand back a terminal running something the caller never asked for. It
also splits an agent launch's quoting from the shell that receives it: POSIX-quoted args typed
into cmd, or cmd-quoted args typed into a WSL shell.
+1 -1
View File
@@ -54,7 +54,7 @@ allowBuilds:
node-pty: true
sherpa-onnx: true
ssh2: false
"@orca/windows-registry": false
'@orca/windows-registry': false
overrides:
monaco-editor>dompurify: 3.4.14
+106
View File
@@ -0,0 +1,106 @@
/** One-line flag descriptions shared by every command's help output. */
export const FLAG_HELP_TEXT: Record<string, string> = {
agent: '--agent <id> Launch a known TUI agent in the first terminal',
'base-branch': '--base-branch <ref> Base branch/ref to create the worktree from',
command: '--command <text> Command to run in the terminal on startup',
comment: '--comment <text> Comment stored in Orca metadata',
cursor: '--cursor <n> Line cursor from a previous read (returns only new output)',
action: '--action <name> Secondary accessibility action name',
activate: '--activate Reveal the new worktree in the Orca app',
app: '--app <app> App name, bundle ID, or pid:N',
direction:
'--direction <dir> Direction: up|down|left|right for scroll, horizontal|vertical for split',
'display-name': '--display-name <name> Override the Orca display name',
'element-index': '--element-index <n> Element index from get-app-state',
title: '--title <text> Custom title for the terminal tab (omit to reset)',
enter: '--enter Append Enter after sending text',
force:
'--force Force worktree removal when supported; does not force branch deletion',
focus: '--focus Reveal the created terminal session in Orca',
for: '--for exit|tui-idle Wait condition to satisfy',
'from-element-index': '--from-element-index <n> Source element index from get-app-state',
'from-x': '--from-x <x> Source window-local x coordinate',
'from-y': '--from-y <y> Source window-local y coordinate',
help: '--help Show this help message',
'include-visual-layouts': '--include-visual-layouts Include tab and pane topology in JSON output',
interrupt: '--interrupt Send as an interrupt-style input when supported',
id: '--id <id> Identifier for a target item or permission',
issue: '--issue <number|null> Linked GitHub issue number',
'linear-issue':
'--linear-issue <id|url|null> Linked Linear issue identifier or URL; null clears on set',
json: '--json Emit machine-readable JSON',
key: '--key <key> Key argument for this command',
limit: '--limit <n> Maximum number of rows to return',
local: '--local Target the current project instead of the global install',
skill: '--skill <name> Bundled skill to act on; repeat for several',
mode: '--mode <mode> Mode such as edit, diff, or both',
model: '--model <id> Provider model id for a new agent launch',
effort: '--effort <level> Reasoning effort for the selected model',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
modifiers: '--modifiers <chord> Modifier keys held only for this click',
name: '--name <name> Name for the new worktree or automation',
'no-parent': '--no-parent Force no parent lineage for unrelated work',
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
pages: '--pages <n> Number of scroll pages',
'parent-worktree':
'--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
path: '--path <path> Path argument for the command',
prompt: '--prompt <text> Prompt text for agent-backed commands',
query: '--query <text> Search text for matching refs',
ref: '--ref <ref> Base ref to persist for the repo',
repo: '--repo <selector> Repo selector such as id:<id>, name:<name>, or path:<path>',
'restore-window': '--restore-window Bring the target app/window forward before the operation',
session: '--session <id> Snapshot namespace for a related computer-use workflow',
setup: '--setup run|skip|inherit Setup policy for repo-defined setup hooks',
shell: '--shell <shell> Windows shell the terminal itself runs as',
terminal: '--terminal <handle> Runtime-issued terminal handle',
text: '--text <text> Text payload to send or type',
'text-stdin': '--text-stdin Read text payload from stdin',
'task-id': '--task-id <id> Task id to include in orchestration payload JSON',
'task-title': '--task-title <text> Concise title for an orchestration task',
'dispatch-id': '--dispatch-id <id> Dispatch id to include in orchestration payload JSON',
'files-modified': '--files-modified <csv> Comma-separated files for orchestration payload JSON',
'report-path': '--report-path <path> Report path to include in orchestration payload JSON',
phase: '--phase <text> Worker phase to include in orchestration payload JSON',
'timeout-ms': '--timeout-ms <ms> Maximum wait time before timing out',
'to-element-index': '--to-element-index <n> Destination element index from get-app-state',
'to-x': '--to-x <x> Destination window-local x coordinate',
'to-y': '--to-y <y> Destination window-local y coordinate',
worktree:
'--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
workspace: '--workspace <selector> Existing worktree selector for automation runs',
'workspace-status':
'--workspace-status <id> Board status id (defaults: todo, in-progress, in-review, completed)',
staged: '--staged Open staged source-control changes',
provider: '--provider <agent> Agent id such as codex, claude, or gemini',
'source-context':
'--source-context <json|null> Explicit TaskSourceContext for automation task/provider data',
trigger: '--trigger <schedule> Automation schedule preset, cron, or RRULE',
schedule: '--schedule <schedule> Alias for --trigger',
time: '--time <HH:MM> Time used with daily/weekdays/weekly presets',
day: '--day <0-6> Day used with weekly preset, Sunday=0',
timezone: '--timezone <tz> IANA timezone for the automation',
enabled: '--enabled Enable the automation',
disabled: '--disabled Disable the automation',
'reuse-session':
'--reuse-session Reuse the previous live session for existing-workspace runs',
'fresh-session': '--fresh-session Disable session reuse for future runs',
'workspace-mode': '--workspace-mode <mode> existing or new-per-run',
'missed-run-grace-minutes': '--missed-run-grace-minutes <n> Missed-run grace window',
'value-stdin': '--value-stdin Read set-value payload from stdin',
'window-id': '--window-id <id> Target a window id from list-windows',
'window-index': '--window-index <n> Target a window index from list-windows',
// Browser automation flags
element: '--element <ref> Element ref from snapshot (e.g. e3)',
url: '--url <url> URL to navigate to',
value: '--value <text> Value to fill or select',
input: '--input <text> Text to type at current focus',
expression: '--expression <js> JavaScript expression to evaluate',
amount: '--amount <pixels> Scroll distance in pixels',
index: '--index <n> Tab index to switch to',
page: '--page <id> Stable browser page id from `orca tab list --json`',
profile: '--profile <id> Browser profile id',
'show-profile': '--show-profile Include tab profile in text output',
'no-ua-spoof': "--no-ua-spoof Keep Electron's native user agent",
format: '--format <png|jpeg> Screenshot image format'
}
+94 -1
View File
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RuntimeClientError, type RuntimeClient } from '../runtime-client'
import { TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import {
TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY,
TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY
} from '../../shared/protocol-version'
import { parseArgs } from '../args'
import { printHelp } from '../help'
import { COMMAND_SPECS } from '../specs'
@@ -685,3 +688,93 @@ describe('terminal send CLI', () => {
])
})
})
describe('terminal create --shell', () => {
const WORKTREE = 'path:C:/src/app'
const shellClient = (
call: ReturnType<typeof vi.fn>,
supported: boolean,
reachable = true
): RuntimeClient => {
const client = {
call,
isRemote: false,
getCliStatus: vi.fn().mockResolvedValue({
result: {
runtime: reachable
? {
reachable: true,
runtimeId: 'runtime-current',
capabilities: supported ? [TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY] : []
}
: { reachable: false, runtimeId: null }
}
})
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `terminal create` reads only `call`, `isRemote`, and `getCliStatus`, all stubbed above; RuntimeClient is a class, so a structural double cannot satisfy it without the cast.
return client as unknown as RuntimeClient
}
afterEach(() => {
vi.restoreAllMocks()
process.exitCode = ORIGINAL_EXIT_CODE
})
function createTerminal(client: RuntimeClient, shell: string) {
return TERMINAL_HANDLERS['terminal create']({
flags: new Map([
['worktree', WORKTREE],
['shell', shell]
]),
client,
cwd: 'C:/src/app',
json: true
})
}
it('sends the shell selection alongside an empty startup command', async () => {
const call = vi.fn().mockResolvedValue({
result: { terminal: { handle: 'term_1', worktreeId: 'repo::C:/src/app', title: null } }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await createTerminal(shellClient(call, true), 'cmd.exe')
expect(call).toHaveBeenCalledWith(
'terminal.create',
expect.objectContaining({ shell: 'cmd.exe', command: undefined })
)
})
it('refuses a shell the host cannot spawn without making the round trip', async () => {
const call = vi.fn()
await expect(createTerminal(shellClient(call, true), 'nu.exe')).rejects.toThrow(
/--shell must be one of/
)
expect(call).not.toHaveBeenCalled()
})
// An older host strips the unknown param and answers with its default shell, which reads as a
// successful create. Creating the wrong shell silently is worse than refusing.
it('refuses rather than creating a default-shell terminal on a host without the capability', async () => {
const call = vi.fn()
await expect(createTerminal(shellClient(call, false), 'cmd.exe')).rejects.toThrow(
/does not support --shell/
)
expect(call).not.toHaveBeenCalled()
})
// A status probe that fails or times out reports no capabilities either; blaming the host
// version would send the caller to update a host that may already be current.
it('reports an unreachable host as unavailable rather than incompatible', async () => {
const call = vi.fn()
await expect(createTerminal(shellClient(call, false, false), 'cmd.exe')).rejects.toMatchObject({
code: 'runtime_unavailable'
})
expect(call).not.toHaveBeenCalled()
})
})
+36
View File
@@ -31,6 +31,11 @@ import {
type WithAnnotatedHostScope
} from '../omitted-host-scope-selectors'
import { RuntimeClientError } from '../runtime-client'
import {
isSupportedWindowsShellOverride,
listSupportedWindowsShellOverrides
} from '../../shared/windows-terminal-shell'
import { TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import {
getBrowserWorktreeSelector,
getOptionalWorktreeSelector,
@@ -153,9 +158,40 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
const useRendererBackedInteractiveTerminal =
!client.isRemote && shouldUseRendererBackedInteractiveTerminal(command)
const focus = flags.get('focus') === true
const shell = getOptionalStringFlag(flags, 'shell')
if (shell !== undefined) {
if (!isSupportedWindowsShellOverride(shell)) {
throw new RuntimeClientError(
'invalid_argument',
`--shell must be one of: ${listSupportedWindowsShellOverrides().join(', ')}`
)
}
// Why refused rather than sent hopefully: an older host strips the unknown param and hands
// back a healthy terminal running its DEFAULT shell. Nothing in that reply says the shell
// was ignored, so a caller that wanted cmd would drive a PowerShell session believing it won.
const status = await client.getCliStatus()
// An unreachable host reports no capabilities at all; that is not evidence it lacks --shell.
if (!status.result.runtime.reachable) {
throw new RuntimeClientError(
'runtime_unavailable',
'Orca could not verify --shell support on the execution host, so no terminal was created. Wait for the execution host to become reachable and retry.'
)
}
if (
status.result.runtime.capabilities?.includes(
TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY
) !== true
) {
throw new RuntimeClientError(
'incompatible_runtime',
'This Orca host does not support --shell, and would silently create a terminal running its default shell instead. No terminal was created; update Orca on the execution host.'
)
}
}
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command,
...(shell !== undefined ? { shell } : {}),
title: getOptionalStringFlag(flags, 'title'),
// Why: interactive local agent TUIs need the renderer-backed terminal
// path for browser-side features, but CLI creates must stay backgrounded
+2 -108
View File
@@ -1,6 +1,7 @@
import type { CommandSpec } from './args'
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
import { unknownCommandData } from './command-suggestion'
import { FLAG_HELP_TEXT } from './flag-help-text'
import { formatSkillsCommandFlagHelp } from './skills-command-flag-help'
import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary'
import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary'
@@ -182,113 +183,6 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
}
export function formatFlagHelp(flag: string): string {
const helpByFlag: Record<string, string> = {
agent: '--agent <id> Launch a known TUI agent in the first terminal',
'base-branch': '--base-branch <ref> Base branch/ref to create the worktree from',
command: '--command <text> Command to run in the terminal on startup',
comment: '--comment <text> Comment stored in Orca metadata',
cursor: '--cursor <n> Line cursor from a previous read (returns only new output)',
action: '--action <name> Secondary accessibility action name',
activate: '--activate Reveal the new worktree in the Orca app',
app: '--app <app> App name, bundle ID, or pid:N',
direction:
'--direction <dir> Direction: up|down|left|right for scroll, horizontal|vertical for split',
'display-name': '--display-name <name> Override the Orca display name',
'element-index': '--element-index <n> Element index from get-app-state',
title: '--title <text> Custom title for the terminal tab (omit to reset)',
enter: '--enter Append Enter after sending text',
force:
'--force Force worktree removal when supported; does not force branch deletion',
focus: '--focus Reveal the created terminal session in Orca',
for: '--for exit|tui-idle Wait condition to satisfy',
'from-element-index': '--from-element-index <n> Source element index from get-app-state',
'from-x': '--from-x <x> Source window-local x coordinate',
'from-y': '--from-y <y> Source window-local y coordinate',
help: '--help Show this help message',
'include-visual-layouts':
'--include-visual-layouts Include tab and pane topology in JSON output',
interrupt: '--interrupt Send as an interrupt-style input when supported',
id: '--id <id> Identifier for a target item or permission',
issue: '--issue <number|null> Linked GitHub issue number',
'linear-issue':
'--linear-issue <id|url|null> Linked Linear issue identifier or URL; null clears on set',
json: '--json Emit machine-readable JSON',
key: '--key <key> Key argument for this command',
limit: '--limit <n> Maximum number of rows to return',
local: '--local Target the current project instead of the global install',
skill: '--skill <name> Bundled skill to act on; repeat for several',
mode: '--mode <mode> Mode such as edit, diff, or both',
model: '--model <id> Provider model id for a new agent launch',
effort: '--effort <level> Reasoning effort for the selected model',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
modifiers: '--modifiers <chord> Modifier keys held only for this click',
name: '--name <name> Name for the new worktree or automation',
'no-parent': '--no-parent Force no parent lineage for unrelated work',
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
pages: '--pages <n> Number of scroll pages',
'parent-worktree':
'--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
path: '--path <path> Path argument for the command',
prompt: '--prompt <text> Prompt text for agent-backed commands',
query: '--query <text> Search text for matching refs',
ref: '--ref <ref> Base ref to persist for the repo',
repo: '--repo <selector> Repo selector such as id:<id>, name:<name>, or path:<path>',
'restore-window':
'--restore-window Bring the target app/window forward before the operation',
session: '--session <id> Snapshot namespace for a related computer-use workflow',
setup: '--setup run|skip|inherit Setup policy for repo-defined setup hooks',
terminal: '--terminal <handle> Runtime-issued terminal handle',
text: '--text <text> Text payload to send or type',
'text-stdin': '--text-stdin Read text payload from stdin',
'task-id': '--task-id <id> Task id to include in orchestration payload JSON',
'task-title': '--task-title <text> Concise title for an orchestration task',
'dispatch-id': '--dispatch-id <id> Dispatch id to include in orchestration payload JSON',
'files-modified': '--files-modified <csv> Comma-separated files for orchestration payload JSON',
'report-path': '--report-path <path> Report path to include in orchestration payload JSON',
phase: '--phase <text> Worker phase to include in orchestration payload JSON',
'timeout-ms': '--timeout-ms <ms> Maximum wait time before timing out',
'to-element-index': '--to-element-index <n> Destination element index from get-app-state',
'to-x': '--to-x <x> Destination window-local x coordinate',
'to-y': '--to-y <y> Destination window-local y coordinate',
worktree:
'--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
workspace: '--workspace <selector> Existing worktree selector for automation runs',
'workspace-status':
'--workspace-status <id> Board status id (defaults: todo, in-progress, in-review, completed)',
staged: '--staged Open staged source-control changes',
provider: '--provider <agent> Agent id such as codex, claude, or gemini',
'source-context':
'--source-context <json|null> Explicit TaskSourceContext for automation task/provider data',
trigger: '--trigger <schedule> Automation schedule preset, cron, or RRULE',
schedule: '--schedule <schedule> Alias for --trigger',
time: '--time <HH:MM> Time used with daily/weekdays/weekly presets',
day: '--day <0-6> Day used with weekly preset, Sunday=0',
timezone: '--timezone <tz> IANA timezone for the automation',
enabled: '--enabled Enable the automation',
disabled: '--disabled Disable the automation',
'reuse-session':
'--reuse-session Reuse the previous live session for existing-workspace runs',
'fresh-session': '--fresh-session Disable session reuse for future runs',
'workspace-mode': '--workspace-mode <mode> existing or new-per-run',
'missed-run-grace-minutes': '--missed-run-grace-minutes <n> Missed-run grace window',
'value-stdin': '--value-stdin Read set-value payload from stdin',
'window-id': '--window-id <id> Target a window id from list-windows',
'window-index': '--window-index <n> Target a window index from list-windows',
// Browser automation flags
element: '--element <ref> Element ref from snapshot (e.g. e3)',
url: '--url <url> URL to navigate to',
value: '--value <text> Value to fill or select',
input: '--input <text> Text to type at current focus',
expression: '--expression <js> JavaScript expression to evaluate',
amount: '--amount <pixels> Scroll distance in pixels',
index: '--index <n> Tab index to switch to',
page: '--page <id> Stable browser page id from `orca tab list --json`',
profile: '--profile <id> Browser profile id',
'show-profile': '--show-profile Include tab profile in text output',
'no-ua-spoof': "--no-ua-spoof Keep Electron's native user agent",
format: '--format <png|jpeg> Screenshot image format'
}
if (flag === 'current') {
return '--current Use the current Orca worktree linked Linear issue'
}
@@ -314,5 +208,5 @@ export function formatFlagHelp(flag: string): string {
return '--full Include all supported V1 issue context within caps'
}
return helpByFlag[flag] ?? `--${flag}`
return FLAG_HELP_TEXT[flag] ?? `--${flag}`
}
+1 -1
View File
@@ -62,7 +62,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--wait-submit <seconds>] [--retry-request <id>] [--json]',
' orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]',
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--shell <shell>] [--focus] [--json]',
' orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]',
' orca terminal switch [--terminal <handle>] [--json]',
' orca terminal close ([--terminal <handle>] [--tab] | --worktree <selector> --all) [--json]',
+7 -4
View File
@@ -250,17 +250,20 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
path: ['terminal', 'create'],
summary: 'Create a terminal session in the current worktree',
usage:
'orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'title', 'focus'],
'orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--shell <shell>] [--focus] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'shell', 'title', 'focus'],
notes: [
'Creates a visible terminal tab without switching focus when possible; falls back to a background handle if the UI cannot adopt it. Pass --focus to switch to it.',
'Use this, not worktree create, for a fresh agent in the current checkout.'
'Use this, not worktree create, for a fresh agent in the current checkout.',
'--shell picks the shell the terminal IS on a Windows host (cmd.exe, powershell.exe, pwsh.exe, wsl.exe, bash.exe, git-bash); --command is typed into whatever shell the host started, so `--command cmd.exe` leaves a cmd running INSIDE the default shell and exiting it drops back to that shell.',
'A host that cannot apply --shell refuses the create rather than quietly spawning its default shell: macOS and Linux execution hosts spawn the login shell, terminals routed over SSH resolve their shell on the SSH host, a --shell that contradicts the project execution runtime (WSL vs Windows host) is refused, and an Orca host older than --shell is refused by the CLI.'
],
examples: [
'orca terminal create --json',
'orca terminal create --worktree active --command "codex" --json',
'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"',
'orca terminal create --worktree path:/projects/myapp --command "opencode" --focus'
'orca terminal create --worktree path:/projects/myapp --command "opencode" --focus',
'orca terminal create --worktree path:C:/src/app --shell cmd.exe --json'
]
},
{
@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
/**
* The two spawn preflights are twins: one serves renderer/IPC spawns, the other serves runtime
* spawns (`terminal.create` from the CLI, headless serve, and paired remote environments). They
* had drifted — the runtime twin passed a literal `undefined` for the caller's shell, so on a
* Windows host a runtime-created terminal could only ever be the host's default shell. A caller
* asking for cmd or PowerShell had to send it as `command`, which the provider TYPES into that
* default shell: the pty stayed the default shell with the requested one running inside it, and
* leaving that child dropped the caller's handle back onto a prompt it never asked for.
*
* Source-level because the functional seam is a whole spawn pipeline; what actually regressed is
* one twin silently not reading a field the other reads.
*/
const PREFLIGHTS = ['ipc', 'runtime'] as const
describe.each(PREFLIGHTS)('%s pty spawn preflight', (lane) => {
const source = readFileSync(join(__dirname, lane, 'spawn-preflight.ts'), 'utf8')
it("resolves Windows terminal runtime options from the caller's requested shell", () => {
expect(source).toContain('requestedShellOverride: args.shellOverride')
expect(source).not.toContain('requestedShellOverride: undefined')
})
})
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { BrowserWindow } from 'electron'
import { getDefaultSettings } from '../../../../shared/constants'
import { finishPtyShutdown } from '../provider/liveness'
import { prepareRuntimePtySpawn } from './spawn-preflight'
import { buildRuntimePtySpawnOptions } from './spawn-options'
import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state'
import type { PtyRuntimeControllerDeps } from './controller-deps'
const HOST_DEFAULT_SHELL = 'powershell.exe'
const hostPlatform = process.platform
function makeDeps(): PtyRuntimeControllerDeps {
const noCodexResumeLaunch: PtyRuntimeControllerDeps['noCodexResumeLaunch'] = (command) => ({
codexResumeHome: null,
command,
notifyResumeUnavailable: false,
droppedResumeArgv: false,
providerSession: null
})
return {
store: undefined,
getSettings: () => ({
...getDefaultSettings('/tmp'),
terminalWindowsShell: HOST_DEFAULT_SHELL
}),
adoptStablePane: async () => null,
getLocalPtyStartupPromise: () => undefined,
getLocalPtyProviderStartupPromise: () => undefined,
prepareCodexResumeHome: () => null,
resolveCodexResumeLaunch: async (command) => noCodexResumeLaunch(command),
noCodexResumeLaunch,
reconcileSharedRuntimeResumeHome: async (resumeHome) => resumeHome.codexHomePath,
stripSequencedStartupResumeArgv: (env) => env,
assertFolderWorkspacePtyPathUsable: () => undefined,
resolvePtySpawnStartupCwd: (_worktreeId, cwd) => cwd,
requestSerializedBuffer: async () => null,
shutdownProviderAndDetectExit: async () => false,
rememberSyntheticKillExit: () => {},
rememberRetiredRejectedPty: () => {},
sendPtyExitToRenderer: () => {},
sendPtySpawnedToRenderer: () => {},
finishPtyShutdown,
trustedTerminalHandleEnv: new Set(),
retiredRejectedPtyIds: new Map(),
reversibleStopOwnersByPtyId: new Map(),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `operations.ts` (write/clearBuffer) reads `mainWindow`; the spawn preflight and option build never touch it, and a real BrowserWindow cannot exist in vitest.
mainWindow: {} as BrowserWindow
}
}
/** Runs the preflight and option build the way `spawnPtyFromRuntimeController` sequences them. */
async function resolveSpawnShell(shellOverride: string | undefined): Promise<string | undefined> {
const args: RuntimePtySpawnArgs = { cols: 120, rows: 40, shellOverride }
const ctx = createRuntimePtySpawnState(makeDeps(), args)
await prepareRuntimePtySpawn(ctx)
await buildRuntimePtySpawnOptions(ctx)
ctx.finishTerminalInstall()
return ctx.spawnOptions.shellOverride
}
/**
* Behavioural twin of `pty-spawn-shell-override-parity.test.ts`: a local Windows runtime spawn
* (`terminal create --shell`, headless serve) must hand the caller's shell to the provider, not
* the host default with the request typed into it.
*/
describe('runtime pty spawn preflight: requested shell on a local Windows host', () => {
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform })
})
it('spawns the requested shell as the pty', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
await expect(resolveSpawnShell('cmd.exe')).resolves.toBe('cmd.exe')
})
it('keeps the host default shell when nothing was requested', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
await expect(resolveSpawnShell(undefined)).resolves.toBe(HOST_DEFAULT_SHELL)
})
})
+6 -1
View File
@@ -72,10 +72,15 @@ export async function prepareRuntimePtySpawn(
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
}
// Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime.
// `args.shellOverride` is the per-request pick (`terminal create --shell`), read here the way
// the renderer twin (ipc/spawn-preflight.ts) reads a tab's override. Without it a runtime create
// could only ever get the host default shell, so a caller asking for cmd/PowerShell got the
// default shell with the request typed into it. Still Windows-only: the override names a
// Windows shell, and spawn-options applies it under the same platform gate.
ctx.terminalRuntimeOptions =
process.platform === 'win32' && !args.connectionId
? resolveLocalWindowsTerminalRuntimeOptions({
requestedShellOverride: undefined,
requestedShellOverride: args.shellOverride,
settings: ctx.deps.getSettings?.(),
projectRuntime: resolveLocalProjectRuntimeForWorktreeId(ctx.deps.store, args.worktreeId),
fallbackHostShell: process.env.COMSPEC || 'powershell.exe'
+1
View File
@@ -102,6 +102,7 @@ export type RuntimePtySpawnArgs = {
tabId?: string
leafId?: string
sessionId?: string
shellOverride?: string
isNewSession?: boolean
persistHostSessionBinding?: boolean
expectedSourceBinding?: PtyBindingSourceExpectation
@@ -18,6 +18,13 @@ export async function createDesktopTerminal(
const launchOpts = workspace
? await runtime.resolveAgentTerminalCreateOptions(workspace, opts)
: opts
// `resolveAgentTerminalCreateOptions` refuses an unapplicable shell, and it only runs with a
// workspace; a worktree-less create has no execution host to apply one to either.
if (!workspace && opts.shellOverride) {
throw new Error(
`--shell ${opts.shellOverride} needs a workspace, because the shell is resolved on the workspace's execution host. No terminal was created.`
)
}
const worktreeId = workspace?.id
const cwd = workspace
? runtime.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd)
@@ -63,6 +70,7 @@ export async function createDesktopTerminal(
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
startupCommandDelivery: launchOpts.startupCommandDelivery,
...(launchOpts.shellOverride ? { shellOverride: launchOpts.shellOverride } : {}),
title: launchOpts.title,
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
@@ -146,6 +146,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
preAllocatedHandle,
tabId,
leafId,
...(launchOpts.shellOverride ? { shellOverride: launchOpts.shellOverride } : {}),
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
...(launchOpts.agentSessionClaim
? {
@@ -16,6 +16,9 @@ import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-termi
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { terminalShellOverrideRefusal } from './terminal-shell-override-host-support'
import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd'
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution'
import { buildAgentStartupPlan } from '../../shared/tui-agent-startup'
import {
@@ -152,6 +155,23 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
workspace: TerminalWorkspaceLaunchScope,
opts: TerminalCreateOptions
): Promise<TerminalCreateOptions> {
// Before any early return: every create lane funnels through here, and a host that cannot
// apply the requested shell must refuse rather than spawn its default one.
const shellRefusal = terminalShellOverrideRefusal({
shellOverride: opts.shellOverride,
connectionId: workspace.connectionId,
platform: process.platform,
projectRuntime:
opts.shellOverride && this.store
? resolveLocalProjectRuntimeForWorktreeId(this.store, workspace.id)
: undefined,
// Same resolution as the spawn lanes below, so the refusal judges the cwd the PTY gets.
cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path,
workspacePath: workspace.path
})
if (shellRefusal) {
throw shellRefusal
}
// Why: raw shell commands like `codex exec` must remain user-authored shell.
// Only unmanaged, repo-backed, bare agent launches get Settings defaults.
const callerSuppliedLaunch =
@@ -186,7 +206,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
const queuedShell = resolveLocalWindowsAgentStartupShell({
platform,
isRemote,
terminalWindowsShell: settings.terminalWindowsShell
// A requested shell is the one this PTY will actually be, so it owns the quoting family.
terminalWindowsShell: opts.shellOverride ?? settings.terminalWindowsShell
})
if (opts.startupAgent && !isTuiAgentEnabled(opts.startupAgent, settings.disabledTuiAgents)) {
throw new Error(`Agent ${opts.startupAgent} is disabled. Choose an enabled agent.`)
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService, makePaneKey } from '../orca-runtime-test-mocks.spec'
import { OrcaRuntimeService, electronMocks, makePaneKey } from '../orca-runtime-test-mocks.spec'
import {
HEADLESS_LEAF_ID,
RESTORED_AUTHORITY_TOKEN,
@@ -92,6 +92,130 @@ describe('OrcaRuntimeService', () => {
})
})
it('asks the pty controller for the requested shell instead of a startup command', async () => {
const hostPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-shell' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
shellOverride: 'cmd.exe',
title: 'win shell'
})
// The defect this pins: a caller asking for cmd could only pass it as `command`, which the
// provider types into whatever shell it spawned — so the pty stayed the default shell and
// leaving cmd dropped the handle back onto a prompt the caller never asked for.
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({ shellOverride: 'cmd.exe', command: undefined })
)
} finally {
Object.defineProperty(process, 'platform', hostPlatform)
}
})
it('refuses a requested shell the execution host cannot apply instead of spawning its default', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-unreachable-shell' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
// Host platform here is POSIX, which has no Windows shell to pick.
await expect(
runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { shellOverride: 'cmd.exe' })
).rejects.toThrow(/--shell cmd\.exe names a Windows shell/)
expect(spawn).not.toHaveBeenCalled()
})
it('quotes the agent startup command for the requested shell, not the host default', async () => {
const hostPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-shell-quoting' })
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => ({
...store.getSettings(),
disabledTuiAgents: [],
terminalWindowsShell: 'powershell.exe',
agentCmdOverrides: {},
agentDefaultArgs: { claude: '--dangerously-skip-permissions' },
agentDefaultEnv: {}
})
})
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'claude' })
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'claude',
shellOverride: 'cmd.exe'
})
// The setting alone still quotes for PowerShell; the requested shell is the one that will
// read the command, so it owns the quoting family. PowerShell quoting typed into cmd is a
// syntax error at the prompt.
expect(spawn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ command: "claude '--dangerously-skip-permissions'" })
)
expect(spawn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
shellOverride: 'cmd.exe',
command: 'claude "--dangerously-skip-permissions"'
})
)
} finally {
Object.defineProperty(process, 'platform', hostPlatform)
}
})
it('refuses a requested shell with no workspace instead of creating a default-shell tab', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-no-workspace-shell' })
const send = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { send }
})
await expect(
runtime.createTerminal(undefined, { shellOverride: 'cmd.exe', rendererBacked: true })
).rejects.toThrow(/--shell cmd\.exe needs a workspace/)
expect(send).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
})
it('retires inherited launch authority when the agent command exits', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-authority', incarnationId: 'process-1' })
const retireAuthority = vi.fn()
@@ -53,6 +53,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [
(canonicalWorktreeSelector, preAllocatedHandle) =>
runtime.createTerminal(canonicalWorktreeSelector, {
command: params.command,
...(params.shell ? { shellOverride: params.shell } : {}),
startupCommandDelivery: params.startupCommandDelivery,
env: params.env,
envToDelete: params.envToDelete,
@@ -58,6 +58,8 @@ export type RuntimePtyController = {
tabId?: string
leafId?: string
sessionId?: string
/** Windows shell to spawn AS this PTY, instead of the host default. */
shellOverride?: string
isNewSession?: boolean
persistHostSessionBinding?: boolean
expectedSourceBinding?: PtyBindingSourceExpectation
@@ -22,6 +22,15 @@ import type { WorkerTerminalHostScope } from './orchestration/worker-terminal-pr
export type TerminalCreateOptions = {
command?: string
/**
* Windows shell to spawn AS the PTY process, instead of the host default shell.
*
* Distinct from `command`, which is typed into whatever shell the host spawns: a caller asking
* for cmd or PowerShell through `command` gets it as a CHILD of the default shell, so the
* terminal's own process is still the default shell and leaving that child lands back on a
* prompt the caller never asked for.
*/
shellOverride?: string
claudeAgentTeamsSourceCommand?: string
cwd?: string
env?: Record<string, string>
@@ -0,0 +1,204 @@
import { describe, expect, it } from 'vitest'
import { terminalShellOverrideRefusal } from './terminal-shell-override-host-support'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
const NO_PROJECT_RUNTIME = undefined
const WINDOWS_WORKSPACE = 'C:\\Users\\u\\app'
const WSL_WORKSPACE = '\\\\wsl$\\Ubuntu\\home\\u\\app'
const ON_WINDOWS_HOST = { cwd: WINDOWS_WORKSPACE, workspacePath: WINDOWS_WORKSPACE }
function resolvedRuntime(kind: 'windows-host' | 'wsl'): ProjectExecutionRuntimeResolution {
return kind === 'wsl'
? {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
distro: 'Ubuntu',
projectId: 'p1',
reason: 'project-override',
cacheKey: 'p1:wsl:Ubuntu'
}
}
: {
status: 'resolved',
runtime: {
kind: 'windows-host',
hostPlatform: 'win32',
projectId: 'p1',
reason: 'project-override',
cacheKey: 'p1:windows-host'
}
}
}
describe('terminalShellOverrideRefusal', () => {
it('allows a requested shell on a local Windows execution host', () => {
expect(
terminalShellOverrideRefusal({
shellOverride: 'cmd.exe',
connectionId: null,
platform: 'win32',
projectRuntime: NO_PROJECT_RUNTIME,
...ON_WINDOWS_HOST
})
).toBeNull()
})
it('stays out of the way when no shell was requested', () => {
for (const platform of ['darwin', 'linux', 'win32'] as const) {
expect(
terminalShellOverrideRefusal({
shellOverride: undefined,
connectionId: 'ssh-1',
platform,
projectRuntime: resolvedRuntime('wsl'),
...ON_WINDOWS_HOST
})
).toBeNull()
}
})
// Both of these hosts would otherwise spawn their default shell and report success.
it('refuses when the spawn happens over SSH', () => {
expect(
terminalShellOverrideRefusal({
shellOverride: 'cmd.exe',
connectionId: 'ssh-1',
platform: 'win32',
projectRuntime: NO_PROJECT_RUNTIME,
...ON_WINDOWS_HOST
})?.message
).toContain('over SSH')
})
it('refuses on a host that has no Windows shells to pick from', () => {
expect(
terminalShellOverrideRefusal({
shellOverride: 'cmd.exe',
connectionId: null,
platform: 'darwin',
projectRuntime: NO_PROJECT_RUNTIME,
...ON_WINDOWS_HOST
})?.message
).toContain('darwin')
})
// `resolveLocalWindowsTerminalRuntimeOptions` rewrites a shell that contradicts the project's
// execution runtime, which would hand back a terminal running something else entirely — and
// would quote an agent's startup command for the shell that was asked for, not the one running.
it('refuses a WSL shell when the project runs its terminals on the Windows host', () => {
expect(
terminalShellOverrideRefusal({
shellOverride: 'wsl.exe',
connectionId: null,
platform: 'win32',
projectRuntime: resolvedRuntime('windows-host'),
...ON_WINDOWS_HOST
})?.message
).toContain('on the Windows host')
})
it('refuses a Windows shell when the project runs its terminals in WSL', () => {
expect(
terminalShellOverrideRefusal({
shellOverride: 'cmd.exe',
connectionId: null,
platform: 'win32',
projectRuntime: resolvedRuntime('wsl'),
...ON_WINDOWS_HOST
})?.message
).toContain('in WSL')
})
it('allows a shell that agrees with the project runtime', () => {
expect(
terminalShellOverrideRefusal({
shellOverride: 'wsl.exe',
connectionId: null,
platform: 'win32',
projectRuntime: resolvedRuntime('wsl'),
...ON_WINDOWS_HOST
})
).toBeNull()
expect(
terminalShellOverrideRefusal({
shellOverride: 'powershell.exe',
connectionId: null,
platform: 'win32',
projectRuntime: resolvedRuntime('windows-host'),
...ON_WINDOWS_HOST
})
).toBeNull()
})
// `resolveWslSessionContext` forces wsl.exe for any `\\wsl$` cwd or workspace path, which is
// the one rewrite the project-runtime check cannot see: a folder workspace has no project.
describe('WSL UNC paths', () => {
it('refuses a Windows shell for a folder workspace inside a WSL distro with no project runtime', () => {
const message = terminalShellOverrideRefusal({
shellOverride: 'cmd.exe',
connectionId: null,
platform: 'win32',
projectRuntime: NO_PROJECT_RUNTIME,
cwd: WSL_WORKSPACE,
workspacePath: WSL_WORKSPACE
})?.message
expect(message).toContain('inside WSL')
expect(message).toContain('No terminal was created')
expect(message).toContain('--shell wsl.exe')
})
it('refuses when only the workspace root is in WSL, since the session path forces wsl.exe too', () => {
expect(
terminalShellOverrideRefusal({
shellOverride: 'powershell.exe',
connectionId: null,
platform: 'win32',
projectRuntime: NO_PROJECT_RUNTIME,
cwd: WINDOWS_WORKSPACE,
workspacePath: WSL_WORKSPACE
})?.message
).toContain(WSL_WORKSPACE)
})
it('accepts the forward-slash wsl.localhost spelling as a WSL path', () => {
expect(
terminalShellOverrideRefusal({
shellOverride: 'cmd.exe',
connectionId: null,
platform: 'win32',
projectRuntime: NO_PROJECT_RUNTIME,
cwd: '//wsl.localhost/Ubuntu/home/u/app/src',
workspacePath: '//wsl.localhost/Ubuntu/home/u/app'
})
).not.toBeNull()
})
it('allows wsl.exe for a WSL path', () => {
expect(
terminalShellOverrideRefusal({
shellOverride: 'wsl.exe',
connectionId: null,
platform: 'win32',
projectRuntime: NO_PROJECT_RUNTIME,
cwd: WSL_WORKSPACE,
workspacePath: WSL_WORKSPACE
})
).toBeNull()
})
it('still allows a Windows shell for a plain Windows path', () => {
expect(
terminalShellOverrideRefusal({
shellOverride: 'cmd.exe',
connectionId: null,
platform: 'win32',
projectRuntime: NO_PROJECT_RUNTIME,
cwd: 'C:\\Users\\u\\app\\src',
workspacePath: WINDOWS_WORKSPACE
})
).toBeNull()
})
})
})
@@ -0,0 +1,90 @@
import { isWslShellName } from '../../shared/local-windows-terminal-runtime'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import { parseWslUncPath } from '../../shared/wsl-paths'
/**
* Whether the host about to spawn a terminal can honour a requested Windows shell.
*
* The whole point of `--shell` is that the caller stops having to guess which shell it got. A host
* that cannot apply the pick must say so: silently spawning its default shell returns a healthy
* terminal running something else, which is the exact failure `--shell` exists to remove.
*/
export function terminalShellOverrideRefusal(args: {
shellOverride: string | undefined
connectionId: string | null
platform: NodeJS.Platform
projectRuntime: ProjectExecutionRuntimeResolution | undefined
/** The cwd the PTY will spawn in, resolved the same way the spawn lanes resolve it. */
cwd: string
workspacePath: string
}): Error | null {
if (!args.shellOverride) {
return null
}
if (args.connectionId) {
// The shell runs on the SSH host, whose platform and installed shells this runtime cannot see.
return new Error(
`This workspace runs its terminals over SSH, and Orca cannot apply --shell ${args.shellOverride} there. No terminal was created. Omit --shell, or create the terminal on the execution host itself.`
)
}
if (args.platform !== 'win32') {
return new Error(
`--shell ${args.shellOverride} names a Windows shell, and this execution host is ${args.platform}, which spawns the user's login shell. No terminal was created; omit --shell.`
)
}
return (
projectRuntimeShellConflict(args.shellOverride, args.projectRuntime) ??
wslUncPathShellConflict(args.shellOverride, args.cwd, args.workspacePath)
)
}
/**
* The project's execution runtime decides which MACHINE the shell runs on, so it outranks a
* per-terminal pick — and `resolveLocalWindowsTerminalRuntimeOptions` enforces that by rewriting
* the value: a WSL project forces `wsl.exe`, and a Windows-host project discards a WSL name in
* favour of the host shell. Either rewrite hands back a terminal running something the caller did
* not ask for, and it also splits the startup-command quoting from the shell that receives it
* (POSIX args typed into cmd, or cmd args typed into a WSL shell). Refusing the contradiction is
* the only answer that keeps the request and the terminal describing the same thing.
*/
function projectRuntimeShellConflict(
shellOverride: string,
projectRuntime: ProjectExecutionRuntimeResolution | undefined
): Error | null {
if (projectRuntime?.status !== 'resolved') {
return null
}
const runsInWsl = projectRuntime.runtime.kind === 'wsl'
if (runsInWsl === isWslShellName(shellOverride)) {
return null
}
return new Error(
runsInWsl
? `This workspace's project runs its terminals in WSL, so --shell ${shellOverride} cannot be applied. No terminal was created. Use --shell wsl.exe, or change the project's execution runtime.`
: `This workspace's project runs its terminals on the Windows host, so --shell ${shellOverride} cannot be applied. No terminal was created. Change the project's execution runtime to WSL, or pass a Windows shell.`
)
}
/**
* A `\\wsl$\<distro>\...` path only exists inside that distro, so the providers force `wsl.exe`
* for it whatever shell was requested (`resolveWslSessionContext` keys off the cwd, then the
* workspace path behind the session). That rewrite is right for the global shell setting and
* wrong for `--shell`, which promised the caller the shell it named. This is the only check that
* still fires for a folder workspace, which has no project runtime to disagree with.
*/
function wslUncPathShellConflict(
shellOverride: string,
cwd: string,
workspacePath: string
): Error | null {
if (isWslShellName(shellOverride)) {
return null
}
const wslPath = [cwd, workspacePath].find((path) => parseWslUncPath(path) !== null)
if (wslPath === undefined) {
return null
}
return new Error(
`This terminal would run inside WSL because ${wslPath} lives in a WSL distro, so --shell ${shellOverride} cannot be applied. No terminal was created. Use --shell wsl.exe, or omit --shell.`
)
}
+30
View File
@@ -180,6 +180,36 @@ describe('runRemoteOrcaCli', () => {
}
)
// Why: `orca terminal create --shell` gates on these; without them an SSH pane was told the
// host was too old, when the accurate refusal is that SSH cannot apply the shell.
it('reports the execution host capabilities through the legacy status fallback', async () => {
const runtime = new OrcaRuntimeService()
vi.spyOn(runtime, 'getStatus').mockReturnValue({
runtimeId: 'runtime-test',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
capabilities: ['terminal.create-shell-selection.v1']
})
const result = await runRemoteOrcaCli(
runtime,
{ argv: ['status', '--json'], cwd: '/home/alice/repo', env: {} },
LEGACY_FALLBACK_OPTIONS
)
expect(result.exitCode, result.stdout).toBe(0)
expect(JSON.parse(result.stdout)).toMatchObject({
ok: true,
result: {
target: { kind: 'environment', environment: 'ssh' },
runtime: { reachable: true, capabilities: ['terminal.create-shell-selection.v1'] }
}
})
})
it('uses the remote ORCA_TERMINAL_HANDLE as orchestration sender identity', async () => {
const { runtime, db } = createRuntime()
+4 -1
View File
@@ -185,7 +185,10 @@ async function dispatchRemoteCli(
state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready',
reachable: true,
connectionState: runtimeHostConnectionState({ hasStatusEntry: true, status }),
runtimeId: status.runtimeId
runtimeId: status.runtimeId,
// Why: `status.get` ran in-process on the execution host, so these ARE that host's
// capabilities; dropping them made `--shell` report an outdated host instead of SSH.
...(status.capabilities ? { capabilities: status.capabilities } : {})
},
graph: { state: status.graphStatus }
}
+2 -19
View File
@@ -5,7 +5,7 @@ import { existsSync } from 'node:fs'
import { basename, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { resolveWindowsGitBashShellPath } from '../main/git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell'
import { isSupportedWindowsShellOverride } from '../shared/windows-terminal-shell'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import {
resolveDefaultShell,
@@ -365,22 +365,6 @@ const ALLOWED_SIGNALS = new Set([
'SIGUSR2'
])
const ALLOWED_WINDOWS_SHELL_OVERRIDES = new Set([
'powershell.exe',
'powershell',
'pwsh.exe',
'pwsh',
'cmd.exe',
'cmd',
'wsl.exe',
'wsl',
// Why: both spellings classify as a POSIX startup family, so rejecting them here made the relay
// the one host that hard-failed a setting the local and daemon PTYs accept.
'bash.exe',
'bash',
WINDOWS_GIT_BASH_SHELL
])
function resolvePtyShellOverride(shellOverride: string): string {
if (!shellOverride) {
return ''
@@ -388,8 +372,7 @@ function resolvePtyShellOverride(shellOverride: string): string {
if (process.platform !== 'win32') {
return ''
}
const normalized = shellOverride.toLowerCase()
if (!ALLOWED_WINDOWS_SHELL_OVERRIDES.has(normalized)) {
if (!isSupportedWindowsShellOverride(shellOverride)) {
throw new Error(`Unsupported Windows shell override: ${shellOverride}`)
}
return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride
@@ -78,7 +78,7 @@ export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void {
recordInteraction: false,
...(data.cwd ? { startupCwd: data.cwd } : {})
}
const tab = store.createTab(worktreeId, data.targetGroupId, undefined, tabOptions)
const tab = store.createTab(worktreeId, data.targetGroupId, data.shellOverride, tabOptions)
if (!shouldActivate) {
// Why: renderer-backed Codex startup must mount its new TerminalPane without switching UI or connecting every saved tab.
requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] })
+6
View File
@@ -130,6 +130,11 @@ export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentia
// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised.
export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
'terminal.create-idempotency.v2' as const
// Why: an older host strips terminal.create's unknown `shell` and answers with a terminal running
// the host default shell. That reply is indistinguishable from success, so a client asking for a
// shell must refuse rather than create the wrong one.
export const TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY =
'terminal.create-shell-selection.v1' as const
export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const
export const SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY =
'session-tabs.authoritative-inventory.v1' as const
@@ -313,6 +318,7 @@ export const RUNTIME_CAPABILITIES = [
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { TerminalCreateParams } from './terminal-unary-params'
import { resolveWindowsShellStartupFamily } from '../windows-terminal-shell'
describe('TerminalCreateParams.shell', () => {
it('stays optional so older callers keep creating terminals', () => {
const parsed = TerminalCreateParams.parse({ worktree: 'path:/repo' })
expect(parsed.shell).toBeUndefined()
})
it('carries an allowed Windows shell through to the runtime', () => {
expect(TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'cmd.exe' }).shell).toBe(
'cmd.exe'
)
expect(TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'git-bash' }).shell).toBe(
'git-bash'
)
})
// The host canonicalizes even when a client did not, so the spawn path and the startup-command
// quoting only ever see the `.exe` spelling they exact-match.
it('canonicalizes an accepted spelling before it reaches the runtime', () => {
expect(TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'cmd' }).shell).toBe(
'cmd.exe'
)
expect(TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'pwsh' }).shell).toBe(
'pwsh.exe'
)
expect(TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'Git-Bash' }).shell).toBe(
'git-bash'
)
})
it('quotes a bare cmd override as cmd rather than PowerShell', () => {
const { shell } = TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'cmd' })
expect(resolveWindowsShellStartupFamily(shell)).toBe('cmd')
})
// The relay refuses these at spawn time; refusing here turns an opaque spawn failure into an
// answer the caller gets before the terminal exists.
it('refuses a shell the host will not spawn', () => {
expect(() => TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'nu.exe' })).toThrow(
/shell must be one of/
)
expect(() =>
TerminalCreateParams.parse({ worktree: 'path:/repo', shell: 'cmd.exe && calc' })
).toThrow(/shell must be one of/)
})
})
@@ -1,6 +1,11 @@
import { z } from 'zod'
import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
import { isTuiAgent } from '../tui-agent-config'
import {
canonicalizeWindowsShellOverride,
isSupportedWindowsShellOverride,
listSupportedWindowsShellOverrides
} from '../windows-terminal-shell'
import { TERMINAL_PANE_SPLIT_SOURCES } from '../feature-education-telemetry'
export const TerminalHandle = z.object({
@@ -180,7 +185,18 @@ export const TerminalCreateParams = z.object({
activate: z.unknown().optional(),
presentation: z.enum(['background', 'focused']).optional(),
tabId: OptionalString,
leafId: OptionalString
leafId: OptionalString,
// Why refused at the boundary rather than at spawn: only the host knows the allowlist, and a
// relay-side throw reaches the caller as an opaque spawn failure after the round trip.
shell: z
.string()
.refine(isSupportedWindowsShellOverride, {
message: `shell must be one of: ${listSupportedWindowsShellOverrides().join(', ')}`
})
// Why here: the host is authoritative, so it canonicalizes even when a client did not; the
// spawn path exact-matches `.exe` spellings and must never see `cmd` or `Git-Bash`.
.transform((shell) => canonicalizeWindowsShellOverride(shell) ?? shell)
.optional()
})
export const TerminalSplit = TerminalHandle.extend({
+2
View File
@@ -263,6 +263,8 @@ type RuntimeTerminalCreateBaseRequestPayload = {
activate?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
/** Windows shell the created tab spawns AS, instead of the host default. */
shellOverride?: string
}
export type RuntimeTerminalCreateRequestPayload =
+86 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { resolveWindowsShellStartupFamily } from './windows-terminal-shell'
import {
canonicalizeWindowsShellOverride,
isSupportedWindowsShellOverride,
listSupportedWindowsShellOverrides,
resolveWindowsShellStartupFamily
} from './windows-terminal-shell'
describe('resolveWindowsShellStartupFamily', () => {
it('defaults to PowerShell when unset', () => {
@@ -33,3 +38,83 @@ describe('resolveWindowsShellStartupFamily', () => {
expect(resolveWindowsShellStartupFamily('C:\\Program Files\\Git\\bin\\bash')).toBe('posix')
})
})
describe('isSupportedWindowsShellOverride', () => {
// Spelled out rather than looped over the list, which would assert the list against itself.
it('accepts exactly the shells the relay is willing to spawn', () => {
expect(listSupportedWindowsShellOverrides()).toEqual([
'bash',
'bash.exe',
'cmd',
'cmd.exe',
'git-bash',
'powershell',
'powershell.exe',
'pwsh',
'pwsh.exe',
'wsl',
'wsl.exe'
])
expect(isSupportedWindowsShellOverride('cmd.exe')).toBe(true)
expect(isSupportedWindowsShellOverride('powershell.exe')).toBe(true)
expect(isSupportedWindowsShellOverride('git-bash')).toBe(true)
})
it('accepts a differently cased spelling of an allowed shell', () => {
expect(isSupportedWindowsShellOverride('CMD.EXE')).toBe(true)
expect(isSupportedWindowsShellOverride('PowerShell.exe')).toBe(true)
})
// The allowlist is what stops `--shell` from naming an arbitrary executable to spawn.
it('refuses anything else, including a path to an allowed shell', () => {
expect(isSupportedWindowsShellOverride('nu.exe')).toBe(false)
expect(isSupportedWindowsShellOverride('')).toBe(false)
expect(isSupportedWindowsShellOverride('C:\\Windows\\System32\\cmd.exe')).toBe(false)
expect(isSupportedWindowsShellOverride('cmd.exe /c calc')).toBe(false)
})
})
describe('canonicalizeWindowsShellOverride', () => {
it('maps every accepted spelling to the `.exe` name the spawn path exact-matches', () => {
expect(canonicalizeWindowsShellOverride('cmd')).toBe('cmd.exe')
expect(canonicalizeWindowsShellOverride('cmd.exe')).toBe('cmd.exe')
expect(canonicalizeWindowsShellOverride('powershell')).toBe('powershell.exe')
expect(canonicalizeWindowsShellOverride('powershell.exe')).toBe('powershell.exe')
expect(canonicalizeWindowsShellOverride('pwsh')).toBe('pwsh.exe')
expect(canonicalizeWindowsShellOverride('pwsh.exe')).toBe('pwsh.exe')
expect(canonicalizeWindowsShellOverride('wsl')).toBe('wsl.exe')
expect(canonicalizeWindowsShellOverride('wsl.exe')).toBe('wsl.exe')
expect(canonicalizeWindowsShellOverride('bash')).toBe('bash.exe')
expect(canonicalizeWindowsShellOverride('bash.exe')).toBe('bash.exe')
expect(canonicalizeWindowsShellOverride('git-bash')).toBe('git-bash')
})
// pwsh (PowerShell 7) and powershell (Windows PowerShell 5.1) are different binaries.
it('never collapses pwsh into powershell', () => {
expect(canonicalizeWindowsShellOverride('pwsh')).not.toBe('powershell.exe')
expect(canonicalizeWindowsShellOverride('pwsh.exe')).not.toBe('powershell.exe')
})
// `resolveWindowsGitBashShellPath` compares the marker case-sensitively.
it('folds case so a mixed-case spelling reaches the exact-match consumers', () => {
expect(canonicalizeWindowsShellOverride('Git-Bash')).toBe('git-bash')
expect(canonicalizeWindowsShellOverride('CMD')).toBe('cmd.exe')
expect(canonicalizeWindowsShellOverride('PowerShell.exe')).toBe('powershell.exe')
})
it('returns undefined for anything the allowlist refuses', () => {
expect(canonicalizeWindowsShellOverride('nu.exe')).toBeUndefined()
expect(canonicalizeWindowsShellOverride('C:\\Windows\\System32\\cmd.exe')).toBeUndefined()
expect(canonicalizeWindowsShellOverride('')).toBeUndefined()
})
// Bare `cmd` falls through resolveWindowsShellStartupFamily to PowerShell quoting; the canonical
// name is what keeps the PTY shell and the queued-command quoting in the same family.
it('yields the cmd startup family for a bare cmd override', () => {
expect(resolveWindowsShellStartupFamily('cmd')).toBe('powershell')
expect(resolveWindowsShellStartupFamily(canonicalizeWindowsShellOverride('cmd'))).toBe('cmd')
expect(resolveWindowsShellStartupFamily(canonicalizeWindowsShellOverride('Git-Bash'))).toBe(
'posix'
)
})
})
+44
View File
@@ -54,3 +54,47 @@ export function resolveLocalWindowsAgentStartupShell(args: {
}
return resolveWindowsShellStartupFamily(args.terminalWindowsShell)
}
/**
* Shell names a caller may request for a single Windows terminal, keyed by accepted spelling and
* mapped to the one canonical spelling every downstream consumer keys on.
*
* The relay owns the spawn and has always refused anything outside this set, but the set lived
* only there — so a bad value from `terminal create --shell` surfaced as a spawn-time throw with
* no way for the CLI to answer before the round trip. Shared so the RPC boundary and the relay
* agree on the same names.
*
* Why canonicalize: `resolveWindowsShellStartupFamily`, the launch-arg builders, and the Git Bash
* path resolver all exact-match the `.exe` spelling, so a bare `cmd` accepted here would spawn cmd
* yet quote its startup command for PowerShell. `pwsh` and `powershell` are different binaries and
* are never collapsed into each other.
*/
const WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES: ReadonlyMap<string, string> = new Map([
['powershell.exe', 'powershell.exe'],
['powershell', 'powershell.exe'],
['pwsh.exe', 'pwsh.exe'],
['pwsh', 'pwsh.exe'],
['cmd.exe', 'cmd.exe'],
['cmd', 'cmd.exe'],
['wsl.exe', 'wsl.exe'],
['wsl', 'wsl.exe'],
// Why: both spellings classify as a POSIX startup family, so rejecting them here made the relay
// the one host that hard-failed a setting the local and daemon PTYs accept.
['bash.exe', 'bash.exe'],
['bash', 'bash.exe'],
[WINDOWS_GIT_BASH_SHELL, WINDOWS_GIT_BASH_SHELL]
])
/** Canonical spelling for an accepted override (case-insensitive), or undefined when refused. */
export function canonicalizeWindowsShellOverride(shell: string): string | undefined {
return WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES.get(shell.toLowerCase())
}
export function isSupportedWindowsShellOverride(shell: string): boolean {
return canonicalizeWindowsShellOverride(shell) !== undefined
}
/** Sorted for a stable error message; callers list these when refusing a value. */
export function listSupportedWindowsShellOverrides(): string[] {
return [...WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES.keys()].sort()
}