Skip to content

feat: unify CLI execution and never spawn a shell#949

Open
EhabY wants to merge 1 commit into
fix/api-workspace-update-defaultsfrom
feat/unify-cli-execution-no-shell
Open

feat: unify CLI execution and never spawn a shell#949
EhabY wants to merge 1 commit into
fix/api-workspace-update-defaultsfrom
feat/unify-cli-execution-no-shell

Conversation

@EhabY
Copy link
Copy Markdown
Collaborator

@EhabY EhabY commented May 12, 2026

Summary

Drops the last Node-level shell: true from the extension and replaces the behaviours we relied on the shell for with explicit expansion. Stacks on top of #945 — merge that one first.

runCliCommand (start / update)

Spawns coder as an argv array (spawn(binary, args)) instead of a quoted command string through a shell. Args reach coder byte-for-byte: no Windows cmd.exe metacharacter escaping surface for server-supplied template parameter values, no surprise re-parsing on Linux/macOS, one code path across platforms.

Reinstates proc.on("error", reject) so a missing/unexecutable binary surfaces via the spawn error event (Node emits error for ENOENT/EACCES when there is no shell) instead of hanging the progress dialog.

coder ping (spawnCliInTerminal)

Same treatment: spawn(binary, argv, { detached }) instead of spawn(cmdString, { shell: true, detached }). The process-group setup for Ctrl+C handling comes from detached alone — the shell wrapper wasn't contributing anything. ping now uses getGlobalFlags (raw) and passes the workspace name unescaped, matching the rest of the codebase.

Shell-context call sites we don't own

Two surfaces still feed a shell because the consumer runs one, and these are intentionally left alone:

  • openAppStatusTerminal (VS Code Terminal → user's shell).
  • buildProxyCommand (string handed to OpenSSH's ProxyCommand).

Both still use getGlobalShellFlags + the platform-appropriate escaper. That's correct — they're outside Node's shell: true and escaping is required by the consuming shell.

Expansion in coder.globalFlags

With no shell to expand values, the extension now substitutes:

  • ${env:VAR} from process.env (missing → empty, matching VS Code's own ${env:VAR} semantics).
  • A leading ~ and ${userHome}os.homedir(). For --flag=value entries the path expansion is scoped to the value half so --cfg=~/coder works.

Substitution lives in getUserGlobalFlags, which feeds both getGlobalFlags and getGlobalShellFlags, so every site that reads the user's flags gets the same expansion. package.json spells out exactly which forms are supported.

Why split this from #945

The shell-mode change is the only piece in the original PR that touches a security-sensitive boundary (server-controlled template parameter values reaching a Windows shell). Splitting it out means it can be reverted on its own if needed without losing the parameter-prompting work.

Pre-existing hardening (proc.stdin.end(), signal-aware close handler, getHeaderArgs escape threading) stays on #945 because it's shell-mode-agnostic — same behaviour either way.

Test plan

  • pnpm test — 1578 passing.
  • pnpm lint, pnpm typecheck — clean.
  • Manual: open a workspace via the extension, confirm coder start / coder update succeed and stream logs.
  • Manual: coder ping <workspace> from the command palette, confirm Ctrl+C kills it cleanly.
  • Manual: set coder.globalFlags to ["--cfg=~/coder", "--token=${env:CODER_SESSION_TOKEN}"] and verify the values reach the CLI expanded.
  • Manual on Windows: run a coder update against a template whose required parameters include shell metacharacters in their names or values; confirm they flow through unescaped.

@EhabY EhabY force-pushed the feat/unify-cli-execution-no-shell branch from 8e8bbc3 to b195482 Compare May 12, 2026 20:14
@EhabY EhabY force-pushed the fix/api-workspace-update-defaults branch from 3dc671f to dfa3c13 Compare May 12, 2026 20:14
Drops the last Node-level \`shell: true\` from the extension and replaces
the behaviours we relied on the shell for with explicit expansion.

Spawns \`coder\` as an argv array (\`spawn(binary, args)\`) instead of a
quoted command string through a shell. Args reach \`coder\` byte-for-byte:
no Windows \`cmd.exe\` metacharacter escaping surface for server-supplied
template parameter values, no surprise re-parsing on Linux/macOS, one
code path across platforms.

Also reinstates \`proc.on("error", reject)\` so a missing/unexecutable
binary surfaces via the spawn error event (Node emits \`error\` for
ENOENT/EACCES when there is no shell) instead of hanging the progress
dialog.

Same treatment: \`spawn(binary, argv, { detached })\` instead of
\`spawn(cmdString, { shell: true, detached })\`. The process-group setup
for Ctrl+C handling comes from \`detached\` alone — the shell wrapper was
not contributing anything. \`ping\` now uses \`getGlobalFlags\` (raw) and
passes the workspace name unescaped, matching the rest of the codebase.

Two surfaces still feed a shell because the consumer runs one:

  - \`openAppStatusTerminal\` (VS Code \`Terminal\` → user's shell).
  - \`buildProxyCommand\` (string handed to OpenSSH's \`ProxyCommand\`).

Both still use \`getGlobalShellFlags\` + the platform-appropriate
escaper. That's correct — they're outside Node's \`shell: true\` and
escaping is required by the consuming shell.

With no shell to expand values, the extension now substitutes:

  - \`\${env:VAR}\` from \`process.env\` (missing → empty, matching VS
    Code's own \`\${env:VAR}\` semantics).
  - Leading \`~\` and \`\${userHome}\` from \`os.homedir()\`. For
    \`--flag=value\` entries the expansion is scoped to the value half
    so \`--cfg=~/coder\` works.

Substitution lives in \`getUserGlobalFlags\`, which feeds both
\`getGlobalFlags\` and \`getGlobalShellFlags\`, so every site that reads
the user's flags gets the same expansion. The doc in package.json
spells out exactly which forms are supported.

- \`cliConfig.test.ts\`: existing \`\${env:VAR}\` test plus a new test
  covering tilde / \`\${userHome}\` expansion (bare \`~\` entry,
  \`--flag=~/value\`, \`\${userHome}\` mid-string, mid-value tildes
  left alone).
- The test-side \`shellQuote\` mirror added during the temporary
  shell-revert is dropped along with its tests — no caller needs it
  now that no test asserts an \`escapeShellArg\` output.

\`getGlobalShellFlags\`'s comment loses the \`spawn({ shell: true })\`
mention since that no longer exists; only \`terminal.sendText\` and SSH
\`ProxyCommand\` remain as shell contexts.
@EhabY EhabY force-pushed the feat/unify-cli-execution-no-shell branch from b195482 to fe65639 Compare May 12, 2026 20:16
@EhabY EhabY self-assigned this May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant