Files
orca/docs/reference/windows-terminal-shell-selection.md
T
Neil d6eb9fb1fc fix(terminal): let a runtime-created Windows terminal BE the requested shell
`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.
2026-09-15 03:16:43 -07:00

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 / 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. macOS and Linux hosts spawn the login shell and ignore it; the relay drops the value off win32 rather 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.