From fdd1740203bb61e34db015747b32dedd2d06f0bb Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 26 Jun 2026 17:38:48 +0000 Subject: [PATCH] fix: add windows trans fs read ops --- .agents/skills/unidesk-trans/SKILL.md | 4 +- .../skills/unidesk-trans/references/full.md | 10 +++- scripts/src/help.ts | 9 ++++ scripts/src/ssh.test.ts | 39 ++++++++++++++ scripts/src/ssh.ts | 54 ++++++++++++++++++- 5 files changed, 113 insertions(+), 3 deletions(-) diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 4006f163..0bf3333d 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -14,6 +14,8 @@ trans D601:/home/ubuntu/workspace/unidesk-dev git status --short --branch trans D601:k3s kubectl get pods -A trans D601:k3s:namespace:workload[:container] logs --tail 120 trans D601:win ps <<'PS' +trans D601:win/c/test cat README.md +trans D601:win/c/test rg -i needle . trans gh:/owner/repo/issue/ cat ``` @@ -24,7 +26,7 @@ Host workspace、k3s、Windows、GitHub issue/PR route,sh/bash/argv/apply-patc - 远端文本修改优先 `trans apply-patch`;不要 download/upload/sed 拼临时 diff 代替 patch。 - `sh`/`bash` 必须显式声明 shell;单进程命令优先 direct argv 或已知 operation。 - 普通 trans/ssh 短连接硬预算 60s;长 CI/CD、trace、logs、build、硬件流程必须 submit-and-poll。 -- Windows route 使用 `win ps` 或 `win cmd`;不要把 POSIX shell 当 Windows shell。 +- Windows route 使用 `win ps`、`win cmd` 或只读 fs 操作 `pwd|ls|cat|head|tail|stat|wc|rg`;不要把 POSIX shell 当 Windows shell。 ## 何时读取 reference diff --git a/.agents/skills/unidesk-trans/references/full.md b/.agents/skills/unidesk-trans/references/full.md index 0c13e790..d4254b15 100644 --- a/.agents/skills/unidesk-trans/references/full.md +++ b/.agents/skills/unidesk-trans/references/full.md @@ -54,9 +54,17 @@ Get-ChildItem C:\test PS trans D601:win/c/test cmd cd +trans D601:win/c/test pwd +trans D601:win/c/test ls --limit 50 +trans D601:win/c/test cat README.md +trans D601:win/c/test head -n 40 README.md +trans D601:win/c/test tail -n 40 README.md +trans D601:win/c/test stat README.md +trans D601:win/c/test wc README.md +trans D601:win/c/test rg -i needle . ``` -Windows operation 必须显式区分:`ps` 走 PowerShell,`cmd` 走 cmd.exe。 +Windows operation 必须显式区分:`ps` 走 PowerShell,`cmd` 走 cmd.exe。`pwd|ls|cat|head|tail|stat|wc|rg` 是 Windows 文件系统只读 helper,带 UTF-8/binary 检查和输出上限,不表示 Windows route 有 POSIX `sh`/`bash`。其中 `rg` 是受限 UTF-8 正则搜索子集,支持 `-i/--ignore-case`、`-F/--fixed-strings`、`-n`、`-m/--max-count`、`--max-files`、`--max-bytes`。 ### GitHub issue/PR route diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 9883baf1..a73a6993 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -185,6 +185,14 @@ export function sshHelp(): unknown { "trans D601:win/c/test cmd <<'CMD'", "trans D601:win cmd ver", "trans D601:win/c/test cmd cd", + "trans D601:win/c/test pwd", + "trans D601:win/c/test ls --limit 50", + "trans D601:win/c/test cat README.md", + "trans D601:win/c/test head -n 40 README.md", + "trans D601:win/c/test tail -n 40 README.md", + "trans D601:win/c/test stat README.md", + "trans D601:win/c/test wc README.md", + "trans D601:win/c/test rg -i needle .", "trans D601:win skills [--scope agents|codex|all] [--limit N]", "trans D601:k3s", "trans D601:k3s kubectl get pods -n hwlab-dev", @@ -207,6 +215,7 @@ export function sshHelp(): unknown { "trans --help and trans --help print this JSON help and never open an interactive session; the underlying ssh subcommand keeps the same help behavior.", "For non-interactive remote commands, prefer argv for a single process and explicit sh/bash stdin for shell logic.", "Windows routes have explicit Windows operations, not POSIX shell aliases: `ps` runs Windows PowerShell from stdin or one inline command, `cmd` runs cmd.exe/batch from stdin or one command line, and `skills` discovers Windows skill directories.", + "Windows routes include read-only filesystem convenience operations `pwd`, `ls`, `cat`, `head`, `tail`, `stat`, `wc`, and a bounded UTF-8 `rg` subset. These are implemented through a Windows fs backend with UTF-8/binary checks and bounded output; they do not imply POSIX `sh`/`bash` availability.", "For Windows PowerShell, use `trans :win ps <<'PS'`; the PowerShell body is written to a temporary .ps1 with UTF-8 settings and executed by powershell.exe. Do not use POSIX `sh` or `bash` for Windows PowerShell.", "For Windows cmd.exe, use `trans :win// cmd <<'CMD'`; `cmd` with no command-line arguments reads the UTF-8 batch body from stdin, injects UTF-8/Python encoding defaults, runs it from a temp .cmd file, and deletes the temp file.", "`argv` executes direct argv tokens only: `trans argv ls -la` is valid, but `trans argv 'ls -la'` is rejected because the single string would be treated as an executable path; use `sh -- 'ls -la'` or `bash -- 'ls -la'` for one-line shell logic.", diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index b3704e10..2f9b0b44 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -5,6 +5,7 @@ import { parseSshInvocation, sshStdoutStreamMaxBytes, sshStdoutTruncationHint, + windowsFsReadOnlyScript, windowsPowerShellScriptPrelude, } from "./ssh"; @@ -20,6 +21,44 @@ describe("ssh windows PowerShell safety prelude", () => { }); }); +describe("ssh windows fs read-only operations", () => { + test("routes common read-only commands through the Windows fs backend", () => { + const invocation = parseSshInvocation("D601:win/c/test", ["cat", "hello.md"]); + const rgInvocation = parseSshInvocation("D601:win/c/test", ["rg", "-i", "needle", "."]); + + expect(invocation.route.plane).toBe("win"); + expect(invocation.route.workspace).toBe("C:\\test"); + expect(invocation.parsed.invocationKind).toBe("helper"); + expect(invocation.parsed.requiresStdin).toBe(false); + expect(invocation.parsed.remoteCommand).toContain("powershell.exe"); + expect(invocation.parsed.remoteCommand).toContain("-EncodedCommand"); + expect(rgInvocation.parsed.invocationKind).toBe("helper"); + expect(rgInvocation.parsed.requiresStdin).toBe(false); + }); + + test("builds bounded UTF-8 scripts for cat, ls, and rg", () => { + const catScript = windowsFsReadOnlyScript("F:\\Work\\demo", "cat", ["--max-bytes", "4096", "中文.md"]); + const lsScript = windowsFsReadOnlyScript("F:\\Work\\demo", "ls", ["-la", "--limit=5"]); + const rgScript = windowsFsReadOnlyScript("F:\\Work\\demo", "rg", ["-i", "--max-count=5", "needle", "."]); + + expect(catScript).toContain("$operation = 'cat';"); + expect(catScript).toContain("F:\\Work\\demo"); + expect(catScript).toContain("binary file refused by windows fs read operation"); + expect(catScript).toContain("file is not valid UTF-8"); + expect(catScript).toContain("file too large for windows fs read operation"); + expect(lsScript).toContain("$operation = 'ls';"); + expect(lsScript).toContain("UNIDESK_WINDOWS_FS_TRUNCATED"); + expect(lsScript).toContain("TYPE BYTES UPDATED NAME"); + expect(rgScript).toContain("$operation = 'rg';"); + expect(rgScript).toContain("unsupported rg option on Windows route"); + expect(rgScript).toContain("UNIDESK_WINDOWS_FS_SKIPPED"); + }); + + test("rejects unsupported Windows read operations instead of treating them as POSIX", () => { + expect(() => parseSshInvocation("D601:win/c/test", ["sed", "-n", "1p", "hello.md"])).toThrow("unsupported ssh win operation: sed"); + }); +}); + describe("ssh stdout bounded streaming", () => { test("uses bounded defaults and clamps env override", () => { expect(sshStdoutStreamMaxBytes({} as NodeJS.ProcessEnv)).toBe(256 * 1024); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index f2ed05fc..d9777e4b 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -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 , 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 , ssh ${route.providerId}:win , 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(["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 = { + 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",