diff --git a/AGENTS.md b/AGENTS.md index 288e7c84..a1b3fb45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server cleanup plan [--min-age-hours N] [--limit N]`:只读/干跑生成主 server Docker 镜像清理计划,默认只列出至少 24 小时前创建的非保护镜像,输出 active/protected images、stale candidates、预计释放空间、风险等级和必须人工确认的 `docker image rm` 命令;禁止默认删除、禁止 prune、禁止触碰 database volume、registry storage 或 Baidu Netdisk 状态。 - `bun scripts/cli.ts server rebuild `:以 build-first、Compose lock、no-deps force-recreate 和 post-up validation 的异步 job 重建主 server Compose 内单个服务;对 database、File Browser、Code Queue 执行面、k3sctl-adapter 或未知对象返回结构化 `unsupported-server-rebuild`,规则见 `docs/reference/deployment.md` 与 `docs/reference/cicd-standardization.md`。 - `bun scripts/cli.ts provider attach [--master-server URL] [--up] [--force]` / `bun scripts/cli.ts provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]`:前者在新增计算节点上生成两项配置的 provider-gateway 挂载包;后者是只读多信号健康裁决入口,默认低噪声输出 `decision`、`healthyScopes`、`failedScopes`、`retryable` 和异常信号摘要,用来把单路径 `provider is not online`、SSH 超时、registry 失败或 proxy 失败归类为 `retryable-transient`、`service-degraded` 或 `global-offline`,完整 evidence 需显式 `--full|--raw`,规则见 `docs/reference/provider-gateway.md` 和 `docs/reference/code-queue-supervision.md`。 -- `bun scripts/cli.ts ssh [operation args...]` / `tran [operation args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥进入 provider、host workspace、k3s 控制面或 pod workspace;主 server 人工/Codex 分布式操作必须用本机 `tran` wrapper,CLI 参考和可移植脚本可保留完整命令,细则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 +- `bun scripts/cli.ts ssh [operation args...]` / `tran [operation args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥进入 provider、host workspace、Windows cmd route、k3s 控制面或 pod workspace;主 server 人工/Codex 分布式操作必须用本机 `tran` wrapper,CLI 参考和可移植脚本可保留完整命令,细则见 `docs/reference/cli.md`、`docs/reference/windows-passthrough.md` 和 `docs/reference/provider-gateway.md`。 - `bun scripts/cli.ts microservice list/status/health/diagnostics/tunnel-self-test/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,`status/health/diagnostics` 默认 compact summary 并用 `--full|--raw` 展开完整 body,`proxy` 支持受控 JSON body,OA Event Flow/Todo Note/Baidu Netdisk/Code Queue Manager on main-server、k3s Control/Code Queue 执行面/MDTODO/Decision Center/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts microservice health/diagnostics/proxy code-agent-sandbox`:验证独立 Code Agent Sandbox 的 health、只读 diagnostics、trace 和 adapter/mode/credential boundary 契约,规则见 `docs/reference/code-agent-sandbox.md`。 - `bun scripts/cli.ts decision upload/list/show/health`:通过 backend-core 用户服务代理上传会议记录/需求/决议 Markdown、列出记录和查看详情;Decision Center 运行在 D601 k3s,规则见 `docs/reference/microservices.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 78cd5537..8a06bacb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -8,7 +8,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `help` 输出命令索引,适合作为交互式入口。 - 每个 CLI 命名空间必须支持 `help`、`--help` 或 `-h` 并返回 JSON,不得为了打印帮助而访问 runtime 服务、拉起交互会话或执行长时任务。 -- `--main-server-ip ` 默认通过公网 frontend 登录态调用主 server 的同源 API 代理,不要求计算节点持有主 server SSH key;显式提供 `--main-server-key` 或 `--main-server-transport ssh` 时才使用旧 SSH 传输。远程 frontend 传输下的 `ssh ...` 必须复用同一套结构化 route parser,支持 `D601`、`G14`、host workspace、`D601:k3s` 和 `D601:k3s::` 这类定位路径;它不向调用容器下发 provider token,也不要求调用容器能解析 backend-core 内网 DNS。 +- `--main-server-ip ` 默认通过公网 frontend 登录态调用主 server 的同源 API 代理,不要求计算节点持有主 server SSH key;显式提供 `--main-server-key` 或 `--main-server-transport ssh` 时才使用旧 SSH 传输。远程 frontend 传输下的 `ssh ...` 必须复用同一套结构化 route parser,支持 `D601`、`G14`、host workspace、`D601:win`、`D601:win/c/test`、`D601:k3s` 和 `D601:k3s::` 这类定位路径;它不向调用容器下发 provider token,也不要求调用容器能解析 backend-core 内网 DNS。 - `config show` 读取并校验根目录 `config.json`,不从环境变量、默认值或隐藏文件静默补配置。 - `check` 默认只执行轻量配置校验、Bun 版本检查和 Bun Transpiler 语法解析(覆盖 CLI 入口、主要 `scripts/` 模块和核心组件入口,不做类型推导);关键文件存在性、`scripts/` TypeScript 类型检查、`src/components/` TypeScript 类型检查、Docker Compose config、日志轮转策略扫描和 D601 recovery guardrails 默认不启用,分别通过 `--files`、`--scripts-typecheck`、`--components`、`--compose`、`--logs`、`--recovery-guardrails` 开启,或用 `--full` 一次性开启。`check recovery-guardrails` 是同一诊断的低噪声直接入口,报告 malformed `/proc/mounts`、kubelet validation risk、stale CRI sandbox count、Code Queue worktree/symlink、Code Queue/MDTODO hostPath 和 `ContainerCreating` 分类;它不得重启 k3s、删除 CRI sandbox、修改 hostPath、deploy/rollout 或 prune/reset。`--rust` 只允许在 D601 CI/dev execution 中配合 `UNIDESK_D601_RUST_CHECK=1` 使用,长期规则见 `docs/reference/dev-environment.md` 和 `docs/reference/devops-hygiene.md`。 - `server start` 创建异步 job,在后台执行 Docker 构建和启动;命令本身只负责返回 job id、日志路径和启动命令。 @@ -19,7 +19,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `server cleanup plan [--min-age-hours N] [--limit N]` 只生成主 server Docker 镜像清理 dry-run 计划,不执行删除;默认 `--min-age-hours 24`,避免把刚发布或刚验证的镜像列为 stale。输出必须包含 `dryRun=true`、`mutation=false`、`policy.deletionExecuted=false`、active containers/images、受保护镜像、candidate stale images、估算释放空间、风险等级、`commandsToReview` 和人工审批清单。计划必须保守白名单:保留 running containers 使用的 image ID,保留 stopped containers 引用的 image ID 直到人工先复核容器,保留 `deploy.json`/`CI.json` 当前 commit-pinned artifact、Compose stable image、上游 digest pin 和 provider-gateway runner image;`protectedStorage` 必须显式列出 PostgreSQL named volume、Baidu Netdisk `.state`、D601 registry storage 和 Docker volumes/host data policy。该入口禁止生成或执行 `docker system prune`、`docker image prune`、`docker builder prune`、`docker volume rm`、`docker compose down -v`、数据库清理或 host data `rm` 命令;未来若增加真实删除,必须另设显式审批参数并先复核 dry-run 输出。 - `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 迭代不得用 `server rebuild backend-core` 在 master server 编译,规则见 `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`、`ssh argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 -- `ssh [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]]`,例如 `G14:k3s`、`D601:k3s` 或 `G14:k3s::`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'`、`tran G14:k3s script <<'SCRIPT'` 或 `tran G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。`script` 需要传递带短横线的短命令 argv 时可以使用命令本地分隔符 `script -- [args...]`,例如 `tran D601:/path script -- sed -n '1,20p' file`;这个直接命令形态不等待 stdin,顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要在 pod 内改文件时优先使用 `:k3s:: apply-patch`,CLI 会临时注入 pod 内 `apply_patch` helper 并把 patch stdin 交给它。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 +- `ssh [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 cmd 入口固定写 `tran D601:win cmd `,需要 Windows cwd 时用 `tran D601:win/c/test cmd cd`,由 CLI 自动设置 `chcp 65001`、`PYTHONUTF8=1` 和 `PYTHONIOENCODING=utf-8`;命名只允许 `win`,不得使用 `win32`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'`、`tran G14:k3s script <<'SCRIPT'` 或 `tran G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。`script` 需要传递带短横线的短命令 argv 时可以使用命令本地分隔符 `script -- [args...]`,例如 `tran D601:/path script -- sed -n '1,20p' file`;这个直接命令形态不等待 stdin,顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要在 pod 内改文件时优先使用 `:k3s:: apply-patch`,CLI 会临时注入 pod 内 `apply_patch` helper 并把 patch stdin 交给它。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 - `ssh apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。 - `ssh py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `ssh skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 @@ -199,7 +199,9 @@ bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*- `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`、`script`、`apply-patch` 等操作仍按同一套 operation parser 执行。`:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。 -当前稳定 plane 是 `k3s`:`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/`,若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。pod 内 workspace 使用 slash 后缀表达,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api/app` 会定位到 deployment `hwlab-cloud-api` 并在 pod 内先 `cd /app`,`D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc/workspace/app:api` 会定位到 pod、container 和 `/workspace/app`。`kubectl`、`logs`、`script`、`apply-patch`、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。 +当前稳定 plane 包括 `win` 和 `k3s`。`:win cmd ` 在 WSL provider 上启动 Windows host 的 `cmd.exe`,CLI 会在命令前固定执行 `chcp 65001>nul`、`set PYTHONUTF8=1` 和 `set PYTHONIOENCODING=utf-8`,让中文和 UTF-8 输出成为默认行为;需要 Windows 当前目录时使用 slash 路由 `:win//`,例如 `D601:win/c/test cmd cd` 会先在 Windows cmd 内执行 `cd /d "C:\test"`。`win32` 不是合法 plane,调用者必须改用 `win`。 + +`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/`,若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。pod 内 workspace 使用 slash 后缀表达,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api/app` 会定位到 deployment `hwlab-cloud-api` 并在 pod 内先 `cd /app`,`D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc/workspace/app:api` 会定位到 pod、container 和 `/workspace/app`。`kubectl`、`logs`、`script`、`apply-patch`、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。 `k3s` 必须出现在 route 的 plane 段里,禁止使用 `ssh G14 k3s ...` 或 `ssh D601 k3s ...` 这类 post-provider shorthand;正确形态是 `ssh G14:k3s kubectl ...` 或 `ssh D601:k3s kubectl ...`。定位和操作必须保持分离,`kubectl`、`logs`、`script`、`apply-patch`、`exec` 等 operation 名也不得放进任何 colon route 段,包括 namespace、workload 或 container 段;新增分布式目标时按 `{provider}:{plane}:{scope}` 扩展 route,而不是在 operation args 中新增另一套定位语法。 @@ -209,6 +211,8 @@ bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*- bun scripts/cli.ts ssh D601:k3s bun scripts/cli.ts ssh D601:k3s kubectl get pods -n hwlab-dev bun scripts/cli.ts ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch +bun scripts/cli.ts ssh D601:win cmd ver +bun scripts/cli.ts ssh D601:win/c/test cmd cd bun scripts/cli.ts ssh G14:k3s bun scripts/cli.ts ssh G14:k3s kubectl get pipelineruns -n hwlab-ci printf 'kubectl get deploy -n hwlab-dev\n' | bun scripts/cli.ts ssh D601:k3s script diff --git a/docs/reference/windows-passthrough.md b/docs/reference/windows-passthrough.md index 444ee859..e3481a13 100644 --- a/docs/reference/windows-passthrough.md +++ b/docs/reference/windows-passthrough.md @@ -12,6 +12,19 @@ Windows 透传用于让 WSL provider 通过 UniDesk 的 Host SSH / WSL SSH 维 - WSL wrapper 负责路径转换、当前目录转换、Windows 进程启动、UTF-8 输出和 cmd/PowerShell 差异。 - skill wrapper 负责把用户仍然熟悉的 `keil`、`serial-monitor`、`board-comm` 命令映射到正确运行侧,避免维护两套互相分叉的 skill。 +## UniDesk Win Route + +简单 Windows `cmd.exe` 命令不需要依赖节点侧 `win-cmd` wrapper。UniDesk CLI 内置 `win` route: + +```bash +tran :win cmd ver +tran :win/c/test cmd cd +``` + +`:win` 只定位到 WSL provider 的 Windows host cmd 执行面,后面的 `cmd ` 才是操作。`:win//` 使用 slash 语法设置 Windows cwd,例如 `D601:win/c/test` 会在 Windows cmd 内执行 `cd /d "C:\test"`。该入口固定在运行用户命令前设置 `chcp 65001>nul`、`PYTHONUTF8=1` 和 `PYTHONIOENCODING=utf-8`,因此中文 stdout/stderr 和 Python 子进程默认按 UTF-8 处理。 + +长期命名只允许 `win`,不允许 `win32`。`win` route 仍复用 provider-gateway 的 Host SSH / WSL SSH 维护桥抵达目标 WSL provider,但不会依赖 WSL 登录 shell 的 Windows `PATH`,也不再通过节点侧 `win-cmd` wrapper 二次转发。CLI 固定从 `/mnt/c/Windows` 启动 Windows PowerShell UTF-8 launcher,再由 launcher 执行 `C:\Windows\System32\cmd.exe /d /s /c `;PowerShell 只负责把 Console/Input/OutputEncoding 设为 UTF-8 并保留 cmd 的 exit code,用户命令语义仍属于 Windows `cmd.exe`。需要 Keil、串口、Windows Node/npm 或复杂路径转换时,继续使用下文的 skill/wrapper 入口,直到对应能力被纳入 UniDesk 内置 route。 + ## Skill Discovery 先用 UniDesk SSH 透传内置的 skill 发现入口确认目标节点上 WSL 与 Windows 两侧 skill 的实际位置: diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 6700f79f..9841d925 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -19,7 +19,7 @@ export function rootHelp(): unknown { { command: "server cleanup plan [--min-age-hours N] [--limit N]", description: "Dry-run Docker image cleanup plan only: list active/protected images, stale candidates older than the default 24h threshold, risk, estimated reclaim, and manual review commands without deleting anything." }, { command: "server rebuild ", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." }, { command: "provider attach [--master-server URL] [--up] [--force] | provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." }, - { command: "ssh [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `G14:k3s` only locates distributed targets." }, + { command: "ssh [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `G14:k3s` or `D601:win/c/test` only locates distributed targets." }, { command: "ssh apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." }, { command: "ssh py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." }, { command: "ssh script [--shell sh|bash] [script-args...] <<'SCRIPT' ...", description: "Run a remote shell script from local stdin using shell -s; the default sh inherits provider proxy env, so --shell bash is only for bash-specific syntax." }, @@ -156,6 +156,8 @@ export function sshHelp(): unknown { "bun scripts/cli.ts ssh find [--contains TEXT] [--limit N]", "bun scripts/cli.ts ssh glob [--root DIR] [--pattern PATTERN]", "bun scripts/cli.ts ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch", + "bun scripts/cli.ts ssh D601:win cmd ver", + "bun scripts/cli.ts ssh D601:win/c/test cmd cd", "bun scripts/cli.ts ssh D601:k3s", "bun scripts/cli.ts ssh D601:k3s kubectl get pods -n hwlab-dev", "bun scripts/cli.ts ssh G14:k3s", @@ -176,7 +178,8 @@ export function sshHelp(): unknown { "For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.", "apply-patch rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.", "script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.", - "Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `:/absolute/workspace`; native k3s providers such as D601 and G14 use :k3s for the control plane, :k3s:: for a workload, and :k3s::/ for a pod workspace.", + "Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `:/absolute/workspace`; WSL providers can use `:win cmd ` to run Windows host cmd.exe with UTF-8 defaults, and `:win/c/test cmd cd` maps the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use :k3s for the control plane, :k3s:: for a workload, and :k3s::/ for a pod workspace.", + "Use `win`, not `win32`; the win route sets chcp 65001, PYTHONUTF8=1, and PYTHONIOENCODING=utf-8 before running the requested cmd command line.", "Do not put operation names in any colon route segment, including nested k3s namespace/workload/container segments.", "Do not use post-provider shorthand such as `ssh G14 k3s ...`; write `ssh G14:k3s ...` so location and operation stay separated.", "If an ssh-like remote command fails with timeout/kex/exit-255 friction, stderr includes one low-noise UNIDESK_SSH_HINT JSON line with the argv retry command.", diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 853fecca..181f59fe 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -6,7 +6,7 @@ import { type UniDeskConfig } from "./config"; import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug"; import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, summarizeMicroserviceProxyResponse } from "./microservices"; import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf"; -import { formatSshFailureHint, formatSshRuntimeTimingHint, parseSshInvocation, sshFailureHint, sshRuntimeTimingHint, wrapSshRemoteCommand } from "./ssh"; +import { formatSshFailureHint, formatSshRuntimeTimingHint, parseSshInvocation, sshFailureHint, sshRoutePayloadCwd, sshRuntimeTimingHint, wrapSshRemoteCommand } from "./ssh"; import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync, codexUnreadTriageAsync } from "./code-queue"; import { runDecisionCenterCommandAsync } from "./decision-center"; import { @@ -901,7 +901,7 @@ async function runRemoteSshWebSocket( const payload = { providerId: invocation.providerId, command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers), - cwd: invocation.route.plane === "host" ? invocation.route.workspace ?? undefined : undefined, + cwd: sshRoutePayloadCwd(invocation.route), tty: parsed.remoteCommand === null, stdinEotOnEnd: parsed.remoteCommand !== null, openTimeoutMs, @@ -1048,7 +1048,7 @@ export function remoteSshFrontendPlanForTest(target: string, args: string[]): Re wrappedRemoteCommand: wrapSshRemoteCommand(invocation.parsed.remoteCommand, invocation.parsed.requiredHelpers), requiresStdin: invocation.parsed.requiresStdin, invocationKind: invocation.parsed.invocationKind, - payloadCwd: invocation.route.plane === "host" ? invocation.route.workspace : null, + payloadCwd: sshRoutePayloadCwd(invocation.route) ?? null, }; } diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 35180403..8e0a4154 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -15,7 +15,7 @@ export type SshHelperName = "apply_patch" | "glob" | "skill-discover"; export interface ParsedSshRoute { providerId: string; - plane: "host" | "k3s"; + plane: "host" | "k3s" | "win"; entry: string | null; namespace: string | null; resource: string | null; @@ -59,6 +59,9 @@ export interface SshRuntimeTimingHint { const argvQuotedSshSubcommands = new Set(["git", "rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]); const nativeK3sKubeconfig = "/etc/rancher/k3s/k3s.yaml"; +const windowsBridgeCwd = "/mnt/c/Windows"; +const windowsPowerShellExePath = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; +const windowsCmdExeNativePath = "C:\\Windows\\System32\\cmd.exe"; const defaultSshSlowWarningMs = 10_000; const k3sResourceKindAliases = new Set(["pod", "po", "pods", "deployment", "deploy", "deployments", "statefulset", "sts", "daemonset", "ds", "job", "jobs"]); const legacyK3sOperationRouteSegments = new Set([ @@ -859,6 +862,9 @@ export function parseSshInvocation(target: string, args: string[]): ParsedSshInv if (route.plane === "k3s") { return { providerId: route.providerId, route, parsed: parseK3sRouteArgs(route, args) }; } + if (route.plane === "win") { + return { providerId: route.providerId, route, parsed: parseWinRouteArgs(route, args) }; + } if ((args[0] ?? "") === "k3s") { throw new Error(`ssh k3s shorthand is unsupported; use route syntax instead: ssh ${route.providerId}:k3s ${args.slice(1).join(" ")}`.trim()); } @@ -880,6 +886,15 @@ export function parseSshRoute(target: string): ParsedSshRoute { if (tail.startsWith("/")) { return hostSshRoute(providerId, target, tail); } + if (tail === "win32" || tail.startsWith("win32/") || tail.startsWith("win32:")) { + throw new Error(`unsupported ssh route plane: win32; use ${providerId}:win or ${providerId}:win/c/path`); + } + if (tail === "win" || tail.startsWith("win/")) { + return winSshRoute(providerId, target, parseWinRouteWorkspace(providerId, tail)); + } + if (tail.startsWith("win:")) { + throw new Error(`ssh win workspace route uses slash syntax, for example: ssh ${providerId}:win/c/test cmd cd`); + } const [plane, ...rest] = tail.split(":"); if (plane === undefined || plane.length === 0 || plane === "host") { const workspace = rest.length > 0 ? rest.join(":") : null; @@ -908,6 +923,87 @@ function hostSshRoute(providerId: string, raw: string, workspace: string | null) return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, workspace, raw }; } +function winSshRoute(providerId: string, raw: string, workspace: string | null): ParsedSshRoute { + return { providerId, plane: "win", entry: null, namespace: null, resource: null, container: null, workspace, raw }; +} + +function parseWinRouteWorkspace(providerId: string, tail: string): string | null { + if (tail === "win") return null; + const suffix = tail.slice("win/".length); + const slashIndex = suffix.indexOf("/"); + const drive = slashIndex < 0 ? suffix : suffix.slice(0, slashIndex); + if (!/^[A-Za-z]$/u.test(drive)) { + throw new Error(`ssh win workspace route requires a drive letter, for example: ssh ${providerId}:win/c/test cmd cd`); + } + const rest = slashIndex < 0 ? "" : suffix.slice(slashIndex + 1); + const segments = rest.split("/").filter((segment) => segment.length > 0); + return `${drive.toUpperCase()}:\\${segments.join("\\")}`; +} + +function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs { + const operation = args[0] ?? ""; + if (operation.length === 0) { + throw new Error(`ssh ${route.raw} requires a Windows operation, for example: ssh ${route.providerId}:win cmd ver`); + } + if (operation !== "cmd" && operation !== "cmd.exe") { + throw new Error(`unsupported ssh win operation: ${operation}; use ssh ${route.providerId}:win cmd `); + } + const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1); + if (commandArgs.length === 0) throw new Error(`ssh ${route.raw} cmd requires a command line, for example: ssh ${route.providerId}:win cmd ver`); + return { + remoteCommand: shellArgv([ + windowsPowerShellExePath, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + buildWindowsPowerShellEncodedCommand(buildWindowsCmdLine(commandArgs.join(" "), route.workspace)), + ]), + requiresStdin: false, + invocationKind: "argv", + }; +} + +function buildWindowsCmdLine(userCommand: string, cwd: string | null): string { + const parts = [ + "chcp 65001>nul", + "set PYTHONUTF8=1", + "set PYTHONIOENCODING=utf-8", + ]; + if (cwd !== null) parts.push(`cd /d ${windowsCmdQuote(cwd)}`); + parts.push(userCommand); + return parts.join(" && "); +} + +function windowsCmdQuote(value: string): string { + if (/[\r\n"]/u.test(value)) throw new Error("ssh win workspace path must not contain quotes or newlines"); + return `"${value}"`; +} + +function powerShellSingleQuote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function buildWindowsPowerShellEncodedCommand(cmdLine: string): string { + const script = [ + "$ErrorActionPreference = 'Stop';", + "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();", + "$OutputEncoding = [System.Text.UTF8Encoding]::new();", + "$env:PYTHONUTF8 = '1';", + "$env:PYTHONIOENCODING = 'utf-8';", + `& ${powerShellSingleQuote(windowsCmdExeNativePath)} /d /s /c ${powerShellSingleQuote(cmdLine)};`, + "exit $LASTEXITCODE;", + ].join(" "); + return Buffer.from(script, "utf16le").toString("base64"); +} + +export function sshRoutePayloadCwd(route: ParsedSshRoute): string | undefined { + if (route.plane === "host") return route.workspace ?? undefined; + if (route.plane === "win") return windowsBridgeCwd; + return undefined; +} + function routeSegmentHead(segment: string): string { return segment.split("/")[0] ?? segment; } @@ -1806,7 +1902,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st const payload = { providerId: invocation.providerId, command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers), - cwd: invocation.route.plane === "host" ? invocation.route.workspace ?? undefined : undefined, + cwd: sshRoutePayloadCwd(invocation.route), tty: parsed.remoteCommand === null, stdinEotOnEnd: parsed.remoteCommand !== null, openTimeoutMs, diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 2bf81f2c..bc60bba1 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -24,6 +24,13 @@ function assertThrows(fn: () => unknown, pattern: RegExp, message: string): void throw new Error(`${message}: expected throw`); } +function decodeWinEncodedCommand(remoteCommand: string | null | undefined): string { + const text = String(remoteCommand ?? ""); + const match = /'-EncodedCommand' '([^']+)'/u.exec(text); + assertCondition(match !== null, "win command must use PowerShell -EncodedCommand", remoteCommand); + return Buffer.from(match[1] ?? "", "base64").toString("utf16le"); +} + function applyPatchFixture(args: string[], patch: string, files: Record): { status: number | null; stdout: string; stderr: string; files: Record } { const root = mkdtempSync(path.join(os.tmpdir(), "unidesk-apply-patch-contract-")); try { @@ -123,6 +130,22 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(hostWorkspaceLongForm.route.workspace === "/home/ubuntu/workspace/hwlab-dev", "host: workspace route must parse as the same location model", hostWorkspaceLongForm); assertCondition(hostWorkspaceLongForm.parsed.remoteCommand === "'git' 'status' '--short'", "host workspace argv operation must stay argv-quoted", hostWorkspaceLongForm); + const winCmd = parseSshInvocation("D601:win", ["cmd", "ver"]); + assertCondition(winCmd.route.plane === "win" && winCmd.route.workspace === null, "win route must parse as the Windows cmd plane", winCmd); + const winCmdScript = decodeWinEncodedCommand(winCmd.parsed.remoteCommand); + assertCondition(String(winCmd.parsed.remoteCommand).startsWith("'/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'") && winCmdScript.includes("C:\\Windows\\System32\\cmd.exe") && winCmdScript.includes("chcp 65001>nul") && winCmdScript.includes("PYTHONIOENCODING"), "win route must execute cmd.exe through a UTF-8 Windows launcher", { winCmd, winCmdScript }); + + const winCmdCwd = parseSshInvocation("D601:win/c/test", ["cmd", "echo", "中文"]); + assertCondition(winCmdCwd.route.plane === "win" && winCmdCwd.route.workspace === String.raw`C:\test`, "win route slash workspace must map to a Windows drive cwd", winCmdCwd); + const winCmdCwdScript = decodeWinEncodedCommand(winCmdCwd.parsed.remoteCommand); + assertCondition(winCmdCwdScript.includes('cd /d "C:\\test"') && winCmdCwdScript.includes("echo 中文"), "win route workspace must cd in Windows cmd before running the command", { winCmdCwd, winCmdCwdScript }); + + assertThrows( + () => parseSshInvocation("D601:win32", ["cmd", "ver"]), + /use D601:win/u, + "win32 route spelling must be rejected in favor of win", + ); + const script = parseSshArgs(["script", "--shell", "bash", "--", "alpha beta"]); assertCondition(script.invocationKind === "helper", "script stdin helper must be classified as helper", script); assertCondition(script.remoteCommand === "'bash' '-s' '--' 'alpha beta'", "script helper must pass stdin to shell directly", script); @@ -376,6 +399,8 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(helpText.includes("inherits provider proxy variables"), "ssh help must state default script inherits provider proxy env", helpText); assertCondition(helpText.includes("not as a proxy workaround"), "ssh help must reserve --shell bash for bash syntax instead of proxy workarounds", helpText); assertCondition(helpText.includes("ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch"), "ssh help must document host workspace routes", helpText); + assertCondition(helpText.includes("ssh D601:win cmd ver") && helpText.includes("ssh D601:win/c/test cmd cd"), "ssh help must document Windows cmd win routes", helpText); + assertCondition(helpText.includes("Use `win`, not `win32`") && helpText.includes("chcp 65001") && helpText.includes("PYTHONIOENCODING=utf-8"), "ssh help must document win route UTF-8 defaults and naming", helpText); assertCondition(helpText.includes("ssh D601:k3s kubectl get pods -n hwlab-dev"), "ssh help must document k3s kubectl operation", helpText); assertCondition(helpText.includes("ssh G14:k3s kubectl get pipelineruns -n hwlab-ci"), "ssh help must document G14 k3s route operation", helpText); assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd"), "ssh help must document k3s pod workspace route", helpText); @@ -409,10 +434,16 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(frontendRemoteWorkspacePlan.payloadCwd === "/home/ubuntu/workspace/hwlab-dev", "remote frontend host workspace route must pass cwd to host.ssh payload", frontendRemoteWorkspacePlan); assertCondition(frontendRemoteWorkspacePlan.remoteCommand === "'git' 'status' '--short'", "remote frontend host workspace route must keep command argv-quoted", frontendRemoteWorkspacePlan); + const frontendRemoteWinPlan = remoteSshFrontendPlanForTest("D601:win/c/test", ["cmd", "cd"]); + assertCondition(frontendRemoteWinPlan.providerId === "D601" && frontendRemoteWinPlan.payloadCwd === "/mnt/c/Windows", "remote frontend win route must dispatch through provider host.ssh from a Windows-mounted cwd", frontendRemoteWinPlan); + const frontendRemoteWinScript = decodeWinEncodedCommand(String(frontendRemoteWinPlan.remoteCommand)); + assertCondition(frontendRemoteWinScript.includes("cmd.exe") && frontendRemoteWinScript.includes("cd /d \"C:\\test\""), "remote frontend win route must assemble Windows cmd cwd internally", { frontendRemoteWinPlan, frontendRemoteWinScript }); + const tranScript = readFileSync(new URL("./tran", import.meta.url), "utf8"); assertCondition(tranScript.includes("CODE_QUEUE_DEV_CONTAINER_MASTER_HOST") && tranScript.includes("--main-server-ip"), "tran wrapper must auto-select frontend transport inside Code Queue runner pods", tranScript); assertCondition(tranScript.includes("UNIDESK_TRAN_LOCAL"), "tran wrapper must keep an explicit local override for diagnostics", tranScript); assertCondition(tranScript.includes("tran_lock_scope") && tranScript.includes("UNIDESK_TRAN_LOCK_DIR"), "tran wrapper must serialize concurrent provider session opens with a local sh lock", tranScript); + assertCondition(tranScript.includes("*:win|*:win/*) plane=win"), "tran wrapper must lock win route calls separately from host/k3s calls", tranScript); const tranLock = tranConcurrentLockFixture(); assertCondition(tranLock.status === 0, "tran lock fixture shell should complete", tranLock); assertCondition(tranLock.stdout.trim() === "0 0", "parallel tran invocations for one provider must serialize instead of overlapping fake bun", tranLock); @@ -450,6 +481,7 @@ export function runSshArgvGuidanceContract(): JsonRecord { "legacy operation-in-route forms are rejected in any k3s route segment with canonical route-plus-operation guidance", "post-provider k3s shorthand is rejected so location and operation stay separated", "k3s route stays location-only while operations fix native kubeconfig and assemble kubectl exec as argv", + "win route runs Windows cmd.exe with UTF-8 defaults and slash cwd syntax such as D601:win/c/test", "top-level remote option parsing preserves command-local -- separators for script -- sed -n style commands", "ssh-like timeout/kex failures emit one structured argv retry hint", "ssh runtime emits structured timing for slow operations over 10 seconds, including successful slow calls", diff --git a/scripts/tran b/scripts/tran index 85e173c2..46833126 100755 --- a/scripts/tran +++ b/scripts/tran @@ -26,6 +26,7 @@ tran_lock_scope() { [ -n "$provider" ] || return 1 plane=host case "$route" in + *:win|*:win/*) plane=win ;; *:k3s*) plane=k3s ;; esac printf '%s\n' "$provider-$plane"