| import { tmpdir } from 'os' | |
| import { join } from 'path' | |
| import { join as posixJoin } from 'path/posix' | |
| import { getSessionEnvVars } from '../sessionEnvVars.js' | |
| import type { ShellProvider } from './shellProvider.js' | |
| /** | |
| * PowerShell invocation flags + command. Shared by the provider's getSpawnArgs | |
| * and the hook spawn path in hooks.ts so the flag set stays in one place. | |
| */ | |
| export function buildPowerShellArgs(cmd: string): string[] { | |
| return ['-NoProfile', '-NonInteractive', '-Command', cmd] | |
| } | |
| /** | |
| * Base64-encode a string as UTF-16LE for PowerShell's -EncodedCommand. | |
| * Same encoding the parser uses (parser.ts toUtf16LeBase64). The output | |
| * is [A-Za-z0-9+/=] only β survives ANY shell-quoting layer, including | |
| * @anthropic-ai/sandbox-runtime's shellquote.quote() which would otherwise | |
| * corrupt !$? to \!$? when re-wrapping a single-quoted string in double | |
| * quotes. Review 2964609818. | |
| */ | |
| function encodePowerShellCommand(psCommand: string): string { | |
| return Buffer.from(psCommand, 'utf16le').toString('base64') | |
| } | |
| export function createPowerShellProvider(shellPath: string): ShellProvider { | |
| let currentSandboxTmpDir: string | undefined | |
| return { | |
| type: 'powershell' as ShellProvider['type'], | |
| shellPath, | |
| detached: false, | |
| async buildExecCommand( | |
| command: string, | |
| opts: { | |
| id: number | string | |
| sandboxTmpDir?: string | |
| useSandbox: boolean | |
| }, | |
| ): Promise<{ commandString: string; cwdFilePath: string }> { | |
| // Stash sandboxTmpDir for getEnvironmentOverrides (mirrors bashProvider) | |
| currentSandboxTmpDir = opts.useSandbox ? opts.sandboxTmpDir : undefined | |
| // When sandboxed, tmpdir() is not writable β the sandbox only allows | |
| // writes to sandboxTmpDir. Put the cwd tracking file there so the | |
| // inner pwsh can actually write it. Only applies on Linux/macOS/WSL2; | |
| // on Windows native, sandbox is never enabled so this branch is dead. | |
| const cwdFilePath = | |
| opts.useSandbox && opts.sandboxTmpDir | |
| ? posixJoin(opts.sandboxTmpDir, `claude-pwd-ps-${opts.id}`) | |
| : join(tmpdir(), `claude-pwd-ps-${opts.id}`) | |
| const escapedCwdFilePath = cwdFilePath.replace(/'/g, "''") | |
| // Exit-code capture: prefer $LASTEXITCODE when a native exe ran. | |
| // On PS 5.1, a native command that writes to stderr while the stream | |
| // is PS-redirected (e.g. `git push 2>&1`) sets $? = $false even when | |
| // the exe returned exit 0 β so `if (!$?)` reports a false positive. | |
| // $LASTEXITCODE is $null only when no native exe has run in the | |
| // session; in that case fall back to $? for cmdlet-only pipelines. | |
| // Tradeoff: `native-ok; cmdlet-fail` now returns 0 (was 1). Reverse | |
| // is also true: `native-fail; cmdlet-ok` now returns the native | |
| // exit code (was 0 β old logic only looked at $? which the trailing | |
| // cmdlet set true). Both rarer than the git/npm/curl stderr case. | |
| 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` | |
| const psCommand = command + cwdTracking | |
| // Sandbox wraps the returned commandString as `<binShell> -c '<cmd>'` β | |
| // hardcoded `-c`, no way to inject -NoProfile -NonInteractive. So for | |
| // the sandbox path, build a command that itself invokes pwsh with the | |
| // full flag set. Shell.ts passes /bin/sh as the sandbox binShell, | |
| // producing: bwrap ... sh -c 'pwsh -NoProfile ... -EncodedCommand ...'. | |
| // The non-sandbox path returns the bare PS command; getSpawnArgs() adds | |
| // the flags via buildPowerShellArgs(). | |
| // | |
| // -EncodedCommand (base64 UTF-16LE), not -Command: the sandbox runtime | |
| // applies its OWN shellquote.quote() on top of whatever we build. Any | |
| // string containing ' triggers double-quote mode which escapes ! as \! β | |
| // POSIX sh preserves that literally, pwsh parse error. Base64 is | |
| // [A-Za-z0-9+/=] β no chars that any quoting layer can corrupt. | |
| // Review 2964609818. | |
| // | |
| // shellPath is POSIX-single-quoted so a space-containing install path | |
| // (e.g. /opt/my tools/pwsh) survives the inner `/bin/sh -c` word-split. | |
| // Flags and base64 are [A-Za-z0-9+/=-] only β no quoting needed. | |
| const commandString = opts.useSandbox | |
| ? [ | |
| `'${shellPath.replace(/'/g, `'\\''`)}'`, | |
| '-NoProfile', | |
| '-NonInteractive', | |
| '-EncodedCommand', | |
| encodePowerShellCommand(psCommand), | |
| ].join(' ') | |
| : psCommand | |
| return { commandString, cwdFilePath } | |
| }, | |
| getSpawnArgs(commandString: string): string[] { | |
| return buildPowerShellArgs(commandString) | |
| }, | |
| async getEnvironmentOverrides(): Promise<Record<string, string>> { | |
| const env: Record<string, string> = {} | |
| // Apply session env vars set via /env (child processes only, not | |
| // the REPL). Without this, `/env PATH=...` affects Bash tool | |
| // commands but not PowerShell β so PyCharm users with a stripped | |
| // PATH can't self-rescue. | |
| // Ordering: session vars FIRST so the sandbox TMPDIR below can't be | |
| // overridden by `/env TMPDIR=...`. bashProvider.ts has these in the | |
| // opposite order (pre-existing), but sandbox isolation should win. | |
| for (const [key, value] of getSessionEnvVars()) { | |
| env[key] = value | |
| } | |
| if (currentSandboxTmpDir) { | |
| // PowerShell on Linux/macOS honors TMPDIR for [System.IO.Path]::GetTempPath() | |
| env.TMPDIR = currentSandboxTmpDir | |
| env.CLAUDE_CODE_TMPDIR = currentSandboxTmpDir | |
| } | |
| return env | |
| }, | |
| } | |
| } | |