feat: split k3s ssh routes from operations

This commit is contained in:
Codex
2026-05-25 06:24:17 +00:00
parent 64c82b5be8
commit 61492b4db5
5 changed files with 463 additions and 70 deletions
+18 -9
View File
@@ -19,11 +19,11 @@ 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``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 <route> [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:k3s``D601:k3s: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 <<'SCRIPT'``bun scripts/cli.ts ssh D601:k3s:<namespace>:<workload> script <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。需要在 pod 内改文件时优先使用 `D601:k3s:<namespace>:<workload> 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 <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`
- `ssh D601:k3s[:kubectl|:namespace:workload[:container]] ...` 是 D601 原生 k3s 结构化 route 入口,CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec 和 logs 参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号。
- `ssh D601:k3s[:namespace:workload[:container]] <operation> ...` 是 D601 原生 k3s 结构化 route 入口,route 只定位控制面或 workload`kubectl``logs``exec``script``apply-patch` 和普通容器命令作为 operation 放在 route 之后;CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec 和 logs 参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号。
- `microservice list/status/health/diagnostics/tunnel-self-test/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 或 k3s 控制面中的用户服务(底层命令名仍为 microservice);`health``status``diagnostics` 默认返回 compact summary、body 字节数和 `--full|--raw` 展开命令,只有小 body 或无法抽取 summary 时才带有界 body preview,避免 Code Queue/k3s 诊断一次性输出爆炸;`tunnel-self-test``proxy` 会走真实 backend-core -> provider-gateway 或 k3sctl-adapter -> 节点服务链路。`microservice health code-queue` 使用 commander-safe 专用摘要,必须保留 ok/status、service id、running count、queue count、heartbeat freshness/risk、split-brain/live/degraded 解释和 raw drill-down 命令;需要完整健康 JSON 时显式加 `--raw``--full`,等价深挖路径是 `microservice proxy code-queue /health --raw --full``proxy` 支持受控 JSON 请求体并对超大响应 body 默认输出有界预览,规则见 `docs/reference/microservices.md`
- `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;`decision list` 默认只返回摘要并省略完整 Markdown body,需要排查大正文时显式加 `--include-body`。正式文书字段通过 records 模型一等字段返回和查询:`--doc-no DC-...``--doc-type DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS``--doc-priority P0|P1|P2|P3``--year YYYY``--signer``--issued-at``--effective-scope``--supersedes``--superseded-by``show``requirement update` 可使用 `id``docNo``decision requirement list/create/upsert/update/show` 在同一 records 模型上管理 `goal|decision|blocker|debt|experiment` 需求记录,`docNo` 唯一,未传 `--doc-no` 但提供 `--doc-type/--doc-priority/--year` 时由服务分配下一个序号。它们不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。
- `decision diary import <markdown-file>` 将带 `# YYYY年M月D日``# YYYY-MM-DD``# YYYY/M/D` 标题的工作日志拆成每天一篇 Markdown 日记,按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径写入 Decision Center PostgreSQL`decision diary list/history` 默认只返回摘要,需要完整 Markdown 时显式加 `--include-body``decision diary show <YYYY-MM-DD|id> [--source-file path]` 查看单日正文,`--source-file` 用于同一天存在多个导入来源时精确选择;`decision diary edit|upsert <YYYY-MM-DD|id> --body-file <path> [--title text] [--source-file path] [--tag tag]` 通过 `PUT /api/diary/entries/:idOrDate` 创建当天或历史条目并编辑既有条目。
@@ -177,23 +177,32 @@ 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` 进入该对象;`D601:k3s:script:<namespace>:<workload>[:container]` 表示把本地 stdin 脚本送入目标 workload。若目标是具体 Podworkload 段写成 `pod/<podid>`若目标是 Deployment,也可以显式写 `deployment/<name>` 或简写 `<name>`
`ssh` 的 route 语法是 `provider:plane[:namespace:resource[:container]]`,只负责定位分布式目标,不表达操作。当前稳定 plane 是 `k3s``D601:k3s` 定位到 D601 原生 k3s 控制面;`D601:k3s:<namespace>:<workload>[:container]` 定位到 namespace 下的一个默认 deployment workload若目标是具体 Podworkload 段写成 `pod/<podid>`若目标是 Deployment,也可以显式写 `deployment/<name>` 或简写 `<name>``kubectl``logs``script``apply-patch``exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。
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 脚本执行。典型用法:
该入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制、容器命令stdin script 和 pod apply-patch 组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml``D601:k3s` 无后续参数时执行 native k3s guard`D601:k3s kubectl ...` 接收原始 kubectl argv`D601:k3s script` 执行带 native kubeconfig 的 D601 host stdin 脚本;`D601:k3s:<namespace>:<workload> logs` 读取有界日志;`D601:k3s:<namespace>:<workload> exec ...``D601:k3s:<namespace>:<workload> <command> ...` 进入目标 workload`D601:k3s:<namespace>:<workload> script` 把本地 stdin 作为 pod 内 shell 脚本执行`D601:k3s:<namespace>:<workload> apply-patch` 把本地标准 patch 作为 stdin 送入 pod 内 `apply_patch`。典型用法:
```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 kubectl get pods -n hwlab-dev
printf 'kubectl get deploy -n hwlab-dev\n' | bun scripts/cli.ts ssh D601:k3s script
bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api logs --tail 80
bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'
printf 'printf "pod=%s\n" "$HOSTNAME"\n' | bun scripts/cli.ts ssh D601:k3s:script:hwlab-dev:hwlab-cloud-api
printf 'printf "pod=%s\n" "$HOSTNAME"\n' | bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api script
bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'
*** Begin Patch
*** Update File: /tmp/example.txt
@@
-old
+new
*** End Patch
PATCH
```
route logs 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。route exec 不要求手写 `--`CLI 会把 route 后续 argv 放到 `kubectl exec --` 后;如果命令本身需要复杂 shell 语法,优先改用 route script,把脚本走 stdin,而不是把 `kubectl exec ... -- sh -c ...` 放进远端命令字符串。
`logs` operation 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。目标 route 后面直接跟普通命令时,CLI 会把 argv 放到 `kubectl exec --` 后;显式 `exec` operation 可用于让命令边界更清晰。如果命令本身需要复杂 shell 语法,优先改用 `script` operation,把脚本走 stdin,而不是把 `kubectl exec ... -- sh -c ...` 放进远端命令字符串。pod 内 `apply-patch` operation 临时注入的是 `sh` 版 helper,不要求目标容器自带 `python3``node` 或仓库里的工具脚本;它面向文本热修复,不用于大文件或二进制改写。
`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``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 传输到目标节点执行。
通过 `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:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <<EOF ...'` 形态;多层 shell 解析容易把 stdin 绑定到错误进程,结果会打开远端交互解释器并留下悬挂的 broker/SSH 会话。长脚本需要复用时,优先提交到 repo 或通过 stdin 传输到目标节点执行。
## Remote Main Server Passthrough
+4 -4
View File
@@ -48,14 +48,14 @@ If a manual repair is needed to unblock the platform, the durable fix must be co
Distributed runtime work should prefer structured CLI passthrough over ad-hoc nested shell strings. The standard escalation order is:
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`.
1. Use a purpose-built UniDesk route plus operation or helper such as `ssh D601:k3s kubectl ...`, `ssh D601:k3s script`, `ssh D601:k3s:<namespace>:<workload> logs`, `ssh D601:k3s:<namespace>:<workload> script`, `ssh D601:k3s:<namespace>:<workload> apply-patch`, `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. 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.
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:<namespace>:<workload> script` 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, `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.
For D601 Kubernetes work, route syntax is preferred over positional shell recipes, but the route must stay a pure locator. `D601:k3s` means the native k3s control plane, and `D601:k3s:<namespace>:<workload>[:container]` means a namespaced workload or pod. Operations come after the route: `kubectl` runs on the control plane, `logs` reads bounded workload logs, `script` streams a local heredoc/stdin script into the host or target pod, and `apply-patch` applies a standard patch inside the target pod. The route-operation split keeps distributed location and execution behavior independently extensible, fixes `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`, refuses long-follow logs, and assembles common `kubectl exec` / `kubectl logs` / stdin script / pod patch 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`, `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.
Longer scripts and patches should move across stdin (`ssh py`, `ssh script`, k3s `script` operation, k3s `apply-patch` operation, or `ssh apply-patch`) or as committed source followed by a short route command. Pod-level `apply-patch` must use the injected `sh` helper path instead of assuming target containers have `python3`, `node` or repository-local tools. 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.
+8 -6
View File
@@ -19,14 +19,14 @@ 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 <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", 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 <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--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 <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 <route> [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `D601:k3s:hwlab-dev:hwlab-cloud-api` only locates distributed targets." },
{ 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|: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 D601:k3s[:namespace:workload[:container]] <kubectl|logs|exec|script|apply-patch|command> ...", description: "Locate the D601 native k3s control plane or workload with route syntax, then run a separate operation 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." },
@@ -154,15 +154,17 @@ export function sshHelp(): unknown {
"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 kubectl get pods -n hwlab-dev",
"bun scripts/cli.ts ssh D601:k3s script <<'SCRIPT'",
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'",
"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",
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'",
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api logs --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 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.",
"Route syntax is provider:plane[:namespace:resource[:container]] and locates a distributed target only. For D601 native k3s, D601:k3s locates the control plane, D601:k3s:<namespace>:<workload> locates a workload, and kubectl/script/logs/apply-patch/exec are operations placed after the route.",
"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.",
],
+387 -42
View File
@@ -5,6 +5,8 @@ export interface ParsedSshArgs {
remoteCommand: string | null;
requiresStdin: boolean;
invocationKind: SshInvocationKind;
stdinPrefix?: string;
stdinSuffix?: string;
}
export type SshInvocationKind = "interactive" | "argv" | "helper" | "ssh-like";
@@ -38,11 +40,12 @@ export interface SshFailureHint {
const argvQuotedSshSubcommands = new Set(["rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]);
const d601NativeKubeconfig = "/etc/rancher/k3s/k3s.yaml";
const k3sRouteEntries = new Set([
const legacyK3sOperationRouteSegments = new Set([
"guard",
"kubectl",
"exec",
"script",
"apply-patch",
"logs",
"get",
"describe",
@@ -200,6 +203,266 @@ if __name__ == "__main__":
main()
`;
const remoteShApplyPatchSource = String.raw`#!/bin/sh
set -eu
die() {
printf 'apply_patch: %s\n' "$*" >&2
exit 1
}
mk_temp() {
mktemp "${"$"}{TMPDIR:-/tmp}/unidesk-apply-patch.XXXXXX" || exit 1
}
ensure_parent() {
case "$1" in
*/*)
dir=${"$"}{1%/*}
[ -n "$dir" ] || dir=/
mkdir -p "$dir"
;;
esac
}
read_text_preserve_newlines() {
marker="__UNIDESK_APPLY_PATCH_EOF_$$__"
text=$(cat "$1"; printf '%s' "$marker") || die "failed to read $1"
printf '%s' "${"$"}{text%"$marker"}"
}
write_file() {
target=$1
source=$2
ensure_parent "$target"
cp "$source" "$target" || die "failed to write $target"
}
replace_once() {
target=$1
search_file=$2
replace_file=$3
[ -e "$target" ] || die "file not found: $target"
marker="__UNIDESK_APPLY_PATCH_EOF_$$__"
old=$(cat "$target"; printf '%s' "$marker") || die "failed to read $target"
old=${"$"}{old%"$marker"}
search=$(cat "$search_file"; printf '%s' "$marker") || die "failed to read hunk search"
search=${"$"}{search%"$marker"}
replacement=$(cat "$replace_file"; printf '%s' "$marker") || die "failed to read hunk replacement"
replacement=${"$"}{replacement%"$marker"}
if [ -z "$search" ]; then
new=$replacement$old
else
case "$old" in
*"$search"*)
prefix=${"$"}{old%%"$search"*}
suffix=${"$"}{old#*"$search"}
new=$prefix$replacement$suffix
;;
*)
die "hunk context not found in $target"
;;
esac
fi
out=$(mk_temp)
printf '%s' "$new" > "$out" || die "failed to render patched file"
write_file "$target" "$out"
rm -f "$out"
}
apply_update() {
target=$1
body=$2
in_hunk=0
search_file=
replace_file=
changed=0
finish_hunk() {
[ "$in_hunk" = 1 ] || return 0
replace_once "$target" "$search_file" "$replace_file"
rm -f "$search_file" "$replace_file"
search_file=
replace_file=
in_hunk=0
changed=1
}
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"*** End of File"*)
continue
;;
"@@"*)
finish_hunk
search_file=$(mk_temp)
replace_file=$(mk_temp)
in_hunk=1
continue
;;
esac
[ "$in_hunk" = 1 ] || die "expected hunk header in $target"
case "$line" in
" "*)
text=${"$"}{line#?}
printf '%s\n' "$text" >> "$search_file"
printf '%s\n' "$text" >> "$replace_file"
;;
"-"*)
text=${"$"}{line#?}
printf '%s\n' "$text" >> "$search_file"
;;
"+"*)
text=${"$"}{line#?}
printf '%s\n' "$text" >> "$replace_file"
;;
"\\ No newline at end of file")
;;
*)
die "bad hunk line in $target: $line"
;;
esac
done < "$body"
finish_hunk
[ "$changed" = 1 ] || [ -e "$target" ] || die "file not found: $target"
}
is_top_header() {
case "$1" in
"*** Add File: "*|"*** Update File: "*|"*** Delete File: "*|"*** End Patch")
return 0
;;
*)
return 1
;;
esac
}
pushed=0
pushed_line=
next_patch_line() {
if [ "$pushed" = 1 ]; then
line=$pushed_line
pushed=0
pushed_line=
return 0
fi
if IFS= read -r line; then
return 0
fi
[ -n "${"$"}{line:-}" ]
}
push_patch_line() {
pushed_line=$1
pushed=1
}
parse_add_file() {
target=$1
[ ! -e "$target" ] || die "file already exists: $target"
tmp=$(mk_temp)
while next_patch_line; do
if is_top_header "$line"; then
push_patch_line "$line"
break
fi
case "$line" in
"+"*)
printf '%s\n' "${"$"}{line#?}" >> "$tmp"
;;
*)
rm -f "$tmp"
die "add file lines must start with + for $target"
;;
esac
done
write_file "$target" "$tmp"
rm -f "$tmp"
}
parse_update_file() {
target=$1
move_to=
body=$(mk_temp)
if next_patch_line; then
case "$line" in
"*** Move to: "*)
move_to=${"$"}{line#"*** Move to: "}
;;
*)
push_patch_line "$line"
;;
esac
fi
while next_patch_line; do
if is_top_header "$line"; then
push_patch_line "$line"
break
fi
case "$line" in
"*** Move to: "*)
rm -f "$body"
die "move marker must appear before update hunks"
;;
*)
printf '%s\n' "$line" >> "$body"
;;
esac
done
if [ -s "$body" ]; then
apply_update "$target" "$body"
elif [ -z "$move_to" ] && [ ! -e "$target" ]; then
rm -f "$body"
die "file not found: $target"
fi
rm -f "$body"
if [ -n "$move_to" ]; then
[ -e "$target" ] || die "file not found: $target"
[ ! -e "$move_to" ] || die "target file already exists: $move_to"
ensure_parent "$move_to"
mv "$target" "$move_to" || die "failed to move $target to $move_to"
fi
}
main() {
if [ "${"$"}{1:-}" = "-h" ] || [ "${"$"}{1:-}" = "--help" ]; then
printf 'apply_patch: sh-only helper; reads *** Begin Patch format from stdin\n'
return 0
fi
next_patch_line || die "patch must start with *** Begin Patch"
[ "$line" = "*** Begin Patch" ] || die "patch must start with *** Begin Patch"
while next_patch_line; do
case "$line" in
"*** End Patch")
printf 'Done!\n'
return 0
;;
"*** Add File: "*)
parse_add_file "${"$"}{line#"*** Add File: "}"
;;
"*** Delete File: "*)
target=${"$"}{line#"*** Delete File: "}
rm -f "$target" || die "failed to delete $target"
;;
"*** Update File: "*)
parse_update_file "${"$"}{line#"*** Update File: "}"
;;
*)
die "unexpected patch line: $line"
;;
esac
done
die "missing *** End Patch"
}
main "$@"
`;
const remoteGlobSource = String.raw`#!/usr/bin/env python3
import argparse
import glob
@@ -552,6 +815,7 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false, invocationKind: "helper" };
}
if (subcommand === "k3s") {
if (args[1] === "apply-patch") return buildK3sApplyPatchCommand(args.slice(2));
return { remoteCommand: buildK3sCommand(args.slice(1)), requiresStdin: false, invocationKind: "helper" };
}
if (argvQuotedSshSubcommands.has(subcommand)) {
@@ -598,19 +862,8 @@ export function parseSshRoute(target: string): ParsedSshRoute {
return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, raw: target };
}
if (plane !== "k3s") throw new Error(`unsupported ssh route plane: ${plane}`);
const [first, second, third, fourth, ...extra] = rest;
if (first && k3sRouteEntries.has(first)) {
if (extra.length > 0) throw new Error("ssh k3s command route supports at most provider:k3s:entry:namespace:resource:container");
return {
providerId,
plane: "k3s",
entry: first,
namespace: second && second.length > 0 ? second : null,
resource: third && third.length > 0 ? third : null,
container: fourth && fourth.length > 0 ? fourth : null,
raw: target,
};
}
const [first, second, third, fourth] = rest;
if (first && legacyK3sOperationRouteSegments.has(first)) throw new Error(k3sOperationInRouteMessage(target, first, second, third));
if (fourth !== undefined) throw new Error("ssh k3s target route supports at most provider:k3s:namespace:resource:container");
return {
providerId,
@@ -623,6 +876,14 @@ export function parseSshRoute(target: string): ParsedSshRoute {
};
}
function k3sOperationInRouteMessage(target: string, operation: string, namespace: string | undefined, resource: string | undefined): string {
if (operation === "kubectl") return `ssh k3s route must locate a target only; use "ssh D601:k3s kubectl ..." instead of "${target}"`;
if (operation === "script" && namespace === undefined) return `ssh k3s route must locate a target only; use "ssh D601:k3s script <<'SCRIPT'" instead of "${target}"`;
if (operation === "guard") return `ssh k3s route must locate a target only; use "ssh D601:k3s guard" or "ssh D601:k3s" instead of "${target}"`;
if (namespace !== undefined && resource !== undefined) return `ssh k3s route must locate a target only; use "ssh D601:k3s:${namespace}:${resource} ${operation} ..." instead of "${target}"`;
return `ssh k3s route must locate a target only; put operation "${operation}" after the route, for example "ssh D601:k3s ${operation} ..."`;
}
function shellArgv(args: string[]): string {
return args.map(shellQuote).join(" ");
}
@@ -729,41 +990,57 @@ function buildFindCommand(args: string[]): string {
function parseK3sRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
if (route.entry === null && route.namespace === null && route.resource === null) {
const k3sArgs = args.length === 0 ? ["guard"] : args;
return { remoteCommand: buildK3sCommand(k3sArgs), requiresStdin: false, invocationKind: "helper" };
}
if (route.entry === "guard") {
if (args.length > 0) throw new Error("ssh route D601:k3s:guard does not accept extra arguments");
return { remoteCommand: buildD601K3sGuardCommand(), requiresStdin: false, invocationKind: "helper" };
}
if (route.entry === "kubectl") {
if (args.length === 0) throw new Error("ssh route D601:k3s:kubectl requires kubectl arguments");
return { remoteCommand: buildK3sCommand(["kubectl", ...args]), requiresStdin: false, invocationKind: "helper" };
}
if (route.entry === "logs") {
return { remoteCommand: buildK3sLogsCommand([...k3sRouteTargetArgs(route), ...args]), requiresStdin: false, invocationKind: "helper" };
}
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" };
return parseK3sControlPlaneOperation(args);
}
if (route.namespace === null || route.resource === null) {
throw new Error("ssh k3s target route requires provider:k3s:<namespace>:<deployment|pod/resource>");
}
return parseK3sTargetOperation(route, args);
}
function parseK3sControlPlaneOperation(args: string[]): ParsedSshArgs {
const operation = args[0] ?? "guard";
if (operation === "apply-patch" || operation === "patch") {
throw new Error("ssh D601:k3s apply-patch requires a workload route: ssh D601:k3s:<namespace>:<workload> apply-patch");
}
if (operation === "script" || operation === "sh") {
return { remoteCommand: buildK3sScriptCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
}
if (operation === "guard") {
if (args.length > 1) throw new Error("ssh D601:k3s guard does not accept extra arguments");
return { remoteCommand: buildD601K3sGuardCommand(), requiresStdin: false, invocationKind: "helper" };
}
return { remoteCommand: buildK3sCommand(args), requiresStdin: false, invocationKind: "helper" };
}
function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
const targetArgs = k3sRouteTargetArgs(route);
if (args.length === 0) {
return {
remoteCommand: shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", "get", "-n", route.namespace, normalizeK3sRouteResource(route.resource), "-o", "wide"]),
remoteCommand: buildK3sTargetObjectCommand("get", route, ["-o", "wide"]),
requiresStdin: false,
invocationKind: "helper",
};
}
return { remoteCommand: buildK3sExecCommand([...k3sRouteTargetArgs(route), ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" };
const operation = args[0] ?? "";
const operationArgs = args.slice(1);
if (operation === "apply-patch" || operation === "patch") return buildK3sApplyPatchCommand([...targetArgs, ...operationArgs]);
if (operation === "script") return { remoteCommand: buildK3sScriptCommand([...targetArgs, ...operationArgs]), requiresStdin: true, invocationKind: "helper" };
if (operation === "logs") return { remoteCommand: buildK3sLogsCommand([...targetArgs, ...operationArgs]), requiresStdin: false, invocationKind: "helper" };
if (operation === "get" || operation === "describe") {
return { remoteCommand: buildK3sTargetObjectCommand(operation, route, operationArgs), requiresStdin: false, invocationKind: "helper" };
}
if (operation === "kubectl") throw new Error("ssh k3s kubectl is a control-plane operation; use ssh D601:k3s kubectl ...");
if (operation === "exec") {
return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(operationArgs)]), requiresStdin: false, invocationKind: "helper" };
}
return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" };
}
function buildK3sTargetObjectCommand(action: "get" | "describe", route: ParsedSshRoute, args: string[]): string {
if (route.namespace === null || route.resource === null) throw new Error(`ssh k3s ${action} target requires namespace and workload route`);
if (args.includes("--follow") || args.includes("-f")) throw new Error(`ssh k3s target ${action} does not support follow mode`);
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", action, "-n", route.namespace, normalizeK3sRouteResource(route.resource), ...args]);
}
function k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
@@ -853,8 +1130,9 @@ function buildK3sExecCommand(args: string[]): string {
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.namespace === null && parsed.resource === null) return buildK3sHostScriptCommand(parsed);
if (parsed.namespace === null) throw new Error("ssh k3s script target requires --namespace <name>");
if (parsed.resource === null) throw new Error("ssh k3s script target 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 = [
@@ -873,6 +1151,45 @@ function buildK3sScriptCommand(args: string[]): string {
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]);
}
function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs {
const parsed = parseK3sTargetOptions(args, "ssh k3s apply-patch", { requireCommand: false, allowCommand: true });
if (parsed.namespace === null) throw new Error("ssh k3s apply-patch requires --namespace <name>");
if (parsed.resource === null) throw new Error("ssh k3s apply-patch requires --deployment <name>, --pod <name> or --resource <type/name>");
if (parsed.tty) throw new Error("ssh k3s apply-patch does not support --tty; stdin is reserved for the patch body");
if (parsed.stdin) throw new Error("ssh k3s apply-patch does not accept --stdin; stdin is always the patch body");
if (parsed.shell !== null) throw new Error("ssh k3s apply-patch does not accept --shell");
if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s apply-patch does not accept kubectl log options");
const kubectlArgs = [
"exec",
"-i",
"-n", parsed.namespace,
parsed.resource,
...(parsed.container === null ? [] : ["-c", parsed.container]),
"--",
"sh",
"-s",
"--",
...parsed.command,
];
const wrapper = podApplyPatchStdinWrapper();
return {
remoteCommand: shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]),
requiresStdin: true,
invocationKind: "helper",
stdinPrefix: wrapper.prefix,
stdinSuffix: wrapper.suffix,
};
}
function buildK3sHostScriptCommand(parsed: K3sTargetOptions): string {
if (parsed.tty) throw new Error("ssh k3s script does not support --tty; stdin is reserved for the script body");
if (parsed.stdin) throw new Error("ssh k3s script does not accept --stdin; stdin is always the script body");
if (parsed.container !== null) throw new Error("ssh k3s script without a workload does not accept --container");
if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s script without a workload does not accept kubectl log options");
const shell = parsed.shell ?? "sh";
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, shell, "-s", "--", ...parsed.command]);
}
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>");
@@ -1026,6 +1343,25 @@ function buildShellStdinCommand(args: string[]): string {
return shellArgv([shell, "-s", "--", ...scriptArgs]);
}
function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } {
const toolMarker = "__UNIDESK_APPLY_PATCH_TOOL__";
const patchMarker = "__UNIDESK_APPLY_PATCH_PAYLOAD__";
if (remoteShApplyPatchSource.includes(toolMarker)) throw new Error("remote apply_patch source contains reserved heredoc marker");
return {
prefix: [
"set -eu",
'UNIDESK_SSH_TOOL_DIR="${UNIDESK_SSH_TOOL_DIR:-/tmp/unidesk-ssh-tools}"',
'mkdir -p "$UNIDESK_SSH_TOOL_DIR"',
`cat > "$UNIDESK_SSH_TOOL_DIR/apply_patch" <<'${toolMarker}'`,
remoteShApplyPatchSource.trimEnd(),
toolMarker,
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"',
`"$UNIDESK_SSH_TOOL_DIR/apply_patch" "$@" <<'${patchMarker}'`,
].join("\n") + "\n",
suffix: `\n${patchMarker}\n`,
};
}
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"';
@@ -1274,7 +1610,16 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
const rawMode = parsed.remoteCommand === null && process.stdin.isTTY;
if (rawMode) process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.pipe(child.stdin);
if (parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined) {
if (parsed.stdinPrefix) child.stdin.write(parsed.stdinPrefix);
process.stdin.pipe(child.stdin, { end: false });
process.stdin.once("end", () => {
if (parsed.stdinSuffix) child.stdin.write(parsed.stdinSuffix);
child.stdin.end();
});
} else {
process.stdin.pipe(child.stdin);
}
let stderrTail = "";
const appendStderrTail = (chunk: Buffer | string): void => {
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
+46 -9
View File
@@ -8,6 +8,17 @@ function assertCondition(condition: unknown, message: string, detail: unknown =
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function assertThrows(fn: () => unknown, pattern: RegExp, message: string): void {
try {
fn();
} catch (error) {
const text = error instanceof Error ? error.message : String(error);
assertCondition(pattern.test(text), message, { error: text });
return;
}
throw new Error(`${message}: expected throw`);
}
export function runSshArgvGuidanceContract(): JsonRecord {
const argv = parseSshArgs(["argv", "true"]);
assertCondition(argv.invocationKind === "argv", "argv subcommand must be classified as argv", argv);
@@ -32,18 +43,40 @@ export function runSshArgvGuidanceContract(): JsonRecord {
assertCondition(k3sExec.invocationKind === "helper", "k3s exec must be classified as helper", k3sExec);
assertCondition(k3sExec.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'node' '-e' 'console.log(process.version)'", "k3s exec must assemble kubectl argv without nested shell quoting", k3sExec);
const routeKubectl = parseSshInvocation("D601:k3s:kubectl", ["get", "pods", "-n", "hwlab-dev"]);
const routeKubectl = parseSshInvocation("D601:k3s", ["kubectl", "get", "pods", "-n", "hwlab-dev"]);
assertCondition(routeKubectl.providerId === "D601", "route must preserve provider id", routeKubectl);
assertCondition(routeKubectl.route.plane === "k3s" && routeKubectl.route.entry === "kubectl", "route must parse k3s kubectl entry", routeKubectl);
assertCondition(routeKubectl.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'get' 'pods' '-n' 'hwlab-dev'", "D601:k3s:kubectl must map to kubectl argv", routeKubectl);
assertCondition(routeKubectl.route.plane === "k3s" && routeKubectl.route.entry === null, "route must keep kubectl as an operation, not as a route entry", routeKubectl);
assertCondition(routeKubectl.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'get' 'pods' '-n' 'hwlab-dev'", "D601:k3s kubectl must map to kubectl argv", routeKubectl);
const routeTarget = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["node", "-e", "console.log(process.version)"]);
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 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 routeScript = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["script", "--shell", "bash", "--", "arg"]);
assertCondition(routeScript.parsed.requiresStdin === true, "k3s script operation 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:<namespace>:<workload> script must map stdin to shell -s", routeScript);
const routeControlScript = parseSshInvocation("D601:k3s", ["script", "--shell", "bash", "--", "arg"]);
assertCondition(routeControlScript.parsed.requiresStdin === true, "k3s control-plane script operation must stream local stdin", routeControlScript);
assertCondition(routeControlScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'bash' '-s' '--' 'arg'", "D601:k3s script must inject native kubeconfig without manual export", routeControlScript);
const routeApplyPatch = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["apply-patch"]);
assertCondition(routeApplyPatch.parsed.requiresStdin === true, "k3s apply-patch operation must stream local patch stdin", routeApplyPatch);
assertCondition(routeApplyPatch.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-i' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-s' '--'", "D601:k3s:<namespace>:<workload> apply-patch must enter pod with stdin", routeApplyPatch);
assertCondition(routeApplyPatch.parsed.stdinPrefix?.includes("apply_patch") && routeApplyPatch.parsed.stdinPrefix.includes("__UNIDESK_APPLY_PATCH_PAYLOAD__"), "k3s apply-patch operation must inject pod helper before patch stdin", routeApplyPatch);
assertCondition(!routeApplyPatch.parsed.stdinPrefix?.includes("python3") && !routeApplyPatch.parsed.stdinPrefix?.includes("node "), "k3s apply-patch operation must use the sh-only pod helper", routeApplyPatch);
assertCondition(routeApplyPatch.parsed.stdinSuffix === "\n__UNIDESK_APPLY_PATCH_PAYLOAD__\n", "k3s apply-patch operation must terminate patch heredoc", routeApplyPatch);
assertThrows(
() => parseSshInvocation("D601:k3s:kubectl", ["get", "pods"]),
/route must locate a target only.*ssh D601:k3s kubectl/u,
"operation names must not be accepted as k3s route segments",
);
assertThrows(
() => parseSshInvocation("D601:k3s:apply-patch:hwlab-dev:hwlab-cloud-api", []),
/route must locate a target only.*apply-patch/u,
"pod apply-patch must be an operation after the route",
);
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);
@@ -62,8 +95,10 @@ export function runSshArgvGuidanceContract(): JsonRecord {
const helpText = JSON.stringify(sshHelp());
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("ssh D601:k3s kubectl get pods -n hwlab-dev"), "ssh help must document k3s kubectl operation", helpText);
assertCondition(helpText.includes("ssh D601:k3s script <<'SCRIPT'"), "ssh help must document k3s control-plane script operation", helpText);
assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'"), "ssh help must document k3s pod apply-patch operation", helpText);
assertCondition(helpText.includes("ssh D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'"), "ssh help must document k3s script operation", helpText);
assertCondition(helpText.includes("UNIDESK_SSH_HINT"), "ssh help must document structured failure hint", helpText);
const crossChecks = providerTriageRecommendedCrossChecks("D601");
@@ -74,7 +109,9 @@ export function runSshArgvGuidanceContract(): JsonRecord {
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",
"pod apply-patch operation injects helper and forwards patch stdin",
"legacy operation-in-route forms are rejected with canonical route-plus-operation guidance",
"k3s route stays location-only while operations fix native kubeconfig and assemble kubectl exec as argv",
"ssh-like timeout/kex failures emit one structured argv retry hint",
"help text documents stdin script passthrough and UNIDESK_SSH_HINT",
"provider triage recommendedCrossChecks keeps ssh D601 argv true",