|
|
|
@@ -1170,6 +1170,13 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs
|
|
|
|
|
if (operation === "playwright") {
|
|
|
|
|
return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
|
|
|
|
|
}
|
|
|
|
|
if (isWindowsFsReadOnlyOperation(operation)) {
|
|
|
|
|
return {
|
|
|
|
|
remoteCommand: buildWindowsPowerShellInvocation(windowsFsReadOnlyScript(route.workspace, operation, args.slice(1))),
|
|
|
|
|
requiresStdin: false,
|
|
|
|
|
invocationKind: "helper",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (operation === "ps" || operation === "powershell" || operation === "powershell.exe") {
|
|
|
|
|
const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1);
|
|
|
|
|
if (commandArgs.length >= 2 && (commandArgs[0] === "-File" || commandArgs[0] === "-file")) {
|
|
|
|
@@ -1194,7 +1201,7 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (operation !== "cmd" && operation !== "cmd.exe") {
|
|
|
|
|
throw new Error(`unsupported ssh win operation: ${operation}; use ssh ${route.providerId}:win ps, ssh ${route.providerId}:win cmd <command-line>, ssh ${route.providerId}:win apply-patch, or ssh ${route.providerId}:win skills`);
|
|
|
|
|
throw new Error(`unsupported ssh win operation: ${operation}; use ssh ${route.providerId}:win ps, ssh ${route.providerId}:win cmd <command-line>, ssh ${route.providerId}:win <pwd|ls|cat|head|tail|stat|wc|rg>, ssh ${route.providerId}:win apply-patch, or ssh ${route.providerId}:win skills`);
|
|
|
|
|
}
|
|
|
|
|
const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1);
|
|
|
|
|
if (commandArgs.length === 0) {
|
|
|
|
@@ -1211,6 +1218,51 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type WindowsFsReadOnlyOperation = "pwd" | "ls" | "cat" | "head" | "tail" | "stat" | "wc" | "rg";
|
|
|
|
|
const windowsFsReadOnlyOperations = new Set<string>(["pwd", "ls", "cat", "head", "tail", "stat", "wc", "rg"]);
|
|
|
|
|
const windowsFsReadOnlyDefaultMaxBytes = 256 * 1024;
|
|
|
|
|
|
|
|
|
|
function isWindowsFsReadOnlyOperation(operation: string): operation is WindowsFsReadOnlyOperation {
|
|
|
|
|
return windowsFsReadOnlyOperations.has(operation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function windowsFsReadOnlyScript(basePath: string | null, operation: WindowsFsReadOnlyOperation, args: string[]): string {
|
|
|
|
|
const argsJsonB64 = Buffer.from(JSON.stringify(args), "utf8").toString("base64");
|
|
|
|
|
const commonScript = [
|
|
|
|
|
"$ErrorActionPreference = 'Stop';",
|
|
|
|
|
"$ProgressPreference = 'SilentlyContinue';",
|
|
|
|
|
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
|
|
|
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
|
|
|
"$OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
|
|
|
`$basePath = ${powerShellSingleQuote(basePath ?? "")};`,
|
|
|
|
|
`$operation = ${powerShellSingleQuote(operation)};`,
|
|
|
|
|
`$argsJsonB64 = ${powerShellSingleQuote(argsJsonB64)};`,
|
|
|
|
|
"$toolArgs = @();",
|
|
|
|
|
"if (-not [string]::IsNullOrEmpty($argsJsonB64)) { $json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($argsJsonB64)); $decoded = ConvertFrom-Json -InputObject $json; if ($null -ne $decoded) { foreach ($item in @($decoded)) { $toolArgs += [string]$item } } }",
|
|
|
|
|
"function Fail([string]$Message, [int]$Code) { [Console]::Error.WriteLine($Message); exit $Code }",
|
|
|
|
|
"function Resolve-UnideskPath([string]$Raw) { if ([string]::IsNullOrWhiteSpace($Raw) -or $Raw -eq '.') { if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [System.IO.Path]::GetFullPath($basePath) }; return (Get-Location).ProviderPath }; if ([System.IO.Path]::IsPathRooted($Raw)) { return [System.IO.Path]::GetFullPath($Raw) }; if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($basePath, $Raw)) }; return [System.IO.Path]::GetFullPath($Raw) }",
|
|
|
|
|
"function Need-File([string]$Path) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { Fail ('file not found: ' + $Path) 1 } }",
|
|
|
|
|
"function Get-Sha256([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() }",
|
|
|
|
|
"function Read-StrictText([string]$Path, [Int64]$MaxBytes) { Need-File $Path; $info = [System.IO.FileInfo]$Path; if ($MaxBytes -gt 0 -and $info.Length -gt $MaxBytes) { Fail ('file too large for windows fs read operation: ' + $Path + ' bytes=' + $info.Length + ' maxBytes=' + $MaxBytes + '; use head/tail/download or --max-bytes') 23 }; $bytes = [System.IO.File]::ReadAllBytes($Path); if ([Array]::IndexOf($bytes, [byte]0) -ge 0) { Fail ('binary file refused by windows fs read operation: ' + $Path) 24 }; $utf8 = [System.Text.UTF8Encoding]::new($false, $true); try { return $utf8.GetString($bytes) } catch { Fail ('file is not valid UTF-8: ' + $Path + ': ' + $_.Exception.Message) 25 } }",
|
|
|
|
|
"function Try-Read-StrictText([string]$Path, [Int64]$MaxBytes) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }; $info = [System.IO.FileInfo]$Path; if ($MaxBytes -gt 0 -and $info.Length -gt $MaxBytes) { return $null }; $bytes = [System.IO.File]::ReadAllBytes($Path); if ([Array]::IndexOf($bytes, [byte]0) -ge 0) { return $null }; $utf8 = [System.Text.UTF8Encoding]::new($false, $true); try { return $utf8.GetString($bytes) } catch { return $null } }",
|
|
|
|
|
"function Parse-PositiveInt([string]$Name, [string]$Value, [int]$Max) { $parsed = 0; if (-not [Int32]::TryParse($Value, [ref]$parsed) -or $parsed -lt 0 -or ($Max -gt 0 -and $parsed -gt $Max)) { Fail ($Name + ' must be an integer from 0 to ' + $Max) 2 }; return $parsed }",
|
|
|
|
|
"function Print-Lines([string[]]$Lines) { if ($Lines.Count -gt 0) { [Console]::Out.Write(([string]::Join(\"`n\", $Lines)) + \"`n\") } }",
|
|
|
|
|
"function Text-Lines([string]$Text) { $lines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($Text -split \"`r?`n\", -1)) { $lines.Add($line) | Out-Null }; if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) }; return [string[]]$lines }",
|
|
|
|
|
"function Path-Args([int]$Start) { $paths = New-Object System.Collections.Generic.List[string]; for ($i = $Start; $i -lt $toolArgs.Count; $i++) { $paths.Add([string]$toolArgs[$i]) | Out-Null }; return [string[]]$paths }",
|
|
|
|
|
];
|
|
|
|
|
const operationScript: Record<WindowsFsReadOnlyOperation, string[]> = {
|
|
|
|
|
pwd: ["if ($toolArgs.Count -ne 0) { Fail 'pwd accepts no arguments on Windows routes' 2 }; [Console]::Out.WriteLine((Resolve-UnideskPath '.'))"],
|
|
|
|
|
ls: ["$all = $false; $limit = 200; $path = '.'; for ($i = 0; $i -lt $toolArgs.Count; $i++) { $arg = [string]$toolArgs[$i]; if ($arg -eq '-a' -or $arg -eq '--all') { $all = $true; continue }; if ($arg -eq '-l' -or $arg -eq '-la' -or $arg -eq '-al') { if ($arg.Contains('a')) { $all = $true }; continue }; if ($arg -eq '--limit') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--limit requires a value' 2 }; $limit = Parse-PositiveInt '--limit' ([string]$toolArgs[$i]) 5000; continue }; if ($arg.StartsWith('--limit=')) { $limit = Parse-PositiveInt '--limit' $arg.Substring(8) 5000; continue }; if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported ls option on Windows route: ' + $arg) 2 }; $path = $arg }; $target = Resolve-UnideskPath $path; if (-not (Test-Path -LiteralPath $target)) { Fail ('path not found: ' + $target) 1 }; $items = if (Test-Path -LiteralPath $target -PathType Container) { if ($all) { @(Get-ChildItem -LiteralPath $target -Force | Sort-Object -Property Name) } else { @(Get-ChildItem -LiteralPath $target | Sort-Object -Property Name) } } else { @(Get-Item -LiteralPath $target) }; [Console]::Out.WriteLine('TYPE BYTES UPDATED NAME'); $count = 0; foreach ($item in $items) { if ($count -ge $limit) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_FS_TRUNCATED ls limit=' + $limit); break }; $type = if ($item.PSIsContainer) { 'dir' } else { 'file' }; $bytes = if ($item.PSIsContainer) { '-' } else { [string]$item.Length }; [Console]::Out.WriteLine($type + ' ' + $bytes + ' ' + $item.LastWriteTimeUtc.ToString('o') + ' ' + $item.Name); $count += 1 }"],
|
|
|
|
|
cat: ["$maxBytes = " + String(windowsFsReadOnlyDefaultMaxBytes) + "; $paths = New-Object System.Collections.Generic.List[string]; for ($i = 0; $i -lt $toolArgs.Count; $i++) { $arg = [string]$toolArgs[$i]; if ($arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-PositiveInt '--max-bytes' ([string]$toolArgs[$i]) 16777216; continue }; if ($arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-PositiveInt '--max-bytes' $arg.Substring(12) 16777216; continue }; if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported cat option on Windows route: ' + $arg) 2 }; $paths.Add($arg) | Out-Null }; if ($paths.Count -ne 1) { Fail 'cat requires exactly one file path on Windows routes' 2 }; [Console]::Out.Write((Read-StrictText (Resolve-UnideskPath $paths[0]) $maxBytes))"],
|
|
|
|
|
head: ["$linesWanted = 10; $paths = New-Object System.Collections.Generic.List[string]; for ($i = 0; $i -lt $toolArgs.Count; $i++) { $arg = [string]$toolArgs[$i]; if ($arg -eq '-n' -or $arg -eq '--lines') { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $linesWanted = Parse-PositiveInt $arg ([string]$toolArgs[$i]) 10000; continue }; if ($arg.StartsWith('-n') -and $arg.Length -gt 2) { $linesWanted = Parse-PositiveInt '-n' $arg.Substring(2) 10000; continue }; if ($arg.StartsWith('--lines=')) { $linesWanted = Parse-PositiveInt '--lines' $arg.Substring(8) 10000; continue }; if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported head option on Windows route: ' + $arg) 2 }; $paths.Add($arg) | Out-Null }; if ($paths.Count -ne 1) { Fail 'head requires exactly one file path on Windows routes' 2 }; $lines = Text-Lines (Read-StrictText (Resolve-UnideskPath $paths[0]) 1048576); Print-Lines ([string[]]($lines | Select-Object -First $linesWanted))"],
|
|
|
|
|
tail: ["$linesWanted = 10; $paths = New-Object System.Collections.Generic.List[string]; for ($i = 0; $i -lt $toolArgs.Count; $i++) { $arg = [string]$toolArgs[$i]; if ($arg -eq '-n' -or $arg -eq '--lines') { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $linesWanted = Parse-PositiveInt $arg ([string]$toolArgs[$i]) 10000; continue }; if ($arg.StartsWith('-n') -and $arg.Length -gt 2) { $linesWanted = Parse-PositiveInt '-n' $arg.Substring(2) 10000; continue }; if ($arg.StartsWith('--lines=')) { $linesWanted = Parse-PositiveInt '--lines' $arg.Substring(8) 10000; continue }; if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported tail option on Windows route: ' + $arg) 2 }; $paths.Add($arg) | Out-Null }; if ($paths.Count -ne 1) { Fail 'tail requires exactly one file path on Windows routes' 2 }; $lines = Text-Lines (Read-StrictText (Resolve-UnideskPath $paths[0]) 1048576); Print-Lines ([string[]]($lines | Select-Object -Last $linesWanted))"],
|
|
|
|
|
rg: ["$ignoreCase = $false; $fixed = $false; $maxCount = 1000; $maxFiles = 5000; $maxBytes = 1048576; $pattern = $null; $paths = New-Object System.Collections.Generic.List[string]; $endOptions = $false; for ($i = 0; $i -lt $toolArgs.Count; $i++) { $arg = [string]$toolArgs[$i]; if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--') { $endOptions = $true; continue }; if ($null -eq $pattern -and -not $endOptions -and ($arg -eq '-i' -or $arg -eq '--ignore-case')) { $ignoreCase = $true; continue }; if ($null -eq $pattern -and -not $endOptions -and ($arg -eq '-F' -or $arg -eq '--fixed-strings')) { $fixed = $true; continue }; if ($null -eq $pattern -and -not $endOptions -and ($arg -eq '-n' -or $arg -eq '--line-number')) { continue }; if ($null -eq $pattern -and -not $endOptions -and ($arg -eq '-m' -or $arg -eq '--max-count')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $maxCount = Parse-PositiveInt $arg ([string]$toolArgs[$i]) 10000; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-count=')) { $maxCount = Parse-PositiveInt '--max-count' $arg.Substring(12) 10000; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-m') -and $arg.Length -gt 2) { $maxCount = Parse-PositiveInt '-m' $arg.Substring(2) 10000; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--max-files') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-files requires a value' 2 }; $maxFiles = Parse-PositiveInt '--max-files' ([string]$toolArgs[$i]) 50000; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-files=')) { $maxFiles = Parse-PositiveInt '--max-files' $arg.Substring(12) 50000; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-PositiveInt '--max-bytes' ([string]$toolArgs[$i]) 16777216; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-PositiveInt '--max-bytes' $arg.Substring(12) 16777216; continue }; if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-')) { Fail ('unsupported rg option on Windows route: ' + $arg) 2 }; if ($null -eq $pattern) { $pattern = $arg } else { $paths.Add($arg) | Out-Null } }; if ($null -eq $pattern) { Fail 'rg requires a pattern on Windows routes' 2 }; if ($paths.Count -eq 0) { $paths.Add('.') | Out-Null }; $regexPattern = if ($fixed) { [regex]::Escape($pattern) } else { $pattern }; $regexOptions = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant; if ($ignoreCase) { $regexOptions = $regexOptions -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase }; try { $regex = [regex]::new($regexPattern, $regexOptions) } catch { Fail ('invalid rg pattern on Windows route: ' + $_.Exception.Message) 2 }; $files = New-Object System.Collections.Generic.List[string]; $fileTruncated = $false; foreach ($raw in $paths) { $target = Resolve-UnideskPath $raw; if (Test-Path -LiteralPath $target -PathType Leaf) { if ($files.Count -lt $maxFiles) { $files.Add($target) | Out-Null } else { $fileTruncated = $true; break } } elseif (Test-Path -LiteralPath $target -PathType Container) { foreach ($child in Get-ChildItem -LiteralPath $target -Recurse -File -ErrorAction SilentlyContinue) { if ($files.Count -ge $maxFiles) { $fileTruncated = $true; break }; $files.Add($child.FullName) | Out-Null } } else { Fail ('path not found: ' + $target) 1 }; if ($fileTruncated) { break } }; if ($fileTruncated) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_FS_TRUNCATED rg maxFiles=' + $maxFiles) }; $matchCount = 0; $skipped = 0; $truncated = $false; foreach ($file in $files) { $text = Try-Read-StrictText $file $maxBytes; if ($null -eq $text) { $skipped += 1; continue }; $lines = Text-Lines $text; for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex += 1) { if ($regex.IsMatch($lines[$lineIndex])) { [Console]::Out.WriteLine($file + ':' + ($lineIndex + 1) + ':' + $lines[$lineIndex]); $matchCount += 1; if ($matchCount -ge $maxCount) { $truncated = $true; break } } }; if ($truncated) { break } }; if ($skipped -gt 0) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_FS_SKIPPED rg files=' + $skipped + ' reason=binary-invalid-utf8-or-too-large') }; if ($truncated) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_FS_TRUNCATED rg maxCount=' + $maxCount) }; if ($matchCount -eq 0) { exit 1 }"],
|
|
|
|
|
stat: ["$paths = Path-Args 0; if ($paths.Count -eq 0) { Fail 'stat requires at least one path on Windows routes' 2 }; [Console]::Out.WriteLine('TYPE BYTES SHA256 PATH'); foreach ($raw in $paths) { $path = Resolve-UnideskPath $raw; if (-not (Test-Path -LiteralPath $path)) { Fail ('path not found: ' + $path) 1 }; $item = Get-Item -LiteralPath $path; if ($item.PSIsContainer) { [Console]::Out.WriteLine('dir - - ' + $path) } else { [Console]::Out.WriteLine('file ' + $item.Length + ' ' + (Get-Sha256 $path) + ' ' + $path) } }"],
|
|
|
|
|
wc: ["$paths = Path-Args 0; if ($paths.Count -eq 0) { Fail 'wc requires at least one file path on Windows routes' 2 }; [Console]::Out.WriteLine('LINES WORDS CHARS BYTES PATH'); foreach ($raw in $paths) { $path = Resolve-UnideskPath $raw; $text = Read-StrictText $path 1048576; $bytes = ([System.IO.FileInfo]$path).Length; $lineCount = [regex]::Matches($text, \"`n\").Count; if ($text.Length -gt 0 -and -not $text.EndsWith(\"`n\")) { $lineCount += 1 }; $wordCount = [regex]::Matches($text, '\\S+').Count; [Console]::Out.WriteLine(([string]$lineCount) + ' ' + $wordCount + ' ' + $text.Length + ' ' + $bytes + ' ' + $path) }"],
|
|
|
|
|
};
|
|
|
|
|
return [...commonScript, ...operationScript[operation]].join(" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildWindowsCmdLine(userCommand: string, cwd: string | null): string {
|
|
|
|
|
const parts = [
|
|
|
|
|
"chcp 65001>nul",
|
|
|
|
|