fix: expose ssh transfer verification

This commit is contained in:
Codex
2026-05-30 08:31:52 +00:00
parent 277e28ce97
commit 91564b3784
4 changed files with 67 additions and 6 deletions
+1 -1
View File
@@ -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-<capability>.ts` 或等价专门模块组织,帮助文本、合同测试和 reference 与代码同一变更集更新。
+2 -2
View File
@@ -24,7 +24,7 @@ export function rootHelp(): unknown {
{ command: "ssh <route> [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `G14:k3s` or `D601:win/c/test` only locates distributed targets." },
{ command: "ssh 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 <route> 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 <route> upload <local-file> <remote-file> | ssh <route> download <remote-file> <local-file>", description: "Transfer whole files through SSH passthrough with remote temp files, byte-count checks, SHA-256 verification, and client-side chunk fallback." },
{ command: "ssh <route> upload <local-file> <remote-file> | ssh <route> download <remote-file> <local-file>", description: "Transfer whole files through SSH passthrough with remote temp files, automatic endpoint byte/SHA-256 verification, and client-side chunk fallback." },
{ command: "ssh <providerId> 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 <providerId> py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." },
{ command: "ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT' ...", description: "Run a remote shell script from local stdin using shell -s; 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 -- <command> ...`; 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 `<provider>:/absolute/workspace`; WSL providers can use `<provider>:win cmd <command-line>` to run Windows host cmd.exe with UTF-8 defaults, and `<provider>:win/c/test cmd cd` maps the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use <provider>:k3s for the control plane, <provider>:k3s:<namespace>:<workload> for a workload, and <provider>:k3s:<namespace>:<workload>/<pod-workspace> for a pod workspace.",
+30
View File
@@ -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<string, unknown> {
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");
}
+34 -3
View File
@@ -406,10 +406,40 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
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<JsonRecord> {
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<JsonRecord> {
"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",