diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index c70ff5ad..67376de2 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -29,6 +29,7 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md - 远端文本修改优先 `trans apply-patch`;不要 download/upload/sed 拼临时 diff 代替 patch。 - Host/WSL 与 Windows route 的 `apply-patch` 优先走 fs adapter bulk update path;不要为了规避旧的 `read-b64-block` / `write-b64-argv` 慢路径改用临时脚本写文件。 +- `apply-patch v2` 的 localized replacement 必须保留纯 CRLF 文件的 CRLF 风格;original/final integrity mismatch 会重新只读采集当前文件并输出 adapter、stage、expected/current bytes、SHA-256、newline 和 encoding 摘要,不披露文件正文,也不回退 `apply-patch-v1`。 - 当前 HWLAB node/lane source workspace 的正式预检/同步入口是 `bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node --lane --confirm` 和 `source-workspace status`;不要把裸 `trans : git fetch ...` 当成当前 HWLAB node/lane workspace 预检/修复入口。 - `sh`/`bash` 必须显式声明 shell;单进程命令优先 direct argv 或已知 operation。 - 普通 trans/ssh 短连接硬预算 60s;长 CI/CD、trace、logs、build、硬件流程必须 submit-and-poll。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index bcec108d..b3c986ca 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -12,6 +12,11 @@ UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式 Windows `ps` 的参数语义分为两种:单个参数是完整 PowerShell source;多个参数是边界明确的 argv。后者对 native application 使用 Windows 命令行引用规则,必须保留 `python -c` 代码中的路径、`encoding="utf-8"`、字典 key 和嵌套引号,并继承 route 对应的 Windows workspace。PowerShell prelude 将 `Get-Content` 默认编码固定为 UTF-8;Windows fs helper 的 `head` / `tail` 同时支持 `-n N`、`-nN`、`--lines=N` 和 GNU `-N`,`wc -l` 必须把选项与路径分开解析。 +- Windows/WSL `apply-patch v2` 文本完整性合同: + - localized replacement 必须保留纯 CRLF 文件的 CRLF 风格,不能把新增行写成 bare LF,也不能把整个文件静默改写为 LF。 + - original/final integrity mismatch 后必须重新只读采集当前文件,输出 adapter、stage、expected/current bytes、SHA-256、newline 和 encoding;摘要不得包含文件正文。 + - Windows 与 WSL route 对同一文件使用同一 replacement plan 和 payload 合同;失败不能自动或人工建议回退 Windows 不支持的 `apply-patch-v1`。 + CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI/CD server 和生产运行面。CLI/server 能力协商、unsupported-version 失败语义和 release-line 边界由 `docs/reference/release-governance.md` 与 [GitHub issue #6](https://github.com/pikasTech/unidesk/issues/6) 约束。 ## CI/CD Control Boundary diff --git a/scripts/src/apply-patch-v2.test.ts b/scripts/src/apply-patch-v2.test.ts index c5455697..bcfe2279 100644 --- a/scripts/src/apply-patch-v2.test.ts +++ b/scripts/src/apply-patch-v2.test.ts @@ -98,6 +98,75 @@ describe("apply-patch v2 fs bulk update path", () => { expect(stderr.text()).toContain("\"remoteOperationCounts\":{\"fs.readFiles\":1,\"fs.applyReplacementsBulk\":1}"); }); + test("preserves CRLF for single and multi-chunk localized replacements", async () => { + const cases = [ + { + name: "single", + patch: [ + "*** Begin Patch", + "*** Update File: src/test.c", + "@@", + " alpha", + "-old-a", + "+new-a", + " middle", + "*** End Patch", + ], + expected: "alpha\r\nnew-a\r\nmiddle\r\nold-b\r\nomega\r\n", + }, + { + name: "multi", + patch: [ + "*** Begin Patch", + "*** Update File: src/test.c", + "@@", + " alpha", + "-old-a", + "+new-a", + " middle", + "@@", + "-old-b", + "+new-b", + " omega", + "*** End Patch", + ], + expected: "alpha\r\nnew-a\r\nmiddle\r\nnew-b\r\nomega\r\n", + }, + ]; + + for (const item of cases) { + const files = new Map([["src/test.c", "alpha\r\nold-a\r\nmiddle\r\nold-b\r\nomega\r\n"]]); + let capturedPlan: ApplyPatchV2BulkReplacementWritePlan | undefined; + const fs: ApplyPatchV2FileSystem = { + async stat() { throw new Error("stat should not be used"); }, + async readBlock() { throw new Error("readBlock should not be used"); }, + async writeFile() { throw new Error("writeFile should not be used"); }, + async deleteFile() { throw new Error("deleteFile should not be used"); }, + async readFiles(paths) { return new Map(paths.map((path) => [path, files.get(path) ?? ""])); }, + async applyReplacementsBulk(paths, plans) { + for (const target of paths) { + const plan = plans.get(target); + if (plan === undefined) throw new Error(`missing plan for ${target}`); + capturedPlan = plan; + files.set(target, applyReplacementPlan(files.get(target) ?? "", plan)); + } + }, + }; + const exitCode = await runApplyPatchV2({ + executor: { fs }, + stdin: Readable.from([item.patch.join("\n")]), + stdout: new CaptureWritable(), + stderr: new CaptureWritable(), + }); + + expect(exitCode, item.name).toBe(0); + expect(files.get("src/test.c"), item.name).toBe(item.expected); + expect(files.get("src/test.c")?.match(/(? { const files = new Map([ ["a.md", "first\nold-a\nlast\n"], @@ -244,4 +313,58 @@ describe("apply-patch v2 fs bulk update path", () => { expect(err).toContain("Remote stderr tail:"); expect(err).toContain("UNIDESK_SSH_RUNTIME_TIMEOUT timeoutSeconds=60"); }); + + test("integrity mismatch reports current fingerprint and text format without file content", async () => { + let readCount = 0; + const fs: ApplyPatchV2FileSystem = { + async stat() { throw new Error("stat should not be used"); }, + async readBlock() { throw new Error("readBlock should not be used"); }, + async writeFile() { throw new Error("writeFile should not be used"); }, + async deleteFile() { throw new Error("deleteFile should not be used"); }, + async readFiles(paths) { + readCount += 1; + const text = readCount === 1 ? "alpha\r\nold\r\nomega\r\n" : "alpha\r\nchanged-elsewhere\r\nomega\r\n"; + return new Map(paths.map((path) => [path, text])); + }, + async applyReplacementsBulk() { + throw new ApplyPatchV2Error("windows apply-patch fs operation failed: apply-replacements-bulk-stdin", { + operation: "apply-replacements-bulk-stdin", + route: "D601:win/F/Work/ConStart", + targetCount: 1, + landed: false, + exitCode: 23, + stderrTail: "bulk replacement original integrity mismatch for F:\\Work\\ConStart\\src\\test.c", + }); + }, + }; + const stdout = new CaptureWritable(); + const stderr = new CaptureWritable(); + const exitCode = await runApplyPatchV2({ + executor: { fs }, + stdin: Readable.from([[ + "*** Begin Patch", + "*** Update File: src/test.c", + "@@", + " alpha", + "-old", + "+new", + " omega", + "*** End Patch", + ].join("\n")]), + stdout, + stderr, + timing: { transport: "local", route: "D601:win/F/Work/ConStart" }, + }); + + expect(exitCode).toBe(1); + expect(stdout.text()).toBe(""); + expect(readCount).toBe(2); + const err = stderr.text(); + expect(err).toContain("Integrity: adapter=windows stage=original file=src/test.c"); + expect(err).toContain("expectedOriginalSha256="); + expect(err).toContain("currentSha256="); + expect(err).toContain("expectedNewline=crlf currentNewline=crlf"); + expect(err).toContain("expectedEncoding=utf8 currentEncoding=utf8"); + expect(err).not.toContain("changed-elsewhere"); + }); }); diff --git a/scripts/src/apply-patch-v2.ts b/scripts/src/apply-patch-v2.ts index db08a429..c2411c42 100644 --- a/scripts/src/apply-patch-v2.ts +++ b/scripts/src/apply-patch-v2.ts @@ -124,6 +124,8 @@ export interface ApplyPatchV2BulkReplacementWritePlan { originalSha256: string; finalBytes: number; finalSha256: string; + newlineStyle: "lf" | "crlf" | "mixed" | "none"; + encodingStyle: "utf8" | "utf8-bom"; replacements: ApplyPatchV2Replacement[]; } type BulkReplacementWritePlan = ApplyPatchV2BulkReplacementWritePlan; @@ -665,7 +667,8 @@ async function applyPatchV2UpdateHunksBulk(executor: ApplyPatchV2Executor, hunks 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 textFormat = detectApplyPatchTextFormat(state.content); + const replacements = preserveReplacementNewlines(computeReplacements(hunk.path, originalLines, hunk.chunks), textFormat.newlineStyle); const newContent = joinLinesWithFinalNewline(applyReplacements(originalLines, replacements)); const originalBuffer = Buffer.from(state.content, "utf8"); const finalBuffer = Buffer.from(newContent, "utf8"); @@ -676,6 +679,8 @@ async function applyPatchV2UpdateHunksBulk(executor: ApplyPatchV2Executor, hunks originalSha256: sha256Hex(originalBuffer), finalBytes: finalBuffer.length, finalSha256: sha256Hex(finalBuffer), + newlineStyle: textFormat.newlineStyle, + encodingStyle: textFormat.encodingStyle, replacements, }); pendingWrites.add(hunk.path); @@ -777,6 +782,27 @@ function appendRemoteOperationFailureDetails(lines: string[], details: Record 0) { lines.push(`Remote operation: ${summaryFields.map(([key, value]) => `${key}=${value}`).join(" ")}`); } + const integrityDiagnostics = Array.isArray(remote.integrityDiagnostics) ? remote.integrityDiagnostics : []; + for (const item of integrityDiagnostics) { + const diagnostic = recordValue(item); + if (diagnostic === null) continue; + const fields = [ + ["adapter", stringField(diagnostic, "adapter")], + ["stage", stringField(diagnostic, "stage")], + ["file", stringField(diagnostic, "path")], + ["expectedOriginalBytes", numberOrStringField(diagnostic, "expectedOriginalBytes")], + ["currentBytes", numberOrStringField(diagnostic, "currentBytes")], + ["expectedOriginalSha256", stringField(diagnostic, "expectedOriginalSha256")], + ["currentSha256", stringField(diagnostic, "currentSha256")], + ["expectedFinalBytes", numberOrStringField(diagnostic, "expectedFinalBytes")], + ["expectedFinalSha256", stringField(diagnostic, "expectedFinalSha256")], + ["expectedNewline", stringField(diagnostic, "expectedNewline")], + ["currentNewline", stringField(diagnostic, "currentNewline")], + ["expectedEncoding", stringField(diagnostic, "expectedEncoding")], + ["currentEncoding", stringField(diagnostic, "currentEncoding")], + ].filter(([, value]) => value !== undefined && value !== null && value !== ""); + lines.push(`Integrity: ${fields.map(([key, value]) => `${key}=${value}`).join(" ")}`); + } const stderrTail = stringField(remote, "stderrTail") ?? stringField(remote, "stderr"); if (stderrTail !== undefined && stderrTail.trim().length > 0) { lines.push("Remote stderr tail:"); @@ -1147,12 +1173,58 @@ async function writeRemoteReplacementsBulk(executor: ApplyPatchV2Executor, paths const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(paths, plans); if (targets.length === 0) return; if (executor.fs?.applyReplacementsBulk !== undefined) { - await executor.fs.applyReplacementsBulk(targets, plans); + try { + await executor.fs.applyReplacementsBulk(targets, plans); + } catch (error) { + throw await enrichBulkReplacementIntegrityFailure(error, executor.fs, targets, plans); + } return; } await checkedRemoteV2(executor, "apply-replacements-bulk-stdin", [String(targets.length)], payload); } +async function enrichBulkReplacementIntegrityFailure(error: unknown, fs: ApplyPatchV2FileSystem, targets: string[], plans: Map): Promise { + const message = error instanceof Error ? error.message : String(error); + const details = error instanceof ApplyPatchV2Error ? error.details : {}; + const serialized = `${message}\n${JSON.stringify(details)}`; + if (fs.readFiles === undefined || !serialized.includes("integrity mismatch")) return error instanceof ApplyPatchV2Error ? error : new ApplyPatchV2Error(message); + let currentFiles: Map; + try { + currentFiles = await fs.readFiles(targets); + } catch (readError) { + return new ApplyPatchV2Error(message, { + ...details, + integrityDiagnosticError: readError instanceof Error ? readError.message : String(readError), + }); + } + const stage = serialized.includes("original integrity mismatch") ? "original" : serialized.includes("final integrity mismatch") ? "final" : "unknown"; + const route = typeof details.route === "string" ? details.route : ""; + const adapter = route.includes(":win") ? "windows" : "posix"; + const integrityDiagnostics = targets.flatMap((target) => { + const plan = plans.get(target); + const current = currentFiles.get(target); + if (plan === undefined || current === undefined) return []; + const currentBuffer = Buffer.from(current, "utf8"); + const currentFormat = detectApplyPatchTextFormat(current); + return [{ + adapter, + stage, + path: target, + expectedOriginalBytes: plan.originalBytes, + expectedOriginalSha256: plan.originalSha256, + expectedFinalBytes: plan.finalBytes, + expectedFinalSha256: plan.finalSha256, + currentBytes: currentBuffer.length, + currentSha256: sha256Hex(currentBuffer), + expectedNewline: plan.newlineStyle, + currentNewline: currentFormat.newlineStyle, + expectedEncoding: plan.encodingStyle, + currentEncoding: currentFormat.encodingStyle, + }]; + }); + return new ApplyPatchV2Error(message, { ...details, integrityDiagnostics }); +} + export function formatApplyPatchV2BulkReplacementPayload(paths: Iterable, plans: Map): { targets: string[]; payload: string } { const targets = Array.from(paths); const records: string[] = []; @@ -1291,6 +1363,19 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation " cat > \"$tmp_payload\"", " python3 - \"$tmp_payload\" \"$tmp_dir\" \"$expected_count\" <<'PY'", "import base64, hashlib, pathlib, shutil, sys", + "def text_profile(source):", + " encoding = 'utf8-bom' if source.startswith(b'\\xef\\xbb\\xbf') else 'utf8'", + " text = source.decode('utf-8')", + " crlf = text.count('\\r\\n')", + " bare_lf = text.count('\\n') - crlf", + " lone_cr = text.count('\\r') - crlf", + " newline = 'crlf' if crlf > 0 and bare_lf == 0 and lone_cr == 0 else ('lf' if bare_lf > 0 and crlf == 0 and lone_cr == 0 else ('none' if crlf == 0 and bare_lf == 0 and lone_cr == 0 else 'mixed'))", + " return encoding, newline", + "def integrity_failure(adapter, stage, target, expected_bytes, expected_sha256, source):", + " actual_sha256 = hashlib.sha256(source).hexdigest()", + " actual_encoding, actual_newline = text_profile(source)", + " print(f'UNIDESK_APPLY_PATCH_V2_INTEGRITY adapter={adapter} stage={stage} expectedBytes={expected_bytes} actualBytes={len(source)} expectedSha256={expected_sha256} actualSha256={actual_sha256} actualEncoding={actual_encoding} actualNewline={actual_newline}', file=sys.stderr)", + " raise SystemExit(f'bulk replacement {stage} integrity mismatch for {target}')", "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()]", @@ -1307,7 +1392,7 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation " 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}')", + " integrity_failure('posix', 'original', target, original_bytes, original_sha256, source)", " lines = source.decode('utf-8').split('\\n')", " if lines and lines[-1] == '':", " lines.pop()", @@ -1327,7 +1412,7 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation " 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}')", + " integrity_failure('posix', 'final', target, final_bytes, final_sha256, output)", " tmp_path = pathlib.Path(tmp_dir) / f'file-{index}.tmp'", " tmp_path.write_bytes(output)", " plans.append((target, final_sha256, tmp_path))", @@ -1551,6 +1636,32 @@ function splitContentLines(content: string): string[] { return lines; } +function detectApplyPatchTextFormat(content: string): Pick { + const crlfCount = (content.match(/\r\n/gu) ?? []).length; + const bareLfCount = (content.match(/(? 0 && bareLfCount === 0 && loneCrCount === 0 + ? "crlf" + : bareLfCount > 0 && crlfCount === 0 && loneCrCount === 0 + ? "lf" + : crlfCount === 0 && bareLfCount === 0 && loneCrCount === 0 + ? "none" + : "mixed"; + return { + newlineStyle, + encodingStyle: content.startsWith("\uFEFF") ? "utf8-bom" : "utf8", + }; +} + +function preserveReplacementNewlines(replacements: Replacement[], newlineStyle: ApplyPatchV2BulkReplacementWritePlan["newlineStyle"]): Replacement[] { + if (newlineStyle !== "crlf") return replacements; + return replacements.map(([start, oldLength, newLines]) => [ + start, + oldLength, + newLines.map((line) => line.endsWith("\r") ? line : `${line}\r`), + ]); +} + function joinLinesWithFinalNewline(lines: string[]): string { if (lines.length === 0) return ""; return `${lines.join("\n")}\n`; diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index b5a39ff3..49605d5f 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -208,6 +208,8 @@ describe("ssh host apply-patch fs backend", () => { originalSha256: sha256Hex(text), finalBytes: Buffer.byteLength("alpha\nnew\nomega\n", "utf8"), finalSha256: sha256Hex("alpha\nnew\nomega\n"), + newlineStyle: "lf", + encodingStyle: "utf8", replacements: [[1, 1, ["new"]]], }, ]]));