Files
orca/src/main/ssh/ssh-remote-powershell.ts
T
Neil 7eb13c184c fix(ssh): keep remote PowerShell commands inside what sshd's cmd.exe accepts (#17947)
* fix(ssh): keep remote Windows commands inside cmd.exe's command-line limit

Windows OpenSSH runs every exec request through sshd's DefaultShell, which is
cmd.exe on a stock install, and cmd.exe refuses a line over 8191 characters with
exit 1 and a localized "The command line is too long". `-EncodedCommand` spends
2.67 characters per script character, so five commands on the first-connect path
were already over: the stale upload-stage recovery that opens a fresh install
(23,210), promote (20,646), cleanup (19,798), the install-lock steal (11,798)
and reserve (9,434). A Windows-to-Windows `ssh:connect` died on the first of
them before the relay was ever uploaded (#16126).

powerShellCommand now falls back to a gzip self-extracting bootstrap once the
inline form passes the budget - these scripts are repetitive enough that the
worst one lands at 6.5KB - and throws a message naming the limit if even that
cannot fit, rather than letting cmd.exe answer in the host's locale. Commands
that already fit are byte-identical.

The real-binary PowerShell suite in ssh-relay-upload-stage-commands.test.ts
exercises the bootstrap end to end, including `exit` and here-string semantics
through Invoke-Expression.

* fix(ssh): cite the real command-line budget and reuse the cmd.exe ceiling
2026-09-02 01:29:30 -07:00

60 lines
3.0 KiB
TypeScript

import { gunzipSync, gzipSync } from 'node:zlib'
import { encodePowerShellCommand } from '../../shared/powershell-command-encoding'
import { CMD_EXE_COMMAND_LINE_MAX_CHARS } from '../providers/windows-shell-args'
export {
quotePowerShellLiteral as powerShellLiteral,
quotePowerShellNativeArgument as powerShellNativeArg
} from '../../shared/powershell-native-argument'
// Why cmd.exe and not the 32767 CreateProcess cap: Windows OpenSSH runs every exec request
// through sshd's DefaultShell, cmd.exe on a stock install. Budget under cmd.exe's own ceiling
// to leave room for the `/c` wrapper sshd adds before cmd.exe counts the line.
const WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS = 8_000
export function powerShellCommand(script: string): string {
const inline = encodedPowerShellCommand(script)
if (inline.length <= WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS) {
return inline
}
// Why: these scripts are repetitive enough that gzip beats the UTF-16LE tax by
// ~4x, which is the difference between a line cmd.exe runs and one it refuses.
const compressed = encodedPowerShellCommand(selfExtractingPowerShellScript(script))
if (compressed.length > WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS) {
throw new Error(
`Remote Windows command needs ${compressed.length} characters; Orca budgets ${WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS} for a line sshd hands to cmd.exe, which itself refuses more than ${CMD_EXE_COMMAND_LINE_MAX_CHARS}.`
)
}
return compressed
}
function encodedPowerShellCommand(script: string): string {
return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}`
}
/** Orca-prefixed names so the payload can never shadow the bootstrap's own state. */
function selfExtractingPowerShellScript(script: string): string {
const payload = gzipSync(Buffer.from(script, 'utf-8'), { level: 9 }).toString('base64')
return [
`$OrcaScriptBytes = [Convert]::FromBase64String('${payload}')`,
'$OrcaScriptMemory = New-Object System.IO.MemoryStream -ArgumentList (,$OrcaScriptBytes)',
'$OrcaScriptGzip = New-Object System.IO.Compression.GZipStream -ArgumentList $OrcaScriptMemory, ([System.IO.Compression.CompressionMode]::Decompress)',
'$OrcaScriptReader = New-Object System.IO.StreamReader -ArgumentList $OrcaScriptGzip, ([System.Text.Encoding]::UTF8)',
'$OrcaScriptText = $OrcaScriptReader.ReadToEnd()',
'$OrcaScriptReader.Dispose()',
'Invoke-Expression $OrcaScriptText'
].join('\n')
}
/** Inverse of `powerShellCommand`: the script the host will actually run. */
export function decodeRemotePowerShellScript(command: string): string {
const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/u)?.[1]
if (!encoded) {
return command
}
const script = Buffer.from(encoded, 'base64').toString('utf16le')
const payload = script.match(
/^\$OrcaScriptBytes = \[Convert\]::FromBase64String\('([A-Za-z0-9+/=]+)'\)/u
)?.[1]
return payload ? gunzipSync(Buffer.from(payload, 'base64')).toString('utf-8') : script
}