| 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. |