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 ` -c ''` — // 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> { const env: Record = {} // 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 }, } }