fix: add transactional remote patch v2

This commit is contained in:
Codex
2026-05-26 17:38:09 +00:00
parent 6a596bc452
commit 98715ce2be
8 changed files with 1278 additions and 40 deletions
+6 -2
View File
@@ -14,11 +14,15 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- 对第三方模型、硬件、跨平台 bridge、CLI/tran 和高频工具链摩擦的修复,必须先用目标运行面上的最小脚本、临时 pod exec、真实端口或受控透传命令证明核心链路跑通;只有闭环通过后,才把修复固化到源码、测试、长期参考和正式发布。
- 外部 API 或模型行为可能变化时,先查官方/一手文档和成熟实践,再在目标运行面做实验验证;不要凭旧记忆反复推 CI/CD 试错。
## Critical HyueAPI Direct NO_PROXY Rule
- P0: `hyueapi.com` / `.hyueapi.com` 是 Codex API 通道的直连域名,必须始终保留在 `NO_PROXY` / `no_proxy` 中;DeepSeek、Codex API 或其他 provider profile 切换、G14 proxy 注入、旧 D601 egress 迁移和 pod/env render 都不得把 hyueapi 流量改成走 HTTP/SOCKS proxy。
## Critical G14 HWLAB Workspace Rule
- P0: `G14:HWLAB` 是当前 HWLAB DEV/PROD source workspace 和 k3s/GitOps 运行面真相;唯一长期 source workspace 是 G14 节点上的 `/root/hwlab`,固定使用 `G14` 分支和 `origin git@github.com:pikasTech/HWLAB.git`;所有 HWLAB 新开发、文档、render、polling、CI/CD/GitOps 修复默认都必须以该目录和 G14 运行面为准。
- P0: 每次开始 `G14:HWLAB` 分布式开发、切换任务、恢复中断或上下文压缩后,必须重新读取目标 workspace 的 `/root/hwlab/AGENTS.md`,并以该文件和其引用的 HWLAB repo 内规则为当前任务约束;禁止只凭压缩摘要或主 server 记忆继续改代码。
- 操作入口必须通过 UniDesk SSH 维护桥:host/source 操作用 `bun scripts/cli.ts ssh G14 script``bun scripts/cli.ts ssh G14 apply-patch`k3s 操作用 `bun scripts/cli.ts ssh G14:k3s ...`;禁止使用 `ssh G14 k3s ...`,定位必须写在第一个 route token,后续 token 才是 operation。
- 操作入口必须通过 UniDesk SSH 维护桥:host/source 操作用 `bun scripts/cli.ts ssh G14 script`workspace route 加 `v2`,远端文本 patch 默认优先 `v2`、旧 `apply-patch` 仅作 fallbackk3s 操作用 `bun scripts/cli.ts ssh G14:k3s ...`;禁止使用 `ssh G14 k3s ...`,定位必须写在第一个 route token,后续 token 才是 operation。
- 每次开始 `G14:HWLAB` 工作前必须先通过 SSH 桥在 G14 执行 `cd /root/hwlab && git status --short --branch && git remote -v`;若分支不是 `G14...origin/G14`、remote 不是 `git@github.com:pikasTech/HWLAB.git`,或当前路径不是 `/root/hwlab`,必须停止并先修正 workspace,不得继续开发、render、polling 或部署。
- G14 HWLAB 开发必须先以 `/root/hwlab` 做固定 repo 预检,再在 `/root/hwlab/.worktree/<task>` 从最新 `origin/G14` 创建独立 worktree 修改和提交;不要把固定 repo 当作并行任务 scratch 区,细则见 `docs/reference/g14.md`
- P0: G14 已有节点本地 GitHub/Google/DockerHub/npm 等 bootstrap 加速 proxy;在 G14 host、临时 pod、构建 pod 或透传 pod 里拉 GitHub/Google 资源前必须优先使用该 proxy,避免把网络直连失败当作代码阻塞。主入口:HTTP/HTTPS `http://127.0.0.1:10808`、SOCKS `socks5h://127.0.0.1:10808`,备份入口和 NO_PROXY 规则见 `docs/reference/g14.md`
@@ -86,7 +90,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
## Critical Tran Shell Boundary Rule
- P0: `tran <route> ...` 后面禁止裸放本地 shell 续接控制符,包括 `&&``;``|`;需要在远端执行多步命令时,必须使用 `tran <route> script -- '远端完整脚本'``tran <route> script <<'SCRIPT'` 或等价的单一 stdin/script 参数,避免后半段被本地 shell 执行或被 CLI 误当成 operation 参数。`apply-patch``script``py`明确 stdin-backed operation 可以使用 heredoc 或 `< patch.diff` 作为本地输入。
- P0: `tran <route> ...` 后面禁止裸放本地 shell 续接控制符,包括 `&&``;``|`;需要在远端执行多步命令时,必须使用 `tran <route> script -- '远端完整脚本'``tran <route> script <<'SCRIPT'` 或等价的单一 stdin/script 参数,避免后半段被本地 shell 执行`script -- '<单个字符串>'` 会按远端 shell one-liner 执行;`script -- <多个 argv>` 才是 direct argv。`v2``apply-patch``script``py` 等 stdin/capture-backed operation 可以使用 heredoc 或 `< patch.diff` 作为本地输入。
## CLI
+35 -22
View File
@@ -19,12 +19,12 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
- `server cleanup plan [--min-age-hours N] [--limit N]` 只生成主 server Docker 镜像清理 dry-run 计划,不执行删除;默认 `--min-age-hours 24`,避免把刚发布或刚验证的镜像列为 stale。输出必须包含 `dryRun=true``mutation=false``policy.deletionExecuted=false`、active containers/images、受保护镜像、candidate stale images、估算释放空间、风险等级、`commandsToReview` 和人工审批清单。计划必须保守白名单:保留 running containers 使用的 image ID,保留 stopped containers 引用的 image ID 直到人工先复核容器,保留 `deploy.json`/`CI.json` 当前 commit-pinned artifact、Compose stable image、上游 digest pin 和 provider-gateway runner image`protectedStorage` 必须显式列出 PostgreSQL named volume、Baidu Netdisk `.state`、D601 registry storage 和 Docker volumes/host data policy。该入口禁止生成或执行 `docker system prune``docker image prune``docker builder prune``docker volume rm``docker compose down -v`、数据库清理或 host data `rm` 命令;未来若增加真实删除,必须另设显式审批参数并先复核 dry-run 输出。
- `server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`;该命令用于替代手工删除容器的兜底流程,其中 `dev-frontend-proxy` 只更新主 server dev 入口薄代理,`todo-note``code-queue-mgr``project-manager``baidu-netdisk``oa-event-flow` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。D601 Code Queue 执行面不由 `server rebuild` 管理,Rust backend-core 迭代不得用 `server rebuild backend-core` 在 master server 编译,规则见 `docs/reference/dev-environment.md`
- `provider attach <providerId> [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-<ID>.env` 默认只包含 `UNIDESK_MASTER_SERVER``PROVIDER_ID``provider-<ID>.yml` 固定 Docker socket、`pid: "host"``restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build``provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap``service-degraded``provider-degraded``global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full``--raw`;推荐交叉验证命令仍包含 `debug health``debug dispatch <providerId> host.ssh --wait-ms 15000``ssh <providerId> argv true``artifact-registry health --provider-id <providerId>``microservice health k3sctl-adapter``microservice health code-queue``codex tasks --view supervisor --limit 20`
- `ssh <route> [operation args...]` / `tran <route> [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:<namespace>:<workload>`。WSL provider 的 Windows cmd 入口固定写 `tran D601:win cmd <command-line>`,需要 Windows cwd 时用 `tran D601:win/c/test cmd cd`,由 CLI 自动设置 `chcp 65001``PYTHONUTF8=1``PYTHONIOENCODING=utf-8`;命名只允许 `win`,不得使用 `win32`。非交互远端命令优先使用 `ssh <providerId> argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'``tran G14:k3s script <<'SCRIPT'``tran G14:k3s:<namespace>:<workload> script <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。`script` 需要传递带短横线的短命令 argv 时可以使用命令本地分隔符 `script -- <command> [args...]`,例如 `tran D601:/path script -- sed -n '1,20p' file`;这个直接命令形态不等待 stdin顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要在 pod 内改文件时优先使用 `<provider>:k3s:<namespace>:<workload> apply-patch`CLI 会临时注入 pod 内 `apply_patch` helper 并把 patch stdin 交给它。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。
- `ssh <providerId> apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点
- `ssh <route> [operation args...]` / `tran <route> [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:<namespace>:<workload>`。WSL provider 的 Windows cmd 入口固定写 `tran D601:win cmd <command-line>`,需要 Windows cwd 时用 `tran D601:win/c/test cmd cd`,由 CLI 自动设置 `chcp 65001``PYTHONUTF8=1``PYTHONIOENCODING=utf-8`;命名只允许 `win`,不得使用 `win32`。非交互远端命令优先使用 `ssh <providerId> argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'``tran G14:k3s script <<'SCRIPT'``tran G14:k3s:<namespace>:<workload> script <<'SCRIPT'`,把脚本走 stdin`script -- '<单个字符串>'` 是无需 stdin 的远端 shell one-liner,例如 `tran G14:/root/hwlab script -- 'cd /root/hwlab && git status --short --branch'``script -- <多个 argv>` 才是 direct argv,适合 `tran D601:/path script -- sed -n '1,20p' file` 这类带短横线的单进程命令。顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 `<route> v2 < patch.diff`v2 不适用或失败时再退回 `<provider>:k3s:<namespace>:<workload> apply-patch``<providerId> apply-patch` helper。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。
- `ssh <route> v2 < patch.diff` 是默认推荐的远端 patch 入口:本地 TypeScript line-based engine 解析和计算新文件内容,远端 route 只负责读写文件;支持 host workspace、k3s pod workspace 和 frontend transport,并优先处理长中文/Unicode、低上下文插入、重复块 `@@` 定位等旧 helper 容易失败的场景。`ssh <providerId> apply-patch [tool args...] < patch.diff` 保留为 v1 fallback直接调用远端注入的 `apply_patch` sh/perl helper;只有 v2 出现问题、需要复用旧 helper 行为或人工确认 `--allow-loose` 时才优先使用 v1
- `ssh <providerId> py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。
- `ssh <providerId> skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills``.codex/skills`
- `ssh <providerId>:k3s[:namespace:workload[:container]] <operation> ...` 是原生 k3s 结构化 route 入口,route 只定位控制面或 workload`kubectl``logs``exec``script``apply-patch` 和普通容器命令作为 operation 放在 route 之后;CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec 和 logs 参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号;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 <public-frontend> ssh ...`,其中 `<public-frontend>` 优先来自 `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:<namespace>:<workload> argv ...``script``apply-patch``py` 等 stdin helper 通过 frontend `/ws/ssh` 流式通道执行,stdout/stderr 也必须完整直通,不得退回 `/api/dispatch` task JSON。
- `ssh <providerId>:k3s[:namespace:workload[:container]] <operation> ...` 是原生 k3s 结构化 route 入口,route 只定位控制面或 workload`kubectl``logs``exec``script``v2`、旧 `apply-patch` 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 <public-frontend> ssh ...`,其中 `<public-frontend>` 优先来自 `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:<namespace>:<workload> argv ...``tran G14:/absolute/workspace v2 ...``v2``script``py` 和旧 `apply-patch` fallback 经 frontend `/ws/ssh` 通道执行,stdout/stderr 也必须完整直通,不得退回 `/api/dispatch` task JSON。
- `microservice list/status/health/diagnostics/tunnel-self-test/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 或 k3s 控制面中的用户服务(底层命令名仍为 microservice);`health``status``diagnostics` 默认返回 compact summary、body 字节数和 `--full|--raw` 展开命令,只有小 body 或无法抽取 summary 时才带有界 body preview,避免 Code Queue/k3s 诊断一次性输出爆炸;`tunnel-self-test``proxy` 会走真实 backend-core -> provider-gateway 或 k3sctl-adapter -> 节点服务链路。`microservice health code-queue` 使用 commander-safe 专用摘要,必须保留 ok/status、service id、running count、queue count、heartbeat freshness/risk、split-brain/live/degraded 解释和 raw drill-down 命令;需要完整健康 JSON 时显式加 `--raw``--full`,等价深挖路径是 `microservice proxy code-queue /health --raw --full``proxy` 支持受控 JSON 请求体并对超大响应 body 默认输出有界预览,规则见 `docs/reference/microservices.md`
- `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;`decision list` 默认只返回摘要并省略完整 Markdown body,需要排查大正文时显式加 `--include-body`。正式文书字段通过 records 模型一等字段返回和查询:`--doc-no DC-...``--doc-type DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS``--doc-priority P0|P1|P2|P3``--year YYYY``--signer``--issued-at``--effective-scope``--supersedes``--superseded-by``show``requirement update` 可使用 `id``docNo``decision requirement list/create/upsert/update/show` 在同一 records 模型上管理 `goal|decision|blocker|debt|experiment` 需求记录,`docNo` 唯一,未传 `--doc-no` 但提供 `--doc-type/--doc-priority/--year` 时由服务分配下一个序号。它们不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。
- `decision diary import <markdown-file>` 将带 `# YYYY年M月D日``# YYYY-MM-DD``# YYYY/M/D` 标题的工作日志拆成每天一篇 Markdown 日记,按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径写入 Decision Center PostgreSQL`decision diary list/history` 默认只返回摘要,需要完整 Markdown 时显式加 `--include-body``decision diary show <YYYY-MM-DD|id> [--source-file path]` 查看单日正文,`--source-file` 用于同一天存在多个导入来源时精确选择;`decision diary edit|upsert <YYYY-MM-DD|id> --body-file <path> [--title text] [--source-file path] [--tag tag]` 通过 `PUT /api/diary/entries/:idOrDate` 创建当天或历史条目并编辑既有条目。
@@ -110,7 +110,7 @@ GitHub issue/PR 写操作必须优先使用 `bun scripts/cli.ts gh issue|pr ...
## SSH Command
`ssh <providerId> [ssh-like args...]` 是面向人的终端透传入口,不包装 JSON 输出。CLI 会在宿主机启动 `docker exec -i unidesk-backend-core backend-core --ssh-broker ...`broker 只连接 backend-core 的 Docker 内网 `/ws/ssh`core 再把 stdin/stdout/stderr 流量通过目标 provider 的既有 WebSocket 转发到 provider-gatewayprovider-gateway 最终执行维护用 SSH 连接宿主或 WSL sshd。TTY 策略固定为交互登录 shell 使用 `ssh -tt`,带远端命令的会话使用 `ssh -T`;脚本 stdin、`apply-patch` `py` 这类命令模式不得被伪终端回显或注入控制字符。该入口不新增 core 公网端口,不暴露 database,也不改变 frontend/dev frontend/provider ingress 之外的公网边界。
`ssh <providerId> [ssh-like args...]` 是面向人的终端透传入口,不包装 JSON 输出。CLI 会在宿主机启动 `docker exec -i unidesk-backend-core backend-core --ssh-broker ...`broker 只连接 backend-core 的 Docker 内网 `/ws/ssh`core 再把 stdin/stdout/stderr 流量通过目标 provider 的既有 WebSocket 转发到 provider-gatewayprovider-gateway 最终执行维护用 SSH 连接宿主或 WSL sshd。TTY 策略固定为交互登录 shell 使用 `ssh -tt`,带远端命令的会话使用 `ssh -T``v2`脚本 stdin、`py` 和旧 `apply-patch` fallback 这类命令模式不得被伪终端回显或注入控制字符。该入口不新增 core 公网端口,不暴露 database,也不改变 frontend/dev frontend/provider ingress 之外的公网边界。
`bun scripts/cli.ts ssh --help``bun scripts/cli.ts ssh <providerId> --help` 是本地 JSON 帮助命令,必须快速返回;不能把 `--help` 解析成 Provider ID,不能打开交互 shell,也不能等待 provider 会话。
@@ -123,9 +123,9 @@ exec /root/unidesk/scripts/tran "$@"
主 server 上的人工/Codex 分布式敏捷操作必须直接写 `tran ...`,不要在 Codex 工具调用里退回完整 `bun scripts/cli.ts ssh ...` 前缀。例如 `tran D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch``tran D601:k3s kubectl get pods -n hwlab-dev``tran D601:k3s:hwlab-dev:hwlab-cloud-web/tmp pwd`。CLI 命令参考和需要跨机器复制的脚本为了说明稳定入口,可以保留完整 `bun scripts/cli.ts ssh ...` 形式;`tran` 是主 server 本机操作纪律,不作为远端 provider 或 CI/CD 的前置依赖。
`tran` 同样遵守 route/operation 解析器;route 后面的第一个 token 不是原生 ssh 命令字符串。不要写 `tran G14:/root/hwlab sh -lc '...'`,因为 `sh` 会被解析为 stdin script helper 的别名,`-lc` 会变成不受支持的 script 选项。带变量展开、管道、重定向或多条命令的远端逻辑,默认使用 `tran G14:/root/hwlab script <<'SCRIPT'`;默认 `script` 走目标节点 `/bin/sh`,并继承 provider-gateway/G14 已长期化的 proxy 环境。需要临时单步执行一行远端 shell 逻辑、且不想先创建脚本文件或 heredoc 时,使用 `tran G14:/root/hwlab shell 'sed -n "1,20p" a && sed -n "1,20p" b'`CLI 会把整段字符串放进目标节点的 `sh -c`,第二个 `sed`、管道和重定向都会留在远端。只有脚本确实使用 `pipefail`、数组、`[[ ... ]]` 等 bash 专有语义时才加 `--shell bash`,不能把 `--shell bash` 当作 proxy 修复手段。单进程命令才直接写成 argv,例如 `tran G14:/root/hwlab git status --short --branch`。遇到分布式开发摩擦时,优先补强 `tran` 的 route/operation、stdin helper 或目标节点环境,并把稳定解法写回长期参考文档,不要退回多层 shell 字符串拼接。
`tran` 同样遵守 route/operation 解析器;route 后面的第一个 token 不是原生 ssh 命令字符串。不要写 `tran G14:/root/hwlab sh -lc '...'`,因为 `sh` 会被解析为 stdin script helper 的别名,`-lc` 会变成不受支持的 script 选项。带变量展开、管道、重定向或多条命令的远端逻辑,默认使用 `tran G14:/root/hwlab script <<'SCRIPT'`;默认 `script` 走目标节点 `/bin/sh`,并继承 provider-gateway/G14 已长期化的 proxy 环境。需要临时单步执行一行远端 shell 逻辑、且不想先创建脚本文件或 heredoc 时,优先使用 `tran G14:/root/hwlab script -- 'sed -n "1,20p" a && sed -n "1,20p" b'`CLI 会把单个字符串放进目标节点的 `sh -c`,第二个 `sed`、管道和重定向都会留在远端;等价 `shell '<command>'` 仍保留为显式 shell operation。`script --` 后跟多个 token 时保持 direct argv,例如 `tran G14:/root/hwlab script -- sed -n '1,20p' AGENTS.md`。只有脚本确实使用 `pipefail`、数组、`[[ ... ]]` 等 bash 专有语义时才加 `--shell bash`,不能把 `--shell bash` 当作 proxy 修复手段。单进程命令才直接写成 argv,例如 `tran G14:/root/hwlab git status --short --branch`。遇到分布式开发摩擦时,优先补强 `tran` 的 route/operation、stdin helper 或目标节点环境,并把稳定解法写回长期参考文档,不要退回多层 shell 字符串拼接。
本地 shell 运算符不是 `tran` 可以拦截的内容。`tran 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 当前目录执行。需要把两个命令都放到目标节点时,必须写成 `tran G14:/root/hwlab shell 'sed -n "1,20p" AGENTS.md && sed -n "1,20p" docs/reference/g14.md'`,或者用 `tran G14:/root/hwlab script <<'SCRIPT'` 把多行脚本送到远端。
本地 shell 运算符不是 `tran` 可以拦截的内容。`tran 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 当前目录执行。需要把两个命令都放到目标节点时,必须写成 `tran G14:/root/hwlab script -- 'sed -n "1,20p" AGENTS.md && sed -n "1,20p" docs/reference/g14.md'`,或者用 `tran G14:/root/hwlab script <<'SCRIPT'` 把多行脚本送到远端。
`tran` 不做本地 provider/plane 串行锁;本地目录锁不是 G14 原生 k3s/Tekton/GitOps 的业务协调机制,stale lock 会阻塞所有后续短查询。以后不要在 `tran` wrapper 里恢复本地锁。业务并发、发布互斥和 rollout 协调必须交给 k8s/Tekton/Argo/Lease 等原生运行面机制;若 provider session allocator 需要限流,应在服务端实现带 TTL 的队列或 lease,而不是在客户端加目录锁。
@@ -139,18 +139,31 @@ core 只允许声明了 `host.ssh` capability 的 provider 使用 `ssh` 透传
本地 broker 默认等待 provider SSH 会话打开 60000ms,以便在目标节点同时有较多 microservice.http 任务时仍能建立维护会话;需要诊断慢连接时可用 `UNIDESK_SSH_OPEN_TIMEOUT_MS=<ms>` 临时调大,但最小有效值固定为 15000ms,避免把真实离线误判为长时间阻塞。注意 open timeout 只控制“会话打开”阶段,不能绕过 60 秒最外层运行时硬超时。
ssh-like 远端命令如果出现 `kex_exchange_identification``Connection closed by remote host`、provider session timeout 或 exit code 255CLI 会在原始 stderr 后追加一行 `UNIDESK_SSH_HINT { ... }`。该 JSON 不回显原始远端命令,只包含 `code=ssh-like-command-friction``trigger``try``triage``try` 固定指向 stdin script 形态,避免把一次 ssh-like 解析/握手摩擦误读成 D601 SSH 整体不可用。`ssh`/`tran` 运行时硬超时会输出 `UNIDESK_SSH_RUNTIME_TIMEOUT { ... }` 或 wrapper 层 `UNIDESK_TRAN_TIMEOUT_HINT { ... }`;这不是远端业务失败,而是调用方需要改成短查询/轮询。`ssh`/`tran` 只有在运行耗时超过默认 10000ms 时才会在 stderr 追加一行 `UNIDESK_SSH_TIMING { ... }`,且 `level=warning`;正常短调用不输出 timing 噪声。慢成功命令也必须保留该 warning,因为它是 provider session、远端命令成本、helper bootstrap 和 `tran`/`apply-patch` 性能回归的重要监控信号。warning 包含 `elapsedMs``elapsedSeconds``transport``invocationKind``exitCode`,提示优先排查 provider/session 延迟、远端命令自身耗时、helper bootstrap 或工具层回归。阈值可用 `UNIDESK_SSH_SLOW_WARNING_MS=<ms>` 临时调节,提示同样不回显原始远端命令。
ssh-like 远端命令如果出现 `kex_exchange_identification``Connection closed by remote host`、provider session timeout 或 exit code 255CLI 会在原始 stderr 后追加一行 `UNIDESK_SSH_HINT { ... }`。该 JSON 不回显原始远端命令,只包含 `code=ssh-like-command-friction``trigger``try``triage``try` 固定指向 stdin script 形态,避免把一次 ssh-like 解析/握手摩擦误读成 D601 SSH 整体不可用。`ssh`/`tran` 运行时硬超时会输出 `UNIDESK_SSH_RUNTIME_TIMEOUT { ... }` 或 wrapper 层 `UNIDESK_TRAN_TIMEOUT_HINT { ... }`;这不是远端业务失败,而是调用方需要改成短查询/轮询。`ssh`/`tran` 只有在运行耗时超过默认 10000ms 时才会在 stderr 追加一行 `UNIDESK_SSH_TIMING { ... }`,且 `level=warning`;正常短调用不输出 timing 噪声。慢成功命令也必须保留该 warning,因为它是 provider session、远端命令成本、helper bootstrap 和 `tran`/远端 patch 性能回归的重要监控信号。warning 包含 `elapsedMs``elapsedSeconds``transport``invocationKind``exitCode`,提示优先排查 provider/session 延迟、远端命令自身耗时、helper bootstrap 或工具层回归。阈值可用 `UNIDESK_SSH_SLOW_WARNING_MS=<ms>` 临时调节,提示同样不回显原始远端命令。
`ssh <providerId>` 只在当前 operation 需要 helper 时才注入 `/tmp/unidesk-ssh-tools`,普通 `argv``script``kubectl``logs` 等路径不得传输无关工具源码。`apply-patch` 只注入 `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`,不修改目标仓库。
`ssh <providerId>` 只在当前 operation 需要 helper 时才注入 `/tmp/unidesk-ssh-tools`,普通 `argv``script``kubectl``logs``v2` 等路径不得传输无关工具源码。`apply-patch` 只注入 `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`,不修改目标仓库。
`apply_patch` 默认拒绝低上下文 update hunk:空搜索/纯插入无锚点、只在插入点前有上下文而没有插入点后上下文、或同一 hunk search 在目标文件中匹配多个位置时,都会结构化失败并提示补充上下文。成功应用时每个 hunk 会在 stderr 输出 `apply_patch: hunk N matched path:line`,用于复核实际落点;只有人工确认确实需要文件开头插入、重复上下文或其他模糊改写时,才允许给 `apply-patch --allow-loose`
远端文本 patch 默认优先使用 `v2`:它不把 hunk 解析交给远端 shell/perl helper,而是在本地按行序列匹配,支持长中文/Unicode 行、纯新增 hunk、低上下文插入和 `@@` 上下文定位,再把完整新内容写回远端。`apply_patch` 旧 helper 默认拒绝低上下文 update hunk:空搜索/纯插入无锚点、只在插入点前有上下文而没有插入点后上下文、或同一 hunk search 在目标文件中匹配多个位置时,都会结构化失败并提示补充上下文。成功应用时每个 hunk 会在 stderr 输出 `apply_patch: hunk N matched path:line`,用于复核实际落点;只有 v2 不适用或人工确认确实需要文件开头插入、重复上下文或其他模糊改写时,才退回 `apply-patch --allow-loose`
如果只是远端打补丁,不需要再手写 `ssh D601 'apply_patch' < patch.diff` 这种命令拼接;正式入口是 `bun scripts/cli.ts ssh D601 apply-patch < patch.diff``apply-patch``patch` 等价,附加参数会原样透传给远端 `apply_patch`,例如 `bun scripts/cli.ts ssh D601 apply-patch --help``bun scripts/cli.ts ssh D601 apply-patch --allow-loose < reviewed.patch`。标准单命令用法如下,不需要先创建本地 patch 临时文件:
如果只是远端打文本补丁,不需要再手写 `ssh D601 'apply_patch' < patch.diff` 这种命令拼接;正式默认入口是 `bun scripts/cli.ts ssh D601:/absolute/workspace v2 < patch.diff``bun scripts/cli.ts ssh D601:k3s:<namespace>:<workload>/<workspace> v2 < patch.diff``apply-patch``patch` 等价的旧 helper 仅作为 fallback,附加参数会原样透传给远端 `apply_patch`,例如 `bun scripts/cli.ts ssh D601 apply-patch --help``bun scripts/cli.ts ssh D601 apply-patch --allow-loose < reviewed.patch`。标准单命令用法如下,不需要先创建本地 patch 临时文件:
```bash
bun scripts/cli.ts ssh D601 apply-patch <<'PATCH'
bun scripts/cli.ts ssh D601:/home/ubuntu/pipeline v2 <<'PATCH'
*** Begin Patch
*** Update File: /home/ubuntu/pipeline/scripts/src/nodeControl.ts
*** Update File: scripts/src/nodeControl.ts
@@
-const value = "old";
+const value = "new";
*** End Patch
PATCH
```
旧 helper fallback 示例:
```bash
bun scripts/cli.ts ssh D601:/home/ubuntu/pipeline apply-patch <<'PATCH'
*** Begin Patch
*** Update File: scripts/src/nodeControl.ts
@@
-const value = "old";
+const value = "new";
@@ -166,7 +179,7 @@ printf 'import sys\nprint(sys.argv)\n' | bun scripts/cli.ts ssh D601 py foo '--b
`ssh <providerId> py` 的附加参数是脚本参数,不是 Python 解释器参数;如需 `-m``-X` 或多条 shell 命令,仍使用原始远端命令入口。为了保证 CLI 输出及时可见,helper 固定采用“临时文件 + `python3 -u`”模式;provider 命令模式不分配 TTY,因此脚本内容不应被远端回显。
如果远端逻辑需要 shell 特性,不要再把整段脚本作为原生 ssh-like 命令字符串传入。正式入口是 `bun scripts/cli.ts ssh D601 script`,脚本正文从 stdin 进入;CLI 会把本地 stdin 直接送到远端 `sh -s --``--shell bash` 可切换为 bash`--` 后的内容会作为脚本参数传入。临时单步执行优先用 quoted heredoc;只有命令很短、明确希望一行内完成时才用 `shell '<command && command>'`;复用脚本时才用 `< script.sh` 文件重定向。典型用法:
如果远端逻辑需要 shell 特性,不要再把整段脚本作为原生 ssh-like 命令字符串传入。正式入口是 `bun scripts/cli.ts ssh D601 script`,脚本正文从 stdin 进入;CLI 会把本地 stdin 直接送到远端 `sh -s --``--shell bash` 可切换为 bash`--` 后的内容会作为脚本参数传入。临时单步执行优先用 quoted heredoc;只有命令很短、明确希望一行内完成时才用 `script -- '<command && command>'`,它会把单个字符串按远端 shell one-liner 执行且不等待 stdin;复用脚本时才用 `< script.sh` 文件重定向。`script -- <多个 argv>` 仍是 direct argv,不经过远端 shell,适合 `script -- sed -n '1,20p' file`。典型用法:
```bash
cat <<'SCRIPT' | bun scripts/cli.ts ssh D601 script --shell bash -- alpha
@@ -201,17 +214,17 @@ bun scripts/cli.ts ssh D601 find /home/ubuntu --max-depth 4 --type d --icontains
bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*-test.cpp' --limit 20 --sort
```
`ssh` 的 route 语法是 `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。Host workspace route 使用 `<provider>:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd``git``script``apply-patch` 等操作仍按同一套 operation parser 执行。`<provider>:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。
`ssh` 的 route 语法是 `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。Host workspace route 使用 `<provider>:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd``git``script``v2`、旧 `apply-patch` fallback 等操作仍按同一套 operation parser 执行。`<provider>:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。
当前稳定 plane 包括 `win``k3s``<provider>:win cmd <command-line>` 在 WSL provider 上启动 Windows host 的 `cmd.exe`CLI 会在命令前固定执行 `chcp 65001>nul``set "PYTHONUTF8=1"``set "PYTHONIOENCODING=utf-8"`,让中文和 UTF-8 输出成为默认行为;需要 Windows 当前目录时使用 slash 路由 `<provider>:win/<drive>/<path>`,例如 `D601:win/c/test cmd cd` 会先在 Windows cmd 内执行 `cd /d "C:\test"``win32` 不是合法 plane,调用者必须改用 `win`
`<provider>: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 控制面;`<provider>:k3s:<namespace>:<workload>[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Podworkload 段写成 `pod/<podid>`,若目标是 Deployment,也可以显式写 `deployment/<name>` 或简写 `<name>`。pod 内 workspace 使用 slash 后缀表达,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api/app` 会定位到 deployment `hwlab-cloud-api` 并在 pod 内先 `cd /app``D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc/workspace/app:api` 会定位到 pod、container 和 `/workspace/app``kubectl``logs``script``apply-patch``exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。
`D601:k3s``G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`<provider>:k3s:<namespace>:<workload>[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Podworkload 段写成 `pod/<podid>`,若目标是 Deployment,也可以显式写 `deployment/<name>` 或简写 `<name>`。pod 内 workspace 使用 slash 后缀表达,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api/app` 会定位到 deployment `hwlab-cloud-api` 并在 pod 内先 `cd /app``D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc/workspace/app:api` 会定位到 pod、container 和 `/workspace/app``kubectl``logs``script``v2`、旧 `apply-patch` fallback`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。
`k3s` 必须出现在 route 的 plane 段里,禁止使用 `ssh G14 k3s ...``ssh D601 k3s ...` 这类 post-provider shorthand;正确形态是 `ssh G14:k3s kubectl ...``ssh D601:k3s kubectl ...`。定位和操作必须保持分离,`kubectl``logs``script``apply-patch``exec` 等 operation 名也不得放进任何 colon route 段,包括 namespace、workload 或 container 段;新增分布式目标时按 `{provider}:{plane}:{scope}` 扩展 route,而不是在 operation args 中新增另一套定位语法。
`k3s` 必须出现在 route 的 plane 段里,禁止使用 `ssh G14 k3s ...``ssh D601 k3s ...` 这类 post-provider shorthand;正确形态是 `ssh G14:k3s kubectl ...``ssh D601:k3s kubectl ...`。定位和操作必须保持分离,`kubectl``logs``script``v2`、旧 `apply-patch` 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 scriptpod apply-patch 组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml``<provider>:k3s` 无后续参数时执行 native k3s guard`<provider>:k3s kubectl ...` 接收原始 kubectl argv`<provider>:k3s script` 执行带 native kubeconfig 的 host stdin 脚本;`<provider>:k3s:<namespace>:<workload> logs` 读取有界日志;`<provider>:k3s:<namespace>:<workload> exec ...``<provider>:k3s:<namespace>:<workload> <command> ...` 进入目标 workload`<provider>:k3s:<namespace>:<workload> script` 把本地 stdin 作为 pod 内 shell 脚本执行;`<provider>:k3s:<namespace>:<workload> apply-patch` 把本地标准 patch 作为 stdin 送入 pod 内 `apply_patch`。典型用法:
该入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制、容器命令、stdin scriptpod workspace `v2` 读写和旧 `apply-patch` fallback 组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml``<provider>:k3s` 无后续参数时执行 native k3s guard`<provider>:k3s kubectl ...` 接收原始 kubectl argv`<provider>:k3s script` 执行带 native kubeconfig 的 host stdin 脚本;`<provider>:k3s:<namespace>:<workload> logs` 读取有界日志;`<provider>:k3s:<namespace>:<workload> exec ...``<provider>:k3s:<namespace>:<workload> <command> ...` 进入目标 workload`<provider>:k3s:<namespace>:<workload> script` 把本地 stdin 作为 pod 内 shell 脚本执行;`<provider>:k3s:<namespace>:<workload>/<workspace> v2` 是 pod 内文本 patch 默认入口;`<provider>:k3s:<namespace>:<workload> apply-patch` 仅在 v2 不适用或失败时作为旧 helper fallback。典型用法:
```bash
bun scripts/cli.ts ssh D601:k3s
@@ -228,7 +241,7 @@ bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(p
bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd
printf 'printf "pod=%s\n" "$HOSTNAME"\n' | bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api script
tar -C /tmp/patched-files -cf - . | bun scripts/cli.ts ssh D601:k3s:unidesk:code-queue/root/unidesk exec --stdin -- tar -xf - -C /root/unidesk
bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'
bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app v2 <<'PATCH'
*** Begin Patch
*** Update File: /tmp/example.txt
@@
@@ -238,9 +251,9 @@ bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'
PATCH
```
`logs` operation 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。目标 route 后面直接跟普通命令时,CLI 会把 argv 放到 `kubectl exec --` 后;显式 `exec` operation 可用于让命令边界更清晰。`exec --stdin -- <command> ...` 是 workload route 的通用 stdin 流入口,适合把 tar、patch 以外的任意字节流直接送进容器命令;operation 选项必须放在 `--` 前,容器命令从 `--` 后开始。需要 shell 语法时优先改用 `script` operation,把脚本走 stdin,而不是把 `kubectl exec ... -- sh -c ...` 放进远端命令字符串。pod 内 `apply-patch` operation 使用同一个 sh helper,不要求目标容器自带 `python3``node` 或仓库里的工具脚本;它面向文本热修复,不用于大文件或二进制改写。
`logs` operation 默认是有界读取;`--follow`/`-f` 会被拒绝,防止 CLI 长时间占用维护桥。目标 route 后面直接跟普通命令时,CLI 会把 argv 放到 `kubectl exec --` 后;显式 `exec` operation 可用于让命令边界更清晰。`exec --stdin -- <command> ...` 是 workload route 的通用 stdin 流入口,适合把 tar、patch 以外的任意字节流直接送进容器命令;operation 选项必须放在 `--` 前,容器命令从 `--` 后开始。需要 shell 语法时优先改用 `script` operation,把脚本走 stdin,而不是把 `kubectl exec ... -- sh -c ...` 放进远端命令字符串。pod 内文本热修默认使用 workspace route 加 `v2`,不要求目标容器自带 `python3``node` 或仓库里的工具脚本;旧 `apply-patch` operation 仍使用同一个 sh helper,只作为 v2 不适用或失败后的 fallback,不用于二进制改写。
`ssh <providerId> argv <command> [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `bun scripts/cli.ts ssh D601 argv true`。需要管道、重定向、变量展开或多条命令时,优先改用 `ssh <providerId> script <<'SCRIPT'``find``glob``apply-patch` 有专用入口;`git``rg``grep``sed``nl``stat``du``ls``cat``head``tail``wc``pwd` 可以直接作为 `ssh` 子命令使用,CLI 会对每个 argv token 做 shell quoting。旧的自由 ssh-like 远端命令入口只保留为近似原生 ssh 的人工兼容路径。
`ssh <providerId> argv <command> [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `bun scripts/cli.ts ssh D601 argv true`。需要管道、重定向、变量展开或多条命令时,优先改用 `ssh <providerId> script <<'SCRIPT'``v2``find``glob` `apply-patch` fallback 有专用入口;`git``rg``grep``sed``nl``stat``du``ls``cat``head``tail``wc``pwd` 可以直接作为 `ssh` 子命令使用,CLI 会对每个 argv token 做 shell quoting。旧的自由 ssh-like 远端命令入口只保留为近似原生 ssh 的人工兼容路径。
通过 `ssh <providerId>` 执行多行脚本时,优先使用结构化 helper,例如 `bun scripts/cli.ts ssh G14 py < script.py``bun scripts/cli.ts ssh G14 script <<'SCRIPT'``bun scripts/cli.ts ssh G14:k3s script <<'SCRIPT'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <<EOF ...'` 形态;多层 shell 解析容易把 stdin 绑定到错误进程,结果会打开远端交互解释器并留下悬挂的 broker/SSH 会话。长脚本需要复用时,优先提交到 repo 或通过 stdin 传输到目标节点执行。
@@ -248,7 +261,7 @@ PATCH
`--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://<ip>:<frontendPort>/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。
默认 frontend 传输支持 `debug health``debug dispatch``debug task``artifact-registry status|health``ci publish-user-service --dry-run``microservice list/status/health/diagnostics/tunnel-self-test/proxy``decision upload/list/show/health``decision requirement list/upsert``decision diary import/list/history/months/show/edit/upsert``codex task <taskId>``codex tasks``codex unread``codex queues``codex output <taskId>``codex judge <taskId> --attempt N``ssh <PROVIDER_ID> <remote-command>``microservice status/health/diagnostics` 经 frontend 远程传输时也复用本地 CLI 的默认 compact summary`microservice health code-queue` 只有显式 `--raw``--full` 才返回完整健康 body。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `ssh` 的 remote frontend 传输使用 authenticated frontend `/ws/ssh` WebSocket 代理接入 backend-core SSH bridgestdout/stderr 按字节流直通到调用端,不经过 `/api/dispatch``/api/tasks` 或 task JSON compactfrontend 运行时必须通过 `PROVIDER_TOKEN`/`UNIDESK_PROVIDER_TOKEN``PROVIDER_TOKEN_FILE`/`UNIDESK_PROVIDER_TOKEN_FILE` 读取 provider token,并且不能把 token 下发给 runner。因此 D601 Code Queue runner 内的 `tran G14 ...` 应与主 server 本机 `tran G14 ...` 在输出完整性上保持同一语义。非交互单进程命令优先 `ssh D601 argv true`stdin script、`py``apply-patch` 这类 stdin-backed helper 也走同一条 `/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 <key>``--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`
默认 frontend 传输支持 `debug health``debug dispatch``debug task``artifact-registry status|health``ci publish-user-service --dry-run``microservice list/status/health/diagnostics/tunnel-self-test/proxy``decision upload/list/show/health``decision requirement list/upsert``decision diary import/list/history/months/show/edit/upsert``codex task <taskId>``codex tasks``codex unread``codex queues``codex output <taskId>``codex judge <taskId> --attempt N``ssh <PROVIDER_ID> <remote-command>``microservice status/health/diagnostics` 经 frontend 远程传输时也复用本地 CLI 的默认 compact summary`microservice health code-queue` 只有显式 `--raw``--full` 才返回完整健康 body。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `ssh` 的 remote frontend 传输使用 authenticated frontend `/ws/ssh` WebSocket 代理接入 backend-core SSH bridgestdout/stderr 按字节流直通到调用端,不经过 `/api/dispatch``/api/tasks` 或 task JSON compactfrontend 运行时必须通过 `PROVIDER_TOKEN`/`UNIDESK_PROVIDER_TOKEN``PROVIDER_TOKEN_FILE`/`UNIDESK_PROVIDER_TOKEN_FILE` 读取 provider token,并且不能把 token 下发给 runner。因此 D601 Code Queue runner 内的 `tran G14 ...` 应与主 server 本机 `tran G14 ...` 在输出完整性上保持同一语义。非交互单进程命令优先 `ssh D601 argv true``v2`stdin script、`py` `apply-patch` 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 <key>``--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`
计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh``hostSshConfigured=true``hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000``bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh <PROVIDER_ID> hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。
+3 -3
View File
@@ -66,14 +66,14 @@ Full CI/CD, GitOps rollout, image build, hardware run, long trace replay and mod
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 `ssh D601:k3s kubectl ...`, `ssh D601:k3s script`, `ssh D601:k3s:<namespace>:<workload> logs`, `ssh D601:k3s:<namespace>:<workload> script`, `ssh D601:k3s:<namespace>:<workload> apply-patch`, `ssh <providerId> py`, `ssh <providerId> apply-patch`, `ssh <providerId> find`, `ssh <providerId> glob` or `ssh <providerId> skills`.
1. Use a purpose-built UniDesk route plus operation or helper such as `ssh D601:k3s kubectl ...`, `ssh D601:k3s script`, `ssh D601:k3s:<namespace>:<workload> logs`, `ssh D601:k3s:<namespace>:<workload> script`, `ssh D601:k3s:<namespace>:<workload>/<workspace> v2`, `ssh <providerId>:/absolute/workspace v2`, `ssh <providerId> py`, `ssh <providerId> find`, `ssh <providerId> glob` or `ssh <providerId> skills`. Use legacy `apply-patch` only as a fallback when `v2` is not suitable or has failed.
2. If no helper exists, use `ssh <providerId> argv <command> [args...]` so the CLI quotes each argv token once.
3. If shell features such as pipes, redirects, loops or variable expansion are required, use a single quoted heredoc with `ssh <providerId> script` or `ssh D601:k3s:<namespace>:<workload> script` so the script body travels over stdin instead of through shell command-string arguments.
4. Treat free-form ssh-like command strings as an interactive compatibility path, not as the default automation surface.
For D601 Kubernetes work, route syntax is preferred over positional shell recipes, but the route must stay a pure locator. `D601:k3s` means the native k3s control plane, and `D601:k3s:<namespace>:<workload>[:container]` means a namespaced workload or pod. Operations come after the route: `kubectl` runs on the control plane, `logs` reads bounded workload logs, `script` streams a local heredoc/stdin script into the host or target pod, and `apply-patch` applies a standard patch inside the target pod. The route-operation split keeps distributed location and execution behavior independently extensible, fixes `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`, refuses long-follow logs, and assembles common `kubectl exec` / `kubectl logs` / stdin script / pod patch target arguments without adding a provider-gateway protocol change. This prevents the common failure mode where a command crosses local shell, UniDesk SSH broker, remote shell command strings, `kubectl exec`, and container shell quoting layers before reaching the process that should run it.
For D601 Kubernetes work, route syntax is preferred over positional shell recipes, but the route must stay a pure locator. `D601:k3s` means the native k3s control plane, and `D601:k3s:<namespace>:<workload>[:container]` means a namespaced workload or pod. Operations come after the route: `kubectl` runs on the control plane, `logs` reads bounded workload logs, `script` streams a local heredoc/stdin script into the host or target pod, and `v2` 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.
Longer scripts and patches should move across stdin (`ssh py`, `ssh script`, k3s `script` operation, k3s `apply-patch` operation, or `ssh apply-patch`) or as committed source followed by a short route command. Pod-level `apply-patch` must use the injected `sh` helper path instead of assuming target containers have `python3`, `node` or repository-local tools. Avoid heredocs nested inside remote command strings, `python - <<EOF` inside SSH strings, or JSON/Markdown bodies passed through shell arguments. These patterns often bind stdin to the wrong process, strip quotes, or leave a half-open provider SSH session that looks like a platform outage.
Longer scripts should move across stdin (`ssh py`, `ssh script` or k3s `script` operation), and remote text patches should default to `v2` with a host or pod workspace route. Legacy `apply-patch` remains available as a 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 - <<EOF` inside SSH strings, or JSON/Markdown bodies passed through shell arguments. These patterns often bind stdin to the wrong process, strip quotes, or leave a half-open provider SSH session that looks like a platform outage.
When structured passthrough is missing for a recurring workflow, fix the CLI first and then document the durable helper. Do not preserve a growing collection of one-off shell recipes as the long-term runbook.
+572
View File
@@ -0,0 +1,572 @@
import path from "node:path";
import { createHash } from "node:crypto";
import type { Readable, Writable } from "node:stream";
export type PatchHunk =
| { kind: "add"; path: string; content: string }
| { kind: "delete"; path: string }
| { kind: "update"; path: string; movePath: string | null; chunks: UpdateChunk[] };
export interface UpdateChunk {
changeContext: string | null;
oldLines: string[];
newLines: string[];
isEndOfFile: boolean;
}
export interface PatchParseResult {
patch: string;
hunks: PatchHunk[];
}
export interface PatchUpdateResult {
oldContent: string;
newContent: string;
}
export interface ApplyPatchV2Options {
executor: ApplyPatchV2Executor;
stdin: Readable;
stdout: Writable;
argv?: string[];
}
export interface ApplyPatchV2Executor {
run(command: string[], input?: string): Promise<ApplyPatchV2RemoteResult>;
}
export interface ApplyPatchV2RemoteResult {
exitCode: number;
stdout: string;
stderr: string;
}
export class ApplyPatchV2Error extends Error {
constructor(message: string, public readonly details: Record<string, unknown> = {}) {
super(message);
this.name = "ApplyPatchV2Error";
}
}
type PlannedFileState = { exists: true; content: string } | { exists: false; content: "" };
type PlannedOperation = { kind: "write"; path: string; content: string } | { kind: "delete"; path: string };
type Replacement = [start: number, oldLength: number, newLines: string[]];
interface ApplyPatchV2Plan {
operations: PlannedOperation[];
changed: string[];
}
const beginMarker = "*** Begin Patch";
const endMarker = "*** End Patch";
const addFileMarker = "*** Add File: ";
const deleteFileMarker = "*** Delete File: ";
const updateFileMarker = "*** Update File: ";
const moveToMarker = "*** Move to: ";
const emptyChangeContextMarker = "@@";
const changeContextMarker = "@@ ";
const eofMarker = "*** End of File";
export function isApplyPatchV2HelpArgs(args: string[] = []): boolean {
const first = args[0] ?? "";
return first === "help" || first === "--help" || first === "-h";
}
export function applyPatchV2HelpPayload() {
return {
ok: true,
command: "ssh <route> v2 < patch.diff",
summary: "Apply a standard apply_patch patch to a remote route with the local v2 line-based engine.",
usage: [
"tran G14:/root/hwlab/.worktree/task v2 < patch.diff",
"bun scripts/cli.ts ssh D601:/tmp v2 < patch.diff"
],
input: {
required: true,
firstLine: beginMarker,
lastLine: endMarker
},
note: "v2 reads patch text from stdin. Use `script -- ...` for ordinary remote shell commands."
};
}
export function parseApplyPatchV2(patchText: string): PatchParseResult {
const patch = stripLenientHeredoc(patchText).trim();
const lines = patch.length === 0 ? [] : patch.split(/\r?\n/u);
const first = lines[0]?.trim();
const last = lines[lines.length - 1]?.trim();
if (first !== beginMarker) throw new ApplyPatchV2Error(`invalid patch: first line must be '${beginMarker}'`);
if (last !== endMarker) throw new ApplyPatchV2Error(`invalid patch: last line must be '${endMarker}'`);
const hunks: PatchHunk[] = [];
let index = 1;
while (index < lines.length - 1) {
const line = lines[index]?.trim() ?? "";
if (line.length === 0) {
index += 1;
continue;
}
if (line.startsWith(addFileMarker)) {
const filePath = validatePatchPath(line.slice(addFileMarker.length), index + 1);
index += 1;
const added: string[] = [];
while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) {
const addLine = lines[index] ?? "";
if (!addLine.startsWith("+")) {
throw new ApplyPatchV2Error("invalid add file line; every added line must start with +", { line: index + 1, path: filePath });
}
added.push(addLine.slice(1));
index += 1;
}
hunks.push({ kind: "add", path: filePath, content: joinLinesWithFinalNewline(added) });
continue;
}
if (line.startsWith(deleteFileMarker)) {
hunks.push({ kind: "delete", path: validatePatchPath(line.slice(deleteFileMarker.length), index + 1) });
index += 1;
continue;
}
if (line.startsWith(updateFileMarker)) {
const filePath = validatePatchPath(line.slice(updateFileMarker.length), index + 1);
index += 1;
let movePath: string | null = null;
if ((lines[index] ?? "").startsWith(moveToMarker)) {
movePath = validatePatchPath((lines[index] ?? "").slice(moveToMarker.length), index + 1);
index += 1;
}
const chunks: UpdateChunk[] = [];
while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) {
if ((lines[index] ?? "").trim().length === 0) {
index += 1;
continue;
}
const parsed = parseUpdateChunk(lines, index, chunks.length === 0);
chunks.push(parsed.chunk);
index = parsed.nextIndex;
}
if (chunks.length === 0) throw new ApplyPatchV2Error("update file hunk is empty", { line: index + 1, path: filePath });
hunks.push({ kind: "update", path: filePath, movePath, chunks });
continue;
}
throw new ApplyPatchV2Error("invalid hunk header", { line: index + 1, text: line });
}
return { patch, hunks };
}
export function deriveUpdatedContent(filePath: string, originalContent: string, chunks: UpdateChunk[]): PatchUpdateResult {
const originalLines = splitContentLines(originalContent);
const replacements = computeReplacements(filePath, originalLines, chunks);
const newLines = applyReplacements(originalLines, replacements);
const newContent = joinLinesWithFinalNewline(newLines);
return { oldContent: originalContent, newContent };
}
export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<number> {
if (isApplyPatchV2HelpArgs(options.argv)) {
options.stdout.write(`${JSON.stringify(applyPatchV2HelpPayload(), null, 2)}\n`);
return 0;
}
const patchText = await readStreamText(options.stdin);
if (!patchText.trim()) {
options.stdout.write(`${JSON.stringify({
ok: false,
error: {
code: "v2_patch_stdin_required",
message: "ssh v2 requires patch text on stdin."
},
help: applyPatchV2HelpPayload()
}, null, 2)}\n`);
return 2;
}
const parsed = parseApplyPatchV2(patchText);
const plan = await planApplyPatchV2(options.executor, parsed.hunks);
for (const operation of plan.operations) {
await executePlannedOperation(options.executor, operation);
}
options.stdout.write("Success. Updated the following files:\n");
for (const item of plan.changed) options.stdout.write(`${item}\n`);
return 0;
}
async function planApplyPatchV2(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise<ApplyPatchV2Plan> {
const states = new Map<string, PlannedFileState>();
const operations: PlannedOperation[] = [];
const changed: string[] = [];
async function readPlannedText(filePath: string): Promise<string> {
const state = states.get(filePath);
if (state !== undefined) {
if (!state.exists) throw new ApplyPatchV2Error("cannot update a file deleted earlier in this patch", { path: filePath });
return state.content;
}
const content = await readRemoteText(executor, filePath);
states.set(filePath, { exists: true, content });
return content;
}
function planWrite(filePath: string, content: string): void {
states.set(filePath, { exists: true, content });
operations.push({ kind: "write", path: filePath, content });
}
function planDelete(filePath: string): void {
states.set(filePath, { exists: false, content: "" });
operations.push({ kind: "delete", path: filePath });
}
for (const hunk of hunks) {
if (hunk.kind === "add") {
planWrite(hunk.path, hunk.content);
changed.push(hunk.path);
continue;
}
if (hunk.kind === "delete") {
planDelete(hunk.path);
changed.push(hunk.path);
continue;
}
const originalContent = await readPlannedText(hunk.path);
const update = deriveUpdatedContent(hunk.path, originalContent, hunk.chunks);
if (hunk.movePath !== null && hunk.movePath !== hunk.path) {
planWrite(hunk.movePath, update.newContent);
planDelete(hunk.path);
changed.push(`${hunk.path} -> ${hunk.movePath}`);
continue;
}
planWrite(hunk.path, update.newContent);
changed.push(hunk.path);
}
return { operations, changed };
}
async function executePlannedOperation(executor: ApplyPatchV2Executor, operation: PlannedOperation): Promise<void> {
if (operation.kind === "write") {
await writeRemoteText(executor, operation.path, operation.content);
return;
}
await checkedRemoteV2(executor, "delete", [operation.path]);
}
async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise<string> {
const read = await checkedRemoteV2(executor, "read", [target]);
return read.stdout;
}
async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise<void> {
const contentBuffer = Buffer.from(content, "utf8");
const encoded = contentBuffer.toString("base64");
const expectedBytes = String(contentBuffer.length);
const expectedSha256 = sha256Hex(contentBuffer);
if (encoded.length <= 48_000) {
await checkedRemoteV2(executor, "write-b64-argv", [target, expectedBytes, expectedSha256, ...chunkString(encoded, 12_000)]);
return;
}
await checkedRemoteV2(executor, "write-b64-stdin", [target, expectedBytes, expectedSha256], encoded);
}
async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[], input?: string): Promise<{ stdout: string }> {
const result = await executor.run(remoteV2Script(operation, args), input);
if (result.exitCode === 0) return result;
throw new ApplyPatchV2Error("remote apply-patch v2 operation failed", {
operation,
args,
exitCode: result.exitCode,
stdout: result.stdout.slice(-2000),
stderr: result.stderr.slice(-4000),
});
}
function remoteV2Script(operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[]): string[] {
const script = [
"set -eu",
"sha256_file() {",
" if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$1\" | awk '{print $1}'; return; fi",
" if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$1\" | awk '{print $1}'; return; fi",
" if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -- \"$1\" | awk '{print $NF}'; return; fi",
" printf 'missing sha256 tool\\n' >&2",
" return 127",
"}",
"verify_tmp() {",
" target=$1",
" tmp=$2",
" expected_bytes=$3",
" expected_sha256=$4",
" actual_bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]')",
" if [ \"$actual_bytes\" != \"$expected_bytes\" ]; then",
" rm -f -- \"$tmp\"",
" printf 'v2 decoded byte count mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_bytes\" \"$actual_bytes\" >&2",
" exit 23",
" fi",
" actual_sha256=$(sha256_file \"$tmp\")",
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then",
" rm -f -- \"$tmp\"",
" printf 'v2 decoded sha256 mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_sha256\" \"$actual_sha256\" >&2",
" exit 24",
" fi",
"}",
"op=$1",
"shift",
"case \"$op\" in",
" read)",
" cat -- \"$1\"",
" ;;",
" write-b64-argv)",
" target=$1",
" expected_bytes=$2",
" expected_sha256=$3",
" shift 3",
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
" base=${target##*/}",
" dir=.",
" case \"$target\" in */*) dir=${target%/*};; esac",
" tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"",
" : > \"$tmp.b64\"",
" for chunk in \"$@\"; do printf '%s' \"$chunk\" >> \"$tmp.b64\"; done",
" base64 -d < \"$tmp.b64\" > \"$tmp\"",
" rm -f -- \"$tmp.b64\"",
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
" mv -f -- \"$tmp\" \"$target\"",
" actual_sha256=$(sha256_file \"$target\")",
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
" ;;",
" write-b64-stdin)",
" target=$1",
" expected_bytes=$2",
" expected_sha256=$3",
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
" base=${target##*/}",
" dir=.",
" case \"$target\" in */*) dir=${target%/*};; esac",
" tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"",
" base64 -d > \"$tmp\"",
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
" mv -f -- \"$tmp\" \"$target\"",
" actual_sha256=$(sha256_file \"$target\")",
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
" ;;",
" delete)",
" rm -f -- \"$1\"",
" ;;",
" move)",
" target=$2",
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
" mv -f -- \"$1\" \"$2\"",
" ;;",
" *)",
" printf 'unsupported op: %s\\n' \"$op\" >&2",
" exit 2",
" ;;",
"esac",
].join("\n");
return ["sh", "-c", script, "unidesk-apply-patch-v2", operation, ...args];
}
function sha256Hex(value: Buffer): string {
return createHash("sha256").update(value).digest("hex");
}
function chunkString(value: string, chunkSize: number): string[] {
const chunks: string[] = [];
for (let index = 0; index < value.length; index += chunkSize) {
chunks.push(value.slice(index, index + chunkSize));
}
return chunks.length > 0 ? chunks : [""];
}
function readStreamText(stream: Readable): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
stream.on("error", reject);
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
stream.resume();
});
}
function stripLenientHeredoc(text: string): string {
const trimmed = text.trim();
const lines = trimmed.length === 0 ? [] : trimmed.split(/\r?\n/u);
const first = lines[0] ?? "";
const last = lines[lines.length - 1] ?? "";
if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF") && lines.length >= 4) {
return lines.slice(1, -1).join("\n");
}
return text;
}
function validatePatchPath(value: string, line: number): string {
const filePath = value.trim();
if (filePath.length === 0) throw new ApplyPatchV2Error("patch path cannot be empty", { line });
if (path.isAbsolute(filePath)) throw new ApplyPatchV2Error("patch paths must be relative", { line, path: filePath });
if (filePath.split(/[\\/]+/u).includes("..")) throw new ApplyPatchV2Error("patch paths cannot contain ..", { line, path: filePath });
return filePath;
}
function isFileHeader(line: string): boolean {
const trimmed = line.trim();
return trimmed.startsWith(addFileMarker) || trimmed.startsWith(deleteFileMarker) || trimmed.startsWith(updateFileMarker) || trimmed === endMarker;
}
function parseUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean): { chunk: UpdateChunk; nextIndex: number } {
let index = startIndex;
let changeContext: string | null = null;
const first = lines[index] ?? "";
if (first === emptyChangeContextMarker) {
index += 1;
} else if (first.startsWith(changeContextMarker)) {
changeContext = first.slice(changeContextMarker.length);
index += 1;
} else if (!allowMissingContext) {
throw new ApplyPatchV2Error("expected update chunk to start with @@ context marker", { line: startIndex + 1, text: first });
}
const oldLines: string[] = [];
const newLines: string[] = [];
let parsed = 0;
let isEndOfFile = false;
while (index < lines.length - 1) {
const line = lines[index] ?? "";
if (isFileHeader(line)) break;
if (line === eofMarker) {
if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: index + 1 });
isEndOfFile = true;
index += 1;
break;
}
const marker = line[0] ?? "";
if (marker === " ") {
oldLines.push(line.slice(1));
newLines.push(line.slice(1));
} else if (marker === "+") {
newLines.push(line.slice(1));
} else if (marker === "-") {
oldLines.push(line.slice(1));
} else if (line.length === 0) {
oldLines.push("");
newLines.push("");
} else if (parsed > 0) {
break;
} else {
throw new ApplyPatchV2Error("unexpected line in update chunk", { line: index + 1, text: line });
}
parsed += 1;
index += 1;
}
if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: startIndex + 1 });
return { chunk: { changeContext, oldLines, newLines, isEndOfFile }, nextIndex: index };
}
function splitContentLines(content: string): string[] {
const lines = content.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines;
}
function joinLinesWithFinalNewline(lines: string[]): string {
if (lines.length === 0) return "";
return `${lines.join("\n")}\n`;
}
function computeReplacements(filePath: string, originalLines: string[], chunks: UpdateChunk[]): Replacement[] {
const replacements: Replacement[] = [];
let lineIndex = 0;
for (const [chunkIndex, chunk] of chunks.entries()) {
if (chunk.changeContext !== null) {
const foundContext = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
if (foundContext === null) {
throw new ApplyPatchV2Error("failed to find update context", { path: filePath, chunk: chunkIndex + 1, context: chunk.changeContext });
}
lineIndex = foundContext + 1;
}
if (chunk.oldLines.length === 0) {
const insertAt = originalLines.length > 0 && originalLines[originalLines.length - 1] === "" ? originalLines.length - 1 : originalLines.length;
replacements.push([insertAt, 0, chunk.newLines]);
continue;
}
let pattern = chunk.oldLines;
let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
let newLines = chunk.newLines;
if (found === null && pattern[pattern.length - 1] === "") {
pattern = pattern.slice(0, -1);
newLines = newLines[newLines.length - 1] === "" ? newLines.slice(0, -1) : newLines;
found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
}
if (found === null) {
throw new ApplyPatchV2Error("failed to find expected lines", {
path: filePath,
chunk: chunkIndex + 1,
expected: chunk.oldLines.join("\n"),
});
}
replacements.push([found, pattern.length, newLines]);
lineIndex = found + pattern.length;
}
assertNonOverlappingReplacements(filePath, replacements, originalLines.length);
return replacements;
}
function applyReplacements(lines: string[], replacements: Replacement[]): string[] {
const result = [...lines];
for (const [start, oldLen, newSegment] of [...replacements].reverse()) {
result.splice(start, oldLen, ...newSegment);
}
return result;
}
function assertNonOverlappingReplacements(filePath: string, replacements: Replacement[], lineCount: number): void {
const sorted = [...replacements].sort((left, right) => left[0] - right[0]);
let previousEnd = 0;
for (const [start, oldLen] of sorted) {
if (start < 0 || oldLen < 0 || start + oldLen > lineCount) {
throw new ApplyPatchV2Error("computed replacement is outside file bounds", {
path: filePath,
start,
oldLen,
lineCount,
});
}
if (start < previousEnd) {
throw new ApplyPatchV2Error("computed replacements overlap", {
path: filePath,
start,
previousEnd,
});
}
previousEnd = Math.max(previousEnd, start + oldLen);
}
}
function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): number | null {
if (pattern.length === 0) return start;
if (pattern.length > lines.length) return null;
const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start;
const attempts: Array<(value: string) => string> = [
(value) => value,
(value) => value.trimEnd(),
(value) => value.trim(),
normalizeLine,
];
for (const normalize of attempts) {
for (let index = searchStart; index <= lines.length - pattern.length; index += 1) {
let ok = true;
for (let offset = 0; offset < pattern.length; offset += 1) {
if (normalize(lines[index + offset] ?? "") !== normalize(pattern[offset] ?? "")) {
ok = false;
break;
}
}
if (ok) return index;
}
}
return null;
}
function normalizeLine(value: string): string {
return value.trim().replace(/[\u2010-\u2015\u2212]/gu, "-")
.replace(/[\u2018\u2019\u201A\u201B]/gu, "'")
.replace(/[\u201C\u201D\u201E\u201F]/gu, "\"")
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/gu, " ");
}
+10 -6
View File
@@ -20,14 +20,15 @@ export function rootHelp(): unknown {
{ command: "server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." },
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." },
{ command: "ssh <route> [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `G14:k3s` or `D601:win/c/test` only locates distributed targets." },
{ command: "ssh <providerId> apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
{ command: "ssh <route> v2 < patch.diff", description: "Preferred remote text patch entry: apply a standard patch with the local TypeScript v2 engine while the remote route only reads and writes files." },
{ command: "ssh <providerId> apply-patch [tool args...] < patch.diff", description: "Fallback to the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
{ command: "ssh <providerId> py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." },
{ command: "ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT' ...", description: "Run a remote shell script from local stdin using shell -s; the default sh inherits provider proxy env, so --shell bash is only for bash-specific syntax." },
{ command: "ssh <providerId> skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." },
{ command: "ssh <providerId> find <path...> [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." },
{ command: "ssh <providerId> glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." },
{ command: "ssh <providerId>:/absolute/workspace <operation args...>", description: "Route directly into a host workspace while keeping the operation parser independent from the location." },
{ command: "ssh <providerId>:k3s[:namespace:workload[:container]] <kubectl|logs|exec|script|apply-patch|command> ...", 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: "ssh <providerId>:k3s[:namespace:workload[:container]] <kubectl|logs|exec|script|v2|apply-patch|command> ...", 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: "ssh <providerId> argv <command> [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `ssh <providerId> script` when shell features are required." },
{ command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." },
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
@@ -148,6 +149,7 @@ export function sshHelp(): unknown {
usage: [
"bun scripts/cli.ts ssh <route>",
"bun scripts/cli.ts ssh <providerId> argv <command> [args...]",
"bun scripts/cli.ts ssh <providerId>:/absolute/workspace v2 < patch.diff",
"bun scripts/cli.ts ssh <providerId> apply-patch [--allow-loose] < patch.diff",
"bun scripts/cli.ts ssh <providerId> py [script-args...] < script.py",
"bun scripts/cli.ts ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT'",
@@ -165,6 +167,7 @@ export function sshHelp(): unknown {
"bun scripts/cli.ts ssh G14:k3s kubectl get pipelineruns -n hwlab-ci",
"bun scripts/cli.ts ssh D601:k3s script <<'SCRIPT'",
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app pwd",
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api/app v2 <<'PATCH'",
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'",
"tar -C /path/to/files -cf - . | bun scripts/cli.ts ssh D601:k3s:unidesk:code-queue/root/unidesk exec --stdin -- tar -xf - -C /root/unidesk",
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'",
@@ -174,10 +177,11 @@ export function sshHelp(): unknown {
notes: [
"ssh --help and ssh <route> --help print this JSON help and never open an interactive session.",
"For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.",
"For one-line remote shell logic without a heredoc, use `shell '<command && command>'`; outer shell operators written outside tran, such as `tran G14:/repo sed ... && sed ...`, are parsed by the local shell before UniDesk starts and therefore cannot be redirected by the CLI.",
"When a short remote command is easier to type through the script path and needs dash-prefixed argv, write `script -- <command> [args...]`; this direct form does not wait for stdin, and the command-local `--` is preserved by local and remote `tran` parsing, so examples such as `tran D601:/work script -- sed -n '1,20p' file` do not require a heredoc just to pass `-n`.",
"For one-line remote shell logic without a heredoc, use `script -- '<command && command>'`; outer shell operators written outside tran, such as `tran G14:/repo sed ... && sed ...`, are parsed by the local shell before UniDesk starts and therefore cannot be redirected by the CLI. The explicit `shell '<command>'` operation remains available for the same sh -c path.",
"When a one-line shell command is easier to type through the script path, `script -- '<command && command>'` 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 `tran D601:/work script -- sed -n '1,20p' file`.",
"For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- <command> ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.",
"apply-patch rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.",
"For remote text patches, prefer `v2`; it keeps the legacy apply-patch command unchanged but uses a local line-based patch engine and remote read/write operations, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser.",
"Legacy apply-patch is a fallback: 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; use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.",
"Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `<provider>:/absolute/workspace`; WSL providers can use `<provider>:win cmd <command-line>` to run Windows host cmd.exe with UTF-8 defaults, and `<provider>:win/c/test cmd cd` maps the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use <provider>:k3s for the control plane, <provider>:k3s:<namespace>:<workload> for a workload, and <provider>:k3s:<namespace>:<workload>/<pod-workspace> for a pod workspace.",
"Use `win`, not `win32`; the win route sets chcp 65001, PYTHONUTF8=1, and PYTHONIOENCODING=utf-8 before running the requested cmd command line.",
@@ -186,7 +190,7 @@ export function sshHelp(): unknown {
"Do not use post-provider shorthand such as `ssh G14 k3s ...`; write `ssh G14:k3s ...` so location and operation stay separated.",
"If an ssh-like remote command fails with timeout/kex/exit-255 friction, stderr includes one low-noise UNIDESK_SSH_HINT JSON line with the argv retry command.",
"Non-interactive ssh/tran operations have a hard top-level runtime timeout capped at 60s. Timeout writes UNIDESK_SSH_RUNTIME_TIMEOUT or UNIDESK_TRAN_TIMEOUT_HINT and disconnects the broker; long CI/CD, trace, logs, build, or hardware work must use submit-and-poll / short query loops instead of keeping tran open.",
"Only slow ssh/tran runtime writes UNIDESK_SSH_TIMING JSON to stderr; operations over 10s are marked level=warning even when they succeed, because slow successful calls are a distributed performance monitoring signal. Check provider latency, remote command cost, helper bootstrap, or tran/apply-patch optimization before repeating high-frequency work. Routine short calls do not emit timing noise.",
"Only slow ssh/tran runtime writes UNIDESK_SSH_TIMING JSON to stderr; operations over 10s are marked level=warning even when they succeed, because slow successful calls are a distributed performance monitoring signal. Check provider latency, remote command cost, helper bootstrap, or remote patch optimization before repeating high-frequency work. Routine short calls do not emit timing noise.",
"The local tran wrapper must not add provider/plane directory locks; rely on k8s/Tekton/Argo/Lease or server-side TTL queues for coordination.",
"Use -- before a remote command that intentionally starts with a dash.",
],
+173
View File
@@ -11,13 +11,16 @@ import {
formatSshRuntimeTimeoutHint,
formatSshRuntimeTimingHint,
parseSshInvocation,
remoteCommandForRoute,
sshFailureHint,
sshRoutePayloadCwd,
sshRuntimeTimeoutHint,
sshRuntimeTimeoutMs,
sshRuntimeTimingHint,
wrapSshRemoteCommand,
type SshCaptureResult,
} from "./ssh";
import { runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2";
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync, codexUnreadTriageAsync } from "./code-queue";
import { runDecisionCenterCommandAsync } from "./decision-center";
import {
@@ -83,6 +86,7 @@ const portOptions = new Set(["--main-server-port", "--server-port"]);
const rootOptions = new Set(["--main-server-root", "--server-root"]);
const keyOptions = new Set(["--main-server-key", "--server-key"]);
const transportOptions = new Set(["--main-server-transport", "--server-transport"]);
const remoteSshInputChunkBytes = 32 * 1024;
function positivePort(raw: string, option: string): number {
const value = Number(raw);
@@ -943,6 +947,12 @@ async function runRemoteSshWebSocket(
const sendInput = (value: Buffer | string): void => {
sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" });
};
const sendInputChunked = (value: string): void => {
const buffer = Buffer.from(value, "utf8");
for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) {
sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes)));
}
};
const sendWhenSessionReady = (value: unknown): void => {
const text = JSON.stringify(value);
if (!sessionReady || ws.readyState !== WebSocket.OPEN) {
@@ -1069,6 +1079,163 @@ async function runRemoteSshWebSocket(
});
}
async function runRemoteSshWebSocketCapture(
session: FrontendSession,
invocation: ReturnType<typeof parseSshInvocation>,
command: string[],
input?: string,
): Promise<SshCaptureResult> {
const remoteCommand = remoteCommandForRoute(invocation.route, command);
const captureInvocation = {
...invocation,
parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const },
};
const startedAtMs = Date.now();
const size = {
cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100,
rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30,
};
const runtimeTimeoutMs = sshRuntimeTimeoutMs();
const payload = {
providerId: invocation.providerId,
command: wrapSshRemoteCommand(remoteCommand),
cwd: sshRoutePayloadCwd(invocation.route),
tty: false,
stdinEotOnEnd: false,
openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)),
runtimeTimeoutMs,
cols: size.cols,
rows: size.rows,
};
const ws = openFrontendSshWebSocket(session);
let exitCode = 255;
let settled = false;
let canSend = false;
let sessionReady = false;
let stdout = "";
let stderr = "";
const pending: string[] = [];
const pendingSessionMessages: string[] = [];
const send = (value: unknown): void => {
const text = JSON.stringify(value);
if (!canSend || ws.readyState !== WebSocket.OPEN) {
pending.push(text);
return;
}
ws.send(text);
};
const sendWhenSessionReady = (value: unknown): void => {
const text = JSON.stringify(value);
if (!sessionReady || ws.readyState !== WebSocket.OPEN) {
pendingSessionMessages.push(text);
return;
}
ws.send(text);
};
const sendInput = (value: Buffer | string): void => {
sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" });
};
const flush = (): void => {
while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!);
};
const flushSessionMessages = (): void => {
if (!sessionReady || ws.readyState !== WebSocket.OPEN) return;
while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!);
};
return await new Promise<SshCaptureResult>((resolve) => {
let killTimer: ReturnType<typeof setTimeout> | null = null;
const finish = (code: number): void => {
if (settled) return;
settled = true;
clearTimeout(openTimer);
clearTimeout(runtimeTimer);
if (killTimer !== null) clearTimeout(killTimer);
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
invocation: captureInvocation,
transport: "frontend-websocket",
exitCode: code,
startedAtMs,
}));
if (timingHint) stderr += timingHint;
resolve({ exitCode: code, stdout, stderr });
};
const openTimer = setTimeout(() => {
if (sessionReady || settled) return;
stderr += "unidesk remote frontend ssh bridge timed out waiting for provider session\n";
exitCode = 255;
try {
ws.close();
} catch {
// Ignore.
}
}, payload.openTimeoutMs);
const runtimeTimer = setTimeout(() => {
if (settled) return;
exitCode = 124;
stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
invocation: captureInvocation,
transport: "frontend-websocket",
timeoutMs: runtimeTimeoutMs,
}));
try {
ws.close();
} catch {
// Ignore.
}
killTimer = setTimeout(() => finish(124), 2000);
finish(124);
}, runtimeTimeoutMs);
ws.addEventListener("open", () => {
canSend = true;
send({ type: "ssh.open", ...payload });
flush();
});
ws.addEventListener("message", (event) => {
const text = webSocketDataText(event.data);
let message: Record<string, unknown>;
try {
message = JSON.parse(text) as Record<string, unknown>;
} catch {
stderr += `${text}\n`;
return;
}
if (message.type === "ssh.dispatched") return;
if (message.type === "ssh.opened") {
sessionReady = true;
clearTimeout(openTimer);
if (input !== undefined) sendInputChunked(input);
sendWhenSessionReady({ type: "ssh.eof" });
flushSessionMessages();
return;
}
if (message.type === "ssh.data") {
const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8").toString("utf8");
if (message.stream === "stderr") stderr += chunk;
else stdout += chunk;
return;
}
if (message.type === "ssh.error") {
stderr += `${String(message.message || "ssh bridge error")}\n`;
exitCode = 255;
ws.close();
return;
}
if (message.type === "ssh.exit") {
exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255;
ws.close();
}
});
ws.addEventListener("close", () => finish(exitCode));
ws.addEventListener("error", () => {
stderr += "unidesk remote frontend ssh bridge websocket error\n";
finish(255);
});
});
}
export function remoteSshFrontendPlanForTest(target: string, args: string[]): Record<string, unknown> {
const invocation = parseSshInvocation(target, args);
return {
@@ -1086,6 +1253,12 @@ export function remoteSshFrontendPlanForTest(target: string, args: string[]): Re
async function runRemoteSshOverFrontend(session: FrontendSession, target: string | undefined, args: string[]): Promise<number> {
if (!target) throw new Error("remote ssh requires a route, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname");
const invocation = parseSshInvocation(target, args);
if ((args[0] ?? "") === "v2") {
const executor: ApplyPatchV2Executor = {
run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input),
};
return await runApplyPatchV2({ executor, stdin: process.stdin, stdout: process.stdout, argv: args.slice(1) });
}
return runRemoteSshWebSocket(session, invocation);
}
+147 -4
View File
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import { type UniDeskConfig, repoRoot } from "./config";
import { isApplyPatchV2HelpArgs, runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2";
export interface ParsedSshArgs {
remoteCommand: string | null;
@@ -30,6 +31,12 @@ export interface ParsedSshInvocation {
parsed: ParsedSshArgs;
}
export interface SshCaptureResult {
exitCode: number;
stdout: string;
stderr: string;
}
export interface SshFailureHint {
code: "ssh-like-command-friction";
providerId: string;
@@ -86,6 +93,7 @@ const legacyK3sOperationRouteSegments = new Set([
"exec",
"script",
"apply-patch",
"v2",
"logs",
"get",
"describe",
@@ -821,6 +829,12 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
const toolArgs = ["apply_patch", ...args.slice(1)];
return { remoteCommand: shellArgv(toolArgs), requiresStdin: true, invocationKind: "helper", requiredHelpers: ["apply_patch"] };
}
if (subcommand === "v2") {
if (isApplyPatchV2HelpArgs(args.slice(1))) {
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
}
return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
}
if (subcommand === "py") {
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
}
@@ -1153,11 +1167,11 @@ function k3sOperationInRouteMessage(target: string, operation: string): string {
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:<namespace>:<workload> ${operationExample}" instead of "${target}"`;
}
function shellArgv(args: string[]): string {
export function shellArgv(args: string[]): string {
return args.map(shellQuote).join(" ");
}
function shellQuote(value: string): string {
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
@@ -1272,6 +1286,9 @@ function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): P
if (operation === "apply-patch" || operation === "patch") {
throw new Error(`ssh ${route.providerId}:k3s apply-patch requires a workload route: ssh ${route.providerId}:k3s:<namespace>:<workload> apply-patch`);
}
if (operation === "v2") {
throw new Error(`ssh ${route.providerId}:k3s v2 requires a workload route: ssh ${route.providerId}:k3s:<namespace>:<workload> v2`);
}
if (operation === "script" || operation === "sh") {
return { remoteCommand: buildK3sScriptCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
}
@@ -1298,6 +1315,7 @@ function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedS
const operation = args[0] ?? "";
const operationArgs = args.slice(1);
if (operation === "apply-patch" || operation === "patch") return buildK3sApplyPatchCommand([...targetArgs, ...operationArgs]);
if (operation === "v2") return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
if (operation === "script") return { remoteCommand: buildK3sScriptCommand([...targetArgs, ...operationArgs]), requiresStdin: true, invocationKind: "helper" };
if (operation === "shell") {
const parsed = parseShellStringOperationArgs(operationArgs, `ssh ${route.raw} shell`);
@@ -1326,6 +1344,10 @@ function buildK3sTargetObjectCommand(action: "get" | "describe", route: ParsedSs
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", action, "-n", route.namespace, normalizeK3sRouteResource(route.resource), ...args]);
}
function buildK3sTargetCommand(route: ParsedSshRoute, command: string[]): string {
return buildK3sExecCommand([...k3sRouteTargetArgs(route), "--", ...command]);
}
function k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
if (route.namespace === null) throw new Error(`ssh route ${route.raw} requires a namespace segment`);
if (route.resource === null) throw new Error(`ssh route ${route.raw} requires a workload or pod segment`);
@@ -1701,6 +1723,7 @@ function buildShellCommand(args: string[]): ParsedSshArgs {
}
if (directArgvMode) {
if (scriptArgs.length === 0) throw new Error("ssh script -- requires a command");
if (scriptArgs.length === 1) return { remoteCommand: shellArgv([shell, "-c", scriptArgs[0] ?? ""]), requiresStdin: false, invocationKind: "helper" };
return { remoteCommand: shellArgv(scriptArgs), requiresStdin: false, invocationKind: "argv" };
}
return { remoteCommand: shellArgv([shell, "-s", "--", ...scriptArgs]), requiresStdin: true, invocationKind: "helper" };
@@ -2032,9 +2055,127 @@ function terminalSize(): { cols: number; rows: number } {
};
}
export function remoteCommandForRoute(route: ParsedSshRoute, command: string[]): string {
if (route.plane === "k3s") return buildK3sTargetCommand(route, command);
if (route.plane === "win") throw new Error(`ssh v2 does not support win routes yet: ${route.raw}`);
return shellArgv(command);
}
async function runSshCaptureCommand(config: UniDeskConfig, invocation: ParsedSshInvocation, command: string[], input?: string): Promise<SshCaptureResult> {
const startedAtMs = Date.now();
const remoteCommand = remoteCommandForRoute(invocation.route, command);
const size = terminalSize();
const runtimeTimeoutMs = sshRuntimeTimeoutMs();
const payload = {
providerId: invocation.providerId,
command: wrapSshRemoteCommand(remoteCommand),
cwd: sshRoutePayloadCwd(invocation.route),
tty: false,
stdinEotOnEnd: false,
openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)),
runtimeTimeoutMs,
cols: size.cols,
rows: size.rows,
};
const payloadJson = JSON.stringify(payload);
const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64");
const script = [
"set -eu",
`payload=${shellQuote(payloadJson)}`,
"if command -v backend-core >/dev/null 2>&1; then",
' exec backend-core --ssh-broker "$payload"',
"fi",
`export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`,
'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"',
'trap \'rm -f "$broker_js"\' EXIT',
`printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`,
'bun "$broker_js" "$payload"',
].join("\n");
const child = spawn("docker", ["exec", "-i", "unidesk-backend-core", "sh", "-c", script], {
cwd: repoRoot,
stdio: ["pipe", "pipe", "pipe"],
});
if (input !== undefined) {
writeChunkedStdin(child.stdin, input);
} else {
child.stdin.end();
}
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
return await new Promise<SshCaptureResult>((resolve) => {
let settled = false;
let killTimer: NodeJS.Timeout | null = null;
const finish = (exitCode: number): void => {
if (settled) return;
settled = true;
clearTimeout(runtimeTimer);
if (killTimer !== null) clearTimeout(killTimer);
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
invocation,
transport: "backend-core-broker",
exitCode,
startedAtMs,
}));
if (timingHint) stderr += timingHint;
resolve({ exitCode, stdout, stderr });
};
const runtimeTimer = setTimeout(() => {
const hint = formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({
invocation,
transport: "backend-core-broker",
timeoutMs: runtimeTimeoutMs,
}));
stderr += hint;
try {
child.kill("SIGTERM");
} catch {
// Ignore.
}
killTimer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// Ignore.
}
}, 2000);
finish(124);
}, runtimeTimeoutMs);
child.on("error", (error) => {
stderr += `unidesk ssh failed to start broker: ${error.message}\n`;
finish(255);
});
child.on("close", (code) => finish(code ?? 255));
});
}
function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void {
const buffer = Buffer.from(input, "utf8");
const chunkSize = 32 * 1024;
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
stdin.write(buffer.subarray(offset, Math.min(buffer.length, offset + chunkSize)));
}
stdin.end();
}
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
const invocation = parseSshInvocation(providerId, args);
const parsed = invocation.parsed;
const operationName = args[0] ?? "";
if (operationName === "v2") {
const executor: ApplyPatchV2Executor = { run: (command, input) => runSshCaptureCommand(config, invocation, command, input) };
return await runApplyPatchV2({
executor,
stdin: process.stdin,
stdout: process.stdout,
argv: args.slice(1),
});
}
const startedAtMs = Date.now();
const size = terminalSize();
const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000));
@@ -2059,8 +2200,10 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
' exec backend-core --ssh-broker "$payload"',
"fi",
`export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`,
`printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >/tmp/unidesk-ssh-broker.js`,
"exec bun /tmp/unidesk-ssh-broker.js \"$payload\"",
'broker_js="$(mktemp "${TMPDIR:-/tmp}/unidesk-ssh-broker.XXXXXX")"',
'trap \'rm -f "$broker_js"\' EXIT',
`printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >"$broker_js"`,
'bun "$broker_js" "$payload"',
].join("\n");
const child = spawn("docker", [
"exec",
+332 -3
View File
@@ -1,8 +1,11 @@
import { PassThrough, Writable } from "node:stream";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { sshHelp } from "./src/help";
import { runApplyPatchV2 } from "./src/apply-patch-v2";
import { providerTriageRecommendedCrossChecks } from "./src/provider-triage";
import { extractRemoteCliOptions, remoteSshFrontendPlanForTest } from "./src/remote";
import {
@@ -35,6 +38,10 @@ function assertThrows(fn: () => unknown, pattern: RegExp, message: string): void
throw new Error(`${message}: expected throw`);
}
function sha256Hex(value: string): string {
return createHash("sha256").update(Buffer.from(value, "utf8")).digest("hex");
}
function decodeWinEncodedCommand(remoteCommand: string | null | undefined): string {
const text = String(remoteCommand ?? "");
const match = /'-EncodedCommand' '([^']+)'/u.exec(text);
@@ -71,7 +78,135 @@ function applyPatchFixture(args: string[], patch: string, files: Record<string,
}
}
export function runSshArgvGuidanceContract(): JsonRecord {
async function applyPatchV2FixtureAttempt(patch: string, files: Record<string, string>): Promise<{ stdout: string; files: Record<string, string>; commands: string[]; error: unknown | null }> {
const state = new Map(Object.entries(files));
const commands: string[] = [];
const stdin = new PassThrough();
stdin.end(patch);
let stdout = "";
const stdoutSink = new Writable({
write(chunk, _encoding, callback) {
stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
callback();
},
});
let error: unknown | null = null;
try {
await runApplyPatchV2({
stdin,
stdout: stdoutSink,
executor: {
async run(command, input) {
const operation = command[4] ?? "";
const target = command[5] ?? "";
commands.push([operation, ...command.slice(5)].join(" "));
if (operation === "read") {
if (!state.has(target)) return { exitCode: 1, stdout: "", stderr: `missing ${target}` };
return { exitCode: 0, stdout: state.get(target) ?? "", stderr: "" };
}
if (operation === "write-b64-argv") {
const expectedBytes = Number(command[6] ?? "-1");
const expectedSha256 = command[7] ?? "";
const content = Buffer.from(command.slice(8).join(""), "base64").toString("utf8");
if (Buffer.byteLength(content, "utf8") !== expectedBytes || sha256Hex(content) !== expectedSha256) {
return { exitCode: 23, stdout: "", stderr: "mock integrity mismatch" };
}
state.set(target, content);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-stdin") {
const expectedBytes = Number(command[6] ?? "-1");
const expectedSha256 = command[7] ?? "";
const content = Buffer.from(input ?? "", "base64").toString("utf8");
if (Buffer.byteLength(content, "utf8") !== expectedBytes || sha256Hex(content) !== expectedSha256) {
return { exitCode: 23, stdout: "", stderr: "mock integrity mismatch" };
}
state.set(target, content);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "delete") {
state.delete(target);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "move") {
state.set(command[6] ?? "", state.get(target) ?? "");
state.delete(target);
return { exitCode: 0, stdout: "", stderr: "" };
}
return { exitCode: 2, stdout: "", stderr: "bad op" };
},
},
});
} catch (caught) {
error = caught;
}
return { stdout, files: Object.fromEntries(state), commands, error };
}
async function applyPatchV2ActualShellFixtureAttempt(
patch: string,
files: Record<string, string>,
mutateInput?: (operation: string, input: string | undefined) => string | undefined,
): Promise<{ stdout: string; files: Record<string, string>; commands: string[]; error: unknown | null }> {
const root = mkdtempSync(path.join(os.tmpdir(), "unidesk-apply-patch-v2-shell-"));
const commands: string[] = [];
const stdin = new PassThrough();
stdin.end(patch);
let stdout = "";
const stdoutSink = new Writable({
write(chunk, _encoding, callback) {
stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
callback();
},
});
try {
for (const [relativePath, content] of Object.entries(files)) {
const target = path.join(root, relativePath);
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, content, "utf8");
}
let error: unknown | null = null;
try {
await runApplyPatchV2({
stdin,
stdout: stdoutSink,
executor: {
async run(command, input) {
const operation = command[4] ?? "";
commands.push([operation, ...command.slice(5)].join(" "));
const run = spawnSync(command[0] ?? "sh", command.slice(1), {
cwd: root,
input: mutateInput ? mutateInput(operation, input) : input,
encoding: "utf8",
});
return {
exitCode: run.status ?? 255,
stdout: run.stdout,
stderr: run.stderr,
};
},
},
});
} catch (caught) {
error = caught;
}
const outputFiles: Record<string, string> = {};
for (const relativePath of Object.keys(files)) {
outputFiles[relativePath] = readFileSync(path.join(root, relativePath), "utf8");
}
return { stdout, files: outputFiles, commands, error };
} finally {
rmSync(root, { recursive: true, force: true });
}
}
async function applyPatchV2Fixture(patch: string, files: Record<string, string>): Promise<{ stdout: string; files: Record<string, string>; commands: string[] }> {
const result = await applyPatchV2FixtureAttempt(patch, files);
if (result.error !== null) throw result.error;
return { stdout: result.stdout, files: result.files, commands: result.commands };
}
export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
const argv = parseSshArgs(["argv", "true"]);
assertCondition(argv.invocationKind === "argv", "argv subcommand must be classified as argv", argv);
assertCondition(argv.remoteCommand === "'true'", "argv command must shell-quote each token", argv);
@@ -129,6 +264,11 @@ export function runSshArgvGuidanceContract(): JsonRecord {
assertCondition(directScriptCommand.remoteCommand === "'sed' '-n' '1,2p' 'file.txt'", "script -- command form must preserve dash-prefixed command args", directScriptCommand);
assertCondition(directScriptCommand.requiresStdin === false, "script -- command form must not wait for stdin", directScriptCommand);
const directScriptOneLiner = parseSshArgs(["script", "--", "cd /root/hwlab && git status --short --branch"]);
assertCondition(directScriptOneLiner.invocationKind === "helper", "script -- single-string command should run through a remote shell", directScriptOneLiner);
assertCondition(directScriptOneLiner.remoteCommand === "'sh' '-c' 'cd /root/hwlab && git status --short --branch'", "script -- single-string command should match the intuitive remote shell one-liner form", directScriptOneLiner);
assertCondition(directScriptOneLiner.requiresStdin === false, "script -- single-string command should not wait for stdin", directScriptOneLiner);
const shellOneLiner = parseSshArgs(["shell", "sed -n '1,2p' a && sed -n '1,2p' b"]);
assertCondition(shellOneLiner.invocationKind === "helper", "shell one-liner must be a helper operation", shellOneLiner);
assertCondition(shellOneLiner.remoteCommand === "'sh' '-c' 'sed -n '\\''1,2p'\\'' a && sed -n '\\''1,2p'\\'' b'", "shell one-liner must keep command operators inside the remote shell", shellOneLiner);
@@ -202,6 +342,191 @@ export function runSshArgvGuidanceContract(): JsonRecord {
assertCondition(hostApplyPatchLoose.requiredHelpers?.length === 1 && hostApplyPatchLoose.requiredHelpers.includes("apply_patch"), "host apply-patch must request only the apply_patch helper bootstrap", hostApplyPatchLoose);
assertCondition(remoteApplyPatchSource.includes("replace_once_with_perl") && remoteApplyPatchSource.includes("perl -0777"), "apply_patch helper must keep a fast path for large files", {});
const hostApplyPatchV2 = parseSshArgs(["v2"]);
assertCondition(hostApplyPatchV2.requiresStdin === true && hostApplyPatchV2.requiredHelpers === undefined && hostApplyPatchV2.remoteCommand === null, "host v2 must be a local engine operation, not a remote helper bootstrap", hostApplyPatchV2);
const hostApplyPatchV2Help = parseSshArgs(["v2", "--help"]);
assertCondition(hostApplyPatchV2Help.requiresStdin === false && hostApplyPatchV2Help.remoteCommand === null, "host v2 --help must not wait for patch stdin", hostApplyPatchV2Help);
const podApplyPatchV2 = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api/app", ["v2"]);
assertCondition(podApplyPatchV2.parsed.requiresStdin === true && podApplyPatchV2.parsed.remoteCommand === null, "pod v2 must be handled by the local v2 engine instead of injecting the legacy helper", podApplyPatchV2);
const longChinesePatch = await applyPatchV2Fixture([
"*** Begin Patch",
"*** Update File: story.md",
"@@",
"+这是一个很长很长的中文段落,用来证明远端 v2 不再依赖 shell hunk 拼接和手写长中文 search block。它只是通过本地行级 patch engine 计算新内容,然后把完整文件写回远端,所以中文、标点、长句都不应该影响 patch 解析和匹配。",
"*** End Patch",
"",
].join("\n"), {
"story.md": "开头\n",
});
assertCondition(longChinesePatch.files["story.md"]?.includes("很长很长的中文段落"), "v2 should accept pure insertion with long Chinese text", longChinesePatch);
assertCondition(longChinesePatch.stdout.includes("Success. Updated the following files:"), "v2 must print visible success output", longChinesePatch);
const lowContextV1Baseline = applyPatchFixture([], [
"*** Begin Patch",
"*** Update File: story.md",
"@@",
" 开头",
"+低上下文纯插入在 v1 会失败,但 v2 应该按 Codex 行级语义允许。",
"*** End Patch",
"",
].join("\n"), {
"story.md": "开头\n结尾\n",
});
assertCondition(lowContextV1Baseline.status !== 0 && lowContextV1Baseline.stderr.includes("insert-only without both leading and trailing context"), "v1 baseline should reject low-context pure insertion", lowContextV1Baseline);
const lowContextV2 = await applyPatchV2Fixture([
"*** Begin Patch",
"*** Update File: story.md",
"@@",
" 开头",
"+低上下文纯插入在 v1 会失败,但 v2 应该按 Codex 行级语义允许。",
"*** End Patch",
"",
].join("\n"), {
"story.md": "开头\n结尾\n",
});
assertCondition(lowContextV2.files["story.md"]?.includes("v2 应该按 Codex 行级语义允许"), "v2 should fix v1 low-context insertion friction", lowContextV2);
assertCondition(lowContextV2.commands.some((command) => command.includes("write-b64-argv")), "v2 should use argv write path for small remote files to work inside k3s pod exec capture", lowContextV2);
const unicodePunctuationV1Baseline = applyPatchFixture([], [
"*** Begin Patch",
"*** Update File: notes.txt",
"@@",
"-alpha - beta",
"+alpha - gamma",
"*** End Patch",
"",
].join("\n"), {
"notes.txt": "alpha beta\n",
});
assertCondition(unicodePunctuationV1Baseline.status !== 0, "v1 baseline should miss ASCII dash against typographic dash", unicodePunctuationV1Baseline);
const unicodePunctuationV2 = await applyPatchV2Fixture([
"*** Begin Patch",
"*** Update File: notes.txt",
"@@",
"-alpha - beta",
"+alpha - gamma",
"*** End Patch",
"",
].join("\n"), {
"notes.txt": "alpha beta\n",
});
assertCondition(unicodePunctuationV2.files["notes.txt"] === "alpha - gamma\n", "v2 should normalize common Unicode punctuation while matching expected lines", unicodePunctuationV2);
const repeatedBlockWithContext = await applyPatchV2Fixture([
"*** Begin Patch",
"*** Update File: repeated.txt",
"@@ section two",
"-marker",
"+patched",
"*** End Patch",
"",
].join("\n"), {
"repeated.txt": "section one\nmarker\nsection two\nmarker\n",
});
assertCondition(repeatedBlockWithContext.files["repeated.txt"] === "section one\nmarker\nsection two\npatched\n", "v2 should use @@ context to target repeated blocks", repeatedBlockWithContext);
const longChineseReplace = await applyPatchV2Fixture([
"*** Begin Patch",
"*** Update File: novel.md",
"@@",
"-林深在透明的舷窗前停下脚步,远处的群星像被压进黑色玻璃里的碎银,安静得让人怀疑整个宇宙都屏住了呼吸。",
"+林深在透明的舷窗前停下脚步,远处的群星像被压进黑色玻璃里的碎银,安静得让人怀疑整个宇宙正在等待他重新命名。",
"*** End Patch",
"",
].join("\n"), {
"novel.md": "林深在透明的舷窗前停下脚步,远处的群星像被压进黑色玻璃里的碎银,安静得让人怀疑整个宇宙都屏住了呼吸。\n",
});
assertCondition(longChineseReplace.files["novel.md"]?.includes("等待他重新命名"), "v2 should replace long Chinese lines without remote shell search blocks", longChineseReplace);
const largeV2 = await applyPatchV2Fixture([
"*** Begin Patch",
"*** Update File: large.txt",
"@@",
"+large insert",
"*** End Patch",
"",
].join("\n"), {
"large.txt": `${"0123456789abcdef\n".repeat(4096)}`,
});
assertCondition(largeV2.commands.some((command) => command.includes("write-b64-stdin")), "v2 should use stdin write path for large remote files to avoid E2BIG", largeV2.commands);
const multiChunkTailV2 = await applyPatchV2ActualShellFixtureAttempt([
"*** Begin Patch",
"*** Update File: two_chunks.txt",
"@@",
"-b",
"+B",
"@@",
"-d",
"+D",
"*** End Patch",
"",
].join("\n"), {
"two_chunks.txt": "a\nb\nc\nd\ne\nf\n",
});
assertCondition(multiChunkTailV2.error === null, "v2 should apply explicit multi-chunk patches through the real shell writer", multiChunkTailV2);
assertCondition(multiChunkTailV2.files["two_chunks.txt"] === "a\nB\nc\nD\ne\nf\n", "v2 must preserve untouched tail lines when applying multiple chunks", multiChunkTailV2);
const truncatedLargeWriteV2 = await applyPatchV2ActualShellFixtureAttempt([
"*** Begin Patch",
"*** Update File: large.txt",
"@@",
"+large insert that forces a rewritten full-file payload",
"*** End Patch",
"",
].join("\n"), {
"large.txt": `${"0123456789abcdef\n".repeat(4096)}`,
}, (operation, input) => {
if (operation !== "write-b64-stdin" || input === undefined) return input;
return input.slice(0, Math.max(0, input.length - 32));
});
assertCondition(truncatedLargeWriteV2.error !== null, "v2 should reject truncated stdin write payloads", truncatedLargeWriteV2);
assertCondition(truncatedLargeWriteV2.files["large.txt"] === `${"0123456789abcdef\n".repeat(4096)}`, "v2 must keep the original file when decoded payload integrity fails", truncatedLargeWriteV2);
assertCondition(
String((truncatedLargeWriteV2.error as Error | null)?.message ?? "").includes("remote apply-patch v2 operation failed"),
"v2 truncated payload failure should be visible to the caller",
truncatedLargeWriteV2,
);
const failedCompoundV2 = await applyPatchV2FixtureAttempt([
"*** Begin Patch",
"*** Update File: first.txt",
"@@",
"-old first",
"+new first",
"*** Update File: second.txt",
"@@",
"-missing second",
"+new second",
"*** End Patch",
"",
].join("\n"), {
"first.txt": "old first\n",
"second.txt": "old second\n",
});
assertCondition(failedCompoundV2.error !== null, "v2 compound patch should fail when a later hunk does not match", failedCompoundV2);
assertCondition(failedCompoundV2.files["first.txt"] === "old first\n", "v2 must not partially write earlier files when a later hunk fails", failedCompoundV2);
assertCondition(failedCompoundV2.files["second.txt"] === "old second\n", "v2 must leave later failed files unchanged", failedCompoundV2);
assertCondition(!failedCompoundV2.commands.some((command) => command.startsWith("write-b64") || command.startsWith("delete")), "v2 must finish all local planning before any remote mutation", failedCompoundV2.commands);
const sequentialCompoundV2 = await applyPatchV2Fixture([
"*** Begin Patch",
"*** Update File: sequence.txt",
"@@",
"-alpha",
"+beta",
"*** Update File: sequence.txt",
"@@",
"-beta",
"+gamma",
"*** End Patch",
"",
].join("\n"), {
"sequence.txt": "alpha\n",
});
assertCondition(sequentialCompoundV2.files["sequence.txt"] === "gamma\n", "v2 should plan later hunks against earlier planned edits before remote writes", sequentialCompoundV2);
const safePatch = applyPatchFixture([], [
"*** Begin Patch",
"*** Update File: sample.txt",
@@ -411,6 +736,9 @@ export function runSshArgvGuidanceContract(): JsonRecord {
assertCondition(String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools"), "host apply-patch must bootstrap the remote apply_patch helper", frontendRemoteHostPatchPlan);
assertCondition(String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("/apply_patch") && !String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("/glob") && !String(frontendRemoteHostPatchPlan.wrappedRemoteCommand ?? "").includes("/skill-discover"), "host apply-patch must not bootstrap unrelated helper tools", frontendRemoteHostPatchPlan);
const frontendRemoteV2Plan = remoteSshFrontendPlanForTest("D601:/tmp", ["v2"]);
assertCondition(frontendRemoteV2Plan.requiresStdin === true && frontendRemoteV2Plan.remoteCommand === null && !String(frontendRemoteV2Plan.wrappedRemoteCommand ?? "").includes("UNIDESK_SSH_TOOL_DIR"), "frontend v2 plan must stay a local engine operation and not bootstrap legacy helpers", frontendRemoteV2Plan);
const frontendRemotePodArgvPlan = remoteSshFrontendPlanForTest("G14:k3s:unidesk:code-queue", ["argv", "sh", "-c", "command -v tran"]);
assertCondition(frontendRemotePodArgvPlan.providerId === "G14", "remote frontend pod route must dispatch through G14 provider", frontendRemotePodArgvPlan);
assertCondition(frontendRemotePodArgvPlan.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'unidesk' 'deployment/code-queue' '--' 'sh' '-c' 'command -v tran'", "remote frontend pod argv route must be fully assembled before dispatch", frontendRemotePodArgvPlan);
@@ -454,7 +782,7 @@ export function runSshArgvGuidanceContract(): JsonRecord {
checks: [
"argv form is classified and quoted as the success path for non-interactive commands",
"stdin script form removes shell-command strings for host and k3s workload scripts",
"script -- command form executes dash-prefixed argv without waiting for stdin",
"script -- single-string runs as a remote shell one-liner while multi-token form keeps dash-prefixed argv",
"pod apply-patch operation injects helper and forwards patch stdin",
"pod exec --stdin streams arbitrary local stdin through workload routes without shell wrapping",
"apply-patch uses one sh helper for host and pod paths and rejects low-context hunks unless --allow-loose is explicit",
@@ -480,5 +808,6 @@ export function runSshArgvGuidanceContract(): JsonRecord {
}
if (import.meta.main) {
process.stdout.write(`${JSON.stringify(runSshArgvGuidanceContract(), null, 2)}\n`);
const result = await runSshArgvGuidanceContract();
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}