fix: add windows trans fs read ops

This commit is contained in:
Codex
2026-06-26 17:38:48 +00:00
parent ee1ff82251
commit fdd1740203
5 changed files with 113 additions and 3 deletions
+3 -1
View File
@@ -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/<number> cat
```
@@ -24,7 +26,7 @@ Host workspace、k3s、Windows、GitHub issue/PR routesh/bash/argv/apply-patc
- 远端文本修改优先 `trans <route> 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
@@ -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
+9
View File
@@ -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 <route> --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 <provider>: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 <provider>:win/<drive>/<path> 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 <route> argv ls -la` is valid, but `trans <route> 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.",
+39
View File
@@ -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);
+53 -1
View File
@@ -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",