From f2ffdb090e227fe40f60fa0f2f2b3800982caac6 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 15:30:49 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=E5=BC=BA=20Windows=20rg=20?= =?UTF-8?q?=E4=B8=8E=20PowerShell=20=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-trans/SKILL.md | 3 ++- docs/reference/cli.md | 6 +++++ scripts/src/ssh-windows-fs/rg.ps1 | 2 +- scripts/src/ssh.test.ts | 22 +++++++++++++++++ scripts/src/ssh.ts | 34 ++++++++++++++++++++++++++- 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 67376de2..fe8e21a9 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -35,7 +35,7 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md - 普通 trans/ssh 短连接硬预算 60s;长 CI/CD、trace、logs、build、硬件流程必须 submit-and-poll。 - `/mnt/` WSL host workspace 由 root 透传执行时,CLI 自动把 workspace owner uid 桥接到进程级 `SUDO_UID`,让 Git 信任 Windows 挂载目录而不写全局 `safe.directory`;非 Git 命令和普通 Linux workspace 不改变所有权信任。 - Windows route 的 `win` 是 route plane,operation 直接写 `ps`、`cmd`、`git` 或只读 fs 操作 `pwd|ls|cat|head|tail|stat|wc|rg`;不要写成 `trans D601:win/... win ps`,也不要把 POSIX shell 当 Windows shell。 -- Windows `rg` 调用 Windows PATH 中的原生 `rg.exe`,支持 `--files`、`-g|--glob`、`--max-count`、`--max-files` 和 `--timeout-ms`;stderr 固定输出 `UNIDESK_WINDOWS_RG_SUMMARY`,其中包含 `matched`、`fileCount`、`elapsedMs` 和 `timeout`,默认预算来自 `config/unidesk-cli.yaml#trans.windowsFs.rg`。缺少 `rg.exe` 时明确返回 127 和安装/PowerShell 替代提示。 +- Windows `rg` 调用 Windows PATH 中的原生 `rg.exe`,支持 `--files`、`-g|--glob`、`--max-count`、`--max-files` 和 `--timeout-ms`;stderr 固定输出 `UNIDESK_WINDOWS_RG_SUMMARY`,其中包含 `matched`、`fileCount`、`elapsedMs` 和 `timeout`,默认预算来自 `config/unidesk-cli.yaml#trans.windowsFs.rg`。超时额外输出 `UNIDESK_WINDOWS_RG_HINT code=timeout exitCode=124 action=narrow-scope-or-add-glob`;缺少 `rg.exe` 时明确返回 127 和安装/PowerShell 替代提示。 - Windows `wc` 支持 `-l|--lines`、`-w|--words`、`-m|--chars` 和 `-c|--bytes`;`skills` 支持 `--name <精确名称>` 与 `--filter <文本>`,单个 skill 查询不应先拉取全量列表。 - Windows `head` / `tail` 支持 `-n N`、`-nN`、`--lines=N` 和 GNU 简写 `-N`;PowerShell prelude 将 `Get-Content` 默认编码固定为 UTF-8,读取 UTF-8 Markdown 时应与 `cat` helper 保持一致。 - Windows `ps ''` 的单个参数按 PowerShell 源码执行;`ps ` 的多个参数按 argv 边界执行,Windows native application 通过显式命令行引用保留 `python -c` 代码、路径、encoding 和字典 key 中的嵌套引号。 @@ -45,6 +45,7 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md - `/mnt/` workspace 的命令返回 `command not found` 时,CLI 会输出对应 `:win//... cmd` 的 `UNIDESK_SSH_HINT`;该提示只建议核对 Windows plane,不改变原命令退出码。 - Windows `git` helper 拒绝包含 `%` format 等需 shell review 的复杂参数时,按错误提示改用 `trans :win/... ps ''`,不要反复改写 direct `git` argv 绕过校验。 - 从本地 Bash 调用一行 Windows `ps` 时,PowerShell 表达式外层使用单引号,保护 `$`、`$_` 和 `$_.Property` 不被本地 shell 提前展开;复杂或多行 PowerShell 改用 `ps <<'PS'` heredoc。 +- 单行 `ps` 源码若因本地 shell 展开而以缺失左值的 `=` 开始,CLI 在远端执行前返回 `ssh-windows-powershell-local-expansion`,并给出 `$p`、`$_` 的单引号示例和 quoted `PS` heredoc。 - 扩展 Windows helper 时保持 operation-scoped PowerShell payload;不要把多操作大脚本塞进 single `EncodedCommand`。 ## 何时读取 reference diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b3c986ca..b601f3b0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -12,6 +12,12 @@ UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式 Windows `ps` 的参数语义分为两种:单个参数是完整 PowerShell source;多个参数是边界明确的 argv。后者对 native application 使用 Windows 命令行引用规则,必须保留 `python -c` 代码中的路径、`encoding="utf-8"`、字典 key 和嵌套引号,并继承 route 对应的 Windows workspace。PowerShell prelude 将 `Get-Content` 默认编码固定为 UTF-8;Windows fs helper 的 `head` / `tail` 同时支持 `-n N`、`-nN`、`--lines=N` 和 GNU `-N`,`wc -l` 必须把选项与路径分开解析。 +- Windows `rg` / PowerShell 本地边界: + - broad `rg` 超时必须先输出 `UNIDESK_WINDOWS_RG_SUMMARY ... timeout=true`,再输出 `UNIDESK_WINDOWS_RG_HINT code=timeout exitCode=124 action=narrow-scope-or-add-glob` 并返回 124;无匹配仍返回 1,二者不能混淆。 + - 一行 PowerShell source 必须用本地单引号,例如 `trans G14-WSL:win/d/Work/demo ps '$p = Start-Process python -PassThru; $p.Id'` 和 `trans G14-WSL:win/d/Work/demo ps 'Get-Process | Where-Object { $_.CPU -gt 0 }'`。 + - 多行 PowerShell 使用 `trans ps <<'PS'`;quoted delimiter 保证 `$p`、`$_`、反斜杠和代码块不会被本地 shell 提前解释。 + - 源码因本地展开而以缺失左值的 `=` 开始时,CLI 必须在远端执行前返回 `ssh-windows-powershell-local-expansion`、单引号示例和 quoted heredoc 示例。 + - Windows/WSL `apply-patch v2` 文本完整性合同: - localized replacement 必须保留纯 CRLF 文件的 CRLF 风格,不能把新增行写成 bare LF,也不能把整个文件静默改写为 LF。 - original/final integrity mismatch 后必须重新只读采集当前文件,输出 adapter、stage、expected/current bytes、SHA-256、newline 和 encoding;摘要不得包含文件正文。 diff --git a/scripts/src/ssh-windows-fs/rg.ps1 b/scripts/src/ssh-windows-fs/rg.ps1 index ac0fc8a1..8df2365e 100644 --- a/scripts/src/ssh-windows-fs/rg.ps1 +++ b/scripts/src/ssh-windows-fs/rg.ps1 @@ -62,7 +62,7 @@ if ($null -ne $nativeRg) { 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 ($timedOut) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_RG_HINT code=timeout exitCode=124 action=narrow-scope-or-add-glob message=retry-with-a-narrower-path-or-g/--glob'); exit 124 } if ($process.ExitCode -ne 0) { exit $process.ExitCode } exit 0 } diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index 49605d5f..6d659a9d 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -98,6 +98,7 @@ describe("ssh windows fs read-only operations", () => { expect(rgScript).toContain("$operation = 'rg';"); expect(rgScript).toContain("unsupported rg option on Windows route"); expect(rgScript).toContain("UNIDESK_WINDOWS_RG_SUMMARY"); + expect(rgScript).toContain("UNIDESK_WINDOWS_RG_HINT code=timeout exitCode=124 action=narrow-scope-or-add-glob"); expect(rgScript).toContain("engine=native-rg"); expect(rgScript).toContain("config/unidesk-cli.yaml#trans.windowsFs.rg"); expect(rgScript).toContain("--files"); @@ -142,6 +143,27 @@ describe("ssh windows fs read-only operations", () => { expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["win", "ps"])).toThrow("route D601:win/F/Work/ConStart already selects the Windows plane"); expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "commit"])).toThrow("ssh win git commit would open an editor"); }); + + test("diagnoses locally expanded PowerShell variables before remote execution", () => { + try { + parseSshInvocation("G14-WSL:win/d/Work/demo", ["ps", "= Start-Process python -PassThru; .Id"]); + throw new Error("expected local PowerShell expansion diagnosis"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const payload = error as Error & { + code?: string; + replacementExamples?: { oneLine?: string; pipeline?: string; stdin?: string }; + migrationHint?: string; + }; + expect(payload.code).toBe("ssh-windows-powershell-local-expansion"); + expect(payload.replacementExamples?.oneLine).toContain("ps '$p = Start-Process python -PassThru; $p.Id'"); + expect(payload.replacementExamples?.pipeline).toContain("Where-Object { $_.CPU -gt 0 }"); + expect(payload.replacementExamples?.stdin).toContain("ps <<'PS'"); + expect(payload.migrationHint).toContain("local single quotes"); + } + + expect(() => parseSshInvocation("G14-WSL:win/d/Work/demo", ["ps", "$p = Start-Process python -PassThru; $p.Id"])).not.toThrow(); + }); }); describe("ssh direct argv boundaries and plane diagnostics", () => { diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 51b7c077..cff9a9bd 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -129,6 +129,32 @@ export class SshRemovedShellAliasError extends Error { } } +export class SshWindowsPowerShellLocalExpansionError extends Error { + code = "ssh-windows-powershell-local-expansion"; + level = "error"; + entrypoint: string; + route: string; + operation = "ps"; + replacementExamples: { oneLine: string; pipeline: string; stdin: string }; + migrationHint: string; + note = "Local Bash expands $p and $_ inside double quotes before trans receives the PowerShell source."; + + constructor(route: string) { + const entrypoint = sshDisplayEntrypoint(); + const replacementExamples = { + oneLine: `${entrypoint} ${route} ps '$p = Start-Process python -PassThru; $p.Id'`, + pipeline: `${entrypoint} ${route} ps 'Get-Process | Where-Object { $_.CPU -gt 0 }'`, + stdin: `${entrypoint} ${route} ps <<'PS'`, + }; + super("ssh win ps source looks like a PowerShell variable was expanded by the local shell before trans received it"); + this.name = "SshWindowsPowerShellLocalExpansionError"; + this.entrypoint = entrypoint; + this.route = route; + this.replacementExamples = replacementExamples; + this.migrationHint = `Wrap the complete PowerShell source in local single quotes, for example: ${replacementExamples.oneLine}. Use a quoted PS heredoc for multiline source.`; + } +} + export interface SshStdoutTruncationHint { code: "ssh-stdout-truncated" | "ssh-stderr-truncated"; level: "warning"; @@ -522,8 +548,10 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs invocationKind: "argv", }; } + const command = commandArgs[0] ?? ""; + if (looksLikeLocallyExpandedPowerShellSource(command)) throw new SshWindowsPowerShellLocalExpansionError(route.raw); return { - remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellInlineLauncherScript(commandArgs[0] ?? "", route.workspace)), + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellInlineLauncherScript(command, route.workspace)), requiresStdin: false, invocationKind: "helper", }; @@ -546,6 +574,10 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs }; } +function looksLikeLocallyExpandedPowerShellSource(command: string): boolean { + return /(?:^|[;{}]\s*)=\s*[^=]/u.test(command); +} + function windowsRouteRepeatedWinOperationMessage(route: ParsedSshRoute, nextArgs: string[]): string { const entrypoint = sshDisplayEntrypoint(); const suffix = nextArgs.length > 0 ? ` ${nextArgs.join(" ")}` : " ps";