Files
orca/config/scripts/generate-rpc-params-catalog.mjs
T
Neil 13ba649c22 fix(terminal): let a runtime-created Windows terminal BE the requested shell (#20825)
* 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.

* fix(terminal): refuse a requested shell the execution host cannot apply

The first commit made `--shell` reach the spawn, but only a LOCAL win32
execution host applies it: `spawn-options` gates the override on
`process.platform === 'win32' && !args.connectionId`. So `--shell cmd.exe`
against an SSH-routed worktree, or against a macOS/Linux host, still returned a
healthy terminal running that host's default shell — the same
indistinguishable-from-success reply the capability gate exists to prevent, one
layer down.

Refuse instead, before anything spawns. The check sits at the top of
`resolveAgentTerminalCreateOptions`, which every create lane funnels through, so
neither lane has to remember it; the desktop lane additionally refuses a
worktree-less create, which has no execution host to resolve a shell on.

An SSH host's platform and installed shells are not visible to this runtime, and
a POSIX host has no Windows shell to pick. Neither can honour the request, and
saying so is the whole point of the flag.

Docs and the CLI spec now say "refused", not "ignored".

* fix(terminal): refuse a shell that contradicts the project execution runtime

`resolveLocalWindowsTerminalRuntimeOptions` does not merely rank the project's
execution runtime above a per-terminal pick -- it REWRITES the pick, in both
directions, and says nothing:

- a WSL project forces `wsl.exe`, discarding `--shell cmd.exe`;
- a Windows-host project discards a WSL name and falls back to `COMSPEC`
  (`getHostShellForProjectRuntime`), so `--shell wsl.exe` spawns cmd. That is
  the common case, not an edge: `resolveProjectExecutionRuntime` resolves
  `windows-host` for every project that is not WSL, while a repo belonging to no
  project honours `wsl.exe` -- so the same flag behaved differently depending on
  whether the repo was in a project.

Either rewrite returns a healthy terminal running a shell the caller did not ask
for, which is the failure `--shell` exists to remove.

It also split an agent launch's quoting from the shell that receives it. The
previous commit made the startup-shell family follow the REQUESTED shell, so
`--shell wsl.exe --command codex` on a Windows-host project typed POSIX-quoted
launch args into cmd. Refusing the contradiction removes that case rather than
papering over it.

Refuse instead, alongside the SSH and non-Windows refusals, from the same
`resolveAgentTerminalCreateOptions` seam every create lane funnels through.

Also from review:
- the allowlist test looped the list against itself; spell the members out.
- the runtime spec case claimed to prove the pty's shell when it asserts the
  controller received the field; name it for what it checks.

Reported by an adversarial review of the branch.

* fix(terminal): canonicalize --shell and refuse a WSL-path rewrite

Review of the --shell create path turned up two ways the terminal could
still end up being a shell the caller never asked for -- the exact failure
--shell exists to remove.

Bare and mixed-case spellings passed the allowlist but reached consumers
that exact-match the canonical name: resolveWindowsShellStartupFamily
classified `cmd` as the PowerShell family, resolveWindowsShellLaunchArgs
fell through to empty shellArgs (no `chcp 65001`, no OSC 133 bootstrap that
Windows foreground status depends on), and resolveWindowsGitBashShellPath
compares case-sensitively so `Git-Bash` spawned a literal `Git-Bash`.
The allowlist is now one canonical-name map and terminal.create canonicalizes
on parse, so the spawn path only ever sees `.exe` spellings. `pwsh` and
`powershell` stay distinct binaries.

A `\\wsl$\<distro>\...` cwd made the providers force wsl.exe regardless of
the request, and terminalShellOverrideRefusal only inspected the project
runtime -- undefined for a folder workspace with no project. Refuse on the
resolved cwd and the workspace path, judging what the PTY actually gets.

Also: the capability gate reported an unreachable host as too old rather
than unavailable; the SSH CLI shim dropped capabilities from status, so
--shell there blamed the host version instead of naming SSH; and --shell
had no help entry, rendering bare in `orca terminal create --help`. Adding
that entry crossed help.ts's max-lines cap, so the flag table moved to
flag-help-text.ts rather than suppressing the rule.

Adds a behavioural test for the runtime preflight (the one-line fix was
pinned only by a source-text scan), plus coverage for the startup-command
quoting family, the no-workspace refusal, and the WSL-path refusal.

* fix(build): keep tests out of the RPC params catalog bundle

The catalog walk under methods/ already skips *.test.ts, but the contract
directory glob took every .ts. terminal-create-shell-param.test.ts is the
first test to live there, so the bundle pulled vitest into a CJS build and
the generator threw on require(). Same exclusion, same reason.
2026-09-15 16:34:16 -07:00

262 lines
9.3 KiB
JavaScript

// Why: the host registry is the only place that binds a method name to its params
// schema. Reading it back — instead of hand-listing 600 methods — is what keeps the
// shared catalog and the dispatcher from drifting apart.
import { execFileSync } from 'node:child_process'
import {
existsSync,
globSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import process from 'node:process'
import * as esbuild from 'esbuild'
import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs'
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const SHARED_DIR = path.join(REPO_ROOT, 'src', 'shared')
const CONTRACT_DIR = path.join(SHARED_DIR, 'rpc-contract')
const RPC_DIR = path.join(REPO_ROOT, 'src', 'main', 'runtime', 'rpc')
const REGISTRY_ENTRY = path.join(RPC_DIR, 'methods', 'index.ts')
const OUTPUT_PATH = path.join(CONTRACT_DIR, 'rpc-params-catalog.generated.ts')
// Why mkdirSync first: out/ is gitignored and absent on a fresh checkout, so
// mkdtempSync threw ENOENT and took `pnpm lint` down with it. Why not os.tmpdir():
// the bundle keeps its node_modules deps external and oxfmt reads .oxfmtrc.json by
// walking up, so both scratch files have to sit under the repo to resolve at all.
function scratchDir(prefix) {
const root = path.join(REPO_ROOT, 'out')
mkdirSync(root, { recursive: true })
return mkdtempSync(path.join(root, prefix))
}
const posix = (value) => value.split(path.sep).join('/')
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 })
.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 })) {
if (file.endsWith('.test.ts')) {
continue
}
const source = readFileSync(path.join(RPC_DIR, file), 'utf8')
for (const [, specifier] of source.matchAll(/from\s+'(\.[^']+)'/g)) {
const resolved = `${path.resolve(path.dirname(path.join(RPC_DIR, file)), specifier)}.ts`
// Never re-add the generator's own output: a module under RPC_DIR may import the
// catalog for a type-only contract, and bundling a stale catalog makes regeneration
// crash in exactly the state that requires regenerating.
if (
resolved !== OUTPUT_PATH &&
resolved.startsWith(`${SHARED_DIR}${path.sep}`) &&
existsSync(resolved)
) {
modules.add(resolved)
}
}
}
return [...modules].sort()
}
// Why: one bundle keeps the registry and the shared modules on the same module
// instances, so schema object identity is what maps a method to its export.
function loadRegistryAndSchemas(modules) {
const buildDir = scratchDir('rpc-params-catalog-')
try {
const entry = path.join(buildDir, 'entry.ts')
const importOf = (file) => JSON.stringify(posix(path.relative(buildDir, file)))
writeFileSync(
entry,
[
`export { ALL_RPC_METHODS } from ${importOf(REGISTRY_ENTRY)}`,
'export const SCHEMA_MODULES = {',
...modules.map(
(file) => ` ${JSON.stringify(repoPath(file))}: require(${importOf(file)}),`
),
'}'
].join('\n')
)
const outfile = path.join(buildDir, 'bundle.cjs')
esbuild.buildSync({
entryPoints: [entry],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
logLevel: 'error',
packages: 'external'
})
const loaded = createRequire(import.meta.url)(outfile)
return { methods: loaded.ALL_RPC_METHODS, schemaModules: loaded.SCHEMA_MODULES }
} finally {
rmSync(buildDir, { recursive: true, force: true })
}
}
// Why: schema objects are compared by identity, not by shape — two structurally
// identical schemas are still two different wire contracts.
function buildSchemaIndex(schemaModules) {
const index = new Map()
for (const [modulePath, moduleExports] of Object.entries(schemaModules)) {
for (const [exportName, value] of Object.entries(moduleExports)) {
if (!value || typeof value !== 'object' || typeof value.safeParse !== 'function') {
continue
}
if (index.has(value)) {
continue
}
index.set(value, { modulePath, exportName })
}
}
return index
}
function localNameFor(origin, taken) {
if (!taken.has(origin.exportName)) {
return origin.exportName
}
const hint = path
.basename(origin.modulePath, '.ts')
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
let candidate = `${origin.exportName}Of${hint}`
let suffix = 2
while (taken.has(candidate)) {
candidate = `${origin.exportName}Of${hint}${suffix++}`
}
return candidate
}
function render({ methods, schemaModules }) {
const index = buildSchemaIndex(schemaModules)
const entries = []
const uncataloged = []
const imports = new Map()
const taken = new Set()
for (const method of [...methods].sort((left, right) => (left.name < right.name ? -1 : 1))) {
if (method.params === null) {
entries.push(` '${method.name}': null`)
continue
}
const origin = index.get(method.params)
if (!origin) {
uncataloged.push(method.name)
continue
}
const key = `${origin.modulePath}#${origin.exportName}`
let local = imports.get(key)
if (!local) {
local = localNameFor(origin, taken)
taken.add(local)
imports.set(key, local)
}
entries.push(` '${method.name}': ${local}`)
}
const byModule = new Map()
for (const [key, local] of imports) {
const [modulePath, exportName] = key.split('#')
if (!byModule.has(modulePath)) {
byModule.set(modulePath, [])
}
byModule.get(modulePath).push(local === exportName ? exportName : `${exportName} as ${local}`)
}
const importLines = [...byModule]
.sort(([left], [right]) => (left < right ? -1 : 1))
.map(([modulePath, names]) => {
let specifier = posix(path.relative(CONTRACT_DIR, path.join(REPO_ROOT, modulePath))).replace(
/\.ts$/,
''
)
if (!specifier.startsWith('.')) {
specifier = `./${specifier}`
}
return `import { ${names.sort().join(', ')} } from '${specifier}'`
})
return `// GENERATED by config/scripts/generate-rpc-params-catalog.mjs. Do not edit;
// run \`pnpm run generate:rpc-params-catalog\`.
import type { z } from 'zod'
${importLines.join('\n')}
// Why: the host parses params with these schemas, so a client that matches this map
// matches the dispatcher. Clients must import it for types only — parsing a params
// schema client-side runs the coercing transforms and rewrites the wire bytes.
export const RPC_PARAMS_BY_METHOD = {
${entries.join(',\n')}
} as const
// Why: these methods bind a schema the shared contract cannot hold because its value
// graph reaches into src/main. Listing them keeps the gap visible instead of absent.
export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [
${uncataloged.map((name) => ` '${name}'`).join(',\n')}
]
export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD
// Why: z.output is the post-parse shape the handler receives, which is not what a
// client may send — a .default() field reads as required. z.input is not the answer
// either: requiredString is z.unknown().transform(...), so its input admits any value.
// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map.
export type RpcParams<Method extends RpcMethodName> =
(typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType
? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]>
: void
`
}
// Why: the drift gate compares bytes, so the generator must emit exactly what the
// formatter would produce or every run would look like drift.
function formatted(source) {
const buildDir = scratchDir('rpc-params-catalog-fmt-')
try {
const file = path.join(buildDir, 'rpc-params-catalog.generated.ts')
writeFileSync(file, source)
const { command, prefixArgs } = resolveOxcCliInvocation('oxfmt', 'oxfmt', REPO_ROOT)
execFileSync(command, [...prefixArgs, '--write', file], {
stdio: 'ignore',
windowsHide: true
})
return readFileSync(file, 'utf8')
} finally {
rmSync(buildDir, { recursive: true, force: true })
}
}
function main() {
const check = process.argv.includes('--check')
const generated = formatted(render(loadRegistryAndSchemas(indexableModules())))
const current = existsSync(OUTPUT_PATH) ? readFileSync(OUTPUT_PATH, 'utf8') : null
if (generated === current) {
if (!check) {
console.log(`rpc params catalog already up to date: ${repoPath(OUTPUT_PATH)}`)
}
return
}
if (check) {
console.error(
`${repoPath(OUTPUT_PATH)} is out of date. Run \`pnpm run generate:rpc-params-catalog\`.`
)
process.exitCode = 1
return
}
writeFileSync(OUTPUT_PATH, generated)
console.log(`wrote ${repoPath(OUTPUT_PATH)}`)
}
main()