diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index af14ef32..d6ea73d8 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -16,6 +16,8 @@ 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 D601:win/c/test git diff --check +trans D601:win/c/test git commit -m 'fix: update docs' trans gh:/owner/repo/issue/ cat ``` @@ -27,7 +29,7 @@ Host workspace、k3s、Windows、GitHub issue/PR route,sh/bash/argv/apply-patc - Host/WSL 与 Windows route 的 `apply-patch` 优先走 fs adapter bulk update path;不要为了规避旧的 `read-b64-block` / `write-b64-argv` 慢路径改用临时脚本写文件。 - `sh`/`bash` 必须显式声明 shell;单进程命令优先 direct argv 或已知 operation。 - 普通 trans/ssh 短连接硬预算 60s;长 CI/CD、trace、logs、build、硬件流程必须 submit-and-poll。 -- Windows route 使用 `win ps`、`win cmd` 或只读 fs 操作 `pwd|ls|cat|head|tail|stat|wc|rg`;不要把 POSIX shell 当 Windows shell。 +- 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 helper 时保持 operation-scoped PowerShell payload;不要把多操作大脚本塞进 single `EncodedCommand`。 ## 何时读取 reference diff --git a/.agents/skills/unidesk-trans/references/full.md b/.agents/skills/unidesk-trans/references/full.md index fc7e792b..bedb4c6f 100644 --- a/.agents/skills/unidesk-trans/references/full.md +++ b/.agents/skills/unidesk-trans/references/full.md @@ -62,9 +62,12 @@ 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/c/test git status --short --branch +trans D601:win/c/test git diff --check +trans D601:win/c/test git commit -m 'fix: update docs' ``` -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`。 +Windows route 里的 `win` 只表示 route plane,后面 operation 直接写 `ps`、`cmd`、`git` 或 fs helper;不要写成 `trans D601:win/... win ps`。Windows operation 必须显式区分:`ps` 走 PowerShell,`cmd` 走 cmd.exe。`git` 是 Windows cmd convenience wrapper,会通过 Windows cmd 在 route cwd 下执行,支持 `git status`、`git diff` 和非交互 `git commit -m ...` 等常规 argv 形态;会打开编辑器或需要复杂 shell 审阅的命令请用 `ps` 或 `cmd` 包装。`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`。 扩展 Windows helper 时保持 operation-scoped PowerShell payload。不要把多 operation 的大 switch 一次性塞进 single `EncodedCommand`;超过 Windows/WSL argv 限制时,常见表现是 `/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe: Invalid argument`,stderr 不会指出真实原因。需要更长逻辑时优先拆短 helper 或切到临时脚本/受控 stdin 路径。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a76b4a7c..65d157b2 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -65,7 +65,7 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status - `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`;该命令用于替代手工删除容器的兜底流程,其中 `dev-frontend-proxy` 只更新主 server dev 入口薄代理,`todo-note`、`code-queue-mgr`、`project-manager`、`baidu-netdisk` 和 `oa-event-flow` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。D601 Code Queue 执行面不由 `server rebuild` 管理;Rust backend-core 常规迭代不得用该命令在 master server 编译,只有明确的 backend-core 主 server 上线例外可以按限流、异步轮询和 health 证据执行,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`trans argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 - `platform-db postgres plan|status|export-secrets|apply --config config/platform-db/postgres-pk01.yaml` 管理 YAML 声明的 PK01 host-native PostgreSQL。`plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL;`apply --confirm` 默认创建本地异步 job,`apply --confirm --wait` 用远端 root-owned job 收敛 systemd PostgreSQL、TLS、`pg_hba`、role/database、Secret export 和备份 timer。输出不得打印密码或完整 `DATABASE_URL`。跨节点消费者使用 YAML 中的 `connectionHost` 直连 PK01 公网 endpoint,DNS alias 不作为 `sslmode=require` 切库 blocker,PK01 规则见 `docs/reference/pk01.md`。 -- `trans [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。WSL provider 的 Windows plane 固定使用 `win`,不得使用 `win32`;Windows operation 必须显式区分:`ps` 执行 Windows PowerShell heredoc 或一行 PowerShell 命令,`cmd` 执行 cmd.exe/batch,`skills` 发现 Windows skill 目录。需要 Windows cwd 时用 `trans D601:win/c/test ps` 或 `trans D601:win/c/test cmd cd`,CLI 自动设置 UTF-8/Python 编码默认值;`cmd` 额外设置 `chcp 65001`。非交互远端命令优先使用 `trans argv ...`;需要 POSIX shell 脚本、管道、变量或循环时必须在 operation 位置显式写 `sh` 或 `bash`,例如 `trans G14 sh <<'SH'`、`trans G14:k3s sh <<'SH'`、`trans G14:k3s:: sh <<'SH'` 或 `trans D601:/workspace bash <<'BASH'`。`sh` 明确表示目标 `/bin/sh`,`bash` 明确表示目标 Bash;`script` 和 `shell` operation 已移除并会失败,避免隐藏 shell 方言。Windows PowerShell 必须写 `trans :win ps <<'PS'`。一行远端 shell 逻辑使用 `sh -- '<单个字符串>'` 或 `bash -- '<单个字符串>'`;顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 ` apply-patch < patch.diff`;需要可靠传输非文本或整文件时使用 ` upload ` 和 ` download `,CLI 会按字节数与 SHA-256 自动校验并在 provider-gateway stdin/argv 限制下切换客户端分块策略;需要旧 helper 时显式使用 `:k3s:: apply-patch-v1` 或 ` apply-patch-v1`。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示改用 `sh`/`bash` stdin heredoc、argv 或 provider triage 交叉验证。 +- `trans [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。WSL provider 的 Windows plane 固定使用 `win`,不得使用 `win32`;Windows route 中 `win` 只负责定位,operation 直接写 `ps`、`cmd`、`git` 或 fs helper,不要写成 `trans D601:win/... win ps`。Windows operation 必须显式区分:`ps` 执行 Windows PowerShell heredoc 或一行 PowerShell 命令,`cmd` 执行 cmd.exe/batch,`skills` 发现 Windows skill 目录。需要 Windows cwd 时用 `trans D601:win/c/test ps`、`trans D601:win/c/test cmd cd`、`trans D601:win/c/test git diff --check` 或 `trans D601:win/c/test git commit -m 'message'`,CLI 自动设置 UTF-8/Python 编码默认值;`cmd` 额外设置 `chcp 65001`。非交互远端命令优先使用 `trans argv ...`;需要 POSIX shell 脚本、管道、变量或循环时必须在 operation 位置显式写 `sh` 或 `bash`,例如 `trans G14 sh <<'SH'`、`trans G14:k3s sh <<'SH'`、`trans G14:k3s:: sh <<'SH'` 或 `trans D601:/workspace bash <<'BASH'`。`sh` 明确表示目标 `/bin/sh`,`bash` 明确表示目标 Bash;`script` 和 `shell` operation 已移除并会失败,避免隐藏 shell 方言。Windows PowerShell 必须写 `trans :win ps <<'PS'`。一行远端 shell 逻辑使用 `sh -- '<单个字符串>'` 或 `bash -- '<单个字符串>'`;顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 ` apply-patch < patch.diff`;需要可靠传输非文本或整文件时使用 ` upload ` 和 ` download `,CLI 会按字节数与 SHA-256 自动校验并在 provider-gateway stdin/argv 限制下切换客户端分块策略;需要旧 helper 时显式使用 `:k3s:: apply-patch-v1` 或 ` apply-patch-v1`。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示改用 `sh`/`bash` stdin heredoc、argv 或 provider triage 交叉验证。 - `trans apply-patch < patch.diff` 是默认推荐的远端 patch 入口:本地 TypeScript line-based engine 解析和计算新文件内容,远端 route 只负责读写文件;支持 host workspace、k3s pod workspace、Windows workspace route(例如 `D601:win/c/test`)和 frontend transport,并优先处理长中文/Unicode、低上下文插入、重复块 `@@` 定位等旧 helper 容易失败的场景。`apply-patch` 输出按 Codex 标准文本口径,不套 UniDesk JSON 限制:成功 stdout 为 `Success. Updated the following files:`,失败 stdout 为空、stderr 写失败原因;多文件补丁中途失败时,stderr 只列出第一个失败前已成功执行的 hunk 和失败 hunk,随后按 Codex 语义停止,不继续尝试后续 hunk。v2 兼容常见 MiniMax/MXCX 非标准补丁输入,例如重复 nested `*** Begin Patch` / `*** End Patch` envelope、unified-diff hunk header、Add/Delete 误加 `@@`、Update context 漏掉前导空格,并在 stderr 给出 canonical 写法 hint;parser 或上下文失败时仍坚持唯一 v2 引擎,只提示修正 patch 文本或 hunk context,不自动重试或切换到 `apply-patch-v1`;大块/函数替换因上下文过期失败时,正确动作是重新读取当前目标块、缩小或拆分 Update File hunk 后继续用 `apply-patch`,不得改走 `download`/`upload`、远端 Python/Perl/sed heredoc 或整文件重写。Windows route 复用同一套 v2 核心算法,只把底层读写替换成 PowerShell 文件系统接口;`trans apply-patch-v1 [tool args...] < patch.diff` 保留为显式 legacy 入口,直接调用远端注入的 `apply_patch` sh/perl helper;默认 `apply-patch` 不把 v1 当 fallback。 - GitHub issue/PR 正文局部修补必须优先走 `trans gh:/owner/repo/issue/ apply-patch` 或 `trans gh:/owner/repo/pr/ apply-patch`,而不是人工优先整篇 `gh issue update --mode replace` 或底层 `gh issue patch`。`gh:/` 路由把 issue/PR body 暴露为一楼虚拟文件 `body.md`;route 未显式写 `/1` 时默认一楼正文,`apply_patch` 与 `patch-apply` 只作为兼容别名。典型补丁写法是 `*** Update File: body.md`;普通小补丁在已用 `trans gh:/... cat|rg|ls` 确认上下文后可以直接 apply,`--dry-run` 只作为高风险大段修改、关闭前验收文案或并发敏感正文的可选预览。写回仍通过 UniDesk `gh issue/pr update` guard 和 REST API,不使用原生 `gh`、手写 REST 或整篇 shell 拼接正文。单条 issue comment 的局部修补当前仍使用 `bun scripts/cli.ts gh issue comment patch --body-patch-stdin`,直到评论楼层 route 成为稳定入口。 - `apply-patch` v2 每次结束都会在 stderr 追加一行 `UNIDESK_APPLY_PATCH_TIMING {json}`,字段包含 `durationMs`、`patchBytes`、`fileCount`、`hunkCount`、`changedCount`、`remoteOperationCount`、`remoteOperationCounts`、`remoteElapsedMs`、`remoteFailureCount`、`providerId`、`route` 和 `transport`(可得时)。普通 POSIX host/k3s 和 Windows workspace 远端的多文件 `Update File` patch 会优先合并成 bulk read/write,避免每个文件单独 stat/read/write 的 SSH 往返;Add/Delete/Move 等复杂 patch 保持原有逐步语义。timing 摘要只用于定位慢在 patch 解析、远端 stat/read/write 或 bulk read/write、provider session 还是传输层,不能替代 Codex 标准 stdout/stderr 成功失败文本,也不是门禁或自动判断。 @@ -405,7 +405,7 @@ trans D601 glob --root /home/ubuntu/pikapython --pattern '**/*-test.cpp' --limit `ssh` 的 route 语法是 `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。Host workspace route 使用 `:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd`、`git`、`sh`、`bash`、`apply-patch` 和旧 `apply-patch-v1` fallback 等操作仍按同一套 operation parser 执行。`:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。 -当前稳定 plane 包括 `win` 和 `k3s`。`win` plane 的 operation 是 Windows 操作,不是 POSIX shell 别名:`:win ps` 在 WSL provider 上启动 Windows PowerShell,stdin heredoc 会被写入临时 `.ps1` 后执行;`:win cmd` 启动 Windows host 的 `cmd.exe`,stdin heredoc 会被写入临时 `.cmd` 后执行;`:win skills` 发现 Windows skill 目录。需要 Windows 当前目录时使用 slash 路由 `:win//`,例如 `D601:win/c/test ps` 会先在 PowerShell 内 `Set-Location -LiteralPath 'C:\test'`,`D601:win/c/test cmd cd` 会先在 cmd 内执行 `cd /d "C:\test"`。`win32` 不是合法 plane,调用者必须改用 `win`。 +当前稳定 plane 包括 `win` 和 `k3s`。`win` plane 的 operation 是 Windows 操作,不是 POSIX shell 别名:`:win ps` 在 WSL provider 上启动 Windows PowerShell,stdin heredoc 会被写入临时 `.ps1` 后执行;`:win cmd` 启动 Windows host 的 `cmd.exe`,stdin heredoc 会被写入临时 `.cmd` 后执行;`:win skills` 发现 Windows skill 目录。需要 Windows 当前目录时使用 slash 路由 `:win//`,例如 `D601:win/c/test ps` 会先在 PowerShell 内 `Set-Location -LiteralPath 'C:\test'`,`D601:win/c/test cmd cd` 会先在 cmd 内执行 `cd /d "C:\test"`。`D601:win/c/test git ...` 是 cmd convenience wrapper,覆盖 `status`、`diff`、非交互 `commit -m ...` 等常规 argv 形态;会打开编辑器或需要 shell 审阅的 git 命令仍用 `ps` 或 `cmd` 包装。`win32` 不是合法 plane,调用者必须改用 `win`。 `:win ps` 是 Windows PowerShell 专用入口,适合管道、变量、`Get-ChildItem`、`Start-Process`、`Test-Path` 和 Windows 路径脚本;不要用 host/k3s 的 `sh`/`bash` operation 表示 PowerShell。`ps` 和 `cmd` 都注入 UTF-8/Python 编码默认值;`cmd` 额外执行 `chcp 65001>nul`。典型用法: diff --git a/scripts/src/help.ts b/scripts/src/help.ts index a73a6993..6d266658 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -193,6 +193,9 @@ export function sshHelp(): unknown { "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/c/test git status --short --branch", + "trans D601:win/c/test git diff --check", + "trans D601:win/c/test git commit -m 'fix: update docs'", "trans D601:win skills [--scope agents|codex|all] [--limit N]", "trans D601:k3s", "trans D601:k3s kubectl get pods -n hwlab-dev", @@ -215,6 +218,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.", + "On Windows routes, the route already contains `win`; write `trans D601:win/c/test ps`, not `trans D601:win/c/test win ps`. Repository commands can use the git convenience wrapper, including `git status`, `git diff`, and non-interactive `git commit -m ...`; commands that need shell review should use `ps` or `cmd`.", "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.", diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index fc7690ba..2a433278 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -42,6 +42,22 @@ describe("ssh windows fs read-only operations", () => { expect(rgInvocation.parsed.requiresStdin).toBe(false); }); + test("routes git status, diff, and commit through Windows cmd convenience", () => { + const statusInvocation = parseSshInvocation("D601:win/F/Work/ConStart", ["git", "status", "--short", "--branch"]); + const diffInvocation = parseSshInvocation("D601:win/F/Work/ConStart", ["git", "diff", "--check"]); + const commitInvocation = parseSshInvocation("D601:win/F/Work/ConStart", ["git", "commit", "-m", "fix: update docs"]); + + expect(statusInvocation.route.plane).toBe("win"); + expect(statusInvocation.route.workspace).toBe("F:\\Work\\ConStart"); + expect(statusInvocation.parsed.invocationKind).toBe("argv"); + expect(statusInvocation.parsed.requiresStdin).toBe(false); + expect(statusInvocation.parsed.remoteCommand).toContain("powershell.exe"); + expect(diffInvocation.parsed.invocationKind).toBe("argv"); + expect(diffInvocation.parsed.requiresStdin).toBe(false); + expect(commitInvocation.parsed.invocationKind).toBe("argv"); + expect(commitInvocation.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"]); @@ -63,6 +79,11 @@ describe("ssh windows fs read-only operations", () => { 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"); }); + + test("reports route-aware hints for repeated win operation and interactive git commit", () => { + 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"); + }); }); describe("ssh host apply-patch fs backend", () => { diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 2dfc0185..35d9e123 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -1147,6 +1147,9 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs if (operation.length === 0) { throw new Error(`ssh ${route.raw} requires a Windows operation, for example: ssh ${route.providerId}:win cmd ver or ssh ${route.providerId}:win skills`); } + if (operation === "win") { + throw new Error(windowsRouteRepeatedWinOperationMessage(route, args.slice(1))); + } if (operation === "upload" || operation === "download") { return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" }; } @@ -1179,6 +1182,9 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs invocationKind: "helper", }; } + if (operation === "git") { + return parseWindowsGitInvocation(route, args.slice(1)); + } 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")) { @@ -1203,7 +1209,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 , ssh ${route.providerId}:win apply-patch, or ssh ${route.providerId}:win skills`); + throw new Error(windowsUnsupportedOperationMessage(route, operation)); } const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1); if (commandArgs.length === 0) { @@ -1220,6 +1226,56 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs }; } +function windowsRouteRepeatedWinOperationMessage(route: ParsedSshRoute, nextArgs: string[]): string { + const entrypoint = sshDisplayEntrypoint(); + const suffix = nextArgs.length > 0 ? ` ${nextArgs.join(" ")}` : " ps"; + return `unsupported ssh win operation: win; route ${route.raw} already selects the Windows plane. ` + + `Write the operation directly after the route, for example \`${entrypoint} ${route.raw}${suffix}\`, not \`${entrypoint} ${route.raw} win${suffix}\`.`; +} + +function windowsUnsupportedOperationMessage(route: ParsedSshRoute, operation: string): string { + const entrypoint = sshDisplayEntrypoint(); + const gitHint = operation === "sed" || operation === "bash" || operation === "sh" + ? ` For repository commands on Windows, use \`${entrypoint} ${route.raw} git status --short --branch\`, \`${entrypoint} ${route.raw} git commit -m \`, or wrap custom commands with \`${entrypoint} ${route.raw} ps <<'PS'\`.` + : ` If you meant to run a repository command, use \`${entrypoint} ${route.raw} git status --short --branch\`, \`${entrypoint} ${route.raw} git commit -m \`, or wrap it with \`${entrypoint} ${route.raw} ps <<'PS'\`.`; + return `unsupported ssh win operation: ${operation}; Windows route operations are ps, cmd, skills, apply-patch, upload/download, git, and read-only fs helpers pwd|ls|cat|head|tail|stat|wc|rg.${gitHint}`; +} + +function parseWindowsGitInvocation(route: ParsedSshRoute, gitArgs: string[]): ParsedSshArgs { + const subcommand = gitArgs[0] ?? ""; + if (subcommand.length === 0) throw new Error(`ssh ${route.raw} git requires a git subcommand, for example: ${sshDisplayEntrypoint()} ${route.raw} git status --short --branch`); + if (subcommand === "commit") validateWindowsGitCommitArgs(route, gitArgs); + const commandLine = ["git", ...gitArgs].map((value) => windowsCmdArgument(value, route)).join(" "); + return { + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript(buildWindowsCmdLine(commandLine, route.workspace))), + requiresStdin: false, + invocationKind: "argv", + }; +} + +function validateWindowsGitCommitArgs(route: ParsedSshRoute, gitArgs: string[]): void { + const hasNonInteractiveMessage = gitArgs.slice(1).some((arg) => ( + arg === "--dry-run" || + arg === "--no-edit" || + arg === "-m" || + arg.startsWith("-m") || + arg === "--message" || + arg.startsWith("--message=") || + arg === "-F" || + arg.startsWith("-F") || + arg === "--file" || + arg.startsWith("--file=") || + arg === "-C" || + arg.startsWith("-C") || + arg === "--reuse-message" || + arg.startsWith("--reuse-message=") || + arg.startsWith("--fixup=") + )); + if (hasNonInteractiveMessage) return; + const entrypoint = sshDisplayEntrypoint(); + throw new Error(`ssh win git commit would open an editor; use ${entrypoint} ${route.raw} git commit -m , ${entrypoint} ${route.raw} git commit --no-edit, or wrap an interactive command with ${entrypoint} ${route.raw} ps <<'PS'`); +} + 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; @@ -1281,6 +1337,15 @@ function windowsCmdQuote(value: string): string { return `"${value}"`; } +function windowsCmdArgument(value: string, route: ParsedSshRoute): string { + if (value.length === 0) return "\"\""; + if (/[\r\n"]/u.test(value) || /[&|<>^%!]/u.test(value)) { + const entrypoint = sshDisplayEntrypoint(); + throw new Error(`ssh win git argument contains characters that require shell review; use ${entrypoint} ${route.raw} ps <<'PS' for this command`); + } + return /\s/u.test(value) ? `"${value}"` : value; +} + function parseWindowsSkillsOptions(args: string[]): { limit: number; scopes: Array<"agents" | "codex"> } { let limit = 100; let scope: "agents" | "codex" | "all" = "agents";