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
+122 -11
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import { createHash } from "node:crypto";
import { createHash, randomBytes } from "node:crypto";
import type { Readable, Writable } from "node:stream";
export type PatchHunk =
@@ -261,9 +261,53 @@ async function executePlannedOperation(executor: ApplyPatchV2Executor, operation
await checkedRemoteV2(executor, "delete", [operation.path]);
}
const readBlockBytes = 45_000;
const writeB64ArgvLimit = 48_000;
const writeB64ChunkChars = 12_000;
async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise<string> {
const read = await checkedRemoteV2(executor, "read", [target]);
return read.stdout;
const stat = await checkedRemoteV2(executor, "stat", [target]);
const [bytesText, expectedSha256] = stat.stdout.trim().split(/\s+/u);
const expectedBytes = Number(bytesText);
if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || !/^[0-9a-f]{64}$/u.test(expectedSha256 ?? "")) {
throw new ApplyPatchV2Error("remote apply-patch v2 stat returned invalid metadata", { path: target, stdout: stat.stdout.slice(0, 500) });
}
const chunks: Buffer[] = [];
let actualBytes = 0;
for (let blockIndex = 0; actualBytes < expectedBytes; blockIndex += 1) {
const read = await checkedRemoteV2(executor, "read-b64-block", [target, String(blockIndex), String(readBlockBytes)]);
const encoded = read.stdout.replace(/\s+/gu, "");
const chunk = encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64");
if (chunk.length === 0) {
throw new ApplyPatchV2Error("remote apply-patch v2 read returned an empty block before EOF", {
path: target,
blockIndex,
expectedBytes,
actualBytes,
});
}
chunks.push(chunk);
actualBytes += chunk.length;
}
const contentBuffer = Buffer.concat(chunks);
if (contentBuffer.length !== expectedBytes) {
throw new ApplyPatchV2Error("remote apply-patch v2 read byte count mismatch", {
path: target,
expectedBytes,
actualBytes: contentBuffer.length,
});
}
const actualSha256 = sha256Hex(contentBuffer);
if (actualSha256 !== expectedSha256) {
throw new ApplyPatchV2Error("remote apply-patch v2 read sha256 mismatch", {
path: target,
expectedSha256,
actualSha256,
});
}
return contentBuffer.toString("utf8");
}
async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise<void> {
@@ -271,14 +315,38 @@ async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, c
const encoded = contentBuffer.toString("base64");
const expectedBytes = String(contentBuffer.length);
const expectedSha256 = sha256Hex(contentBuffer);
if (encoded.length <= 48_000) {
await checkedRemoteV2(executor, "write-b64-argv", [target, expectedBytes, expectedSha256, ...chunkString(encoded, 12_000)]);
if (encoded.length <= writeB64ArgvLimit) {
await checkedRemoteV2(executor, "write-b64-argv", [target, expectedBytes, expectedSha256, ...chunkString(encoded, writeB64ChunkChars)]);
return;
}
await checkedRemoteV2(executor, "write-b64-stdin", [target, expectedBytes, expectedSha256], encoded);
try {
await checkedRemoteV2(executor, "write-b64-stdin", [target, expectedBytes, expectedSha256], encoded);
return;
} catch {
// Some SSH/websocket bridges cap stdin payloads without a stable public
// contract. The stdin path is still the fast path; fall back to small argv
// chunks only after the remote sha/byte guard proves no partial write moved.
}
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
await checkedRemoteV2(executor, "write-b64-begin", [target, token]);
for (const chunk of chunkString(encoded, writeB64ChunkChars)) {
await checkedRemoteV2(executor, "write-b64-append", [target, token, chunk]);
}
await checkedRemoteV2(executor, "write-b64-commit", [target, token, expectedBytes, expectedSha256]);
}
async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[], input?: string): Promise<{ stdout: string }> {
type RemoteV2Operation =
| "stat"
| "read-b64-block"
| "write-b64-argv"
| "write-b64-stdin"
| "write-b64-begin"
| "write-b64-append"
| "write-b64-commit"
| "delete"
| "move";
async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: RemoteV2Operation, args: string[], input?: string): Promise<{ stdout: string }> {
const result = await executor.run(remoteV2Script(operation, args), input);
if (result.exitCode === 0) return result;
throw new ApplyPatchV2Error("remote apply-patch v2 operation failed", {
@@ -290,7 +358,7 @@ async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: "read"
});
}
function remoteV2Script(operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[]): string[] {
function remoteV2Script(operation: RemoteV2Operation, args: string[]): string[] {
const script = [
"set -eu",
"sha256_file() {",
@@ -318,11 +386,31 @@ function remoteV2Script(operation: "read" | "write-b64-argv" | "write-b64-stdin"
" exit 24",
" fi",
"}",
"set_tmp_paths() {",
" target=$1",
" token=$2",
" case \"$token\" in ''|*[!a-zA-Z0-9_.-]*) printf 'invalid v2 temp token\\n' >&2; exit 2;; esac",
" base=${target##*/}",
" dir=.",
" case \"$target\" in */*) dir=${target%/*};; esac",
" tmp=\"$dir/.${base}.unidesk-v2-${token}.tmp\"",
" tmp_b64=\"$tmp.b64\"",
"}",
"op=$1",
"shift",
"case \"$op\" in",
" read)",
" cat -- \"$1\"",
" stat)",
" target=$1",
" bytes=$(wc -c < \"$target\" | tr -d '[:space:]')",
" digest=$(sha256_file \"$target\")",
" printf '%s %s\\n' \"$bytes\" \"$digest\"",
" ;;",
" read-b64-block)",
" target=$1",
" block_index=$2",
" block_size=$3",
" case \"$block_index:$block_size\" in *[!0-9:]*|:*) printf 'invalid read block args\\n' >&2; exit 2;; esac",
" dd if=\"$target\" bs=\"$block_size\" skip=\"$block_index\" count=1 2>/dev/null | base64 | tr -d '\\n'",
" ;;",
" write-b64-argv)",
" target=$1",
@@ -352,7 +440,30 @@ function remoteV2Script(operation: "read" | "write-b64-argv" | "write-b64-stdin"
" dir=.",
" case \"$target\" in */*) dir=${target%/*};; esac",
" tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"",
" base64 -d > \"$tmp\"",
" if ! base64 -d > \"$tmp\"; then rm -f -- \"$tmp\"; printf 'v2 base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
" mv -f -- \"$tmp\" \"$target\"",
" actual_sha256=$(sha256_file \"$target\")",
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
" ;;",
" write-b64-begin)",
" target=$1",
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
" set_tmp_paths \"$target\" \"$2\"",
" : > \"$tmp_b64\"",
" ;;",
" write-b64-append)",
" target=$1",
" set_tmp_paths \"$target\" \"$2\"",
" printf '%s' \"$3\" >> \"$tmp_b64\"",
" ;;",
" write-b64-commit)",
" target=$1",
" set_tmp_paths \"$target\" \"$2\"",
" expected_bytes=$3",
" expected_sha256=$4",
" if ! base64 -d < \"$tmp_b64\" > \"$tmp\"; then rm -f -- \"$tmp\" \"$tmp_b64\"; printf 'v2 base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
" rm -f -- \"$tmp_b64\"",
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
" mv -f -- \"$tmp\" \"$target\"",
" actual_sha256=$(sha256_file \"$target\")",