diff --git a/AGENTS.md b/AGENTS.md index f039b32e..ff51658f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 仅作 fallback;k3s 操作用 `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/` 从最新 `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 ...` 后面禁止裸放本地 shell 续接控制符,包括 `&&`、`;` 和 `|`;需要在远端执行多步命令时,必须使用 `tran script -- '远端完整脚本'`、`tran script <<'SCRIPT'` 或等价的单一 stdin/script 参数,避免后半段被本地 shell 执行或被 CLI 误当成 operation 参数。`apply-patch`、`script`、`py` 等明确 stdin-backed operation 可以使用 heredoc 或 `< patch.diff` 作为本地输入。 +- P0: `tran ...` 后面禁止裸放本地 shell 续接控制符,包括 `&&`、`;` 和 `|`;需要在远端执行多步命令时,必须使用 `tran script -- '远端完整脚本'`、`tran 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 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 49f8d8cb..19829da9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 ` 创建异步 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 [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`ssh argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 -- `ssh [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。WSL provider 的 Windows cmd 入口固定写 `tran D601:win cmd `,需要 Windows cwd 时用 `tran D601:win/c/test cmd cd`,由 CLI 自动设置 `chcp 65001`、`PYTHONUTF8=1` 和 `PYTHONIOENCODING=utf-8`;命名只允许 `win`,不得使用 `win32`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'`、`tran G14:k3s script <<'SCRIPT'` 或 `tran G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin,而不是把脚本压成多层引号字符串。`script` 需要传递带短横线的短命令 argv 时可以使用命令本地分隔符 `script -- [args...]`,例如 `tran D601:/path script -- sed -n '1,20p' file`;这个直接命令形态不等待 stdin,顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要在 pod 内改文件时优先使用 `:k3s:: 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 apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。 +- `ssh [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。WSL provider 的 Windows cmd 入口固定写 `tran D601:win cmd `,需要 Windows cwd 时用 `tran D601:win/c/test cmd cd`,由 CLI 自动设置 `chcp 65001`、`PYTHONUTF8=1` 和 `PYTHONIOENCODING=utf-8`;命名只允许 `win`,不得使用 `win32`。非交互远端命令优先使用 `ssh argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'`、`tran G14:k3s script <<'SCRIPT'` 或 `tran G14:k3s:: 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 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 ` v2 < patch.diff`;v2 不适用或失败时再退回 `:k3s:: apply-patch` 或 ` apply-patch` 旧 helper。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 +- `ssh v2 < patch.diff` 是默认推荐的远端 patch 入口:本地 TypeScript line-based engine 解析和计算新文件内容,远端 route 只负责读写文件;支持 host workspace、k3s pod workspace 和 frontend transport,并优先处理长中文/Unicode、低上下文插入、重复块 `@@` 定位等旧 helper 容易失败的场景。`ssh apply-patch [tool args...] < patch.diff` 保留为 v1 fallback,直接调用远端注入的 `apply_patch` sh/perl helper;只有 v2 出现问题、需要复用旧 helper 行为或人工确认 `--allow-loose` 时才优先使用 v1。 - `ssh py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `ssh skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 -- `ssh :k3s[:namespace:workload[:container]] ...` 是原生 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 ssh ...`,其中 `` 优先来自 `UNIDESK_MAIN_SERVER_IP` / `UNIDESK_MAIN_SERVER_HOST` / `CODE_QUEUE_DEV_CONTAINER_MASTER_HOST`。runner remote frontend HTTP 客户端默认使用 `curl` 后端,降低 Bun 在部分 runner 内读取非 SSH HTTP response body 时触发 native crash 的风险;显式 `UNIDESK_REMOTE_HTTP_CLIENT=fetch` 可用于诊断。runner 内跨 D601/G14 的分布式访问应优先使用结构化 route/operation,例如 `tran D601 argv ...`、`tran G14 argv ...`、`tran D601:k3s kubectl ...` 和 `tran D601:k3s:: argv ...`;`script`、`apply-patch`、`py` 等 stdin helper 通过 frontend `/ws/ssh` 流式通道执行,stdout/stderr 也必须完整直通,不得退回 `/api/dispatch` task JSON。 +- `ssh :k3s[:namespace:workload[:container]] ...` 是原生 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 ssh ...`,其中 `` 优先来自 `UNIDESK_MAIN_SERVER_IP` / `UNIDESK_MAIN_SERVER_HOST` / `CODE_QUEUE_DEV_CONTAINER_MASTER_HOST`。runner remote frontend HTTP 客户端默认使用 `curl` 后端,降低 Bun 在部分 runner 内读取非 SSH HTTP response body 时触发 native crash 的风险;显式 `UNIDESK_REMOTE_HTTP_CLIENT=fetch` 可用于诊断。runner 内跨 D601/G14 的分布式访问应优先使用结构化 route/operation,例如 `tran D601 argv ...`、`tran G14 argv ...`、`tran D601:k3s kubectl ...`、`tran D601:k3s:: argv ...` 和 `tran G14:/absolute/workspace 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 ` 将带 `# YYYY年M月D日`、`# YYYY-MM-DD` 或 `# YYYY/M/D` 标题的工作日志拆成每天一篇 Markdown 日记,按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径写入 Decision Center PostgreSQL;`decision diary list/history` 默认只返回摘要,需要完整 Markdown 时显式加 `--include-body`;`decision diary show [--source-file path]` 查看单日正文,`--source-file` 用于同一天存在多个导入来源时精确选择;`decision diary edit|upsert --body-file [--title text] [--source-file path] [--tag tag]` 通过 `PUT /api/diary/entries/:idOrDate` 创建当天或历史条目并编辑既有条目。 @@ -110,7 +110,7 @@ GitHub issue/PR 写操作必须优先使用 `bun scripts/cli.ts gh issue|pr ... ## SSH Command -`ssh [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-gateway,provider-gateway 最终执行维护用 SSH 连接宿主或 WSL sshd。TTY 策略固定为交互登录 shell 使用 `ssh -tt`,带远端命令的会话使用 `ssh -T`;脚本 stdin、`apply-patch` 和 `py` 这类命令模式不得被伪终端回显或注入控制字符。该入口不新增 core 公网端口,不暴露 database,也不改变 frontend/dev frontend/provider ingress 之外的公网边界。 +`ssh [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-gateway,provider-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 --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 ''` 仍保留为显式 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=` 临时调大,但最小有效值固定为 15000ms,避免把真实离线误判为长时间阻塞。注意 open timeout 只控制“会话打开”阶段,不能绕过 60 秒最外层运行时硬超时。 -ssh-like 远端命令如果出现 `kex_exchange_identification`、`Connection closed by remote host`、provider session timeout 或 exit code 255,CLI 会在原始 stderr 后追加一行 `UNIDESK_SSH_HINT { ... }`。该 JSON 不回显原始远端命令,只包含 `code=ssh-like-command-friction`、`trigger`、`try` 和 `triage`;`try` 固定指向 stdin script 形态,避免把一次 ssh-like 解析/握手摩擦误读成 D601 SSH 整体不可用。`ssh`/`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=` 临时调节,提示同样不回显原始远端命令。 +ssh-like 远端命令如果出现 `kex_exchange_identification`、`Connection closed by remote host`、provider session timeout 或 exit code 255,CLI 会在原始 stderr 后追加一行 `UNIDESK_SSH_HINT { ... }`。该 JSON 不回显原始远端命令,只包含 `code=ssh-like-command-friction`、`trigger`、`try` 和 `triage`;`try` 固定指向 stdin script 形态,避免把一次 ssh-like 解析/握手摩擦误读成 D601 SSH 整体不可用。`ssh`/`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=` 临时调节,提示同样不回显原始远端命令。 -`ssh ` 只在当前 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 ` 只在当前 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::/ 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 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 ''`;复用脚本时才用 `< script.sh` 文件重定向。典型用法: +如果远端逻辑需要 shell 特性,不要再把整段脚本作为原生 ssh-like 命令字符串传入。正式入口是 `bun scripts/cli.ts ssh D601 script`,脚本正文从 stdin 进入;CLI 会把本地 stdin 直接送到远端 `sh -s --`,`--shell bash` 可切换为 bash,`--` 后的内容会作为脚本参数传入。临时单步执行优先用 quoted heredoc;只有命令很短、明确希望一行内完成时才用 `script -- ''`,它会把单个字符串按远端 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 使用 `:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd`、`git`、`script`、`apply-patch` 等操作仍按同一套 operation parser 执行。`:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。 +`ssh` 的 route 语法是 `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。Host workspace route 使用 `:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd`、`git`、`script`、`v2`、旧 `apply-patch` fallback 等操作仍按同一套 operation parser 执行。`:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。 当前稳定 plane 包括 `win` 和 `k3s`。`:win cmd ` 在 WSL provider 上启动 Windows host 的 `cmd.exe`,CLI 会在命令前固定执行 `chcp 65001>nul`、`set "PYTHONUTF8=1"` 和 `set "PYTHONIOENCODING=utf-8"`,让中文和 UTF-8 输出成为默认行为;需要 Windows 当前目录时使用 slash 路由 `:win//`,例如 `D601:win/c/test cmd cd` 会先在 Windows cmd 内执行 `cd /d "C:\test"`。`win32` 不是合法 plane,调用者必须改用 `win`。 `:win skills [--scope agents|codex|all] [--limit N]` 是 Windows 用户 skill 发现入口,默认只读取当前 Windows 用户的 `%USERPROFILE%\.agents\skills`,输出 JSON 中包含 `roots`、`counts` 和每个 skill 的 `name`、`path`、`skillFile`、`description`。需要同时检查 `%USERPROFILE%\.codex\skills` 时显式加 `--scope all`;不要为了列 skill 手写 `cmd dir` 或宽泛扫描整个用户目录。 -`D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/`,若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。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 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/`,若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。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 script 和 pod apply-patch 组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。`:k3s` 无后续参数时执行 native k3s guard;`:k3s kubectl ...` 接收原始 kubectl argv;`:k3s script` 执行带 native kubeconfig 的 host stdin 脚本;`:k3s:: logs` 读取有界日志;`:k3s:: exec ...` 和 `:k3s:: ...` 进入目标 workload;`:k3s:: script` 把本地 stdin 作为 pod 内 shell 脚本执行;`:k3s:: apply-patch` 把本地标准 patch 作为 stdin 送入 pod 内 `apply_patch`。典型用法: +该入口解决运行面调试中最常见的多层 shell 引号问题。它不要求升级 provider-gateway,也不新增业务 API,只复用现有 Host SSH 维护桥;CLI 在本地把 Kubernetes 目标、namespace、container、log 限制、容器命令、stdin script、pod workspace `v2` 读写和旧 `apply-patch` fallback 组装成 kubectl argv,并固定远端 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`。`:k3s` 无后续参数时执行 native k3s guard;`:k3s kubectl ...` 接收原始 kubectl argv;`:k3s script` 执行带 native kubeconfig 的 host stdin 脚本;`:k3s:: logs` 读取有界日志;`:k3s:: exec ...` 和 `:k3s:: ...` 进入目标 workload;`:k3s:: script` 把本地 stdin 作为 pod 内 shell 脚本执行;`:k3s::/ v2` 是 pod 内文本 patch 默认入口;`:k3s:: 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 -- ...` 是 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 -- ...` 是 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 argv [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `bun scripts/cli.ts ssh D601 argv true`。需要管道、重定向、变量展开或多条命令时,优先改用 `ssh 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 argv [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它是非交互远端单进程命令的默认成功路径,不需要 shell 管道时直接传命令和参数,例如 `bun scripts/cli.ts ssh D601 argv true`。需要管道、重定向、变量展开或多条命令时,优先改用 `ssh 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 ` 执行多行脚本时,优先使用结构化 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 - <:/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。 -默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`artifact-registry status|health`、`ci publish-user-service --dry-run`、`microservice list/status/health/diagnostics/tunnel-self-test/proxy`、`decision upload/list/show/health`、`decision requirement list/upsert`、`decision diary import/list/history/months/show/edit/upsert`、`codex task `、`codex tasks`、`codex unread`、`codex queues`、`codex output `、`codex judge --attempt N` 和 `ssh `。`microservice status/health/diagnostics` 经 frontend 远程传输时也复用本地 CLI 的默认 compact summary,`microservice health code-queue` 只有显式 `--raw` 或 `--full` 才返回完整健康 body。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `ssh` 的 remote frontend 传输使用 authenticated frontend `/ws/ssh` WebSocket 代理接入 backend-core SSH bridge,stdout/stderr 按字节流直通到调用端,不经过 `/api/dispatch`、`/api/tasks` 或 task JSON compact;frontend 运行时必须通过 `PROVIDER_TOKEN`/`UNIDESK_PROVIDER_TOKEN` 或 `PROVIDER_TOKEN_FILE`/`UNIDESK_PROVIDER_TOKEN_FILE` 读取 provider token,并且不能把 token 下发给 runner。因此 D601 Code Queue runner 内的 `tran G14 ...` 应与主 server 本机 `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 ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 +默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`artifact-registry status|health`、`ci publish-user-service --dry-run`、`microservice list/status/health/diagnostics/tunnel-self-test/proxy`、`decision upload/list/show/health`、`decision requirement list/upsert`、`decision diary import/list/history/months/show/edit/upsert`、`codex task `、`codex tasks`、`codex unread`、`codex queues`、`codex output `、`codex judge --attempt N` 和 `ssh `。`microservice status/health/diagnostics` 经 frontend 远程传输时也复用本地 CLI 的默认 compact summary,`microservice health code-queue` 只有显式 `--raw` 或 `--full` 才返回完整健康 body。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `ssh` 的 remote frontend 传输使用 authenticated frontend `/ws/ssh` WebSocket 代理接入 backend-core SSH bridge,stdout/stderr 按字节流直通到调用端,不经过 `/api/dispatch`、`/api/tasks` 或 task JSON compact;frontend 运行时必须通过 `PROVIDER_TOKEN`/`UNIDESK_PROVIDER_TOKEN` 或 `PROVIDER_TOKEN_FILE`/`UNIDESK_PROVIDER_TOKEN_FILE` 读取 provider token,并且不能把 token 下发给 runner。因此 D601 Code Queue runner 内的 `tran G14 ...` 应与主 server 本机 `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 ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh`、`hostSshConfigured=true`、`hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。 diff --git a/docs/reference/devops-hygiene.md b/docs/reference/devops-hygiene.md index 59bc69ed..75974b75 100644 --- a/docs/reference/devops-hygiene.md +++ b/docs/reference/devops-hygiene.md @@ -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:: logs`, `ssh D601:k3s:: script`, `ssh D601:k3s:: apply-patch`, `ssh py`, `ssh apply-patch`, `ssh find`, `ssh glob` or `ssh 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:: logs`, `ssh D601:k3s:: script`, `ssh D601:k3s::/ v2`, `ssh :/absolute/workspace v2`, `ssh py`, `ssh find`, `ssh glob` or `ssh skills`. Use legacy `apply-patch` only as a fallback when `v2` is not suitable or has failed. 2. If no helper exists, use `ssh argv [args...]` so the CLI quotes each argv token once. 3. If shell features such as pipes, redirects, loops or variable expansion are required, use a single quoted heredoc with `ssh script` or `ssh D601:k3s:: script` so the script body travels over stdin instead of through shell command-string arguments. 4. Treat free-form ssh-like command strings as an interactive compatibility path, not as the default automation surface. -For D601 Kubernetes work, route syntax is preferred over positional shell recipes, but the route must stay a pure locator. `D601:k3s` means the native k3s control plane, and `D601:k3s::[:container]` means a namespaced workload or pod. Operations come after the route: `kubectl` runs on the control plane, `logs` reads bounded workload logs, `script` streams a local heredoc/stdin script into the host or target pod, and `apply-patch` 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::[: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 - <; +} + +export interface ApplyPatchV2RemoteResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export class ApplyPatchV2Error extends Error { + constructor(message: string, public readonly details: Record = {}) { + 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 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 { + 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 { + const states = new Map(); + const operations: PlannedOperation[] = []; + const changed: string[] = []; + + async function readPlannedText(filePath: string): Promise { + 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 { + 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 { + const read = await checkedRemoteV2(executor, "read", [target]); + return read.stdout; +} + +async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise { + 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 { + 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 === "<= 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, " "); +} diff --git a/scripts/src/help.ts b/scripts/src/help.ts index c2d51652..94beb5b0 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -20,14 +20,15 @@ export function rootHelp(): unknown { { command: "server rebuild ", 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 [--master-server URL] [--up] [--force] | provider triage [--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 [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 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 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 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 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 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 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 find [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." }, { command: "ssh 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 :/absolute/workspace ", description: "Route directly into a host workspace while keeping the operation parser independent from the location." }, - { command: "ssh :k3s[:namespace:workload[:container]] ...", description: "Locate a native k3s control plane or workload with route syntax, then run a separate operation with KUBECONFIG fixed and argv assembled by the CLI." }, + { command: "ssh :k3s[:namespace:workload[:container]] ...", description: "Locate a native k3s control plane or workload with route syntax, then run a separate operation with KUBECONFIG fixed and argv assembled by the CLI." }, { command: "ssh argv [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `ssh script` when shell features are required." }, { command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." }, { command: "microservice status ", description: "Show one user service config, repository reference, backend mapping, and runtime status." }, @@ -148,6 +149,7 @@ export function sshHelp(): unknown { usage: [ "bun scripts/cli.ts ssh ", "bun scripts/cli.ts ssh argv [args...]", + "bun scripts/cli.ts ssh :/absolute/workspace v2 < patch.diff", "bun scripts/cli.ts ssh apply-patch [--allow-loose] < patch.diff", "bun scripts/cli.ts ssh py [script-args...] < script.py", "bun scripts/cli.ts ssh 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 --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 ''`; 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 -- [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 -- ''`; 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 ''` operation remains available for the same sh -c path.", + "When a one-line shell command is easier to type through the script path, `script -- ''` runs that single string through the remote shell without waiting for stdin. When `script --` is followed by multiple tokens, it stays a direct argv form for commands such as `tran D601:/work script -- sed -n '1,20p' file`.", "For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.", - "apply-patch 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 `:/absolute/workspace`; WSL providers can use `:win cmd ` to run Windows host cmd.exe with UTF-8 defaults, and `:win/c/test cmd cd` maps the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use :k3s for the control plane, :k3s:: for a workload, and :k3s::/ for a pod workspace.", "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.", ], diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 4ea3444d..90bedd46 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -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, + command: string[], + input?: string, +): Promise { + 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((resolve) => { + let killTimer: ReturnType | 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; + try { + message = JSON.parse(text) as Record; + } 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 { 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 { 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); } diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 1bded7a0..2e08ed19 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -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:: ${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:: apply-patch`); } + if (operation === "v2") { + throw new Error(`ssh ${route.providerId}:k3s v2 requires a workload route: ssh ${route.providerId}:k3s:: 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 { + 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((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 { 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", diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index ab7dc3cc..32754cc0 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -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): Promise<{ stdout: string; files: Record; 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, + mutateInput?: (operation: string, input: string | undefined) => string | undefined, +): Promise<{ stdout: string; files: Record; 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 = {}; + 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): Promise<{ stdout: string; files: Record; 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 { 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`); }