feat: add stdin script ssh routes

This commit is contained in:
Codex
2026-05-25 05:58:51 +00:00
parent 5d636d2d5b
commit 64c82b5be8
5 changed files with 132 additions and 33 deletions
+22 -10
View File
@@ -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 <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>` 创建异步 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 <providerId> [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-<ID>.env` 默认只包含 `UNIDESK_MASTER_SERVER``PROVIDER_ID``provider-<ID>.yml` 固定 Docker socket、`pid: "host"``restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build``provider triage <providerId> [--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 <providerId> host.ssh --wait-ms 15000``ssh <providerId> argv true``artifact-registry health --provider-id <providerId>``microservice health k3sctl-adapter``microservice health code-queue``codex tasks --view supervisor --limit 20`
- `ssh <route> [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601`,也可以扩展为 `provider:plane:entry-or-scope...`,例如 `D601:k3s:kubectl``D601:k3s:hwlab-dev:hwlab-cloud-api`。非交互远端命令优先使用 `ssh <providerId> argv ...`需要 shell 特性时用 `bun scripts/cli.ts ssh D601 argv bash -lc '<command>'`ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 argv 重试和 provider triage 交叉验证。
- `ssh <route> [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601`,也可以扩展为 `provider:plane:entry-or-scope...`,例如 `D601:k3s:kubectl``D601:k3s:hwlab-dev:hwlab-cloud-api``D601:k3s:script:hwlab-dev:hwlab-cloud-api`。非交互远端命令优先使用 `ssh <providerId> argv ...`需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `bun scripts/cli.ts ssh D601 script <<'SCRIPT'``bun scripts/cli.ts ssh D601:k3s:script:<namespace>:<workload> <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。
- `ssh <providerId> apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。
- `ssh <providerId> py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。
- `ssh <providerId> skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills``.codex/skills`
@@ -113,13 +113,13 @@ GitHub issue/PR 写操作必须优先使用 `bun scripts/cli.ts gh issue|pr ...
`bun scripts/cli.ts ssh --help``bun scripts/cli.ts ssh <providerId> --help` 是本地 JSON 帮助命令,必须快速返回;不能把 `--help` 解析成 Provider ID,不能打开交互 shell,也不能等待 provider 会话。
`bun scripts/cli.ts ssh D518` 应表现为登录 D518 WSL 的 shell`bun scripts/cli.ts ssh D518 hostname` 应像 `ssh D518 hostname` 一样只输出远端命令结果并返回远端 exit code。Provider ID 前的目标选择由 UniDesk 节点清单决定,`-p``-i``-l``-o` 等传统 ssh 传输参数由 provider-gateway 部署配置统一管理,CLI 会兼容性消费这些参数但不会覆盖节点侧维护桥配置。指挥官、CI 预检和其他非交互流程不要依赖 ssh-like 自由拼接;标准写法是 `bun scripts/cli.ts ssh D601 argv true`或者在需要管道、变量展开和多条命令时使`bun scripts/cli.ts ssh D601 argv bash -lc '<command>'`
`bun scripts/cli.ts ssh D518` 应表现为登录 D518 WSL 的 shell`bun scripts/cli.ts ssh D518 hostname` 应像 `ssh D518 hostname` 一样只输出远端命令结果并返回远端 exit code。Provider ID 前的目标选择由 UniDesk 节点清单决定,`-p``-i``-l``-o` 等传统 ssh 传输参数由 provider-gateway 部署配置统一管理,CLI 会兼容性消费这些参数但不会覆盖节点侧维护桥配置。指挥官、CI 预检和其他非交互流程不要依赖 ssh-like 自由拼接;单进程标准写法是 `bun scripts/cli.ts ssh D601 argv true`多行 shell 逻辑标准写法是 quoted heredoc 单步调`bun scripts/cli.ts ssh D601 script <<'SCRIPT'`
core 只允许声明了 `host.ssh` capability 的 provider 使用 `ssh` 透传或 `host.ssh` dispatch;旧 provider 不支持该能力时必须快速失败并输出错误,不能把未知命令误判成 `echo` 成功。
本地 broker 默认等待 provider SSH 会话打开 60000ms,以便在目标节点同时有较多 microservice.http 任务时仍能建立维护会话;需要诊断慢连接时可用 `UNIDESK_SSH_OPEN_TIMEOUT_MS=<ms>` 临时调大,但最小有效值固定为 15000ms,避免把真实离线误判为长时间阻塞。
ssh-like 远端命令如果出现 `kex_exchange_identification``Connection closed by remote host`、provider session timeout 或 exit code 255CLI 会在原始 stderr 后追加一行 `UNIDESK_SSH_HINT { ... }`。该 JSON 不回显原始远端命令,只包含 `code=ssh-like-command-friction``trigger``try``triage``try` 固定指向 `bun scripts/cli.ts ssh D601 argv bash -lc '<command>'` 形态,避免把一次 ssh-like 解析/握手摩擦误读成 D601 SSH 整体不可用。
ssh-like 远端命令如果出现 `kex_exchange_identification``Connection closed by remote host`、provider session timeout 或 exit code 255CLI 会在原始 stderr 后追加一行 `UNIDESK_SSH_HINT { ... }`。该 JSON 不回显原始远端命令,只包含 `code=ssh-like-command-friction``trigger``try``triage``try` 固定指向 stdin script 形态,避免把一次 ssh-like 解析/握手摩擦误读成 D601 SSH 整体不可用。
`ssh <providerId>` 会在远端会话启动时注入 `/tmp/unidesk-ssh-tools/apply_patch``/tmp/unidesk-ssh-tools/glob``/tmp/unidesk-ssh-tools/skill-discover`,并把该目录加入远端 `PATH``apply_patch` 接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;`glob` 在远端用 Python 执行路径匹配,避免依赖 shell glob 展开;`skill-discover` 用于列出远端 Linux/WSL 与 Windows skill。目标节点需要具备 `python3``base64`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库,交互式 shell 和远端命令都可以直接调用这些工具。
@@ -144,6 +144,18 @@ printf 'import sys\nprint(sys.argv)\n' | bun scripts/cli.ts ssh D601 py foo '--b
`ssh <providerId> py` 的附加参数是脚本参数,不是 Python 解释器参数;如需 `-m``-X` 或多条 shell 命令,仍使用原始远端命令入口。为了保证 CLI 输出及时可见,helper 固定采用“临时文件 + `python3 -u`”模式;provider 命令模式不分配 TTY,因此脚本内容不应被远端回显。
如果远端逻辑需要 shell 特性,不要再把整段脚本作为命令字符串传入。正式入口是 `bun scripts/cli.ts ssh D601 script`,脚本正文从 stdin 进入;CLI 会把本地 stdin 直接送到远端 `sh -s --``--shell bash` 可切换为 bash`--` 后的内容会作为脚本参数传入。临时单步执行优先用 quoted heredoc,复用脚本时才用 `< script.sh` 文件重定向。典型用法:
```bash
cat <<'SCRIPT' | bun scripts/cli.ts ssh D601 script --shell bash -- alpha
set -euo pipefail
printf 'arg=%s\n' "$1"
hostname
SCRIPT
```
这个入口的目标是分布式调试的“0 shell-command-string”路径:本地 shell 只负责 heredoc/stdinUniDesk 只负责 provider 路由,远端 shell 只解释脚本正文。脚本正文里仍然要遵守 shell 语言自身的规则,但不再穿过本地 shell、远端 shell、kubectl exec 和容器 shell 的多重字符串转义。
`ssh <providerId> skills` 是远端 skill 发现入口,也可写作 `ssh <providerId> skill discover`。输出固定为 JSON,包含 `node``roots``counts``skills``roots` 会显示每个候选 skill 根目录是否存在、扫描到多少 skill 以及错误;`skills` 会给出 `scope``name``description``path``skillMd` 和可转换时的 `windowsPath`。默认扫描远端用户的 `~/.agents/skills``~/.codex/skills`、可访问的 `/root/.agents/skills``/root/.codex/skills`;如果目标是 WSL,还会扫描 `/mnt/c/Users/*/.agents/skills``/mnt/c/Users/*/.codex/skills`,从而一次性看清 WSL 和 Windows 两套 skill。常用参数是 `--scope wsl``--scope windows``--limit N``--max-depth N``--root <path>``--windows-root <path>`;不要用宽泛的 Linux `find /mnt/*` 扫 Windows 盘,优先用这个结构化入口避免卡在 Windows 挂载层。
```bash
@@ -165,29 +177,29 @@ bun scripts/cli.ts ssh D601 find /home/ubuntu --max-depth 4 --type d --icontains
bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*-test.cpp' --limit 20 --sort
```
`ssh` 的 route 语法是 `provider:plane:entry-or-scope...`。当前稳定 plane 是 `k3s``D601:k3s` 表示 D601 原生 k3s 控制面;`D601:k3s:kubectl` 表示该控制面的 kubectl CLI`D601:k3s:<namespace>:<workload>[:container]` 表示 namespace 下的一个默认 deployment workload,后续 argv 默认通过 `kubectl exec` 进入该对象;如果目标是具体 Podworkload 段写成 `pod/<podid>`,如果目标是 Deployment,也可以显式写 `deployment/<name>` 或简写 `<name>`
`ssh` 的 route 语法是 `provider:plane:entry-or-scope...`。当前稳定 plane 是 `k3s``D601:k3s` 表示 D601 原生 k3s 控制面;`D601:k3s:kubectl` 表示该控制面的 kubectl CLI`D601:k3s:<namespace>:<workload>[:container]` 表示 namespace 下的一个默认 deployment workload,后续 argv 默认通过 `kubectl exec` 进入该对象;`D601:k3s:script:<namespace>:<workload>[:container]` 表示把本地 stdin 脚本送入目标 workload。若目标是具体 Podworkload 段写成 `pod/<podid>`;若目标是 Deployment,也可以显式写 `deployment/<name>` 或简写 `<name>`
该 route 入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制容器命令组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml``D601:k3s` 无后续参数时执行 native k3s guard`D601:k3s:kubectl` 接收原始 kubectl argv`D601:k3s:logs:<namespace>:<workload>` 读取有界日志;`D601:k3s:exec:<namespace>:<workload>``D601:k3s:<namespace>:<workload>` 进入目标 workload。典型用法:
该 route 入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制容器命令和 stdin script 路由组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml``D601:k3s` 无后续参数时执行 native k3s guard`D601:k3s:kubectl` 接收原始 kubectl argv`D601:k3s:logs:<namespace>:<workload>` 读取有界日志;`D601:k3s:exec:<namespace>:<workload>``D601:k3s:<namespace>:<workload>` 进入目标 workload`D601:k3s:script:<namespace>:<workload>` 把本地 stdin 作为 pod 内 shell 脚本执行。典型用法:
```bash
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:k3s:logs:hwlab-dev:hwlab-cloud-api --tail 80
bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'
bun scripts/cli.ts ssh D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc:api sh -lc 'printf "%s\n" "$HOSTNAME"'
printf 'printf "pod=%s\n" "$HOSTNAME"\n' | bun scripts/cli.ts ssh D601:k3s:script:hwlab-dev:hwlab-cloud-api
```
route logs 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。route exec 不要求手写 `--`CLI 会把 route 后续 argv 放到 `kubectl exec --` 后;如果命令本身需要复杂 shell 语法,把它作为容器内 shell 的单个 argv,例如 `sh -lc 'printf "%s\n" "$HOSTNAME"'`。这比把整条 `kubectl exec ... -- sh -lc ...` 放进远端 `bash -lc` 更稳定,因为本地 CLI 只需要处理一层容器内 shell 字符串。
route logs 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。route exec 不要求手写 `--`CLI 会把 route 后续 argv 放到 `kubectl exec --` 后;如果命令本身需要复杂 shell 语法,优先改用 route script,把脚本走 stdin,而不是把 `kubectl exec ... -- sh -c ...` 放进远端命令字符串。
`ssh <providerId> argv <command> [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `bun scripts/cli.ts ssh D601 argv true`需要管道、重定向、变量展开或多条命令时使用 `bun scripts/cli.ts ssh D601 argv bash -lc '<command>'`,让 shell 脚本作为 `bash -lc` 的一个 argv token 传递`find``glob``apply-patch` 有专用入口;`rg``grep``sed``nl``stat``du``ls``cat``head``tail``wc``pwd` 可以直接作为 `ssh` 子命令使用,CLI 会对每个 argv token 做 shell quoting。旧的自由 ssh-like 远端命令入口只保留为近似原生 ssh 的人工兼容路径。
`ssh <providerId> argv <command> [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `bun scripts/cli.ts ssh D601 argv true`需要管道、重定向、变量展开或多条命令时,优先改用 `ssh <providerId> script <<'SCRIPT'``find``glob``apply-patch` 有专用入口;`rg``grep``sed``nl``stat``du``ls``cat``head``tail``wc``pwd` 可以直接作为 `ssh` 子命令使用,CLI 会对每个 argv token 做 shell quoting。旧的自由 ssh-like 远端命令入口只保留为近似原生 ssh 的人工兼容路径。
通过 `ssh <providerId>` 执行多行脚本时,优先使用结构化 helper,例如 `bun scripts/cli.ts ssh D601 py < script.py``printf ... | (bun scripts/cli.ts ssh D601 'bash -s')` 这种单层 stdin 传输。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <<EOF ...'` 形态;多层 shell 解析容易把 stdin 绑定到错误进程,结果会打开远端交互解释器并留下悬挂的 broker/SSH 会话。长脚本需要复用时,优先通过 stdin 写入目标节点的临时脚本,再在同一个远端命令中显式执行并清理
通过 `ssh <providerId>` 执行多行脚本时,优先使用结构化 helper,例如 `bun scripts/cli.ts ssh D601 py < script.py``bun scripts/cli.ts ssh D601 script <<'SCRIPT'``bun scripts/cli.ts ssh D601:k3s:script:hwlab-dev:hwlab-cloud-api <<'SCRIPT'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <<EOF ...'` 形态;多层 shell 解析容易把 stdin 绑定到错误进程,结果会打开远端交互解释器并留下悬挂的 broker/SSH 会话。长脚本需要复用时,优先提交到 repo 或通过 stdin 传输到目标节点执行
## Remote Main Server Passthrough
`--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://<ip>:<frontendPort>/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。
默认 frontend 传输支持 `debug health``debug dispatch``debug task``artifact-registry status|health``ci publish-user-service --dry-run``microservice list/status/health/diagnostics/tunnel-self-test/proxy``decision upload/list/show/health``decision requirement list/upsert``decision diary import/list/history/months/show/edit/upsert``codex task <taskId>``codex tasks``codex unread``codex queues``codex output <taskId>``codex judge <taskId> --attempt N``ssh <PROVIDER_ID> <remote-command>``microservice status/health/diagnostics` 经 frontend 远程传输时也复用本地 CLI 的默认 compact summary`microservice health code-queue` 只有显式 `--raw``--full` 才返回完整健康 body。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,非交互命令同样优先 `ssh D601 argv true``ssh D601 argv bash -lc '<command>'`;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py``ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。当 backend-core、database、provider-dispatch 或 provider-host-ssh 缺失时,这些 read-only 预检必须返回结构化 `runnerDisposition=infra-blocked` 和缺失通道列表,而不是裸 `No such container`。若确实需要旧行为,可使用 `--main-server-key <key>``--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`
默认 frontend 传输支持 `debug health``debug dispatch``debug task``artifact-registry status|health``ci publish-user-service --dry-run``microservice list/status/health/diagnostics/tunnel-self-test/proxy``decision upload/list/show/health``decision requirement list/upsert``decision diary import/list/history/months/show/edit/upsert``codex task <taskId>``codex tasks``codex unread``codex queues``codex output <taskId>``codex judge <taskId> --attempt N``ssh <PROVIDER_ID> <remote-command>``microservice status/health/diagnostics` 经 frontend 远程传输时也复用本地 CLI 的默认 compact summary`microservice health code-queue` 只有显式 `--raw``--full` 才返回完整健康 body。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,非交互单进程命令优先 `ssh D601 argv true`;需要 stdin script`py``apply-patch` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。当 backend-core、database、provider-dispatch 或 provider-host-ssh 缺失时,这些 read-only 预检必须返回结构化 `runnerDisposition=infra-blocked` 和缺失通道列表,而不是裸 `No such container`。若确实需要旧行为,可使用 `--main-server-key <key>``--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`
计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh``hostSshConfigured=true``hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000``bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh <PROVIDER_ID> hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。
+3 -3
View File
@@ -50,12 +50,12 @@ Distributed runtime work should prefer structured CLI passthrough over ad-hoc ne
1. Use a purpose-built UniDesk route or helper such as `ssh D601:k3s:kubectl`, `ssh D601:k3s:<namespace>:<workload>`, `ssh <providerId> py`, `ssh <providerId> apply-patch`, `ssh <providerId> find`, `ssh <providerId> glob` or `ssh <providerId> skills`.
2. If no helper exists, use `ssh <providerId> argv <command> [args...]` so the CLI quotes each argv token once.
3. Use `ssh <providerId> argv bash -lc '<script>'` only when shell features such as pipes, redirects, loops or variable expansion are genuinely required.
3. If shell features such as pipes, redirects, loops or variable expansion are required, use a single quoted heredoc with `ssh <providerId> script` or `ssh D601:k3s:script:<namespace>:<workload>` so the script body travels over stdin instead of through shell command-string arguments.
4. Treat free-form ssh-like command strings as an interactive compatibility path, not as the default automation surface.
For D601 Kubernetes work, route syntax is preferred over positional shell recipes. `D601:k3s` means the native k3s control plane, `D601:k3s:kubectl` means kubectl on that plane, and `D601:k3s:<namespace>:<workload>[:container]` means exec into a namespaced workload or pod. The route fixes `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`, refuses long-follow logs, and assembles common `kubectl exec` / `kubectl logs` target arguments without adding a provider-gateway protocol change. This prevents the common failure mode where a command crosses local shell, UniDesk SSH broker, remote `bash -lc`, `kubectl exec`, and container shell quoting layers before reaching the process that should run it.
For D601 Kubernetes work, route syntax is preferred over positional shell recipes. `D601:k3s` means the native k3s control plane, `D601:k3s:kubectl` means kubectl on that plane, `D601:k3s:<namespace>:<workload>[:container]` means exec into a namespaced workload or pod, and `D601:k3s:script:<namespace>:<workload>[:container]` means stream a local heredoc/stdin script into that workload. The route fixes `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`, refuses long-follow logs, and assembles common `kubectl exec` / `kubectl logs` / stdin script target arguments without adding a provider-gateway protocol change. This prevents the common failure mode where a command crosses local shell, UniDesk SSH broker, remote shell command strings, `kubectl exec`, and container shell quoting layers before reaching the process that should run it.
Longer scripts should move across stdin as files (`ssh py` or `ssh apply-patch`) or as committed source followed by a short remote command. Avoid heredocs nested inside remote command strings, `python - <<EOF` inside SSH strings, or JSON/Markdown bodies passed through shell arguments. These patterns often bind stdin to the wrong process, strip quotes, or leave a half-open provider SSH session that looks like a platform outage.
Longer scripts should move across stdin as files (`ssh py`, `ssh script`, k3s route `script`, or `ssh apply-patch`) or as committed source followed by a short route command. Avoid heredocs nested inside remote command strings, `python - <<EOF` inside SSH strings, or JSON/Markdown bodies passed through shell arguments. These patterns often bind stdin to the wrong process, strip quotes, or leave a half-open provider SSH session that looks like a platform outage.
When structured passthrough is missing for a recurring workflow, fix the CLI first and then document the durable helper. Do not preserve a growing collection of one-off shell recipes as the long-term runbook.
+7 -5
View File
@@ -22,11 +22,12 @@ export function rootHelp(): unknown {
{ command: "ssh <route> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `D601:k3s:kubectl` keeps distributed targets structured." },
{ command: "ssh <providerId> 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 <providerId> 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 <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT' ...", description: "Run a remote shell script from local stdin using shell -s, avoiding shell-command strings and nested remote heredocs." },
{ command: "ssh <providerId> skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." },
{ command: "ssh <providerId> find <path...> [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." },
{ command: "ssh <providerId> glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." },
{ command: "ssh D601:k3s[:kubectl|:namespace:workload[:container]] ...", description: "Run D601 native k3s kubectl or direct workload exec through route syntax with KUBECONFIG fixed and argv assembled by the CLI." },
{ command: "ssh <providerId> argv <command> [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `argv bash -lc '<command>'` when shell features are required." },
{ command: "ssh D601:k3s[:kubectl|:script:namespace:workload|:namespace:workload[:container]] ...", description: "Run D601 native k3s kubectl, direct workload exec, or stdin shell scripts through route syntax with KUBECONFIG fixed and argv assembled by the CLI." },
{ command: "ssh <providerId> argv <command> [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `ssh <providerId> script` when shell features are required." },
{ command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." },
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
{ command: "microservice health <id> [--compact|--raw|--full]", description: "Probe one user service through backend-core -> provider-gateway HTTP proxy; default output is compact, and code-queue uses a commander-safe liveness summary unless raw/full is requested." },
@@ -146,21 +147,22 @@ export function sshHelp(): unknown {
usage: [
"bun scripts/cli.ts ssh <route>",
"bun scripts/cli.ts ssh <providerId> argv <command> [args...]",
"bun scripts/cli.ts ssh D601 argv bash -lc '<command>'",
"bun scripts/cli.ts ssh <providerId> apply-patch < patch.diff",
"bun scripts/cli.ts ssh <providerId> py [script-args...] < script.py",
"bun scripts/cli.ts ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT'",
"bun scripts/cli.ts ssh <providerId> skills [--scope all|wsl|windows] [--limit N]",
"bun scripts/cli.ts ssh <providerId> find <path...> [--contains TEXT] [--limit N]",
"bun scripts/cli.ts ssh <providerId> glob [--root DIR] [--pattern 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:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'",
"bun scripts/cli.ts ssh D601:k3s:script:hwlab-dev:hwlab-cloud-api <<'SCRIPT'",
"bun scripts/cli.ts ssh D601:k3s:logs:hwlab-dev:hwlab-cloud-api --tail 80",
],
notes: [
"ssh --help and ssh <route> --help print this JSON help and never open an interactive session.",
"For non-interactive remote commands, prefer argv: bun scripts/cli.ts ssh D601 argv bash -lc '<command>'.",
"Route syntax is provider:plane:entry-or-namespace:resource:container. For D601 native k3s, D601:k3s controls the cluster, D601:k3s:kubectl exposes kubectl, and D601:k3s:<namespace>:<workload> defaults to kubectl exec.",
"For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.",
"Route syntax is provider:plane:entry-or-namespace:resource:container. For D601 native k3s, D601:k3s controls the cluster, D601:k3s:kubectl exposes kubectl, D601:k3s:<namespace>:<workload> defaults to kubectl exec, and D601:k3s:script:<namespace>:<workload> streams local stdin to shell -s in the target pod.",
"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.",
"Use -- before a remote command that intentionally starts with a dash.",
],
+82 -8
View File
@@ -42,6 +42,7 @@ const k3sRouteEntries = new Set([
"guard",
"kubectl",
"exec",
"script",
"logs",
"get",
"describe",
@@ -536,6 +537,9 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
if (subcommand === "py") {
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
}
if (subcommand === "script" || subcommand === "sh") {
return { remoteCommand: buildShellStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
}
if (subcommand === "argv" || subcommand === "exec") {
const toolArgs = args.slice(1);
if (toolArgs.length === 0) throw new Error(`ssh ${subcommand} requires a command`);
@@ -742,6 +746,9 @@ function parseK3sRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs
if (route.entry === "exec") {
return { remoteCommand: buildK3sExecCommand([...k3sRouteTargetArgs(route), ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" };
}
if (route.entry === "script") {
return { remoteCommand: buildK3sScriptCommand([...k3sRouteTargetArgs(route), ...args]), requiresStdin: true, invocationKind: "helper" };
}
if (route.entry !== null) {
const k3sArgs = [route.entry, ...(route.namespace === null ? [] : [route.namespace]), ...(route.resource === null ? [] : [route.resource]), ...args];
return { remoteCommand: buildK3sCommand(k3sArgs), requiresStdin: false, invocationKind: "helper" };
@@ -781,6 +788,7 @@ function buildK3sCommand(args: string[]): string {
}
if (action === "guard") return buildD601K3sGuardCommand();
if (action === "exec") return buildK3sExecCommand(args.slice(1));
if (action === "script") return buildK3sScriptCommand(args.slice(1));
if (action === "logs") return buildK3sLogsCommand(args.slice(1));
if (action === "kubectl") {
const kubectlArgs = args.slice(1);
@@ -805,7 +813,7 @@ function buildD601K3sGuardCommand(): string {
"case \"$server\" in *127.0.0.1:11700*|*docker-desktop*) printf 'native_k3s_guard=blocked reason=docker-desktop-context server=%s\\n' \"$server\" >&2; exit 1;; esac",
"printf 'native_k3s_guard=ok\\n'",
].join("; ");
return shellArgv(["bash", "-lc", script]);
return shellArgv(["bash", "-c", script]);
}
interface K3sTargetOptions {
@@ -814,10 +822,17 @@ interface K3sTargetOptions {
container: string | null;
stdin: boolean;
tty: boolean;
shell: string | null;
command: string[];
kubectlOptions: string[];
}
interface ParseK3sTargetOptionsOptions {
requireCommand: boolean;
allowCommand?: boolean;
allowShell?: boolean;
}
function buildK3sExecCommand(args: string[]): string {
const parsed = parseK3sTargetOptions(args, "ssh k3s exec", { requireCommand: true });
if (parsed.namespace === null) throw new Error("ssh k3s exec requires --namespace <name>");
@@ -836,6 +851,28 @@ function buildK3sExecCommand(args: string[]): string {
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]);
}
function buildK3sScriptCommand(args: string[]): string {
const parsed = parseK3sTargetOptions(args, "ssh k3s script", { requireCommand: false, allowCommand: true, allowShell: true });
if (parsed.namespace === null) throw new Error("ssh k3s script requires --namespace <name>");
if (parsed.resource === null) throw new Error("ssh k3s script requires --deployment <name>, --pod <name> or --resource <type/name>");
if (parsed.tty) throw new Error("ssh k3s script does not support --tty; stdin is reserved for the script body");
const shell = parsed.shell ?? "sh";
const kubectlArgs = [
"exec",
"-i",
"-n", parsed.namespace,
parsed.resource,
...(parsed.container === null ? [] : ["-c", parsed.container]),
...parsed.kubectlOptions,
"--",
shell,
"-s",
"--",
...parsed.command,
];
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]);
}
function buildK3sLogsCommand(args: string[]): string {
const parsed = parseK3sTargetOptions(args, "ssh k3s logs", { requireCommand: false });
if (parsed.namespace === null) throw new Error("ssh k3s logs requires --namespace <name>");
@@ -851,12 +888,13 @@ function buildK3sLogsCommand(args: string[]): string {
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]);
}
function parseK3sTargetOptions(args: string[], commandName: string, options: { requireCommand: boolean }): K3sTargetOptions {
function parseK3sTargetOptions(args: string[], commandName: string, options: ParseK3sTargetOptionsOptions): K3sTargetOptions {
let namespace: string | null = null;
let resource: string | null = null;
let container: string | null = null;
let stdin = false;
let tty = false;
let shell: string | null = null;
const kubectlOptions: string[] = [];
const command: string[] = [];
let afterDoubleDash = false;
@@ -896,6 +934,12 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: { r
index += 1;
continue;
}
if (arg === "--shell") {
if (!options.allowShell) throw new Error(`${commandName} does not support --shell`);
shell = k3sScriptShell(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`);
index += 1;
continue;
}
if (arg === "--stdin" || arg === "-i") {
stdin = true;
continue;
@@ -930,8 +974,8 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: { r
}
if (options.requireCommand && command.length === 0) throw new Error(`${commandName} requires -- <command> [args...]`);
if (!options.requireCommand && command.length > 0) throw new Error(`${commandName} does not accept a command after --`);
return { namespace, resource, container, stdin, tty, command, kubectlOptions };
if (!options.requireCommand && options.allowCommand !== true && command.length > 0) throw new Error(`${commandName} does not accept a command after --`);
return { namespace, resource, container, stdin, tty, shell, command, kubectlOptions };
}
function k3sOptionValue(args: string[], index: number, option: string): string {
@@ -952,6 +996,36 @@ function normalizeK3sRouteResource(value: string): string {
return `deployment/${value}`;
}
function k3sScriptShell(value: string, option: string): string {
if (!/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${option} must be a shell executable name or path without whitespace`);
return value;
}
function buildShellStdinCommand(args: string[]): string {
let shell = "sh";
const scriptArgs: string[] = [];
let afterDoubleDash = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (afterDoubleDash) {
scriptArgs.push(arg);
continue;
}
if (arg === "--") {
afterDoubleDash = true;
continue;
}
if (arg === "--shell") {
shell = k3sScriptShell(k3sOptionValue(args, index, "ssh script --shell"), "ssh script --shell");
index += 1;
continue;
}
if (arg.startsWith("-")) throw new Error(`unsupported ssh script option: ${arg}`);
scriptArgs.push(arg);
}
return shellArgv([shell, "-s", "--", ...scriptArgs]);
}
function buildPythonStdinCommand(args: string[]): string {
const pythonArgs = args.map(shellQuote).join(" ");
const execArgs = pythonArgs.length > 0 ? ` "$UNIDESK_SSH_PY_FILE" ${pythonArgs}` : ' "$UNIDESK_SSH_PY_FILE"';
@@ -1017,8 +1091,8 @@ export function sshFailureHint(providerId: string, parsed: ParsedSshArgs, exitCo
providerId: shownProviderId,
trigger,
exitCode,
message: "ssh-like remote command failed before proving Host SSH is globally unavailable; prefer argv form for non-interactive commands.",
try: `bun scripts/cli.ts ssh ${shownProviderId} argv bash -lc '<command>'`,
message: "ssh-like remote command failed before proving Host SSH is globally unavailable; prefer structured argv or stdin script passthrough for non-interactive commands.",
try: `bun scripts/cli.ts ssh ${shownProviderId} script <<'SCRIPT'`,
triage: `bun scripts/cli.ts provider triage ${shownProviderId} --observed-scope ssh --observed-error '<ssh-like timeout or kex failure>'`,
note: "This hint intentionally does not echo the original remote command.",
};
@@ -1176,7 +1250,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
const payloadJson = JSON.stringify(payload);
const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64");
const script = [
"set -euo pipefail",
"set -eu",
`payload=${shellQuote(payloadJson)}`,
"if command -v backend-core >/dev/null 2>&1; then",
' exec backend-core --ssh-broker "$payload"',
@@ -1190,7 +1264,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
"-i",
"unidesk-backend-core",
"sh",
"-lc",
"-c",
script,
], {
cwd: repoRoot,
+18 -7
View File
@@ -9,9 +9,9 @@ function assertCondition(condition: unknown, message: string, detail: unknown =
}
export function runSshArgvGuidanceContract(): JsonRecord {
const argv = parseSshArgs(["argv", "bash", "-lc", "echo ok"]);
const argv = parseSshArgs(["argv", "true"]);
assertCondition(argv.invocationKind === "argv", "argv subcommand must be classified as argv", argv);
assertCondition(argv.remoteCommand === "'bash' '-lc' 'echo ok'", "argv command must shell-quote each token", argv);
assertCondition(argv.remoteCommand === "'true'", "argv command must shell-quote each token", argv);
assertCondition(argv.requiresStdin === false, "argv command must not require stdin", argv);
assertCondition(sshFailureHint("D601", argv, 255, "kex_exchange_identification: Connection closed by remote host") === null, "argv failures must not produce ssh-like friction hint", argv);
@@ -19,6 +19,11 @@ export function runSshArgvGuidanceContract(): JsonRecord {
assertCondition(shortcut.invocationKind === "argv", "safe command shortcuts must use argv quoting", shortcut);
assertCondition(shortcut.remoteCommand === "'pwd'", "safe command shortcut should be shell-quoted", shortcut);
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);
assertCondition(script.requiresStdin === true, "script helper must require stdin", script);
const k3sGuard = parseSshArgs(["k3s", "guard"]);
assertCondition(k3sGuard.invocationKind === "helper", "k3s guard must be classified as helper", k3sGuard);
assertCondition(k3sGuard.remoteCommand?.includes("KUBECONFIG") && k3sGuard.remoteCommand.includes("/etc/rancher/k3s/k3s.yaml"), "k3s guard must force native k3s kubeconfig", k3sGuard);
@@ -36,13 +41,17 @@ export function runSshArgvGuidanceContract(): JsonRecord {
assertCondition(routeTarget.route.namespace === "hwlab-dev" && routeTarget.route.resource === "hwlab-cloud-api", "route target must parse namespace and workload", routeTarget);
assertCondition(routeTarget.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'node' '-e' 'console.log(process.version)'", "D601:k3s:<namespace>:<workload> must default to deployment exec", routeTarget);
const routePodTarget = parseSshInvocation("D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc:api", ["--", "sh", "-lc", "echo ok"]);
assertCondition(routePodTarget.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'pod/hwlab-cloud-api-abc' '-c' 'api' '--' 'sh' '-lc' 'echo ok'", "pod route with container must preserve explicit pod kind", routePodTarget);
const routeScript = parseSshInvocation("D601:k3s:script:hwlab-dev:hwlab-cloud-api", ["--shell", "bash", "--", "arg"]);
assertCondition(routeScript.parsed.requiresStdin === true, "k3s script route must stream local stdin", routeScript);
assertCondition(routeScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'bash' '-s' '--' 'arg'", "D601:k3s:script:<namespace>:<workload> must map stdin to shell -s", routeScript);
const routePodTarget = parseSshInvocation("D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc:api", ["printenv", "HOSTNAME"]);
assertCondition(routePodTarget.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'pod/hwlab-cloud-api-abc' '-c' 'api' '--' 'printenv' 'HOSTNAME'", "pod route with container must preserve explicit pod kind", routePodTarget);
const sshLike = parseSshArgs(["echo hello"]);
const hint = sshFailureHint("D601", sshLike, 255, "kex_exchange_identification: Connection closed by remote host");
assertCondition(hint !== null, "ssh-like kex failure must produce a hint", sshLike);
assertCondition(hint?.try === "bun scripts/cli.ts ssh D601 argv bash -lc '<command>'", "hint must provide canonical argv retry", hint);
assertCondition(hint?.try === "bun scripts/cli.ts ssh D601 script <<'SCRIPT'", "hint must provide canonical stdin script retry", hint);
assertCondition(hint?.triage.includes("provider triage D601"), "hint must provide provider triage command", hint);
const formatted = formatSshFailureHint(hint!);
assertCondition(formatted.startsWith("UNIDESK_SSH_HINT "), "formatted hint must have structured prefix", formatted);
@@ -52,8 +61,9 @@ export function runSshArgvGuidanceContract(): JsonRecord {
assertCondition(timeoutHint?.trigger === "timeout-or-kex", "provider session timeout must map to timeout-or-kex", timeoutHint);
const helpText = JSON.stringify(sshHelp());
assertCondition(helpText.includes("ssh D601 argv bash -lc '<command>'"), "ssh help must recommend argv bash -lc for non-interactive commands", helpText);
assertCondition(helpText.includes("ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT'"), "ssh help must recommend stdin script passthrough for shell scripts", helpText);
assertCondition(helpText.includes("ssh D601:k3s:kubectl get pods -n hwlab-dev"), "ssh help must document k3s kubectl route", helpText);
assertCondition(helpText.includes("ssh D601:k3s:script:hwlab-dev:hwlab-cloud-api <<'SCRIPT'"), "ssh help must document k3s script route", helpText);
assertCondition(helpText.includes("UNIDESK_SSH_HINT"), "ssh help must document structured failure hint", helpText);
const crossChecks = providerTriageRecommendedCrossChecks("D601");
@@ -63,9 +73,10 @@ export function runSshArgvGuidanceContract(): JsonRecord {
ok: true,
checks: [
"argv form is classified and quoted as the success path for non-interactive commands",
"stdin script form removes shell-command strings for host and k3s workload scripts",
"k3s route fixes native kubeconfig and assembles kubectl exec as argv",
"ssh-like timeout/kex failures emit one structured argv retry hint",
"help text documents argv bash -lc and UNIDESK_SSH_HINT",
"help text documents stdin script passthrough and UNIDESK_SSH_HINT",
"provider triage recommendedCrossChecks keeps ssh D601 argv true",
],
};