From b6ac6f71f6f0e5f0394b0e1bc4bd8325382084cc Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 13:31:26 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Windows=20trans=20?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E4=B8=8E=E5=8F=82=E6=95=B0=E4=BC=A0=E9=80=92?= 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 | 4 ++ scripts/cli.ts | 18 +++++++ scripts/src/ssh-windows-fs/head-tail.ps1 | 1 + scripts/src/ssh.test.ts | 20 +++++++ scripts/src/ssh.ts | 66 ++++++++++++++++++++++-- 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 330962c1..c70ff5ad 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -36,6 +36,9 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md - 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 `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 中的嵌套引号。 +- `trans` / `tran` 是 PATH wrapper,不是 `bun scripts/cli.ts` 的根子命令;误写 `bun scripts/cli.ts trans ...` 时应读取结构化 `trans-root-cli-entrypoint-moved` 错误,并改用 `trans [args...]`。 - Direct argv 会保留调用端已经形成的每个 argv token,包括空格和中文;需要 shell 语法时仍显式使用 `sh`、`bash`、Windows `cmd` 或 `ps`。 - Windows workspace 中依赖 Windows PATH、解释器或工具链的命令优先走 `:win// cmd|ps`;不得因同一 WSL provider 的 host/WSL plane 缺少 `python`、编译器或其他命令,就判断 Windows plane 也未安装。Windows Python 例如 `trans G14-WSL:win/d/Work/CONSTAR_workspace cmd python --version`。 - `/mnt/` workspace 的命令返回 `command not found` 时,CLI 会输出对应 `:win//... cmd` 的 `UNIDESK_SSH_HINT`;该提示只建议核对 Windows plane,不改变原命令退出码。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 24e0c606..bcec108d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -8,6 +8,10 @@ UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式 `trans` wrapper 是 SSH/WSL/k3s 透传的唯一默认入口:人工/Codex 远端操作、长期参考文档、AGENTS 索引、CLI help、非交互脚本和非交互 `exec` 都必须直接调主 server PATH 上的 `/root/.local/bin/trans`;禁止把 `bun scripts/cli.ts ssh ...`、`bun scripts/cli.ts trans ...` 或任何带 `bun scripts/cli.ts` 前缀的透传写法作为默认入口。`bun scripts/cli.ts help`、`config`、`server`、`provider`、`microservice` 等普通根 CLI 子命令不受这条限制,仍使用 `bun scripts/cli.ts `,避免透传命令和根子命令在调用前缀上互相混淆。 +误写 `bun scripts/cli.ts trans ...` 或 `bun scripts/cli.ts tran ...` 时,根 CLI 必须返回结构化 `trans-root-cli-entrypoint-moved` 错误、`replacementExamples` 和 `Use: trans [args...]` 迁移提示,不能退化成通用 `Unknown command`。 + +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` 必须把选项与路径分开解析。 + CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI/CD server 和生产运行面。CLI/server 能力协商、unsupported-version 失败语义和 release-line 边界由 `docs/reference/release-governance.md` 与 [GitHub issue #6](https://github.com/pikasTech/unidesk/issues/6) 约束。 ## CI/CD Control Boundary diff --git a/scripts/cli.ts b/scripts/cli.ts index eab3ef26..48d86223 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -768,6 +768,24 @@ async function main(): Promise { return; } + if (top === "trans" || top === "tran") { + const error = new Error(`${top} is a PATH wrapper, not a bun scripts/cli.ts subcommand`); + Object.assign(error, { + name: "TransEntrypointMigrationError", + code: "trans-root-cli-entrypoint-moved", + level: "error", + entrypoint: top, + operation: "root-cli-dispatch", + replacementExamples: { + canonical: `trans ${sub ?? ""} ${args.slice(2).join(" ") || ""}`, + legacyUnified: `bun scripts/cli.ts ssh ${sub ?? ""} ${args.slice(2).join(" ") || ""}`, + }, + migrationHint: "Use: trans [args...]. The PATH wrapper delegates to the lightweight SSH CLI.", + note: "Ordinary root CLI commands continue to use bun scripts/cli.ts .", + }); + throw error; + } + throw new Error(`Unknown command: ${commandName}`); } diff --git a/scripts/src/ssh-windows-fs/head-tail.ps1 b/scripts/src/ssh-windows-fs/head-tail.ps1 index 1bdbe695..054432a4 100644 --- a/scripts/src/ssh-windows-fs/head-tail.ps1 +++ b/scripts/src/ssh-windows-fs/head-tail.ps1 @@ -5,6 +5,7 @@ if ($operation -in @('head', 'tail')) { 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 -match '^-(\d+)$') { $linesWanted = Parse-NonNegativeInt ('-' + $Matches[1]) $Matches[1] 10000; continue } if ($arg.StartsWith('-') -and $arg -ne '-') { Fail ('unsupported ' + $operation + ' option on Windows route: ' + $arg) 2 } $paths.Add($arg) } diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index e68170c0..b5a39ff3 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -46,6 +46,7 @@ describe("ssh windows PowerShell safety prelude", () => { expect(prelude).toContain("function ConvertTo-Json"); expect(prelude).toContain("'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider','ReadCount'"); expect(prelude).toContain("Microsoft.PowerShell.Utility\\ConvertTo-Json"); + expect(prelude).toContain("$PSDefaultParameterValues['Get-Content:Encoding'] = 'utf8'"); expect(prelude).toContain("Set-Location -LiteralPath 'C:\\test'"); }); }); @@ -114,6 +115,25 @@ describe("ssh windows fs read-only operations", () => { expect(decodedWindowsPowerShellCommand(cmd.parsed.remoteCommand)).toContain('python tool.py "71-FILTER 高频输出精度与频率跳变改进"'); }); + test("supports GNU head/tail counts and preserves PowerShell native argv", () => { + const headScript = windowsFsReadOnlyScript("F:\\Work\\demo", "head", ["-35", "docs/read me.md"]); + const tailScript = windowsFsReadOnlyScript("F:\\Work\\demo", "tail", ["-20", "docs/read me.md"]); + const pythonCode = 'import pathlib; print({"utf8": pathlib.Path(r"docs/报告.md").read_text(encoding="utf-8") != ""})'; + const python = parseSshInvocation("D601:win/F/Work/demo", ["ps", "python", "-c", pythonCode]); + const launcher = decodedWindowsPowerShellCommand(python.parsed.remoteCommand); + const encodedPayload = launcher.match(/\$payloadJsonB64 = ''([A-Za-z0-9+/=]+)'';/u)?.[1]; + + expect(headScript).toContain("$arg -match '^-(\\d+)$'"); + expect(tailScript).toContain("$arg -match '^-(\\d+)$'"); + expect(python.parsed.invocationKind).toBe("argv"); + expect(encodedPayload).toBeDefined(); + expect(JSON.parse(Buffer.from(encodedPayload ?? "", "base64").toString("utf8"))).toEqual({ + command: "python", + args: ["-c", pythonCode], + nativeArguments: `"-c" "${pythonCode.replaceAll('"', '\\"')}"`, + }); + }); + 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"); }); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 0d1913b7..51b7c077 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -501,11 +501,11 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs 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")) { - const fileArgs = commandArgs.slice(1).map((a: string) => a.replace(/\\/g, "/")).join(" "); + const fileArgs = [commandArgs[1]?.replace(/\\/g, "/") ?? "", ...commandArgs.slice(2)]; return { - remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellInlineLauncherScript("& " + fileArgs, route.workspace)), + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellArgvLauncherScript(fileArgs, route.workspace)), requiresStdin: false, - invocationKind: "helper", + invocationKind: "argv", }; } if (commandArgs.length === 0) { @@ -515,8 +515,15 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs invocationKind: "helper", }; } + if (commandArgs.length > 1) { + return { + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellArgvLauncherScript(commandArgs, route.workspace)), + requiresStdin: false, + invocationKind: "argv", + }; + } return { - remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellInlineLauncherScript(commandArgs.join(" "), route.workspace)), + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellInlineLauncherScript(commandArgs[0] ?? "", route.workspace)), requiresStdin: false, invocationKind: "helper", }; @@ -810,6 +817,7 @@ export function windowsPowerShellScriptPrelude(cwd: string | null): string { "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new()", "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()", "$OutputEncoding = [System.Text.UTF8Encoding]::new()", + "$PSDefaultParameterValues['Get-Content:Encoding'] = 'utf8'", "$env:PYTHONUTF8 = '1'", "$env:PYTHONIOENCODING = 'utf-8'", ...windowsPowerShellJsonSafetyPreludeLines, @@ -822,6 +830,56 @@ function buildWindowsPowerShellInlineLauncherScript(command: string, cwd: string return buildWindowsPowerShellScriptRunner(powerShellSingleQuote(command), cwd); } +function buildWindowsPowerShellArgvLauncherScript(commandArgs: string[], cwd: string | null): string { + const command = commandArgs[0] ?? ""; + if (command.trim().length === 0) throw new Error("ssh win ps requires a command before argv arguments"); + const args = commandArgs.slice(1); + const payloadJsonB64 = Buffer.from(JSON.stringify({ + command, + args, + nativeArguments: args.map(windowsNativeProcessArgument).join(" "), + }), "utf8").toString("base64"); + const script = [ + `$payloadJsonB64 = ${powerShellSingleQuote(payloadJsonB64)};`, + "$payload = ConvertFrom-Json -InputObject ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payloadJsonB64)));", + "$command = [string]$payload.command;", + "$commandArgs = @();", + "if ($null -ne $payload.args) { foreach ($item in @($payload.args)) { $commandArgs += [string]$item } }", + "$resolvedCommand = Get-Command -Name $command -ErrorAction Stop;", + "if ($resolvedCommand.CommandType -eq 'Application') {", + " $startInfo = [Diagnostics.ProcessStartInfo]::new();", + " $startInfo.FileName = $resolvedCommand.Source;", + " $startInfo.Arguments = [string]$payload.nativeArguments;", + " $startInfo.WorkingDirectory = (Get-Location).ProviderPath;", + " $startInfo.UseShellExecute = $false;", + " $process = [Diagnostics.Process]::new(); $process.StartInfo = $startInfo;", + " if (-not $process.Start()) { throw ('failed to start native command: ' + $command) }", + " $process.WaitForExit(); $global:LASTEXITCODE = $process.ExitCode;", + "} else { & $command @commandArgs; }", + ].join(" "); + return buildWindowsPowerShellScriptRunner(powerShellSingleQuote(script), cwd); +} + +function windowsNativeProcessArgument(value: string): string { + if (value.length === 0) return '""'; + let quoted = '"'; + let backslashes = 0; + for (const character of value) { + if (character === "\\") { + backslashes += 1; + continue; + } + if (character === '"') { + quoted += "\\".repeat(backslashes * 2 + 1) + '"'; + backslashes = 0; + continue; + } + quoted += "\\".repeat(backslashes) + character; + backslashes = 0; + } + return quoted + "\\".repeat(backslashes * 2) + '"'; +} + function buildWindowsPowerShellStdinLauncherScript(cwd: string | null): string { return buildWindowsPowerShellScriptRunner("[Console]::In.ReadToEnd()", cwd); }