diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 10054500..af14ef32 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -24,6 +24,7 @@ Host workspace、k3s、Windows、GitHub issue/PR route,sh/bash/argv/apply-patc ## P0 边界 - 远端文本修改优先 `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` 慢路径改用临时脚本写文件。 - `sh`/`bash` 必须显式声明 shell;单进程命令优先 direct argv 或已知 operation。 - 普通 trans/ssh 短连接硬预算 60s;长 CI/CD、trace、logs、build、硬件流程必须 submit-and-poll。 - Windows route 使用 `win ps`、`win cmd` 或只读 fs 操作 `pwd|ls|cat|head|tail|stat|wc|rg`;不要把 POSIX shell 当 Windows shell。 diff --git a/.agents/skills/unidesk-trans/references/full.md b/.agents/skills/unidesk-trans/references/full.md index b362a4af..fc7e792b 100644 --- a/.agents/skills/unidesk-trans/references/full.md +++ b/.agents/skills/unidesk-trans/references/full.md @@ -148,6 +148,8 @@ trans G14:/root/hwlab apply-patch < patch.diff v2 引擎(默认):本地 TypeScript 解析 hunk,远端只读写文件。v1 legacy 入口:`apply-patch-v1`。 +Host/WSL 与 Windows route 的 `apply-patch` 优先使用 route fs adapter 的 bulk update path。单文件文本 update 应看到 `remoteOperationCounts` 类似 `{"fs.readFiles":1,"fs.applyReplacementsBulk":1}`,而不是旧慢路径 `stat/read-b64-block/write-b64-argv`。如果 D601 这类 provider 出现瞬态通道抖动,先保留 `apply-patch` 路径并重试/查 `debug ssh-pool `,不要退回 PowerShell/Python/sed 临时写文件。 + ### py(远端 Python 脚本) ```bash diff --git a/scripts/src/apply-patch-v2.ts b/scripts/src/apply-patch-v2.ts index 7e945b03..db08a429 100644 --- a/scripts/src/apply-patch-v2.ts +++ b/scripts/src/apply-patch-v2.ts @@ -1179,7 +1179,7 @@ export function formatApplyPatchV2BulkReplacementPayload(paths: Iterable return { targets, payload: `${records.join("\n")}\n` }; } -type RemoteV2Operation = +export type ApplyPatchV2RemoteOperation = | "stat" | "read-b64-block" | "read-bulk-b64" @@ -1191,11 +1191,11 @@ type RemoteV2Operation = | "write-b64-commit" | "delete"; -async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: RemoteV2Operation, args: string[], input?: string): Promise<{ stdout: string }> { +async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: ApplyPatchV2RemoteOperation, args: string[], input?: string): Promise<{ stdout: string }> { if (!executor.run) { throw new ApplyPatchV2Error("remote apply-patch v2 executor does not provide a command runner", { operation, args }); } - const result = await executor.run(remoteV2Script(operation, args), input); + const result = await executor.run(applyPatchV2RemoteCommand(operation, args), input); if (result.exitCode === 0) return result; throw new ApplyPatchV2Error("remote apply-patch v2 operation failed", { operation, @@ -1206,7 +1206,7 @@ async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: Remote }); } -function remoteV2Script(operation: RemoteV2Operation, args: string[]): string[] { +export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation, args: string[]): string[] { const script = [ "set -eu", "sha256_file() {", diff --git a/scripts/src/remote-ssh.ts b/scripts/src/remote-ssh.ts index b95e3e03..58ac1f2c 100644 --- a/scripts/src/remote-ssh.ts +++ b/scripts/src/remote-ssh.ts @@ -7,6 +7,7 @@ import { type UniDeskConfig } from "./config"; import { type RemoteCliOptions } from "./remote-options"; import { buildWindowsPowerShellInvocation, + createPosixApplyPatchFileSystem, createWindowsApplyPatchFileSystem, createSshStdoutForwarder, formatSshFailureHint, @@ -904,7 +905,9 @@ async function runRemoteSshOverFrontend(session: FrontendSession, target: string if ((normalizedArgs[0] ?? "") === "apply-patch") { const executor: ApplyPatchV2Executor = invocation.route.plane === "win" ? { fs: createWindowsApplyPatchFileSystem(invocation, (remoteCommand, input) => runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input)) } - : { run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input) }; + : invocation.route.plane === "host" + ? { fs: createPosixApplyPatchFileSystem(invocation, (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input)) } + : { run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input) }; return await runApplyPatchV2({ executor, stdin: process.stdin, diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 15cbfdea..2ba1b93e 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -8,6 +8,7 @@ import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf"; import { buildWindowsPowerShellInvocation, + createPosixApplyPatchFileSystem, createWindowsApplyPatchFileSystem, createSshStdoutForwarder, formatSshFailureHint, @@ -1555,7 +1556,9 @@ async function runRemoteSshOverFrontend(session: FrontendSession, target: string if ((normalizedArgs[0] ?? "") === "apply-patch") { const executor: ApplyPatchV2Executor = invocation.route.plane === "win" ? { fs: createWindowsApplyPatchFileSystem(invocation, (remoteCommand, input) => runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input)) } - : { run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input) }; + : invocation.route.plane === "host" + ? { fs: createPosixApplyPatchFileSystem(invocation, (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input)) } + : { run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input) }; return await runApplyPatchV2({ executor, stdin: process.stdin, diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index 2f9b0b44..fc7690ba 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -1,5 +1,7 @@ +import { createHash } from "node:crypto"; import { describe, expect, test } from "bun:test"; import { + createPosixApplyPatchFileSystem, createSshStdoutForwarder, formatSshStdoutTruncationHint, parseSshInvocation, @@ -9,6 +11,10 @@ import { windowsPowerShellScriptPrelude, } from "./ssh"; +function sha256Hex(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + describe("ssh windows PowerShell safety prelude", () => { test("shadows ConvertTo-Json and strips filesystem ETS metadata", () => { const prelude = windowsPowerShellScriptPrelude("C:\\test"); @@ -59,6 +65,58 @@ describe("ssh windows fs read-only operations", () => { }); }); +describe("ssh host apply-patch fs backend", () => { + test("uses POSIX bulk fs operations for host routes", async () => { + const invocation = parseSshInvocation("D601:/mnt/f/Work/ConStart", ["apply-patch"]); + const text = "alpha\nold\nomega\n"; + const calls: Array<{ operation: string; args: string[]; input?: string }> = []; + const fs = createPosixApplyPatchFileSystem(invocation, async (command, input) => { + const operation = command[4] ?? ""; + const args = command.slice(5); + calls.push({ operation, args, input }); + if (operation === "read-bulk-b64") { + return { + exitCode: 0, + stderr: "", + stdout: [ + "UNIDESK_APPLY_PATCH_V2_BULK_READ 1", + [ + Buffer.from("docs/test.md", "utf8").toString("base64"), + String(Buffer.byteLength(text, "utf8")), + sha256Hex(text), + Buffer.from(text, "utf8").toString("base64"), + ].join(" "), + "", + ].join("\n"), + }; + } + if (operation === "apply-replacements-bulk-stdin") { + expect(input).toContain(Buffer.from("docs/test.md", "utf8").toString("base64")); + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected operation ${operation}`); + }); + + const files = await fs.readFiles!(["docs/test.md"]); + await fs.applyReplacementsBulk!(["docs/test.md"], new Map([[ + "docs/test.md", + { + path: "docs/test.md", + originalBytes: Buffer.byteLength(text, "utf8"), + originalSha256: sha256Hex(text), + finalBytes: Buffer.byteLength("alpha\nnew\nomega\n", "utf8"), + finalSha256: sha256Hex("alpha\nnew\nomega\n"), + replacements: [[1, 1, ["new"]]], + }, + ]])); + + expect(files.get("docs/test.md")).toBe(text); + expect(calls.map((call) => call.operation)).toEqual(["read-bulk-b64", "apply-replacements-bulk-stdin"]); + expect(calls[0]?.args).toEqual(["docs/test.md"]); + expect(calls[1]?.args).toEqual(["1"]); + }); +}); + describe("ssh stdout bounded streaming", () => { test("uses bounded defaults and clamps env override", () => { expect(sshStdoutStreamMaxBytes({} as NodeJS.ProcessEnv)).toBe(256 * 1024); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index d9777e4b..2dfc0185 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { type UniDeskConfig, repoRoot } from "./config"; import { ApplyPatchV2Error, + applyPatchV2RemoteCommand, decodeApplyPatchV2BulkRead, formatApplyPatchV2BulkReplacementPayload, isApplyPatchV2HelpArgs, @@ -13,6 +14,7 @@ import { type ApplyPatchV2BulkReplacementWritePlan, type ApplyPatchV2Executor, type ApplyPatchV2FileSystem, + type ApplyPatchV2RemoteOperation, } from "./apply-patch-v2"; import { isSshFileTransferOperation, @@ -2912,16 +2914,18 @@ type WindowsApplyPatchFsOperation = | "delete"; const windowsApplyPatchWriteB64ChunkChars = 12_000; +const posixApplyPatchWriteB64ChunkChars = 12_000; -export function createWindowsApplyPatchFileSystem(invocation: ParsedSshInvocation, runRemoteCommand: (remoteCommand: string, input?: string) => Promise): ApplyPatchV2FileSystem { - async function checked(operation: WindowsApplyPatchFsOperation, args: string[], input?: string): Promise { - const command = buildWindowsPowerShellInvocation(windowsApplyPatchFsScript(invocation.route.workspace, operation, args)); +type PosixApplyPatchFsOperation = Exclude; + +export function createPosixApplyPatchFileSystem(invocation: ParsedSshInvocation, runRemoteCommand: (command: string[], input?: string) => Promise): ApplyPatchV2FileSystem { + async function checked(operation: PosixApplyPatchFsOperation, args: string[], input?: string): Promise { const startedAtMs = Date.now(); let result: SshCaptureResult; try { - result = await runRemoteCommand(command, input); + result = await runRemoteCommand(applyPatchV2RemoteCommand(operation, args), input); } catch (error) { - throw new ApplyPatchV2Error(`windows apply-patch fs operation failed: ${operation}`, windowsApplyPatchFsFailureDetails({ + throw new ApplyPatchV2Error(`posix apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ operation, args, input, @@ -2931,7 +2935,85 @@ export function createWindowsApplyPatchFileSystem(invocation: ParsedSshInvocatio })); } if (result.exitCode === 0) return result; - throw new ApplyPatchV2Error(`windows apply-patch fs operation failed: ${operation}`, windowsApplyPatchFsFailureDetails({ + throw new ApplyPatchV2Error(`posix apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ + operation, + args, + input, + invocation, + result, + remoteElapsedMs: Math.max(0, Date.now() - startedAtMs), + })); + } + + return { + async stat(filePath) { + const result = await checked("stat", [filePath]); + const [bytesText, sha256] = result.stdout.trim().split(/\s+/u); + const bytes = Number(bytesText); + if (!Number.isSafeInteger(bytes) || bytes < 0 || !/^[0-9a-f]{64}$/u.test(sha256 ?? "")) { + throw new Error(`posix apply-patch fs stat returned invalid metadata: ${JSON.stringify({ filePath, stdout: result.stdout.slice(0, 500) })}`); + } + return { bytes, sha256: sha256! }; + }, + async readBlock(filePath, blockIndex, blockBytes) { + const result = await checked("read-b64-block", [filePath, String(blockIndex), String(blockBytes)]); + const encoded = result.stdout + .split(/\r?\n/u) + .filter((line) => !line.startsWith("UNIDESK_APPLY_PATCH_V2_BLOCK ")) + .join("") + .replace(/\s+/gu, ""); + return encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64"); + }, + async writeFile(filePath, content) { + const encoded = content.toString("base64"); + const expectedBytes = String(content.length); + const expectedSha256 = createHash("sha256").update(content).digest("hex"); + try { + await checked("write-b64-stdin", [filePath, expectedBytes, expectedSha256], encoded); + return; + } catch { + const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`; + await checked("write-b64-begin", [filePath, token]); + for (const chunk of chunkString(encoded, posixApplyPatchWriteB64ChunkChars)) { + await checked("write-b64-append", [filePath, token, chunk]); + } + await checked("write-b64-commit", [filePath, token, expectedBytes, expectedSha256]); + } + }, + async deleteFile(filePath) { + await checked("delete", [filePath]); + }, + async readFiles(filePaths) { + const result = await checked("read-bulk-b64", filePaths); + return decodeApplyPatchV2BulkRead(result.stdout, filePaths); + }, + async applyReplacementsBulk(filePaths, plans: Map) { + const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(filePaths, plans); + if (targets.length === 0) return; + await checked("apply-replacements-bulk-stdin", [String(targets.length)], payload); + }, + }; +} + +export function createWindowsApplyPatchFileSystem(invocation: ParsedSshInvocation, runRemoteCommand: (remoteCommand: string, input?: string) => Promise): ApplyPatchV2FileSystem { + async function checked(operation: WindowsApplyPatchFsOperation, args: string[], input?: string): Promise { + const command = buildWindowsPowerShellInvocation(windowsApplyPatchFsScript(invocation.route.workspace, operation, args)); + const startedAtMs = Date.now(); + let result: SshCaptureResult; + try { + result = await runRemoteCommand(command, input); + } catch (error) { + throw new ApplyPatchV2Error(`windows apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ + operation, + args, + input, + invocation, + remoteElapsedMs: Math.max(0, Date.now() - startedAtMs), + cause: error instanceof Error ? { name: error.name, message: error.message } : { message: String(error) }, + })); + } + if (result.exitCode === 0) return result; + throw new ApplyPatchV2Error(`windows apply-patch fs operation failed: ${operation}`, applyPatchFsFailureDetails({ operation, args, input, @@ -2987,8 +3069,8 @@ export function createWindowsApplyPatchFileSystem(invocation: ParsedSshInvocatio }; } -function windowsApplyPatchFsFailureDetails(options: { - operation: WindowsApplyPatchFsOperation; +function applyPatchFsFailureDetails(options: { + operation: string; args: string[]; input?: string; invocation: ParsedSshInvocation; @@ -2998,8 +3080,8 @@ function windowsApplyPatchFsFailureDetails(options: { }): Record { const { operation, args, input, invocation, result } = options; const inputBytes = input === undefined ? 0 : Buffer.byteLength(input, "utf8"); - const expected = windowsApplyPatchExpectedWrite(operation, args); - const targetCount = windowsApplyPatchBulkTargetCount(operation, args); + const expected = applyPatchFsExpectedWrite(operation, args); + const targetCount = applyPatchFsBulkTargetCount(operation, args); return { operation, route: invocation.route.raw, @@ -3021,14 +3103,18 @@ function windowsApplyPatchFsFailureDetails(options: { }; } -function windowsApplyPatchExpectedWrite(operation: WindowsApplyPatchFsOperation, args: string[]): { expectedBytes?: string; expectedSha256?: string } { +function applyPatchFsExpectedWrite(operation: string, args: string[]): { expectedBytes?: string; expectedSha256?: string } { if (operation === "write-b64-stdin") return { expectedBytes: args[1], expectedSha256: args[2] }; if (operation === "write-b64-commit") return { expectedBytes: args[2], expectedSha256: args[3] }; return {}; } -function windowsApplyPatchBulkTargetCount(operation: WindowsApplyPatchFsOperation, args: string[]): number | null { - if (operation !== "read-bulk-b64" && operation !== "apply-replacements-bulk-stdin") return null; +function applyPatchFsBulkTargetCount(operation: string, args: string[]): number | null { + if (operation === "read-bulk-b64") { + const count = Number(args[0]); + return Number.isSafeInteger(count) && count >= 0 ? count : args.length; + } + if (operation !== "apply-replacements-bulk-stdin") return null; const count = Number(args[0]); return Number.isSafeInteger(count) && count >= 0 ? count : null; } @@ -3476,7 +3562,9 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st const applyPatch = effectiveApplyPatchV2Invocation(invocation, normalizedArgs.slice(1)); const executor: ApplyPatchV2Executor = applyPatch.invocation.route.plane === "win" ? { fs: createWindowsApplyPatchFileSystem(applyPatch.invocation, (remoteCommand, input) => runSshCaptureRemoteCommand(config, applyPatch.invocation, remoteCommand, input)) } - : { run: (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input) }; + : applyPatch.invocation.route.plane === "host" + ? { fs: createPosixApplyPatchFileSystem(applyPatch.invocation, (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input)) } + : { run: (command, input) => runSshCaptureCommand(config, applyPatch.invocation, command, input) }; return await runApplyPatchV2({ executor, stdin: process.stdin,