From 91564b3784f1a6c5d400f5018b81b4dba8e8e720 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 30 May 2026 08:31:52 +0000 Subject: [PATCH] fix: expose ssh transfer verification --- docs/reference/cli.md | 2 +- scripts/src/help.ts | 4 +-- scripts/src/ssh-file-transfer.ts | 30 ++++++++++++++++++ scripts/ssh-argv-guidance-contract-test.ts | 37 ++++++++++++++++++++-- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ebb143a4..4f1de3d4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -153,7 +153,7 @@ exec /root/unidesk/scripts/tran "$@" `bun scripts/cli.ts ssh D518` 应表现为登录 D518 WSL 的 shell;`bun scripts/cli.ts ssh D518 hostname` 应像 `ssh D518 hostname` 一样只输出远端命令结果并返回远端 exit code。Provider ID 前的目标选择由 UniDesk 节点清单决定,`-p`、`-i`、`-l`、`-o` 等传统 ssh 传输参数由 provider-gateway 部署配置统一管理,CLI 会兼容性消费这些参数但不会覆盖节点侧维护桥配置。指挥官、CI 预检和其他非交互流程不要依赖 ssh-like 自由拼接;单进程标准写法是 `bun scripts/cli.ts ssh D601 argv true`,多行 shell 逻辑标准写法是 quoted heredoc 单步调用 `bun scripts/cli.ts ssh D601 script <<'SCRIPT'`。 -UniDesk CLI/tran 客户端改进本身是 master server 高频控制入口维护,可以直接在 `/root/unidesk` 轻量开发、提交并推送 `origin/master`;不要为这类客户端小改强制迁移到 D601 worktree。该例外不改变 master server 禁重型验证规则:仓库级 check、Playwright/browser smoke、镜像构建、Rust/Go 编译、Code Queue runner 实测仍必须在 D601、CI runner 或目标运行面执行。若 `tran`/SSH 文件传输遇到 provider-gateway 单次 stdin、argv 或 stdout 限制,先在 CLI 客户端做分块、SHA-256 校验、失败可观测输出和最小真实闭环;只有 client 侧不能解决且有证据时,才改 provider-gateway。 +UniDesk CLI/tran 客户端改进本身是 master server 高频控制入口维护,可以直接在 `/root/unidesk` 轻量开发、提交并推送 `origin/master`;不要为这类客户端小改强制迁移到 D601 worktree。该例外不改变 master server 禁重型验证规则:仓库级 check、Playwright/browser smoke、镜像构建、Rust/Go 编译、Code Queue runner 实测仍必须在 D601、CI runner 或目标运行面执行。若 `tran`/SSH 文件传输遇到 provider-gateway 单次 stdin、argv 或 stdout 限制,先在 CLI 客户端做分块、SHA-256 校验、失败可观测输出和最小真实闭环;只有 client 侧不能解决且有证据时,才改 provider-gateway。`upload`/`download` 成功 JSON 中的 `verified=true`、`verification.automatic=true`、`verification.verified=true` 和 `verification.match.{bytes,sha256}=true` 就是端到端完整性证明,调用方不需要再额外手写 `sha256sum` 比对。 `scripts/src/ssh.ts` 只承担 route/operation parser、共享远端命令构造、broker 调用和顶层 dispatch。新增或扩展高频 operation 不得继续把完整实现堆进 `ssh.ts`;应按能力拆到专门模块,例如整文件传输放在 `scripts/src/ssh-file-transfer.ts`,再由 `ssh.ts` 和 frontend remote transport 传入共享 command builder/bridge executor。后续新增 operation 也按 `scripts/src/ssh-.ts` 或等价专门模块组织,帮助文本、合同测试和 reference 与代码同一变更集更新。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 6f772119..52d2cb6a 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -24,7 +24,7 @@ export function rootHelp(): unknown { { 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 gh:/owner/repo[/pr|/issue][/number[/1]] ls|cat|rg|patch-apply", description: "Treat GitHub PRs/issues as virtual text directories; `ls --full` shows state/floors/body length, and `patch-apply` updates first-floor `body.md` through UniDesk gh plus apply-patch v2." }, { command: "ssh apply-patch < patch.diff", description: "Default 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 upload | ssh download ", description: "Transfer whole files through SSH passthrough with remote temp files, byte-count checks, SHA-256 verification, and client-side chunk fallback." }, + { command: "ssh upload | ssh download ", description: "Transfer whole files through SSH passthrough with remote temp files, automatic endpoint byte/SHA-256 verification, and client-side chunk fallback." }, { command: "ssh apply-patch-v1 [tool args...] < patch.diff", description: "Fallback to the injected legacy remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." }, { command: "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; default sh inherits provider proxy env and gets the portable printf helper used by shell/script." }, @@ -192,7 +192,7 @@ export function sshHelp(): unknown { "script and shell helper modes inject a tiny POSIX-compatible printf wrapper before user shell text, so portable printf headings such as `printf \"--- section ---\\n\"` work consistently under dash/sh and bash. Direct argv commands are unchanged.", "For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.", "`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser. Its stdout/stderr follows Codex apply_patch text output rather than UniDesk JSON output; on multi-file failure, stderr lists applied hunks before the first failed hunk and the failed hunk, then stops like Codex apply_patch.", - "`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and fall back from a single stdin payload to bounded client-side chunks before treating provider-gateway limits as a server-side problem.", + "`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and return `verification.automatic=true`, `verification.verified=true`, and `verification.match.{bytes,sha256}=true`; this JSON is the transfer integrity proof, so callers do not need a separate manual `sha256sum` check. The client falls back from a single stdin payload to bounded chunks before treating provider-gateway limits as a server-side problem.", "`apply-patch-v1` is the only legacy fallback entry: it rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.", "script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; 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.", diff --git a/scripts/src/ssh-file-transfer.ts b/scripts/src/ssh-file-transfer.ts index 60af6a55..67e78858 100644 --- a/scripts/src/ssh-file-transfer.ts +++ b/scripts/src/ssh-file-transfer.ts @@ -33,6 +33,11 @@ interface SshFileTransferStat { sha256: string; } +interface SshFileTransferEndpointStat extends SshFileTransferStat { + side: "local" | "remote"; + path: string; +} + interface SshFileTransferWriteResult { strategy: "argv" | "stdin" | "chunked-stdin"; chunks: number; @@ -68,6 +73,10 @@ export async function runSshFileTransferOperation( const write = await writeRemoteFileVerified(invocation, executor, builders, options.remotePath, content); const remote = await statRemoteFile(invocation, executor, builders, options.remotePath); assertTransferStat("upload final remote verification", options.remotePath, expected, remote); + const verification = buildTransferVerification( + { side: "local", path: localPath, ...expected }, + { side: "remote", path: options.remotePath, ...remote }, + ); process.stdout.write(`${JSON.stringify({ ok: true, command: "ssh upload", @@ -78,6 +87,7 @@ export async function runSshFileTransferOperation( bytes: expected.bytes, sha256: expected.sha256, verified: true, + verification, transfer: write, }, null, 2)}\n`); return 0; @@ -89,6 +99,10 @@ export async function runSshFileTransferOperation( const local = await readFile(localPath); const localStat = { bytes: local.length, sha256: sha256HexBuffer(local) }; assertTransferStat("download final local verification", localPath, read.remote, localStat); + const verification = buildTransferVerification( + { side: "remote", path: options.remotePath, ...read.remote }, + { side: "local", path: localPath, ...localStat }, + ); process.stdout.write(`${JSON.stringify({ ok: true, command: "ssh download", @@ -99,6 +113,7 @@ export async function runSshFileTransferOperation( bytes: read.remote.bytes, sha256: read.remote.sha256, verified: true, + verification, transfer: { strategy: "chunked-read", chunks: read.chunks, @@ -274,6 +289,21 @@ function assertTransferStat(label: string, pathName: string, expected: SshFileTr }); } +function buildTransferVerification(source: SshFileTransferEndpointStat, target: SshFileTransferEndpointStat): Record { + return { + automatic: true, + algorithm: "sha256", + checked: ["bytes", "sha256"], + verified: source.bytes === target.bytes && source.sha256 === target.sha256, + source, + target, + match: { + bytes: source.bytes === target.bytes, + sha256: source.sha256 === target.sha256, + }, + }; +} + function sha256HexBuffer(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); } diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index c395de31..83d7c1e3 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -406,10 +406,40 @@ export async function runSshArgvGuidanceContract(): Promise { writeFileSync(localSource, payload); const transfer = fileTransferFixture(); const uploadResult = await captureStdout(() => runSshFileTransferOperation(hostUploadParse, ["upload", localSource, "/tmp/remote.bin"], transfer.executor, transfer.builders)); - assertCondition(uploadResult.exitCode === 0 && JSON.parse(uploadResult.stdout).verified === true, "upload should report verified JSON success", uploadResult); + const uploadJson = JSON.parse(uploadResult.stdout) as JsonRecord; + const uploadVerification = uploadJson.verification as JsonRecord; + const uploadMatch = uploadVerification.match as JsonRecord; + const uploadSource = uploadVerification.source as JsonRecord; + const uploadTarget = uploadVerification.target as JsonRecord; + assertCondition(uploadResult.exitCode === 0 && uploadJson.verified === true, "upload should report verified JSON success", uploadResult); + assertCondition( + uploadVerification.automatic === true + && uploadVerification.verified === true + && uploadMatch.bytes === true + && uploadMatch.sha256 === true + && uploadSource.side === "local" + && uploadTarget.side === "remote", + "upload should expose automatic endpoint verification so callers do not need manual sha256sum checks", + uploadJson, + ); assertCondition(transfer.state.get("/tmp/remote.bin")?.equals(payload), "upload must preserve binary and UTF-8 bytes in the mock remote file", transfer.commands); const downloadResult = await captureStdout(() => runSshFileTransferOperation(parseSshInvocation("D601", ["download", "/tmp/remote.bin", localDownload]), ["download", "/tmp/remote.bin", localDownload], transfer.executor, transfer.builders)); - assertCondition(downloadResult.exitCode === 0 && JSON.parse(downloadResult.stdout).sha256 === sha256BufferHex(payload), "download should report the verified sha256", downloadResult); + const downloadJson = JSON.parse(downloadResult.stdout) as JsonRecord; + const downloadVerification = downloadJson.verification as JsonRecord; + const downloadMatch = downloadVerification.match as JsonRecord; + const downloadSource = downloadVerification.source as JsonRecord; + const downloadTarget = downloadVerification.target as JsonRecord; + assertCondition(downloadResult.exitCode === 0 && downloadJson.sha256 === sha256BufferHex(payload), "download should report the verified sha256", downloadResult); + assertCondition( + downloadVerification.automatic === true + && downloadVerification.verified === true + && downloadMatch.bytes === true + && downloadMatch.sha256 === true + && downloadSource.side === "remote" + && downloadTarget.side === "local", + "download should expose automatic endpoint verification so callers do not need manual sha256sum checks", + downloadJson, + ); assertCondition(readFileSync(localDownload).equals(payload), "download must preserve binary and UTF-8 bytes locally", { commands: transfer.commands }); assertCondition(transfer.commands.some((item) => item.operation === "stat") && transfer.commands.some((item) => item.operation === "read-b64-block"), "file transfer should use stat plus chunked verified reads", transfer.commands); } finally { @@ -1139,6 +1169,7 @@ export async function runSshArgvGuidanceContract(): Promise { const sshSource = readFileSync(new URL("./src/ssh.ts", import.meta.url), "utf8"); const sshFileTransferSource = readFileSync(new URL("./src/ssh-file-transfer.ts", import.meta.url), "utf8"); assertCondition(sshFileTransferSource.includes("runSshFileTransferOperation") && sshFileTransferSource.includes("write-b64-commit"), "file transfer operation implementation must live in the dedicated ssh-file-transfer module", {}); + assertCondition(sshFileTransferSource.includes("buildTransferVerification") && sshFileTransferSource.includes("automatic: true") && sshFileTransferSource.includes("match"), "file transfer JSON must expose automatic endpoint verification instead of relying on manual sha256sum checks", sshFileTransferSource); assertCondition(!sshSource.includes("type SshFileTransferOperation") && !sshSource.includes("posixFileTransferScript"), "ssh.ts must not accumulate the full upload/download implementation", {}); const frontendSource = readFileSync(new URL("../src/components/frontend/src/index.ts", import.meta.url), "utf8"); @@ -1164,7 +1195,7 @@ export async function runSshArgvGuidanceContract(): Promise { "script/shell helpers inject a portable printf prelude for common section headings", "pod apply-patch operation uses the v2 local engine and apply-patch-v1 injects the legacy helper", "pod exec --stdin streams arbitrary local stdin through workload routes without shell wrapping", - "upload/download file transfer operations use a dedicated module with byte-count and sha256 verification", + "upload/download file transfer operations use a dedicated module with automatic endpoint byte-count and sha256 verification JSON", "apply-patch-v1 uses one sh helper for host and pod paths and rejects low-context hunks unless --allow-loose is explicit", "legacy operation-in-route forms are rejected in any k3s route segment with canonical route-plus-operation guidance", "post-provider k3s shorthand is rejected so location and operation stay separated",