From 5fe54703c80538e87def0be20f812ea18986434b Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Fri, 16 Oct 2026 01:54:30 +0800 Subject: [PATCH] fix(checkpoint): flush captured members through update handles on Windows On native Windows every runtime.checkpoint_artifacts capture and materialize aborts with EPERM -4048 fsync: copyComponent flushes each copied member through a read-only handle (open(destination, "r") + sync()), and Windows FlushFileBuffers requires update access. #6415 made the flush read-only so an ordinary non-root POSIX controller can capture a read-only participant's 0o444 copy, and the flush itself is deliberate (#6258). Keep the flush on every platform: open the staged copy for update, and when the copy is read-only (POSIX 0o444 mode or the Windows read-only attribute) grant owner access only for the sync, then restore the reviewed mode before the copy is witnessed or published. The checkpoint artifact suite gains a read-only participant case and the established Windows symlink host-privilege guard; 14 of 14 tests pass on native Windows (was 5 of 13). Signed-off-by: JasonBuildAI --- .../runtime/checkpoint_artifacts.ts | 29 +++++++++++++++---- .../checkpoint_artifacts.test.ts | 26 +++++++++++++++-- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/loopx/control_plane/runtime/checkpoint_artifacts.ts b/loopx/control_plane/runtime/checkpoint_artifacts.ts index e721c330f6..234f18f808 100644 --- a/loopx/control_plane/runtime/checkpoint_artifacts.ts +++ b/loopx/control_plane/runtime/checkpoint_artifacts.ts @@ -4,7 +4,7 @@ */ import {createHash} from "node:crypto"; import {createReadStream} from "node:fs"; -import {copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rm} from "node:fs/promises"; +import {chmod, copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rm} from "node:fs/promises"; import {dirname, isAbsolute, join, resolve, sep} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; @@ -114,6 +114,28 @@ async function sqliteSidecar(path: string, suffixes: string[]): Promise try {return (await lstat(database)).isFile() && await sqliteFile(database);} catch (error) {if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error;} } +async function syncCopiedFile(path: string): Promise { + const handle = await open(path, "r+"); + try {await handle.sync();} finally {await handle.close();} +} +/** Flush a freshly copied checkpoint member through an updatable handle. + * copyFile preserves a read-only participant's mode, and Windows + * FlushFileBuffers rejects read-only handles, so a read-only copy grants owner + * access only to this staged copy for the durability sync and restores its + * mode before the caller witnesses or publishes it. POSIX syncs a read-only + * handle directly, but the update path covers both read-only cases. + */ +async function flushCopiedFile(path: string): Promise { + try {await syncCopiedFile(path);} + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EACCES" && code !== "EPERM") throw error; + const mode = (await lstat(path)).mode & 0o777; + await chmod(path, mode | 0o600); + try {await syncCopiedFile(path);} + finally {await chmod(path, mode);} + } +} async function copyComponent(source: string, destination: string, snapshotDatabases = true): Promise { const stat = await lstat(source); if (stat.isSymbolicLink()) throw new Error("checkpoint source became a symlink"); @@ -135,10 +157,7 @@ async function copyComponent(source: string, destination: string, snapshotDataba // SQLite's existing physical snapshot owner seals committed WAL contents. if (snapshotDatabases && await sqliteFile(source)) await snapshotSqliteDatabase(source, destination); else await copyFile(source, destination); - // copyFile preserves a read-only participant's mode. Syncing its bytes - // needs no write access; r+ fails for an ordinary non-root controller. - const handle = await open(destination, "r"); - try {await handle.sync();} finally {await handle.close();} + await flushCopiedFile(destination); } else throw new Error("checkpoint source contains an unsupported file type"); } function manifestBody(raw: unknown): JsonObject { diff --git a/tests/control_plane_ts/checkpoint_artifacts.test.ts b/tests/control_plane_ts/checkpoint_artifacts.test.ts index 08a76647a0..9f6ef69e3f 100644 --- a/tests/control_plane_ts/checkpoint_artifacts.test.ts +++ b/tests/control_plane_ts/checkpoint_artifacts.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import {chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile} from "node:fs/promises"; +import {chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; import {checkpointArtifacts} from "../../loopx/control_plane/runtime/checkpoint_artifacts.ts"; @@ -39,6 +39,22 @@ test("capture, verify and inert materialization preserve executable code and emp expected_sha256: captured.sha256, destination: join(root, "restored")}), /already exists/); }); +test("a read-only participant's copied mode survives the durability flush and materialization", async t => { + const {root, request} = await fixture(t); + await chmod(join(root, "code/solution.sh"), 0o444); + const components = request.components; + const probe = await checkpointArtifacts({action: "probe", components}); + const captured = await checkpointArtifacts({...request, components, expected_source_sha256: probe.source_sha256}); + assert.equal(captured.integrity_verified, true); + const copied = join(root, "captured/components/code/solution.sh"); + assert.equal((await stat(copied)).mode & 0o777, 0o444); + await checkpointArtifacts({action: "materialize", root: request.destination, expected_sha256: captured.sha256, + destination: join(root, "restored")}); + const restored = join(root, "restored/components/code/solution.sh"); + assert.equal(await readFile(restored, "utf8"), "#!/bin/sh\nexit 0\n"); + assert.equal((await stat(restored)).mode & 0o777, 0o444); +}); + test("changed source, duplicate identities and overlapping sources publish no unit", async t => { const {root, request} = await fixture(t); await writeFile(join(root, "conversation"), "new native cursor"); @@ -90,8 +106,12 @@ test("symlink sources, nested links and redirected destinations reject without p const {root, request} = await fixture(t); await symlink(join(root, "code"), join(root, "alias"), process.platform === "win32" ? "junction" : "dir"); await assert.rejects(checkpointArtifacts({action: "probe", components: [{id: "code", source_path: join(root, "alias")}]}), /symlink/); - await symlink(join(root, "conversation"), join(root, "code/link")); - await assert.rejects(checkpointArtifacts(request), /symlink/); + // File symlink creation needs Developer Mode or admin on Windows; the + // junction cases still cover redirected sources and destinations. + if (process.platform !== "win32") { + await symlink(join(root, "conversation"), join(root, "code/link")); + await assert.rejects(checkpointArtifacts(request), /symlink/); + } await assert.rejects(checkpointArtifacts({...request, destination: join(root, "alias/capture")}), /symlink/); });