diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 71310b21..31471a8b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -34,7 +34,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P - `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`、`trans argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 - `trans [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 plane 固定使用 `win`,不得使用 `win32`;Windows operation 必须显式区分:`ps` 执行 Windows PowerShell heredoc 或一行 PowerShell 命令,`cmd` 执行 cmd.exe/batch,`skills` 发现 Windows skill 目录。需要 Windows cwd 时用 `trans D601:win/c/test ps` 或 `trans D601:win/c/test cmd cd`,CLI 自动设置 UTF-8/Python 编码默认值;`cmd` 额外设置 `chcp 65001`。非交互远端命令优先使用 `trans argv ...`;需要 POSIX shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `trans G14 script <<'SCRIPT'`、`trans G14:k3s script <<'SCRIPT'` 或 `trans G14:k3s:: script <<'SCRIPT'`,把脚本走 stdin。`script` 只表示 host/k3s POSIX shell,不表示 Windows PowerShell;Windows PowerShell 必须写 `trans :win ps <<'PS'`。`script -- '<单个字符串>'` 是无需 stdin 的远端 POSIX shell one-liner,例如 `trans G14:/root/hwlab script -- 'cd /root/hwlab && git status --short --branch'`;`script -- <多个 argv>` 才是 direct argv,适合 `trans D601:/path script -- sed -n '1,20p' file` 这类带短横线的单进程命令。顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 ` apply-patch < patch.diff`;需要可靠传输非文本或整文件时使用 ` upload ` 和 ` download `,CLI 会按字节数与 SHA-256 自动校验并在 provider-gateway stdin/argv 限制下切换客户端分块策略;需要旧 helper 时显式使用 `:k3s:: apply-patch-v1` 或 ` apply-patch-v1`。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。 - `trans apply-patch < patch.diff` 是默认推荐的远端 patch 入口:本地 TypeScript line-based engine 解析和计算新文件内容,远端 route 只负责读写文件;支持 host workspace、k3s pod workspace、Windows workspace route(例如 `D601:win/c/test`)和 frontend transport,并优先处理长中文/Unicode、低上下文插入、重复块 `@@` 定位等旧 helper 容易失败的场景。`apply-patch` 输出按 Codex 标准文本口径,不套 UniDesk JSON 限制:成功 stdout 为 `Success. Updated the following files:`,失败 stdout 为空、stderr 写失败原因;多文件补丁中途失败时,stderr 只列出第一个失败前已成功执行的 hunk 和失败 hunk,随后按 Codex 语义停止,不继续尝试后续 hunk。v2 兼容常见 MiniMax/MXCX 非标准补丁输入,例如重复 nested `*** Begin Patch` / `*** End Patch` envelope、unified-diff hunk header、Add/Delete 误加 `@@`、Update context 漏掉前导空格,并在 stderr 给出 canonical 写法 hint;parser 或上下文失败时仍坚持唯一 v2 引擎,只提示修正 patch 文本或 hunk context,不自动重试或切换到 `apply-patch-v1`;大块/函数替换因上下文过期失败时,正确动作是重新读取当前目标块、缩小或拆分 Update File hunk 后继续用 `apply-patch`,不得改走 `download`/`upload`、远端 Python/Perl/sed heredoc 或整文件重写。Windows route 复用同一套 v2 核心算法,只把底层读写替换成 PowerShell 文件系统接口;`trans apply-patch-v1 [tool args...] < patch.diff` 保留为显式 legacy 入口,直接调用远端注入的 `apply_patch` sh/perl helper;默认 `apply-patch` 不把 v1 当 fallback。 -- `apply-patch` v2 每次结束都会在 stderr 追加一行 `UNIDESK_APPLY_PATCH_TIMING {json}`,字段包含 `durationMs`、`patchBytes`、`fileCount`、`hunkCount`、`changedCount`、`remoteOperationCount`、`remoteOperationCounts`、`remoteElapsedMs`、`remoteFailureCount`、`providerId`、`route` 和 `transport`(可得时)。这条 timing 摘要只用于定位慢在 patch 解析、远端 stat/read/write、provider session 还是传输层,不能替代 Codex 标准 stdout/stderr 成功失败文本,也不是门禁或自动判断。 +- `apply-patch` v2 每次结束都会在 stderr 追加一行 `UNIDESK_APPLY_PATCH_TIMING {json}`,字段包含 `durationMs`、`patchBytes`、`fileCount`、`hunkCount`、`changedCount`、`remoteOperationCount`、`remoteOperationCounts`、`remoteElapsedMs`、`remoteFailureCount`、`providerId`、`route` 和 `transport`(可得时)。普通 POSIX host/k3s 远端的多文件 `Update File` patch 会优先合并成 bulk read/write,避免每个文件单独 stat/read/write 的 SSH 往返;Windows route 和 Add/Delete/Move 等复杂 patch 保持原有逐步语义。timing 摘要只用于定位慢在 patch 解析、远端 stat/read/write 或 bulk read/write、provider session 还是传输层,不能替代 Codex 标准 stdout/stderr 成功失败文本,也不是门禁或自动判断。 - `trans py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `trans skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 - `trans :k3s[:namespace:workload[:container]] ...` 是原生 k3s 结构化 route 入口,route 只定位控制面或 workload,`kubectl`、`logs`、`exec`、`script`、`apply-patch`、旧 `apply-patch-v1` 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 节点身份。 diff --git a/scripts/src/apply-patch-v2.ts b/scripts/src/apply-patch-v2.ts index 84f4af4f..4aa36c57 100644 --- a/scripts/src/apply-patch-v2.ts +++ b/scripts/src/apply-patch-v2.ts @@ -115,6 +115,14 @@ export class ApplyPatchV2Error extends Error { 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 BulkReplacementWritePlan { + path: string; + originalBytes: number; + originalSha256: string; + finalBytes: number; + finalSha256: string; + replacements: Replacement[]; +} interface ApplyPatchV2Plan { changed: string[]; @@ -516,7 +524,17 @@ function applyPatchV2ChangedCountFromOutcomes(outcomes: ApplyPatchV2Outcome[]): async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise { if (hunks.length === 0) throw new ApplyPatchV2Error("No files were modified."); + if (shouldUseBulkUpdatePath(executor, hunks)) { + try { + return await applyPatchV2UpdateHunksBulk(executor, hunks as Array>); + } catch (error) { + if (!isBulkReadFailure(error)) throw error; + } + } + return applyPatchV2HunksSequential(executor, hunks); +} +async function applyPatchV2HunksSequential(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise { const states = new Map(); const changed: string[] = []; const outcomes: ApplyPatchV2Outcome[] = []; @@ -596,6 +614,83 @@ async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHun return { changed, outcomes }; } +function isBulkReadFailure(error: unknown): boolean { + if (!(error instanceof ApplyPatchV2Error)) return false; + if (error.message.includes("bulk read")) return true; + return JSON.stringify(error.details).includes("read-bulk-b64"); +} + +function shouldUseBulkUpdatePath(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): boolean { + if (!executor.run || executor.fs !== undefined) return false; + const paths = new Set(); + for (const hunk of hunks) { + if (hunk.kind !== "update" || hunk.movePath !== null) return false; + if (paths.has(hunk.path)) return false; + paths.add(hunk.path); + } + return paths.size >= 2; +} + +async function applyPatchV2UpdateHunksBulk(executor: ApplyPatchV2Executor, hunks: Array>): Promise { + const paths = uniqueStrings(hunks.map((hunk) => hunk.path)); + const originalFiles = await readRemoteTextsBulk(executor, paths); + const states = new Map(); + for (const [filePath, content] of originalFiles) states.set(filePath, { exists: true, content }); + + const changed: string[] = []; + const outcomes: ApplyPatchV2Outcome[] = []; + const pendingWrites = new Set(); + const replacementPlans = new Map(); + + for (let index = 0; index < hunks.length; index += 1) { + const hunk = hunks[index] as Extract; + const changedBefore = changed.length; + try { + const state = states.get(hunk.path); + if (state === undefined || !state.exists) throw new ApplyPatchV2Error("cannot update a missing file in bulk patch", { path: hunk.path }); + const originalLines = splitContentLines(state.content); + const replacements = computeReplacements(hunk.path, originalLines, hunk.chunks); + const newContent = joinLinesWithFinalNewline(applyReplacements(originalLines, replacements)); + const originalBuffer = Buffer.from(state.content, "utf8"); + const finalBuffer = Buffer.from(newContent, "utf8"); + states.set(hunk.path, { exists: true, content: newContent }); + replacementPlans.set(hunk.path, { + path: hunk.path, + originalBytes: originalBuffer.length, + originalSha256: sha256Hex(originalBuffer), + finalBytes: finalBuffer.length, + finalSha256: sha256Hex(finalBuffer), + replacements, + }); + pendingWrites.add(hunk.path); + pushChanged(changed, `M ${hunk.path}`); + outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `M ${hunk.path}` }); + } catch (error) { + const partialChanges = changed.slice(changedBefore); + if (pendingWrites.size > 0) await writeRemoteReplacementsBulk(executor, pendingWrites, replacementPlans); + outcomes.push({ + ...outcomeBase(hunk, index), + status: "failed", + ...(partialChanges.length > 0 ? { partialChanges } : {}), + error: errorSummary(error), + }); + throw new ApplyPatchV2Error(error instanceof Error ? error.message : String(error), { + partialChanges: changed, + outcomes, + failed: outcomes.find((item) => item.status === "failed") ?? null, + cause: error instanceof ApplyPatchV2Error ? error.details : undefined, + }); + } + } + + if (pendingWrites.size > 0) await writeRemoteReplacementsBulk(executor, pendingWrites, replacementPlans); + return { changed, outcomes }; +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values)); +} + function pushChanged(changed: string[], item: string): void { if (!changed.includes(item)) changed.push(item); } @@ -743,6 +838,7 @@ const readBlockBytes = 45_000; const writeB64ArgvLimit = 48_000; const writeB64ChunkChars = 12_000; const remoteReadBlockMarker = "UNIDESK_APPLY_PATCH_V2_BLOCK"; +const remoteBulkReadMarker = "UNIDESK_APPLY_PATCH_V2_BULK_READ"; async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise { if (executor.fs) { @@ -828,6 +924,17 @@ async function readRemoteText(executor: ApplyPatchV2Executor, target: string): P return contentBuffer.toString("utf8"); } +async function readRemoteTextsBulk(executor: ApplyPatchV2Executor, targets: string[]): Promise> { + if (targets.length === 0) return new Map(); + if (executor.fs || targets.length === 1) { + const files = new Map(); + for (const target of targets) files.set(target, await readRemoteText(executor, target)); + return files; + } + const result = await checkedRemoteV2(executor, "read-bulk-b64", targets); + return decodeRemoteBulkRead(result.stdout, targets); +} + function decodeRemoteReadBlock(stdout: string, target: string, blockIndex: number, expectedChunkBytes: number): Buffer { const lines = stdout.split(/\r?\n/u); const markerIndex = lines.findIndex((line) => line.startsWith(`${remoteReadBlockMarker} `)); @@ -872,6 +979,47 @@ function decodeRemoteReadBlock(stdout: string, target: string, blockIndex: numbe return decoded.length > expectedChunkBytes ? decoded.subarray(0, expectedChunkBytes) : decoded; } +function decodeRemoteBulkRead(stdout: string, targets: string[]): Map { + const lines = stdout.split(/\r?\n/u); + const markerIndex = lines.findIndex((line) => line.startsWith(`${remoteBulkReadMarker} `)); + if (markerIndex < 0) { + throw new ApplyPatchV2Error("remote apply-patch v2 bulk read returned no marker", { expectedTargets: targets.length, stdout: stdout.slice(0, 500) }); + } + const declaredCount = Number(lines[markerIndex].split(/\s+/u)[1] ?? "-1"); + if (!Number.isSafeInteger(declaredCount) || declaredCount !== targets.length) { + throw new ApplyPatchV2Error("remote apply-patch v2 bulk read target count mismatch", { expectedTargets: targets.length, declaredCount }); + } + const records = lines.slice(markerIndex + 1).filter((line) => line.trim().length > 0); + if (records.length !== declaredCount) { + throw new ApplyPatchV2Error("remote apply-patch v2 bulk read record count mismatch", { expectedTargets: targets.length, records: records.length }); + } + const files = new Map(); + for (const record of records) { + const [pathB64, bytesText, sha256, contentB64] = record.split(/\s+/u); + const target = Buffer.from(pathB64, "base64").toString("utf8"); + const expectedBytes = Number(bytesText); + const contentBuffer = Buffer.from(contentB64 ?? "", "base64"); + if (!targets.includes(target)) { + throw new ApplyPatchV2Error("remote apply-patch v2 bulk read returned unexpected target", { target, expectedTargets: targets }); + } + if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || !/^[0-9a-f]{64}$/u.test(sha256)) { + throw new ApplyPatchV2Error("remote apply-patch v2 bulk read returned invalid metadata", { target, expectedBytes: bytesText, sha256 }); + } + if (contentBuffer.length !== expectedBytes) { + throw new ApplyPatchV2Error(`remote apply-patch v2 bulk read byte count mismatch for ${target}: expected=${expectedBytes} actual=${contentBuffer.length}`, { target, expectedBytes, actualBytes: contentBuffer.length }); + } + const actualSha256 = sha256Hex(contentBuffer); + if (actualSha256 !== sha256) { + throw new ApplyPatchV2Error(`remote apply-patch v2 bulk read sha256 mismatch for ${target}`, { target, expectedSha256: sha256, actualSha256 }); + } + files.set(target, contentBuffer.toString("utf8")); + } + for (const target of targets) { + if (!files.has(target)) throw new ApplyPatchV2Error("remote apply-patch v2 bulk read omitted target", { target }); + } + return files; +} + async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise { const contentBuffer = Buffer.from(content, "utf8"); if (executor.fs) { @@ -901,9 +1049,38 @@ async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, c await checkedRemoteV2(executor, "write-b64-commit", [target, token, expectedBytes, expectedSha256]); } +async function writeRemoteReplacementsBulk(executor: ApplyPatchV2Executor, paths: Iterable, plans: Map): Promise { + const targets = Array.from(paths); + if (targets.length === 0) return; + const records: string[] = []; + for (const target of targets) { + const plan = plans.get(target); + if (plan === undefined) throw new ApplyPatchV2Error("missing bulk replacement plan", { target }); + const replacementFields: string[] = []; + for (const [start, oldLength, newLines] of plan.replacements) { + replacementFields.push([ + String(start), + String(oldLength), + Buffer.from(joinLinesWithFinalNewline(newLines), "utf8").toString("base64"), + ].join(",")); + } + records.push([ + Buffer.from(plan.path, "utf8").toString("base64"), + String(plan.originalBytes), + plan.originalSha256, + String(plan.finalBytes), + plan.finalSha256, + replacementFields.join(";"), + ].join(" ")); + } + await checkedRemoteV2(executor, "apply-replacements-bulk-stdin", [String(targets.length)], `${records.join("\n")}\n`); +} + type RemoteV2Operation = | "stat" | "read-b64-block" + | "read-bulk-b64" + | "apply-replacements-bulk-stdin" | "write-b64-argv" | "write-b64-stdin" | "write-b64-begin" @@ -989,6 +1166,80 @@ function remoteV2Script(operation: RemoteV2Operation, args: string[]): string[] " printf '\\n'", " rm -f -- \"$tmp\"", " ;;", + " read-bulk-b64)", + " count=$#", + ` printf '${remoteBulkReadMarker} %s\\n' "$count"`, + " for target in \"$@\"; do", + " if [ ! -e \"$target\" ]; then printf 'file not found: %s\\n' \"$target\" >&2; exit 1; fi", + " if [ -d \"$target\" ]; then printf 'not a file: %s\\n' \"$target\" >&2; exit 1; fi", + " path_b64=$(printf '%s' \"$target\" | base64 | tr -d '\\n')", + " bytes=$(wc -c < \"$target\" | tr -d '[:space:]')", + " digest=$(sha256_file \"$target\")", + " printf '%s %s %s ' \"$path_b64\" \"$bytes\" \"$digest\"", + " base64 < \"$target\" | tr -d '\\n'", + " printf '\\n'", + " done", + " ;;", + " apply-replacements-bulk-stdin)", + " expected_count=$1", + " tmp_payload=$(mktemp)", + " tmp_dir=$(mktemp -d)", + " trap 'rm -f -- \"$tmp_payload\"; rm -rf -- \"$tmp_dir\"' EXIT HUP INT TERM", + " cat > \"$tmp_payload\"", + " python3 - \"$tmp_payload\" \"$tmp_dir\" \"$expected_count\" <<'PY'", + "import base64, hashlib, pathlib, shutil, sys", + "payload_path, tmp_dir, expected_count_text = sys.argv[1:4]", + "expected_count = int(expected_count_text)", + "records = [line for line in pathlib.Path(payload_path).read_text(encoding='utf-8').splitlines() if line.strip()]", + "if len(records) != expected_count:", + " raise SystemExit(f'bulk replacement record count mismatch: expected={expected_count} actual={len(records)}')", + "plans = []", + "for index, record in enumerate(records, start=1):", + " fields = record.split()", + " if len(fields) != 6:", + " raise SystemExit('bulk replacement malformed record')", + " path_b64, original_bytes_text, original_sha256, final_bytes_text, final_sha256, replacements_text = fields", + " target = base64.b64decode(path_b64).decode('utf-8')", + " original_bytes = int(original_bytes_text)", + " final_bytes = int(final_bytes_text)", + " source = pathlib.Path(target).read_bytes()", + " if len(source) != original_bytes or hashlib.sha256(source).hexdigest() != original_sha256:", + " raise SystemExit(f'bulk replacement original integrity mismatch for {target}')", + " lines = source.decode('utf-8').split('\\n')", + " if lines and lines[-1] == '':", + " lines.pop()", + " replacements = []", + " for item in replacements_text.split(';'):", + " if not item:", + " continue", + " start_text, old_len_text, new_b64 = item.split(',', 2)", + " new_text = base64.b64decode(new_b64).decode('utf-8')", + " new_lines = new_text.split('\\n')", + " if new_lines and new_lines[-1] == '':", + " new_lines.pop()", + " replacements.append((int(start_text), int(old_len_text), new_lines))", + " for start, old_len, new_lines in sorted(replacements, reverse=True):", + " if start < 0 or old_len < 0 or start + old_len > len(lines):", + " raise SystemExit(f'bulk replacement out of bounds for {target}')", + " lines[start:start + old_len] = new_lines", + " output = (('\\n'.join(lines) + '\\n') if lines else '').encode('utf-8')", + " if len(output) != final_bytes or hashlib.sha256(output).hexdigest() != final_sha256:", + " raise SystemExit(f'bulk replacement final integrity mismatch for {target}')", + " tmp_path = pathlib.Path(tmp_dir) / f'file-{index}.tmp'", + " tmp_path.write_bytes(output)", + " plans.append((target, final_sha256, tmp_path))", + "for target, final_sha256, tmp_path in plans:", + " target_path = pathlib.Path(target)", + " if str(target_path.parent) not in ('', '.'):", + " target_path.parent.mkdir(parents=True, exist_ok=True)", + " shutil.move(str(tmp_path), str(target_path))", + " if hashlib.sha256(target_path.read_bytes()).hexdigest() != final_sha256:", + " raise SystemExit(f'bulk replacement final sha256 mismatch for {target}')", + "PY", + " rm -f -- \"$tmp_payload\"", + " rm -rf -- \"$tmp_dir\"", + " trap - EXIT HUP INT TERM", + " ;;", " write-b64-argv)", " target=$1", " expected_bytes=$2", diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 01e95f42..ef1e492e 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -202,7 +202,7 @@ export function sshHelp(): unknown { "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 `trans D601:/work script -- sed -n '1,20p' file`.", "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 follows Codex apply_patch text output rather than UniDesk JSON output; stderr keeps Codex-style failure text and appends one `UNIDESK_APPLY_PATCH_TIMING` JSON summary with durationMs, patchBytes, fileCount, hunkCount, changedCount, remoteOperationCount, remoteOperationCounts and remoteElapsedMs so slow patch runs can be attributed without changing success stdout.", + "`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. Plain multi-file Update File patches on POSIX host/k3s routes use bulk read/write operations to avoid per-file SSH round trips. Its stdout follows Codex apply_patch text output rather than UniDesk JSON output; stderr keeps Codex-style failure text and appends one `UNIDESK_APPLY_PATCH_TIMING` JSON summary with durationMs, patchBytes, fileCount, hunkCount, changedCount, remoteOperationCount, remoteOperationCounts and remoteElapsedMs so slow patch runs can be attributed without changing success stdout.", "`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; it is for host/k3s POSIX shell only. Use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.", diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index ba77f1e2..33e6c5da 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -166,6 +166,21 @@ async function applyPatchV2FixtureAttempt(patch: string, files: Record line.trim().length > 0); + if (records.length !== expectedCount) return { exitCode: 23, stdout: "", stderr: "mock bulk replacement record count mismatch" }; + for (const record of records) { + const fields = record.split(/\s+/u); + if (fields.length !== 6) return { exitCode: 23, stdout: "", stderr: "mock bulk replacement malformed record" }; + const [pathB64, originalBytesText, originalSha256, finalBytesText, finalSha256, replacementsText] = fields; + const targetPath = Buffer.from(pathB64 ?? "", "base64").toString("utf8"); + const original = state.get(targetPath); + if (original === undefined) return { exitCode: 1, stdout: "", stderr: `missing ${targetPath}` }; + const originalBuffer = Buffer.from(original, "utf8"); + if (originalBuffer.length !== Number(originalBytesText) || sha256Hex(original) !== originalSha256) return { exitCode: 23, stdout: "", stderr: "mock bulk replacement original integrity mismatch" }; + const lines = original.split("\n"); + if (lines.at(-1) === "") lines.pop(); + for (const item of (replacementsText ?? "").split(";").filter(Boolean).reverse()) { + const [startText, oldLengthText, newB64] = item.split(",", 3); + const newLines = Buffer.from(newB64 ?? "", "base64").toString("utf8").split("\n"); + if (newLines.at(-1) === "") newLines.pop(); + lines.splice(Number(startText), Number(oldLengthText), ...newLines); + } + const updated = lines.length === 0 ? "" : `${lines.join("\n")}\n`; + if (Buffer.byteLength(updated, "utf8") !== Number(finalBytesText) || sha256Hex(updated) !== finalSha256) return { exitCode: 23, stdout: "", stderr: "mock bulk replacement final integrity mismatch" }; + state.set(targetPath, updated); + } + return { exitCode: 0, stdout: "", stderr: "" }; + } if (operation === "write-b64-begin") { pendingWrites.set(`${target}\0${command[6] ?? ""}`, ""); return { exitCode: 0, stdout: "", stderr: "" }; @@ -800,6 +842,29 @@ export async function runSshArgvGuidanceContract(): Promise { assertCondition((unifiedTiming.remoteOperationCounts.stat ?? 0) >= 1 && (unifiedTiming.remoteOperationCounts["read-b64-block"] ?? 0) >= 1 && Object.keys(unifiedTiming.remoteOperationCounts).some((key) => key.startsWith("write-b64")), "v2 timing summary should classify stat/read/write operations", unifiedTiming); assertCondition(unifiedHeaderLineRangeV2.stdout.startsWith("Success. Updated the following files:"), "v2 timing summary must not change Codex-compatible success stdout", unifiedHeaderLineRangeV2.stdout); + const bulkPatchLines = ["*** Begin Patch"]; + const bulkFiles: Record = {}; + for (let fileIndex = 0; fileIndex < 4; fileIndex += 1) { + const fileName = `bulk-${fileIndex}.txt`; + bulkFiles[fileName] = Array.from({ length: 120 }, (_, lineIndex) => `file=${fileIndex} line=${lineIndex} value=alpha`).join("\n") + "\n"; + bulkPatchLines.push(`*** Update File: ${fileName}`); + bulkPatchLines.push("@@"); + bulkPatchLines.push(` file=${fileIndex} line=39 value=alpha`); + bulkPatchLines.push(`-file=${fileIndex} line=40 value=alpha`); + bulkPatchLines.push(`+file=${fileIndex} line=40 value=beta`); + bulkPatchLines.push(` file=${fileIndex} line=41 value=alpha`); + } + bulkPatchLines.push("*** End Patch", ""); + const bulkV2 = await applyPatchV2FixtureAttempt(bulkPatchLines.join("\n"), bulkFiles, { stderrOutput: true }); + assertCondition(bulkV2.exitCode === 0 && bulkV2.error === null, "v2 multi-file update patch should succeed through the bulk path", bulkV2); + assertCondition(bulkV2.commands.filter((command) => command.startsWith("read-bulk-b64")).length === 1 && bulkV2.commands.filter((command) => command.startsWith("apply-replacements-bulk-stdin")).length === 1, "v2 multi-file updates should collapse remote IO to one bulk read and one line-level bulk apply", bulkV2.commands); + assertCondition(!bulkV2.commands.some((command) => command.startsWith("stat") || command.startsWith("read-b64-block") || command.startsWith("write-b64-stdin")), "v2 bulk path should avoid per-file stat/read/write operations", bulkV2.commands); + for (let fileIndex = 0; fileIndex < 4; fileIndex += 1) { + assertCondition(bulkV2.files[`bulk-${fileIndex}.txt`]?.includes(`file=${fileIndex} line=40 value=beta`), "v2 bulk path should write all changed files", { fileIndex, files: bulkV2.files }); + } + const bulkTiming = applyPatchTimingFromStderr(bulkV2.stderr); + assertCondition(bulkTiming.remoteOperationCount === 2 && bulkTiming.remoteOperationCounts["read-bulk-b64"] === 1 && bulkTiming.remoteOperationCounts["apply-replacements-bulk-stdin"] === 1, "v2 timing summary should classify bulk read and line-level apply operations", bulkTiming); + const unprefixedUpdateContextV2 = await applyPatchV2FixtureAttempt([ "*** Begin Patch", "*** Update File: internal/cloud/access-control.ts", @@ -1018,7 +1083,7 @@ export async function runSshArgvGuidanceContract(): Promise { assertCondition(failedCompoundV2.error !== null, "v2 compound patch should fail when a later hunk does not match", failedCompoundV2); assertCondition(failedCompoundV2.files["first.txt"] === "new first\n", "v2 should match Codex apply_patch by preserving earlier committed changes 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")), "v2 should commit preceding operations in patch order like Codex apply_patch", failedCompoundV2.commands); + assertCondition(failedCompoundV2.commands.some((command) => command.startsWith("write-b64") || command.startsWith("apply-replacements-bulk-stdin")), "v2 should commit preceding operations in patch order like Codex apply_patch", failedCompoundV2.commands); assertCondition( Array.isArray((failedCompoundV2.error as { details?: { partialChanges?: unknown } })?.details?.partialChanges) && ((failedCompoundV2.error as { details?: { partialChanges?: string[] } }).details?.partialChanges ?? []).includes("M first.txt"),