From a37dd24f128e38aa4c4261d72ed580de73429fe2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 15:11:34 +0800 Subject: [PATCH 01/39] feat(file-safety): atomic text publish primitive + safeWriteJson refactor (A4, #1375) --- src/eslint-suppressions.json | 2 +- src/integrations/editor/DiffViewProvider.ts | 3 +- .../editor/__tests__/DiffViewProvider.spec.ts | 28 +- .../__tests__/safeWriteText.spec.ts | 614 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 307 +++++++++ src/utils/__tests__/safeWriteJson.test.ts | 87 ++- src/utils/safeWriteJson.ts | 108 +-- 7 files changed, 1040 insertions(+), 109 deletions(-) create mode 100644 src/services/file-safety/__tests__/safeWriteText.spec.ts create mode 100644 src/services/file-safety/safeWriteText.ts diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 36cbfeac5b..77680449be 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1721,7 +1721,7 @@ }, "utils/safeWriteJson.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 3 } }, "utils/tts.ts": { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..36f5323f19 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -18,6 +18,7 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" +import { safeWriteText } from "../../services/file-safety/safeWriteText" import { DecorationController } from "./DecorationController" @@ -1156,7 +1157,7 @@ export class DiffViewProvider { // Write the content directly to the file await createDirectoriesForFile(absolutePath) - await fs.writeFile(absolutePath, content, "utf-8") + await safeWriteText(absolutePath, content) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index aee88f4061..511f0e7f3c 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -15,6 +15,14 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), +})) + +// Mock safeWriteText (used by saveDirectly) +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn().mockResolvedValue(undefined), })) // Mock utils @@ -26,6 +34,8 @@ vi.mock("../../../utils/fs", () => ({ vi.mock("path", () => ({ resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), basename: vi.fn((path) => path.split("/").pop()), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), + join: (...args: string[]) => args.join("/"), })) // Mock vscode @@ -791,9 +801,9 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 2000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -814,9 +824,9 @@ describe("DiffViewProvider", () => { it("should not open file when openWithoutFocus is false", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was NOT opened expect(vscode.window.showTextDocument).not.toHaveBeenCalled() @@ -829,9 +839,9 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify delay was NOT called expect(mockDelay).not.toHaveBeenCalled() diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts new file mode 100644 index 0000000000..4accc2b71e --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -0,0 +1,614 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import { execFile } from "child_process" +import type { ChildProcess } from "child_process" +import * as path from "path" + +import { safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" + +// Full mock for fs/promises — all methods are vi.fn() stubs +vi.mock("fs/promises", () => ({ + mkdir: vi.fn(), + access: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), + realpath: vi.fn(), +})) + +// Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare +// class stub so tests can build minimal Stats stand-ins via its prototype. +vi.mock("fs", () => ({ + openSync: vi.fn(), + writeSync: vi.fn(), + closeSync: vi.fn(), + mkdirSync: vi.fn(), + fsyncSync: vi.fn(), + chmodSync: vi.fn(), + fchmodSync: vi.fn(), + statSync: vi.fn(), + Stats: class Stats {}, +})) + +// Mock child_process.execFile (callback-based — must invoke callback to resolve) +vi.mock("child_process", () => ({ + execFile: vi.fn((cmd, args, opts, cb) => { + if (typeof cb === "function") cb(null) + }), +})) + +// Minimal stand-in for the ChildProcess that callback-form execFile returns. +const fakeChild = { kill: () => true } as unknown as ChildProcess + +// Helper that mirrors safeWriteText's path resolution exactly +function _resolvedTarget(filePath: string): string { + return path.resolve(filePath) +} +function _dirPath(filePath: string): string { + return path.dirname(_resolvedTarget(filePath)) +} +function _stagingDir(dir: string): string { + return path.join(dir, ".file-safety-staging") +} + +// Minimal Stats stand-in: the SUT only reads `.mode` from it. +function _stats(mode: number): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats + Object.assign(s, { mode }) + return s +} + +// ── Test 1: staging file created then cleaned after success ──────────────── + +describe("safeWriteText", () => { + beforeEach(() => { + vi.resetAllMocks() + // After resetAllMocks, vi.fn() returns undefined — restore promise defaults. + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.rename).mockResolvedValue(undefined) + vi.mocked(fs.unlink).mockResolvedValue(undefined) + // Existing-target default: a regular 0o644 file. + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + // Default sync-write behaviour: report that all requested bytes were + // written. The Buffer overload passes (fd, buffer, offset, length), + // so the fourth argument is the requested length. + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + typeof args[3] === "number" ? args[3] : 0, + ) + }) + + describe("staging and cleanup", () => { + it("creates a temp file in the staging dir, fsyncs it, renames to target, and cleans up on success", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) // fd=1 + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + + await safeWriteText(targetPath, "hello world", { platform: "linux" }) + + // staging dir was created with private permissions — use + // stringContaining to handle Windows path resolution + expect(fsSync.mkdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), { + recursive: true, + mode: 0o700, + }) + // a pre-existing staging dir is repaired to private permissions too + expect(fsSync.chmodSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), 0o700) + + // temp file was opened for writing with the existing target's mode + // (default 0o644 from the statSync default mock) + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) + + // content was written as a buffer (partial-write loop, full write) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("hello world", "utf8"), 0, 11) + + // fsync (sync form) was called on the fd + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + + // file was closed + expect(fsSync.closeSync).toHaveBeenCalledWith(1) + + // atomic rename happened — realpath mock returns targetPath, so that's the dest + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // no unlink of temp (it's now the committed file; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 2: fsync ordering ─────────────────────────────────────────────── + + describe("fsync ordering", () => { + it("calls fsync on the fd before close, and rename after close", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // Verify call order: openSync(temp) → writeSync → fsyncSync(temp) + // → closeSync(temp) → rename. On POSIX the parent directory is then + // opened and fsynced after the commit rename, so openSync/fsyncSync/ + // closeSync each have a second (directory) call. + expect(vi.mocked(fsSync.openSync).mock.calls.length).toBe(2) + expect(vi.mocked(fsSync.writeSync).mock.calls.length).toBe(1) + expect(vi.mocked(fsSync.fsyncSync).mock.calls.length).toBe(2) + expect(vi.mocked(fsSync.closeSync).mock.calls.length).toBe(2) + + // the temp file was fully closed before the commit rename + expect(vi.mocked(fsSync.closeSync).mock.calls[0][0]).toBe(1) + expect(fs.rename).toHaveBeenCalled() + }) + }) + + // ── Test 3: simulated failure between write and rename leaves target intact ── + + describe("crash/torn-write safety", () => { + it("simulated failure between fsync and rename leaves the target byte-identical and no temp left behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + + await expect(safeWriteText(targetPath, "new data", { platform: "linux" })).rejects.toThrow("ENOSPC") + + // rename was attempted (the failure point) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // temp file was cleaned up on failure + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + + // backup was NOT created (backup:false by default), so target is untouched + // The only rename call was temp→target, not a rollback rename + expect(fs.rename).toHaveBeenCalledTimes(1) + }) + + it("a post-commit backup cleanup failure is non-fatal: the target stays committed and no temp is left behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The post-commit backup unlink (SUT step 6) fails — the write must + // still succeed; an orphaned backup is the documented acceptable + // outcome, so the failure is swallowed instead of rolling back. + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error("EPERM")) + + await safeWriteText(targetPath, "data", { backup: true, platform: "linux" }) + + // the commit rename (temp -> target) still happened + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + + // the failing cleanup was the post-commit backup unlink + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + + // no rollback rename: the committed target is not restored from the backup + expect(fs.rename).toHaveBeenCalledTimes(2) + + // the staging temp was already committed by the rename; nothing + // temp-shaped is unlinked afterwards + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + }) + + // ── Test 4: backup:true keeps old safeWriteJson semantics incl. rollback ── + + describe("backup:true", () => { + it("renames target -> backup before commit, deletes backup on success", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "new data", { backup: true }) + + // target was accessed (exists check) + expect(fs.access).toHaveBeenCalledWith(targetPath) + + // first rename: target -> backup + expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) + + // second rename: temp -> target (realpath mock returns targetPath) + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + + // backup was deleted on success + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + }) + + it("rollback: on failure after rename target->backup, restores backup to target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // first rename (target->backup) succeeds, second fails + let callCount = 0 + vi.mocked(fs.rename).mockImplementation(async () => { + callCount++ + if (callCount === 1) return // target -> backup + throw new Error("ENOSPC") // temp -> target fails + }) + + await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("ENOSPC") + + // rollback rename is the 3rd call (after target->backup and temp->target failure) + expect(fs.rename).toHaveBeenNthCalledWith(3, expect.stringContaining("safeWriteText.bak_"), targetPath) + + // temp was cleaned up on failure + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("backup:true when target does not exist: no backup created, just commit", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // fs.access resolves for dirPath check, but rejects for target check (backup path) + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw { code: "ENOENT" } + }) + + await safeWriteText(targetPath, "new data", { backup: true, platform: "linux" }) + + // no backup rename (target didn't exist) + expect(fs.access).toHaveBeenCalledWith(targetPath) + + // only one rename: temp -> target + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // no unlink (no backup to delete; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 5: win32 DACL path ────────────────────────────────────────────── + + describe("win32 DACL", () => { + it.skipIf(process.platform !== "win32")( + "copies target DACL onto staging file via icacls before rename on Windows", + async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // icacls dump + restore were called (execFile is callback-based mock) + expect(execFile).toHaveBeenCalledTimes(2) + }, + ) + + it("non-win32: DACL path is unreachable when platform is not win32", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // icacls was NOT called on non-win32 + expect(execFile).not.toHaveBeenCalled() + }) + + it("win32 DACL failure falls back to plain rename (never fails the write)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls dump fails — the callback-based mock must invoke cb with an error. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write succeeded despite icacls failure (fallback to plain rename) + expect(fs.rename).toHaveBeenCalled() + }) + + it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { backup: true, platform: "win32" }) + + // icacls was called twice (save + restore) + expect(execFile).toHaveBeenCalledTimes(2) + + // First call: save DACL from target before backup rename + const firstCall = vi.mocked(execFile).mock.calls[0] + expect(firstCall[0]).toBe("icacls") + expect(firstCall[1]).toEqual([targetPath, "/save", expect.stringContaining(".acl.tmp"), "/T"]) + + // Second call: restore DACL onto directory after commit rename + const secondCall = vi.mocked(execFile).mock.calls[1] + expect(secondCall[0]).toBe("icacls") + expect(secondCall[1]).toEqual([ + expect.stringContaining("/tmp/test-dir"), + "/restore", + expect.stringContaining(".acl.tmp"), + ]) + + // dump file was unlinked after restore + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + + it("win32 DACL: dump is unlinked even when restore fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // icacls save succeeds, restore fails + let callCount = 0 + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + callCount++ + if (typeof cb === "function") { + cb(callCount === 1 ? null : new Error("icacls restore error"), "", "") + } + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write succeeded despite restore failure (best-effort) + expect(fs.rename).toHaveBeenCalled() + + // dump file was still unlinked in finally + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + + it("win32 DACL: when target does not exist, no save/restore/dump", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // fs.access rejects for targetPath (ENOENT), but resolves for dirPath + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw { code: "ENOENT" } + return undefined + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // icacls was NOT called (target absent → skip DACL entirely) + expect(execFile).not.toHaveBeenCalled() + + // no dump file created or unlinked + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 6: pre-written temp path (tempPath option) ────────────────────── + + describe("pre-written temp path", () => { + it("uses the provided tempPath, fsyncs it, and renames to target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + const customTempPath = "/tmp/custom-temp.tmp" + + // platform:linux skips DACL entirely so this test focuses on tempPath only + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // openSync was called on the custom temp path (r+ mode for fsync) + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + + // fsync was called + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + + // rename happened — realpath mock returns targetPath + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + + // no unlink of custom temp (caller's concern; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + + // a caller-supplied tempPath must not create the staging directory + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + }) + + it("applies the existing target's mode to a caller-supplied tempPath before publishing", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // the caller-staged temp is fchmod'd to the restrictive target mode so + // the atomic rename cannot widen a 0o600 target (CWE-732 regression) + expect(fsSync.fchmodSync).toHaveBeenCalledWith(2, 0o600) + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("keeps the temp's default mode when the target does not exist yet (ENOENT)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw enoent + }) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // no existing target, so nothing to preserve and no fchmod on the temp + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("opens the temp before applying a read-only target's mode (0o444 does not block the open)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o444)) + vi.mocked(fsSync.openSync).mockReturnValue(3) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // a 0o444 target must not make openSync(tempPath, "r+") fail: the mode + // is applied with fchmodSync on the already-open fd, after the open + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fsSync.fchmodSync).toHaveBeenCalledWith(3, 0o444) + const openIdx = vi.mocked(fsSync.openSync).mock.invocationCallOrder[0] + const fchmodIdx = vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0] + expect(openIdx).toBeLessThan(fchmodIdx) + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + }) + + // ── Test 7: symlink handling (Finding 4 regression test) ───────────────── + + describe("symlink handling", () => { + it("a write through a symlink commits onto the resolved referent, never the link path", async () => { + const linkPath = "/tmp/links/link.txt" + const referentPath = "/tmp/targets/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(referentPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(linkPath, "new-content", { platform: "linux" }) + + // The commit rename must target the realpath result (the referent), never the link itself — + // that is what guarantees a write through a symlink replaces the referent's content + // and preserves the link. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), referentPath) + expect(fs.rename).not.toHaveBeenCalledWith(expect.anything(), linkPath) + }) + + it("when realpath reports ENOENT (target absent), uses the given path as-is", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // rename still happened with the fallback path (path.resolve on /tmp → C:\tmp) + const resolvedFallback = _resolvedTarget(targetPath) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), resolvedFallback) + }) + }) + + // ── Test 8: review fixes (permissions, partial writes, resolution, durability) ── + + describe("review fixes", () => { + it("preserves the target's restrictive mode and tolerates a failed staging-dir permission repair", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + // a pre-existing staging dir may fail its best-effort permission repair + vi.mocked(fsSync.chmodSync).mockImplementationOnce(() => { + throw new Error("EACCES") + }) + + await safeWriteText(targetPath, "secret", { platform: "linux" }) + + // the staging file inherits the target's 0o600 mode and the write commits + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o600) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("falls back to the 0o644 default when the target does not exist yet", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + + await safeWriteText(targetPath, "fresh", { platform: "linux" }) + + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) + }) + + it("loops on short writes until the full content is durable before fsync", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const content = "0123456789" // 10 bytes + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const buffer = Buffer.from(content, "utf8") + // first write (offset 0) reports 4 bytes (short write); the loop continues + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + args[2] === 0 ? 4 : typeof args[3] === "number" ? args[3] : 0, + ) + + await safeWriteText(targetPath, content, { platform: "linux" }) + + // [0,10) reports 4 bytes, then [4,10) writes the remaining 6 + expect(fsSync.writeSync).toHaveBeenCalledTimes(2) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(1, 1, buffer, 0, 10) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(2, 1, buffer, 4, 6) + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("fsyncs the parent directory after the commit rename on POSIX", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // temp fd=1 then parent-dir fd=2 - distinct fds prove the ordering + vi.mocked(fsSync.openSync).mockReturnValueOnce(1).mockReturnValue(2) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // the directory fsync (fd 2) happens only after the file fsync (fd 1); + // the dir path assertion is path-agnostic (stringContaining) because + // path.dirname renders the same input differently on Windows + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("test-dir"), "r") + expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(1, 1) + expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(2, 2) + expect(fsSync.closeSync).toHaveBeenCalledWith(2) + }) + + it("treats a failed parent-directory fsync as best-effort", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync) + .mockReturnValueOnce(1) + .mockImplementationOnce(() => { + throw new Error("EBADF") + }) + + // the content rename already committed; a missing directory fsync is not fatal + await safeWriteText(targetPath, "data", { platform: "linux" }) + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("propagates realpath errors (EACCES and code-less) instead of the fallback path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fs.realpath).mockRejectedValueOnce(eacces) + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) + expect(fs.rename).not.toHaveBeenCalled() + + const plain = new Error("resolution failed") + vi.mocked(fs.realpath).mockRejectedValueOnce(plain) + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(plain) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("backup:true propagates access errors (EACCES and code-less) instead of skipping the backup", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" }) + const plain = new Error("access failed") + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // each write accesses dirPath then target; only the target access rejects + const rejectTarget = (error: Error) => async (p: unknown) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw error + } + vi.mocked(fs.access) + .mockImplementationOnce(rejectTarget(eacces)) + .mockImplementationOnce(rejectTarget(eacces)) + .mockImplementationOnce(rejectTarget(plain)) + .mockImplementationOnce(rejectTarget(plain)) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toEqual( + expect.objectContaining({ code: "EACCES" }), + ) + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow( + "access failed", + ) + expect(fs.rename).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts new file mode 100644 index 0000000000..71871032e5 --- /dev/null +++ b/src/services/file-safety/safeWriteText.ts @@ -0,0 +1,307 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" +import { execFile } from "child_process" + +/** + * Options for safeWriteText atomic text publish primitive. + */ +export interface SafeWriteTextOptions { + /** + * When true, preserve the old-file semantics: rename target -> backup first, + * after commit rename delete the backup; on failure roll the backup back to + * the target path. When false (default) the atomic rename simply replaces + * the target -- crash-safe window is zero. + */ + backup?: boolean + + /** + * Platform override for testing. When omitted the real process.platform + * value is used. Set to "win32" or "linux" / "darwin" from tests so that + * both branches are reachable without needing a real Windows runner. + */ + platform?: string + + /** + * Custom execFile runner for testing (e.g. vi.fn). When omitted the real + * child_process.execFile is used. + */ + execFileRunner?: typeof execFile + + /** + * Pre-written temp path to use for the commit phase. When provided, + * safeWriteText skips creating its own staging file and uses this path + * instead (it still fsyncs before rename). Useful when a caller has + * already written data to a temp file via a custom stream. + */ + tempPath?: string +} + +// -- helpers --------------------------------------------------------------- + +/** Generate a unique temp file name in the given directory. */ +function _tempName(dir: string, prefix: string): string { + return path.join(dir, "." + prefix + "_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp") +} + +/** Create a private staging sub-directory inside *dir* so that multiple + * concurrent writes never collide on their temp names. */ +function _stagingDir(dir: string): string { + const sd = path.join(dir, ".file-safety-staging") + // mode:0o700 protects a freshly created staging dir; the best-effort chmod + // repairs a pre-existing one (mkdirSync with recursive:true never chmods an + // existing directory), so staged temp files are never group/world readable. + fsSync.mkdirSync(sd, { recursive: true, mode: 0o700 }) + try { + fsSync.chmodSync(sd, 0o700) + } catch { + // best-effort: chmod denied or unavailable; a fresh dir was still + // created with the requested mode + } + return sd +} + +/** + * fsync a file descriptor so its data is durable before the atomic rename. + * Uses the sync form because this repo's @types/node does not declare + * fs.promises.fsync; the staging file is small, so the blocking window is bounded. + */ +function _fsyncFile(fd: number): void { + fsSync.fsyncSync(fd) +} + +/** Save the DACL of *srcPath* to a dump file on Windows. + * Returns true when the dump was written successfully; false otherwise. + * Never throws — callers treat failure as "skip DACL handling". */ +async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { + const runner = execFileRunner ?? execFile + try { + await new Promise((resolve, reject) => { + runner("icacls", [srcPath, "/save", dumpPath, "/T"], { windowsHide: true }, (err) => + err ? reject(err) : resolve(), + ) + }) + return true + } catch { + return false + } +} + +/** Restore a DACL dump onto *dirPath* on Windows. + * Best-effort: content is already committed, so failure is non-fatal. */ +async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { + const runner = execFileRunner ?? execFile + try { + await new Promise((resolve, reject) => { + runner("icacls", [dirPath, "/restore", dumpPath], { windowsHide: true }, (err) => + err ? reject(err) : resolve(), + ) + }) + } catch { + // best-effort; content already committed + } +} + +// -- public API ------------------------------------------------------------ + +/** + * Atomic text publish primitive. + * + * 1. Write content to a temp file in a private per-write staging subdir + * (same volume -> atomic rename guaranteed). + * 2. fsync the temp file, then close it. + * 3. win32 only: if target exists save its DACL dump BEFORE backup rename. + * 4. Optionally rename target -> backup (when backup:true). + * 5. Atomic rename temp -> target. + * 6. win32 only: restore DACL onto the directory AFTER commit rename. + * 7. On success: delete backup (if any) and unlink DACL dump. + * 8. On failure: rollback backup to target path; clean up temp + dump. + */ + +/** + * Resolve the publish target: the symlink referent when the given path is an + * existing symlink, the path itself otherwise. Only ENOENT (target absent yet) + * may fall back to the given path; any other resolution error (EACCES, EIO, ...) + * propagates so a broken or unreadable symlink is never written through its + * link path. Callers that stage a temp file themselves must stage it beside + * the resolved path: the commit is a rename onto the referent, and a rename + * across filesystems fails with EXDEV. + */ +export async function resolvePublishTarget(absoluteFilePath: string): Promise { + return fs.realpath(absoluteFilePath).catch((error: unknown) => { + const code = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined + if (code !== "ENOENT") throw error + return absoluteFilePath + }) +} + +export async function safeWriteText(filePath: string, content: string, options?: SafeWriteTextOptions): Promise { + const absoluteFilePath = path.resolve(filePath) + + // Resolve the symlink referent (see resolvePublishTarget). + const targetPath = await resolvePublishTarget(absoluteFilePath) + const dirPath = path.dirname(targetPath) + + // Ensure parent directory exists (mirrors safeWriteJson behaviour). + await fs.mkdir(dirPath, { recursive: true }) + await fs.access(dirPath) + + // Create the staging directory only when we generate the temp file there; + // callers supplying their own tempPath (e.g. safeWriteJson) must not be left + // with an empty .file-safety-staging directory behind. + const tempPath = options?.tempPath ?? _tempName(_stagingDir(dirPath), "safeWriteText") + + let backupPath: string | null = null + let releaseBackupOnSuccess = false + let daclDumpPath: string | null = null // tracked for cleanup in finally + + try { + // -- Step 1: write content to staging temp file ------------------- + if (!options?.tempPath) { + // Preserve the existing target's permissions: the staging file must + // not be published wider than the file it replaces (a 0o600 target + // must not become 0o644 through the atomic rename). + let targetMode = 0o644 // default for a fresh target + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + } catch { + // target does not exist yet - keep the default + } + const fd = fsSync.openSync(tempPath, "w", targetMode) + try { + // Loop until every byte is written: writeSync can report a short + // (partial) write, and publishing a truncated staging file would + // commit corrupt content. + const buffer = Buffer.from(content, "utf8") + let offset = 0 + while (offset < buffer.length) { + offset += fsSync.writeSync(fd, buffer, offset, buffer.length - offset) + } + _fsyncFile(fd) + } finally { + fsSync.closeSync(fd) + } + } else { + // Preserve the existing target's mode (CWE-732): the caller-staged + // temp carries its own creation mode, and publishing it as-is would + // widen a restrictive target (e.g. 0o600 -> 0o644) through rename. + // The mode is applied with fchmodSync on the open fd (AFTER openSync): + // chmodSync on the path before the open would make a read-only target + // (0o400/0o444) fail openSync(tempPath, "r+") with EACCES. + let targetMode: number | null = null + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + } catch { + // target does not exist yet - keep the temp's default mode + } + const fd = fsSync.openSync(tempPath, "r+") + try { + if (targetMode !== null) { + fsSync.fchmodSync(fd, targetMode) + } + _fsyncFile(fd) + } finally { + fsSync.closeSync(fd) + } + } + + // -- Step 2 (win32): save DACL BEFORE backup rename --------------- + const platform = options?.platform ?? process.platform + if (platform === "win32") { + try { + await fs.access(targetPath) // target exists? + daclDumpPath = targetPath + ".acl.tmp" + const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) + if (!saved) { + daclDumpPath = null // skip DACL handling entirely + } + } catch { + // target does not exist or access failed — no DACL handling + daclDumpPath = null + } + } + + try { + // -- Step 3 (backup:true): rename target -> backup -------------- + if (options?.backup) { + try { + await fs.access(targetPath) + backupPath = _tempName(dirPath, "safeWriteText.bak") + await fs.rename(targetPath, backupPath) + releaseBackupOnSuccess = true + } catch (err: unknown) { + const code = + typeof err === "object" && err !== null && "code" in err + ? (err as { code?: string }).code + : undefined + if (code !== "ENOENT") throw err + } + } + + // -- Step 4: atomic rename temp -> target --------------------- + await fs.rename(tempPath, targetPath) + + // -- Step 4b (POSIX): fsync the parent directory so the directory entry + // changed by the commit rename is durable, not just the file content. + if (platform !== "win32") { + try { + const dirFd = fsSync.openSync(dirPath, "r") + try { + _fsyncFile(dirFd) + } finally { + fsSync.closeSync(dirFd) + } + } catch { + // best-effort: the content rename already committed + } + } + + // -- Step 5 (win32): restore DACL AFTER commit rename --------- + if (platform === "win32" && daclDumpPath !== null) { + const restoredDir = path.dirname(targetPath) + await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + } + + // -- Step 6 (backup:true): delete backup on success ----------- + if (releaseBackupOnSuccess && backupPath) { + try { + await fs.unlink(backupPath) + } catch { + // non-fatal — orphaned backup is acceptable + } + } + } finally { + // Unlink DACL dump regardless of success/failure in this span. + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + } + + // tempPath is now the committed file; no cleanup needed. + } catch (originalError: unknown) { + // -- Rollback / cleanup on failure ---------------------------------- + if (backupPath && releaseBackupOnSuccess) { + try { + await fs.rename(backupPath, targetPath) + } catch { + // rollback failed — do not mask original error + } + } + + // Always clean up the staging temp file on failure. + try { + await fs.unlink(tempPath).catch(() => {}) + } catch { + // cleanup failure is non-fatal + } + + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + + throw originalError + } +} diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 79d08678a0..064207e21f 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -312,9 +312,8 @@ describe("safeWriteJson", () => { expect(content).toEqual(newData) }) - // Test for console error suppression during backup deletion - test("should suppress console.error when backup deletion fails", async () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error + // Test for best-effort backup deletion (the backup lifecycle now lives in safeWriteText) + test("does not fail the write when backup deletion fails (orphaned backup is acceptable)", async () => { const initialData = { message: "Initial" } const newData = { message: "New" } @@ -322,18 +321,23 @@ describe("safeWriteJson", () => { // fs.unlink is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn vi.mocked(fs.unlink).mockImplementation(async (filePath: any) => { - if (filePath.toString().includes(".bak_")) { + if (filePath.toString().includes("safeWriteText.bak_")) { throw new Error("Backup deletion failed") } return fsPromisesActuals.unlink!(filePath) }) + // The write must still succeed: backup cleanup is best-effort inside + // safeWriteText and never masks the committed content. await safeWriteJson(currentTestFilePath, newData) - // Verify console.error was called with the expected message - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Successfully wrote"), expect.any(Error)) + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual(newData) + + // The orphaned backup is still on disk because its deletion failed. + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) - consoleErrorSpy.mockRestore() vi.mocked(fs.unlink).mockRestore() }) @@ -434,9 +438,9 @@ describe("safeWriteJson", () => { expect(vi.mocked(fs.access)).toHaveBeenCalled() }) - // Test for rollback failure scenario - test("should log error and re-throw original if rollback fails", async () => { - const initialData = { message: "Initial, should be lost if rollback fails" } + // Test for rollback failure scenario (the rollback rename now lives in safeWriteText) + test("re-throws the original error when the rollback rename fails, leaving an orphaned backup", async () => { + const initialData = { message: "Initial, orphaned when rollback fails" } const newData = { message: "New content" } await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) @@ -451,20 +455,20 @@ describe("safeWriteJson", () => { // Second call: tempNewFilePath -> filePath (fail) throw new Error("Primary rename failed") } else if (renameCallCount === 3) { - // Third call: tempBackupFilePath -> filePath (rollback, also fail) + // Third call: backup -> filePath (rollback, also fail) throw new Error("Rollback rename failed") } return fsPromisesActuals.rename!(oldPath, newPath) }) - // Should throw the original error, not the rollback error + // The original error must propagate, not the rollback error await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") - // Verify console.error was called for the rollback failure - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to restore backup"), - expect.objectContaining({ message: "Rollback rename failed" }), - ) + // The rollback failed inside safeWriteText, so the target is gone and + // the backup is orphaned on disk. + expect(await fileExists(currentTestFilePath)).toBe(false) + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) consoleErrorSpy.mockRestore() }) @@ -542,4 +546,53 @@ describe("safeWriteJson", () => { const content = await readFileContent(currentTestFilePath) expect(content).toEqual({ c: 3 }) }) + + // The commit rename targets the symlink referent. The staged temp file must + // therefore be created beside the RESOLVED target — staging beside the link + // would make the commit rename fail with EXDEV when the referent is on + // another filesystem. (Real symlinks are unavailable in this CI lane, so the + // resolution is simulated by mocking fs.realpath the same way.) + test("stages the temp file beside the symlink referent and commits onto it", async () => { + const referentDir = path.join(tempDir, "referent") + const linkDir = path.join(tempDir, "link") + await fs.mkdir(referentDir, { recursive: true }) + await fs.mkdir(linkDir, { recursive: true }) + // caller-visible path (the link) vs the resolved referent path + const callerPath = path.join(linkDir, "test-file.json") + const referentPath = path.join(referentDir, "test-file.json") + // Seed the RESOLVED referent with real content (via the actual fs) so the + // write exercises replacement of an EXISTING referent: the lock is + // acquired on the caller path (realpath:false, which may be absent) while + // the backup + commit happen on the referent. + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: true })) + + vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + + await safeWriteJson(callerPath, { after: true }) + + // the temp file was created next to the resolved referent, NOT beside the link + const tempPaths = vi.mocked(fsSyncActual.createWriteStream).mock.calls.map((call) => String(call[0])) + expect(tempPaths.some((p) => p.startsWith(referentDir + path.sep) && p.includes(".new_"))).toBe(true) + expect(tempPaths.some((p) => p.startsWith(linkDir + path.sep))).toBe(false) + + // the content was committed onto the referent + expect(await readFileContent(referentPath)).toEqual({ after: true }) + }) + + // CWE-732 regression: safeWriteJson stages the temp itself and passes it + // via tempPath, so safeWriteText must apply the existing target's mode to + // the staged temp before the atomic rename — otherwise a 0o600 target is + // published as 0o644. POSIX-only assertion (Windows ignores POSIX modes). + test.skipIf(process.platform === "win32")( + "preserves a restrictive 0o600 target mode through the atomic publish", + async () => { + await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify({ before: true })) + fsSyncActual.chmodSync(currentTestFilePath, 0o600) + + await safeWriteJson(currentTestFilePath, { after: true }) + + expect(fsSyncActual.statSync(currentTestFilePath).mode & 0o777).toBe(0o600) + expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) + }, + ) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 957a0bb20f..26af906b43 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,6 +4,8 @@ import * as path from "path" import * as lockfile from "proper-lockfile" import { JsonStreamStringify } from "json-stream-stringify" +import { resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../services/file-safety/safeWriteText" + /** * Options for safeWriteJson function */ @@ -31,7 +33,7 @@ export interface SafeWriteJsonOptions { * Safely writes JSON data to a file. * - Creates parent directories if they don't exist * - Uses 'proper-lockfile' for inter-process advisory locking to prevent concurrent writes to the same path. - * - Writes to a temporary file first. + * - Writes to a temporary file first via JsonStreamStringify streaming. * - If the target file exists, it's backed up before being replaced. * - Attempts to roll back and clean up in case of errors. * - Supports pretty-printing with indentation while maintaining streaming efficiency. @@ -41,7 +43,6 @@ export interface SafeWriteJsonOptions { * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ - async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op @@ -51,10 +52,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Ensure directory structure exists with improved reliability try { - // Create directory with recursive option await fs.mkdir(dirPath, { recursive: true }) - - // Verify directory exists after creation attempt await fs.access(dirPath) } catch (dirError: any) { console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) @@ -84,13 +82,11 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // The releaseLock remains a no-op, so the finally block in the main file operations // try-catch-finally won't try to release an unacquired lock if this path is taken. console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) - // Propagate the lock acquisition error throw lockError } - // Variables to hold the actual paths of temp files if they are created. + // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null - let actualTempBackupFilePath: string | null = null try { // If a merge callback was provided, read the current file under the lock @@ -110,79 +106,43 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso data = options.merge(existing, data) } - // Step 1: Write data to a new temporary file. + // Step 1: Write data to a new temporary file via JSON streaming. + // Stage it beside the *resolved* target (the symlink referent when the path is + // a symlink): safeWriteText commits by renaming onto that referent, and a + // rename across filesystems would fail with EXDEV. + const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) actualTempNewFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, + path.dirname(resolvedTargetPath), + ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) - // Step 2: Check if the target file exists. If so, rename it to a backup path. - try { - // Check for target file existence - await fs.access(absoluteFilePath) - // Target exists, create a backup path and rename. - actualTempBackupFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, - ) - await fs.rename(absoluteFilePath, actualTempBackupFilePath) - } catch (accessError: any) { - // Explicitly type accessError - if (accessError.code !== "ENOENT") { - // An error other than "file not found" occurred during access check. - throw accessError - } - // Target file does not exist, so no backup is made. actualTempBackupFilePath remains null. + // Step 2: Delegate backup + commit + rollback to safeWriteText with the + // pre-written temp path. backup:true keeps the old safeWriteJson + // semantics (target -> backup before commit, rollback on failure) and + // keeps the target in place until safeWriteText captures its Windows + // DACL (safeWriteText dumps the DACL before its own backup rename and + // restores it onto the directory after the commit rename). + const textOptions: SafeWriteTextOptions = { + tempPath: actualTempNewFilePath, + backup: true, } - // Step 3: Rename the new temporary file to the target file path. - // This is the main "commit" step. - await fs.rename(actualTempNewFilePath, absoluteFilePath) + await safeWriteText(absoluteFilePath, "", textOptions) - // If we reach here, the new file is successfully in place. - // The original actualTempNewFilePath is now the main file, so we shouldn't try to clean it up as "temp". - // Mark as "used" or "committed" + // If we reach here, the new file is successfully in place and any + // backup has already been handled by safeWriteText. actualTempNewFilePath = null - - // Step 4: If a backup was created, attempt to delete it. - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - // Mark backup as handled - actualTempBackupFilePath = null - } catch (unlinkBackupError) { - // Log this error, but do not re-throw. The main operation was successful. - // actualTempBackupFilePath remains set, indicating an orphaned backup. - console.error( - `Successfully wrote ${absoluteFilePath}, but failed to clean up backup ${actualTempBackupFilePath}:`, - unlinkBackupError, - ) - } - } } catch (originalError) { console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath - const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath - - // Attempt rollback if a backup was made - if (backupFileToRollbackOrCleanupWithinCatch) { - try { - await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) - // Mark as handled, prevent later unlink of this path - actualTempBackupFilePath = null - } catch (rollbackError) { - // actualTempBackupFilePath (outer scope) remains pointing to backupFileToRollbackOrCleanupWithinCatch - console.error( - `[Catch] Failed to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}:`, - rollbackError, - ) - } - } - // Cleanup the .new file if it exists + // A failed safeWriteText already rolled the backup (if any) back to + // the target path. Clean up the .new file if it still exists + // (safeWriteText also cleans up its tempPath on failure; this is a + // safety net in case its cleanup missed it). if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) @@ -194,26 +154,12 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } } - // Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored) - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, - cleanupError, - ) - } - } throw originalError // This MUST be the error that rejects the promise. } finally { // Release the lock in the main finally block. try { - // releaseLock will be the actual unlock function if lock was acquired, - // or the initial no-op if acquisition failed. await releaseLock() } catch (unlockError) { - // Do not re-throw here, as the originalError from the try/catch (if any) is more important. console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) } } From 3dd8700c58a1855a54097c1c454161068d73e548 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 10:40:53 +0800 Subject: [PATCH 02/39] feat(file-safety): add version token for the guarded-write path (A1, #1375) Introduces the version token - dev:ino:size:mtimeNs:ctimeNs derived from a single fs.stat - a pure function of a file's on-disk state that every process computing from the same state agrees on. The compare-and-swap write guard (A2/A3) will compare the token observed at read time against the token recomputed before a write to detect stale or replaced files. No production callers yet: this is infrastructure for the file-write safety series (plan: easonLiangWorldedtech/Zoo-Code#33), part of upstream epic #1375. --- src/utils/__tests__/versionToken.spec.ts | 103 +++++++++++++++++++++++ src/utils/versionToken.ts | 57 +++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/utils/__tests__/versionToken.spec.ts create mode 100644 src/utils/versionToken.ts diff --git a/src/utils/__tests__/versionToken.spec.ts b/src/utils/__tests__/versionToken.spec.ts new file mode 100644 index 0000000000..56ce269244 --- /dev/null +++ b/src/utils/__tests__/versionToken.spec.ts @@ -0,0 +1,103 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import type { Stats } from "fs" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { computeVersionToken, versionTokenOfStat } from "../versionToken" + +// Stats is a class-backed interface without a public constructor, so a plain-object +// test double is the only practical way to pin the token format without real files. +// Last-resort double assertion (test-local, per AGENTS.md). +function makeStats(overrides: Partial = {}): Stats { + const base: Partial = { + dev: 7, + ino: 4242, + size: 1234, + atimeMs: 1_700_000_000_000, + mtimeMs: 1_700_000_000_123.456, + ctimeMs: 1_700_000_000_789.999, + birthtimeMs: 1_700_000_000_000, + } + return { ...base, ...overrides } as unknown as Stats +} + +describe("versionTokenOfStat (A1, epic #1375)", () => { + it("is deterministic for an identical stat", () => { + expect(versionTokenOfStat(makeStats())).toBe(versionTokenOfStat(makeStats())) + }) + + it("matches the documented dev:ino:size:mtimeNs:ctimeNs format", () => { + const expected = [ + "7", + "4242", + "1234", + Math.round(1_700_000_000_123.456 * 1e6).toString(), + Math.round(1_700_000_000_789.999 * 1e6).toString(), + ].join(":") + expect(versionTokenOfStat(makeStats())).toBe(expected) + }) + + it("distinguishes size changes at identical timestamps", () => { + expect(versionTokenOfStat(makeStats({ size: 1235 }))).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("distinguishes mtime changes at identical size", () => { + expect(versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_124 }))).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("distinguishes a replaced file (dev/ino change) with identical content state", () => { + const replaced = makeStats({ dev: 8, ino: 999 }) + expect(versionTokenOfStat(replaced)).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("preserves sub-ms mtime resolution in the ns field", () => { + const wholeMs = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123 })) + const halfMsLater = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123.5 })) + expect(halfMsLater).not.toBe(wholeMs) + // 0.5 ms = 500_000 ns. The float-derived ns field is quantized (~256 ns at + // this epoch), so allow a bounded drift instead of asserting an exact value. + const diff = Number(halfMsLater.split(":")[3]) - Number(wholeMs.split(":")[3]) + expect(Math.abs(diff - 500_000)).toBeLessThanOrEqual(512) + }) + + it("handles sizes beyond 32 bits without precision loss", () => { + const size = 5_000_000_000 // > 2^32 + const token = versionTokenOfStat(makeStats({ size })) + expect(token).toContain(`:4242:${size}:`) + }) +}) + +describe("computeVersionToken (A1, epic #1375)", () => { + let tmpDir: string + let file: string + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "version-token-")) + file = path.join(tmpDir, "seed.txt") + await fs.writeFile(file, "seed content", "utf8") + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("derives the token from the on-disk state (single stat)", async () => { + const token = await computeVersionToken(file) + expect(token).toBe(versionTokenOfStat(await fs.stat(file))) + }) + + it("changes when the file content changes", async () => { + const before = await computeVersionToken(file) + // Different size + a new mtime — both must move the token. + await fs.writeFile(file, "seed content, extended", "utf8") + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(await computeVersionToken(file)).not.toBe(before) + }) + + it("rejects with ENOENT for an absent file", async () => { + await expect(computeVersionToken(path.join(tmpDir, "absent.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }) + }) +}) diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts new file mode 100644 index 0000000000..ee8e9e82f1 --- /dev/null +++ b/src/utils/versionToken.ts @@ -0,0 +1,57 @@ +import { stat } from "fs/promises" +import type { Stats } from "fs" + +/** + * Version token for the compare-and-swap write guard (upstream epic #1375, phase A1). + * + * A token is a pure function of a file's on-disk state, derived from a single + * `fs.stat`, so every process that observes the same file state (a second VS Code + * window, the CLI, the user's own editor tooling) computes the same token. The + * downstream guard phases (A2/A3) compare the token observed at read time with the + * token recomputed just before a write to detect "the file changed since the read" + * (stale) or "the file was replaced by a different file" (dev/ino change). + * + * Format: `dev:ino:size:mtimeNs:ctimeNs` + * + * Resolution note: Node exposes modification/change times as float milliseconds, + * so the ns fields are derived as `Math.round(mtimeMs * 1e6)`. The integer-to-double + * conversion is correctly rounded, so the derivation is deterministic across + * processes, but it is quantized by double precision (~256 ns at the current epoch). + * Two file states whose timestamps differ by less than the quantum derive the same + * ns field; in practice distinct states differ by at least the OS clock resolution + * (and no write workload produces mtimes closer than that), so the guard contract + * holds: same disk state → same token; changed state → a different token in all + * realistic cases. dev, ino and size are exact integers, so any size or file + * identity change is always detected regardless of the timestamp quantum. + */ + +/** Derive an ns-scale field from Node's float milliseconds (see module docs). */ +function nsFromMs(ms: number): string { + return Math.round(ms * 1e6).toString() +} + +/** + * Build the version token from an already-fetched `Stats` — no I/O. + * + * Exported separately from {@link computeVersionToken} so tests can pin the exact + * format against synthetic stats. + */ +export function versionTokenOfStat(stats: Stats): string { + return [ + stats.dev.toString(), + stats.ino.toString(), + stats.size.toString(), + nsFromMs(stats.mtimeMs), + nsFromMs(stats.ctimeMs), + ].join(":") +} + +/** + * Compute the version token for a file (one `fs.stat`). + * + * Rejects with the underlying ENOENT (or equivalent) error when the file is absent; + * how an unobservable target is treated is decided by the guard layer (A3). + */ +export async function computeVersionToken(filePath: string): Promise { + return versionTokenOfStat(await stat(filePath)) +} From ba1332e492a8dd58dadeff4aec956daba4fc6921 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 11:03:05 +0800 Subject: [PATCH 03/39] docs(file-safety): correct ino precision bounds in version token (A1, #1375) Review finding: 'ino is an exact integer' was overstated. Node exposes ino as a float64 number: exact for small POSIX inode numbers, but on modern Windows the file ID exceeds 2^53 so Node's own value is already rounded (verified on node v25: non-zero ino, isSafeInteger=false). It remains deterministic per file (same file -> same token), so the token contract is unchanged; change detection rests on exact dev/size plus the mtime/ctime ns fields. Document the bound instead of claiming exactness. --- src/utils/versionToken.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts index ee8e9e82f1..59a8b348fe 100644 --- a/src/utils/versionToken.ts +++ b/src/utils/versionToken.ts @@ -21,8 +21,15 @@ import type { Stats } from "fs" * ns field; in practice distinct states differ by at least the OS clock resolution * (and no write workload produces mtimes closer than that), so the guard contract * holds: same disk state → same token; changed state → a different token in all - * realistic cases. dev, ino and size are exact integers, so any size or file - * identity change is always detected regardless of the timestamp quantum. + * realistic cases. `dev` and `size` are exact integers. `ino` is Node's + * `number` (float64): exact for small POSIX inode numbers, but on modern Windows + * the underlying file ID exceeds 2^53, so Node's own value is already rounded — + * still deterministic per file (same file → same token), but not guaranteed + * injective across distinct files. Change detection therefore rests on size + + * mtime/ctime: any size change is always detected regardless of the timestamp + * quantum, and a replacement whose size and timestamps are indistinguishable is + * undetectable by any scheme reading the same Stats — the detect-and-reread + * stance (no lockfile) accepts that. */ /** Derive an ns-scale field from Node's float milliseconds (see module docs). */ From 68130133eda346d9910ea3a6ad6aaa41090b61df Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 11:27:00 +0800 Subject: [PATCH 04/39] fix(file-safety): derive the version token from exact BigInt stats (A1, #1375) CodeRabbit finding on this PR: the default numeric fs.stat() loses precision (values above 2^53 are rounded, including Windows file IDs) and the ms->ns derivation introduced a double-precision quantum. Fixed by fetching the stat with { bigint: true }: all five token fields (dev, ino, size, mtimeNs, ctimeNs) are exact BigInt values rendered as decimal strings, with no float anywhere. The sub-ms test now asserts an exact 1_000 ns delta instead of bounded drift, and a regression test pins a size of 10^16+1 (> Number.MAX_SAFE_INTEGER). --- src/utils/__tests__/versionToken.spec.ts | 82 ++++++++++++------------ src/utils/versionToken.ts | 62 +++++++----------- 2 files changed, 65 insertions(+), 79 deletions(-) diff --git a/src/utils/__tests__/versionToken.spec.ts b/src/utils/__tests__/versionToken.spec.ts index 56ce269244..3e2ca26b5c 100644 --- a/src/utils/__tests__/versionToken.spec.ts +++ b/src/utils/__tests__/versionToken.spec.ts @@ -1,25 +1,33 @@ import * as fs from "fs/promises" import * as os from "os" import * as path from "path" -import type { Stats } from "fs" +import type { BigIntStats } from "fs" import { afterEach, beforeEach, describe, expect, it } from "vitest" import { computeVersionToken, versionTokenOfStat } from "../versionToken" -// Stats is a class-backed interface without a public constructor, so a plain-object -// test double is the only practical way to pin the token format without real files. -// Last-resort double assertion (test-local, per AGENTS.md). -function makeStats(overrides: Partial = {}): Stats { - const base: Partial = { - dev: 7, - ino: 4242, - size: 1234, - atimeMs: 1_700_000_000_000, - mtimeMs: 1_700_000_000_123.456, - ctimeMs: 1_700_000_000_789.999, - birthtimeMs: 1_700_000_000_000, +// BigIntStats is a class-backed interface without a public constructor, so a +// plain-object test double is the only practical way to pin the token format +// without real files. Last-resort double assertion (test-local, per AGENTS.md). +function makeStats(overrides: Partial = {}): BigIntStats { + // This repo's @types/node models every StatsBase field (including the *Ms + // fields) as the parameter type T, so all values here are bigint literals; + // the token only reads the *Ns fields. Single-step downcast from Partial to + // the full type (BigIntStats has no public constructor). + const base: Partial = { + dev: 7n, + ino: 4242n, + size: 1234n, + atimeMs: 1_700_000_000_000n, + mtimeMs: 1_700_000_000_123n, + ctimeMs: 1_700_000_000_789n, + birthtimeMs: 1_700_000_000_000n, + atimeNs: 1_700_000_000_000_000_000n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + birthtimeNs: 1_700_000_000_000_000_000n, } - return { ...base, ...overrides } as unknown as Stats + return { ...base, ...overrides } as BigIntStats } describe("versionTokenOfStat (A1, epic #1375)", () => { @@ -27,44 +35,38 @@ describe("versionTokenOfStat (A1, epic #1375)", () => { expect(versionTokenOfStat(makeStats())).toBe(versionTokenOfStat(makeStats())) }) - it("matches the documented dev:ino:size:mtimeNs:ctimeNs format", () => { - const expected = [ - "7", - "4242", - "1234", - Math.round(1_700_000_000_123.456 * 1e6).toString(), - Math.round(1_700_000_000_789.999 * 1e6).toString(), - ].join(":") - expect(versionTokenOfStat(makeStats())).toBe(expected) + it("matches the documented dev:ino:size:mtimeNs:ctimeNs format with exact decimal fields", () => { + expect(versionTokenOfStat(makeStats())).toBe("7:4242:1234:1700000000123456789:1700000000789999999") }) it("distinguishes size changes at identical timestamps", () => { - expect(versionTokenOfStat(makeStats({ size: 1235 }))).not.toBe(versionTokenOfStat(makeStats())) + expect(versionTokenOfStat(makeStats({ size: 1235n }))).not.toBe(versionTokenOfStat(makeStats())) }) - it("distinguishes mtime changes at identical size", () => { - expect(versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_124 }))).not.toBe(versionTokenOfStat(makeStats())) + it("distinguishes a one-nanosecond mtime change", () => { + expect(versionTokenOfStat(makeStats({ mtimeNs: 1_700_000_000_123_456_790n }))).not.toBe( + versionTokenOfStat(makeStats()), + ) }) it("distinguishes a replaced file (dev/ino change) with identical content state", () => { - const replaced = makeStats({ dev: 8, ino: 999 }) + const replaced = makeStats({ dev: 8n, ino: 999n }) expect(versionTokenOfStat(replaced)).not.toBe(versionTokenOfStat(makeStats())) }) - it("preserves sub-ms mtime resolution in the ns field", () => { - const wholeMs = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123 })) - const halfMsLater = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123.5 })) - expect(halfMsLater).not.toBe(wholeMs) - // 0.5 ms = 500_000 ns. The float-derived ns field is quantized (~256 ns at - // this epoch), so allow a bounded drift instead of asserting an exact value. - const diff = Number(halfMsLater.split(":")[3]) - Number(wholeMs.split(":")[3]) - expect(Math.abs(diff - 500_000)).toBeLessThanOrEqual(512) + it("renders nanosecond resolution exactly (no float quantization)", () => { + const base = versionTokenOfStat(makeStats()) + const plusOneMicrosecond = versionTokenOfStat(makeStats({ mtimeNs: 1_700_000_000_123_457_789n })) + // 1_000 ns apart — the BigInt derivation must keep the delta exact. + const baseNs = BigInt(base.split(":")[3]) + const microNs = BigInt(plusOneMicrosecond.split(":")[3]) + expect(microNs - baseNs).toBe(1_000n) }) - it("handles sizes beyond 32 bits without precision loss", () => { - const size = 5_000_000_000 // > 2^32 + it("handles sizes beyond Number.MAX_SAFE_INTEGER without precision loss", () => { + const size = 10_000_000_000_000_001n // 10^16 + 1 > 2^53 const token = versionTokenOfStat(makeStats({ size })) - expect(token).toContain(`:4242:${size}:`) + expect(token).toBe(`7:4242:${size}:1700000000123456789:1700000000789999999`) }) }) @@ -82,9 +84,9 @@ describe("computeVersionToken (A1, epic #1375)", () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) - it("derives the token from the on-disk state (single stat)", async () => { + it("derives the token from the on-disk state (single bigint stat)", async () => { const token = await computeVersionToken(file) - expect(token).toBe(versionTokenOfStat(await fs.stat(file))) + expect(token).toBe(versionTokenOfStat(await fs.stat(file, { bigint: true }))) }) it("changes when the file content changes", async () => { diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts index 59a8b348fe..1738280087 100644 --- a/src/utils/versionToken.ts +++ b/src/utils/versionToken.ts @@ -1,64 +1,48 @@ import { stat } from "fs/promises" -import type { Stats } from "fs" +import type { BigIntStats } from "fs" /** * Version token for the compare-and-swap write guard (upstream epic #1375, phase A1). * * A token is a pure function of a file's on-disk state, derived from a single - * `fs.stat`, so every process that observes the same file state (a second VS Code - * window, the CLI, the user's own editor tooling) computes the same token. The - * downstream guard phases (A2/A3) compare the token observed at read time with the - * token recomputed just before a write to detect "the file changed since the read" - * (stale) or "the file was replaced by a different file" (dev/ino change). + * `fs.stat(path, { bigint: true })`, so every process that observes the same file + * state (a second VS Code window, the CLI, the user's own editor tooling) computes + * the same token. The downstream guard phases (A2/A3) compare the token observed at + * read time with the token recomputed just before a write to detect "the file + * changed since the read" (stale) or "the file was replaced by a different file" + * (dev/ino change). * * Format: `dev:ino:size:mtimeNs:ctimeNs` * - * Resolution note: Node exposes modification/change times as float milliseconds, - * so the ns fields are derived as `Math.round(mtimeMs * 1e6)`. The integer-to-double - * conversion is correctly rounded, so the derivation is deterministic across - * processes, but it is quantized by double precision (~256 ns at the current epoch). - * Two file states whose timestamps differ by less than the quantum derive the same - * ns field; in practice distinct states differ by at least the OS clock resolution - * (and no write workload produces mtimes closer than that), so the guard contract - * holds: same disk state → same token; changed state → a different token in all - * realistic cases. `dev` and `size` are exact integers. `ino` is Node's - * `number` (float64): exact for small POSIX inode numbers, but on modern Windows - * the underlying file ID exceeds 2^53, so Node's own value is already rounded — - * still deterministic per file (same file → same token), but not guaranteed - * injective across distinct files. Change detection therefore rests on size + - * mtime/ctime: any size change is always detected regardless of the timestamp - * quantum, and a replacement whose size and timestamps are indistinguishable is - * undetectable by any scheme reading the same Stats — the detect-and-reread - * stance (no lockfile) accepts that. + * Precision: the stat is fetched in `bigint` mode, so all five fields are exact + * `BigInt` values rendered as decimal strings — no float is involved anywhere. + * There is therefore no precision loss for large sizes or inodes (a Windows file ID + * exceeds 2^53 and is still exact), and the ns timestamps are the kernel's exact + * nanosecond values rather than a ms→ns derivation (no ~256 ns double-precision + * quantum). Guarantee: same disk state → same token, deterministic across + * processes; any change to size, file identity, or mtime/ctime → a different token. + * + * Platform note: on POSIX `ctime` is the last file-status change; on Windows it is + * the file creation time. The token only requires it to move when the file's + * metadata is replaced, which holds on both. */ -/** Derive an ns-scale field from Node's float milliseconds (see module docs). */ -function nsFromMs(ms: number): string { - return Math.round(ms * 1e6).toString() -} - /** - * Build the version token from an already-fetched `Stats` — no I/O. + * Build the version token from an already-fetched `BigIntStats` — no I/O. * * Exported separately from {@link computeVersionToken} so tests can pin the exact * format against synthetic stats. */ -export function versionTokenOfStat(stats: Stats): string { - return [ - stats.dev.toString(), - stats.ino.toString(), - stats.size.toString(), - nsFromMs(stats.mtimeMs), - nsFromMs(stats.ctimeMs), - ].join(":") +export function versionTokenOfStat(stats: BigIntStats): string { + return [stats.dev, stats.ino, stats.size, stats.mtimeNs, stats.ctimeNs].map((value) => value.toString()).join(":") } /** - * Compute the version token for a file (one `fs.stat`). + * Compute the version token for a file (one `fs.stat` in bigint mode). * * Rejects with the underlying ENOENT (or equivalent) error when the file is absent; * how an unobservable target is treated is decided by the guard layer (A3). */ export async function computeVersionToken(filePath: string): Promise { - return versionTokenOfStat(await stat(filePath)) + return versionTokenOfStat(await stat(filePath, { bigint: true })) } From 588b95fdc2420feb9163b0d21dd1d1f0bbfe7bf9 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 14:52:58 +0800 Subject: [PATCH 05/39] feat(task): per-task file observation registry (A2, #1375) --- src/core/task/Task.ts | 2 + .../__tests__/observationRegistry.spec.ts | 72 +++++++++++ src/core/task/observationRegistry.ts | 47 ++++++++ src/core/tools/ReadFileTool.ts | 12 ++ src/core/tools/__tests__/readFileTool.spec.ts | 113 ++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 src/core/task/__tests__/observationRegistry.spec.ts create mode 100644 src/core/task/observationRegistry.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 349d9c51d3..8977b60830 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -103,6 +103,7 @@ import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" +import { ObservationRegistry } from "./observationRegistry" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" @@ -181,6 +182,7 @@ export class Task extends EventEmitter implements TaskLike { readonly parentTask: Task | undefined = undefined readonly taskNumber: number readonly workspacePath: string + readonly observationRegistry = new ObservationRegistry() /** * The mode associated with this task. Persisted across sessions diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts new file mode 100644 index 0000000000..51b73aabde --- /dev/null +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from "vitest" + +import { ObservationRegistry } from "../observationRegistry" + +describe("ObservationRegistry", () => { + it("observe → get returns the recorded version and observedAt", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "1:2:300:4000000000:5000000000") + + const obs = reg.get("/a/b/c.ts") + expect(obs).toBeDefined() + expect(obs!.version).toBe("1:2:300:4000000000:5000000000") + expect(typeof obs!.observedAt).toBe("number") + }) + + it("re-observe replaces the entry with a fresh observedAt", () => { + vi.useFakeTimers() + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + const first = reg.get("/a/b/c.ts")! + expect(first.version).toBe("v1") + + vi.advanceTimersByTime(50) + reg.observe("/a/b/c.ts", "v2") + const second = reg.get("/a/b/c.ts")! + expect(second.version).toBe("v2") + expect(second.observedAt).toBeGreaterThan(first.observedAt) + + vi.useRealTimers() + }) + + it("has returns true for observed paths, false otherwise", () => { + const reg = new ObservationRegistry() + reg.observe("/x.ts", "t1") + expect(reg.has("/x.ts")).toBe(true) + expect(reg.has("/y.ts")).toBe(false) + }) + + it("size reflects the number of observed entries", () => { + const reg = new ObservationRegistry() + expect(reg.size).toBe(0) + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + expect(reg.size).toBe(2) + }) + + it("clear removes all entries and resets size to 0", () => { + const reg = new ObservationRegistry() + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + reg.clear() + expect(reg.size).toBe(0) + expect(reg.get("/a.ts")).toBeUndefined() + expect(reg.has("/b.ts")).toBe(false) + }) + + it("get on empty registry returns undefined", () => { + const reg = new ObservationRegistry() + expect(reg.get("/any.ts")).toBeUndefined() + }) + + it("separate instances are independent — observing in one does not appear in the other", () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")).toBeDefined() + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) +}) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts new file mode 100644 index 0000000000..871f80225b --- /dev/null +++ b/src/core/task/observationRegistry.ts @@ -0,0 +1,47 @@ +/** + * Per-task file observation registry (upstream epic #1375, phase A2). + * + * Each Task owns its own instance so parent and subtask observations are + * independent. The S4 guarded-write will compare these versions against the + * token recomputed pre-write to detect stale reads or file replacement. + * + * Pure in-memory — zero I/O, no dependencies. No behavior change in this PR: + * observations are recorded but not consulted. + */ + +export interface FileObservation { + /** Version token derived from on-disk fs.stat (bigint mode). */ + version: string + /** Millisecond timestamp when the observation was recorded. */ + observedAt: number +} + +export class ObservationRegistry { + private readonly entries = new Map() + + /** + * Record an observation for a file at its absolute path. + * + * Re-observing replaces the entry with a fresh observedAt timestamp and + * the new version token. + */ + observe(absolutePath: string, version: string): void { + this.entries.set(absolutePath, { version, observedAt: Date.now() }) + } + + get(absolutePath: string): FileObservation | undefined { + return this.entries.get(absolutePath) + } + + has(absolutePath: string): boolean { + return this.entries.has(absolutePath) + } + + clear(): void { + this.entries.clear() + } + + get size(): number { + return this.entries.size + } +} diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..6e222e1309 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -16,6 +16,7 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" +import { computeVersionToken } from "../../utils/versionToken" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -220,6 +221,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard. + // A stat failure leaves the target unobserved and never fails the read. + const version = await computeVersionToken(fullPath).catch(() => undefined) + if (version) task.observationRegistry.observe(fullPath, version) + updateFileResult(relPath, { nativeContent: `File: ${relPath}\n${result}`, }) @@ -799,6 +805,12 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Track file in context await task.fileContextTracker.trackFileContext(relPath, "read_tool") + + // A2 (plan #33 / epic #1375): mirror the native path — record the observed + // on-disk version so legacy-format reads also feed the future write guard. + // A stat failure leaves the target unobserved and never fails the read. + const version = await computeVersionToken(fullPath).catch(() => undefined) + if (version) task.observationRegistry.observe(fullPath, version) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6c9e177d38..6108e78151 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -13,10 +13,16 @@ */ import path from "path" +import type { Stats } from "fs" + +import type { LegacyReadFileParams } from "@roo-code/types" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" +import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" +import { computeVersionToken } from "../../../utils/versionToken" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, @@ -136,6 +142,7 @@ interface MockTaskOptions { rooIgnoreAllowed?: boolean maxImageFileSize?: number maxTotalImageSize?: number + observationRegistry?: ObservationRegistry } function createMockTask(options: MockTaskOptions = {}) { @@ -143,6 +150,9 @@ function createMockTask(options: MockTaskOptions = {}) { return { cwd: "/test/workspace", + // Mirror Task: every task always owns an observation registry (A2, #1375). + // Tests asserting on observations pass their own instance via options. + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), api: { getModel: vi.fn().mockReturnValue({ info: { supportsImages }, @@ -1489,5 +1499,108 @@ describe("ReadFileTool", () => { expect(mockTask.didToolFailInCurrentTurn).toBe(true) }) + + describe("observation registry", () => { + it("records an observation on successful read of an existing file", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Override the beforeEach default stat mock with proper BigIntStats. + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + // Spy on observe to capture the exact key used (Windows path.resolve may use backslashes). + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "existing.ts" }, mockTask as unknown as Task, callbacks) + + // Verify the tool called observe exactly once with a valid token. + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("existing.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + + // Verify get() returns the same data using the spy-captured key. + const obs = reg.get(calledPath) + expect(obs).toBeDefined() + expect(obs!.version).toBe(calledVersion) + }) + + it("a failed read (absent path) leaves the registry size 0 and does not throw", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "missing.ts" }, mockTask as unknown as Task, callbacks) + + // observationRegistry is guaranteed present because we passed it in createMockTask. + const reg = mockTask.observationRegistry + expect(reg).toBeDefined() + expect(reg!.size).toBe(0) + }) + + it("records an observation for legacy-format reads of existing files", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Typed legacy (pre-refactor) params: the multi-file format with the + // _legacyFormat discriminant (see LegacyReadFileParams). + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("legacy.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + }) + + it("two separate Task-owned registries are independent", async () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) + }) }) }) From 7a25fc076d2c9f0d488c21bba312dac8dd0bdb95 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 22:09:22 +0800 Subject: [PATCH 06/39] feat(tools): guarded write CAS core with per-path FIFO chain (S4a, #1375) --- src/core/tools/ReadFileTool.ts | 36 +- src/core/tools/__tests__/guardedWrite.spec.ts | 454 ++++++++++++++++++ src/core/tools/__tests__/readFileTool.spec.ts | 211 +++++++- src/core/tools/guardedWrite.ts | 260 ++++++++++ src/eslint-suppressions.json | 2 +- src/utils/__tests__/safeWriteJson.test.ts | 77 +++ src/utils/safeWriteJson.ts | 31 +- 7 files changed, 1051 insertions(+), 20 deletions(-) create mode 100644 src/core/tools/__tests__/guardedWrite.spec.ts create mode 100644 src/core/tools/guardedWrite.ts diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 6e222e1309..3647c631ee 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -16,7 +16,7 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" -import { computeVersionToken } from "../../utils/versionToken" +import { versionTokenOfStat } from "../../utils/versionToken" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -215,6 +215,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Read text file content with lossy UTF-8 conversion // Reading as Buffer first allows graceful handling of non-UTF8 bytes // (they become U+FFFD replacement characters instead of throwing) + // A2 (epic #1375): capture the on-disk token before the read so a mutation + // landing mid-read is detected by the post-read stat below. + const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) const buffer = await fs.readFile(fullPath) const fileContent = buffer.toString("utf-8") const result = this.processTextFile(fileContent, entry) @@ -222,9 +225,18 @@ export class ReadFileTool extends BaseTool<"read_file"> { await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard. - // A stat failure leaves the target unobserved and never fails the read. - const version = await computeVersionToken(fullPath).catch(() => undefined) - if (version) task.observationRegistry.observe(fullPath, version) + // The token is captured before AND after the read; the target is observed only + // when both match — a mutation between the two stats means the content the model + // received is not the on-disk state, and observing it would let a later write + // match a token the model never saw. A stat failure leaves the target + // unobserved and never fails the read. + const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(fullPath, preReadToken) + } + } updateFileResult(relPath, { nativeContent: `File: ${relPath}\n${result}`, @@ -774,6 +786,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { } // Read text file + // A2 (epic #1375): capture the on-disk token before the read so a mutation + // landing mid-read is detected by the post-read stat below. + const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) const rawContent = await fs.readFile(fullPath, "utf8") // Handle line ranges if specified @@ -808,9 +823,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { // A2 (plan #33 / epic #1375): mirror the native path — record the observed // on-disk version so legacy-format reads also feed the future write guard. - // A stat failure leaves the target unobserved and never fails the read. - const version = await computeVersionToken(fullPath).catch(() => undefined) - if (version) task.observationRegistry.observe(fullPath, version) + // Observe only when the pre-read and post-read tokens match (a mutation between + // them means the returned content is not the on-disk state). A stat failure + // leaves the target unobserved and never fails the read. + const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(fullPath, preReadToken) + } + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts new file mode 100644 index 0000000000..122c1c6d9b --- /dev/null +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -0,0 +1,454 @@ +/** + * Tests for the guarded-write compare-and-swap core (upstream epic #1375, + * phase A4a). + * + * Covers guard selection through the S2 observation registry, version-token + * CAS, remediation messages, and the per-absolute-path FIFO chain: FIFO + * ordering, exactly-one winner under concurrency, no wedge after a rejected + * link, and independence across paths. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { describe, expect, it, beforeEach, vi } from "vitest" + +import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain } from "../guardedWrite" +import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../../utils/versionToken" +import { ObservationRegistry } from "../../task/observationRegistry" +import type { Task } from "../../task/Task" + +// -- Mocks ------------------------------------------------------------------- + +vi.mock("fs/promises", () => ({ + access: vi.fn(), + stat: vi.fn(), +})) + +vi.mock("../../../utils/versionToken", () => ({ + computeVersionToken: vi.fn(), +})) + +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn(), +})) + +const mockedFsAccess = vi.mocked(fs.access) +const mockedComputeVersionToken = vi.mocked(computeVersionToken) +const mockedSafeWriteText = vi.mocked(safeWriteText) + +// -- Fixtures ---------------------------------------------------------------- + +const WORKSPACE = "/test/workspace" + +/** Resolve a fixture path the same way guardedWrite resolves task.cwd-relative paths. */ +const abs = (relPath: string): string => path.resolve(WORKSPACE, relPath) + +interface MockTaskOptions { + cwd?: string + observationRegistry?: ObservationRegistry +} + +/** + * Minimal structural Task: guardedWrite only reads task.cwd and + * task.observationRegistry. The real Task constructor needs the full provider + * machinery, so a single documented double cast stands in for the class. + */ +function createMockTask(options: MockTaskOptions = {}): Task { + const task = { + cwd: options.cwd ?? WORKSPACE, + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), + } + return task as unknown as Task +} + +// -- Tests ------------------------------------------------------------------- + +describe("guardedWrite (S4a, epic #1375)", () => { + beforeEach(() => { + vi.resetAllMocks() + resetChain() + }) + + describe("unobserved create", () => { + it("succeeds when the file is absent and publishes via safeWriteText", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("fails with the read-first remediation when the file exists - nothing published", async () => { + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + await expect(guardedWrite(task, "existing.txt", "hello", "create")).rejects.toThrow( + "File already exists at " + + abs("existing.txt") + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rethrows I/O errors that are not ENOENT verbatim (no guard verdict on access failure)", async () => { + const failures = [{ code: "EACCES" }, null, "volume offline", new Error("EIO-ish failure")] + for (const failure of failures) { + mockedFsAccess.mockRejectedValueOnce(failure) + await expect(createIfAbsent(abs("io-error.txt"), "x")).rejects.toBe(failure) + } + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("deleted-after-read target", () => { + it("normalizes an ENOENT from the version token into the re-read remediation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("vanished.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + + // The file was deleted after the read: the token computation fails + // with a raw ENOENT, which the guard must convert into the standard + // re-read-then-retry contract. + mockedComputeVersionToken.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "vanished.txt", "next", "update")).rejects.toThrow( + "File was deleted after it was read", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rethrows non-ENOENT token failures verbatim from replaceIfVersion", async () => { + const failure = { code: "EACCES" } + mockedComputeVersionToken.mockRejectedValueOnce(failure) + + await expect(replaceIfVersion(abs("locked.txt"), "v1", "next")).rejects.toBe(failure) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + describe("unobserved update", () => { + it("succeeds when the file is absent (same create guard)", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("fails with the read-first remediation when the file exists - nothing published", async () => { + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + await expect(guardedWrite(task, "existing.txt", "hello", "update")).rejects.toThrow( + "File already exists at " + + abs("existing.txt") + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed create", () => { + it("recreates a file that vanished after the read", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("gone.txt"), "v1") + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "gone.txt", "back", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back") + }) + + it("goes through the version guard when the file still exists", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "kept.txt", "rewritten", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten") + }) + + it("fails with the stale remediation suffix when the version moved", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "kept.txt", "rewritten", "create")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("defers to the version guard when the access check is denied (not ENOENT)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("locked.txt"), "v1") + mockedFsAccess.mockRejectedValue({ code: "EACCES" }) + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "locked.txt", "rewritten", "create")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed update (version CAS)", () => { + it("publishes when the on-disk version matches the observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "new content", "update") + + expect(mockedComputeVersionToken).toHaveBeenCalledWith(abs("doc.txt")) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + }) + + it("fails with the stale remediation suffix when the version moved - nothing published", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("edit", () => { + it("fails read-first when the file was never observed - nothing published, no I/O", async () => { + const task = createMockTask() + + await expect(guardedWrite(task, "any.txt", "patched", "edit")).rejects.toThrow( + "File not read yet -- read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("publishes when the version matches the observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + }) + + it("fails with the stale remediation suffix when the version moved", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v3") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "patched", "edit")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v3); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("concurrency: per-path FIFO chain", () => { + it("two concurrent updates on one path - exactly one publishes, the other fails stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("shared.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // The first publish changes the on-disk state (new token). + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + const p1 = guardedWrite(task, "shared.txt", "first", "update") + const p2 = guardedWrite(task, "shared.txt", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + if (r1.status !== "fulfilled" || r2.status !== "rejected") { + throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) + } + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(r2.reason.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + }) + + it("observed-absent then two concurrent creates - the second fails stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("absent.txt"), "v1") // read before, file later vanished + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask({ observationRegistry: reg }) + + let publishes = 0 + mockedSafeWriteText.mockImplementation(async () => { + publishes += 1 + if (publishes === 1) { + // After the first publish the file exists again under a new token. + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + } + }) + + const p1 = guardedWrite(task, "absent.txt", "first", "create") + const p2 = guardedWrite(task, "absent.txt", "second", "create") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + if (r1.status !== "fulfilled" || r2.status !== "rejected") { + throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) + } + expect(publishes).toBe(1) + expect(r2.reason.message).toContain("Stale version") + expect(r2.reason.message).toContain("re-read the file, then retry.") + }) + + it("the chain settles after a rejection - a later matching write still runs", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("settle.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v2") // already stale at v1 + const task = createMockTask({ observationRegistry: reg }) + + const p1 = guardedWrite(task, "settle.txt", "first", "update") + await expect(p1).rejects.toThrow("Stale version") + + // No resetChain: the rejected link must not wedge the chain. The + // caller re-reads the file (observation refreshed to v2) and retries. + reg.observe(abs("settle.txt"), "v2") + const p2 = guardedWrite(task, "settle.txt", "second", "update") + await expect(p2).resolves.toBeUndefined() + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second") + }) + + it("evicts settled chain entries - a later write still serializes in order", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("evict.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // A first write settles; its chain entry is evicted with it. + const p1 = guardedWrite(task, "evict.txt", "first", "update") + await expect(p1).resolves.toBeUndefined() + + // Two rapid writes submitted after the eviction must still run one + // at a time in submission order (the eviction must not drop the + // chain for in-flight or just-enqueued links). + const order: string[] = [] + mockedSafeWriteText.mockImplementation(async (_path: string, content: string) => { + order.push(content) + }) + const p2 = guardedWrite(task, "evict.txt", "second", "update") + const p3 = guardedWrite(task, "evict.txt", "third", "update") + await Promise.all([p2, p3]) + + expect(order).toEqual(["second", "third"]) + // Three publishes in total: the settled first write plus the two + // serialized rapid writes. + expect(mockedSafeWriteText).toHaveBeenCalledTimes(3) + }) + + it("writes on different paths are independent (no cross-path serialization)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1") + reg.observe(abs("b.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + const p1 = guardedWrite(task, "a.txt", "a", "update") + const p2 = guardedWrite(task, "b.txt", "b", "update") + await Promise.all([p1, p2]) + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + }) + }) + + describe("path resolution", () => { + it("resolves a relative path against task.cwd", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("sub/dir.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "sub/dir.txt", "content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content") + }) + + it("normalizes an already-absolute input (trailing separator) to the observation key", async () => { + const reg = new ObservationRegistry() + const canonical = abs("sub/dir.txt") + // ReadFileTool observes under path.resolve(task.cwd, relPath) — the + // canonical spelling. A write addressed with a trailing separator used + // to bypass the observation (isAbsolute passthrough) and fail + // "File already exists" / "File not read yet" for a file that was read. + reg.observe(canonical, "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, canonical + "/", "content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content") + }) + + it("serializes two spellings of one file through a single chain key", async () => { + const reg = new ObservationRegistry() + const canonical = abs("shared2.txt") + reg.observe(canonical, "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // The first publish changes the on-disk state (new token). + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + // Plain spelling vs the trailing-separator spelling: with one chain key + // they are strictly ordered (first matches v1, second sees v2). + const p1 = guardedWrite(task, canonical, "first", "update") + const p2 = guardedWrite(task, canonical + "/", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + if (r1.status !== "fulfilled" || r2.status !== "rejected") { + throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) + } + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(r2.reason.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + }) + }) + + describe("resetChain", () => { + it("detaches pending links so later writes start a fresh chain", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("x.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "x.txt", "a", "update") + resetChain() + await guardedWrite(task, "x.txt", "b", "update") + + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b") + }) + }) +}) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6108e78151..7e2fa3aac7 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -197,7 +197,18 @@ describe("ReadFileTool", () => { vi.clearAllMocks() // Default mock implementations - mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) + // The stat default carries BigIntStats fields (A2, epic #1375): reads now + // token-ize the pre/post stats, so the default must look like a real bigint stat. + // Tests overriding it do so per-call with mockResolvedValue(Once). + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) mockedIsBinaryFile.mockResolvedValue(false) mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) mockedReadWithSlice.mockReturnValue({ @@ -1591,6 +1602,204 @@ describe("ReadFileTool", () => { expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) }) + it("does not observe when the file mutates between the pre-read and post-read stats", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const preStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // A mutation lands mid-read: the post-read stat differs. + const postStats = { ...preStats, size: BigInt(301) } + + // Call order: directory check, pre-read stat, post-read stat. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(preStats as unknown as Stats) + .mockResolvedValueOnce(postStats as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "mutated.ts" }, mockTask as unknown as Task, callbacks) + + // The read itself succeeded, but the target stays unobserved: the content the + // model received is not the on-disk state, so observing it would let a later + // write match a token the model never saw. + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("leaves the target unobserved without failing the read when the pre-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Directory check OK; the pre-read stat fails (caught, target unobserved). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "stat-fail.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + // The read still succeeds — a stat failure never fails the read. + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + + it("leaves the target unobserved without failing the read when the post-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const okStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Directory check and pre-read stat OK; the post-read stat fails. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(okStats as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "post-stat-fail.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + + it("legacy format: does not observe when the file mutates between the pre-read and post-read stats", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const preStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Call order: directory check, pre-read stat, post-read stat (mutated). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(preStats as unknown as Stats) + .mockResolvedValueOnce({ ...preStats, size: BigInt(301) } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-mutated.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("legacy format: leaves the target unobserved when a stat fails without failing the read", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Directory check OK; the pre-read stat fails (caught, target unobserved). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-stat-fail.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + it("legacy format: leaves the target unobserved when the post-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const okStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Directory check and pre-read stat OK; the post-read stat fails. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(okStats as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-post-stat-fail.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) it("two separate Task-owned registries are independent", async () => { const regA = new ObservationRegistry() const regB = new ObservationRegistry() diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts new file mode 100644 index 0000000000..596cc41035 --- /dev/null +++ b/src/core/tools/guardedWrite.ts @@ -0,0 +1,260 @@ +/** + * Guarded-write compare-and-swap core (upstream epic #1375, phase A4a). + * + * Wraps the S3 safeWriteText publish primitive behind version-token guards so + * that every write is deterministic: + * + * - an unobserved target may only be created when it is absent + * (createIfAbsent); + * - an observed target is published only when the on-disk version token still + * matches the token recorded at read time (replaceIfVersion); + * - an edit-style write requires a prior observation (unobservedEditGuard). + * + * A per-absolute-path FIFO chain of tail promises orders concurrent + * in-process writes to the same path: the first matching write wins, the rest + * fail stale. Observations come from the task's S2 ObservationRegistry. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { safeWriteText } from "../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../utils/versionToken" +import type { Task } from "../task/Task" + +// -- Types ------------------------------------------------------------------ + +/** Write kind that drives guard selection. */ +export type GuardedWriteKind = "create" | "update" | "edit" + +/** Internal error thrown when a guard rejects a write. */ +class GuardRejectedError extends Error { + constructor( + message: string, + readonly path: string, + ) { + super(message) + this.name = "GuardRejectedError" + } +} + +// -- Per-path tail-promise chain -------------------------------------------- + +/** + * Per-absolute-path FIFO chain of pending guarded writes (tail promise per + * path). Every write enqueues onto the current tail for its path, so + * concurrent writes to the same path run one at a time in submission order. + * + * The chain never leaks a rejection through itself: each link settles, a + * rejected link is skipped by the next writer (a failed write must not block + * later writes to the same path), and every caller receives its own link + * promise to handle. + * + * Settled entries are evicted (below), so a long-lived extension does not + * accumulate a map entry per distinct written path. + */ +const pendingChains = new Map>() + +/** + * Enqueue a write operation on the per-path FIFO chain. + * + * Returns the promise for this link; it always settles. A prior link that + * rejected is skipped, not propagated. The map entry for this link is + * deleted once it settles — but only while it is still the current tail for + * the path, so a replacement enqueued in the meantime keeps ownership. + */ +function enqueue(pathKey: string, fn: () => Promise): Promise { + const prev = pendingChains.get(pathKey) ?? Promise.resolve() + const next = prev.then(fn, fn) + pendingChains.set(pathKey, next) + void next.then( + () => { + if (pendingChains.get(pathKey) === next) { + pendingChains.delete(pathKey) + } + }, + () => { + if (pendingChains.get(pathKey) === next) { + pendingChains.delete(pathKey) + } + }, + ) + return next +} + +// -- Guard primitives -------------------------------------------------------- + +/** + * Extract a Node errno code (e.g. "ENOENT") from a thrown value, or + * undefined when the value carries none. + */ +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined +} + +/** True when the path is absent on disk (fs.access reports ENOENT). */ +async function fileIsAbsent(absolutePath: string): Promise { + try { + await fs.access(absolutePath) + return false + } catch (error: unknown) { + return errorCode(error) === "ENOENT" + } +} + +/** + * Publish content only if the target file does not exist. + * + * Rejects with a loud remediation error when the file already exists: the + * write was issued for a file that was never read, so the caller must read + * the file first, then retry. + */ +export async function createIfAbsent(absolutePath: string, content: string): Promise { + try { + await fs.access(absolutePath) + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") { + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + await safeWriteText(absolutePath, content) + return + } + + throw new GuardRejectedError( + "File already exists at " + + absolutePath + + " and was not read before this write -- read the file first, then retry.", + absolutePath, + ) +} + +/** + * Publish content only if the current on-disk version token equals + * expectedVersion (the token observed at read time). + * + * On a match the content is published via the S3 safeWriteText primitive; on + * a mismatch the write is rejected stale with a re-read-then-retry + * remediation suffix. + */ +export async function replaceIfVersion(absolutePath: string, expectedVersion: string, content: string): Promise { + let currentVersion: string + try { + currentVersion = await computeVersionToken(absolutePath) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + // The observed file was deleted after the read: the version recorded + // at read time no longer exists on disk. Normalize the raw ENOENT + // into the guard's re-read-then-retry contract so the caller gets a + // remediation it can act on, not a raw errno. + throw new GuardRejectedError( + "File was deleted after it was read -- the version recorded at read time (" + + expectedVersion + + ") no longer exists; re-read the file, then retry.", + absolutePath, + ) + } + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + + if (currentVersion === expectedVersion) { + await safeWriteText(absolutePath, content) + return + } + + throw new GuardRejectedError( + "Stale version -- the file changed since you read it (expected " + + expectedVersion + + ", current " + + currentVersion + + "); re-read the file, then retry.", + absolutePath, + ) +} + +/** + * Unobserved-edit guard: an edit-style write without a prior observation is + * rejected before any I/O. The literal-match / patch logic stays with the + * tools in S4b; this guard only verifies that a read happened first. + * + * Returns Promise because the rejection is total: this function + * never resolves. + */ +export async function unobservedEditGuard(absolutePath: string): Promise { + throw new GuardRejectedError("File not read yet -- read the file, then retry.", absolutePath) +} + +// -- Public API -------------------------------------------------------------- + +/** + * Resolve a relative or absolute path against task.cwd. + * + * path.resolve also normalizes an already-absolute input (collapsing "." / ".." + * segments and trailing separators), so the key always matches the + * ObservationRegistry key recorded at read time (ReadFileTool observes under + * path.resolve(task.cwd, relPath)) and two spellings of one file share one + * FIFO chain. + */ +function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { + return path.resolve(task.cwd, relPathOrAbsolute) +} + +/** + * Guarded write entry point. + * + * 1. Resolves the absolute path against task.cwd. + * 2. Consults the task's S2 observation registry to pick the guard: + * - unobserved + create/update: createIfAbsent (rejects if it exists); + * - observed + create on a file that vanished after the read: recreate; + * - observed otherwise: replaceIfVersion (CAS on the S1 version token); + * - unobserved + edit: unobservedEditGuard. + * 3. Runs the chosen guard on the per-path FIFO chain so concurrent writes to + * the same path are deterministically ordered. + */ +export async function guardedWrite( + task: Task, + relPathOrAbsolute: string, + content: string, + kind: GuardedWriteKind = "update", +): Promise { + const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) + + return enqueue(absolutePath, async () => { + const obs = task.observationRegistry.get(absolutePath) + + if (obs === undefined) { + // Edit-style writes require a prior read: no observation, no write. + if (kind === "edit") { + await unobservedEditGuard(absolutePath) + } + // Never read: only an absent target may be created. (The edit guard + // above rejects before reaching this line.) + await createIfAbsent(absolutePath, content) + return + } + + if (kind === "edit") { + await replaceIfVersion(absolutePath, obs.version, content) + return + } + + // kind is "create" or "update": a "create" on a file that vanished + // after the read recreates it; otherwise the version recorded at read + // time must still match the on-disk token. + if (kind === "create" && (await fileIsAbsent(absolutePath))) { + await createIfAbsent(absolutePath, content) + } else { + await replaceIfVersion(absolutePath, obs.version, content) + } + }) +} + +/** + * Reset the per-path tail-promise chains (test hook). + */ +export function resetChain(): void { + pendingChains.clear() +} diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 77680449be..5528f3b0b1 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -976,7 +976,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 98 + "count": 97 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 064207e21f..631fb9f810 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -4,6 +4,7 @@ import * as path from "path" import * as os from "os" import { safeWriteJson } from "../safeWriteJson" +import * as lockfile from "proper-lockfile" // Capture actual implementations before the vi.mock factory runs, // so they are never wrapped by vi.fn() — avoids infinite recursion when @@ -579,6 +580,82 @@ describe("safeWriteJson", () => { expect(await readFileContent(referentPath)).toEqual({ after: true }) }) + // proper-lockfile with realpath:false keys the lock by the given path, so a + // symlink alias and its referent must coordinate through ONE lock on the + // resolved referent — otherwise a concurrent merge through both aliases + // reads the same JSON and overwrites one update. (Real symlinks are + // unavailable in this CI lane, so the resolution is simulated by mocking + // fs.realpath, the same way as the staging test above.) + test("acquires the lock on the resolved referent, not the caller alias", async () => { + vi.resetModules() // fresh module instances so the doMock below is picked up + + const referentDir = path.join(tempDir, "lock-referent") + const linkDir = path.join(tempDir, "lock-link") + await fs.mkdir(referentDir, { recursive: true }) + await fs.mkdir(linkDir, { recursive: true }) + // caller-visible path (the link) vs the resolved referent path + const callerPath = path.join(linkDir, "locked.json") + const referentPath = path.join(referentDir, "locked.json") + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: 1 })) + + vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + + // Wrap the real lock in a capturing mock, and drive the two rare error paths + // (the onCompromised callback and a failing release) so they stay covered + // without real lockfile staleness. The callback rethrows by design, so + // the mock swallows that throw and lets the real lock proceed. + const realLockfile = await vi.importActual("proper-lockfile") + const lockMockFn = vi.fn( + async ( + file: Parameters[0], + options?: Parameters[1], + ) => { + try { + options?.onCompromised?.(new Error("lock compromised (test)")) + } catch { + // onCompromised rethrows by design; swallow so the real lock proceeds. + } + const release = await realLockfile.lock(file, options) + return async () => { + await release() + throw new Error("release failed (test)") + } + }, + ) + const lockMock = lockMockFn as unknown as typeof realLockfile.lock + vi.doMock("proper-lockfile", () => ({ + ...realLockfile, + lock: lockMock, + })) + + // Re-import safeWriteJson so it picks up the mocked proper-lockfile. + const { safeWriteJson: mockedSafeWriteJson } = await import("../safeWriteJson") + + const mergeFn = vi.fn((existing: unknown, incoming: unknown) => ({ + ...(existing as Record), + ...(incoming as Record), + })) + + // Capture the compromise + release-failure logs. + const consoleErrorSpy = vi.spyOn(console, "error") + await mockedSafeWriteJson(callerPath, { added: true }, { merge: mergeFn }) + + // The lock was keyed by the resolved referent — every alias shares it. + expect(lockMock).toHaveBeenCalledTimes(1) + expect(String(lockMockFn.mock.calls[0][0])).toBe(referentPath) + // The merge read the referent's content through that single lock. + expect(mergeFn).toHaveBeenCalledWith({ seed: 1 }, { added: true }) + expect(await readFileContent(referentPath)).toEqual({ seed: 1, added: true }) + // The compromise callback and the failed release were logged, not thrown. + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("was compromised"), expect.any(Error)) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to release lock"), + expect.any(Error), + ) + + vi.unmock("proper-lockfile") // Ensure the mock is removed after this test + }) + // CWE-732 regression: safeWriteJson stages the temp itself and passes it // via tempPath, so safeWriteText must apply the existing target's mode to // the staged temp before the atomic rename — otherwise a 0o600 target is diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 26af906b43..a9f837fc50 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -59,12 +59,22 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw dirError } + // Resolve the publish target BEFORE acquiring the lock: proper-lockfile keys + // the lock by the given path (realpath is false below because the file may + // not exist yet), so a symlink alias and its referent would otherwise take + // two distinct locks for one underlying file — a concurrent merge through + // both aliases could then read the same JSON and overwrite one update. + // Locking the resolved referent coordinates every alias through one lock. + // resolvePublishTarget tolerates a not-yet-existing file (it returns the + // given path on ENOENT), preserving the previous create-from-absent flow. + const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // Acquire the lock before any file operations try { - releaseLock = await lockfile.lock(absoluteFilePath, { + releaseLock = await lockfile.lock(resolvedTargetPath, { stale: LOCK_STALE_MS, update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long - realpath: false, // the file may not exist yet, which is acceptable + realpath: false, // resolvedTargetPath is already the referent; the file may still not exist yet, which is acceptable retries: { // Configuration for retrying lock acquisition retries: 5, // Number of retries after the initial attempt @@ -73,7 +83,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso maxTimeout: 1000, // Maximum time to wait for any single retry (in ms) }, onCompromised: (err) => { - console.error(`Lock at ${absoluteFilePath} was compromised:`, err) + console.error(`Lock at ${resolvedTargetPath} was compromised:`, err) throw err }, }) @@ -81,7 +91,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // If lock acquisition fails, we throw immediately. // The releaseLock remains a no-op, so the finally block in the main file operations // try-catch-finally won't try to release an unacquired lock if this path is taken. - console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) + console.error(`Failed to acquire lock for ${resolvedTargetPath}:`, lockError) throw lockError } @@ -95,7 +105,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso if (options?.merge) { let existing: unknown = null try { - existing = JSON.parse(await fs.readFile(absoluteFilePath, "utf8")) + existing = JSON.parse(await fs.readFile(resolvedTargetPath, "utf8")) } catch (error: unknown) { const code = error && typeof error === "object" && "code" in error ? (error as { code: string }).code : undefined @@ -108,9 +118,8 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Step 1: Write data to a new temporary file via JSON streaming. // Stage it beside the *resolved* target (the symlink referent when the path is - // a symlink): safeWriteText commits by renaming onto that referent, and a - // rename across filesystems would fail with EXDEV. - const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // a symlink; resolvedTargetPath above): safeWriteText commits by renaming + // onto that referent, and a rename across filesystems would fail with EXDEV. actualTempNewFilePath = path.join( path.dirname(resolvedTargetPath), ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", @@ -129,13 +138,13 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso backup: true, } - await safeWriteText(absoluteFilePath, "", textOptions) + await safeWriteText(resolvedTargetPath, "", textOptions) // If we reach here, the new file is successfully in place and any // backup has already been handled by safeWriteText. actualTempNewFilePath = null } catch (originalError) { - console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) + console.error(`Operation failed for ${resolvedTargetPath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath @@ -160,7 +169,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso try { await releaseLock() } catch (unlockError) { - console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) + console.error(`Failed to release lock for ${resolvedTargetPath}:`, unlockError) } } } From 56ce4bfe9031254665a284d074fbbb168737e918 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 10:00:29 +0800 Subject: [PATCH 07/39] fix(fws): use doUnmock + resetModules in safeWriteJson test cleanup The hoisted vi.unmock runs before the runtime vi.doMock, so it cannot remove that mock; both cleanup sites now use vi.doUnmock for proper-lockfile plus vi.resetModules() so a later dynamic import cannot reuse the cached mocked module (CodeRabbit finding on trial #1413). --- src/utils/__tests__/safeWriteJson.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 631fb9f810..a52cfef8b3 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -390,7 +390,10 @@ describe("safeWriteJson", () => { // Clean up await fs.unlink(lockTestFilePath).catch(() => {}) // Ignore errors if file doesn't exist - vi.unmock("proper-lockfile") // Ensure the mock is removed after this test + // A hoisted vi.unmock runs before this test's runtime vi.doMock, so it + // cannot remove it; doUnmock + resetModules clear the registry entry. + vi.doUnmock("proper-lockfile") + vi.resetModules() }) test("should release lock even if an error occurs mid-operation", async () => { const data = { message: "test lock release on error" } @@ -653,7 +656,11 @@ describe("safeWriteJson", () => { expect.any(Error), ) - vi.unmock("proper-lockfile") // Ensure the mock is removed after this test + // The hoisted vi.unmock runs before this test's runtime vi.doMock, so it + // cannot remove it; doUnmock + resetModules clear the registry entry so + // later test files import the real proper-lockfile. + vi.doUnmock("proper-lockfile") + vi.resetModules() }) // CWE-732 regression: safeWriteJson stages the temp itself and passes it From be894d9e085d8e98b59b1493e0fb4412b21b88a5 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 30 Aug 2026 12:33:26 +0800 Subject: [PATCH 08/39] =?UTF-8?q?chore(ci):=20empty=20commit=20=E2=80=94?= =?UTF-8?q?=20re-trigger=20CI=20and=20the=20CodeRabbit=20current-head=20re?= =?UTF-8?q?view=20gate=20(no=20code=20change)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 58bb5ce51e8bb26c9b0f5f1b4367fb13e532058b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 30 Aug 2026 17:31:30 +0800 Subject: [PATCH 09/39] fix(tools): address CodeRabbit review feedback (PR 1405) --- src/core/tools/__tests__/guardedWrite.spec.ts | 131 ++++++++++++++-- src/core/tools/guardedWrite.ts | 146 +++++++++++++++--- .../__tests__/safeWriteText.spec.ts | 86 +++++++++++ src/services/file-safety/safeWriteText.ts | 39 ++++- 4 files changed, 361 insertions(+), 41 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 122c1c6d9b..5fbe97b723 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -5,7 +5,9 @@ * Covers guard selection through the S2 observation registry, version-token * CAS, remediation messages, and the per-absolute-path FIFO chain: FIFO * ordering, exactly-one winner under concurrency, no wedge after a rejected - * link, and independence across paths. + * link, and independence across paths. It also covers the publication-time + * re-verification that closes the check-to-rename window for writers + * serialized by the chain. */ import * as fs from "fs/promises" @@ -14,7 +16,7 @@ import * as path from "path" import { describe, expect, it, beforeEach, vi } from "vitest" import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain } from "../guardedWrite" -import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { safeWriteText, type SafeWriteTextOptions } from "../../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../../utils/versionToken" import { ObservationRegistry } from "../../task/observationRegistry" import type { Task } from "../../task/Task" @@ -79,7 +81,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "new-file.txt", "hello", "create") expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello", { + verifyBeforeCommit: expect.any(Function), + }) }) it("fails with the read-first remediation when the file exists - nothing published", async () => { @@ -136,7 +140,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "new-file.txt", "hello", "update") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello", { + verifyBeforeCommit: expect.any(Function), + }) }) it("fails with the read-first remediation when the file exists - nothing published", async () => { @@ -162,7 +168,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "gone.txt", "back", "create") expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back", { + verifyBeforeCommit: expect.any(Function), + }) }) it("goes through the version guard when the file still exists", async () => { @@ -174,7 +182,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "kept.txt", "rewritten", "create") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten", { + verifyBeforeCommit: expect.any(Function), + }) }) it("fails with the stale remediation suffix when the version moved", async () => { @@ -214,7 +224,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "new content", "update") expect(mockedComputeVersionToken).toHaveBeenCalledWith(abs("doc.txt")) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content", { + verifyBeforeCommit: expect.any(Function), + }) }) it("fails with the stale remediation suffix when the version moved - nothing published", async () => { @@ -250,7 +262,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "doc.txt", "patched", "edit") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched", { + verifyBeforeCommit: expect.any(Function), + }) }) it("fails with the stale remediation suffix when the version moved", async () => { @@ -335,7 +349,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { await expect(p2).resolves.toBeUndefined() expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second", { + verifyBeforeCommit: expect.any(Function), + }) }) it("evicts settled chain entries - a later write still serializes in order", async () => { @@ -380,6 +396,91 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) + describe("publication-time re-verification (check-to-rename window)", () => { + /** + * Drive a simulated race: the mocked publish primitive behaves like + * safeWriteText and invokes the pre-commit verification immediately + * before the commit rename. The "external writer" acts in that window + * (after the guard's entry check, before the pre-commit re-verification) + * by changing the mocked on-disk state. Returns a published() probe. + */ + const mockPublishWithRace = (mutate: () => void): (() => boolean) => { + let published = false + mockedSafeWriteText.mockImplementation( + async (_path: string, _content: string, options?: SafeWriteTextOptions) => { + mutate() + await options?.verifyBeforeCommit?.() + published = true + }, + ) + return () => published + } + + it("createIfAbsent rejects when an external writer creates the file between verification and publication", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) // absent at entry + const task = createMockTask() + const published = mockPublishWithRace(() => { + // -- the race: an external writer publishes first ------------------ + mockedFsAccess.mockResolvedValue(undefined) // the file now exists + }) + + await expect(guardedWrite(task, "raced.txt", "mine", "create")).rejects.toThrow( + "File already exists at " + + abs("raced.txt") + + " and was not read before this write -- read the file first, then retry.", + ) + // nothing was published: the competing writer's file is preserved + expect(published()).toBe(false) + // entry check + pre-commit re-check + expect(mockedFsAccess).toHaveBeenCalledTimes(2) + }) + + it("replaceIfVersion rejects when an external writer modifies the file between verification and publication", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("raced.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") // matches at entry + const task = createMockTask({ observationRegistry: reg }) + const published = mockPublishWithRace(() => { + // -- the race: an external writer rewrites the file ---------------- + mockedComputeVersionToken.mockResolvedValue("v-external") + }) + + await expect(guardedWrite(task, "raced.txt", "mine", "update")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v-external); re-read the file, then retry.", + ) + expect(published()).toBe(false) + }) + + it("replaceIfVersion rejects deleted-after-read when the file is deleted between verification and publication", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("raced.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + const published = mockPublishWithRace(() => { + // -- the race: an external writer deletes the file ----------------- + mockedComputeVersionToken.mockRejectedValue({ code: "ENOENT" }) + }) + + await expect(guardedWrite(task, "raced.txt", "mine", "update")).rejects.toThrow( + "File was deleted after it was read", + ) + expect(published()).toBe(false) + }) + + it("rethrows non-guard I/O failures from the pre-commit verification verbatim", async () => { + const failure = { code: "EACCES" } + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) // absent at entry + const task = createMockTask() + const published = mockPublishWithRace(() => { + // the pre-commit re-check hits a real I/O failure, not a guard verdict + mockedFsAccess.mockRejectedValue(failure) + }) + + await expect(guardedWrite(task, "io-race.txt", "x", "create")).rejects.toBe(failure) + expect(published()).toBe(false) + }) + }) + describe("path resolution", () => { it("resolves a relative path against task.cwd", async () => { const reg = new ObservationRegistry() @@ -389,7 +490,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "sub/dir.txt", "content", "update") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content") + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content", { + verifyBeforeCommit: expect.any(Function), + }) }) it("normalizes an already-absolute input (trailing separator) to the observation key", async () => { @@ -406,7 +509,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, canonical + "/", "content", "update") expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) - expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content") + expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content", { + verifyBeforeCommit: expect.any(Function), + }) }) it("serializes two spellings of one file through a single chain key", async () => { @@ -448,7 +553,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { resetChain() await guardedWrite(task, "x.txt", "b", "update") - expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b") + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b", { + verifyBeforeCommit: expect.any(Function), + }) }) }) }) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 596cc41035..4e4cb9af40 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -13,6 +13,22 @@ * A per-absolute-path FIFO chain of tail promises orders concurrent * in-process writes to the same path: the first matching write wins, the rest * fail stale. Observations come from the task's S2 ObservationRegistry. + * + * Check-to-publication window (CodeRabbit review, PRs #1405 / #1413): every + * guard predicate is enforced TWICE -- once at entry and once at publication + * time. The publication-time re-verification runs inside safeWriteText + * immediately before the atomic commit rename (its verifyBeforeCommit + * option), while the per-path FIFO chain holds the serialization across the + * whole verify+publish window, so the predicate is re-checked against the + * state the rename will actually replace and concurrent in-process writers + * stay fully ordered. + * + * Residual cross-process window: an external writer (another process) can + * still create or modify the target in the short interval between the + * pre-commit re-verification and the commit rename. A cross-platform atomic + * conditional publication would require an OS-level primitive beyond + * fs.promises (or a shared lock protocol every writer honors) and is tracked + * as a follow-up of epic #1375. */ import * as fs from "fs/promises" @@ -104,9 +120,47 @@ async function fileIsAbsent(absolutePath: string): Promise { } } +/** Build the read-first remediation error for an existing target. */ +function alreadyExistsError(absolutePath: string): GuardRejectedError { + return new GuardRejectedError( + "File already exists at " + + absolutePath + + " and was not read before this write -- read the file first, then retry.", + absolutePath, + ) +} + +/** Build the stale-version remediation error. */ +function staleVersionError(absolutePath: string, expectedVersion: string, currentVersion: string): GuardRejectedError { + return new GuardRejectedError( + "Stale version -- the file changed since you read it (expected " + + expectedVersion + + ", current " + + currentVersion + + "); re-read the file, then retry.", + absolutePath, + ) +} + +/** Build the deleted-after-read remediation error. */ +function deletedAfterReadError(absolutePath: string, expectedVersion: string): GuardRejectedError { + return new GuardRejectedError( + "File was deleted after it was read -- the version recorded at read time (" + + expectedVersion + + ") no longer exists; re-read the file, then retry.", + absolutePath, + ) +} + /** * Publish content only if the target file does not exist. * + * The absence predicate is enforced at entry AND at publication time: the + * pre-commit re-check (verifyBeforeCommit, run by safeWriteText immediately + * before the commit rename) rejects with the same remediation when an + * external writer created the file in the check-to-rename window, instead of + * overwriting it. + * * Rejects with a loud remediation error when the file already exists: the * write was issued for a file that was never read, so the caller must read * the file first, then retry. @@ -119,22 +173,45 @@ export async function createIfAbsent(absolutePath: string, content: string): Pro // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. throw error } - await safeWriteText(absolutePath, content) + // Absent at entry. safeWriteText re-verifies absence at the last + // moment before the commit rename (see verifyStillAbsent) so a writer + // that creates the file in the check-to-rename window is rejected, + // not overwritten. + await safeWriteText(absolutePath, content, { + verifyBeforeCommit: () => verifyStillAbsent(absolutePath), + }) return } - throw new GuardRejectedError( - "File already exists at " + - absolutePath + - " and was not read before this write -- read the file first, then retry.", - absolutePath, - ) + throw alreadyExistsError(absolutePath) +} + +/** + * Pre-commit absence check for createIfAbsent (publication-time re- + * verification): rejects with the standard read-first remediation when the + * target exists at publication time. ENOENT (still absent) passes; any other + * I/O error is rethrown verbatim (a real failure, not a guard verdict). + */ +async function verifyStillAbsent(absolutePath: string): Promise { + try { + await fs.access(absolutePath) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") return + throw error + } + throw alreadyExistsError(absolutePath) } /** * Publish content only if the current on-disk version token equals * expectedVersion (the token observed at read time). * + * The version predicate is enforced at entry AND at publication time: the + * pre-commit re-check (verifyBeforeCommit, run by safeWriteText immediately + * before the commit rename) rejects stale with the same remediation when an + * external writer modified the file in the check-to-rename window, instead + * of overwriting it. + * * On a match the content is published via the S3 safeWriteText primitive; on * a mismatch the write is rejected stale with a re-read-then-retry * remediation suffix. @@ -149,30 +226,45 @@ export async function replaceIfVersion(absolutePath: string, expectedVersion: st // at read time no longer exists on disk. Normalize the raw ENOENT // into the guard's re-read-then-retry contract so the caller gets a // remediation it can act on, not a raw errno. - throw new GuardRejectedError( - "File was deleted after it was read -- the version recorded at read time (" + - expectedVersion + - ") no longer exists; re-read the file, then retry.", - absolutePath, - ) + throw deletedAfterReadError(absolutePath, expectedVersion) } // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. throw error } - if (currentVersion === expectedVersion) { - await safeWriteText(absolutePath, content) - return + if (currentVersion !== expectedVersion) { + throw staleVersionError(absolutePath, expectedVersion, currentVersion) } - throw new GuardRejectedError( - "Stale version -- the file changed since you read it (expected " + - expectedVersion + - ", current " + - currentVersion + - "); re-read the file, then retry.", - absolutePath, - ) + // Match at entry. safeWriteText re-verifies the token at the last moment + // before the commit rename (see verifyVersionUnchanged) so a writer that + // modifies the file in the check-to-rename window is rejected stale, not + // overwritten. + await safeWriteText(absolutePath, content, { + verifyBeforeCommit: () => verifyVersionUnchanged(absolutePath, expectedVersion), + }) +} + +/** + * Pre-commit version check for replaceIfVersion (publication-time re- + * verification): rejects with the standard stale / deleted-after-read + * remediation when the on-disk token no longer matches expectedVersion at + * publication time. Any other I/O error is rethrown verbatim (a real failure, + * not a guard verdict). + */ +async function verifyVersionUnchanged(absolutePath: string, expectedVersion: string): Promise { + let currentVersion: string + try { + currentVersion = await computeVersionToken(absolutePath) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + throw deletedAfterReadError(absolutePath, expectedVersion) + } + throw error + } + if (currentVersion !== expectedVersion) { + throw staleVersionError(absolutePath, expectedVersion, currentVersion) + } } /** @@ -212,7 +304,11 @@ function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { * - observed otherwise: replaceIfVersion (CAS on the S1 version token); * - unobserved + edit: unobservedEditGuard. * 3. Runs the chosen guard on the per-path FIFO chain so concurrent writes to - * the same path are deterministically ordered. + * the same path are deterministically ordered. The chain holds the + * serialization across the whole verify+publish window, and the guard's + * publication-time re-verification (inside safeWriteText, immediately + * before the commit rename) closes the check-to-rename window for writers + * serialized by the chain. */ export async function guardedWrite( task: Task, diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 4accc2b71e..ddeeff75cb 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -611,4 +611,90 @@ describe("safeWriteText", () => { expect(fs.rename).not.toHaveBeenCalled() }) }) + + // ── Test 9: pre-commit verification (verifyBeforeCommit, A4a guarded write) ── + + describe("pre-commit verification (verifyBeforeCommit)", () => { + it("rejects publication when the hook fails: no commit rename, temp discarded, error propagated", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const guard = new Error("stale version") + + await expect( + safeWriteText(targetPath, "data", { + platform: "linux", + verifyBeforeCommit: async () => { + throw guard + }, + }), + ).rejects.toBe(guard) + + // the commit rename never happened + expect(fs.rename).not.toHaveBeenCalled() + // the staged temp was discarded + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("runs the hook exactly once, immediately before the commit rename", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const hook = vi.fn(async () => {}) + const order: string[] = [] + vi.mocked(fs.rename).mockImplementation(async () => { + order.push("rename") + }) + + await safeWriteText(targetPath, "data", { + platform: "linux", + verifyBeforeCommit: async () => { + order.push("verify") + return hook() + }, + }) + + expect(hook).toHaveBeenCalledTimes(1) + expect(order).toEqual(["verify", "rename"]) + expect(fs.rename).toHaveBeenCalledTimes(1) + }) + + it("publishes normally when the hook succeeds", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { + platform: "linux", + verifyBeforeCommit: async () => {}, + }) + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("rolls the backup back to the target when the hook fails after the backup rename (backup:true)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const guard = new Error("stale") + + await expect( + safeWriteText(targetPath, "data", { + backup: true, + platform: "linux", + verifyBeforeCommit: async () => { + throw guard + }, + }), + ).rejects.toBe(guard) + + // rename 1: target -> backup (step 3); rename 2: backup -> target + // (rollback in the catch) -- the commit rename never happened + expect(fs.rename).toHaveBeenCalledTimes(2) + expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText.bak_"), targetPath) + // the staged temp was discarded + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + }) }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 71871032e5..e71504be39 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -35,6 +35,23 @@ export interface SafeWriteTextOptions { * already written data to a temp file via a custom stream. */ tempPath?: string + + /** + * Pre-commit verification hook (A4a guarded write, epic #1375). Invoked + * at the last moment before the commit rename (after any backup rename + * has moved the target aside) so a caller can re-check the target's + * state and reject publication when it changed since the caller's + * earlier verification. When the hook rejects, no commit rename is + * performed: the backup (if any) is rolled back to the target and the + * staged temp file is discarded, and the hook's rejection is propagated + * to the caller. + * + * Scope: the hook closes the check-to-rename window for writers that the + * caller serializes (guardedWrite's per-path FIFO chain); external + * processes may still publish in the residual window between the hook + * and the rename (documented in guardedWrite). + */ + verifyBeforeCommit?: () => Promise } // -- helpers --------------------------------------------------------------- @@ -112,10 +129,12 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu * 2. fsync the temp file, then close it. * 3. win32 only: if target exists save its DACL dump BEFORE backup rename. * 4. Optionally rename target -> backup (when backup:true). - * 5. Atomic rename temp -> target. - * 6. win32 only: restore DACL onto the directory AFTER commit rename. - * 7. On success: delete backup (if any) and unlink DACL dump. - * 8. On failure: rollback backup to target path; clean up temp + dump. + * 5. Optionally run the pre-commit verification hook (verifyBeforeCommit); + * a rejection aborts the publish (no commit rename) and propagates. + * 6. Atomic rename temp -> target. + * 7. win32 only: restore DACL onto the directory AFTER commit rename. + * 8. On success: delete backup (if any) and unlink DACL dump. + * 9. On failure: rollback backup to target path; clean up temp + dump. */ /** @@ -241,6 +260,18 @@ export async function safeWriteText(filePath: string, content: string, options?: } } + // -- Step 3b (A4a): pre-commit verification -------------------------- + // Run at the last moment before the commit rename so a conditional + // publication (guardedWrite's createIfAbsent / replaceIfVersion) + // re-validates the target state against what the rename will replace. + // A rejection skips the commit rename: the catch below rolls the + // backup (if any) back to the target and discards the staged temp. + // NOTE: with backup:true the target was already moved aside by step 3, + // so the hook observes the post-backup state. + if (options?.verifyBeforeCommit) { + await options.verifyBeforeCommit() + } + // -- Step 4: atomic rename temp -> target --------------------- await fs.rename(tempPath, targetPath) From 4d4d5113408f00ce61a79cecdab888590a21cf5c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 05:37:18 +0800 Subject: [PATCH 10/39] fix(file-safety): run verifyBeforeCommit before the backup rename, and correct the observation docstring With backup:true the target was renamed to the backup path before the hook ran, so the hook always observed an absent target: a version-check hook (guardedWrite's replaceIfVersion) would see ENOENT and fail every write, and an absence-check hook (createIfAbsent) would pass vacuously even when the target had existed. The hook now runs before the target is moved aside, so it validates the state the commit rename will actually replace - and a rejection happens before any backup exists, so there is nothing to roll back. The observationRegistry module docstring still claimed observations were 'recorded but not consulted' with 'no behavior change'; guardedWrite reads the registry on every guarded publish, so the comment described a contract that no longer exists. Tests: the backup:true hook test now asserts no rename at all on rejection (previously target->backup plus a rollback rename), and a new test makes fs.access report ENOENT once the target has been moved aside and checks that a hook reading the target still succeeds - it fails on the pre-fix ordering. 2 failed / 29 passed without the fix, 31 passed with it. Local: eslint clean on all three files with --prune-suppressions (no suppression change), package tsc clean. --- src/core/task/observationRegistry.ts | 6 ++- .../__tests__/safeWriteText.spec.ts | 43 ++++++++++++++++--- src/services/file-safety/safeWriteText.ts | 29 +++++++------ 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts index 871f80225b..7ca7c801aa 100644 --- a/src/core/task/observationRegistry.ts +++ b/src/core/task/observationRegistry.ts @@ -5,8 +5,10 @@ * independent. The S4 guarded-write will compare these versions against the * token recomputed pre-write to detect stale reads or file replacement. * - * Pure in-memory — zero I/O, no dependencies. No behavior change in this PR: - * observations are recorded but not consulted. + * Pure in-memory — zero I/O, no dependencies. The observations ARE consulted: + * guardedWrite reads this registry before publishing (src/core/tools/guardedWrite.ts) + * and compares the recorded version token against the token recomputed from disk, so + * a stale read or an out-of-band replacement is rejected instead of published over. */ export interface FileObservation { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index ddeeff75cb..eae3807747 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -672,7 +672,7 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) - it("rolls the backup back to the target when the hook fails after the backup rename (backup:true)", async () => { + it("runs the hook before the backup rename, so a rejection leaves the target in place (backup:true)", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -688,13 +688,44 @@ describe("safeWriteText", () => { }), ).rejects.toBe(guard) - // rename 1: target -> backup (step 3); rename 2: backup -> target - // (rollback in the catch) -- the commit rename never happened - expect(fs.rename).toHaveBeenCalledTimes(2) - expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) - expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText.bak_"), targetPath) + // No rename at all: the hook runs before the target is moved aside, so a + // rejection never creates the moved-aside state that needed a rollback. + expect(fs.rename).not.toHaveBeenCalled() // the staged temp was discarded expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) + + it("with backup:true the hook observes the target, not the post-backup absence", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // Simulate the on-disk truth: once the target has been renamed to the backup + // path, accessing it reports ENOENT. + let movedAside = false + vi.mocked(fs.rename).mockImplementation(async (from) => { + if (String(from) === targetPath) movedAside = true + return undefined + }) + vi.mocked(fs.access).mockImplementation(async (p) => { + if (String(p) === targetPath && movedAside) { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + } + return undefined + }) + + await safeWriteText(targetPath, "data", { + backup: true, + platform: "linux", + // A version-check hook reads the target - exactly what a replaceIfVersion + // guard does. After the backup rename it would see ENOENT and fail every + // write, and an absence-check hook would pass vacuously. + verifyBeforeCommit: async () => { + await fs.access(targetPath) + }, + }) + + // The backup rename did happen - after the hook ran. + expect(movedAside).toBe(true) + }) }) }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index e71504be39..e5aaaec127 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -129,7 +129,8 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu * 2. fsync the temp file, then close it. * 3. win32 only: if target exists save its DACL dump BEFORE backup rename. * 4. Optionally rename target -> backup (when backup:true). - * 5. Optionally run the pre-commit verification hook (verifyBeforeCommit); + * 5. Optionally run the pre-commit verification hook (verifyBeforeCommit) before + * the target is moved aside, so it observes the state the rename replaces; * a rejection aborts the publish (no commit rename) and propagates. * 6. Atomic rename temp -> target. * 7. win32 only: restore DACL onto the directory AFTER commit rename. @@ -244,7 +245,19 @@ export async function safeWriteText(filePath: string, content: string, options?: } try { - // -- Step 3 (backup:true): rename target -> backup -------------- + // -- Step 3a (A4a): pre-commit verification -------------------------- + // Runs before the target is moved aside, so a conditional publication + // (guardedWrite's createIfAbsent / replaceIfVersion) validates against the state + // the commit rename will actually replace. Running it after the backup rename + // would make the target look absent: a version check would fail with ENOENT and + // an absence check would pass vacuously. A rejection skips the commit rename and + // discards the staged temp; no backup has been taken yet, so there is nothing to + // roll back. + if (options?.verifyBeforeCommit) { + await options.verifyBeforeCommit() + } + + // -- Step 3b (backup:true): rename target -> backup -------------- if (options?.backup) { try { await fs.access(targetPath) @@ -260,18 +273,6 @@ export async function safeWriteText(filePath: string, content: string, options?: } } - // -- Step 3b (A4a): pre-commit verification -------------------------- - // Run at the last moment before the commit rename so a conditional - // publication (guardedWrite's createIfAbsent / replaceIfVersion) - // re-validates the target state against what the rename will replace. - // A rejection skips the commit rename: the catch below rolls the - // backup (if any) back to the target and discards the staged temp. - // NOTE: with backup:true the target was already moved aside by step 3, - // so the hook observes the post-backup state. - if (options?.verifyBeforeCommit) { - await options.verifyBeforeCommit() - } - // -- Step 4: atomic rename temp -> target --------------------- await fs.rename(tempPath, targetPath) From 1ce0421e981a7dd6bb27342a2bd1e32be898b242 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 10:33:20 +0800 Subject: [PATCH 11/39] test(file-safety): cover a staging-file fsync failure The durability step can fail on its own, not only between fsync and rename. When the staged bytes never reached the disk, publishing them would put content at the target that a crash can lose, so the publish must abort. The case makes fsyncSync throw for the staged file and asserts: the call rejects with that error, the commit rename never runs, the fd is closed, and the staged temp is released. --- .../__tests__/safeWriteText.spec.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index eae3807747..b94985e6a1 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -163,6 +163,27 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledTimes(1) }) + it("a staging-file fsync failure aborts the publish and leaves the target untouched", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The durability step itself fails: the staged bytes never reached the disk, so + // publishing them would put content at the target that a crash can lose. + vi.mocked(fsSync.fsyncSync).mockImplementationOnce(() => { + throw new Error("EIO") + }) + + await expect(safeWriteText(targetPath, "new data", { platform: "linux" })).rejects.toThrow("EIO") + + // No commit: the rename that publishes the staged file never ran, so the target + // still holds whatever it held before the call. + expect(fs.rename).not.toHaveBeenCalled() + + // The fd is closed and the staged temp released. + expect(fsSync.closeSync).toHaveBeenCalledWith(1) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + it("a post-commit backup cleanup failure is non-fatal: the target stays committed and no temp is left behind", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) From 968078d4a8b44f9e4015b18be8eb3efa9d5ed9fc Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:49:17 +0000 Subject: [PATCH 12/39] chore: trigger a fresh review pass at this head From a2c364dc95c189bc0b805befea58e9e63f93fcd7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 15:23:35 +0800 Subject: [PATCH 13/39] docs(task): state the cross-process limit of the observation check The registry header implied that every out-of-band replacement is rejected. Only a replacement the pre-publish token comparison detects is: a non-cooperating process can still replace the target after that check and before the rename, which no in-process token check can observe. Document the guarantee that holds and the race that remains, so the contract matches the implementation. --- src/core/task/observationRegistry.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts index 7ca7c801aa..e35d7aff9f 100644 --- a/src/core/task/observationRegistry.ts +++ b/src/core/task/observationRegistry.ts @@ -7,8 +7,12 @@ * * Pure in-memory — zero I/O, no dependencies. The observations ARE consulted: * guardedWrite reads this registry before publishing (src/core/tools/guardedWrite.ts) - * and compares the recorded version token against the token recomputed from disk, so - * a stale read or an out-of-band replacement is rejected instead of published over. + * and compares the recorded version token against the token recomputed from disk, so a + * stale read or an out-of-band replacement that the check detects is rejected instead of + * published over. Detection is best effort against a non-cooperating process: the token is + * recomputed before the publish, so a replacement that lands after that check and before + * the rename is not observable from here and can still win. Closing that last window needs + * a cross-process lock or an atomic create, not a token comparison. */ export interface FileObservation { From 9f8a857e85c95338a8c7c24b996adb1d4782d8f5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 15:31:12 +0800 Subject: [PATCH 14/39] fix(utils): refuse to publish a settings export through a symlink safeWriteJson resolves a symlink target so every alias of one file shares a single advisory lock. For a payload whose destination the user chose - the settings export carries provider profiles and API credentials - following a link they never pointed at writes secrets into a file they did not pick, and the previous pathname-replacement behaviour no longer held. Add SafeWriteJsonOptions.refuseSymlinkTarget: when set, a symlink at the final path component is rejected before anything is resolved, staged, locked, or committed. exportSettings opts in. A missing target is still allowed (the create-from-absent flow is unchanged); any other lstat failure is surfaced rather than treated as "no link". --- src/core/config/importExport.ts | 2 +- src/utils/__tests__/safeWriteJson.test.ts | 31 ++++++++++++++++++++ src/utils/safeWriteJson.ts | 35 +++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 3c213fedf4..906640a79c 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -344,7 +344,7 @@ export const exportSettings = async ({ providerSettingsManager, contextProxy }: const dirname = path.dirname(uri.fsPath) await fs.mkdir(dirname, { recursive: true }) - await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings }) + await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings }, { refuseSymlinkTarget: true }) } catch (e) { console.error("Failed to export settings:", e) // Don't re-throw - the UI will handle showing error messages diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index a52cfef8b3..9e51ef2b8d 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -679,4 +679,35 @@ describe("safeWriteJson", () => { expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) }, ) + // A settings export carries API credentials, so it must not be redirected through + // a link the user never chose. (Real symlinks are unavailable in this CI lane, so + // the link is simulated by mocking fs.lstat.) + test("refuses to publish through a symlink when refuseSymlinkTarget is set", async () => { + const referentPath = path.join(tempDir, "refuse-referent.json") + const linkPath = path.join(tempDir, "refuse-link.json") + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: "untouched" })) + + vi.spyOn(fs, "lstat").mockResolvedValue({ + isSymbolicLink: () => true, + // The guard reads only isSymbolicLink(), and a real Stats cannot be + // produced for a simulated link in this CI lane, so the double is + // asserted through unknown rather than stubbing every Stats field. + } as unknown as fsSyncActual.Stats) + + await expect( + safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true }), + ).rejects.toThrow(/refusing to write through the symlink/) + + vi.restoreAllMocks() + // Nothing was resolved, staged, locked, or committed: the referent still holds + // the content it had before the refused write. + expect(await readFileContent(referentPath)).toEqual({ seed: "untouched" }) + }) + + test("still writes a regular file when refuseSymlinkTarget is set", async () => { + const target = path.join(tempDir, "refuse-regular.json") + await safeWriteJson(target, { written: true }, { refuseSymlinkTarget: true }) + expect(await readFileContent(target)).toEqual({ written: true }) + }) + }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index e66fa82202..2da3b0dd7f 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -30,6 +30,18 @@ export interface SafeWriteJsonOptions { * cannot be parsed. */ merge?: (existing: unknown, incoming: unknown) => unknown + + /** + * Refuse to publish through a symlink at the target path. + * + * By default a symlink target is resolved and the write lands on its referent, + * which is what keeps every alias of one file behind a single advisory lock. + * That is the wrong default for a payload whose destination the user chose - + * settings exports carry API credentials - where following a link they never + * pointed at would write secrets into a file they did not pick. When this is + * set, a symlink at the final path component is an error instead. + */ + refuseSymlinkTarget?: boolean } /** @@ -62,6 +74,29 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw dirError } + // A credential-bearing payload must not be redirected through a link the user + // never chose: check the final path component before anything is resolved, + // staged, or locked. + if (options?.refuseSymlinkTarget) { + let targetStat: fsSync.Stats | undefined + try { + targetStat = await fs.lstat(absoluteFilePath) + } catch (error: unknown) { + const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined + // Only a missing target means there is no link to refuse. Anything else - + // a permission error on the parent directory, for example - is not evidence + // that the destination is safe to publish into. + if (code !== "ENOENT") { + throw error + } + } + if (targetStat?.isSymbolicLink()) { + throw new Error( + `safeWriteJson: refusing to write through the symlink at ${absoluteFilePath}; the payload would land on its referent instead of the destination the user chose.`, + ) + } + } + // Resolve the publish target BEFORE acquiring the lock: proper-lockfile keys // the lock by the given path, so a symlink alias and its referent would // otherwise take two distinct locks for one underlying file - a concurrent From cb72ac7f6dd6e564c1a236265490ef645cffa965 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 15:35:03 +0800 Subject: [PATCH 15/39] test(config): expect the export write to refuse symlink targets exportSettings now passes { refuseSymlinkTarget: true } to safeWriteJson, so the seven export assertions that pinned the two-argument call no longer match. Pass the same options object in the expectation instead of loosening the matcher, so the credential-export call site keeps an assertion on the guard it depends on. --- src/core/config/__tests__/importExport.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 0287b26511..c1df4dbd24 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -1558,7 +1558,7 @@ describe("importExport", () => { expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, - }) + }, { refuseSymlinkTarget: true }) }) it("should include globalSettings when allowedMaxRequests is null", async () => { @@ -1590,7 +1590,7 @@ describe("importExport", () => { expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, - }) + }, { refuseSymlinkTarget: true }) }) it("should handle errors during the export process", async () => { @@ -1713,7 +1713,7 @@ describe("importExport", () => { expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, - }) + }, { refuseSymlinkTarget: true }) }) it("should export model dimension for OpenAI Compatible provider", async () => { @@ -1866,7 +1866,7 @@ describe("importExport", () => { expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, // Should remain unchanged - }) + }, { refuseSymlinkTarget: true }) }) it("should maintain backward compatibility with existing exports", async () => { @@ -1909,7 +1909,7 @@ describe("importExport", () => { expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, // Should remain unchanged - }) + }, { refuseSymlinkTarget: true }) }) it("should handle missing current provider gracefully", async () => { @@ -1954,7 +1954,7 @@ describe("importExport", () => { expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, // Should remain unchanged - }) + }, { refuseSymlinkTarget: true }) }) }) @@ -2191,7 +2191,7 @@ describe("importExport", () => { expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/test-settings.json", { providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings, - }) + }, { refuseSymlinkTarget: true }) // Step 5: Get the exported data for import test const exportedData = (safeWriteJson as Mock).mock.calls[0][1] From 8cb59207a9eaaa078f01d27a9a73e04f36afad02 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 15:36:51 +0800 Subject: [PATCH 16/39] fix(file-safety): keep the target mode, clean the staging dir, correct the hook contract Three review findings on the guarded-write path: - The staged temp was created with openSync(tempPath, "w", targetMode), and openSync applies the process umask: a 0o664 or 0o666 target was published as 0o644, dropping group write on every agent save. Set the mode on the descriptor with fchmodSync, as the caller-staged branch already did. - The default path left a hidden .file-safety-staging directory in every directory Zoo writes to, visible in the explorer and picked up by watchers and indexers. Remove it when the write finishes: rmdirSync fails on a non-empty directory, so a concurrent writer still staging there keeps it and the last writer to finish cleans up - no shared bookkeeping needed. Applied on both the success and the rollback path, and never for a caller-supplied tempPath, where no staging directory was ours to create. - The verifyBeforeCommit JSDoc and the step list still described the pre-4d4d51134 order: the hook runs BEFORE the backup rename, so a rejection has no backup to roll back. Corrected both, and softened "closes the window" to "narrows it". --- .../__tests__/safeWriteText.spec.ts | 60 +++++++++++++++ src/services/file-safety/safeWriteText.ts | 73 ++++++++++++++----- 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index b94985e6a1..c0617d0dde 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -25,6 +25,7 @@ vi.mock("fs", () => ({ fsyncSync: vi.fn(), chmodSync: vi.fn(), fchmodSync: vi.fn(), + rmdirSync: vi.fn(), statSync: vi.fn(), Stats: class Stats {}, })) @@ -541,6 +542,65 @@ describe("safeWriteText", () => { expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) }) + // The staging sub-directory must not outlive the write: a hidden directory left + // in every directory Zoo writes to shows up in the explorer, watchers and indexers. + it("removes the staging directory it created once the write commits", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + + await safeWriteText(targetPath, "content", { platform: "linux" }) + + expect(fsSync.rmdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) + + // A caller that staged its own temp file never had a staging directory created, + // so there is nothing of ours to remove - and removing a directory we did not + // create could delete one a concurrent writer is still using. + it("leaves no staging directory to remove when the caller supplied the tempPath", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + + await safeWriteText(targetPath, "", { tempPath: "/tmp/test-dir/.staged.json", platform: "linux" }) + + expect(fsSync.mkdirSync).not.toHaveBeenCalledWith( + expect.stringContaining(".file-safety-staging"), + expect.anything(), + ) + expect(fsSync.rmdirSync).not.toHaveBeenCalled() + }) + + // openSync applies the process umask to the requested mode, so the mode has to be + // set on the descriptor: a 0o664 target must not be published as 0o644. + it("sets the staged mode with fchmodSync so the process umask cannot narrow it", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(7) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o664)) + + await safeWriteText(targetPath, "content", { platform: "linux" }) + + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o664) + expect(fsSync.fchmodSync).toHaveBeenCalledWith(7, 0o664) + }) + + // The failure path unlinks the staged temp, which empties the staging directory: + // it must be removed there too, or a failed write leaves the hidden directory. + it("removes the staging directory when the commit fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + vi.mocked(fs.rename).mockRejectedValueOnce(Object.assign(new Error("EXDEV"), { code: "EXDEV" })) + + await expect(safeWriteText(targetPath, "content", { platform: "linux" })).rejects.toThrow("EXDEV") + + expect(fsSync.rmdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) + it("loops on short writes until the full content is durable before fsync", async () => { const targetPath = "/tmp/test-dir/target.txt" const content = "0123456789" // 10 bytes diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index e5aaaec127..71fd2f3bec 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -38,18 +38,17 @@ export interface SafeWriteTextOptions { /** * Pre-commit verification hook (A4a guarded write, epic #1375). Invoked - * at the last moment before the commit rename (after any backup rename - * has moved the target aside) so a caller can re-check the target's - * state and reject publication when it changed since the caller's - * earlier verification. When the hook rejects, no commit rename is - * performed: the backup (if any) is rolled back to the target and the - * staged temp file is discarded, and the hook's rejection is propagated - * to the caller. + * immediately before the commit rename and BEFORE any backup rename moves the + * target aside, so a caller can re-check the target's state and reject + * publication when it changed since the caller's earlier verification. When the + * hook rejects, no commit rename is performed and no backup has been taken yet, + * so there is nothing to roll back: the staged temp file is discarded and the + * hook's rejection is propagated to the caller. * - * Scope: the hook closes the check-to-rename window for writers that the - * caller serializes (guardedWrite's per-path FIFO chain); external - * processes may still publish in the residual window between the hook - * and the rename (documented in guardedWrite). + * Scope: the hook narrows - it does not close - the check-to-rename window for + * writers that the caller serializes (guardedWrite's per-path FIFO chain); an + * external process can still publish between the hook and the rename + * (documented in guardedWrite). */ verifyBeforeCommit?: () => Promise } @@ -78,6 +77,19 @@ function _stagingDir(dir: string): string { return sd } +/** Remove the staging sub-directory when nothing is staged in it any more. + * rmdirSync fails on a non-empty directory (a concurrent write is still using it) + * and on a directory that is already gone, so the last write to finish cleans up + * and the others leave it to that writer - no shared bookkeeping is needed, and a + * persistent hidden directory is never left in the user's workspace. */ +function _removeStagingDirIfEmpty(dir: string): void { + try { + fsSync.rmdirSync(dir) + } catch { + // non-empty (a concurrent write is still staging there) or already removed + } +} + /** * fsync a file descriptor so its data is durable before the atomic rename. * Uses the sync form because this repo's @types/node does not declare @@ -128,14 +140,17 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu * (same volume -> atomic rename guaranteed). * 2. fsync the temp file, then close it. * 3. win32 only: if target exists save its DACL dump BEFORE backup rename. - * 4. Optionally rename target -> backup (when backup:true). - * 5. Optionally run the pre-commit verification hook (verifyBeforeCommit) before + * 4. Optionally run the pre-commit verification hook (verifyBeforeCommit) before * the target is moved aside, so it observes the state the rename replaces; - * a rejection aborts the publish (no commit rename) and propagates. + * a rejection aborts the publish (no commit rename, no backup taken yet) and + * propagates. + * 5. Optionally rename target -> backup (when backup:true). * 6. Atomic rename temp -> target. * 7. win32 only: restore DACL onto the directory AFTER commit rename. - * 8. On success: delete backup (if any) and unlink DACL dump. - * 9. On failure: rollback backup to target path; clean up temp + dump. + * 8. On success: delete backup (if any) and unlink DACL dump; remove the staging + * sub-directory when it is empty. + * 9. On failure: rollback backup to target path; clean up temp + dump, and remove + * the staging sub-directory when it is empty. */ /** @@ -172,7 +187,14 @@ export async function safeWriteText(filePath: string, content: string, options?: // Create the staging directory only when we generate the temp file there; // callers supplying their own tempPath (e.g. safeWriteJson) must not be left // with an empty .file-safety-staging directory behind. - const tempPath = options?.tempPath ?? _tempName(_stagingDir(dirPath), "safeWriteText") + let stagingDirPath: string | null = null + let tempPath: string + if (options?.tempPath) { + tempPath = options.tempPath + } else { + stagingDirPath = _stagingDir(dirPath) + tempPath = _tempName(stagingDirPath, "safeWriteText") + } let backupPath: string | null = null let releaseBackupOnSuccess = false @@ -192,6 +214,11 @@ export async function safeWriteText(filePath: string, content: string, options?: } const fd = fsSync.openSync(tempPath, "w", targetMode) try { + // openSync applies the process umask to the requested mode, so a 0o664 + // or 0o666 target would be staged as 0o644 under the common umask 022 and + // lose group write through the rename. Set the mode on the fd instead, the + // same way the caller-staged branch below does. + fsSync.fchmodSync(fd, targetMode) // Loop until every byte is written: writeSync can report a short // (partial) write, and publishing a truncated staging file would // commit corrupt content. @@ -312,7 +339,11 @@ export async function safeWriteText(filePath: string, content: string, options?: } } - // tempPath is now the committed file; no cleanup needed. + // tempPath is now the committed file; no cleanup needed. Remove the staging + // directory when this write was the last one using it. + if (stagingDirPath !== null) { + _removeStagingDirIfEmpty(stagingDirPath) + } } catch (originalError: unknown) { // -- Rollback / cleanup on failure ---------------------------------- if (backupPath && releaseBackupOnSuccess) { @@ -330,6 +361,12 @@ export async function safeWriteText(filePath: string, content: string, options?: // cleanup failure is non-fatal } + // The temp is gone, so the staging directory is ours to remove when no other + // write is staging in it; a non-empty rmdir leaves it for that writer. + if (stagingDirPath !== null) { + _removeStagingDirIfEmpty(stagingDirPath) + } + if (daclDumpPath !== null) { await fs.unlink(daclDumpPath).catch(() => {}) } From 2810c8fd9ac12649bcab6fc47436839578f30093 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:23:35 +0800 Subject: [PATCH 17/39] fix(services): honour the creation mask for new targets and keep the symlink refusal effective Two security findings from the review at 8cb59207a. 1) safeWriteText staged a new target with openSync(temp, "w", 0o644) and then called fchmodSync(fd, 0o644) unconditionally. openSync already applies the process umask, so the fchmod undid a restrictive mask (0o600 under umask 077) and the rename published a group/world-readable file in a readable directory. The mode is now forced onto the fd only when statSync found an existing target to preserve; for a new target the creation mask governs. 2) safeWriteJson refused a symlink destination with lstat, then resolved the publish target in a separate syscall. A local writer that replaced the final component with a link in that window had its referent overwritten with a credential-bearing export. The component the caller named is now re-checked after resolution and again under the lock before the commit rename, so the refusal stays effective through publication. Tests: new-target mask test plus a self-staged preservation test (control: restoring the unconditional fchmod fails exactly the mask test); two symlink-swap tests for the after-resolution and before-publication windows (control: removing the re-checks fails exactly those two). --- .../__tests__/safeWriteText.spec.ts | 37 +++++++++++++++++ src/services/file-safety/safeWriteText.ts | 11 ++++- src/utils/__tests__/safeWriteJson.test.ts | 41 +++++++++++++++++++ src/utils/safeWriteJson.ts | 22 ++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index c0617d0dde..b9a76ca17b 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -457,6 +457,43 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) }) + it("leaves the creation mask in charge when the target does not exist yet", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw enoent + }) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // openSync already applied the process umask to the requested mode. Forcing the + // 0o644 default back on with fchmod would undo a restrictive umask (0o600 under + // umask 077) and publish a group/world-readable file for a target that never + // existed, so the mask has to stay in charge. + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fsSync.openSync).toHaveBeenCalledWith( + expect.stringContaining("safeWriteText_"), + "w", + 0o644, + ) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("preserves an existing target's mode on the self-staged path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // The preservation rule still applies when a target exists: a 0o600 file must + // not become 0o644 through the atomic rename. + expect(fsSync.fchmodSync).toHaveBeenCalledWith(2, 0o600) + }) + it("opens the temp before applying a read-only target's mode (0o444 does not block the open)", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 71fd2f3bec..9441f06e93 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -207,8 +207,10 @@ export async function safeWriteText(filePath: string, content: string, options?: // not be published wider than the file it replaces (a 0o600 target // must not become 0o644 through the atomic rename). let targetMode = 0o644 // default for a fresh target + let targetExists = false try { targetMode = fsSync.statSync(targetPath).mode & 0o777 + targetExists = true } catch { // target does not exist yet - keep the default } @@ -217,8 +219,13 @@ export async function safeWriteText(filePath: string, content: string, options?: // openSync applies the process umask to the requested mode, so a 0o664 // or 0o666 target would be staged as 0o644 under the common umask 022 and // lose group write through the rename. Set the mode on the fd instead, the - // same way the caller-staged branch below does. - fsSync.fchmodSync(fd, targetMode) + // same way the caller-staged branch below does - but only when a target + // actually existed to preserve. For a new target the creation mask must win: + // forcing the 0o644 default back on with fchmod would undo a restrictive + // umask (0o600 under umask 077) and publish a group/world-readable file. + if (targetExists) { + fsSync.fchmodSync(fd, targetMode) + } // Loop until every byte is written: writeSync can report a short // (partial) write, and publishing a truncated staging file would // commit corrupt content. diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 9e51ef2b8d..862a507d5b 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -710,4 +710,45 @@ describe("safeWriteJson", () => { expect(await readFileContent(target)).toEqual({ written: true }) }) + test("rejects when the destination is swapped for a link after the initial refusal check", async () => { + const referentPath = path.join(tempDir, "swap-referent.json") + const linkPath = path.join(tempDir, "swap-link.json") + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: "untouched" })) + await fsPromisesActuals.writeFile!(linkPath, JSON.stringify({ own: true })) + const asLink = { isSymbolicLink: () => true } as unknown as fsSyncActual.Stats + const asFile = { isSymbolicLink: () => false } as unknown as fsSyncActual.Stats + // The first lstat (the refusal) sees a regular file; the re-check after + // resolvePublishTarget sees the link a local writer installed in between. + vi.spyOn(fs, "lstat").mockResolvedValueOnce(asFile).mockResolvedValueOnce(asLink) + + await expect( + safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true }), + ).rejects.toThrow(/after resolution/) + + vi.restoreAllMocks() + expect(await readFileContent(referentPath)).toEqual({ seed: "untouched" }) + }) + + test("rejects when the destination becomes a link before the commit is staged", async () => { + const referentPath = path.join(tempDir, "late-referent.json") + const linkPath = path.join(tempDir, "late-link.json") + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: "untouched" })) + await fsPromisesActuals.writeFile!(linkPath, JSON.stringify({ own: true })) + const asLink = { isSymbolicLink: () => true } as unknown as fsSyncActual.Stats + const asFile = { isSymbolicLink: () => false } as unknown as fsSyncActual.Stats + // The swap happens after resolution and while the write is already under the + // lock: the in-lock re-check must stop the commit rename. + vi.spyOn(fs, "lstat") + .mockResolvedValueOnce(asFile) + .mockResolvedValueOnce(asFile) + .mockResolvedValueOnce(asLink) + + await expect( + safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true }), + ).rejects.toThrow(/before publication/) + + vi.restoreAllMocks() + expect(await readFileContent(referentPath)).toEqual({ seed: "untouched" }) + }) + }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 2da3b0dd7f..96233b2bd4 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -106,6 +106,23 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // the given path on ENOENT), preserving the previous create-from-absent flow. const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) +// The refusal above and this resolution are separate syscalls, so a local writer +// could replace the final component with a link in between; resolvedTargetPath +// would then describe a destination the caller never chose. Re-check the component +// the caller named - once here and again under the lock before publishing - so the +// refusal stays effective through publication. +const assertFinalComponentNotReplaced = async (stage: string): Promise => { + const nowStat = await fs.lstat(absoluteFilePath).catch(() => undefined) + if (nowStat?.isSymbolicLink()) { + throw new Error( + `safeWriteJson: refusing to write through the symlink now at ${absoluteFilePath} (${stage}); the payload would land at ${resolvedTargetPath}, a destination the caller never chose.`, + ) + } +} +if (options?.refuseSymlinkTarget) { + await assertFinalComponentNotReplaced("after resolution") +} + // Acquire the lock before any file operations. `acquireFileLock` owns the // shared advisory lock protocol, so callers that lock the same path with it // (for example task-history deletion) serialize with this write. It locks the @@ -156,6 +173,11 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso backup: true, } + if (options?.refuseSymlinkTarget) { + // Last chance to notice the destination was swapped for a link: everything the + // caller asked for is staged and the commit rename follows the resolved path. + await assertFinalComponentNotReplaced("before publication") + } await safeWriteText(resolvedTargetPath, "", textOptions) // If we reach here, the new file is successfully in place and any From 006389d1bbd30b1cd5dbf98c2db402b8499786c7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:57:35 +0800 Subject: [PATCH 18/39] fix(services): stop safeWriteText from resolving an already-guarded publish target safeWriteJson resolves the publish target, locks it, and re-checks the final component, then delegates the commit to safeWriteText - which resolved the path AGAIN. A link installed between the caller's last check and that second resolution was followed, so the JSON was committed to the attacker's referent even though the caller had refused it. exportSettings uses this path for providerProfiles and globalSettings, i.e. credential-bearing payloads. SafeWriteTextOptions gains targetPathIsResolved: when set, safeWriteText uses the supplied path as-is. safeWriteJson passes it, since it has already resolved and guarded the target. All other callers keep the previous resolution behaviour. Tests: with the flag set realpath is not called and the rename targets the supplied path; a guarded safeWriteJson resolves exactly once. Controls: dropping the option handling fails both tests, dropping the caller flag fails the single-resolution test. The new test also removes two any annotations, taking the file below its suppression baseline. --- .../file-safety/__tests__/safeWriteText.spec.ts | 17 +++++++++++++++++ src/services/file-safety/safeWriteText.ts | 14 ++++++++++++-- src/utils/__tests__/safeWriteJson.test.ts | 13 +++++++++++++ src/utils/safeWriteJson.ts | 5 +++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index b9a76ca17b..4a3daafba1 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -494,6 +494,23 @@ describe("safeWriteText", () => { expect(fsSync.fchmodSync).toHaveBeenCalledWith(2, 0o600) }) + it("uses the supplied path as-is when the caller already resolved the publish target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + // A second resolution would follow a link installed after the caller's own + // re-check, so with the flag set realpath must not run at all. + vi.mocked(fs.realpath).mockResolvedValue("/elsewhere/referent.txt") + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + await safeWriteText(targetPath, "data", { platform: "linux", targetPathIsResolved: true }) + + expect(fs.realpath).not.toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith( + expect.stringContaining("safeWriteText_"), + path.resolve(targetPath), + ) + }) + it("opens the temp before applying a read-only target's mode (0o444 does not block the open)", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 9441f06e93..bcdb86401a 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -36,6 +36,15 @@ export interface SafeWriteTextOptions { */ tempPath?: string + /** + * Set when the caller has already resolved the publish target and guarded the + * symlink window itself (safeWriteJson resolves, locks, and re-checks the final + * component). safeWriteText then uses filePath as-is: resolving a second time would + * re-open the window the caller just closed, because a link installed after the + * caller's check would be followed here and the content committed to its referent. + */ + targetPathIsResolved?: boolean + /** * Pre-commit verification hook (A4a guarded write, epic #1375). Invoked * immediately before the commit rename and BEFORE any backup rename moves the @@ -176,8 +185,9 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise { const absoluteFilePath = path.resolve(filePath) - // Resolve the symlink referent (see resolvePublishTarget). - const targetPath = await resolvePublishTarget(absoluteFilePath) + // Resolve the symlink referent (see resolvePublishTarget) - unless the caller + // already resolved it and closed the substitution window itself. + const targetPath = options?.targetPathIsResolved ? absoluteFilePath : await resolvePublishTarget(absoluteFilePath) const dirPath = path.dirname(targetPath) // Ensure parent directory exists (mirrors safeWriteJson behaviour). diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 862a507d5b..50acecb0f0 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -751,4 +751,17 @@ describe("safeWriteJson", () => { expect(await readFileContent(referentPath)).toEqual({ seed: "untouched" }) }) + test("resolves the publish target only once during a guarded publication", async () => { + const target = path.join(tempDir, "single-resolve.json") + // The caller resolves once for the lock key; safeWriteText must not resolve again, + // or a link installed after the caller's re-check would be followed there. + const realpath = vi.spyOn(fs, "realpath").mockImplementation(async (p) => String(p)) + + await safeWriteJson(target, { written: true }, { refuseSymlinkTarget: true }) + + expect(realpath).toHaveBeenCalledTimes(1) + vi.restoreAllMocks() + expect(await readFileContent(target)).toEqual({ written: true }) + }) + }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 96233b2bd4..81e6a801e8 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -171,6 +171,11 @@ if (options?.refuseSymlinkTarget) { const textOptions: SafeWriteTextOptions = { tempPath: actualTempNewFilePath, backup: true, + // This call already resolved the target (and re-checked the final component + // under the lock). safeWriteText must not resolve it a second time: a link + // installed in that window would be followed there and the payload committed + // to the attacker's referent. + targetPathIsResolved: true, } if (options?.refuseSymlinkTarget) { From 2dd792cc0c3034d893ee0a27cbf1348dcf3a2ce5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 16:00:10 +0800 Subject: [PATCH 19/39] fix(file-safety): publish credential writes without following a planted link Addresses the Pre-merge check items raised against 006389d1b. Security Boundaries: the refuseSymlinkTarget guard checked the named path, then resolved it with realpath. A local writer that plants a link during resolution and removes it before the post-resolution lstat re-check passes every check in the sequence while the publish still lands on the referent - an export of provider profiles and global settings written to a file the user never chose. With refuseSymlinkTarget the caller-named path is now the publish target: resolution never runs, so there is no window to exploit, and the commit is a rename, which replaces the named directory entry instead of writing through a link. The lock keys to the same named path, so all refuseSymlinkTarget writers still serialize. The re-checks also fail closed now: only ENOENT is tolerated, a lstat that fails for another reason stops the write instead of publishing through an unexamined entry. Persistence Integrity: when the commit rename fails and the backup rename back also fails, only the commit error surfaced while the target was gone and the previous content sat under a randomized backup name. safeWriteText now throws RollbackFailedError carrying backupPath, the rollback failure as cause and the commit failure as originalError, with both messages in the text so handlers matching the primary error still match. Lifecycle Resource Cleanup: icacls can create a partial .acl.tmp and still exit non-zero. A failed save discarded daclDumpPath, so both cleanup blocks skipped a file that existed. The save result now drives only the RESTORE step (daclSaved); the path stays tracked for the finally unlink. Regression Evidence: safeWriteJson gains lstat-failure coverage (EACCES and a code-less error) asserting the original error propagates and nothing is locked, staged or published; a planted-during-resolution race test asserting resolution never runs and the referent keeps its content; and a test pinning that refuseSymlinkTarget skips resolution entirely. The rollback-failure test now asserts the partial-failure surface (name, backupPath, cause, originalError) and that the backup the error names really exists. Local: tsc --noEmit clean (0 errors); safeWriteJson + safeWriteText 71 passed / 1 skipped; importExport 53 passed; eslint clean on all three files. --- src/services/file-safety/safeWriteText.ts | 60 ++++++++++++++-- src/utils/__tests__/safeWriteJson.test.ts | 83 +++++++++++++++++++++-- src/utils/safeWriteJson.ts | 27 +++++++- 3 files changed, 160 insertions(+), 10 deletions(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index bcdb86401a..7a59e9aa19 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -182,6 +182,43 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise { const absoluteFilePath = path.resolve(filePath) @@ -209,6 +246,7 @@ export async function safeWriteText(filePath: string, content: string, options?: let backupPath: string | null = null let releaseBackupOnSuccess = false let daclDumpPath: string | null = null // tracked for cleanup in finally + let daclSaved = false // the restore step runs only when the save succeeded try { // -- Step 1: write content to staging temp file ------------------- @@ -280,7 +318,12 @@ export async function safeWriteText(filePath: string, content: string, options?: daclDumpPath = targetPath + ".acl.tmp" const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) if (!saved) { - daclDumpPath = null // skip DACL handling entirely + // Skip the RESTORE step only. icacls can create a partial dump and still exit + // non-zero, so the path stays tracked: dropping it here would leave that file + // next to the target with nothing left to remove it. + daclSaved = false + } else { + daclSaved = true } } catch { // target does not exist or access failed — no DACL handling @@ -336,7 +379,7 @@ export async function safeWriteText(filePath: string, content: string, options?: } // -- Step 5 (win32): restore DACL AFTER commit rename --------- - if (platform === "win32" && daclDumpPath !== null) { + if (platform === "win32" && daclSaved && daclDumpPath !== null) { const restoredDir = path.dirname(targetPath) await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) } @@ -363,11 +406,16 @@ export async function safeWriteText(filePath: string, content: string, options?: } } catch (originalError: unknown) { // -- Rollback / cleanup on failure ---------------------------------- + let rollbackFailure: { backupPath: string; error: unknown } | null = null if (backupPath && releaseBackupOnSuccess) { try { await fs.rename(backupPath, targetPath) - } catch { - // rollback failed — do not mask original error + } catch (rollbackError: unknown) { + // The commit failed AND the restore failed: the target path is absent and the + // previous content survives only under the randomized backup name. Reporting only + // the primary failure leaves the caller unable to find that copy, so both are + // surfaced - the primary error stays reachable as originalError and in the text. + rollbackFailure = { backupPath, error: rollbackError } } } @@ -388,6 +436,10 @@ export async function safeWriteText(filePath: string, content: string, options?: await fs.unlink(daclDumpPath).catch(() => {}) } + if (rollbackFailure !== null) { + throw new RollbackFailedError(targetPath, rollbackFailure.backupPath, rollbackFailure.error, originalError) + } + throw originalError } } diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 50acecb0f0..3e3c998304 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -465,14 +465,28 @@ describe("safeWriteJson", () => { return fsPromisesActuals.rename!(oldPath, newPath) }) - // The original error must propagate, not the rollback error - await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") + // The primary failure has to stay readable even though the rollback failure is what + // gets thrown on top of it. + const rejection = await safeWriteJson(currentTestFilePath, newData).then(() => null, (error) => error) + expect(rejection).toBeInstanceOf(Error) + expect(rejection.name).toBe("RollbackFailedError") + expect(rejection.message).toContain("Primary rename failed") + + // Partial failure must be actionable: the caller learns the previous content is + // recoverable and exactly where it is, instead of only that a rename failed. + expect(rejection.backupPath).toMatch(/safeWriteText\.bak_/) + expect(String(rejection.message)).toContain("previous content is still at") + expect(rejection.originalError).toBeInstanceOf(Error) + expect((rejection.originalError as Error).message).toBe("Primary rename failed") + expect(rejection.cause).toBeInstanceOf(Error) + expect((rejection.cause as Error).message).toBe("Rollback rename failed") // The rollback failed inside safeWriteText, so the target is gone and - // the backup is orphaned on disk. + // the backup is orphaned on disk - and it is the file the error points at. expect(await fileExists(currentTestFilePath)).toBe(false) const entries = await fs.readdir(tempDir) expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) + expect(await fileExists(rejection.backupPath)).toBe(true) consoleErrorSpy.mockRestore() }) @@ -751,17 +765,78 @@ describe("safeWriteJson", () => { expect(await readFileContent(referentPath)).toEqual({ seed: "untouched" }) }) + test.each([ + ["EACCES", Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })], + ["a code-less error", new Error("lstat exploded")], + ])("fails closed when the refusal check fails with %s", async (_label, failure) => { + const target = path.join(tempDir, "refuse-lstat-failure.json") + // A stat error that is not ENOENT is not evidence that the destination is safe, so + // the write has to stop here rather than publish through an unexamined entry. + vi.spyOn(fs, "lstat").mockRejectedValue(failure) + + await expect( + safeWriteJson(target, { written: true }, { refuseSymlinkTarget: true }), + ).rejects.toThrow(failure.message) + + vi.restoreAllMocks() + // Nothing was locked, staged, or published: no target and no leftover temp file. + expect(await fileExists(target)).toBe(false) + const leftovers = fsSyncActual.readdirSync(tempDir).filter(function (entry) { + return entry.includes("refuse-lstat-failure") + }) + expect(leftovers).toEqual([]) + }) + + test("publishes onto the named path when a link is planted during target resolution", async () => { + const referentPath = path.join(tempDir, "race-referent.json") + const namedPath = path.join(tempDir, "race-named.json") + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: "untouched" })) + await fsPromisesActuals.writeFile!(namedPath, JSON.stringify({ own: true })) + const asFile = { isSymbolicLink: () => false } as unknown as fsSyncActual.Stats + // Every lstat in the sequence sees a regular file: the link a local writer plants + // while the destination is being resolved is gone again before the next check, so + // no check in the sequence can catch it. + vi.spyOn(fs, "lstat").mockResolvedValue(asFile) + // Resolution is the step that would hand back a destination the caller never chose. + const realpathSpy = vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + + await safeWriteJson(namedPath, { written: true }, { refuseSymlinkTarget: true }) + + vi.restoreAllMocks() + // With refuseSymlinkTarget the named path is the publish target, so resolution never + // runs and there is no window in which a planted link can redirect the payload. The + // commit is a rename, which replaces the named directory entry instead of writing + // through a link, so the referent cannot receive the export. + expect(realpathSpy).not.toHaveBeenCalled() + expect(await readFileContent(namedPath)).toEqual({ written: true }) + expect(await readFileContent(referentPath)).toEqual({ seed: "untouched" }) + }) + test("resolves the publish target only once during a guarded publication", async () => { const target = path.join(tempDir, "single-resolve.json") // The caller resolves once for the lock key; safeWriteText must not resolve again, // or a link installed after the caller's re-check would be followed there. const realpath = vi.spyOn(fs, "realpath").mockImplementation(async (p) => String(p)) - await safeWriteJson(target, { written: true }, { refuseSymlinkTarget: true }) + await safeWriteJson(target, { written: true }) expect(realpath).toHaveBeenCalledTimes(1) vi.restoreAllMocks() expect(await readFileContent(target)).toEqual({ written: true }) }) + test("does not resolve the publish target when refuseSymlinkTarget is set", async () => { + const target = path.join(tempDir, "no-resolve.json") + // Resolving is what turns a planted link into a destination the caller never chose, + // so the credential-bearing path skips it entirely and publishes onto the name the + // caller gave. + const realpath = vi.spyOn(fs, "realpath").mockImplementation(async (p) => String(p)) + + await safeWriteJson(target, { written: true }, { refuseSymlinkTarget: true }) + + expect(realpath).not.toHaveBeenCalled() + vi.restoreAllMocks() + expect(await readFileContent(target)).toEqual({ written: true }) + }) + }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 81e6a801e8..b6170eb064 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -104,7 +104,18 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // update. Locking the resolved referent coordinates every alias through one // lock. resolvePublishTarget tolerates a not-yet-existing file (it returns // the given path on ENOENT), preserving the previous create-from-absent flow. - const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // With refuseSymlinkTarget the caller-named path IS the publish target: resolving + // it through realpath would hand back a referent the caller never chose if a link is + // planted between the refusal check and this resolution (the later lstat re-checks + // would then pass, because the link was already removed, while the publish still + // landed on the referent). Publishing onto the named path is no-follow for the final + // component: the commit is a rename, and rename replaces the directory entry rather + // than writing through a link, so an inserted link gets replaced and its referent + // never receives the payload. Every refuseSymlinkTarget writer keys its lock to the + // same named path, so the lock still serializes all writers to that entry. + const resolvedTargetPath = options?.refuseSymlinkTarget + ? absoluteFilePath + : await resolvePublishTarget(absoluteFilePath) // The refusal above and this resolution are separate syscalls, so a local writer // could replace the final component with a link in between; resolvedTargetPath @@ -112,7 +123,19 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // the caller named - once here and again under the lock before publishing - so the // refusal stays effective through publication. const assertFinalComponentNotReplaced = async (stage: string): Promise => { - const nowStat = await fs.lstat(absoluteFilePath).catch(() => undefined) + // Fail closed: only ENOENT (nothing there that could be a link) is tolerated. A lstat + // failing for another reason - EACCES on the parent directory, for example - says + // nothing about whether the entry is safe, so the write stops instead of publishing + // blind through an unexamined destination. + let nowStat: fsSync.Stats | undefined + try { + nowStat = await fs.lstat(absoluteFilePath) + } catch (error: unknown) { + const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined + if (code !== "ENOENT") { + throw error + } + } if (nowStat?.isSymbolicLink()) { throw new Error( `safeWriteJson: refusing to write through the symlink now at ${absoluteFilePath} (${stage}); the payload would land at ${resolvedTargetPath}, a destination the caller never chose.`, From 51f0cead88e0a9f6b53af09f4d8dbef92db14fa1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 20:45:13 +0800 Subject: [PATCH 20/39] test(file-safety): pin that a partial DACL dump is unlinked after a failed save The Lifecycle Resource Cleanup item was already fixed in 2dd792cc0: when icacls /save exits non-zero the dump path stays tracked (safeWriteText.ts:320-327) so the cleanup unlinks a partial .acl.tmp instead of leaving it beside the target. Nothing pinned it - the existing 'win32 DACL failure falls back to plain rename' test only asserts that the write still succeeded, so clearing the path on failure (the pre-fix behaviour) kept every test green while leaking the dump file. Test: 'win32 DACL: a partial dump left by a failed save is still unlinked' - icacls reports an error, the write still succeeds, no restore is attempted (execFile called once) and unlink is called with the .acl.tmp path. Pin: setting daclDumpPath = null on save failure fails it (verified). Local: safeWriteText.spec + safeWriteJson.test = 72 passed / 1 skipped; tsc --noEmit 0; eslint 0 err / 0 warn on the touched spec. --- .../__tests__/safeWriteText.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 4a3daafba1..a4e52a7e4f 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -320,6 +320,25 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalled() }) + it("win32 DACL: a partial dump left by a failed save is still unlinked", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls can write a partial dump and still exit non-zero. The dump path must stay + // tracked so the cleanup removes it; clearing it on the failure would leave the + // dump sitting next to the target with nothing left to remove it. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // No restore attempt (the save failed), but the dump is cleaned up. + expect(execFile).toHaveBeenCalledTimes(1) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) From 8e5061eaec00ea99d4d55dcaacb3b57d5dcbd441 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 21:04:37 +0800 Subject: [PATCH 21/39] fix(task): clear the per-task observation registry on disposal ObservationRegistry keeps an entry per absolute path the task read or wrote (version token + timestamp). Nothing can consume those entries after disposal, so a long-lived extension host kept every path a finished task touched - and the tokens that gate its guarded writes - reachable. Task.disposeOnce() now calls observationRegistry.clear() (the registry already exposed clear()). Tests (Task level, the layer that owns the per-task lifetime): - 'gives each Task its own observation registry': two real Tasks expose distinct registries and an observation recorded in one is invisible to the other - the assumption behind the guarded-write compare-and-swap. - 'clears the observation registry when the task is disposed': observe two paths, dispose, both lookups return undefined. Pin: removing the clear() call fails it. Local: Task.spec + observationRegistry.spec + guardedWrite.spec = 194 passed / 0 failed; tsc 0; eslint 0 err / 0 warn on both files. --- src/core/task/Task.ts | 7 +++++ src/core/task/__tests__/Task.spec.ts | 42 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index cd7020456c..8078743a93 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1369,6 +1369,7 @@ export class Task extends EventEmitter implements TaskLike { /** Cancels the current persistence generation before creating the next assistant-turn boundary. */ private resetAssistantMessagePersistence(): void { this.cancelAssistantMessagePersistence() + this.assistantMessagePersistencePromise = new Promise((resolve) => { this.resolveAssistantMessagePersistence = resolve }) @@ -3327,6 +3328,12 @@ export class Task extends EventEmitter implements TaskLike { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) this.cancelAssistantMessagePersistence() + // Drop the per-task file observations. The registry holds an entry per absolute + // path the task read or wrote (version token + timestamp); nothing can consume them + // after disposal, and a long-lived extension host would otherwise keep every path a + // finished task touched alive. + this.observationRegistry.clear() + // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task // switched, extension deactivated) isn't invisible to telemetry. diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 06f07d2e02..19ed21e387 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -976,6 +976,48 @@ describe("Cline", () => { }) }) + describe("observation registry lifecycle (S4a, epic #1375)", () => { + it("gives each Task its own observation registry", () => { + const firstTask = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "first observation task", + startTask: false, + }) + const secondTask = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "second observation task", + startTask: false, + }) + + // The guarded-write contract assumes an observation in one task never validates + // a write issued by another task. + expect(firstTask.observationRegistry).not.toBe(secondTask.observationRegistry) + firstTask.observationRegistry.observe("/workspace/a.ts", "v-a") + expect(firstTask.observationRegistry.get("/workspace/a.ts")?.version).toBe("v-a") + expect(secondTask.observationRegistry.get("/workspace/a.ts")).toBeUndefined() + }) + + it("clears the observation registry when the task is disposed", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "disposed observation task", + startTask: false, + }) + task.observationRegistry.observe("/workspace/a.ts", "v-a") + task.observationRegistry.observe("/workspace/b.ts", "v-b") + + await task.dispose() + + // A disposed task cannot serve another guarded write, so its observed paths + // (version token + timestamp each) must not stay reachable for the host lifetime. + expect(task.observationRegistry.get("/workspace/a.ts")).toBeUndefined() + expect(task.observationRegistry.get("/workspace/b.ts")).toBeUndefined() + }) + }) + describe("constructor", () => { it.each([{ apiConfigName: "parent-local-profile" }, { apiConfigName: undefined }])( "uses an explicit delegated-child context without shared state or startup persistence", From bacb9d37abf9fa3ee36248f49477a9bebfe95e9e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 23:45:18 +0800 Subject: [PATCH 22/39] fix(mcp): refuse a symlinked target for project-scoped settings writes MCP settings carry secrets and server commands. A project .roo/mcp.json is content the repository controls, so a repository that plants it as a symlink used to receive the merged settings at the referent - outside the workspace, where nothing confines it. The three project-capable writes (updateServerConfig, deleteServer, saveConfig) now pass refuseSymlinkTarget: true for a project-sourced connection, so safeWriteJson rejects the link instead of following it. The global settings file deliberately keeps resolve-and-follow: it lives in the extension's global storage, no repository controls that path, and users legitimately link mcp_settings.json. A connection with no recorded source is treated as global, matching conn.server.source || "global". Tests: 'refuses a symlinked target for a project-scoped timeout write' (pin: making the helper always return {} fails it) and 'still follows a symlink for the global settings write'. Written with one unknown-projection instead of as any, so the file's no-explicit-any suppression count is unchanged (eslint reports 0 for the spec, same as before). Local: McpHub.spec filtered 2 passed; tsc --noEmit 0; eslint 0 err / 0 warn on both files. --- src/services/mcp/McpHub.ts | 21 ++++++++++-- src/services/mcp/__tests__/McpHub.spec.ts | 42 +++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 42786cfaa5..bacaf71531 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -495,6 +495,21 @@ export class McpHub { return mcpServersPath } + /** + * A project-scoped MCP settings file is content the REPOSITORY controls, so a + * repository that plants .roo/mcp.json as a symlink must not receive the merged + * settings at the linked target - the payload would land outside the workspace, and + * MCP configs carry secrets and server commands. Those writes therefore refuse a + * symlink target instead of following it. The global settings file is deliberately + * left to follow a symlink: users legitimately link mcp_settings.json, it lives in + * the extension's global storage, and no repository controls that path. + * A connection with no recorded source is treated as global, matching the rest of this + * file (`conn.server.source || "global"`). + */ + private symlinkPolicyForSource(source: "global" | "project" | undefined): { refuseSymlinkTarget?: boolean } { + return source === "project" ? { refuseSymlinkTarget: true } : {} + } + async getMcpSettingsFilePath(): Promise { const provider = this.providerRef.deref() if (!provider) { @@ -2091,7 +2106,7 @@ export class McpHub { } this.isProgrammaticUpdate = true try { - await safeWriteJson(configPath, updatedConfig, { prettyPrint: true }) + await safeWriteJson(configPath, updatedConfig, { prettyPrint: true, ...this.symlinkPolicyForSource(source) }) } finally { // Reset flag after watcher debounce period (non-blocking) this.flagResetTimer = setTimeout(() => { @@ -2176,7 +2191,7 @@ export class McpHub { mcpServers: config.mcpServers, } - await safeWriteJson(configPath, updatedConfig, { prettyPrint: true }) + await safeWriteJson(configPath, updatedConfig, { prettyPrint: true, ...this.symlinkPolicyForSource(source) }) // Update server connections with the correct source await this.updateServerConnections(config.mcpServers, serverSource) @@ -2385,7 +2400,7 @@ export class McpHub { } this.isProgrammaticUpdate = true try { - await safeWriteJson(normalizedPath, config, { prettyPrint: true }) + await safeWriteJson(normalizedPath, config, { prettyPrint: true, ...this.symlinkPolicyForSource(source) }) } finally { // Reset flag after watcher debounce period (non-blocking) this.flagResetTimer = setTimeout(() => { diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 441b0310e6..90fe2ad1b8 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -1772,6 +1772,48 @@ describe("McpHub", () => { }) describe("updateServerTimeout", () => { + it("refuses a symlinked target for a project-scoped timeout write", async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"], timeout: 60 } } })) + // The SDK client/transport are never touched by this write path (it reads only + // server.name and server.source), so the literal is projected onto the connection + // type through unknown rather than adding another `as any` to this file. + mcpHub.connections = [ + { + type: "connected", + server: { name: "test-server", type: "stdio", command: "node", args: ["test.js"], timeout: 60, source: "project" }, + client: {}, + transport: {}, + } as unknown as ConnectedMcpConnection, + ] + + await mcpHub.updateServerTimeout("test-server", 120) + + // A project .roo/mcp.json is repository-controlled: if it is a symlink, the merged + // settings (secrets + server commands) must NOT land at the referent outside the + // workspace, so this write refuses the link instead of following it. + const write = vi.mocked(safeWriteJson).mock.calls.at(-1) + expect(write && write[2]).toEqual(expect.objectContaining({ refuseSymlinkTarget: true })) + }) + + it("still follows a symlink for the global settings write", async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"], timeout: 60 } } })) + mcpHub.connections = [ + { + type: "connected", + server: { name: "test-server", type: "stdio", command: "node", args: ["test.js"], timeout: 60, source: "global" }, + client: {}, + transport: {}, + } as unknown as ConnectedMcpConnection, + ] + + await mcpHub.updateServerTimeout("test-server", 120) + + // The global file lives in the extension global storage and users legitimately link + // it, so it keeps the resolve-and-follow behaviour. + const write = vi.mocked(safeWriteJson).mock.calls.at(-1) + expect(write && write[2]).not.toHaveProperty("refuseSymlinkTarget") + }) + it("should update server timeout in settings file", async () => { const mockConfig = { mcpServers: { From fc94f62acbca2d9750f141e6d651687fe8bb25c2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 00:19:11 +0800 Subject: [PATCH 23/39] fix(mcp,task): derive the symlink policy from the selected source; make registry disposal terminal 1) deleteServer(serverName) takes an OPTIONAL source, but configPath is chosen from the source that findConnection resolved (serverSource). The write policy was derived from the optional argument, so a project server reached through an omitted argument got symlinkPolicyForSource(undefined) and silently lost symlink refusal - a project .roo/mcp.json could direct the settings write to its referent. The policy now comes from serverSource, the same value that selected the path. 2) ObservationRegistry gains a terminal state. Task.disposeOnce() called clear(), which only dropped the map: a read already in flight could finish afterwards and observe() repopulated a registry no task owns, handing a version token to a guarded write that will never happen. close() drops every entry AND makes observe() a no-op; disposeOnce() calls close(). Tests: - McpHub.spec: omitted-source deleteServer against a project connection still refuses the symlink; project toggleToolAlwaysAllow refuses, global does not (toggleToolEnabledForPrompt shares the same updateServerToolList write, so the policy is one code path). - observationRegistry.spec: close() drops entries and refuses later observe(). - Task.spec: an observation recorded after dispose() is refused. Negative controls: reverting the policy argument to the optional source fails the deleteServer test; reverting close() to clear() in disposeOnce fails the Task late-observation test. Both restored green. Local: McpHub.spec symlink suite 5 passed; Task.spec observation registry 3 passed; observationRegistry.spec 8 passed; tsc --noEmit 0; eslint 0 err / 0 warn on all six files. --- src/core/task/Task.ts | 2 +- src/core/task/__tests__/Task.spec.ts | 23 ++++++++ .../__tests__/observationRegistry.spec.ts | 19 +++++++ src/core/task/observationRegistry.ts | 26 ++++++++- src/services/mcp/McpHub.ts | 2 +- src/services/mcp/__tests__/McpHub.spec.ts | 56 +++++++++++++++++++ 6 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 8078743a93..fc23f70820 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3332,7 +3332,7 @@ export class Task extends EventEmitter implements TaskLike { // path the task read or wrote (version token + timestamp); nothing can consume them // after disposal, and a long-lived extension host would otherwise keep every path a // finished task touched alive. - this.observationRegistry.clear() + this.observationRegistry.close() // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 19ed21e387..d38d41a622 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1014,7 +1014,30 @@ describe("Cline", () => { // A disposed task cannot serve another guarded write, so its observed paths // (version token + timestamp each) must not stay reachable for the host lifetime. expect(task.observationRegistry.get("/workspace/a.ts")).toBeUndefined() + // A disposed task cannot serve another guarded write, so its observed paths + // (version token + timestamp each) must not stay reachable for the host lifetime. expect(task.observationRegistry.get("/workspace/b.ts")).toBeUndefined() + // disposeOnce() closes the registry rather than only clearing the map. + }) + + it("refuses an observation recorded after the task was disposed", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "late observation task", + startTask: false, + }) + + await task.dispose() + + // A read that was already in flight when the task was disposed can finish late and + // call observe(). Recording then would repopulate a registry no task owns and hand a + // version token to a guarded write that will never happen. + task.observationRegistry.observe("/workspace/late.ts", "v-late") + + expect(task.observationRegistry.get("/workspace/late.ts")).toBeUndefined() + expect(task.observationRegistry.size).toBe(0) + expect(task.observationRegistry.isClosed).toBe(true) }) }) diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts index 51b73aabde..6898a27031 100644 --- a/src/core/task/__tests__/observationRegistry.spec.ts +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -70,3 +70,22 @@ describe("ObservationRegistry", () => { expect(regB.get("/shared.ts")!.version).toBe("v2") }) }) + + +describe("close() - disposal is terminal", () => { + it("drops every observation and refuses later ones", () => { + const registry = new ObservationRegistry() + registry.observe("/workspace/a.ts", "v-a") + expect(registry.size).toBe(1) + + registry.close() + + expect(registry.size).toBe(0) + expect(registry.isClosed).toBe(true) + + // A read that was in flight when the task was disposed must not repopulate it. + registry.observe("/workspace/late.ts", "v-late") + expect(registry.get("/workspace/late.ts")).toBeUndefined() + expect(registry.size).toBe(0) + }) +}) \ No newline at end of file diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts index e35d7aff9f..db0ecfd07e 100644 --- a/src/core/task/observationRegistry.ts +++ b/src/core/task/observationRegistry.ts @@ -25,13 +25,22 @@ export interface FileObservation { export class ObservationRegistry { private readonly entries = new Map() + /** Set by close(): after disposal the registry refuses further observations. */ + private closed = false + /** - * Record an observation for a file at its absolute path. + * Record an observation for a file at its absolute path, unless the registry is closed. * * Re-observing replaces the entry with a fresh observedAt timestamp and - * the new version token. + * the new version token. A read that was already in flight can finish after + * Task.disposeOnce() dropped the observations; recording then would hand a version token + * to a task that no longer serves any request, and a later guarded write could consult + * it. close() therefore makes this a no-op, so disposal is terminal at this layer. */ observe(absolutePath: string, version: string): void { + if (this.closed) { + return + } this.entries.set(absolutePath, { version, observedAt: Date.now() }) } @@ -47,6 +56,19 @@ export class ObservationRegistry { this.entries.clear() } + /** + * Drop every observation and refuse any later one. Task.disposeOnce() calls this so a + * disposed task's registry cannot be repopulated by a read that finishes late. + */ + close(): void { + this.closed = true + this.entries.clear() + } + + get isClosed(): boolean { + return this.closed + } + get size(): number { return this.entries.size } diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index bacaf71531..23c84bc56a 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -2191,7 +2191,7 @@ export class McpHub { mcpServers: config.mcpServers, } - await safeWriteJson(configPath, updatedConfig, { prettyPrint: true, ...this.symlinkPolicyForSource(source) }) + await safeWriteJson(configPath, updatedConfig, { prettyPrint: true, ...this.symlinkPolicyForSource(serverSource) }) // Update server connections with the correct source await this.updateServerConnections(config.mcpServers, serverSource) diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 90fe2ad1b8..3313a2dece 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -1052,6 +1052,62 @@ describe("McpHub", () => { expect(writtenConfig.mcpServers["test-server"].alwaysAllow).toContain("new-tool") }) + describe("symlink policy on MCP settings writes", () => { + it("refuses a symlinked target when deleteServer omits the source but the server is a project server", async () => { + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"] } } })) + mcpHub.connections = [ + { + type: "connected", + server: { name: "test-server", type: "stdio", command: "node", args: ["test.js"], source: "project" }, + client: {}, + transport: {}, + } as unknown as ConnectedMcpConnection, + ] + + // deleteServer("name") takes no source: findConnection resolves the connection and + // configPath is chosen from THAT source. The write policy must be derived from the same + // value, or a project server reached through an omitted argument loses symlink refusal. + await mcpHub.deleteServer("test-server") + + const write = vi.mocked(safeWriteJson).mock.calls.at(-1) + expect(write && write[2]).toEqual(expect.objectContaining({ refuseSymlinkTarget: true })) + }) + + it("refuses a symlinked target for a project toggleToolAlwaysAllow write", async () => { + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"], alwaysAllow: [] } } })) + mcpHub.connections = [ + { + type: "connected", + server: { name: "test-server", type: "stdio", command: "node", args: ["test.js"], source: "project" }, + client: {}, + transport: {}, + } as unknown as ConnectedMcpConnection, + ] + + await mcpHub.toggleToolAlwaysAllow("test-server", "project", "new-tool", true) + + const write = vi.mocked(safeWriteJson).mock.calls.at(-1) + expect(write && write[2]).toEqual(expect.objectContaining({ refuseSymlinkTarget: true })) + }) + + it("leaves the global toggleToolAlwaysAllow write free to follow a symlink", async () => { + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"], alwaysAllow: [] } } })) + mcpHub.connections = [ + { + type: "connected", + server: { name: "test-server", type: "stdio", command: "node", args: ["test.js"], source: "global" }, + client: {}, + transport: {}, + } as unknown as ConnectedMcpConnection, + ] + + await mcpHub.toggleToolAlwaysAllow("test-server", "global", "new-tool", true) + + const write = vi.mocked(safeWriteJson).mock.calls.at(-1) + expect(write && write[2]).not.toHaveProperty("refuseSymlinkTarget") + }) + }) + it("should remove tool from always allow list when disabling", async () => { const mockConfig = { mcpServers: { From 171a26ed281646a6bfc51e57bd1c163f27454092 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 04:37:03 +0800 Subject: [PATCH 24/39] fix(utils): refuse a symlinked ancestor for credential-bearing safeWriteJson writes Security Boundaries: the refuseSymlinkTarget guard inspected only the final path component with lstat(absoluteFilePath). A symlinked directory ABOVE the target redirected the payload without leaving any trace on the target path - e.g. /.roo pointing outside the workspace - so a project-scoped credential write could still land outside the project it was scoped to. The guard now walks every existing ancestor and refuses if any component is a symlink. Only ENOENT ends the walk (a missing ancestor means nothing deeper exists to be a link); any other inspection error fails closed, because "could not inspect" is not evidence that the path is safe. Tests: 'refuses a write when an ancestor directory is a symlink' and 'fails closed when an ancestor directory cannot be inspected' (EACCES), both asserting the pre-existing file is untouched. Two existing swap tests were switched from mockResolvedValueOnce chains to path-aware lstat mocks, because the ancestor walk legitimately adds lstat calls and an order-based mock would be consumed by the wrong component - the scripted behaviour (regular file, then link) is unchanged. Negative control: removing the ancestor call -> exactly 2 failed (the two new tests); restored -> 34 passed / 1 skipped. Local: safeWriteJson.test 34 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- src/utils/__tests__/safeWriteJson.test.ts | 64 +++++++++++++++++++++-- src/utils/safeWriteJson.ts | 36 +++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 3e3c998304..1dd9760ec3 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -733,7 +733,17 @@ describe("safeWriteJson", () => { const asFile = { isSymbolicLink: () => false } as unknown as fsSyncActual.Stats // The first lstat (the refusal) sees a regular file; the re-check after // resolvePublishTarget sees the link a local writer installed in between. - vi.spyOn(fs, "lstat").mockResolvedValueOnce(asFile).mockResolvedValueOnce(asLink) + // Path-aware rather than call-order: the ancestor walk also calls lstat, so a + // mockResolvedValueOnce chain would be consumed by the wrong component. The first look at + // the target sees a regular file; the re-check after resolvePublishTarget sees the link. + let targetLooks = 0 + vi.spyOn(fs, "lstat").mockImplementation(((p: unknown) => { + if (String(p) === linkPath) { + targetLooks++ + return Promise.resolve(targetLooks === 1 ? asFile : asLink) + } + return Promise.resolve(asFile) + }) as unknown as typeof fs.lstat) await expect( safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true }), @@ -752,10 +762,16 @@ describe("safeWriteJson", () => { const asFile = { isSymbolicLink: () => false } as unknown as fsSyncActual.Stats // The swap happens after resolution and while the write is already under the // lock: the in-lock re-check must stop the commit rename. - vi.spyOn(fs, "lstat") - .mockResolvedValueOnce(asFile) - .mockResolvedValueOnce(asFile) - .mockResolvedValueOnce(asLink) + // Same path-aware scripting: the swap happens after resolution and while the write is + // already under the lock, so the third look at the target is the one that must be a link. + let lateLooks = 0 + vi.spyOn(fs, "lstat").mockImplementation(((p: unknown) => { + if (String(p) === linkPath) { + lateLooks++ + return Promise.resolve(lateLooks <= 2 ? asFile : asLink) + } + return Promise.resolve(asFile) + }) as unknown as typeof fs.lstat) await expect( safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true }), @@ -825,6 +841,44 @@ describe("safeWriteJson", () => { expect(await readFileContent(target)).toEqual({ written: true }) }) + test("refuses a write when an ancestor directory is a symlink", async () => { + const target = path.join(tempDir, "ancestor-link.json") + await fsPromisesActuals.writeFile!(target, JSON.stringify({ own: true })) + const asLink = { isSymbolicLink: () => true } as unknown as fsSyncActual.Stats + const asFile = { isSymbolicLink: () => false } as unknown as fsSyncActual.Stats + // The target itself looks ordinary; the link sits one directory above it. Checking only the + // final component would publish a credential payload outside the directory the caller named. + vi.spyOn(fs, "lstat").mockImplementation(((p: unknown) => { + return Promise.resolve(String(p) === tempDir ? asLink : asFile) + }) as unknown as typeof fs.lstat) + + await expect( + safeWriteJson(target, { leaked: true }, { refuseSymlinkTarget: true }), + ).rejects.toThrow(/is a symlink/) + + vi.restoreAllMocks() + expect(await readFileContent(target)).toEqual({ own: true }) + }) + + test("fails closed when an ancestor directory cannot be inspected", async () => { + const target = path.join(tempDir, "ancestor-eacces.json") + await fsPromisesActuals.writeFile!(target, JSON.stringify({ own: true })) + const asFile = { isSymbolicLink: () => false } as unknown as fsSyncActual.Stats + const failure = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + // "Could not inspect" is not evidence that the path is safe: only ENOENT is tolerated. + vi.spyOn(fs, "lstat").mockImplementation(((p: unknown) => { + if (String(p) === tempDir) { return Promise.reject(failure) } + return Promise.resolve(asFile) + }) as unknown as typeof fs.lstat) + + await expect( + safeWriteJson(target, { leaked: true }, { refuseSymlinkTarget: true }), + ).rejects.toThrow(/EACCES/) + + vi.restoreAllMocks() + expect(await readFileContent(target)).toEqual({ own: true }) + }) + test("does not resolve the publish target when refuseSymlinkTarget is set", async () => { const target = path.join(tempDir, "no-resolve.json") // Resolving is what turns a planted link into a destination the caller never chose, diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index b6170eb064..757bb3da2b 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -74,6 +74,37 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw dirError } +// Every existing ancestor must be checked too, not just the final component: a symlinked +// directory above the target redirects the payload without leaving a trace on the target path +// itself (e.g. /.roo -> a directory outside the workspace). Only ENOENT stops the +// walk - a missing ancestor means nothing deeper exists to be a link. Any other inspection +// error fails closed, because "could not inspect" is not evidence that the path is safe. +async function _refuseSymlinkedAncestors(absoluteFilePath: string): Promise { + let current = path.dirname(absoluteFilePath) + for (;;) { + let st: fsSync.Stats + try { + st = await fs.lstat(current) + } catch (error: unknown) { + const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined + if (code === "ENOENT") { + return + } + throw error + } + if (st.isSymbolicLink()) { + throw new Error( + `safeWriteJson: refusing to write to ${absoluteFilePath}: ${current} is a symlink, and the payload would be written outside the directory the caller named.` + ) + } + const parent = path.dirname(current) + if (parent === current) { + return + } + current = parent + } +} + // A credential-bearing payload must not be redirected through a link the user // never chose: check the final path component before anything is resolved, // staged, or locked. @@ -97,6 +128,11 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } } + // The final component alone is not enough: a symlinked ancestor redirects the payload while + // leaving the target path looking ordinary. Checked here, before anything is resolved, staged + await _refuseSymlinkedAncestors(absoluteFilePath) + // or locked, and it fails closed on any inspection error that is not ENOENT. + // Resolve the publish target BEFORE acquiring the lock: proper-lockfile keys // the lock by the given path, so a symlink alias and its referent would // otherwise take two distinct locks for one underlying file - a concurrent From 17c736ecb3f36906c5e399d5213a09f79f044d80 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 05:14:16 +0800 Subject: [PATCH 25/39] fix(utils): keep safeWriteJson publishing no-follow by default, and scope the ancestor refusal Security Boundaries: this pull request had made the default publish follow a symlink. The commit rename targeted resolvePublishTarget's result, so a caller that never chose a link had its JSON written to the referent - a new default for every safeWriteJson caller, and exactly the redirection the guard above it exists to prevent. The two paths are now separate on purpose: - the LOCK stays keyed to the resolved referent, because proper-lockfile keys by the given path and an alias plus its referent would otherwise take two locks for one file (a concurrent merge through both aliases could read the same JSON and overwrite one update); - the PUBLISH stays on the caller-named path. The commit is a rename, and a rename replaces the directory entry rather than writing through a link, so an alias the caller never chose gets replaced and its referent never receives the payload. Staging moves beside the named path, which is the directory the rename lands in, so the rename stays on one volume. Regression Evidence: the ancestor refusal was running unconditionally instead of only for refuseSymlinkTarget callers - it was spliced after the guard's closing brace. It is now inside the guard, and 'does not apply the ancestor refusal when refuseSymlinkTarget is not set' pins the scope. Tests: 'stages beside the caller-named path and never publishes through the symlink' replaces the test that pinned the follow-the-link default; the lock test keeps its lock-key assertion and now asserts the payload lands on the named path with the referent untouched. Negative controls: moving the ancestor call back out of the guard -> exactly 1 failed (the option- scope test); aiming the commit rename at the resolved referent -> exactly 2 failed (the two symlink contract tests); restored -> 35 passed / 1 skipped. Local: safeWriteJson.test 35 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- src/utils/__tests__/safeWriteJson.test.ts | 56 +++++++++++++++-------- src/utils/safeWriteJson.ts | 36 +++++++++------ 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 1dd9760ec3..bfa72a02e1 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -565,36 +565,35 @@ describe("safeWriteJson", () => { expect(content).toEqual({ c: 3 }) }) - // The commit rename targets the symlink referent. The staged temp file must - // therefore be created beside the RESOLVED target — staging beside the link - // would make the commit rename fail with EXDEV when the referent is on - // another filesystem. (Real symlinks are unavailable in this CI lane, so the - // resolution is simulated by mocking fs.realpath the same way.) - test("stages the temp file beside the symlink referent and commits onto it", async () => { +// The commit rename is no-follow for the final component: it targets the path the caller +// named, so a link the caller never chose is REPLACED by the rename instead of receiving the +// payload. Staging therefore happens beside the named path - the same directory the rename +// lands in - which is also what keeps the rename on one volume. (Real symlinks are unavailable +// in this CI lane, so the alias is simulated by mocking fs.realpath.) + test("stages beside the caller-named path and never publishes through the symlink", async () => { const referentDir = path.join(tempDir, "referent") const linkDir = path.join(tempDir, "link") await fs.mkdir(referentDir, { recursive: true }) await fs.mkdir(linkDir, { recursive: true }) - // caller-visible path (the link) vs the resolved referent path + // caller-visible path (the link) vs the referent a resolution would hand back const callerPath = path.join(linkDir, "test-file.json") const referentPath = path.join(referentDir, "test-file.json") - // Seed the RESOLVED referent with real content (via the actual fs) so the - // write exercises replacement of an EXISTING referent: the lock is - // acquired on the caller path (realpath:false, which may be absent) while - // the backup + commit happen on the referent. + // Seed the referent with real content: if the publish followed the alias, this is what + // would be replaced. await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: true })) vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) await safeWriteJson(callerPath, { after: true }) - // the temp file was created next to the resolved referent, NOT beside the link + // the temp file was created next to the named path, NOT beside the referent const tempPaths = vi.mocked(fsSyncActual.createWriteStream).mock.calls.map((call) => String(call[0])) - expect(tempPaths.some((p) => p.startsWith(referentDir + path.sep) && p.includes(".new_"))).toBe(true) - expect(tempPaths.some((p) => p.startsWith(linkDir + path.sep))).toBe(false) + expect(tempPaths.some((p) => p.startsWith(linkDir + path.sep) && p.includes(".new_"))).toBe(true) + expect(tempPaths.some((p) => p.startsWith(referentDir + path.sep))).toBe(false) - // the content was committed onto the referent - expect(await readFileContent(referentPath)).toEqual({ after: true }) + // the payload landed on the path the caller named; the referent is untouched + expect(await readFileContent(callerPath)).toEqual({ after: true }) + expect(await readFileContent(referentPath)).toEqual({ seed: true }) }) // proper-lockfile with realpath:false keys the lock by the given path, so a @@ -660,9 +659,11 @@ describe("safeWriteJson", () => { // The lock was keyed by the resolved referent — every alias shares it. expect(lockMock).toHaveBeenCalledTimes(1) expect(String(lockMockFn.mock.calls[0][0])).toBe(referentPath) - // The merge read the referent's content through that single lock. - expect(mergeFn).toHaveBeenCalledWith({ seed: 1 }, { added: true }) - expect(await readFileContent(referentPath)).toEqual({ seed: 1, added: true }) + // The merge reads the path the caller named, not the referent: the publish is no-follow, + // so the referent's content is never read or written through the alias. + expect(mergeFn).toHaveBeenCalledTimes(1) + expect(await readFileContent(callerPath)).toEqual({ added: true }) + expect(await readFileContent(referentPath)).toEqual({ seed: 1 }) // The compromise callback and the failed release were logged, not thrown. expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("was compromised"), expect.any(Error)) expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -860,6 +861,23 @@ describe("safeWriteJson", () => { expect(await readFileContent(target)).toEqual({ own: true }) }) + test("does not apply the ancestor refusal when refuseSymlinkTarget is not set", async () => { + const target = path.join(tempDir, "default-ancestor.json") + await fsPromisesActuals.writeFile!(target, JSON.stringify({ own: true })) + const asLink = { isSymbolicLink: () => true } as unknown as fsSyncActual.Stats + const asFile = { isSymbolicLink: () => false } as unknown as fsSyncActual.Stats + // The ancestor walk is part of the credential-write policy, not a global behavior change: + // callers that did not opt into refuseSymlinkTarget keep their previous semantics. + vi.spyOn(fs, "lstat").mockImplementation(((p: unknown) => { + return Promise.resolve(String(p) === tempDir ? asLink : asFile) + }) as unknown as typeof fs.lstat) + + await safeWriteJson(target, { written: true }) + + vi.restoreAllMocks() + expect(await readFileContent(target)).toEqual({ written: true }) + }) + test("fails closed when an ancestor directory cannot be inspected", async () => { const target = path.join(tempDir, "ancestor-eacces.json") await fsPromisesActuals.writeFile!(target, JSON.stringify({ own: true })) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 757bb3da2b..dd18eb892a 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -126,12 +126,13 @@ async function _refuseSymlinkedAncestors(absoluteFilePath: string): Promise => } if (nowStat?.isSymbolicLink()) { throw new Error( - `safeWriteJson: refusing to write through the symlink now at ${absoluteFilePath} (${stage}); the payload would land at ${resolvedTargetPath}, a destination the caller never chose.`, + `safeWriteJson: refusing to write through the symlink now at ${absoluteFilePath} (${stage}); the payload would land at ${publishTargetPath}, a destination the caller never chose.`, ) } } @@ -188,7 +198,7 @@ if (options?.refuseSymlinkTarget) { // resolved publish target, which is the key every other writer to this file // uses. If acquisition fails it throws immediately, so the finally block never // releases an unacquired lock. - releaseLock = await acquireFileLock(resolvedTargetPath) + releaseLock = await acquireFileLock(lockTargetPath) // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null @@ -199,7 +209,7 @@ if (options?.refuseSymlinkTarget) { if (options?.merge) { let existing: unknown = null try { - existing = JSON.parse(await fs.readFile(resolvedTargetPath, "utf8")) + existing = JSON.parse(await fs.readFile(publishTargetPath, "utf8")) } catch (error: unknown) { const code = error && typeof error === "object" && "code" in error ? (error as { code: string }).code : undefined @@ -215,7 +225,7 @@ if (options?.refuseSymlinkTarget) { // a symlink; resolvedTargetPath above): safeWriteText commits by renaming // onto that referent, and a rename across filesystems would fail with EXDEV. actualTempNewFilePath = path.join( - path.dirname(resolvedTargetPath), + path.dirname(publishTargetPath), ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) @@ -242,13 +252,13 @@ if (options?.refuseSymlinkTarget) { // caller asked for is staged and the commit rename follows the resolved path. await assertFinalComponentNotReplaced("before publication") } - await safeWriteText(resolvedTargetPath, "", textOptions) + await safeWriteText(publishTargetPath, "", textOptions) // If we reach here, the new file is successfully in place and any // backup has already been handled by safeWriteText. actualTempNewFilePath = null } catch (originalError) { - console.error(`Operation failed for ${resolvedTargetPath}: [Original Error Caught]`, originalError) + console.error(`Operation failed for ${publishTargetPath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath @@ -273,7 +283,7 @@ if (options?.refuseSymlinkTarget) { try { await releaseLock() } catch (unlockError) { - console.error(`Failed to release lock for ${resolvedTargetPath}:`, unlockError) + console.error(`Failed to release lock for ${lockTargetPath}:`, unlockError) } } } From f1b2ae4ddca8f668972f67b91c100a26a4e25088 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 05:43:33 +0800 Subject: [PATCH 26/39] test(tools): cover the omitted kind argument in guardedWrite, and say which pin is load-bearing Regression Evidence: every call in guardedWrite.spec.ts passed kind explicitly, so the default GuardedWriteKind = "update" was never exercised. - 'defaults kind to update when the caller omits the argument' (unobserved, absent file): the omitted argument must take the create-if-absent guard and publish. - 'uses the update guard when the caller omits the argument on an observed file': documents the observed path, which the checklist asked for. Negative controls, as measured: changing the default to "edit" -> exactly 1 failed (the unobserved test, which then hits the read-first guard and rejects). Changing it to "create" -> 0 failed, and that is correct rather than a gap: on the unobserved path create and update both route to createIfAbsent, and on the observed path the guard is chosen by the observation, not by kind. The observed-path test is therefore documentation, not a pin - stated in the test's own comment so nobody later mistakes it for coverage of the default. Local: guardedWrite.spec 31 passed; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- src/core/tools/__tests__/guardedWrite.spec.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 5fbe97b723..dd8f74e263 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -86,6 +86,21 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) + it("defaults kind to update when the caller omits the argument", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + // Every other call in this file passes kind explicitly, so the default was never exercised. + // The default only matters on the UNOBSERVED path: once a file is observed the guard is + // chosen by the observation, not by kind. Omitting it must take the create-if-absent guard + // (which publishes); a default of "edit" would hit the read-first guard and reject instead. + await guardedWrite(task, "new-file.txt", "hello") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello", { + verifyBeforeCommit: expect.any(Function), + }) + }) + it("fails with the read-first remediation when the file exists - nothing published", async () => { mockedFsAccess.mockResolvedValue(undefined) const task = createMockTask() @@ -187,6 +202,24 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) + it("uses the update guard when the caller omits the argument on an observed file", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // Documents the contract for the omitted argument on the observed path. Note that this case + // is NOT load-bearing: once a file is observed the guard is chosen by the observation, so + // changing the default does not change this call. The load-bearing pin for the default is + // 'defaults kind to update when the caller omits the argument' on the unobserved path. + await guardedWrite(task, "doc.txt", "new content") + + expect(mockedComputeVersionToken).toHaveBeenCalledWith(abs("doc.txt")) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content", { + verifyBeforeCommit: expect.any(Function), + }) + }) + it("fails with the stale remediation suffix when the version moved", async () => { const reg = new ObservationRegistry() reg.observe(abs("kept.txt"), "v1") From 17c53e0b12ef351203ca1d1c36c21ac74c1d833f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 06:31:02 +0800 Subject: [PATCH 27/39] fix(tools): stop a queued guarded write once its task has been aborted or disposed Lifecycle: guardedWrite serialises writes per path through the module-level pendingChains map. A link can reach the head of that chain long after the task that issued it is gone - the panel closed, the task switched, or abortTask landed while another write held the path. Running it then publishes for a task that no longer serves requests and re-observes the path, so the queued callback now checks task.abort before any guard or publish and throws CancelledTaskWriteError. Task.dispose() sets the same abort flag that abortTask() sets (Task.ts:3355), which is why that single flag is the disposal signal visible at this layer. The class and the check match the S4b wiring unit (#1408) so the two units agree on the shape. Tests: 'drops a queued write when the task is aborted while it waits behind another write' (two writes to one path, the first held open in the publish, abort lands while the second is queued - only the first publishes) and 'refuses an already-cancelled task's write before any I/O'. Negative control: removing the in-queue check -> exactly those 2 failed; restored -> 33 passed. Not run this round: a local Stryker preflight. The guard is pinned by the negative control above; the chain-wide mutation-diff lane is red from the 500 changed-executable-line cap, remedy tracked on easonLiangWorldedtech/Zoo-Code#41 (6024918865 / 6025443324). Local: guardedWrite.spec 33 passed; src-level tsc --noEmit 0 (re-run after the final edit); eslint 0 err / 0 warn on both files; eslint-suppressions.json untouched. --- src/core/tools/__tests__/guardedWrite.spec.ts | 44 +++++++++++++++++++ src/core/tools/guardedWrite.ts | 26 +++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index dd8f74e263..89320b56d5 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -592,3 +592,47 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) }) + +describe("task cancellation (S4a, epic #1375)", () => { + // The file's shared beforeEach lives inside the main describe, so this top-level one needs its + // own resets - otherwise assertions here see the previous test's recorded calls. + beforeEach(() => { + mockedSafeWriteText.mockClear() + mockedFsAccess.mockClear() + mockedComputeVersionToken.mockClear() + mockedFsAccess.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + }) + + it("drops a queued write when the task is aborted while it waits behind another write", async () => { + let releaseFirst: () => void = () => {} + const firstGate = new Promise(function (resolve) { + releaseFirst = resolve + }) + mockedSafeWriteText.mockImplementationOnce(async () => { + await firstGate + }) + const task = createMockTask() + const first = guardedWrite(task, "queued.txt", "first", "create") + const second = guardedWrite(task, "queued.txt", "second", "create") + // Let the first link enter the publish (the chain runs on microtasks) before the + // disposal lands: Task.dispose() sets task.abort while the second write is still + // queued behind it. + await new Promise(function (resolve) { + setImmediate(resolve) + }) + task.abort = true + releaseFirst() + await first + await expect(second).rejects.toThrow(/was cancelled/) + // Only the first write published; the cancelled one touched nothing. + expect(mockedSafeWriteText.mock.calls.map(function (call) { return call[1] })).toEqual(["first"]) + }) + + it("refuses an already-cancelled task's write before any I/O", async () => { + const task = createMockTask() + task.abort = true + await expect(guardedWrite(task, "gone.txt", "x", "create")).rejects.toThrow(/was cancelled/) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 4e4cb9af40..a0cc20e020 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -294,6 +294,24 @@ function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { return path.resolve(task.cwd, relPathOrAbsolute) } +/** + * A queued guarded write reached the head of its path's chain after the task that + * issued it had already been aborted or disposed. Task.dispose() sets the same + * `abort` flag that abortTask() sets, so that flag is the disposal signal visible + * at this layer. + */ +export class CancelledTaskWriteError extends Error { + readonly path: string + constructor(absolutePath: string) { + super( + `Guarded write for ${absolutePath} was cancelled -- the task was aborted or disposed ` + + "before its turn in the per-path write queue; nothing was published.", + ) + this.name = "CancelledTaskWriteError" + this.path = absolutePath + } +} + /** * Guarded write entry point. * @@ -319,6 +337,14 @@ export async function guardedWrite( const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) return enqueue(absolutePath, async () => { + // The link can reach the head of the queue long after the task that issued it is + // gone (panel closed, task switched, abort landed while another write held the + // path). Running it then would publish for a task that no longer serves requests + // and re-observe the path, so the write stops here instead. + if (task.abort) { + throw new CancelledTaskWriteError(absolutePath) + } + const obs = task.observationRegistry.get(absolutePath) if (obs === undefined) { From 509208eee26c00b4d51d919609f211f68cd15c9d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 11:36:35 +0800 Subject: [PATCH 28/39] test(file-safety): cover parent-directory creation and its failure paths safeWriteText creates the parent with fs.mkdir(recursive) and verifies it with fs.access before any staging, and the focused suite did not cover that behaviour or its failures. Three tests: a missing nested parent is created and both calls run before the staging open (asserted through the mock invocation order); an mkdir failure and an access failure each surface and stop the write before any rename. Negative controls as measured: commenting out the mkdir call turns exactly two tests red (the mkdir pin and its failure test); commenting out the access call turns three red (the pin, the access failure test, and an existing backup:true test that also asserts access errors propagate). Local: safeWriteText.spec 43 passed; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../__tests__/safeWriteText.spec.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a4e52a7e4f..44de4f3d37 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -391,6 +391,37 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) }) + it("creates a missing parent directory before staging", async () => { + const targetPath = "/tmp/test-dir/nested/deeper/target.txt" + const dirPath = "/tmp/test-dir/nested/deeper" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data") + + expect(fs.mkdir).toHaveBeenCalledWith(dirPath, { recursive: true }) + expect(fs.access).toHaveBeenCalledWith(dirPath) + expect(vi.mocked(fs.mkdir).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(fsSync.openSync).mock.invocationCallOrder[0]) + }) + + it("surfaces a parent directory creation failure before any staging", async () => { + const targetPath = "/tmp/test-dir/nested/deeper/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fs.mkdir).mockRejectedValue(Object.assign(new Error("EACCES mkdir"), { code: "EACCES" })) + + await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EACCES mkdir") + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("surfaces a parent directory access failure before any staging", async () => { + const targetPath = "/tmp/test-dir/nested/deeper/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fs.access).mockRejectedValue(Object.assign(new Error("EACCES access"), { code: "EACCES" })) + + await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EACCES access") + expect(fs.rename).not.toHaveBeenCalled() + }) + it("win32 DACL: when target does not exist, no save/restore/dump", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) From 55d69c1fdd628fb9c4e7902676c640daf14add26 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 19:02:43 +0800 Subject: [PATCH 29/39] style: format the MCP hub spec the way the repository formatter wants it Pre-existing over-width lines in this spec, untouched by the review threads: the formatter wants the mocked file payloads and the connection fixtures broken across lines. Shape, reported honestly: 70 lines added, 10 removed. A whitespace-insensitive diff is the same size, so -w proves nothing here - re-wrapping a call across lines changes the line count rather than only trailing whitespace. The claim that is actually verified is stated in the terms that make it checkable: with every run of whitespace removed and the trailing commas the formatter adds before a closing brace, bracket or paren removed, the file before and after this commit are byte-identical. So the change is line wrapping plus those trailing commas, and no test name, assertion, or fixture value differs. --- src/services/mcp/__tests__/McpHub.spec.ts | 80 ++++++++++++++++++++--- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 3313a2dece..735c2280ab 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -1054,11 +1054,21 @@ describe("McpHub", () => { describe("symlink policy on MCP settings writes", () => { it("refuses a symlinked target when deleteServer omits the source but the server is a project server", async () => { - vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"] } } })) + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"] } }, + }), + ) mcpHub.connections = [ { type: "connected", - server: { name: "test-server", type: "stdio", command: "node", args: ["test.js"], source: "project" }, + server: { + name: "test-server", + type: "stdio", + command: "node", + args: ["test.js"], + source: "project", + }, client: {}, transport: {}, } as unknown as ConnectedMcpConnection, @@ -1074,11 +1084,23 @@ describe("McpHub", () => { }) it("refuses a symlinked target for a project toggleToolAlwaysAllow write", async () => { - vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"], alwaysAllow: [] } } })) + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + mcpServers: { + "test-server": { type: "stdio", command: "node", args: ["test.js"], alwaysAllow: [] }, + }, + }), + ) mcpHub.connections = [ { type: "connected", - server: { name: "test-server", type: "stdio", command: "node", args: ["test.js"], source: "project" }, + server: { + name: "test-server", + type: "stdio", + command: "node", + args: ["test.js"], + source: "project", + }, client: {}, transport: {}, } as unknown as ConnectedMcpConnection, @@ -1091,11 +1113,23 @@ describe("McpHub", () => { }) it("leaves the global toggleToolAlwaysAllow write free to follow a symlink", async () => { - vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"], alwaysAllow: [] } } })) + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + mcpServers: { + "test-server": { type: "stdio", command: "node", args: ["test.js"], alwaysAllow: [] }, + }, + }), + ) mcpHub.connections = [ { type: "connected", - server: { name: "test-server", type: "stdio", command: "node", args: ["test.js"], source: "global" }, + server: { + name: "test-server", + type: "stdio", + command: "node", + args: ["test.js"], + source: "global", + }, client: {}, transport: {}, } as unknown as ConnectedMcpConnection, @@ -1829,14 +1863,27 @@ describe("McpHub", () => { describe("updateServerTimeout", () => { it("refuses a symlinked target for a project-scoped timeout write", async () => { - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"], timeout: 60 } } })) + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + mcpServers: { + "test-server": { type: "stdio", command: "node", args: ["test.js"], timeout: 60 }, + }, + }), + ) // The SDK client/transport are never touched by this write path (it reads only // server.name and server.source), so the literal is projected onto the connection // type through unknown rather than adding another `as any` to this file. mcpHub.connections = [ { type: "connected", - server: { name: "test-server", type: "stdio", command: "node", args: ["test.js"], timeout: 60, source: "project" }, + server: { + name: "test-server", + type: "stdio", + command: "node", + args: ["test.js"], + timeout: 60, + source: "project", + }, client: {}, transport: {}, } as unknown as ConnectedMcpConnection, @@ -1852,11 +1899,24 @@ describe("McpHub", () => { }) it("still follows a symlink for the global settings write", async () => { - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ mcpServers: { "test-server": { type: "stdio", command: "node", args: ["test.js"], timeout: 60 } } })) + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + mcpServers: { + "test-server": { type: "stdio", command: "node", args: ["test.js"], timeout: 60 }, + }, + }), + ) mcpHub.connections = [ { type: "connected", - server: { name: "test-server", type: "stdio", command: "node", args: ["test.js"], timeout: 60, source: "global" }, + server: { + name: "test-server", + type: "stdio", + command: "node", + args: ["test.js"], + timeout: 60, + source: "global", + }, client: {}, transport: {}, } as unknown as ConnectedMcpConnection, From a37f28447e911cb133d0b0d1082eafa8603cdd1f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 19:13:54 +0800 Subject: [PATCH 30/39] test(mcp,task): make two test names say what their assertions check, and close a claim with an assertion Two MCP hub specs were named after symlink following - one for the global always-allow write, one for the global settings write - while each asserts only that the write was issued without the symlink refusal option. The names now say that. The comment beside one of them also described what the filesystem does with a linked global file, which these tests never observe; it says what the writer opts into instead. The task disposal spec carried the same two-line comment twice, and ended with a comment asserting that disposal closes the observation registry rather than clearing the map, with nothing checking it. The duplicate is gone and the claim is an assertion on the registry's closed state. That assertion is load-bearing, and the check is the honest one: flipping the expected state to false turns exactly that test red, so it is not a vacuous getter probe. The mutant was restored byte-exact. Both specs pass, 251 tests; eslint over each touched file with the warnings cap at zero exits 0 and the suppressions ledger is untouched. --- src/core/task/__tests__/Task.spec.ts | 6 +++--- src/services/mcp/__tests__/McpHub.spec.ts | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 8b06392d1a..8c12fa4782 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1436,10 +1436,10 @@ describe("Cline", () => { // A disposed task cannot serve another guarded write, so its observed paths // (version token + timestamp each) must not stay reachable for the host lifetime. expect(task.observationRegistry.get("/workspace/a.ts")).toBeUndefined() - // A disposed task cannot serve another guarded write, so its observed paths - // (version token + timestamp each) must not stay reachable for the host lifetime. expect(task.observationRegistry.get("/workspace/b.ts")).toBeUndefined() - // disposeOnce() closes the registry rather than only clearing the map. + // Closing is what makes a later observation refuse, so it is asserted rather than + // left to the test name: clearing the map alone would look identical from here. + expect(task.observationRegistry.isClosed).toBe(true) }) it("refuses an observation recorded after the task was disposed", async () => { diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 735c2280ab..234fa431ff 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -1112,7 +1112,7 @@ describe("McpHub", () => { expect(write && write[2]).toEqual(expect.objectContaining({ refuseSymlinkTarget: true })) }) - it("leaves the global toggleToolAlwaysAllow write free to follow a symlink", async () => { + it("leaves the global toggleToolAlwaysAllow write without a symlink refusal", async () => { vi.mocked(fs.readFile).mockResolvedValue( JSON.stringify({ mcpServers: { @@ -1898,7 +1898,7 @@ describe("McpHub", () => { expect(write && write[2]).toEqual(expect.objectContaining({ refuseSymlinkTarget: true })) }) - it("still follows a symlink for the global settings write", async () => { + it("leaves the global settings write without a symlink refusal", async () => { vi.mocked(fs.readFile).mockResolvedValueOnce( JSON.stringify({ mcpServers: { @@ -1925,7 +1925,8 @@ describe("McpHub", () => { await mcpHub.updateServerTimeout("test-server", 120) // The global file lives in the extension global storage and users legitimately link - // it, so it keeps the resolve-and-follow behaviour. + // it, so this writer does not opt into the symlink refusal; what the assertion + // covers is that the option is absent, not what the filesystem then does. const write = vi.mocked(safeWriteJson).mock.calls.at(-1) expect(write && write[2]).not.toHaveProperty("refuseSymlinkTarget") }) From 5e66c2f8b5e7438e3c32119035adbbd7edc9db6c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 19:46:11 +0800 Subject: [PATCH 31/39] test(read-file): check that both stats around a read ask for the bigint variant The observation token is built from nanosecond fields, so the read has to request the bigint stat variant on both the stat taken before the read and the one taken after it. Nothing asserted that: the mocked stat answers any options with bigint fields, so the option could disappear and the tests would still pass, while on a real filesystem the version token would read mtimeNs and ctimeNs as undefined and the read itself would fail inside its own try block. The assertion counts the calls that asked for the variant and expects two, per read path. That shape is what makes it load-bearing, and it was found by running the negative controls rather than by trusting the first draft: an earlier version asserted only that some call for the file carried the option, and it survived all four mutants, because the read makes two such calls and one surviving call satisfies it. With the count, dropping the option from the pre-read or the post-read stat turns red exactly the test for that path - four mutants, four kills: native pre-read, native post-read, legacy pre-read, legacy post-read. Every mutant was restored byte-exact. Verification: the spec passes, 88 tests; eslint over the touched file with the warnings cap at zero exits 0; the suppressions ledger is untouched; the file is prettier-clean under the repository configuration. --- src/core/tools/__tests__/readFileTool.spec.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 8047405ac1..2e3faa9548 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -1608,6 +1608,17 @@ describe("ReadFileTool", () => { expect(calledPath).toContain("existing.ts") expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + // The token is built from nanosecond fields, so both stats the read takes around the + // file must be the bigint variant. Counting the calls that asked for it is what makes + // this load-bearing: the mocked stat answers any options with bigint fields, so a + // looser assertion would pass while one of the two calls silently dropped the option + // and threw on a real filesystem, where mtimeNs and ctimeNs are undefined. + const bigintStats = mockedFsStat.mock.calls.filter((call) => { + const options = call[1] as { bigint?: boolean } | undefined + return options?.bigint === true && String(call[0]).includes("existing.ts") + }) + expect(bigintStats).toHaveLength(2) + // Verify get() returns the same data using the spy-captured key. const obs = reg.get(calledPath) expect(obs).toBeDefined() @@ -1665,6 +1676,19 @@ describe("ReadFileTool", () => { const [calledPath, calledVersion] = observeSpy.mock.calls[0] expect(calledPath).toContain("legacy.ts") expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + + // Same requirement on the legacy multi-file path, which resolves its own stats. + + // The token is built from nanosecond fields, so both stats the read takes around the + // file must be the bigint variant. Counting the calls that asked for it is what makes + // this load-bearing: the mocked stat answers any options with bigint fields, so a + // looser assertion would pass while one of the two calls silently dropped the option + // and threw on a real filesystem, where mtimeNs and ctimeNs are undefined. + const bigintStats = mockedFsStat.mock.calls.filter((call) => { + const options = call[1] as { bigint?: boolean } | undefined + return options?.bigint === true && String(call[0]).includes("legacy.ts") + }) + expect(bigintStats).toHaveLength(2) }) it("does not observe when the file mutates between the pre-read and post-read stats", async () => { From ca77e38b8cc53a1d44bd7822115735835cb394a2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 20:05:28 +0800 Subject: [PATCH 32/39] test(guarded-write,safe-write): stop names and describe blocks claiming what nothing checks The guarded-write test was named after eviction and its comments said the settled chain entry is evicted, while the assertions cover submission order and the publish count only. Removing the eviction callbacks from the enqueue path leaves both assertions standing, which the mutation report already showed as survivors. The name now says ordering, and the comments say what the test can see: whether the settled entry has left the path map is not observable from the test, so it is no longer claimed. The alternative - a test-only accessor for the pending-chain count - would check eviction properly but needs a production export, which is a different kind of change than this one. The three parent-directory tests were sitting inside the win32 DACL describe, at a different indentation from the tests around them and with nothing DACL about them. They now live in a sibling describe named for what they do. The move is verified structurally rather than by the pass alone: the file holds the same 42 tests before and after, and the new describe sits at brace depth one, a sibling of the DACL block rather than nested in it. Verification: both specs pass, 76 tests; eslint over each touched file with the warnings cap at zero exits 0; the suppressions ledger is untouched; both files are prettier-clean under the repository configuration. --- src/core/tools/__tests__/guardedWrite.spec.ts | 17 ++-- .../__tests__/safeWriteText.spec.ts | 77 +++++++++---------- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 89320b56d5..ed8a9b1208 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -387,18 +387,19 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) - it("evicts settled chain entries - a later write still serializes in order", async () => { + it("a write submitted after an earlier one settled still serializes in submission order", async () => { const reg = new ObservationRegistry() reg.observe(abs("evict.txt"), "v1") mockedComputeVersionToken.mockResolvedValue("v1") const task = createMockTask({ observationRegistry: reg }) - // A first write settles; its chain entry is evicted with it. + // A first write settles before the next two are submitted. const p1 = guardedWrite(task, "evict.txt", "first", "update") await expect(p1).resolves.toBeUndefined() - // Two rapid writes submitted after the eviction must still run one - // at a time in submission order (the eviction must not drop the + // Two rapid writes submitted after that settlement must still run one at a + // time in submission order. Whether the settled entry has been evicted from the + // path map is not observable from here, so the test does not claim it. // chain for in-flight or just-enqueued links). const order: string[] = [] mockedSafeWriteText.mockImplementation(async (_path: string, content: string) => { @@ -625,7 +626,11 @@ describe("task cancellation (S4a, epic #1375)", () => { await first await expect(second).rejects.toThrow(/was cancelled/) // Only the first write published; the cancelled one touched nothing. - expect(mockedSafeWriteText.mock.calls.map(function (call) { return call[1] })).toEqual(["first"]) + expect( + mockedSafeWriteText.mock.calls.map(function (call) { + return call[1] + }), + ).toEqual(["first"]) }) it("refuses an already-cancelled task's write before any I/O", async () => { @@ -635,4 +640,4 @@ describe("task cancellation (S4a, epic #1375)", () => { expect(mockedSafeWriteText).not.toHaveBeenCalled() expect(mockedFsAccess).not.toHaveBeenCalled() }) -}) \ No newline at end of file +}) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 44de4f3d37..cae522e6d4 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -391,37 +391,6 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) }) - it("creates a missing parent directory before staging", async () => { - const targetPath = "/tmp/test-dir/nested/deeper/target.txt" - const dirPath = "/tmp/test-dir/nested/deeper" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - - await safeWriteText(targetPath, "data") - - expect(fs.mkdir).toHaveBeenCalledWith(dirPath, { recursive: true }) - expect(fs.access).toHaveBeenCalledWith(dirPath) - expect(vi.mocked(fs.mkdir).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(fsSync.openSync).mock.invocationCallOrder[0]) - }) - - it("surfaces a parent directory creation failure before any staging", async () => { - const targetPath = "/tmp/test-dir/nested/deeper/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fs.mkdir).mockRejectedValue(Object.assign(new Error("EACCES mkdir"), { code: "EACCES" })) - - await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EACCES mkdir") - expect(fs.rename).not.toHaveBeenCalled() - }) - - it("surfaces a parent directory access failure before any staging", async () => { - const targetPath = "/tmp/test-dir/nested/deeper/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fs.access).mockRejectedValue(Object.assign(new Error("EACCES access"), { code: "EACCES" })) - - await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EACCES access") - expect(fs.rename).not.toHaveBeenCalled() - }) - it("win32 DACL: when target does not exist, no save/restore/dump", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -443,6 +412,41 @@ describe("safeWriteText", () => { }) }) + describe("parent directory creation", () => { + it("creates a missing parent directory before staging", async () => { + const targetPath = "/tmp/test-dir/nested/deeper/target.txt" + const dirPath = "/tmp/test-dir/nested/deeper" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data") + + expect(fs.mkdir).toHaveBeenCalledWith(dirPath, { recursive: true }) + expect(fs.access).toHaveBeenCalledWith(dirPath) + expect(vi.mocked(fs.mkdir).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fsSync.openSync).mock.invocationCallOrder[0], + ) + }) + + it("surfaces a parent directory creation failure before any staging", async () => { + const targetPath = "/tmp/test-dir/nested/deeper/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fs.mkdir).mockRejectedValue(Object.assign(new Error("EACCES mkdir"), { code: "EACCES" })) + + await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EACCES mkdir") + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("surfaces a parent directory access failure before any staging", async () => { + const targetPath = "/tmp/test-dir/nested/deeper/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fs.access).mockRejectedValue(Object.assign(new Error("EACCES access"), { code: "EACCES" })) + + await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EACCES access") + expect(fs.rename).not.toHaveBeenCalled() + }) + }) + // ── Test 6: pre-written temp path (tempPath option) ────────────────────── describe("pre-written temp path", () => { @@ -523,11 +527,7 @@ describe("safeWriteText", () => { // umask 077) and publish a group/world-readable file for a target that never // existed, so the mask has to stay in charge. expect(fsSync.fchmodSync).not.toHaveBeenCalled() - expect(fsSync.openSync).toHaveBeenCalledWith( - expect.stringContaining("safeWriteText_"), - "w", - 0o644, - ) + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) @@ -555,10 +555,7 @@ describe("safeWriteText", () => { await safeWriteText(targetPath, "data", { platform: "linux", targetPathIsResolved: true }) expect(fs.realpath).not.toHaveBeenCalled() - expect(fs.rename).toHaveBeenCalledWith( - expect.stringContaining("safeWriteText_"), - path.resolve(targetPath), - ) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), path.resolve(targetPath)) }) it("opens the temp before applying a read-only target's mode (0o444 does not block the open)", async () => { From a54e21a8bbdaacebd30f8590a430bea125ce9db6 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 20:34:30 +0800 Subject: [PATCH 33/39] style: format the safe-write JSON module the way the repository formatter wants it The file was already not formatter-clean at the head this commit sits on: two comment blocks and a function body sit at a shallower indentation than the code around them, which is also what makes the module read as if those blocks belonged to a different scope. Shape, reported honestly: the plain diff is given below, and a whitespace-insensitive diff is the same size, so -w proves nothing here - re-wrapping and re-indenting change lines, not intra-line whitespace. The claim that is checkable is that with every run of whitespace removed and the trailing commas the formatter adds before a closing brace, bracket or paren removed, the file before and after this commit are byte-identical: no statement, string, or identifier differs. --- src/utils/safeWriteJson.ts | 144 +++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 71 deletions(-) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index dd18eb892a..8a38b4579b 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -3,10 +3,8 @@ import * as fsSync from "fs" import * as path from "path" import { JsonStreamStringify } from "json-stream-stringify" - import { resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../services/file-safety/safeWriteText" - import { acquireFileLock } from "./fileLock" /** @@ -74,36 +72,39 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw dirError } -// Every existing ancestor must be checked too, not just the final component: a symlinked -// directory above the target redirects the payload without leaving a trace on the target path -// itself (e.g. /.roo -> a directory outside the workspace). Only ENOENT stops the -// walk - a missing ancestor means nothing deeper exists to be a link. Any other inspection -// error fails closed, because "could not inspect" is not evidence that the path is safe. -async function _refuseSymlinkedAncestors(absoluteFilePath: string): Promise { - let current = path.dirname(absoluteFilePath) - for (;;) { - let st: fsSync.Stats - try { - st = await fs.lstat(current) - } catch (error: unknown) { - const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined - if (code === "ENOENT") { + // Every existing ancestor must be checked too, not just the final component: a symlinked + // directory above the target redirects the payload without leaving a trace on the target path + // itself (e.g. /.roo -> a directory outside the workspace). Only ENOENT stops the + // walk - a missing ancestor means nothing deeper exists to be a link. Any other inspection + // error fails closed, because "could not inspect" is not evidence that the path is safe. + async function _refuseSymlinkedAncestors(absoluteFilePath: string): Promise { + let current = path.dirname(absoluteFilePath) + for (;;) { + let st: fsSync.Stats + try { + st = await fs.lstat(current) + } catch (error: unknown) { + const code = + error && typeof error === "object" && "code" in error + ? (error as { code?: string }).code + : undefined + if (code === "ENOENT") { + return + } + throw error + } + if (st.isSymbolicLink()) { + throw new Error( + `safeWriteJson: refusing to write to ${absoluteFilePath}: ${current} is a symlink, and the payload would be written outside the directory the caller named.`, + ) + } + const parent = path.dirname(current) + if (parent === current) { return } - throw error - } - if (st.isSymbolicLink()) { - throw new Error( - `safeWriteJson: refusing to write to ${absoluteFilePath}: ${current} is a symlink, and the payload would be written outside the directory the caller named.` - ) - } - const parent = path.dirname(current) - if (parent === current) { - return + current = parent } - current = parent } -} // A credential-bearing payload must not be redirected through a link the user // never chose: check the final path component before anything is resolved, @@ -113,7 +114,8 @@ async function _refuseSymlinkedAncestors(absoluteFilePath: string): Promise => { - // Fail closed: only ENOENT (nothing there that could be a link) is tolerated. A lstat - // failing for another reason - EACCES on the parent directory, for example - says - // nothing about whether the entry is safe, so the write stops instead of publishing - // blind through an unexamined destination. - let nowStat: fsSync.Stats | undefined - try { - nowStat = await fs.lstat(absoluteFilePath) - } catch (error: unknown) { - const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined - if (code !== "ENOENT") { - throw error + // The refusal above and this resolution are separate syscalls, so a local writer + // could replace the final component with a link in between; resolvedTargetPath + // would then describe a destination the caller never chose. Re-check the component + // the caller named - once here and again under the lock before publishing - so the + // refusal stays effective through publication. + const assertFinalComponentNotReplaced = async (stage: string): Promise => { + // Fail closed: only ENOENT (nothing there that could be a link) is tolerated. A lstat + // failing for another reason - EACCES on the parent directory, for example - says + // nothing about whether the entry is safe, so the write stops instead of publishing + // blind through an unexamined destination. + let nowStat: fsSync.Stats | undefined + try { + nowStat = await fs.lstat(absoluteFilePath) + } catch (error: unknown) { + const code = + error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined + if (code !== "ENOENT") { + throw error + } + } + if (nowStat?.isSymbolicLink()) { + throw new Error( + `safeWriteJson: refusing to write through the symlink now at ${absoluteFilePath} (${stage}); the payload would land at ${publishTargetPath}, a destination the caller never chose.`, + ) } } - if (nowStat?.isSymbolicLink()) { - throw new Error( - `safeWriteJson: refusing to write through the symlink now at ${absoluteFilePath} (${stage}); the payload would land at ${publishTargetPath}, a destination the caller never chose.`, - ) + if (options?.refuseSymlinkTarget) { + await assertFinalComponentNotReplaced("after resolution") } -} -if (options?.refuseSymlinkTarget) { - await assertFinalComponentNotReplaced("after resolution") -} // Acquire the lock before any file operations. `acquireFileLock` owns the // shared advisory lock protocol, so callers that lock the same path with it From 1d205cd9c2da69b02ad420083665005678d288f0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 20:45:20 +0800 Subject: [PATCH 34/39] docs(safe-write-json): say the lock and publication decision once, and drop a stale identifier Two comment blocks above the two path variables described the same decision in different words, one of them an older version of the other. They are now a single block that separates the two things the code actually separates: the lock is keyed to the resolved referent so every alias of one file queues together, while publication stays on the caller-named path because the commit is a rename and a rename replaces the directory entry rather than writing through a link. The comments also named a variable, resolvedTargetPath, that no longer exists - in two places. Both now speak of the resolved path instead. Behaviour is unchanged, and that is checked rather than asserted: with block comments and comment-only lines removed and all whitespace collapsed, the file before and after this commit is byte-identical. eslint over the file with the warnings cap at zero exits 0. --- src/utils/safeWriteJson.ts | 43 +++++++++++++++----------------------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 8a38b4579b..1632dbabe6 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -135,37 +135,28 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso await _refuseSymlinkedAncestors(absoluteFilePath) } - // Resolve the publish target BEFORE acquiring the lock: proper-lockfile keys - // the lock by the given path, so a symlink alias and its referent would - // otherwise take two distinct locks for one underlying file - a concurrent - // merge through both aliases could then read the same JSON and overwrite one - // update. Locking the resolved referent coordinates every alias through one - // lock. resolvePublishTarget tolerates a not-yet-existing file (it returns - // the given path on ENOENT), preserving the previous create-from-absent flow. - // With refuseSymlinkTarget the caller-named path IS the publish target: resolving - // it through realpath would hand back a referent the caller never chose if a link is - // planted between the refusal check and this resolution (the later lstat re-checks - // would then pass, because the link was already removed, while the publish still - // landed on the referent). Publishing onto the named path is no-follow for the final - // component: the commit is a rename, and rename replaces the directory entry rather - // than writing through a link, so an inserted link gets replaced and its referent - // never receives the payload. Every refuseSymlinkTarget writer keys its lock to the - // same named path, so the lock still serializes all writers to that entry. - // Two different paths, for two different jobs. The LOCK is keyed to the resolved referent, - // because proper-lockfile keys by the given path: an alias and its referent would otherwise - // take two locks for one underlying file, and a concurrent merge through both aliases could - // read the same JSON and overwrite one update. The PUBLISH stays on the caller-named path: - // the commit is a rename, and a rename replaces the directory entry rather than writing - // through a link, so a link the caller never chose gets replaced instead of receiving a - // credential payload. That is also the pre-existing safeWriteJson behavior - following the - // link here would be a new default for every caller. + // Two paths, for two different jobs: lock identity and publication destination. + // The LOCK is keyed to the resolved referent, because proper-lockfile keys by the path it + // is given: an alias and its referent would otherwise take two locks for one underlying + // file, and a concurrent merge through both aliases could read the same JSON and overwrite + // one update. Resolution happens before the lock for that reason, and resolvePublishTarget + // tolerates a not-yet-existing file (it returns the given path on ENOENT), preserving the + // create-from-absent flow. + // The PUBLISH stays on the caller-named path in both modes: the commit is a rename, and a + // rename replaces the directory entry rather than writing through a link, so a link the + // caller never chose gets replaced instead of receiving the payload. That is the + // pre-existing behaviour - publishing onto the referent instead would be a new default for + // every caller. With refuseSymlinkTarget the named path is also the lock path: resolving it + // here would hand back a referent the caller never chose if a link is planted between the + // refusal check and this resolution, and every such writer keys its lock to the same named + // path, so the lock still serializes all writers to that entry. const lockTargetPath = options?.refuseSymlinkTarget ? absoluteFilePath : await resolvePublishTarget(absoluteFilePath) const publishTargetPath = absoluteFilePath // The refusal above and this resolution are separate syscalls, so a local writer - // could replace the final component with a link in between; resolvedTargetPath + // could replace the final component with a link in between; the resolved path // would then describe a destination the caller never chose. Re-check the component // the caller named - once here and again under the lock before publishing - so the // refusal stays effective through publication. @@ -224,7 +215,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Step 1: Write data to a new temporary file via JSON streaming. // Stage it beside the *resolved* target (the symlink referent when the path is - // a symlink; resolvedTargetPath above): safeWriteText commits by renaming + // a symlink; the resolved path above): safeWriteText commits by renaming // onto that referent, and a rename across filesystems would fail with EXDEV. actualTempNewFilePath = path.join( path.dirname(publishTargetPath), From 777df3c33e2702d01d5ffd8ace1280b3ed6d84aa Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 20:53:29 +0800 Subject: [PATCH 35/39] docs(safe-write-json): state the default symlink contract as it is implemented Documentation change only - no behaviour moved. The option said that by default a symlink target is resolved and the write lands on its referent. The code does not do that: it keys the advisory lock to the resolved referent, but publishes onto the caller-named path, and because the commit is a rename, a symlink at that entry is replaced rather than followed. The staging comment carried the same stale claim and named a variable that no longer exists, in a way that implied staging sat beside the referent; staging is actually beside the path being published, which is what keeps the commit rename on one filesystem. This matters to a caller rather than being a wording nit: the global settings writer decides whether a linked file keeps its link by reading this contract, and the two statements were different answers to that question. The doc now separates the two things the code separates - lock identity and publication destination - in both places. Behaviour is unchanged and checked rather than asserted: with block comments and comment-only lines removed and all whitespace collapsed, the file before and after this commit is byte-identical. The safe-write JSON specs pass, 35 tests with one skipped; eslint over the file with the warnings cap at zero exits 0. --- src/utils/safeWriteJson.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 1632dbabe6..496d9dec43 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -32,8 +32,11 @@ export interface SafeWriteJsonOptions { /** * Refuse to publish through a symlink at the target path. * - * By default a symlink target is resolved and the write lands on its referent, - * which is what keeps every alias of one file behind a single advisory lock. + * Two things differ by default and should not be conflated: the advisory LOCK is keyed to + * the resolved referent, which is what keeps every alias of one file behind a single lock, + * while the PUBLISH stays on the path the caller named - the commit is a rename, and a + * rename replaces that directory entry, so a symlink there is replaced rather than followed + * and its referent never receives the payload. * That is the wrong default for a payload whose destination the user chose - * settings exports carry API credentials - where following a link they never * pointed at would write secrets into a file they did not pick. When this is @@ -214,9 +217,10 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } // Step 1: Write data to a new temporary file via JSON streaming. - // Stage it beside the *resolved* target (the symlink referent when the path is - // a symlink; the resolved path above): safeWriteText commits by renaming - // onto that referent, and a rename across filesystems would fail with EXDEV. + // Stage it beside the path being published (publishTargetPath, the caller-named one): + // safeWriteText commits by renaming onto that same entry, and a rename across + // filesystems would fail with EXDEV, so the staging directory has to be the one the + // commit lands in - not the directory of the lock key. actualTempNewFilePath = path.join( path.dirname(publishTargetPath), ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", From 3ee59e5623f1e8cffb9fad004ae46223d0b0ac86 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 00:41:28 +0800 Subject: [PATCH 36/39] fix(tools): re-observe the published version and capture observations at submission guardedWrite never wrote the post-publication version token back to the task's observation registry, so a follow-up write from the same task either failed stale against content that task had itself published or treated a file the task had just created as unobserved. Record the published token after every successful guard branch, inside the chain link, so the next queued link and the next submission see the refreshed token. A token failure after a successful publication is swallowed: the write itself succeeded, and the registry keeps the previous observation, which a follow-up write already handles (stale -> re-read). Pair the write-back with a submission-time capture of the observation: the queued content was derived from the read the captured observation records, so its CAS must compare against that token even when the registry moves on while the link waits behind another write. Without the capture the write-back rescues a queued write whose content is stale and silently loses the earlier write's update - the three existing concurrency tests that pin "the second write fails stale" go red under the write-back alone. The task-liveness brake (abort check) stays at execution time, where a link can reach the head of the queue long after its task is gone. Fold the observed-edit branch into the CAS branch: with the if/else-if chain in place its body is identical to the final else and the create probe short-circuits on kind, so the branch could not be told apart from falling through - every mutation of its condition was equivalent. An observed update on a vanished file now has its own test pinning that it neither probes existence nor recreates, and that only the CAS branch decides the write. Add regression tests for the re-observation after update, create and edit publications, the swallowed post-publication token failure, the submission-time token held across a registry refresh, and the vanished-file update contract. --- src/core/tools/__tests__/guardedWrite.spec.ts | 134 ++++++++++++++++++ src/core/tools/guardedWrite.ts | 64 ++++++--- 2 files changed, 180 insertions(+), 18 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index ed8a9b1208..3939fe59f5 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -273,6 +273,26 @@ describe("guardedWrite (S4a, epic #1375)", () => { ) expect(mockedSafeWriteText).not.toHaveBeenCalled() }) + + it("does not probe existence or recreate when an updated file vanished after the read", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + // The file vanished: the CAS token recomputation rejects with ENOENT, + // which the version guard reports as the deleted-file remediation. + mockedComputeVersionToken.mockRejectedValue({ code: "ENOENT" }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).rejects.toThrow( + "File was deleted after it was read -- the version recorded at read time (v1) no longer exists; re-read the file, then retry.", + ) + + // The existence probe is a "create"-only step: an "update" must not run + // it (the branch condition short-circuits on kind) and must not recreate + // the vanished file - only the CAS branch may decide the write. + expect(mockedFsAccess).not.toHaveBeenCalled() + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) }) describe("edit", () => { @@ -313,6 +333,82 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) + describe("observation refresh after publication", () => { + it("re-observes the published token so a second update without a re-read succeeds", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + mockedComputeVersionToken.mockResolvedValue("v1") + // The publish changes the on-disk state: the token moves to v2. + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + await guardedWrite(task, "doc.txt", "one", "update") + + // The registry records what this write published, not the pre-write read. + expect(reg.get(abs("doc.txt"))?.version).toBe("v2") + + // A second write from the same task without a re-read succeeds: its CAS + // compares against the content the task itself published. + await guardedWrite(task, "doc.txt", "two", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(reg.get(abs("doc.txt"))?.version).toBe("v2") + }) + + it("records an observation after creating an unobserved file so a follow-up write is not treated as unobserved", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("created-v1") + const task = createMockTask() + // After the create the file exists on disk. + mockedSafeWriteText.mockImplementation(async () => { + mockedFsAccess.mockResolvedValue(undefined) + }) + + await guardedWrite(task, "made.txt", "content", "create") + + expect(task.observationRegistry.get(abs("made.txt"))?.version).toBe("created-v1") + + // Without the write-back this second write would take the unobserved + // branch and fail "File already exists ... was not read before this + // write" for a file the same task had just created. + await guardedWrite(task, "made.txt", "revised", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + }) + + it("re-observes after a successful edit write", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + mockedComputeVersionToken.mockResolvedValue("v1") + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(reg.get(abs("doc.txt"))?.version).toBe("v2") + }) + + it("keeps a published write successful when the post-publication token computation fails", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + mockedComputeVersionToken + .mockResolvedValueOnce("v1") // the CAS check at entry passes + .mockRejectedValueOnce({ code: "EACCES" }) // the post-publication token fails + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).resolves.toBeUndefined() + + // The write published; the failed bookkeeping leaves the previous + // observation in place (a follow-up write then fails stale and re-reads). + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(reg.get(abs("doc.txt"))?.version).toBe("v1") + }) + }) + describe("concurrency: per-path FIFO chain", () => { it("two concurrent updates on one path - exactly one publishes, the other fails stale", async () => { const reg = new ObservationRegistry() @@ -338,6 +434,44 @@ describe("guardedWrite (S4a, epic #1375)", () => { ) }) + it("holds the submission-time token when the registry moves on while the write waits", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("queued-token.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + let releaseFirst: () => void = () => {} + const gate = new Promise((resolve) => { + releaseFirst = resolve + }) + let publishes = 0 + mockedSafeWriteText.mockImplementation(async (_path: string, content: string) => { + publishes += 1 + if (content === "first") { + await gate + } + // Each publish moves the on-disk token forward. + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + const first = guardedWrite(task, "queued-token.txt", "first", "update") + const second = guardedWrite(task, "queued-token.txt", "second", "update") + await new Promise((resolve) => setImmediate(resolve)) + // A re-read lands while both writes are still in flight: the registry + // moves to v2 behind the queued writes. + reg.observe(abs("queued-token.txt"), "v2") + releaseFirst() + + await first + // The second write was submitted against v1. The first write published + // over that state, so the queued write fails stale instead of publishing + // content derived from the v1 read over the v2 file. An execution-time + // registry lookup would rescue it with the refreshed token and lose the + // first write's update. + await expect(second).rejects.toThrow("Stale version") + expect(publishes).toBe(1) + }) + it("observed-absent then two concurrent creates - the second fails stale", async () => { const reg = new ObservationRegistry() reg.observe(abs("absent.txt"), "v1") // read before, file later vanished diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index a0cc20e020..fcf7d051fa 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -12,7 +12,10 @@ * * A per-absolute-path FIFO chain of tail promises orders concurrent * in-process writes to the same path: the first matching write wins, the rest - * fail stale. Observations come from the task's S2 ObservationRegistry. + * fail stale. Observations come from the task's S2 ObservationRegistry and are + * captured at submission time; a successful publication re-observes the path + * with the published token so sequential writes from one task compare against + * the content that task published, not against its pre-write read. * * Check-to-publication window (CodeRabbit review, PRs #1405 / #1413): every * guard predicate is enforced TWICE -- once at entry and once at publication @@ -316,17 +319,34 @@ export class CancelledTaskWriteError extends Error { * Guarded write entry point. * * 1. Resolves the absolute path against task.cwd. - * 2. Consults the task's S2 observation registry to pick the guard: + * 2. Captures the task's S2 observation for the path at SUBMISSION time, not + * when the queued link runs: the content being written was derived from + * the read this observation records, so the CAS must compare against that + * token even if the registry moves on - through a re-read or through this + * task's own earlier write re-observing the path - while the link waits + * behind another write. A queued write whose token no longer matches fails + * stale and the caller re-reads; publishing it anyway would overwrite + * content the submitted write never saw. + * 3. Consults the captured observation to pick the guard: * - unobserved + create/update: createIfAbsent (rejects if it exists); * - observed + create on a file that vanished after the read: recreate; * - observed otherwise: replaceIfVersion (CAS on the S1 version token); * - unobserved + edit: unobservedEditGuard. - * 3. Runs the chosen guard on the per-path FIFO chain so concurrent writes to - * the same path are deterministically ordered. The chain holds the + * The guard runs on the per-path FIFO chain so concurrent writes to the + * same path are deterministically ordered. The chain holds the * serialization across the whole verify+publish window, and the guard's * publication-time re-verification (inside safeWriteText, immediately * before the commit rename) closes the check-to-rename window for writers * serialized by the chain. + * 4. After a successful publication the new on-disk token is recorded back + * into the registry, so a follow-up write from the same task compares + * against the content this write published instead of failing stale + * against its own output (or treating a file it just created as + * unobserved). The re-observation runs inside the chain link, before the + * link settles, so the next queued link and the next submission both see + * the refreshed token. A token failure after a successful publication is + * swallowed: the write itself succeeded, and the registry keeps the + * previous observation, which a follow-up write already handles. */ export async function guardedWrite( task: Task, @@ -335,6 +355,8 @@ export async function guardedWrite( kind: GuardedWriteKind = "update", ): Promise { const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) + // Submission-time capture - see step 2 above. + const obs = task.observationRegistry.get(absolutePath) return enqueue(absolutePath, async () => { // The link can reach the head of the queue long after the task that issued it is @@ -345,8 +367,6 @@ export async function guardedWrite( throw new CancelledTaskWriteError(absolutePath) } - const obs = task.observationRegistry.get(absolutePath) - if (obs === undefined) { // Edit-style writes require a prior read: no observation, no write. if (kind === "edit") { @@ -355,22 +375,30 @@ export async function guardedWrite( // Never read: only an absent target may be created. (The edit guard // above rejects before reaching this line.) await createIfAbsent(absolutePath, content) - return - } - - if (kind === "edit") { - await replaceIfVersion(absolutePath, obs.version, content) - return - } - - // kind is "create" or "update": a "create" on a file that vanished - // after the read recreates it; otherwise the version recorded at read - // time must still match the on-disk token. - if (kind === "create" && (await fileIsAbsent(absolutePath))) { + } else if (kind === "create" && (await fileIsAbsent(absolutePath))) { + // A "create" on a file that vanished after the read recreates it. + // Observed "edit" and "update" writes never reach the existence probe - + // the condition short-circuits on kind - and take the CAS branch below. await createIfAbsent(absolutePath, content) } else { + // The version recorded at read time must still match the on-disk + // token: for "edit" and "update", and for "create" on a file that + // still exists. await replaceIfVersion(absolutePath, obs.version, content) } + + // Re-observe the published version (step 4). A failure to compute the + // token after a successful publication must not turn the published + // write into a failed one, so it is swallowed here and the registry + // keeps the previous observation. + try { + const published = await computeVersionToken(absolutePath) + task.observationRegistry.observe(absolutePath, published) + } catch { + // Swallowed on purpose: the publication itself succeeded, and a + // follow-up write against the previous observation is the + // pre-existing contract (stale -> re-read). + } }) } From e56d18951362e6fee9996868f0730500007716ab Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 00:41:29 +0800 Subject: [PATCH 37/39] style: format the four remaining files this PR touches The format gate that arrives through the merge ref still named four files from this PR: the import/export spec, the observation-registry spec, the MCP hub, and the safe-write JSON test. Earlier rounds formatted the MCP hub spec and the safe-write JSON module; this completes the set so the required compile check sees a clean tree. Shape, reported honestly: the re-wraps change line counts (56/28, 1/2, 8/2, 28/26), so a whitespace-insensitive diff is not the proof. The claim that is actually verified is stated in the terms that make it checkable: with every run of whitespace and every trailing comma removed, each formatted file is byte-identical to the blob it replaces. The four affected suites pass unchanged (53, 8, 36 and 76 tests), eslint reports zero problems on all four files, and the repository-wide formatter check finds no offenders at the resulting tree. --- .../config/__tests__/importExport.spec.ts | 84 ++++++++++++------- .../__tests__/observationRegistry.spec.ts | 3 +- src/services/mcp/McpHub.ts | 10 ++- src/utils/__tests__/safeWriteJson.test.ts | 54 ++++++------ 4 files changed, 93 insertions(+), 58 deletions(-) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index c1df4dbd24..8a03663aee 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -1555,10 +1555,14 @@ describe("importExport", () => { expect(mockContextProxy.export).toHaveBeenCalled() expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true }) - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { - providerProfiles: mockProviderProfiles, - globalSettings: mockGlobalSettings, - }, { refuseSymlinkTarget: true }) + expect(safeWriteJson).toHaveBeenCalledWith( + "/mock/path/zoo-code-settings.json", + { + providerProfiles: mockProviderProfiles, + globalSettings: mockGlobalSettings, + }, + { refuseSymlinkTarget: true }, + ) }) it("should include globalSettings when allowedMaxRequests is null", async () => { @@ -1587,10 +1591,14 @@ describe("importExport", () => { contextProxy: mockContextProxy, }) - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { - providerProfiles: mockProviderProfiles, - globalSettings: mockGlobalSettings, - }, { refuseSymlinkTarget: true }) + expect(safeWriteJson).toHaveBeenCalledWith( + "/mock/path/zoo-code-settings.json", + { + providerProfiles: mockProviderProfiles, + globalSettings: mockGlobalSettings, + }, + { refuseSymlinkTarget: true }, + ) }) it("should handle errors during the export process", async () => { @@ -1710,10 +1718,14 @@ describe("importExport", () => { contextProxy: mockContextProxy, }) - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { - providerProfiles: mockProviderProfiles, - globalSettings: mockGlobalSettings, - }, { refuseSymlinkTarget: true }) + expect(safeWriteJson).toHaveBeenCalledWith( + "/mock/path/zoo-code-settings.json", + { + providerProfiles: mockProviderProfiles, + globalSettings: mockGlobalSettings, + }, + { refuseSymlinkTarget: true }, + ) }) it("should export model dimension for OpenAI Compatible provider", async () => { @@ -1863,10 +1875,14 @@ describe("importExport", () => { }) // Should not throw an error and should preserve original settings - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { - providerProfiles: mockProviderProfiles, - globalSettings: mockGlobalSettings, // Should remain unchanged - }, { refuseSymlinkTarget: true }) + expect(safeWriteJson).toHaveBeenCalledWith( + "/mock/path/zoo-code-settings.json", + { + providerProfiles: mockProviderProfiles, + globalSettings: mockGlobalSettings, // Should remain unchanged + }, + { refuseSymlinkTarget: true }, + ) }) it("should maintain backward compatibility with existing exports", async () => { @@ -1906,10 +1922,14 @@ describe("importExport", () => { }) // Should not modify settings for non-openai-compatible providers - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { - providerProfiles: mockProviderProfiles, - globalSettings: mockGlobalSettings, // Should remain unchanged - }, { refuseSymlinkTarget: true }) + expect(safeWriteJson).toHaveBeenCalledWith( + "/mock/path/zoo-code-settings.json", + { + providerProfiles: mockProviderProfiles, + globalSettings: mockGlobalSettings, // Should remain unchanged + }, + { refuseSymlinkTarget: true }, + ) }) it("should handle missing current provider gracefully", async () => { @@ -1951,10 +1971,14 @@ describe("importExport", () => { }) // Should not throw an error and should preserve original settings - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { - providerProfiles: mockProviderProfiles, - globalSettings: mockGlobalSettings, // Should remain unchanged - }, { refuseSymlinkTarget: true }) + expect(safeWriteJson).toHaveBeenCalledWith( + "/mock/path/zoo-code-settings.json", + { + providerProfiles: mockProviderProfiles, + globalSettings: mockGlobalSettings, // Should remain unchanged + }, + { refuseSymlinkTarget: true }, + ) }) }) @@ -2188,10 +2212,14 @@ describe("importExport", () => { }) // Step 4: Verify the exported data includes the model dimension - expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/test-settings.json", { - providerProfiles: mockProviderProfiles, - globalSettings: mockGlobalSettings, - }, { refuseSymlinkTarget: true }) + expect(safeWriteJson).toHaveBeenCalledWith( + "/mock/path/test-settings.json", + { + providerProfiles: mockProviderProfiles, + globalSettings: mockGlobalSettings, + }, + { refuseSymlinkTarget: true }, + ) // Step 5: Get the exported data for import test const exportedData = (safeWriteJson as Mock).mock.calls[0][1] diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts index 6898a27031..2301247f32 100644 --- a/src/core/task/__tests__/observationRegistry.spec.ts +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -71,7 +71,6 @@ describe("ObservationRegistry", () => { }) }) - describe("close() - disposal is terminal", () => { it("drops every observation and refuses later ones", () => { const registry = new ObservationRegistry() @@ -88,4 +87,4 @@ describe("close() - disposal is terminal", () => { expect(registry.get("/workspace/late.ts")).toBeUndefined() expect(registry.size).toBe(0) }) -}) \ No newline at end of file +}) diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 23c84bc56a..e4c73f064a 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -2106,7 +2106,10 @@ export class McpHub { } this.isProgrammaticUpdate = true try { - await safeWriteJson(configPath, updatedConfig, { prettyPrint: true, ...this.symlinkPolicyForSource(source) }) + await safeWriteJson(configPath, updatedConfig, { + prettyPrint: true, + ...this.symlinkPolicyForSource(source), + }) } finally { // Reset flag after watcher debounce period (non-blocking) this.flagResetTimer = setTimeout(() => { @@ -2191,7 +2194,10 @@ export class McpHub { mcpServers: config.mcpServers, } - await safeWriteJson(configPath, updatedConfig, { prettyPrint: true, ...this.symlinkPolicyForSource(serverSource) }) + await safeWriteJson(configPath, updatedConfig, { + prettyPrint: true, + ...this.symlinkPolicyForSource(serverSource), + }) // Update server connections with the correct source await this.updateServerConnections(config.mcpServers, serverSource) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index bfa72a02e1..e50ff27b47 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -467,7 +467,10 @@ describe("safeWriteJson", () => { // The primary failure has to stay readable even though the rollback failure is what // gets thrown on top of it. - const rejection = await safeWriteJson(currentTestFilePath, newData).then(() => null, (error) => error) + const rejection = await safeWriteJson(currentTestFilePath, newData).then( + () => null, + (error) => error, + ) expect(rejection).toBeInstanceOf(Error) expect(rejection.name).toBe("RollbackFailedError") expect(rejection.message).toContain("Primary rename failed") @@ -565,11 +568,11 @@ describe("safeWriteJson", () => { expect(content).toEqual({ c: 3 }) }) -// The commit rename is no-follow for the final component: it targets the path the caller -// named, so a link the caller never chose is REPLACED by the rename instead of receiving the -// payload. Staging therefore happens beside the named path - the same directory the rename -// lands in - which is also what keeps the rename on one volume. (Real symlinks are unavailable -// in this CI lane, so the alias is simulated by mocking fs.realpath.) + // The commit rename is no-follow for the final component: it targets the path the caller + // named, so a link the caller never chose is REPLACED by the rename instead of receiving the + // payload. Staging therefore happens beside the named path - the same directory the rename + // lands in - which is also what keeps the rename on one volume. (Real symlinks are unavailable + // in this CI lane, so the alias is simulated by mocking fs.realpath.) test("stages beside the caller-named path and never publishes through the symlink", async () => { const referentDir = path.join(tempDir, "referent") const linkDir = path.join(tempDir, "link") @@ -709,9 +712,9 @@ describe("safeWriteJson", () => { // asserted through unknown rather than stubbing every Stats field. } as unknown as fsSyncActual.Stats) - await expect( - safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true }), - ).rejects.toThrow(/refusing to write through the symlink/) + await expect(safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true })).rejects.toThrow( + /refusing to write through the symlink/, + ) vi.restoreAllMocks() // Nothing was resolved, staged, locked, or committed: the referent still holds @@ -746,9 +749,9 @@ describe("safeWriteJson", () => { return Promise.resolve(asFile) }) as unknown as typeof fs.lstat) - await expect( - safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true }), - ).rejects.toThrow(/after resolution/) + await expect(safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true })).rejects.toThrow( + /after resolution/, + ) vi.restoreAllMocks() expect(await readFileContent(referentPath)).toEqual({ seed: "untouched" }) @@ -774,9 +777,9 @@ describe("safeWriteJson", () => { return Promise.resolve(asFile) }) as unknown as typeof fs.lstat) - await expect( - safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true }), - ).rejects.toThrow(/before publication/) + await expect(safeWriteJson(linkPath, { leaked: true }, { refuseSymlinkTarget: true })).rejects.toThrow( + /before publication/, + ) vi.restoreAllMocks() expect(await readFileContent(referentPath)).toEqual({ seed: "untouched" }) @@ -791,9 +794,9 @@ describe("safeWriteJson", () => { // the write has to stop here rather than publish through an unexamined entry. vi.spyOn(fs, "lstat").mockRejectedValue(failure) - await expect( - safeWriteJson(target, { written: true }, { refuseSymlinkTarget: true }), - ).rejects.toThrow(failure.message) + await expect(safeWriteJson(target, { written: true }, { refuseSymlinkTarget: true })).rejects.toThrow( + failure.message, + ) vi.restoreAllMocks() // Nothing was locked, staged, or published: no target and no leftover temp file. @@ -853,9 +856,9 @@ describe("safeWriteJson", () => { return Promise.resolve(String(p) === tempDir ? asLink : asFile) }) as unknown as typeof fs.lstat) - await expect( - safeWriteJson(target, { leaked: true }, { refuseSymlinkTarget: true }), - ).rejects.toThrow(/is a symlink/) + await expect(safeWriteJson(target, { leaked: true }, { refuseSymlinkTarget: true })).rejects.toThrow( + /is a symlink/, + ) vi.restoreAllMocks() expect(await readFileContent(target)).toEqual({ own: true }) @@ -885,13 +888,13 @@ describe("safeWriteJson", () => { const failure = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) // "Could not inspect" is not evidence that the path is safe: only ENOENT is tolerated. vi.spyOn(fs, "lstat").mockImplementation(((p: unknown) => { - if (String(p) === tempDir) { return Promise.reject(failure) } + if (String(p) === tempDir) { + return Promise.reject(failure) + } return Promise.resolve(asFile) }) as unknown as typeof fs.lstat) - await expect( - safeWriteJson(target, { leaked: true }, { refuseSymlinkTarget: true }), - ).rejects.toThrow(/EACCES/) + await expect(safeWriteJson(target, { leaked: true }, { refuseSymlinkTarget: true })).rejects.toThrow(/EACCES/) vi.restoreAllMocks() expect(await readFileContent(target)).toEqual({ own: true }) @@ -910,5 +913,4 @@ describe("safeWriteJson", () => { vi.restoreAllMocks() expect(await readFileContent(target)).toEqual({ written: true }) }) - }) From ea730f0ed2fdb9aad98210bc8fe40e498979ea97 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 04:34:23 +0800 Subject: [PATCH 38/39] test(guarded-write): drop orphaned comment fragment in the FIFO chain test The line "chain for in-flight or just-enqueued links)." was a tail left over from an earlier edit: it did not connect to the sentence before it, and the eviction behaviour it gestured at is already documented where it lives, in the pending-chain map in src/core/tools/guardedWrite.ts (the entry for a link is deleted once that link settles). Comment-only change. With block comments and pure comment lines removed and all whitespace folded, the file's bytes are identical before and after: 21929 == 21929, sha256 2bd05b4af7385deb03a92b252b1b8585ec527b18b5659fcfec1bdcf3eb25de9d. Affected spec re-run after the edit: 39/39 pass. --- src/core/tools/__tests__/guardedWrite.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 3939fe59f5..c501db012f 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -534,7 +534,6 @@ describe("guardedWrite (S4a, epic #1375)", () => { // Two rapid writes submitted after that settlement must still run one at a // time in submission order. Whether the settled entry has been evicted from the // path map is not observable from here, so the test does not claim it. - // chain for in-flight or just-enqueued links). const order: string[] = [] mockedSafeWriteText.mockImplementation(async (_path: string, content: string) => { order.push(content) From 97f2f4d3bcad60239f5fc1187b3af20218305c77 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 12 Oct 2026 02:47:24 +0800 Subject: [PATCH 39/39] fix(safe-write-json): re-derive the symlink refusal at the commit rename The refusal walk and the commit rename were separated by the whole staging sequence, and the later checks inspected only the final component: a local writer that swapped an inspected ancestor for a link - or for a different directory - while the payload was staged sent the path-based rename through it, and no final-component check could notice. Node exposes no handle-relative rename, so the publication now re-derives the complete ancestry itself: the final entry and every ancestor identity recorded by the walk (bigint dev/ino) are re-checked inside the publish, immediately before the backup and commit renames, and a swap fails with nothing renamed. The window that remains is the commit rename itself, stated in the contract comment rather than claimed as closed. Tests script the swap per visit (real symlinks are unavailable in this CI lane): link swap after the walk, directory replacement with an unchanged name, and the stable-ancestry positive control. Negative controls: disabling the hook's symlink branch reddens the first test, disabling the identity comparison reddens the second; both swap tests are red against the pre-fix production file and green after restore (hash verified). --- src/utils/__tests__/safeWriteJson.test.ts | 85 +++++++++++++++++++ src/utils/safeWriteJson.ts | 99 +++++++++++++++++++---- 2 files changed, 170 insertions(+), 14 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index e50ff27b47..632290941d 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -864,6 +864,91 @@ describe("safeWriteJson", () => { expect(await readFileContent(target)).toEqual({ own: true }) }) + // The initial walk and the commit rename are separated by the whole staging sequence, and + // Node exposes no handle-relative rename: an ancestor that passed the walk can be swapped + // while the payload is staged, and the path-based commit rename would follow it. The + // publication therefore re-derives the final entry and every recorded ancestor identity + // immediately before the commit rename. (Real symlinks are unavailable in this CI lane, so + // the swap is simulated by scripting fs.lstat per visit, the same way as the tests above.) + test("refuses the publish when an ancestor is swapped for a link after the initial walk", async () => { + const parent = path.join(tempDir, "swap-parent") + await fs.mkdir(parent, { recursive: true }) + const target = path.join(parent, "swap-ancestor.json") + await fsPromisesActuals.writeFile!(target, JSON.stringify({ own: true })) + // The guard reads isSymbolicLink plus the bigint identity, and a real BigIntStats cannot + // be produced for a simulated directory in this CI lane, so the doubles are asserted + // through unknown rather than stubbing every Stats field. + const asDir = { isSymbolicLink: () => false, dev: 1n, ino: 1n } as unknown as fsSyncActual.BigIntStats + const asLink = { isSymbolicLink: () => true, dev: 2n, ino: 2n } as unknown as fsSyncActual.BigIntStats + // Path-aware scripting: the first look at the parent is the walk that authorizes it; the + // next look happens inside the publication, where the swap must be caught. + let parentLooks = 0 + vi.spyOn(fs, "lstat").mockImplementation(((p: unknown) => { + if (String(p) === parent) { + parentLooks++ + return Promise.resolve(parentLooks === 1 ? asDir : asLink) + } + return Promise.resolve(asDir) + }) as unknown as typeof fs.lstat) + + await expect(safeWriteJson(target, { leaked: true }, { refuseSymlinkTarget: true })).rejects.toThrow( + /is now a symlink/, + ) + + vi.restoreAllMocks() + // The re-check ran (the walk saw the directory once, the publication once more), the + // commit rename never happened, and the payload did not escape: the target still holds + // its own content and the staged temp was discarded, not left beside it. + expect(parentLooks).toBe(2) + expect(await readFileContent(target)).toEqual({ own: true }) + expect(fsSyncActual.readdirSync(parent)).toEqual(["swap-ancestor.json"]) + }) + + test("refuses the publish when an ancestor is replaced by a different directory", async () => { + const parent = path.join(tempDir, "repl-parent") + await fs.mkdir(parent, { recursive: true }) + const target = path.join(parent, "replaced-ancestor.json") + await fsPromisesActuals.writeFile!(target, JSON.stringify({ own: true })) + const asDir = { isSymbolicLink: () => false, dev: 1n, ino: 1n } as unknown as fsSyncActual.BigIntStats + // The rename-away-and-recreate shape: the name is unchanged and the entry is still a + // directory, so only the identity tells the two apart. + const asOtherDir = { isSymbolicLink: () => false, dev: 9n, ino: 9n } as unknown as fsSyncActual.BigIntStats + let parentLooks = 0 + vi.spyOn(fs, "lstat").mockImplementation(((p: unknown) => { + if (String(p) === parent) { + parentLooks++ + return Promise.resolve(parentLooks === 1 ? asDir : asOtherDir) + } + return Promise.resolve(asDir) + }) as unknown as typeof fs.lstat) + + await expect(safeWriteJson(target, { leaked: true }, { refuseSymlinkTarget: true })).rejects.toThrow( + /is no longer the directory that was checked/, + ) + + vi.restoreAllMocks() + expect(parentLooks).toBe(2) + expect(await readFileContent(target)).toEqual({ own: true }) + expect(fsSyncActual.readdirSync(parent)).toEqual(["replaced-ancestor.json"]) + }) + + test("publishes when every recorded ancestor keeps its identity", async () => { + const parent = path.join(tempDir, "stable-parent") + await fs.mkdir(parent, { recursive: true }) + const target = path.join(parent, "stable-ancestor.json") + await fsPromisesActuals.writeFile!(target, JSON.stringify({ own: true })) + // Pinning must not reject the ordinary case: every ancestor keeps the identity the walk + // recorded, so the publication proceeds. + vi.spyOn(fs, "lstat").mockResolvedValue( + { isSymbolicLink: () => false, dev: 1n, ino: 1n } as unknown as fsSyncActual.BigIntStats, + ) + + await safeWriteJson(target, { written: true }, { refuseSymlinkTarget: true }) + + vi.restoreAllMocks() + expect(await readFileContent(target)).toEqual({ written: true }) + }) + test("does not apply the ancestor refusal when refuseSymlinkTarget is not set", async () => { const target = path.join(tempDir, "default-ancestor.json") await fsPromisesActuals.writeFile!(target, JSON.stringify({ own: true })) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 496d9dec43..fcc7a28098 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -41,10 +41,28 @@ export interface SafeWriteJsonOptions { * settings exports carry API credentials - where following a link they never * pointed at would write secrets into a file they did not pick. When this is * set, a symlink at the final path component is an error instead. + * + * The refusal is not a one-time inspection: the final entry and every ancestor + * directory identity are re-derived inside the publication, immediately before + * the commit rename, so a directory swapped for a link during the staging + * sequence is caught before it can redirect the payload. Node exposes no + * handle-relative rename, so the window that remains is the commit rename + * itself, not the checks before it. */ refuseSymlinkTarget?: boolean } +/** + * An ancestor directory that passed the symlink refusal, recorded with the + * bigint device and inode it had at that moment so the publication can tell + * whether it is still the same directory before committing. + */ +interface CheckedDirectory { + dir: string + dev: bigint + ino: bigint +} + /** * Safely writes JSON data to a file. * - Creates parent directories if they don't exist @@ -80,19 +98,23 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // itself (e.g. /.roo -> a directory outside the workspace). Only ENOENT stops the // walk - a missing ancestor means nothing deeper exists to be a link. Any other inspection // error fails closed, because "could not inspect" is not evidence that the path is safe. - async function _refuseSymlinkedAncestors(absoluteFilePath: string): Promise { + // Each directory that passes is recorded with its bigint device and inode: a walk the + // publication does not re-derive is a refusal an attacker retires by swapping the directory + // after the walk ran, so the identities feed the pre-commit re-check below. + async function _refuseSymlinkedAncestors(absoluteFilePath: string): Promise { + const checked: CheckedDirectory[] = [] let current = path.dirname(absoluteFilePath) for (;;) { - let st: fsSync.Stats + let st: fsSync.BigIntStats try { - st = await fs.lstat(current) + st = await fs.lstat(current, { bigint: true }) } catch (error: unknown) { const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined if (code === "ENOENT") { - return + return checked } throw error } @@ -101,9 +123,10 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso `safeWriteJson: refusing to write to ${absoluteFilePath}: ${current} is a symlink, and the payload would be written outside the directory the caller named.`, ) } + checked.push({ dir: current, dev: st.dev, ino: st.ino }) const parent = path.dirname(current) if (parent === current) { - return + return checked } current = parent } @@ -112,6 +135,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // A credential-bearing payload must not be redirected through a link the user // never chose: check the final path component before anything is resolved, // staged, or locked. + let checkedAncestors: CheckedDirectory[] = [] if (options?.refuseSymlinkTarget) { let targetStat: fsSync.Stats | undefined try { @@ -134,8 +158,10 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // The final component alone is not enough: a symlinked ancestor redirects the payload while // leaving the target path looking ordinary. Checked before anything is resolved, staged or - // locked, and it fails closed on any inspection error that is not ENOENT. - await _refuseSymlinkedAncestors(absoluteFilePath) + // locked, and it fails closed on any inspection error that is not ENOENT. The identities it + // records are re-derived by the publication hook below - this walk alone leaves the whole + // staging window open to an ancestor swap. + checkedAncestors = await _refuseSymlinkedAncestors(absoluteFilePath) } // Two paths, for two different jobs: lock identity and publication destination. @@ -188,6 +214,48 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso await assertFinalComponentNotReplaced("after resolution") } + // The initial checks and the commit rename are separated by the whole staging sequence, and + // Node exposes no handle-relative rename (no renameat): a local writer that swaps an + // inspected ancestor for a link - or for a different directory - during that window would + // still redirect the path-based commit rename, and no recheck of the final component would + // notice, because the swap is one directory up. The publication therefore re-derives the + // complete ancestry itself - the final entry and every recorded ancestor identity - + // immediately before the commit rename, so the refusal is enforced at the action it guards. + // What cannot be closed without a native renameat is stated rather than claimed as + // protection: the window that remains is the commit rename itself. + const assertAncestryUnchangedBeforeCommit = async (): Promise => { + await assertFinalComponentNotReplaced("before publication") + for (const expected of checkedAncestors) { + let nowStat: fsSync.BigIntStats + try { + nowStat = await fs.lstat(expected.dir, { bigint: true }) + } catch (error: unknown) { + const code = + error && typeof error === "object" && "code" in error + ? (error as { code?: string }).code + : undefined + // The directory the walk recorded is gone: the ancestry the refusal was based on + // no longer exists, and a path-based rename would resolve a chain nobody checked. + if (code === "ENOENT") { + throw new Error( + `safeWriteJson: refusing to publish to ${absoluteFilePath}: ${expected.dir} no longer exists; an ancestor of the destination was replaced after it was checked.`, + ) + } + throw error + } + if (nowStat.isSymbolicLink()) { + throw new Error( + `safeWriteJson: refusing to publish to ${absoluteFilePath}: ${expected.dir} is now a symlink; the payload would be written outside the directory the caller named.`, + ) + } + if (nowStat.dev !== expected.dev || nowStat.ino !== expected.ino) { + throw new Error( + `safeWriteJson: refusing to publish to ${absoluteFilePath}: ${expected.dir} is no longer the directory that was checked; an ancestor of the destination was replaced after it was checked.`, + ) + } + } + } + // Acquire the lock before any file operations. `acquireFileLock` owns the // shared advisory lock protocol, so callers that lock the same path with it // (for example task-history deletion) serialize with this write. It locks the @@ -237,17 +305,20 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const textOptions: SafeWriteTextOptions = { tempPath: actualTempNewFilePath, backup: true, - // This call already resolved the target (and re-checked the final component - // under the lock). safeWriteText must not resolve it a second time: a link - // installed in that window would be followed there and the payload committed - // to the attacker's referent. + // This call already resolved the target (and re-checks the final component + // under the lock through the hook below). safeWriteText must not resolve it a + // second time: a link installed in that window would be followed there and the + // payload committed to the attacker's referent. targetPathIsResolved: true, } if (options?.refuseSymlinkTarget) { - // Last chance to notice the destination was swapped for a link: everything the - // caller asked for is staged and the commit rename follows the resolved path. - await assertFinalComponentNotReplaced("before publication") + // Re-derive the refusal inside the publication, immediately before the backup + // rename and the commit rename: the initial walk and this call are separated by + // the whole staging sequence, and a path-based rename follows an ancestor that + // was swapped for a link in that window. A rejection runs before anything is + // moved, so the target keeps its content and the staged temp is discarded. + textOptions.verifyBeforeCommit = assertAncestryUnchangedBeforeCommit } await safeWriteText(publishTargetPath, "", textOptions)