fix: harden apply-patch v2 transport

This commit is contained in:
Codex
2026-05-27 03:42:22 +00:00
parent 9a03a062e0
commit 29ec9254bf
3 changed files with 210 additions and 23 deletions
+87 -12
View File
@@ -80,6 +80,7 @@ function applyPatchFixture(args: string[], patch: string, files: Record<string,
async function applyPatchV2FixtureAttempt(patch: string, files: Record<string, string>): Promise<{ stdout: string; files: Record<string, string>; commands: string[]; error: unknown | null }> {
const state = new Map(Object.entries(files));
const pendingWrites = new Map<string, string>();
const commands: string[] = [];
const stdin = new PassThrough();
stdin.end(patch);
@@ -100,9 +101,21 @@ async function applyPatchV2FixtureAttempt(patch: string, files: Record<string, s
const operation = command[4] ?? "";
const target = command[5] ?? "";
commands.push([operation, ...command.slice(5)].join(" "));
if (operation === "read") {
if (operation === "stat") {
if (!state.has(target)) return { exitCode: 1, stdout: "", stderr: `missing ${target}` };
return { exitCode: 0, stdout: state.get(target) ?? "", stderr: "" };
const content = state.get(target) ?? "";
return { exitCode: 0, stdout: `${Buffer.byteLength(content, "utf8")} ${sha256Hex(content)}\n`, stderr: "" };
}
if (operation === "read-b64-block") {
if (!state.has(target)) return { exitCode: 1, stdout: "", stderr: `missing ${target}` };
const content = Buffer.from(state.get(target) ?? "", "utf8");
const blockIndex = Number(command[6] ?? "-1");
const blockSize = Number(command[7] ?? "-1");
if (!Number.isSafeInteger(blockIndex) || !Number.isSafeInteger(blockSize) || blockIndex < 0 || blockSize <= 0) {
return { exitCode: 2, stdout: "", stderr: "bad read block args" };
}
const start = blockIndex * blockSize;
return { exitCode: 0, stdout: content.subarray(start, start + blockSize).toString("base64"), stderr: "" };
}
if (operation === "write-b64-argv") {
const expectedBytes = Number(command[6] ?? "-1");
@@ -124,6 +137,28 @@ async function applyPatchV2FixtureAttempt(patch: string, files: Record<string, s
state.set(target, content);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-begin") {
pendingWrites.set(`${target}\0${command[6] ?? ""}`, "");
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-append") {
const key = `${target}\0${command[6] ?? ""}`;
if (!pendingWrites.has(key)) return { exitCode: 2, stdout: "", stderr: "missing pending write" };
pendingWrites.set(key, `${pendingWrites.get(key) ?? ""}${command[7] ?? ""}`);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "write-b64-commit") {
const key = `${target}\0${command[6] ?? ""}`;
const expectedBytes = Number(command[7] ?? "-1");
const expectedSha256 = command[8] ?? "";
const content = Buffer.from(pendingWrites.get(key) ?? "", "base64").toString("utf8");
if (Buffer.byteLength(content, "utf8") !== expectedBytes || sha256Hex(content) !== expectedSha256) {
return { exitCode: 23, stdout: "", stderr: "mock integrity mismatch" };
}
state.set(target, content);
pendingWrites.delete(key);
return { exitCode: 0, stdout: "", stderr: "" };
}
if (operation === "delete") {
state.delete(target);
return { exitCode: 0, stdout: "", stderr: "" };
@@ -147,6 +182,7 @@ async function applyPatchV2ActualShellFixtureAttempt(
patch: string,
files: Record<string, string>,
mutateInput?: (operation: string, input: string | undefined) => string | undefined,
mutateResult?: (operation: string, result: { exitCode: number; stdout: string; stderr: string }) => { exitCode: number; stdout: string; stderr: string },
): Promise<{ stdout: string; files: Record<string, string>; commands: string[]; error: unknown | null }> {
const root = mkdtempSync(path.join(os.tmpdir(), "unidesk-apply-patch-v2-shell-"));
const commands: string[] = [];
@@ -179,11 +215,12 @@ async function applyPatchV2ActualShellFixtureAttempt(
input: mutateInput ? mutateInput(operation, input) : input,
encoding: "utf8",
});
return {
const result = {
exitCode: run.status ?? 255,
stdout: run.stdout,
stderr: run.stderr,
};
return mutateResult ? mutateResult(operation, result) : result;
},
},
});
@@ -445,6 +482,7 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
});
assertCondition(longChineseReplace.files["novel.md"]?.includes("等待他重新命名"), "v2 should replace long Chinese lines without remote shell search blocks", longChineseReplace);
const largeOriginal = `${"0123456789abcdef\n".repeat(4096)}`;
const largeV2 = await applyPatchV2Fixture([
"*** Begin Patch",
"*** Update File: large.txt",
@@ -453,9 +491,11 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
"*** End Patch",
"",
].join("\n"), {
"large.txt": `${"0123456789abcdef\n".repeat(4096)}`,
"large.txt": largeOriginal,
});
assertCondition(largeV2.commands.some((command) => command.includes("write-b64-stdin")), "v2 should use stdin write path for large remote files to avoid E2BIG", largeV2.commands);
assertCondition(!largeV2.commands.some((command) => command.includes("write-b64-append")), "v2 should keep the single stdin write as the normal large-file fast path", largeV2.commands);
assertCondition(largeV2.commands.filter((command) => command.startsWith("read-b64-block")).length <= 2, "v2 large-file verified read should use coarse chunks, not many tiny SSH calls", largeV2.commands);
const multiChunkTailV2 = await applyPatchV2ActualShellFixtureAttempt([
"*** Begin Patch",
@@ -474,6 +514,40 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
assertCondition(multiChunkTailV2.error === null, "v2 should apply explicit multi-chunk patches through the real shell writer", multiChunkTailV2);
assertCondition(multiChunkTailV2.files["two_chunks.txt"] === "a\nB\nc\nD\ne\nf\n", "v2 must preserve untouched tail lines when applying multiple chunks", multiChunkTailV2);
const largeTailV2 = await applyPatchV2ActualShellFixtureAttempt([
"*** Begin Patch",
"*** Update File: large-tail.txt",
"@@ LINE-2048 tail-preserve",
"-LINE-2049 keep middle",
"+LINE-2049 patched middle",
"*** End Patch",
"",
].join("\n"), {
"large-tail.txt": Array.from({ length: 5000 }, (_, index) => `LINE-${String(index).padStart(4, "0")} ${index === 2049 ? "keep middle" : "tail-preserve"}`).join("\n") + "\n",
});
assertCondition(largeTailV2.error === null, "v2 should patch a large file through the real shell writer", largeTailV2);
assertCondition(largeTailV2.files["large-tail.txt"]?.includes("LINE-2049 patched middle"), "v2 large-file patch should update the target line", largeTailV2);
assertCondition(largeTailV2.files["large-tail.txt"]?.endsWith("LINE-4999 tail-preserve\n"), "v2 must preserve the untouched tail of large files", largeTailV2);
assertCondition(largeTailV2.commands.some((command) => command.startsWith("write-b64-stdin")), "v2 large-file real shell path should use stdin fast path before any fallback", largeTailV2.commands);
assertCondition(!largeTailV2.commands.some((command) => command.startsWith("write-b64-append")), "v2 large-file real shell path should not use slower chunk fallback unless stdin integrity fails", largeTailV2.commands);
const truncatedLargeReadV2 = await applyPatchV2ActualShellFixtureAttempt([
"*** Begin Patch",
"*** Update File: large-read.txt",
"@@",
"+this write must not happen after a truncated read",
"*** End Patch",
"",
].join("\n"), {
"large-read.txt": largeOriginal,
}, undefined, (operation, result) => {
if (operation !== "read-b64-block" || result.exitCode !== 0) return result;
return { ...result, stdout: result.stdout.slice(0, Math.max(0, result.stdout.length - 32)) };
});
assertCondition(truncatedLargeReadV2.error !== null, "v2 should reject a truncated remote read before planning writes", truncatedLargeReadV2);
assertCondition(truncatedLargeReadV2.files["large-read.txt"] === largeOriginal, "v2 must keep the original file when bridge stdout truncates a read block", truncatedLargeReadV2);
assertCondition(!truncatedLargeReadV2.commands.some((command) => command.startsWith("write-b64")), "v2 must not write after read integrity fails", truncatedLargeReadV2.commands);
const truncatedLargeWriteV2 = await applyPatchV2ActualShellFixtureAttempt([
"*** Begin Patch",
"*** Update File: large.txt",
@@ -482,18 +556,19 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
"*** End Patch",
"",
].join("\n"), {
"large.txt": `${"0123456789abcdef\n".repeat(4096)}`,
"large.txt": largeOriginal,
}, (operation, input) => {
if (operation !== "write-b64-stdin" || input === undefined) return input;
return input.slice(0, Math.max(0, input.length - 32));
});
assertCondition(truncatedLargeWriteV2.error !== null, "v2 should reject truncated stdin write payloads", truncatedLargeWriteV2);
assertCondition(truncatedLargeWriteV2.files["large.txt"] === `${"0123456789abcdef\n".repeat(4096)}`, "v2 must keep the original file when decoded payload integrity fails", truncatedLargeWriteV2);
assertCondition(
String((truncatedLargeWriteV2.error as Error | null)?.message ?? "").includes("remote apply-patch v2 operation failed"),
"v2 truncated payload failure should be visible to the caller",
truncatedLargeWriteV2,
);
assertCondition(truncatedLargeWriteV2.error === null, "v2 should fall back to bounded argv chunks when the stdin write path is truncated", truncatedLargeWriteV2);
assertCondition(truncatedLargeWriteV2.files["large.txt"]?.includes("large insert that forces a rewritten full-file payload"), "v2 fallback write should still apply the patch", truncatedLargeWriteV2);
assertCondition(truncatedLargeWriteV2.files["large.txt"]?.startsWith(largeOriginal), "v2 fallback write must preserve the original large-file content before appending the inserted line", {
commands: truncatedLargeWriteV2.commands.map((command) => command.split(" ").slice(0, 2).join(" ")),
outputBytes: Buffer.byteLength(truncatedLargeWriteV2.files["large.txt"] ?? "", "utf8"),
});
assertCondition(truncatedLargeWriteV2.commands.some((command) => command.startsWith("write-b64-stdin")), "v2 should attempt the stdin fast path first", truncatedLargeWriteV2.commands);
assertCondition(truncatedLargeWriteV2.commands.some((command) => command.startsWith("write-b64-commit")), "v2 should commit the chunked fallback after stdin integrity failure", truncatedLargeWriteV2.commands);
const failedCompoundV2 = await applyPatchV2FixtureAttempt([
"*** Begin Patch",