fix: harden Windows trans helpers

Resolve #1691 by preserving argv boundaries, adding bounded native rg and wc/skill query support, surfacing WSL-to-Windows hints, and splitting the oversized SSH module and embedded remote scripts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-07-10 13:13:03 +02:00
parent 2d4c1a5ffa
commit 3a17d3b9fd
22 changed files with 2712 additions and 2249 deletions
+13
View File
@@ -0,0 +1,13 @@
if ($operation -eq 'cat') {
$maxBytes = 262144; $paths = [Collections.Generic.List[string]]::new()
for ($i = 0; $i -lt $toolArgs.Count; $i++) {
$arg = $toolArgs[$i]
if ($arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-NonNegativeInt '--max-bytes' $toolArgs[$i] 16777216; continue }
if ($arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-NonNegativeInt '--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)
}
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))
exit 0
}
+50
View File
@@ -0,0 +1,50 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new()
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
$OutputEncoding = [System.Text.UTF8Encoding]::new()
$toolArgs = @()
$decodedArgs = ConvertFrom-Json -InputObject ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($argsJsonB64)))
if ($null -ne $decodedArgs) { foreach ($item in @($decodedArgs)) { $toolArgs += [string]$item } }
$rgPolicy = ConvertFrom-Json -InputObject ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($rgPolicyJsonB64)))
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 [IO.Path]::GetFullPath($basePath) }
return (Get-Location).ProviderPath
}
if ([IO.Path]::IsPathRooted($Raw)) { return [IO.Path]::GetFullPath($Raw) }
if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [IO.Path]::GetFullPath([IO.Path]::Combine($basePath, $Raw)) }
return [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 Parse-NonNegativeInt([string]$Name, [string]$Value, [int]$Max) {
$parsed = 0
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 0 -or $parsed -gt $Max) { Fail ($Name + ' must be an integer from 0 to ' + $Max) 2 }
return $parsed
}
function Read-StrictText([string]$Path, [long]$MaxBytes) {
Need-File $Path
$info = [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 = [IO.File]::ReadAllBytes($Path)
if ([Array]::IndexOf($bytes, [byte]0) -ge 0) { Fail ('binary file refused by windows fs read operation: ' + $Path) 24 }
try { return [Text.UTF8Encoding]::new($false, $true).GetString($bytes) } catch { Fail ('file is not valid UTF-8: ' + $Path + ': ' + $_.Exception.Message) 25 }
}
function Try-Read-StrictText([string]$Path, [long]$MaxBytes) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
if (([IO.FileInfo]$Path).Length -gt $MaxBytes) { return $null }
$bytes = [IO.File]::ReadAllBytes($Path)
if ([Array]::IndexOf($bytes, [byte]0) -ge 0) { return $null }
try { return [Text.UTF8Encoding]::new($false, $true).GetString($bytes) } catch { return $null }
}
function Text-Lines([string]$Text) {
$lines = [Collections.Generic.List[string]]::new()
foreach ($line in ($Text -split "`r?`n", -1)) { $lines.Add($line) }
if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) }
return [string[]]$lines
}
function Print-Lines([string[]]$Lines) { if ($Lines.Count -gt 0) { [Console]::Out.Write(([string]::Join("`n", $Lines)) + "`n") } }
+15
View File
@@ -0,0 +1,15 @@
if ($operation -in @('head', 'tail')) {
$linesWanted = 10; $paths = [Collections.Generic.List[string]]::new()
for ($i = 0; $i -lt $toolArgs.Count; $i++) {
$arg = $toolArgs[$i]
if ($arg -in @('-n', '--lines')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $linesWanted = Parse-NonNegativeInt $arg $toolArgs[$i] 10000; continue }
if ($arg.StartsWith('-n') -and $arg.Length -gt 2) { $linesWanted = Parse-NonNegativeInt '-n' $arg.Substring(2) 10000; continue }
if ($arg.StartsWith('--lines=')) { $linesWanted = Parse-NonNegativeInt '--lines' $arg.Substring(8) 10000; continue }
if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported ' + $operation + ' option on Windows route: ' + $arg) 2 }
$paths.Add($arg)
}
if ($paths.Count -ne 1) { Fail ($operation + ' requires exactly one file path on Windows routes') 2 }
$lines = Text-Lines (Read-StrictText (Resolve-UnideskPath $paths[0]) 1048576)
if ($operation -eq 'head') { Print-Lines ([string[]]($lines | Select-Object -First $linesWanted)) } else { Print-Lines ([string[]]($lines | Select-Object -Last $linesWanted)) }
exit 0
}
+25
View File
@@ -0,0 +1,25 @@
if ($operation -eq 'ls') {
$all = $false; $limit = 200; $path = '.'
for ($i = 0; $i -lt $toolArgs.Count; $i++) {
$arg = $toolArgs[$i]
if ($arg -in @('-a', '--all')) { $all = $true; continue }
if ($arg -in @('-l', '-la', '-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-NonNegativeInt '--limit' $toolArgs[$i] 5000; continue }
if ($arg.StartsWith('--limit=')) { $limit = Parse-NonNegativeInt '--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) { @(Get-ChildItem -LiteralPath $target -Force:$all | Sort-Object 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
}
exit 0
}
+5
View File
@@ -0,0 +1,5 @@
if ($operation -eq 'pwd') {
if ($toolArgs.Count -ne 0) { Fail 'pwd accepts no arguments on Windows routes' 2 }
[Console]::Out.WriteLine((Resolve-UnideskPath '.'))
exit 0
}
+68
View File
@@ -0,0 +1,68 @@
if ($operation -ne 'rg') { Fail ('unsupported Windows fs operation: ' + $operation) 2 }
$ignoreCase = $false; $fixed = $false; $filesOnly = $false; $pattern = $null; $endOptions = $false
$maxCount = [int]$rgPolicy.maxMatches; $maxFiles = [int]$rgPolicy.maxFiles; $maxBytes = [int]$rgPolicy.maxBytesPerFile; $timeoutMs = [int]$rgPolicy.timeoutMs
$paths = [Collections.Generic.List[string]]::new(); $globs = [Collections.Generic.List[string]]::new()
for ($i = 0; $i -lt $toolArgs.Count; $i++) {
$arg = $toolArgs[$i]
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--') { $endOptions = $true; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-i', '--ignore-case')) { $ignoreCase = $true; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-F', '--fixed-strings')) { $fixed = $true; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-n', '--line-number')) { continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--files') { $filesOnly = $true; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-g', '--glob')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $globs.Add($toolArgs[$i]); continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--glob=')) { $globs.Add($arg.Substring(7)); continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-m', '--max-count')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $maxCount = Parse-NonNegativeInt $arg $toolArgs[$i] 10000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-m') -and $arg.Length -gt 2) { $maxCount = Parse-NonNegativeInt '-m' $arg.Substring(2) 10000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-count=')) { $maxCount = Parse-NonNegativeInt '--max-count' $arg.Substring(12) 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-NonNegativeInt '--max-files' $toolArgs[$i] 50000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-files=')) { $maxFiles = Parse-NonNegativeInt '--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-NonNegativeInt '--max-bytes' $toolArgs[$i] 16777216; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-NonNegativeInt '--max-bytes' $arg.Substring(12) 16777216; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--timeout-ms') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--timeout-ms requires a value' 2 }; $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $toolArgs[$i] 60000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--timeout-ms=')) { $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $arg.Substring(13) 60000; continue }
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-')) { Fail ('unsupported rg option on Windows route: ' + $arg + '; supported: --files -g/--glob -i -F -n -m/--max-count --max-files --max-bytes --timeout-ms') 2 }
if ($filesOnly) { $paths.Add($arg) } elseif ($null -eq $pattern) { $pattern = $arg } else { $paths.Add($arg) }
}
if (-not $filesOnly -and $null -eq $pattern) { Fail 'rg requires a pattern on Windows routes (or use --files)' 2 }
if ($paths.Count -eq 0) { $paths.Add('.') }
$nativeRg = Get-Command rg.exe -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -eq $nativeRg) { Fail 'rg.exe is unavailable on this Windows plane; install ripgrep or use ps with a reviewed PowerShell search' 127 }
if ($null -ne $nativeRg) {
function Quote-NativeArg([string]$Value) {
if ($Value.Contains('"')) { Fail 'rg argument contains a quote; use ps for shell-reviewed quoting' 2 }
if ($Value -match '\s') { return '"' + $Value + '"' }
return $Value
}
$nativeArgs = [Collections.Generic.List[string]]::new()
$nativeArgs.Add('--color=never'); $nativeArgs.Add('--no-heading'); $nativeArgs.Add('--line-number')
$nativeArgs.Add('--max-filesize'); $nativeArgs.Add([string]$maxBytes)
if ($ignoreCase) { $nativeArgs.Add('--ignore-case') }
if ($fixed) { $nativeArgs.Add('--fixed-strings') }
foreach ($glob in $globs) { $nativeArgs.Add('--glob'); $nativeArgs.Add($glob) }
if ($filesOnly) { $nativeArgs.Add('--files') } else { $nativeArgs.Add($pattern) }
foreach ($raw in $paths) { $nativeArgs.Add((Resolve-UnideskPath $raw)) }
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $nativeRg.Source
$startInfo.Arguments = [string]::Join(' ', @($nativeArgs | ForEach-Object { Quote-NativeArg $_ }))
$startInfo.UseShellExecute = $false; $startInfo.CreateNoWindow = $true; $startInfo.RedirectStandardOutput = $true; $startInfo.RedirectStandardError = $true
$process = [Diagnostics.Process]::new(); $process.StartInfo = $startInfo
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
if (-not $process.Start()) { Fail 'failed to start native rg.exe' 2 }
$stdoutTask = $process.StandardOutput.ReadToEndAsync(); $stderrTask = $process.StandardError.ReadToEndAsync()
$timedOut = -not $process.WaitForExit($timeoutMs)
if ($timedOut) { try { $process.Kill() } catch {}; $process.WaitForExit() }
$stdoutText = $stdoutTask.Result; $stderrText = $stderrTask.Result; $stopwatch.Stop()
$lines = Text-Lines $stdoutText; $outputLimit = if ($filesOnly) { $maxFiles } else { $maxCount }; $selected = [string[]]($lines | Select-Object -First $outputLimit)
Print-Lines $selected
if (-not [string]::IsNullOrWhiteSpace($stderrText)) { [Console]::Error.Write($stderrText) }
$truncated = $lines.Count -gt $selected.Count
$matchedFiles = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
if (-not $filesOnly) { foreach ($line in $selected) { if ($line -match '^([A-Za-z]:\\.*?):\d+:') { [void]$matchedFiles.Add($Matches[1]) } } }
$fileCount = if ($filesOnly) { $selected.Count } else { $matchedFiles.Count }
[Console]::Error.WriteLine('UNIDESK_WINDOWS_RG_SUMMARY matched=' + $selected.Count + ' fileCount=' + $fileCount + ' elapsedMs=' + $stopwatch.ElapsedMilliseconds + ' timeout=' + $timedOut.ToString().ToLowerInvariant() + ' skipped=0 fileLimit=' + ($filesOnly -and $truncated).ToString().ToLowerInvariant() + ' matchLimit=' + ((-not $filesOnly) -and $truncated).ToString().ToLowerInvariant() + ' engine=native-rg config=config/unidesk-cli.yaml#trans.windowsFs.rg')
if ($timedOut) { exit 124 }
if ($process.ExitCode -ne 0) { exit $process.ExitCode }
exit 0
}
+11
View File
@@ -0,0 +1,11 @@
if ($operation -eq 'stat') {
if ($toolArgs.Count -eq 0) { Fail 'stat requires at least one path on Windows routes' 2 }
[Console]::Out.WriteLine('TYPE BYTES SHA256 PATH')
foreach ($raw in $toolArgs) {
$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) }
}
exit 0
}
+23
View File
@@ -0,0 +1,23 @@
if ($operation -eq 'wc') {
$showLines = $false; $showWords = $false; $showChars = $false; $showBytes = $false; $paths = [Collections.Generic.List[string]]::new()
foreach ($arg in $toolArgs) {
if ($arg -eq '-l' -or $arg -eq '--lines') { $showLines = $true; continue }
if ($arg -eq '-w' -or $arg -eq '--words') { $showWords = $true; continue }
if ($arg -eq '-m' -or $arg -eq '--chars') { $showChars = $true; continue }
if ($arg -eq '-c' -or $arg -eq '--bytes') { $showBytes = $true; continue }
if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported wc option on Windows route: ' + $arg) 2 }
$paths.Add($arg)
}
if ($paths.Count -eq 0) { Fail 'wc requires at least one file path on Windows routes' 2 }
if (-not ($showLines -or $showWords -or $showChars -or $showBytes)) { $showLines = $true; $showWords = $true; $showChars = $true; $showBytes = $true }
foreach ($raw in $paths) {
$path = Resolve-UnideskPath $raw; $text = Read-StrictText $path 1048576; $values = [Collections.Generic.List[string]]::new()
$lineCount = [regex]::Matches($text, "`n").Count; if ($text.Length -gt 0 -and -not $text.EndsWith("`n")) { $lineCount += 1 }
if ($showLines) { $values.Add([string]$lineCount) }
if ($showWords) { $values.Add([string]([regex]::Matches($text, '\S+').Count)) }
if ($showChars) { $values.Add([string]$text.Length) }
if ($showBytes) { $values.Add([string]([IO.FileInfo]$path).Length) }
$values.Add($path); [Console]::Out.WriteLine([string]::Join(' ', $values))
}
exit 0
}