`orca terminal create --environment <windows-host> --command 'cmd.exe'` never
created a cmd terminal. `--command` is text the provider TYPES into whatever
shell it spawned, so the PTY stayed the host's default shell with cmd running
inside it. Captured on `awin`, whose default is Git Bash:
$ orca terminal create --environment awin --command 'cmd.exe' --json
$ orca terminal send --environment awin --terminal term_10656cf7... \
--text exit --enter
$ orca terminal read --environment awin --terminal term_10656cf7... --screen
neil@awin MINGW64 ~/orca/orca ((30f820708f...))
$ cmd.exe
Microsoft Windows [Version 10.0.26200.9445]
C:\Users\neil\orca\orca>exit
neil@awin MINGW64 ~/orca/orca ((30f820708f...))
$
The handle is alive the whole time and `terminal list` shows one healthy
terminal, because the PTY never changed — so the only symptom is that the
caller's terminal is now a shell it never asked for, and every later `send` is
quoted for the wrong one. On `win-lowspec` (default pwsh) the same create lands
cmd inside PowerShell.
Root cause
----------
There are two spawn preflights and they are twins:
- `src/main/ipc/pty/ipc/spawn-preflight.ts` — renderer/IPC spawns, i.e. a
terminal tab opened in the app.
- `src/main/ipc/pty/runtime/spawn-preflight.ts` — runtime spawns: the CLI's
`terminal.create`, headless `orca serve`, and every paired remote
environment.
Only the IPC twin read the caller's requested shell. The runtime twin passed a
literal `requestedShellOverride: undefined`, so a runtime-created terminal on
Windows could only ever be the host default. Everything downstream of that
point — `spawn-options`, the daemon, `resolvePtyShellOverride` in the relay,
`local-pty-launch-plan` — already honoured `shellOverride`; nothing upstream
could supply one.
Change
------
- Thread `shellOverride` through the runtime lane: `RuntimePtySpawnArgs` ->
runtime `spawn-preflight` -> `RuntimePtyController.spawn` ->
`TerminalCreateOptions` -> the `terminal.create` RPC's new `shell` param ->
`orca terminal create --shell`.
- Thread it through the renderer-backed lane too (`createDesktopTerminal` ->
`terminal:requestTabCreate` -> `store.createTab`), so `--shell --focus` is not
silently dropped on a local Windows app.
- An agent launch quotes its startup command for the shell it will actually run
in, so a requested shell now owns the startup-shell family instead of the
global `terminalWindowsShell` setting.
- Lift the relay's `ALLOWED_WINDOWS_SHELL_OVERRIDES` into
`isSupportedWindowsShellOverride` in `src/shared/windows-terminal-shell.ts`
(membership unchanged) so the CLI, the zod param schema, and the relay refuse
the same names. `--shell` therefore cannot carry a path or a command line into
`pty.spawn`; only allowlisted bare shell names pass.
- Gate on `TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY`. An older host
strips the unknown `shell` param and answers with a healthy terminal running
its default shell — a reply indistinguishable from success — so the CLI
refuses before creating anything rather than creating the wrong shell quietly.
`--shell` stays Windows-only; macOS and Linux hosts spawn the login shell and
the relay drops the value off win32 rather than honouring it half-way. A WSL
project runtime still outranks it, unchanged.
Tests
-----
- `pty-spawn-shell-override-parity.test.ts` pins both preflights against the
exact drift that caused this (verified failing with the fix reverted).
- `createTerminal` passes `shellOverride` to `ptyController.spawn` with no
startup command.
- CLI: sends `shell`, refuses a shell the host cannot spawn, and refuses a host
without the capability — in both refusals without making the round trip.
- Allowlist and `terminal.create` schema accept/refuse cases, including paths
and appended arguments.
2.8 KiB
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/shellOverridenames the executable the PTY is spawned as. The terminal's own process is that shell for its whole life.--command/startupCommandis text the provider types into whatever shell it spawned.--command cmd.exetherefore 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.createfrom the CLI, headlessorca 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 throughcommand— it looks like it worked. - The allowlist is
isSupportedWindowsShellOverrideinsrc/shared/windows-terminal-shell.ts, and it is the reason--shellcannot name an arbitrary executable. The CLI, theterminal.createRPC 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
--shellcan never carry a command line intopty.spawn. - A host that predates
--shellSTRIPS it (terminal.createparams 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 onTERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITYand refuses before creating anything, rather than creating the wrong shell quietly. --shellis Windows-only. macOS and Linux hosts spawn the login shell and ignore it; the relay drops the value offwin32rather than honouring it half-way.- A WSL project runtime still wins over
--shell(resolveLocalWindowsTerminalRuntimeOptions). That is deliberate: the project's runtime decides which machine the shell runs on, and a per-terminal pick may not override that.