/ utils / shell / powershellProvider.ts
powershellProvider.ts
  1  import { tmpdir } from 'os'
  2  import { join } from 'path'
  3  import { join as posixJoin } from 'path/posix'
  4  import { getSessionEnvVars } from '../sessionEnvVars.js'
  5  import type { ShellProvider } from './shellProvider.js'
  6  
  7  /**
  8   * PowerShell invocation flags + command. Shared by the provider's getSpawnArgs
  9   * and the hook spawn path in hooks.ts so the flag set stays in one place.
 10   */
 11  export function buildPowerShellArgs(cmd: string): string[] {
 12    return ['-NoProfile', '-NonInteractive', '-Command', cmd]
 13  }
 14  
 15  /**
 16   * Base64-encode a string as UTF-16LE for PowerShell's -EncodedCommand.
 17   * Same encoding the parser uses (parser.ts toUtf16LeBase64). The output
 18   * is [A-Za-z0-9+/=] only — survives ANY shell-quoting layer, including
 19   * @anthropic-ai/sandbox-runtime's shellquote.quote() which would otherwise
 20   * corrupt !$? to \!$? when re-wrapping a single-quoted string in double
 21   * quotes. Review 2964609818.
 22   */
 23  function encodePowerShellCommand(psCommand: string): string {
 24    return Buffer.from(psCommand, 'utf16le').toString('base64')
 25  }
 26  
 27  export function createPowerShellProvider(shellPath: string): ShellProvider {
 28    let currentSandboxTmpDir: string | undefined
 29  
 30    return {
 31      type: 'powershell' as ShellProvider['type'],
 32      shellPath,
 33      detached: false,
 34  
 35      async buildExecCommand(
 36        command: string,
 37        opts: {
 38          id: number | string
 39          sandboxTmpDir?: string
 40          useSandbox: boolean
 41        },
 42      ): Promise<{ commandString: string; cwdFilePath: string }> {
 43        // Stash sandboxTmpDir for getEnvironmentOverrides (mirrors bashProvider)
 44        currentSandboxTmpDir = opts.useSandbox ? opts.sandboxTmpDir : undefined
 45  
 46        // When sandboxed, tmpdir() is not writable — the sandbox only allows
 47        // writes to sandboxTmpDir. Put the cwd tracking file there so the
 48        // inner pwsh can actually write it. Only applies on Linux/macOS/WSL2;
 49        // on Windows native, sandbox is never enabled so this branch is dead.
 50        const cwdFilePath =
 51          opts.useSandbox && opts.sandboxTmpDir
 52            ? posixJoin(opts.sandboxTmpDir, `claude-pwd-ps-${opts.id}`)
 53            : join(tmpdir(), `claude-pwd-ps-${opts.id}`)
 54        const escapedCwdFilePath = cwdFilePath.replace(/'/g, "''")
 55        // Exit-code capture: prefer $LASTEXITCODE when a native exe ran.
 56        // On PS 5.1, a native command that writes to stderr while the stream
 57        // is PS-redirected (e.g. `git push 2>&1`) sets $? = $false even when
 58        // the exe returned exit 0 — so `if (!$?)` reports a false positive.
 59        // $LASTEXITCODE is $null only when no native exe has run in the
 60        // session; in that case fall back to $? for cmdlet-only pipelines.
 61        // Tradeoff: `native-ok; cmdlet-fail` now returns 0 (was 1). Reverse
 62        // is also true: `native-fail; cmdlet-ok` now returns the native
 63        // exit code (was 0 — old logic only looked at $? which the trailing
 64        // cmdlet set true). Both rarer than the git/npm/curl stderr case.
 65        const cwdTracking = `\n; $_ec = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 }\n; (Get-Location).Path | Out-File -FilePath '${escapedCwdFilePath}' -Encoding utf8 -NoNewline\n; exit $_ec`
 66        const psCommand = command + cwdTracking
 67  
 68        // Sandbox wraps the returned commandString as `<binShell> -c '<cmd>'` —
 69        // hardcoded `-c`, no way to inject -NoProfile -NonInteractive. So for
 70        // the sandbox path, build a command that itself invokes pwsh with the
 71        // full flag set. Shell.ts passes /bin/sh as the sandbox binShell,
 72        // producing: bwrap ... sh -c 'pwsh -NoProfile ... -EncodedCommand ...'.
 73        // The non-sandbox path returns the bare PS command; getSpawnArgs() adds
 74        // the flags via buildPowerShellArgs().
 75        //
 76        // -EncodedCommand (base64 UTF-16LE), not -Command: the sandbox runtime
 77        // applies its OWN shellquote.quote() on top of whatever we build. Any
 78        // string containing ' triggers double-quote mode which escapes ! as \! —
 79        // POSIX sh preserves that literally, pwsh parse error. Base64 is
 80        // [A-Za-z0-9+/=] — no chars that any quoting layer can corrupt.
 81        // Review 2964609818.
 82        //
 83        // shellPath is POSIX-single-quoted so a space-containing install path
 84        // (e.g. /opt/my tools/pwsh) survives the inner `/bin/sh -c` word-split.
 85        // Flags and base64 are [A-Za-z0-9+/=-] only — no quoting needed.
 86        const commandString = opts.useSandbox
 87          ? [
 88              `'${shellPath.replace(/'/g, `'\\''`)}'`,
 89              '-NoProfile',
 90              '-NonInteractive',
 91              '-EncodedCommand',
 92              encodePowerShellCommand(psCommand),
 93            ].join(' ')
 94          : psCommand
 95  
 96        return { commandString, cwdFilePath }
 97      },
 98  
 99      getSpawnArgs(commandString: string): string[] {
100        return buildPowerShellArgs(commandString)
101      },
102  
103      async getEnvironmentOverrides(): Promise<Record<string, string>> {
104        const env: Record<string, string> = {}
105        // Apply session env vars set via /env (child processes only, not
106        // the REPL). Without this, `/env PATH=...` affects Bash tool
107        // commands but not PowerShell — so PyCharm users with a stripped
108        // PATH can't self-rescue.
109        // Ordering: session vars FIRST so the sandbox TMPDIR below can't be
110        // overridden by `/env TMPDIR=...`. bashProvider.ts has these in the
111        // opposite order (pre-existing), but sandbox isolation should win.
112        for (const [key, value] of getSessionEnvVars()) {
113          env[key] = value
114        }
115        if (currentSandboxTmpDir) {
116          // PowerShell on Linux/macOS honors TMPDIR for [System.IO.Path]::GetTempPath()
117          env.TMPDIR = currentSandboxTmpDir
118          env.CLAUDE_CODE_TMPDIR = currentSandboxTmpDir
119        }
120        return env
121      },
122    }
123  }