From b8fb3c41a821d89464997f67457fd0b2171a229c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 15 Jun 2026 04:01:04 +0000 Subject: [PATCH] fix: require explicit trans shell operations --- .agents/skills/unidesk-trans/SKILL.md | 52 +++--- docs/reference/cli.md | 72 ++++---- scripts/src/agentrun.ts | 42 ++--- scripts/src/ci.ts | 6 +- scripts/src/help.ts | 29 +-- scripts/src/hwlab-g14.ts | 52 +++--- scripts/src/hwlab-node-control-plane.ts | 2 +- scripts/src/hwlab-node.ts | 14 +- scripts/src/pk01-caddy.ts | 2 +- scripts/src/platform-db.ts | 6 +- scripts/src/platform-infra-langbot.ts | 10 +- scripts/src/platform-infra-n8n.ts | 10 +- scripts/src/platform-infra-ops-library.ts | 2 +- scripts/src/platform-infra-sub2api-codex.ts | 14 +- scripts/src/platform-infra-wechat-archive.ts | 10 +- scripts/src/platform-infra.ts | 12 +- scripts/src/secrets.ts | 4 +- scripts/src/ssh.ts | 175 ++++++++----------- 18 files changed, 241 insertions(+), 273 deletions(-) diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 41b96982..53ac28d8 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -1,6 +1,6 @@ --- name: unidesk-trans -description: UniDesk SSH 透传与 apply-patch 语法 — `trans ` 分布式执行入口,包含 route 语法、workspace/k3s/Windows 路由、apply-patch envelope 格式、script/py/upload/download operation 和 60s 短连接约束。用户提到 trans、tran、ssh 透传、远端执行、apply-patch、apply_patch、远端 patch、k3s route、workspace route 时使用。 +description: UniDesk SSH 透传与 apply-patch 语法 — `trans ` 分布式执行入口,包含 route 语法、workspace/k3s/Windows 路由、apply-patch envelope 格式、sh/bash/py/upload/download operation 和 60s 短连接约束。用户提到 trans、tran、ssh 透传、远端执行、apply-patch、apply_patch、远端 patch、k3s route、workspace route 时使用。 --- # UniDesk Trans(SSH 透传 + apply-patch) @@ -22,13 +22,13 @@ trans :[::[:]] [a ```bash trans G14:/root/hwlab git status --short --branch -trans G14:/root/hwlab-v02 script -- 'git fetch origin v0.2 && git pull --ff-only origin v0.2' -trans D601:/home/ubuntu/workspace/unidesk-dev script <<'SCRIPT' +trans G14:/root/hwlab-v02 sh -- 'git fetch origin v0.2 && git pull --ff-only origin v0.2' +trans D601:/home/ubuntu/workspace/unidesk-dev sh <<'SH' ... -SCRIPT +SH ``` -**规则**: workspace 路径写在 route 第一个 token,不写进 `cd` 串。反面示例:~~`trans G14 script -- 'cd /root/hwlab && git status'`~~ +**规则**: workspace 路径写在 route 第一个 token,不写进 `cd` 串。反面示例:~~`trans G14 sh -- 'cd /root/hwlab && git status'`~~ ### k3s route @@ -80,24 +80,32 @@ GitHub issue/PR 正文读写也走 `trans gh:/owner/repo/...`。常见读取优 ## Operation -### script(POSIX shell heredoc) +### sh / bash(显式 shell heredoc) ```bash -# heredoc(多行脚本) -trans G14:/root/hwlab script <<'SCRIPT' +# POSIX /bin/sh heredoc +trans G14:/root/hwlab sh <<'SH' echo "step 1" git status --short --branch echo "step 2" -SCRIPT +SH + +# Bash heredoc:只有用到 pipefail、数组、[[ ... ]]、${var:0:8} 等 Bash 语法时使用 +trans G14:/root/hwlab bash <<'BASH' +set -euo pipefail +short="${GITHUB_SHA:0:8}" +printf '%s\n' "$short" +BASH # one-liner -trans G14:/root/hwlab script -- 'git fetch origin G14 && git pull --ff-only origin G14' +trans G14:/root/hwlab sh -- 'git fetch origin G14 && git pull --ff-only origin G14' -# direct argv(单进程,带 -- 参数) -trans D601:/path script -- sed -n '1,20p' AGENTS.md +# 单进程 direct argv 不放在 sh/bash 下,直接用已知子命令或 argv +trans D601:/path sed -n '1,20p' AGENTS.md +trans D601:/path argv sed -n '1,20p' AGENTS.md ``` -`script` 只走 host/k3s POSIX shell。Windows PowerShell 必须用 `ps`。 +`script` 和 `shell` operation 已移除并会失败;必须在 operation 位置明确写 `sh` 或 `bash`,让调用者和 Agent 都知道脚本语法边界。Windows PowerShell 必须用 `ps`。 ### argv(单命令) @@ -293,18 +301,18 @@ PATCH ```bash # ✅ 异步启动 + 短轮询 -trans G14:/root/hwlab script -- 'hwlab-cli case run start d601-f103-v2-compile' -trans G14:/root/hwlab script -- 'hwlab-cli case run status ' +trans G14:/root/hwlab sh -- 'hwlab-cli case run start d601-f103-v2-compile' +trans G14:/root/hwlab sh -- 'hwlab-cli case run status ' # ❌ 长时间挂着等 -trans G14:/root/hwlab script -- 'long_running_command && wait' +trans G14:/root/hwlab sh -- 'long_running_command && wait' ``` ### HINT 信号 | stderr 输出 | 含义 | |-------------|------| -| `UNIDESK_SSH_HINT` | SSH 握手/超时摩擦,提示改用 stdin script | +| `UNIDESK_SSH_HINT` | SSH 握手/超时摩擦,提示改用 `sh`/`bash` stdin heredoc | | `UNIDESK_TRAN_TIMEOUT_HINT` | 60s 硬超时触发,提示改用短查询+轮询 | | `UNIDESK_SSH_TIMING` | 慢命令 warning(>10s),用于性能回归监控 | | `UNIDESK_APPLY_PATCH_TIMING` | apply-patch 耗时摘要 | @@ -319,25 +327,25 @@ trans G14:/root/hwlab script -- 'long_running_command && wait' ```bash # ✅ 推荐 -trans G14:/root/hwlab script <<'SCRIPT' +trans G14:/root/hwlab sh <<'SH' cmd1 cmd2 -SCRIPT +SH # ❌ 避免 -trans G14 script -- 'cmd1 && cmd2 && cmd3' +trans G14 sh -- 'cmd1 && cmd2 && cmd3' ``` ### 本地 shell 运算符陷阱 -`&&` / `;` / `|` 在 `trans` 后面会被本地 shell 截获。需要远端执行多条命令时用 `script` 包裹: +`&&` / `;` / `|` 在 `trans` 后面会被本地 shell 截获。需要远端执行多条命令时用 `sh` 或 `bash` 包裹: ```bash # ❌ 第二个 sed 在 master server 本地执行 trans G14:/root/hwlab sed -n '1,20p' a && sed -n '1,20p' b # ✅ 两个 sed 都在远端 -trans G14:/root/hwlab script -- 'sed -n "1,20p" a && sed -n "1,20p" b' +trans G14:/root/hwlab sh -- 'sed -n "1,20p" a && sed -n "1,20p" b' ``` ### Windows quoting diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2193145a..624fdf6a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -43,14 +43,14 @@ G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期 - `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`;该命令用于替代手工删除容器的兜底流程,其中 `dev-frontend-proxy` 只更新主 server dev 入口薄代理,`todo-note`、`code-queue-mgr`、`project-manager`、`baidu-netdisk` 和 `oa-event-flow` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。D601 Code Queue 执行面不由 `server rebuild` 管理;Rust backend-core 常规迭代不得用该命令在 master server 编译,只有明确的 backend-core 主 server 上线例外可以按限流、异步轮询和 health 证据执行,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`trans argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 - `platform-db postgres plan|status|export-secrets|apply --config config/platform-db/postgres-pk01.yaml` 管理 YAML 声明的 PK01 host-native PostgreSQL。`plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL;`apply --confirm` 默认创建本地异步 job,`apply --confirm --wait` 用远端 root-owned job 收敛 systemd PostgreSQL、TLS、`pg_hba`、role/database、Secret export 和备份 timer。输出不得打印密码或完整 `DATABASE_URL`。跨节点消费者使用 YAML 中的 `connectionHost` 直连 PK01 公网 endpoint,DNS alias 不作为 `sslmode=require` 切库 blocker,PK01 规则见 `docs/reference/pk01.md`。 -- `trans [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。WSL provider 的 Windows plane 固定使用 `win`,不得使用 `win32`;Windows operation 必须显式区分:`ps` 执行 Windows PowerShell heredoc 或一行 PowerShell 命令,`cmd` 执行 cmd.exe/batch,`skills` 发现 Windows skill 目录。需要 Windows cwd 时用 `trans D601:win/c/test ps` 或 `trans D601:win/c/test cmd cd`,CLI 自动设置 UTF-8/Python 编码默认值;`cmd` 额外设置 `chcp 65001`。非交互远端命令优先使用 `trans argv ...`;需要 POSIX shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `trans G14 script <<'SCRIPT'`、`trans G14:k3s script <<'SCRIPT'` 或 `trans G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin。`script` 只表示 host/k3s POSIX shell,不表示 Windows PowerShell;Windows PowerShell 必须写 `trans :win ps <<'PS'`。`script -- '<单个字符串>'` 是无需 stdin 的远端 POSIX shell one-liner,例如 `trans G14:/root/hwlab script -- 'cd /root/hwlab && git status --short --branch'`;`script -- <多个 argv>` 才是 direct argv,适合 `trans D601:/path script -- sed -n '1,20p' file` 这类带短横线的单进程命令。顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 ` apply-patch < patch.diff`;需要可靠传输非文本或整文件时使用 ` upload ` 和 ` download `,CLI 会按字节数与 SHA-256 自动校验并在 provider-gateway stdin/argv 限制下切换客户端分块策略;需要旧 helper 时显式使用 `:k3s:: apply-patch-v1` 或 ` apply-patch-v1`。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 +- `trans [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。WSL provider 的 Windows plane 固定使用 `win`,不得使用 `win32`;Windows operation 必须显式区分:`ps` 执行 Windows PowerShell heredoc 或一行 PowerShell 命令,`cmd` 执行 cmd.exe/batch,`skills` 发现 Windows skill 目录。需要 Windows cwd 时用 `trans D601:win/c/test ps` 或 `trans D601:win/c/test cmd cd`,CLI 自动设置 UTF-8/Python 编码默认值;`cmd` 额外设置 `chcp 65001`。非交互远端命令优先使用 `trans argv ...`;需要 POSIX shell 脚本、管道、变量或循环时必须在 operation 位置显式写 `sh` 或 `bash`,例如 `trans G14 sh <<'SH'`、`trans G14:k3s sh <<'SH'`、`trans G14:k3s:: sh <<'SH'` 或 `trans D601:/workspace bash <<'BASH'`。`sh` 明确表示目标 `/bin/sh`,`bash` 明确表示目标 Bash;`script` 和 `shell` operation 已移除并会失败,避免隐藏 shell 方言。Windows PowerShell 必须写 `trans :win ps <<'PS'`。一行远端 shell 逻辑使用 `sh -- '<单个字符串>'` 或 `bash -- '<单个字符串>'`;顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 ` apply-patch < patch.diff`;需要可靠传输非文本或整文件时使用 ` upload ` 和 ` download `,CLI 会按字节数与 SHA-256 自动校验并在 provider-gateway stdin/argv 限制下切换客户端分块策略;需要旧 helper 时显式使用 `:k3s:: apply-patch-v1` 或 ` apply-patch-v1`。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示改用 `sh`/`bash` stdin heredoc、argv 或 provider triage 交叉验证。 - `trans apply-patch < patch.diff` 是默认推荐的远端 patch 入口:本地 TypeScript line-based engine 解析和计算新文件内容,远端 route 只负责读写文件;支持 host workspace、k3s pod workspace、Windows workspace route(例如 `D601:win/c/test`)和 frontend transport,并优先处理长中文/Unicode、低上下文插入、重复块 `@@` 定位等旧 helper 容易失败的场景。`apply-patch` 输出按 Codex 标准文本口径,不套 UniDesk JSON 限制:成功 stdout 为 `Success. Updated the following files:`,失败 stdout 为空、stderr 写失败原因;多文件补丁中途失败时,stderr 只列出第一个失败前已成功执行的 hunk 和失败 hunk,随后按 Codex 语义停止,不继续尝试后续 hunk。v2 兼容常见 MiniMax/MXCX 非标准补丁输入,例如重复 nested `*** Begin Patch` / `*** End Patch` envelope、unified-diff hunk header、Add/Delete 误加 `@@`、Update context 漏掉前导空格,并在 stderr 给出 canonical 写法 hint;parser 或上下文失败时仍坚持唯一 v2 引擎,只提示修正 patch 文本或 hunk context,不自动重试或切换到 `apply-patch-v1`;大块/函数替换因上下文过期失败时,正确动作是重新读取当前目标块、缩小或拆分 Update File hunk 后继续用 `apply-patch`,不得改走 `download`/`upload`、远端 Python/Perl/sed heredoc 或整文件重写。Windows route 复用同一套 v2 核心算法,只把底层读写替换成 PowerShell 文件系统接口;`trans apply-patch-v1 [tool args...] < patch.diff` 保留为显式 legacy 入口,直接调用远端注入的 `apply_patch` sh/perl helper;默认 `apply-patch` 不把 v1 当 fallback。 - GitHub issue/PR 正文局部修补必须优先走 `trans gh:/owner/repo/issue/ apply-patch` 或 `trans gh:/owner/repo/pr/ apply-patch`,而不是人工优先整篇 `gh issue update --mode replace` 或底层 `gh issue patch`。`gh:/` 路由把 issue/PR body 暴露为一楼虚拟文件 `body.md`;route 未显式写 `/1` 时默认一楼正文,`apply_patch` 与 `patch-apply` 只作为兼容别名。典型补丁写法是 `*** Update File: body.md`;普通小补丁在已用 `trans gh:/... cat|rg|ls` 确认上下文后可以直接 apply,`--dry-run` 只作为高风险大段修改、关闭前验收文案或并发敏感正文的可选预览。写回仍通过 UniDesk `gh issue/pr update` guard 和 REST API,不使用原生 `gh`、手写 REST 或整篇 shell 拼接正文。单条 issue comment 的局部修补当前仍使用 `bun scripts/cli.ts gh issue comment patch --body-patch-stdin`,直到评论楼层 route 成为稳定入口。 - `apply-patch` v2 每次结束都会在 stderr 追加一行 `UNIDESK_APPLY_PATCH_TIMING {json}`,字段包含 `durationMs`、`patchBytes`、`fileCount`、`hunkCount`、`changedCount`、`remoteOperationCount`、`remoteOperationCounts`、`remoteElapsedMs`、`remoteFailureCount`、`providerId`、`route` 和 `transport`(可得时)。普通 POSIX host/k3s 和 Windows workspace 远端的多文件 `Update File` patch 会优先合并成 bulk read/write,避免每个文件单独 stat/read/write 的 SSH 往返;Add/Delete/Move 等复杂 patch 保持原有逐步语义。timing 摘要只用于定位慢在 patch 解析、远端 stat/read/write 或 bulk read/write、provider session 还是传输层,不能替代 Codex 标准 stdout/stderr 成功失败文本,也不是门禁或自动判断。 - `trans py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `trans skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 -- `trans :k3s[:namespace:workload[:container]] ...` 是原生 k3s 结构化 route 入口,route 只定位控制面或 workload,`kubectl`、`logs`、`exec`、`script`、`apply-patch`、旧 `apply-patch-v1` fallback 和普通容器命令作为 operation 放在 route 之后;CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec、logs 和 pod workspace 读写参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号;D601 与 G14 都有 provider-specific guard,分别校验 `d601` 和 G14 k3s 节点身份。 -- Code Queue runner 镜像必须在 PATH 上提供 `/usr/local/bin/tran`。runner 内的 `tran` 检测到 `CODE_QUEUE_*` 或 `KUBERNETES_SERVICE_HOST` 后,默认执行 `bun /root/unidesk/scripts/cli.ts --main-server-ip ssh ...`,其中 `` 优先来自 `UNIDESK_MAIN_SERVER_IP` / `UNIDESK_MAIN_SERVER_HOST` / `CODE_QUEUE_DEV_CONTAINER_MASTER_HOST`。runner remote frontend HTTP 客户端默认使用 `curl` 后端,降低 Bun 在部分 runner 内读取非 SSH HTTP response body 时触发 native crash 的风险;显式 `UNIDESK_REMOTE_HTTP_CLIENT=fetch` 可用于诊断。runner 内跨 D601/G14 的分布式访问应优先使用结构化 route/operation,例如 `tran D601 argv ...`、`tran G14 argv ...`、`tran D601:k3s kubectl ...`、`tran D601:k3s:: argv ...`、`tran G14:/absolute/workspace apply-patch ...` 和 `tran upload|download ...`;`apply-patch`、`upload`、`download`、`script`、`py` 和旧 `apply-patch-v1` fallback 经 frontend `/ws/ssh` 通道执行,stdout/stderr 也必须完整直通,不得退回 `/api/dispatch` task JSON。非 UniDesk admin runner 可通过 `UNIDESK_SSH_CLIENT_TOKEN` 使用 scoped `/ws/ssh` client token,frontend 侧用 `UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST` 约束允许 route;该 token 只授予 ssh passthrough,不下发 provider token、主 server SSH key 或完整 frontend 登录态。 +- `trans :k3s[:namespace:workload[:container]] ...` 是原生 k3s 结构化 route 入口,route 只定位控制面或 workload,`kubectl`、`logs`、`exec`、`sh`、`bash`、`apply-patch`、旧 `apply-patch-v1` fallback 和普通容器命令作为 operation 放在 route 之后;CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec、logs 和 pod workspace 读写参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号;D601 与 G14 都有 provider-specific guard,分别校验 `d601` 和 G14 k3s 节点身份。 +- Code Queue runner 镜像必须在 PATH 上提供 `/usr/local/bin/tran`。runner 内的 `tran` 检测到 `CODE_QUEUE_*` 或 `KUBERNETES_SERVICE_HOST` 后,默认执行 `bun /root/unidesk/scripts/cli.ts --main-server-ip ssh ...`,其中 `` 优先来自 `UNIDESK_MAIN_SERVER_IP` / `UNIDESK_MAIN_SERVER_HOST` / `CODE_QUEUE_DEV_CONTAINER_MASTER_HOST`。runner remote frontend HTTP 客户端默认使用 `curl` 后端,降低 Bun 在部分 runner 内读取非 SSH HTTP response body 时触发 native crash 的风险;显式 `UNIDESK_REMOTE_HTTP_CLIENT=fetch` 可用于诊断。runner 内跨 D601/G14 的分布式访问应优先使用结构化 route/operation,例如 `tran D601 argv ...`、`tran G14 argv ...`、`tran D601:k3s kubectl ...`、`tran D601:k3s:: argv ...`、`tran G14:/absolute/workspace apply-patch ...` 和 `tran upload|download ...`;`apply-patch`、`upload`、`download`、`sh`、`bash`、`py` 和旧 `apply-patch-v1` fallback 经 frontend `/ws/ssh` 通道执行,stdout/stderr 也必须完整直通,不得退回 `/api/dispatch` task JSON。非 UniDesk admin runner 可通过 `UNIDESK_SSH_CLIENT_TOKEN` 使用 scoped `/ws/ssh` client token,frontend 侧用 `UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST` 约束允许 route;该 token 只授予 ssh passthrough,不下发 provider token、主 server SSH key 或完整 frontend 登录态。 - `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 ` 将带 `# 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 [--source-file path]` 查看单日正文,`--source-file` 用于同一天存在多个导入来源时精确选择;`decision diary edit|upsert --body-file [--title text] [--source-file path] [--tag tag]` 通过 `PUT /api/diary/entries/:idOrDate` 创建当天或历史条目并编辑既有条目。 @@ -261,35 +261,35 @@ exec /root/unidesk/scripts/trans "$@" 主 server 上的人工/Codex 分布式敏捷操作必须直接写 `trans ...`,不要在 Codex 工具调用里退回完整 `bun scripts/cli.ts ssh ...` 前缀。例如 `trans D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch`、`trans D601:k3s kubectl get pods -n hwlab-dev` 或 `trans D601:k3s:hwlab-dev:hwlab-cloud-web exec --cwd /tmp -- pwd`。`tran` 是历史兼容 wrapper 和 runner 固化入口;新写长期参考、AGENTS 索引和 CLI help 时优先写 `trans ...`。 -`trans` 同样遵守 route/operation 解析器;route 后面的第一个 token 不是原生 ssh 命令字符串。不要写 `trans G14:/root/hwlab sh -lc '...'`,因为 `sh` 会被解析为 stdin script helper 的别名,`-lc` 会变成不受支持的 script 选项。带变量展开、管道、重定向或多条命令的远端逻辑,默认使用 `trans G14:/root/hwlab script <<'SCRIPT'`;默认 `script` 走目标节点 `/bin/sh`,并继承 provider-gateway/G14 已长期化的 proxy 环境。需要临时单步执行一行远端 shell 逻辑、且不想先创建脚本文件或 heredoc 时,优先使用 `trans G14:/root/hwlab script -- 'sed -n "1,20p" a && sed -n "1,20p" b'`,CLI 会把单个字符串放进目标节点的 `sh -c`,第二个 `sed`、管道和重定向都会留在远端;等价 `shell ''` 仍保留为显式 shell operation。`script` 和 `shell` helper 会在用户 shell 文本前注入一个极小的 POSIX 兼容 `printf` wrapper,使 `printf "--- section ---\n"` 这类高频排障分隔标题在 dash/sh 与 bash 下行为一致;direct argv 形态不注入该 wrapper。`script --` 后跟多个 token 时保持 direct argv,例如 `trans G14:/root/hwlab script -- sed -n '1,20p' AGENTS.md`。只有脚本确实使用 `pipefail`、数组、`[[ ... ]]` 等 bash 专有语义时才加 `--shell bash`,不能把 `--shell bash` 当作 proxy 修复手段。单进程命令才直接写成 argv,例如 `trans G14:/root/hwlab git status --short --branch`。遇到分布式开发摩擦时,优先补强 `trans`/`tran` 的 route/operation、stdin helper 或目标节点环境,并把稳定解法写回长期参考文档,不要退回多层 shell 字符串拼接。 +`trans` 同样遵守 route/operation 解析器;route 后面的第一个 token 不是原生 ssh 命令字符串。带变量展开、管道、重定向或多条命令的远端逻辑,默认使用 `trans G14:/root/hwlab sh <<'SH'`;`sh` 走目标节点 `/bin/sh`,并继承 provider-gateway/G14 已长期化的 proxy 环境。需要 Bash 专有语法,例如 `set -o pipefail`、数组、`[[ ... ]]` 或 `${var:0:8}` 子串展开时,必须把 operation 写成 `bash`,例如 `trans D601:/home/ubuntu/workspace/hwlab-v03 bash <<'BASH'`。需要临时单步执行一行远端 shell 逻辑、且不想先创建脚本文件或 heredoc 时,使用 `trans G14:/root/hwlab sh -- 'sed -n "1,20p" a && sed -n "1,20p" b'` 或 `bash -- ''`,CLI 会把单个字符串放进目标节点对应 shell 的 `-c`,第二个 `sed`、管道和重定向都会留在远端;`sh --` 与 `bash --` 只接受一个 shell command 字符串,多 argv 单进程命令必须走 `argv`、`exec` 或已知直接子命令。`script` 和 `shell` operation 已移除并会失败;不得用 `--shell bash` 或旧名绕过显式 shell 选择。`sh` 和 `bash` helper 会在用户 shell 文本前注入一个极小的 POSIX 兼容 `printf` wrapper,使 `printf "--- section ---\n"` 这类高频排障分隔标题在 dash/sh 与 bash 下行为一致;direct argv 形态不注入该 wrapper。单进程命令才直接写成 argv,例如 `trans G14:/root/hwlab git status --short --branch`。遇到分布式开发摩擦时,优先补强 `trans`/`tran` 的 route/operation、stdin helper 或目标节点环境,并把稳定解法写回长期参考文档,不要退回多层 shell 字符串拼接。 ### Standard Workspace-Prefixed Passthrough - 长期参考、AGENTS 索引、CLI help、Codex 任务脚本、CI/CD 排障和人工远端操作必须统一把已知的远端 workspace 写在 route 的第一个 token,而不是塞进 `cd` 串。`route` 段只表达分布式定位,operation 段才执行命令;workspace 路径是定位信息,不是命令。 -- 标准形态是 `trans :/absolute/workspace [args...]`:例如 `trans G14:/root/hwlab git status --short --branch`、`trans G14:/root/hwlab-v02 script -- 'git fetch origin v0.2 && git pull --ff-only origin v0.2'`、`trans D601:/home/ubuntu/workspace/unidesk-dev script <<'SCRIPT'`、`trans G14:/root/hwlab apply-patch < patch.diff` 和 `trans G14:/root/hwlab glob --root . --pattern 'web/hwlab-cloud-web/*.ts' --contains session-tabs`。 -- 反面形态必须删除或迁移:`trans G14 script -- 'cd /root/hwlab && git status --short --branch'`、`trans G14 script <<'SCRIPT' cd /root/hwlab-v02 git fetch origin v0.2 SCRIPT`、`tran G14 script -- 'cd /home/ubuntu/workspace/unidesk-dev && ...'`、`bun scripts/cli.ts ssh G14 -- 'cd /root/hwlab && ...'`。这些写法把已知 workspace 写进 command 字符串,破坏 route/operation 分离,引入本地 shell 二次解析、远端 cwd 漂移和并行 worktree 切换摩擦。 +- 标准形态是 `trans :/absolute/workspace [args...]`:例如 `trans G14:/root/hwlab git status --short --branch`、`trans G14:/root/hwlab-v02 sh -- 'git fetch origin v0.2 && git pull --ff-only origin v0.2'`、`trans D601:/home/ubuntu/workspace/unidesk-dev sh <<'SH'`、`trans D601:/home/ubuntu/workspace/hwlab-v03 bash <<'BASH'`、`trans G14:/root/hwlab apply-patch < patch.diff` 和 `trans G14:/root/hwlab glob --root . --pattern 'web/hwlab-cloud-web/*.ts' --contains session-tabs`。 +- 反面形态必须删除或迁移:`trans G14 sh -- 'cd /root/hwlab && git status --short --branch'`、`trans G14 sh <<'SH' cd /root/hwlab-v02 git fetch origin v0.2 SH`、`tran G14 sh -- 'cd /home/ubuntu/workspace/unidesk-dev && ...'`、`trans G14 script -- '...'`、`trans G14 shell '...'`、`bun scripts/cli.ts ssh G14 -- 'cd /root/hwlab && ...'`。这些写法把已知 workspace 写进 command 字符串,破坏 route/operation 分离,引入本地 shell 二次解析、远端 cwd 漂移和并行 worktree 切换摩擦,或继续使用已移除的模糊 shell 旧名。 - 例外只限于一次性探测、临时 heredoc 草稿或旧文档复用;任何被复用第二次的 `cd && ...` 都必须重写成 `trans :/absolute/workspace` 形式。 -- 当远端存在多个并行 workspace(例如 `G14:/root/hwlab` 与 `G14:/root/hwlab-v02`)时,route 必须显式带 workspace,CLI 的 `pwd` 输出、后续 `apply-patch` 的相对路径和 `script` 的 cwd 全部跟随该 workspace;切换 workspace 必须切换 route,不允许在同一次 `trans` 链里再 `cd`。 +- 当远端存在多个并行 workspace(例如 `G14:/root/hwlab` 与 `G14:/root/hwlab-v02`)时,route 必须显式带 workspace,CLI 的 `pwd` 输出、后续 `apply-patch` 的相对路径和 `sh`/`bash` 的 cwd 全部跟随该 workspace;切换 workspace 必须切换 route,不允许在同一次 `trans` 链里再 `cd`。 - 本规则覆盖所有 host workspace 形态,包括 `G14:/root/hwlab`、`G14:/root/hwlab-v02`、`G14:/root/agentrun-v01`、`D601:/home/ubuntu/workspace/unidesk-dev`、`D601:/home/ubuntu/workspace/hwlab-dev`;provider-gateway 侧已经把它们注册为 host workspace route。 - 与 `k3s` route 的分工不变:定位控制面继续写 `trans G14:k3s`、定位 workload/container 继续写 `trans G14:k3s::[:]`;pod/container 内 cwd 用 operation 参数 `--cwd /path`,或在已经明确选出 container 后才使用 `/path` route 后缀表达文件系统位置。host workspace 路径里的 `cd` 才需要被替换,控制面或 pod 内的多层 shell 不在本规则的清理范围。 非交互 `ssh`/`trans`/`tran` 不是登录 shell,不能依赖 `.bashrc`、`.profile` 或交互 alias。 -CLI 会在 Host route、workspace route、k3s 控制面脚本和 pod route 的 shell/script helper +CLI 会在 Host route、workspace route、k3s 控制面脚本和 pod route 的 `sh`/`bash` helper 前统一注入用户级工具 PATH:`$HOME/.bun/bin`、`$HOME/.local/bin`、`$HOME/bin` 和 `/root/.bun/bin`。因此 G14 这类节点只要已经安装了 `/root/.bun/bin/bun`, -`trans G14:/root/hwlab-v02 script -- 'bun --version'` 应该直接可用, +`trans G14:/root/hwlab-v02 sh -- 'bun --version'` 应该直接可用, 不需要在任务里硬写绝对路径。direct argv 不经过 shell 初始化;如果某个 direct argv 工具找不到, -优先改用 `script -- ''` 或补强 CLI 的 argv PATH 处理, +优先改用 `sh -- ''` / `bash -- ''` 或补强 CLI 的 argv PATH 处理, 不要在业务脚本里长期散落绝对路径 workaround。 -本地 shell 运算符不是 `trans` 可以拦截的内容。`trans G14:/root/hwlab sed -n '1,20p' AGENTS.md && sed -n '1,20p' docs/reference/g14.md` 会先由 master server 的本地 shell 拆成两个命令,只有第一个 `sed` 进入 G14,第二个 `sed` 会在 master server 当前目录执行。需要把两个命令都放到目标节点时,必须写成 `trans G14:/root/hwlab script -- 'sed -n "1,20p" AGENTS.md && sed -n "1,20p" docs/reference/g14.md'`,或者用 `trans G14:/root/hwlab script <<'SCRIPT'` 把多行脚本送到远端。 +本地 shell 运算符不是 `trans` 可以拦截的内容。`trans G14:/root/hwlab sed -n '1,20p' AGENTS.md && sed -n '1,20p' docs/reference/g14.md` 会先由 master server 的本地 shell 拆成两个命令,只有第一个 `sed` 进入 G14,第二个 `sed` 会在 master server 当前目录执行。需要把两个命令都放到目标节点时,必须写成 `trans G14:/root/hwlab sh -- 'sed -n "1,20p" AGENTS.md && sed -n "1,20p" docs/reference/g14.md'`,或者用 `trans G14:/root/hwlab sh <<'SH'` 把多行脚本送到远端;需要 Bash 专有语法时把 operation 改成 `bash`。 `trans`/`tran` 不做本地 provider/plane 串行锁;本地目录锁不是 G14 原生 k3s/Tekton/GitOps 的业务协调机制,stale lock 会阻塞所有后续短查询。以后不要在 wrapper 里恢复本地锁。业务并发、发布互斥和 rollout 协调必须交给 k8s/Tekton/Argo/Lease 等原生运行面机制;若 provider session allocator 需要限流,应在服务端实现带 TTL 的队列或 lease,而不是在客户端加目录锁。 非交互 `trans`/`tran`/`ssh` 有最外层运行时硬超时,默认和最大值都是 60 秒;`UNIDESK_TRAN_RUNTIME_TIMEOUT_SECONDS`、`UNIDESK_TRAN_RUNTIME_TIMEOUT_MS` 或 `UNIDESK_SSH_RUNTIME_TIMEOUT_MS` 只能把超时调小,不能调大超过 60 秒。到点后 wrapper、backend-core broker 或 frontend websocket 路径会主动断开并在 stderr 输出 `UNIDESK_TRAN_TIMEOUT_HINT` 或 `UNIDESK_SSH_RUNTIME_TIMEOUT`,提示改用短查询加轮询。长时间 CI/CD、Tekton/Argo 观察、trace/result、日志 tail、构建下载和硬件任务都必须按 submit-and-poll/短查询语义拆成多次 `trans` 调用;不得让单个 `trans` 挂着等待最终完成。本规则的跟踪 issue 是 [pikasTech/unidesk#187](https://github.com/pikasTech/unidesk/issues/187)。 -长任务的标准形态是:用一次短 `trans script` 启动目标侧 job、后台进程、PipelineRun 或受控命令,并把 job id、日志路径、状态文件或 Kubernetes 对象名写到目标侧;后续用多次短 `trans` 查询 `status`、`logs --tail`、`kubectl get/describe`、trace result 或工具自带 job-status。不要为了观察 Docker build、镜像 push、Keil 下载、串口抓取或 Code Agent turn,把 `trans` 一直挂到结束;超过 60 秒的断开只说明调用方式需要改成轮询,不应立即归因成 provider session、CI/CD 或硬件失败。 +长任务的标准形态是:用一次短 `trans sh` 或 `trans bash` 启动目标侧 job、后台进程、PipelineRun 或受控命令,并把 job id、日志路径、状态文件或 Kubernetes 对象名写到目标侧;后续用多次短 `trans` 查询 `status`、`logs --tail`、`kubectl get/describe`、trace result 或工具自带 job-status。不要为了观察 Docker build、镜像 push、Keil 下载、串口抓取或 Code Agent turn,把 `trans` 一直挂到结束;超过 60 秒的断开只说明调用方式需要改成轮询,不应立即归因成 provider session、CI/CD 或硬件失败。 HWLAB `hwlab-cli client agent` 端到端验证经 UniDesk `trans`/`tran`/`ssh` 进入 G14 时,也按短连接 submit-and-poll 执行:在目标 workspace 和锁定 runtime env 下先 `agent send` 提交 turn,避免在可能超过 60 秒的路径上使用 `--wait` 长挂;随后多次短查询 `agent result `、`agent trace --render web` 或 `agent inspect --trace-id ` 取证。关闭 context-loss、AgentRun 复用或多轮会话类 issue 时,证据至少应记录 `conversationId`、`sessionId`、`threadId`、`traceId`、`runId`、`commandId` 和关键 event label。需要验证“第二轮继承第一轮上下文”时,显式传入同一 conversation/session/thread 标识,不能依赖旧 Cloud Web workspace 历史或人工印象;`--no-workspace` 这类绕过恢复的实验只可作为定位证据,不能替代默认入口验收。 @@ -297,7 +297,7 @@ HWLAB Code Agent provider profile 的 `config.toml`、完整 Codex `auth.json` HWLAB Cloud Web Workbench 或 Code Agent 装配类 issue 的 CLI 验证必须贴近 Web 路径:优先使用会调用同一 Cloud API/Web dispatcher 的正式 `hwlab-cli client agent` 或等价 UniDesk 高层 CLI,再从 trace/inspect/result 中确认 runner job、AgentRun runtime assembly、`transientEnv`、`runId` 和 `commandId`。当前 HWLAB v0.2 资源装配需求以 UniDesk OA 的 [Runtime装配](../../project-management/PJ2026-01/specs/PJ2026-010202-runtime-assembly.md) 和 [HWLAB接入](../../project-management/PJ2026-01/specs/PJ2026-010205-hwlab-dispatch.md) 为权威:`ResourceBundleRef.kind="gitbundle"`,通过 `bundles[]` 装配 `tools/` 与 `.agents/skills`;不要在本 CLI 文档重复维护旧字段清单。直接调用 AgentRun manager、手写 `dispatchHwlabAgentRun()` 或临时 runner job 只可作为基础设施 canary;它不能替代 Web Workbench 原入口验收,也不能作为关闭 Web issue 的唯一证据。若缺少这种同路径 CLI,先补 CLI 可见性和 submit-and-poll 入口,再继续修复或关闭 issue。 -`trans D518` 应表现为登录 D518 WSL 的 shell;`trans D518 hostname` 应像 `ssh D518 hostname` 一样只输出远端命令结果并返回远端 exit code。Provider ID 前的目标选择由 UniDesk 节点清单决定,`-p`、`-i`、`-l`、`-o` 等传统 ssh 传输参数由 provider-gateway 部署配置统一管理,CLI 会兼容性消费这些参数但不会覆盖节点侧维护桥配置。指挥官、CI 预检和其他非交互流程不要依赖 ssh-like 自由拼接;单进程标准写法是 `trans D601 argv true`,多行 shell 逻辑标准写法是 quoted heredoc 单步调用 `trans D601 script <<'SCRIPT'`。 +`trans D518` 应表现为登录 D518 WSL 的 shell;`trans D518 hostname` 应像 `ssh D518 hostname` 一样只输出远端命令结果并返回远端 exit code。Provider ID 前的目标选择由 UniDesk 节点清单决定,`-p`、`-i`、`-l`、`-o` 等传统 ssh 传输参数由 provider-gateway 部署配置统一管理,CLI 会兼容性消费这些参数但不会覆盖节点侧维护桥配置。指挥官、CI 预检和其他非交互流程不要依赖 ssh-like 自由拼接;单进程标准写法是 `trans D601 argv true`,多行 shell 逻辑标准写法是 quoted heredoc 单步调用 `trans D601 sh <<'SH'`。 UniDesk CLI/trans/tran 客户端改进本身是 master server 高频控制入口维护,可以直接在 `/root/unidesk` 轻量开发、提交并推送 `origin/master`;不要为这类客户端小改强制迁移到 D601 worktree。该例外不改变 master server 禁重型验证规则:仓库级 check、Playwright/browser smoke、镜像构建、Rust/Go 编译、Code Queue runner 实测仍必须在 D601、CI runner 或目标运行面执行,backend-core 主 server 上线受控编译例外按 `docs/reference/dev-environment.md` 单独处理。若 `trans`/`tran`/SSH 文件传输遇到 provider-gateway 单次 stdin、argv 或 stdout 限制,先在 CLI 客户端做分块、SHA-256 校验、失败可观测输出和最小真实闭环;只有 client 侧不能解决且有证据时,才改 provider-gateway。文件传输默认按 1MiB raw chunk 读写,避免 100MiB 大文件被 45KiB 小块和 base64 全量暂存拖慢;`upload`/`download` 成功 JSON 中的 `verified=true`、`verification.automatic=true`、`verification.verified=true` 和 `verification.match.{bytes,sha256}=true` 就是端到端完整性证明,调用方不需要再额外手写 `sha256sum` 比对。 @@ -307,11 +307,11 @@ core 只允许声明了 `host.ssh` capability 的 provider 使用 `host.ssh` dis 本地 broker 默认等待 provider SSH 会话打开 60000ms,以便在目标节点同时有较多 microservice.http 任务时仍能建立维护会话;需要诊断慢连接时可用 `UNIDESK_SSH_OPEN_TIMEOUT_MS=` 临时调大,但最小有效值固定为 15000ms,避免把真实离线误判为长时间阻塞。注意 open timeout 只控制“会话打开”阶段,不能绕过 60 秒最外层运行时硬超时。 -ssh-like 远端命令如果出现 `kex_exchange_identification`、`Connection closed by remote host`、provider session timeout 或 exit code 255,CLI 会在原始 stderr 后追加一行 `UNIDESK_SSH_HINT { ... }`。该 JSON 不回显原始远端命令,只包含 `code=ssh-like-command-friction`、`trigger`、`try` 和 `triage`;`try` 固定指向 stdin script 形态,避免把一次 ssh-like 解析/握手摩擦误读成 D601 SSH 整体不可用。`ssh`/`trans`/`tran` 在失败路径识别到 tcp-pool 数据面问题时会追加 `UNIDESK_SSH_TCP_POOL_HINT { ... }`,`failureKind` 固定分为 `provider-data-channel-closed`、`provider-data-channel-missing` 和 `provider-data-pool-exhausted`;这类 hint 表示 transport/data-pool transient,幂等受控操作应先运行 `bun scripts/cli.ts debug ssh-pool ` 查看 labels,再重试原受控 CLI,不能单独定性为远端 runtime 配置失败。backend-core/provider 控制面返回结构化 `ssh.error` 时 broker 还会输出 `UNIDESK_SSH_ERROR { ... }`,供 `job status` 从旧日志或异步 job 日志中恢复 failureKind。`ssh`/`trans`/`tran` 运行时硬超时会输出 `UNIDESK_SSH_RUNTIME_TIMEOUT { ... }` 或 wrapper 层 `UNIDESK_TRAN_TIMEOUT_HINT { ... }`;这不是远端业务失败,而是调用方需要改成短查询/轮询。`ssh`/`trans`/`tran` 只有在运行耗时超过默认 10000ms 时才会在 stderr 追加一行 `UNIDESK_SSH_TIMING { ... }`,且 `level=warning`;正常短调用不输出 timing 噪声。慢成功命令也必须保留该 warning,因为它是 provider session、远端命令成本、helper bootstrap 和 `trans`/`tran`/远端 patch 性能回归的重要监控信号。warning 包含 `elapsedMs`、`elapsedSeconds`、`transport`、`invocationKind` 和 `exitCode`,提示优先排查 provider/session 延迟、远端命令自身耗时、helper bootstrap 或工具层回归。阈值可用 `UNIDESK_SSH_SLOW_WARNING_MS=` 临时调节,提示同样不回显原始远端命令。 +ssh-like 远端命令如果出现 `kex_exchange_identification`、`Connection closed by remote host`、provider session timeout 或 exit code 255,CLI 会在原始 stderr 后追加一行 `UNIDESK_SSH_HINT { ... }`。该 JSON 不回显原始远端命令,只包含 `code=ssh-like-command-friction`、`trigger`、`try` 和 `triage`;`try` 固定指向显式 `sh` stdin heredoc 形态,避免把一次 ssh-like 解析/握手摩擦误读成 D601 SSH 整体不可用。`ssh`/`trans`/`tran` 在失败路径识别到 tcp-pool 数据面问题时会追加 `UNIDESK_SSH_TCP_POOL_HINT { ... }`,`failureKind` 固定分为 `provider-data-channel-closed`、`provider-data-channel-missing` 和 `provider-data-pool-exhausted`;这类 hint 表示 transport/data-pool transient,幂等受控操作应先运行 `bun scripts/cli.ts debug ssh-pool ` 查看 labels,再重试原受控 CLI,不能单独定性为远端 runtime 配置失败。backend-core/provider 控制面返回结构化 `ssh.error` 时 broker 还会输出 `UNIDESK_SSH_ERROR { ... }`,供 `job status` 从旧日志或异步 job 日志中恢复 failureKind。`ssh`/`trans`/`tran` 运行时硬超时会输出 `UNIDESK_SSH_RUNTIME_TIMEOUT { ... }` 或 wrapper 层 `UNIDESK_TRAN_TIMEOUT_HINT { ... }`;这不是远端业务失败,而是调用方需要改成短查询/轮询。`ssh`/`trans`/`tran` 只有在运行耗时超过默认 10000ms 时才会在 stderr 追加一行 `UNIDESK_SSH_TIMING { ... }`,且 `level=warning`;正常短调用不输出 timing 噪声。慢成功命令也必须保留该 warning,因为它是 provider session、远端命令成本、helper bootstrap 和 `trans`/`tran`/远端 patch 性能回归的重要监控信号。warning 包含 `elapsedMs`、`elapsedSeconds`、`transport`、`invocationKind` 和 `exitCode`,提示优先排查 provider/session 延迟、远端命令自身耗时、helper bootstrap 或工具层回归。阈值可用 `UNIDESK_SSH_SLOW_WARNING_MS=` 临时调节,提示同样不回显原始远端命令。 非交互 `ssh`/`trans`/`tran` 远端命令的流式 stdout 默认有本地输出上限,避免远端日志、PowerShell JSON 或错误对象一次性输出过大导致上下文被淹没;交互登录 shell 不套该上限。超过上限时,CLI 只继续读取远端流并把完整内容写入 `/tmp/unidesk-cli-output/*.stdout.bin`,本地 stderr 追加 `UNIDESK_SSH_STDOUT_TRUNCATED { ... }`,其中包含 `thresholdBytes`、`observedBytesAtTruncation`、`dumpPath` 和 `dumpError`;stdout 本身只保留上限内的开头内容。默认上限是 256KiB,可用 `UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES=` 或 `UNIDESK_TRAN_STDOUT_STREAM_MAX_BYTES=` 临时调整,最小 4KiB,最大 16MiB。该机制只做渐进披露和完整 dump,不替代远端命令失败判断;看到该 hint 时应优先改成 `tail`、分页或更窄的结构化查询。 -`trans ` 透传只在当前 operation 需要 helper 时才注入 `/tmp/unidesk-ssh-tools`,普通 `argv`、`script`、`kubectl`、`logs` 和默认 `apply-patch` 等路径不得传输无关工具源码。`apply-patch-v1` 只注入 `apply_patch`;`glob` 只注入 `glob`;`skills`/`skill discover` 只注入 `skill-discover`。`apply_patch` 接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;远端存在 `perl` 时必须走快速精确匹配路径,避免大文件 hunk 被 sh 模式匹配拖成几十秒,缺少 `perl` 时才退回 sh-only 实现。`glob` 和 `skill-discover` 需要远端 `python3`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库。 +`trans ` 透传只在当前 operation 需要 helper 时才注入 `/tmp/unidesk-ssh-tools`,普通 `argv`、`sh`/`bash`、`kubectl`、`logs` 和默认 `apply-patch` 等路径不得传输无关工具源码。`apply-patch-v1` 只注入 `apply_patch`;`glob` 只注入 `glob`;`skills`/`skill discover` 只注入 `skill-discover`。`apply_patch` 接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;远端存在 `perl` 时必须走快速精确匹配路径,避免大文件 hunk 被 sh 模式匹配拖成几十秒,缺少 `perl` 时才退回 sh-only 实现。`glob` 和 `skill-discover` 需要远端 `python3`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库。 远端文本 patch 默认使用 `apply-patch` 的 v2 引擎:它不把 hunk 解析交给远端 shell/perl helper,而是在本地按行序列匹配,支持长中文/Unicode 行、纯新增 hunk、低上下文插入和 `@@` 上下文定位,再把完整新内容写回远端。v2 的文件操作提交顺序按 Codex 标准 `apply_patch` 语义执行:空 patch 会失败;删除不存在的文件会失败;`Add File` 可覆盖已有文件;`Move to` 可覆盖目标文件;当大 patch 后续 hunk 不匹配时,已成功提交的前序文件操作会保留,并在错误详情中记录 `partialChanges`,调用方应基于当前文件内容继续补一个更小的 patch,而不是期待全量事务回滚。若 stderr 报 `failed to find expected lines` 且显示 partial context match,尤其是大块/函数替换,调用方必须先重读目标文件当前块,再用更少稳定上下文、`@@ ` 或多个小 hunk 重试;该失败不构成改用 `download`/`upload`、远端脚本整文件替换或 `apply-patch-v1` 的理由。`apply_patch` 旧 helper 默认拒绝低上下文 update hunk:空搜索/纯插入无锚点、只在插入点前有上下文而没有插入点后上下文、或同一 hunk search 在目标文件中匹配多个位置时,都会结构化失败并提示补充上下文。成功应用时每个 hunk 会在 stderr 输出 `apply_patch: hunk N matched path:line`,用于复核实际落点;只有人工确认确实需要旧 helper 行为或 `--allow-loose` 时,才显式调用 `apply-patch-v1 --allow-loose`。 @@ -364,29 +364,19 @@ printf 'import sys\nprint(sys.argv)\n' | trans D601 py foo '--bar=baz' `trans py` 的附加参数是脚本参数,不是 Python 解释器参数;如需 `-m`、`-X` 或多条 shell 命令,仍使用原始远端命令入口。为了保证 CLI 输出及时可见,helper 固定采用“临时文件 + `python3 -u`”模式;provider 命令模式不分配 TTY,因此脚本内容不应被远端回显。 -如果远端逻辑需要 shell 特性,不要再把整段脚本作为原生 ssh-like 命令字符串传入。正式入口是 -`trans D601 script`,脚本正文从 stdin 进入;CLI 会把本地 stdin 直接送到远端 -`sh -s --`,`--shell bash` 可切换为 bash,`--` 后的内容会作为脚本参数传入。 -`script`/`shell` helper 会在用户脚本文本前注入用户级工具 PATH 和兼容前缀, -让 `bun`、`tsx` 等用户级工具在非交互 shell 中可见,也让 `printf "--- section ---\n"` -这类分隔标题不再因目标 `/bin/sh` 方言失败;已有 `printf '%s\n' value`、`printf -- ...` -和 bash 的 `printf -v` 仍按原语义工作。临时单步执行优先用 quoted heredoc; -只有命令很短、明确希望一行内完成时才用 `script -- ''`, -它会把单个字符串按远端 shell one-liner 执行且不等待 stdin; -复用脚本时才用 `< script.sh` 文件重定向。`script -- <多个 argv>` 仍是 direct argv, -不经过远端 shell,适合 `script -- sed -n '1,20p' file`。典型用法: +如果远端逻辑需要 shell 特性,不要再把整段脚本作为原生 ssh-like 命令字符串传入。正式入口必须在 operation 位置显式声明 shell 方言:`trans D601 sh` 表示目标 `/bin/sh`,`trans D601 bash` 表示目标 Bash。脚本正文从 stdin 进入;operation 后的普通参数会作为脚本参数传入。`sh`/`bash` helper 会在用户脚本文本前注入用户级工具 PATH 和兼容前缀,让 `bun`、`tsx` 等用户级工具在非交互 shell 中可见,也让 `printf "--- section ---\n"` 这类分隔标题不再因目标 `/bin/sh` 方言失败;已有 `printf '%s\n' value`、`printf -- ...` 和 bash 的 `printf -v` 仍按原语义工作。临时单步执行优先用 quoted heredoc;只有命令很短、明确希望一行内完成时才用 `sh -- ''` 或 `bash -- ''`,它会把单个字符串按所选远端 shell one-liner 执行且不等待 stdin;`sh --` / `bash --` 后出现多个 argv token 会失败,单进程命令必须改用 `argv` 或直接子命令;复用脚本时才用 `< script.sh` 文件重定向。`script` 和 `shell` operation 已移除并会失败,不能用 `--shell bash` 或旧名绕过显式 shell 选择。典型用法: ```bash -cat <<'SCRIPT' | trans D601 script --shell bash -- alpha +cat <<'BASH' | trans D601 bash alpha set -euo pipefail printf 'arg=%s\n' "$1" hostname -SCRIPT +BASH ``` 这个入口的目标是分布式调试的“0 shell-command-string”路径:本地 shell 只负责 heredoc/stdin,UniDesk 只负责 provider 路由,远端 shell 只解释脚本正文。脚本正文里仍然要遵守 shell 语言自身的规则,但不再穿过本地 shell、远端 shell、kubectl exec 和容器 shell 的多重字符串转义。 -`trans shell ''` 是一行远端 shell 逻辑的逃生阀,不取代 `script`。它的输入必须作为一个 quoted argv 到达 CLI,适合 `sed ... && sed ...`、`kubectl get ... | head` 或一次性环境探测;它仍然只穿过一次目标 shell,不能解决本地 shell 已经拆开的外层 `&&`、`|`、`>`。k3s 控制面同样支持 `trans G14:k3s shell 'kubectl get nodes && kubectl get pods -A'`,并默认注入 `/etc/rancher/k3s/k3s.yaml`;pod route 需要 cwd 时使用 `exec --cwd /path -- sh -c ''`,例如 `trans D601:k3s:hwlab-dev:hwlab-cloud-api exec --cwd /app -- sh -c 'pwd && ls'`。 +一行远端 shell 逻辑同样必须用显式方言:POSIX 写 `trans sh -- ''`,Bash 专有语法写 `trans bash -- ''`。它的输入必须作为一个 quoted argv 到达 CLI,适合 `sed ... && sed ...`、`kubectl get ... | head` 或一次性环境探测;它仍然只穿过一次目标 shell,不能解决本地 shell 已经拆开的外层 `&&`、`|`、`>`。k3s 控制面同样支持 `trans G14:k3s sh -- 'kubectl get nodes && kubectl get pods -A'`,并默认注入 `/etc/rancher/k3s/k3s.yaml`;pod route 需要 cwd 时使用 workload route 的 `sh`/`bash` operation 或 `exec --cwd /path -- sh -c ''`,例如 `trans D601:k3s:hwlab-dev:hwlab-cloud-api sh --cwd /app -- 'pwd && ls'`。 `trans skills` 是远端 skill 发现入口,也可写作 `trans 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 ` 和 `--windows-root `;不要用宽泛的 Linux `find /mnt/*` 扫 Windows 盘,优先用这个结构化入口避免卡在 Windows 挂载层。 @@ -409,11 +399,11 @@ trans D601 find /home/ubuntu --max-depth 4 --type d --icontains pika --limit 50 trans D601 glob --root /home/ubuntu/pikapython --pattern '**/*-test.cpp' --limit 20 --sort ``` -`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` 和旧 `apply-patch-v1` fallback 等操作仍按同一套 operation parser 执行。`:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。 +`ssh` 的 route 语法是 `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。Host workspace route 使用 `:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd`、`git`、`sh`、`bash`、`apply-patch` 和旧 `apply-patch-v1` fallback 等操作仍按同一套 operation parser 执行。`:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。 当前稳定 plane 包括 `win` 和 `k3s`。`win` plane 的 operation 是 Windows 操作,不是 POSIX shell 别名:`:win ps` 在 WSL provider 上启动 Windows PowerShell,stdin heredoc 会被写入临时 `.ps1` 后执行;`:win cmd` 启动 Windows host 的 `cmd.exe`,stdin heredoc 会被写入临时 `.cmd` 后执行;`:win skills` 发现 Windows skill 目录。需要 Windows 当前目录时使用 slash 路由 `:win//`,例如 `D601:win/c/test ps` 会先在 PowerShell 内 `Set-Location -LiteralPath 'C:\test'`,`D601:win/c/test cmd cd` 会先在 cmd 内执行 `cd /d "C:\test"`。`win32` 不是合法 plane,调用者必须改用 `win`。 -`:win ps` 是 Windows PowerShell 专用入口,适合管道、变量、`Get-ChildItem`、`Start-Process`、`Test-Path` 和 Windows 路径脚本;不要用 host/k3s 的 `script` operation 表示 PowerShell。`ps` 和 `cmd` 都注入 UTF-8/Python 编码默认值;`cmd` 额外执行 `chcp 65001>nul`。典型用法: +`:win ps` 是 Windows PowerShell 专用入口,适合管道、变量、`Get-ChildItem`、`Start-Process`、`Test-Path` 和 Windows 路径脚本;不要用 host/k3s 的 `sh`/`bash` operation 表示 PowerShell。`ps` 和 `cmd` 都注入 UTF-8/Python 编码默认值;`cmd` 额外执行 `chcp 65001>nul`。典型用法: ```bash trans D601:win ps <<'PS' @@ -424,11 +414,11 @@ PS `:win skills [--scope agents|codex|all] [--limit N]` 是 Windows 用户 skill 发现入口,默认只读取当前 Windows 用户的 `%USERPROFILE%\.agents\skills`,输出 JSON 中包含 `roots`、`counts` 和每个 skill 的 `name`、`path`、`skillFile`、`description`。需要同时检查 `%USERPROFILE%\.codex\skills` 时显式加 `--scope all`;不要为了列 skill 手写 `cmd dir` 或宽泛扫描整个用户目录。 -`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,标准 workload 段写成 `pod:`,例如 `D601:k3s:hwlab-v03:pod:hwlab-cloud-api-abc:hwlab-cloud-api`。`pod/` 不是合法 route 写法,因为 `:` 是分布式路由分隔符,`/` 只表示目标容器里的文件系统 cwd;如果调用者写成 `pod//`,CLI 必须在连接运行面前报错并提示改用 `pod::` 或 `--container `。若目标是 Deployment,也可以显式写 `deployment:` 或简写 ``;容器选择写 `:` 或 operation 参数 `--container `。pod 内 cwd 推荐用 operation 参数 `--cwd /path`,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api:hwlab-cloud-api exec --cwd /app -- pwd`。`kubectl`、`logs`、`script`、`apply-patch`、旧 `apply-patch-v1` fallback、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。 +`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,标准 workload 段写成 `pod:`,例如 `D601:k3s:hwlab-v03:pod:hwlab-cloud-api-abc:hwlab-cloud-api`。`pod/` 不是合法 route 写法,因为 `:` 是分布式路由分隔符,`/` 只表示目标容器里的文件系统 cwd;如果调用者写成 `pod//`,CLI 必须在连接运行面前报错并提示改用 `pod::` 或 `--container `。若目标是 Deployment,也可以显式写 `deployment:` 或简写 ``;容器选择写 `:` 或 operation 参数 `--container `。pod 内 cwd 推荐用 operation 参数 `--cwd /path`,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api:hwlab-cloud-api exec --cwd /app -- pwd`。`kubectl`、`logs`、`sh`、`bash`、`apply-patch`、旧 `apply-patch-v1` fallback、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。 -`k3s` 必须出现在 route 的 plane 段里,禁止使用 `trans G14 k3s ...` 或 `trans D601 k3s ...` 这类 post-provider shorthand;正确形态是 `trans G14:k3s kubectl ...` 或 `trans D601:k3s kubectl ...`。定位和操作必须保持分离,`kubectl`、`logs`、`script`、`apply-patch`、旧 `apply-patch-v1` fallback、`exec` 等 operation 名也不得放进任何 colon route 段,包括 namespace、workload 或 container 段;新增分布式目标时按 `{provider}:{plane}:{scope}` 扩展 route,而不是在 operation args 中新增另一套定位语法。 +`k3s` 必须出现在 route 的 plane 段里,禁止使用 `trans G14 k3s ...` 或 `trans D601 k3s ...` 这类 post-provider shorthand;正确形态是 `trans G14:k3s kubectl ...` 或 `trans D601:k3s kubectl ...`。定位和操作必须保持分离,`kubectl`、`logs`、`sh`、`bash`、`apply-patch`、旧 `apply-patch-v1` fallback、`exec` 等 operation 名也不得放进任何 colon route 段,包括 namespace、workload 或 container 段;新增分布式目标时按 `{provider}:{plane}:{scope}` 扩展 route,而不是在 operation args 中新增另一套定位语法。 -该入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制、容器命令、stdin script、pod cwd `apply-patch` 读写和旧 `apply-patch-v1` fallback 组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。`:k3s` 无后续参数时执行 native k3s guard;`:k3s kubectl ...` 接收原始 kubectl argv;`:k3s script` 执行带 native kubeconfig 的 host stdin 脚本;`:k3s::[:container] logs` 读取有界日志;`:k3s::[:container] exec ...` 和 `:k3s::[:container] ...` 进入目标 workload/container;`:k3s::[:container] script` 把本地 stdin 作为 pod 内 shell 脚本执行;`:k3s::[:container] apply-patch --cwd /workspace` 是 pod 内文本 patch 默认入口;旧 helper 仅通过 `:k3s:: apply-patch-v1` 显式调用。典型用法: +该入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制、容器命令、`sh`/`bash` stdin、pod cwd `apply-patch` 读写和旧 `apply-patch-v1` fallback 组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。`:k3s` 无后续参数时执行 native k3s guard;`:k3s kubectl ...` 接收原始 kubectl argv;`:k3s sh` 或 `bash` 执行带 native kubeconfig 的 host stdin 脚本;`:k3s::[:container] logs` 读取有界日志;`:k3s::[:container] exec ...` 和 `:k3s::[:container] ...` 进入目标 workload/container;`:k3s::[:container] sh` 或 `bash` 把本地 stdin 作为 pod 内 shell 脚本执行;`:k3s::[:container] apply-patch --cwd /workspace` 是 pod 内文本 patch 默认入口;旧 helper 仅通过 `:k3s:: apply-patch-v1` 显式调用。典型用法: ```bash trans D601:k3s @@ -442,14 +432,14 @@ trans D601:win/c/test cmd cd trans D601:win skills --limit 20 trans G14:k3s trans G14:k3s kubectl get pipelineruns -n hwlab-ci -printf 'kubectl get deploy -n hwlab-dev\n' | trans D601:k3s script +printf 'kubectl get deploy -n hwlab-dev\n' | trans D601:k3s sh trans D601:k3s:hwlab-dev:hwlab-cloud-api logs --tail 80 -trans D601:k3s:hwlab-v03:pod:hwlab-cloud-api-abc:hwlab-cloud-api script -- 'curl -fsS http://user-billing/health' +trans D601:k3s:hwlab-v03:pod:hwlab-cloud-api-abc:hwlab-cloud-api sh -- 'curl -fsS http://user-billing/health' trans G14:k3s logs --namespace=devops-infra --deployment=git-mirror-http --tail=80 trans G14:k3s logs -n agentrun-ci -l tekton.dev/pipelineRun=agentrun-v01-ci-xxxx --tail=120 trans D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)' trans D601:k3s:hwlab-dev:hwlab-cloud-api exec --cwd /app -- pwd -printf 'printf "pod=%s\n" "$HOSTNAME"\n' | trans D601:k3s:hwlab-dev:hwlab-cloud-api script +printf 'printf "pod=%s\n" "$HOSTNAME"\n' | trans D601:k3s:hwlab-dev:hwlab-cloud-api sh tar -C /tmp/patched-files -cf - . | trans D601:k3s:unidesk:code-queue exec --cwd /root/unidesk --stdin -- tar -xf - -C /root/unidesk trans D601:k3s:hwlab-dev:hwlab-cloud-api:hwlab-cloud-api apply-patch --cwd /app <<'PATCH' *** Begin Patch @@ -461,17 +451,17 @@ trans D601:k3s:hwlab-dev:hwlab-cloud-api:hwlab-cloud-api apply-patch --cwd /app PATCH ``` -`logs` operation 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。目标 route 后面直接跟普通命令时,CLI 会把 argv 放到 `kubectl exec --` 后;显式 `exec` operation 可用于让命令边界更清晰。`exec --stdin -- ...` 是 workload route 的通用 stdin 流入口,适合把 tar、patch 以外的任意字节流直接送进容器命令;operation 选项必须放在 `--` 前,容器命令从 `--` 后开始。需要 shell 语法时优先改用 `script` operation,把脚本走 stdin,而不是把 `kubectl exec ... -- sh -c ...` 放进远端命令字符串。pod 内文本热修默认使用 workspace route 加 `apply-patch`,不要求目标容器自带 `python3`、`node` 或仓库里的工具脚本;旧 `apply-patch-v1` operation 仍使用同一个 sh helper,只作为显式 legacy fallback,不用于二进制改写。 +`logs` operation 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。目标 route 后面直接跟普通命令时,CLI 会把 argv 放到 `kubectl exec --` 后;显式 `exec` operation 可用于让命令边界更清晰。`exec --stdin -- ...` 是 workload route 的通用 stdin 流入口,适合把 tar、patch 以外的任意字节流直接送进容器命令;operation 选项必须放在 `--` 前,容器命令从 `--` 后开始。需要 shell 语法时优先改用 `sh` 或 `bash` operation,把脚本走 stdin,而不是把 `kubectl exec ... -- sh -c ...` 放进远端命令字符串。pod 内文本热修默认使用 workspace route 加 `apply-patch`,不要求目标容器自带 `python3`、`node` 或仓库里的工具脚本;旧 `apply-patch-v1` operation 仍使用同一个 sh helper,只作为显式 legacy fallback,不用于二进制改写。 -`trans argv [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `trans D601 argv true`。需要管道、重定向、变量展开或多条命令时,优先改用 `trans script <<'SCRIPT'`。`apply-patch`、`find`、`glob` 和旧 `apply-patch-v1` fallback 有专用入口;`git`、`rg`、`grep`、`sed`、`nl`、`stat`、`du`、`ls`、`cat`、`head`、`tail`、`wc` 和 `pwd` 可以直接作为 `trans` 子命令使用,CLI 会对每个 argv token 做 shell quoting。旧的自由 ssh-like 远端命令入口只保留为近似原生 ssh 的人工兼容路径。 +`trans argv [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `trans D601 argv true`。需要管道、重定向、变量展开或多条命令时,优先改用 `trans sh <<'SH'`。`apply-patch`、`find`、`glob` 和旧 `apply-patch-v1` fallback 有专用入口;`git`、`rg`、`grep`、`sed`、`nl`、`stat`、`du`、`ls`、`cat`、`head`、`tail`、`wc` 和 `pwd` 可以直接作为 `trans` 子命令使用,CLI 会对每个 argv token 做 shell quoting。旧的自由 ssh-like 远端命令入口只保留为近似原生 ssh 的人工兼容路径。 -通过 `trans ` 执行多行脚本时,优先使用结构化 helper,例如 `trans G14 py < script.py`、`trans G14 script <<'SCRIPT'` 或 `trans G14:k3s script <<'SCRIPT'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <` 执行多行脚本时,优先使用结构化 helper,例如 `trans G14 py < script.py`、`trans G14 sh <<'SH'` 或 `trans G14:k3s sh <<'SH'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <:/` 获取 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 `、`codex tasks`、`codex unread`、`codex queues`、`codex output `、`codex judge --attempt N` 和 `ssh `。`microservice status/health/diagnostics` 经 frontend 远程传输时也复用本地 CLI 的默认 compact summary,`microservice health code-queue` 只有显式 `--raw` 或 `--full` 才返回完整健康 body。运行中纠偏已切到 AgentRun `send session/`;旧 `codex steer` 属于冻结写入口,不应通过 frontend 远程传输或旧 proxy 绕过。其中 `ssh` 的 remote frontend 传输使用 authenticated frontend `/ws/ssh` WebSocket 代理接入 backend-core SSH bridge,stdout/stderr 按字节流直通到调用端,不经过 `/api/dispatch`、`/api/tasks` 或 task JSON compact;frontend 运行时必须通过 `PROVIDER_TOKEN`/`UNIDESK_PROVIDER_TOKEN` 或 `PROVIDER_TOKEN_FILE`/`UNIDESK_PROVIDER_TOKEN_FILE` 读取 provider token,并且不能把 token 下发给 runner。因此 D601 Code Queue runner 内的 `tran G14 ...` 应与主 server 本机 `trans G14 ...` / `tran G14 ...` 在输出完整性上保持同一语义。非交互单进程命令优先 `trans D601 argv true`;`apply-patch`、stdin script、`py` 和旧 `apply-patch-v1` fallback 也走同一条 `/ws/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 ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 +默认 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 `、`codex tasks`、`codex unread`、`codex queues`、`codex output `、`codex judge --attempt N` 和 `ssh `。`microservice status/health/diagnostics` 经 frontend 远程传输时也复用本地 CLI 的默认 compact summary,`microservice health code-queue` 只有显式 `--raw` 或 `--full` 才返回完整健康 body。运行中纠偏已切到 AgentRun `send session/`;旧 `codex steer` 属于冻结写入口,不应通过 frontend 远程传输或旧 proxy 绕过。其中 `ssh` 的 remote frontend 传输使用 authenticated frontend `/ws/ssh` WebSocket 代理接入 backend-core SSH bridge,stdout/stderr 按字节流直通到调用端,不经过 `/api/dispatch`、`/api/tasks` 或 task JSON compact;frontend 运行时必须通过 `PROVIDER_TOKEN`/`UNIDESK_PROVIDER_TOKEN` 或 `PROVIDER_TOKEN_FILE`/`UNIDESK_PROVIDER_TOKEN_FILE` 读取 provider token,并且不能把 token 下发给 runner。因此 D601 Code Queue runner 内的 `tran G14 ...` 应与主 server 本机 `trans G14 ...` / `tran G14 ...` 在输出完整性上保持同一语义。非交互单进程命令优先 `trans D601 argv true`;`apply-patch`、`sh`/`bash` stdin、`py` 和旧 `apply-patch-v1` fallback 也走同一条 `/ws/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 ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 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.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。 diff --git a/scripts/src/agentrun.ts b/scripts/src/agentrun.ts index 2e7f584f..c41750dc 100644 --- a/scripts/src/agentrun.ts +++ b/scripts/src/agentrun.ts @@ -2045,7 +2045,7 @@ async function controlPlaneApply(config: UniDeskConfig, options: LaneConfirmOpti valuesPrinted: false, }; } - const applied = await capture(config, spec.nodeKubeRoute, ["script", "--", applyYamlScript(manifestYaml, "unidesk-agentrun-control-plane", false)]); + const applied = await capture(config, spec.nodeKubeRoute, ["sh", "--", applyYamlScript(manifestYaml, "unidesk-agentrun-control-plane", false)]); const payload = captureJsonPayload(applied); return { ok: applied.exitCode === 0 && payload.ok !== false, @@ -2071,15 +2071,15 @@ async function status(config: UniDeskConfig, options: StatusOptions): Promise> { const spec = target.spec; - const sourceProbe = await timedStatusStage("source", () => capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["script", "--", yamlLaneSourceStatusScript(spec)])); + const sourceProbe = await timedStatusStage("source", () => capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)])); const sourcePayload = captureJsonPayload(sourceProbe.value); const sourceCommit = options.sourceCommit ?? stringOrNull(sourcePayload.remoteBranchCommit) ?? stringOrNull(sourcePayload.localHead); const pipelineRunName = options.pipelineRun ?? (sourceCommit ? agentRunPipelineRunName(spec, sourceCommit) : null); const [runtimeProbe, mirrorProbe] = await Promise.all([ - timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["script", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)])), - timedStatusStage("git-mirror", () => capture(config, spec.nodeKubeRoute, ["script", "--", yamlLaneGitMirrorStatusScript(spec)])), + timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)])), + timedStatusStage("git-mirror", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneGitMirrorStatusScript(spec)])), ]); const runtimePayload = captureJsonPayload(runtimeProbe.value); const mirrorPayload = captureJsonPayload(mirrorProbe.value); @@ -2218,7 +2218,7 @@ async function restartYamlLane(config: UniDeskConfig, options: LaneConfirmOption valuesPrinted: false, }; } - const result = await capture(config, spec.nodeKubeRoute, ["script", "--", restartYamlLaneScript(spec)]); + const result = await capture(config, spec.nodeKubeRoute, ["sh", "--", restartYamlLaneScript(spec)]); const payload = captureJsonPayload(result); return { ok: result.exitCode === 0 && payload.ok !== false, @@ -2281,7 +2281,7 @@ async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Pr valuesPrinted: false, }; } - const result = await capture(config, spec.nodeKubeRoute, ["script", "--", secretSyncScript(spec, values.map(({ spec: item, value }) => ({ targetRef: item.targetRef, value: value.value })))]); + const result = await capture(config, spec.nodeKubeRoute, ["sh", "--", secretSyncScript(spec, values.map(({ spec: item, value }) => ({ targetRef: item.targetRef, value: value.value })))]); const payload = captureJsonPayload(result); return { ok: result.exitCode === 0 && payload.ok !== false, @@ -2470,7 +2470,7 @@ async function triggerCurrent(config: UniDeskConfig, options: TriggerOptions): P async function triggerCurrentYamlLane(config: UniDeskConfig, options: TriggerOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise> { const spec = target.spec; - const probe = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneSourceBootstrapProbeScript(spec)]); + const probe = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceBootstrapProbeScript(spec)]); const source = captureJsonPayload(probe); const sourceCommit = stringOrNull(source.sourceCommit); const remoteBranchExists = source.remoteBranchExists === true; @@ -2544,7 +2544,7 @@ async function triggerCurrentYamlLaneConfirmed(config: UniDeskConfig, spec: Agen lane: spec.lane, status: "submitting", }); - const bootstrapSubmit = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneSourceBootstrapSubmitScript(spec)]); + const bootstrapSubmit = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceBootstrapSubmitScript(spec)]); const bootstrapSubmitPayload = captureJsonPayload(bootstrapSubmit); if (bootstrapSubmit.exitCode !== 0 || bootstrapSubmitPayload.ok === false) { return { @@ -2577,7 +2577,7 @@ async function triggerCurrentYamlLaneConfirmed(config: UniDeskConfig, spec: Agen valuesPrinted: false, }; } - const buildSubmit = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneBuildImageSubmitScript(spec, sourceCommit)]); + const buildSubmit = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneBuildImageSubmitScript(spec, sourceCommit)]); const buildSubmitPayload = captureJsonPayload(buildSubmit); if (buildSubmit.exitCode !== 0 || buildSubmitPayload.ok === false) { return { @@ -2618,7 +2618,7 @@ async function triggerCurrentYamlLaneConfirmed(config: UniDeskConfig, spec: Agen } const image = agentRunImageArtifact(spec, { sourceCommit, envIdentity, digest, status: stringOrNull(buildPayload.status) ?? "built" }); const renderedFiles = renderAgentRunGitopsFiles(spec, { sourceCommit, image }); - const gitops = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneGitopsPublishScript(spec, renderedFiles)]); + const gitops = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneGitopsPublishScript(spec, renderedFiles)]); const gitopsPayload = captureJsonPayload(gitops); if (gitops.exitCode !== 0 || gitopsPayload.ok === false) { return { @@ -2656,7 +2656,7 @@ async function triggerCurrentYamlLaneConfirmed(config: UniDeskConfig, spec: Agen }; } const pipelineRun = agentRunPipelineRunName(spec, sourceCommit); - const created = await capture(config, spec.nodeKubeRoute, ["script", "--", yamlLanePipelineRunCreateScript(spec, sourceCommit, pipelineRun)]); + const created = await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLanePipelineRunCreateScript(spec, sourceCommit, pipelineRun)]); const createPayload = captureJsonPayload(created); return { ok: created.exitCode === 0 && createPayload.ok !== false, @@ -2719,7 +2719,7 @@ async function refreshYamlLane(config: UniDeskConfig, options: RefreshOptions): valuesPrinted: false, }; } - const refreshed = await capture(config, spec.nodeKubeRoute, ["script", "--", refreshYamlLaneScript(spec)]); + const refreshed = await capture(config, spec.nodeKubeRoute, ["sh", "--", refreshYamlLaneScript(spec)]); const payload = captureJsonPayload(refreshed); return { ok: refreshed.exitCode === 0 && payload.ok !== false, @@ -2740,7 +2740,7 @@ async function refreshYamlLane(config: UniDeskConfig, options: RefreshOptions): async function cleanupRuns(config: UniDeskConfig, options: CleanupRunsOptions): Promise> { const { configPath, spec } = resolveAgentRunLaneTarget(options); - const result = await capture(config, spec.nodeKubeRoute, ["script", "--", cleanupRunsScript(options, spec.ci.namespace, spec.ci.pipelineRunPrefix)]); + const result = await capture(config, spec.nodeKubeRoute, ["sh", "--", cleanupRunsScript(options, spec.ci.namespace, spec.ci.pipelineRunPrefix)]); const payload = captureJsonPayload(result); const ok = result.exitCode === 0 && payload.ok !== false; const base = { @@ -2779,7 +2779,7 @@ async function cleanupRuns(config: UniDeskConfig, options: CleanupRunsOptions): async function cleanupReleasedPvs(config: UniDeskConfig, options: CleanupReleasedPvOptions): Promise> { const { configPath, spec } = resolveAgentRunLaneTarget(options); - const result = await capture(config, spec.nodeKubeRoute, ["script", "--", cleanupReleasedPvsScript(options, spec.ci.namespace)]); + const result = await capture(config, spec.nodeKubeRoute, ["sh", "--", cleanupReleasedPvsScript(options, spec.ci.namespace)]); const payload = captureJsonPayload(result); const ok = result.exitCode === 0 && payload.ok !== false; const base = { @@ -3111,7 +3111,7 @@ async function waitForYamlLaneSourceBootstrap(config: UniDeskConfig, spec: Agent let polls = 0; while (Date.now() - startedAt < timeoutMs) { polls += 1; - const probe = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneSourceBootstrapStatusScript(spec, jobId)]); + const probe = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceBootstrapStatusScript(spec, jobId)]); const payload = captureJsonPayload(probe); lastPayload = payload; progressEvent("agentrun.yaml-lane.source-bootstrap.progress", { @@ -3231,7 +3231,7 @@ async function waitForYamlLaneBuildImage(config: UniDeskConfig, spec: AgentRunLa let polls = 0; while (Date.now() - startedAt < timeoutMs) { polls += 1; - const probe = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneBuildImageStatusScript(spec, jobId)]); + const probe = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneBuildImageStatusScript(spec, jobId)]); const payload = captureJsonPayload(probe); lastPayload = payload; progressEvent("agentrun.yaml-lane.image-build.progress", { @@ -3311,7 +3311,7 @@ function yamlLaneGitopsPublishScript(spec: AgentRunLaneSpec, files: readonly { p async function runYamlLaneGitMirrorSyncJob(config: UniDeskConfig, spec: AgentRunLaneSpec): Promise> { const jobName = `${spec.gitMirror.syncJobPrefix}-${Date.now().toString(36)}`.slice(0, 63); const manifest = yamlLaneGitMirrorJobManifest(spec, "sync", jobName); - const created = await capture(config, spec.nodeKubeRoute, ["script", "--", createYamlLaneJobScript(spec.gitMirror.namespace, jobName, manifest)]); + const created = await capture(config, spec.nodeKubeRoute, ["sh", "--", createYamlLaneJobScript(spec.gitMirror.namespace, jobName, manifest)]); if (created.exitCode !== 0) { return { ok: false, phase: "create-job", jobName, capture: compactCapture(created, { full: true, stdoutTailChars: 4000, stderrTailChars: 4000 }), valuesPrinted: false }; } @@ -3320,7 +3320,7 @@ async function runYamlLaneGitMirrorSyncJob(config: UniDeskConfig, spec: AgentRun let lastProbe: SshCaptureResult | null = null; while (Date.now() - startedAt < 300_000) { polls += 1; - lastProbe = await capture(config, spec.nodeKubeRoute, ["script", "--", yamlLaneJobProbeScript(spec.gitMirror.namespace, jobName)]); + lastProbe = await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneJobProbeScript(spec.gitMirror.namespace, jobName)]); const payload = captureJsonPayload(lastProbe); progressEvent("agentrun.yaml-lane.git-mirror.progress", { node: spec.nodeId, @@ -4265,7 +4265,7 @@ async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorStatusOp async function readGitMirrorStatus(config: UniDeskConfig, target: { configPath: string; spec: AgentRunLaneSpec }): Promise & { result: SshCaptureResult; raw: string; summary: Record }> { const spec = target.spec; - const result = await capture(config, spec.nodeKubeRoute, ["script", "--", yamlLaneGitMirrorStatusScript(spec)]); + const result = await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneGitMirrorStatusScript(spec)]); const raw = result.stdout; const summary = captureJsonPayload(result); return { @@ -4300,7 +4300,7 @@ async function runGitMirrorJob(config: UniDeskConfig, action: "sync" | "flush", next: { confirm: `bun scripts/cli.ts ${command} --node ${spec.nodeId} --lane ${spec.lane} --confirm` }, }; } - const created = await capture(config, spec.nodeKubeRoute, ["script", "--", createYamlLaneJobScript(spec.gitMirror.namespace, jobName, manifest)]); + const created = await capture(config, spec.nodeKubeRoute, ["sh", "--", createYamlLaneJobScript(spec.gitMirror.namespace, jobName, manifest)]); if (created.exitCode !== 0 || !options.wait) { const status = await gitMirrorStatus(config, { full: false, raw: false, node: spec.nodeId, lane: spec.lane }); return { @@ -4347,7 +4347,7 @@ async function waitForGitMirrorJob(config: UniDeskConfig, spec: AgentRunLaneSpec let polls = 0; while (Date.now() - startedAtMs <= timeoutSeconds * 1000) { polls += 1; - lastProbe = await capture(config, spec.nodeKubeRoute, ["script", "--", yamlLaneJobProbeScript(spec.gitMirror.namespace, jobName)]); + lastProbe = await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneJobProbeScript(spec.gitMirror.namespace, jobName)]); const summary = captureJsonPayload(lastProbe); process.stderr.write(`${JSON.stringify({ event: `agentrun.git-mirror.${action}.progress`, diff --git a/scripts/src/ci.ts b/scripts/src/ci.ts index 80b85f81..ecbbce31 100644 --- a/scripts/src/ci.ts +++ b/scripts/src/ci.ts @@ -1077,7 +1077,7 @@ async function remoteApplyManifest(config: UniDeskConfig, path: string, target = "kubectl apply -f \"$tmp\"", ].join("\n"); emitCiInstallProgress("kubectl-apply", "started", { providerId: target.providerId, manifest: path }); - const result = await runSshCommandCapture(config, `${target.providerId}:k3s`, ["script"], script); + const result = await runSshCommandCapture(config, `${target.providerId}:k3s`, ["sh"], script); if (result.exitCode !== 0) throw new Error(`kubectl apply failed for ${path}: ${result.stderr || result.stdout}`); emitCiInstallProgress("upload-manifest", "succeeded", { providerId: target.providerId, manifest: path, upload: result.stdout.split(/\r?\n/u).find((line) => line.startsWith("manifest_bytes=")) ?? "" }); emitCiInstallProgress("kubectl-apply", "succeeded", { providerId: target.providerId, manifest: path }); @@ -2987,7 +2987,7 @@ async function logs(config: UniDeskConfig, name: string, target = ciTarget(null) if (/^[a-z0-9]([-a-z0-9]{0,46}[a-z0-9])?$/u.test(name)) { const runScript = remoteRunLogsScript(name, target, options); const result = options.capture === "ssh-stream" - ? await runSshCommandCapture(config, `${target.providerId}:k3s`, ["script"], runScript) + ? await runSshCommandCapture(config, `${target.providerId}:k3s`, ["sh"], runScript) : await dispatchSsh(runScript, 60_000, 45_000, true, target); const resultOk = "ok" in result ? result.ok : result.exitCode === 0; if (resultOk || (result.exitCode !== 42 && !result.stderr.includes("no_run_files="))) { @@ -3006,7 +3006,7 @@ async function logs(config: UniDeskConfig, name: string, target = ciTarget(null) } const script = pipelineRunLogsScript(name, options); const result = options.capture === "ssh-stream" - ? await runSshCommandCapture(config, `${target.providerId}:k3s`, ["script"], script) + ? await runSshCommandCapture(config, `${target.providerId}:k3s`, ["sh"], script) : await runRemoteKubectl(script, 60_000, 45_000, target); return ciLogsResultFromCapture({ ok: result.exitCode === 0, diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 53bf787f..47551e02 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -28,13 +28,13 @@ export function rootHelp(): unknown { { command: "trans upload | trans download ", description: "Transfer whole files through SSH passthrough with automatic endpoint byte/SHA-256 verification; downloads stream stdout over host.ssh.tcp-pool." }, { command: "trans apply-patch-v1 [tool args...] < patch.diff", description: "Fallback to the injected legacy remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." }, { command: "trans 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: "trans script [--shell sh|bash] [script-args...] <<'SCRIPT' ...", description: "Run a remote shell script from local stdin using shell -s; default sh inherits provider proxy env and gets the portable printf helper used by shell/script." }, + { command: "trans sh|bash [arg...] <<'SH|BASH' ...", description: "Run a remote POSIX sh or Bash body from local stdin; the operation name declares the shell dialect, and removed `script`/`shell` operations fail with a migration hint." }, { command: "trans skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." }, { command: "trans find [--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: "trans 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: "trans :/absolute/workspace ", description: "Route directly into a host workspace while keeping the operation parser independent from the location." }, - { command: "trans :k3s[:namespace:workload[:container]] ...", description: "Locate a native k3s control plane or workload with route syntax, then run a separate operation with KUBECONFIG fixed and argv assembled by the CLI." }, - { command: "trans argv [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `trans script` when shell features are required." }, + { command: "trans :k3s[:namespace:workload[:container]] ...", description: "Locate a native k3s control plane or workload with route syntax, then run a separate operation with KUBECONFIG fixed and argv assembled by the CLI." }, + { command: "trans argv [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use explicit `sh` or `bash` when shell features are required." }, { command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." }, { command: "microservice status ", description: "Show one user service config, repository reference, backend mapping, and runtime status." }, { command: "microservice health [--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." }, @@ -168,8 +168,8 @@ export function sshHelp(): unknown { "trans playwright [--local-dir /tmp] <<'PW'", "trans apply-patch-v1 [--allow-loose] < patch.diff", "trans py [script-args...] < script.py", - "trans script [--shell sh|bash] [script-args...] <<'SCRIPT'", - "trans shell [--shell sh|bash] \"sed -n '1,20p' a && sed -n '1,20p' b\"", + "trans sh [arg...] <<'SH'", + "trans bash [arg...] <<'BASH'", "trans skills [--scope all|wsl|windows] [--limit N]", "trans find [--contains TEXT] [--limit N]", "trans glob [--root DIR] [--pattern PATTERN]", @@ -184,7 +184,7 @@ export function sshHelp(): unknown { "trans D601:k3s kubectl get pods -n hwlab-dev", "trans G14:k3s", "trans G14:k3s kubectl get pipelineruns -n hwlab-ci", - "trans D601:k3s script <<'SCRIPT'", + "trans D601:k3s sh <<'SH'", "trans D601:k3s:hwlab-dev:hwlab-cloud-api:hwlab-cloud-api exec --cwd /app -- pwd", "trans D601:k3s:hwlab-dev:hwlab-cloud-api:hwlab-cloud-api apply-patch --cwd /app <<'PATCH'", "trans D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch-v1 <<'PATCH'", @@ -193,26 +193,27 @@ export function sshHelp(): unknown { "trans D601:win upload ./tool.mjs F:\\Work\\hwlab\\.tmp\\tool.mjs", "trans D601:win download F:\\Work\\hwlab\\.tmp\\tool.mjs ./tool.mjs", "trans D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'", - "trans D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'", + "trans D601:k3s:hwlab-dev:hwlab-cloud-api sh <<'SH'", "trans D601:k3s:hwlab-dev:hwlab-cloud-api logs --tail 80", "trans G14:k3s logs -n agentrun-ci -l tekton.dev/pipelineRun= --tail 120", ], notes: [ "trans --help and trans --help print this JSON help and never open an interactive session; the underlying ssh subcommand keeps the same help behavior.", - "For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.", + "For non-interactive remote commands, prefer argv for a single process and explicit sh/bash stdin for shell logic.", "Windows routes have explicit Windows operations, not POSIX shell aliases: `ps` runs Windows PowerShell from stdin or one inline command, `cmd` runs cmd.exe/batch from stdin or one command line, and `skills` discovers Windows skill directories.", - "For Windows PowerShell, use `trans :win ps <<'PS'`; the PowerShell body is written to a temporary .ps1 with UTF-8 settings and executed by powershell.exe. Do not use `script` for Windows PowerShell.", + "For Windows PowerShell, use `trans :win ps <<'PS'`; the PowerShell body is written to a temporary .ps1 with UTF-8 settings and executed by powershell.exe. Do not use POSIX `sh` or `bash` for Windows PowerShell.", "For Windows cmd.exe, use `trans :win// cmd <<'CMD'`; `cmd` with no command-line arguments reads the UTF-8 batch body from stdin, injects UTF-8/Python encoding defaults, runs it from a temp .cmd file, and deletes the temp file.", - "`argv` executes direct argv tokens only: `trans argv ls -la` is valid, but `trans argv 'ls -la'` is rejected because the single string would be treated as an executable path; use `script -- 'ls -la'` for one-line shell logic.", - "For one-line remote shell logic without a heredoc, use `script -- ''`; outer shell operators written outside trans, such as `trans G14:/repo sed ... && sed ...`, are parsed by the local shell before UniDesk starts and therefore cannot be redirected by the CLI. The explicit `shell ''` operation remains available for the same sh -c path.", - "When a one-line shell command is easier to type through the script path, `script -- ''` runs that single string through the remote shell without waiting for stdin. When `script --` is followed by multiple tokens, it stays a direct argv form for commands such as `trans D601:/work script -- sed -n '1,20p' file`.", - "script and shell helper modes inject a tiny POSIX-compatible printf wrapper before user shell text, so portable printf headings such as `printf \"--- section ---\\n\"` work consistently under dash/sh and bash. Direct argv commands are unchanged.", + "`argv` executes direct argv tokens only: `trans argv ls -la` is valid, but `trans argv 'ls -la'` is rejected because the single string would be treated as an executable path; use `sh -- 'ls -la'` or `bash -- 'ls -la'` for one-line shell logic.", + "For one-line remote shell logic without a heredoc, use `sh -- ''` for POSIX syntax or `bash -- ''` for Bash syntax. Outer shell operators written outside trans, such as `trans G14:/repo sed ... && sed ...`, are parsed by the local shell before UniDesk starts and therefore cannot be redirected by the CLI.", + "`sh --` and `bash --` accept exactly one shell command string. For direct argv commands with multiple tokens, use `argv`, `exec`, or a known direct subcommand such as `git`, `rg`, `sed`, or `cat`.", + "The removed `script` and `shell` operations intentionally fail. The operation name must declare the shell dialect: `sh` maps to target `/bin/sh`, while `bash` maps to target `bash`.", + "sh and bash helper modes inject a tiny POSIX-compatible printf wrapper before user shell text, so portable printf headings such as `printf \"--- section ---\\n\"` work consistently under dash/sh and bash. Direct argv commands are unchanged.", "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` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser. Plain multi-file Update File patches on POSIX host/k3s and Windows workspace routes use bulk read/write operations to avoid per-file SSH round trips. Its stdout follows Codex apply_patch text output rather than UniDesk JSON output; stderr keeps Codex-style failure text and appends one `UNIDESK_APPLY_PATCH_TIMING` JSON summary with durationMs, patchBytes, fileCount, hunkCount, changedCount, remoteOperationCount, remoteOperationCounts and remoteElapsedMs so slow patch runs can be attributed without changing success stdout.", "`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and return `verification.automatic=true`, `verification.verified=true`, and `verification.match.{bytes,sha256}=true`; this JSON is the transfer integrity proof, so callers do not need a separate manual `sha256sum` check. Downloads stream over `host.ssh.tcp-pool`, emit progress JSON, and may receive a caller-supplied `--inactivity-timeout-ms` from async artifact/deploy jobs so active large transfers are not killed by the generic short-command budget.", "`playwright` runs a stdin heredoc on the target POSIX host/workload with a temporary `playwright-cli` wrapper in PATH, submits the remote script as a background job, polls short status commands for the manifest, then downloads image/pdf artifacts to local `/tmp` by default with the same verified SHA-256 transfer path. Canonical syntax is `trans D601 playwright <<'PW' ... PW`; workspace and known UniDesk host workspaces are preferred for wrapper discovery before external skill passthroughs.", "`apply-patch-v1` is the only legacy fallback entry: it 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; it is for host/k3s POSIX shell only. Use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.", + "`sh` inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY and runs target `/bin/sh`; use `bash` only for Bash syntax such as `pipefail`, arrays, substring expansion, 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`; WSL providers can use `:win ps` for Windows PowerShell and `:win cmd` for Windows cmd.exe, with `:win/c/test ...` mapping the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use `:k3s` for the control plane and `:k3s::[:]` for a workload/container. In k3s routes, `:` is the distributed route separator; `/...` is only an in-container filesystem cwd and never selects a container. Prefer operation `--cwd /path` when a container is also specified.", "Use `win`, not `win32`; the win route is a Windows operation plane. `ps` and `cmd` both set UTF-8/Python encoding defaults, while `cmd` also sets `chcp 65001`.", "`:win skills` discovers the current Windows user's `%USERPROFILE%\\.agents\\skills` by default; use `--scope all` to include `%USERPROFILE%\\.codex\\skills`.", diff --git a/scripts/src/hwlab-g14.ts b/scripts/src/hwlab-g14.ts index c2030013..731f3fb2 100644 --- a/scripts/src/hwlab-g14.ts +++ b/scripts/src/hwlab-g14.ts @@ -972,11 +972,11 @@ function shortSha(sha: string): string { } function precheckWorkspace(): CommandJsonResult { - return cliJson(["ssh", `${G14_PROVIDER}:${G14_WORKSPACE}`, "script", "--", "pwd; git fetch origin G14 --prune; git status --short --branch; git remote -v | sed -n '1,4p'"], 120_000); + return cliJson(["ssh", `${G14_PROVIDER}:${G14_WORKSPACE}`, "sh", "--", "pwd; git fetch origin G14 --prune; git status --short --branch; git remote -v | sed -n '1,4p'"], 120_000); } function g14HostScript(script: string, timeoutMs = 120_000): CommandJsonResult { - return cliJson(["ssh", G14_PROVIDER, "script", "--", script], timeoutMs); + return cliJson(["ssh", G14_PROVIDER, "sh", "--", script], timeoutMs); } function g14K3s(args: string[], timeoutMs = 60_000): CommandJsonResult { @@ -984,7 +984,7 @@ function g14K3s(args: string[], timeoutMs = 60_000): CommandJsonResult { } function g14K3sInlineScriptWithInput(script: string, input: string, timeoutMs = 60_000): CommandJsonResult { - return commandJsonWithInput(["bun", "scripts/cli.ts", "ssh", `${G14_PROVIDER}:k3s`, "script", "--", script], input, timeoutMs); + return commandJsonWithInput(["bun", "scripts/cli.ts", "ssh", `${G14_PROVIDER}:k3s`, "sh", "--", script], input, timeoutMs); } function remoteAsyncStatusDir(label: string): string { @@ -1119,7 +1119,7 @@ function parseRemoteAsyncPayload(result: CommandJsonResult): Record): Record/dev/null | sort -h | tail -20'", + storage: "trans G14 sh -- 'df -h /; sudo du -xh -d 1 /var/lib/rancher/k3s/storage 2>/dev/null | sort -h | tail -20'", }, }; } @@ -3342,7 +3342,7 @@ function applyRuntimeLaneControlPlaneFiles(spec: HwlabRuntimeLaneSpec, renderDir ? ["set -eu", shellCommand.replace(/^exec /, ""), ...cleanupCommands].join("\n") : shellCommand; const visibleCommand = cleanupCommands.length > 0 - ? ["bun", "scripts/cli.ts", "ssh", `${G14_PROVIDER}:k3s`, "script", "--", script] + ? ["bun", "scripts/cli.ts", "ssh", `${G14_PROVIDER}:k3s`, "sh", "--", script] : command; return runG14K3sRemoteAsync({ script, @@ -3642,7 +3642,7 @@ function createRuntimeLanePipelineRun(spec: HwlabRuntimeLaneSpec, sourceCommit: "fi", `kubectl get pipelinerun -n ${shellQuote(CI_NAMESPACE)} ${shellQuote(pipelineRun)} -o jsonpath='{.metadata.name}{\"\\n\"}{.metadata.labels.hwlab\\.pikastech\\.local/source-commit}{\"\\n\"}{.status.conditions[0].status}{\"\\n\"}{.status.conditions[0].reason}{\"\\n\"}'`, ].join("\n"); - return g14K3s(["script", "--", script], timeoutSeconds * 1000); + return g14K3s(["sh", "--", script], timeoutSeconds * 1000); } function createV02PipelineRun(sourceCommit: string, timeoutSeconds: number): CommandJsonResult { @@ -3927,7 +3927,7 @@ function runtimeLaneControlPlaneStatusBundle(spec: HwlabRuntimeLaneSpec, target: shellQuote(pipelineRunRowsJsonPath()), ].join(" "), ].join("\n"); - return g14K3s(["script", "--", script], 120_000); + return g14K3s(["sh", "--", script], 120_000); } function runtimeLanePublicProbeScript(spec: HwlabRuntimeLaneSpec): string { @@ -4174,7 +4174,7 @@ function runtimeLaneArgoRefreshScript(spec: HwlabRuntimeLaneSpec, dryRun: boolea } function refreshRuntimeLaneArgoApplication(spec: HwlabRuntimeLaneSpec, options: G14ControlPlaneOptions): Record { - const result = g14K3s(["script", "--", runtimeLaneArgoRefreshScript(spec, options.dryRun)], options.timeoutSeconds * 1000); + const result = g14K3s(["sh", "--", runtimeLaneArgoRefreshScript(spec, options.dryRun)], options.timeoutSeconds * 1000); const fields = keyValueLinesFromText(statusText(result)); const ok = isCommandSuccess(result); return { @@ -5315,7 +5315,7 @@ function runG14Secret(options: G14SecretOptions): Record { : null; const result = options.preset === "master-server-admin-api-key" ? g14K3sInlineScriptWithInput(script, localAdminApiKey?.key ?? "", options.timeoutSeconds * 1000 + 2000) - : g14K3s(["script", "--", script], options.timeoutSeconds * 1000); + : g14K3s(["sh", "--", script], options.timeoutSeconds * 1000); const status = v02SecretStatusFromText(statusText(result), isCommandSuccess(result), result.exitCode, result.stderr); const dryRunOk = options.action === "ensure" && options.dryRun && isCommandSuccess(result); const deleteDryRunOk = options.action === "delete" && options.dryRun && isCommandSuccess(result); @@ -5482,7 +5482,7 @@ function legacyG14RetirementStatusScript(): string { } function legacyG14RetirementStatus(): Record { - const result = g14K3s(["script", "--", legacyG14RetirementStatusScript()], 60_000); + const result = g14K3s(["sh", "--", legacyG14RetirementStatusScript()], 60_000); const sections = parseShellSections(statusText(result)); const legacyApplications = parseSectionJsonArray(sections.legacyApplications).map(applicationSummary); const legacyNamespaces = parseSectionJsonArray(sections.legacyNamespaces).map(namespaceSummary); @@ -5628,7 +5628,7 @@ function runLegacyG14Retirement(options: G14LegacyRetirementOptions): Record `kubectl exec -n ${shellQuote(GIT_MIRROR_NAMESPACE)} deploy/git-mirror-http -- sh -lc ${shellQuote(`cat /cache/HWLAB.last-sync.json 2>/dev/null || true; printf "\\n"; git --git-dir=/cache/pikasTech/HWLAB.git rev-parse refs/heads/${spec.sourceBranch} refs/mirror-stage/heads/${spec.sourceBranch} refs/heads/${spec.gitopsBranch} refs/mirror-stage/heads/${spec.gitopsBranch} 2>/dev/null || true`)}`, ].join("\n"), ].join("\n"); - const result = g14K3s(["script", "--", script], options.timeoutSeconds * 1000 + 30_000); + const result = g14K3s(["sh", "--", script], options.timeoutSeconds * 1000 + 30_000); const syncCommand = spec.lane === "v02" ? "bun scripts/cli.ts hwlab g14 git-mirror sync --lane v02 --confirm" : `bun scripts/cli.ts hwlab nodes git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`; @@ -6574,7 +6574,7 @@ function runGitMirrorFlush(options: G14GitMirrorOptions): Record/dev/null || true; printf "\\n"; git --git-dir=/cache/pikasTech/HWLAB.git rev-parse refs/heads/${options.lane === "v02" ? "v0.2-gitops" : hwlabRuntimeLaneSpec(options.lane).gitopsBranch} refs/mirror-stage/heads/${options.lane === "v02" ? "v0.2-gitops" : hwlabRuntimeLaneSpec(options.lane).gitopsBranch} 2>/dev/null || true`)}`, ].join("\n"), ].join("\n"); - const result = g14K3s(["script", "--", script], options.timeoutSeconds * 1000 + 30_000); + const result = g14K3s(["sh", "--", script], options.timeoutSeconds * 1000 + 30_000); return { ok: isCommandSuccess(result), command: "hwlab g14 git-mirror flush", @@ -7367,7 +7367,7 @@ function g14ObservabilityStatus(): Record { `section workloadMonitors kubectl get servicemonitor,prometheusrule -n ${shellQuote(V02_RUNTIME_NAMESPACE)} -l hwlab.pikastech.local/monitoring=enabled -o json`, `section query kubectl get --raw ${shellQuote(queryPath)}`, ].join("\n"); - const bundle = g14K3s(["script", "--", script], 120_000); + const bundle = g14K3s(["sh", "--", script], 120_000); const sections = parseShellSections(statusText(bundle)); const namespace = parseSectionJson(sections.namespace); const discoveryNamespace = parseSectionJson(sections.discoveryNamespace); @@ -7538,7 +7538,7 @@ function runG14ObservabilityApply(options: G14ObservabilityOptions): Record/dev/null 2>&1; git rev-parse origin/G14"], 120_000); + const result = cliJson(["ssh", `${G14_PROVIDER}:${G14_WORKSPACE}`, "sh", "--", "git fetch origin G14 --prune >/dev/null 2>&1; git rev-parse origin/G14"], 120_000); if (!isCommandSuccess(result)) return null; const output = String(nested(result.parsed, ["data", "stdout"]) ?? result.stdout).trim(); const match = /[0-9a-f]{40}/iu.exec(output); diff --git a/scripts/src/hwlab-node-control-plane.ts b/scripts/src/hwlab-node-control-plane.ts index a5414293..84fc2e50 100644 --- a/scripts/src/hwlab-node-control-plane.ts +++ b/scripts/src/hwlab-node-control-plane.ts @@ -1748,7 +1748,7 @@ function manifestObjectSummary(manifest: readonly Record[]): Re } function runTransK3s(kubeRoute: string, script: string, timeoutSeconds: number): CommandResult { - return runCommand(["/root/.local/bin/trans", kubeRoute, "script", "--", script], rootPath(), { timeoutMs: timeoutSeconds * 1000 }); + return runCommand(["/root/.local/bin/trans", kubeRoute, "sh", "--", script], rootPath(), { timeoutMs: timeoutSeconds * 1000 }); } function proxyExportBlock(node: ControlPlaneNodeSpec): string { diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index e3868685..5674a7ee 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -449,7 +449,7 @@ function transPath(): string { } function runNodeHostScript(spec: HwlabRuntimeLaneSpec, script: string, timeoutSeconds: number, input = ""): CommandResult { - return runCommand([transPath(), spec.nodeRoute, "script", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 }); + return runCommand([transPath(), spec.nodeRoute, "sh", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 }); } function runNodeHostScriptAsync(spec: HwlabRuntimeLaneSpec, script: string, timeoutSeconds: number, label: string): CommandResult { @@ -535,7 +535,7 @@ function runNodeHostScriptAsync(spec: HwlabRuntimeLaneSpec, script: string, time "cat \"$status_path\" 2>/dev/null || true", ].join("\n"), 55); return { - command: [transPath(), spec.nodeRoute, "script", "--", ""], + command: [transPath(), spec.nodeRoute, "sh", "--", ""], cwd: repoRoot, exitCode: 124, stdout: typeof payload.stdout === "string" ? payload.stdout : "", @@ -561,7 +561,7 @@ function parseJsonObject(text: string): Record { function commandResultFromAsync(spec: HwlabRuntimeLaneSpec, payload: Record, statusPath: string, timedOut: boolean): CommandResult { return { - command: [transPath(), spec.nodeRoute, "script", "--", ""], + command: [transPath(), spec.nodeRoute, "sh", "--", ""], cwd: repoRoot, exitCode: typeof payload.exitCode === "number" ? payload.exitCode : payload.ok === true ? 0 : 1, stdout: typeof payload.stdout === "string" ? payload.stdout : "", @@ -576,7 +576,7 @@ function runNodeK3sArgs(spec: HwlabRuntimeLaneSpec, args: string[], timeoutSecon } function runNodeK3sScript(spec: HwlabRuntimeLaneSpec, script: string, timeoutSeconds: number, input = ""): CommandResult { - return runCommand([transPath(), spec.nodeKubeRoute, "script", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 }); + return runCommand([transPath(), spec.nodeKubeRoute, "sh", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 }); } function isCommandSuccess(result: CommandResult): boolean { @@ -3925,7 +3925,7 @@ function runPublicExposureFrpcRecreate(options: NodePublicExposureOptions): Comm && fields.rolloutStatusExitCode === "0"; const stdout = Object.entries(fields).map(([key, value]) => `${key}\t${value}`).join("\n") + "\n"; return { - command: [transPath(), `${options.node}:k3s`, "script", "--", ""], + command: [transPath(), `${options.node}:k3s`, "sh", "--", ""], cwd: repoRoot, exitCode: ok ? 0 : 1, stdout: [stdout.trim(), ...stdoutParts].filter(Boolean).join("\n") + "\n", @@ -4082,11 +4082,11 @@ ${tlsLines} @api path /health* /auth* /v1* /json-rpc* /openapi* /docs* /swagg } function runTransScript(node: string, script: string, input: string, timeoutSeconds: number): CommandResult { - return runCommand([transPath(), `${node}:k3s`, "script", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 }); + return runCommand([transPath(), `${node}:k3s`, "sh", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 }); } function runTransHostScript(node: string, script: string, input: string, timeoutSeconds: number): CommandResult { - return runCommand([transPath(), node, "script", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 }); + return runCommand([transPath(), node, "sh", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 }); } function runObsoleteSecretCleanup(options: NodeSecretOptions, spec: RuntimeSecretSpec): Record { diff --git a/scripts/src/pk01-caddy.ts b/scripts/src/pk01-caddy.ts index 9964a450..06038b56 100644 --- a/scripts/src/pk01-caddy.ts +++ b/scripts/src/pk01-caddy.ts @@ -138,7 +138,7 @@ print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY `; - const result = await runSshCommandCapture(config, exposure.pk01.route, ["script"], script); + const result = await runSshCommandCapture(config, exposure.pk01.route, ["sh"], script); const parsed = parseJsonOutput(result.stdout); return parsed ?? { ok: false, capture: compactCapture(result, { full: true }) }; } diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index 99bed44f..c261b541 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -1226,7 +1226,7 @@ function desiredChecks(pg: PostgresHostConfig, facts: RemoteFacts, secrets: Secr } async function remoteFacts(config: UniDeskConfig, pg: PostgresHostConfig, secrets: SecretInspection | null): Promise<{ capture: SshCaptureResult; parsed: RemoteFacts | null }> { - const capture = await runSshCommandCapture(config, pg.node.route, ["script"], factsScript(pg, appProbes(pg, secrets))); + const capture = await runSshCommandCapture(config, pg.node.route, ["sh"], factsScript(pg, appProbes(pg, secrets))); const parsed = parseJsonOutput(capture.stdout) as RemoteFacts | null; return { capture, parsed }; } @@ -1486,7 +1486,7 @@ function releaseUrl(pg: PostgresHostConfig): string { async function startRemoteApplyJob(config: UniDeskConfig, pg: PostgresHostConfig, localState: { inspection: SecretInspection }): Promise { const payload = remoteApplyPayload(pg, localState.inspection); const script = startRemoteApplyJobScript(pg, payload); - const capture = await runSshCommandCapture(config, pg.node.route, ["script"], script); + const capture = await runSshCommandCapture(config, pg.node.route, ["sh"], script); const parsed = parseJsonOutput(capture.stdout); return { ok: capture.exitCode === 0 && parsed !== null && parsed.ok === true, @@ -1938,7 +1938,7 @@ printf '\\n---LOG---\\n' if [ -f "$log" ]; then tail -80 "$log"; fi ROOT `; - const capture = await runSshCommandCapture(config, pg.node.route, ["script"], script); + const capture = await runSshCommandCapture(config, pg.node.route, ["sh"], script); const [stateText, logTail = ""] = capture.stdout.split("\n---LOG---\n"); const parsed = parseJsonOutput(stateText) ?? {}; const statusValue = typeof parsed.status === "string" ? parsed.status : null; diff --git a/scripts/src/platform-infra-langbot.ts b/scripts/src/platform-infra-langbot.ts index 55824a77..ffb43570 100644 --- a/scripts/src/platform-infra-langbot.ts +++ b/scripts/src/platform-infra-langbot.ts @@ -500,7 +500,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { const langbot = readLangBotConfig(); const target = resolveTarget(langbot, options.targetId); - const result = await capture(config, target.route, ["script"], statusScript(langbot, target)); + const result = await capture(config, target.route, ["sh"], statusScript(langbot, target)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -562,7 +562,7 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise> { const langbot = readLangBotConfig(); const target = resolveTarget(langbot, options.targetId); - const result = await capture(config, target.route, ["script"], logsScript(target, options)); + const result = await capture(config, target.route, ["sh"], logsScript(target, options)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -579,7 +579,7 @@ async function logs(config: UniDeskConfig, options: LogsOptions): Promise> { const langbot = readLangBotConfig(); const target = resolveTarget(langbot, options.targetId); - const k8s = await capture(config, target.route, ["script"], validateScript(target)); + const k8s = await capture(config, target.route, ["sh"], validateScript(target)); const k8sParsed = parseJsonOutput(k8s.stdout); const publicProbe = publicHttpProbe(target.publicExposure.publicBaseUrl, "/api/v1/system/info"); return { diff --git a/scripts/src/platform-infra-n8n.ts b/scripts/src/platform-infra-n8n.ts index 9d054969..f70ae47b 100644 --- a/scripts/src/platform-infra-n8n.ts +++ b/scripts/src/platform-infra-n8n.ts @@ -289,7 +289,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { const n8n = readN8nConfig(); const target = resolveTarget(n8n, options.targetId); - const result = await capture(config, target.route, ["script"], statusScript(n8n, target)); + const result = await capture(config, target.route, ["sh"], statusScript(n8n, target)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -350,7 +350,7 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise> { const n8n = readN8nConfig(); const target = resolveTarget(n8n, options.targetId); - const result = await capture(config, target.route, ["script"], logsScript(target, options)); + const result = await capture(config, target.route, ["sh"], logsScript(target, options)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -367,7 +367,7 @@ async function logs(config: UniDeskConfig, options: LogsOptions): Promise> { const n8n = readN8nConfig(); const target = resolveTarget(n8n, options.targetId); - const k8s = await capture(config, target.route, ["script"], validateScript(target)); + const k8s = await capture(config, target.route, ["sh"], validateScript(target)); const k8sParsed = parseJsonOutput(k8s.stdout); const publicProbe = publicHttpProbe(target.publicExposure.publicBaseUrl, "/"); return { diff --git a/scripts/src/platform-infra-ops-library.ts b/scripts/src/platform-infra-ops-library.ts index 9aa738c1..6de7039a 100644 --- a/scripts/src/platform-infra-ops-library.ts +++ b/scripts/src/platform-infra-ops-library.ts @@ -655,7 +655,7 @@ print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY `; - const result = await capture(config, params.targetRoute, ["script"], script); + const result = await capture(config, params.targetRoute, ["sh"], script); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && parsed?.ok === true, diff --git a/scripts/src/platform-infra-sub2api-codex.ts b/scripts/src/platform-infra-sub2api-codex.ts index 4a2fe034..b254ce11 100644 --- a/scripts/src/platform-infra-sub2api-codex.ts +++ b/scripts/src/platform-infra-sub2api-codex.ts @@ -1058,7 +1058,7 @@ async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptio async function codexPoolTrace(config: UniDeskConfig, options: TraceOptions): Promise | RenderedCliResult> { const pool = readCodexPoolConfig(); const runtimeTarget = codexPoolRuntimeTarget(options.targetId); - const result = await capture(config, runtimeTarget.route, ["script"], traceScript(pool, options, runtimeTarget)); + const result = await capture(config, runtimeTarget.route, ["sh"], traceScript(pool, options, runtimeTarget)); const parsed = parseJsonOutput(result.stdout); const ok = result.exitCode === 0 && boolField(parsed, "ok", false); if (options.raw) { @@ -1081,7 +1081,7 @@ async function codexPoolTrace(config: UniDeskConfig, options: TraceOptions): Pro async function codexPoolSentinelReport(config: UniDeskConfig, options: SentinelReportOptions): Promise | RenderedCliResult> { const pool = readCodexPoolConfig(); const runtimeTarget = codexPoolRuntimeTarget(options.targetId); - const result = await capture(config, runtimeTarget.route, ["script"], sentinelReportScript(pool, options.events, runtimeTarget)); + const result = await capture(config, runtimeTarget.route, ["sh"], sentinelReportScript(pool, options.events, runtimeTarget)); const parsed = parseJsonOutput(result.stdout); const ok = result.exitCode === 0 && boolField(parsed, "ok", false); if (options.raw) { @@ -1177,7 +1177,7 @@ async function codexPoolCleanupProbes(config: UniDeskConfig, options: ConfirmOpt }; } const pool = readCodexPoolConfig(); - const result = await capture(config, runtimeTarget.route, ["script"], cleanupProbesScript(pool, runtimeTarget)); + const result = await capture(config, runtimeTarget.route, ["sh"], cleanupProbesScript(pool, runtimeTarget)); const parsed = parseJsonOutput(result.stdout); if (options.raw) { return { @@ -1221,7 +1221,7 @@ async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions): } const secretMaterial = prepareTargetPublicExposureSecret(runtimeTarget); const caddyResult = await applyPk01CaddyBlock(config, serviceName, runtimeTarget.publicExposure); - const remoteResult = await capture(config, runtimeTarget.route, ["script"], targetPublicExposureApplyScript(runtimeTarget, secretMaterial)); + const remoteResult = await capture(config, runtimeTarget.route, ["sh"], targetPublicExposureApplyScript(runtimeTarget, secretMaterial)); const parsed = parseJsonOutput(remoteResult.stdout); const publicProbe = await probePublicModels(pool, "without-api-key", undefined, runtimeTarget); const ok = caddyResult.ok === true && remoteResult.exitCode === 0 && boolField(parsed, "ok", false) && publicProbe.httpStatus === 401; @@ -3420,7 +3420,7 @@ PY } async function fetchPoolApiKey(config: UniDeskConfig, pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): Promise<{ apiKey: string | null; error: string | null }> { - const result = await capture(config, target.route, ["script"], ` + const result = await capture(config, target.route, ["sh"], ` set -u kubectl -n ${target.namespace} get secret ${pool.apiKeySecretName} -o json `); @@ -7237,14 +7237,14 @@ type RemoteCodexPoolMode = "sync" | "validate" | "sentinel-probe" | "sentinel-im async function runRemoteCodexPoolScript(config: UniDeskConfig, mode: RemoteCodexPoolMode, script: string, target = codexPoolRuntimeTarget()): Promise { const jobName = `codex-pool-${mode}-${Date.now().toString(36)}`.slice(0, 63); const startedAtMs = Date.now(); - const start = await capture(config, target.route, ["script"], remoteJobStartScript(jobName, script)); + const start = await capture(config, target.route, ["sh"], remoteJobStartScript(jobName, script)); const started = parseJsonOutput(start.stdout); if (start.exitCode !== 0 || boolField(started, "ok", false) !== true) return start; let latest: RemoteCodexPoolJobStatus | null = null; while (Date.now() - startedAtMs <= remoteJobTimeoutMs) { await sleep(remoteJobPollMs); - const probe = await capture(config, target.route, ["script"], remoteJobStatusScript(jobName)); + const probe = await capture(config, target.route, ["sh"], remoteJobStatusScript(jobName)); const parsed = parseJsonOutput(probe.stdout); latest = normalizeRemoteJobStatus(parsed); process.stderr.write(`${JSON.stringify({ diff --git a/scripts/src/platform-infra-wechat-archive.ts b/scripts/src/platform-infra-wechat-archive.ts index d36b57ac..55eab574 100644 --- a/scripts/src/platform-infra-wechat-archive.ts +++ b/scripts/src/platform-infra-wechat-archive.ts @@ -529,7 +529,7 @@ async function collectorImageBuild(config: UniDeskConfig, options: OpsApplyOptio source: collectorSourceSummary(archive), }; } - const result = await capture(config, archive.personalWechatIngress.target.hostRoute, ["script"], collectorImageBuildScript(archive)); + const result = await capture(config, archive.personalWechatIngress.target.hostRoute, ["sh"], collectorImageBuildScript(archive)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -546,7 +546,7 @@ async function collectorImageBuild(config: UniDeskConfig, options: OpsApplyOptio async function collectorImageStatus(config: UniDeskConfig, options: OpsCommonOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); - const result = await capture(config, archive.personalWechatIngress.target.hostRoute, ["script"], collectorImageStatusScript(archive)); + const result = await capture(config, archive.personalWechatIngress.target.hostRoute, ["sh"], collectorImageStatusScript(archive)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && parsed !== null && parsed.ok === true, @@ -580,7 +580,7 @@ async function collectorApply(config: UniDeskConfig, options: OpsApplyOptions): }; } if (options.dryRun) { - const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["script"], collectorApplyScript(archive, manifest, null, true)); + const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["sh"], collectorApplyScript(archive, manifest, null, true)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -595,7 +595,7 @@ async function collectorApply(config: UniDeskConfig, options: OpsApplyOptions): }; } const token = readArchiveCallbackToken(archive); - const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["script"], collectorApplyScript(archive, manifest, token.value, false)); + const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["sh"], collectorApplyScript(archive, manifest, token.value, false)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -624,7 +624,7 @@ async function collectorApply(config: UniDeskConfig, options: OpsApplyOptions): async function collectorStatus(config: UniDeskConfig, options: OpsCommonOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); - const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["script"], collectorStatusScript(archive, options.full)); + const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["sh"], collectorStatusScript(archive, options.full)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && parsed !== null && parsed.ok === true, diff --git a/scripts/src/platform-infra.ts b/scripts/src/platform-infra.ts index 6ec2c332..8c8eee2e 100644 --- a/scripts/src/platform-infra.ts +++ b/scripts/src/platform-infra.ts @@ -1731,7 +1731,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { const sub2api = readSub2ApiConfig(); const target = resolveTarget(sub2api, options.targetId); - const result = await capture(config, target.route, ["script"], statusScript(sub2api, target)); + const result = await capture(config, target.route, ["sh"], statusScript(sub2api, target)); const parsed = parseJsonOutput(result.stdout); if (options.raw) { return { @@ -1810,7 +1810,7 @@ async function validate(config: UniDeskConfig, options: DisclosureOptions): Prom : target.databaseMode === "external-active" ? validateExternalActiveScript(sub2api, target) : validateScript(sub2api, target); - const result = await capture(config, target.route, ["script"], script); + const result = await capture(config, target.route, ["sh"], script); const parsed = parseJsonOutput(result.stdout); if (options.raw) { return { @@ -2333,7 +2333,7 @@ payload = { print(json.dumps(payload, ensure_ascii=False, indent=2)) PY `; - const result = await capture(config, exposure.pk01.route, ["script"], script); + const result = await capture(config, exposure.pk01.route, ["sh"], script); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -2403,7 +2403,7 @@ download_cache="$HOME/.cache/unidesk/platform-infra/caddy-linux-amd64" if [ -f "$download_part" ]; then stat -c 'part_bytes=%s part_mtime=%y' "$download_part"; fi if [ -f "$download_cache" ]; then stat -c 'cache_bytes=%s cache_mtime=%y' "$download_cache"; fi `; - const result = await capture(config, exposure.pk01.route, ["script"], script); + const result = await capture(config, exposure.pk01.route, ["sh"], script); const [stateText, rest = ""] = result.stdout.split("\n---STDOUT---\n"); const [stdoutAndMaybeStderr = "", downloadProgress = ""] = rest.split("\n---DOWNLOAD---\n"); const [stdoutTail = "", stderrTail = ""] = stdoutAndMaybeStderr.split("\n---STDERR---\n"); diff --git a/scripts/src/secrets.ts b/scripts/src/secrets.ts index e7d91254..9c8c0303 100644 --- a/scripts/src/secrets.ts +++ b/scripts/src/secrets.ts @@ -492,7 +492,7 @@ function selectedTargets(config: SecretDistributionConfig, options: SecretsOptio async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise> { if (secrets.length === 0) return { ok: true, target: targetSummary(target), mode: "skipped-no-secrets" }; const yaml = renderSecretManifest(target, secrets); - const result = await capture(config, target.route, ["script"], applySecretScript(target, secrets, yaml)); + const result = await capture(config, target.route, ["sh"], applySecretScript(target, secrets, yaml)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -504,7 +504,7 @@ async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTar } async function statusTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise> { - const result = await capture(config, target.route, ["script"], statusSecretScript(target, secrets)); + const result = await capture(config, target.route, ["sh"], statusSecretScript(target, secrets)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 07a06d88..5225f830 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -187,6 +187,9 @@ const legacyK3sOperationRouteSegments = new Set([ "kubectl", "exec", "script", + "shell", + "sh", + "bash", "apply-patch", "apply-patch-v1", "patch", @@ -945,11 +948,11 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { if (subcommand === "playwright") { return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" }; } - if (subcommand === "script" || subcommand === "sh") { - return buildShellCommand(args.slice(1)); + if (subcommand === "script" || subcommand === "shell") { + throw removedShellAliasError(subcommand); } - if (subcommand === "shell") { - return buildShellStringCommand(args.slice(1)); + if (subcommand === "sh" || subcommand === "bash") { + return buildShellCommand(args.slice(1), subcommand, `ssh ${subcommand}`); } if (subcommand === "argv" || subcommand === "exec") { const toolArgs = args.slice(1); @@ -1003,7 +1006,7 @@ export function sshRouteSeparatorCompatibilityHint(rawArgs: string[], normalized if (rawArgs === normalizedArgs || rawArgs[0] !== "--") return ""; const operation = normalizedArgs[0] ?? ""; const operationText = operation.length === 0 ? "operation" : `operation \`${operation}\``; - return `UNIDESK_SSH_HINT route-level -- is ignored before ${operationText}; canonical form is \`trans ${normalizedArgs.join(" ")}\`. Keep -- only inside operations such as \`script -- \` or \`exec -- \`.\n`; + return `UNIDESK_SSH_HINT route-level -- is ignored before ${operationText}; canonical form is \`trans ${normalizedArgs.join(" ")}\`. Keep -- only inside operations such as \`sh -- \`, \`bash -- \`, or \`exec -- \`.\n`; } function validateDirectArgvCommand(commandName: string, toolArgs: string[]): void { @@ -1012,7 +1015,7 @@ function validateDirectArgvCommand(commandName: string, toolArgs: string[]): voi if (!looksLikeShellCommandString(command)) return; throw new Error( `ssh ${commandName} received one shell-like command string; ${commandName} executes a single process and treats that string as the executable path. ` + - `Use \`trans script -- ${shellQuote(command)}\` for one-line shell logic, or split argv tokens, for example \`trans ${commandName} ls -la\`.` + `Use \`trans sh -- ${shellQuote(command)}\` or \`trans bash -- ${shellQuote(command)}\` for one-line shell logic, or split argv tokens, for example \`trans ${commandName} ls -la\`.` ); } @@ -1533,6 +1536,9 @@ function k3sOperationInRouteMessage(target: string, operation: string): string { if (operation === "v2" || operation === "patch" || operation === "patch-v1") { return `ssh k3s route must locate a target only; remote patch entrypoints are "apply-patch" for the default v2 engine and "apply-patch-v1" for the legacy helper instead of "${target}"`; } + if (operation === "script" || operation === "shell") { + return `ssh k3s route must locate a target only; operation "${operation}" has been removed because it hides the shell dialect; use explicit "sh" or "bash" after the route instead of "${target}"`; + } const operationExample = operation === "guard" ? "guard" : `${operation} ...`; return `ssh k3s route must locate a target only; put operation "${operation}" after the route, for example "ssh ${providerId}:k3s ${operationExample}" or "ssh ${providerId}:k3s:: ${operationExample}" instead of "${target}"`; } @@ -1662,12 +1668,11 @@ function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): P if (operation === "patch" || operation === "patch-v1" || operation === "v2") { throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper"); } - if (operation === "script" || operation === "sh") { - return buildK3sScriptOperation(args.slice(1)); + if (operation === "script" || operation === "shell") { + throw removedShellAliasError(operation); } - if (operation === "shell") { - const parsed = parseShellStringOperationArgs(args.slice(1), `ssh ${route.providerId}:k3s shell`); - return { remoteCommand: shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, parsed.shell, "-c", shellScriptWithCompatibility(parsed.command)]), requiresStdin: false, invocationKind: "helper" }; + if (operation === "sh" || operation === "bash") { + return buildK3sScriptOperation(args.slice(1), operation, `ssh ${route.providerId}:k3s ${operation}`); } if (operation === "guard") { if (args.length > 1) throw new Error(`ssh ${route.providerId}:k3s guard does not accept extra arguments`); @@ -1700,10 +1705,11 @@ function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedS if (operation === "patch" || operation === "patch-v1" || operation === "v2") { throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper"); } - if (operation === "script") return buildK3sScriptOperation([...targetArgs, ...operationArgs]); - if (operation === "shell") { - const parsed = parseShellStringOperationArgs(operationArgs, `ssh ${route.raw} shell`); - return { remoteCommand: buildK3sExecCommand([...targetArgs, "--", parsed.shell, "-c", shellScriptWithCompatibility(parsed.command)]), requiresStdin: false, invocationKind: "helper" }; + if (operation === "script" || operation === "shell") { + throw removedShellAliasError(operation); + } + if (operation === "sh" || operation === "bash") { + return buildK3sScriptOperation([...targetArgs, ...operationArgs], operation, `ssh ${route.raw} ${operation}`); } if (operation === "logs") return { remoteCommand: buildK3sLogsCommand([...targetArgs, ...operationArgs]), requiresStdin: false, invocationKind: "helper" }; if (operation === "argv") { @@ -1832,9 +1838,12 @@ function buildK3sCommand(providerId: string, args: string[]): string { } if (action === "guard") return buildK3sGuardCommand(providerId); if (action === "exec") return buildK3sExecCommand(args.slice(1)); - if (action === "script") { - const parsed = buildK3sScriptOperation(args.slice(1)); - if (parsed.remoteCommand === null) throw new Error("ssh k3s script resolved to an interactive command unexpectedly"); + if (action === "script" || action === "shell") { + throw removedShellAliasError(action); + } + if (action === "sh" || action === "bash") { + const parsed = buildK3sScriptOperation(args.slice(1), action, `ssh k3s ${action}`); + if (parsed.remoteCommand === null) throw new Error(`ssh k3s ${action} resolved to an interactive command unexpectedly`); return parsed.remoteCommand; } if (action === "logs") return buildK3sLogsCommand(args.slice(1)); @@ -1911,21 +1920,24 @@ function buildK3sExecCommand(args: string[]): string { return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } -function buildK3sScriptOperation(args: string[]): ParsedSshArgs { - const parsed = parseK3sTargetOptions(args, "ssh k3s script", { requireCommand: false, allowCommand: true, allowShell: true }); - if (parsed.shell === null && parsed.command.length > 0) { - return { remoteCommand: buildK3sInlineScriptCommand(parsed), requiresStdin: false, invocationKind: "helper" }; - } - return { remoteCommand: buildK3sStdinScriptCommand(parsed), requiresStdin: true, invocationKind: "helper", stdinPrefix: shellScriptStdinPrefix() }; +function removedShellAliasError(operation: string): Error { + return new Error(`ssh ${operation} operation has been removed because it hides the shell dialect; use explicit \`sh\` for POSIX /bin/sh or \`bash\` for Bash syntax`); } -function buildK3sStdinScriptCommand(parsed: K3sTargetOptions): string { - if (parsed.namespace === null && parsed.resource === null) return buildK3sHostScriptCommand(parsed); - if (parsed.namespace === null) throw new Error("ssh k3s script target requires --namespace "); - if (parsed.selector !== null) throw new Error("ssh k3s script does not support --selector"); - if (parsed.resource === null) throw new Error("ssh k3s script target requires --deployment , --pod or --resource "); - 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"; +function buildK3sScriptOperation(args: string[], shell: "sh" | "bash", commandName: string): ParsedSshArgs { + const parsed = parseK3sTargetOptions(args, commandName, { requireCommand: false, allowCommand: true }); + if (parsed.command.length > 0) { + return { remoteCommand: buildK3sInlineScriptCommand(parsed, shell, commandName), requiresStdin: false, invocationKind: "helper" }; + } + return { remoteCommand: buildK3sStdinScriptCommand(parsed, shell, commandName), requiresStdin: true, invocationKind: "helper", stdinPrefix: shellScriptStdinPrefix() }; +} + +function buildK3sStdinScriptCommand(parsed: K3sTargetOptions, shell: "sh" | "bash", commandName: string): string { + if (parsed.namespace === null && parsed.resource === null) return buildK3sHostScriptCommand(parsed, shell, commandName); + if (parsed.namespace === null) throw new Error(`${commandName} target requires --namespace `); + if (parsed.selector !== null) throw new Error(`${commandName} does not support --selector`); + if (parsed.resource === null) throw new Error(`${commandName} target requires --deployment , --pod or --resource `); + if (parsed.tty) throw new Error(`${commandName} does not support --tty; stdin is reserved for the shell body`); const kubectlArgs = [ "exec", "-i", @@ -1939,20 +1951,21 @@ function buildK3sStdinScriptCommand(parsed: K3sTargetOptions): string { return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } -function buildK3sInlineScriptCommand(parsed: K3sTargetOptions): string { - if (parsed.command.length === 0) throw new Error("ssh k3s script -- requires a command"); - if (parsed.selector !== null) throw new Error("ssh k3s script -- does not support --selector"); - 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"); - const command = parsed.command.length === 1 ? ["sh", "-c", shellScriptWithCompatibility(parsed.command[0] ?? "")] : parsed.command; +function buildK3sInlineScriptCommand(parsed: K3sTargetOptions, shell: "sh" | "bash", commandName: string): string { + if (parsed.command.length === 0) throw new Error(`${commandName} -- requires a command`); + if (parsed.command.length !== 1) throw new Error(`${commandName} -- requires exactly one shell command string; use exec/argv for direct argv commands`); + if (parsed.selector !== null) throw new Error(`${commandName} -- does not support --selector`); + if (parsed.tty) throw new Error(`${commandName} does not support --tty; stdin is reserved for the shell body`); + if (parsed.stdin) throw new Error(`${commandName} -- does not accept --stdin`); + const command = [shell, "-c", shellScriptWithCompatibility(parsed.command[0] ?? "")]; if (parsed.namespace === null && parsed.resource === null) { - if (parsed.container !== null) throw new Error("ssh k3s script without a workload does not accept --container"); - if (parsed.workspace !== null) throw new Error("ssh k3s script without a workload does not accept --workdir"); - if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s script without a workload does not accept kubectl log options"); + if (parsed.container !== null) throw new Error(`${commandName} without a workload does not accept --container`); + if (parsed.workspace !== null) throw new Error(`${commandName} without a workload does not accept --workdir`); + if (parsed.kubectlOptions.length > 0) throw new Error(`${commandName} without a workload does not accept kubectl log options`); return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, ...command]); } - if (parsed.namespace === null) throw new Error("ssh k3s script target requires --namespace "); - if (parsed.resource === null) throw new Error("ssh k3s script target requires --deployment , --pod or --resource "); + if (parsed.namespace === null) throw new Error(`${commandName} target requires --namespace `); + if (parsed.resource === null) throw new Error(`${commandName} target requires --deployment , --pod or --resource `); const kubectlArgs = [ "exec", "-n", parsed.namespace, @@ -1992,36 +2005,15 @@ function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs { }; } -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.workspace !== null) throw new Error("ssh k3s script without a workload does not accept --workdir"); - 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"; +function buildK3sHostScriptCommand(parsed: K3sTargetOptions, shell: "sh" | "bash", commandName: string): string { + if (parsed.tty) throw new Error(`${commandName} does not support --tty; stdin is reserved for the shell body`); + if (parsed.stdin) throw new Error(`${commandName} does not accept --stdin; stdin is always the shell body`); + if (parsed.container !== null) throw new Error(`${commandName} without a workload does not accept --container`); + if (parsed.workspace !== null) throw new Error(`${commandName} without a workload does not accept --workdir`); + if (parsed.kubectlOptions.length > 0) throw new Error(`${commandName} without a workload does not accept kubectl log options`); return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, shell, "-s", "--", ...parsed.command]); } -function shellStringFromArgs(args: string[], commandName = "ssh shell"): string { - if (args.length === 0) throw new Error(`${commandName} requires a command string`); - return args.join(" "); -} - -function parseShellStringOperationArgs(args: string[], commandName: string): { shell: string; command: string } { - let shell = "sh"; - const commandArgs: string[] = []; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] ?? ""; - if (arg === "--shell") { - shell = k3sScriptShell(k3sOptionValue(args, index, `${commandName} --shell`), `${commandName} --shell`); - index += 1; - continue; - } - commandArgs.push(arg); - } - return { shell, command: shellStringFromArgs(commandArgs, commandName) }; -} - function buildK3sLogsCommand(args: string[]): string { const parsed = parseK3sTargetOptions(args, "ssh k3s logs", { requireCommand: false, allowSelector: true }); if (parsed.namespace === null) throw new Error("ssh k3s logs requires --namespace "); @@ -2264,49 +2256,26 @@ function shellScriptStdinPrefix(): string { return `${shellScriptPrelude()}\n`; } -function buildShellCommand(args: string[]): ParsedSshArgs { - let shell = "sh"; +function buildShellCommand(args: string[], shell: "sh" | "bash", commandName: string): ParsedSshArgs { const scriptArgs: string[] = []; - let afterDoubleDash = false; - let directArgvMode = false; - let shellOptionSeen = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; - if (afterDoubleDash) { - scriptArgs.push(arg); - continue; - } if (arg === "--") { - if (!shellOptionSeen && scriptArgs.length === 0) { - directArgvMode = true; - scriptArgs.push(...args.slice(index + 1)); - break; + if (scriptArgs.length === 0) { + const command = args.slice(index + 1); + if (command.length === 0) throw new Error(`${commandName} -- requires a command`); + if (command.length !== 1) throw new Error(`${commandName} -- requires exactly one shell command string; use argv or a direct subcommand for direct argv commands`); + return { remoteCommand: shellArgv([shell, "-c", shellScriptWithCompatibility(command[0] ?? "")]), requiresStdin: false, invocationKind: "helper" }; } - afterDoubleDash = true; - continue; + scriptArgs.push(...args.slice(index + 1)); + break; } - if (arg === "--shell") { - shell = k3sScriptShell(k3sOptionValue(args, index, "ssh script --shell"), "ssh script --shell"); - shellOptionSeen = true; - index += 1; - continue; - } - if (arg.startsWith("-")) throw new Error(`unsupported ssh script option: ${arg}`); + if (arg.startsWith("-")) throw new Error(`unsupported ${commandName} option: ${arg}`); scriptArgs.push(arg); } - if (directArgvMode) { - if (scriptArgs.length === 0) throw new Error("ssh script -- requires a command"); - if (scriptArgs.length === 1) return { remoteCommand: shellArgv([shell, "-c", shellScriptWithCompatibility(scriptArgs[0] ?? "")]), requiresStdin: false, invocationKind: "helper" }; - return { remoteCommand: shellArgv(scriptArgs), requiresStdin: false, invocationKind: "argv" }; - } return { remoteCommand: shellArgv([shell, "-s", "--", ...scriptArgs]), requiresStdin: true, invocationKind: "helper", stdinPrefix: shellScriptStdinPrefix() }; } -function buildShellStringCommand(args: string[]): ParsedSshArgs { - const parsed = parseShellStringOperationArgs(args, "ssh shell"); - return { remoteCommand: shellArgv([parsed.shell, "-c", shellScriptWithCompatibility(parsed.command)]), requiresStdin: false, invocationKind: "helper" }; -} - function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } { const toolMarker = "__UNIDESK_APPLY_PATCH_TOOL__"; const patchMarker = "__UNIDESK_APPLY_PATCH_PAYLOAD__"; @@ -2399,8 +2368,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 structured argv or stdin script passthrough for non-interactive commands.", - try: `trans ${shownProviderId} script <<'SCRIPT'`, + message: "ssh-like remote command failed before proving Host SSH is globally unavailable; prefer structured argv or explicit sh/bash stdin passthrough for non-interactive commands.", + try: `trans ${shownProviderId} sh <<'SH'`, triage: `bun scripts/cli.ts provider triage ${shownProviderId} --observed-scope ssh --observed-error ''`, note: "This hint intentionally does not echo the original remote command.", };