diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 4410a115..ee52d098 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -38,13 +38,13 @@ trans G14:k3s kubectl get pods -n hwlab-dev trans D601:k3s kubectl get pods -A # 指定 namespace + workload -trans G14:k3s:hwlab-dev:hwlab-cloud-web-abc/app cat /app/version.txt -trans G14:k3s:hwlab-dev:pod-name/workspace apply-patch <<'PATCH' +trans G14:k3s:hwlab-dev:hwlab-cloud-web:app exec --cwd /app -- cat /app/version.txt +trans G14:k3s:hwlab-dev:pod:hwlab-cloud-web-abc:app apply-patch --cwd /workspace <<'PATCH' ... PATCH ``` -CLI 自动注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。 +CLI 自动注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。k3s route 中 `:` 是分布式路由分隔符,`/` 只表示容器内文件系统 cwd;容器选择必须写 `:` 或 operation 参数 `--container `,不要写成 `pod//`。 ### Windows route @@ -233,11 +233,11 @@ for i in $(seq 1 10); do echo "[$i]"; cat "$tmp/$i.out"; cat "$tmp/$i.err" >&2; ### 远端 patch 正确姿势 -第一个 route token 直接定位到目标 pod+workspace,不要从 host 生成 diff 再改路径上传: +第一个 route token 直接定位到目标 pod/container,容器内 cwd 用 operation 参数 `--cwd /path`,不要从 host 生成 diff 再改路径上传: ```bash # ✅ 正确 -trans G14:k3s:hwlab-dev:hwlab-cloud-web-abc/app/web apply-patch <<'PATCH' +trans G14:k3s:hwlab-dev:pod:hwlab-cloud-web-abc:app apply-patch --cwd /web <<'PATCH' *** Begin Patch *** Update File: app.mjs @@ diff --git a/AGENTS.md b/AGENTS.md index d0ca6702..c9996d91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 ## Critical Remote Patch Transport Rule - P0: 对 G14/D601/远端 worktree 做文本源码修改时,必须优先使用 UniDesk SSH workspace route 的 `apply-patch` 透传入口;不要优先用远端 Python/Perl/sed heredoc 或复杂 shell quoting 拼接大段文本补丁。 -- P0: 对 k3s pod 内文本热修必须把第一个 route token 直接定位到目标 pod/workspace 后再执行 `apply-patch`;禁止从 host/worktree 取 diff、改路径、再管道拼到 pod,反面案例和正确写法见 `docs/reference/cli.md`。 +- P0: 对 k3s pod 内文本热修必须把第一个 route token 直接定位到目标 pod/container,容器内 cwd 用 `--cwd /path` 表达后再执行 `apply-patch`;`:` 是分布式路由符号,`/` 是文件系统 cwd 符号,禁止用 `pod//` 选择容器,也禁止从 host/worktree 取 diff、改路径、再管道拼到 pod,反面案例和正确写法见 `docs/reference/cli.md`。 - P0: 只有在 `apply-patch` 本身不可用或需要处理非文本/批量机械生成文件时,才使用其他受控方式;使用前必须说明原因,并在修改后立即用 `git diff`、语法检查或文件尾部检查确认没有截断或污染。 - P0: `apply-patch` 一旦出现误删、尾部截断、匹配漂移或其他正确性问题,必须立即优先修复 UniDesk `apply-patch` 本身;算法必须按 Codex 开源 `apply_patch` 源码语义做 1:1 对齐,不能用局部护栏、兼容绕行、分支开关或改用其他 patch 入口掩盖基础链路缺陷。 - P0: Codex 开源 `apply_patch` 参考源码已固定缓存到 `/tmp/codex-apply-patch/codex/codex-rs/apply-patch/`,core 侧相关文件和 commit 记录在 `/tmp/codex-apply-patch/`;排查或对齐 `apply-patch` 算法时必须先读该本地缓存,只有缓存缺失或明确需要更新时才重新联网拉取。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index feae7371..0c54acb9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -242,7 +242,7 @@ GitHub issue/PR 写操作必须优先使用 `bun scripts/cli.ts gh issue|pr ... 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/tmp pwd`。`tran` 是历史兼容 wrapper 和 runner 固化入口;新写长期参考、AGENTS 索引和 CLI help 时优先写 `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 字符串拼接。 @@ -255,7 +255,7 @@ exec /root/unidesk/scripts/trans "$@" - 例外只限于一次性探测、临时 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`。 - 本规则覆盖所有 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 继续写 `trans G14:k3s::[/]`;host workspace 路径里的 `cd` 才需要被替换,控制面或 pod 内的多层 shell 不在本规则的清理范围。 +- 与 `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 @@ -298,12 +298,12 @@ ssh-like 远端命令如果出现 `kex_exchange_identification`、`Connection cl 远端文本 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`。 -如果只是远端打文本补丁,不需要再手写 `ssh D601 'apply_patch' < patch.diff` 这种命令拼接;正式默认入口是 `trans D601:/absolute/workspace apply-patch < patch.diff`、`trans D601:k3s::/ apply-patch < patch.diff` 或 `trans D601:win/c/test apply-patch < patch.diff`。旧 helper 只有 `apply-patch-v1` 一个入口,附加参数会原样透传给远端 `apply_patch`,例如 `trans D601 apply-patch-v1 --help` 或 `trans D601 apply-patch-v1 --allow-loose < reviewed.patch`。标准单命令用法如下,不需要先创建本地 patch 临时文件: +如果只是远端打文本补丁,不需要再手写 `ssh D601 'apply_patch' < patch.diff` 这种命令拼接;正式默认入口是 `trans D601:/absolute/workspace apply-patch < patch.diff`、`trans D601:k3s::[:] apply-patch --cwd /workspace < patch.diff` 或 `trans D601:win/c/test apply-patch < patch.diff`。旧 helper 只有 `apply-patch-v1` 一个入口,附加参数会原样透传给远端 `apply_patch`,例如 `trans D601 apply-patch-v1 --help` 或 `trans D601 apply-patch-v1 --allow-loose < reviewed.patch`。标准单命令用法如下,不需要先创建本地 patch 临时文件: -pod 内文本热修的反面案例是:先在 host/source worktree 生成 `git diff`,再用本地 `sed` 改路径、拼 `*** Begin Patch` 包头,最后管道到 `G14:k3s::`。这种做法把 source workspace、local shell、远端 shell 和 pod workspace 四层混在一起,容易出现 patch 格式错误、路径漂移、部分成功后误判、以及“看起来在热修 pod,实际主要在搬运 host diff”的错误行为。正确做法是把第一个 route token 直接定位到目标 pod 和 pod 内 workspace,然后在同一条 route 上写标准 Codex patch: +pod 内文本热修的反面案例是:先在 host/source worktree 生成 `git diff`,再用本地 `sed` 改路径、拼 `*** Begin Patch` 包头,最后管道到 `G14:k3s::`。这种做法把 source workspace、local shell、远端 shell 和 pod workspace 四层混在一起,容易出现 patch 格式错误、路径漂移、部分成功后误判、以及“看起来在热修 pod,实际主要在搬运 host diff”的错误行为。正确做法是把第一个 route token 直接定位到目标 pod/container,容器内 cwd 用 `--cwd /path` 表达,然后在同一条 route 上写标准 Codex patch: ```bash -trans G14:k3s:hwlab-dev:pod:hwlab-cloud-web-abc/app/web/hwlab-cloud-web apply-patch <<'PATCH' +trans G14:k3s:hwlab-dev:pod:hwlab-cloud-web-abc:hwlab-cloud-web apply-patch --cwd /app/web/hwlab-cloud-web <<'PATCH' *** Begin Patch *** Update File: app.mjs @@ @@ -313,7 +313,7 @@ trans G14:k3s:hwlab-dev:pod:hwlab-cloud-web-abc/app/web/hwlab-cloud-web apply-pa PATCH ``` -如果运行面实际加载 `dist/` 产物,同样直接定位到 `.../app/web/hwlab-cloud-web/dist apply-patch` 再改 `app.mjs`,不要从源码 worktree 生成 diff 后改路径上传。热修后立即用同一个 pod route 做 `grep`/`sha256sum`/语法检查或浏览器 smoke 确认落点;若热修内容已经等同于上传一份较大源码,优先停止热修,改走 PR/CI/CD。 +如果运行面实际加载 `dist/` 产物,同样直接定位到 `...:hwlab-cloud-web apply-patch --cwd /app/web/hwlab-cloud-web/dist` 再改 `app.mjs`,不要从源码 worktree 生成 diff 后改路径上传。热修后立即用同一个 pod route 做 `grep`/`sha256sum`/语法检查或浏览器 smoke 确认落点;若热修内容已经等同于上传一份较大源码,优先停止热修,改走 PR/CI/CD。 ```bash trans D601:/home/ubuntu/pipeline apply-patch <<'PATCH' @@ -369,7 +369,7 @@ SCRIPT 这个入口的目标是分布式调试的“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 支持 `trans D601:k3s:hwlab-dev:hwlab-cloud-api/app shell 'pwd && ls'`,先进入 pod workspace 再执行一行 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'`。 `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 挂载层。 @@ -407,11 +407,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:`,旧 `pod/` 只作为兼容输入继续接受;若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。pod 内 workspace 使用 slash 后缀表达,slash 只用于 pod/container 内文件系统定位,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api/app` 会定位到 deployment `hwlab-cloud-api` 并在 pod 内先 `cd /app`,`D601:k3s:hwlab-dev:pod:hwlab-cloud-api-abc/workspace/app:api` 会定位到 pod、container 和 `/workspace/app`。`kubectl`、`logs`、`script`、`apply-patch`、旧 `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`、`script`、`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 中新增另一套定位语法。 -该入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制、容器命令、stdin script、pod workspace `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:: logs` 读取有界日志;`:k3s:: exec ...` 和 `:k3s:: ...` 进入目标 workload;`:k3s:: script` 把本地 stdin 作为 pod 内 shell 脚本执行;`:k3s::/ apply-patch` 是 pod 内文本 patch 默认入口;旧 helper 仅通过 `:k3s:: apply-patch-v1` 显式调用。典型用法: +该入口解决运行面调试中最常见的多层 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` 显式调用。典型用法: ```bash trans D601:k3s @@ -427,13 +427,14 @@ trans G14:k3s trans G14:k3s kubectl get pipelineruns -n hwlab-ci printf 'kubectl get deploy -n hwlab-dev\n' | trans D601:k3s script 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 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/app pwd +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 -tar -C /tmp/patched-files -cf - . | trans D601:k3s:unidesk:code-queue/root/unidesk exec --stdin -- tar -xf - -C /root/unidesk -trans D601:k3s:hwlab-dev:hwlab-cloud-api/app apply-patch <<'PATCH' +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 *** Update File: /tmp/example.txt @@ diff --git a/docs/reference/devops-hygiene.md b/docs/reference/devops-hygiene.md index 8d1bfd19..c6892fde 100644 --- a/docs/reference/devops-hygiene.md +++ b/docs/reference/devops-hygiene.md @@ -62,12 +62,12 @@ If a manual repair is needed to unblock the platform, the durable fix must be co Distributed runtime work should prefer structured CLI passthrough over ad-hoc nested shell strings. The standard escalation order is: -1. Use a purpose-built UniDesk route plus operation or helper such as `trans D601:k3s kubectl ...`, `trans D601:k3s script`, `trans D601:k3s:: logs`, `trans D601:k3s:: script`, `trans D601:k3s::/ apply-patch`, `trans :/absolute/workspace apply-patch`, `trans py`, `trans find`, `trans glob` or `trans skills`. Use legacy `apply-patch-v1` only when the old remote helper is explicitly required. +1. Use a purpose-built UniDesk route plus operation or helper such as `trans D601:k3s kubectl ...`, `trans D601:k3s script`, `trans D601:k3s:: logs`, `trans D601:k3s:: script`, `trans D601:k3s::[:] apply-patch --cwd /workspace`, `trans :/absolute/workspace apply-patch`, `trans py`, `trans find`, `trans glob` or `trans skills`. Use legacy `apply-patch-v1` only when the old remote helper is explicitly required. 2. If no helper exists, use `trans argv [args...]` so the CLI quotes each argv token once. 3. If shell features such as pipes, redirects, loops or variable expansion are required, use a single quoted heredoc with `trans script` or `trans D601:k3s:: script` so the script body travels over stdin instead of through shell command-string arguments. 4. Treat free-form ssh-like command strings as an interactive compatibility path, not as the default automation surface. -For D601 Kubernetes work, route syntax is preferred over positional shell recipes, but the route must stay a pure locator. `D601:k3s` means the native k3s control plane, and `D601:k3s::[:container]` means a namespaced workload or pod. Operations come after the route: `kubectl` runs on the control plane, `logs` reads bounded workload logs, `script` streams a local heredoc/stdin script into the host or target pod, and `apply-patch` is the default remote text patch operation for host or pod workspaces. The route-operation split keeps distributed location and execution behavior independently extensible, fixes `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`, refuses long-follow logs, and assembles common `kubectl exec` / `kubectl logs` / stdin script / pod patch target arguments without adding a provider-gateway protocol change. This prevents the common failure mode where a command crosses local shell, UniDesk SSH broker, remote shell command strings, `kubectl exec`, and container shell quoting layers before reaching the process that should run it. +For D601 Kubernetes work, route syntax is preferred over positional shell recipes, but the route must stay a pure locator. `D601:k3s` means the native k3s control plane, and `D601:k3s::[:container]` means a namespaced workload or pod/container. `:` is the distributed route separator; `/` is only an in-container filesystem cwd, so container selection must use `:` or `--container `, not `pod//`. Operations come after the route: `kubectl` runs on the control plane, `logs` reads bounded workload logs, `script` streams a local heredoc/stdin script into the host or target pod, and `apply-patch --cwd /workspace` is the default remote text patch operation for pod workspaces. The route-operation split keeps distributed location and execution behavior independently extensible, fixes `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`, refuses long-follow logs, and assembles common `kubectl exec` / `kubectl logs` / stdin script / pod patch target arguments without adding a provider-gateway protocol change. This prevents the common failure mode where a command crosses local shell, UniDesk SSH broker, remote shell command strings, `kubectl exec`, and container shell quoting layers before reaching the process that should run it. Longer scripts should move across stdin (`trans py`, `trans script` or k3s `script` operation), and remote text patches should default to `apply-patch` with a host or pod workspace route. Legacy `apply-patch-v1` remains available as the explicit fallback and uses the injected `sh` helper path instead of assuming target containers have `python3`, `node` or repository-local tools. Avoid heredocs nested inside remote command strings, `python - <:/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, :k3s:: for a workload, and :k3s::/ for a pod workspace.", + "Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `:/absolute/workspace`; WSL providers can use `:win 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`.", "Do not put operation names in any colon route segment, including nested k3s namespace/workload/container segments.", diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 11e5837c..c11fdcb0 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -49,6 +49,11 @@ export interface ParsedSshInvocation { parsed: ParsedSshArgs; } +interface EffectiveApplyPatchV2Invocation { + invocation: ParsedSshInvocation; + argv: string[]; +} + export interface SshCaptureResult { exitCode: number; stdout: string; @@ -149,7 +154,6 @@ export const sshUserToolPathPrelude = [ "export PATH", ].join("\n"); const k3sResourceKindAliases = new Set(["pod", "po", "pods", "deployment", "deploy", "deployments", "statefulset", "sts", "daemonset", "ds", "job", "jobs"]); -const k3sPodRoutePrefixes = ["pod:", "po:", "pods:"]; const legacyK3sOperationRouteSegments = new Set([ "guard", "kubectl", @@ -1031,6 +1035,7 @@ export function parseSshRoute(target: string): ParsedSshRoute { return hostSshRoute(providerId, target, workspace); } if (plane !== "k3s") throw new Error(`unsupported ssh route plane: ${plane}`); + rejectK3sSlashKindRouteSegments(target, rest); const normalizedRest = normalizeK3sRouteRestSegments(rest); const [first, second, third, fourth] = normalizedRest; const operationInRoute = [first, second, third].map((segment) => segment === undefined ? undefined : routeSegmentHead(segment)).find((segment) => segment !== undefined && legacyK3sOperationRouteSegments.has(segment)); @@ -1414,25 +1419,48 @@ function normalizeK3sRouteRestSegments(rest: string[]): string[] { const normalized: string[] = []; for (let index = 0; index < rest.length; index += 1) { const current = rest[index] ?? ""; - const podPrefix = k3sPodRoutePrefixes.find((prefix) => current === prefix.slice(0, -1) || current.startsWith(prefix)); - if (podPrefix === undefined) { + const kindRoute = k3sColonKindRoute(current); + if (kindRoute === null) { normalized.push(current); continue; } - if (current === podPrefix.slice(0, -1)) { + if (kindRoute.name === null) { const next = rest[index + 1]; - if (next === undefined || next.length === 0) throw new Error("ssh k3s pod: route requires a pod name after pod:"); - normalized.push(`pod/${next}`); + if (next === undefined || next.length === 0) throw new Error(`ssh k3s ${kindRoute.kind}: route requires a resource name after ${kindRoute.kind}:`); + normalized.push(`${kindRoute.kind}/${next}`); index += 1; continue; } - const podName = current.slice(podPrefix.length); - if (podName.length === 0) throw new Error("ssh k3s pod: route requires a pod name after pod:"); - normalized.push(`pod/${podName}`); + normalized.push(`${kindRoute.kind}/${kindRoute.name}`); } return normalized; } +function rejectK3sSlashKindRouteSegments(target: string, rest: string[]): void { + const resourceSegment = rest[1]; + if (resourceSegment === undefined || resourceSegment.length === 0) return; + const slashIndex = resourceSegment.indexOf("/"); + if (slashIndex < 0) return; + const head = resourceSegment.slice(0, slashIndex); + if (!isK3sResourceKindAlias(head)) return; + throw new Error( + `ssh k3s route segment "${resourceSegment}" in "${target}" uses "/" between Kubernetes route parts. ` + + `":" is the distributed route separator and "/" is only for the in-container filesystem cwd. ` + + `Use "trans :k3s::${head}:[:] ..." for ${head} routes, ` + + `and use "--cwd /path" or a route cwd suffix only for filesystem paths.` + ); +} + +function k3sColonKindRoute(value: string): { kind: string; name: string | null } | null { + const colonIndex = value.indexOf(":"); + const head = colonIndex < 0 ? value : value.slice(0, colonIndex); + if (!isK3sResourceKindAlias(head)) return null; + if (colonIndex < 0) return { kind: head, name: null }; + const name = value.slice(colonIndex + 1); + if (name.length === 0) throw new Error(`ssh k3s ${head}: route requires a resource name after ${head}:`); + return { kind: head, name }; +} + function splitK3sResourceWorkspace(value: string | null): { resource: string | null; workspace: string | null } { if (value === null || value.length === 0) return { resource: null, workspace: null }; const parts = value.split("/"); @@ -1714,6 +1742,55 @@ function k3sRouteExecOperationArgs(args: string[]): string[] { throw new Error("ssh k3s target exec operation requires a command to exec"); } +function effectiveApplyPatchV2Invocation(invocation: ParsedSshInvocation, argv: string[]): EffectiveApplyPatchV2Invocation { + if (invocation.route.plane !== "k3s" || isApplyPatchV2HelpArgs(argv)) return { invocation, argv }; + let container: string | null = null; + let workspace: string | null = null; + const remaining: string[] = []; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] ?? ""; + if (arg === "--container" || arg === "-c") { + container = k3sOptionValue(argv, index, `ssh ${invocation.route.raw} apply-patch ${arg}`); + index += 1; + continue; + } + const containerValue = k3sEqualsOptionValue(arg, "--container", `ssh ${invocation.route.raw} apply-patch`); + if (containerValue !== null) { + container = containerValue; + continue; + } + if (arg === "--workdir" || arg === "--cwd") { + workspace = k3sWorkspaceValue(k3sOptionValue(argv, index, `ssh ${invocation.route.raw} apply-patch ${arg}`), `ssh ${invocation.route.raw} apply-patch ${arg}`); + index += 1; + continue; + } + const workdirValue = k3sEqualsOptionValue(arg, "--workdir", `ssh ${invocation.route.raw} apply-patch`) ?? k3sEqualsOptionValue(arg, "--cwd", `ssh ${invocation.route.raw} apply-patch`); + if (workdirValue !== null) { + workspace = k3sWorkspaceValue(workdirValue, `ssh ${invocation.route.raw} apply-patch ${arg.split("=", 1)[0]}`); + continue; + } + remaining.push(arg); + } + + const route = invocation.route; + if (container !== null && route.container !== null && container !== route.container) { + throw new Error("ssh k3s apply-patch container can be specified once, either in the route : segment or with --container"); + } + + return { + invocation: { + ...invocation, + route: { + ...route, + container: route.container ?? container, + workspace: combineK3sRouteWorkspace(route.workspace, workspace), + }, + }, + argv: remaining, + }; +} + function buildK3sCommand(providerId: string, args: string[]): string { const action = args[0] ?? ""; if (action.length === 0 || action === "--help" || action === "-h" || action === "help") { @@ -2109,7 +2186,16 @@ function k3sWorkspaceValue(value: string, option: string): string { function withK3sWorkspace(parsed: K3sTargetOptions, command: string[]): string[] { if (parsed.workspace === null) return command; if (command.length === 0) throw new Error("ssh k3s workspace route requires a command to execute"); - return ["sh", "-c", 'cd "$1" || exit; shift; exec "$@"', "unidesk-cwd", parsed.workspace, ...command]; + const cwdScript = [ + "if ! cd \"$1\"; then", + "rc=$?", + "printf 'UNIDESK_K3S_CWD_HINT cwd=%s message=%s\\n' \"$1\" '/... is interpreted as remote cwd, not container; use : or --container for container selection.' >&2", + "exit \"$rc\"", + "fi", + "shift", + "exec \"$@\"", + ].join("\n"); + return ["sh", "-c", cwdScript, "unidesk-cwd", parsed.workspace, ...command]; } function normalizeK3sResource(value: string): string { @@ -3130,18 +3216,19 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st }); } if (operationName === "apply-patch") { - const executor: ApplyPatchV2Executor = invocation.route.plane === "win" - ? { fs: createWindowsApplyPatchFileSystem(config, invocation) } - : { run: (command, input) => runSshCaptureCommand(config, invocation, command, input) }; + const applyPatch = effectiveApplyPatchV2Invocation(invocation, normalizedArgs.slice(1)); + const executor: ApplyPatchV2Executor = applyPatch.invocation.route.plane === "win" + ? { fs: createWindowsApplyPatchFileSystem(config, applyPatch.invocation) } + : { run: (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input) }; return await runApplyPatchV2({ executor, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, - argv: normalizedArgs.slice(1), + argv: applyPatch.argv, timing: { - providerId: invocation.providerId, - route: invocation.route.raw, + providerId: applyPatch.invocation.providerId, + route: applyPatch.invocation.route.raw, transport: "backend-core-broker", }, });