fix: use fs backend for host apply-patch
This commit is contained in:
@@ -24,6 +24,7 @@ Host workspace、k3s、Windows、GitHub issue/PR route,sh/bash/argv/apply-patc
|
||||
## P0 边界
|
||||
|
||||
- 远端文本修改优先 `trans <route> 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。
|
||||
|
||||
@@ -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 <provider>`,不要退回 PowerShell/Python/sed 临时写文件。
|
||||
|
||||
### py(远端 Python 脚本)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1179,7 +1179,7 @@ export function formatApplyPatchV2BulkReplacementPayload(paths: Iterable<string>
|
||||
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() {",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+102
-14
@@ -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<SshCaptureResult>): ApplyPatchV2FileSystem {
|
||||
async function checked(operation: WindowsApplyPatchFsOperation, args: string[], input?: string): Promise<SshCaptureResult> {
|
||||
const command = buildWindowsPowerShellInvocation(windowsApplyPatchFsScript(invocation.route.workspace, operation, args));
|
||||
type PosixApplyPatchFsOperation = Exclude<ApplyPatchV2RemoteOperation, "write-b64-argv">;
|
||||
|
||||
export function createPosixApplyPatchFileSystem(invocation: ParsedSshInvocation, runRemoteCommand: (command: string[], input?: string) => Promise<SshCaptureResult>): ApplyPatchV2FileSystem {
|
||||
async function checked(operation: PosixApplyPatchFsOperation, args: string[], input?: string): Promise<SshCaptureResult> {
|
||||
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<string, ApplyPatchV2BulkReplacementWritePlan>) {
|
||||
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<SshCaptureResult>): ApplyPatchV2FileSystem {
|
||||
async function checked(operation: WindowsApplyPatchFsOperation, args: string[], input?: string): Promise<SshCaptureResult> {
|
||||
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<string, unknown> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user