From 426f809ebfef39148a6e78f51ebc07d736ae99d8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 20:34:31 +0800 Subject: [PATCH 001/115] split unit U1 of PR 1833 (issue 1375) --- .../__tests__/safeWriteText.spec.ts | 922 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 420 ++++++++ 2 files changed, 1342 insertions(+) create mode 100644 src/services/file-safety/__tests__/safeWriteText.spec.ts create mode 100644 src/services/file-safety/safeWriteText.ts 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..e2f947c8bc --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -0,0 +1,922 @@ +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 { RollbackFailureError, 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(), + rmdir: vi.fn(), + realpath: vi.fn(), + lstat: 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)) +} +// 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) + vi.mocked(fs.rmdir).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() + }) + + it("removes the now-empty staging directory after a successful self-staged commit", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "hello", { platform: "linux" }) + + // the staging subdir is removed best-effort after the commit rename + // (stringContaining: the SUT and the test helper resolve Windows + // drive-relative paths differently, as in the existing staging tests) + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + // the win32 DACL restore gate must stay closed on other platforms: + // no icacls save or restore is attempted + expect(execFile).not.toHaveBeenCalled() + }) + + it("still removes the staging directory when no options are supplied at all", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // options is undefined: the self-staged check and the optional-chained + // DACL runner lookup must not dereference it + await expect(safeWriteText(targetPath, "hello")).resolves.toBeUndefined() + + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + if (process.platform === "win32") { + // default platform is win32: the DACL save + restore still ran + // through the default icacls path (options?.execFileRunner must + // not throw when options is undefined) + expect(vi.mocked(execFile)).toHaveBeenCalledTimes(2) + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + } + }) + + it("does not remove the staging directory when the caller supplies its own tempPath", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const callerTemp = "/tmp/test-dir/caller-staged.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "hello", { platform: "linux", tempPath: callerTemp }) + + // the caller owns its temp file's directory; safeWriteText must not + // rmdir a directory it did not create + expect(fs.rmdir).not.toHaveBeenCalled() + }) + + it("a failed staging-dir removal never fails the committed write", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rmdir).mockRejectedValue(Object.assign(new Error("ENOTEMPTY"), { code: "ENOTEMPTY" })) + + await expect(safeWriteText(targetPath, "hello", { platform: "linux" })).resolves.toBeUndefined() + + // the commit rename still happened and the rmdir error was swallowed + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) + + it("gives each self-staged write its own staging directory so a concurrent write cannot remove it", async () => { + const targetA = "/tmp/test-dir/target-a.txt" + const targetB = "/tmp/test-dir/target-b.txt" + vi.mocked(fs.realpath).mockImplementation((p) => Promise.resolve(p as string)) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetA, "a", { platform: "linux" }) + await safeWriteText(targetB, "b", { platform: "linux" }) + + // Two self-staged writes in the same directory must not share one staging + // directory: the first write's best-effort rmdir would otherwise delete the + // directory the second write had created but not yet opened (ENOENT on openSync). + const created = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0])) + const staging = created.filter((p) => p.includes(".file-safety-staging_")) + expect(staging).toHaveLength(2) + expect(staging[0]).not.toBe(staging[1]) + // Uniqueness comes from the documented name shape + // /.file-safety-staging__: pinning the shape + // keeps the separator and the random suffix meaningful, not just the prefix. + for (const dir of staging) { + expect(dir).toMatch(/\.file-safety-staging_\d+_[a-z0-9]+$/) + } + const removed = vi.mocked(fs.rmdir).mock.calls.map((c) => String(c[0])) + expect(removed).toEqual([staging[0], staging[1]]) + }) + + it("removes its own staging directory when a self-staged write fails", 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(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect(safeWriteText(targetPath, "hello", { platform: "linux" })).rejects.toThrow("EACCES") + + // The failed write's temp file is unlinked, then the directory it + // created is removed — a failed write must not leave an empty + // .file-safety-staging directory behind. + // mkdirSync created this write's staging directory; the temp file lives + // inside it, so the unlink targets a path under that directory. + const staging = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0])) + expect(staging).toHaveLength(1) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(staging[0])) + expect(fs.rmdir).toHaveBeenCalledWith(staging[0]) + // The directory is only empty after its temp file is gone, so the + // unlink must happen before the rmdir. + expect(vi.mocked(fs.unlink).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fs.rmdir).mock.invocationCallOrder[0], + ) + }) + + it("does not remove a staging directory it did not create when a caller-staged write fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const callerTemp = "/tmp/test-dir/caller-staged.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect( + safeWriteText(targetPath, "hello", { platform: "linux", tempPath: callerTemp }), + ).rejects.toThrow("EACCES") + + // The caller owns that directory: only the caller's temp file is cleaned, + // never a rmdir of a directory safeWriteText never created. + expect(fs.unlink).toHaveBeenCalledWith(callerTemp) + expect(fs.rmdir).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).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + // The title promises the order, so compare the invocations rather than + // only count them: a rename before closeSync, or a close before fsync, + // would not be a durable commit. + const fsyncOrder = vi.mocked(fsSync.fsyncSync).mock.invocationCallOrder[0] + const closeOrder = vi.mocked(fsSync.closeSync).mock.invocationCallOrder[0] + const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[0] + expect(fsyncOrder).toBeLessThan(closeOrder) + expect(closeOrder).toBeLessThan(renameOrder) + }) + }) + + // ── 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 + if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails + return // the rollback rename succeeds + }) + + 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("a failed rollback reports the partial state, not only the publish error", async () => { + // The content is still on disk, but only at the backup path. A caller that gets + // just the publish error has data it cannot find at the expected path. + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + let callCount = 0 + vi.mocked(fs.rename).mockImplementation(async () => { + callCount++ + if (callCount === 1) return // target -> backup + if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails + throw new Error("EACCES") // the rollback rename fails too + }) + + let failure: RollbackFailureError | undefined + await safeWriteText(targetPath, "new data", { backup: true }).catch((e: unknown) => { + if (e instanceof RollbackFailureError) { + failure = e + return + } + throw e + }) + + expect(failure).toBeInstanceOf(RollbackFailureError) + expect(failure?.publishError).toBeInstanceOf(Error) + expect((failure?.publishError as Error).message).toBe("ENOSPC") + expect((failure?.rollbackError as Error).message).toBe("EACCES") + expect(failure?.backupPath).toContain("safeWriteText.bak_") + // The backup is what the caller can still recover, so it must stay on disk. + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + }) + + 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).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + // one icacls attempt only: a failed DACL apply must not try to restore + expect(execFile).toHaveBeenCalledTimes(1) + }) + + it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls save fails — a real icacls may have written a partial dump + // before erroring, so the dump path must be cleaned up and must never + // be used for a restore. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls save error"), "", "") + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write committed; only the save was attempted (no restore from a failed dump) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(execFile).toHaveBeenCalledTimes(1) + const saveArgs = vi.mocked(execFile).mock.calls[0]?.[1] + expect(saveArgs?.[1]).toBe("/save") + // the dump path (possibly partially created by icacls) was unlinked + 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) + 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).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) + + // 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("propagates a non-ENOENT stat failure rather than defaulting the mode (caller-staged)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw eacces + }) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + // A target that cannot be stat'd is not a fresh target: publishing with + // the default mode would widen a restrictive target through the rename. + await expect( + safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow("EACCES") + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("propagates a non-ENOENT stat failure rather than defaulting the mode (self-staged)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const eio = Object.assign(new Error("EIO: i/o error"), { code: "EIO" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw eio + }) + + // The mode is read before the temp is opened, so a real I/O failure stops + // the write before anything is staged. + await expect(safeWriteText(targetPath, "hello world", { platform: "linux" })).rejects.toThrow("EIO") + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + 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) + }) + + it("applies the existing target's exact mode to the self-staged temp (umask must not narrow it)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o664)) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // openSync's creation mode is narrowed by the process umask (0o664 -> 0o644 with + // the common 0o022), and the rename publishes the temp's mode onto the target, + // so the existing target's mode must be applied on the fd before the commit. + expect(fsSync.fchmodSync).toHaveBeenCalledWith(1, 0o664) + 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(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("does not fchmod the self-staged temp for a fresh target", 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(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // Nothing exists to preserve: the default creation mode is the intended one. + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + }) + }) + + // ── 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" })) + // lstat reports the path itself as absent, so this is a new target and + // the fallback is allowed. + vi.mocked(fs.lstat).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) + }) + + it("propagates a dangling symlink instead of writing through the link path", async () => { + // realpath resolves the referent, so a link whose target is missing reports + // ENOENT. Falling back to the link path would replace the symlink with a + // regular file, so the error must propagate and nothing may be committed. + const linkPath = "/tmp/test-dir/dangling-link.txt" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const linkStats = Object.create(fsSync.Stats.prototype) as fsSync.Stats + linkStats.isSymbolicLink = () => true + vi.mocked(fs.lstat).mockResolvedValue(linkStats) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await expect(safeWriteText(linkPath, "data", { platform: "linux" })).rejects.toThrow("ENOENT") + + expect(fs.rename).not.toHaveBeenCalled() + }) + }) + + // ── 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() + }) + }) + describe("content bytes", () => { + const targetPath = "/tmp/enc-dir/target.txt" + + beforeEach(() => { + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + }) + + it("stages UTF-8 bytes for string content", async () => { + await safeWriteText(targetPath, "héllo", { platform: "linux" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("héllo", "utf8"), 0, 6) + }) + + it("publishes caller-supplied bytes unchanged instead of re-encoding them", async () => { + // The extension host encodes a document with VS Code's own codec, which + // covers the legacy code pages and BOMs Node cannot represent, and hands + // the result over: those bytes must reach the commit rename exactly as + // they were given. + const bytes = Buffer.from([0x00, 0x68, 0x00, 0x69]) + await safeWriteText(targetPath, bytes, { platform: "linux" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, bytes, 0, 4) + }) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts new file mode 100644 index 0000000000..b5f82a4f81 --- /dev/null +++ b/src/services/file-safety/safeWriteText.ts @@ -0,0 +1,420 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" +import { execFile } from "child_process" + +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 +} + +/** + * A publish that failed and whose rollback also failed: the content survives only + * at the backup path, not at the canonical target. The publish failure stays the + * cause, and the rollback failure plus the backup location travel with the error so + * the caller can tell what it is looking at. + */ +export class RollbackFailureError extends Error { + readonly publishError: unknown + readonly rollbackError: unknown + readonly backupPath: string + + constructor(publishError: unknown, rollbackError: unknown, backupPath: string) { + super( + "Publish failed and the backup could not be restored to its original path -- the content is preserved at the backup location reported on this error.", + { cause: publishError }, + ) + this.name = "RollbackFailureError" + this.publishError = publishError + this.rollbackError = rollbackError + this.backupPath = backupPath + } +} +// -- 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 per-write staging sub-directory inside *dir*. The name is + * unique per write, so concurrent writes never collide on their temp names and + * never remove a staging directory another write is still using: with one shared + * name, one write's best-effort rmdir could delete the directory another write + * had just created but not yet opened, failing its openSync with ENOENT. */ +function _stagingDir(dir: string): string { + const sd = path.join(dir, ".file-safety-staging_" + Date.now() + "_" + Math.random().toString(36).substring(2)) + // 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 +} + +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 ------------------------------------------------------------ + +/** + * 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(async (error: unknown) => { + const code = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined + if (code !== "ENOENT") throw error + // ENOENT also covers a dangling symlink, which must never be written through. + const linkStat = await fs.lstat(absoluteFilePath).catch(() => undefined) + if (linkStat?.isSymbolicLink()) throw error + return absoluteFilePath + }) +} +/** + * Distinguish "the target does not exist" from a real I/O failure (EACCES, + * EIO, ...). The mode-preservation path may only fall back to the fresh-file + * default on ENOENT; any other failure is propagated, otherwise a restrictive + * target (0o600) would be published with the default 0o644 through the rename. + */ +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String((error as { code: unknown }).code) + : undefined +} + +/** + * Canonicalize the parent directory and re-join the basename. fs.realpath + * canonicalizes every component, including a symlinked ancestor directory or a + * Windows 8.3 short name, so a lock key must be canonical even when the file + * itself is not there yet -- otherwise the key for one file depends on whether + * the file exists when the key is computed, and two writers take two locks. + */ + +async function canonicalDirKey(absoluteFilePath: string): Promise { + const dirPath = path.dirname(absoluteFilePath) + const canonicalDir = await fs.realpath(dirPath).catch(() => dirPath) + return path.join(canonicalDir, path.basename(absoluteFilePath)) +} + +/** + * Lock key for a publish target: the symlink referent when the path is an + * existing symlink, the path itself otherwise. Unlike resolvePublishTarget this + * tolerates a dangling link, because the lock key has to be computable while a + * peer writer is mid-commit (backup mode renames the referent away and back). + * The walk is bounded so a two-link cycle terminates, and every key it returns is + * canonicalized through canonicalDirKey. + */ +export async function resolveLockKey(absoluteFilePath: string): Promise { + try { + return await canonicalDirKey(await resolvePublishTarget(absoluteFilePath)) + } catch { + // A real readlink throws for anything that is not a link, so a normal chain + // ends the walk. Two links that point at each other never would, so the + // walk is bounded and callers use the key they actually reached. + let key = absoluteFilePath + for (let depth = 0; depth < 8; depth++) { + const target = await fs.readlink(key).catch(() => undefined) + if (target === undefined) return await canonicalDirKey(key) + key = await canonicalDirKey(path.resolve(path.dirname(key), target)) + } + return await canonicalDirKey(key) + } +} + +export async function safeWriteText( + filePath: string, + content: string | Uint8Array, + 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. Track the directory this + // write created so its cleanup removes its own directory, not a shared one. + let stagingDir: string | null = null + let tempPath: string + if (options?.tempPath) { + tempPath = options.tempPath + } else { + stagingDir = _stagingDir(dirPath) + tempPath = _tempName(stagingDir, "safeWriteText") + } + + let backupPath: string | null = null + let releaseBackupOnSuccess = false + // Non-null only when the win32 step-2 block saved a successful DACL dump: + // it gates the step-5 restore and is tracked for the cleanup unlinks. + let daclDumpPath: string | null = null + + 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). + // Encode before opening the staging file: an encoding Node cannot + // represent must not leave a half-written temp file behind. + // A string is encoded as UTF-8; bytes handed in by the caller (the + // extension host encodes a document with VS Code's own codec, which + // covers the legacy code pages Node cannot represent) are published + // unchanged. + const buffer = Buffer.from(content) + let targetMode = 0o644 // default for a fresh target + let targetExists = false + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + targetExists = true + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") throw error + // target does not exist yet - keep the default + } + // openSync's creation mode is narrowed by the process umask, so an + // existing 0o664 target would be published as 0o644 through the + // rename. Apply the existing target's exact mode on the fd, as the + // caller-staged branch does; a fresh target keeps the default mode. + const fd = fsSync.openSync(tempPath, "w", targetMode) + try { + 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. + 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 (error: unknown) { + if (errorCode(error) !== "ENOENT") throw error + // 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? + const dumpPath = targetPath + ".acl.tmp" + const saved = await _saveDaclWindows(targetPath, dumpPath, options?.execFileRunner) + if (saved) { + // Only a successfully saved dump may be restored onto the + // committed file (step 5). + daclDumpPath = dumpPath + } else { + // A failed icacls may have left a partial dump behind; + // remove it now (best-effort) so no partial dump survives and + // no later step can restore from it. + await fs.unlink(dumpPath).catch(() => {}) + } + } 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 --------- + // daclDumpPath is non-null only when the win32 step-2 block saved a + // successful dump, so this gate is closed on every other platform + // and on every failed save. + if (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. + + // Best-effort: remove the now-empty staging directory. Self-staged + // writes only, and only this write's own directory: a per-write directory + // cannot be the one another concurrent write is still using. A failure must + // never un-commit a published file, so the removal swallows all errors. + if (stagingDir) { + await fs.rmdir(stagingDir).catch(() => {}) + } + } catch (originalError: unknown) { + if (backupPath && releaseBackupOnSuccess) { + try { + await fs.rename(backupPath, targetPath) + } catch (rollbackError: unknown) { + // The content survives only at the backup path now, and the canonical + // target is gone. Reporting just the publish failure would leave the + // caller with data it cannot find at the expected path, so the + // partial-failure state travels with the error. + throw new RollbackFailureError(originalError, rollbackError, backupPath) + } + } + try { + await fs.unlink(tempPath).catch(() => {}) + } catch { + // cleanup failure is non-fatal + } + + // A failed self-staged write must not leave its staging directory behind. + // Only the directory this write created, and only after its temp file is + // gone, so the directory is empty and the removal stays best-effort. + if (stagingDir) { + await fs.rmdir(stagingDir).catch(() => {}) + } + + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + + throw originalError + } +} From 452807518b9cc057b46702ea98204dc2fb78cb0f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 20:49:29 +0800 Subject: [PATCH 002/115] fix(file-safety): close the pre-merge findings on the publish primitive (U1, issue 1375) Split unit U1 of PR 1833. Three changes, each with a test that fails without it: - a caller-supplied staging path is checked for location and file type before anything is written, so an arbitrary path or a symlink cannot be published onto the target; - a failed parent-directory fsync on POSIX is reported as PostCommitDurabilityError instead of being swallowed, so a successful return never claims durability the filesystem did not grant; - the staged file and this write's own staging directory are released before RollbackFailureError is thrown. Focused coverage for resolveLockKey added: canonical parent directory, a dangling-link chain, and termination at the bounded depth on a two-link cycle. --- .../__tests__/safeWriteText.spec.ts | 169 ++++++++++++++++-- src/services/file-safety/safeWriteText.ts | 82 ++++++++- 2 files changed, 229 insertions(+), 22 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index e2f947c8bc..66878d8b9d 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -4,7 +4,14 @@ import { execFile } from "child_process" import type { ChildProcess } from "child_process" import * as path from "path" -import { RollbackFailureError, safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" +import { + PostCommitDurabilityError, + resolveLockKey, + RollbackFailureError, + safeWriteText, + StagingPathError, + type SafeWriteTextOptions, +} from "../safeWriteText" // Full mock for fs/promises — all methods are vi.fn() stubs vi.mock("fs/promises", () => ({ @@ -15,6 +22,7 @@ vi.mock("fs/promises", () => ({ rmdir: vi.fn(), realpath: vi.fn(), lstat: vi.fn(), + readlink: vi.fn(), })) // Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare @@ -49,6 +57,25 @@ function _dirPath(filePath: string): string { return path.dirname(_resolvedTarget(filePath)) } // Minimal Stats stand-in: the SUT only reads `.mode` from it. +// Async lstat stand-in: the SUT only asks whether the path is a link or a file. +function _fileStats(isLink: boolean) { + return { isSymbolicLink: () => isLink, isFile: () => !isLink } +} + +function mockDefaults(): void { + 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) + vi.mocked(fs.rmdir).mockResolvedValue(undefined) + // Existing-target default: a regular 0o644 file. + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + // Staged-file default: a regular file, not a link, so a caller-supplied + // tempPath passes the location and file-type check by default. + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) +} function _stats(mode: number): fsSync.Stats { const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats Object.assign(s, { mode }) @@ -59,15 +86,7 @@ function _stats(mode: number): fsSync.Stats { 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) - vi.mocked(fs.rmdir).mockResolvedValue(undefined) - // Existing-target default: a regular 0o644 file. - vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + mockDefaults() // 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. @@ -577,7 +596,7 @@ describe("safeWriteText", () => { vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - const customTempPath = "/tmp/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" // platform:linux skips DACL entirely so this test focuses on tempPath only await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -604,7 +623,7 @@ describe("safeWriteText", () => { vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) vi.mocked(fsSync.openSync).mockReturnValue(2) - const customTempPath = "/tmp/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -624,7 +643,7 @@ describe("safeWriteText", () => { }) vi.mocked(fsSync.openSync).mockReturnValue(2) - const customTempPath = "/tmp/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -642,7 +661,7 @@ describe("safeWriteText", () => { }) vi.mocked(fsSync.openSync).mockReturnValue(2) - const customTempPath = "/tmp/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" // A target that cannot be stat'd is not a fresh target: publishing with // the default mode would widen a restrictive target through the rename. @@ -674,7 +693,7 @@ describe("safeWriteText", () => { vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o444)) vi.mocked(fsSync.openSync).mockReturnValue(3) - const customTempPath = "/tmp/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -843,7 +862,7 @@ describe("safeWriteText", () => { expect(fsSync.closeSync).toHaveBeenCalledWith(2) }) - it("treats a failed parent-directory fsync as best-effort", async () => { + it("reports a failed parent-directory fsync instead of claiming a durable write", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync) @@ -852,8 +871,13 @@ describe("safeWriteText", () => { throw new Error("EBADF") }) - // the content rename already committed; a missing directory fsync is not fatal - await safeWriteText(targetPath, "data", { platform: "linux" }) + // The content rename committed, so the caller can still find the data at + // the target; what the write cannot claim is that the directory entry + // reached the disk. Returning success here would claim durability the + // filesystem did not grant. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toThrow( + PostCommitDurabilityError, + ) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) @@ -920,3 +944,112 @@ describe("safeWriteText", () => { }) }) }) + +// ── Test 12: lock key, staging path, and post-commit durability ───────────── + +describe("resolveLockKey", () => { + beforeEach(() => mockDefaults()) + + it("canonicalizes the parent directory, not just the file", async () => { + vi.mocked(fs.realpath).mockImplementation(async (target: string) => { + if (target === "/tmp/linkdir/file.json") return "/real/dir/file.json" + if (target === "/real/dir") return "/real/dir" + return target + }) + + // The key is the canonical directory plus the basename, so a symlinked + // ancestor and its referent share one lock. + await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe(path.join("/real/dir", "file.json")) + }) + + it("computes a key for a dangling link, which resolvePublishTarget refuses", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) + vi.mocked(fs.readlink).mockImplementation(async (target: string) => + target === "/tmp/linkdir/file.json" ? "referent.json" : undefined, + ) + + // Mid-commit a peer writer renames the referent away and back, so the key + // must still be computable while the link dangles. + await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe( + path.resolve(path.join("/tmp/linkdir", "referent.json")), + ) + }) + + it("terminates on a two-link cycle instead of walking forever", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) + // Every readlink answers with the same link, so an unbounded walk would + // never end; the bounded walk returns the key it actually reached. + vi.mocked(fs.readlink).mockImplementation(async () => "a.json") + + await expect(resolveLockKey("/tmp/linkdir/a.json")).resolves.toBe( + path.resolve(path.join("/tmp/linkdir", "a.json")), + ) + expect(fs.readlink).toHaveBeenCalledTimes(8) + }) +}) + +describe("caller-supplied staging path", () => { + beforeEach(() => mockDefaults()) + + it("rejects a staging file outside the target's directory before writing anything", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + + // A rename across filesystems fails with EXDEV, and a path elsewhere lets + // a caller publish an unrelated file onto the target. + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/other-dir/x.tmp", platform: "linux" }), + ).rejects.toThrow(StagingPathError) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("rejects a staging path that is a symlink", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) + + // Renaming a link over the target publishes whatever the link points at. + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/x.tmp", platform: "linux" }), + ).rejects.toThrow(StagingPathError) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) +}) + +describe("cleanup before a rollback failure is reported", () => { + beforeEach(() => mockDefaults()) + + it("releases the staged file and its own staging directory before throwing", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + let callCount = 0 + vi.mocked(fs.rename).mockImplementation(async () => { + callCount++ + if (callCount === 1) return // target -> backup + if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails + throw new Error("EACCES") // the rollback rename fails too + }) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow( + RollbackFailureError, + ) + + // The backup is what the caller can still recover, so it stays on disk; the + // staging file and this write's own directory must not leak alongside it. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + expect(fs.rmdir).toHaveBeenCalled() + + const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[2] + const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] + const rmdirOrder = vi.mocked(fs.rmdir).mock.invocationCallOrder[0] + expect(unlinkOrder).toBeGreaterThan(failingRenameOrder) + expect(rmdirOrder).toBeGreaterThan(failingRenameOrder) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index b5f82a4f81..9f62d2aed4 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -56,6 +56,41 @@ export class RollbackFailureError extends Error { this.backupPath = backupPath } } + +/** + * A caller-supplied staging path that is not a file this write may publish: it + * sits outside the target's directory (so the commit rename would cross + * filesystems) or is not a regular file. Rejecting it before any write keeps the + * target from being replaced by whatever the path points at. + */ +export class StagingPathError extends Error { + readonly stagingPath: string + + constructor(message: string, stagingPath: string) { + super(message) + this.name = "StagingPathError" + this.stagingPath = stagingPath + } +} + +/** + * The commit rename succeeded but the parent-directory fsync did not, so the + * directory entry is not known to be durable. The content is at the target; the + * caller cannot assume it survives a crash. Reported as its own error so a + * successful return never claims durability the filesystem did not grant. + */ +export class PostCommitDurabilityError extends Error { + readonly targetPath: string + + constructor(targetPath: string, cause: unknown) { + super( + "The rename committed but the parent directory could not be fsynced -- the content is at the target path reported on this error, and the directory entry may not be durable.", + { cause }, + ) + this.name = "PostCommitDurabilityError" + this.targetPath = targetPath + } +} // -- helpers --------------------------------------------------------------- /** Generate a unique temp file name in the given directory. */ @@ -216,6 +251,29 @@ export async function safeWriteText( let stagingDir: string | null = null let tempPath: string if (options?.tempPath) { + // A caller-supplied staging file is only safe when it is the file this + // write is staging, not an arbitrary path. Two properties are checked: + // it must sit beside the resolved target (a rename across filesystems + // fails with EXDEV, and a path elsewhere lets a caller publish an + // unrelated file onto the target), and it must be a regular file rather + // than a link — renaming a link over the target publishes whatever the + // link points at, which is the same trust problem as writing through a + // dangling symlink in resolvePublishTarget. + const supplied = path.resolve(options.tempPath) + if (path.dirname(supplied) !== path.resolve(dirPath)) { + throw new StagingPathError( + `Staging file must sit in the target's directory (${dirPath}), got ${supplied}`, + supplied, + ) + } + const stagingStat = await fs.lstat(supplied) + if (stagingStat.isSymbolicLink() || !stagingStat.isFile()) { + throw new StagingPathError( + `Staging file must be a regular file, not ${stagingStat.isSymbolicLink() ? "a symlink" : "another file type"}`, + supplied, + ) + } + // The caller's own path is used as given; only the check is canonical. tempPath = options.tempPath } else { stagingDir = _stagingDir(dirPath) @@ -227,6 +285,9 @@ export async function safeWriteText( // Non-null only when the win32 step-2 block saved a successful DACL dump: // it gates the step-5 restore and is tracked for the cleanup unlinks. let daclDumpPath: string | null = null + // Set when the rollback itself fails, so cleanup runs before the error that + // reports the partial state is thrown. + let rollbackFailure: unknown = undefined try { // -- Step 1: write content to staging temp file ------------------- @@ -348,8 +409,14 @@ export async function safeWriteText( } finally { fsSync.closeSync(dirFd) } - } catch { - // best-effort: the content rename already committed + } catch (error: unknown) { + // The content rename committed, but the directory entry that + // points at it is not known to be durable. Reporting success + // here would let a caller believe the write survives a crash, + // so the failure is surfaced as its own error: the caller can + // still find the content at the target, it just cannot rely on + // the directory entry having reached the disk. + throw new PostCommitDurabilityError(targetPath, error) } } @@ -394,8 +461,11 @@ export async function safeWriteText( // The content survives only at the backup path now, and the canonical // target is gone. Reporting just the publish failure would leave the // caller with data it cannot find at the expected path, so the - // partial-failure state travels with the error. - throw new RollbackFailureError(originalError, rollbackError, backupPath) + // partial-failure state travels with the error. The staged temp file + // and this write's staging directory are released first: a rollback + // failure is already a hard enough state to reason about without also + // leaking the staging file. + rollbackFailure = rollbackError } } try { @@ -415,6 +485,10 @@ export async function safeWriteText( await fs.unlink(daclDumpPath).catch(() => {}) } + if (rollbackFailure) { + throw new RollbackFailureError(originalError, rollbackFailure, backupPath) + } + throw originalError } } From 9207cee45967cd1df16cbe1a1fabb187e321495a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 21:14:20 +0800 Subject: [PATCH 003/115] fix(file-safety): propagate a non-ENOENT lstat failure in resolvePublishTarget (U1, issue 1375) The resolver may fall back to the given path only when lstat also reports the path as absent. An EACCES or EIO failure says nothing about whether the path is a link, so falling back would publish through a link we were not allowed to inspect. Focused tests added for both branches. --- .../__tests__/safeWriteText.spec.ts | 30 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 9 +++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 66878d8b9d..674df74070 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1053,3 +1053,33 @@ describe("cleanup before a rollback failure is reported", () => { expect(rmdirOrder).toBeGreaterThan(failingRenameOrder) }) }) + +describe("resolvePublishTarget", () => { + beforeEach(() => mockDefaults()) + + it("propagates an lstat failure that is not ENOENT instead of falling back to the link path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockRejectedValue(eacces) + + // A failed lstat says nothing about whether the path is a link, so the + // fallback would publish through a link we were not allowed to inspect. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("still falls back to the given path when lstat also reports the path as absent", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockRejectedValue(enoent) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // The fallback is the resolved path, not the string that was handed in. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), path.resolve(targetPath)) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 9f62d2aed4..d2a55ab349 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -173,7 +173,14 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise undefined) + // Only a lstat that also reports the path as absent may fall back to the + // given path; a real lstat failure (EACCES, EIO) says nothing about whether + // the path is a link, so falling back would write through a link we were + // simply not allowed to inspect. + const linkStat = await fs.lstat(absoluteFilePath).catch((lstatError: unknown) => { + if (errorCode(lstatError) === "ENOENT") return undefined + throw lstatError + }) if (linkStat?.isSymbolicLink()) throw error return absoluteFilePath }) From 5d029482b73f4599ae8d66e967f03fe7e74329ce Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:30:44 +0800 Subject: [PATCH 004/115] fix(file-safety): keep the rollback pair typed and the mock stand-ins type-sound compile failed at the unit head on three points: - RollbackFailureError needs a string backupPath, but the throw now happens after cleanup, so the `string | null` narrowing was lost. The failure is now held as { error, backupPath }. - The async lstat stand-in is built on the Stats prototype so it satisfies fsSync.Stats. - The realpath/readlink mocks are typed to the real signatures; the readlink mock answers once because only the link path is read. tsc clean, 50 tests pass, ESLint --max-warnings=0 clean, no suppression change. --- .../__tests__/safeWriteText.spec.ts | 23 +++++++++++-------- src/services/file-safety/safeWriteText.ts | 8 ++++--- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 674df74070..007ff4d3c5 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -58,8 +58,12 @@ function _dirPath(filePath: string): string { } // Minimal Stats stand-in: the SUT only reads `.mode` from it. // Async lstat stand-in: the SUT only asks whether the path is a link or a file. -function _fileStats(isLink: boolean) { - return { isSymbolicLink: () => isLink, isFile: () => !isLink } +// Built on the Stats prototype so the mock value still satisfies fsSync.Stats. +function _fileStats(isLink: boolean): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats + s.isSymbolicLink = () => isLink + s.isFile = () => !isLink + return s } function mockDefaults(): void { @@ -951,10 +955,11 @@ describe("resolveLockKey", () => { beforeEach(() => mockDefaults()) it("canonicalizes the parent directory, not just the file", async () => { - vi.mocked(fs.realpath).mockImplementation(async (target: string) => { - if (target === "/tmp/linkdir/file.json") return "/real/dir/file.json" - if (target === "/real/dir") return "/real/dir" - return target + vi.mocked(fs.realpath).mockImplementation(async (target) => { + const key = String(target) + if (key === "/tmp/linkdir/file.json") return "/real/dir/file.json" + if (key === "/real/dir") return "/real/dir" + return key }) // The key is the canonical directory plus the basename, so a symlinked @@ -966,9 +971,9 @@ describe("resolveLockKey", () => { const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) vi.mocked(fs.realpath).mockRejectedValue(enoent) vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) - vi.mocked(fs.readlink).mockImplementation(async (target: string) => - target === "/tmp/linkdir/file.json" ? "referent.json" : undefined, - ) + // Only the link path is read, so a single answer is enough and keeps the mock's + // return type matching fs.promises.readlink. + vi.mocked(fs.readlink).mockResolvedValue("referent.json") // Mid-commit a peer writer renames the referent away and back, so the key // must still be computable while the link dangles. diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index d2a55ab349..e378cbbae8 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -294,7 +294,9 @@ export async function safeWriteText( let daclDumpPath: string | null = null // Set when the rollback itself fails, so cleanup runs before the error that // reports the partial state is thrown. - let rollbackFailure: unknown = undefined + // Held as a pair so the reported error still names the path the content survived at; + // declaring it as `unknown` alone would lose the string narrowing at the throw site. + let rollbackFailure: { error: unknown; backupPath: string } | null = null try { // -- Step 1: write content to staging temp file ------------------- @@ -472,7 +474,7 @@ export async function safeWriteText( // and this write's staging directory are released first: a rollback // failure is already a hard enough state to reason about without also // leaking the staging file. - rollbackFailure = rollbackError + rollbackFailure = { error: rollbackError, backupPath } } } try { @@ -493,7 +495,7 @@ export async function safeWriteText( } if (rollbackFailure) { - throw new RollbackFailureError(originalError, rollbackFailure, backupPath) + throw new RollbackFailureError(originalError, rollbackFailure.error, rollbackFailure.backupPath) } throw originalError From 1e7c39063143cf76687dd4e5b534a3a66825a892 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:34:46 +0800 Subject: [PATCH 005/115] rebuild unit u2 on the fixed chain --- .../__tests__/safeWriteJson.lockKey.spec.ts | 183 ++++++++++++++++ src/utils/__tests__/safeWriteJson.test.ts | 196 ++++++++++++++++-- src/utils/safeWriteJson.ts | 167 +++++++-------- 3 files changed, 432 insertions(+), 114 deletions(-) create mode 100644 src/utils/__tests__/safeWriteJson.lockKey.spec.ts diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts new file mode 100644 index 0000000000..33989f8b81 --- /dev/null +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -0,0 +1,183 @@ +// npx vitest run utils/__tests__/safeWriteJson.lockKey.spec.ts + +import * as os from "os" +import path from "path" +import type { BigIntStats } from "fs" +import * as fs from "fs/promises" +import { acquireFileLock } from "../fileLock" +import { safeWriteJson } from "../safeWriteJson" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" + +vi.mock("../fileLock", () => ({ + acquireFileLock: vi.fn(async () => async () => {}), +})) + +vi.mock("fs/promises", async () => { + const actual = await vi.importActual("fs/promises") + return { ...actual, realpath: vi.fn(), lstat: vi.fn(), readlink: vi.fn() } +}) + +const mockedRealpath = vi.mocked(fs.realpath) +const mockedLstat = vi.mocked(fs.lstat) +const mockedReadlink = vi.mocked(fs.readlink) +const mockedAcquireFileLock = vi.mocked(acquireFileLock) + +const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + +// Each test creates a real temp directory so the real fs calls still work. +// doubles between tests so an implementation from one test cannot carry over. +const createdDirs: string[] = [] +async function makeDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)) + createdDirs.push(dir) + return dir +} + +beforeEach(() => { + mockedRealpath.mockReset() + mockedLstat.mockReset() + mockedReadlink.mockReset() + mockedAcquireFileLock.mockReset() +}) + +afterEach(async () => { + for (const dir of createdDirs) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined) + } + createdDirs.length = 0 +}) + +// Only isSymbolicLink() is consulted by the guard, so the double carries just +// that method. The mocks reject asynchronously: a synchronous throw would bypass +// resolvePublishTarget's catch and skip the ENOENT/symlink branch under test. +const symlinkStat = (target: unknown) => ({ + isSymbolicLink: () => target === currentLink, + // The staging-path check in safeWriteText also asks whether the path is a + // regular file, so the double carries that predicate as well. + isFile: () => target !== currentLink, +}) as unknown as BigIntStats +let currentLink = "" + +describe("safeWriteJson lock key under a peer commit", () => { + it("waits for the peer instead of rejecting, and locks the referent", async () => { + const order: string[] = [] + const dir = await makeDir("lockkey-") + const referent = path.join(dir, "history_item.json") + currentLink = path.join(dir, "link.json") + + // The peer writer has renamed the referent away and has not committed yet, + // so the first resolution fails with ENOENT while lstat still reports a + // symbolic link. A strict resolve here rejects the caller before it can ever + // queue behind the peer, and the caller's delta write is lost. + mockedRealpath + .mockImplementationOnce(async () => { + order.push("resolve-failed") + throw enoent + }) + .mockImplementation(async (target) => { + order.push("resolve") + // The second call happens under the lock, where the peer has committed. + return target === currentLink ? referent : String(target) + }) + mockedLstat.mockImplementation(async (target) => { + order.push("lstat") + return symlinkStat(target) + }) + mockedReadlink.mockImplementation(async (target) => + target === currentLink ? referent : Promise.reject(new Error("not a link")), + ) + mockedAcquireFileLock.mockImplementation(async () => { + order.push("lock") + return async () => {} + }) + + await safeWriteJson(currentLink, { id: "task-1" }) + + // The lock key is the key every other writer to this file uses, so the caller + // queued behind the peer instead of failing before the lock. + expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) + // The trailing lstat is safeWriteText's staging-path check on the temp file + // this write created: it runs after the key was resolved and the lock taken, + // so it does not change which lock the caller queued behind. + expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "resolve", "resolve", "lstat"]) + expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) + }) + + it("releases the lock when the resolution under the lock rejects", async () => { + const order: string[] = [] + let released = false + const dir = await makeDir("lockkey-") + const referent = path.join(dir, "history_item.json") + currentLink = path.join(dir, "link.json") + + // A real dangling link: the walk tolerates it so the caller can queue behind + // the peer, but once the lock is held the strict rejection still applies. A + // rejection outside the protected block would leave the lock held until the + // stale timeout for every other writer to the same file. + mockedRealpath.mockImplementation(async () => { + throw enoent + }) + mockedLstat.mockImplementation(async (target) => { + order.push("lstat") + return symlinkStat(target) + }) + mockedReadlink.mockImplementation(async (target) => + target === currentLink ? referent : Promise.reject(new Error("not a link")), + ) + mockedAcquireFileLock.mockImplementation(async () => { + order.push("lock") + return async () => { + order.push("release") + released = true + } + }) + + await expect(safeWriteJson(currentLink, { id: "task-1" })).rejects.toThrow(enoent) + expect(released).toBe(true) + // The strict rejection is reached through the ENOENT + symlink branch, not + // through a synchronous throw that skips it. + expect(order).toEqual(["lstat", "lock", "lstat", "release"]) + }) + + it("canonicalizes the parent directory when the file itself is not there yet", async () => { + // fs.realpath canonicalizes every component, including a symlinked ancestor + // directory or a Windows 8.3 short name. If the fallback returns the alias + // directory, the key depends on whether the file exists at the moment the key + // is computed, and a writer that resolved the canonical directory takes a + // different lock for the same file. + const aliasDir = path.join(os.tmpdir(), "alias-dir") + const canonicalDir = path.join(os.tmpdir(), "canonical-dir") + const file = path.join(aliasDir, "history_item.json") + mockedRealpath.mockImplementation(async (target) => { + if (target === file) throw enoent + return canonicalDir + }) + mockedLstat.mockImplementation(async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats) + + expect(await resolveLockKey(file)).toBe(path.join(canonicalDir, "history_item.json")) + }) +}) + +it("does not log a cleanup error when the safety net finds the temp file already gone", async () => { + // safeWriteText removes its own temp file on failure, so the safety net in + // safeWriteJson normally finds it gone. That is the expected outcome, not a + // second failure, and it must not be logged as one. + const dir = await makeDir("cleanup-") + const target = path.join(dir, "history_item.json") + currentLink = "" + mockedRealpath.mockImplementation(async (t) => String(t)) + mockedLstat.mockImplementation(async (t) => symlinkStat(t)) + + const renameSpy = vi.spyOn(fs, "rename").mockRejectedValue(new Error("commit rename failed")) + const unlinkSpy = vi.spyOn(fs, "unlink").mockRejectedValue(enoent) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteJson(target, { id: "task-1" })).rejects.toThrow("commit rename failed") + + // Only the original failure is reported. + expect(consoleError).toHaveBeenCalledTimes(1) + + renameSpy.mockRestore() + unlinkSpy.mockRestore() + consoleError.mockRestore() +}) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 0ebdea281d..045c425c1b 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -4,6 +4,8 @@ import * as path from "path" import * as os from "os" import { safeWriteJson } from "../safeWriteJson" +import { RollbackFailureError } from "../../services/file-safety/safeWriteText" +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 @@ -312,9 +314,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 +323,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 +440,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 +457,34 @@ 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 - await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") + // The original error must propagate, not the rollback error + // The rollback also failed, so the error reports the partial state: the publish + // failure stays the cause and the backup location is named. + let failure: RollbackFailureError | undefined + await safeWriteJson(currentTestFilePath, newData).catch((e: unknown) => { + if (e instanceof RollbackFailureError) { + failure = e + return + } + throw e + }) + + expect(failure).toBeInstanceOf(RollbackFailureError) + expect(failure?.cause).toBeInstanceOf(Error) + expect(failure?.rollbackError).toBeInstanceOf(Error) + expect(failure?.backupPath).toContain("safeWriteText.bak_") - // 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 +562,144 @@ 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, the + // backup, and the commit all target the resolved referent. + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: true })) + + // Only the file resolves through the link; the directory is already canonical, + // so the lock key is the referent rather than the alias directory + basename. + vi.spyOn(fs, "realpath").mockImplementation(async (target) => + target === callerPath ? referentPath : String(target), + ) + + 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 }) + }) + + // 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 })) + + // Only the file resolves through the link; the directory is already canonical, + // so the lock key is the referent rather than the alias directory + basename. + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (target) => (target === callerPath ? referentPath : String(target))) + + // 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") + try { + 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), + ) + } finally { + // Cleanup must run even when an assertion fails: a leaked mock + // registration or console spy changes later tests, and vi.unmock + // alone does not reset a module that already imported the mock. + realpathSpy.mockRestore() + vi.unmock("proper-lockfile") + vi.resetModules() + consoleErrorSpy.mockRestore() + } + }) + + // 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 7da68b2a7a..bae200dd38 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,6 +4,12 @@ import * as path from "path" import { JsonStreamStringify } from "json-stream-stringify" import { acquireFileLock } from "./fileLock" +import { + resolveLockKey, + resolvePublishTarget, + safeWriteText, + type SafeWriteTextOptions, +} from "../services/file-safety/safeWriteText" /** * Options for safeWriteJson function @@ -32,7 +38,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. @@ -42,7 +48,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,38 +56,46 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const dirPath = path.dirname(absoluteFilePath) // Ensure directory structure exists with improved reliability + // Declared outside the protected block so the catch and finally can still name + // the target when the resolution itself rejects. + let resolvedTargetPath: string | undefined + 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) throw dirError } - // 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. - // If lock acquisition fails, it throws 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. - releaseLock = await acquireFileLock(absoluteFilePath) + // Lock key: the symlink referent when the path is an existing symlink, so a + // symlink alias and its referent share one lock. The key must be computable + // while a peer writer is mid-commit (backup mode renames the referent away and + // back), so the walk tolerates a dangling link instead of rejecting it here. + const lockKey = await resolveLockKey(absoluteFilePath) - // Variables to hold the actual paths of temp files if they are created. + // Acquire the lock before any file operations. If acquisition fails it throws + // immediately, and releaseLock stays a no-op so the finally block does not try + // to release an unacquired lock. + releaseLock = await acquireFileLock(lockKey) + + // 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 { + // Resolve the publish target under the lock: the peer has committed by now, so + // the strict dangling-link rejection still applies to a real dangling link. It + // must stay inside the protected block, otherwise a rejection here leaves the + // advisory lock held until the stale timeout for every other writer. + resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // If a merge callback was provided, read the current file under the lock // and let the caller merge before we write. Must be inside try/finally // so a throwing merge still releases the lock. 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 @@ -93,111 +106,73 @@ 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; resolvedTargetPath above): safeWriteText commits by renaming + // onto that referent, and a rename across filesystems would fail with EXDEV. 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(resolvedTargetPath, "", 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) + console.error( + `Operation failed for ${resolvedTargetPath ?? 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) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`, - cleanupError, - ) + } catch (cleanupError: unknown) { + // The expected case: safeWriteText already removed its own temp file, so a + // missing file here is not a cleanup failure worth logging. Returning would + // also swallow the original error the caller needs. + const isAbsent = + typeof cleanupError === "object" && + cleanupError !== null && + "code" in cleanupError && + cleanupError.code === "ENOENT" + if (!isAbsent) { + console.error( + `[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`, + cleanupError, + ) + } } } - // 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) + console.error(`Failed to release lock for ${resolvedTargetPath ?? absoluteFilePath}:`, unlockError) } } } From 86ae0141a6793d845b0403d3c4dad4a8893c9514 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:47:25 +0800 Subject: [PATCH 006/115] chore(lint): prune the safeWriteJson suppression this unit earns The any usage this entry covered is gone in the rewritten file, so the count drops 4 -> 3. eslint --prune-suppressions --max-warnings=0 confirms it. --- src/eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index f1b2590f7f..671c9f594c 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1701,7 +1701,7 @@ }, "utils/safeWriteJson.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 3 } }, "vitest.setup.ts": { From cbe99cc23e1242975e3594a66e71d777f1a0c96c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:47:37 +0800 Subject: [PATCH 007/115] rebuild unit u3 on the fixed chain --- .../__tests__/observationRegistry.spec.ts | 108 ++++++++++++++++++ src/core/task/observationRegistry.ts | 59 ++++++++++ 2 files changed, 167 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/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts new file mode 100644 index 0000000000..a3c55ebc6d --- /dev/null +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -0,0 +1,108 @@ +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") + }) + + describe("completeness scope (S4b follow-up #46)", () => { + it("defaults to a complete observation when the read scope is not given", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + + expect(reg.get("/a/b/c.ts")!.complete).toBe(true) + }) + + it("records a partial observation when the read only returned a view of the file", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1", false) + + expect(reg.get("/a/b/c.ts")!.complete).toBe(false) + }) + + it("re-observing replaces the entry's completeness with the new read's scope", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1", false) + reg.observe("/a/b/c.ts", "v2") + + const obs = reg.get("/a/b/c.ts")! + expect(obs.version).toBe("v2") + expect(obs.complete).toBe(true) + }) + + it("re-observing with a partial scope downgrades a previously complete entry", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + reg.observe("/a/b/c.ts", "v2", false) + + const obs = reg.get("/a/b/c.ts")! + expect(obs.version).toBe("v2") + expect(obs.complete).toBe(false) + }) + }) +}) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts new file mode 100644 index 0000000000..0ef9115f21 --- /dev/null +++ b/src/core/task/observationRegistry.ts @@ -0,0 +1,59 @@ +/** + * 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. The S4 guarded-write consults + * these observations for the version check and for the completeness check that + * gates a full-file replacement. + */ + +export interface FileObservation { + /** Version token derived from on-disk fs.stat (bigint mode). */ + version: string + /** Millisecond timestamp when the observation was recorded. */ + observedAt: number + /** + * Whether the read that produced this observation returned the complete + * file. A slice, line-range, truncated, or indentation-block read returns + * only a view of the file; such an observation authorizes targeted edits + * on the view the model saw, but never a full-file replacement. + */ + complete: boolean +} + +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, the + * new version token, and the read's completeness. `complete` defaults to + * true for callers that read the whole file themselves (spec doubles, + * WriteToFileTool). A caller whose read is internal to a targeted edit must + * carry the model's prior completeness instead, so the tool's own read cannot + * upgrade a partial read into authority for a full-file replacement. + */ + observe(absolutePath: string, version: string, complete: boolean = true): void { + this.entries.set(absolutePath, { version, observedAt: Date.now(), complete }) + } + + 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 + } +} From 755954320ac31f9532b62fe727971bff4163418f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:34:05 +0800 Subject: [PATCH 008/115] fix(task): declare the observation registry on Task in this unit The read tools record the observed on-disk version through task.observationRegistry, but the field was only declared in a later unit, so at this head the call dereferences undefined and the mocked e2e run fails on the read_file smoke tests. The registry is introduced by this unit, so the field belongs here. tsc clean on this unit, 11 observationRegistry tests pass, ESLint --max-warnings=0 clean. --- src/core/task/Task.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fdcc740998..9ccc2d7968 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -104,6 +104,7 @@ import { buildNativeToolsArrayWithRestrictions } from "./build-tools" import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" +import { ObservationRegistry } from "./observationRegistry" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" @@ -282,6 +283,10 @@ export class Task extends EventEmitter implements TaskLike { readonly instanceId: string readonly metadata: TaskMetadata + // The observed on-disk version of each file this task has read. Declared here so the + // read tools can record it; a write guard later compares a token against this registry. + readonly observationRegistry = new ObservationRegistry() + todoList?: TodoItem[] readonly rootTask: Task | undefined = undefined From 0fcf305eb30dc84b0b776f1a95889e656bd53943 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:48:10 +0800 Subject: [PATCH 009/115] rebuild unit u4 on the fixed chain --- src/core/tools/ReadFileTool.ts | 93 ++- src/core/tools/__tests__/readFileTool.spec.ts | 786 +++++++++++++++++- .../misc/__tests__/indentation-reader.spec.ts | 43 + src/integrations/misc/indentation-reader.ts | 12 +- 4 files changed, 923 insertions(+), 11 deletions(-) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 00fc507692..dd38a47809 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -16,13 +16,14 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" +import { versionTokenOfStat } from "../../utils/versionToken" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { getReadablePath } from "../../utils/path" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader" -import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" +import { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH } from "../prompts/tools/native-tools/read_file" import type { ToolUse, PushToolResult } from "../../shared/tools" import { @@ -214,14 +215,36 @@ 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) + // A lossy decode is not the whole file: the model never saw those bytes. + const lossyDecode = !Buffer.from(fileContent).equals(buffer) + // S4b follow-up (#46 / epic #1375): processTextFile reports whether the + // returned content is the whole file; the observation below records that + // scope so the write guard can deny full-file updates built on a partial view. + const processed = this.processTextFile(fileContent, entry) 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. + // 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, processed.complete && !lossyDecode) + } + } + updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${result}`, + nativeContent: `File: ${relPath}\n${processed.content}`, }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) @@ -265,8 +288,14 @@ export class ReadFileTool extends BaseTool<"read_file"> { /** * Process a text file according to the requested mode. + * + * Returns the content string plus whether that content is the complete + * file (S4b follow-up #46 / epic #1375): slice mode is complete only + * when it starts at line 1, returns every line, and was not truncated; + * indentation mode is never complete because it returns semantic blocks + * of the file, not the file itself. */ - private processTextFile(content: string, entry: InternalFileEntry): string { + private processTextFile(content: string, entry: InternalFileEntry): { content: string; complete: boolean } { const mode = entry.mode || "slice" if (mode === "indentation") { @@ -299,7 +328,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { output += `\n\nIncluded ranges: ${rangeStr} (total: ${result.totalLines} lines)` } - return output + // Indentation mode returns semantic blocks: never a complete file view. + return { content: output, complete: false } } // Slice mode (default): simple offset/limit reading @@ -322,11 +352,28 @@ export class ReadFileTool extends BaseTool<"read_file"> { To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. ${result.content}` + if (result.hasClippedLines) { + // The slice cut lines off and also clipped long lines inside it, so both + // notices belong to the response. + output += `\nNote: Some lines in this view exceed ${MAX_LINE_LENGTH} characters and were clipped in this view.` + } + } else if (result.hasClippedLines) { + // Every line was returned, so there is no later offset to read: report the + // clipping without a next-offset hint, and keep the read incomplete so a + // full-file replacement cannot be built from a clipped line. + output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. The file was read in full, but the clipped lines were not shown in full. + ${result.content}` } else if (result.returnedLines === 0) { output = "Note: File is empty" } - return output + // Complete only when the slice starts at line 1, returned every line, and + // showed every line in full (returnedLines === totalLines follows from the + // first two conditions): a partial start, a truncated tail, or a clipped + // line means the model did not see the whole file. + const complete = offset0 === 0 && !result.wasTruncated && !result.hasClippedLines + + return { content: output, complete } } /** @@ -768,9 +815,20 @@ export class ReadFileTool extends BaseTool<"read_file"> { } // Read text file - const rawContent = await fs.readFile(fullPath, "utf8") + // 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 rawBuffer = await fs.readFile(fullPath) + const rawContent = rawBuffer.toString("utf-8") + // Same contract: a lossy decode is a partial view. + const lossyDecode = !Buffer.from(rawContent).equals(rawBuffer) // Handle line ranges if specified + // S4b follow-up (#46 / epic #1375): a line-range read returns only the requested + // ranges, and a slice truncated to DEFAULT_LINE_LIMIT returns only the head of + // the file — record such observations as partial so the write guard denies a + // full-file update built on them. + let readComplete = false let content: string if (entry.lineRanges && entry.lineRanges.length > 0) { const lines = rawContent.split("\n") @@ -790,8 +848,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Read with default limits using slice mode const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) content = result.content + readComplete = !result.wasTruncated && !result.hasClippedLines if (result.wasTruncated) { content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` + if (result.hasClippedLines) { + // Both notices: the slice was truncated and a line inside it was + // clipped. + content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]` + } + } else if (result.hasClippedLines) { + content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]` } } @@ -799,6 +865,19 @@ 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. + // 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, readComplete && !lossyDecode) + } + } } 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 a457ff87bc..353e4845cb 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -13,13 +13,19 @@ */ import path from "path" +import type { Stats } from "fs" + +import type { LegacyReadFileParams } from "@roo-code/types" import type { ToolUse } from "../../../shared/tools" import type { Task } from "../../task/Task" import { isBinaryFile } from "isbinaryfile" -import { readFileTool } from "../ReadFileTool" + +import { readFileTool, ReadFileTool } from "../ReadFileTool" +import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, processImageFile, isSupportedImageFormat } from "../helpers/imageHelpers" import { extractTextFromFile, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text" @@ -134,6 +140,7 @@ interface MockTaskOptions { rooIgnoreAllowed?: boolean maxImageFileSize?: number maxTotalImageSize?: number + observationRegistry?: ObservationRegistry } function createMockTask(options: MockTaskOptions = {}) { @@ -141,6 +148,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 }, @@ -185,7 +195,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({ @@ -837,7 +858,7 @@ describe("ReadFileTool", () => { mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) // fs.readFile with "utf8" encoding returns a string, not a Buffer - mockedFsReadFile.mockResolvedValue("line1\nline2\nline3\nline4\nline5" as any) + mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3\nline4\nline5")) await readFileTool.execute( { files: [{ path: "test.ts", lineRanges: [{ start: 2, end: 4 }] }] } as any, @@ -1550,5 +1571,764 @@ 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("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) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + + 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) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: post-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + + 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) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: legacy-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + 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) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: legacy-post-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + 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") + }) + }) + + describe("read completeness scope (S4b follow-up #46)", () => { + // The stat mock only implements the members the tool and versionToken read. + const bigintStats = (): Stats => + ({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + }) as unknown as Stats + + it("native: a full, untruncated slice read from line 1 records a complete observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "full.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion, calledComplete] = observeSpy.mock.calls[0] + expect(calledPath).toContain("full.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + expect(calledComplete).toBe(true) + expect(reg.get(calledPath)!.complete).toBe(true) + }) + + it("native: a truncated slice read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a", + returnedLines: 1, + totalLines: 5, + wasTruncated: true, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "trunc.ts", offset: 1, limit: 1 }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + + it("native: a full read whose line content was clipped records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "clipped.ts" }, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + // Every line was returned, so the notice must not point at a next + // offset that is beyond the file. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("clipped in this view") + expect(pushed).not.toContain("To read more") + // The notice is added on top of the read, it does not replace it. + expect(pushed).toContain("1 | a") + }) + + it("native: a truncated slice that also clipped a line reports both notices", async () => { + // A long line inside a slice that also cut lines off is a plausible case, + // and the response has to say both things. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 3, + wasTruncated: true, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "both.ts" }, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("Showing lines 1-2 of 3 total lines") + expect(pushed).toContain("clipped in this view") + }) + + it("native: an offset read that is not truncated still records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + mockedReadWithSlice.mockReturnValue({ + content: "4 | d\n5 | e", + returnedLines: 2, + totalLines: 5, + wasTruncated: false, + includedRanges: [[4, 5]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "offset.ts", offset: 4, limit: 2 }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + + it("native: an indentation-mode block read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("function f() { return 1 }")) + mockedReadWithIndentation.mockReturnValue({ + content: "10 | function f() {", + wasTruncated: false, + includedRanges: [[10, 20]], + totalLines: 100, + returnedLines: 11, + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "indent.ts", mode: "indentation" }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + + it("legacy: a line-range read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "ranges.ts", lineRanges: [{ start: 2, end: 4 }] }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledPath).toContain("ranges.ts") + expect(calledComplete).toBe(false) + }) + + it("legacy: a full, untruncated slice read records a complete observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-full.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(true) + + // Nothing was omitted and nothing was clipped, so no note is added. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).not.toContain("clipped in this view") + expect(pushed).not.toContain("total lines") + }) + + it("legacy: a full read with a clipped line records a partial observation and reports the clipping", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-clipped.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + // Every line was returned, so the note reports the clipping instead of + // a showing-N-of-N count that would point past the file. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("clipped in this view") + expect(pushed).not.toContain("showing 2 of 2 total lines") + }) + + it("legacy: a truncated slice that also clipped a line reports both notices", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 3, + wasTruncated: true, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-both.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("showing 2 of 3 total lines") + expect(pushed).toContain("clipped in this view") + }) + + it("legacy: a slice truncated to the default limit records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a", + returnedLines: 1, + totalLines: 5000, + wasTruncated: true, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-trunc.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + // Lines were omitted here, so the note reports the omitted range rather + // than clipping. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("showing 1 of 5000 total lines") + }) + + it("native: a read whose bytes did not survive the UTF-8 decode records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // 0xFF is not valid UTF-8, so the model receives U+FFFD instead of the byte. + const raw = Buffer.from([0x61, 0xff]) + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(raw) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\uFFFD", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "lossy.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + + it("legacy: a read whose bytes did not survive the UTF-8 decode records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const raw = Buffer.from([0x61, 0xff]) + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(raw) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\uFFFD", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-lossy.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + }) }) }) diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts index b6c28365aa..fa951ea85e 100644 --- a/src/integrations/misc/__tests__/indentation-reader.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest" +import { MAX_LINE_LENGTH } from "../../../core/prompts/tools/native-tools/read_file" import { parseLines, formatWithLineNumbers, @@ -278,11 +279,45 @@ describe("readWithSlice", () => { expect(result.wasTruncated).toBe(true) }) + it("reports a clipped line separately from omitted lines", () => { + // Every line is returned, but formatWithLineNumbers clips a line longer + // than MAX_LINE_LENGTH, so the model did not see the whole file. + const lines = ["x".repeat(MAX_LINE_LENGTH + 10), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.wasTruncated).toBe(false) + expect(result.hasClippedLines).toBe(true) + }) + + it("keeps a slice complete when a line is exactly at the length cap", () => { + // formatWithLineNumbers clips only lines strictly longer than the cap, so a + // line at exactly MAX_LINE_LENGTH is shown in full and the read is complete. + const lines = ["x".repeat(MAX_LINE_LENGTH), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.wasTruncated).toBe(false) + expect(result.hasClippedLines).toBe(false) + }) + + it("flags clipping when any line is clipped, not only when every line is", () => { + // The first line is clipped and the second is shown in full: some lines are + // a partial view even though every line was returned. + const lines = ["y".repeat(MAX_LINE_LENGTH + 1), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.hasClippedLines).toBe(true) + }) + it("should handle offset beyond file end", () => { const result = readWithSlice(SIMPLE_CODE, 1000, 10) expect(result.returnedLines).toBe(0) expect(result.content).toContain("Error") + // No line was returned, so nothing could have been clipped. + expect(result.hasClippedLines).toBe(false) }) it("should handle negative offset", () => { @@ -296,6 +331,14 @@ describe("readWithSlice", () => { // ─── readWithIndentation Tests ──────────────────────────────────────────────── describe("readWithIndentation", () => { + it("reports an out-of-range anchor as an error with no clipping", () => { + const result = readWithIndentation(SIMPLE_CODE, { anchorLine: 1000 }) + + expect(result.content).toContain("out of range") + expect(result.returnedLines).toBe(0) + expect(result.hasClippedLines).toBe(false) + }) + describe("basic block extraction", () => { it("should extract content around the anchor line", () => { const result = readWithIndentation(PYTHON_CODE, { diff --git a/src/integrations/misc/indentation-reader.ts b/src/integrations/misc/indentation-reader.ts index 47ff4593bf..9f521c4358 100644 --- a/src/integrations/misc/indentation-reader.ts +++ b/src/integrations/misc/indentation-reader.ts @@ -58,8 +58,10 @@ export interface IndentationReadResult { totalLines: number /** Lines actually returned */ returnedLines: number - /** Whether output was truncated due to limit */ + /** Whether output was truncated because lines were omitted */ wasTruncated: boolean + /** Whether any returned line was clipped by the per-line length cap */ + hasClippedLines?: boolean } // ─── Constants ──────────────────────────────────────────────────────────────── @@ -244,6 +246,7 @@ export function readWithIndentation(content: string, options: IndentationReadOpt totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -386,6 +389,7 @@ export function readWithSlice( totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -393,6 +397,11 @@ export function readWithSlice( const endIdx = Math.min(offset + limit, totalLines) const selectedLines = lines.slice(offset, endIdx) const wasTruncated = endIdx < totalLines + // A returned line can still be a partial view: formatWithLineNumbers clips a + // line longer than MAX_LINE_LENGTH, so a slice that returned every line may + // still hide content. Clipping is reported separately from omission so the + // caller does not suggest a next offset that is beyond the file. + const hasClippedLines = selectedLines.some((line) => line.content.length > MAX_LINE_LENGTH) // Format output const formattedContent = formatWithLineNumbers(selectedLines) @@ -403,5 +412,6 @@ export function readWithSlice( totalLines, returnedLines: selectedLines.length, wasTruncated, + hasClippedLines, } } From 7e0533e406b5a6d6aa87dad1326e8de36c95ee65 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:49:49 +0800 Subject: [PATCH 010/115] rebuild unit u5 on the fixed chain --- src/core/tools/__tests__/guardedWrite.spec.ts | 859 ++++++++++++++++++ src/core/tools/guardedWrite.ts | 418 +++++++++ 2 files changed, 1277 insertions(+) create mode 100644 src/core/tools/__tests__/guardedWrite.spec.ts create mode 100644 src/core/tools/guardedWrite.ts diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts new file mode 100644 index 0000000000..e111bd74db --- /dev/null +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -0,0 +1,859 @@ +/** + * 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, last-write-wins for same-task writes through the post-publish + * observation refresh, 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, GuardRejectedError } from "../guardedWrite" +import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../../utils/versionToken" +import { withFileLock } from "../../../utils/fileLock" +import { resolveLockKey } from "../../../services/file-safety/safeWriteText" +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(), + resolveLockKey: vi.fn(async (p: string) => p), +})) + +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(), +})) + +const mockedWithFileLock = vi.mocked(withFileLock) +const mockedResolveLockKey = vi.mocked(resolveLockKey) +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 + abort?: boolean +} + +/** + * 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, + abort: options.abort ?? false, + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), + } + return task as unknown as Task +} + +// -- Tests ------------------------------------------------------------------- + +describe("guardedWrite (S4a, epic #1375)", () => { + beforeEach(() => { + vi.resetAllMocks() + mockedWithFileLock.mockImplementation((filePath, operation) => operation(path.resolve(filePath))) + resetChain() + }) + + describe("unobserved create", () => { + it("succeeds when the file is absent and publishes via safeWriteText", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("publishes caller-supplied bytes unchanged", async () => { + // The extension host hands over bytes already encoded by VS Code's + // codec; the guard must pass them to the publish primitive as they are. + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask() + + await guardedWrite(task, "bytes.txt", Buffer.from([0x00, 0x68]), "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("bytes.txt"), Buffer.from([0x00, 0x68])) + }) + + it("records an unobserved create as complete so a later full-file update is allowed", async () => { + // Nothing was read, so the model supplied the whole file: the post-publish + // refresh must record completeness, otherwise the next update would be + // rejected as a partial read. + const reg = new ObservationRegistry() + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "new-file.txt", "hello", "create") + + expect(reg.get(abs("new-file.txt"))?.complete).toBe(true) + }) + + it("carries a caller-supplied completeness through the publish for a fresh destination", async () => { + // A move publishes content built from a view of another file. Recording the + // create as complete would hand the model authority over source lines it never + // read, so the caller's completeness has to survive the refresh. + const reg = new ObservationRegistry() + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "new-file.txt", "hello", "create", false) + + expect(reg.get(abs("new-file.txt"))?.complete).toBe(false) + }) + + 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 " + + "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", "io-error.txt")).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", "locked.txt")).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" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + 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 " + + "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" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + 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("observed update (read completeness, S4b follow-up #46)", () => { + it("rejects a full-file update when only a partial read observed the file - no I/O, nothing published", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("publishes a full-file update when the observation is complete and the version matches", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", true) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "new content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + }) + + it("leaves edit-kind publishes unaffected by a partial observation - the model saw the edited region", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + }) + + it("keeps a partial observation partial after an edit so a later full replacement is rejected", async () => { + // The edit replaced only the region the model saw. Refreshing the + // observation to complete would let a following full-file write publish + // content built from the slice alone. + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(reg.get(abs("doc.txt"))?.complete).toBe(false) + + await expect(guardedWrite(task, "doc.txt", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + + it("rejects a create-kind full-file overwrite of an existing file when only a partial read observed it", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedFsAccess.mockResolvedValue(undefined) // target still on disk + const task = createMockTask({ observationRegistry: reg }) + + // A "create" whose target exists publishes through the same + // full-file replacement path as "update": a partial observation must + // not authorize dropping the content the model never read. + await expect(guardedWrite(task, "doc.txt", "created", "create")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + }) + + it("allows a create-kind recreate of a vanished file despite a partial observation - a fresh create needs no prior read", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) // target absent + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "created", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") + }) + + it("publishes a create-kind overwrite of an existing file when the observation is complete", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) // target still on disk + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "created", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") + }) + }) + + describe("observation refresh after publish (S4b review round)", () => { + it("refreshes the observation with the post-publish token so a consecutive edit does not fail stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + // The first publish moves the on-disk token: edit 1's pre-write CAS + // sees v1, the post-publish refresh sees v2, and edit 2's CAS sees v2. + mockedComputeVersionToken.mockResolvedValueOnce("v1").mockResolvedValueOnce("v2").mockResolvedValue("v2") + + await guardedWrite(task, "doc.txt", "first edit", "edit") + await guardedWrite(task, "doc.txt", "second edit", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("doc.txt"), "second edit") + // the observation now carries the post-publish token, complete + expect(reg.get(abs("doc.txt"))?.version).toBe("v2") + expect(reg.get(abs("doc.txt"))?.complete).toBe(true) + }) + + it("refreshes as a COMPLETE observation so a consecutive full-file update is not rejected partial", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + mockedComputeVersionToken + .mockResolvedValueOnce("v1") // update 1 pre-write CAS + .mockResolvedValueOnce("v2") // update 1 post-publish refresh + .mockResolvedValue("v2") // update 2 pre-write CAS + + await guardedWrite(task, "doc.txt", "first", "update") + await guardedWrite(task, "doc.txt", "second", "update") + + // a refresh recorded as partial would have the second update + // rejected by the completeness gate + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(reg.get(abs("doc.txt"))?.complete).toBe(true) + }) + + it("keeps the previous observation when the post-publish token cannot be computed (deletion race after publish)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + // The publish succeeded but the file was deleted before the refresh + // stat: the token computation rejects (ENOENT) and the guard's + // .catch normalizes it to undefined. The observation must keep the + // pre-publish version (the next write fails closed through the + // standard deleted/stale path) rather than a token-less record. + mockedComputeVersionToken + .mockResolvedValueOnce("v1") // edit 1 pre-write CAS + .mockRejectedValueOnce({ code: "ENOENT" }) // edit 1 post-publish refresh - file deleted + .mockResolvedValue("v1") // edit 2 pre-write CAS + + await guardedWrite(task, "doc.txt", "first edit", "edit") + await guardedWrite(task, "doc.txt", "second edit", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(reg.get(abs("doc.txt"))?.version).toBe("v1") + }) + }) + + 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 - serialized, both publish against the refreshed observation", 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), and the + // guarded write refreshes the observation to it, so the second + // write CASes against v2 and publishes too: same-task writes are + // serialized last-write-wins in submission order, while a token that + // moves outside the task's own publish still fails stale. + 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]) + + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("shared.txt"), "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("shared.txt"), "second") + }) + + it("observed-absent then two concurrent creates - the second publishes against the refreshed observation", 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, and the guarded write refreshes the observation to it. + 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]) + + // Both same-task creates serialize: the second CASes against the + // refreshed v2 observation and publishes its content last-write-wins. + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(publishes).toBe(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("absent.txt"), "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("absent.txt"), "second") + }) + + 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 | Uint8Array)[] = [] + mockedSafeWriteText.mockImplementation(async (_path: string, content: string | Uint8Array) => { + 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. The first matches v1 and publishes; + // its post-publish refresh records v2, so the second CASes against + // v2 and publishes too (serialized same-task writes). + const p1 = guardedWrite(task, canonical, "first", "update") + const p2 = guardedWrite(task, canonical + "/", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, canonical, "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, canonical, "second") + }) + }) + + describe("lock serialization with other writers", () => { + it("holds the shared advisory lock across the version check and the publish", async () => { + // The guard decision and the publish must be one operation under the same + // advisory lock that safeWriteJson and task-history deletion use, otherwise + // a lock-using writer can land between the check and the write. + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedComputeVersionToken.mockImplementation(async () => { + order.push("check") + return "v1" + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + }) + + await replaceIfVersion(abs("a.txt"), "v1", "content", "a.txt") + + expect(order).toEqual(["lock", "check", "publish", "check", "release"]) + expect(mockedWithFileLock).toHaveBeenCalledWith(abs("a.txt"), expect.any(Function)) + }) + + it("holds the same lock across the absence check and the publish for an unobserved create", async () => { + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedFsAccess.mockImplementation(async () => { + order.push("check") + throw { code: "ENOENT" } + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + }) + + mockedComputeVersionToken.mockImplementation(async () => { + order.push("token") + return "v1" + }) + + await createIfAbsent(abs("new.txt"), "hello", "new.txt") + + expect(order).toEqual(["lock", "check", "publish", "token", "release"]) + }) + + it("locks the resolved publish target instead of the link path", async () => { + // proper-lockfile keys by the path it is given, so a symlink alias and its + // referent would take two locks for one file. The guard must lock the key + // every other writer to that file uses. + const referent = abs("real/file.txt") + mockedResolveLockKey.mockResolvedValue(referent) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + + await createIfAbsent(abs("link.txt"), "hello", "link.txt") + + expect(mockedResolveLockKey).toHaveBeenCalledWith(abs("link.txt")) + expect(mockedWithFileLock).toHaveBeenCalledWith(referent, expect.any(Function)) + }) + + it("reads the post-publish token inside the lock, before releasing it", async () => { + // A peer lock-using writer can publish in the gap between the publish and + // a post-publish stat made after the lock is released, and the task would + // then record that peer's token as its own observation. + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedComputeVersionToken.mockImplementation(async () => { + order.push("check") + return "v1" + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + return undefined + }) + + const token = await replaceIfVersion(abs("a.txt"), "v1", "content", "a.txt") + + expect(order).toEqual(["lock", "check", "publish", "check", "release"]) + expect(token).toBe("v1") + }) + }) + + 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") + }) + }) + + it("does not run a queued write once the owning task is cancelled", async () => { + const task = createMockTask({ abort: true }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "new-file.txt", "hello", "create")).rejects.toThrow( + "Task was cancelled before this write ran -- the queued publish is not performed.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks cancellation under the publish lock before writing", async () => { + // The dequeue check passed; the abort landed while the lock was being taken, + // so the guard must refuse before the publish rather than write for a task that + // is already gone. + const task = createMockTask() + mockedWithFileLock.mockImplementation(function (filePath, operation) { + task.abort = true + return operation(path.resolve(filePath)) + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "new-file.txt", "hello", "create")).rejects.toThrow( + "Task was cancelled before this write published -- nothing was written.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks cancellation after the awaited preflight, before publication starts", async () => { + // The abort lands while the version token is being computed. The guard has to + // refuse on the way to the publish, not write for a task that is already gone. + mockedComputeVersionToken.mockImplementation(async () => { + task.abort = true + return "v1" + }) + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1", true) + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "a.txt", "content", "update")).rejects.toThrow( + "Task was cancelled before this write published -- nothing was written.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("names the caller's path, not the resolved absolute path, in a model-facing rejection", async () => { + // The guard key stays absolute, but the message and the error field go to the + // model, so they must not carry a user-specific absolute path. + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + let error: GuardRejectedError | undefined + await guardedWrite(task, "src/thing.ts", "hello", "create").catch((e: unknown) => { + if (e instanceof GuardRejectedError) { + error = e + return + } + throw e + }) + + expect(error?.message).toBe( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + expect(error?.path).toBe("src/thing.ts") + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + it("names the caller's path on a stale-version rejection as well", async () => { + // The stale branch is the most common rejection, so it has to follow the same rule + // as the create, delete and cancellation branches. + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + const reg = new ObservationRegistry() + reg.observe(abs("src/thing.ts"), "v1", true) + const task = createMockTask({ observationRegistry: reg }) + + let staleError: GuardRejectedError | undefined + await guardedWrite(task, "src/thing.ts", "hello", "update").catch((e: unknown) => { + if (e instanceof GuardRejectedError) { + staleError = e + return + } + throw e + }) + + expect(staleError?.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(staleError?.path).toBe("src/thing.ts") + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts new file mode 100644 index 0000000000..13d870308f --- /dev/null +++ b/src/core/tools/guardedWrite.ts @@ -0,0 +1,418 @@ +/** + * 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 current observation token (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. Each publish refreshes the observation to + * the token it wrote when the new token can be computed, so same-task writes + * apply last-write-wins; when that refresh fails the observation keeps the + * previous observation token. A write that goes through replaceIfVersion fails + * stale when the on-disk token differs from the token the observation currently + * holds; an observed "create" whose target has disappeared instead uses + * createIfAbsent and can recreate it. Observations come from the task's S2 + * ObservationRegistry and authorize the write as well as the version check. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { safeWriteText } from "../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../utils/versionToken" +import { withFileLock } from "../../utils/fileLock" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" +import type { Task } from "../task/Task" + +// -- Types ------------------------------------------------------------------ + +/** Write kind that drives guard selection. */ +export type GuardedWriteKind = "create" | "update" | "edit" + +/** Error thrown when a guard rejects a write. Exported so a caller can tell a guard verdict from an unrelated failure. + */ +export 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. + */ +export function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined +} + +/** + * Cancellation re-check under the publish lock. A write queued on the FIFO + * chain can outlive its task: the caller already reported the write to the model, + * so a task aborted while its link waited must not publish afterwards. + */ +function cancelledBeforePublish(absolutePath: string, displayPath: string, isCancelled?: () => boolean): void { + if (isCancelled?.()) { + throw new GuardRejectedError( + "Task was cancelled before this write published -- nothing was written.", + displayPath, + ) + } +} + +/** 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. + */ +/** + * Read the on-disk token after a publish, best-effort: a publish that + * succeeded is not undone by a failed stat, so the caller keeps the publish + * and only skips the observation refresh. + */ +async function tokenAfterPublish(absolutePath: string): Promise { + return computeVersionToken(absolutePath).catch(() => undefined) +} + +export async function createIfAbsent( + absolutePath: string, + content: string | Uint8Array, + displayPath: string, + // Re-checked under the lock: a link that waited on the FIFO chain can outlive + // the task that queued it. + isCancelled?: () => boolean, +): Promise { + // Lock the key every other writer to this file uses: the resolved publish + // target, so a symlink alias and its referent share one lock. + return withFileLock(await resolveLockKey(absolutePath), async () => { + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + 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 + } + // Immediately before publication starts. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + await safeWriteText(absolutePath, content) + // Read the new token under the same lock, otherwise a peer lock-using + // writer can publish in the gap and the caller records that writer's + // token as its own observation. + return tokenAfterPublish(absolutePath) + } + + throw new GuardRejectedError( + "File already exists at " + + displayPath + + " and was not read before this write -- read the file first, then retry.", + displayPath, + ) + }) +} + +/** + * 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. + * + * Atomicity boundary: the check and the publish run inside one acquisition of the + * shared advisory lock, and the post-publish token is read under that same lock, so + * no writer that participates in the protocol can interleave here. Every production + * caller of the publish primitive holds the same canonical key (safeWriteJson + * acquires it before publishing; TaskHistoryStore deletion takes it on the same + * resolved key). The primitive itself cannot re-acquire the lock -- withFileLock must + * not be re-entered inside an operation, and the callers already hold it, so wrapping + * it would deadlock them. A writer that never takes the lock is outside the supported + * threat model: a plain rename has no conditional form, so no advisory mechanism can + * bind a checked version to it. + */ +export async function replaceIfVersion( + absolutePath: string, + expectedVersion: string, + content: string | Uint8Array, + // Re-checked under the lock: a link that waited on the FIFO chain can outlive + // the task that queued it. + displayPath: string, + isCancelled?: () => boolean, +): Promise { + // Lock the key every other writer to this file uses: the resolved publish + // target, so a symlink alias and its referent share one lock. + return withFileLock(await resolveLockKey(absolutePath), async () => { + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + 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.", + displayPath, + ) + } + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + + // Re-checked after the awaited preflight: the task can be aborted while this + // operation waits, and a publish that starts after that is a write the caller + // has already reported as not performed. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + + if (currentVersion === expectedVersion) { + // Immediately before publication starts. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + await safeWriteText(absolutePath, content) + // Read the new token under the same lock, otherwise a peer lock-using + // writer can publish in the gap and the caller records that writer's + // token as its own observation. + return tokenAfterPublish(absolutePath) + } + + throw new GuardRejectedError( + "Stale version -- the file changed since you read it (expected " + + expectedVersion + + ", current " + + currentVersion + + "); re-read the file, then retry.", + displayPath, + ) + }) +} + +/** + * 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, displayPath: string): Promise { + throw new GuardRejectedError("File not read yet -- read the file, then retry.", displayPath) +} + +// -- 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. A full-file replacement ("update", or a "create" whose target still + * exists) additionally requires a complete observation: a partial read + * (slice, range, truncated, indentation block) authorizes targeted edits + * only, never a full-file overwrite of the existing content. + * 4. Runs the chosen guard on the per-path FIFO chain so concurrent writes to + * the same path are deterministically ordered. + * 5. After a successful publish, refreshes the observation with the new + * on-disk token (complete) so consecutive writes by the same task do not + * fail stale against the version they just published. + */ +export async function guardedWrite( + task: Task, + relPathOrAbsolute: string, + content: string | Uint8Array, + kind: GuardedWriteKind = "update", + // Optional completeness for the refresh. A tool that built its content from a + // view of another file must carry that view's completeness through the publish + // instead of claiming completeness for lines it never read. + completeOverride?: boolean, +): Promise { + const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) + // Model-facing path: the caller's own spelling, not the resolved absolute + // path. The guard key stays absolute, but a rejection must not put a + // user-specific absolute path into the model's context. + + return enqueue(absolutePath, async () => { + // Cancellation is checked when the link is dequeued, not when it was enqueued: + // a write queued before an abort can still reach its turn on the chain after the + // task is gone, and the caller has already reported the write to the model. + const displayPath = relPathOrAbsolute + + if (task.abort) { + throw new GuardRejectedError( + "Task was cancelled before this write ran -- the queued publish is not performed.", + displayPath, + ) + } + const obs = task.observationRegistry.get(absolutePath) + // A targeted edit authorizes only the view the model saw, so a partial + // observation stays partial; a full-file publish is complete. + let staysPartial = false + // Token the guard read under the lock, so the refresh records the token + // this write published rather than a peer writer's. + let publishedToken: string | undefined + + if (kind === "edit") { + // Edit-style writes require a prior read: no observation, no write. + // A targeted edit only authorizes the view the model saw, so a + // partial observation is valid for the edit itself; the version + // check still rejects a file that moved since the read. + if (obs === undefined) { + await unobservedEditGuard(absolutePath, displayPath) + } else { + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + ) + staysPartial = obs.complete === false + } + } else { + // "create" or "update" publish a full file built on the model's + // content. A replacement of an existing target -- an "update", or a + // "create" whose target is still on disk -- therefore requires a + // complete observation: a slice, range, truncated, or + // indentation-block read only authorizes the view the model saw, and + // publishing over the existing file would silently drop everything + // the model never read, so the guard fails closed with a + // re-read-the-whole-file remediation. A fresh create (absent target) + // needs no prior read and stays allowed. + const absent = kind === "create" && (await fileIsAbsent(absolutePath)) + if (!absent && obs !== undefined && obs.complete === false) { + throw new GuardRejectedError( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + displayPath, + ) + } + + if (obs === undefined) { + // Never read: only an absent target may be created. + publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort) + } else if (absent) { + // A "create" on a file that vanished after the read recreates it. + publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort) + } else { + // The version recorded at read time must still match the on-disk + // token. + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + ) + } + } + + // A publish changes the on-disk token (the rename changes ino, size, and + // mtime). The model just wrote the full content, so refresh the + // observation with the new token: a consecutive write by the same task + // must not fail stale against the version it just published. + // The guard already read the token under the lock; a failed stat after a + // successful publish only skips the refresh, it does not undo the publish. + if (publishedToken !== undefined) { + // Refresh with the new token, keeping the completeness the guard + // established: a partial observation that authorized a targeted edit + // must stay partial, otherwise a later full-file replacement would + // publish content built from the slice alone. + task.observationRegistry.observe(absolutePath, publishedToken, completeOverride ?? !staysPartial) + } + }) +} + +/** + * Reset the per-path tail-promise chains (test hook). + */ +export function resetChain(): void { + pendingChains.clear() +} From 2f42d5c76b25eec8ad93db38bdfb37663ba25ccb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:12:08 +0800 Subject: [PATCH 011/115] rebuild unit U8 on U5 (corrected merge order) U6's ApplyPatchTool calls saveChanges with the writeKind argument, so the parameter must exist before U6 can build. U8 owns that signature, so U8 now lands before U6. --- src/integrations/editor/DiffViewProvider.ts | 486 +++- .../editor/__tests__/DiffViewProvider.spec.ts | 2058 ++++++++++++++++- 2 files changed, 2361 insertions(+), 183 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..caa3ee1d26 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -18,6 +18,10 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" +import { versionTokenOfStat } from "../../utils/versionToken" +import { withFileLock } from "../../utils/fileLock" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" +import { guardedWrite, GuardRejectedError, type GuardedWriteKind } from "../../core/tools/guardedWrite" import { DecorationController } from "./DecorationController" @@ -39,6 +43,7 @@ export class DiffViewProvider { // it when re-showing the edited file afterward. private documentWasPinned = false private relPath?: string + private teardownInFlight: Promise | undefined private newContent?: string private activeDiffEditor?: vscode.TextEditor private fadedOverlayController?: DecorationController @@ -82,6 +87,14 @@ export class DiffViewProvider { viewColumn: vscode.ViewColumn }> = [] private taskRef: WeakRef + /** + * Version token of the empty placeholder open() wrote for a new file (the + * create branch), captured under the S2 stat-matched contract. A rejected + * guarded save removes the placeholder only while its on-disk token still + * equals this value, so a file created by someone else in the meantime is + * never unlinked. + */ + private placeholderVersion: string | undefined = undefined constructor( private cwd: string, @@ -120,7 +133,31 @@ export class DiffViewProvider { this.preDiagnostics = vscode.languages.getDiagnostics() if (fileExists) { + // S4b follow-up (#44 / epic #1375): the preview is a full read of the + // on-disk original. Observe it with the S2 stat-matched contract so the + // accepted save (a full-file replacement) publishes through the guard's + // version check instead of bypassing it; a stat mismatch (or failure) + // leaves the target unobserved and the save fails closed. + const preStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) this.originalContent = await fs.readFile(absolutePath, "utf-8") + const postStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const displayTask = this.taskRef.deref() + // Only record the preview token when the task has no observation for + // this path: a read_file observation recorded the version the model's + // content was built on, and the accept-time guard must compare against + // THAT token. Replacing it with the current on-disk token would blind + // the save to changes that happened between the model's read and this + // preview (e.g. an external editor), letting a v1-based overwrite + // clobber the v2 change. An unread target has no observation, so the + // preview token is recorded (stat-matched) but never as a complete read: + // this is the tool's own preview, not a read the model made, so it must + // not authorize a later full-file replacement. + if (displayTask && preStats && postStats && !displayTask.observationRegistry.has(absolutePath)) { + const displayToken = versionTokenOfStat(preStats) + if (displayToken === versionTokenOfStat(postStats)) { + displayTask.observationRegistry.observe(absolutePath, displayToken, false) + } + } } else { this.originalContent = "" } @@ -132,6 +169,46 @@ export class DiffViewProvider { // Make sure the file exists before we open it. if (!fileExists) { await fs.writeFile(absolutePath, "") + // S4b follow-up (#44 / epic #1375): the empty placeholder is fully + // known (empty), but verify it with the same S2 stat-matched contract + // as the modify branch above: a stat-mismatched or non-empty read + // means another writer touched the placeholder in the window after + // open() wrote it, and that writer's token must not be observed as + // complete (observing it would claim we saw content we never read, and + // the token-guarded cleanup would unlink their file) - nothing is + // recorded and the save fails closed instead. When the placeholder is + // verified empty, its token replaces any prior observation for the + // path: a prior observation describes a file that no longer exists, and + // keeping it would make the accept-time CAS (the placeholder token on + // disk vs. the vanished file's token) fail every time, so recreating + // the file would always fail. The placeholder token is the correct + // baseline for the new file: an external change to the placeholder + // before the accept moves the on-disk token and fails the CAS. The + // cleanup token is captured whether or not the task is still live: + // a rejected save must not leave the placeholder behind even when the + // owning task has been collected. + const placeholderPreStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (placeholderPreStats) { + const placeholderContent = await fs.readFile(absolutePath, "utf-8").catch(() => undefined) + const placeholderPostStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const placeholderToken = versionTokenOfStat(placeholderPreStats) + // Stat-matched (S2): the read is trusted only while the bracketing + // stats agree and the content is exactly the empty placeholder. + if ( + placeholderPostStats && + placeholderToken === versionTokenOfStat(placeholderPostStats) && + placeholderContent === "" + ) { + // Remember the placeholder token so a rejected save can remove + // the placeholder only while it is still the exact file open() + // wrote. + this.placeholderVersion = placeholderToken + const displayTask = this.taskRef.deref() + if (displayTask) { + displayTask.observationRegistry.observe(absolutePath, placeholderToken, true) + } + } + } } // If the file was already open, close it (must happen after showing the @@ -323,9 +400,81 @@ export class DiffViewProvider { } } + /** + * Revert one document through the workbench command. The command takes no + * resource argument: when the Open Editors view has focus with a selection it + * force-reverts every selected editor, otherwise the active editor. Activate + * the target first so only this document is reverted, then give the user + * their focus back. + */ + private async revertDocument(document: vscode.TextDocument): Promise { + const previous = vscode.window.activeTextEditor + try { + await vscode.window.showTextDocument(document, { preserveFocus: false, preview: false }) + await vscode.commands.executeCommand("workbench.action.files.revert") + } catch { + // best-effort: a document that cannot be reverted stays dirty + } + if (previous && previous.document !== document) { + // Give the user their focus back. + try { + await vscode.window.showTextDocument(previous.document, { preserveFocus: false, preview: false }) + } catch { + // best-effort: the focus cannot always be restored + } + } + // The caller must know whether the discard actually completed: a document + // that stays dirty can still be saved by VS Code's ordinary file service, + // which would recreate a placeholder the cleanup removed. + return !document.isDirty + } + + /** + * Adopt content that is already on disk as the result of this save. + * + * VS Code's autosave can write the modified side of a diff before the user + * accepts it, which moves the version token without changing the bytes, so + * the compare-and-swap rejects a guard that is already satisfied. The read is + * paired with the bigint stat that produced the token and re-checked after the + * read, so a write landing between the two cannot make an unrelated content + * match look like this publish. The observation keeps the completeness of the + * original read: a caller-side check must not upgrade a partial observation + * into authority for a full-file replacement. + * + * Returns true only when the on-disk bytes are exactly the content this save + * intended to publish. + */ + private async adoptAlreadyPublishedContent( + task: Task, + absolutePath: string, + encodedContent: Uint8Array, + ): Promise { + const before = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (!before) { + return false + } + const disk = await fs.readFile(absolutePath).catch(() => undefined) + if (!disk) { + return false + } + const after = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (!after || versionTokenOfStat(before) !== versionTokenOfStat(after)) { + return false + } + if (Buffer.compare(Buffer.from(disk), Buffer.from(encodedContent)) !== 0) { + return false + } + const observation = task.observationRegistry.get(absolutePath) + task.observationRegistry.observe(absolutePath, versionTokenOfStat(after), observation?.complete ?? false) + return true + } async saveChanges( diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + // Stryker disable next-line StringLiteral: an empty kind dispatches exactly + // like "update" in guardedWrite (only "edit" and "create" branch distinctly), + // so the StringLiteral mutant here is equivalent. + writeKind: GuardedWriteKind = "update", ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -339,20 +488,156 @@ export class DiffViewProvider { const updatedDocument = this.activeDiffEditor.document const editedContent = updatedDocument.getText() - if (updatedDocument.isDirty) { - await updatedDocument.save() + // S4b follow-up (#44 / epic #1375): the accepted diff is a full-file + // replacement, so publish it through the guarded-write API instead of + // saving the document raw. open() observed the on-disk version the + // preview was built on; replaceIfVersion rejects the save when the file + // changed after the preview or the target was never observed, with the + // standard re-read-then-retry remediation. + const saveTask = this.taskRef.deref() + let encodedContent: Uint8Array | undefined + try { + if (!saveTask) { + // Fail closed: without the owning task the observation registry is + // unreachable and the save cannot be guarded. The rejection flows + // through the same discard-only cleanup as a guard verdict, so a + // dead task cannot leave the empty placeholder behind either. + throw new Error("Cannot guard the write: the owning task is no longer available") + } + // Publish with the document's own encoding. VS Code's codec covers the + // legacy code pages Node cannot represent (a hand-rolled encoder would + // have to reject them), so encode here and let the guarded write publish + // the bytes unchanged. The write kind comes from the caller: a targeted + // edit after a partial read is authorized by the edit guard, while a + // full-file replacement still needs a complete observation. + encodedContent = await vscode.workspace.encode(editedContent, { + encoding: updatedDocument.encoding, + }) + await guardedWrite(saveTask, this.relPath, encodedContent, writeKind) + } catch (error) { + // Autosave can publish the modified side of the diff before the user + // accepts, so the bytes on disk may already be exactly what this save + // intended. The compare-and-swap still rejects because the version token + // moved, and the cleanup below would report a failure for content that is + // already published. When a stat-matched read returns the same bytes the + // write already happened, so adopt that state instead of failing. + if ( + // Stryker disable next-line LogicalOperator: GuardRejectedError is only thrown + // by guardedWrite, and guardedWrite is reached only after the !saveTask check + // above has thrown a plain Error. So whenever this operand is evaluated saveTask + // is provably non-null: dropping it cannot change which branch is taken. + error instanceof GuardRejectedError && + saveTask && + encodedContent && + // Only the autosave shape: a clean buffer means its content is what autosave + // already put on disk. A dirty buffer means the disk content came from + // someone else, so the discard cleanup below is still the right outcome. + !updatedDocument.isDirty && + (await this.adoptAlreadyPublishedContent(saveTask, absolutePath, encodedContent)) + ) { + // The publish is already satisfied; fall through to the normal + // post-save flow rather than reporting a rejection. + } else { + // Discard-only failure cleanup. The publish was rejected (stale + // version, unobserved target, a partial-read observation, or an + // unavailable task), so the on-disk content is the newer source of + // truth. Reload it + // into the buffer (discarding the rejected edit), remove the empty + // new-file placeholder while it is still exactly the file open() + // wrote, and close the diff views. Never use revertChanges() here: + // it restores originalContent and saves it, which would overwrite + // the newer disk content that caused the rejection. Best-effort — + // the guard verdict is rethrown below. + try { + // Dispose before any programmatic activation: showTextDocument can + // change the active editor even with preserveFocus, so a listener + // still attached here would record this cleanup as a user touch and + // let the auto-close preferences keep the transient tab open. + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + + await this.runTeardown(async () => { + let discardSucceeded = !updatedDocument.isDirty + if (updatedDocument.isDirty) { + discardSucceeded = await this.revertDocument(updatedDocument) + } + if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { + // Cleanup has to be serialized with the same resolved-path advisory lock + // every other writer to this file uses. A token check and an unlink in + // separate steps let a peer writer commit in the gap and lose its write. + // A differing token, or a lock that cannot be taken, leaves the file in place. + await withFileLock(await resolveLockKey(absolutePath), async () => { + const placeholderStats = await fs + .stat(absolutePath, { bigint: true }) + .catch(() => undefined) + if ( + !placeholderStats || + versionTokenOfStat(placeholderStats) !== this.placeholderVersion + ) { + return + } + let unlinked = false + try { + await fs.unlink(absolutePath) + unlinked = true + } catch { + // the placeholder vanished or the unlink failed + } + if (!unlinked) { + return + } + // The file is gone, so its tab must go too: closing only the diff + // views would leave a clean plain-text tab for a deleted file. + await this.closeFileTab(absolutePath) + // The directories open() created for the new file must go with it, + // innermost first. rmdir refuses a directory another writer populated + // in the meantime, so the cleanup stops at the first failure. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + try { + await fs.rmdir(this.createdDirs[i]) + } catch { + break + } + } + }) + } + await this.closeOwnDiffView(absolutePath) + }) + } catch { + // cleanup is best-effort; the guard verdict below is the outcome + } + throw error + } } + // The publish wrote the buffer's exact content to disk, but the + // document still carries its pre-save dirty flag and the close helpers + // skip dirty tabs. Revert from disk (content is identical — no write, + // no token change) to clear the dirty state before the close logic. + // document.save() would re-publish through the unguarded VS Code file + // service and advance the on-disk token, so the revert is the + // content-safe way to clear it. // Stop tracking touches and cancel any pending scroll-to-diff before any - // programmatic editor activation below. + // programmatic editor activation: showTextDocument below can change the + // active editor even with preserveFocus, so a listener still attached would + // record this programmatic revert as a user touch and keep the transient + // tab open against the auto-close preference. this.disposeActiveEditorListener() this.cancelDeferredScroll() + // Revert only while the buffer is still exactly what the guard published. + // Keystrokes typed during the publish would be discarded by a revert, so a + // buffer that moved on stays dirty: the close helpers skip dirty tabs and + // the user's text survives in the editor. + if (updatedDocument.isDirty && updatedDocument.getText() === editedContent) { + await this.revertDocument(updatedDocument) + } + await this.closeAllDiffViews() // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset. - const saveTask = this.taskRef.deref() + // preserve the existing behavior when unset (saveTask was resolved above + // for the guarded publish). const saveState = await saveTask?.providerRef.deref()?.getState() await this.keepOrCloseEditedFile( @@ -527,55 +812,56 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - if (!fileExists) { - if (updatedDocument.isDirty) { - await updatedDocument.save() - } + await this.runTeardown(async () => { + if (!fileExists) { + if (updatedDocument.isDirty) { + await updatedDocument.save() + } - await this.closeAllDiffViews() - // The file was newly created for this edit; close its transiently - // opened tab before deleting it from disk. - await this.closeFileTab(absolutePath) - await fs.unlink(absolutePath) + await this.closeAllDiffViews() + // The file was newly created for this edit; close its transiently + // opened tab before deleting it from disk. + await this.closeFileTab(absolutePath) + await fs.unlink(absolutePath) - // Remove only the directories we created, in reverse order. - for (let i = this.createdDirs.length - 1; i >= 0; i--) { - await fs.rmdir(this.createdDirs[i]) - } - } else { - // Revert document. - const edit = new vscode.WorkspaceEdit() + // Remove only the directories we created, in reverse order. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + await fs.rmdir(this.createdDirs[i]) + } + } else { + // Revert document. + const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - updatedDocument.positionAt(0), - updatedDocument.positionAt(updatedDocument.getText().length), - ) + const fullRange = new vscode.Range( + updatedDocument.positionAt(0), + updatedDocument.positionAt(updatedDocument.getText().length), + ) - edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) + edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) - // Apply the edit and save, since contents shouldn't have changed - // this won't show in local history unless of course the user made - // changes and saved during the edit. - await vscode.workspace.applyEdit(edit) - await updatedDocument.save() + // Apply the edit and save, since contents shouldn't have changed + // this won't show in local history unless of course the user made + // changes and saved during the edit. + await vscode.workspace.applyEdit(edit) + await updatedDocument.save() - await this.closeAllDiffViews() + await this.closeAllDiffViews() - // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset. - const revertTask = this.taskRef.deref() - const revertState = await revertTask?.providerRef.deref()?.getState() - - await this.keepOrCloseEditedFile( - absolutePath, - false, - revertState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, - revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? - DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, - revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, - ) - } + // Read auto-close preferences from state; fall back to defaults that + // preserve the existing behavior when unset. + const revertTask = this.taskRef.deref() + const revertState = await revertTask?.providerRef.deref()?.getState() + await this.keepOrCloseEditedFile( + absolutePath, + false, + revertState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, + revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? + DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + ) + } + }) // Restore any preview tabs the diff evicted, reconstructing the user's // prior not-yet-edited tab state. await this.restorePreviewTabs() @@ -618,6 +904,69 @@ export class DiffViewProvider { await Promise.all(closeOps) } + /** + * Close only this provider's diff tab. closeAllDiffViews() closes every clean + * diff tab in the workbench, so a rejected save would also close another task's + * diff view while that task's provider still holds its activation listener and + * deferred scroll timer against a tab that is gone. A rejection belongs to one + * task, so the cleanup must stay inside that task's view. + */ + private async closeOwnDiffView(absolutePath: string): Promise { + const target = path.resolve(absolutePath) + const tabs = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter((tab) => { + if (tab.isDirty) { + return false + } + if (tab.input instanceof vscode.TabInputTextDiff) { + // Only Zoo's own diff tabs, not a Source Control diff the user has open + // for the same file. + return ( + tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && + path.resolve(tab.input.modified.fsPath) === target + ) + } + // A diff tab for a file that was already open is identified by its label + // rather than by the URI scheme. A basename alone cannot tell two tasks in + // different directories apart, so the label only counts when the tab's own + // URI points at this provider's target. + const uri = (tab.input as { uri?: { fsPath?: string } })?.uri + return ( + typeof uri?.fsPath === "string" && + path.resolve(uri.fsPath) === target && + typeof tab.label === "string" && + tab.label.startsWith(`${path.basename(target)}: ${DIFF_VIEW_LABEL_CHANGES}`) + ) + }) + for (const tab of tabs) { + try { + await vscode.window.tabGroups.close(tab) + } catch { + // best-effort: the tab stays open + } + } + } + /** + * Only one teardown path may act on a document at a time. A cancellation can reach + * revertChanges() while a rejected save is already discarding the same buffer, and + * both would edit the document and close the same tabs. The second caller awaits the + * cleanup already in flight instead of repeating it. + */ + private async runTeardown(cleanup: () => Promise): Promise { + if (this.teardownInFlight !== undefined) { + await this.teardownInFlight + return + } + const inFlight = cleanup() + this.teardownInFlight = inFlight + try { + await inFlight + } finally { + this.teardownInFlight = undefined + } + } + // Stop tracking user activation of the target file. Called before any // programmatic showTextDocument so our own re-show never counts as a "touch". private disposeActiveEditorListener(): void { @@ -1108,7 +1457,15 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - await this.closeAllDiffViews() + // A reset belongs to one task. Closing every clean diff tab would close another + // task's view while that task's provider still holds its activation listener and + // deferred scroll timer against a tab that is gone, so the cleanup stays inside + // this provider's view whenever it knows which file it was editing. + if (this.relPath) { + await this.closeOwnDiffView(path.resolve(this.cwd, this.relPath)) + } else { + await this.closeAllDiffViews() + } this.editType = undefined this.isEditing = false this.originalContent = undefined @@ -1127,6 +1484,7 @@ export class DiffViewProvider { this.userTouchedDocument = false this.userTouchedDiffEditor = false this.snapshotPreviewTabs = [] + this.placeholderVersion = undefined } /** @@ -1136,6 +1494,10 @@ export class DiffViewProvider { * @param relPath - Relative path to the file * @param content - Content to write to the file * @param openFile - Whether to show the file in editor (false = open in memory only for diagnostics) + * @param writeKind - Guarded-write kind that selects the S4a guard for this publish. + * Defaults to "create" because this method always publishes a complete file + * content: an unobserved target may only be created when absent, and an + * observed target must still carry the current observation token. * @returns Result of the save operation including any new problems detected */ async saveDirectly( @@ -1144,6 +1506,10 @@ export class DiffViewProvider { openFile: boolean = true, diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + writeKind: GuardedWriteKind = "create", + // Completeness the caller earned elsewhere; a move carries the source's + // view through the publish instead of claiming completeness for lines it never read. + completeOverride?: boolean, ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -1154,9 +1520,19 @@ export class DiffViewProvider { // Get diagnostics before editing the file this.preDiagnostics = vscode.languages.getDiagnostics() - // Write the content directly to the file + // Publish through the S4 guarded-write API (epic #1375): an unobserved + // write to an existing file and a stale observed version are rejected + // with a re-read-then-retry remediation instead of overwriting the file. + // Concurrent in-process writes to the same path are already ordered by + // the guard's per-path FIFO chain, so no additional locking is added here. + const task = this.taskRef.deref() + if (!task) { + // Fail closed: without the owning task the observation registry is + // unreachable and the write cannot be guarded. + throw new Error("Cannot guard the write: the owning task is no longer available") + } await createDirectoriesForFile(absolutePath) - await fs.writeFile(absolutePath, content, "utf-8") + await guardedWrite(task, relPath, content, writeKind, completeOverride) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory @@ -1167,13 +1543,11 @@ export class DiffViewProvider { preserveFocus: true, }) } else { - // Just open the document in memory to trigger diagnostics without showing it - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) - - // Save the document to ensure VSCode recognizes it as saved and triggers diagnostics - if (doc.isDirty) { - await doc.save() - } + // Just open the document in memory to trigger diagnostics without showing it. + // Do not save here: the guarded publish already committed the accepted content, + // and saving a dirty buffer would republish its stale bytes through VS Code's + // unguarded save path, over what the guard wrote. + await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) // Force a small delay to ensure diagnostics are triggered await new Promise((resolve) => setTimeout(resolve, 100)) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index a740ebed5c..36f5445df2 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -5,6 +5,15 @@ import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { makeRange, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import * as fs from "fs/promises" + +import { computeVersionToken, versionTokenOfStat } from "../../../utils/versionToken" +import type { BigIntStats } from "fs" +import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { withFileLock } from "../../../utils/fileLock" +import { ObservationRegistry } from "../../../core/task/observationRegistry" +import type { Task } from "../../../core/task/Task" + // Mock delay vi.mock("delay", () => ({ default: vi.fn().mockResolvedValue(undefined), @@ -15,23 +24,67 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + // The S4b follow-up (#44) preview observation stats the target before and + // after reading it; undefined stats leave the target unobserved (fail closed). + stat: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +// Mock safeWriteText (used by saveDirectly) +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn().mockResolvedValue(undefined), + resolveLockKey: vi.fn(async (p: string) => p), })) +// Mock the S1 version token (used by the S4 guarded write); the real +// computeVersionToken needs fs.stat, which is not part of the fs/promises mock above. +// Keep the real versionTokenOfStat: DiffViewProvider.open() (S4b follow-up #44) +// derives the preview token from its synthetic stat mock with that pure function. +vi.mock("../../../utils/versionToken", async () => { + const actual = await vi.importActual("../../../utils/versionToken") + return { + computeVersionToken: vi.fn(), + versionTokenOfStat: actual.versionTokenOfStat, + } +}) + // Mock utils +// Mock the shared advisory lock that the guarded-write path uses; the real +// proper-lockfile would try to create a lock directory on the mocked fs. +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(async (filePath: string, operation: (p: string) => Promise) => operation(filePath)), +})) + vi.mock("../../../utils/fs", () => ({ createDirectoriesForFile: vi.fn().mockResolvedValue([]), })) // Mock path vi.mock("path", () => ({ - resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), + resolve: vi.fn((cwd: string, relPath?: string) => (relPath === undefined ? cwd : `${cwd}/${relPath}`)), + isAbsolute: vi.fn((p: string) => p.startsWith("/")), basename: vi.fn((path) => path.split("/").pop()), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), + join: (...args: string[]) => args.join("/"), })) // Mock vscode vi.mock("vscode", () => ({ workspace: { applyEdit: vi.fn(), + // VS Code's own codec. The double returns bytes that differ from the plain + // UTF-8 encoding of the same text, so an assertion can prove the publish + // writes the codec's output rather than re-encoding the string itself. + encode: vi.fn((content: string, options: { encoding: string }) => + Promise.resolve( + options.encoding === "utf8bom" + ? Buffer.concat([Buffer.from("\uFEFF"), Buffer.from(content)]) + : Buffer.from(content), + ), + ), onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), openTextDocument: vi.fn().mockResolvedValue({ isDirty: false, @@ -44,6 +97,7 @@ vi.mock("vscode", () => ({ }, window: { createTextEditorDecorationType: vi.fn(), + activeTextEditor: undefined as unknown, showTextDocument: vi.fn(), onDidChangeVisibleTextEditors: vi.fn(() => ({ dispose: vi.fn() })), onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), @@ -149,8 +203,11 @@ describe("DiffViewProvider", () => { return mockWorkspaceEdit as any }) - // Create a mock Task instance + // Create a mock Task instance. The guarded write (S4b) consults the task's + // S2 observation registry, so the mock carries a real (in-memory) instance. mockTask = { + cwd: mockCwd, + observationRegistry: new ObservationRegistry(), providerRef: { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ @@ -188,6 +245,15 @@ describe("DiffViewProvider", () => { addLines: vi.fn(), clear: vi.fn(), } + + // S4b follow-up (#44): saveChanges publishes the accepted diff through the + // guarded write, which requires the target to be observed at the previewed + // on-disk version. Seed the preconditions for the relPaths the suites below + // use; the guarded-write suites override or clear as needed. + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "v1") + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1") + mockTask.observationRegistry.observe(`${mockCwd}/mock-target-file.ts`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") }) describe("update method", () => { @@ -778,11 +844,188 @@ describe("DiffViewProvider", () => { }) }) + // A rejected save belongs to one task. closeAllDiffViews() closes every clean + // diff tab in the workbench, so it would close another task's diff view while + // that task's provider still holds its activation listener and deferred scroll + // timer against a tab that is gone. + describe("closeOwnDiffView method", () => { + it("closes only this provider's tab and leaves another task's diff view open", async () => { + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + const otherTaskTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + isDirty: false, + } + for (const tab of [ownTab, otherTaskTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab]) + }) + + it("leaves another task's tab when the same basename sits in another directory", async () => { + // Two tasks can edit files with the same name. Matching by basename alone + // would close the other task's clean tab, so the tab's own URI has to decide. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const sameNameOtherDir = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: "/other-cwd/test.ts" }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + // A pre-opened file's tab is identified by its label, so the URI check is the + // only thing that can tell the two apart here. + const labelOnlyOtherDir = { + input: { uri: { fsPath: "/other-cwd/test.ts" } }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const labelOnlyOwn = { + input: { uri: { fsPath: `${mockCwd}/test.ts` } }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + for (const tab of [ownTab, sameNameOtherDir]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, sameNameOtherDir, labelOnlyOtherDir, labelOnlyOwn] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab, labelOnlyOwn]) + }) + + it("leaves a Source Control diff the user has open for the same file", async () => { + // A git diff of the same file is the user's tab, not this task's, so a reset + // must not close it. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + const gitDiffTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: "git" }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + for (const tab of [ownTab, gitDiffTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, gitDiffTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab]) + }) + }) + + it("reset() closes only this provider's tab, not another task's", async () => { + // A guard rejection belongs to one task, and every tool caller resets that + // task's provider in its catch block, so the teardown must stay inside this + // provider's view. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const otherTaskTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + label: `other-task.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + for (const tab of [ownTab, otherTaskTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + diffViewProvider["relPath"] = "test.ts" + await diffViewProvider.reset() + + expect(closedTabs).toEqual([ownTab]) + }) + describe("saveDirectly method", () => { beforeEach(() => { // Mock vscode functions vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + + // Baseline for the single-writer flow these tests encode: the file was read + // before the write, so the observation registry holds the version token the + // guarded write recomputes and compares, and the target exists on disk. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") + vi.mocked(fs.access).mockResolvedValue(undefined) }) it("should write content directly to file without opening diff view", async () => { @@ -791,9 +1034,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,14 +1057,32 @@ 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() }) + it("does not save a dirty buffer in the memory-only diagnostics path", async () => { + // The guarded publish already committed the accepted content. Saving a dirty + // buffer here would republish its stale bytes through VS Code's unguarded save + // path, over what the guard wrote. + const dirtyDoc = { + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.TextDocument + vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue(dirtyDoc) + + await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 0) + + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith( + expect.objectContaining({ fsPath: `${mockCwd}/test.ts` }), + ) + expect(dirtyDoc.save).not.toHaveBeenCalled() + }) + it("should skip diagnostics when diagnosticsEnabled is false", async () => { const mockDelay = vi.mocked(delay) mockDelay.mockClear() @@ -829,9 +1090,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() @@ -858,169 +1119,1675 @@ describe("DiffViewProvider", () => { expect((diffViewProvider as any).relPath).toBe("test.ts") expect((diffViewProvider as any).newContent).toBe("new content") }) - }) - - describe("saveChanges method with diagnostic settings", () => { - beforeEach(() => { - // Setup common mocks for saveChanges tests - ;(diffViewProvider as any).relPath = "test.ts" - ;(diffViewProvider as any).newContent = "new content" - ;(diffViewProvider as any).activeDiffEditor = { - document: { - getText: vi.fn().mockReturnValue("new content"), - isDirty: false, - save: vi.fn().mockResolvedValue(undefined), - }, - } - ;(diffViewProvider as any).preDiagnostics = [] - - // Mock vscode functions - vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) - vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) - }) - it("should apply diagnostic delay when diagnosticsEnabled is true", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() - - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + describe("guarded write (S4b, epic #1375)", () => { + const enoent = () => Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) - const result = await diffViewProvider.saveChanges(true, 3000) + it("rejects an unobserved write to an existing file with the read-first remediation", async () => { + mockTask.observationRegistry.clear() - // Verify delay was called with correct duration - expect(mockDelay).toHaveBeenCalledWith(3000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( + "File already exists at test.ts and was not read before this write -- read the file first, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) - it("should skip diagnostics when diagnosticsEnabled is false", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + it("creates an unobserved file when the target is absent", async () => { + mockTask.observationRegistry.clear() + vi.mocked(fs.access).mockRejectedValue(enoent()) - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - const result = await diffViewProvider.saveChanges(false, 2000) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + }) - // Verify delay was NOT called and diagnostics were NOT checked - expect(mockDelay).not.toHaveBeenCalled() - expect(vscode.languages.getDiagnostics).not.toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + it("rejects an observed write whose version token is stale", async () => { + // The file changed on disk after the read that recorded "v1". + vi.mocked(computeVersionToken).mockResolvedValue("v2") - it("should use default values when no parameters provided", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + const result = diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + await expect(result).rejects.toThrow("Stale version") + await expect(result).rejects.toThrow("re-read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + }) - const result = await diffViewProvider.saveChanges() + it("rejects an unobserved edit-kind write before any I/O", async () => { + mockTask.observationRegistry.clear() - // Verify default behavior (enabled=true, delay falls back to DEFAULT_WRITE_DELAY_MS) - expect(mockDelay).toHaveBeenCalledWith(0) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + await expect( + diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0, "edit"), + ).rejects.toThrow("File not read yet -- read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + expect(fs.access).not.toHaveBeenCalled() + }) - it("pins the default write delay to zero", () => { - // The tests above compare against the constant, so they would still pass if the - // default were changed back to 1000. Pin the value the write path actually uses. - expect(DEFAULT_WRITE_DELAY_MS).toBe(0) - }) + it("recreates an observed file that vanished after the read", async () => { + vi.mocked(fs.access).mockRejectedValue(enoent()) - it("should handle custom delay values", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + }) - await diffViewProvider.saveChanges(true, 5000) + it("fails closed when the owning task has been collected", async () => { + // A real WeakRef cannot be forced to deref to undefined deterministically + // (GC timing), so a structural stub stands in for the collected reference. + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef - // Verify custom delay was used - expect(mockDelay).toHaveBeenCalledWith(5000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) }) }) - describe("preEditScrollLine capture and restore", () => { - it("should capture scroll line from visible editor at open() time", async () => { - const mockEditor = { + describe("saveChanges guarded publish (S4b follow-up #44)", () => { + // Synthetic stat the preview observation tokenizes with the real (unmocked) + // versionTokenOfStat. Cast: the mock only implements the members the tool + // and versionToken read. + const previewStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + + // Structural TextEditor double for the open()/saveChanges flow: only the + // members they touch. One documented unknown cast stands in for the full + // vscode.TextEditor type (avoids any casts; see AGENTS.md). + const mockTextEditor = (fsPath: string, text = ""): vscode.TextEditor => + ({ document: { - uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" }, - getText: vi.fn().mockReturnValue(""), + uri: { fsPath, scheme: "file" }, + getText: vi.fn().mockReturnValue(text), lineCount: 0, + encoding: "utf8", }, selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, edit: vi.fn().mockResolvedValue(true), revealRange: vi.fn(), - visibleRanges: [{ start: { line: 42 } }], - } + }) as unknown as vscode.TextEditor + + // Structural TextDocument double for the onDidOpenTextDocument callback. + const mockTextDocument = (fsPath: string): vscode.TextDocument => + ({ uri: { fsPath, scheme: "file" } }) as unknown as vscode.TextDocument + // The workbench revert clears the model's dirty flag; the double must model + // that so the cleanup can tell a completed discard from a failed one. + const revertClearsDirty = (document: { isDirty: boolean }, onRevert?: () => void): void => { + vi.mocked(vscode.commands.executeCommand).mockImplementation((command: string) => { + if (command === "workbench.action.files.revert") { + document.isDirty = false + onRevert?.() + } + return Promise.resolve(undefined) + }) + } - vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + beforeEach(() => { + // Private members are set via bracket notation (spec convention). + // Reset the focus the revert helper reads: a test that sets it must not leak + // into the next one, where cleanup could restore a stale editor. + vi.mocked(vscode.window).activeTextEditor = undefined + diffViewProvider["relPath"] = "test.ts" + diffViewProvider["newContent"] = "new content" + diffViewProvider["activeDiffEditor"] = mockTextEditor(`${mockCwd}/test.ts`, "new content") + diffViewProvider["preDiagnostics"] = [] + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockTextEditor(`${mockCwd}/test.ts`)) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + }) + + it("open() observes the previewed version of an existing file as a partial read, not a model read", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/observed.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { - setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" } } as any), 0) + setTimeout(() => callback(mockTextDocument(`${mockCwd}/observed.ts`)), 0) return { dispose: vi.fn() } }) - vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) - ;(diffViewProvider as any).editType = "modify" + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("observed.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/observed.ts`) + expect(obs).toBeDefined() + expect(obs!.version).toBe(versionTokenOfStat(previewStats)) + // The preview is the tool's own read, not a read the model made, so it must + // not claim completeness for content the model never saw. + expect(obs!.complete).toBe(false) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/observed.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/observed.ts`, { bigint: true }) + }) + + it("open() records no observation when the post-read stat rejects", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/observed.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/observed.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat) + .mockResolvedValueOnce(previewStats) + .mockRejectedValueOnce(Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("observed.ts") + + expect(mockTask.observationRegistry.has(`${mockCwd}/observed.ts`)).toBe(false) + // The accepted save is a full-file replacement, so an unobserved target still + // fails closed instead of publishing content built on a preview that could not + // be tied to a version token. + await expect( + diffViewProvider.saveDirectly("observed.ts", "new content", false, false, 0, "update"), + ).rejects.toThrow( + "File already exists at observed.ts and was not read before this write -- read the file first, then retry.", + ) + }) + it("open() observes the empty placeholder of a new file so the accepted save can be guarded", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/brand-new.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/brand-new.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("brand-new.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/brand-new.ts`) + expect(obs).toBeDefined() + expect(obs!.version).toBe(versionTokenOfStat(previewStats)) + expect(obs!.complete).toBe(true) + // The create path stat-matches the placeholder (pre + post read), so + // both calls carry the bigint requirement, and the verification read + // uses utf-8. + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/brand-new.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/brand-new.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + expect(vi.mocked(fs.readFile)).toHaveBeenCalledWith(`${mockCwd}/brand-new.ts`, "utf-8") + }) + + it("open() leaves the target unobserved when the pre/post stat mismatch (mid-preview mutation)", async () => { + const mutatedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(301), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/mutated.ts`)), 0) + return { dispose: vi.fn() } + }) + const mockEditor = mockTextEditor(`${mockCwd}/mutated.ts`) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(mutatedStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() - await diffViewProvider.open("scroll.ts") + await diffViewProvider.open("mutated.ts") - expect((diffViewProvider as any).preEditScrollLine).toBe(42) + expect(mockTask.observationRegistry.get(`${mockCwd}/mutated.ts`)).toBeUndefined() + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/mutated.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/mutated.ts`, { bigint: true }) }) - it("should set preEditScrollLine to undefined when the visible editor has no visibleRanges", async () => { - const mockEditorNoRanges = { - document: { - uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" }, - getText: vi.fn().mockReturnValue(""), - lineCount: 0, - }, - selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, - edit: vi.fn().mockResolvedValue(true), - revealRange: vi.fn(), - // No visibleRanges, so the capture in open() yields undefined. - } + it("open() leaves the target unobserved when the pre-read stat fails (stat gap)", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/gap.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/gap.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + // The pre-read stat fails and only the post-read stat resolves: the + // on-disk version the preview is built on is unproven, so open() must + // leave the target unobserved even though a post stat is available. + vi.mocked(fs.stat) + .mockRejectedValueOnce(new Error("EPERM: operation not permitted")) + .mockResolvedValueOnce(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() - vi.mocked(vscode.window).visibleTextEditors = [mockEditorNoRanges as any] - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditorNoRanges as any) + await diffViewProvider.open("gap.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/gap.ts`)).toBeUndefined() + }) + + it("open() leaves a new file unobserved when the placeholder stat fails", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/gap-create.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { - setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" } } as any), 0) + setTimeout(() => callback(mockTextDocument(`${mockCwd}/gap-create.ts`)), 0) return { dispose: vi.fn() } }) - vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) - ;(diffViewProvider as any).editType = "modify" + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockRejectedValue(new Error("EPERM: operation not permitted")) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() - await diffViewProvider.open("new.ts") + await diffViewProvider.open("gap-create.ts") - expect((diffViewProvider as any).preEditScrollLine).toBeUndefined() + expect(mockTask.observationRegistry.get(`${mockCwd}/gap-create.ts`)).toBeUndefined() }) - it("saveChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { - const mockRevealRange = vi.fn() - const mockSavedEditor = { revealRange: mockRevealRange } + it("publishes the accepted content through the guarded write (safeWriteText)", async () => { + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor as any) - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) - ;(diffViewProvider as any).documentWasOpen = true - ;(diffViewProvider as any).preEditScrollLine = 30 - // saveChanges early-exits without relPath, newContent, activeDiffEditor - ;(diffViewProvider as any).newContent = "content" - ;(diffViewProvider as any).activeDiffEditor = { + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(result.newProblemsMessage).toBe("") + }) + + it("publishes the accepted content in the document's own encoding", async () => { + // A utf8bom document: getText() returns the text without the BOM, so the + // publish must go through VS Code's codec for the document's own + // encoding rather than re-encoding the text as plain UTF-8. + const bomEditor = { document: { - getText: vi.fn().mockReturnValue("content"), - isDirty: false, - save: vi.fn().mockResolvedValue(undefined), + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8bom", }, - } + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = bomEditor await diffViewProvider.saveChanges(false) - expect(mockRevealRange).toHaveBeenCalledWith( + expect(vi.mocked(vscode.workspace.encode)).toHaveBeenCalledWith("new content", { + encoding: "utf8bom", + }) + expect(safeWriteText).toHaveBeenCalledWith( + `${mockCwd}/test.ts`, + Buffer.concat([Buffer.from("\uFEFF"), Buffer.from("new content")]), + ) + }) + + it("rejects the accepted save when the file changed after the preview (stale version)", async () => { + vi.mocked(computeVersionToken).mockResolvedValue("v2") + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("rejects the accepted save when the target was never observed (fail closed)", async () => { + mockTask.observationRegistry.clear() + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "File already exists at test.ts and was not read before this write -- read the file first, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("rejects the accepted save when the observation was only a partial read", async () => { + // A partial observation (slice/range/truncated/indentation read) must not + // authorize the full-file replacement the accept path performs, even when + // the version is current. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1", false) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + it("publishes a targeted edit that a partial observation authorizes", async () => { + // The same partial observation rejects a full-file replacement but + // authorizes the targeted edit the tool performed, so the write kind + // the tool passed must reach the guard. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1", false) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await diffViewProvider.saveChanges(false, 0, "edit") + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + }) + + it("fails closed when the owning task has been collected", async () => { + // A real WeakRef cannot be forced to deref to undefined deterministically + // (GC timing), so a structural stub stands in for the collected reference. + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + + // Nothing may be published for a collected task, and the discard-only + // cleanup still runs before the error is rethrown. + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalledTimes(1) + }) + + it("open() keeps the model's existing observation instead of replacing it with the preview token", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/t3-modify.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-modify.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + // The model read the file before the preview: its observation must + // survive so the accept-time guard compares against the version the + // model's content was built on, not the on-disk version at preview + // time. + mockTask.observationRegistry.observe(`${mockCwd}/t3-modify.ts`, "model-token", true) + + await diffViewProvider.open("t3-modify.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/t3-modify.ts`) + expect(obs?.version).toBe("model-token") + expect(obs?.version).not.toBe(versionTokenOfStat(previewStats)) + // the stat-matched pair is still taken; only the observation is kept + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + }) + + it("open() records the placeholder token for a create even when the model read the file before it vanished", async () => { + // The vanished file's old observation must NOT win here: it describes + // a file that no longer exists, and keeping it would make the + // accept-time CAS (placeholder token on disk vs. the vanished file's + // token) fail every time, so recreating the file would always fail + // and the placeholder would leak. The placeholder token is the + // correct baseline for the new file. + const mockEditor = mockTextEditor(`${mockCwd}/t3-create.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-create.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + mockTask.observationRegistry.observe(`${mockCwd}/t3-create.ts`, "model-token", true) + + await diffViewProvider.open("t3-create.ts") + + const placeholderToken = versionTokenOfStat(previewStats) + // the placeholder is written, stat-matched, observed (replacing the + // vanished file's stale token), and remembered for cleanup + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "") + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + expect(mockTask.observationRegistry.get(`${mockCwd}/t3-create.ts`)?.version).toBe(placeholderToken) + expect(diffViewProvider["placeholderVersion"]).toBe(placeholderToken) + }) + + it("open() on a create with a collected task writes the placeholder but tracks nothing", async () => { + // The task has been collected (dead WeakRef): the placeholder is still + // written (the file must exist to open the diff), but there is no live + // task to observe - the later save fails closed through the taskRef + // fail-closed path. + const mockEditor = mockTextEditor(`${mockCwd}/t3-dead-task.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-dead-task.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await diffViewProvider.open("t3-dead-task.ts") + + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-dead-task.ts`, "") + // no live task: nothing observed, but the cleanup token is still + // captured (provider state) so the fail-closed save rejection can + // remove the placeholder instead of leaking it + expect(mockTask.observationRegistry.get(`${mockCwd}/t3-dead-task.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBe(versionTokenOfStat(previewStats)) + }) + + it("open() does not record a placeholder another writer touched after the write", async () => { + // CR d037753 finding: a writer that touched the placeholder between + // open()'s fs.writeFile() and the observation would have its token + // recorded as a complete observation of content open() never read. + // The stat-matched verification must reject it: no observation (the + // prior observation stays untouched), no cleanup token (so a rejected + // save cannot unlink the writer's file), and the save fails closed. + const mockEditor = mockTextEditor(`${mockCwd}/contested.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/contested.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("external content") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + mockTask.observationRegistry.observe(`${mockCwd}/contested.ts`, "model-token", true) + + await diffViewProvider.open("contested.ts") + + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/contested.ts`, "") + // nothing recorded, and the vanished file's prior observation was + // not replaced with the writer's token + expect(mockTask.observationRegistry.get(`${mockCwd}/contested.ts`)?.version).toBe("model-token") + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("open() does not record the placeholder when the post-stat fails after the write", async () => { + // The bracketing stats must both succeed: a failed post-stat means + // the read is not trustworthy, so nothing is recorded. + const mockEditor = mockTextEditor(`${mockCwd}/statfail.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/statfail.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat) + .mockResolvedValueOnce(previewStats) + .mockRejectedValueOnce(Object.assign(new Error("ENOENT: no such file"), { code: "ENOENT" })) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("statfail.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/statfail.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("open() does not record the placeholder when the bracketing stats disagree (mid-preview mutation)", async () => { + // A token change between the bracketing stats means the placeholder + // was replaced or rewritten while open() was reading it - same S2 + // rule as the modify branch: nothing is recorded. + const mutatedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(301), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const mockEditor = mockTextEditor(`${mockCwd}/mutated-new.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/mutated-new.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(mutatedStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("mutated-new.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/mutated-new.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("saveChanges() accepts a recreate after a prior read - the accept-time CAS checks the placeholder token", async () => { + // The exact recreate-always-failed trace: the model read the file + // (observed "v1" by the outer beforeEach), the file then vanished, + // open() wrote the placeholder, and the accept must succeed against + // the placeholder token (not the vanished file's stale token). + const mockEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/test.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + // prior read observation (the file has since vanished) + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe("v1") + + await diffViewProvider.open("test.ts") + + // the placeholder is untouched on disk: the accept-time token matches + // the placeholder token open() recorded + vi.mocked(computeVersionToken).mockResolvedValue(versionTokenOfStat(previewStats)) + + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(result.newProblemsMessage).toBe("") + }) + + it("saveChanges() checks the placeholder token and unlinks inside the same lock", async () => { + // A peer writer that commits between the token check and the unlink would lose + // its write, so the cleanup runs under the same resolved-path advisory lock every + // other writer to this file uses. A token that moved inside the lock window means + // the file is no longer the placeholder and must be left in place. + const mockEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/test.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + + await diffViewProvider.open("test.ts") + + const lockKeys: string[] = [] + vi.mocked(withFileLock).mockImplementation(async (lockKey, operation) => { + lockKeys.push(lockKey) + if (lockKeys.length === 2) { + // the peer committed while the cleanup waited for the lock + vi.mocked(fs.stat).mockResolvedValue({ ...previewStats, size: 9999n, mtimeNs: 5n, ctimeNs: 6n }) + } + return operation(lockKey) + }) + + // The publish is rejected against the peer's token, so the discard cleanup runs. + vi.mocked(computeVersionToken).mockResolvedValue("peer-token") + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + /Stale version|not read before this write/, + ) + + // One acquisition for the guarded publish, one for the cleanup. + expect(lockKeys).toEqual([`${mockCwd}/test.ts`, `${mockCwd}/test.ts`]) + expect(vi.mocked(fs.unlink)).not.toHaveBeenCalled() + }) + + it("clears the dirty buffer via a disk revert after a successful guarded publish", async () => { + // The user edited the buffer before accepting, so the document is + // dirty: the publish wrote the exact buffer content, and the dirty + // flag must be cleared by reverting from disk rather than saving the + // buffer (which would republish through the unguarded file service). + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + // the revert activates the exact document with the exact options: + // preserveFocus keeps the user's focus, preview: false pins the tab + // the target is activated so the revert command is scoped to this document + // (no active editor to restore in this case) + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { + preserveFocus: false, + preview: false, + }) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(dirtyEditor.document.save).not.toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("discards the dirty buffer and removes the new-file placeholder after a guarded rejection, then rethrows", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + // open() wrote and observed the empty placeholder; the on-disk token + // then moved past it, so the guard rejects the publish. + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(safeWriteText).not.toHaveBeenCalled() + // discard-only cleanup: the newer disk content is reloaded into the + // buffer (never re-saved from originalContent), and the placeholder + // is unlinked while it is still exactly the file open() wrote + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { + preserveFocus: false, + preview: false, + }) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + // the placeholder stat uses the bigint stat options (the version + // token requires the full-precision fields) + // The read must be attributed to the bigint stat that produced the token: + expect(vi.mocked(fs.stat).mock.calls[0][1]).toEqual({ bigint: true }) + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("disposes the active-editor listener before the programmatic revert", async () => { + // showTextDocument can change the active editor even with + // preserveFocus, so the listener must be gone before the revert + // activates the document: otherwise this programmatic activation is + // recorded as a user touch and the auto-close preference is overridden. + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider["disposeActiveEditorListener"] = vi.fn(() => { + order.push("dispose") + }) + revertClearsDirty(dirtyEditor.document, () => order.push("revert")) + + await diffViewProvider.saveChanges(false) + + expect(order).toEqual(["dispose", "revert"]) + }) + + it("disposes the listener before the discard revert and closes the deleted file's tab after a successful unlink", async () => { + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["disposeActiveEditorListener"] = vi.fn(() => { + order.push("dispose") + }) + diffViewProvider["cancelDeferredScroll"] = vi.fn(() => { + order.push("cancel") + }) + diffViewProvider["closeFileTab"] = vi.fn().mockImplementation(() => { + order.push("closeTab") + return Promise.resolve() + }) + revertClearsDirty(dirtyEditor.document, () => order.push("revert")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // The tab for the file that was just unlinked is closed, and only + // after the revert; closing only the diff views would leave a clean + // plain-text tab for a deleted file behind. + expect(order).toEqual(["dispose", "cancel", "revert", "closeTab"]) + }) + + it("does not close the file tab when the placeholder unlink fails", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error("EACCES: permission denied, unlink")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // Nothing was deleted, so there is no deleted-file tab to close. + expect(closeFileTab).not.toHaveBeenCalled() + }) + + it("removes the directories open() created, innermost first, after the placeholder unlink", async () => { + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["createdDirs"] = [`${mockCwd}/new`, `${mockCwd}/new/dir`] + diffViewProvider["closeFileTab"] = vi.fn().mockImplementation(() => { + order.push("closeTab") + return Promise.resolve() + }) + vi.mocked(fs.rmdir).mockImplementation(async (p) => { + order.push("rmdir:" + p) + }) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // The empty directories open() made for the rejected new file go with the + // placeholder, innermost first, and only after the unlink succeeded. + expect(order).toEqual(["closeTab", "rmdir:" + `${mockCwd}/new/dir`, "rmdir:" + `${mockCwd}/new`]) + }) + + it("stops removing created directories when one cannot be removed", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["createdDirs"] = [`${mockCwd}/a`, `${mockCwd}/a/b`, `${mockCwd}/a/b/c`] + vi.mocked(fs.rmdir).mockRejectedValueOnce(new Error("ENOTEMPTY: directory not empty")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // A directory another writer populated in the meantime must not be removed, + // and the best-effort cleanup must still finish so the guard verdict stays + // the outcome. + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/a/b/c`) + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("does not revert a buffer the user changed during the publish", async () => { + // The publish captured the buffer text before awaiting the write; a keystroke + // typed during that wait is newer than the published bytes, so the buffer must + // stay dirty instead of being reverted to disk. + const getText = vi.fn() + getText.mockReturnValueOnce("new content").mockReturnValueOnce("new content typed during the publish") + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText, + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(vi.mocked(vscode.commands.executeCommand)).not.toHaveBeenCalledWith("workbench.action.files.revert") + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + }) + + it("activates the document before the revert so the command is scoped to it, then restores the focus", async () => { + // workbench.action.files.revert takes no resource argument: with the Open + // Editors view focused it force-reverts every selected editor, otherwise the + // active editor. Activating the target keeps the revert scoped to this + // document, and the user's previous focus is given back afterwards. + const previousEditor = mockTextEditor(`${mockCwd}/other.ts`, "other") + vi.mocked(vscode.window).activeTextEditor = previousEditor + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + await diffViewProvider.saveChanges(false) + + const calls = vi.mocked(vscode.window.showTextDocument).mock.calls + expect(calls[0]).toEqual([dirtyEditor.document, { preserveFocus: false, preview: false }]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(calls[1]).toEqual([previousEditor.document, { preserveFocus: false, preview: false }]) + }) + + it("does not restore focus when the user had no active editor", async () => { + vi.mocked(vscode.window).activeTextEditor = undefined + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + await diffViewProvider.saveChanges(false) + + // Only the activation happened; there was no focus to give back. + expect(vi.mocked(vscode.window.showTextDocument).mock.calls).toEqual([ + [dirtyEditor.document, { preserveFocus: false, preview: false }], + ]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + }) + + it("does not restore focus when the active editor is already the target document", async () => { + // The user was already looking at this document, so there is nothing to give + // back: re-showing it would be a redundant activation. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + vi.mocked(vscode.window).activeTextEditor = editor + diffViewProvider["activeDiffEditor"] = editor + revertClearsDirty(editor.document) + + await diffViewProvider.saveChanges(false) + + // Only the activation happened: the focus was already on the target, so there + // was nothing to give back. + expect(vi.mocked(vscode.window.showTextDocument).mock.calls).toEqual([ + [editor.document, { preserveFocus: false, preview: false }], + ]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + }) + it("keeps the placeholder when the discard fails, so a later save cannot recreate the rejected content", async () => { + // The revert command can fail (a locked or orphaned model). While the buffer + // is still dirty, VS Code's ordinary file service can save it back to the path, + // so the placeholder open() wrote must survive and the tab must stay open. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + vi.mocked(vscode.commands.executeCommand).mockRejectedValue(new Error("revert failed")) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(fs.unlink).not.toHaveBeenCalled() + expect(closeFileTab).not.toHaveBeenCalled() + }) + + it("does not reload a clean buffer when the guard rejects - only dirty buffers are discarded", async () => { + // The buffer was never touched (isDirty is falsy): there is nothing + // to discard, so the failure cleanup must not activate the document + // or run the revert command. + const cleanEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + diffViewProvider["activeDiffEditor"] = cleanEditor + vi.mocked(computeVersionToken).mockResolvedValue("v2") + // The placeholder on disk is still exactly what open() wrote, so the cleanup + // can still remove it. + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(safeWriteText).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + expect(vi.mocked(vscode.commands.executeCommand)).not.toHaveBeenCalled() + // A clean buffer has nothing that can be saved back, so the placeholder + // cleanup still runs even though no revert was needed. + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("does not unlink the placeholder when the edit type is not create - the outer gate short-circuits", async () => { + // placeholderVersion is remembered (open() took the placeholder path) + // but the edit type is not create: the outer gate must short-circuit + // before statting or unlinking, so the placeholder on disk is left + // untouched. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "modify" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) // placeholder still on disk + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.unlink).not.toHaveBeenCalled() + // the dirty discard and the view close still ran + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("adopts content autosave already published instead of reporting a stale rejection", async () => { + // Autosave wrote the buffer before acceptance: the document is clean and the + // disk already holds exactly the bytes this save intended, but the version + // token moved, so the compare-and-swap still rejects. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + // A partial observation must stay partial: adopting content that is already on + // disk cannot upgrade it into authority for a full-file replacement. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), false) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + + // The observation now points at the state that already matches, keeping the + // completeness of the original read. + const observation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(observation?.version).toBe(versionTokenOfStat(previewStats)) + expect(observation?.complete).toBe(false) + // Nothing was clobbered, so the buffer is not reloaded and no placeholder is + // unlinked; the normal post-save close flow still ran. + expect(fs.unlink).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) + + it("still rejects when the disk content does not match what the save intended", async () => { + // Same autosave shape, different bytes: the guard verdict stands. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("someone else") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + // The observation is left at the state the read saw, not upgraded to the + // autosaved content the save did not author. + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + // The rejection still stands: the buffer is discarded and the views close, + // so the caller sees the guard verdict, not a silent success. + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + it("keeps the rejection when the buffer is still dirty even though the disk matches", async () => { + // A dirty buffer means the disk content came from someone else, so the + // discard cleanup is still the outcome even when the bytes happen to match. + // Without the clean-document gate this test would adopt the match and skip + // the discard. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + // The clean-document gate short-circuits before the adoption check, so the + // rejection path never stats or reads the file. + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("keeps the rejection when the file moved between the stat and the read", async () => { + // Same bytes, but the second stat differs: the read is not attributable to + // the state the token describes, so a match cannot be adopted. + const movedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(9), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(movedStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + }) + + it("keeps a complete observation complete when adopting an autosaved match", async () => { + // The completeness of the original read must survive the adoption unchanged: + // a complete read stays complete, so the fallback default must not silently + // downgrade it. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + const observation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(observation?.version).toBe(versionTokenOfStat(previewStats)) + expect(observation?.complete).toBe(true) + // The stat that pairs with the read must be the bigint form, otherwise the + // token is built from truncated fields. + expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) + }) + + it("keeps the rejection when the stat paired with the read is unavailable", async () => { + // There is no version to attribute the read to, so a match cannot be adopted. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockRejectedValueOnce(new Error("stat failed")) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + }) + + it("keeps the rejection when the paired read fails", async () => { + // The bytes cannot be compared, so a match cannot be assumed. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockRejectedValueOnce(new Error("read failed")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + }) + + it("records an incomplete observation when the path was never observed", async () => { + // Nothing was read in full, so the adopted state must be recorded as an + // incomplete observation rather than dereferencing a missing entry. A path the + // registry has never seen keeps the case independent of earlier tests. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/never-observed.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + diffViewProvider["relPath"] = "never-observed.ts" + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + const freshObservation = mockTask.observationRegistry.get(`${mockCwd}/never-observed.ts`) + expect(freshObservation?.version).toBe(versionTokenOfStat(previewStats)) + expect(freshObservation?.complete).toBe(false) + }) + it("keeps a non-guard write failure a failure even when the disk already matches", async () => { + // The guard passed and the write itself failed. The disk happens to hold the + // same bytes, but this save did not publish them, so the failure must not be + // reinterpreted as a success. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue(versionTokenOfStat(previewStats)) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + vi.mocked(safeWriteText).mockRejectedValueOnce(new Error("EACCES: permission denied")) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("EACCES: permission denied") + // No adoption read, so the guard verdict is the outcome. + expect(fs.readFile).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + it("does not adopt content when the failure is not a guard verdict", async () => { + // The bytes match and the buffer is clean, but the failure is the dead-task + // error, not a guard rejection: adoption must not turn an unrelated failure + // into a success. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + // The adoption check never runs, so the file is neither stat-ed nor read. + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("pairs the read with the bigint form on both sides of the comparison", async () => { + // A non-bigint stat truncates the fields the version token is built from, so + // both stats around the read must request the bigint form. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + expect(vi.mocked(fs.stat).mock.calls.map((call) => call[1])).toEqual([{ bigint: true }, { bigint: true }]) + }) + it("does not unlink when the placeholder stat is unavailable and still closes the diff views", async () => { + // The placeholder vanished between open() and the rejected save: the + // stat guard must short-circuit BEFORE the token comparison (no + // unlink) and the best-effort cleanup must not skip the view close. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + // the placeholder vanished: stat rejects and the guard's .catch + // normalizes it to undefined stats + vi.mocked(fs.stat).mockRejectedValue(new Error("ENOENT: no such file or directory")) + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.unlink).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("does not unlink a placeholder whose content changed after open() - the token gate refuses", async () => { + // The placeholder is still on disk, but its stats no longer match the + // token open() recorded: another writer touched the file, so the + // cleanup must refuse to unlink (it would destroy the other + // writer's content). + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + // the on-disk placeholder moved on since open(): same identity, new + // size -> a different token than the one open() recorded + const movedStats = { ...previewStats, size: BigInt(301) } as unknown as BigIntStats + vi.mocked(fs.stat).mockResolvedValue(movedStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) + // token mismatch -> the placeholder is NOT ours anymore: no unlink + expect(fs.unlink).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + }) + + describe("saveChanges method with diagnostic settings", () => { + beforeEach(() => { + // Setup common mocks for saveChanges tests + ;(diffViewProvider as any).relPath = "test.ts" + ;(diffViewProvider as any).newContent = "new content" + ;(diffViewProvider as any).activeDiffEditor = { + document: { + getText: vi.fn().mockReturnValue("new content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + } + ;(diffViewProvider as any).preDiagnostics = [] + + // Mock vscode functions + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + }) + + it("should apply diagnostic delay when diagnosticsEnabled is true", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges(true, 3000) + + // Verify delay was called with correct duration + expect(mockDelay).toHaveBeenCalledWith(3000) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("should skip diagnostics when diagnosticsEnabled is false", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges(false, 2000) + + // Verify delay was NOT called and diagnostics were NOT checked + expect(mockDelay).not.toHaveBeenCalled() + expect(vscode.languages.getDiagnostics).not.toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("should use default values when no parameters provided", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges() + + // Verify default behavior (enabled=true, delay falls back to DEFAULT_WRITE_DELAY_MS) + expect(mockDelay).toHaveBeenCalledWith(0) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("pins the default write delay to zero", () => { + // The tests above compare against the constant, so they would still pass if the + // default were changed back to 1000. Pin the value the write path actually uses. + expect(DEFAULT_WRITE_DELAY_MS).toBe(0) + }) + + it("should handle custom delay values", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + await diffViewProvider.saveChanges(true, 5000) + + // Verify custom delay was used + expect(mockDelay).toHaveBeenCalledWith(5000) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + }) + }) + + describe("preEditScrollLine capture and restore", () => { + it("should capture scroll line from visible editor at open() time", async () => { + const mockEditor = { + document: { + uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + visibleRanges: [{ start: { line: 42 } }], + } + + vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("scroll.ts") + + expect((diffViewProvider as any).preEditScrollLine).toBe(42) + }) + + it("should set preEditScrollLine to undefined when the visible editor has no visibleRanges", async () => { + const mockEditorNoRanges = { + document: { + uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + // No visibleRanges, so the capture in open() yields undefined. + } + + vi.mocked(vscode.window).visibleTextEditors = [mockEditorNoRanges as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditorNoRanges as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("new.ts") + + expect((diffViewProvider as any).preEditScrollLine).toBeUndefined() + }) + + it("saveChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { + const mockRevealRange = vi.fn() + const mockSavedEditor = { revealRange: mockRevealRange } + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor as any) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).documentWasOpen = true + ;(diffViewProvider as any).preEditScrollLine = 30 + // saveChanges early-exits without relPath, newContent, activeDiffEditor + ;(diffViewProvider as any).newContent = "content" + ;(diffViewProvider as any).activeDiffEditor = { + document: { + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + } + + await diffViewProvider.saveChanges(false) + + expect(mockRevealRange).toHaveBeenCalledWith( expect.objectContaining({ start: { line: 30, character: 0 } }), vscode.TextEditorRevealType.AtTop, ) @@ -1153,6 +2920,34 @@ describe("DiffViewProvider", () => { expect(vscode.window.showTextDocument).not.toHaveBeenCalled() }) + it("revertChanges() does not run a second teardown while one is already in flight", async () => { + // Cancellation can reach revertChanges() while a rejected save is still + // discarding the same buffer. Both paths acting on the document and the same + // tabs is duplicate cleanup, so the second caller waits for the first. + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + const editor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + diffViewProvider["activeDiffEditor"] = editor + + const first = diffViewProvider.revertChanges() + const second = diffViewProvider.revertChanges() + await Promise.all([first, second]) + + expect(applyEdit).toHaveBeenCalledTimes(1) + }) + it("saveChanges() keeps the file open when the user touched it", async () => { const closeFileTab = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) @@ -1670,6 +3465,11 @@ describe("DiffViewProvider", () => { const setupProvider = (stateOverrides: Record = {}) => { const task = { + cwd: mockCwd, + // S4b follow-up (#44): saveChanges publishes through the guarded write, + // which resolves the path against task.cwd and consults the task's + // observation registry — both must be present on this suite's mock task. + observationRegistry: new ObservationRegistry(), providerRef: { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ @@ -1681,6 +3481,10 @@ describe("DiffViewProvider", () => { }, } const provider = new DiffViewProvider(mockCwd, task as any) + // The preview observation + matching version token let the guarded save + // proceed so these tests stay focused on the auto-close decision table. + task.observationRegistry.observe(`${mockTargetPath}`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") ;(provider as any).relPath = "auto-close-test.ts" ;(provider as any).newContent = "content" ;(provider as any).activeDiffEditor = buildActiveDiffEditor() From 47dc49219e0fb85216c7871d5e895c4c00ac2f5e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:12:11 +0800 Subject: [PATCH 012/115] rebuild unit u6 in the corrected order --- src/core/tools/ApplyPatchTool.ts | 105 +++- .../__tests__/applyPatchTool.execute.spec.ts | 586 +++++++++++++++++- 2 files changed, 682 insertions(+), 9 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 2713e08ffe..f9d202bba2 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -11,6 +11,8 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { versionTokenOfStat } from "../../utils/versionToken" +import { GuardRejectedError, errorCode } from "./guardedWrite" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { parsePatch, ParseError, processAllHunks } from "./apply-patch" @@ -85,10 +87,30 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } - // Process each hunk + // Process each hunk. The read doubles as the S2 observation for the + // guarded publish (ReadFileTool contract: stat before and after the + // read, observe only when the on-disk version is unchanged between the + // two stats). Without it, the in-place modify publish is an unobserved + // write and the composed chat-diff default rejects it ("File already + // exists ... and was not read before this write") even though this tool + // just read the exact content the patch was applied to. const readFile = async (filePath: string): Promise => { const absolutePath = path.resolve(task.cwd, filePath) - return await fs.readFile(absolutePath, "utf8") + const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const content: string = await fs.readFile(absolutePath, "utf8") + const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + // The tool's own hunk read, not a model read: keep the completeness the + // model earned, and only on the version it was earned on. A file the model + // never read, or a version that moved since, stays partial. + const prior = task.observationRegistry.get(absolutePath) + const complete = prior?.complete === true && prior.version === preReadToken + task.observationRegistry.observe(absolutePath, preReadToken, complete) + } + } + return content } let changes: ApplyPatchFileChange[] @@ -214,9 +236,20 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, true, diagnosticsEnabled, writeDelayMs) + // Guarded publish: the patch supplies the complete new content, so create-guard + // semantics apply (an unobserved existing target is rejected, not overwritten). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + true, + diagnosticsEnabled, + writeDelayMs, + "create", + ) } else { - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + // The add path publishes a whole new file, so create-guard semantics + // apply here as well. + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "create") } // Track file edit operation @@ -408,12 +441,59 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save new content to the new path if (isPreventFocusDisruptionEnabled) { + // The destination content is the source file plus one targeted hunk, so it can + // only be as complete as the view the model had of the source. Carry that + // completeness to the destination BEFORE the guarded publish: the guard decides + // completeness at publish time, so a partial view must already be recorded when + // the per-path chain runs. Downgrading only after saveDirectly leaves a window + // in which a concurrent writer sees the destination as complete for content the + // model never fully read. + const sourceObs = task.observationRegistry.get(absolutePath) + const sourceComplete = sourceObs !== undefined && sourceObs.complete === true + if (!sourceComplete) { + // Only carry completeness the model already had for the destination. An + // unobserved destination stays unobserved so the guard's "read before a + // full-file write" rule still applies; recording a partial observation here + // would hand the model authority to edit a file it never read. + const destObs = task.observationRegistry.get(moveAbsolutePath) + if (destObs !== undefined) { + let destAbsent = false + try { + await fs.access(moveAbsolutePath) + } catch (error: unknown) { + // Only an access error that means "not there" is an absence verdict. An + // ELOOP or EACCES is a real I/O failure: reading it as absent would mark + // the destination partial and let the publish run against a path the tool + // never actually observed. + if (errorCode(error) !== "ENOENT") throw error + destAbsent = true + } + if (!destAbsent) { + // Replacing an existing destination with content built from a partial + // source view must be re-authorized by reading the source in full. Reject + // before any state changes and name the source: a remediation that names + // only the destination sends the model to re-read the wrong file, and a + // downgrade that survives a rejected publish loses a full destination read. + throw new GuardRejectedError( + `Cannot move a partially read file onto ${change.movePath}: re-read the whole source (${change.path}) first, then retry.`, + change.movePath, + ) + } + // The destination was read and then deleted: the create guard permits the + // publish, and the content is still only as complete as the view the model + // had of the source, so carry the source's completeness instead of the + // destination's stale one. + task.observationRegistry.observe(moveAbsolutePath, destObs.version, false) + } + } await task.diffViewProvider.saveDirectly( change.movePath, newContent, false, diagnosticsEnabled, writeDelayMs, + "create", + sourceComplete, ) } else { // Write to new path and delete old file @@ -433,9 +513,22 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } else { // Save changes to the same file if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Guarded publish: an update hunk is a targeted change, so it is guarded + // as an edit -- a partial observation authorizes it and stays partial. A + // stale observed version is still rejected with the re-read hint. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + ) } else { - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + // The diff-view save is the same targeted hunk as the guarded save above: + // it must select the same edit guard, otherwise a partial read is + // rejected and the approved patch is thrown away. + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 72ffb112bc..4684ea59fe 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -4,14 +4,57 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import path from "path" +import * as fsPromises from "fs/promises" import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" +import { guardedWrite } from "../guardedWrite" import { ApplyPatchTool } from "../ApplyPatchTool" -vi.mock("fs/promises", () => ({ - default: { +// The vi.mock factory exposes the fs/promises functions under a `default` +// property (matching the SUT's default import), which the static module type +// does not declare; cast once at this boundary rather than at each call site. +const mockedFsPromises = vi.mocked( + fsPromises as unknown as { + default: { + access: MockedFunction + stat: ReturnType + unlink: MockedFunction + } + }, +) + +vi.mock("fs/promises", () => { + // The SUT imports the module two ways: applyPatchTool uses the default + // export, safeWriteText uses the namespace. Both must see the same doubles. + const doubles = { + // The target exists on disk, so the completeness gate applies to the + // follow-up guarded write in the partial-observation test. + access: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue("original file content\n"), + // Stable on-disk version for the S2 self-read observation (the hunk + // read now stats before and after; equal tokens record the observe). + stat: vi.fn().mockResolvedValue({ + dev: 7n, + ino: 4242n, + size: 1234n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + }), unlink: vi.fn().mockResolvedValue(undefined), - }, + // resolveLockKey canonicalizes the lock key, so the guard needs these + // even when no symlink is involved. + lstat: vi.fn().mockResolvedValue({ isSymbolicLink: () => false }), + readlink: vi.fn().mockRejectedValue(new Error("not a symbolic link")), + realpath: vi.fn(async (p: string) => String(p)), + } + return { default: doubles, ...doubles } +}) + +// Mock the shared advisory lock that the guarded-write path uses; the real +// proper-lockfile would try to create a lock directory on the mocked fs. +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(async (filePath: string, operation: (p: string) => Promise) => operation(filePath)), })) vi.mock("../../../utils/fs", () => ({ @@ -38,6 +81,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { | "say" | "processQueuedMessages" | "didEditFile" + | "observationRegistry" > let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> @@ -52,6 +96,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), recordToolUsage: vi.fn(), recordToolError: vi.fn(), rooIgnoreController: { @@ -94,3 +139,538 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.recordToolError).not.toHaveBeenCalled() }) }) + +describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyPatchTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + | "observationRegistry" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockSaveChanges: MockedFunction<(...args: unknown[]) => Promise> + let mockGetState: MockedFunction<() => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + const updatePatch = `*** Begin Patch +*** Update File: src/thing.ts +@@ +-original file content ++modified file content +*** End Patch` + + const addPatch = `*** Begin Patch +*** Add File: src/new.ts ++new line one ++new line two +*** End Patch` + + const movePatch = `*** Begin Patch +*** Update File: src/old.ts +*** Move to: src/new.ts +@@ +-original file content ++modified file content +*** End Patch` + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + mockSaveChanges = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + mockGetState = vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }) + + // Structural stubs for the guarded-write path: the real DiffViewProvider is + // out of scope here, so vi.fn() doubles stand in for the members the tool + // touches (the saveDirectly double also records the writeKind plumbing). + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + saveChanges: mockSaveChanges, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + } + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: mockGetState, + }), + } as unknown as Task["providerRef"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyPatchTool() + }) + + it("update: publishes the targeted hunk through the guarded saveDirectly with edit kind", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("update: keeps a partial observation partial so a later full-file write is still rejected", async () => { + // The hunk read is the tool's own read, not a model read. Upgrading the + // model's partial view to complete here would let a later write_to_file + // replace the file with content built from the slice the model saw. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999999", false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(false) + // The targeted hunk itself is still allowed, guarded as an edit. + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + + await expect(guardedWrite(mockTask as Task, "src/thing.ts", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + + it("update: keeps a complete observation complete across the tool's own hunk read", async () => { + // Completeness is the model's, not the tool's: a complete read stays complete + // and a partial one stays partial, so a later full-file write is still gated. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999999", true) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(true) + }) + + it("update: does not carry completeness across a version the model never read", async () => { + // The model earned completeness on a different version than the one the patch + // helper read: the intervening change was never seen, so a later full-file + // replacement must still fail closed. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999998", true) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(false) + + await expect(guardedWrite(mockTask as Task, "src/thing.ts", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + + it("update: observes the hunk read so the guarded publish is not unobserved", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The hunk read doubles as the S2 observation (ReadFileTool contract): + // stable pre/post stats record the version token, so the in-place modify + // publish is not rejected as an unobserved write. + const observed = mockTask.observationRegistry.get(path.resolve("/workspace/project", "src/thing.ts")) + expect(observed?.version).toBe("7:4242:1234:1700000000123456789:1700000000789999999") + }) + + it("update: does not observe when the pre- and post-read tokens disagree", async () => { + // The file changed mid-read: pre/post stats differ, so no observation is + // recorded and the guarded publish surfaces the unobserved-existing + // remediation instead of publishing against a stale version. + const statMock = mockedFsPromises.default.stat + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 9999n, mtimeNs: 3n, ctimeNs: 4n }) + + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + // The rejection value is controlled here, so assert the exact error that + // reached handleError instead of any call: a stat or parse failure would + // otherwise satisfy a bare toHaveBeenCalled(). + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + }) + + it("update: does not observe when the post-read stat rejects", async () => { + // The pre-read stat succeeds and the read itself succeeds, but the post-read + // stat fails, so the read cannot be tied to a version token and no + // observation is recorded. The guarded publish then fails closed against the + // existing, unobserved file rather than publishing content built from an + // unverifiable read. + const statMock = mockedFsPromises.default.stat + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + statMock.mockRejectedValueOnce(new Error("EACCES: permission denied")) + + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + }) + it("add: publishes the new file through the guarded saveDirectly with create kind", async () => { + mockedFileExistsAtPath.mockResolvedValueOnce(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "new line one\nnew line two\n", + true, + true, + 1000, + "create", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: publishes the destination through the guarded saveDirectly with create kind", async () => { + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + false, + ) + }) + + it("move: rejects a partial source onto an observed destination before changing any state", async () => { + // The destination content is the source file plus one hunk, so it can only be + // as complete as the view the model had of the source. Rejecting before the + // registry is modified keeps a full destination read intact, and the message + // must name the source, because re-reading the destination cannot restore the + // source's completeness and would loop. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Nothing was downgraded: the destination keeps the completeness the model earned. + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ + message: + "Cannot move a partially read file onto src/new.ts: re-read the whole source (src/old.ts) first, then retry.", + }), + ) + }) + + it("move: carries the source's completeness when the observed destination was deleted", async () => { + // The destination was read and then deleted, so its observation is stale but the + // create guard permits the publish. Rejecting here would refuse a move onto an + // absent path; the content is still only as complete as the source view, so the + // source's completeness is carried instead of the destination's stale one. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockedFsPromises.default.access.mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + const completeAtPublish: Array = [] + mockSaveDirectly.mockImplementationOnce(async () => { + completeAtPublish.push(reg.get(destKey)?.complete) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(completeAtPublish).toEqual([false]) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: propagates a non-ENOENT destination access error before changing the observation", async () => { + // An ELOOP is not an absence verdict. Reading it as "the destination is gone" + // would mark the observation partial and let the publish run against a path the + // tool never actually observed. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockedFsPromises.default.access.mockRejectedValueOnce( + Object.assign(new Error("ELOOP: too many symbolic links"), { code: "ELOOP" }), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ message: "ELOOP: too many symbolic links" }), + ) + }) + + it("move: a complete source read keeps the destination complete", async () => { + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockSaveDirectly.mockImplementationOnce(async () => { + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: the destination cannot claim completeness when the source was never observed", async () => { + // The hunk read records no observation when its stat fails, so the model has + // no authority over the source content the move carried over. Nothing is + // recorded for the destination before the write chain runs, so the guard still + // refuses a full-file write to a file the model never read. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + mockedFsPromises.default.stat.mockImplementationOnce(() => Promise.reject(new Error("stat failed"))) + const observedAtPublish: boolean[] = [] + mockSaveDirectly.mockImplementationOnce(async () => { + observedAtPublish.push(reg.has(destKey)) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(path.resolve("/workspace/project", "src/old.ts"))).toBeUndefined() + expect(observedAtPublish).toEqual([false]) + await expect(guardedWrite(mockTask as Task, "src/new.ts", "full replacement", "create")).rejects.toThrow( + "File already exists at " + + "src/new.ts" + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: leaves an unobserved destination alone when the source read was partial", async () => { + // The publish records an observation only when it can compute the new on-disk + // token. With nothing recorded for the destination there is nothing to + // downgrade, and the carry must not dereference a missing observation. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe( + path.resolve("/workspace/project", "src/old.ts"), + "7:4242:1234:1700000000123456789:1700000000789999999", + false, + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(destKey)).toBeUndefined() + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("update: surfaces the unobserved-existing remediation as a tool error", async () => { + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") + }) + + it("update: surfaces the stale-version remediation as a tool error", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + + it("update: the diff-view save selects the same edit guard as the guarded save", async () => { + // With focus-disruption prevention off the tool saves through the diff view. + // A partial read must not be rejected there, otherwise the patch the user + // approved is thrown away. + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("add: the diff-view save uses the create guard for a new file", async () => { + mockedFileExistsAtPath.mockResolvedValueOnce(false) + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create") + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() + }) +}) From b5a213ab327c3cd18697740c81631204e9ae382e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:42:02 +0800 Subject: [PATCH 013/115] fix(apply-patch): a full hunk read is a complete observation The carry rule only applies when the model already observed the file. With no prior observation there is nothing to carry, and the hunk read returned the whole content, so the observation is complete. Recording it as partial made the guard reject a file the tool had just read in full - the four apply_diff extension-host tests timed out on that rejection. 21 tests pass, tsc clean, ESLint --max-warnings=0 clean. --- src/core/tools/ApplyPatchTool.ts | 12 ++++++--- .../__tests__/applyPatchTool.execute.spec.ts | 25 ++++++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index f9d202bba2..3f0ceafe18 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -102,11 +102,15 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { - // The tool's own hunk read, not a model read: keep the completeness the - // model earned, and only on the version it was earned on. A file the model - // never read, or a version that moved since, stays partial. + // The tool's own hunk read, not a model read. When the model already observed the + // file, keep the completeness it earned and only on the version it was earned on; a + // partial view stays partial. With no prior observation this read returned the whole + // content, so the observation is complete. const prior = task.observationRegistry.get(absolutePath) - const complete = prior?.complete === true && prior.version === preReadToken + // Nothing to carry when the model never observed the file: this read returned the + // whole content, so it is a complete observation. Carry only when a prior observation + // exists and still describes the version that was read. + const complete = prior === undefined ? true : prior.complete === true && prior.version === preReadToken task.observationRegistry.observe(absolutePath, preReadToken, complete) } } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 4684ea59fe..9d4b23bcdd 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -47,6 +47,8 @@ vi.mock("fs/promises", () => { lstat: vi.fn().mockResolvedValue({ isSymbolicLink: () => false }), readlink: vi.fn().mockRejectedValue(new Error("not a symbolic link")), realpath: vi.fn(async (p: string) => String(p)), + // safeWriteText creates the backup directory before publishing. + mkdir: vi.fn().mockResolvedValue(undefined), } return { default: doubles, ...doubles } }) @@ -322,6 +324,25 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(reg.get(key)?.complete).toBe(true) }) + it("update: a hunk read with no prior observation records a complete observation", async () => { + // Nothing was earned before, so there is nothing to carry. The hunk read returned + // the whole file, which is a complete read, and the guarded publish must not be + // rejected for a file the tool itself read in full. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(true) + // The guard reads the same entry, so a complete read here is what lets a later + // full-file publish through. + expect(reg.has(key)).toBe(true) + }) + it("update: does not carry completeness across a version the model never read", async () => { // The model earned completeness on a different version than the one the patch // helper read: the intervening change was never seen, so a later full-file @@ -437,6 +458,8 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) + // The source had no prior observation and the hunk read returned the whole file, + // so the destination publish is a complete-content publish. expect(mockSaveDirectly).toHaveBeenCalledWith( "src/new.ts", "modified file content\n", @@ -444,7 +467,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "create", - false, + true, ) }) From cf751fb5578e4b7f24aef7d3eebfd29a570ed62f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:43:20 +0800 Subject: [PATCH 014/115] rebuild unit u7 on the corrected u6 --- src/core/task/Task.ts | 15 +- src/core/tools/ApplyDiffTool.ts | 6 +- src/core/tools/EditFileTool.ts | 7 +- src/core/tools/EditTool.ts | 14 +- src/core/tools/SearchReplaceTool.ts | 14 +- src/core/tools/WriteToFileTool.ts | 11 +- .../applyDiffTool.guardedWrite.spec.ts | 195 ++++++++++++++++++ src/core/tools/__tests__/editFileTool.spec.ts | 108 +++++++++- src/core/tools/__tests__/editTool.spec.ts | 48 ++++- .../tools/__tests__/searchReplaceTool.spec.ts | 48 ++++- .../tools/__tests__/writeToFileTool.spec.ts | 73 +++++++ 11 files changed, 516 insertions(+), 23 deletions(-) create mode 100644 src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9ccc2d7968..fbd622d0c2 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -106,6 +106,7 @@ import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { ObservationRegistry } from "./observationRegistry" import { RooIgnoreController } from "../ignore/RooIgnoreController" +import { ObservationRegistry } from "./observationRegistry" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { type RequestPolicySnapshot } from "../prompts/tools/effective-tool-policy" @@ -293,6 +294,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 @@ -403,6 +405,7 @@ export class Task extends EventEmitter implements TaskLike { abandoned = false abortReason?: ClineApiReqCancelReason isInitialized = false + isPaused: boolean = false runState: RunState = initialRunState readonly runRejections: RunRejection[] = [] @@ -1906,14 +1909,6 @@ export class Task extends EventEmitter implements TaskLike { const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny" const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined - // Re-check: an abort during the getState/checkAutoApproval awaits must not post an ask row. - if (this.abort) { - if (queuedMessage) { - this.messageQueueService.releaseMessage(queuedMessage.id) - } - throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) - } - if (partial !== undefined) { const lastMessage = this.clineMessages.at(-1) @@ -4960,7 +4955,9 @@ export class Task extends EventEmitter implements TaskLike { this.consecutiveNoToolUseCount = 0 } - if (this.userMessageContent.length > 0) { + // Push to stack if there's content OR if we're paused waiting for a subtask. + // When paused, we push an empty item so the loop continues to the pause check. + if (this.userMessageContent.length > 0 || this.isPaused) { stack.push({ userContent: [...this.userMessageContent], // Create a copy to avoid mutation issues includeFileDetails: false, // Subsequent iterations don't need file details diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 3b664b3bd2..fa873b8e5d 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -173,7 +173,8 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } - // Save directly without showing diff view or opening the file + // Save directly without showing diff view or opening the file. The diff is + // applied to an existing file, so edit-guard semantics require a prior read. task.diffViewProvider.editType = "modify" task.diffViewProvider.originalContent = originalContent await task.diffViewProvider.saveDirectly( @@ -182,6 +183,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { false, diagnosticsEnabled, writeDelayMs, + "edit", ) } else { // Original behavior with diff view @@ -221,7 +223,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } // Track file edit operation diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index a7301e2ac9..2cb6f74a49 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -436,17 +436,20 @@ export class EditFileTool extends BaseTool<"edit_file"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file + // Direct file write without diff view or opening the file. In-place edits + // use edit-guard semantics (a prior read is required); new-file creation + // keeps create-guard semantics. await task.diffViewProvider.saveDirectly( relPath, newContent, isNewFile, diagnosticsEnabled, writeDelayMs, + isNewFile ? "create" : "edit", ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, isNewFile ? "create" : "edit") } // Track file edit operation diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index b121fcc096..2fcfc8e70c 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -211,11 +211,19 @@ export class EditTool extends BaseTool<"edit"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Direct file write without diff view or opening the file. This tool only + // edits existing files, so edit-guard semantics require a prior read. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } // Track file edit operation diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index a0f2dadfcc..69f8e51fc1 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -207,11 +207,19 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Direct file write without diff view or opening the file. This tool only + // edits existing files, so edit-guard semantics require a prior read. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } // Track file edit operation diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 0c5c80abb9..71bb864c43 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -132,7 +132,16 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Guarded publish: this write carries the complete file content, so it uses + // create-guard semantics (unobserved targets may only be created when absent). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "create", + ) } else { if (!task.diffViewProvider.isEditing) { const partialMessage = JSON.stringify(sharedMessageProps) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts new file mode 100644 index 0000000000..62ff44f75a --- /dev/null +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -0,0 +1,195 @@ +// npx vitest run core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts + +import type { MockedFunction } from "vitest" + +import { fileExistsAtPath } from "../../../utils/fs" +import type { Task } from "../../task/Task" +import { ApplyDiffTool } from "../ApplyDiffTool" + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original file content\n"), + }, +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg: string) => `Error: ${msg}`), + rooIgnoreError: vi.fn((filePath: string) => `Access denied: ${filePath}`), + createPrettyPatch: vi.fn(() => "mock-diff"), + }, +})) + +vi.mock("../../diff/stats", () => ({ + sanitizeUnifiedDiff: vi.fn((diff: string) => diff), + computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), +})) + +describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyDiffTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "consecutiveMistakeCountForApplyDiff" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "api" + | "diffStrategy" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockSaveChanges: MockedFunction<(...args: unknown[]) => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + mockSaveChanges = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + + // Structural stubs for the guarded-write path: the real DiffViewProvider is + // out of scope here, so vi.fn() doubles stand in for the members the tool + // touches (the saveDirectly double also records the writeKind plumbing). + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + saveChanges: mockSaveChanges, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + } + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + consecutiveMistakeCountForApplyDiff: new Map(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + api: { + getModel: () => ({ id: "claude-sonnet-4-5" }), + } as unknown as Task["api"], + diffStrategy: { + applyDiff: vi.fn().mockResolvedValue({ success: true, content: "modified file content\n" }), + } as unknown as Task["diffStrategy"], + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }), + }), + } as unknown as Task["providerRef"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyDiffTool() + }) + + it("publishes through the guarded saveDirectly with edit kind", async () => { + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("applying diff", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") + }) + + it("passes the edit kind to the diff-view save path", async () => { + // Without focus disruption the tool saves through the diff view, and the + // kind it passes must carry its intent: a targeted edit, not the default + // full-file replacement. + // The settings double is rebuilt for this test so the focus-disruption + // experiment is off and the tool takes the diff-view save path. + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }), + } as unknown as Task["providerRef"] + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index e7ac792f05..c1dce4d4f7 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -168,6 +168,7 @@ describe("editFileTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -178,6 +179,13 @@ describe("editFileTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const nativeArgs: Record = { file_path: testFilePath, @@ -558,8 +566,19 @@ describe("editFileTool", () => { await executeEditFileTool() - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit") expect(mockTask.didEditFile).toBe(true) + }) + + it("keeps create-guard semantics for a new file on the diff-view save path", async () => { + // A new file is still a create, even when it is saved through the diff + // view rather than directly; only an existing targeted edit becomes an + // edit kind. + mockAskApproval.mockResolvedValue(true) + + await executeEditFileTool({ old_string: "", new_string: "New file content" }, { fileExists: false }) + + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "create") // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. expect(mockTask.recordToolUsage).not.toHaveBeenCalled() @@ -687,6 +706,93 @@ describe("editFileTool", () => { }) }) + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes an existing-file edit through saveDirectly with edit kind", async () => { + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + ) + expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(true) + expect(result).toContain("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("publishes new-file creation through saveDirectly with create kind", async () => { + await executeEditFileTool( + { old_string: "", new_string: "New file content" }, + { fileExists: false, experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "New file content", + true, + true, + 1000, + "create", + ) + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.didEditFile).toBe(false) + }) + + it("surfaces the stale-version remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + + it("still fails a literal mismatch with the existing message before the guard runs", async () => { + const result = await executeEditFileTool( + { old_string: "NonExistent", new_string: "Whatever" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(result).toContain("No match found") + expect(result).toContain("") + expect(mockTask.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + }) + }) + describe("CRLF normalization", () => { it("preserves CRLF line endings on output", async () => { const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index a5f665b9e5..902bd2e63d 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -169,6 +169,7 @@ describe("editTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -179,6 +180,13 @@ describe("editTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const defaultParams = { file_path: testFilePath, @@ -343,7 +351,7 @@ describe("editTool", () => { await executeEditTool() - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit") expect(mockTask.didEditFile).toBe(true) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. @@ -424,4 +432,42 @@ describe("editTool", () => { expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") }) }) + + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with edit kind", async () => { + const result = await executeEditTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + ) + expect(mockTask.didEditFile).toBe(true) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + }) }) diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 2be4f3efe6..9f1ca0111c 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -166,6 +166,7 @@ describe("searchReplaceTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -176,6 +177,13 @@ describe("searchReplaceTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockCline.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const nativeArgs: Record = { file_path: testFilePath, @@ -312,7 +320,7 @@ describe("searchReplaceTool", () => { await executeSearchReplaceTool() - expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit") expect(mockCline.didEditFile).toBe(true) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. @@ -439,4 +447,42 @@ describe("searchReplaceTool", () => { expect(mockAskApproval).toHaveBeenCalled() }) }) + + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with edit kind", async () => { + const result = await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + ) + expect(mockCline.didEditFile).toBe(true) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("search and replace", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 81d5056d10..49895b4fb9 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -26,6 +26,13 @@ vi.mock("delay", () => ({ default: vi.fn(), })) +// The focus-disruption save path reads the original file content via fs.readFile. +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original content"), + }, +})) + vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false), createDirectoriesForFile: vi.fn().mockResolvedValue([]), @@ -156,6 +163,11 @@ describe("writeToFileTool", () => { userEdits: null, finalContent: "final content", }), + saveDirectly: vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "final content", + }), scrollToFirstDiff: vi.fn(), updateDiagnosticSettings: vi.fn(), pushToolWriteResult: vi.fn().mockImplementation(async function ( @@ -187,6 +199,7 @@ describe("writeToFileTool", () => { mockCline.say = vi.fn().mockResolvedValue(undefined) mockCline.ask = vi.fn().mockResolvedValue(undefined) mockCline.recordToolError = vi.fn() + mockCline.processQueuedMessages = vi.fn() mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") mockAskApproval = vi.fn().mockResolvedValue(true) @@ -204,6 +217,7 @@ describe("writeToFileTool", () => { fileExists?: boolean isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { // Configure mocks based on test scenario @@ -213,6 +227,13 @@ describe("writeToFileTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockCline.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) // Create a tool use object const toolUse: ToolUse = { @@ -450,6 +471,58 @@ describe("writeToFileTool", () => { }) }) + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with create kind when the write is approved", async () => { + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + testContent, + false, + true, + 1000, + "create", + ) + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") + expect(mockCline.didEditFile).toBe(true) + expect(mockCline.consecutiveMistakeCount).toBe(0) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved-existing remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + `File already exists at ${absoluteFilePath} and was not read before this write -- read the file first, then retry.`, + ) + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + + it("surfaces the stale-version remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + }) + describe("error handling", () => { it("handles general file operation errors", async () => { mockCline.diffViewProvider.open.mockRejectedValue(new Error("General error")) From 38d99ed3980e28c5dd55b7efd293b2ab3b9567a0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:44:00 +0800 Subject: [PATCH 015/115] rebuild unit U7 on the corrected U6 The registry import and field are already declared by U3, so this unit no longer re-adds them. Also keeps main's abort re-check in ask, which the port had dropped. --- src/core/task/Task.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fbd622d0c2..b04b63a009 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -106,7 +106,6 @@ import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { ObservationRegistry } from "./observationRegistry" import { RooIgnoreController } from "../ignore/RooIgnoreController" -import { ObservationRegistry } from "./observationRegistry" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { type RequestPolicySnapshot } from "../prompts/tools/effective-tool-policy" @@ -294,7 +293,6 @@ 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 From 6a46b6b57c1a4f992d21c70547fafe9838389b0d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:45:13 +0800 Subject: [PATCH 016/115] fix(task): keep main's abort re-check in ask Porting this unit's delta onto the rebuilt base dropped the re-check, so an abort during the getState/checkAutoApproval awaits posted an ask row again. The unit's own change is the pause handling. The registry import and field are already declared by U3, so this unit does not re-add them. --- src/core/task/Task.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b04b63a009..31d8b3d285 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1907,6 +1907,14 @@ export class Task extends EventEmitter implements TaskLike { const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny" const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined + // Re-check: an abort during the getState/checkAutoApproval awaits must not post an ask row. + if (this.abort) { + if (queuedMessage) { + this.messageQueueService.releaseMessage(queuedMessage.id) + } + throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) + } + if (partial !== undefined) { const lastMessage = this.clineMessages.at(-1) From 9adf66f22a3c647f384700d2925378df5d751600 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 02:49:19 +0800 Subject: [PATCH 017/115] fix(file-safety): inherit unit 1 committed guard This unit was rebuilt from the pre-fix content source, so its copy of safeWriteText.ts still restored the backup over a write whose commit rename had already succeeded when the parent-directory fsync failed. Unit 1 (fws/u1-atomic-publish) already fixes this. Taking the file here keeps the shared code byte-identical across the units, so merging the chain in order does not overwrite unit 1's fix. Tests: 52 passed in safeWriteText.spec.ts. --- src/services/file-safety/safeWriteText.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index e378cbbae8..2aafd0cce0 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -167,11 +167,7 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu */ export async function resolvePublishTarget(absoluteFilePath: string): Promise { return fs.realpath(absoluteFilePath).catch(async (error: unknown) => { - const code = - typeof error === "object" && error !== null && "code" in error - ? (error as { code?: string }).code - : undefined - if (code !== "ENOENT") throw error + if (errorCode(error) !== "ENOENT") throw error // ENOENT also covers a dangling symlink, which must never be written through. // Only a lstat that also reports the path as absent may fall back to the // given path; a real lstat failure (EACCES, EIO) says nothing about whether @@ -289,6 +285,10 @@ export async function safeWriteText( let backupPath: string | null = null let releaseBackupOnSuccess = false + // Set once the commit rename has published the new content. After that point the + // backup is no longer a safe restore source: rolling it back would overwrite + // content the caller can already observe at the target path. + let committed = false // Non-null only when the win32 step-2 block saved a successful DACL dump: // it gates the step-5 restore and is tracked for the cleanup unlinks. let daclDumpPath: string | null = null @@ -397,16 +397,13 @@ export async function safeWriteText( 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 + if (errorCode(err) !== "ENOENT") throw err } } // -- Step 4: atomic rename temp -> target --------------------- await fs.rename(tempPath, targetPath) + committed = true // -- Step 4b (POSIX): fsync the parent directory so the directory entry // changed by the commit rename is durable, not just the file content. @@ -463,7 +460,10 @@ export async function safeWriteText( await fs.rmdir(stagingDir).catch(() => {}) } } catch (originalError: unknown) { - if (backupPath && releaseBackupOnSuccess) { + // Only a pre-commit failure can restore the backup. Once the commit rename + // published, a later failure (for example the post-commit directory fsync) + // must not overwrite the published content with the old file. + if (backupPath && releaseBackupOnSuccess && !committed) { try { await fs.rename(backupPath, targetPath) } catch (rollbackError: unknown) { From 8647b7d8eb65a6935e8a87b5de8436db445d6e83 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:44:33 +0800 Subject: [PATCH 018/115] fix(file-safety): keep the publish error message in RollbackFailureError safeWriteJson rethrows RollbackFailureError and the telemetry callers record only error.message, so the generic wrapper message lost the filesystem errno text of the publish failure. Include the publish error message while keeping the rollback context (publishError, rollbackError, backupPath) unchanged. Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0. --- src/services/file-safety/safeWriteText.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 2aafd0cce0..85c6a071ba 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -47,7 +47,7 @@ export class RollbackFailureError extends Error { constructor(publishError: unknown, rollbackError: unknown, backupPath: string) { super( - "Publish failed and the backup could not be restored to its original path -- the content is preserved at the backup location reported on this error.", + `Publish failed (${publishError instanceof Error ? publishError.message : String(publishError)}) and the backup could not be restored to its original path -- the content is preserved at the backup location reported on this error.`, { cause: publishError }, ) this.name = "RollbackFailureError" From 3ba741a51522a35c01e5aed61fe43cf38cb52e29 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:45:54 +0800 Subject: [PATCH 019/115] test(file-safety): assert the exact staging directory removed after a rollback failure The assertion accepted any rmdir argument, so a regression that removed a different directory still passed. Read this write's own staging directory from fsSync.mkdirSync and assert that exact path. Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0. --- .../__tests__/safeWriteText.spec.ts | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 007ff4d3c5..99fd6291fb 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -348,6 +348,26 @@ describe("safeWriteText", () => { // temp-shaped is unlinked afterwards expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) + + it("a failed post-commit directory fsync does not roll the backup back over the published content", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const dirPath = path.dirname(targetPath) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // The file fd opens normally; the parent-directory open after the commit + // rename fails, which is the post-commit durability failure. + vi.mocked(fsSync.openSync).mockImplementation((target) => { + if (String(target) === dirPath) throw new Error("EBADF") + return 1 + }) + + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow(PostCommitDurabilityError) + + // The commit rename already published the new content, so the backup must + // not be renamed back over it: only target->backup and temp->target run. + expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(2) + }) }) // ── Test 4: backup:true keeps old safeWriteJson semantics incl. rollback ── @@ -546,6 +566,28 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) }) + it("win32 DACL save runs before the backup rename, not after it", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // The title is about order, so assert the order the mocks were actually + // called in. If the save ran after the backup rename the target would + // already be gone and the dump would describe the wrong file. + await safeWriteText(targetPath, "data", { backup: true, platform: "win32" }) + + const callOrder = vi.mocked(execFile).mock.invocationCallOrder + const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder + const saveCall = callOrder[0] + const restoreCall = callOrder[1] + const backupRename = renameOrder[0] + const commitRename = renameOrder[1] + + expect(saveCall).toBeLessThan(backupRename) + expect(backupRename).toBeLessThan(commitRename) + expect(commitRename).toBeLessThan(restoreCall) + }) + it("win32 DACL: dump is unlinked even when restore fails", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -1049,7 +1091,9 @@ describe("cleanup before a rollback failure is reported", () => { // The backup is what the caller can still recover, so it stays on disk; the // staging file and this write's own directory must not leak alongside it. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) - expect(fs.rmdir).toHaveBeenCalled() + const stagingDirs = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0])) + expect(stagingDirs.length).toBe(1) + expect(fs.rmdir).toHaveBeenCalledWith(stagingDirs[0]) const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[2] const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] From 7ad01ae11dbf35b29bd54420d21fa7862ade3a1b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 05:51:13 +0800 Subject: [PATCH 020/115] test: re-run mocked e2e and the ubuntu lane - no source change since the last green run on these files From 14ec1fba503be0215ec2ece7e053f4b8b844bf42 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 06:47:34 +0800 Subject: [PATCH 021/115] fix(file-safety): give the Windows DACL dump a per-write name The dump path was the fixed sibling .acl.tmp. A pre-existing user file at that path is unlinked by the failed-save branch and by both cleanup paths, and two concurrent writes to the same target share one dump, so one write can restore or delete the other's. Use the per-write unique name like the staging file. Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0. --- .../file-safety/__tests__/safeWriteText.spec.ts | 12 ++++++------ src/services/file-safety/safeWriteText.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 99fd6291fb..8b76906dda 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -170,7 +170,7 @@ describe("safeWriteText", () => { // through the default icacls path (options?.execFileRunner must // not throw when options is undefined) expect(vi.mocked(execFile)).toHaveBeenCalledTimes(2) - expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) } }) @@ -535,7 +535,7 @@ describe("safeWriteText", () => { const saveArgs = vi.mocked(execFile).mock.calls[0]?.[1] expect(saveArgs?.[1]).toBe("/save") // the dump path (possibly partially created by icacls) was unlinked - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { @@ -551,7 +551,7 @@ describe("safeWriteText", () => { // 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"]) + expect(firstCall[1]).toEqual([targetPath, "/save", expect.stringContaining("safeWriteText.acl"), "/T"]) // Second call: restore DACL onto directory after commit rename const secondCall = vi.mocked(execFile).mock.calls[1] @@ -559,11 +559,11 @@ describe("safeWriteText", () => { expect(secondCall[1]).toEqual([ expect.stringContaining("/tmp/test-dir"), "/restore", - expect.stringContaining(".acl.tmp"), + expect.stringContaining("safeWriteText.acl"), ]) // dump file was unlinked after restore - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) it("win32 DACL save runs before the backup rename, not after it", async () => { @@ -610,7 +610,7 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledTimes(1) // dump file was still unlinked in finally - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) it("win32 DACL: when target does not exist, no save/restore/dump", async () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 85c6a071ba..e548e5c3b5 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -370,7 +370,7 @@ export async function safeWriteText( if (platform === "win32") { try { await fs.access(targetPath) // target exists? - const dumpPath = targetPath + ".acl.tmp" + const dumpPath = _tempName(dirPath, "safeWriteText.acl") const saved = await _saveDaclWindows(targetPath, dumpPath, options?.execFileRunner) if (saved) { // Only a successfully saved dump may be restored onto the From b27f4c8882d302ab262d15a8a209051284458d5e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 08:15:02 +0800 Subject: [PATCH 022/115] fix(tools): carry U1's hunk-read completeness rule into this unit This unit's copy of ApplyPatchTool still recorded complete: true when the model had no prior observation, which is the behaviour already fixed on #1910/#1911/#1912/#1913/#1914 and on #1915: the tool's own hunk read is not a model read, so it cannot grant authority for a later full-file replacement. Tests updated to match, including the move case where the completeness flag is now false. Local note: this spec cannot run in this worktree (the 'diff' package is not resolvable from either node_modules here) and ESLint cannot resolve its config here; CI covers both. --- src/core/tools/ApplyPatchTool.ts | 2 +- .../__tests__/applyPatchTool.execute.spec.ts | 22 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 3f0ceafe18..b4fa11f6ff 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -110,7 +110,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Nothing to carry when the model never observed the file: this read returned the // whole content, so it is a complete observation. Carry only when a prior observation // exists and still describes the version that was read. - const complete = prior === undefined ? true : prior.complete === true && prior.version === preReadToken + const complete = prior === undefined ? false : prior.complete === true && prior.version === preReadToken task.observationRegistry.observe(absolutePath, preReadToken, complete) } } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 9d4b23bcdd..0aa6ed1dc6 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -324,10 +324,11 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(reg.get(key)?.complete).toBe(true) }) - it("update: a hunk read with no prior observation records a complete observation", async () => { - // Nothing was earned before, so there is nothing to carry. The hunk read returned - // the whole file, which is a complete read, and the guarded publish must not be - // rejected for a file the tool itself read in full. + it("update: a hunk read with no prior observation records a partial observation", async () => { + // Nothing was earned before, so there is nothing to carry. The tool read the whole + // file for its own hunk matching, but the model only ever saw the patch context, so + // this read cannot grant authority for a later full-file replacement. The targeted + // patch itself is still allowed: the "edit" guard accepts a partial observation. const key = path.resolve("/workspace/project", "src/thing.ts") const reg = mockTask.observationRegistry @@ -337,9 +338,9 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - expect(reg.get(key)?.complete).toBe(true) - // The guard reads the same entry, so a complete read here is what lets a later - // full-file publish through. + expect(reg.get(key)?.complete).toBe(false) + // The guard reads the same entry: a partial observation authorizes targeted edits on + // the view the model saw, but not a full-file replacement. expect(reg.has(key)).toBe(true) }) @@ -458,8 +459,9 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - // The source had no prior observation and the hunk read returned the whole file, - // so the destination publish is a complete-content publish. + // The source had no prior observation, so the hunk read records a partial + // observation: the model only saw the patch context, not the whole source. The + // destination is still published (create kind), but the completeness flag stays false. expect(mockSaveDirectly).toHaveBeenCalledWith( "src/new.ts", "modified file content\n", @@ -467,7 +469,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "create", - true, + false, ) }) From 46c1cfb74b153bfed276cc582e72bd29da9da994 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 13:23:49 +0800 Subject: [PATCH 023/115] fix(tools): bring U7 onto the current file-safety core U7 still carried the pre-U1 publish core and the pre-U5 guard, so its tool wiring was being reviewed against behaviour the rest of the series no longer has: - safeWriteText: durable backup copy (open wx + copyFile + chmod + fsync, never a rename of the target), the staging identity guard (lstat with bigint stats, dev+ino equality), lstat error propagation, and confineTo confinement with ConfinedPathEscapeError; - safeWriteJson: the matching lock-key and publish wiring; - guardedWrite: workspace containment (lexical assertInsideWorkspace plus the canonical re-check that fails closed when the workspace cannot be resolved) and the verifyTarget re-check inside the file lock. The DiffViewProvider spec gained path.relative/sep and fs.realpath doubles, which the containment check needs; the realpath double returns the path unchanged so the check sees the same spelling the rest of the file uses. Verification: 920 passed / 9 skipped across services/file-safety, safeWriteJson (+lockKey), core/tools, integrations editor and observationRegistry; project-wide tsc --noEmit reports 0 errors on this branch; eslint --max-warnings=0 clean on every touched file, no suppression drift. --- src/core/tools/__tests__/guardedWrite.spec.ts | 106 +++++++ src/core/tools/guardedWrite.ts | 123 +++++++- .../editor/__tests__/DiffViewProvider.spec.ts | 14 + .../__tests__/safeWriteText.spec.ts | 286 +++++++++++++----- src/services/file-safety/safeWriteText.ts | 180 +++++++---- .../__tests__/safeWriteJson.lockKey.spec.ts | 9 +- src/utils/__tests__/safeWriteJson.test.ts | 158 +++++++--- src/utils/safeWriteJson.ts | 91 ++++++ 8 files changed, 771 insertions(+), 196 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index e111bd74db..ac7707c03a 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -27,6 +27,7 @@ import type { Task } from "../../task/Task" vi.mock("fs/promises", () => ({ access: vi.fn(), stat: vi.fn(), + realpath: vi.fn(), })) vi.mock("../../../utils/versionToken", () => ({ @@ -45,6 +46,7 @@ vi.mock("../../../utils/fileLock", () => ({ const mockedWithFileLock = vi.mocked(withFileLock) const mockedResolveLockKey = vi.mocked(resolveLockKey) const mockedFsAccess = vi.mocked(fs.access) +const mockedFsRealpath = vi.mocked(fs.realpath) const mockedComputeVersionToken = vi.mocked(computeVersionToken) const mockedSafeWriteText = vi.mocked(safeWriteText) @@ -81,6 +83,11 @@ describe("guardedWrite (S4a, epic #1375)", () => { beforeEach(() => { vi.resetAllMocks() mockedWithFileLock.mockImplementation((filePath, operation) => operation(path.resolve(filePath))) + // The canonical containment check resolves the workspace first. The fixture + // workspace is not a real directory, so the default is "cannot resolve" and the + // check falls back to the lexical decision; the containment tests below override + // this to exercise the canonical path. + mockedFsRealpath.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) resetChain() }) @@ -207,6 +214,105 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) + describe("workspace containment", () => { + it("rejects an absolute path outside the workspace before any lock or publish", async () => { + const task = createMockTask() + + await expect(guardedWrite(task, "/elsewhere/outside.txt", "data", "create")).rejects.toThrow( + "Path resolves outside the workspace", + ) + + // Nothing is queued, locked, or touched: the decision is made on the path + // alone, before the FIFO chain or the filesystem is involved. + expect(mockedWithFileLock).not.toHaveBeenCalled() + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("rejects a relative path that escapes the workspace through dot-dot", async () => { + const task = createMockTask() + + await expect(guardedWrite(task, "../outside.txt", "data", "create")).rejects.toThrow( + "Path resolves outside the workspace", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rejects a target whose resolved path leaves the workspace", async () => { + // The lexical check cannot see a link that lands outside. With the workspace + // resolvable, the canonical comparison is what rejects the write. + mockedFsRealpath.mockImplementation(async (p) => { + const s = String(p) + if (s === path.resolve(WORKSPACE)) { + return "/real/workspace" + } + return "/real/outside/secret.txt" + }) + const task = createMockTask() + + await expect(guardedWrite(task, "planted.txt", "data", "create")).rejects.toThrow( + "resolves through a link to outside the workspace", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + + it("fails closed when the workspace itself cannot be resolved", async () => { + // EACCES/ELOOP on the workspace means a symlink inside it would never be resolved, so + // the containment decision cannot be made at all: the write is refused rather than + // falling back to the lexical-only check. + mockedFsRealpath.mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + const task = createMockTask() + + await expect(guardedWrite(task, "inside.txt", "data", "create")).rejects.toThrow( + "Workspace could not be resolved", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks containment under the lock, after the wait on the FIFO chain", async () => { + // The path was inside the workspace when it was queued; a link is swapped in while the + // write waits. The publish must not follow the new link. + let targetLookups = 0 + mockedFsRealpath.mockImplementation(async (p) => { + const s = String(p) + if (s === path.resolve(WORKSPACE)) { + return "/real/workspace" + } + targetLookups++ + return targetLookups === 1 ? "/real/workspace/in.txt" : "/real/outside/secret.txt" + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await expect(guardedWrite(task, "in.txt", "data", "create")).rejects.toThrow( + "resolves through a link to outside the workspace", + ) + + expect(targetLookups).toBeGreaterThan(1) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + it("publishes when the resolved path stays inside the workspace", async () => { + mockedFsRealpath.mockImplementation(async (p) => { + const s = String(p) + if (s === path.resolve(WORKSPACE)) { + return "/real/workspace" + } + return "/real/workspace/nested/in.txt" + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + + await guardedWrite(task, "nested/in.txt", "data", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("nested/in.txt"), "data") + }) + }) + describe("observed create", () => { it("recreates a file that vanished after the read", async () => { const reg = new ObservationRegistry() diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 13d870308f..8f144f3285 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -150,6 +150,10 @@ export async function createIfAbsent( // Re-checked under the lock: a link that waited on the FIFO chain can outlive // the task that queued it. isCancelled?: () => boolean, + // Re-checked under the lock, immediately before the publish: the path was authorized + // when it was queued, but a symlink can be swapped in while the link waited on the + // FIFO chain or on this lock. + verifyTarget?: () => Promise, ): Promise { // Lock the key every other writer to this file uses: the resolved publish // target, so a symlink alias and its referent share one lock. @@ -164,6 +168,7 @@ export async function createIfAbsent( } // Immediately before publication starts. cancelledBeforePublish(absolutePath, displayPath, isCancelled) + await verifyTarget?.() await safeWriteText(absolutePath, content) // Read the new token under the same lock, otherwise a peer lock-using // writer can publish in the gap and the caller records that writer's @@ -207,6 +212,10 @@ export async function replaceIfVersion( // the task that queued it. displayPath: string, isCancelled?: () => boolean, + // Re-checked under the lock, immediately before the publish: the path was authorized + // when it was queued, but a symlink can be swapped in while the link waited on the + // FIFO chain or on this lock. + verifyTarget?: () => Promise, ): Promise { // Lock the key every other writer to this file uses: the resolved publish // target, so a symlink alias and its referent share one lock. @@ -240,6 +249,7 @@ export async function replaceIfVersion( if (currentVersion === expectedVersion) { // Immediately before publication starts. cancelledBeforePublish(absolutePath, displayPath, isCancelled) + await verifyTarget?.() await safeWriteText(absolutePath, content) // Read the new token under the same lock, otherwise a peer lock-using // writer can publish in the gap and the caller records that writer's @@ -285,6 +295,101 @@ function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { return path.resolve(task.cwd, relPathOrAbsolute) } +/** + * Reject a target that is not inside the task's workspace, before anything is + * queued or touched. path.resolve keeps an absolute input unchanged and collapses + * ".." segments, so a model-supplied string forwarded verbatim can name any path + * on the machine. The rooignore rules, protected-path rules and the user-approval + * flow live in the tool layer; this is the containment line inside the publish + * helper itself, so a caller that forgets them cannot publish outside the + * workspace by accident. + */ +function isInside(root: string, target: string): boolean { + const relative = path.relative(root, target) + return !( + relative === "" || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative) + ) +} + +function assertInsideWorkspace(task: Task, absolutePath: string, displayPath: string): void { + if (!isInside(path.resolve(task.cwd), absolutePath)) { + throw new GuardRejectedError( + `Path resolves outside the workspace -- write inside the workspace, then retry.`, + displayPath, + ) + } +} + +/** + * The lexical check above cannot see a symlink whose referent leaves the + * workspace, and the publish resolves through that link. Resolve both sides the + * same way and compare again. Only a missing path is walked up to the nearest + * existing ancestor (a create has no target yet); any other realpath failure is + * treated as "cannot be authorized" and rejected, and a workspace that cannot be + * resolved at all falls back to the lexical decision already made. + */ +async function assertCanonicalInsideWorkspace(task: Task, absolutePath: string, displayPath: string): Promise { + let workspaceRoot: string + try { + workspaceRoot = await fs.realpath(path.resolve(task.cwd)) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + // The workspace itself is not on disk (a deleted workspace, or a fixture cwd in + // tests): there is no root to contain the write in, and the publish would fail on + // the missing directory anyway. Anything else - EACCES, ELOOP - means the root + // exists but cannot be resolved, and a symlink inside the workspace would then + // never be checked, so this fails closed rather than falling back to lexical only. + return + } + throw new GuardRejectedError( + "Workspace could not be resolved, so this write cannot be checked against it -- retry with a path inside the workspace.", + displayPath, + ) + } + const target = await realpathNearest(absolutePath, displayPath) + if (!isInside(workspaceRoot, target)) { + throw new GuardRejectedError( + `Path resolves through a link to outside the workspace -- write a real file inside the workspace, then retry.`, + displayPath, + ) + } +} + +async function realpathNearest(target: string, displayPath: string): Promise { + const lexical = path.resolve(target) + try { + return await fs.realpath(lexical) + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") { + throw new GuardRejectedError( + "Path could not be resolved, so it cannot be checked against the workspace -- retry with a path inside the workspace.", + displayPath, + ) + } + const missing: string[] = [] + let ancestor = lexical + while (true) { + const parent = path.dirname(ancestor) + if (parent === ancestor) { + return lexical + } + missing.push(path.basename(ancestor)) + ancestor = parent + try { + const real = await fs.realpath(ancestor) + return path.join(real, ...missing.reverse()) + } catch (innerError: unknown) { + if (errorCode(innerError) !== "ENOENT") { + throw new GuardRejectedError( + "Path could not be resolved, so it cannot be checked against the workspace -- retry with a path inside the workspace.", + displayPath, + ) + } + } + } + } +} + /** * Guarded write entry point. * @@ -318,13 +423,21 @@ export async function guardedWrite( // Model-facing path: the caller's own spelling, not the resolved absolute // path. The guard key stays absolute, but a rejection must not put a // user-specific absolute path into the model's context. + const displayPath = relPathOrAbsolute + + // Containment is decided before the link is queued: a write that would land + // outside the workspace must not take a slot on the FIFO chain, take the file + // lock, or touch the filesystem at all. + assertInsideWorkspace(task, absolutePath, displayPath) + // Bound to the task and the caller's spelling so the guard can re-run the same + // decision under the lock, immediately before the publish. + const verifyTarget = () => assertCanonicalInsideWorkspace(task, absolutePath, displayPath) + await verifyTarget() return enqueue(absolutePath, async () => { // Cancellation is checked when the link is dequeued, not when it was enqueued: // a write queued before an abort can still reach its turn on the chain after the // task is gone, and the caller has already reported the write to the model. - const displayPath = relPathOrAbsolute - if (task.abort) { throw new GuardRejectedError( "Task was cancelled before this write ran -- the queued publish is not performed.", @@ -353,6 +466,7 @@ export async function guardedWrite( content, displayPath, () => task.abort, + verifyTarget, ) staysPartial = obs.complete === false } @@ -377,10 +491,10 @@ export async function guardedWrite( if (obs === undefined) { // Never read: only an absent target may be created. - publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort) + publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort, verifyTarget) } else if (absent) { // A "create" on a file that vanished after the read recreates it. - publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort) + publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort, verifyTarget) } else { // The version recorded at read time must still match the on-disk // token. @@ -390,6 +504,7 @@ export async function guardedWrite( content, displayPath, () => task.abort, + verifyTarget, ) } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 36f5445df2..3705aa4e4b 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -31,6 +31,10 @@ vi.mock("fs/promises", () => ({ rename: vi.fn().mockResolvedValue(undefined), unlink: vi.fn().mockResolvedValue(undefined), rmdir: vi.fn().mockResolvedValue(undefined), + // guardedWrite resolves the workspace root (and the nearest existing ancestor of the + // target) before publishing. The double returns the path unchanged so the containment + // check sees the same spelling the rest of the test uses. + realpath: vi.fn(async (p: string) => p), })) // Mock safeWriteText (used by saveDirectly) @@ -69,6 +73,16 @@ vi.mock("path", () => ({ basename: vi.fn((path) => path.split("/").pop()), dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), join: (...args: string[]) => args.join("/"), + sep: "/", + // guardedWrite's workspace containment compares paths lexically, so the double needs + // a POSIX relative() that matches the resolve()/join() doubles above. + relative: (from: string, to: string) => { + const f = from.split("/").filter(Boolean) + const t = to.split("/").filter(Boolean) + let i = 0 + while (i < f.length && i < t.length && f[i] === t[i]) i++ + return [...Array.from({ length: f.length - i }, () => ".."), ...t.slice(i)].join("/") + }, })) // Mock vscode diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8b76906dda..a20df2b9f9 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -7,7 +7,6 @@ import * as path from "path" import { PostCommitDurabilityError, resolveLockKey, - RollbackFailureError, safeWriteText, StagingPathError, type SafeWriteTextOptions, @@ -15,6 +14,8 @@ import { // Full mock for fs/promises — all methods are vi.fn() stubs vi.mock("fs/promises", () => ({ + copyFile: vi.fn(), + chmod: vi.fn(), mkdir: vi.fn(), access: vi.fn(), rename: vi.fn(), @@ -66,6 +67,17 @@ function _fileStats(isLink: boolean): fsSync.Stats { return s } +// fs.BigIntStats is a type-only export (fs.BigIntStats is undefined at runtime), so the stand-in is +// a Stats object carrying bigint ino/dev - exactly what fs.lstat(path, { bigint: true }) hands +// back at runtime. +function _fileStatsWithIdentity(ino: bigint, dev: bigint): fsSync.BigIntStats { + // Double assertion: the runtime value is the Stats stand-in, the type is the bigint variant. + const s = _fileStats(false) as unknown as fsSync.BigIntStats + s.ino = ino + s.dev = dev + return s +} + function mockDefaults(): void { vi.resetAllMocks() // After resetAllMocks, vi.fn() returns undefined — restore promise defaults. @@ -335,15 +347,13 @@ describe("safeWriteText", () => { 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 commit rename (temp -> target) still happened; it is the only rename + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(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_")) @@ -362,18 +372,22 @@ describe("safeWriteText", () => { await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow(PostCommitDurabilityError) - // The commit rename already published the new content, so the backup must - // not be renamed back over it: only target->backup and temp->target run. - expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) - expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) - expect(fs.rename).toHaveBeenCalledTimes(2) + // The commit rename already published the new content, and the backup was only + // ever a copy: the target was never moved, so there is nothing to rename back. + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // The durability failure is reported, not swallowed - and the backup copy is not + // left beside the target where no caller could find it. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) }) }) - // ── Test 4: backup:true keeps old safeWriteJson semantics incl. rollback ── + // ── Test 4: backup:true keeps old safeWriteJson semantics, copy-based ── describe("backup:true", () => { - it("renames target -> backup before commit, deletes backup on success", async () => { + it("copies target -> backup before commit without moving the target, deletes the copy on success", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -383,68 +397,94 @@ describe("safeWriteText", () => { // target was accessed (exists check) expect(fs.access).toHaveBeenCalledWith(targetPath) - // first rename: target -> backup - expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) + // The backup is a copy: the canonical target is never moved away, so readers + // never see a missing file and no later step can clobber a concurrent publish. + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.rename).not.toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) - // second rename: temp -> target (realpath mock returns targetPath) - expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + // the only rename is the atomic commit temp -> target + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) - // backup was deleted on success + // backup copy 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 () => { + it("a failed commit does not move the target, so nothing has to be rolled back", 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 - if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails - return // the rollback rename succeeds - }) + // The commit rename is the only rename in the flow and it fails. + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) 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) + // The target never left its path, so there is no restore rename and the + // pre-write content is still what a reader sees at targetPath. + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) - // temp was cleaned up on failure + // Both the backup copy and the staging temp are cleaned up on failure. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) - it("a failed rollback reports the partial state, not only the publish error", async () => { - // The content is still on disk, but only at the backup path. A caller that gets - // just the publish error has data it cannot find at the expected path. + it("creates the backup privately before its content exists, then fsyncs it", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - let callCount = 0 - vi.mocked(fs.rename).mockImplementation(async () => { - callCount++ - if (callCount === 1) return // target -> backup - if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails - throw new Error("EACCES") // the rollback rename fails too + + await safeWriteText(targetPath, "new data", { backup: true }) + + // The destination must exist with a private mode before copyFile writes anything + // into it: copyFile chooses the destination mode itself, so a restrictive target + // could otherwise leave a group/world-readable copy that a later chmod cannot + // undo. "wx" also means a pre-existing path is never silently reused. + const seedOpen = vi.mocked(fsSync.openSync).mock.calls.find(function (call) { + return String(call[0]).includes("safeWriteText.bak_") && call[1] === "wx" }) + expect(seedOpen).toBeDefined() + expect(seedOpen?.[2]).toBe(0o600) + const seedOrder = vi.mocked(fsSync.openSync).mock.invocationCallOrder[vi.mocked(fsSync.openSync).mock.calls.indexOf(seedOpen!)] + expect(seedOrder).toBeLessThan(vi.mocked(fs.copyFile).mock.invocationCallOrder[0]) + + // The chmod keeps a copied read-only attribute (Windows) from breaking the fsync + // open, and keeps a backup of a permissive file private. + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.chmod).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_"), 0o600) + expect(vi.mocked(fs.chmod).mock.invocationCallOrder[0]).toBeGreaterThan( + vi.mocked(fs.copyFile).mock.invocationCallOrder[0], + ) - let failure: RollbackFailureError | undefined - await safeWriteText(targetPath, "new data", { backup: true }).catch((e: unknown) => { - if (e instanceof RollbackFailureError) { - failure = e - return + // The copy is then opened for fsync with the writable flag. + const backupOpen = vi.mocked(fsSync.openSync).mock.calls.find(function (call) { + return String(call[0]).includes("safeWriteText.bak_") && call[1] === "r+" + }) + expect(backupOpen).toBeDefined() + }) + + it("a failed backup flush is reported and leaves no partial backup behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The copy lands, but the fsync of the copy fails: the retained content is not + // known to be durable, so the write must not proceed on a half-written backup. + // The staged temp is fsynced earlier with a different handle, so target the + // backup's fd specifically. + vi.mocked(fsSync.openSync).mockImplementation((p: unknown) => (String(p).includes("safeWriteText.bak_") ? 7 : 1)) + vi.mocked(fsSync.fsyncSync).mockImplementation((fd: unknown) => { + if (fd === 7) { + throw new Error("EIO") } - throw e }) - expect(failure).toBeInstanceOf(RollbackFailureError) - expect(failure?.publishError).toBeInstanceOf(Error) - expect((failure?.publishError as Error).message).toBe("ENOSPC") - expect((failure?.rollbackError as Error).message).toBe("EACCES") - expect(failure?.backupPath).toContain("safeWriteText.bak_") - // The backup is what the caller can still recover, so it must stay on disk. - expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow("EIO") + + // Nothing was published, and the incomplete copy is removed rather than left + // next to the target looking like a usable backup. + expect(fs.rename).not.toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) it("backup:true when target does not exist: no backup created, just commit", async () => { @@ -514,6 +554,43 @@ describe("safeWriteText", () => { expect(execFile).toHaveBeenCalledTimes(1) }) + it("win32: reports that access rights may change when the DACL cannot be saved", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + const warnings: string[] = [] + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + + // The write still commits - a failing icacls must not leave the user unable to save - + // but the caller is told the replacement may not carry the old ACL. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(warnings.filter((m) => m.includes("different access rights"))).toHaveLength(1) + }) + + it("win32: reports when the target cannot be checked for DACL preservation", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The target exists but is not readable: that is not "absent", and skipping DACL + // preservation has to be visible. + vi.mocked(fs.access).mockImplementation(async (p) => { + if (String(p) === targetPath) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + }) + const warnings: string[] = [] + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + + expect(execFile).not.toHaveBeenCalled() + expect(warnings.filter((m) => m.includes("Could not check"))).toHaveLength(1) + }) + it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -566,32 +643,34 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) - it("win32 DACL save runs before the backup rename, not after it", async () => { + it("win32 DACL save runs before the backup copy, not after it", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) // The title is about order, so assert the order the mocks were actually - // called in. If the save ran after the backup rename the target would - // already be gone and the dump would describe the wrong file. + // called in. If the save ran after the backup copy the dump could describe a + // file that a concurrent publish had already replaced. await safeWriteText(targetPath, "data", { backup: true, platform: "win32" }) const callOrder = vi.mocked(execFile).mock.invocationCallOrder + const copyOrder = vi.mocked(fs.copyFile).mock.invocationCallOrder const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder const saveCall = callOrder[0] const restoreCall = callOrder[1] - const backupRename = renameOrder[0] - const commitRename = renameOrder[1] + const backupCopy = copyOrder[0] + const commitRename = renameOrder[0] - expect(saveCall).toBeLessThan(backupRename) - expect(backupRename).toBeLessThan(commitRename) + expect(saveCall).toBeLessThan(backupCopy) + expect(backupCopy).toBeLessThan(commitRename) expect(commitRename).toBeLessThan(restoreCall) }) - it("win32 DACL: dump is unlinked even when restore fails", async () => { + it("win32 DACL: a failed restore is reported and the dump is still unlinked", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) // icacls save succeeds, restore fails let callCount = 0 @@ -605,10 +684,15 @@ describe("safeWriteText", () => { await safeWriteText(targetPath, "data", { platform: "win32" }) - // write succeeded despite restore failure (best-effort) + // The content did commit: failing here would break every publish on a machine + // where icacls cannot reapply the saved ACEs. expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) expect(fs.rename).toHaveBeenCalledTimes(1) + // The changed access rights are reported instead of being swallowed. + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("could not be restored")) + warnSpy.mockRestore() + // dump file was still unlinked in finally expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) @@ -1067,35 +1151,83 @@ describe("caller-supplied staging path", () => { expect(fsSync.openSync).not.toHaveBeenCalled() expect(fs.rename).not.toHaveBeenCalled() }) -}) -describe("cleanup before a rollback failure is reported", () => { + it("rejects a staging path that is the target itself", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // Same inode and device for the supplied staging path and the target: the + // failure handler would unlink the only copy of the content, so a failed + // write would delete the file it was meant to protect. + const stats = _fileStatsWithIdentity(42n, 7n) + vi.mocked(fs.lstat).mockResolvedValue(stats) + + await expect( + safeWriteText(targetPath, "data", { tempPath: targetPath, platform: "linux" }), + ).rejects.toThrow(StagingPathError) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + // The comparison is only sound when both stats are read as bigint: on NTFS/ReFS the file + // identifiers exceed Number.MAX_SAFE_INTEGER. + // Filter on the options, not the spelling: path.resolve prefixes a drive letter on Windows, + // so the two identity reads are the calls that asked for options at all. + const identityLookups = vi.mocked(fs.lstat).mock.calls.filter((c) => c[1] !== undefined) + expect(identityLookups.length).toBeGreaterThanOrEqual(2) + for (const c of identityLookups) { + expect(c[1]).toEqual({ bigint: true }) + } + expect(fs.unlink).not.toHaveBeenCalled() + }) + + + it("rejects when the target identity cannot be compared for a reason other than a missing target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // A hard-linked staging file shares the target's inode, so the identity comparison is the only thing + // between this write and a rename onto the very file the guard protects. An EACCES from the target + // lstat must not be mistaken for "there is no target". + const stagingStats = _fileStatsWithIdentity(42n, 7n) + vi.mocked(fs.lstat).mockImplementation(async (p) => { + if (String(p) === targetPath) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + return stagingStats + }) + + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/hardlink.txt", platform: "linux" }), + ).rejects.toThrow("Staging file could not be compared with the target") + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + // Both identity reads must ask for bigint stats, or the comparison silently falls + // back to rounded numbers on NTFS/ReFS. + const identityLookups = vi.mocked(fs.lstat).mock.calls.filter((c) => c[1] !== undefined) + expect(identityLookups).toHaveLength(2) + for (const c of identityLookups) { + expect(c[1]).toEqual({ bigint: true }) + } + })}) + +describe("cleanup when a backed-up write fails before commit", () => { beforeEach(() => mockDefaults()) - it("releases the staged file and its own staging directory before throwing", async () => { + it("releases the staged file, its copy and its own staging directory before throwing", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - let callCount = 0 - vi.mocked(fs.rename).mockImplementation(async () => { - callCount++ - if (callCount === 1) return // target -> backup - if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails - throw new Error("EACCES") // the rollback rename fails too - }) + // The commit rename is the only rename in this flow and it fails. + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) - await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow( - RollbackFailureError, - ) + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow("ENOSPC") - // The backup is what the caller can still recover, so it stays on disk; the - // staging file and this write's own directory must not leak alongside it. + // The staging file and this write's own directory must not leak, and neither may + // the backup copy: the target still holds the pre-write content on disk. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) const stagingDirs = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0])) expect(stagingDirs.length).toBe(1) expect(fs.rmdir).toHaveBeenCalledWith(stagingDirs[0]) - const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[2] + const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[0] const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] const rmdirOrder = vi.mocked(fs.rmdir).mock.invocationCallOrder[0] expect(unlinkOrder).toBeGreaterThan(failingRenameOrder) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index e548e5c3b5..fbdd2feaef 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -5,10 +5,12 @@ import { execFile } from "child_process" 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. + * When true, keep the old-file semantics without ever removing the target: the + * previous content is copied to a hidden backup path and flushed before the + * commit rename, the commit rename atomically replaces the target, and on success + * the backup copy is deleted. A failure before the commit leaves the target + * untouched (there is nothing to roll back) and removes the backup copy. When + * false (default) the atomic rename simply replaces the target. */ backup?: boolean @@ -25,6 +27,14 @@ export interface SafeWriteTextOptions { */ execFileRunner?: typeof execFile + /** + * Sink for non-fatal safety notices. A Windows DACL that could not be captured means the + * committed file may inherit different access rights: the write still proceeds (a missing or + * failing icacls must not block saving), but the caller is told instead of the change being + * silent. Defaults to console.warn. + */ + onWarning?: (message: string) => void + /** * Pre-written temp path to use for the commit phase. When provided, * safeWriteText skips creating its own staging file and uses this path @@ -34,29 +44,6 @@ export interface SafeWriteTextOptions { tempPath?: string } -/** - * A publish that failed and whose rollback also failed: the content survives only - * at the backup path, not at the canonical target. The publish failure stays the - * cause, and the rollback failure plus the backup location travel with the error so - * the caller can tell what it is looking at. - */ -export class RollbackFailureError extends Error { - readonly publishError: unknown - readonly rollbackError: unknown - readonly backupPath: string - - constructor(publishError: unknown, rollbackError: unknown, backupPath: string) { - super( - `Publish failed (${publishError instanceof Error ? publishError.message : String(publishError)}) and the backup could not be restored to its original path -- the content is preserved at the backup location reported on this error.`, - { cause: publishError }, - ) - this.name = "RollbackFailureError" - this.publishError = publishError - this.rollbackError = rollbackError - this.backupPath = backupPath - } -} - /** * A caller-supplied staging path that is not a file this write may publish: it * sits outside the target's directory (so the commit rename would cross @@ -140,8 +127,8 @@ async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunne } /** 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 { + * Returns whether icacls succeeded; the caller reports a failure. */ +async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { const runner = execFileRunner ?? execFile try { await new Promise((resolve, reject) => { @@ -149,8 +136,9 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu err ? reject(err) : resolve(), ) }) + return true } catch { - // best-effort; content already committed + return false } } @@ -211,7 +199,8 @@ async function canonicalDirKey(absoluteFilePath: string): Promise { * Lock key for a publish target: the symlink referent when the path is an * existing symlink, the path itself otherwise. Unlike resolvePublishTarget this * tolerates a dangling link, because the lock key has to be computable while a - * peer writer is mid-commit (backup mode renames the referent away and back). + * peer writer is mid-commit (a publish renames the staged file onto the referent, + * and backup mode keeps a copy beside it). * The walk is bounded so a two-link cycle terminates, and every key it returns is * canonicalized through canonicalDirKey. */ @@ -269,13 +258,39 @@ export async function safeWriteText( supplied, ) } - const stagingStat = await fs.lstat(supplied) + // BigInt stats: on NTFS/ReFS the file identity can exceed Number.MAX_SAFE_INTEGER, and + // a rounded number makes two different files look identical (rejecting a valid staging + // file) or hides a real alias. + const stagingStat = await fs.lstat(supplied, { bigint: true }) if (stagingStat.isSymbolicLink() || !stagingStat.isFile()) { throw new StagingPathError( `Staging file must be a regular file, not ${stagingStat.isSymbolicLink() ? "a symlink" : "another file type"}`, supplied, ) } + // A staging path that is the target would be unlinked by the failure handler + // while it still holds the only copy of the content, so a failed write would + // delete the file it was meant to protect. Compare identities, not spellings: + // an alias of the target is the same hazard. + // Only a missing target may be skipped: an EACCES/ELOOP/ENOTDIR here means the + // identity comparison could not be made, and treating that as "no target" would let a + // staging alias reach the commit and let cleanup delete the file it was meant to + // protect. + const targetStat = await fs.lstat(targetPath, { bigint: true }).catch((error: unknown) => { + if (errorCode(error) !== "ENOENT") { + throw new StagingPathError("Staging file could not be compared with the target", supplied) + } + return null + }) + if ( + targetStat && + typeof stagingStat.ino === "bigint" && + typeof targetStat.ino === "bigint" && + stagingStat.ino === targetStat.ino && + stagingStat.dev === targetStat.dev + ) { + throw new StagingPathError("Staging file must not be the target itself", supplied) + } // The caller's own path is used as given; only the check is canonical. tempPath = options.tempPath } else { @@ -292,12 +307,6 @@ export async function safeWriteText( // Non-null only when the win32 step-2 block saved a successful DACL dump: // it gates the step-5 restore and is tracked for the cleanup unlinks. let daclDumpPath: string | null = null - // Set when the rollback itself fails, so cleanup runs before the error that - // reports the partial state is thrown. - // Held as a pair so the reported error still names the path the content survived at; - // declaring it as `unknown` alone would lose the string narrowing at the throw site. - let rollbackFailure: { error: unknown; backupPath: string } | null = null - try { // -- Step 1: write content to staging temp file ------------------- if (!options?.tempPath) { @@ -365,11 +374,17 @@ export async function safeWriteText( } } - // -- Step 2 (win32): save DACL BEFORE backup rename --------------- + // -- Step 2 (win32): save DACL BEFORE the backup copy ----------- const platform = options?.platform ?? process.platform + const warn = options?.onWarning ?? ((message: string) => console.warn(message)) if (platform === "win32") { + let accessError: unknown = null try { await fs.access(targetPath) // target exists? + } catch (error: unknown) { + accessError = error + } + if (accessError === null) { const dumpPath = _tempName(dirPath, "safeWriteText.acl") const saved = await _saveDaclWindows(targetPath, dumpPath, options?.execFileRunner) if (saved) { @@ -381,20 +396,60 @@ export async function safeWriteText( // remove it now (best-effort) so no partial dump survives and // no later step can restore from it. await fs.unlink(dumpPath).catch(() => {}) + // The target exists and its DACL could not be captured, so the commit rename + // replaces it with a file that inherits different access rights. The write still + // proceeds - a missing or failing icacls must not leave the user unable to save - + // but the replacement is no longer ACL-identical and that has to be visible + // instead of silent. + warn(`Could not save the DACL of ${targetPath}; the replacement may inherit different access rights.`) } - } catch { - // target does not exist or access failed — no DACL handling - daclDumpPath = null + } else if (errorCode(accessError) !== "ENOENT") { + // Not "absent": the target is there but could not be checked (EACCES, ...), so + // DACL preservation was skipped for a reason the caller cannot infer from the + // successful write alone. + warn(`Could not check ${targetPath} for DACL preservation (${errorCode(accessError) ?? "unknown error"}); the replacement may inherit different access rights.`) } } - try { - // -- Step 3 (backup:true): rename target -> backup -------------- + // -- Step 3 (backup:true): durable copy target -> backup ---- if (options?.backup) { try { await fs.access(targetPath) backupPath = _tempName(dirPath, "safeWriteText.bak") - await fs.rename(targetPath, backupPath) + // Copy, never move. Renaming the target away leaves the canonical path absent for + // the whole commit window: readers see a missing file, and a concurrent + // writer can create a new target that a later rollback would destroy. A copy + // keeps the target present, so the step 4 rename is the only change to the + // canonical path. The copy is flushed so the retained content survives a crash. + try { + // Create the destination BEFORE any content exists at it, with the mode fixed + // at open time. fs.copyFile picks the destination mode itself (the platform + // creation mask subject to umask on some platforms, the source's mode - or its + // read-only attribute - on others), so letting it create the file would either + // leave a restrictive target's bytes briefly readable to others, or leave the + // copy unwritable so the fsync open below fails with EACCES. open() ignores its + // mode argument for an existing file, so this 0o600 survives the copy on POSIX; + // the chmod afterwards is what clears a copied read-only attribute on Windows + // and keeps a backup of a permissive file private. + const seedFd = fsSync.openSync(backupPath, "wx", 0o600) + fsSync.closeSync(seedFd) + await fs.copyFile(targetPath, backupPath) + await fs.chmod(backupPath, 0o600) + // "r+" not "r": fsync on a read-only handle is EPERM on Windows, and the same + // flag the staged temp file uses above. + const backupFd = fsSync.openSync(backupPath, "r+") + try { + _fsyncFile(backupFd) + } finally { + fsSync.closeSync(backupFd) + } + } catch (backupError: unknown) { + // A partial backup must not outlive this attempt: it is not a complete copy + // of anything, and once the write fails nothing else removes it. + await fs.unlink(backupPath).catch(() => {}) + backupPath = null + throw backupError + } releaseBackupOnSuccess = true } catch (err: unknown) { if (errorCode(err) !== "ENOENT") throw err @@ -432,7 +487,16 @@ export async function safeWriteText( // and on every failed save. if (daclDumpPath !== null) { const restoredDir = path.dirname(targetPath) - await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + const restored = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + if (!restored) { + // The content is committed, but the published file may carry a different DACL + // from the one that was saved. Failing the write here would break every + // publish on machines where icacls cannot reapply the saved ACEs (a plain + // temp directory restore fails with "Not all privileges or groups referenced + // are assigned to the caller"), so the change of access rights is reported + // rather than thrown. + console.warn(`safeWriteText: content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file may carry different access rights than the one it replaced.`) + } } // -- Step 6 (backup:true): delete backup on success ----------- @@ -463,19 +527,13 @@ export async function safeWriteText( // Only a pre-commit failure can restore the backup. Once the commit rename // published, a later failure (for example the post-commit directory fsync) // must not overwrite the published content with the old file. - if (backupPath && releaseBackupOnSuccess && !committed) { - try { - await fs.rename(backupPath, targetPath) - } catch (rollbackError: unknown) { - // The content survives only at the backup path now, and the canonical - // target is gone. Reporting just the publish failure would leave the - // caller with data it cannot find at the expected path, so the - // partial-failure state travels with the error. The staged temp file - // and this write's staging directory are released first: a rollback - // failure is already a hard enough state to reason about without also - // leaking the staging file. - rollbackFailure = { error: rollbackError, backupPath } - } + if (backupPath && releaseBackupOnSuccess) { + // Nothing to restore: the backup is a copy, so the target still holds whatever + // the commit left there - before the commit that is the pre-write content, and + // after it the published content. Either way the copy has served its purpose + // and must not be left beside the target where no caller can find it. + await fs.unlink(backupPath).catch(() => {}) + backupPath = null } try { await fs.unlink(tempPath).catch(() => {}) @@ -494,10 +552,6 @@ export async function safeWriteText( await fs.unlink(daclDumpPath).catch(() => {}) } - if (rollbackFailure) { - throw new RollbackFailureError(originalError, rollbackFailure.error, rollbackFailure.backupPath) - } - throw originalError } } diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 33989f8b81..beddaf9ea9 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -96,10 +96,11 @@ describe("safeWriteJson lock key under a peer commit", () => { // The lock key is the key every other writer to this file uses, so the caller // queued behind the peer instead of failing before the lock. expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) - // The trailing lstat is safeWriteText's staging-path check on the temp file - // this write created: it runs after the key was resolved and the lock taken, - // so it does not change which lock the caller queued behind. - expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "resolve", "resolve", "lstat"]) + // The two trailing lstat calls are safeWriteText's staging-path checks: the + // regular-file check on the temp file this write created, and the identity check + // that the staging path is not the target. Both run after the key was resolved + // and the lock was taken, so neither changes which lock the caller queued behind. + expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "resolve", "resolve", "lstat", "lstat"]) expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) }) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 045c425c1b..9393774a25 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -3,8 +3,7 @@ import { Writable } from "stream" import * as path from "path" import * as os from "os" -import { safeWriteJson } from "../safeWriteJson" -import { RollbackFailureError } from "../../services/file-safety/safeWriteText" +import { ConfinedPathEscapeError, safeWriteJson } from "../safeWriteJson" import * as lockfile from "proper-lockfile" // Capture actual implementations before the vi.mock factory runs, @@ -160,7 +159,7 @@ describe("safeWriteJson", () => { expect(content).toEqual({ initial: "content" }) }) - test("should handle failure when renaming filePath to tempBackupFilePath (filePath exists)", async () => { + test("should handle failure when the commit rename fails (filePath exists)", async () => { const initialData = { message: "Initial content, should remain" } const newData = { message: "New content, should not be written" } @@ -179,7 +178,7 @@ describe("safeWriteJson", () => { expect(content).toEqual(initialData) }) - test("should handle failure when renaming tempNewFilePath to filePath (filePath exists, backup succeeded)", async () => { + test("should handle failure when renaming tempNewFilePath to filePath (filePath exists, backup copy taken)", async () => { const initialData = { message: "Initial content, should be restored" } const newData = { message: "New content" } @@ -193,14 +192,8 @@ describe("safeWriteJson", () => { vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { renameCallCount++ if (renameCallCount === 1) { - // First call: filePath -> tempBackupFilePath (should succeed) - return fsPromisesActuals.rename!(oldPath, newPath) - } else if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (should fail) + // The commit rename is the only rename in this flow: it fails. throw new Error("Rename from temp to final failed") - } else if (renameCallCount === 3) { - // Third call: tempBackupFilePath -> filePath (rollback, should succeed) - return fsPromisesActuals.rename!(oldPath, newPath) } // Default: use original implementation return fsPromisesActuals.rename!(oldPath, newPath) @@ -351,16 +344,11 @@ describe("safeWriteJson", () => { await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) - // fs.rename is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn - let renameCallCount = 0 - vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { - renameCallCount++ - if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (should fail) - throw new Error("Rename failed") - } - // For all other calls, use the original implementation - return fsPromisesActuals.rename!(oldPath, newPath) + // fs.rename is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn. + // Once-only so the override does not leak into later tests: the commit rename is + // the only rename in this flow. + vi.mocked(fs.rename).mockImplementationOnce(async () => { + throw new Error("Rename failed") }) await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Rename failed") @@ -440,9 +428,10 @@ describe("safeWriteJson", () => { expect(vi.mocked(fs.access)).toHaveBeenCalled() }) - // 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" } + // The backup is a copy taken before the commit, so a failed commit has nothing to + // roll back: the target keeps its previous content and the copy is removed. + test("a failed commit keeps the previous content at the target and removes the backup copy", async () => { + const initialData = { message: "Initial, must survive a failed commit" } const newData = { message: "New content" } await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) @@ -453,38 +442,24 @@ describe("safeWriteJson", () => { let renameCallCount = 0 vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { renameCallCount++ - if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (fail) + if (renameCallCount === 1) { + // The commit rename fails; there is no rollback rename to fail. throw new Error("Primary rename failed") - } else if (renameCallCount === 3) { - // Third call: backup -> filePath (rollback, also fail) - throw new Error("Rollback rename failed") } return fsPromisesActuals.rename!(oldPath, newPath) }) - // The original error must propagate, not the rollback error - // The rollback also failed, so the error reports the partial state: the publish - // failure stays the cause and the backup location is named. - let failure: RollbackFailureError | undefined - await safeWriteJson(currentTestFilePath, newData).catch((e: unknown) => { - if (e instanceof RollbackFailureError) { - failure = e - return - } - throw e - }) + await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") - expect(failure).toBeInstanceOf(RollbackFailureError) - expect(failure?.cause).toBeInstanceOf(Error) - expect(failure?.rollbackError).toBeInstanceOf(Error) - expect(failure?.backupPath).toContain("safeWriteText.bak_") + // Exactly one rename was attempted, and it was the commit. + expect(renameCallCount).toBe(1) - // The rollback failed inside safeWriteText, so the target is gone and - // the backup is orphaned on disk. - expect(await fileExists(currentTestFilePath)).toBe(false) + // The target never left its path, so the previous content is still what a + // reader sees, and no orphaned backup copy is left behind either. + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual(initialData) const entries = await fs.readdir(tempDir) - expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(false) consoleErrorSpy.mockRestore() }) @@ -690,6 +665,93 @@ describe("safeWriteJson", () => { // 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("rejects a confined write whose target is outside the confined directory", async () => { + const scope = path.join(tempDir, "project") + await fs.mkdir(scope) + const outside = path.join(tempDir, "elsewhere.json") + + // No symlink needed: the check runs on the resolved publish target, so an + // out-of-scope path is rejected on every platform, and it is rejected before the + // lock is taken and before anything is staged. + await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: scope })).rejects.toThrow( + ConfinedPathEscapeError, + ) + + const left = await fs.readdir(tempDir) + expect(left).not.toContain("elsewhere.json") + expect(left.filter((entry) => entry.includes(".new_") || entry.endsWith(".lock"))).toEqual([]) + }) + + test.skipIf(process.platform === "win32")( + "rejects a confined write whose symlink resolves outside the confined directory", + async () => { + const projectDir = path.join(tempDir, "project") + await fs.mkdir(projectDir) + const outside = path.join(tempDir, "outside.json") + await fsSyncActual.promises.writeFile(outside, JSON.stringify({ secret: "original" }), "utf8") + // A repository that plants its project settings file as a link to somewhere else + // must not receive the settings write at the linked path. The caller picked + // projectDir/mcp.json from the workspace, so it declares that scope. + const projectConfig = path.join(projectDir, "mcp.json") + await fs.symlink(outside, projectConfig) + + await expect( + safeWriteJson(projectConfig, { mcpServers: {} }, { confineTo: projectDir }), + ).rejects.toThrow(ConfinedPathEscapeError) + + // The linked file is untouched and nothing was staged beside it. + expect(JSON.parse(await fsSyncActual.promises.readFile(outside, "utf8"))).toEqual({ secret: "original" }) + const entries = await fs.readdir(tempDir) + expect(entries).toContain("outside.json") + expect( + entries.filter((entry) => entry.includes(".new_") || entry.includes("safeWriteText") || entry.endsWith(".lock")), + ).toEqual([]) + }, + ) + + test.skipIf(process.platform === "win32")( + "confines a write whose symlink referent stays inside the confined directory", + async () => { + const projectDir = path.join(tempDir, "project-in") + await fs.mkdir(projectDir) + const referent = path.join(projectDir, "real-mcp.json") + await fsSyncActual.promises.writeFile(referent, JSON.stringify({ mcpServers: {} }), "utf8") + const alias = path.join(projectDir, "mcp.json") + await fs.symlink(referent, alias) + + // Confining is about the scope, not about forbidding links: a link that stays + // inside the project still publishes to its referent. + await safeWriteJson(alias, { mcpServers: { local: { url: "http://localhost" } } }, { confineTo: projectDir }) + + expect(JSON.parse(await fsSyncActual.promises.readFile(referent, "utf8"))).toEqual({ + mcpServers: { local: { url: "http://localhost" } }, + }) + }, + ) + + test.skipIf(process.platform === "win32")( + "confines a scope path that itself runs through a symlink and does not exist yet", + async () => { + const real = path.join(tempDir, "real-project") + await fs.mkdir(real) + const alias = path.join(tempDir, "alias-project") + await fs.symlink(real, alias) + // The scope is declared through the alias, and the directory it names does not + // exist yet. Resolving it lexically would compare an unresolved scope against a + // fully resolved target and reject a write that is in fact inside the project - + // the macOS /var -> /private/var shape. The nearest existing ancestor is resolved + // and the remainder re-joined instead. + const nested = path.join(alias, "nested") + const target = path.join(nested, "mcp.json") + + await safeWriteJson(target, { mcpServers: {} }, { confineTo: nested }) + + expect( + JSON.parse(await fsSyncActual.promises.readFile(path.join(real, "nested", "mcp.json"), "utf8")), + ).toEqual({ mcpServers: {} }) + }, + ) + test.skipIf(process.platform === "win32")( "preserves a restrictive 0o600 target mode through the atomic publish", async () => { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index bae200dd38..ab6f7d145a 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -32,6 +32,78 @@ export interface SafeWriteJsonOptions { * cannot be parsed. */ merge?: (existing: unknown, incoming: unknown) => unknown + + /** + * Restrict the write to a directory. The publish target is resolved through + * symlinks before this check runs, so a caller that picked the path from a + * known scope (a workspace, a project settings directory) can refuse a write + * that a planted symlink would land somewhere else. The check runs before the + * advisory lock is taken and before anything is staged. + */ + confineTo?: string +} + +/** + * Thrown when a write declared with `confineTo` resolves outside that directory. + */ +export class ConfinedPathEscapeError extends Error { + constructor( + readonly requestedPath: string, + readonly resolvedPath: string, + readonly confineTo: string, + ) { + super( + `Refusing to write ${resolvedPath}: it resolves outside the confined directory ${confineTo} (requested ${requestedPath}).`, + ) + this.name = "ConfinedPathEscapeError" + } +} + +/** + * Canonicalize the directory a write is confined to. The publish target is fully + * resolved through symlinks, so the scope has to be resolved the same way or a + * scope path that itself runs through a symlink (macOS /var -> /private/var is the + * common case) would compare lexically against a resolved target and reject every + * legitimate in-scope write. When the scope does not exist yet, the nearest + * existing ancestor is resolved and the remainder re-appended. + */ +async function _resolveScopeRoot(confineTo: string): Promise { + const lexical = path.resolve(confineTo) + try { + return await fs.realpath(lexical) + } catch (error: unknown) { + // Only a missing path means "walk up and re-join". EACCES or ELOOP means the + // scope cannot be canonicalized at all, and continuing would build a partly + // lexical root that can disagree with the canonical target - the failure has to + // surface rather than decide the scope from a guess. + if (_scopeErrorCode(error) !== "ENOENT") { + throw error + } + const missing: string[] = [] + let ancestor = lexical + while (true) { + const parent = path.dirname(ancestor) + if (parent === ancestor) { + return lexical + } + missing.push(path.basename(ancestor)) + ancestor = parent + try { + const real = await fs.realpath(ancestor) + return path.join(real, ...missing.reverse()) + } catch (innerError: unknown) { + if (_scopeErrorCode(innerError) !== "ENOENT") { + throw innerError + } + } + } + } +} + +function _scopeErrorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined } /** @@ -89,6 +161,25 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // advisory lock held until the stale timeout for every other writer. resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // Confinement, if the caller declared a scope. Both sides are canonicalized the + // same way: the publish target is resolved through symlinks, and a target that + // does not exist yet still carries the alias components of the path it was + // given. This runs before the merge read and before anything is staged, so a + // rejected write leaves nothing behind. + if (options?.confineTo) { + const scopeRoot = await _resolveScopeRoot(options.confineTo) + const resolvedTarget = await _resolveScopeRoot(resolvedTargetPath) + const relative = path.relative(scopeRoot, resolvedTarget) + if ( + relative === "" || + relative === ".." || + relative.startsWith(".." + path.sep) || + path.isAbsolute(relative) + ) { + throw new ConfinedPathEscapeError(absoluteFilePath, resolvedTargetPath, scopeRoot) + } + } + // If a merge callback was provided, read the current file under the lock // and let the caller merge before we write. Must be inside try/finally // so a throwing merge still releases the lock. From ba10fd47147804b9e6ec7b80cef16f69396dfb81 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:49:29 +0000 Subject: [PATCH 024/115] chore: trigger a fresh review pass at this head From 1b2f187595b02d80be5a4c47def43764e2389cc2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:38:01 +0800 Subject: [PATCH 025/115] fix(integrations): preview observation must not authorize a save; async warning sinks handled Series alignment with fws/u6-apply-patch-wiring (plans in easonLiangWorldedtech/Zoo-Code#41): 1. ApplyDiffTool builds its content from a read, then DiffViewProvider.open() records an observation for the path. If the file changed between the tool's read and that preview, the preview token is the only entry in the registry, and because the save is issued with kind "edit" the partial-observation rule accepts it: the tool's stale full-file content is published over the intervening change. open() now snapshots the observation that existed BEFORE it touched the registry and an edit-kind save restores it, so the compare-and-swap runs against the version the caller's content was built on; with no pre-open observation the preview's entry is withdrawn (ObservationRegistry#forget) and the unobserved-edit guard rejects the save with the re-read remediation. 2. safeWriteText's warning wrapper previously used the caller's callback directly, so a throwing sink aborted a committed write and an async sink leaked an unhandled rejection (Node's default mode can end the process after a successful write). It now catches synchronous throws and attaches a catch handler to a returned promise without awaiting it. Tests ported from u6 (preview rejected with no authorization left behind; legitimate pre-preview read still publishes; rejecting async sink reported through the fallback). The safeWriteJson confinement-before-mkdir ordering is not ported here: this unit has no pre-lock confinement check, and it inherits u6's version at rebase (U8 -> U6 -> U7). --- src/core/task/observationRegistry.ts | 10 +++ src/integrations/editor/DiffViewProvider.ts | 33 +++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 67 +++++++++++++++++++ .../__tests__/safeWriteText.spec.ts | 32 +++++++++ src/services/file-safety/safeWriteText.ts | 26 ++++++- 5 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts index 0ef9115f21..78a1f3d4cc 100644 --- a/src/core/task/observationRegistry.ts +++ b/src/core/task/observationRegistry.ts @@ -49,6 +49,16 @@ export class ObservationRegistry { return this.entries.has(absolutePath) } + /** + * Drop the observation for one path. A caller that must revoke an + * authorization it did not earn - a preview that observed a version the model + * never read - needs this instead of clear(), which would also discard the + * observations other reads of the same task still rely on. + */ + forget(absolutePath: string): boolean { + return this.entries.delete(absolutePath) + } + clear(): void { this.entries.clear() } diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index caa3ee1d26..bb8c4d0e60 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -95,6 +95,15 @@ export class DiffViewProvider { * never unlinked. */ private placeholderVersion: string | undefined = undefined + /** + * The observation this path had BEFORE open() recorded the preview's own + * version token: null when there was none, undefined when this provider never + * opened a preview. open() observes the current on-disk version, so a file that + * changed between the tool's read and this preview leaves the preview token as + * the only entry - and a targeted save would then CAS against a version the + * caller never read, publishing stale content over the intervening change. + */ + private preOpenObservation: { version: string; complete: boolean } | null | undefined = undefined constructor( private cwd: string, @@ -109,6 +118,13 @@ export class DiffViewProvider { const absolutePath = path.resolve(this.cwd, relPath) this.isEditing = true + // Snapshot the authorization as it stands before this preview touches the + // registry; saveChanges(..., "edit") restores it below. + const priorObservation = this.taskRef.deref()?.observationRegistry.get(absolutePath) + this.preOpenObservation = priorObservation + ? { version: priorObservation.version, complete: priorObservation.complete } + : null + // Capture the current scroll position before we close the tab so we can // restore it after saving/reverting. const existingEditor = vscode.window.visibleTextEditors.find( @@ -513,6 +529,22 @@ export class DiffViewProvider { encodedContent = await vscode.workspace.encode(editedContent, { encoding: updatedDocument.encoding, }) + // The preview must not authorize the save it made unverifiable. Restore + // the observation that existed before open() so the compare-and-swap runs + // against the version the caller's content was built on; when there was + // none, drop the preview's entry so the unobserved-edit guard rejects the + // write and the caller gets the re-read remediation instead. + if (writeKind === "edit" && this.preOpenObservation !== undefined) { + if (this.preOpenObservation) { + saveTask.observationRegistry.observe( + absolutePath, + this.preOpenObservation.version, + this.preOpenObservation.complete, + ) + } else { + saveTask.observationRegistry.forget(absolutePath) + } + } await guardedWrite(saveTask, this.relPath, encodedContent, writeKind) } catch (error) { // Autosave can publish the modified side of the diff before the user @@ -1485,6 +1517,7 @@ export class DiffViewProvider { this.userTouchedDiffEditor = false this.snapshotPreviewTabs = [] this.placeholderVersion = undefined + this.preOpenObservation = undefined } /** diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 3705aa4e4b..f171834214 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1415,6 +1415,73 @@ describe("DiffViewProvider", () => { expect(result.newProblemsMessage).toBe("") }) + const openPreview = async () => { + // Enough of the editor double for open() to find the diff editor again. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.txt`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 1, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(editor as unknown as vscode.TextEditor) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.window).visibleTextEditors = [editor as unknown as vscode.TextEditor] + // openDiffEditor resolves from the document-open event, so fire it. + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => { + callback({ uri: { fsPath: `${mockCwd}/test.txt`, scheme: "file" } } as unknown as vscode.TextDocument) + }, 0) + return { dispose: vi.fn() } + }) + // fs/promises is mocked; open() only reads these BigIntStats fields when it + // stat-matches the preview read. + vi.mocked(fs.stat).mockResolvedValue({ + dev: 1n, + ino: 2n, + size: 5n, + mtimeNs: 100n, + ctimeNs: 100n, + } as unknown as BigIntStats) + ;(diffViewProvider as unknown as { editType: string }).editType = "modify" + await diffViewProvider.open("test.txt") + ;(diffViewProvider as unknown as { newContent?: string }).newContent = "new content" + } + + it("does not let the preview's own observation authorize a targeted edit", async () => { + // The tool read never observed this path, so the only entry the save could + // point at is the one open() records for the preview. Authorizing from that + // entry publishes the tool's content over anything that changed between the + // read and the preview, so the save must fall back to the unobserved-edit + // guard and be rejected with the re-read remediation. + mockTask.observationRegistry.clear() + await openPreview() + // The preview did record an observation - that is the entry under test. + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(true) + + await expect(diffViewProvider.saveChanges(false, 0, "edit")).rejects.toThrow( + /File not read yet/, + ) + expect(safeWriteText).not.toHaveBeenCalled() + // The preview's authorization was withdrawn rather than left behind. + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(false) + }) + + it("still publishes a targeted edit authorized by a pre-preview observation", async () => { + // The same preview must not break the legitimate case: the model read the + // file (partially) and nothing changed before the preview, so the restored + // pre-open observation authorizes the targeted edit. + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "v1", false) + await openPreview() + + await diffViewProvider.saveChanges(false, 0, "edit") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.txt`, Buffer.from("new content")) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.txt`, Buffer.from("new content")) + }) + it("publishes the accepted content in the document's own encoding", async () => { // A utf8bom document: getText() returns the text without the BOM, so the // publish must go through VS Code's codec for the document's own diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a20df2b9f9..00c08290ff 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -281,6 +281,38 @@ describe("safeWriteText", () => { }) }) + it("win32: a rejecting async onWarning does not abort the write or leak an unhandled rejection", async () => { + // TypeScript accepts an async sink where a void callback is expected, so the + // wrapper has to attach a handler to the returned promise: an unhandled + // rejection can end the process under Node's default mode, after a write that + // already succeeded. + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await expect( + safeWriteText(targetPath, "data", { + platform: "win32", + onWarning: async () => { + throw new Error("async sink down") + }, + }), + ).resolves.toBeUndefined() + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + // The rejection is reported through the fallback sink rather than surfacing as an + // unhandled rejection. + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining("onWarning callback rejected"), + ) + consoleWarn.mockRestore() + }) + // ── Test 2: fsync ordering ─────────────────────────────────────────────── describe("fsync ordering", () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index fbdd2feaef..cc28b51483 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -376,7 +376,31 @@ export async function safeWriteText( // -- Step 2 (win32): save DACL BEFORE the backup copy ----------- const platform = options?.platform ?? process.platform - const warn = options?.onWarning ?? ((message: string) => console.warn(message)) + // Warning delivery must never abort the write: the notices below describe a + // committed-but-imperfect publish, and a caller whose callback throws (a UI sink, + // a logger that is mid-restart) must not turn that into a failed save. + const warn = (message: string) => { + const report = (label: string, error: unknown) => { + console.warn( + `safeWriteText: onWarning callback ${label}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + const sink = options?.onWarning ?? ((m: string) => console.warn(m)) + const result: unknown = sink(message) + // A sink may be async - TypeScript accepts a value-returning callback where + // a void one is expected. Awaiting it would let warning delivery delay a + // write that has already committed (and hang it if the sink never settles), + // while leaving the promise unhandled turns a rejection into an unhandled + // rejection, which under Node's default mode can end the process after a + // successful write. Attach a handler without awaiting. + if (result instanceof Promise) { + result.catch((error: unknown) => report("rejected", error)) + } + } catch (error: unknown) { + report("failed", error) + } + } if (platform === "win32") { let accessError: unknown = null try { From f4f917f4c55582448a81fd9cae3b227c37435ac7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:45:41 +0800 Subject: [PATCH 026/115] fix(tools): ApplyDiffTool must observe the version its diff was built on Series alignment with fws/u6-apply-patch-wiring. The e2e apply_diff suite drives apply_diff without a prior read_file, so the only observation available at save time was the one DiffViewProvider.open() records for the preview. Now that the preview cannot authorize the save, the guarded save falls back to the unobserved-edit guard and the flow has no authorization of its own. ApplyDiffTool reads the file itself to compute the diff, so it observes that read (stat around the read, observe only when the file did not change underneath it - the same contract as ApplyPatchTool's hunk read). The save is authorized against the version the diff was computed against: unchanged file -> the save proceeds; file changed after the read -> the compare-and-swap rejects and the stale content is not published. --- src/core/tools/ApplyDiffTool.ts | 21 +++++++++ .../applyDiffTool.guardedWrite.spec.ts | 47 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index fa873b8e5d..49c416a687 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -5,6 +5,7 @@ import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { getReadablePath } from "../../utils/path" +import { versionTokenOfStat } from "../../utils/versionToken" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { fileExistsAtPath } from "../../utils/fs" @@ -68,7 +69,27 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } + // The diff below is built from this exact read, so the save that follows must be + // authorized against the version captured here - not against whatever version the + // preview happens to stat afterwards. Same contract as ApplyPatchTool's hunk read: + // stat around the read and observe only when the file did not change underneath it. + const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) const originalContent: string = await fs.readFile(absolutePath, "utf-8") + const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + // A tool read is not a model read: when the model already observed the file, + // keep the completeness it earned and only on the version it was earned on; + // with no prior observation this stays a partial observation of the version + // the diff was computed against. + const prior = task.observationRegistry.get(absolutePath) + const complete = + prior === undefined ? false : prior.complete === true && prior.version === preReadToken + task.observationRegistry.observe(absolutePath, preReadToken, complete) + } + } + // Apply the diff to the original content const diffResult = (await task.diffStrategy?.applyDiff( diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 62ff44f75a..dbc6fe2d21 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -1,14 +1,26 @@ // npx vitest run core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +import path from "path" + import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import type { Task } from "../../task/Task" import { ApplyDiffTool } from "../ApplyDiffTool" +import { ObservationRegistry } from "../../task/observationRegistry" vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue("original file content\n"), + // The tool stats around its read to authorize the save against the version it + // actually read; one shared stats object means the file did not change. + stat: vi.fn().mockResolvedValue({ + dev: 1n, + ino: 2n, + size: 22n, + mtimeNs: 100n, + ctimeNs: 100n, + }), }, })) @@ -117,6 +129,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { }), }), } as unknown as Task["providerRef"], + observationRegistry: new ObservationRegistry(), fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined), } as unknown as Task["fileContextTracker"], @@ -192,4 +205,38 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockHandleError).not.toHaveBeenCalled() }) + + it("authorizes the save against the version its own read was built on", async () => { + // The model never read this file, so the only authorization available at save + // time is the one the tool earns from its own hunk read. Without it the guarded + // save falls back to the preview's version token, which can describe a version + // the diff was never computed against. + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) as unknown as void + + const observation = mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts")) + expect(observation?.version).toBe( + "1:2:22:100:100", + ) + // A tool read is not a model read, so completeness stays unearned. + expect(observation?.complete).toBe(false) + }) + + it("does not authorize a read that changed underneath it", async () => { + const stat = vi.mocked((await import("fs/promises")).default.stat) + stat + .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n }) + .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 30n, mtimeNs: 200n, ctimeNs: 100n }) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) as unknown as void + + expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + }) }) From fd1c719211493897f424cd27a61085737cf95a02 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:52:59 +0800 Subject: [PATCH 027/115] test(tools): type the apply_diff observation doubles for check-types The CI compile job rejected the new applyDiffTool.guardedWrite.spec.ts code: the task stub's Pick<> did not list observationRegistry, and the BigIntStats doubles passed to stat's mockResolvedValueOnce are partial (only the fields versionTokenOfStat reads - a full BigIntStats cannot be built against the mocked fs, so the double assertion is the narrowest option). --- .../tools/__tests__/applyDiffTool.guardedWrite.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index dbc6fe2d21..2a9791e4e6 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -1,6 +1,7 @@ // npx vitest run core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts import path from "path" +import type { BigIntStats } from "fs" import type { MockedFunction } from "vitest" @@ -58,6 +59,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { | "didEditFile" | "api" | "diffStrategy" + | "observationRegistry" | "diffViewProvider" | "providerRef" | "fileContextTracker" @@ -226,10 +228,12 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { }) it("does not authorize a read that changed underneath it", async () => { + // Only the fields versionTokenOfStat reads; a full BigIntStats cannot be built + // against the mocked fs, so the double assertion is the narrowest option. const stat = vi.mocked((await import("fs/promises")).default.stat) stat - .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n }) - .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 30n, mtimeNs: 200n, ctimeNs: 100n }) + .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats) + .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 30n, mtimeNs: 200n, ctimeNs: 100n } as unknown as BigIntStats) await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { askApproval: mockAskApproval, From d6446b54a518256ec10d1e473f80d5702f7c8ecb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:10:00 +0800 Subject: [PATCH 028/115] fix(integrations): never adopt an autosaved match for an unauthorized edit Series alignment with fws/u6-apply-patch-wiring. The autosave adoption branch accepted ANY GuardRejectedError when the buffer was clean. An "edit" save with no pre-open observation is rejected by the unobserved-edit guard - an authorization verdict, not a moved-token verdict - so if VS Code autosave had already written the buffer, adoption reported success AND recorded a partial observation for a file the model never read, which would then authorize a later targeted publish. Adoption is now limited to saves that were authorized before open(). Also removes the dead committed flag in safeWriteText (declared and set, never read) and corrects the comment describing a backup-restore guard that does not exist: the backup is a copy and is never restored. --- src/integrations/editor/DiffViewProvider.ts | 5 +++ .../editor/__tests__/DiffViewProvider.spec.ts | 33 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 10 ++---- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb8c4d0e60..b29c7ecedf 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -561,6 +561,11 @@ export class DiffViewProvider { error instanceof GuardRejectedError && saveTask && encodedContent && + // An edit with no pre-open observation was rejected for AUTHORIZATION, not for a + // moved token. Adopting the match would report success and record a partial + // observation for a file the model never read, which would then authorize a + // later edit publish - exactly what the unobserved-edit guard exists to prevent. + !(writeKind === "edit" && this.preOpenObservation === null) && // Only the autosave shape: a clean buffer means its content is what autosave // already put on disk. A dirty buffer means the disk content came from // someone else, so the discard cleanup below is still the right outcome. diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index f171834214..dd5a45dee9 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2328,6 +2328,39 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) + it("does not adopt an autosaved match for an edit that was never authorized", async () => { + // Same autosave shape, different verdict: with no observation from before open() + // the guard rejects for AUTHORIZATION. Adopting the byte match would report a + // modified result and record a partial observation for a file the model never + // read, which would then authorize a later targeted publish. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + diffViewProvider["preOpenObservation"] = null + mockTask.observationRegistry.clear() + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false, 0, "edit")).rejects.toThrow(/File not read yet/) + + // No adoption read, and no observation granted. + expect(fs.readFile).not.toHaveBeenCalled() + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)).toBeUndefined() + }) + it("still rejects when the disk content does not match what the save intended", async () => { // Same autosave shape, different bytes: the guard verdict stands. const cleanEditor = { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index cc28b51483..dc86e9fad9 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -300,10 +300,6 @@ export async function safeWriteText( let backupPath: string | null = null let releaseBackupOnSuccess = false - // Set once the commit rename has published the new content. After that point the - // backup is no longer a safe restore source: rolling it back would overwrite - // content the caller can already observe at the target path. - let committed = false // Non-null only when the win32 step-2 block saved a successful DACL dump: // it gates the step-5 restore and is tracked for the cleanup unlinks. let daclDumpPath: string | null = null @@ -482,7 +478,6 @@ export async function safeWriteText( // -- Step 4: atomic rename temp -> target --------------------- await fs.rename(tempPath, targetPath) - committed = true // -- Step 4b (POSIX): fsync the parent directory so the directory entry // changed by the commit rename is durable, not just the file content. @@ -548,9 +543,8 @@ export async function safeWriteText( await fs.rmdir(stagingDir).catch(() => {}) } } catch (originalError: unknown) { - // Only a pre-commit failure can restore the backup. Once the commit rename - // published, a later failure (for example the post-commit directory fsync) - // must not overwrite the published content with the old file. + // The backup is never restored: it is a copy, and the target already holds + // either the pre-write content (before the commit) or the published content. if (backupPath && releaseBackupOnSuccess) { // Nothing to restore: the backup is a copy, so the target still holds whatever // the commit left there - before the commit that is the pre-write content, and From d2d9e9d28eaf10a9e4718c3c95204795d9349cf3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:38:45 +0800 Subject: [PATCH 029/115] fix(tools): never refresh a model observation the tool read on a different version Series alignment with fws/u6-apply-patch-wiring and fws/u9-task-history-delete. ApplyDiffTool rewrites an observation only when none exists (its own hunk read is the only authorization apply_diff can have, since it computes its hunks from that read) or when the prior entry is on the same version, preserving the completeness the model earned. A prior entry on an older version is left alone, so content built from a stale read cannot pass the save's compare-and-swap. Also repairs the interleaved confinement comment in safeWriteJson (and, where present, the confineTo doc) so it describes both checks: the declared path before the lock and before any parent-directory creation, and the resolved publish target again under the lock. --- src/core/tools/ApplyDiffTool.ts | 20 +++++--- .../applyDiffTool.guardedWrite.spec.ts | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 49c416a687..98e642a6c8 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -79,14 +79,20 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { - // A tool read is not a model read: when the model already observed the file, - // keep the completeness it earned and only on the version it was earned on; - // with no prior observation this stays a partial observation of the version - // the diff was computed against. + // A tool read is not a model read. With no prior observation this stays a + // partial observation of the version the diff was computed against - the only + // authorization the save can have, since apply_diff computes its hunks from + // this read. When the model already observed the file, keep the completeness it + // earned, but only on the version it was earned on: refreshing an OLDER + // observation to the current version would let content the model built from a + // stale read pass the compare-and-swap, so an out-of-date observation is left + // alone and the save fails with the re-read remediation. const prior = task.observationRegistry.get(absolutePath) - const complete = - prior === undefined ? false : prior.complete === true && prior.version === preReadToken - task.observationRegistry.observe(absolutePath, preReadToken, complete) + if (prior === undefined) { + task.observationRegistry.observe(absolutePath, preReadToken, false) + } else if (prior.version === preReadToken) { + task.observationRegistry.observe(absolutePath, preReadToken, prior.complete === true) + } } } diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 2a9791e4e6..49a76cde84 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -70,6 +70,16 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + afterEach(async () => { + // clearAllMocks drops call records but not queued once-values, so stats queued by + // one test would otherwise be handed to the next test's reads. Restore the default. + const stat = vi.mocked((await import("fs/promises")).default.stat) + stat.mockReset() + stat.mockResolvedValue( + { dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats, + ) + }) + beforeEach(() => { vi.clearAllMocks() @@ -242,5 +252,42 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { }) as unknown as void expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + // Exactly the two bracketing stats: no queued value left over for the next test. + expect(stat).toHaveBeenCalledTimes(2) + }) + + it("keeps a complete model observation complete when the tool read matches its version", async () => { + // The model read the file in full first. The tool's own read of the same version + // must not downgrade that earned completeness to a partial observation. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.observationRegistry.observe(key, "1:2:22:100:100", true) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) as unknown as void + + const observation = mockTask.observationRegistry.get(key) + expect(observation?.version).toBe("1:2:22:100:100") + expect(observation?.complete).toBe(true) + }) + + it("does not refresh an observation the model earned on an older version", async () => { + // Refreshing a stale observation to the current version would authorize content the + // model built from the older read. The observation is left as the model earned it, + // so the save's compare-and-swap fails and the model is told to re-read. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.observationRegistry.observe(key, "1:2:9:9:9", true) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) as unknown as void + + const observation = mockTask.observationRegistry.get(key) + expect(observation?.version).toBe("1:2:9:9:9") + expect(observation?.complete).toBe(true) }) }) From 160ddb5db6af03cafb52d9284eb842308841e4e3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 17:21:24 +0800 Subject: [PATCH 030/115] fix(safety): confine before the lock, keep the post-commit backup Five review findings plus one accidental upstream revert: 1. safeWriteJson: the confineTo check ran only under the lock, while its JSDoc promised it ran before. The lock file lives NEXT TO the lock key (the symlink referent), so a planted link pointing outside the declared scope first made this write create .lock outside that scope, and an unwritable directory there surfaced a lock-acquisition error instead of ConfinedPathEscapeError. The scope is now checked against the lock key before acquireFileLock, and the under-lock check stays because it covers the target re-resolved after a peer commits. The escape predicate is one helper (_escapesScope) shared by both sites. 2. safeWriteText: the failure cleanup deleted the backup whenever backupPath and releaseBackupOnSuccess held, with nothing distinguishing a pre-commit failure from a post-commit one. A Step 4b PostCommitDurabilityError therefore destroyed the only copy known to hold the previous content, contradicting the contract stated on the Step 4b thread. A committed flag now gates the deletion; the existing post-commit test asserted the old behaviour and is updated to assert the recovery copy survives. 3. safeWriteText: the DACL-restore failure after the commit went to console.warn, so a caller that passed onWarning never learned that access rights changed. It now goes through the same warn sink as the Step 2 DACL notices. 4. ApplyPatchTool: two comments claimed the hunk read was a complete observation when no prior observation existed, while the code records false - the intended invariant. The comments now say what the code does, so nobody 'fixes' line 113 back to true. 5. applyDiffTool.guardedWrite.spec: four unjustified 'as unknown as void' assertions removed. 6. Task: the unit re-added Task.isPaused and the '|| this.isPaused' continuation condition that upstream deleted as dead code in 9af61f87e (RSK-12, #1894); the branch contains that commit, so this was a silent revert. Nothing sets the field and the loop has no pause check for the pushed empty item to reach. Tests: safeWriteJson now asserts the confinement verdict precedes any lock attempt by wrapping proper-lockfile's lock in a capturing mock and re-importing the whole graph (without vi.resetModules the already-loaded fileLock keeps the real lockfile and the capture sees nothing). Verified as a pin: with safeWriteJson.ts stashed the assertion fails on a non-empty lock call list. The backup test is likewise a pin: with safeWriteText.ts stashed it fails on an unexpected backup unlink. Local: utils + services/file-safety + core/tools + core/task + integrations lanes 2759 passed / 22 skipped across 143 files; eslint clean on all seven files; tsc unchanged from the 50-error local baseline. --- src/core/task/Task.ts | 5 +-- src/core/tools/ApplyPatchTool.ts | 11 ++--- .../applyDiffTool.guardedWrite.spec.ts | 8 ++-- .../__tests__/safeWriteText.spec.ts | 15 +++++-- src/services/file-safety/safeWriteText.ts | 19 +++++--- src/utils/__tests__/safeWriteJson.test.ts | 43 +++++++++++++++++++ src/utils/safeWriteJson.ts | 37 ++++++++++++---- 7 files changed, 109 insertions(+), 29 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 31d8b3d285..9ccc2d7968 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -403,7 +403,6 @@ export class Task extends EventEmitter implements TaskLike { abandoned = false abortReason?: ClineApiReqCancelReason isInitialized = false - isPaused: boolean = false runState: RunState = initialRunState readonly runRejections: RunRejection[] = [] @@ -4961,9 +4960,7 @@ export class Task extends EventEmitter implements TaskLike { this.consecutiveNoToolUseCount = 0 } - // Push to stack if there's content OR if we're paused waiting for a subtask. - // When paused, we push an empty item so the loop continues to the pause check. - if (this.userMessageContent.length > 0 || this.isPaused) { + if (this.userMessageContent.length > 0) { stack.push({ userContent: [...this.userMessageContent], // Create a copy to avoid mutation issues includeFileDetails: false, // Subsequent iterations don't need file details diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index b4fa11f6ff..14aa6d8bbe 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -104,12 +104,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (preReadToken === versionTokenOfStat(postReadStats)) { // The tool's own hunk read, not a model read. When the model already observed the // file, keep the completeness it earned and only on the version it was earned on; a - // partial view stays partial. With no prior observation this read returned the whole - // content, so the observation is complete. + // partial view stays partial. With no prior observation the model has seen only the + // patch's hunks, so this read stays incomplete: it must not become authority for a + // later full-file replacement. const prior = task.observationRegistry.get(absolutePath) - // Nothing to carry when the model never observed the file: this read returned the - // whole content, so it is a complete observation. Carry only when a prior observation - // exists and still describes the version that was read. + // With no prior observation there is nothing to carry, and the read stays + // incomplete. Carry completeness only when a prior observation exists and still + // describes the version that was read. const complete = prior === undefined ? false : prior.complete === true && prior.version === preReadToken task.observationRegistry.observe(absolutePath, preReadToken, complete) } diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 49a76cde84..bf7bc4b520 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -227,7 +227,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - }) as unknown as void + }) const observation = mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts")) expect(observation?.version).toBe( @@ -249,7 +249,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - }) as unknown as void + }) expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() // Exactly the two bracketing stats: no queued value left over for the next test. @@ -266,7 +266,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - }) as unknown as void + }) const observation = mockTask.observationRegistry.get(key) expect(observation?.version).toBe("1:2:22:100:100") @@ -284,7 +284,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - }) as unknown as void + }) const observation = mockTask.observationRegistry.get(key) expect(observation?.version).toBe("1:2:9:9:9") diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 00c08290ff..00fc9bbafc 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -410,9 +410,18 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledTimes(1) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) - // The durability failure is reported, not swallowed - and the backup copy is not - // left beside the target where no caller could find it. - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + // The durability failure is reported, not swallowed. The backup is NOT deleted: + // after the commit the target holds the NEW content, and this copy is the only + // one known to hold the previous content - the recovery copy the contract + // promises for exactly this path. + const backupUnlinks = vi + .mocked(fs.unlink) + .mock.calls.filter(function (call: unknown[]) { + return String(call[0]).includes("safeWriteText.bak_") + }) + expect(backupUnlinks).toEqual([]) + // The staged temp is still cleaned up. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index dc86e9fad9..ff9ad849b4 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -300,6 +300,10 @@ export async function safeWriteText( let backupPath: string | null = null let releaseBackupOnSuccess = false + // Whether the commit rename ran. A failure after it is a durability problem + // with the published file, not a pre-commit failure, and the backup is then + // the only known-good copy of the previous content. + let committed = false // Non-null only when the win32 step-2 block saved a successful DACL dump: // it gates the step-5 restore and is tracked for the cleanup unlinks. let daclDumpPath: string | null = null @@ -478,6 +482,7 @@ export async function safeWriteText( // -- Step 4: atomic rename temp -> target --------------------- await fs.rename(tempPath, targetPath) + committed = true // -- Step 4b (POSIX): fsync the parent directory so the directory entry // changed by the commit rename is durable, not just the file content. @@ -514,7 +519,7 @@ export async function safeWriteText( // temp directory restore fails with "Not all privileges or groups referenced // are assigned to the caller"), so the change of access rights is reported // rather than thrown. - console.warn(`safeWriteText: content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file may carry different access rights than the one it replaced.`) + warn(`Content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file may carry different access rights than the one it replaced.`) } } @@ -545,11 +550,15 @@ export async function safeWriteText( } catch (originalError: unknown) { // The backup is never restored: it is a copy, and the target already holds // either the pre-write content (before the commit) or the published content. - if (backupPath && releaseBackupOnSuccess) { + // Only a pre-commit failure may discard the backup. After the commit the target + // holds the NEW content, and when the failure is a post-commit durability error the + // backup is the only copy known to hold the previous content - deleting it here + // would destroy the recovery copy the contract promises. + if (backupPath && releaseBackupOnSuccess && !committed) { // Nothing to restore: the backup is a copy, so the target still holds whatever - // the commit left there - before the commit that is the pre-write content, and - // after it the published content. Either way the copy has served its purpose - // and must not be left beside the target where no caller can find it. + // the commit left there. Before the commit that is the pre-write content, and + // the copy beside it is redundant; it must not be left where no caller can + // find it. await fs.unlink(backupPath).catch(() => {}) backupPath = null } diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 9393774a25..eff1bf840e 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -682,6 +682,49 @@ describe("safeWriteJson", () => { expect(left.filter((entry) => entry.includes(".new_") || entry.endsWith(".lock"))).toEqual([]) }) + test("rejects an out-of-scope target before the advisory lock is taken", async () => { + const projectDir = path.join(tempDir, "scope") + await fs.mkdir(projectDir) + const outside = path.join(tempDir, "elsewhere.json") + + // Capture every lock attempt without changing how locking works. The lock file + // lives NEXT TO the key, so taking the lock on an out-of-scope referent already + // writes outside the scope the caller declared - and an unwritable directory + // there would surface a lock error instead of the confinement verdict. + const realLockfile = await vi.importActual("proper-lockfile") + const lockCalls: string[] = [] + const lockMock = vi.fn(async (file: unknown, options?: unknown) => { + lockCalls.push(String(file)) + return realLockfile.lock(file as never, options as never) + }) + vi.doMock("proper-lockfile", () => ({ + ...realLockfile, + lock: lockMock as unknown as typeof realLockfile.lock, + })) + + // Re-import the whole graph so the SUT AND its fileLock dependency pick up the + // wrapped lock; without the reset, fileLock keeps the already-loaded real + // proper-lockfile and the capture never sees a call. The error class is taken from + // the same module instance the SUT came from. + vi.resetModules() + const { safeWriteJson: reimported, ConfinedPathEscapeError: ReimportedError } = await import( + "../safeWriteJson" + ) + + try { + await expect( + reimported(outside, { mcpServers: {} }, { confineTo: projectDir }), + ).rejects.toThrow(ReimportedError) + + // The confinement verdict came first: no lock was ever attempted. + expect(lockCalls).toEqual([]) + expect((await fs.readdir(tempDir)).filter(function (entry: string) { return entry.endsWith(".lock") })).toEqual([]) + } finally { + vi.doUnmock("proper-lockfile") + vi.resetModules() + } + }) + test.skipIf(process.platform === "win32")( "rejects a confined write whose symlink resolves outside the confined directory", async () => { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index ab6f7d145a..0bb092d847 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -67,6 +67,19 @@ export class ConfinedPathEscapeError extends Error { * legitimate in-scope write. When the scope does not exist yet, the nearest * existing ancestor is resolved and the remainder re-appended. */ +/** + * Whether `candidate` sits outside `scopeRoot`. The scope root itself counts as + * outside: a file write cannot land on the directory that declares the scope. + */ +function _escapesScope(scopeRoot: string, candidate: string): boolean { + const relative = path.relative(scopeRoot, candidate) + return ( + relative === "" || + relative === ".." || + relative.startsWith(".." + path.sep) || + path.isAbsolute(relative) + ) +} async function _resolveScopeRoot(confineTo: string): Promise { const lexical = path.resolve(confineTo) try { @@ -146,6 +159,21 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // back), so the walk tolerates a dangling link instead of rejecting it here. const lockKey = await resolveLockKey(absoluteFilePath) + // Confinement is checked here as well as under the lock. The lock file lives NEXT + // TO the key, so a planted symlink that points outside the declared scope would + // first make this write create ".lock" outside that scope - a side effect + // the caller declared it would not have - and if that directory is not writable the + // caller would get a lock-acquisition error instead of the ConfinedPathEscapeError + // it is entitled to. The key is the symlink referent, so this check sees the same + // destination the lock would be taken on. The under-lock check stays: it covers the + // target re-resolved after a peer commits. + if (options?.confineTo) { + const scopeRoot = await _resolveScopeRoot(options.confineTo) + if (_escapesScope(scopeRoot, await _resolveScopeRoot(lockKey))) { + throw new ConfinedPathEscapeError(absoluteFilePath, lockKey, scopeRoot) + } + } + // Acquire the lock before any file operations. If acquisition fails it throws // immediately, and releaseLock stays a no-op so the finally block does not try // to release an unacquired lock. @@ -168,14 +196,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // rejected write leaves nothing behind. if (options?.confineTo) { const scopeRoot = await _resolveScopeRoot(options.confineTo) - const resolvedTarget = await _resolveScopeRoot(resolvedTargetPath) - const relative = path.relative(scopeRoot, resolvedTarget) - if ( - relative === "" || - relative === ".." || - relative.startsWith(".." + path.sep) || - path.isAbsolute(relative) - ) { + if (_escapesScope(scopeRoot, await _resolveScopeRoot(resolvedTargetPath))) { throw new ConfinedPathEscapeError(absoluteFilePath, resolvedTargetPath, scopeRoot) } } From 0f8c6c0ff507bc07059458fd25a174e349523426 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 17:37:53 +0800 Subject: [PATCH 031/115] fix(safety): confine before mkdir, report the retained backup, stop pre-creating dirs Four review findings: 1. safeWriteJson: the confineTo check ran before the lock but still AFTER fs.mkdir(dirPath, { recursive: true }). A planted symlinked ancestor (projectDir/.roo -> /elsewhere) therefore created directories outside the declared scope before the ConfinedPathEscapeError, and an unwritable referent surfaced the mkdir error instead of the verdict. The scope is now checked against the requested path before any filesystem side effect; the pre-lock and under-lock checks stay because they cover the lock key and the re-resolved target. _resolveScopeRoot already resolves a missing path through its nearest existing ancestor, so this works for missing parents too. 2. PostCommitDurabilityError now carries the retained backup path (nullable). The catch block deliberately keeps the backup after a post-commit durability failure, but the name is a hidden random string, so the caller was told a recovery copy exists with no way to find it - and each failure left an unrecoverable orphan. Retention behavior is unchanged. 3. DiffViewProvider.saveDirectly no longer calls createDirectoriesForFile before the guard. safeWriteText creates missing parents at publish time, so a rejected guard now leaves nothing behind - including directories outside the workspace. This replaces the earlier 'create then roll back' shape: not creating them is both smaller and leaves no window where the directories exist. 4. Comments in safeWriteJson still described the removed rename-based backup and rollback (three places), and the _resolveScopeRoot JSDoc had ended up attached to _escapesScope. Corrected and re-ordered. Tests: confinement-before-mkdir (asserts the out-of-scope directory is never created), backup path reported on the post-commit error and null when no backup was taken, and no directory creation when the guard rejects. All three are pins: each fails when its production file is stashed. Local: utils + services/file-safety + core/tools + core/task + integrations lanes 2763 passed / 22 skipped across 143 files; eslint clean on all six touched files; tsc unchanged from the 50-error local baseline. --- src/integrations/editor/DiffViewProvider.ts | 4 +- .../editor/__tests__/DiffViewProvider.spec.ts | 17 +++++ .../__tests__/safeWriteText.spec.ts | 57 +++++++++++++++ src/services/file-safety/safeWriteText.ts | 19 ++++- src/utils/__tests__/safeWriteJson.test.ts | 20 ++++++ src/utils/safeWriteJson.ts | 69 ++++++++++++------- 6 files changed, 157 insertions(+), 29 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index b29c7ecedf..4beb580034 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1569,7 +1569,9 @@ export class DiffViewProvider { // unreachable and the write cannot be guarded. throw new Error("Cannot guard the write: the owning task is no longer available") } - await createDirectoriesForFile(absolutePath) + // No pre-guard mkdir: safeWriteText creates missing parent directories at publish + // time, so a rejected guard leaves NO directories behind - including outside the + // workspace, where a rejected write must not leave a trace. await guardedWrite(task, relPath, content, writeKind, completeOverride) // Open the document to ensure diagnostics are loaded diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index dd5a45dee9..9fbead2530 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -11,6 +11,7 @@ import { computeVersionToken, versionTokenOfStat } from "../../../utils/versionT import type { BigIntStats } from "fs" import { safeWriteText } from "../../../services/file-safety/safeWriteText" import { withFileLock } from "../../../utils/fileLock" +import { createDirectoriesForFile } from "../../../utils/fs" import { ObservationRegistry } from "../../../core/task/observationRegistry" import type { Task } from "../../../core/task/Task" @@ -1155,6 +1156,22 @@ describe("DiffViewProvider", () => { expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") }) + it("leaves no directories behind when the guard rejects the write", async () => { + // A rejected guard must not touch the filesystem at all. Creating the parent + // directories before the guard runs would leave empty directories behind - and + // for an outside-workspace target, outside the workspace. safeWriteText creates + // missing parents itself at publish time. + mockTask.observationRegistry.clear() + vi.mocked(createDirectoriesForFile).mockClear() + + await expect( + diffViewProvider.saveDirectly("nested/dir/test.ts", "new content", true, false, 0), + ).rejects.toThrow("File already exists at nested/dir/test.ts") + + expect(createDirectoriesForFile).not.toHaveBeenCalled() + expect(safeWriteText).not.toHaveBeenCalled() + }) + it("rejects an observed write whose version token is stale", async () => { // The file changed on disk after the read that recorded "v1". vi.mocked(computeVersionToken).mockResolvedValue("v2") diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 00fc9bbafc..b4257c33d9 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -423,6 +423,63 @@ describe("safeWriteText", () => { // The staged temp is still cleaned up. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) + + it("reports the retained backup path on a post-commit durability failure", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const dirPath = path.dirname(targetPath) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockImplementation((target) => { + if (String(target) === dirPath) { + throw new Error("EBADF") + } + return 1 + }) + + const error = await safeWriteText(targetPath, "new data", { + backup: true, + platform: "linux", + }).catch(function (caught: unknown) { + return caught + }) + + // The backup is kept on this path, and its name is a hidden random string. Without + // the field the caller is told a recovery copy exists and given no way to find it. + expect(error).toBeInstanceOf(PostCommitDurabilityError) + const retained = (error as PostCommitDurabilityError).backupPath + expect(typeof retained).toBe("string") + expect(String(retained)).toContain("safeWriteText.bak_") + // The reported path is exactly the copy that was made, so a caller can open it. + const copiedBackups = vi + .mocked(fs.copyFile) + .mock.calls.map(function (call: unknown[]) { + return String(call[1]) + }) + .filter(function (p: string) { + return p.includes("safeWriteText.bak_") + }) + expect(copiedBackups).toContain(retained) + }) + + it("reports a null backup path when no backup was taken", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const dirPath = path.dirname(targetPath) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockImplementation((target) => { + if (String(target) === dirPath) { + throw new Error("EBADF") + } + return 1 + }) + + const error = await safeWriteText(targetPath, "new data", { + platform: "linux", + }).catch(function (caught: unknown) { + return caught + }) + + expect(error).toBeInstanceOf(PostCommitDurabilityError) + expect((error as PostCommitDurabilityError).backupPath).toBeNull() + }) }) // ── Test 4: backup:true keeps old safeWriteJson semantics, copy-based ── diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index ff9ad849b4..170c11053d 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -68,14 +68,22 @@ export class StagingPathError extends Error { */ export class PostCommitDurabilityError extends Error { readonly targetPath: string + /** + * The old-content copy retained for recovery on this path, or null when backup + * mode was off or the target did not exist before the write. The name is a hidden + * random path, so without this field no caller could find the copy the catch block + * deliberately keeps. + */ + readonly backupPath: string | null - constructor(targetPath: string, cause: unknown) { + constructor(targetPath: string, cause: unknown, backupPath: string | null = null) { super( "The rename committed but the parent directory could not be fsynced -- the content is at the target path reported on this error, and the directory entry may not be durable.", { cause }, ) this.name = "PostCommitDurabilityError" this.targetPath = targetPath + this.backupPath = backupPath } } // -- helpers --------------------------------------------------------------- @@ -501,7 +509,14 @@ export async function safeWriteText( // so the failure is surfaced as its own error: the caller can // still find the content at the target, it just cannot rely on // the directory entry having reached the disk. - throw new PostCommitDurabilityError(targetPath, error) + // The catch block keeps the backup on this path, so hand the caller the + // path of the copy it is keeping - it is a hidden random name and is + // otherwise unrecoverable. + throw new PostCommitDurabilityError( + targetPath, + error, + releaseBackupOnSuccess ? backupPath : null, + ) } } diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index eff1bf840e..57b9ae672e 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -725,6 +725,26 @@ describe("safeWriteJson", () => { } }) + test("rejects an out-of-scope target before creating any directory", async () => { + const projectDir = path.join(tempDir, "scope") + await fs.mkdir(projectDir) + // The target lives in a directory that does not exist. A confinement check that runs + // after the mkdir -p would create that directory OUTSIDE the declared scope first - + // the same side effect the pre-lock check exists to prevent, reached through a + // symlinked ancestor in the reported case - and an unwritable referent would surface + // the mkdir error instead of the confinement verdict. + const missingParent = path.join(tempDir, "no-such-dir") + const outside = path.join(missingParent, "elsewhere.json") + + await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + ConfinedPathEscapeError, + ) + + // Nothing was created outside the scope, and no lock was attempted there. + expect(fsSyncActual.existsSync(missingParent)).toBe(false) + expect((await fs.readdir(tempDir)).filter(function (entry: string) { return entry.endsWith(".lock") })).toEqual([]) + }) + test.skipIf(process.platform === "win32")( "rejects a confined write whose symlink resolves outside the confined directory", async () => { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 0bb092d847..9488227f5e 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -67,19 +67,6 @@ export class ConfinedPathEscapeError extends Error { * legitimate in-scope write. When the scope does not exist yet, the nearest * existing ancestor is resolved and the remainder re-appended. */ -/** - * Whether `candidate` sits outside `scopeRoot`. The scope root itself counts as - * outside: a file write cannot land on the directory that declares the scope. - */ -function _escapesScope(scopeRoot: string, candidate: string): boolean { - const relative = path.relative(scopeRoot, candidate) - return ( - relative === "" || - relative === ".." || - relative.startsWith(".." + path.sep) || - path.isAbsolute(relative) - ) -} async function _resolveScopeRoot(confineTo: string): Promise { const lexical = path.resolve(confineTo) try { @@ -113,6 +100,20 @@ async function _resolveScopeRoot(confineTo: string): Promise { } } +/** + * Whether `candidate` sits outside `scopeRoot`. The scope root itself counts as + * outside: a file write cannot land on the directory that declares the scope. + */ +function _escapesScope(scopeRoot: string, candidate: string): boolean { + const relative = path.relative(scopeRoot, candidate) + return ( + relative === "" || + relative === ".." || + relative.startsWith(".." + path.sep) || + path.isAbsolute(relative) + ) +} + function _scopeErrorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code @@ -145,6 +146,20 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // the target when the resolution itself rejects. let resolvedTargetPath: string | undefined + // Confinement is checked before ANY filesystem side effect. mkdir -p through a + // planted symlinked ancestor (projectDir/.roo -> /elsewhere) would create + // directories outside the declared scope, and an unwritable referent would surface + // the mkdir error instead of the ConfinedPathEscapeError the caller is entitled to. + // _resolveScopeRoot resolves a not-yet-existing path through its nearest existing + // ancestor, so this works for the target's missing parents too. + if (options?.confineTo) { + const scopeRoot = await _resolveScopeRoot(options.confineTo) + const requested = await _resolveScopeRoot(absoluteFilePath) + if (_escapesScope(scopeRoot, requested)) { + throw new ConfinedPathEscapeError(absoluteFilePath, requested, scopeRoot) + } + } + try { await fs.mkdir(dirPath, { recursive: true }) await fs.access(dirPath) @@ -154,9 +169,12 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } // Lock key: the symlink referent when the path is an existing symlink, so a - // symlink alias and its referent share one lock. The key must be computable - // while a peer writer is mid-commit (backup mode renames the referent away and - // back), so the walk tolerates a dangling link instead of rejecting it here. + // symlink alias and its referent share one lock. The key must stay computable while + // a peer writer is mid-commit - the commit rename briefly leaves the path without + // the referent it had when the walk started - so the walk tolerates a dangling link + // instead of rejecting it here. (The backup itself is a copy: the target is never + // renamed away, so a dangling link here means a peer's commit or an external + // unlink, not a backup rename.) const lockKey = await resolveLockKey(absoluteFilePath) // Confinement is checked here as well as under the lock. The lock file lives NEXT @@ -229,12 +247,11 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) - // 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). + // Step 2: Delegate backup + commit to safeWriteText with the pre-written + // temp path. backup:true makes safeWriteText COPY the target to a backup + // before the commit rename; the target itself never moves, so there is no + // rollback to undo. On win32 the DACL is dumped before that backup copy and + // restored onto the directory after the commit rename. const textOptions: SafeWriteTextOptions = { tempPath: actualTempNewFilePath, backup: true, @@ -253,10 +270,10 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const newFileToCleanupWithinCatch = actualTempNewFilePath - // 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). + // A pre-commit failure leaves the target untouched, and safeWriteText has + // already removed its own backup copy and temp file (a POST-commit failure + // keeps the backup on purpose and reports its path on the error). Clean up + // the .new file if it still exists - safety net in case its cleanup missed it. if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) From 483ed65c21673efc0b5174ac627b8c628bf6365c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 17:54:03 +0800 Subject: [PATCH 032/115] fix(tools): honor an approval for a write outside the task root guardedWrite contained every write against task.cwd, a single root, while the write tools classify paths with isPathOutsideWorkspace over ALL VS Code workspace folders and ask the user for approval only for paths outside every one of them. Two writes the tool layer had already treated as legitimate were therefore rejected after the fact: a file in a second workspace folder (never flagged, so never approved - just refused), and a path outside every folder that the user had explicitly approved. guardedWrite now takes an options object: - additionalRoots: the other workspace folders, supplied by the extension-host caller so core stays host-agnostic. Containment is against any of them; a missing root is skipped, a root that cannot be resolved at all still fails closed. - approvedOutsideWorkspace: set only by a tool's post-approval path. It means 'an approval decision was obtained for this path', not 'this path is outside', so every unapproved call keeps both containment checks. For an approved call the under-lock re-check becomes an identity check: the path that resolves at publish time must still be the path that was approved, so a symlink swapped in while the write waited on the FIFO chain cannot move the publish. DiffViewProvider.saveDirectly and saveChanges forward both, taking the roots from vscode.workspace.workspaceFolders. SearchReplaceTool, EditTool, EditFileTool, WriteToFileTool and ApplyPatchTool pass the isOutsideWorkspace value they already computed after approval; the patch move passes the destination's own classification, since the user approved the patch that names it. ApplyDiffTool is left unchanged: it never classifies or asks about outside-workspace targets, so there is no approval to honor and its containment stays as it was. Tests: five in guardedWrite.spec (approved publish, unapproved still rejected, identity re-check after a swapped link, second-folder root accepted, unnamed second folder still rejected) and two in DiffViewProvider.spec for the forwarding. Existing saveDirectly/ saveChanges arity assertions updated for the new argument. Local: core/tools + integrations/editor + core/task + services/file-safety + utils 2320 passed / 10 skipped (120 files); tsc unchanged; eslint clean on all 14 touched files. Pins verified: the new guardedWrite tests fail with the production file stashed, and the forwarding test fails with DiffViewProvider.ts stashed. --- src/core/tools/ApplyPatchTool.ts | 18 ++- src/core/tools/EditFileTool.ts | 8 +- src/core/tools/EditTool.ts | 8 +- src/core/tools/SearchReplaceTool.ts | 8 +- src/core/tools/WriteToFileTool.ts | 8 +- .../__tests__/applyPatchTool.execute.spec.ts | 8 +- src/core/tools/__tests__/editFileTool.spec.ts | 6 +- src/core/tools/__tests__/editTool.spec.ts | 3 +- src/core/tools/__tests__/guardedWrite.spec.ts | 81 +++++++++++++ .../tools/__tests__/searchReplaceTool.spec.ts | 3 +- .../tools/__tests__/writeToFileTool.spec.ts | 1 + src/core/tools/guardedWrite.ts | 106 ++++++++++++++---- src/integrations/editor/DiffViewProvider.ts | 26 ++++- .../editor/__tests__/DiffViewProvider.spec.ts | 26 +++++ 14 files changed, 274 insertions(+), 36 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 14aa6d8bbe..82eb67e39d 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -250,11 +250,17 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diagnosticsEnabled, writeDelayMs, "create", + isOutsideWorkspace, ) } else { // The add path publishes a whole new file, so create-guard semantics // apply here as well. - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "create") + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + "create", + isOutsideWorkspace, + ) } // Track file edit operation @@ -499,6 +505,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { writeDelayMs, "create", sourceComplete, + // The user approved the whole patch, which names this destination. + isPathOutsideWorkspace(moveAbsolutePath), ) } else { // Write to new path and delete old file @@ -528,12 +536,18 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diagnosticsEnabled, writeDelayMs, "edit", + isOutsideWorkspace, ) } else { // The diff-view save is the same targeted hunk as the guarded save above: // it must select the same edit guard, otherwise a partial read is // rejected and the approved patch is thrown away. - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + "edit", + isOutsideWorkspace, + ) } await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 2cb6f74a49..d79c4f75b1 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -446,10 +446,16 @@ export class EditFileTool extends BaseTool<"edit_file"> { diagnosticsEnabled, writeDelayMs, isNewFile ? "create" : "edit", + isOutsideWorkspace, ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, isNewFile ? "create" : "edit") + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + isNewFile ? "create" : "edit", + isOutsideWorkspace, + ) } // Track file edit operation diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 2fcfc8e70c..50b16dae57 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -220,10 +220,16 @@ export class EditTool extends BaseTool<"edit"> { diagnosticsEnabled, writeDelayMs, "edit", + isOutsideWorkspace, ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + "edit", + isOutsideWorkspace, + ) } // Track file edit operation diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index 69f8e51fc1..8b58bb0965 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -216,10 +216,16 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { diagnosticsEnabled, writeDelayMs, "edit", + isOutsideWorkspace, ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + "edit", + isOutsideWorkspace, + ) } // Track file edit operation diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 71bb864c43..dbefd331bc 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -141,6 +141,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { diagnosticsEnabled, writeDelayMs, "create", + isOutsideWorkspace, ) } else { if (!task.diffViewProvider.isEditing) { @@ -173,7 +174,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + undefined, + isOutsideWorkspace, + ) } if (relPath) { diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 0aa6ed1dc6..9c99220fed 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -271,6 +271,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "edit", + false, ) expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockTask.didEditFile).toBe(true) @@ -300,6 +301,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "edit", + false, ) await expect(guardedWrite(mockTask as Task, "src/thing.ts", "full replacement", "update")).rejects.toThrow( @@ -446,6 +448,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "create", + false, ) expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockTask.didEditFile).toBe(true) @@ -470,6 +473,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { 1000, "create", false, + false, ) }) @@ -678,7 +682,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit", false) expect(mockSaveDirectly).not.toHaveBeenCalled() expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockHandleError).not.toHaveBeenCalled() @@ -694,7 +698,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create") + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create", false) expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockHandleError).not.toHaveBeenCalled() }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index c1dce4d4f7..4bccc35eb7 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -566,7 +566,7 @@ describe("editFileTool", () => { await executeEditFileTool() - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false) expect(mockTask.didEditFile).toBe(true) }) @@ -578,7 +578,7 @@ describe("editFileTool", () => { await executeEditFileTool({ old_string: "", new_string: "New file content" }, { fileExists: false }) - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "create") + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "create", false) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. expect(mockTask.recordToolUsage).not.toHaveBeenCalled() @@ -722,6 +722,7 @@ describe("editFileTool", () => { true, 1000, "edit", + false, ) expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() expect(mockTask.didEditFile).toBe(true) @@ -742,6 +743,7 @@ describe("editFileTool", () => { true, 1000, "create", + false, ) expect(mockTask.didEditFile).toBe(true) expect(mockHandleError).not.toHaveBeenCalled() diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index 902bd2e63d..270a054e5e 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -351,7 +351,7 @@ describe("editTool", () => { await executeEditTool() - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false) expect(mockTask.didEditFile).toBe(true) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. @@ -449,6 +449,7 @@ describe("editTool", () => { true, 1000, "edit", + false, ) expect(mockTask.didEditFile).toBe(true) expect(result).toBe("Tool result message") diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index ac7707c03a..81ba9c1d8d 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -313,6 +313,87 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) + describe("approved outside-workspace writes", () => { + it("publishes a target outside the workspace once the tool layer obtained approval for it", async () => { + // The write tools classify against ALL workspace folders and put an outside- + // workspace path in front of the user. Once the user approved that path, the + // guard must not reject the write it was told about. + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + + await guardedWrite(task, "/elsewhere/outside.txt", "data", "create", undefined, { + approvedOutsideWorkspace: true, + }) + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("/elsewhere/outside.txt"), "data") + }) + + it("still rejects the same target when no approval was obtained", async () => { + // The flag means 'an approval decision was obtained', not 'the path is outside'. + // An unapproved call keeps the containment checks in force. + const task = createMockTask() + + await expect( + guardedWrite(task, "/elsewhere/outside.txt", "data", "create", undefined, { + approvedOutsideWorkspace: false, + }), + ).rejects.toThrow("Path resolves outside the workspace") + + expect(mockedWithFileLock).not.toHaveBeenCalled() + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks the approved identity under the lock and rejects a swapped-in link", async () => { + // Containment was the user's decision, but the target must still be the target the + // user saw: a link planted while the write waits on the chain would move the + // publish, so the identity comparison rejects it. + let targetLookups = 0 + mockedFsRealpath.mockImplementation(async (p) => { + targetLookups++ + return targetLookups === 1 ? "/real/approved/outside.txt" : "/real/elsewhere/secret.txt" + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await expect( + guardedWrite(task, "/elsewhere/outside.txt", "data", "create", undefined, { + approvedOutsideWorkspace: true, + }), + ).rejects.toThrow("no longer resolves to the path that was approved") + + expect(targetLookups).toBeGreaterThan(1) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("contains a write in another workspace folder named by the caller", async () => { + // Multi-root workspace: the tool layer treats a path in a second folder as an + // ordinary in-workspace edit and asks for no approval, so the guard must accept + // it once the caller names that root. + mockedFsRealpath.mockImplementation(async (p) => String(p)) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + + await guardedWrite(task, "/other-folder/in.txt", "data", "create", undefined, { + additionalRoots: ["/other-folder"], + }) + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("/other-folder/in.txt"), "data") + }) + + it("rejects that same second-folder path when the caller does not name the root", async () => { + mockedFsRealpath.mockImplementation(async (p) => String(p)) + const task = createMockTask() + + await expect(guardedWrite(task, "/other-folder/in.txt", "data", "create")).rejects.toThrow( + "Path resolves outside the workspace", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + describe("observed create", () => { it("recreates a file that vanished after the read", async () => { const reg = new ObservationRegistry() diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 9f1ca0111c..be9302eefb 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -320,7 +320,7 @@ describe("searchReplaceTool", () => { await executeSearchReplaceTool() - expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false) expect(mockCline.didEditFile).toBe(true) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. @@ -464,6 +464,7 @@ describe("searchReplaceTool", () => { true, 1000, "edit", + false, ) expect(mockCline.didEditFile).toBe(true) expect(result).toBe("Tool result message") diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 49895b4fb9..fab813d33a 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -484,6 +484,7 @@ describe("writeToFileTool", () => { true, 1000, "create", + false, ) expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 8f144f3285..3c3e677728 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -35,6 +35,29 @@ import type { Task } from "../task/Task" /** Write kind that drives guard selection. */ export type GuardedWriteKind = "create" | "update" | "edit" +/** + * Call-site context the guard needs but cannot derive itself. + */ +export interface GuardedWriteOptions { + /** + * The target sits outside every workspace root and the tool layer obtained an + * approval DECISION for it (the user approved, or the auto-approval policy + * permitted it). Only a tool's post-approval path may set this: it is not the + * same as 'the path is outside the workspace', and an unapproved call keeps the + * containment checks in force. + */ + approvedOutsideWorkspace?: boolean + /** + * Roots other than task.cwd that a write may be contained in - the other VS Code + * workspace folders. The tool layer classifies paths against ALL workspace folders + * (isPathOutsideWorkspace) and only asks for approval for paths outside every one + * of them, so a guard that knew only task.cwd would reject a write the tool layer + * had already treated as an ordinary in-workspace edit. Core stays host-agnostic: + * the extension-host caller supplies them. + */ + additionalRoots?: string[] +} + /** Error thrown when a guard rejects a write. Exported so a caller can tell a guard verdict from an unrelated failure. */ export class GuardRejectedError extends Error { @@ -311,8 +334,8 @@ function isInside(root: string, target: string): boolean { ) } -function assertInsideWorkspace(task: Task, absolutePath: string, displayPath: string): void { - if (!isInside(path.resolve(task.cwd), absolutePath)) { +function assertInsideWorkspace(roots: string[], absolutePath: string, displayPath: string): void { + if (!roots.some((root) => isInside(root, absolutePath))) { throw new GuardRejectedError( `Path resolves outside the workspace -- write inside the workspace, then retry.`, displayPath, @@ -328,26 +351,36 @@ function assertInsideWorkspace(task: Task, absolutePath: string, displayPath: st * treated as "cannot be authorized" and rejected, and a workspace that cannot be * resolved at all falls back to the lexical decision already made. */ -async function assertCanonicalInsideWorkspace(task: Task, absolutePath: string, displayPath: string): Promise { - let workspaceRoot: string - try { - workspaceRoot = await fs.realpath(path.resolve(task.cwd)) - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") { - // The workspace itself is not on disk (a deleted workspace, or a fixture cwd in - // tests): there is no root to contain the write in, and the publish would fail on - // the missing directory anyway. Anything else - EACCES, ELOOP - means the root - // exists but cannot be resolved, and a symlink inside the workspace would then - // never be checked, so this fails closed rather than falling back to lexical only. - return +async function assertCanonicalInsideWorkspace( + roots: string[], + absolutePath: string, + displayPath: string, +): Promise { + const resolvedRoots: string[] = [] + for (const root of roots) { + try { + resolvedRoots.push(await fs.realpath(root)) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + // This root is not on disk (a deleted workspace, or a fixture cwd in + // tests): there is nothing to contain the write in here. Other roots may + // still resolve, and the write is then checked against those. + continue + } + throw new GuardRejectedError( + "Workspace could not be resolved, so this write cannot be checked against it -- retry with a path inside the workspace.", + displayPath, + ) } - throw new GuardRejectedError( - "Workspace could not be resolved, so this write cannot be checked against it -- retry with a path inside the workspace.", - displayPath, - ) + } + if (resolvedRoots.length === 0) { + // No root exists: the publish would fail on the missing directory anyway, and + // the lexical decision above already ran. Anything OTHER than a missing root + // failed closed above rather than falling back to lexical only. + return } const target = await realpathNearest(absolutePath, displayPath) - if (!isInside(workspaceRoot, target)) { + if (!resolvedRoots.some((root) => isInside(root, target))) { throw new GuardRejectedError( `Path resolves through a link to outside the workspace -- write a real file inside the workspace, then retry.`, displayPath, @@ -418,6 +451,7 @@ export async function guardedWrite( // view of another file must carry that view's completeness through the publish // instead of claiming completeness for lines it never read. completeOverride?: boolean, + options?: GuardedWriteOptions, ): Promise { const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) // Model-facing path: the caller's own spelling, not the resolved absolute @@ -425,13 +459,41 @@ export async function guardedWrite( // user-specific absolute path into the model's context. const displayPath = relPathOrAbsolute + const approvedOutside = options?.approvedOutsideWorkspace === true + // task.cwd is the task's own root; the other workspace folders come from the + // extension-host caller because the tool layer classifies against all of them. + const roots = [path.resolve(task.cwd), ...(options?.additionalRoots ?? []).map((root) => path.resolve(root))] + // Containment is decided before the link is queued: a write that would land // outside the workspace must not take a slot on the FIFO chain, take the file - // lock, or touch the filesystem at all. - assertInsideWorkspace(task, absolutePath, displayPath) + // lock, or touch the filesystem at all. An approved outside-workspace write skips + // containment - the tool layer already put it in front of the user - but not the + // identity re-check below. + if (!approvedOutside) { + assertInsideWorkspace(roots, absolutePath, displayPath) + } // Bound to the task and the caller's spelling so the guard can re-run the same // decision under the lock, immediately before the publish. - const verifyTarget = () => assertCanonicalInsideWorkspace(task, absolutePath, displayPath) + let authorizedTarget: string | undefined + const verifyTarget = async (): Promise => { + if (!approvedOutside) { + await assertCanonicalInsideWorkspace(roots, absolutePath, displayPath) + return + } + // For an approved outside-workspace write the re-check is an IDENTITY check: + // the path that resolves now must still be the path that was approved. A symlink + // swapped in while this link waited on the chain would otherwise move the write + // somewhere the user never saw. + const resolved = await realpathNearest(absolutePath, displayPath) + if (authorizedTarget === undefined) { + authorizedTarget = resolved + } else if (authorizedTarget !== resolved) { + throw new GuardRejectedError( + "The approved target no longer resolves to the path that was approved -- re-approve the write, then retry.", + displayPath, + ) + } + } await verifyTarget() return enqueue(absolutePath, async () => { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 4beb580034..e3e671562e 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -484,6 +484,16 @@ export class DiffViewProvider { task.observationRegistry.observe(absolutePath, versionTokenOfStat(after), observation?.complete ?? false) return true } + /** + * Roots the guard may contain a write in, beyond the task's own cwd: every other + * VS Code workspace folder. The write tools classify paths against ALL workspace + * folders (isPathOutsideWorkspace) and only ask for approval for paths outside every + * one of them, so a guard that knew only task.cwd would reject a write in a second + * workspace folder that the tool layer had treated as an ordinary in-workspace edit. + */ + private additionalWorkspaceRoots(): string[] { + return (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath) + } async saveChanges( diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, @@ -491,6 +501,10 @@ export class DiffViewProvider { // like "update" in guardedWrite (only "edit" and "create" branch distinctly), // so the StringLiteral mutant here is equivalent. writeKind: GuardedWriteKind = "update", + // The tool layer put this write in front of the user and the user approved it, + // for a target outside every workspace root. Only that post-approval path sets + // it; unapproved saves keep the guard's containment checks. + approvedOutsideWorkspace?: boolean, ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -545,7 +559,10 @@ export class DiffViewProvider { saveTask.observationRegistry.forget(absolutePath) } } - await guardedWrite(saveTask, this.relPath, encodedContent, writeKind) + await guardedWrite(saveTask, this.relPath, encodedContent, writeKind, undefined, { + approvedOutsideWorkspace: approvedOutsideWorkspace === true, + additionalRoots: this.additionalWorkspaceRoots(), + }) } catch (error) { // Autosave can publish the modified side of the diff before the user // accepts, so the bytes on disk may already be exactly what this save @@ -1548,6 +1565,8 @@ export class DiffViewProvider { // Completeness the caller earned elsewhere; a move carries the source's // view through the publish instead of claiming completeness for lines it never read. completeOverride?: boolean, + // Approved by the user for a target outside every workspace root (see saveChanges). + approvedOutsideWorkspace?: boolean, ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -1572,7 +1591,10 @@ export class DiffViewProvider { // No pre-guard mkdir: safeWriteText creates missing parent directories at publish // time, so a rejected guard leaves NO directories behind - including outside the // workspace, where a rejected write must not leave a trace. - await guardedWrite(task, relPath, content, writeKind, completeOverride) + await guardedWrite(task, relPath, content, writeKind, completeOverride, { + approvedOutsideWorkspace: approvedOutsideWorkspace === true, + additionalRoots: this.additionalWorkspaceRoots(), + }) // 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 9fbead2530..b8a654f8ab 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1080,6 +1080,32 @@ describe("DiffViewProvider", () => { expect(vscode.window.showTextDocument).not.toHaveBeenCalled() }) + it("publishes an approved outside-workspace target", async () => { + // The tool layer classified this path as outside every workspace folder, put it in + // front of the user, and the user approved. The guard must not then reject the + // write it was told about. + vi.mocked(fs.access).mockRejectedValue({ code: "ENOENT" }) + vi.mocked(computeVersionToken).mockResolvedValue("v1") + + await diffViewProvider.saveDirectly("../outside.ts", "new content", false, true, 1000, "create", undefined, true) + + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/../outside.ts`, "new content") + }) + + it("rejects the same escape when no approval was obtained", async () => { + // The flag is what carries the approval; without it the containment line inside the + // publish helper still holds. + vi.mocked(fs.access).mockRejectedValue({ code: "ENOENT" }) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect( + diffViewProvider.saveDirectly("../outside.ts", "new content", false, true, 1000, "create"), + ).rejects.toThrow("Path resolves outside the workspace") + + expect(safeWriteText).not.toHaveBeenCalled() + }) + it("does not save a dirty buffer in the memory-only diagnostics path", async () => { // The guarded publish already committed the accepted content. Saving a dirty // buffer here would republish its stale bytes through VS Code's unguarded save From 8fc2c5b2db19bed039c423031f63216e84ba07f1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 01:33:38 +0800 Subject: [PATCH 033/115] fix(u7): no containment decision without a canonical workspace, and no observation without a baseline Security Boundaries: assertCanonicalInsideWorkspace() skipped the canonical comparison whenever every workspace root failed to resolve with ENOENT, falling back to the lexical decision - the exact check a planted symlink defeats. A root that is not on disk cannot contain anything, so any failure to canonicalize a root now refuses the write. realpathNearest() also rejoined the lexical names of missing components, which cannot tell a directory that has not been created yet from a symlink whose referent is gone; the walk now stats each missing component and refuses a path that runs through a dangling link instead of authorizing a publish outside the container it checked. Regression Evidence: tool-level coverage for the bracketing stats that were unproven - apply_patch when the pre-read stat rejects, and apply_diff when either bracketing stat rejects. The read and the guarded publish still happen; nothing is observed, so the publish fails closed instead of authorizing content from an unverifiable read. --- .../applyDiffTool.guardedWrite.spec.ts | 52 ++++++++++++++++ .../__tests__/applyPatchTool.execute.spec.ts | 26 ++++++++ src/core/tools/__tests__/guardedWrite.spec.ts | 62 +++++++++++++++++-- src/core/tools/guardedWrite.ts | 43 ++++++++----- 4 files changed, 163 insertions(+), 20 deletions(-) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index bf7bc4b520..b84d0eaefc 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -290,4 +290,56 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { expect(observation?.version).toBe("1:2:9:9:9") expect(observation?.complete).toBe(true) }) + + it("still performs the diff read when the pre-read stat fails, and records no observation", async () => { + // A stat failure is not a tool failure: the read still happens, the diff still + // applies, and the save still goes through the guard (which fails closed without + // an observation). What must not happen is an observation recorded for a version + // the tool could not bracket. + const stat = vi.mocked((await import("fs/promises")).default.stat) + stat.mockRejectedValueOnce(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + // The read itself is unaffected: the diff was computed and the save attempted. + expect(mockTask.diffStrategy?.applyDiff).toHaveBeenCalled() + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockHandleError).not.toHaveBeenCalled() + // Both bracketing stats were still attempted - the failure is not swallowed into + // skipping the second one. + expect(stat).toHaveBeenCalledTimes(2) + }) + + it("records no observation when the post-read stat fails", async () => { + // The file changed or vanished between the read and the second stat; with no + // post-read token the tool cannot prove the version it read, so the read + // authorizes nothing and the save falls back to the re-read remediation. + const stat = vi.mocked((await import("fs/promises")).default.stat) + stat.mockRejectedValue( + Object.assign(new Error("EACCES"), { code: "EACCES" }), + ) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + expect(mockSaveDirectly).toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(stat).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 9c99220fed..35c1a955d6 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -432,6 +432,32 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) }) + + it("update: does not observe when the pre-read stat rejects", async () => { + // The mirror of the post-read case: the FIRST bracketing stat fails, so there is + // no baseline token to compare the read against. The hunk read still happens and + // the patch still reaches the guarded publish, but nothing is observed - the + // publish fails closed against the existing, unobserved file. + const statMock = mockedFsPromises.default.stat + statMock.mockRejectedValueOnce(new Error("EACCES: permission denied")) + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + // The read was not skipped: the publish was still attempted through the guard. + expect(mockSaveDirectly).toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + }) it("add: publishes the new file through the guarded saveDirectly with create kind", async () => { mockedFileExistsAtPath.mockResolvedValueOnce(false) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 81ba9c1d8d..8639a112e7 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -28,6 +28,7 @@ vi.mock("fs/promises", () => ({ access: vi.fn(), stat: vi.fn(), realpath: vi.fn(), + lstat: vi.fn(), })) vi.mock("../../../utils/versionToken", () => ({ @@ -47,6 +48,7 @@ const mockedWithFileLock = vi.mocked(withFileLock) const mockedResolveLockKey = vi.mocked(resolveLockKey) const mockedFsAccess = vi.mocked(fs.access) const mockedFsRealpath = vi.mocked(fs.realpath) +const mockedFsLstat = vi.mocked(fs.lstat) const mockedComputeVersionToken = vi.mocked(computeVersionToken) const mockedSafeWriteText = vi.mocked(safeWriteText) @@ -83,11 +85,15 @@ describe("guardedWrite (S4a, epic #1375)", () => { beforeEach(() => { vi.resetAllMocks() mockedWithFileLock.mockImplementation((filePath, operation) => operation(path.resolve(filePath))) - // The canonical containment check resolves the workspace first. The fixture - // workspace is not a real directory, so the default is "cannot resolve" and the - // check falls back to the lexical decision; the containment tests below override - // this to exercise the canonical path. - mockedFsRealpath.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + // The canonical containment check resolves the workspace first, and an + // unresolvable workspace now refuses the write instead of falling back to the + // lexical decision. The fixture workspace is not a real directory, so the default + // is "every path resolves to itself": containment behaves exactly as the lexical + // spelling, and the containment tests below override it to exercise links. + mockedFsRealpath.mockImplementation(async (p) => String(p)) + // Nothing on the walked path is a symlink by default; the dangling-link test + // overrides this for the component it plants. + mockedFsLstat.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) resetChain() }) @@ -273,6 +279,52 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).not.toHaveBeenCalled() }) + it("refuses a write when the workspace directory itself is missing", async () => { + // ENOENT on the workspace used to fall through to the lexical-only decision. + // A workspace that is not on disk cannot contain the write, and the lexical + // check is the one a planted symlink defeats, so the write is refused. + mockedFsRealpath.mockImplementation(async (p) => { + if (String(p) === path.resolve(WORKSPACE)) { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + } + return String(p) + }) + const task = createMockTask() + + await expect(guardedWrite(task, "inside.txt", "data", "create")).rejects.toThrow( + "Workspace could not be resolved", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedWithFileLock).not.toHaveBeenCalled() + }) + + it("refuses a target that runs through a dangling symlink ancestor", async () => { + // A component of the path exists as a symlink whose referent is gone. realpath + // reports ENOENT for it, which is also what a not-yet-created directory + // reports; rejoining the lexical names would authorize a write whose publish + // lands outside the container that was checked. + const planted = path.join(path.resolve(WORKSPACE), "linkdir") + mockedFsRealpath.mockImplementation(async (p) => { + const s = String(p) + if (s === path.resolve(WORKSPACE)) return path.resolve(WORKSPACE) + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + mockedFsLstat.mockImplementation(async (p) => { + if (String(p) === planted) { + return { isSymbolicLink: () => true } as never + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + const task = createMockTask() + + await expect(guardedWrite(task, "linkdir/nested/new.txt", "data", "create")).rejects.toThrow( + "runs through a link", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + it("re-checks containment under the lock, after the wait on the FIFO chain", async () => { // The path was inside the workspace when it was queued; a link is swapped in while the // write waits. The publish must not follow the new link. diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 3c3e677728..7caa64fb38 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -346,10 +346,12 @@ function assertInsideWorkspace(roots: string[], absolutePath: string, displayPat /** * The lexical check above cannot see a symlink whose referent leaves the * workspace, and the publish resolves through that link. Resolve both sides the - * same way and compare again. Only a missing path is walked up to the nearest - * existing ancestor (a create has no target yet); any other realpath failure is - * treated as "cannot be authorized" and rejected, and a workspace that cannot be - * resolved at all falls back to the lexical decision already made. + * same way and compare again. Only a missing TARGET is walked up to the nearest + * existing ancestor (a create has no target yet); a workspace that cannot be + * resolved - for any reason, ENOENT included - is rejected rather than falling back + * to the lexical decision, because the lexical decision is exactly what a symlink + * defeats. A root that is not on disk is not a container this write can be checked + * against, so the write is refused instead of published on a weaker guarantee. */ async function assertCanonicalInsideWorkspace( roots: string[], @@ -360,13 +362,11 @@ async function assertCanonicalInsideWorkspace( for (const root of roots) { try { resolvedRoots.push(await fs.realpath(root)) - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") { - // This root is not on disk (a deleted workspace, or a fixture cwd in - // tests): there is nothing to contain the write in here. Other roots may - // still resolve, and the write is then checked against those. - continue - } + } catch { + // ENOENT is not a safe fallback either: a workspace directory that is not on + // disk cannot contain anything, and the lexical check that already ran is the + // very check a planted symlink defeats. Every failure to canonicalize a root + // therefore refuses the write. throw new GuardRejectedError( "Workspace could not be resolved, so this write cannot be checked against it -- retry with a path inside the workspace.", displayPath, @@ -374,10 +374,10 @@ async function assertCanonicalInsideWorkspace( } } if (resolvedRoots.length === 0) { - // No root exists: the publish would fail on the missing directory anyway, and - // the lexical decision above already ran. Anything OTHER than a missing root - // failed closed above rather than falling back to lexical only. - return + throw new GuardRejectedError( + "Workspace could not be resolved, so this write cannot be checked against it -- retry with a path inside the workspace.", + displayPath, + ) } const target = await realpathNearest(absolutePath, displayPath) if (!resolvedRoots.some((root) => isInside(root, target))) { @@ -418,6 +418,19 @@ async function realpathNearest(target: string, displayPath: string): Promise undefined) + if (asLink?.isSymbolicLink()) { + throw new GuardRejectedError( + `Path runs through a link (${ancestor}) that does not resolve -- retry with a path inside the workspace.`, + displayPath, + ) + } } } } From 183de845637b719a60a6855ff0044d4d55411d41 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 01:43:34 +0800 Subject: [PATCH 034/115] fix(file-safety): one lock key whether the parent directory exists yet Port of the #1917 fix into this unit: the unit branches are not cumulative, so this branch carries its own copy of safeWriteText's lock-key helper and the same defect. canonicalDirKey() canonicalized only the immediate parent and fell back to that literal spelling on ENOENT. A writer whose parent directory already existed canonicalized through a symlinked ancestor (or a Windows short name) while a writer racing to create the same directory got the literal path, so the two took different locks for one file and a read-modify-write lost one side. It now walks up to the nearest ancestor that exists, canonicalizes that, and re-joins the missing components; a realpath failure that is not ENOENT is propagated instead of being papered over with a key that may be wrong. Identifiers and comments are kept identical to the other units so the merge resolves trivially. --- .../__tests__/safeWriteText.spec.ts | 44 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 30 ++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index b4257c33d9..e0a4362162 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1362,3 +1362,47 @@ describe("resolvePublishTarget", () => { expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), path.resolve(targetPath)) }) }) +describe("resolveLockKey when the parent directory does not exist yet", () => { + beforeEach(() => mockDefaults()) + + it("canonicalizes through the nearest existing ancestor instead of the literal parent", async () => { + // Two writers must take ONE lock: the one whose parent directory is already there, + // and the one racing to create it. Resolving only the immediate parent and falling + // back to its literal spelling on ENOENT gave them different keys whenever an + // ancestor was a symlink or a Windows short name, so a read-modify-write under the + // advisory lock lost one side. + const aliasDir = path.resolve("/tmp/alias-parent") + const canonicalDir = path.resolve("/tmp/real-parent") + const nested = path.join(aliasDir, "nested") + const target = path.join(nested, "history_item.json") + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) + vi.mocked(fs.realpath).mockImplementation(async (p) => { + const s = String(p) + if (s === target || s === nested) { + throw Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + } + if (s === aliasDir) { + return canonicalDir + } + return s + }) + + expect(await resolveLockKey(target)).toBe(path.join(canonicalDir, "nested", "history_item.json")) + }) + + it("propagates a realpath failure that is not ENOENT instead of guessing a key", async () => { + // A realpath that fails for another reason says nothing about the canonical form; + // returning a literal key would silently put this writer on a different lock. + const target = path.join(path.resolve("/tmp/test-dir"), "history_item.json") + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) + // Not a link: the walk must reach the canonicalization step, which is where the + // non-ENOENT failure has to surface. + vi.mocked(fs.readlink).mockRejectedValue(Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })) + vi.mocked(fs.realpath).mockImplementation(async (p) => { + if (String(p) === target) return target + throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + }) + + await expect(resolveLockKey(target)).rejects.toThrow("EACCES") + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 170c11053d..835289981b 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -199,8 +199,34 @@ function errorCode(error: unknown): string | undefined { async function canonicalDirKey(absoluteFilePath: string): Promise { const dirPath = path.dirname(absoluteFilePath) - const canonicalDir = await fs.realpath(dirPath).catch(() => dirPath) - return path.join(canonicalDir, path.basename(absoluteFilePath)) + // Walk up to the nearest ancestor that EXISTS, canonicalize that, and re-join the + // components that are not there yet. Falling back to the unresolved spelling of the + // whole parent - what a single realpath(...).catch(() => dirPath) used to do - makes + // the key depend on whether the directory happens to exist: a writer whose parent is + // already there canonicalizes through a symlinked ancestor (or a short name) while a + // writer racing to create the same directory gets the literal spelling, so the two + // take different locks for one file and a read-modify-write loses one side. + // A realpath failure that is not "not there yet" says nothing about the canonical + // form, so it is propagated rather than papered over with a key that may be wrong. + let cursor = dirPath + const missing: string[] = [] + for (;;) { + const canonical = await fs.realpath(cursor).catch((error: unknown) => { + if (errorCode(error) === "ENOENT") return undefined + throw error + }) + if (canonical !== undefined) { + return path.join(canonical, ...missing.reverse(), path.basename(absoluteFilePath)) + } + missing.push(path.basename(cursor)) + const parent = path.dirname(cursor) + if (parent === cursor) { + // Every component up to the root is missing: there is nothing to canonicalize + // against, and the literal path is the only key left. + return path.join(dirPath, path.basename(absoluteFilePath)) + } + cursor = parent + } } /** From e06bed1a15a9bbcbb8c71c8cf292a760f30a7ad3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 03:28:20 +0800 Subject: [PATCH 035/115] test(file-safety): a backup copy that fails part-way must not survive the write A defect reported on a sibling PR in the base repo: the backup destination is named before the copy runs, while the rollback cleanup keys off a flag that only becomes true once the copy succeeded - so a copyFile that fails after creating the destination leaves a half-written .bak beside the target forever. This branch does not have that shape. The whole backup creation (seed open with "wx", copyFile, chmod, fsync) is wrapped in a catch that unlinks the destination and clears backupPath before rethrowing, so the cleanup keys off the attempt rather than off the success. What was missing is coverage for the exact case the report describes: copyFile failing with the destination already created. Only the fsync-failure variant was tested. No production change. Negative control: deleting the cleanup unlink inside that catch fails exactly two tests - this one and the existing "a failed backup flush is reported and leaves no partial backup behind" - and restoring it leaves the file byte-identical. --- .../__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 e0a4362162..57e7fe1914 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -585,6 +585,25 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) + it("a backup copy that fails part-way is removed, not left as a usable-looking backup", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The destination is seeded with openSync("wx") BEFORE any content exists at it, + // and the copy then fails part-way: the name is there, holding a partial copy of + // nothing usable. Cleanup has to key off the attempt, not off a copy that + // succeeded, or this file outlives the failed write beside the target. + vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("EIO"), { code: "EIO" })) + + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow("EIO") + + // Nothing was published, and the half-written copy is removed rather than left + // next to the target looking like a backup someone could restore. + expect(fs.rename).not.toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + 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) From 3187e582cc6776a6d92a0fe4dc85d5e2c84231e7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 05:49:27 +0800 Subject: [PATCH 036/115] test(tools): pin which bracketing stat fails in the diff-read observation test The test "still performs the diff read when the pre-read stat fails, and records no observation" queued its failure with mockRejectedValueOnce. ApplyDiffTool stats the SAME path twice around the read (pre-read at ApplyDiffTool.ts:76, post-read at :78), so a once-value cannot say which role it breaks: flipping the injected failure to the post-read stat left the test green while testing a different scenario. The outcome assertions cannot separate the two cases either - with either stat missing, the guard at :79 records no observation - so the role has to be asserted, not assumed. The mock is now keyed to the interleaving with the read (readFile records when the read starts) and records which call threw; the test asserts ["pre:threw", "post:ok"]. The afterEach also resets readFile, because the role-aware mock installs an implementation that would otherwise decide which stat call the NEXT test sees as pre-read. Negative control, blast radius as measured: flipping the injected failure to the post-read stat now fails exactly 1 test (before this change the same flip failed 0). Full spec 9 passed. src-level tsc (cwd=src) 50 = this branch's baseline with 0 error lines in the touched file; eslint --max-warnings=0 clean; eslint-suppressions.json byte-identical; diff 31/2. Committed but NOT pushed: per the push-is-budget rule, the lead schedules when this goes up. --- .../applyDiffTool.guardedWrite.spec.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index b84d0eaefc..ef2608dbc0 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -78,6 +78,11 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { stat.mockResolvedValue( { dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats, ) + // The role-aware stat mock above also drives readFile, so its implementation is reset here too: + // a leaked implementation would decide which stat call the NEXT test sees as pre-read. + const readFile = vi.mocked((await import("fs/promises")).default.readFile) + readFile.mockReset() + readFile.mockResolvedValue("original file content\n") }) beforeEach(() => { @@ -296,8 +301,28 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { // applies, and the save still goes through the guard (which fails closed without // an observation). What must not happen is an observation recorded for a version // the tool could not bracket. - const stat = vi.mocked((await import("fs/promises")).default.stat) - stat.mockRejectedValueOnce(Object.assign(new Error("EACCES"), { code: "EACCES" })) + const fsMock = await import("fs/promises") + const stat = vi.mocked(fsMock.default.stat) + const readFile = vi.mocked(fsMock.default.readFile) + // Pin WHICH of the two bracketing stats fails. Both run against the same path, so a + // once-value cannot express the role: verified by flipping the injected failure to the + // post-read stat, which leaves this test green while testing a different scenario. The + // pre-read stat is the one that runs before the read, so the failure is keyed to that + // interleaving rather than to a call index. + let readStarted = false + readFile.mockImplementation(async () => { + readStarted = true + return "original file content\n" + }) + const statRoles: string[] = [] + stat.mockImplementation(async () => { + if (!readStarted) { + statRoles.push("pre:threw") + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + statRoles.push("post:ok") + return { dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats + }) await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { askApproval: mockAskApproval, @@ -306,6 +331,10 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { }) expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + // Both bracketing stats ran, and the one that failed was the PRE-read one. The + // roles are recorded from the interleaving with the read, so flipping which call throws + // makes this assertion fail - the outcome assertions alone cannot tell the two apart. + expect(statRoles).toEqual(["pre:threw", "post:ok"]) // The read itself is unaffected: the diff was computed and the save attempted. expect(mockTask.diffStrategy?.applyDiff).toHaveBeenCalled() expect(mockSaveDirectly).toHaveBeenCalledWith( From 318381041a943d2f1b6ac63e867973c3feff87c1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 06:58:35 +0800 Subject: [PATCH 037/115] fix(diff-view): put the save's post-publish cleanup inside the same teardown as a cancellation Port of the owner fix on U8 (#1916, commit 6a4f5110d) into fws-u7-fix. CodeRabbit's Lifecycle row applies to every branch that carries a copy of DiffViewProvider's guarded publish, and each of those copies needs its own verification. - New state: `teardownPasses` counts the teardown passes this provider actually ran (a caller that awaited an in-flight pass does not count). runTeardown() increments it; open() resets it, so a provider reused after a cancelled session does not report every later save as cancelled. - saveChanges() returns the "no save flow of its own" shape when a teardown began while the guarded publish was awaiting: that teardown owns the session. - The post-publish cleanup (listener disposal, buffer revert, diff-view close, auto-close decision, restorePreviewTabs) now runs inside runTeardown(), so a cancellation landing during it waits instead of closing the same tabs underneath it. Two tests added to DiffViewProvider.spec.ts (+102): - "saveChanges() skips its post-publish cleanup when a teardown began during the publish" - the cancellation is injected inside the mocked publish; asserts the empty return shape and that applyEdit / closeAllDiffViews / restorePreviewTabs each ran exactly once. The publish implementation is restored in a finally: clearAllMocks() keeps queued implementations, and a leaked one cancels every later save in the file. - "saveChanges() serializes its post-publish cleanup with a revertChanges() that lands during it" - the cleanup is gated; the revert started while it is in flight must not touch the document. Negative controls, blast radius as measured (conditions extended in place, parseable): - cancelled-check removed -> exactly 1 failed (the skip test). - teardown counter never incremented -> exactly 1 failed (the skip test). - runTeardown's in-flight guard removed -> 2 failed: the new serialization test AND the pre-existing "revertChanges() does not run a second teardown while one is already in flight"; that mutation removes the guarantee for both callers, so the wider blast radius is expected. All restores byte-identical. Full spec green. src-level tsc (cwd=src) unchanged from this branch's baseline with 0 error lines in the touched files; eslint --max-warnings=0 clean; eslint-suppressions.json byte-identical; diff 48/24 + 102/0. --- src/integrations/editor/DiffViewProvider.ts | 72 ++++++++----- .../editor/__tests__/DiffViewProvider.spec.ts | 102 ++++++++++++++++++ 2 files changed, 150 insertions(+), 24 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index e3e671562e..f704d4639e 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -44,6 +44,12 @@ export class DiffViewProvider { private documentWasPinned = false private relPath?: string private teardownInFlight: Promise | undefined + // Counts the teardown passes this provider has actually run; a caller that awaited an + // in-flight pass does not count. A save uses it to tell its own post-publish cleanup apart + // from a teardown a cancellation or disposal started underneath it: once any teardown has + // begun the session is being taken down, and a second pass over the same buffers and tabs is + // duplicate cleanup. + private teardownPasses = 0 private newContent?: string private activeDiffEditor?: vscode.TextEditor private fadedOverlayController?: DecorationController @@ -117,6 +123,9 @@ export class DiffViewProvider { const fileExists = this.editType === "modify" const absolutePath = path.resolve(this.cwd, relPath) this.isEditing = true + // A new diff session may reuse this provider after a cancelled one, so the teardown marker + // starts clean: otherwise every later save would report itself cancelled without publishing. + this.teardownPasses = 0 // Snapshot the authorization as it stands before this preview touches the // registry; saveChanges(..., "edit") restores it below. @@ -676,35 +685,49 @@ export class DiffViewProvider { // active editor even with preserveFocus, so a listener still attached would // record this programmatic revert as a user touch and keep the transient // tab open against the auto-close preference. - this.disposeActiveEditorListener() - this.cancelDeferredScroll() - - // Revert only while the buffer is still exactly what the guard published. - // Keystrokes typed during the publish would be discarded by a revert, so a - // buffer that moved on stays dirty: the close helpers skip dirty tabs and - // the user's text survives in the editor. - if (updatedDocument.isDirty && updatedDocument.getText() === editedContent) { - await this.revertDocument(updatedDocument) + // A cancellation or disposal that reached teardown while the guarded publish was awaiting + // already owns this session: that teardown closed the diff views and applied the auto-close + // preferences. Running this save's own post-publish cleanup on top of it would tear the same + // session down twice, so the save reports that it did not complete a save flow of its own. + if (this.teardownPasses > 0) { + return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } } - await this.closeAllDiffViews() + // The post-publish cleanup is a teardown pass like any other, so it goes through the same + // serialization revertChanges() uses: a cancellation that lands while it runs waits for it + // instead of closing the same tabs underneath it. + await this.runTeardown(async () => { + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + + // Revert only while the buffer is still exactly what the guard published. + // Keystrokes typed during the publish would be discarded by a revert, so a + // buffer that moved on stays dirty: the close helpers skip dirty tabs and + // the user's text survives in the editor. + if (updatedDocument.isDirty && updatedDocument.getText() === editedContent) { + await this.revertDocument(updatedDocument) + } + + await this.closeAllDiffViews() - // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset (saveTask was resolved above - // for the guarded publish). - const saveState = await saveTask?.providerRef.deref()?.getState() + // Read auto-close preferences from state; fall back to defaults that + // preserve the existing behavior when unset (saveTask was resolved above + // for the guarded publish). + const saveState = await saveTask?.providerRef.deref()?.getState() + + await this.keepOrCloseEditedFile( + absolutePath, + this.userTouchedDiffEditor, + saveState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, + saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + saveState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + ) - await this.keepOrCloseEditedFile( - absolutePath, - this.userTouchedDiffEditor, - saveState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, - saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, - saveState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, - ) + // Restore any preview tabs the diff evicted, reconstructing the user's + // prior not-yet-edited tab state. + await this.restorePreviewTabs() + }) - // Restore any preview tabs the diff evicted, reconstructing the user's - // prior not-yet-edited tab state. - await this.restorePreviewTabs() // Getting diagnostics before and after the file edit is a better approach than // automatically tracking problems in real-time. This method ensures we only @@ -1013,6 +1036,7 @@ export class DiffViewProvider { return } const inFlight = cleanup() + this.teardownPasses++ this.teardownInFlight = inFlight try { await inFlight diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index b8a654f8ab..d2bbf66dd5 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3104,6 +3104,108 @@ describe("DiffViewProvider", () => { expect(applyEdit).toHaveBeenCalledTimes(1) }) + // The save's post-publish cleanup touches the same buffers and tabs a cancellation's + // teardown does, so it has to be one serialized pass - and a save whose session was torn + // down while the guarded publish was still awaiting must not run a cleanup of its own. + it("saveChanges() skips its post-publish cleanup when a teardown began during the publish", async () => { + // A cancellation can reach revertChanges() while the publish is awaiting. That teardown + // already closed the diff views and applied the auto-close preferences; a second pass + // would close the same tabs and re-run the same document revert. + const closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // The cancellation lands inside the guarded publish. The implementation has to be + // restored afterwards: clearAllMocks() drops call records but keeps queued + // implementations, and a leaked publish would cancel every later save in this file. + vi.mocked(safeWriteText).mockImplementation(async () => { + await diffViewProvider.revertChanges() + }) + + let result: Awaited> | undefined + try { + result = await diffViewProvider.saveChanges(false) + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + expect(result).toEqual({ + newProblemsMessage: undefined, + userEdits: undefined, + finalContent: undefined, + }) + // Exactly one teardown ran - the cancellation's - and the save added no cleanup of its + // own on top of it. + expect(applyEdit).toHaveBeenCalledTimes(1) + expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + }) + + it("saveChanges() serializes its post-publish cleanup with a revertChanges() that lands during it", async () => { + // The cleanup closes the same tabs a cancellation would close. Going through the shared + // teardown state means the cancellation waits for the save instead of racing it. + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeAllDiffViews = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + const save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + + // The revert has not touched the document: it is waiting for the save's cleanup. + expect(applyEdit).not.toHaveBeenCalled() + + release() + await Promise.all([save, revert]) + + // One pass over the tabs, and the cancellation did not repeat the document revert. + expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + expect(applyEdit).not.toHaveBeenCalled() + }) it("saveChanges() keeps the file open when the user touched it", async () => { const closeFileTab = vi.fn().mockResolvedValue(undefined) From c8caff09bed8cfe999691411a5d03708706d60af Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 08:10:27 +0800 Subject: [PATCH 038/115] fix(diff-view): let only the teardown that started a pass finalize the session Port of the owner fix on U8 (#1916, commit 0fbdf496c) into fws-u7-fix. CodeRabbit's Lifecycle row applies to every branch carrying a guarded publish, and each copy is verified in its own harness. This branch's copy needed both halves: the ownership return value and the guard in revertChanges(). - runTeardown() reports ownership: false when it awaited an in-flight pass, true when it ran one. - revertChanges() returns before restorePreviewTabs()/reset() when it did not own the pass; the pass that started the teardown owns the finalization. Test added (DiffViewProvider.spec.ts +51): "revertChanges() does not restore preview tabs or reset when it waited for another teardown" - the save's cleanup is gated, the revert starts while it is in flight, and after both settle restorePreviewTabs ran exactly once, reset never, and the revert did not touch the document. Negative controls, re-measured in THIS branch's harness (not copied from the owner): - waiter finalizing anyway (if (!ownedTeardown && false)) -> exactly 1 failed (the new test) - cancelled-check removed -> exactly 1 failed - teardown counter not incremented -> exactly 1 failed - runTeardown in-flight guard removed -> 3 failed (the three teardown tests) All restores byte-identical. Full spec green. src-level tsc unchanged from this branch's baseline with 0 error lines in the touched files; eslint --max-warnings=0 clean; eslint-suppressions.json byte-identical. --- src/integrations/editor/DiffViewProvider.ts | 17 +++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 51 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index f704d4639e..e7b2ccdcf2 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -889,7 +889,7 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - await this.runTeardown(async () => { + const ownedTeardown = await this.runTeardown(async () => { if (!fileExists) { if (updatedDocument.isDirty) { await updatedDocument.save() @@ -939,6 +939,14 @@ export class DiffViewProvider { ) } }) + + if (!ownedTeardown) { + // Another teardown started first and already ran the pass over this session's buffers, + // tabs and preview state. Restoring preview tabs or resetting here again would be that + // same work a second time, and the reset would clear state whose closing this provider + // does not own. The pass that started the teardown finalizes the session. + return + } // Restore any preview tabs the diff evicted, reconstructing the user's // prior not-yet-edited tab state. await this.restorePreviewTabs() @@ -1030,10 +1038,12 @@ export class DiffViewProvider { * both would edit the document and close the same tabs. The second caller awaits the * cleanup already in flight instead of repeating it. */ - private async runTeardown(cleanup: () => Promise): Promise { + private async runTeardown(cleanup: () => Promise): Promise { if (this.teardownInFlight !== undefined) { await this.teardownInFlight - return + // The pass belongs to whoever started it; that caller also owns the finalization + // steps (preview tabs, reset), so the caller is told it did not own this pass. + return false } const inFlight = cleanup() this.teardownPasses++ @@ -1043,6 +1053,7 @@ export class DiffViewProvider { } finally { this.teardownInFlight = undefined } + return true } // Stop tracking user activation of the target file. Called before any diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index d2bbf66dd5..cc53266902 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3207,6 +3207,57 @@ describe("DiffViewProvider", () => { expect(applyEdit).not.toHaveBeenCalled() }) + // Finalization belongs to the teardown pass that started, not to every caller that waited + // for it: a second restorePreviewTabs()/reset() pair is the same cleanup running twice. + it("revertChanges() does not restore preview tabs or reset when it waited for another teardown", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const gatedCleanup = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + diffViewProvider["closeAllDiffViews"] = gatedCleanup + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + const save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + + // The revert waits for the save's pass instead of starting one of its own. + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + await Promise.all([save, revert]) + + // The owning pass restored the preview tabs exactly once, and the waiting caller did + // not finalize the session a second time. + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).not.toHaveBeenCalled() + expect(applyEdit).not.toHaveBeenCalled() + }) + it("saveChanges() keeps the file open when the user touched it", async () => { const closeFileTab = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) From c434b0ba515c1183a8d4bfb41d264c231f73ce00 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:04:30 +0800 Subject: [PATCH 039/115] fix(diff-view): restore preview tabs for the pass that owns a rejected save Port of the #1916 fix (commit 308178a22, inline 4225550644) into fws-u7-fix. The gap is real in this branch too, and it was probed before anything was written. Probe on the unmodified branch: a rejected publish reaches its discard-only cleanup (closeOwnDiffView called once) and restorePreviewTabs is called 0 times - the preview tab the diff evicted is never put back. With the ownership guard in revertChanges(), a concurrent revert that only waits no longer finalizes either, so nothing restores it. Fix: the rejected-save path captures the boolean from runTeardown() and restores the preview tabs only when it owns the pass, before rethrowing. reset() stays with the tool callers' error handling, which owns the provider lifecycle. Tests (3 new, +150): the owning rejected save restores them once; a save whose revert waits restores them once in total and does not reset; a save that joins an already-owned pass restores nothing. Negative controls, re-measured in THIS branch's harness: - restore removed -> 2 failed (both positive tests) - ownership check dropped -> exactly 1 failed (the third test is what makes that check a real check; the first two cannot see it) All restores byte-identical. Full spec green. src-level tsc at this branch's baseline with 0 error lines in the touched files; eslint --max-warnings=0 clean; eslint-suppressions.json byte-identical. --- src/integrations/editor/DiffViewProvider.ts | 10 +- .../editor/__tests__/DiffViewProvider.spec.ts | 150 ++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index e7b2ccdcf2..d4ab99b140 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -619,7 +619,7 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - await this.runTeardown(async () => { + const ownedTeardown = await this.runTeardown(async () => { let discardSucceeded = !updatedDocument.isDirty if (updatedDocument.isDirty) { discardSucceeded = await this.revertDocument(updatedDocument) @@ -666,6 +666,14 @@ export class DiffViewProvider { } await this.closeOwnDiffView(absolutePath) }) + + if (ownedTeardown) { + // Opening the diff evicted any preview tab the file had. This path closes its own diff + // view and rethrows, so this pass is the only one that can put that preview state back; + // a caller that merely waited for it must not restore the tabs a second time. reset() + // stays with the tool caller's error handling, which owns the provider lifecycle. + await this.restorePreviewTabs() + } } catch { // cleanup is best-effort; the guard verdict below is the outcome } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index cc53266902..dc4b443eb8 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3258,6 +3258,156 @@ describe("DiffViewProvider", () => { expect(applyEdit).not.toHaveBeenCalled() }) + // A rejected publish closes its own diff view and rethrows, so the preview tabs the diff + // evicted are restored by that pass - and only by that pass. + it("saveChanges() restores preview tabs when a rejected publish tears the session down", async () => { + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const closeOwnDiffView = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // The publish is rejected, so the discard-only cleanup runs and the guard verdict is + // rethrown. Restore the publish mock afterwards: a queued rejection would fail every + // later save in this file. + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + try { + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("guard rejected for test") + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + expect(closeOwnDiffView).toHaveBeenCalledTimes(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + }) + + it("saveChanges() restores preview tabs once when a revert waits for the rejected save", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeOwnDiffView = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + let save: Promise = Promise.resolve() + try { + save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + // The revert joins the rejected save's pass instead of starting its own. + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + await expect(save).rejects.toThrow("guard rejected for test") + await revert + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).not.toHaveBeenCalled() + }) + + // The other direction: the rejected save is the one that joins an existing pass. It then + // runs no cleanup of its own, and the pass that started the teardown restores the tabs once. + it("saveChanges() restores no preview tabs when a revert already owns the teardown", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeAllDiffViews = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const closeOwnDiffView = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + // reset() closes this provider's diff tab; stub it so closeOwnDiffView counts only the + // save's own cleanup pass. + diffViewProvider["reset"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // The revert starts its pass first and is held inside it. + const revert = diffViewProvider.revertChanges() + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + let save: Promise = Promise.resolve() + try { + save = diffViewProvider.saveChanges(false) + await new Promise((resolve) => setImmediate(resolve)) + release() + await expect(save).rejects.toThrow("guard rejected for test") + await revert + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + // The save's cleanup never ran, so it restored nothing; the owning revert pass did the + // restoring exactly once. + expect(closeOwnDiffView).not.toHaveBeenCalled() + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + }) + it("saveChanges() keeps the file open when the user touched it", async () => { const closeFileTab = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) From 9e59f010bf20dde6f35416611d743353386891ae Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:29:13 +0800 Subject: [PATCH 040/115] fix(tools): pass the outside-workspace approval flag in its own position Inline 4225718826 on #1918 (Major), verified against the code before touching anything. saveDirectly is (relPath, content, openFile, diagnosticsEnabled, writeDelayMs, writeKind, completeOverride?, approvedOutsideWorkspace?) - DiffViewProvider.ts:1601-1612. Six production call sites passed isOutsideWorkspace as the SEVENTH argument (completeOverride) and left the eighth undefined: WriteToFileTool.ts:145, EditTool.ts:223, EditFileTool.ts:449, SearchReplaceTool.ts:219, ApplyPatchTool.ts:253 and :539. Both parameters are boolean | undefined, so TypeScript cannot catch the swap. Only the patch move path (ApplyPatchTool.ts:507-509) was correct. Effect: an in-workspace full-file publish records the wrong completeness, and an approved outside-workspace write is never forwarded as approved, so the guard's containment check rejects a write the user already approved. Fix: each of the six calls now passes undefined for completeOverride and isOutsideWorkspace for the approval flag, with a comment naming both positions. Tests: four new tool-layer tests assert the positions directly (args[6] undefined, args[7] true with isPathOutsideWorkspace mocked to true), and the nine existing saveDirectly assertions now state both trailing arguments instead of ending at the seventh. Negative controls, measured in this harness - each site reverted on its own: - WriteToFileTool.ts:145 -> 2 failed - EditTool.ts:223 -> 2 failed - EditFileTool.ts:449 -> 3 failed - SearchReplaceTool.ts:219 -> 2 failed - ApplyPatchTool.ts:253 -> 1 failed - ApplyPatchTool.ts:539 -> 2 failed All restores byte-identical; baseline after the sweep 131 passed / 5 skipped. src-level tsc at this branch's baseline with 0 error lines in the touched files; eslint --max-warnings=0 clean; eslint-suppressions.json byte-identical. --- src/core/tools/ApplyPatchTool.ts | 6 ++++++ src/core/tools/EditFileTool.ts | 3 +++ src/core/tools/EditTool.ts | 3 +++ src/core/tools/SearchReplaceTool.ts | 3 +++ src/core/tools/WriteToFileTool.ts | 3 +++ .../__tests__/applyPatchTool.execute.spec.ts | 3 +++ src/core/tools/__tests__/editFileTool.spec.ts | 14 ++++++++++++++ src/core/tools/__tests__/editTool.spec.ts | 13 +++++++++++++ .../tools/__tests__/searchReplaceTool.spec.ts | 13 +++++++++++++ .../tools/__tests__/writeToFileTool.spec.ts | 19 +++++++++++++++++++ 10 files changed, 80 insertions(+) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 82eb67e39d..924ce91c65 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -250,6 +250,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diagnosticsEnabled, writeDelayMs, "create", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, isOutsideWorkspace, ) } else { @@ -536,6 +539,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diagnosticsEnabled, writeDelayMs, "edit", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, isOutsideWorkspace, ) } else { diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index d79c4f75b1..311c5a4245 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -446,6 +446,9 @@ export class EditFileTool extends BaseTool<"edit_file"> { diagnosticsEnabled, writeDelayMs, isNewFile ? "create" : "edit", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, isOutsideWorkspace, ) } else { diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 50b16dae57..4a562dd2fc 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -220,6 +220,9 @@ export class EditTool extends BaseTool<"edit"> { diagnosticsEnabled, writeDelayMs, "edit", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, isOutsideWorkspace, ) } else { diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index 8b58bb0965..12ac3f75a6 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -216,6 +216,9 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { diagnosticsEnabled, writeDelayMs, "edit", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, isOutsideWorkspace, ) } else { diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index dbefd331bc..be649e316a 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -141,6 +141,9 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { diagnosticsEnabled, writeDelayMs, "create", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, isOutsideWorkspace, ) } else { diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 35c1a955d6..d28aa5c839 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -271,6 +271,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "edit", + undefined, false, ) expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") @@ -301,6 +302,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "edit", + undefined, false, ) @@ -474,6 +476,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "create", + undefined, false, ) expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 4bccc35eb7..bce5bccbc2 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -722,6 +722,7 @@ describe("editFileTool", () => { true, 1000, "edit", + undefined, false, ) expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() @@ -730,6 +731,18 @@ describe("editFileTool", () => { expect(mockHandleError).not.toHaveBeenCalled() }) + it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { + mockedIsPathOutsideWorkspace.mockReturnValue(true) + await executeEditFileTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) + const args = mockTask.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + + // Reset: the flag is a module mock, and later tests in this file assume an + // in-workspace target. + mockedIsPathOutsideWorkspace.mockReturnValue(false) + }) + it("publishes new-file creation through saveDirectly with create kind", async () => { await executeEditFileTool( { old_string: "", new_string: "New file content" }, @@ -743,6 +756,7 @@ describe("editFileTool", () => { true, 1000, "create", + undefined, false, ) expect(mockTask.didEditFile).toBe(true) diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index 270a054e5e..0ee09b8160 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -449,6 +449,7 @@ describe("editTool", () => { true, 1000, "edit", + undefined, false, ) expect(mockTask.didEditFile).toBe(true) @@ -456,6 +457,18 @@ describe("editTool", () => { expect(mockHandleError).not.toHaveBeenCalled() }) + it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { + mockedIsPathOutsideWorkspace.mockReturnValue(true) + await executeEditTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) + const args = mockTask.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + + // Reset: the flag is a module mock, and later tests in this file assume an + // in-workspace target. + mockedIsPathOutsideWorkspace.mockReturnValue(false) + }) + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { const guardError = new Error("File not read yet -- read the file, then retry.") mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index be9302eefb..b2ff2e2c3e 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -464,6 +464,7 @@ describe("searchReplaceTool", () => { true, 1000, "edit", + undefined, false, ) expect(mockCline.didEditFile).toBe(true) @@ -471,6 +472,18 @@ describe("searchReplaceTool", () => { expect(mockHandleError).not.toHaveBeenCalled() }) + it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { + mockedIsPathOutsideWorkspace.mockReturnValue(true) + await executeSearchReplaceTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) + const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + + // Reset: the flag is a module mock, and later tests in this file assume an + // in-workspace target. + mockedIsPathOutsideWorkspace.mockReturnValue(false) + }) + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { const guardError = new Error("File not read yet -- read the file, then retry.") mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index fab813d33a..29d5fe5d2c 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -484,6 +484,7 @@ describe("writeToFileTool", () => { true, 1000, "create", + undefined, false, ) expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() @@ -494,6 +495,24 @@ describe("writeToFileTool", () => { expect(mockHandleError).not.toHaveBeenCalled() }) + it("forwards an approved outside-workspace write as approved, not as completeness", async () => { + // Both trailing parameters are boolean | undefined, so only an assertion on the + + // positions catches a swap: seventh is completeOverride, eighth is the approval + + // flag. + + mockedIsPathOutsideWorkspace.mockReturnValue(true) + await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + + // Reset: the flag is a module mock, and later tests in this file assume an + // in-workspace target. + mockedIsPathOutsideWorkspace.mockReturnValue(false) + }) + it("surfaces the unobserved-existing remediation as a tool error and publishes nothing", async () => { const guardError = new Error( `File already exists at ${absoluteFilePath} and was not read before this write -- read the file first, then retry.`, From cd5bfdca8f3029ef339aeab2cd290f27d80f34bc Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:41:26 +0800 Subject: [PATCH 041/115] fix(guardedWrite): one unresolvable workspace folder must not veto every write Inline 4225764607 on #1917 (Minor, Stability), verified against the code first. assertCanonicalInsideWorkspace resolved every workspace root up front and refused the write if ANY of them failed to canonicalize. In a multi-root workspace that turns one broken folder - deleted, on an unmounted drive, EACCES on a network share - into a global write outage: every guarded write in every other folder is refused, including writes whose containment is fully decidable. Fix: a root that cannot be canonicalized is dropped from the allow-list instead of aborting the check. Containment only gets narrower - a target is admitted only when a root that DID resolve contains it, so the broken root never vouches for anything - and a target that only the broken root could have covered still falls through to a refusal, now with a message that says which situation happened. When no root resolves at all the behaviour is unchanged: the write is refused. Tests: two new cases in guardedWrite.spec.ts - a write inside a resolvable folder is published while a second folder is unresolvable, and a write that lives only under the unresolvable folder is still refused. Negative control, measured in this harness: restoring the per-root throw -> exactly 2 failed (both new cases), all other 60 pass. Restore byte-identical; baseline after the sweep 62 passed. Probe run before the change confirmed the hole was reachable: with the root throw in place the resolvable-folder case failed. src-level tsc at this branch's baseline (50 error lines, none in guardedWrite), eslint --max-warnings=0 clean, eslint-suppressions.json byte-identical. --- src/core/tools/__tests__/guardedWrite.spec.ts | 41 +++++++++++++++++++ src/core/tools/guardedWrite.ts | 19 +++++---- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 8639a112e7..60caa74f2b 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -299,6 +299,47 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedWithFileLock).not.toHaveBeenCalled() }) + it("accepts a write in a workspace folder that does resolve when another folder cannot", async () => { + // A second workspace folder that cannot be canonicalized must not veto every guarded + // write. It is dropped from the allow-list, which only narrows what is admitted: + // this write is inside a folder that DID resolve. + mockedFsRealpath.mockImplementation(async (p) => { + if (String(p) === path.resolve(WORKSPACE)) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + return String(p) + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + + await guardedWrite(task, "/other-folder/in.txt", "data", "create", undefined, { + additionalRoots: ["/other-folder"], + }) + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("/other-folder/in.txt"), "data") + }) + + it("still refuses a write that lives only under the folder that cannot be resolved", async () => { + // Dropping the broken root narrows the allow-list, it does not widen it: a target + // that only the broken root could have vouched for is still refused. + mockedFsRealpath.mockImplementation(async (p) => { + if (String(p) === path.resolve(WORKSPACE)) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + return String(p) + }) + const task = createMockTask() + + await expect( + guardedWrite(task, "inside.txt", "data", "create", undefined, { + additionalRoots: ["/other-folder"], + }), + ).rejects.toThrow("not inside any workspace folder that could be resolved") + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + it("refuses a target that runs through a dangling symlink ancestor", async () => { // A component of the path exists as a symlink whose referent is gone. realpath // reports ENOENT for it, which is also what a not-yet-created directory diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 7caa64fb38..90927d17a6 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -359,18 +359,17 @@ async function assertCanonicalInsideWorkspace( displayPath: string, ): Promise { const resolvedRoots: string[] = [] + const unresolvedRoots: string[] = [] for (const root of roots) { try { resolvedRoots.push(await fs.realpath(root)) } catch { - // ENOENT is not a safe fallback either: a workspace directory that is not on - // disk cannot contain anything, and the lexical check that already ran is the - // very check a planted symlink defeats. Every failure to canonicalize a root - // therefore refuses the write. - throw new GuardRejectedError( - "Workspace could not be resolved, so this write cannot be checked against it -- retry with a path inside the workspace.", - displayPath, - ) + // A root that cannot be canonicalized is dropped from the ALLOW-LIST rather than + // refusing every write in a multi-root workspace. Containment only gets narrower: + // a target is still accepted only if a root that DID resolve contains it, so the + // broken root never vouches for anything. A write that lives only under the broken + // root falls through to the refusal below. + unresolvedRoots.push(root) } } if (resolvedRoots.length === 0) { @@ -382,7 +381,9 @@ async function assertCanonicalInsideWorkspace( const target = await realpathNearest(absolutePath, displayPath) if (!resolvedRoots.some((root) => isInside(root, target))) { throw new GuardRejectedError( - `Path resolves through a link to outside the workspace -- write a real file inside the workspace, then retry.`, + unresolvedRoots.length > 0 + ? `Path is not inside any workspace folder that could be resolved -- retry with a path inside the workspace.` + : `Path resolves through a link to outside the workspace -- write a real file inside the workspace, then retry.`, displayPath, ) } From 6b68a57d5ad44f2a35ed3c3ed293d516fe88bf4e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 10:41:00 +0800 Subject: [PATCH 042/115] fix(guardedWrite): bind an approved outside-workspace write to the identity approved The approved path re-checked the target's identity against a baseline the guard took from its OWN lookup. Every lookup it performs runs after the approval, so a name repointed between the approval and the publish was captured as the baseline and the write was published to whatever the name pointed at by then - the user had approved a different file. Measured on unmodified production before this change: repointing the approved target at a link before the guard ran let the write through. GuardedWriteOptions.approvedCanonicalTarget carries the canonical identity captured before the approval was asked; canonicalizeForApproval is exported so the tool layer can capture it. The guard now binds the publish to that value and only compares: an approved write with no captured identity is refused instead of trusting a post-approval lookup. DiffViewProvider.saveChanges and saveDirectly thread it, and the five write tools capture it right after classifying the path, before askApproval; the patch move path binds the destination at classification time. Tests: a name repointed before the guard ran is refused; an approved write with no captured identity is refused. Negative controls measured in this harness: dropping the identity comparison -> 2 failed; taking the baseline from the post-approval lookup with the caller capture dropped -> 1 failed; accepting a missing capture -> 1 failed. Baseline after the sweep 352 passed / 5 skipped across the nine affected specs; tsc at its 50 baseline; eslint clean with suppressions unchanged. --- src/core/tools/ApplyPatchTool.ts | 25 +++++++++- src/core/tools/EditFileTool.ts | 9 ++++ src/core/tools/EditTool.ts | 9 ++++ src/core/tools/SearchReplaceTool.ts | 9 ++++ src/core/tools/WriteToFileTool.ts | 9 ++++ .../__tests__/applyPatchTool.execute.spec.ts | 8 +++- src/core/tools/__tests__/editFileTool.spec.ts | 9 +++- src/core/tools/__tests__/editTool.spec.ts | 6 ++- src/core/tools/__tests__/guardedWrite.spec.ts | 40 ++++++++++++++++ .../tools/__tests__/searchReplaceTool.spec.ts | 6 ++- .../tools/__tests__/writeToFileTool.spec.ts | 4 ++ src/core/tools/guardedWrite.ts | 46 ++++++++++++++++--- src/integrations/editor/DiffViewProvider.ts | 10 ++++ .../editor/__tests__/DiffViewProvider.spec.ts | 2 +- 14 files changed, 177 insertions(+), 15 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 924ce91c65..ff01563100 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -12,7 +12,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import { versionTokenOfStat } from "../../utils/versionToken" -import { GuardRejectedError, errorCode } from "./guardedWrite" +import { canonicalizeForApproval, GuardRejectedError, errorCode } from "./guardedWrite" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { parsePatch, ParseError, processAllHunks } from "./apply-patch" @@ -187,6 +187,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const newContent = change.newContent || "" const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined // Initialize diff view for new file task.diffViewProvider.editType = "create" @@ -254,6 +260,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // own); the eighth is the approval flag for a target outside every workspace root. undefined, isOutsideWorkspace, + approvedCanonicalTarget, ) } else { // The add path publishes a whole new file, so create-guard semantics @@ -263,6 +270,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { writeDelayMs, "create", isOutsideWorkspace, + approvedCanonicalTarget, ) } @@ -357,6 +365,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const originalContent = change.originalContent || "" const newContent = change.newContent || "" const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined // Initialize diff view task.diffViewProvider.editType = "modify" @@ -443,6 +457,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Check if destination path is outside workspace const isMoveOutsideWorkspace = isPathOutsideWorkspace(moveAbsolutePath) + // Bound at classification time: the patch approval covers the whole patch, so the + // destination's identity is captured here - before the publish and before the FIFO + // queue - and the guard refuses a destination that no longer resolves to it. + const moveCanonicalTarget = isMoveOutsideWorkspace + ? await canonicalizeForApproval(moveAbsolutePath, change.movePath) + : undefined if (isMoveOutsideWorkspace) { task.consecutiveMistakeCount++ task.recordToolError("apply_patch") @@ -510,6 +530,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { sourceComplete, // The user approved the whole patch, which names this destination. isPathOutsideWorkspace(moveAbsolutePath), + moveCanonicalTarget, ) } else { // Write to new path and delete old file @@ -543,6 +564,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // own); the eighth is the approval flag for a target outside every workspace root. undefined, isOutsideWorkspace, + approvedCanonicalTarget, ) } else { // The diff-view save is the same targeted hunk as the guarded save above: @@ -553,6 +575,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { writeDelayMs, "edit", isOutsideWorkspace, + approvedCanonicalTarget, ) } diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 311c5a4245..963c6aabff 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -14,6 +14,7 @@ import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { canonicalizeForApproval } from "./guardedWrite" interface EditFileParams { file_path: string @@ -400,6 +401,12 @@ export class EditFileTool extends BaseTool<"edit_file"> { const sanitizedDiff = sanitizeUnifiedDiff(diff || "") const diffStats = computeDiffStats(sanitizedDiff) || undefined const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined const sharedMessageProps: ClineSayTool = { tool: isNewFile ? "newFileCreated" : "appliedDiff", @@ -450,6 +457,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { // own); the eighth is the approval flag for a target outside every workspace root. undefined, isOutsideWorkspace, + approvedCanonicalTarget, ) } else { // Call saveChanges to update the DiffViewProvider properties @@ -458,6 +466,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { writeDelayMs, isNewFile ? "create" : "edit", isOutsideWorkspace, + approvedCanonicalTarget, ) } diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 4a562dd2fc..f801b098f4 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -14,6 +14,7 @@ import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { canonicalizeForApproval } from "./guardedWrite" interface EditParams { file_path: string @@ -175,6 +176,12 @@ export class EditTool extends BaseTool<"edit"> { const sanitizedDiff = sanitizeUnifiedDiff(diff) const diffStats = computeDiffStats(sanitizedDiff) || undefined const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", @@ -224,6 +231,7 @@ export class EditTool extends BaseTool<"edit"> { // own); the eighth is the approval flag for a target outside every workspace root. undefined, isOutsideWorkspace, + approvedCanonicalTarget, ) } else { // Call saveChanges to update the DiffViewProvider properties @@ -232,6 +240,7 @@ export class EditTool extends BaseTool<"edit"> { writeDelayMs, "edit", isOutsideWorkspace, + approvedCanonicalTarget, ) } diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index 12ac3f75a6..41b7c8061b 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -14,6 +14,7 @@ import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { canonicalizeForApproval } from "./guardedWrite" interface SearchReplaceParams { file_path: string @@ -171,6 +172,12 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { const sanitizedDiff = sanitizeUnifiedDiff(diff) const diffStats = computeDiffStats(sanitizedDiff) || undefined const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", @@ -220,6 +227,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { // own); the eighth is the approval flag for a target outside every workspace root. undefined, isOutsideWorkspace, + approvedCanonicalTarget, ) } else { // Call saveChanges to update the DiffViewProvider properties @@ -228,6 +236,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { writeDelayMs, "edit", isOutsideWorkspace, + approvedCanonicalTarget, ) } diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index be649e316a..dfd3262d2b 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -16,6 +16,7 @@ import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } fr import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { canonicalizeForApproval } from "./guardedWrite" interface WriteToFileParams { path: string @@ -86,6 +87,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const fullPath = relPath ? path.resolve(task.cwd, relPath) : "" const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined const sharedMessageProps: ClineSayTool = { tool: fileExists ? "editedExistingFile" : "newFileCreated", @@ -145,6 +152,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // own); the eighth is the approval flag for a target outside every workspace root. undefined, isOutsideWorkspace, + approvedCanonicalTarget, ) } else { if (!task.diffViewProvider.isEditing) { @@ -182,6 +190,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { writeDelayMs, undefined, isOutsideWorkspace, + approvedCanonicalTarget, ) } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index d28aa5c839..4f47cd8c0b 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -273,6 +273,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { "edit", undefined, false, + undefined, ) expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockTask.didEditFile).toBe(true) @@ -304,6 +305,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { "edit", undefined, false, + undefined, ) await expect(guardedWrite(mockTask as Task, "src/thing.ts", "full replacement", "update")).rejects.toThrow( @@ -478,6 +480,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { "create", undefined, false, + undefined, ) expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockTask.didEditFile).toBe(true) @@ -503,6 +506,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { "create", false, false, + undefined, ) }) @@ -711,7 +715,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit", false) + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit", false, undefined) expect(mockSaveDirectly).not.toHaveBeenCalled() expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockHandleError).not.toHaveBeenCalled() @@ -727,7 +731,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create", false) + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create", false, undefined) expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockHandleError).not.toHaveBeenCalled() }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index bce5bccbc2..d2a65c68da 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -10,6 +10,9 @@ import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from import { editFileTool } from "../EditFileTool" vi.mock("fs/promises", () => ({ + // guardedWrite resolves the target through a namespace import, so the double has + // to expose realpath as a named export as well as on the default object. + realpath: vi.fn(async (p: string) => String(p)), default: { readFile: vi.fn().mockResolvedValue(""), }, @@ -566,7 +569,7 @@ describe("editFileTool", () => { await executeEditFileTool() - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false) + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false, undefined) expect(mockTask.didEditFile).toBe(true) }) @@ -578,7 +581,7 @@ describe("editFileTool", () => { await executeEditFileTool({ old_string: "", new_string: "New file content" }, { fileExists: false }) - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "create", false) + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "create", false, undefined) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. expect(mockTask.recordToolUsage).not.toHaveBeenCalled() @@ -724,6 +727,7 @@ describe("editFileTool", () => { "edit", undefined, false, + undefined, ) expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() expect(mockTask.didEditFile).toBe(true) @@ -758,6 +762,7 @@ describe("editFileTool", () => { "create", undefined, false, + undefined, ) expect(mockTask.didEditFile).toBe(true) expect(mockHandleError).not.toHaveBeenCalled() diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index 0ee09b8160..bab2b8e85c 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -10,6 +10,9 @@ import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from import { editTool } from "../EditTool" vi.mock("fs/promises", () => ({ + // guardedWrite resolves the target through a namespace import, so the double has + // to expose realpath as a named export as well as on the default object. + realpath: vi.fn(async (p: string) => String(p)), default: { readFile: vi.fn().mockResolvedValue(""), }, @@ -351,7 +354,7 @@ describe("editTool", () => { await executeEditTool() - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false) + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false, undefined) expect(mockTask.didEditFile).toBe(true) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. @@ -451,6 +454,7 @@ describe("editTool", () => { "edit", undefined, false, + undefined, ) expect(mockTask.didEditFile).toBe(true) expect(result).toBe("Tool result message") diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 60caa74f2b..fc9d76a10a 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -417,6 +417,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "/elsewhere/outside.txt", "data", "create", undefined, { approvedOutsideWorkspace: true, + approvedCanonicalTarget: abs("/elsewhere/outside.txt"), }) expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("/elsewhere/outside.txt"), "data") @@ -452,6 +453,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { await expect( guardedWrite(task, "/elsewhere/outside.txt", "data", "create", undefined, { approvedOutsideWorkspace: true, + approvedCanonicalTarget: "/real/approved/outside.txt", }), ).rejects.toThrow("no longer resolves to the path that was approved") @@ -459,6 +461,44 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).not.toHaveBeenCalled() }) + it("refuses an approved write whose name was repointed before the guard ran", async () => { + // The user approved /elsewhere/approved.txt. Before the guarded publish the name is + // repointed at a link to /elsewhere/victim.txt, so EVERY lookup the guard performs + // sees the swapped identity. A baseline taken from any lookup would accept this + // write; the identity captured before the approval does not. + mockedFsRealpath.mockImplementation(async (p) => + String(p).startsWith("/elsewhere") ? "/elsewhere/victim.txt" : String(p)) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await expect( + guardedWrite(task, "/elsewhere/approved.txt", "data", "create", undefined, { + approvedOutsideWorkspace: true, + approvedCanonicalTarget: "/elsewhere/approved.txt", + }), + ).rejects.toThrow("no longer resolves to the path that was approved") + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("refuses an approved write that carries no identity captured before the approval", async () => { + // Without the pre-approval identity there is nothing to bind the publish to, so the + // approved path cannot be taken at all: fail closed rather than trusting a lookup + // performed after the approval. + mockedFsRealpath.mockImplementation(async (p) => String(p)) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await expect( + guardedWrite(task, "/elsewhere/outside.txt", "data", "create", undefined, { + approvedOutsideWorkspace: true, + }), + ).rejects.toThrow("was not captured before approval") + + expect(mockedWithFileLock).not.toHaveBeenCalled() + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + it("contains a write in another workspace folder named by the caller", async () => { // Multi-root workspace: the tool layer treats a path in a second folder as an // ordinary in-workspace edit and asks for no approval, so the guard must accept diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index b2ff2e2c3e..2be86c92b2 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -10,6 +10,9 @@ import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from import { searchReplaceTool } from "../SearchReplaceTool" vi.mock("fs/promises", () => ({ + // guardedWrite resolves the target through a namespace import, so the double has + // to expose realpath as a named export as well as on the default object. + realpath: vi.fn(async (p: string) => String(p)), default: { readFile: vi.fn().mockResolvedValue(""), }, @@ -320,7 +323,7 @@ describe("searchReplaceTool", () => { await executeSearchReplaceTool() - expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false) + expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false, undefined) expect(mockCline.didEditFile).toBe(true) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. @@ -466,6 +469,7 @@ describe("searchReplaceTool", () => { "edit", undefined, false, + undefined, ) expect(mockCline.didEditFile).toBe(true) expect(result).toBe("Tool result message") diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 29d5fe5d2c..659aae538b 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -28,6 +28,9 @@ vi.mock("delay", () => ({ // The focus-disruption save path reads the original file content via fs.readFile. vi.mock("fs/promises", () => ({ + // guardedWrite resolves the target through a namespace import, so the double has + // to expose realpath as a named export as well as on the default object. + realpath: vi.fn(async (p: string) => String(p)), default: { readFile: vi.fn().mockResolvedValue("original content"), }, @@ -486,6 +489,7 @@ describe("writeToFileTool", () => { "create", undefined, false, + undefined, ) expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 90927d17a6..8d57f0cd25 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -47,6 +47,15 @@ export interface GuardedWriteOptions { * containment checks in force. */ approvedOutsideWorkspace?: boolean + /** + * The canonical identity of the target, captured BEFORE the approval was asked, + * for an approved outside-workspace write. The guard binds the publish to it, so a + * name repointed between the approval and the publish resolves elsewhere and is + * refused. The guard cannot derive it itself: any lookup it performs happens after + * the approval, and would capture the swapped identity as the baseline. Required + * whenever approvedOutsideWorkspace is set. + */ + approvedCanonicalTarget?: string /** * Roots other than task.cwd that a write may be contained in - the other VS Code * workspace folders. The tool layer classifies paths against ALL workspace folders @@ -437,6 +446,18 @@ async function realpathNearest(target: string, displayPath: string): Promise { + return realpathNearest(target, displayPath ?? target) + } + /** * Guarded write entry point. * @@ -489,19 +510,30 @@ export async function guardedWrite( // Bound to the task and the caller's spelling so the guard can re-run the same // decision under the lock, immediately before the publish. let authorizedTarget: string | undefined + if (approvedOutside) { + // The identity has to come from the caller, captured before the approval was + // asked. A lookup performed here runs after the approval, so a name repointed in + // between would be captured as the baseline and the swapped write accepted. + if (options?.approvedCanonicalTarget === undefined) { + throw new GuardRejectedError( + "The approved target was not captured before approval, so this write cannot be bound to the path that was approved -- re-approve the write, then retry.", + displayPath, + ) + } + authorizedTarget = options.approvedCanonicalTarget + } const verifyTarget = async (): Promise => { if (!approvedOutside) { await assertCanonicalInsideWorkspace(roots, absolutePath, displayPath) return } - // For an approved outside-workspace write the re-check is an IDENTITY check: - // the path that resolves now must still be the path that was approved. A symlink - // swapped in while this link waited on the chain would otherwise move the write - // somewhere the user never saw. + // For an approved outside-workspace write the re-check is an IDENTITY check: what + // resolves now must still be what was approved. A symlink swapped in - before this + // call or while the link waited on the chain - moves the write somewhere the user + // never saw, so the comparison is against the identity captured before the + // approval, never against a lookup done here. const resolved = await realpathNearest(absolutePath, displayPath) - if (authorizedTarget === undefined) { - authorizedTarget = resolved - } else if (authorizedTarget !== resolved) { + if (authorizedTarget !== resolved) { throw new GuardRejectedError( "The approved target no longer resolves to the path that was approved -- re-approve the write, then retry.", displayPath, diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index d4ab99b140..c11b92f89f 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -514,6 +514,10 @@ export class DiffViewProvider { // for a target outside every workspace root. Only that post-approval path sets // it; unapproved saves keep the guard's containment checks. approvedOutsideWorkspace?: boolean, + // The canonical identity of the approved target, captured by the tool BEFORE it + // asked for approval. Threaded to guardedWrite, which refuses a publish whose name + // no longer resolves to it. + approvedCanonicalTarget?: string, ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -570,6 +574,7 @@ export class DiffViewProvider { } await guardedWrite(saveTask, this.relPath, encodedContent, writeKind, undefined, { approvedOutsideWorkspace: approvedOutsideWorkspace === true, + approvedCanonicalTarget, additionalRoots: this.additionalWorkspaceRoots(), }) } catch (error) { @@ -1610,6 +1615,10 @@ export class DiffViewProvider { completeOverride?: boolean, // Approved by the user for a target outside every workspace root (see saveChanges). approvedOutsideWorkspace?: boolean, + // The canonical identity of the approved target, captured by the tool BEFORE it + // asked for approval. Threaded to guardedWrite, which refuses a publish whose name + // no longer resolves to it. + approvedCanonicalTarget?: string, ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -1636,6 +1645,7 @@ export class DiffViewProvider { // workspace, where a rejected write must not leave a trace. await guardedWrite(task, relPath, content, writeKind, completeOverride, { approvedOutsideWorkspace: approvedOutsideWorkspace === true, + approvedCanonicalTarget, additionalRoots: this.additionalWorkspaceRoots(), }) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index dc4b443eb8..d6ac097c15 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1087,7 +1087,7 @@ describe("DiffViewProvider", () => { vi.mocked(fs.access).mockRejectedValue({ code: "ENOENT" }) vi.mocked(computeVersionToken).mockResolvedValue("v1") - await diffViewProvider.saveDirectly("../outside.ts", "new content", false, true, 1000, "create", undefined, true) + await diffViewProvider.saveDirectly("../outside.ts", "new content", false, true, 1000, "create", undefined, true, path.resolve(mockCwd, "../outside.ts")) const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/../outside.ts`, "new content") From 510effe9299d889ae9267c1aaa9b850684b2b035 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 11:16:59 +0800 Subject: [PATCH 043/115] fix(diff-view): keep the teardown guard held through the finalization steps runTeardown() released teardownInFlight as soon as its cleanup callback settled, but the owner of a pass still had steps to run afterwards - the preview-tab restore, and reset() in revertChanges(). A cancellation landing in that window saw no teardown in flight and started a second pass: the document was reverted again, the same tabs were closed again, and the tabs were restored and the provider reset twice. runTeardown() now takes the finalization as a second argument and tracks a gate promise that spans cleanup and finalization, so a late caller waits for the whole pass instead of joining only its first half. The two call sites with post-callback steps (the saveChanges error path and revertChanges) pass their tail into that argument; the post-publish cleanup already kept everything inside its callback and is unchanged. Test: revertChanges() holds the teardown through its finalization steps - the finalization is held on a gate and a second revertChanges() is fired inside that window; applyEdit, closeAllDiffViews, restorePreviewTabs and reset must each have run exactly once. It fails on the previous shape (applyEdit twice). Negative controls measured in this harness: releasing the guard as soon as the cleanup callback settles -> 1 failed (applyEdit 2 times); not awaiting the finalization inside the guard -> 1 failed (applyEdit 2 times). Restores byte-identical. Ported so the two units do not evolve the same teardown differently. Baseline after --- src/integrations/editor/DiffViewProvider.ts | 67 ++++++++++++------- .../editor/__tests__/DiffViewProvider.spec.ts | 53 +++++++++++++++ 2 files changed, 97 insertions(+), 23 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index c11b92f89f..15f032a121 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -624,7 +624,7 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - const ownedTeardown = await this.runTeardown(async () => { + await this.runTeardown(async () => { let discardSucceeded = !updatedDocument.isDirty if (updatedDocument.isDirty) { discardSucceeded = await this.revertDocument(updatedDocument) @@ -670,15 +670,18 @@ export class DiffViewProvider { }) } await this.closeOwnDiffView(absolutePath) - }) - - if (ownedTeardown) { + }, + // The tail of the pass belongs to the same guard: a caller that joined this teardown + // must not repeat it, and a caller that lands while these steps are still running must + // not start a new pass. + async () => { // Opening the diff evicted any preview tab the file had. This path closes its own diff // view and rethrows, so this pass is the only one that can put that preview state back; // a caller that merely waited for it must not restore the tabs a second time. reset() // stays with the tool caller's error handling, which owns the provider lifecycle. await this.restorePreviewTabs() - } + }, + ) } catch { // cleanup is best-effort; the guard verdict below is the outcome } @@ -902,7 +905,7 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - const ownedTeardown = await this.runTeardown(async () => { + await this.runTeardown(async () => { if (!fileExists) { if (updatedDocument.isDirty) { await updatedDocument.save() @@ -951,21 +954,22 @@ export class DiffViewProvider { revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, ) } - }) - - if (!ownedTeardown) { + }, + // The tail of the pass belongs to the same guard: a caller that joined this teardown + // must not repeat it, and a caller that lands while these steps are still running must + // not start a new pass. + async () => { // Another teardown started first and already ran the pass over this session's buffers, // tabs and preview state. Restoring preview tabs or resetting here again would be that // same work a second time, and the reset would clear state whose closing this provider // does not own. The pass that started the teardown finalizes the session. - return - } - // Restore any preview tabs the diff evicted, reconstructing the user's - // prior not-yet-edited tab state. - await this.restorePreviewTabs() - - // Edit is done. - await this.reset() + // Restore any preview tabs the diff evicted, reconstructing the user's + // prior not-yet-edited tab state. + // Edit is done. + await this.restorePreviewTabs() + await this.reset() + }, + ) } private async closeAllDiffViews(): Promise { @@ -1051,20 +1055,37 @@ export class DiffViewProvider { * both would edit the document and close the same tabs. The second caller awaits the * cleanup already in flight instead of repeating it. */ - private async runTeardown(cleanup: () => Promise): Promise { + private async runTeardown( + cleanup: () => Promise, + // Steps that belong to the same teardown pass but run after the cleanup callback: the + // preview-tab restore and reset(). They have to sit inside the tracked promise, or a + // cancellation landing after the callback settled and before these finished would see + // no teardown in flight and run the whole pass - document revert, tab closing, restore, + // reset - a second time. + finalize?: () => Promise, + ): Promise { if (this.teardownInFlight !== undefined) { await this.teardownInFlight - // The pass belongs to whoever started it; that caller also owns the finalization - // steps (preview tabs, reset), so the caller is told it did not own this pass. + // Not ours: the caller that started this teardown owns everything that + // belongs to it, including the steps that follow the callback. return false } - const inFlight = cleanup() + // A gate, not the cleanup promise itself: the tracked promise stays in place until + // the finalization settles, so a late caller waits for the whole pass. + let release!: () => void + const tracked = new Promise((resolve) => { + release = resolve + }) + this.teardownInFlight = tracked this.teardownPasses++ - this.teardownInFlight = inFlight try { - await inFlight + await cleanup() + if (finalize) { + await finalize() + } } finally { this.teardownInFlight = undefined + release() } return true } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index d6ac097c15..2abb59c2d8 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3077,6 +3077,59 @@ describe("DiffViewProvider", () => { expect(vscode.window.showTextDocument).not.toHaveBeenCalled() }) + it("revertChanges() holds the teardown through its finalization steps, not just the cleanup callback", async () => { + // The guard has to stay held until the owner's finalization - the preview-tab restore + // and reset() - settles as well. A cancellation that lands after the cleanup callback + // but before those steps finished sees no teardown in flight and runs the whole pass a + // second time: the document is reverted again, the same tabs are closed again, and the + // tabs are restored and the provider reset twice. + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + const closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + const editor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + diffViewProvider["activeDiffEditor"] = editor + // The finalization is held open on a gate: that is exactly the window a real + // cancellation lands in - the cleanup callback has settled, the tabs are not back yet. + let releaseFinalization!: () => void + const finalizationGate = new Promise((resolve) => { + releaseFinalization = resolve + }) + const restorePreviewTabs = vi.fn().mockImplementation(async () => { + await finalizationGate + }) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + + const first = diffViewProvider.revertChanges() + // Join only once the owner is INSIDE its finalization, so the second caller is + // testing the window the guard used to release early on. + for (let i = 0; i < 100 && restorePreviewTabs.mock.calls.length === 0; i++) { + await new Promise((resolve) => setImmediate(resolve)) + } + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + const second = diffViewProvider.revertChanges() + releaseFinalization() + await Promise.all([first, second]) + + expect(applyEdit).toHaveBeenCalledTimes(1) + expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + }) + it("revertChanges() does not run a second teardown while one is already in flight", async () => { // Cancellation can reach revertChanges() while a rejected save is still // discarding the same buffer. Both paths acting on the document and the same From 376a7b95f0febc8a35b9876c110d27947737d290 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 11:44:37 +0800 Subject: [PATCH 044/115] fix(write-to-file): resolve the approved identity inside the tool's error handling The outside-workspace identity capture sat before the try block of the write flow, so a target that could not be resolved threw past the tool: no handleError, no diff-view reset, and the failure escaped a path that is supposed to report it to the model like any other write failure. The capture now sits at the top of that try block - still before askApproval, so the approval is still bound to an identity captured ahead of it, and a path that cannot be resolved is never put in front of the user at all. Test: an outside-workspace target whose resolution fails reaches handleError("writing file", ...), resets the diff view, and publishes nothing. Negative control measured in this harness: moving the capture back outside the try -> 1 failed, with the GuardRejectedError escaping the tool instead of being handled. Restore byte-identical. Baseline after the sweep: 354 passed / 5 skipped across the nine affected specs (u7), 355 / 5 (u9); tsc at its pre-existing 50-error baseline; eslint --max-warnings=0 clean; src/eslint-suppressions.json untouched. --- src/core/tools/WriteToFileTool.ts | 15 ++++++------- .../tools/__tests__/writeToFileTool.spec.ts | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index dfd3262d2b..5d7e81735e 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -87,13 +87,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const fullPath = relPath ? path.resolve(task.cwd, relPath) : "" const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - // Captured before the approval is asked: guardedWrite binds the publish to this - // identity, so a name repointed between the approval and the publish is refused - // instead of publishing to whatever the name points at by then. - const approvedCanonicalTarget = isOutsideWorkspace - ? await canonicalizeForApproval(absolutePath, relPath) - : undefined - const sharedMessageProps: ClineSayTool = { tool: fileExists ? "editedExistingFile" : "newFileCreated", path: getReadablePath(task.cwd, relPath), @@ -103,6 +96,14 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } try { + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. Inside the try so a + // target that cannot be resolved goes through the tool's normal error handling + // (handleError and the diff-view reset) instead of escaping the write flow. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined task.consecutiveMistakeCount = 0 const provider = task.providerRef.deref() diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 659aae538b..c9b50f6238 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -517,6 +517,27 @@ describe("writeToFileTool", () => { mockedIsPathOutsideWorkspace.mockReturnValue(false) }) + it("routes an outside-workspace target that cannot be resolved through the tool's error handling", async () => { + // The identity capture runs before the approval is asked, so a target whose + // resolution fails must not escape the write flow: it has to reach handleError and + // the diff-view reset like every other failure of this tool, and it must not get + // as far as asking the user about a path that cannot be checked. + const { realpath } = await import("fs/promises") + vi.mocked(realpath).mockRejectedValue({ code: "EPERM" }) + mockedIsPathOutsideWorkspace.mockReturnValue(true) + + await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + + // Reset both doubles: later tests in this file assume an in-workspace target that + // resolves to itself. + vi.mocked(realpath).mockImplementation(async (p) => String(p)) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + }) + it("surfaces the unobserved-existing remediation as a tool error and publishes nothing", async () => { const guardError = new Error( `File already exists at ${absoluteFilePath} and was not read before this write -- read the file first, then retry.`, From d63ba737658c2801b04c39aee51f166c7da76ae9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 11:53:22 +0800 Subject: [PATCH 045/115] test(tools): keep the outside-workspace flag reset on the failure path Two review findings about the tests added for the approved-identity work: - The outside-workspace forwarding tests reset the isPathOutsideWorkspace module mock after their assertions, so a failing assertion would leave the flag true and the tests that follow would fail for the wrong reason. The reset now sits in a finally block. - The failed-delete test in the delete-semantics spec carried its explanation next to the assertions while the seeding it describes happens above the delete. The comment now points back at that seeding instead of implying it happens at the assertions. Measured in this harness: with the assertion inside the wrapped test broken on purpose, the file still reports exactly one failure - the flag leak does not currently surface as a wrong-cause failure in these four specs. The reset is moved for the failure path, not because a cascade was reproduced. Baselines after the sweep: 370 passed / 5 skipped across the ten affected specs (u9), 354 / 5 (u7); tsc at its pre-existing 50-error baseline; eslint --max-warnings=0 clean on every touched file; src/eslint-suppressions.json untouched. --- src/core/tools/__tests__/editFileTool.spec.ts | 17 ++++++++++------- src/core/tools/__tests__/editTool.spec.ts | 17 ++++++++++------- .../tools/__tests__/searchReplaceTool.spec.ts | 17 ++++++++++------- .../tools/__tests__/writeToFileTool.spec.ts | 17 ++++++++++------- 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index d2a65c68da..1fc10958b2 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -737,14 +737,17 @@ describe("editFileTool", () => { it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { mockedIsPathOutsideWorkspace.mockReturnValue(true) - await executeEditFileTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) - const args = mockTask.diffViewProvider.saveDirectly.mock.calls.at(-1)! - expect(args[6]).toBeUndefined() - expect(args[7]).toBe(true) - - // Reset: the flag is a module mock, and later tests in this file assume an - // in-workspace target. + try { + await executeEditFileTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) + const args = mockTask.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + } finally { + // The flag is a module mock. A reset after the assertions only runs when they pass; + // a failing assertion would leave it true and the following tests would then fail for + // the wrong reason. mockedIsPathOutsideWorkspace.mockReturnValue(false) + } }) it("publishes new-file creation through saveDirectly with create kind", async () => { diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index bab2b8e85c..e5e3e805ef 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -463,14 +463,17 @@ describe("editTool", () => { it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { mockedIsPathOutsideWorkspace.mockReturnValue(true) - await executeEditTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) - const args = mockTask.diffViewProvider.saveDirectly.mock.calls.at(-1)! - expect(args[6]).toBeUndefined() - expect(args[7]).toBe(true) - - // Reset: the flag is a module mock, and later tests in this file assume an - // in-workspace target. + try { + await executeEditTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) + const args = mockTask.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + } finally { + // The flag is a module mock. A reset after the assertions only runs when they pass; + // a failing assertion would leave it true and the following tests would then fail for + // the wrong reason. mockedIsPathOutsideWorkspace.mockReturnValue(false) + } }) it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 2be86c92b2..a4999c3bfd 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -478,14 +478,17 @@ describe("searchReplaceTool", () => { it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { mockedIsPathOutsideWorkspace.mockReturnValue(true) - await executeSearchReplaceTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) - const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! - expect(args[6]).toBeUndefined() - expect(args[7]).toBe(true) - - // Reset: the flag is a module mock, and later tests in this file assume an - // in-workspace target. + try { + await executeSearchReplaceTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) + const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + } finally { + // The flag is a module mock. A reset after the assertions only runs when they pass; + // a failing assertion would leave it true and the following tests would then fail for + // the wrong reason. mockedIsPathOutsideWorkspace.mockReturnValue(false) + } }) it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index c9b50f6238..b8826e68d9 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -507,14 +507,17 @@ describe("writeToFileTool", () => { // flag. mockedIsPathOutsideWorkspace.mockReturnValue(true) - await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) - const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! - expect(args[6]).toBeUndefined() - expect(args[7]).toBe(true) - - // Reset: the flag is a module mock, and later tests in this file assume an - // in-workspace target. + try { + await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + } finally { + // The flag is a module mock. A reset after the assertions only runs when they pass; + // a failing assertion would leave it true and the following tests would then fail for + // the wrong reason. mockedIsPathOutsideWorkspace.mockReturnValue(false) + } }) it("routes an outside-workspace target that cannot be resolved through the tool's error handling", async () => { From 800916ea34de823973ad9417844d283768d1a84d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 15:41:06 +0800 Subject: [PATCH 046/115] fix(diff-view): finalize the session when a cancellation lands during a teardown it does not own Port of `0974ad534` (U8, #1916) to U7. Same defect, open on four units at their current heads - #1915 (U6), #1916 (U8), #1917 (U9), #1918 (U7, this PR) - and DiffViewProvider.ts already carries four distinct blobs across those heads (82b58576d / 44049b58e / 854569b96 / 15f032a12). Authored once in the unit that owns the save-gate teardown and ported in the declared merge order U1 U2 U3 U4 U5 U8 U6 U7 U9, so the blob table on easonLiangWorldedtech/Zoo-Code#41 traces every copy back to `0974ad534`. Task.disposeOnce() can reach revertChanges() while saveChanges() owns its post-publish teardown. runTeardown() made the caller a waiter and returned false, revertChanges() then skipped its finalization, and the save's own pass closes the views and restores the tabs but never resets - the tool caller that owns the provider lifecycle may never come back after a disposal. The provider was left with isEditing true and activeDiffEditor retained. - runTeardown() records that a cancellation is waiting on the pass that owns the session. - saveChanges() reports no completed save when a cancellation landed during its post-publish pass, instead of running diagnostics and the EOL/patch tail against provider state (newContent, relPath) that the finalization clears. - revertChanges() closes the session after the owning pass returned, when that pass did not. Port adaptations for this unit (this branch's runTeardown takes a second `finalize` argument, which U8's does not): - The finalization decision is a recorded flag, `teardownPassResets`, set inside revertChanges()'s own finalize step and cleared when a pass starts - not U8's `isEditing || activeDiffEditor` state check. On this branch the rejected save's discard pass also passes a finalize (it restores the preview tabs but does not reset), so "has a finalize step" is not the same question as "closes the session", and a state check would let a waiter reset a session the owner had already closed. - No `ownedTeardown` result check exists in this unit's saveChanges(), so the source commit's `!ownedTeardown` block was not ported; only the cancellation bail-out was added there. Verification on this unit: red first with the production hunks absent and the tests ported -> 4 failed | 141 passed. Green -> 145 passed. Negative control: removing the waiter finalization -> 4 failed | 141 passed, production file restored byte-exactly, post-restore run 145 passed. tsc --noEmit 50 errors, identical to this branch's baseline and 0 in the touched files; eslint --max-warnings=0 clean on both files; src/eslint-suppressions.json untouched. (cherry picked from commit 0974ad5344ae7d76f8ff277e56b0b6ebf0763a46) --- src/integrations/editor/DiffViewProvider.ts | 46 +++++- .../editor/__tests__/DiffViewProvider.spec.ts | 153 +++++++++++++++++- 2 files changed, 190 insertions(+), 9 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 15f032a121..e3592fa80a 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -44,6 +44,20 @@ export class DiffViewProvider { private documentWasPinned = false private relPath?: string private teardownInFlight: Promise | undefined + /** + * A cancellation (revertChanges) that arrived while another pass already owned this + * session. The owning pass reads it when its cleanup returns, so a save does not run + * diagnostics or read provider state that a cancellation is closing underneath it. + */ + private teardownCancellationRequested = false + /** + * Whether the teardown pass in flight closes the session itself. revertChanges() sets it + * inside its finalize step; every pass starts with it cleared. A caller that joined a pass + * that already reset must not reset again, while a pass that only closed views and restored + * tabs - a save's post-publish cleanup, a rejected save's discard cleanup - leaves the + * session open for the waiting caller to close. + */ + private teardownPassResets = false // Counts the teardown passes this provider has actually run; a caller that awaited an // in-flight pass does not count. A save uses it to tell its own post-publish cleanup apart // from a teardown a cancellation or disposal started underneath it: once any teardown has @@ -744,6 +758,15 @@ export class DiffViewProvider { await this.restorePreviewTabs() }) + if (this.teardownCancellationRequested) { + // A cancellation or disposal reached revertChanges() while this save owned the post-publish + // pass. The publish is on disk, but the session is closing: the waiting caller finalizes + // it, and diagnostics or the EOL/patch tail below read provider state (newContent, + // relPath) that the finalization clears. Report no completed save instead. + this.teardownCancellationRequested = false + return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } + } + // Getting diagnostics before and after the file edit is a better approach than // automatically tracking problems in real-time. This method ensures we only @@ -905,7 +928,7 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - await this.runTeardown(async () => { + const ownedTeardown = await this.runTeardown(async () => { if (!fileExists) { if (updatedDocument.isDirty) { await updatedDocument.save() @@ -966,10 +989,23 @@ export class DiffViewProvider { // Restore any preview tabs the diff evicted, reconstructing the user's // prior not-yet-edited tab state. // Edit is done. + // Record that this pass closes the session, so a caller that joined it does not reset + // a second time. + this.teardownPassResets = true await this.restorePreviewTabs() await this.reset() }, ) + + if (!ownedTeardown && !this.teardownPassResets) { + // The pass that started the teardown owns the tab work, so this caller must not repeat + // it. A pass that carries its own finalization (revertChanges passes do) has already + // closed the session. A pass without one - a save's post-publish cleanup, or a rejected + // save's discard cleanup - leaves the session open, and the tool caller whose error + // handling owns that reset may never come back after a cancellation or disposal. The + // owning pass has returned, so the session is closed once and only once. + await this.reset() + } } private async closeAllDiffViews(): Promise { @@ -1065,6 +1101,10 @@ export class DiffViewProvider { finalize?: () => Promise, ): Promise { if (this.teardownInFlight !== undefined) { + // Record the cancellation before waiting: the pass that owns the session has to know a + // cancellation is waiting on it, and the waiting caller must not leave the session + // mid-edit if that pass turns out not to finalize it. + this.teardownCancellationRequested = true await this.teardownInFlight // Not ours: the caller that started this teardown owns everything that // belongs to it, including the steps that follow the callback. @@ -1072,6 +1112,10 @@ export class DiffViewProvider { } // A gate, not the cleanup promise itself: the tracked promise stays in place until // the finalization settles, so a late caller waits for the whole pass. + // A pass starts clean: a cancellation recorded for an earlier pass must not leak into + // this one. + this.teardownCancellationRequested = false + this.teardownPassResets = false let release!: () => void const tracked = new Promise((resolve) => { release = resolve diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 2abb59c2d8..87914ce117 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3260,9 +3260,10 @@ describe("DiffViewProvider", () => { expect(applyEdit).not.toHaveBeenCalled() }) - // Finalization belongs to the teardown pass that started, not to every caller that waited - // for it: a second restorePreviewTabs()/reset() pair is the same cleanup running twice. - it("revertChanges() does not restore preview tabs or reset when it waited for another teardown", async () => { + // The pass that started the teardown owns the tab work, so a waiting caller must not + // restore the preview tabs twice. It still has to leave the session closed: the owning + // pass here is a save's post-publish cleanup, which has no finalize step of its own. + it("revertChanges() restores no preview tabs twice but finalizes the session the owning pass left open", async () => { let release: () => void = () => {} const gate = new Promise((resolve) => { release = resolve @@ -3304,10 +3305,143 @@ describe("DiffViewProvider", () => { release() await Promise.all([save, revert]) - // The owning pass restored the preview tabs exactly once, and the waiting caller did - // not finalize the session a second time. + // The owning pass restored the preview tabs exactly once, and the waiting caller + // finalized the session exactly once - the pass that ran the tabs does not reset, so + // the cancellation that waited on it closes the session instead of leaving it active. expect(restorePreviewTabs).toHaveBeenCalledTimes(1) - expect(reset).not.toHaveBeenCalled() + expect(reset).toHaveBeenCalledTimes(1) + expect(applyEdit).not.toHaveBeenCalled() + }) + + // The pre-merge row this covers: a cancellation or disposal that lands while the save owns + // its post-publish teardown used to leave the provider mid-edit. The save's pass closes the + // views and restores the tabs but never resets, the waiting revertChanges() returned without + // finalizing, and Task.disposeOnce() only awaits the reversion promise - so isEditing and the + // active editor survived the task that owned them. + it("saveChanges() finalizes the session when a cancellation lands during its post-publish teardown", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeAllDiffViews = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + const listener = { dispose: vi.fn() } + diffViewProvider["activeEditorListener"] = listener + diffViewProvider["deferredScrollTimer"] = setTimeout(() => {}, 10_000) + + const save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + + // Task.disposeOnce() reaches revertChanges() while the save's pass owns the session. + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + const [saveResult] = await Promise.all([save, revert]) + + // The session is closed: no edit flag, no retained editor, listeners and timer gone. + expect(diffViewProvider.isEditing).toBe(false) + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + expect(listener.dispose).toHaveBeenCalledTimes(1) + expect(diffViewProvider["activeEditorListener"]).toBeUndefined() + expect(diffViewProvider["deferredScrollTimer"]).toBeUndefined() + + // One pass over the tabs and buffers: the waiting cancellation repeated neither the + // document revert nor the preview-tab restoration. + expect(applyEdit).not.toHaveBeenCalled() + expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + + // A session a cancellation is closing reports no completed save, so no caller can present + // diagnostics or user-edit output for provider state that is already gone. + expect(saveResult).toEqual({ + newProblemsMessage: undefined, + userEdits: undefined, + finalContent: undefined, + }) + }) + + // The same gap on the other teardown owner: a rejected publish's discard cleanup never resets + // (the tool caller's error handling owns that), so a cancellation that only waited for it left + // the session mid-edit with nobody finalizing it. + it("a cancellation that waits for a rejected save's discard cleanup still finalizes the session", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeOwnDiffView = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // Restore the publish mock afterwards: a queued rejection would fail every later save. + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + type SaveResult = Awaited> + let settled: PromiseSettledResult[] | undefined + try { + const save: Promise = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + settled = await Promise.allSettled([save, revert]) + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + // The guard verdict still surfaces... + expect(settled?.[0].status).toBe("rejected") + // ...and the session that cancellation was waiting on is finalized. + expect(diffViewProvider.isEditing).toBe(false) + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + // The waiting cancellation did not repeat the document revert. expect(applyEdit).not.toHaveBeenCalled() }) @@ -3347,7 +3481,10 @@ describe("DiffViewProvider", () => { expect(restorePreviewTabs).toHaveBeenCalledTimes(1) }) - it("saveChanges() restores preview tabs once when a revert waits for the rejected save", async () => { + // The rejected save's discard cleanup restores the tabs once, and it never resets - the + // tool caller's error handling owns that reset. A cancellation that only waited for that + // pass still has to leave the session closed, so it performs the finalization. + it("saveChanges() restores preview tabs once and the waiting revert finalizes the session it left open", async () => { let release: () => void = () => {} const gate = new Promise((resolve) => { release = resolve @@ -3397,7 +3534,7 @@ describe("DiffViewProvider", () => { } expect(restorePreviewTabs).toHaveBeenCalledTimes(1) - expect(reset).not.toHaveBeenCalled() + expect(reset).toHaveBeenCalledTimes(1) }) // The other direction: the rejected save is the one that joins an existing pass. It then From 13fa14b897e04450fbd6299cfb84d30fbd518a7c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 21:41:53 +0800 Subject: [PATCH 047/115] fix(diff-view): one caller owns the session's finalization, by claim rather than by a flag Port of fc1c87f0c (landed on the U6 branch) to this unit. This unit carries the flag shape: the pass that resets recorded `teardownPassResets` and a caller that had only waited for the pass decided with `!ownedTeardown && !this.teardownPassResets`. A flag set by whichever pass happened to run first does not serialize the finalization: two cancellations that both waited on a pass which never resets - a save's post-publish cleanup, or a rejected save's discard cleanup - each saw the flag clear and each ran reset(). Measured before the change here: reset called twice for one session. - `teardownPassResets` is gone. `finalizeSession` claims the finalization: the first caller runs it, a caller arriving while it runs joins that attempt, a caller arriving afterwards does nothing. The revertChanges finalize and waiting callers both go through it. - The claim is per session and `open()` clears it, so a reused provider is finalizable again. - `reset()` also records the claim. Stated honestly that assignment is an invariant guard, not a reachable branch: reset clears relPath and activeDiffEditor, and every path that could reach finalizeSession returns early when either is missing, so no test reaches it. It is kept so that "a reset means the session is closed" holds at the reset itself. Tests ported with the fix. Red first with the tests ported and the production change absent: 2 failed | 146 passed. Green: 148 passed. Negative controls: claim removed entirely -> 5 failed (the two new cases plus three pre-existing teardown/finalization tests); in-flight dedup without the completed-claim check -> 2 failed, one more than on the U6/U8 branches because this unit's "revertChanges() holds the teardown through its finalization steps" test also depends on the completed claim; the guard inside reset() removed -> suite stays green at 148, reported with the reachability argument rather than labelled equivalent. Production file restored byte-exactly after the mutants (34a65032ec9f) and re-run at 148 passed. `tsc --noEmit` stays at this unit's 50-error baseline with no error in either touched file; eslint `--max-warnings=0` clean on both files; `src/eslint-suppressions.json` untouched. --- src/integrations/editor/DiffViewProvider.ts | 60 +++++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 115 ++++++++++++++++++ 2 files changed, 164 insertions(+), 11 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index e3592fa80a..bfffd5428b 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -57,7 +57,15 @@ export class DiffViewProvider { * tabs - a save's post-publish cleanup, a rejected save's discard cleanup - leaves the * session open for the waiting caller to close. */ - private teardownPassResets = false + /** + * Whether this edit session has been closed (its reset has been claimed). A caller that + * waited for someone else's teardown cannot decide this by reading a flag set by the pass it + * joined, nor by reading provider state: the state is exactly what the finalization changes, + * so two callers that both waited on a pass which never resets would each see the session + * still open and each close it. The claim makes "exactly one of us finalizes" a fact. + */ + private sessionFinalizationClaimed = false + private finalizationInFlight: Promise | undefined // Counts the teardown passes this provider has actually run; a caller that awaited an // in-flight pass does not count. A save uses it to tell its own post-publish cleanup apart // from a teardown a cancellation or disposal started underneath it: once any teardown has @@ -140,6 +148,10 @@ export class DiffViewProvider { // A new diff session may reuse this provider after a cancelled one, so the teardown marker // starts clean: otherwise every later save would report itself cancelled without publishing. this.teardownPasses = 0 + // A new session has to be finalizable: a provider that was reset once would otherwise + // report its finalization as already claimed. + this.sessionFinalizationClaimed = false + this.finalizationInFlight = undefined // Snapshot the authorization as it stands before this preview touches the // registry; saveChanges(..., "edit") restores it below. @@ -989,22 +1001,21 @@ export class DiffViewProvider { // Restore any preview tabs the diff evicted, reconstructing the user's // prior not-yet-edited tab state. // Edit is done. - // Record that this pass closes the session, so a caller that joined it does not reset - // a second time. - this.teardownPassResets = true + // The claim, not a flag read back afterwards, is what keeps a caller that joined this + // pass from closing the session a second time. await this.restorePreviewTabs() - await this.reset() + await this.finalizeSession(() => this.reset()) }, ) - if (!ownedTeardown && !this.teardownPassResets) { + if (!ownedTeardown) { // The pass that started the teardown owns the tab work, so this caller must not repeat // it. A pass that carries its own finalization (revertChanges passes do) has already - // closed the session. A pass without one - a save's post-publish cleanup, or a rejected + // claimed the close. A pass without one - a save's post-publish cleanup, or a rejected // save's discard cleanup - leaves the session open, and the tool caller whose error - // handling owns that reset may never come back after a cancellation or disposal. The - // owning pass has returned, so the session is closed once and only once. - await this.reset() + // handling owns that reset may never come back after a cancellation or disposal. Whoever + // gets here first claims it; a second waiter joins the claim instead of resetting again. + await this.finalizeSession(() => this.reset()) } } @@ -1115,7 +1126,6 @@ export class DiffViewProvider { // A pass starts clean: a cancellation recorded for an earlier pass must not leak into // this one. this.teardownCancellationRequested = false - this.teardownPassResets = false let release!: () => void const tracked = new Promise((resolve) => { release = resolve @@ -1134,6 +1144,31 @@ export class DiffViewProvider { return true } + /** + * Run the session's finalization exactly once. The first caller claims it and runs it; + * anyone who arrives while it is running awaits that attempt rather than starting their own, + * and anyone who arrives after it is done does nothing. A pass that owns its own teardown and + * a caller that merely waited for one go through here, which is what keeps "only one caller + * owns reset()" true regardless of how many cancellations piled onto the same pass. + */ + private async finalizeSession(finalize: () => Promise): Promise { + if (this.sessionFinalizationClaimed) { + return + } + if (this.finalizationInFlight) { + await this.finalizationInFlight + return + } + const inFlight = finalize() + this.finalizationInFlight = inFlight + try { + await inFlight + this.sessionFinalizationClaimed = true + } finally { + this.finalizationInFlight = undefined + } + } + // Stop tracking user activation of the target file. Called before any // programmatic showTextDocument so our own re-show never counts as a "touch". private disposeActiveEditorListener(): void { @@ -1616,6 +1651,9 @@ export class DiffViewProvider { } async reset(): Promise { + // A reset closes the session whoever calls it through, so the finalization is claimed + // here as well: a teardown waiter that arrives afterwards must not run a second one. + this.sessionFinalizationClaimed = true // Dispose touch listeners and cancel any pending deferred scroll BEFORE any // async editor manipulation. closeAllDiffViews() awaits tab-close operations, // so leaving listeners/timers live across that await could let a stale handler diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 87914ce117..184e74f437 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3537,6 +3537,121 @@ describe("DiffViewProvider", () => { expect(reset).toHaveBeenCalledTimes(1) }) + it("revertChanges() finalizes the session once when two cancellations wait on the same pass", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeOwnDiffView = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + let save: Promise = Promise.resolve() + try { + save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + // Two cancellations - a tool caller and a disposal, say - both join the pass that is + // already running. Neither owns it, and the owning pass never finalizes the session, + // so exactly one of them may close it. + const first = diffViewProvider.revertChanges() + const second = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + await expect(save).rejects.toThrow("guard rejected for test") + await Promise.all([first, second]) + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + }) + + it("revertChanges() does not finalize a session a second time after the first cancellation closed it", async () => { + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // Two cancellations one after the other: the first owns its pass and closes the session, + // the second starts a pass of its own and must still not close the session a second time. + await diffViewProvider.revertChanges() + await diffViewProvider.revertChanges() + + expect(restorePreviewTabs).toHaveBeenCalledTimes(2) + expect(reset).toHaveBeenCalledTimes(1) + }) + + it("revertChanges() does not finalize a session that a direct reset already closed", async () => { + const realReset = diffViewProvider["reset"].bind(diffViewProvider) + let resetCalls = 0 + diffViewProvider["reset"] = async () => { + resetCalls++ + await realReset() + } + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // A reset outside the teardown paths closes the session for everyone, so the claim has to + // come from the reset itself and not only from the caller that went through the helper. + await diffViewProvider["reset"]() + await diffViewProvider.revertChanges() + + expect(resetCalls).toBe(1) + }) + // The other direction: the rejected save is the one that joins an existing pass. It then // runs no cleanup of its own, and the pass that started the teardown restores the tabs once. it("saveChanges() restores no preview tabs when a revert already owns the teardown", async () => { From 0b0c15bf803a556b61f03e1942825b4abeced020 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 07:34:47 +0800 Subject: [PATCH 048/115] fix(test): clear the merge artifacts on the U9 branch Two artifacts of merging org main a101c613e, both of the kind that only exist in the merged tree: 1. src/core/tools/__tests__/readFileTool.spec.ts carried the same import twice - "import type { Task } from \"../../task/Task\"" at line 21 and again at line 26, one from each side of the merge. oxc reports it as [PARSE_ERROR] Identifier 'Task' has already been declared, which kills the whole file before a single test runs: vitest then says "Tests no tests" and CI's unit-test job is red with no failing test to point at. The second occurrence is removed; the first, in the type-import block, is the one the file's own ordering keeps. 2. This branch carries the hasClippedLines field (5 uses in src/integrations/misc/indentation-reader.ts) and main added src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts, which pins the whole reader result with toEqual - a second-class merge conflict git cannot see. The spec's expectations learn the field, matching production: true for the two clipped-line cases, false for empty input and for both reader-error results. The spec's intent is untouched. Contract change to an existing test, stated explicitly: it pinned the pre-merge shape. numstat 6/0. The negative control was measured on the U6 branch (forcing the production flag to false turns exactly the two clipped-line tests red). src/eslint-suppressions.json is re-pruned after the merge, as both sides' violation counts move: the only real change is core/tools/__tests__/readFileTool.spec.ts @typescript-eslint/no-explicit-any 96 -> 94 (a decrease); the rest of the 1731/1731 numstat is the file's own re-serialization, verified by comparing the parsed files leaf by leaf - one changed leaf, zero increases. eslint . --ext=ts --max-warnings=0 is exit 0 on the merged tree after this (it exits 2 with the stale counts). --- src/core/tools/__tests__/readFileTool.spec.ts | 1 - .../misc/__tests__/indentation-reader-unicode.spec.ts | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 353e4845cb..e031b7b0d4 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -24,7 +24,6 @@ import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" -import type { Task } from "../../task/Task" import { ObservationRegistry } from "../../task/observationRegistry" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, processImageFile, isSupportedImageFormat } from "../helpers/imageHelpers" diff --git a/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts b/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts index 90d5007f16..2496ee5088 100644 --- a/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts @@ -74,6 +74,8 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 3, returnedLines: 1, wasTruncated: true, + // This unit's read-scope work reports whether any returned line was clipped. + hasClippedLines: true, }) expect(Buffer.from(result.content).toString("utf8")).toBe(result.content) }) @@ -115,6 +117,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 1, wasTruncated: false, + hasClippedLines: true, }) expect(readWithSlice("")).toEqual({ content: "1 | ", @@ -122,6 +125,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 1, wasTruncated: false, + hasClippedLines: false, }) }) @@ -132,6 +136,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, }) expect(readWithIndentation(longLine, { anchorLine: 0 })).toEqual({ content: "Error: anchor_line 0 is out of range (1-1)", @@ -139,6 +144,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, }) }) }) From da2e0efed53d6119f2512c2ef0ccad0855e031e1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 11:33:25 +0800 Subject: [PATCH 049/115] test(safety,tools) and refactor(apply-patch): make four tests prove what they claim Four open threads on this PR, all of them about a test that passed without isolating the thing it names, plus the one production change among them. 1. safeWriteText.spec - the dangling-link key test ran the cycle path. mockResolvedValue("referent.json") answered EVERY readlink, including the call on the already-resolved key, so the walk never reached its normal exit; it looped to the depth limit and returned through the same door the cycle test below asserts. The mock now answers the link path only and rejects with EINVAL for the referent, the way a real readlink fails on a plain file. The readlink argument is compared on separators rather than through path.join, because the caller's spelling is "/tmp/linkdir/file.json" while path.join renders it with backslashes on Windows - a mock that never recognises the link would answer every call as "not a link", which is a third scenario, not this one. New assertion: readlink is called twice, once by resolvePublishTarget on the link and once by the fallback walk on the referent, and that count is what distinguishes the normal exit from the eight-call depth-limit exit. Negative control: restoring the blanket mock turns this test red with "called 2 times, but got 8 times"; the mutant was restored byte-exact. 2. applyDiffTool.guardedWrite.spec - "records no observation when the post-read stat fails" failed both stats. stat.mockRejectedValue rejects every call, so the pre-read stat failed too, and the observation was missing for the wrong reason: a regression that observes the file whenever the pre-read stat succeeds and ignores the post-read result passed this test. The first stat now resolves with the harness identity and only the second rejects. Negative control run both ways against the mutant "if (preReadStats)" plus versionTokenOfStat(postReadStats ?? preReadStats) in ApplyDiffTool: with the new mock the test is red (an observation is recorded), with the old blanket mock the same mutant is green - the reviewer's point, demonstrated rather than asserted. Both files restored byte-exact afterwards. 3. ApplyPatchTool - the move path's destination identity capture is dead here exactly as it was on the apply_diff unit: lines 459-474 return early whenever isMoveOutsideWorkspace, so isPathOutsideWorkspace(moveAbsolutePath) at the publish is always false and moveCanonicalTarget always undefined. Checked as dead rather than as a missed wiring, because the early return is the deliberate policy that a move outside every workspace root is refused with a tool error. It was not harmless: canonicalizeForApproval can throw, so a destination whose name could not be resolved raised an exception where the user should get that tool error. The capture is gone and the publish passes false and undefined with the guarantee in a comment. Same limitation as the unit this was ported from, recorded rather than hidden: no test in this repo drives an outside-workspace move, so this failure path is verified by reading the control flow, and the placeholder row on the plan of record (the three acceptance criteria: refused with a tool error, inside-workspace moves still publish, a canonicalizeForApproval rejection never reaches the caller) carries the test. 4. safeWriteJson.test - the CWE-732 mode-preservation comment sat above the confinement test and said "POSIX-only assertion" above a test that runs on every platform. It moved to the test it describes, the 0o600 mode-preservation case, which is the one wrapped in test.skipIf(win32) - so the note is now true of the test it annotates. Measured: 120 passed, 4 skipped across safeWriteText.spec, applyDiffTool.guardedWrite.spec, applyPatchTool.execute.spec and safeWriteJson.test. tsc --noEmit with a local paths override: 0 errors. eslint . --ext=ts --max-warnings=0 exit 0. eslint-suppressions.json untouched. --- src/core/tools/ApplyPatchTool.ts | 19 ++++++++------- .../applyDiffTool.guardedWrite.spec.ts | 9 ++++--- .../__tests__/safeWriteText.spec.ts | 24 ++++++++++++++++--- src/utils/__tests__/safeWriteJson.test.ts | 8 +++---- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index ff01563100..e3fd547e7a 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -457,12 +457,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Check if destination path is outside workspace const isMoveOutsideWorkspace = isPathOutsideWorkspace(moveAbsolutePath) - // Bound at classification time: the patch approval covers the whole patch, so the - // destination's identity is captured here - before the publish and before the FIFO - // queue - and the guard refuses a destination that no longer resolves to it. - const moveCanonicalTarget = isMoveOutsideWorkspace - ? await canonicalizeForApproval(moveAbsolutePath, change.movePath) - : undefined + // A destination outside every workspace root is refused right here, so the publish at the + // end of this branch is always an inside-workspace one: it needs neither the approval flag + // nor a canonical identity. Capturing the identity here instead - as this did - ran + // canonicalizeForApproval for a destination that was about to be refused, so a destination + // whose name could not be resolved surfaced an exception where the user should get the tool + // error below. if (isMoveOutsideWorkspace) { task.consecutiveMistakeCount++ task.recordToolError("apply_patch") @@ -528,9 +528,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { writeDelayMs, "create", sourceComplete, - // The user approved the whole patch, which names this destination. - isPathOutsideWorkspace(moveAbsolutePath), - moveCanonicalTarget, + // Guaranteed by the refusal above: this destination is inside a workspace root, + // so the guard's own containment check is what applies and no approval is claimed. + false, + undefined, ) } else { // Write to new path and delete old file diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index ef2608dbc0..f972f57c05 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -356,9 +356,12 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { // post-read token the tool cannot prove the version it read, so the read // authorizes nothing and the save falls back to the re-read remediation. const stat = vi.mocked((await import("fs/promises")).default.stat) - stat.mockRejectedValue( - Object.assign(new Error("EACCES"), { code: "EACCES" }), - ) + // Only the SECOND stat fails. Rejecting every call also fails the pre-read stat, so a + // regression that observes the file whenever the pre-read succeeds and ignores the post-read + // result would still pass here - the observation would be missing for the wrong reason. The + // first call answers with the same identity the harness's default provides. + stat.mockImplementationOnce(async () => ({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n }) as never) + stat.mockRejectedValueOnce(Object.assign(new Error("EACCES"), { code: "EACCES" })) await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { askApproval: mockAskApproval, diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 57e7fe1914..cc03fba8f4 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1214,15 +1214,33 @@ describe("resolveLockKey", () => { const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) vi.mocked(fs.realpath).mockRejectedValue(enoent) vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) - // Only the link path is read, so a single answer is enough and keeps the mock's - // return type matching fs.promises.readlink. - vi.mocked(fs.readlink).mockResolvedValue("referent.json") + // Only the link path is a symlink: the referent it names is answered as the plain file it + // is. Answering every readlink call - as a blanket mockResolvedValue did - made the walk + // re-read its own resolved key as another link, so this test ran the bounded-cycle path + // (eight readlink calls and the depth limit) instead of the dangling-link path it is named + // for, and could not tell a correct walk from one that only stops because it gives up. + const enotlink = Object.assign(new Error("EINVAL: invalid argument, not a symlink"), { code: "EINVAL" }) + vi.mocked(fs.readlink).mockImplementation(async (target) => + // Compared on separators, not through path.join: the argument is the caller's spelling, + // which on Windows is "/tmp/linkdir/file.json" while path.join would render it with + // backslashes - and a mock that never recognises the link answers every call as "not a + // link", which is a different scenario from the one this test names. + String(target).replace(/\\/g, "/") === "/tmp/linkdir/file.json" + ? "referent.json" + : Promise.reject(enotlink), + ) // Mid-commit a peer writer renames the referent away and back, so the key // must still be computable while the link dangles. await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe( path.resolve(path.join("/tmp/linkdir", "referent.json")), ) + // Two readlink calls, and that count is the proof of the normal exit: one from + // resolvePublishTarget on the link, one from the fallback walk on the referent, which is + // answered as "not a link" and ends the walk. The bounded walk would have asked eight times + // - what the cycle test below asserts - so this test now fails if the walk ever mistakes the + // referent for another link and runs to the depth limit instead. + expect(fs.readlink).toHaveBeenCalledTimes(2) }) it("terminates on a two-link cycle instead of walking forever", async () => { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 57b9ae672e..c0e7557f0b 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -661,10 +661,6 @@ describe("safeWriteJson", () => { } }) - // 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("rejects a confined write whose target is outside the confined directory", async () => { const scope = path.join(tempDir, "project") await fs.mkdir(scope) @@ -815,6 +811,10 @@ describe("safeWriteJson", () => { }, ) + // 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 () => { From 6d8f0779396f8adc4d606e720a6f9155ff8e269e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 12:11:51 +0800 Subject: [PATCH 050/115] style: format the fifteen files the formatting gate flags on this PR The compile job's Check formatting step runs 'prettier --check .' and lists 15 files on this PR (job 114122103447). The list is read from the job log with the ANSI codes stripped - the escape sequence sits between the bracket and the word, so a search for '[warn]' finds nothing and the reader is left with the summary line instead of the files. Every one of the 15 is inside this PR's own diff. Formatting only, verified as such: prettier --check passes on all 15; tsc --noEmit with the local paths override reports 0 errors; eslint . --ext=ts --max-warnings=0 exits 0; eslint-suppressions.json untouched. Five tests fail in this worktree (DiffViewProvider saveChanges default write delay x2, ClineProvider blanket auto-deny x3). Classified, not waved at: the same two spec files were run with the formatting stashed and unstashed and the failure sets are identical by name and by count (5 failed / 347 passed both ways), and the unit-test jobs are green at this head on CI - so they are local artifacts (the write-delay pair is the known DEFAULT_WRITE_DELAY_MS junction difference), neither introduced nor hidden by this commit. --- src/core/tools/ApplyDiffTool.ts | 1 - src/core/tools/ApplyPatchTool.ts | 9 +- .../applyDiffTool.guardedWrite.spec.ts | 28 ++- src/core/tools/__tests__/editFileTool.spec.ts | 5 +- src/core/tools/__tests__/editTool.spec.ts | 5 +- src/core/tools/__tests__/guardedWrite.spec.ts | 4 +- .../tools/__tests__/searchReplaceTool.spec.ts | 5 +- src/core/tools/guardedWrite.ts | 40 +-- src/integrations/editor/DiffViewProvider.ts | 236 +++++++++--------- .../editor/__tests__/DiffViewProvider.spec.ts | 20 +- .../__tests__/safeWriteText.spec.ts | 108 ++++---- src/services/file-safety/safeWriteText.ts | 24 +- .../__tests__/safeWriteJson.lockKey.spec.ts | 29 ++- src/utils/__tests__/safeWriteJson.test.ts | 38 ++- src/utils/safeWriteJson.ts | 7 +- 15 files changed, 316 insertions(+), 243 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 98e642a6c8..eeca26c198 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -96,7 +96,6 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } } - // Apply the diff to the original content const diffResult = (await task.diffStrategy?.applyDiff( originalContent, diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index e3fd547e7a..a36944e59b 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -111,7 +111,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // With no prior observation there is nothing to carry, and the read stays // incomplete. Carry completeness only when a prior observation exists and still // describes the version that was read. - const complete = prior === undefined ? false : prior.complete === true && prior.version === preReadToken + const complete = + prior === undefined ? false : prior.complete === true && prior.version === preReadToken task.observationRegistry.observe(absolutePath, preReadToken, complete) } } @@ -256,9 +257,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diagnosticsEnabled, writeDelayMs, "create", - // Seventh parameter is completeOverride (this call claims no completeness of its - // own); the eighth is the approval flag for a target outside every workspace root. - undefined, + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, isOutsideWorkspace, approvedCanonicalTarget, ) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index f972f57c05..81d6cfda6d 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -75,9 +75,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { // one test would otherwise be handed to the next test's reads. Restore the default. const stat = vi.mocked((await import("fs/promises")).default.stat) stat.mockReset() - stat.mockResolvedValue( - { dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats, - ) + stat.mockResolvedValue({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats) // The role-aware stat mock above also drives readFile, so its implementation is reset here too: // a leaked implementation would decide which stat call the NEXT test sees as pre-read. const readFile = vi.mocked((await import("fs/promises")).default.readFile) @@ -235,9 +233,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { }) const observation = mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts")) - expect(observation?.version).toBe( - "1:2:22:100:100", - ) + expect(observation?.version).toBe("1:2:22:100:100") // A tool read is not a model read, so completeness stays unearned. expect(observation?.complete).toBe(false) }) @@ -246,9 +242,19 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { // Only the fields versionTokenOfStat reads; a full BigIntStats cannot be built // against the mocked fs, so the double assertion is the narrowest option. const stat = vi.mocked((await import("fs/promises")).default.stat) - stat - .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats) - .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 30n, mtimeNs: 200n, ctimeNs: 100n } as unknown as BigIntStats) + stat.mockResolvedValueOnce({ + dev: 1n, + ino: 2n, + size: 22n, + mtimeNs: 100n, + ctimeNs: 100n, + } as unknown as BigIntStats).mockResolvedValueOnce({ + dev: 1n, + ino: 2n, + size: 30n, + mtimeNs: 200n, + ctimeNs: 100n, + } as unknown as BigIntStats) await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { askApproval: mockAskApproval, @@ -360,7 +366,9 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { // regression that observes the file whenever the pre-read succeeds and ignores the post-read // result would still pass here - the observation would be missing for the wrong reason. The // first call answers with the same identity the harness's default provides. - stat.mockImplementationOnce(async () => ({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n }) as never) + stat.mockImplementationOnce( + async () => ({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n }) as never, + ) stat.mockRejectedValueOnce(Object.assign(new Error("EACCES"), { code: "EACCES" })) await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 1fc10958b2..1571cf254d 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -738,7 +738,10 @@ describe("editFileTool", () => { it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { mockedIsPathOutsideWorkspace.mockReturnValue(true) try { - await executeEditFileTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) + await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) const args = mockTask.diffViewProvider.saveDirectly.mock.calls.at(-1)! expect(args[6]).toBeUndefined() expect(args[7]).toBe(true) diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index e5e3e805ef..5df2668878 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -464,7 +464,10 @@ describe("editTool", () => { it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { mockedIsPathOutsideWorkspace.mockReturnValue(true) try { - await executeEditTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) + await executeEditTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) const args = mockTask.diffViewProvider.saveDirectly.mock.calls.at(-1)! expect(args[6]).toBeUndefined() expect(args[7]).toBe(true) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index fc9d76a10a..fd72c6f59e 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -264,7 +264,6 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).not.toHaveBeenCalled() }) - it("fails closed when the workspace itself cannot be resolved", async () => { // EACCES/ELOOP on the workspace means a symlink inside it would never be resolved, so // the containment decision cannot be made at all: the write is refused rather than @@ -467,7 +466,8 @@ describe("guardedWrite (S4a, epic #1375)", () => { // sees the swapped identity. A baseline taken from any lookup would accept this // write; the identity captured before the approval does not. mockedFsRealpath.mockImplementation(async (p) => - String(p).startsWith("/elsewhere") ? "/elsewhere/victim.txt" : String(p)) + String(p).startsWith("/elsewhere") ? "/elsewhere/victim.txt" : String(p), + ) mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) const task = createMockTask() diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index a4999c3bfd..2f5438d475 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -479,7 +479,10 @@ describe("searchReplaceTool", () => { it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { mockedIsPathOutsideWorkspace.mockReturnValue(true) try { - await executeSearchReplaceTool({ old_string: "Line 2", new_string: "Modified Line 2" }, { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }) + await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! expect(args[6]).toBeUndefined() expect(args[7]).toBe(true) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 8d57f0cd25..0fbd1352e9 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -338,9 +338,7 @@ function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { */ function isInside(root: string, target: string): boolean { const relative = path.relative(root, target) - return !( - relative === "" || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative) - ) + return !(relative === "" || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) } function assertInsideWorkspace(roots: string[], absolutePath: string, displayPath: string): void { @@ -447,16 +445,16 @@ async function realpathNearest(target: string, displayPath: string): Promise { - return realpathNearest(target, displayPath ?? target) - } + * The canonical identity of a write target, for a caller that needs to bind an + * approval to it. Call this BEFORE asking the user: the guard compares its + * pre-publish resolution against this value, so a name repointed in between is + * refused. A target that does not exist yet is tolerated the way the guard tolerates + * it (a create makes the missing components); any other resolution failure throws a + * GuardRejectedError instead of falling back to the lexical path. + */ +export async function canonicalizeForApproval(target: string, displayPath?: string): Promise { + return realpathNearest(target, displayPath ?? target) +} /** * Guarded write entry point. @@ -599,10 +597,22 @@ export async function guardedWrite( if (obs === undefined) { // Never read: only an absent target may be created. - publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort, verifyTarget) + publishedToken = await createIfAbsent( + absolutePath, + content, + displayPath, + () => task.abort, + verifyTarget, + ) } else if (absent) { // A "create" on a file that vanished after the read recreates it. - publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort, verifyTarget) + publishedToken = await createIfAbsent( + absolutePath, + content, + displayPath, + () => task.abort, + verifyTarget, + ) } else { // The version recorded at read time must still match the on-disk // token. diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bfffd5428b..e5db6ec8c7 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -650,63 +650,64 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - await this.runTeardown(async () => { - let discardSucceeded = !updatedDocument.isDirty - if (updatedDocument.isDirty) { - discardSucceeded = await this.revertDocument(updatedDocument) - } - if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { - // Cleanup has to be serialized with the same resolved-path advisory lock - // every other writer to this file uses. A token check and an unlink in - // separate steps let a peer writer commit in the gap and lose its write. - // A differing token, or a lock that cannot be taken, leaves the file in place. - await withFileLock(await resolveLockKey(absolutePath), async () => { - const placeholderStats = await fs - .stat(absolutePath, { bigint: true }) - .catch(() => undefined) - if ( - !placeholderStats || - versionTokenOfStat(placeholderStats) !== this.placeholderVersion - ) { - return - } - let unlinked = false - try { - await fs.unlink(absolutePath) - unlinked = true - } catch { - // the placeholder vanished or the unlink failed - } - if (!unlinked) { - return - } - // The file is gone, so its tab must go too: closing only the diff - // views would leave a clean plain-text tab for a deleted file. - await this.closeFileTab(absolutePath) - // The directories open() created for the new file must go with it, - // innermost first. rmdir refuses a directory another writer populated - // in the meantime, so the cleanup stops at the first failure. - for (let i = this.createdDirs.length - 1; i >= 0; i--) { + await this.runTeardown( + async () => { + let discardSucceeded = !updatedDocument.isDirty + if (updatedDocument.isDirty) { + discardSucceeded = await this.revertDocument(updatedDocument) + } + if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { + // Cleanup has to be serialized with the same resolved-path advisory lock + // every other writer to this file uses. A token check and an unlink in + // separate steps let a peer writer commit in the gap and lose its write. + // A differing token, or a lock that cannot be taken, leaves the file in place. + await withFileLock(await resolveLockKey(absolutePath), async () => { + const placeholderStats = await fs + .stat(absolutePath, { bigint: true }) + .catch(() => undefined) + if ( + !placeholderStats || + versionTokenOfStat(placeholderStats) !== this.placeholderVersion + ) { + return + } + let unlinked = false try { - await fs.rmdir(this.createdDirs[i]) + await fs.unlink(absolutePath) + unlinked = true } catch { - break + // the placeholder vanished or the unlink failed } - } - }) - } - await this.closeOwnDiffView(absolutePath) - }, - // The tail of the pass belongs to the same guard: a caller that joined this teardown - // must not repeat it, and a caller that lands while these steps are still running must - // not start a new pass. - async () => { - // Opening the diff evicted any preview tab the file had. This path closes its own diff - // view and rethrows, so this pass is the only one that can put that preview state back; - // a caller that merely waited for it must not restore the tabs a second time. reset() - // stays with the tool caller's error handling, which owns the provider lifecycle. - await this.restorePreviewTabs() - }, + if (!unlinked) { + return + } + // The file is gone, so its tab must go too: closing only the diff + // views would leave a clean plain-text tab for a deleted file. + await this.closeFileTab(absolutePath) + // The directories open() created for the new file must go with it, + // innermost first. rmdir refuses a directory another writer populated + // in the meantime, so the cleanup stops at the first failure. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + try { + await fs.rmdir(this.createdDirs[i]) + } catch { + break + } + } + }) + } + await this.closeOwnDiffView(absolutePath) + }, + // The tail of the pass belongs to the same guard: a caller that joined this teardown + // must not repeat it, and a caller that lands while these steps are still running must + // not start a new pass. + async () => { + // Opening the diff evicted any preview tab the file had. This path closes its own diff + // view and rethrows, so this pass is the only one that can put that preview state back; + // a caller that merely waited for it must not restore the tabs a second time. reset() + // stays with the tool caller's error handling, which owns the provider lifecycle. + await this.restorePreviewTabs() + }, ) } catch { // cleanup is best-effort; the guard verdict below is the outcome @@ -761,7 +762,8 @@ export class DiffViewProvider { absolutePath, this.userTouchedDiffEditor, saveState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, - saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? + DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, saveState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, ) @@ -779,7 +781,6 @@ export class DiffViewProvider { return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } } - // Getting diagnostics before and after the file edit is a better approach than // automatically tracking problems in real-time. This method ensures we only // report new problems that are a direct result of this specific edit. @@ -940,72 +941,73 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - const ownedTeardown = await this.runTeardown(async () => { - if (!fileExists) { - if (updatedDocument.isDirty) { - await updatedDocument.save() - } + const ownedTeardown = await this.runTeardown( + async () => { + if (!fileExists) { + if (updatedDocument.isDirty) { + await updatedDocument.save() + } - await this.closeAllDiffViews() - // The file was newly created for this edit; close its transiently - // opened tab before deleting it from disk. - await this.closeFileTab(absolutePath) - await fs.unlink(absolutePath) + await this.closeAllDiffViews() + // The file was newly created for this edit; close its transiently + // opened tab before deleting it from disk. + await this.closeFileTab(absolutePath) + await fs.unlink(absolutePath) - // Remove only the directories we created, in reverse order. - for (let i = this.createdDirs.length - 1; i >= 0; i--) { - await fs.rmdir(this.createdDirs[i]) - } - } else { - // Revert document. - const edit = new vscode.WorkspaceEdit() + // Remove only the directories we created, in reverse order. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + await fs.rmdir(this.createdDirs[i]) + } + } else { + // Revert document. + const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - updatedDocument.positionAt(0), - updatedDocument.positionAt(updatedDocument.getText().length), - ) + const fullRange = new vscode.Range( + updatedDocument.positionAt(0), + updatedDocument.positionAt(updatedDocument.getText().length), + ) - edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) + edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) - // Apply the edit and save, since contents shouldn't have changed - // this won't show in local history unless of course the user made - // changes and saved during the edit. - await vscode.workspace.applyEdit(edit) - await updatedDocument.save() - - await this.closeAllDiffViews() - - // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset. - const revertTask = this.taskRef.deref() - const revertState = await revertTask?.providerRef.deref()?.getState() - - await this.keepOrCloseEditedFile( - absolutePath, - false, - revertState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, - revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? - DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, - revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, - ) - } - }, - // The tail of the pass belongs to the same guard: a caller that joined this teardown - // must not repeat it, and a caller that lands while these steps are still running must - // not start a new pass. - async () => { - // Another teardown started first and already ran the pass over this session's buffers, - // tabs and preview state. Restoring preview tabs or resetting here again would be that - // same work a second time, and the reset would clear state whose closing this provider - // does not own. The pass that started the teardown finalizes the session. - // Restore any preview tabs the diff evicted, reconstructing the user's - // prior not-yet-edited tab state. - // Edit is done. - // The claim, not a flag read back afterwards, is what keeps a caller that joined this - // pass from closing the session a second time. - await this.restorePreviewTabs() - await this.finalizeSession(() => this.reset()) - }, + // Apply the edit and save, since contents shouldn't have changed + // this won't show in local history unless of course the user made + // changes and saved during the edit. + await vscode.workspace.applyEdit(edit) + await updatedDocument.save() + + await this.closeAllDiffViews() + + // Read auto-close preferences from state; fall back to defaults that + // preserve the existing behavior when unset. + const revertTask = this.taskRef.deref() + const revertState = await revertTask?.providerRef.deref()?.getState() + + await this.keepOrCloseEditedFile( + absolutePath, + false, + revertState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, + revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? + DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + ) + } + }, + // The tail of the pass belongs to the same guard: a caller that joined this teardown + // must not repeat it, and a caller that lands while these steps are still running must + // not start a new pass. + async () => { + // Another teardown started first and already ran the pass over this session's buffers, + // tabs and preview state. Restoring preview tabs or resetting here again would be that + // same work a second time, and the reset would clear state whose closing this provider + // does not own. The pass that started the teardown finalizes the session. + // Restore any preview tabs the diff evicted, reconstructing the user's + // prior not-yet-edited tab state. + // Edit is done. + // The claim, not a flag read back afterwards, is what keeps a caller that joined this + // pass from closing the session a second time. + await this.restorePreviewTabs() + await this.finalizeSession(() => this.reset()) + }, ) if (!ownedTeardown) { diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 184e74f437..c443acebee 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1087,7 +1087,17 @@ describe("DiffViewProvider", () => { vi.mocked(fs.access).mockRejectedValue({ code: "ENOENT" }) vi.mocked(computeVersionToken).mockResolvedValue("v1") - await diffViewProvider.saveDirectly("../outside.ts", "new content", false, true, 1000, "create", undefined, true, path.resolve(mockCwd, "../outside.ts")) + await diffViewProvider.saveDirectly( + "../outside.ts", + "new content", + false, + true, + 1000, + "create", + undefined, + true, + path.resolve(mockCwd, "../outside.ts"), + ) const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/../outside.ts`, "new content") @@ -1476,7 +1486,9 @@ describe("DiffViewProvider", () => { // openDiffEditor resolves from the document-open event, so fire it. vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { setTimeout(() => { - callback({ uri: { fsPath: `${mockCwd}/test.txt`, scheme: "file" } } as unknown as vscode.TextDocument) + callback({ + uri: { fsPath: `${mockCwd}/test.txt`, scheme: "file" }, + } as unknown as vscode.TextDocument) }, 0) return { dispose: vi.fn() } }) @@ -1505,9 +1517,7 @@ describe("DiffViewProvider", () => { // The preview did record an observation - that is the entry under test. expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(true) - await expect(diffViewProvider.saveChanges(false, 0, "edit")).rejects.toThrow( - /File not read yet/, - ) + await expect(diffViewProvider.saveChanges(false, 0, "edit")).rejects.toThrow(/File not read yet/) expect(safeWriteText).not.toHaveBeenCalled() // The preview's authorization was withdrawn rather than left behind. expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(false) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index cc03fba8f4..b173c01084 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -307,9 +307,7 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) // The rejection is reported through the fallback sink rather than surfacing as an // unhandled rejection. - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining("onWarning callback rejected"), - ) + expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("onWarning callback rejected")) consoleWarn.mockRestore() }) @@ -402,7 +400,9 @@ describe("safeWriteText", () => { return 1 }) - await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow(PostCommitDurabilityError) + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow( + PostCommitDurabilityError, + ) // The commit rename already published the new content, and the backup was only // ever a copy: the target was never moved, so there is nothing to rename back. @@ -414,11 +414,9 @@ describe("safeWriteText", () => { // after the commit the target holds the NEW content, and this copy is the only // one known to hold the previous content - the recovery copy the contract // promises for exactly this path. - const backupUnlinks = vi - .mocked(fs.unlink) - .mock.calls.filter(function (call: unknown[]) { - return String(call[0]).includes("safeWriteText.bak_") - }) + const backupUnlinks = vi.mocked(fs.unlink).mock.calls.filter(function (call: unknown[]) { + return String(call[0]).includes("safeWriteText.bak_") + }) expect(backupUnlinks).toEqual([]) // The staged temp is still cleaned up. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) @@ -543,7 +541,9 @@ describe("safeWriteText", () => { }) expect(seedOpen).toBeDefined() expect(seedOpen?.[2]).toBe(0o600) - const seedOrder = vi.mocked(fsSync.openSync).mock.invocationCallOrder[vi.mocked(fsSync.openSync).mock.calls.indexOf(seedOpen!)] + const seedOrder = vi.mocked(fsSync.openSync).mock.invocationCallOrder[ + vi.mocked(fsSync.openSync).mock.calls.indexOf(seedOpen!) + ] expect(seedOrder).toBeLessThan(vi.mocked(fs.copyFile).mock.invocationCallOrder[0]) // The chmod keeps a copied read-only attribute (Windows) from breaking the fsync @@ -569,14 +569,18 @@ describe("safeWriteText", () => { // known to be durable, so the write must not proceed on a half-written backup. // The staged temp is fsynced earlier with a different handle, so target the // backup's fd specifically. - vi.mocked(fsSync.openSync).mockImplementation((p: unknown) => (String(p).includes("safeWriteText.bak_") ? 7 : 1)) + vi.mocked(fsSync.openSync).mockImplementation((p: unknown) => + String(p).includes("safeWriteText.bak_") ? 7 : 1, + ) vi.mocked(fsSync.fsyncSync).mockImplementation((fd: unknown) => { if (fd === 7) { throw new Error("EIO") } }) - await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow("EIO") + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow( + "EIO", + ) // Nothing was published, and the incomplete copy is removed rather than left // next to the target looking like a usable backup. @@ -595,7 +599,9 @@ describe("safeWriteText", () => { // succeeded, or this file outlives the failed write beside the target. vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("EIO"), { code: "EIO" })) - await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow("EIO") + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow( + "EIO", + ) // Nothing was published, and the half-written copy is removed rather than left // next to the target looking like a backup someone could restore. @@ -671,42 +677,42 @@ describe("safeWriteText", () => { expect(execFile).toHaveBeenCalledTimes(1) }) - it("win32: reports that access rights may change when the DACL cannot be saved", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { - if (typeof cb === "function") cb(new Error("icacls error"), "", "") - return fakeChild - }) - const warnings: string[] = [] - - await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + it("win32: reports that access rights may change when the DACL cannot be saved", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + const warnings: string[] = [] - // The write still commits - a failing icacls must not leave the user unable to save - - // but the caller is told the replacement may not carry the old ACL. - expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) - expect(warnings.filter((m) => m.includes("different access rights"))).toHaveLength(1) - }) + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) - it("win32: reports when the target cannot be checked for DACL preservation", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - // The target exists but is not readable: that is not "absent", and skipping DACL - // preservation has to be visible. - vi.mocked(fs.access).mockImplementation(async (p) => { - if (String(p) === targetPath) { - throw Object.assign(new Error("EACCES"), { code: "EACCES" }) - } + // The write still commits - a failing icacls must not leave the user unable to save - + // but the caller is told the replacement may not carry the old ACL. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(warnings.filter((m) => m.includes("different access rights"))).toHaveLength(1) }) - const warnings: string[] = [] - await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + it("win32: reports when the target cannot be checked for DACL preservation", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The target exists but is not readable: that is not "absent", and skipping DACL + // preservation has to be visible. + vi.mocked(fs.access).mockImplementation(async (p) => { + if (String(p) === targetPath) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + }) + const warnings: string[] = [] + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) - expect(execFile).not.toHaveBeenCalled() - expect(warnings.filter((m) => m.includes("Could not check"))).toHaveLength(1) - }) + expect(execFile).not.toHaveBeenCalled() + expect(warnings.filter((m) => m.includes("Could not check"))).toHaveLength(1) + }) it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => { const targetPath = "/tmp/test-dir/target.txt" @@ -1296,9 +1302,9 @@ describe("caller-supplied staging path", () => { const stats = _fileStatsWithIdentity(42n, 7n) vi.mocked(fs.lstat).mockResolvedValue(stats) - await expect( - safeWriteText(targetPath, "data", { tempPath: targetPath, platform: "linux" }), - ).rejects.toThrow(StagingPathError) + await expect(safeWriteText(targetPath, "data", { tempPath: targetPath, platform: "linux" })).rejects.toThrow( + StagingPathError, + ) expect(fsSync.openSync).not.toHaveBeenCalled() expect(fs.rename).not.toHaveBeenCalled() // The comparison is only sound when both stats are read as bigint: on NTFS/ReFS the file @@ -1313,7 +1319,6 @@ describe("caller-supplied staging path", () => { expect(fs.unlink).not.toHaveBeenCalled() }) - it("rejects when the target identity cannot be compared for a reason other than a missing target", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -1340,7 +1345,8 @@ describe("caller-supplied staging path", () => { for (const c of identityLookups) { expect(c[1]).toEqual({ bigint: true }) } - })}) + }) +}) describe("cleanup when a backed-up write fails before commit", () => { beforeEach(() => mockDefaults()) @@ -1434,7 +1440,9 @@ describe("resolveLockKey when the parent directory does not exist yet", () => { vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) // Not a link: the walk must reach the canonicalization step, which is where the // non-ENOENT failure has to surface. - vi.mocked(fs.readlink).mockRejectedValue(Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })) + vi.mocked(fs.readlink).mockRejectedValue( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) vi.mocked(fs.realpath).mockImplementation(async (p) => { if (String(p) === target) return target throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 835289981b..c0730baf21 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -136,7 +136,11 @@ async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunne /** Restore a DACL dump onto *dirPath* on Windows. * Returns whether icacls succeeded; the caller reports a failure. */ -async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { +async function _restoreDaclWindows( + dirPath: string, + dumpPath: string, + execFileRunner?: typeof execFile, +): Promise { const runner = execFileRunner ?? execFile try { await new Promise((resolve, reject) => { @@ -459,13 +463,17 @@ export async function safeWriteText( // proceeds - a missing or failing icacls must not leave the user unable to save - // but the replacement is no longer ACL-identical and that has to be visible // instead of silent. - warn(`Could not save the DACL of ${targetPath}; the replacement may inherit different access rights.`) + warn( + `Could not save the DACL of ${targetPath}; the replacement may inherit different access rights.`, + ) } } else if (errorCode(accessError) !== "ENOENT") { // Not "absent": the target is there but could not be checked (EACCES, ...), so // DACL preservation was skipped for a reason the caller cannot infer from the // successful write alone. - warn(`Could not check ${targetPath} for DACL preservation (${errorCode(accessError) ?? "unknown error"}); the replacement may inherit different access rights.`) + warn( + `Could not check ${targetPath} for DACL preservation (${errorCode(accessError) ?? "unknown error"}); the replacement may inherit different access rights.`, + ) } } try { @@ -538,11 +546,7 @@ export async function safeWriteText( // The catch block keeps the backup on this path, so hand the caller the // path of the copy it is keeping - it is a hidden random name and is // otherwise unrecoverable. - throw new PostCommitDurabilityError( - targetPath, - error, - releaseBackupOnSuccess ? backupPath : null, - ) + throw new PostCommitDurabilityError(targetPath, error, releaseBackupOnSuccess ? backupPath : null) } } @@ -560,7 +564,9 @@ export async function safeWriteText( // temp directory restore fails with "Not all privileges or groups referenced // are assigned to the caller"), so the change of access rights is reported // rather than thrown. - warn(`Content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file may carry different access rights than the one it replaced.`) + warn( + `Content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file may carry different access rights than the one it replaced.`, + ) } } diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index beddaf9ea9..c9f2415402 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -50,12 +50,13 @@ afterEach(async () => { // Only isSymbolicLink() is consulted by the guard, so the double carries just // that method. The mocks reject asynchronously: a synchronous throw would bypass // resolvePublishTarget's catch and skip the ENOENT/symlink branch under test. -const symlinkStat = (target: unknown) => ({ - isSymbolicLink: () => target === currentLink, - // The staging-path check in safeWriteText also asks whether the path is a - // regular file, so the double carries that predicate as well. - isFile: () => target !== currentLink, -}) as unknown as BigIntStats +const symlinkStat = (target: unknown) => + ({ + isSymbolicLink: () => target === currentLink, + // The staging-path check in safeWriteText also asks whether the path is a + // regular file, so the double carries that predicate as well. + isFile: () => target !== currentLink, + }) as unknown as BigIntStats let currentLink = "" describe("safeWriteJson lock key under a peer commit", () => { @@ -100,7 +101,17 @@ describe("safeWriteJson lock key under a peer commit", () => { // regular-file check on the temp file this write created, and the identity check // that the staging path is not the target. Both run after the key was resolved // and the lock was taken, so neither changes which lock the caller queued behind. - expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "resolve", "resolve", "lstat", "lstat"]) + expect(order).toEqual([ + "resolve-failed", + "lstat", + "resolve", + "resolve", + "lock", + "resolve", + "resolve", + "lstat", + "lstat", + ]) expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) }) @@ -153,7 +164,9 @@ describe("safeWriteJson lock key under a peer commit", () => { if (target === file) throw enoent return canonicalDir }) - mockedLstat.mockImplementation(async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats) + mockedLstat.mockImplementation( + async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, + ) expect(await resolveLockKey(file)).toBe(path.join(canonicalDir, "history_item.json")) }) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index c0e7557f0b..3ba0a5389c 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -703,18 +703,20 @@ describe("safeWriteJson", () => { // proper-lockfile and the capture never sees a call. The error class is taken from // the same module instance the SUT came from. vi.resetModules() - const { safeWriteJson: reimported, ConfinedPathEscapeError: ReimportedError } = await import( - "../safeWriteJson" - ) + const { safeWriteJson: reimported, ConfinedPathEscapeError: ReimportedError } = await import("../safeWriteJson") try { - await expect( - reimported(outside, { mcpServers: {} }, { confineTo: projectDir }), - ).rejects.toThrow(ReimportedError) + await expect(reimported(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + ReimportedError, + ) // The confinement verdict came first: no lock was ever attempted. expect(lockCalls).toEqual([]) - expect((await fs.readdir(tempDir)).filter(function (entry: string) { return entry.endsWith(".lock") })).toEqual([]) + expect( + (await fs.readdir(tempDir)).filter(function (entry: string) { + return entry.endsWith(".lock") + }), + ).toEqual([]) } finally { vi.doUnmock("proper-lockfile") vi.resetModules() @@ -738,7 +740,11 @@ describe("safeWriteJson", () => { // Nothing was created outside the scope, and no lock was attempted there. expect(fsSyncActual.existsSync(missingParent)).toBe(false) - expect((await fs.readdir(tempDir)).filter(function (entry: string) { return entry.endsWith(".lock") })).toEqual([]) + expect( + (await fs.readdir(tempDir)).filter(function (entry: string) { + return entry.endsWith(".lock") + }), + ).toEqual([]) }) test.skipIf(process.platform === "win32")( @@ -754,16 +760,18 @@ describe("safeWriteJson", () => { const projectConfig = path.join(projectDir, "mcp.json") await fs.symlink(outside, projectConfig) - await expect( - safeWriteJson(projectConfig, { mcpServers: {} }, { confineTo: projectDir }), - ).rejects.toThrow(ConfinedPathEscapeError) + await expect(safeWriteJson(projectConfig, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + ConfinedPathEscapeError, + ) // The linked file is untouched and nothing was staged beside it. expect(JSON.parse(await fsSyncActual.promises.readFile(outside, "utf8"))).toEqual({ secret: "original" }) const entries = await fs.readdir(tempDir) expect(entries).toContain("outside.json") expect( - entries.filter((entry) => entry.includes(".new_") || entry.includes("safeWriteText") || entry.endsWith(".lock")), + entries.filter( + (entry) => entry.includes(".new_") || entry.includes("safeWriteText") || entry.endsWith(".lock"), + ), ).toEqual([]) }, ) @@ -780,7 +788,11 @@ describe("safeWriteJson", () => { // Confining is about the scope, not about forbidding links: a link that stays // inside the project still publishes to its referent. - await safeWriteJson(alias, { mcpServers: { local: { url: "http://localhost" } } }, { confineTo: projectDir }) + await safeWriteJson( + alias, + { mcpServers: { local: { url: "http://localhost" } } }, + { confineTo: projectDir }, + ) expect(JSON.parse(await fsSyncActual.promises.readFile(referent, "utf8"))).toEqual({ mcpServers: { local: { url: "http://localhost" } }, diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 9488227f5e..ef07851b67 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -106,12 +106,7 @@ async function _resolveScopeRoot(confineTo: string): Promise { */ function _escapesScope(scopeRoot: string, candidate: string): boolean { const relative = path.relative(scopeRoot, candidate) - return ( - relative === "" || - relative === ".." || - relative.startsWith(".." + path.sep) || - path.isAbsolute(relative) - ) + return relative === "" || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative) } function _scopeErrorCode(error: unknown): string | undefined { From bc43841f6690e28a139b439fb0a21b0812e541d3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:02:01 +0800 Subject: [PATCH 051/115] fix(u7): align the unit with main's lint re-enable and format gate; drop Stryker directives #2038 turned @typescript-eslint/no-unused-vars back on in src and #799 added pnpm format:check to Code QA. Replaying this unit onto that main exposed unused bindings left by main's dead-code removal (ReadFileTool / SafeWriteTextOptions / lockfile imports, four write-path destructurings no assertion reads, an unused realpath callback parameter), a duplicate Task import, and the path import main pruned from DiffViewProvider.spec that this unit's tests still need. Per AGENTS.md the two Stryker suppression comments in the guarded save path are removed; their equivalence claims now live in the PR description (writeKind default StringLiteral is out of the changed-line selector and dispatches like "update" inside guardedWrite; the adoption-probe saveTask operand is provably non-null whenever evaluated). Formatting brought to prettier-canonical under the repo config. No behavior change: 15 unit specs 635 passed / 9 skipped, eslint clean, prettier clean, tsc adds nothing beyond the pre-existing local donor-package drift. --- src/core/tools/__tests__/guardedWrite.spec.ts | 2 +- src/core/tools/__tests__/readFileTool.spec.ts | 11 +++++------ src/eslint-suppressions.json | 2 +- src/integrations/editor/DiffViewProvider.ts | 7 ------- .../editor/__tests__/DiffViewProvider.spec.ts | 1 + .../file-safety/__tests__/safeWriteText.spec.ts | 8 +------- src/utils/__tests__/safeWriteJson.test.ts | 1 - 7 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index fd72c6f59e..f69203a0bc 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -442,7 +442,7 @@ describe("guardedWrite (S4a, epic #1375)", () => { // user saw: a link planted while the write waits on the chain would move the // publish, so the identity comparison rejects it. let targetLookups = 0 - mockedFsRealpath.mockImplementation(async (p) => { + mockedFsRealpath.mockImplementation(async (_p) => { targetLookups++ return targetLookups === 1 ? "/real/approved/outside.txt" : "/real/elsewhere/secret.txt" }) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index e031b7b0d4..097cdf62e1 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -22,8 +22,7 @@ import type { Task } from "../../task/Task" import { isBinaryFile } from "isbinaryfile" - -import { readFileTool, ReadFileTool } from "../ReadFileTool" +import { readFileTool } from "../ReadFileTool" import { ObservationRegistry } from "../../task/observationRegistry" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, processImageFile, isSupportedImageFormat } from "../helpers/imageHelpers" @@ -2056,7 +2055,7 @@ describe("ReadFileTool", () => { ) expect(observeSpy).toHaveBeenCalledTimes(1) - const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + const [_calledPath, , calledComplete] = observeSpy.mock.calls[0] expect(calledComplete).toBe(false) }) @@ -2087,7 +2086,7 @@ describe("ReadFileTool", () => { ) expect(observeSpy).toHaveBeenCalledTimes(1) - const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + const [_calledPath, , calledComplete] = observeSpy.mock.calls[0] expect(calledComplete).toBe(false) }) @@ -2145,7 +2144,7 @@ describe("ReadFileTool", () => { await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) expect(observeSpy).toHaveBeenCalledTimes(1) - const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + const [_calledPath, , calledComplete] = observeSpy.mock.calls[0] expect(calledComplete).toBe(true) // Nothing was omitted and nothing was clipped, so no note is added. @@ -2256,7 +2255,7 @@ describe("ReadFileTool", () => { await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) expect(observeSpy).toHaveBeenCalledTimes(1) - const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + const [_calledPath, , calledComplete] = observeSpy.mock.calls[0] expect(calledComplete).toBe(false) // Lines were omitted here, so the note reports the omitted range rather diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 671c9f594c..d885b4920a 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -966,7 +966,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 96 + "count": 94 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index e5db6ec8c7..5b96871e97 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -532,9 +532,6 @@ export class DiffViewProvider { async saveChanges( diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, - // Stryker disable next-line StringLiteral: an empty kind dispatches exactly - // like "update" in guardedWrite (only "edit" and "create" branch distinctly), - // so the StringLiteral mutant here is equivalent. writeKind: GuardedWriteKind = "update", // The tool layer put this write in front of the user and the user approved it, // for a target outside every workspace root. Only that post-approval path sets @@ -611,10 +608,6 @@ export class DiffViewProvider { // already published. When a stat-matched read returns the same bytes the // write already happened, so adopt that state instead of failing. if ( - // Stryker disable next-line LogicalOperator: GuardRejectedError is only thrown - // by guardedWrite, and guardedWrite is reached only after the !saveTask check - // above has thrown a plain Error. So whenever this operand is evaluated saveTask - // is provably non-null: dropping it cannot change which branch is taken. error instanceof GuardRejectedError && saveTask && encodedContent && diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index c443acebee..087b391df8 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1,5 +1,6 @@ import { DiffViewProvider, DIFF_VIEW_URI_SCHEME, DIFF_VIEW_LABEL_CHANGES } from "../DiffViewProvider" import * as vscode from "vscode" +import * as path from "path" import delay from "delay" import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index b173c01084..4982c6e56e 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -4,13 +4,7 @@ import { execFile } from "child_process" import type { ChildProcess } from "child_process" import * as path from "path" -import { - PostCommitDurabilityError, - resolveLockKey, - safeWriteText, - StagingPathError, - type SafeWriteTextOptions, -} from "../safeWriteText" +import { PostCommitDurabilityError, resolveLockKey, safeWriteText, StagingPathError } from "../safeWriteText" // Full mock for fs/promises — all methods are vi.fn() stubs vi.mock("fs/promises", () => ({ diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 3ba0a5389c..84159249c9 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -4,7 +4,6 @@ import * as path from "path" import * as os from "os" import { ConfinedPathEscapeError, 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 From 41c02956fd4bb20345bf8f263ce3f3d64221e506 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:43:36 +0800 Subject: [PATCH 052/115] fix(editor): withdraw the preview observation on every exit path The preview-observation rule ("the tool's own look must not authorize a write") was applied only on saveChanges(..., "edit"). On every other exit path the partial preview entry stayed in task.observationRegistry: a full-file open() records the preview token with complete:false, a rejected "update" save reloads the buffer without writing (token unchanged, nothing forgets), and the model's next apply_diff is then authorized by an observation the model never earned. open() now records the token its preview wrote; reset() - reached by every tool catch block and by revertChanges through finalizeSession - forgets the entry while it still holds that token AND the preview's incomplete flag. A successful publish replaces the token first, and a model read_file records complete: true, so earned observations survive both ways. Regression tests: the full trigger sequence (preview -> rejected update -> leftover -> reset withdraws), plus the two survival cases that pin the guard's conditions (same-token complete:true from a model read survives; a moved token survives). Negative controls, each exactly one red: withdrawal disabled reddens the trigger test; dropping the incomplete-flag condition reddens the same-token survival test; dropping the token condition reddens the moved-token survival test. Also per the trivial row: the lock-cleanup test now asserts the exact stale-version rejection (the outcome is deterministic - open() observed the placeholder, computeVersionToken answers the peer's token) instead of accepting either guard verdict, and the duplicated toHaveBeenCalledWith in the pre-preview authorization test is deduplicated. 152/152 green, eslint clean, prettier clean, tsc adds nothing beyond the known local donor drift. --- src/integrations/editor/DiffViewProvider.ts | 25 ++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 57 +++++++++++++++++-- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 5b96871e97..21d68a8d62 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -132,6 +132,13 @@ export class DiffViewProvider { * caller never read, publishing stale content over the intervening change. */ private preOpenObservation: { version: string; complete: boolean } | null | undefined = undefined + // The observation this session's preview recorded (path + token), if any. + // reset() withdraws it while the entry still holds that token: the preview is + // the tool's own look, not a read the model made, and on the exit paths that + // never reach saveChanges' restore (rejected update saves, cancelled tools, + // reverts) a leftover entry would authorize a later targeted edit for a file + // the model never read. + private previewRecordedObservation: { path: string; version: string } | undefined = undefined constructor( private cwd: string, @@ -207,6 +214,7 @@ export class DiffViewProvider { const displayToken = versionTokenOfStat(preStats) if (displayToken === versionTokenOfStat(postStats)) { displayTask.observationRegistry.observe(absolutePath, displayToken, false) + this.previewRecordedObservation = { path: absolutePath, version: displayToken } } } } else { @@ -1649,6 +1657,23 @@ export class DiffViewProvider { // A reset closes the session whoever calls it through, so the finalization is claimed // here as well: a teardown waiter that arrives afterwards must not run a second one. this.sessionFinalizationClaimed = true + + // Withdraw a preview observation that no save path restored. Every caller + // reaches reset() - tool catch blocks call it, and revertChanges arrives + // through finalizeSession - so this single hook covers the exits that never + // touch saveChanges' restore. The entry is dropped only while it still holds + // the preview's token AND the preview's incomplete flag: a successful + // publish replaced the token, and a model read_file records complete: true, + // so earned observations survive. + const previewRecorded = this.previewRecordedObservation + this.previewRecordedObservation = undefined + if (previewRecorded) { + const previewTask = this.taskRef.deref() + const stillPreview = previewTask?.observationRegistry.get(previewRecorded.path) + if (stillPreview && stillPreview.version === previewRecorded.version && !stillPreview.complete) { + previewTask.observationRegistry.forget(previewRecorded.path) + } + } // Dispose touch listeners and cancel any pending deferred scroll BEFORE any // async editor manipulation. closeAllDiffViews() awaits tab-close operations, // so leaving listeners/timers live across that await could let a stale handler diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 087b391df8..c8cd168e20 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1524,6 +1524,55 @@ describe("DiffViewProvider", () => { expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(false) }) + it("withdraws the preview observation on a rejected update save once reset runs", async () => { + // The full trigger the review row spells out: the model never read the + // file, open() records the preview entry (complete: false), and + // saveChanges(..., "update") is rejected as only-partially-read. The + // discard cleanup reloads the buffer; nothing wrote, so the token is + // unchanged and nothing forgets the entry. reset() - reached by every + // tool catch and by revertChanges through finalizeSession - must + // withdraw it, or a later apply_diff is authorized by a look the model + // never made (the edit-path test above covers only the first attempt). + mockTask.observationRegistry.clear() + await openPreview() + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(true) + + await expect(diffViewProvider.saveChanges(false, 0, "update")).rejects.toThrow(/only partially read/) + // Step 2 of the trigger: the rejected update leaves the preview entry in + // place - this is the leftover the fix must catch. + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(true) + + await diffViewProvider.reset() + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(false) + }) + + it("keeps an observation the model earned at the same token after the preview", async () => { + // A read_file after the preview records complete: true at the same + // version token (nothing wrote in between). The withdrawal must not + // touch it: the model did read the file, and its earned authorization + // outlives the preview. + mockTask.observationRegistry.clear() + await openPreview() + const previewEntry = mockTask.observationRegistry.get(`${mockCwd}/test.txt`) + expect(previewEntry).toBeDefined() + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, previewEntry!.version, true) + + await diffViewProvider.reset() + expect(mockTask.observationRegistry.get(`${mockCwd}/test.txt`)?.complete).toBe(true) + }) + + it("keeps an entry whose token moved on - the preview no longer owns it", async () => { + // A landed publish (or a later session) replaced the entry: the version + // no longer names the preview's token, so the withdrawal leaves it + // alone even though the flag still reads incomplete. + mockTask.observationRegistry.clear() + await openPreview() + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "landed-token", false) + + await diffViewProvider.reset() + expect(mockTask.observationRegistry.get(`${mockCwd}/test.txt`)?.version).toBe("landed-token") + }) + it("still publishes a targeted edit authorized by a pre-preview observation", async () => { // The same preview must not break the legitimate case: the model read the // file (partially) and nothing changed before the preview, so the restored @@ -1533,7 +1582,6 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveChanges(false, 0, "edit") expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.txt`, Buffer.from("new content")) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.txt`, Buffer.from("new content")) }) it("publishes the accepted content in the document's own encoding", async () => { @@ -1860,9 +1908,10 @@ describe("DiffViewProvider", () => { // The publish is rejected against the peer's token, so the discard cleanup runs. vi.mocked(computeVersionToken).mockResolvedValue("peer-token") - await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( - /Stale version|not read before this write/, - ) + // Deterministic: open() observed the placeholder token and + // computeVersionToken answers the peer's token, so the guard takes the + // stale-version branch - pin it, not the unobserved-create alternative. + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow(/^Stale version -- the file changed/) // One acquisition for the guarded publish, one for the cleanup. expect(lockKeys).toEqual([`${mockCwd}/test.ts`, `${mockCwd}/test.ts`]) From a00c7e2c235e6b05bff36438605c55bc96f91d22 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:09:29 +0800 Subject: [PATCH 053/115] fix(editor): narrow the preview task before the withdrawal forget CI check-types: the optional-chain get() does not narrow previewTask for the forget() call; add the explicit presence condition (behavior identical - the get() already short-circuits on undefined). --- src/integrations/editor/DiffViewProvider.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 21d68a8d62..03f3d9c34c 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1670,7 +1670,12 @@ export class DiffViewProvider { if (previewRecorded) { const previewTask = this.taskRef.deref() const stillPreview = previewTask?.observationRegistry.get(previewRecorded.path) - if (stillPreview && stillPreview.version === previewRecorded.version && !stillPreview.complete) { + if ( + previewTask && + stillPreview && + stillPreview.version === previewRecorded.version && + !stillPreview.complete + ) { previewTask.observationRegistry.forget(previewRecorded.path) } } From 14f8634eb915051e6c0a9672c91e422cd4f7af13 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:25:50 +0800 Subject: [PATCH 054/115] fix(editor): withdraw the preview entry by ownership, not content A model partial read while the diff is open replaces the preview observation with a fresh entry at the SAME token and the SAME incomplete flag - indistinguishable by content - so the version+flag withdrawal condition deleted the model's earned authorization and a later targeted edit needed another read. open() now keeps the exact entry object its observe() wrote; reset() forgets only while the registry still stores that object. Any later observe() - publish, model read, partial or not - replaces the entry with a fresh object, so every earned observation survives. Regression test: model partial read after open() survives reset(). Negative controls, each exactly one red: content-based condition (the pre-fix rule) reddens the new test; disabling the withdrawal reddens the trigger test. 153/153 green. --- src/integrations/editor/DiffViewProvider.ts | 40 ++++++++++--------- .../editor/__tests__/DiffViewProvider.spec.ts | 17 ++++++++ 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 03f3d9c34c..ca3adb8f6e 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 type { FileObservation } from "../../core/task/observationRegistry" import { versionTokenOfStat } from "../../utils/versionToken" import { withFileLock } from "../../utils/fileLock" import { resolveLockKey } from "../../services/file-safety/safeWriteText" @@ -132,13 +133,15 @@ export class DiffViewProvider { * caller never read, publishing stale content over the intervening change. */ private preOpenObservation: { version: string; complete: boolean } | null | undefined = undefined - // The observation this session's preview recorded (path + token), if any. - // reset() withdraws it while the entry still holds that token: the preview is - // the tool's own look, not a read the model made, and on the exit paths that - // never reach saveChanges' restore (rejected update saves, cancelled tools, - // reverts) a leftover entry would authorize a later targeted edit for a file - // the model never read. - private previewRecordedObservation: { path: string; version: string } | undefined = undefined + // The entry object this session's preview wrote (path + the exact stored + // object), if any. reset() withdraws only that object: the preview is the + // tool's own look, not a read the model made, and on the exit paths that never + // reach saveChanges' restore (rejected update saves, cancelled tools, reverts) + // a leftover entry would authorize a later targeted edit for a file the model + // never read. Identity, not version/completeness: a model partial read while + // the diff is open replaces the entry with a fresh object at the same token + // and flag, and that read earned its own authorization. + private previewRecordedObservation: { path: string; entry: FileObservation } | undefined = undefined constructor( private cwd: string, @@ -214,7 +217,10 @@ export class DiffViewProvider { const displayToken = versionTokenOfStat(preStats) if (displayToken === versionTokenOfStat(postStats)) { displayTask.observationRegistry.observe(absolutePath, displayToken, false) - this.previewRecordedObservation = { path: absolutePath, version: displayToken } + const recorded = displayTask.observationRegistry.get(absolutePath) + if (recorded) { + this.previewRecordedObservation = { path: absolutePath, entry: recorded } + } } } } else { @@ -1661,21 +1667,17 @@ export class DiffViewProvider { // Withdraw a preview observation that no save path restored. Every caller // reaches reset() - tool catch blocks call it, and revertChanges arrives // through finalizeSession - so this single hook covers the exits that never - // touch saveChanges' restore. The entry is dropped only while it still holds - // the preview's token AND the preview's incomplete flag: a successful - // publish replaced the token, and a model read_file records complete: true, - // so earned observations survive. + // touch saveChanges' restore. The entry is dropped only while it still + // holds the exact entry object it wrote: any later observe() - a successful + // publish, a model read (even a partial one at the same token and flag) - + // replaces the entry with a fresh object, so earned observations survive. const previewRecorded = this.previewRecordedObservation this.previewRecordedObservation = undefined if (previewRecorded) { const previewTask = this.taskRef.deref() - const stillPreview = previewTask?.observationRegistry.get(previewRecorded.path) - if ( - previewTask && - stillPreview && - stillPreview.version === previewRecorded.version && - !stillPreview.complete - ) { + // Ownership by object identity: the stored object still being the one + // this preview wrote means no read has claimed the entry since. + if (previewTask && previewTask.observationRegistry.get(previewRecorded.path) === previewRecorded.entry) { previewTask.observationRegistry.forget(previewRecorded.path) } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index c8cd168e20..8cbc720183 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1561,6 +1561,23 @@ describe("DiffViewProvider", () => { expect(mockTask.observationRegistry.get(`${mockCwd}/test.txt`)?.complete).toBe(true) }) + it("keeps a model partial read that replaced the preview entry at the same token", async () => { + // A partial read_file while the diff is open replaces the preview + // observation with a fresh entry at the SAME token and the SAME + // incomplete flag - indistinguishable by content, but a different + // owner. The withdrawal is by entry identity, so the model's earned + // partial authorization survives the reset and a later targeted edit + // stays authorized. + mockTask.observationRegistry.clear() + await openPreview() + const previewEntry = mockTask.observationRegistry.get(`${mockCwd}/test.txt`) + expect(previewEntry).toBeDefined() + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, previewEntry!.version, false) + + await diffViewProvider.reset() + expect(mockTask.observationRegistry.get(`${mockCwd}/test.txt`)).toBeDefined() + }) + it("keeps an entry whose token moved on - the preview no longer owns it", async () => { // A landed publish (or a later session) replaced the entry: the version // no longer names the preview's token, so the withdrawal leaves it From cf07183a4c595c1cbf3ece746c561bbe56cfb3fe Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:19:57 +0800 Subject: [PATCH 055/115] fix(guarded-write): bind the publish to the authorized canonical target Security Boundaries: verifyTarget checked containment against the CANONICAL target, then guardedWrite handed the caller's alias to safeWriteText, which resolves it again before renaming - a symlink swapped in after the check moved the bytes to a file the containment decision never covered. verifyTarget now resolves the path ONCE, checks containment (or the approved identity) against that resolution, and RETURNS it; createIfAbsent and replaceIfAbsent bind every subsequent step - existence check, version comparison, publish, post-publish token read - to the returned canonical target, so the alias is never resolved again after the decision. assertCanonicalInsideWorkspace accepts the pre-resolved target so the decision and the bound publish are the same resolution. Regression tests: the create publish goes to the canonical target, not the alias; the version comparison's FIRST token read acts on the bound target (call order pinned). NCs: publishing to the alias reddens the canonical tests; versioning the alias reddens exactly the version test. Linked Issues follow-up: WriteToFileTool's normal approval path passed undefined as saveChanges' third argument and relied on the parameter default for the write kind; it now passes "update" explicitly, and the full-workflow test pins the kind. NC: reverting to undefined reddens exactly that test. 66/66 guardedWrite.spec, 24 passed/5 skipped writeToFileTool.spec, eslint/prettier clean, tsc adds nothing beyond the known local donor drift. --- src/core/tools/WriteToFileTool.ts | 5 +- src/core/tools/__tests__/guardedWrite.spec.ts | 58 +++++++++++++++++-- .../tools/__tests__/writeToFileTool.spec.ts | 4 ++ src/core/tools/guardedWrite.ts | 45 +++++++++----- 4 files changed, 92 insertions(+), 20 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 5d7e81735e..5b0e48cfd9 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -189,7 +189,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { await task.diffViewProvider.saveChanges( diagnosticsEnabled, writeDelayMs, - undefined, + // Name the guard explicitly: a whole-file write is an update, and + // relying on the parameter's default would silently re-select a + // different guard if the default ever moved. + "update", isOutsideWorkspace, approvedCanonicalTarget, ) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index f69203a0bc..69147ec110 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -271,9 +271,9 @@ describe("guardedWrite (S4a, epic #1375)", () => { mockedFsRealpath.mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) const task = createMockTask() - await expect(guardedWrite(task, "inside.txt", "data", "create")).rejects.toThrow( - "Workspace could not be resolved", - ) + // The first resolution that fails is the target's own; either refusal is + // fail-closed and neither publishes. + await expect(guardedWrite(task, "inside.txt", "data", "create")).rejects.toThrow(/could not be resolved/) expect(mockedSafeWriteText).not.toHaveBeenCalled() }) @@ -401,7 +401,57 @@ describe("guardedWrite (S4a, epic #1375)", () => { await guardedWrite(task, "nested/in.txt", "data", "create") - expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("nested/in.txt"), "data") + // The publish goes to the canonical target the containment check + // authorized, not to the alias the caller spelled. + expect(mockedSafeWriteText).toHaveBeenCalledWith("/real/workspace/nested/in.txt", "data") + }) + + it("publishes to the canonical target the containment check authorized, not to the alias", async () => { + // The check resolves the alias once and must publish to THAT value: + // safeWriteText re-resolves whatever path it is given, so handing it + // the alias lets a link retargeted after the check send the bytes to a + // file the containment decision never covered. + const alias = abs("link.txt") + const referent = path.join(path.resolve(WORKSPACE), "real.txt") + mockedFsRealpath.mockImplementation(async (p) => { + const str = String(p) + if (str === alias) { + return referent + } + return str + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + + await guardedWrite(task, "link.txt", "data", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(referent, "data") + }) + + it("compares the expected version against the bound canonical target", async () => { + // The version check and the publish must act on one target: comparing + // the token against the alias while publishing to the canonical path + // (or vice versa) authorizes a file the check never looked at. + const alias = abs("link.txt") + const referent = path.join(path.resolve(WORKSPACE), "real.txt") + mockedFsRealpath.mockImplementation(async (p) => { + const str = String(p) + if (str === alias) { + return referent + } + return str + }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + task.observationRegistry.observe(alias, "v1", true) + + await guardedWrite(task, "link.txt", "data", "update") + + // The FIRST token read is the version comparison (the post-publish + // refresh also reads the bound target, so call order is the assertion). + expect(mockedComputeVersionToken.mock.calls[0]).toEqual([referent]) + expect(mockedSafeWriteText).toHaveBeenCalledWith(referent, "data") }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index b8826e68d9..127bdd1284 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -397,6 +397,10 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, true) expect(mockAskApproval).toHaveBeenCalled() expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() + // The approved whole-file write names its guard explicitly: the third + // argument selects the write kind - passing undefined relied on the + // parameter's default instead of the tool's own decision. + expect(mockCline.diffViewProvider.saveChanges.mock.calls[0][2]).toBe("update") expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") expect(mockCline.didEditFile).toBe(true) }) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 0fbd1352e9..097e5e4c72 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -185,14 +185,18 @@ export async function createIfAbsent( // Re-checked under the lock, immediately before the publish: the path was authorized // when it was queued, but a symlink can be swapped in while the link waited on the // FIFO chain or on this lock. - verifyTarget?: () => Promise, + verifyTarget?: () => Promise, ): Promise { // Lock the key every other writer to this file uses: the resolved publish // target, so a symlink alias and its referent share one lock. return withFileLock(await resolveLockKey(absolutePath), async () => { cancelledBeforePublish(absolutePath, displayPath, isCancelled) + // Bind the publish to the target the authorization check covers: the + // re-check runs first and returns the canonical path it authorized, and + // every step below acts on that fixed target. + const boundTarget = (await verifyTarget?.()) ?? absolutePath try { - await fs.access(absolutePath) + await fs.access(boundTarget) } catch (error: unknown) { if (errorCode(error) !== "ENOENT") { // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. @@ -200,12 +204,11 @@ export async function createIfAbsent( } // Immediately before publication starts. cancelledBeforePublish(absolutePath, displayPath, isCancelled) - await verifyTarget?.() - await safeWriteText(absolutePath, content) + await safeWriteText(boundTarget, content) // Read the new token under the same lock, otherwise a peer lock-using // writer can publish in the gap and the caller records that writer's // token as its own observation. - return tokenAfterPublish(absolutePath) + return tokenAfterPublish(boundTarget) } throw new GuardRejectedError( @@ -247,15 +250,18 @@ export async function replaceIfVersion( // Re-checked under the lock, immediately before the publish: the path was authorized // when it was queued, but a symlink can be swapped in while the link waited on the // FIFO chain or on this lock. - verifyTarget?: () => Promise, + verifyTarget?: () => Promise, ): Promise { // Lock the key every other writer to this file uses: the resolved publish // target, so a symlink alias and its referent share one lock. return withFileLock(await resolveLockKey(absolutePath), async () => { cancelledBeforePublish(absolutePath, displayPath, isCancelled) + // Bind first: the version comparison and the publish must act on the same + // target the authorization check covered. + const boundTarget = (await verifyTarget?.()) ?? absolutePath let currentVersion: string try { - currentVersion = await computeVersionToken(absolutePath) + currentVersion = await computeVersionToken(boundTarget) } catch (error: unknown) { if (errorCode(error) === "ENOENT") { // The observed file was deleted after the read: the version recorded @@ -281,12 +287,11 @@ export async function replaceIfVersion( if (currentVersion === expectedVersion) { // Immediately before publication starts. cancelledBeforePublish(absolutePath, displayPath, isCancelled) - await verifyTarget?.() - await safeWriteText(absolutePath, content) + await safeWriteText(boundTarget, content) // Read the new token under the same lock, otherwise a peer lock-using // writer can publish in the gap and the caller records that writer's // token as its own observation. - return tokenAfterPublish(absolutePath) + return tokenAfterPublish(boundTarget) } throw new GuardRejectedError( @@ -364,6 +369,10 @@ async function assertCanonicalInsideWorkspace( roots: string[], absolutePath: string, displayPath: string, + // The caller may already hold the canonical target it wants to publish to; + // checking THAT value (rather than resolving the path a second time) keeps the + // containment decision and the bound publish target the same resolution. + preResolvedTarget?: string, ): Promise { const resolvedRoots: string[] = [] const unresolvedRoots: string[] = [] @@ -385,7 +394,7 @@ async function assertCanonicalInsideWorkspace( displayPath, ) } - const target = await realpathNearest(absolutePath, displayPath) + const target = preResolvedTarget ?? (await realpathNearest(absolutePath, displayPath)) if (!resolvedRoots.some((root) => isInside(root, target))) { throw new GuardRejectedError( unresolvedRoots.length > 0 @@ -520,23 +529,29 @@ export async function guardedWrite( } authorizedTarget = options.approvedCanonicalTarget } - const verifyTarget = async (): Promise => { + const verifyTarget = async (): Promise => { + // Resolve ONCE and return the canonical target: the containment decision + // below and the publish must act on the same resolution, so the guarded + // writers publish to THIS value instead of handing the caller's alias to + // safeWriteText (which would resolve it a third time - a link swapped in + // after the check would otherwise move the bytes to another file). + const resolved = await realpathNearest(absolutePath, displayPath) if (!approvedOutside) { - await assertCanonicalInsideWorkspace(roots, absolutePath, displayPath) - return + await assertCanonicalInsideWorkspace(roots, absolutePath, displayPath, resolved) + return resolved } // For an approved outside-workspace write the re-check is an IDENTITY check: what // resolves now must still be what was approved. A symlink swapped in - before this // call or while the link waited on the chain - moves the write somewhere the user // never saw, so the comparison is against the identity captured before the // approval, never against a lookup done here. - const resolved = await realpathNearest(absolutePath, displayPath) if (authorizedTarget !== resolved) { throw new GuardRejectedError( "The approved target no longer resolves to the path that was approved -- re-approve the write, then retry.", displayPath, ) } + return resolved } await verifyTarget() From f756ee33a5b2e506dd39d8e531da17d143de42a5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:38:49 +0800 Subject: [PATCH 056/115] test(tools): pass toolCallId in guarded-write spec callbacks main's tool-call scoping (#2015) made ToolCallbacks.toolCallId required; the unit's own spec callbacks name it with the repo's test convention so check-types passes on the merge ref with either BaseTool shape. --- .../applyDiffTool.guardedWrite.spec.ts | 9 ++++++++ .../__tests__/applyPatchTool.execute.spec.ts | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 81d6cfda6d..6de4340f34 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -162,6 +162,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockSaveDirectly).toHaveBeenCalledWith( @@ -185,6 +186,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockHandleError).toHaveBeenCalledWith("applying diff", guardError) @@ -213,6 +215,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") @@ -230,6 +233,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) const observation = mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts")) @@ -260,6 +264,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() @@ -277,6 +282,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) const observation = mockTask.observationRegistry.get(key) @@ -295,6 +301,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) const observation = mockTask.observationRegistry.get(key) @@ -334,6 +341,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() @@ -375,6 +383,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 4f47cd8c0b..1cd14422ea 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -128,6 +128,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockAskApproval).toHaveBeenCalled() @@ -262,6 +263,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockSaveDirectly).toHaveBeenCalledWith( @@ -292,6 +294,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(reg.get(key)?.complete).toBe(false) @@ -325,6 +328,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(reg.get(key)?.complete).toBe(true) @@ -342,6 +346,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(reg.get(key)?.complete).toBe(false) @@ -362,6 +367,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(reg.get(key)?.complete).toBe(false) @@ -377,6 +383,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) // The hunk read doubles as the S2 observation (ReadFileTool contract): @@ -403,6 +410,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) @@ -431,6 +439,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) @@ -455,6 +464,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) @@ -469,6 +479,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockSaveDirectly).toHaveBeenCalledWith( @@ -492,6 +503,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) // The source had no prior observation, so the hunk read records a partial @@ -526,6 +538,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) // Nothing was downgraded: the destination keeps the completeness the model earned. @@ -563,6 +576,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(completeAtPublish).toEqual([false]) @@ -586,6 +600,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(reg.get(destKey)?.complete).toBe(true) @@ -610,6 +625,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(reg.get(destKey)?.complete).toBe(true) @@ -634,6 +650,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(reg.get(path.resolve("/workspace/project", "src/old.ts"))).toBeUndefined() @@ -662,6 +679,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(reg.get(destKey)).toBeUndefined() @@ -678,6 +696,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) @@ -696,6 +715,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) @@ -713,6 +733,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit", false, undefined) @@ -729,6 +750,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", }) expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create", false, undefined) From 6d2bf3e3c4c1a640b6ad911436d99705b8976241 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:54:04 +0800 Subject: [PATCH 057/115] fix(guarded-write): refuse a dangling leaf link before publishing realpathNearest checked only ancestor components: realpath reports ENOENT for a dangling leaf exactly as for a missing file, and the ancestor walk then authorized the lexical path. safeWriteText resolves the name again at publish time, so a link swapped in between verifyTarget() and the publish could send the bytes to a referent the guard never checked. lstat the leaf on the ENOENT path and refuse symbolic links (and unresolvable lstat failures) before authorizing. --- src/core/tools/__tests__/guardedWrite.spec.ts | 26 +++++++++++++++++++ src/core/tools/guardedWrite.ts | 18 +++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 69147ec110..87276706a6 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -365,6 +365,32 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).not.toHaveBeenCalled() }) + it("refuses a target whose leaf is a dangling symlink", async () => { + // realpath reports ENOENT for a dangling leaf exactly as for a missing + // file, and the ancestor walk then authorizes the lexical path. The + // publish resolves the name again - it could follow a referent the + // guard never checked - so a dangling leaf is refused before publish. + const leaf = path.join(path.resolve(WORKSPACE), "ghost.txt") + mockedFsRealpath.mockImplementation(async (p) => { + const s = String(p) + if (s === path.resolve(WORKSPACE)) return path.resolve(WORKSPACE) + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + mockedFsLstat.mockImplementation(async (p) => { + if (String(p) === leaf) { + return { isSymbolicLink: () => true } as never + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + const task = createMockTask() + + await expect(guardedWrite(task, "ghost.txt", "data", "create")).rejects.toThrow( + "link that does not resolve", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + it("re-checks containment under the lock, after the wait on the FIFO chain", async () => { // The path was inside the workspace when it was queued; a link is swapped in while the // write waits. The publish must not follow the new link. diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 097e5e4c72..c145fe161f 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -416,6 +416,24 @@ async function realpathNearest(target: string, displayPath: string): Promise { + if (errorCode(lstatError) === "ENOENT") { + return undefined + } + throw new GuardRejectedError( + "Path could not be resolved, so it cannot be checked against the workspace -- retry with a path inside the workspace.", + displayPath, + ) + }) + if (leaf?.isSymbolicLink()) { + throw new GuardRejectedError( + `Path is a link that does not resolve -- retry with a path inside the workspace.`, + displayPath, + ) + } const missing: string[] = [] let ancestor = lexical while (true) { From f5fddf6efae9369f8bd473a506f33d3daec6199e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:48:15 +0800 Subject: [PATCH 058/115] fix(diff-view): reconcile an already-open document after the guarded publish saveDirectly() commits through guardedWrite(), which writes the bytes to disk directly; the diff removed the awaited document.save() that used to follow the publish, so an already-open target document kept its pre-publish buffer. A dirty buffer there is a live hazard: VS Code's ordinary save path would republish those stale bytes over what the guard committed. saveDirectly now reconciles the open document before returning - reverting reloads the published bytes and clears the dirty flag - so no open buffer is left whose text differs from the publish. Reverting a document that already matches the disk is a no-op, and the memory-only path still never saves the buffer over the publish. --- src/integrations/editor/DiffViewProvider.ts | 15 +++++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 26 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index ca3adb8f6e..9edc514711 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1779,6 +1779,21 @@ export class DiffViewProvider { additionalRoots: this.additionalWorkspaceRoots(), }) + // The guarded publish wrote the bytes to disk directly. If the target + // document is already open, its buffer still holds the pre-publish text: + // a dirty buffer would republish those stale bytes over what the guard + // committed through VS Code's unguarded save path, and a clean one only + // catches up when the file watcher fires. Reconcile it before returning - + // reverting reloads the published bytes and clears the dirty flag - so no + // open buffer is left whose text differs from the publish. Reverting a + // document that already matches the disk is a no-op. + const openTarget = vscode.workspace.textDocuments.find((document) => + arePathsEqual(document.uri.fsPath, absolutePath), + ) + if (openTarget !== undefined) { + await this.revertDocument(openTarget) + } + // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory if (openFile) { diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 8cbc720183..7f1d2e01f3 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1135,6 +1135,32 @@ describe("DiffViewProvider", () => { expect(dirtyDoc.save).not.toHaveBeenCalled() }) + it("reconciles an already-open target document after the guarded publish", async () => { + // guardedWrite wrote the published bytes to disk directly; the open + // buffer still holds the pre-publish text, and its dirty state means + // VS Code's ordinary save path would republish those stale bytes over + // what the guard committed. saveDirectly must reconcile the buffer + // before returning: reverting reloads the published bytes and clears + // the dirty flag. + const openDirtyDoc = { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.TextDocument // Structural double: the revert path only reads uri and dirty state. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.push(openDirtyDoc) + try { + await diffViewProvider.saveDirectly("test.ts", "new content", false, false, 0) + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.files.revert") + // The reconcile is a revert, not a save: saving would republish the + // stale buffer bytes over the publish. + expect(openDirtyDoc.save).not.toHaveBeenCalled() + } finally { + documents.splice(documents.indexOf(openDirtyDoc), 1) + } + }) + it("should skip diagnostics when diagnosticsEnabled is false", async () => { const mockDelay = vi.mocked(delay) mockDelay.mockClear() From c198c2e00e6560ce3575c6ed69f54837e9257f70 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:59:21 +0800 Subject: [PATCH 059/115] fix(diff-view): fail saveDirectly when the reconcile leaves the target dirty The reconcile added after the guarded publish ignored its own result: revertDocument() returns false when the buffer is still dirty and can throw when the revert command fails, and saveDirectly proceeded to the diagnostics/open path and reported success either way. A dirty buffer surviving the guarded publish keeps VS Code's ordinary save path armed to republish stale bytes over what the guard committed. saveDirectly now requires a successful reconciliation. When the revert fails or leaves the buffer dirty it closes the tab and reopens the document from disk; if the reopened buffer is still dirty the write is reported as failed instead of succeeding over a stale buffer, and the diagnostics/open path does not run while the target is dirty. Tests: the reconcile test now models the revert clearing the dirty flag; a new test rejects the revert command and keeps the buffer dirty, then asserts saveDirectly rejects, the tab was closed, the document was reopened from disk, and the stale buffer's save never ran. Negative controls: dropping the fail-safe to a bare revert, and tolerating a dirty reopened document, each redden exactly that test. 155/155 green. --- src/integrations/editor/DiffViewProvider.ts | 26 ++++++++- .../editor/__tests__/DiffViewProvider.spec.ts | 54 +++++++++++++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 9edc514711..2fbb4f1f4e 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1791,7 +1791,31 @@ export class DiffViewProvider { arePathsEqual(document.uri.fsPath, absolutePath), ) if (openTarget !== undefined) { - await this.revertDocument(openTarget) + const reconciled = await this.revertDocument(openTarget).catch(() => false) + if (!reconciled) { + // The revert failed or the buffer stayed dirty: its stale bytes could + // still be republished by VS Code's ordinary save path over what the + // guard committed. Close the tab and reopen the document from disk so + // no dirty buffer survives; if the reopened buffer is still dirty, + // fail the operation rather than reporting success over it - the + // diagnostics/open path must not run while the target is dirty. + try { + await this.closeFileTab(absolutePath) + } catch { + // best-effort close: the reopened-document check below decides + } + await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) + const reopened = vscode.workspace.textDocuments.find((document) => + arePathsEqual(document.uri.fsPath, absolutePath), + ) + if (reopened?.isDirty) { + throw new Error( + `saveDirectly: the open document at ${absolutePath} is still dirty after the guarded publish -- ` + + "reverting and reopening it did not reconcile the buffer, so its stale bytes could overwrite " + + "the published content on the next save; the write is reported as failed.", + ) + } + } } // Open the document to ensure diagnostics are loaded diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 7f1d2e01f3..7e2cb3c6eb 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1146,10 +1146,19 @@ describe("DiffViewProvider", () => { uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, isDirty: true, save: vi.fn().mockResolvedValue(undefined), - } as unknown as vscode.TextDocument // Structural double: the revert path only reads uri and dirty state. + } + const docDouble = openDirtyDoc as unknown as vscode.TextDocument // Structural double: the revert path only reads uri and dirty state. const documents = vscode.workspace.textDocuments as vscode.TextDocument[] - documents.push(openDirtyDoc) + documents.push(docDouble) try { + // The revert command reloads the published bytes: the double's + // dirty flag clears when the revert runs, as it does in the editor. + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command: string) => { + if (command === "workbench.action.files.revert") { + openDirtyDoc.isDirty = false + } + }) + await diffViewProvider.saveDirectly("test.ts", "new content", false, false, 0) expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.files.revert") @@ -1157,7 +1166,46 @@ describe("DiffViewProvider", () => { // stale buffer bytes over the publish. expect(openDirtyDoc.save).not.toHaveBeenCalled() } finally { - documents.splice(documents.indexOf(openDirtyDoc), 1) + documents.splice(documents.indexOf(docDouble), 1) + vi.mocked(vscode.commands.executeCommand).mockReset() + } + }) + + it("fails the write when the reconcile leaves the target dirty", async () => { + // The revert command rejects and the buffer stays dirty: reporting + // success would leave VS Code's ordinary save path free to republish + // the stale buffer over the guarded bytes. saveDirectly must close the + // tab and reopen the document from disk, and when the reopened buffer + // is still dirty it must fail instead of reporting success - the + // diagnostics/open path must not run over a dirty target. + const staleDoc = { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + } + const docDouble = staleDoc as unknown as vscode.TextDocument // Structural double: the reconcile path only reads uri and dirty state. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.push(docDouble) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + try { + vi.mocked(vscode.commands.executeCommand).mockRejectedValue(new Error("revert command failed")) + + await expect(diffViewProvider.saveDirectly("test.ts", "new content", false, false, 0)).rejects.toThrow( + "still dirty after the guarded publish", + ) + + // The stale buffer is taken out of the save path: the tab was closed + // and the document reopened from disk before the failure surfaced, + // so a later save reads the published bytes, not the stale ones. + expect(closeFileTab).toHaveBeenCalledWith(`${mockCwd}/test.ts`) + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith( + expect.objectContaining({ fsPath: `${mockCwd}/test.ts` }), + ) + expect(staleDoc.save).not.toHaveBeenCalled() + } finally { + documents.splice(documents.indexOf(docDouble), 1) + vi.mocked(vscode.commands.executeCommand).mockReset() } }) From e3495284d06f05790a110670042fad9a5bc9de62 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:09:39 +0800 Subject: [PATCH 060/115] fix(diff-view): keep a dirty open buffer that differs from the guarded publish The guarded publish writes the accepted bytes to disk, then saveDirectly reconciled an already-open target buffer by reverting it. When the user had unsaved edits in that buffer, the revert discarded them (the CR finding: guardedWrite checks the on-disk file, so it publishes even though the open buffer is dirty). A dirty buffer whose text differs from the published content is now retained and the conflict is reported; only a clean buffer, or one whose text already equals the publish, is reloaded. Regression test added; DiffViewProvider spec 156 passed. --- src/integrations/editor/DiffViewProvider.ts | 11 +++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 2fbb4f1f4e..eabfa77f31 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1791,6 +1791,17 @@ export class DiffViewProvider { arePathsEqual(document.uri.fsPath, absolutePath), ) if (openTarget !== undefined) { + // A dirty buffer holds edits the user has not saved. The guarded publish wrote the + // accepted content to disk, so reverting would discard those edits. Only a clean + // buffer (or one whose text already equals the published content) may be reloaded; + // a buffer with different unsaved edits is retained and the conflict is reported. + if (openTarget.isDirty && openTarget.getText() !== content) { + throw new Error( + `saveDirectly: ${absolutePath} is open with unsaved edits that differ from the guarded publish; ` + + "the buffer was kept so those edits are not lost. Save or discard it, then retry. " + + "The published content is already on disk.", + ) + } const reconciled = await this.revertDocument(openTarget).catch(() => false) if (!reconciled) { // The revert failed or the buffer stayed dirty: its stale bytes could diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 7e2cb3c6eb..2afb0e0747 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1145,6 +1145,8 @@ describe("DiffViewProvider", () => { const openDirtyDoc = { uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, isDirty: true, + // The buffer already holds the published content, so reloading it loses no edits. + getText: () => "new content", save: vi.fn().mockResolvedValue(undefined), } const docDouble = openDirtyDoc as unknown as vscode.TextDocument // Structural double: the revert path only reads uri and dirty state. @@ -1171,6 +1173,33 @@ describe("DiffViewProvider", () => { } }) + it("keeps a dirty buffer with different unsaved edits and reports the conflict", async () => { + // The user has unsaved edits in the open target that differ from what the guard + // published. Reverting would discard them, so the buffer is retained and the + // conflict is reported instead; the published content stays on disk. + const userEditedDoc = { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + isDirty: true, + getText: () => "user unsaved edit", + save: vi.fn().mockResolvedValue(undefined), + } + const docDouble = userEditedDoc as unknown as vscode.TextDocument + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.push(docDouble) + try { + await expect(diffViewProvider.saveDirectly("test.ts", "new content", false, false, 0)).rejects.toThrow( + /unsaved edits that differ from the guarded publish/, + ) + + // No revert ran: the user's edits are still in the buffer. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.files.revert") + expect(documents).toContain(docDouble) + expect(userEditedDoc.save).not.toHaveBeenCalled() + } finally { + documents.splice(documents.indexOf(docDouble), 1) + } + }) + it("fails the write when the reconcile leaves the target dirty", async () => { // The revert command rejects and the buffer stays dirty: reporting // success would leave VS Code's ordinary save path free to republish @@ -1181,6 +1210,7 @@ describe("DiffViewProvider", () => { const staleDoc = { uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, isDirty: true, + getText: () => "new content", save: vi.fn().mockResolvedValue(undefined), } const docDouble = staleDoc as unknown as vscode.TextDocument // Structural double: the reconcile path only reads uri and dirty state. From 2f237b85660b4ff2e3d5e3e6db87cffcf5d363b1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:22:50 +0800 Subject: [PATCH 061/115] chore(knip): mark the guarded-write chain reset hook @internal --- src/core/tools/guardedWrite.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index c145fe161f..baa581a064 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -678,6 +678,7 @@ export async function guardedWrite( /** * Reset the per-path tail-promise chains (test hook). + * @internal Exported for focused implementation tests. */ export function resetChain(): void { pendingChains.clear() From f7aae8b85e982a63b0b5bd3421704fadc2ecec35 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:54:50 +0800 Subject: [PATCH 062/115] fix(editor): report the post-publish buffer conflict as a committed write Review follow-up: a dirty buffer that differs from the guarded publish now raises CommittedWriteBufferConflictError, whose message says the write is committed, forbids retrying the edit, and requires save/discard plus a fresh read before a separate edit; the tool-level test pins the message. DiffViewProvider+searchReplace specs 178 passed. --- .../tools/__tests__/searchReplaceTool.spec.ts | 25 +++++++++++++++++++ src/integrations/editor/DiffViewProvider.ts | 13 ++++++---- .../editor/__tests__/DiffViewProvider.spec.ts | 14 ++++++++--- .../committedWriteBufferConflictError.ts | 20 +++++++++++++++ 4 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 src/integrations/editor/committedWriteBufferConflictError.ts diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 2f5438d475..ffc4f25a10 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -494,6 +494,31 @@ describe("searchReplaceTool", () => { } }) + it("reports a committed-write buffer conflict without telling the model to retry the edit", async () => { + // saveDirectly publishes first and then throws when an open buffer has different + // unsaved edits. The tool must surface the committed-write contract, not a retry + // instruction: re-reading and re-applying a non-idempotent edit would duplicate it. + const { CommittedWriteBufferConflictError } = await import( + "../../../integrations/editor/committedWriteBufferConflictError" + ) + const conflictError = new CommittedWriteBufferConflictError("test.ts") + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(conflictError) + + const result = await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("search and replace", conflictError) + const message = conflictError.message + expect(message).toContain("write is already committed") + expect(message).toContain("do not retry") + expect(message).not.toContain("then retry") + // The rejected save did not edit the file. + expect(mockCline.didEditFile).toBe(false) + expect(result).toBeUndefined() + }) + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { const guardError = new Error("File not read yet -- read the file, then retry.") mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index eabfa77f31..8afc716b09 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1,4 +1,8 @@ import * as vscode from "vscode" + +import { CommittedWriteBufferConflictError } from "./committedWriteBufferConflictError" + +export { CommittedWriteBufferConflictError } import * as path from "path" import * as fs from "fs/promises" import * as diff from "diff" @@ -1796,11 +1800,10 @@ export class DiffViewProvider { // buffer (or one whose text already equals the published content) may be reloaded; // a buffer with different unsaved edits is retained and the conflict is reported. if (openTarget.isDirty && openTarget.getText() !== content) { - throw new Error( - `saveDirectly: ${absolutePath} is open with unsaved edits that differ from the guarded publish; ` + - "the buffer was kept so those edits are not lost. Save or discard it, then retry. " + - "The published content is already on disk.", - ) + // The publish already committed: report the committed-write buffer conflict. + // A generic error would tell the model to retry a non-idempotent edit + // (SearchReplaceTool/EditTool would re-read and duplicate the change). + throw new CommittedWriteBufferConflictError(absolutePath) } const reconciled = await this.revertDocument(openTarget).catch(() => false) if (!reconciled) { diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 2afb0e0747..700c665ee6 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1,4 +1,4 @@ -import { DiffViewProvider, DIFF_VIEW_URI_SCHEME, DIFF_VIEW_LABEL_CHANGES } from "../DiffViewProvider" +import { CommittedWriteBufferConflictError, DiffViewProvider, DIFF_VIEW_URI_SCHEME, DIFF_VIEW_LABEL_CHANGES } from "../DiffViewProvider" import * as vscode from "vscode" import * as path from "path" import delay from "delay" @@ -1187,9 +1187,15 @@ describe("DiffViewProvider", () => { const documents = vscode.workspace.textDocuments as vscode.TextDocument[] documents.push(docDouble) try { - await expect(diffViewProvider.saveDirectly("test.ts", "new content", false, false, 0)).rejects.toThrow( - /unsaved edits that differ from the guarded publish/, - ) + const conflict = await diffViewProvider + .saveDirectly("test.ts", "new content", false, false, 0) + .catch((error: unknown) => error) + expect(conflict).toBeInstanceOf(CommittedWriteBufferConflictError) + expect((conflict as Error).name).toBe("CommittedWriteBufferConflictError") + // The model must be told the write is committed and must not retry the edit. + expect((conflict as Error).message).toContain("write is already committed") + expect((conflict as Error).message).toContain("do not retry") + expect((conflict as Error).message).not.toContain("then retry") // No revert ran: the user's edits are still in the buffer. expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.files.revert") diff --git a/src/integrations/editor/committedWriteBufferConflictError.ts b/src/integrations/editor/committedWriteBufferConflictError.ts new file mode 100644 index 0000000000..41a4dd739e --- /dev/null +++ b/src/integrations/editor/committedWriteBufferConflictError.ts @@ -0,0 +1,20 @@ +/** + * The guarded publish committed the accepted content to disk, but an open buffer + * with different unsaved edits prevented reconciling the editor. The write is + * committed: callers must not retry the same (possibly non-idempotent) edit, + * because re-reading and re-applying it would duplicate the change. + * + * Kept in its own vscode-free module so tool-level tests can construct it + * without loading the DiffViewProvider's editor dependencies. + */ +export class CommittedWriteBufferConflictError extends Error { + constructor(absolutePath: string) { + super( + `saveDirectly: the guarded publish committed the accepted content to ${absolutePath}, but the file is ` + + "open with unsaved edits that differ from it; the buffer was kept so those edits are not lost. " + + "The write is already committed to disk -- do not retry this edit. Save or discard the buffer, " + + "then read the file again before making a separate edit.", + ) + this.name = "CommittedWriteBufferConflictError" + } +} From 8466a37cc2b8ba946bd0632fd7edc637ab6ab5e9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:57:30 +0800 Subject: [PATCH 063/115] style: format the committed-write conflict assertions --- src/core/tools/__tests__/searchReplaceTool.spec.ts | 5 ++--- src/integrations/editor/__tests__/DiffViewProvider.spec.ts | 7 ++++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index ffc4f25a10..d2dbacf167 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -498,9 +498,8 @@ describe("searchReplaceTool", () => { // saveDirectly publishes first and then throws when an open buffer has different // unsaved edits. The tool must surface the committed-write contract, not a retry // instruction: re-reading and re-applying a non-idempotent edit would duplicate it. - const { CommittedWriteBufferConflictError } = await import( - "../../../integrations/editor/committedWriteBufferConflictError" - ) + const { CommittedWriteBufferConflictError } = + await import("../../../integrations/editor/committedWriteBufferConflictError") const conflictError = new CommittedWriteBufferConflictError("test.ts") mockCline.diffViewProvider.saveDirectly.mockRejectedValue(conflictError) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 700c665ee6..634950b580 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1,4 +1,9 @@ -import { CommittedWriteBufferConflictError, DiffViewProvider, DIFF_VIEW_URI_SCHEME, DIFF_VIEW_LABEL_CHANGES } from "../DiffViewProvider" +import { + CommittedWriteBufferConflictError, + DiffViewProvider, + DIFF_VIEW_URI_SCHEME, + DIFF_VIEW_LABEL_CHANGES, +} from "../DiffViewProvider" import * as vscode from "vscode" import * as path from "path" import delay from "delay" From 9edc71a412347d6c18773ddf1182e5eb13b0f2a2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:08:01 +0800 Subject: [PATCH 064/115] test(apply-diff): mock the stat/observation doubles the guarded read requires After merging main, the failure-reporting suite reads through the same stat-around-read observation contract as the guarded tools; add the stat and observation-registry doubles so its failure paths do not report a spurious TypeError. 35 passed. --- .../applyDiffTool.failureReporting.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/core/tools/__tests__/applyDiffTool.failureReporting.spec.ts b/src/core/tools/__tests__/applyDiffTool.failureReporting.spec.ts index bf7b3f5351..a0daefcf59 100644 --- a/src/core/tools/__tests__/applyDiffTool.failureReporting.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.failureReporting.spec.ts @@ -15,6 +15,16 @@ vi.mock("@roo-code/telemetry", () => ({ vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue(""), + // apply_diff stats around its own read (same contract as ApplyPatchTool's hunk + // read): without this double the stat call throws and every failure-path test + // sees a spurious handleError("applying diff", TypeError). + stat: vi.fn().mockResolvedValue({ + dev: 7n, + ino: 4242n, + size: 1234n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + }), }, })) @@ -82,6 +92,10 @@ describe("ApplyDiffTool failure reporting", () => { }), }), }, + observationRegistry: { + get: vi.fn().mockReturnValue(undefined), + observe: vi.fn(), + }, diffViewProvider: { editType: undefined, originalContent: "", From ae4b018a30a0c89075b6ca21c5f6291cba252c75 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:53:32 +0800 Subject: [PATCH 065/115] fix(apply-diff): drop only the partial observation this call created on failure exits Review follow-up: track the observation path+version created by the hunk read and forget it only while the registry still holds that version, on diff-failure, denial and catch exits; replaced/published observations are preserved. applyDiffTool.guardedWrite.spec 12 passed. --- src/core/tools/ApplyDiffTool.ts | 19 ++++++ .../applyDiffTool.guardedWrite.spec.ts | 63 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index c580928dbc..bdf7902d63 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -38,6 +38,20 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { diffContent = unescapeHtmlEntities(diffContent) } + // Identity of a partial observation this call created (undefined when the model had + // already observed the file): only this exact observation may be removed on a failure + // exit, so a replaced or published observation is never discarded here. Declared outside + // the try so the catch exit can use it too. + let partialObservation: { path: string; version: string } | undefined + const forgetPartialObservationIfCurrent = () => { + if ( + partialObservation && + task.observationRegistry.get(partialObservation.path)?.version === partialObservation.version + ) { + task.observationRegistry.forget(partialObservation.path) + } + } + try { if (!relPath) { task.consecutiveMistakeCount++ @@ -95,6 +109,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { const prior = task.observationRegistry.get(absolutePath) if (prior === undefined) { task.observationRegistry.observe(absolutePath, preReadToken, false) + partialObservation = { path: absolutePath, version: preReadToken } } else if (prior.version === preReadToken) { task.observationRegistry.observe(absolutePath, preReadToken, prior.complete === true) } @@ -141,6 +156,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { task.recordToolError("apply_diff", formattedError) pushToolResult(formattedError) + forgetPartialObservationIfCurrent() return } @@ -197,6 +213,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) if (!didApprove) { + forgetPartialObservationIfCurrent() return } @@ -245,6 +262,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { if (!didApprove) { await task.diffViewProvider.revertChanges() + forgetPartialObservationIfCurrent() task.processQueuedMessages() return } @@ -301,6 +319,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } catch (error) { await handleError("applying diff", error as Error) + forgetPartialObservationIfCurrent() await task.diffViewProvider.reset() this.resetPartialState() task.processQueuedMessages() diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 6de4340f34..085add454c 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -272,6 +272,69 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { expect(stat).toHaveBeenCalledTimes(2) }) + it("removes the partial observation this call created when the diff cannot be applied", async () => { + // The edit never happens, so the partial authorization earned from the tool's own + // read must not outlive the failed call. It is removed only while it is still the + // same observation. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.diffStrategy = { + applyDiff: vi.fn().mockResolvedValue({ success: false, error: "no match" }), + } as unknown as Task["diffStrategy"] + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.observationRegistry.get(key)).toBeUndefined() + }) + + it("keeps a partial observation replaced by another operation on a failed diff", async () => { + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.diffStrategy = { + applyDiff: vi.fn().mockImplementation(async () => { + // Another operation publishes/observes this path while the diff is failing. + mockTask.observationRegistry.observe(key, "1:2:99:9:9", true) + return { success: false, error: "no match" } + }), + } as unknown as Task["diffStrategy"] + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The replaced observation belongs to the other operation, not this failed call. + expect(mockTask.observationRegistry.get(key)?.version).toBe("1:2:99:9:9") + }) + + it("removes the partial observation when the user denies the edit", async () => { + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: false }, + }), + }), + } as unknown as Task["providerRef"] + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.observationRegistry.get(key)).toBeUndefined() + }) + it("keeps a complete model observation complete when the tool read matches its version", async () => { // The model read the file in full first. The tool's own read of the same version // must not downgrade that earned completeness to a partial observation. From 2264dff4ed86a0f3fd981257aeea59190b398bf6 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:23:48 +0800 Subject: [PATCH 066/115] test(apply-diff): complete the denial fixture and assert no tool error --- src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 085add454c..d5ee1b2eb1 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -112,6 +112,9 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { scrollToFirstDiff: vi.fn(), pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), reset: vi.fn().mockResolvedValue(undefined), + // Used by the approval-denial branch: the denial must complete through its own + // path (no catch, no handleError) while the observation is cleaned up. + revertChanges: vi.fn().mockResolvedValue(undefined), } mockTask = { cwd: "/workspace/project", @@ -332,7 +335,10 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { toolCallId: "test-tool-call-id", }) + // The denial cleanup completed through its own path, not the catch: the observation is + // removed and no tool error was reported. expect(mockTask.observationRegistry.get(key)).toBeUndefined() + expect(mockHandleError).not.toHaveBeenCalled() }) it("keeps a complete model observation complete when the tool read matches its version", async () => { From cbb6a167d02a26aa072e106612bbff0a8de13139 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:02:56 +0800 Subject: [PATCH 067/115] fix(merge): restore the upstream hunks dropped by the main merge Keep branch clipping logic, restore upstream's empty-file condition, offset assertion, typed safeWriteJson signature and typed test doubles. --- src/core/tools/ReadFileTool.ts | 2 +- src/eslint-suppressions.json | 65 ------------------- .../misc/__tests__/indentation-reader.spec.ts | 2 +- src/utils/__tests__/safeWriteJson.test.ts | 2 +- src/utils/safeWriteJson.ts | 3 +- 5 files changed, 5 insertions(+), 69 deletions(-) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 6b62300439..c30f71a715 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -364,7 +364,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { // full-file replacement cannot be built from a clipped line. output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. The file was read in full, but the clipped lines were not shown in full. ${result.content}` - } else if (result.returnedLines === 0) { + } else if (result.returnedLines === 0 && result.totalLines === 0) { output = "Note: File is empty" } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index ea44474652..277d14b8c0 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1619,71 +1619,6 @@ "count": 2 } }, - "utils/__tests__/autoImportSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 15 - } - }, - "utils/__tests__/enhance-prompt.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "utils/__tests__/git.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 95 - } - }, - "utils/__tests__/json-schema.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "utils/__tests__/migrateSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "utils/__tests__/outputChannelLogger.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "utils/__tests__/safeWriteJson.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "utils/__tests__/shell.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "utils/__tests__/storage.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "utils/__tests__/tiktoken.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/config.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/export.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/safeWriteJson.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, "vitest.setup.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts index fa951ea85e..2a2efcb4b4 100644 --- a/src/integrations/misc/__tests__/indentation-reader.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader.spec.ts @@ -315,7 +315,7 @@ describe("readWithSlice", () => { const result = readWithSlice(SIMPLE_CODE, 1000, 10) expect(result.returnedLines).toBe(0) - expect(result.content).toContain("Error") + expect(result.content).toContain("Error: offset 1001 is beyond file end") // No line was returned, so nothing could have been clipped. expect(result.hasClippedLines).toBe(false) }) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 9723cf88a7..e2a53af863 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -315,7 +315,7 @@ describe("safeWriteJson", () => { await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) // fs.unlink is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn - vi.mocked(fs.unlink).mockImplementation(async (filePath: any) => { + vi.mocked(fs.unlink).mockImplementation(async (filePath) => { if (filePath.toString().includes("safeWriteText.bak_")) { throw new Error("Backup deletion failed") } diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 898821b7e9..502df933c9 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -129,7 +129,8 @@ function _scopeErrorCode(error: unknown): string | undefined { * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ -async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { + +async function safeWriteJson(filePath: string, data: unknown, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op From 6e8d494604fd4c4cdcd1f9eb9689145f55772b54 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 4 Nov 2026 02:33:16 +0800 Subject: [PATCH 068/115] fix(fws-u7): scope the clipped-read notice and re-check the lock under the lock ReadFileTool reports the line range a clipped slice actually covered instead of claiming "the file was read in full" for a slice that starts past line 1 and stops at the last line. safeWriteJson compares the lock key it holds against the publish target resolved under the lock, and refuses to commit through a lock that no longer names the file it is publishing. safeWriteText creates the parent directory only after a caller-supplied staging path has been accepted, and bounds both icacls helper calls with a finite 20s timeout - below LOCK_STALE_MS - so a hung helper degrades to the designed "skip DACL handling" path instead of holding the write and the per-file advisory lock open forever. --- src/core/tools/ReadFileTool.ts | 19 ++++- src/core/tools/__tests__/readFileTool.spec.ts | 68 ++++++++++++++++ .../__tests__/safeWriteText.spec.ts | 77 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 38 +++++++-- .../__tests__/safeWriteJson.lockKey.spec.ts | 72 ++++++++++++++++- src/utils/safeWriteJson.ts | 26 +++++++ 6 files changed, 284 insertions(+), 16 deletions(-) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index c30f71a715..d3faf05ab9 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -359,10 +359,21 @@ export class ReadFileTool extends BaseTool<"read_file"> { output += `\nNote: Some lines in this view exceed ${MAX_LINE_LENGTH} characters and were clipped in this view.` } } else if (result.hasClippedLines) { - // Every line was returned, so there is no later offset to read: report the - // clipping without a next-offset hint, and keep the read incomplete so a - // full-file replacement cannot be built from a clipped line. - output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. The file was read in full, but the clipped lines were not shown in full. + // Nothing was omitted, so there is no later offset to read: report the clipping + // without a next-offset hint, and keep the read incomplete so a full-file + // replacement cannot be built from a clipped line. "In full" is only true when the + // slice covered the whole file - a slice that started past line 1 and stopped at the + // last line is not truncated, but it is not the file either, and saying "read in + // full" would hide the lines before the offset from the reader. + const covered = result.includedRanges.map(([start, end]) => `${start}-${end}`).join(", ") + const coversWholeFile = + result.includedRanges.length > 0 && + result.includedRanges[0][0] === 1 && + result.includedRanges[result.includedRanges.length - 1][1] === result.totalLines + const scope = coversWholeFile + ? "The file was read in full, but the clipped lines were not shown in full." + : `Lines ${covered} of ${result.totalLines} total lines were shown, and the clipped lines were not shown in full.` + output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. ${scope} ${result.content}` } else if (result.returnedLines === 0 && result.totalLines === 0) { output = "Note: File is empty" diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 99bebbc6d8..43cc88f337 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -2133,6 +2133,74 @@ describe("ReadFileTool", () => { expect(pushed).toContain("1 | a") }) + it("native: a clipped slice that started past line 1 reports the range it covered", async () => { + // Not truncated does not mean complete: a slice that begins at offset 4 and + // stops at the last line returned the tail only. Claiming "read in full" here + // is what hides the first three lines from whatever reads the notice. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + mockedReadWithSlice.mockReturnValue({ + content: "4 | d\n5 | e", + returnedLines: 2, + totalLines: 5, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[4, 5]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "tail.ts", offset: 4, limit: 2 }, + mockTask as unknown as Task, + callbacks, + ) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("clipped in this view") + expect(pushed).toContain("Lines 4-5 of 5 total lines") + expect(pushed).not.toContain("The file was read in full") + // Still no next-offset hint: nothing was omitted after the slice. + expect(pushed).not.toContain("To read more") + expect(pushed).toContain("4 | d") + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + + it("native: a clipped read that covered the whole file still says it read in full", async () => { + // The other half of the contract: the range sentence must not replace the + // accurate statement when the slice really did cover the file. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b\n3 | c", + returnedLines: 3, + totalLines: 3, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 3]], + }) + + await readFileTool.execute({ path: "whole.ts" }, mockTask as unknown as Task, callbacks) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("The file was read in full, but the clipped lines were not shown in full.") + expect(pushed).not.toContain("total lines were shown") + }) + it("native: a truncated slice that also clipped a line reports both notices", async () => { // A long line inside a slice that also cut lines off is a plausible case, // and the response has to say both things. diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 4982c6e56e..e6664e82fd 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -689,6 +689,54 @@ describe("safeWriteText", () => { expect(warnings.filter((m) => m.includes("different access rights"))).toHaveLength(1) }) + it.skipIf(process.platform !== "win32")( + "bounds every icacls call with a finite timeout under the lock staleness window", + 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: "win32" }) + + const calls = vi.mocked(execFile).mock.calls + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(call[0]).toBe("icacls") + // An execFile without a timeout waits for its child forever. While it waits, + // this write is open and - for a safeWriteJson caller - the per-file advisory + // lock is held while every other writer of the same file burns its retries. + const timeout = Number((call[2] as { timeout?: number } | undefined)?.timeout) + expect(Number.isFinite(timeout)).toBe(true) + expect(timeout).toBeGreaterThan(0) + // Below LOCK_STALE_MS (src/utils/fileLock.ts) so a hung helper cannot outlive + // the staleness window the lock protocol is written around. + expect(timeout).toBeLessThan(31_000) + } + }, + ) + + it("win32: a timed-out icacls degrades to the designed skip-DACL path and still commits", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // What execFile hands back when its timeout fires: an error whose code is ETIMEDOUT. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") { + cb(Object.assign(new Error("spawnSync icacls ETIMEDOUT"), { code: "ETIMEDOUT" }), "", "") + } + return fakeChild + }) + const warnings: string[] = [] + + await expect( + safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }), + ).resolves.toBeUndefined() + + expect(execFile).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(warnings.filter((m) => m.includes("different access rights"))).toHaveLength(1) + }) + it("win32: reports when the target cannot be checked for DACL preservation", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -1274,6 +1322,35 @@ describe("caller-supplied staging path", () => { expect(fs.rename).not.toHaveBeenCalled() }) + it("creates no directory when a supplied staging path is rejected", async () => { + const targetPath = "/tmp/test-dir/nested-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + + // A rejected write has to leave the filesystem as it found it. mkdir -p ran ahead of + // these checks, so a caller could create directories in the target's tree - and learn + // about them only from a StagingPathError that never mentioned them. + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/other-dir/x.tmp", platform: "linux" }), + ).rejects.toThrow(StagingPathError) + + expect(fs.mkdir).not.toHaveBeenCalled() + expect(fs.access).not.toHaveBeenCalled() + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("still creates the parent directory for a staging path it accepts", async () => { + const targetPath = "/tmp/test-dir/nested-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/nested-dir/x.tmp", platform: "linux" }) + + expect(fs.mkdir).toHaveBeenCalledWith(path.dirname(targetPath), { recursive: true }) + expect(fs.rename).toHaveBeenCalledWith("/tmp/test-dir/nested-dir/x.tmp", targetPath) + }) + it("rejects a staging path that is a symlink", 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 c0730baf21..cb24e0e317 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -117,6 +117,18 @@ function _fsyncFile(fd: number): void { fsSync.fsyncSync(fd) } +/** + * Upper bound for the Windows DACL child processes, in milliseconds. execFile without a timeout + * waits for the child indefinitely: a icacls blocked on a reparse point, a network share, or an + * interception filter would hold the caller's write open forever, and for a safeWriteJson caller it + * would hold the per-file advisory lock while every other writer of the same file exhausts its + * retries (src/utils/fileLock.ts: 5 retries, 100ms..1s backoff) and fails. A timeout lands on the + * path this module already has for a failing icacls - skip DACL handling, commit the write, report + * the warning - and it stays below LOCK_STALE_MS (31_000) so a hung helper cannot outlive the + * staleness window the lock protocol is written around. + */ +const DACL_COMMAND_TIMEOUT_MS = 20_000 + /** 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". */ @@ -124,8 +136,11 @@ async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunne const runner = execFileRunner ?? execFile try { await new Promise((resolve, reject) => { - runner("icacls", [srcPath, "/save", dumpPath, "/T"], { windowsHide: true }, (err) => - err ? reject(err) : resolve(), + runner( + "icacls", + [srcPath, "/save", dumpPath, "/T"], + { timeout: DACL_COMMAND_TIMEOUT_MS, windowsHide: true }, + (err) => (err ? reject(err) : resolve()), ) }) return true @@ -144,8 +159,11 @@ async function _restoreDaclWindows( const runner = execFileRunner ?? execFile try { await new Promise((resolve, reject) => { - runner("icacls", [dirPath, "/restore", dumpPath], { windowsHide: true }, (err) => - err ? reject(err) : resolve(), + runner( + "icacls", + [dirPath, "/restore", dumpPath], + { timeout: DACL_COMMAND_TIMEOUT_MS, windowsHide: true }, + (err) => (err ? reject(err) : resolve()), ) }) return true @@ -270,10 +288,6 @@ export async function safeWriteText( 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. Track the directory this @@ -336,6 +350,14 @@ export async function safeWriteText( tempPath = _tempName(stagingDir, "safeWriteText") } + // The parent directory is created only after the staging path this write will actually use has + // been accepted (mirrors safeWriteJson behaviour). mkdir -p ahead of the caller-supplied checks + // let a *rejected* write create a directory in the target's own tree before anything was + // validated. A self-staged write has already created it through _stagingDir's + // mkdirSync(recursive:true). + await fs.mkdir(dirPath, { recursive: true }) + await fs.access(dirPath) + let backupPath: string | null = null let releaseBackupOnSuccess = false // Whether the commit rename ran. A failure after it is a durability problem diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index c9f2415402..3492959854 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -97,10 +97,10 @@ describe("safeWriteJson lock key under a peer commit", () => { // The lock key is the key every other writer to this file uses, so the caller // queued behind the peer instead of failing before the lock. expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) - // The two trailing lstat calls are safeWriteText's staging-path checks: the - // regular-file check on the temp file this write created, and the identity check - // that the staging path is not the target. Both run after the key was resolved - // and the lock was taken, so neither changes which lock the caller queued behind. + // After the lock: the strict publish resolution, then the key/publish-target + // agreement check (resolve of the publish target plus canonicalization of its + // directory), then safeWriteText's two staging-path lstat checks. None of them + // changes which lock the caller queued behind. expect(order).toEqual([ "resolve-failed", "lstat", @@ -109,6 +109,8 @@ describe("safeWriteJson lock key under a peer commit", () => { "lock", "resolve", "resolve", + "resolve", + "resolve", "lstat", "lstat", ]) @@ -151,6 +153,68 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(order).toEqual(["lstat", "lock", "lstat", "release"]) }) + it("refuses to publish through a lock that no longer names the target", async () => { + const order: string[] = [] + let released = false + const dir = await makeDir("lockkey-") + const keyedFile = path.join(dir, "history_item.json") + const movedFile = path.join(dir, "elsewhere.json") + currentLink = path.join(dir, "link.json") + + // The key is resolved from the alias as it looks before the lock; the publish target is + // resolved again under it. Repoint the alias in between - a peer committing, or a planted + // link swap - and this writer would commit onto a file whose lock it never took, while + // another writer can hold that file's lock and commit onto it too. + let referent = keyedFile + mockedRealpath.mockImplementation(async (target) => (target === currentLink ? referent : String(target))) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedReadlink.mockImplementation(async (target) => + target === currentLink ? referent : Promise.reject(new Error("not a link")), + ) + mockedAcquireFileLock.mockImplementation(async (lockedPath) => { + order.push("lock:" + path.basename(lockedPath)) + referent = movedFile + return async () => { + order.push("release") + released = true + } + }) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteJson(currentLink, { id: "task-1" })).rejects.toThrow("the advisory lock was taken for") + + expect(order).toEqual(["lock:history_item.json", "release"]) + expect(released).toBe(true) + // Nothing was staged or published: neither the keyed file nor the file the alias moved to. + for (const candidate of [keyedFile, movedFile]) { + const present = await fs.access(candidate).then( + () => true, + () => false, + ) + expect(present).toBe(false) + } + consoleError.mockRestore() + }) + + it("locks and publishes the same file when the alias does not move", async () => { + // The guard has to stay quiet on the normal path: key and publish target resolve through + // the same alias, and the write goes ahead under one lock. + const dir = await makeDir("lockkey-") + const target = path.join(dir, "history_item.json") + currentLink = "" + mockedRealpath.mockImplementation(async (t) => String(t)) + mockedLstat.mockImplementation( + async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, + ) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await safeWriteJson(target, { id: "task-1" }) + + expect(mockedAcquireFileLock).toHaveBeenCalledWith(target) + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ id: "task-1" }) + consoleError.mockRestore() + }) + it("canonicalizes the parent directory when the file itself is not there yet", async () => { // fs.realpath canonicalizes every component, including a symlinked ancestor // directory or a Windows 8.3 short name. If the fallback returns the alias diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 502df933c9..4f6455afb6 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -59,6 +59,21 @@ export class ConfinedPathEscapeError extends Error { } } +/** + * Thrown when the advisory lock this write holds no longer names the path it is about to + * publish. The lock key is resolved before the lock is taken and the publish target is + * resolved under it, so an alias repointed in between would otherwise let this writer commit + * onto a file whose lock belongs to another writer. Nothing is staged when this rejects. + */ +class LockKeyDriftError extends Error { + constructor(requestedPath: string, lockKey: string, publishTarget: string) { + super( + `Refusing to publish ${publishTarget}: the advisory lock was taken for ${lockKey}, which is no longer the file this write resolves to (requested ${requestedPath}). Retry the write.`, + ) + this.name = "LockKeyDriftError" + } +} + /** * Canonicalize the directory a write is confined to. The publish target is fully * resolved through symlinks, so the scope has to be resolved the same way or a @@ -203,6 +218,17 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit // advisory lock held until the stale timeout for every other writer. resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // The lock above was chosen from the path as it looked before the lock was taken, and the + // publish target is resolved under the lock - after a peer committed, and after the alias + // itself may have been repointed. If the two no longer name the same file, this writer is + // holding the lock of a path it is not about to publish while some other writer can hold + // the lock of the one it is about to publish, which is two commits onto one file each + // believing it owned it. Nothing has been staged or merged yet, so rejecting here leaves + // every file untouched and the caller can retry against the key it resolves to now. + if ((await resolveLockKey(resolvedTargetPath)) !== lockKey) { + throw new LockKeyDriftError(absoluteFilePath, lockKey, resolvedTargetPath) + } + // Confinement, if the caller declared a scope. Both sides are canonicalized the // same way: the publish target is resolved through symlinks, and a target that // does not exist yet still carries the alias components of the path it was From 1046c837bff09716a99121f3063dc8fbab0c31dd Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 4 Nov 2026 02:49:26 +0800 Subject: [PATCH 069/115] fix(fws-u7): re-verify the staging directory before the staged write safeWriteJson picks the staging location from the publish target it resolved under the lock and writes the staged JSON itself, before safeWriteText runs its own staging checks. A resolved path is a pathname, not a bound directory identity: if the target's parent directory was replaced with a link pointing elsewhere inside that window, the staged file would be created outside the directory this write was approved for, and the rejection that follows could not undo it. Re-resolve the staging directory and compare it against the approved one before anything is written into it, rejecting with StagingDirectoryDriftError so the write leaves nothing on disk. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 41 ++++++++++++-- src/utils/safeWriteJson.ts | 55 +++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 3492959854..903e870f83 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -97,10 +97,10 @@ describe("safeWriteJson lock key under a peer commit", () => { // The lock key is the key every other writer to this file uses, so the caller // queued behind the peer instead of failing before the lock. expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) - // After the lock: the strict publish resolution, then the key/publish-target - // agreement check (resolve of the publish target plus canonicalization of its - // directory), then safeWriteText's two staging-path lstat checks. None of them - // changes which lock the caller queued behind. + // After the lock: the strict publish resolution, the key/publish-target agreement check + // (resolve of the publish target plus canonicalization of its directory), the re-resolution of + // the staging directory before anything is written into it, then safeWriteText's two + // staging-path lstat checks. None of them changes which lock the caller queued behind. expect(order).toEqual([ "resolve-failed", "lstat", @@ -111,6 +111,7 @@ describe("safeWriteJson lock key under a peer commit", () => { "resolve", "resolve", "resolve", + "resolve", "lstat", "lstat", ]) @@ -196,6 +197,38 @@ describe("safeWriteJson lock key under a peer commit", () => { consoleError.mockRestore() }) + it("refuses to stage when the target's parent no longer names that directory", async () => { + let released = false + const dir = await makeDir("lockkey-") + const target = path.join(dir, "history_item.json") + const outside = path.join(dir, "elsewhere") + currentLink = "" + + // The staging location is chosen from the parent directory resolved under the lock, and the + // staged file is written here in safeWriteJson before safeWriteText runs. Replace that + // directory with a link pointing outside it in between, and the staged JSON lands outside the + // directory this write was approved for - safeWriteText can still reject the publish, but the + // stray file already exists. Canonicalizing the parent is the only thing that can see the swap. + mockedRealpath.mockImplementation(async (t) => (String(t) === dir ? outside : String(t))) + mockedLstat.mockImplementation( + async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, + ) + mockedAcquireFileLock.mockImplementation(async () => async () => { + released = true + }) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteJson(target, { id: "task-1" })).rejects.toThrow( + "the directory this write was approved for", + ) + + expect(released).toBe(true) + // Nothing was staged: no target, and no .new_*.tmp left behind in the directory. + await expect(fs.readFile(target, "utf8")).rejects.toThrow() + expect((await fs.readdir(dir)).filter((name) => name.startsWith(".new_"))).toEqual([]) + consoleError.mockRestore() + }) + it("locks and publishes the same file when the alias does not move", async () => { // The guard has to stay quiet on the normal path: key and publish target resolve through // the same alias, and the write goes ahead under one lock. diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 4f6455afb6..3d83888b91 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -74,6 +74,33 @@ class LockKeyDriftError extends Error { } } +/** + * Thrown when the directory this write was approved to stage into no longer names that + * directory. The publish target and the staging location are resolved under the lock, but + * a resolved path is a pathname, not a bound identity: if the target's parent is replaced + * with a link pointing elsewhere before the staged file is written, the staged JSON is + * created outside the directory this write was approved for, and the staging checks inside + * safeWriteText - which run after that write - could reject the publish without undoing + * the file. Rejecting here leaves nothing on disk. + */ +class StagingDirectoryDriftError extends Error { + constructor(requestedPath: string, approvedDirectory: string, actualDirectory: string) { + super( + `Refusing to stage ${requestedPath}: the directory this write was approved for (${approvedDirectory}) now resolves to ${actualDirectory}. Retry the write.`, + ) + this.name = "StagingDirectoryDriftError" + } +} + +/** + * Whether two paths name the same directory. `realpath` reports the on-disk spelling, + * which can differ in case on Windows, so the identity being compared here is the + * directory, not the spelling of it. + */ +function _sameDirectory(approved: string, actual: string): boolean { + return process.platform === "win32" ? approved.toLowerCase() === actual.toLowerCase() : approved === actual +} + /** * Canonicalize the directory a write is confined to. The publish target is fully * resolved through symlinks, so the scope has to be resolved the same way or a @@ -267,6 +294,34 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) + // The staging location was chosen from the parent directory resolved under the lock. Re-verify + // that directory against itself before writing anything into it: a resolved pathname is not an + // identity, and a parent that was replaced with a link pointing elsewhere in the meantime would + // put the staged file outside the directory this write was approved for. safeWriteText would + // then reject the publish, but the stray file would already exist. + const approvedStagingDirectory = path.dirname(actualTempNewFilePath) + let currentStagingDirectory: string + try { + currentStagingDirectory = await fs.realpath(approvedStagingDirectory) + } catch (driftError: unknown) { + const code = + driftError && typeof driftError === "object" && "code" in driftError + ? (driftError as { code?: string }).code + : undefined + throw new StagingDirectoryDriftError( + resolvedTargetPath ?? absoluteFilePath, + approvedStagingDirectory, + code === "ENOENT" ? "no existing directory" : String(driftError), + ) + } + if (!_sameDirectory(approvedStagingDirectory, currentStagingDirectory)) { + throw new StagingDirectoryDriftError( + resolvedTargetPath ?? absoluteFilePath, + approvedStagingDirectory, + currentStagingDirectory, + ) + } + await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) // Step 2: Delegate backup + commit to safeWriteText with the pre-written From 504af846a1a533ad94e09169edfc4965cb4c46e5 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:56:58 +0800 Subject: [PATCH 070/115] fix(utils): canonicalize both sides of the staging-directory drift check The staging-directory guard compared a lexical approval against fs.realpath of the same directory, so the two sides were canonicalized differently. For a target that does not exist yet resolvePublishTarget returns the path it was given, and on a Windows runner the temp root is spelled with its 8.3 short name while realpath reports the long name: the same directory compared unequal and every write into a directory that did not exist yet was rejected with StagingDirectoryDriftError. That is what broke test:misc on windows-latest (four parent-directory-creation cases). The approval is now canonicalized with the same directory walk the confinement check above already uses, the approved directory is statted, and the re-verification re-walks it, so both sides of the comparison are canonical outputs of the same walk and a short/long spelling pair compares equal by construction. A parent that really was replaced by a link still lands on a different canonical directory and still rejects; the stat keeps rejecting a deleted approval. Controls added in src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts: a write through an alias directory (junction on Windows, directory symlink elsewhere) with a not-yet-existing target - red before this change with 'Refusing to stage ... alias-dir ... now resolves to ... referent-dir', green after; the approved directory replaced by an outside-pointing link between the two walks still rejects; a deleted approved directory still rejects with 'no existing directory'. The windows lane's own four parent-creation tests are the native 8.3 reproduction; this box has no 8.3 names, so the alias case reproduces the same lexical-versus-canonical divergence portably. src/utils/__tests__/safeWriteJson.lockKey.spec.ts gains the one extra canonicalization the fix adds to the recorded operation order; the lock the caller queues behind is unchanged. Verification: vitest run of safeWriteJson.test.ts, safeWriteJson.lockKey.spec.ts and the new spec - 3 files, 36 passed, 4 skipped (win32-only cases), exit 0; eslint --max-warnings=0 --no-warn-ignored on the three files exit 0 with --prune-suppressions reporting zero count changes; prettier --check exit 0 on all three. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 4 +- .../safeWriteJson.stagingDirectory.spec.ts | 116 ++++++++++++++++++ src/utils/safeWriteJson.ts | 29 ++++- 3 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 903e870f83..ddfb2b9777 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -99,7 +99,8 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) // After the lock: the strict publish resolution, the key/publish-target agreement check // (resolve of the publish target plus canonicalization of its directory), the re-resolution of - // the staging directory before anything is written into it, then safeWriteText's two + // the staging directory twice - the approval and the re-verification before anything is staged - + // then safeWriteText's two // staging-path lstat checks. None of them changes which lock the caller queued behind. expect(order).toEqual([ "resolve-failed", @@ -112,6 +113,7 @@ describe("safeWriteJson lock key under a peer commit", () => { "resolve", "resolve", "resolve", + "resolve", "lstat", "lstat", ]) diff --git a/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts new file mode 100644 index 0000000000..83a7746b85 --- /dev/null +++ b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts @@ -0,0 +1,116 @@ +import * as os from "os" +import * as path from "path" + +import { safeWriteJson } from "../safeWriteJson" + +// Capture the actual implementations before the mock factory wraps them, so a mocked realpath or +// stat can delegate to the real one without recursing into itself. +const actuals = vi.hoisted(() => ({ + realpath: undefined as (typeof import("fs/promises"))["realpath"], + stat: undefined as (typeof import("fs/promises"))["stat"], +})) + +vi.mock("fs/promises", async () => { + const actual = await vi.importActual("fs/promises") + actuals.realpath = actual.realpath + actuals.stat = actual.stat + return { ...actual, realpath: vi.fn(actual.realpath), stat: vi.fn(actual.stat) } +}) + +import * as fs from "fs/promises" + +/** + * Controls on the staging-directory drift guard in safeWriteJson: the re-verification that runs + * between approving a staging directory beside the publish target and streaming into it. The guard + * has to keep rejecting a parent that was genuinely replaced by a link, and it must not reject an + * ordinary write because the approved path is spelled differently from the way the same directory is + * reported. The alias below is the portable form of the Windows case that broke the windows + * test:misc lane: on a GitHub runner the temp root arrives spelled with its 8.3 short name + * (C:\Users\RUNNER~1\AppData\Local\Temp), resolvePublishTarget returns that lexical path for a + * target that does not exist yet, and realpath reports the long name for that very same directory. + */ +describe("safeWriteJson staging directory drift", () => { + let tmpRoot = "" + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "swj-staging-drift-")) + }) + + afterEach(async () => { + vi.restoreAllMocks() + if (tmpRoot) { + await fs.rm(tmpRoot, { recursive: true, force: true }) + tmpRoot = "" + } + }) + + it("writes through an alias directory when the target does not exist yet", async () => { + const referent = path.join(tmpRoot, "referent-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const target = path.join(alias, "settings.json") + + await safeWriteJson(target, { preserved: true }) + + const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") + expect(JSON.parse(written)).toEqual({ preserved: true }) + // Nothing may be left staged on either spelling of the same directory. + expect((await fs.readdir(referent)).filter((name) => name.startsWith(".new_"))).toEqual([]) + expect((await fs.readdir(alias)).filter((name) => name.startsWith(".new_"))).toEqual([]) + }) + + it("still rejects when the approved directory is replaced by a link before staging", async () => { + const parent = path.join(tmpRoot, "approved-parent") + await fs.mkdir(parent, { recursive: true }) + const elsewhere = path.join(tmpRoot, "elsewhere") + await fs.mkdir(elsewhere, { recursive: true }) + const target = path.join(parent, "settings.json") + const approvedReal = await actuals.realpath!(parent) + const elsewhereReal = await actuals.realpath!(elsewhere) + // The guard canonicalizes the approved directory, stats it, and re-walks it. Arm on the stat - + // the point between the two walks - and report the walk after it as landing elsewhere, which is + // what a parent replaced by an outside-pointing link produces. + let armed = false + vi.mocked(fs.stat).mockImplementation(async (...args) => { + if (path.resolve(String(args[0])) === approvedReal) { + armed = true + } + return await actuals.stat!(...args) + }) + vi.mocked(fs.realpath).mockImplementation(async (...args) => { + const lexical = path.resolve(String(args[0])) + if (armed && lexical === approvedReal) { + return elsewhereReal + } + return await actuals.realpath!(...args) + }) + + await expect(safeWriteJson(target, { preserved: true })).rejects.toThrow(/Refusing to stage/) + + // Rejected before staging: nothing reached the directory the guard landed on, and nothing was + // published under the approved spelling either. + expect(await fs.readdir(elsewhere)).toEqual([]) + expect((await fs.readdir(parent)).filter((name) => name === "settings.json")).toEqual([]) + }) + + it("still rejects when the approved directory no longer exists", async () => { + const parent = path.join(tmpRoot, "doomed-parent") + await fs.mkdir(parent, { recursive: true }) + const target = path.join(parent, "settings.json") + const approvedReal = await actuals.realpath!(parent) + + vi.mocked(fs.stat).mockImplementation(async (...args) => { + if (path.resolve(String(args[0])) === approvedReal) { + const error = new Error("ENOENT: no such file or directory") as NodeJS.ErrnoException + error.code = "ENOENT" + throw error + } + return await actuals.stat!(...args) + }) + + await expect(safeWriteJson(target, { preserved: true })).rejects.toThrow(/no existing directory/) + + expect((await fs.readdir(parent)).filter((name) => name.startsWith(".new_"))).toEqual([]) + }) +}) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 3d83888b91..ca33bf4c09 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -102,7 +102,7 @@ function _sameDirectory(approved: string, actual: string): boolean { } /** - * Canonicalize the directory a write is confined to. The publish target is fully + * Canonicalize a directory a write is confined to or approved to stage into. The publish * resolved through symlinks, so the scope has to be resolved the same way or a * scope path that itself runs through a symlink (macOS /var -> /private/var is the * common case) would compare lexically against a resolved target and reject every @@ -299,10 +299,30 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit // identity, and a parent that was replaced with a link pointing elsewhere in the meantime would // put the staged file outside the directory this write was approved for. safeWriteText would // then reject the publish, but the stray file would already exist. + // The staging location was chosen from the parent directory resolved under the lock. Re-verify + // that directory against itself before writing anything into it: a resolved pathname is not an + // identity, and a parent that was replaced with a link pointing elsewhere in the meantime would + // put the staged file outside the directory this write was approved for. safeWriteText would + // then reject the publish, but the stray file would already exist. Both sides of this comparison + // are canonicalized with the same directory walk the confinement check above uses, because for a + // target that does not exist yet resolvePublishTarget returns the path it was given: comparing + // that lexical spelling against fs.realpath of the same directory rejects an ordinary write + // whenever the two spellings differ. On a Windows runner the temp root arrives spelled with its + // 8.3 short name (C:\Users\RUNNER~1\AppData\Local\Temp) and realpath reports the long name for + // that very same directory, so every write into a directory that did not exist yet rejected + // here. The two walks run against the same frozen approval one after the other, so a parent that + // really was replaced by a link still lands on a different canonical directory and still + // rejects; the stat between them keeps rejecting a deleted approval, which the walk alone cannot + // tell apart from a replaced one. Where a filesystem reports no usable file identity at all the + // comparison degrades to today's spelling comparison, which is why the identity being compared + // here is the canonical directory rather than the text of a path. const approvedStagingDirectory = path.dirname(actualTempNewFilePath) + let approvedCanonical: string let currentStagingDirectory: string try { - currentStagingDirectory = await fs.realpath(approvedStagingDirectory) + approvedCanonical = await _resolveScopeRoot(approvedStagingDirectory) + await fs.stat(approvedCanonical) + currentStagingDirectory = await _resolveScopeRoot(approvedCanonical) } catch (driftError: unknown) { const code = driftError && typeof driftError === "object" && "code" in driftError @@ -314,14 +334,13 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit code === "ENOENT" ? "no existing directory" : String(driftError), ) } - if (!_sameDirectory(approvedStagingDirectory, currentStagingDirectory)) { + if (!_sameDirectory(approvedCanonical, currentStagingDirectory)) { throw new StagingDirectoryDriftError( resolvedTargetPath ?? absoluteFilePath, - approvedStagingDirectory, + approvedCanonical, currentStagingDirectory, ) } - await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) // Step 2: Delegate backup + commit to safeWriteText with the pre-written From 40ac71f29aac534100dae6cc4762f44dc16929c7 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:25:43 +0800 Subject: [PATCH 071/115] test(utils): type the hoisted fs actuals by the callback return type The drift control's hoisted holder cast undefined straight to two overloaded function types, which tsc rejects (TS2352 at safeWriteJson.stagingDirectory.spec.ts:9 and :10). The holder is now typed by the hoisted callback's return type instead, so there is no cast on undefined and no double assertion. Measured with the worktree's own node_modules (split-1833/u7 got a real pnpm install this round, and @roo-code/types now resolves to that worktree's packages/types): tsc -p tsconfig.json exit 0 with 0 errors; vitest run of safeWriteJson.test.ts, safeWriteJson.lockKey.spec.ts and safeWriteJson.stagingDirectory.spec.ts exit 0, 3 files, 36 passed, 4 skipped. --- .../__tests__/safeWriteJson.stagingDirectory.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts index 83a7746b85..93eab4ad7f 100644 --- a/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts +++ b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts @@ -5,10 +5,14 @@ import { safeWriteJson } from "../safeWriteJson" // Capture the actual implementations before the mock factory wraps them, so a mocked realpath or // stat can delegate to the real one without recursing into itself. -const actuals = vi.hoisted(() => ({ - realpath: undefined as (typeof import("fs/promises"))["realpath"], - stat: undefined as (typeof import("fs/promises"))["stat"], -})) +// Typed by the hoisted callback's return type rather than by a cast on undefined: the target types +// are overloaded function types, and casting undefined straight to them is what tsc rejects (TS2352). +const actuals = vi.hoisted( + (): { realpath?: (typeof import("fs/promises"))["realpath"]; stat?: (typeof import("fs/promises"))["stat"] } => ({ + realpath: undefined, + stat: undefined, + }), +) vi.mock("fs/promises", async () => { const actual = await vi.importActual("fs/promises") From 64dc74cab4a74f3a1686c806c13631c19b56a331 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:26:01 +0800 Subject: [PATCH 072/115] fix(utils): treat an unanswered drift probe as no evidence of drift platform-unit-test (ubuntu-latest) was red at 40ac71f29 on exactly one case: core/task/__tests__/Task.spec.ts > 'finalizePartialToolAsk still updates the webview when a later save stage fails' (expected "updateClineMessage" to be called with arguments, Number of calls: 0). Measured by printing every realpath call in that window with its outcome and, at the same instant, running fs.statSync on the same path through the never-mocked fs: not one realpath returned ENOENT for the directory - the guard's first walk and both lock-key canonicalizations resolved it, four times, to inode 22517998136858990 - and the only ENOENTs were on ui_messages.json itself, the target file that genuinely is not there. The failure is the mirror image of a missing directory: the guard's own 'await fs.stat(approvedCanonical)' reported ENOENT for a directory the unmocked fs reported as that same inode, because Task.spec.ts:144 mocks fs/promises with 'stat: vi.fn().mockRejectedValue({ code: "ENOENT" })' for every path. The second walk never ran, so the drift comparison never happened. The guard was failing on a question it invented, and the only reason our own lane never caught it is that the suite which drives this write path mocks stat to fail for every path. The guard now acts on a single signal: the re-resolution of the approved directory succeeds and lands on a different directory. A probe that cannot answer is not an answer - ENOENT because a component is mid-replacement or because the caller's fs reports the path as missing, EACCES, EPERM, ELOOP - and a failing probe is a normal condition of this write path rather than an attack, so _probeApprovedStagingDirectory reports 'no answer' instead of throwing and the write proceeds. The existence probe is gone. A directory that really was deleted then fails at the staging write itself: still fail-closed, still with nothing created outside the directory the write was approved for, and the error names the failed write instead of claiming to be evidence of replacement. The confinement checks above deliberately keep the opposite rule: there the guard has to prove the write is inside the scope, and a probe that cannot answer cannot prove anything. The guard's comment paragraph was duplicated verbatim twice in the previous head (lines 297-301 and 302-306, a chain artifact); it is collapsed here. Controls. Reverting this change reddens exactly the two new cannot-answer cases: 'stages anyway when the guard's probe cannot answer' (EACCES on the re-resolution - the failure the canonicalization walk itself rethrows, so the guard really is out of answers) and 'stages anyway under the task persistence suite's blanket missing stat', which puts Task.spec.ts:144's blanket double over a write whose directory exists and asserts expect(statCalls).toBe(0) (under the old guard that write died after exactly one stat call). Reverted: 2 failed | 12 passed on the two drift specs, and Task.spec.ts 1 failed | 198 passed - the CI case and only it. Restored: 5 spec files, 306 passed | 4 skipped, and the restore is byte-identical by SHA256 on all three touched files. Two cases are labelled in-file as absorbed rather than controls: the re-walk ENOENT case is absorbed by the walk's retry from its nearest existing ancestor, and the real-deletion case is keyed to the second reading, so the old guard had already cleared its probe before the directory went. The re-point case is now decided by the OS rather than by a stub: the approved directory is renamed aside and a junction onto a different directory is planted at the guard's second reading, realpath left unstubbed and the wrapper choosing only when the plant happens - the junction being the portable twin of the POSIX symlink case. safeWriteJson.lockKey.spec.ts's 'refuses to stage when the target's parent no longer names that directory' used to pass off the deleted existence probe (its stub named a path that never existed, so the guard rejected for 'no existing directory'); it now flips on the fourth reading with expect(readings).toBe(4), which is evidence about the re-verification and fails loudly if the guard ever stops taking it. Known property, deliberately not fixed here: when the approved directory is replaced before the guard's first reading, both readings agree and no layer rejects the write - measured byte-for-byte the same outcome before and after this change (payload published at wherever the approved pathname now points, no staged file left in either spelling, only the lock directory travelling with the renamed directory). The file can be published into a directory the writer's lock does not name. Closing that means safeWriteText binding the publish destination to the directory its own advisory lock names, which is a design change in the path #1916's guardedWrite layer builds on, so it is a follow-up unit rather than something to smuggle into a green-lane fix. Gates, all in split-1833/u7 against its own install: tsc -p tsconfig.json --noEmit exit 0; eslint --prune-suppressions --max-warnings=0 on the three files exit 0, with src/eslint-suppressions.json restored to HEAD bytes and the parsed (file,rule)->count delta computed in Node straight from 'git cat-file blob' - headSum 3537, nowSum 3537, 325 keys on both sides, 0 deltas, and the three touched files carrying no suppression entries at all; prettier --write then --check exit 0; root 'pnpm run check-types' exit 0 (10 packages cache hits, zoo-code:check-types ran 'tsc --noEmit' fresh). Cross-checked in a scratch merge of 40ac71f29 with upstream/main 6bbc7a091e258d931ea493829a9c0810621e26cb: the CI case is green there with this fix and red there without it at the same merge, and the single remaining failure in that tree - main's 'bills a 1h cache write at the 1h rate', expected +0 to be close to 0.6 - reproduces at pure merge content without these three files, so it is the scratch tree's donor-linked install resolving @roo-code/telemetry outside the merge, not this change. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 29 +- .../safeWriteJson.stagingDirectory.spec.ts | 293 ++++++++++++++---- src/utils/safeWriteJson.ts | 72 +++-- 3 files changed, 304 insertions(+), 90 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index ddfb2b9777..585915ba91 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -207,11 +207,29 @@ describe("safeWriteJson lock key under a peer commit", () => { currentLink = "" // The staging location is chosen from the parent directory resolved under the lock, and the - // staged file is written here in safeWriteJson before safeWriteText runs. Replace that - // directory with a link pointing outside it in between, and the staged JSON lands outside the - // directory this write was approved for - safeWriteText can still reject the publish, but the - // stray file already exists. Canonicalizing the parent is the only thing that can see the swap. - mockedRealpath.mockImplementation(async (t) => (String(t) === dir ? outside : String(t))) + // staged file is written here in safeWriteJson before safeWriteText runs. Replace that directory + // with a link pointing outside it between the guard's approval of it and its re-verification, + // and the staged JSON lands outside the directory this write was approved for - safeWriteText + // can still reject the publish, but the stray file already exists. Canonicalizing the parent is + // the only thing that can see the swap, and it sees it as the re-verification landing somewhere + // the approval did not. (A swap planted before the first reading is invisible to this guard by + // construction: both readings then agree, and safeWriteText's own beside-the-target check is + // what rejects it.) + let readings = 0 + mockedRealpath.mockImplementation(async (t) => { + const key = String(t) + if (key !== dir) { + return key + } + // Readings 1 and 2 are the lock-key canonicalization before and after the lock, reading 3 + // is the guard approving the directory as it currently resolves, and reading 4 is the guard + // re-verifying it - the one a planted swap has to move. This case used to pass off the guard's + // separate existence probe instead: its stub named a path that never existed, so the guard's + // fs.stat of the resolution failed and the write was rejected for "no existing directory", + // which was never evidence about a re-point. The readings below are what make it evidence. + readings += 1 + return readings <= 3 ? dir : outside + }) mockedLstat.mockImplementation( async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, ) @@ -223,6 +241,7 @@ describe("safeWriteJson lock key under a peer commit", () => { await expect(safeWriteJson(target, { id: "task-1" })).rejects.toThrow( "the directory this write was approved for", ) + expect(readings).toBe(4) // the guard really did take its second reading of the approved directory expect(released).toBe(true) // Nothing was staged: no target, and no .new_*.tmp left behind in the directory. diff --git a/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts index 93eab4ad7f..85258ac5c3 100644 --- a/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts +++ b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts @@ -3,22 +3,21 @@ import * as path from "path" import { safeWriteJson } from "../safeWriteJson" -// Capture the actual implementations before the mock factory wraps them, so a mocked realpath or -// stat can delegate to the real one without recursing into itself. -// Typed by the hoisted callback's return type rather than by a cast on undefined: the target types -// are overloaded function types, and casting undefined straight to them is what tsc rejects (TS2352). -const actuals = vi.hoisted( - (): { realpath?: (typeof import("fs/promises"))["realpath"]; stat?: (typeof import("fs/promises"))["stat"] } => ({ - realpath: undefined, - stat: undefined, - }), -) +// Capture the actual implementation before the mock factory wraps it, so a wrapped realpath can +// delegate to the real one - and run a real filesystem side effect at the moment the guard asks - +// without recursing into itself. +// Typed by the hoisted callback's return type rather than by a cast on undefined: the target type +// is an overloaded function type, and casting undefined straight to it is what tsc rejects (TS2352). +const actuals = vi.hoisted((): { realpath?: (typeof import("fs/promises"))["realpath"] } => ({ + realpath: undefined, +})) vi.mock("fs/promises", async () => { const actual = await vi.importActual("fs/promises") actuals.realpath = actual.realpath - actuals.stat = actual.stat - return { ...actual, realpath: vi.fn(actual.realpath), stat: vi.fn(actual.stat) } + // Only realpath is wrapped. The staging guard does not consult stat at all: a probe that cannot + // answer is not an answer, and nothing here has to stub stat for the guard to do its job. + return { ...actual, realpath: vi.fn(actual.realpath) } }) import * as fs from "fs/promises" @@ -26,12 +25,14 @@ import * as fs from "fs/promises" /** * Controls on the staging-directory drift guard in safeWriteJson: the re-verification that runs * between approving a staging directory beside the publish target and streaming into it. The guard - * has to keep rejecting a parent that was genuinely replaced by a link, and it must not reject an - * ordinary write because the approved path is spelled differently from the way the same directory is - * reported. The alias below is the portable form of the Windows case that broke the windows - * test:misc lane: on a GitHub runner the temp root arrives spelled with its 8.3 short name - * (C:\Users\RUNNER~1\AppData\Local\Temp), resolvePublishTarget returns that lexical path for a - * target that does not exist yet, and realpath reports the long name for that very same directory. + * may act on one thing only - a re-resolution that succeeds and lands on a different directory - + * and it must not reject an ordinary write because a probe could not answer, because the approved + * path is spelled differently from the way the same directory is reported, or because a directory + * that really is gone is reported as missing. The alias below is the portable form of the Windows + * case that broke the windows test:misc lane: on a GitHub runner the temp root arrives spelled with + * its 8.3 short name (C:\Users\RUNNER~1\AppData\Local\Temp), resolvePublishTarget returns that + * lexical path for a target that does not exist yet, and realpath reports the long name for that + * very same directory. */ describe("safeWriteJson staging directory drift", () => { let tmpRoot = "" @@ -41,13 +42,43 @@ describe("safeWriteJson staging directory drift", () => { }) afterEach(async () => { - vi.restoreAllMocks() + vi.mocked(fs.realpath).mockReset() + vi.mocked(fs.realpath).mockImplementation(async (...args) => await actuals.realpath!(...args)) if (tmpRoot) { await fs.rm(tmpRoot, { recursive: true, force: true }) tmpRoot = "" } }) + /** + * Run a real filesystem side effect at the guard's second reading of the approved directory. The + * guard canonicalizes the approved directory and then re-resolves that canonical path; every + * other realpath this write performs is passed the file itself or the alias spelling, because the + * lock key and the staging directory are both derived from the directory as the caller spelled + * it. So "the argument is already the canonical referent" identifies the second reading without + * counting calls. Returns whether the effect ever ran, which every case asserts so a guard that + * stopped asking is caught rather than silently passed. + + * What the guard may act on is a single signal: the re-resolution of the approved directory + * succeeds and lands on a different directory. A probe that cannot answer - ENOENT, EACCES, EPERM, + * ELOOP, or an fs double that reports the path as missing - carries no information about + * replacement, so it must not reject the write; a directory that really is gone then fails at the + * staging write itself, which is still fail-closed with nothing created outside the approved + * directory. + */ + function actAtRewalk(canonicalReferent: string, act: () => Promise): () => boolean { + let acted = false + vi.mocked(fs.realpath).mockImplementation(async (...args) => { + const lexical = path.resolve(String(args[0])) + if (!acted && lexical === canonicalReferent) { + acted = true + await act() + } + return await actuals.realpath!(...args) + }) + return () => acted + } + it("writes through an alias directory when the target does not exist yet", async () => { const referent = path.join(tmpRoot, "referent-dir") await fs.mkdir(referent, { recursive: true }) @@ -64,57 +95,205 @@ describe("safeWriteJson staging directory drift", () => { expect((await fs.readdir(alias)).filter((name) => name.startsWith(".new_"))).toEqual([]) }) - it("still rejects when the approved directory is replaced by a link before staging", async () => { - const parent = path.join(tmpRoot, "approved-parent") - await fs.mkdir(parent, { recursive: true }) + it("stages anyway when the guard's probe cannot answer", async () => { + // A probe that cannot answer is not an answer. The filesystem refuses to canonicalize the + // directory this write was approved for - here with EACCES, which is the failure the walk + // cannot absorb into a retry from an existing ancestor - and refusal says nothing about the + // directory having been replaced, so the write proceeds. This is the case that reddens when + // the narrowing is reverted: without it the guard is back to treating "I could not look" as + // evidence of drift, and an ordinary write into a directory it cannot stat is rejected. + const referent = path.join(tmpRoot, "referent-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const referentReal = await actuals.realpath!(referent) + const target = path.join(alias, "settings.json") + const acted = actAtRewalk(referentReal, async () => { + const refused = new Error("EACCES: permission denied, realpath") as NodeJS.ErrnoException + refused.code = "EACCES" + throw refused + }) + + await safeWriteJson(target, { preserved: true }) + + expect(acted()).toBe(true) // the guard really asked, and really was not answered + const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") + expect(JSON.parse(written)).toEqual({ preserved: true }) + expect((await fs.readdir(referent)).filter((name) => name.startsWith(".new_"))).toEqual([]) + }) + + it("stages anyway under the task persistence suite's blanket missing stat", async () => { + // The shape that made CI red: core/task/__tests__/Task.spec.ts mocks fs/promises with + // stat: vi.fn().mockRejectedValue({ code: "ENOENT" }) for every path, and its task directory + // (a Windows temp path under \\test-storage\\tasks\\, created moments earlier by + // fsReal.mkdirSync) demonstrably existed at the instant the guard asked - both directory + // resolutions returned it and the unmocked fs.statSync reported the same inode. Measured on + // the old guard, exactly one fs.stat call was enough to reject that write as drift. Here the + // same blanket double sits over a write whose directory really is there: the guard must not + // consult it at all, and this case reddens if it ever starts to. + const referent = path.join(tmpRoot, "referent-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const target = path.join(alias, "settings.json") + let statCalls = 0 + const statSpy = vi.spyOn(fs, "stat").mockRejectedValue({ code: "ENOENT" }) + let failure: unknown + try { + await safeWriteJson(target, { preserved: true }) + } catch (error) { + failure = error + } finally { + statCalls = statSpy.mock.calls.length + statSpy.mockRestore() + } + expect(failure).toBeUndefined() + expect(statCalls).toBe(0) // nothing on this path asks fs.stat about the approved directory + const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") + expect(JSON.parse(written)).toEqual({ preserved: true }) + }) + + it("stages anyway when the re-walk reports the directory missing mid-replacement", async () => { + // The other reading of "cannot answer": the directory is there, but a component is being + // swapped and realpath reports ENOENT for one instant. _resolveScopeRoot already tolerates + // that - it retries from its nearest existing ancestor and rejoins the same spelling - so + // this case is absorbed by the walk rather than by the guard, and it is NOT the control for + // the narrowing: the EACCES case above is, because that failure is the one the walk rethrows. + const referent = path.join(tmpRoot, "referent-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const referentReal = await actuals.realpath!(referent) + const target = path.join(alias, "settings.json") + const acted = actAtRewalk(referentReal, async () => { + const missing = new Error("ENOENT: no such file or directory, realpath") as NodeJS.ErrnoException + missing.code = "ENOENT" + throw missing + }) + + await safeWriteJson(target, { preserved: true }) + + expect(acted()).toBe(true) + const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") + expect(JSON.parse(written)).toEqual({ preserved: true }) + }) + + it("still rejects when the approved directory is replaced by a junction before staging", async () => { + // The fail-closed side, decided by a real filesystem rather than by a stubbed answer: the + // directory the write was approved for is renamed aside and a junction is planted in its place + // pointing at a different directory, at the instant the guard takes its second reading of it. + // The junction is the portable twin of the POSIX symlink case (a Windows runner cannot create + // symlinks in an unprivileged checkout but can create junctions, and the first case above + // already plants one). realpath is left real: the wrapper decides only WHEN the plant happens, + // and the answer the guard reads comes from the OS. + const referent = path.join(tmpRoot, "approved-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") const elsewhere = path.join(tmpRoot, "elsewhere") await fs.mkdir(elsewhere, { recursive: true }) - const target = path.join(parent, "settings.json") - const approvedReal = await actuals.realpath!(parent) + const referentReal = await actuals.realpath!(referent) const elsewhereReal = await actuals.realpath!(elsewhere) - // The guard canonicalizes the approved directory, stats it, and re-walks it. Arm on the stat - - // the point between the two walks - and report the walk after it as landing elsewhere, which is - // what a parent replaced by an outside-pointing link produces. - let armed = false - vi.mocked(fs.stat).mockImplementation(async (...args) => { - if (path.resolve(String(args[0])) === approvedReal) { - armed = true - } - return await actuals.stat!(...args) - }) - vi.mocked(fs.realpath).mockImplementation(async (...args) => { - const lexical = path.resolve(String(args[0])) - if (armed && lexical === approvedReal) { - return elsewhereReal - } - return await actuals.realpath!(...args) + const moved = path.join(tmpRoot, "approved-dir-moved") + const target = path.join(alias, "settings.json") + const acted = actAtRewalk(referentReal, async () => { + await fs.rename(referent, moved) + await fs.symlink(elsewhereReal, referent, process.platform === "win32" ? "junction" : "dir") }) await expect(safeWriteJson(target, { preserved: true })).rejects.toThrow(/Refusing to stage/) - // Rejected before staging: nothing reached the directory the guard landed on, and nothing was - // published under the approved spelling either. + expect(acted()).toBe(true) + // Rejected before staging: the planted destination got nothing, and the directory this write + // was approved for - now spelled by where it was moved to - got no staged file and no published + // target. (Its advisory lock directory is still there: the lock lives beside the target, and the + // plant moved the target out from under the held lock, which is the state this case creates.) expect(await fs.readdir(elsewhere)).toEqual([]) - expect((await fs.readdir(parent)).filter((name) => name === "settings.json")).toEqual([]) + expect( + (await fs.readdir(moved)).filter((name) => name === "settings.json" || name.startsWith(".new_")), + ).toEqual([]) }) - it("still rejects when the approved directory no longer exists", async () => { - const parent = path.join(tmpRoot, "doomed-parent") - await fs.mkdir(parent, { recursive: true }) - const target = path.join(parent, "settings.json") - const approvedReal = await actuals.realpath!(parent) - - vi.mocked(fs.stat).mockImplementation(async (...args) => { - if (path.resolve(String(args[0])) === approvedReal) { - const error = new Error("ENOENT: no such file or directory") as NodeJS.ErrnoException - error.code = "ENOENT" - throw error + it("fails as a failed write when the approved directory is really gone", async () => { + // The case the removed stat probe used to decide by asserting drift: the directory is deleted + // for real at the guard's second reading. Nothing is created outside the directory the write + // was approved for and nothing is published, so the write is still fail-closed - it simply + // fails as the failed write it is instead of claiming to be evidence of replacement. This case + // is NOT a control for the narrowing: the deletion is keyed to the guard's second reading, so + // the old guard had already cleared its existence probe before the directory went, and it + // passes on both guards. The two cannot-answer cases above are the load-bearing controls. + const referent = path.join(tmpRoot, "doomed-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const referentReal = await actuals.realpath!(referent) + const target = path.join(alias, "settings.json") + const acted = actAtRewalk(referentReal, async () => { + await fs.rm(referent, { recursive: true, force: true }) + }) + + await expect(safeWriteJson(target, { preserved: true })).rejects.toThrow(/ENOENT/) + + expect(acted()).toBe(true) + // The alias now dangles, nothing was published through it, and the only thing left under the + // temp root is the alias itself: no staged file, no lock directory, no stray destination. + const published = await fs.readFile(target, "utf8").catch(() => null) + expect(published).toBeNull() + expect((await fs.readdir(tmpRoot)).sort()).toEqual(["alias-dir"]) + }) + it("follows the approved pathname when the directory is replaced before the guard reads it", async () => { + // The ordering this guard cannot see, recorded rather than wished away: the approved directory + // is renamed aside and replaced by a junction onto a different directory BEFORE the guard + // takes its first reading, so both readings agree on the foreign directory and the drift + // signal stays silent. Neither layer rejects the write - not the drift guard (both readings + // land on the same place) and not safeWriteText's beside-the-target check (its tempPath and + // its re-resolved publish target are both spelled through the same alias, and + // resolvePublishTarget returns the path it was given while the target file is still absent). + // The write therefore completes at wherever the approved pathname now points, with no staged + // file left anywhere: measured identical before and after the narrowing, so this is a property + // of the guard's design, not a change in behavior. What it does mean is that the file can be + // published into a directory the writer's lock - which stayed with the renamed directory - + // does not name, the same hazard the lock-key drift check covers for a target alias that + // moves, and a reader who wants that ordering rejected has to reject it in safeWriteText. + const referent = path.join(tmpRoot, "approved-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const elsewhere = path.join(tmpRoot, "elsewhere") + await fs.mkdir(elsewhere, { recursive: true }) + const elsewhereReal = await actuals.realpath!(elsewhere) + const moved = path.join(tmpRoot, "approved-dir-moved") + const aliasSpelling = path.resolve(alias) + const target = path.join(alias, "settings.json") + let readings = 0 + let planted = false + vi.mocked(fs.realpath).mockImplementation(async (...args) => { + const lexical = path.resolve(String(args[0])) + // Readings 1 and 2 are the lock-key canonicalization before and after the lock; reading 3 + // is the guard's approval of the staging directory, and the swap is planted as that reading + // is taken, so the approval already sees the foreign directory. + if (lexical === aliasSpelling) { + readings += 1 + if (readings === 3 && !planted) { + planted = true + await fs.rename(referent, moved) + await fs.symlink(elsewhereReal, referent, process.platform === "win32" ? "junction" : "dir") + } } - return await actuals.stat!(...args) + return await actuals.realpath!(...args) }) - await expect(safeWriteJson(target, { preserved: true })).rejects.toThrow(/no existing directory/) + await safeWriteJson(target, { preserved: true }) - expect((await fs.readdir(parent)).filter((name) => name.startsWith(".new_"))).toEqual([]) + expect(readings).toBe(3) + expect(planted).toBe(true) + const written = await fs.readFile(path.join(elsewhere, "settings.json"), "utf8") + expect(JSON.parse(written)).toEqual({ preserved: true }) + // Nothing stranded: the renamed directory holds only the advisory lock directory that lives + // beside the target, and neither spelling of it nor the new location holds a staged file. + expect( + (await fs.readdir(moved)).filter((name) => name === "settings.json" || name.startsWith(".new_")), + ).toEqual([]) + expect((await fs.readdir(elsewhere)).filter((name) => name.startsWith(".new_"))).toEqual([]) }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index ca33bf4c09..dc7bc9e99d 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -157,6 +157,23 @@ function _scopeErrorCode(error: unknown): string | undefined { : undefined } +/** + * Canonicalize the staging directory this write was approved to stage into, or report that the + * probe could not answer. Returning undefined instead of throwing is the point: the drift check + * can only read meaning out of a resolution that succeeded, and every failure this probe can + * produce - ENOENT while the directory exists because a component is mid-replacement or because + * the caller's fs double reports the path as missing, EACCES, EPERM, ELOOP - is equally consistent + * with the approved directory being exactly where it was approved. A probe that cannot answer is + * not an answer. + */ +async function _probeApprovedStagingDirectory(directory: string): Promise { + try { + return await _resolveScopeRoot(directory) + } catch { + return undefined + } +} + /** * Safely writes JSON data to a file. * - Creates parent directories if they don't exist @@ -294,11 +311,6 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) - // The staging location was chosen from the parent directory resolved under the lock. Re-verify - // that directory against itself before writing anything into it: a resolved pathname is not an - // identity, and a parent that was replaced with a link pointing elsewhere in the meantime would - // put the staged file outside the directory this write was approved for. safeWriteText would - // then reject the publish, but the stray file would already exist. // The staging location was chosen from the parent directory resolved under the lock. Re-verify // that directory against itself before writing anything into it: a resolved pathname is not an // identity, and a parent that was replaced with a link pointing elsewhere in the meantime would @@ -310,37 +322,41 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit // whenever the two spellings differ. On a Windows runner the temp root arrives spelled with its // 8.3 short name (C:\Users\RUNNER~1\AppData\Local\Temp) and realpath reports the long name for // that very same directory, so every write into a directory that did not exist yet rejected - // here. The two walks run against the same frozen approval one after the other, so a parent that + // here. The two walks run against the same frozen approval one after the other, and both sides + // are compared as canonical directories rather than as the text of a path, so a parent that // really was replaced by a link still lands on a different canonical directory and still - // rejects; the stat between them keeps rejecting a deleted approval, which the walk alone cannot - // tell apart from a replaced one. Where a filesystem reports no usable file identity at all the - // comparison degrades to today's spelling comparison, which is why the identity being compared - // here is the canonical directory rather than the text of a path. + // rejects. + // + // What this guard may act on is one thing only: a re-resolution that succeeds and lands on a + // different directory. A probe that cannot answer is not an answer. A probe that fails - ENOENT + // because a component is mid-replacement or is simply not there yet, EACCES, EPERM or ELOOP + // because the filesystem refused to say - carries no information about replacement whatsoever, + // and a failing probe is a normal condition of this write path rather than an attack: the task + // persistence suite runs this write against an fs/promises double whose stat rejects with + // ENOENT for every path, and its task directory was created moments earlier with the real fs. + // Reading that as drift rejected an ordinary write, which is the shape upstream's newer + // Task.spec hit on a Windows temp path. So a probe that cannot answer proceeds. A directory that + // really was deleted in the meantime then fails at the staging write itself: still fail-closed, + // still nothing created outside the directory the write was approved for, and the error names + // the failed write instead of claiming to be evidence of replacement. The confinement checks + // above decide the opposite way on purpose - there the guard has to prove the write is inside + // the scope, and a probe that cannot answer cannot prove anything. const approvedStagingDirectory = path.dirname(actualTempNewFilePath) - let approvedCanonical: string - let currentStagingDirectory: string - try { - approvedCanonical = await _resolveScopeRoot(approvedStagingDirectory) - await fs.stat(approvedCanonical) - currentStagingDirectory = await _resolveScopeRoot(approvedCanonical) - } catch (driftError: unknown) { - const code = - driftError && typeof driftError === "object" && "code" in driftError - ? (driftError as { code?: string }).code - : undefined - throw new StagingDirectoryDriftError( - resolvedTargetPath ?? absoluteFilePath, - approvedStagingDirectory, - code === "ENOENT" ? "no existing directory" : String(driftError), - ) - } - if (!_sameDirectory(approvedCanonical, currentStagingDirectory)) { + const approvedCanonical = await _probeApprovedStagingDirectory(approvedStagingDirectory) + const currentStagingDirectory = + approvedCanonical === undefined ? undefined : await _probeApprovedStagingDirectory(approvedCanonical) + if ( + approvedCanonical !== undefined && + currentStagingDirectory !== undefined && + !_sameDirectory(approvedCanonical, currentStagingDirectory) + ) { throw new StagingDirectoryDriftError( resolvedTargetPath ?? absoluteFilePath, approvedCanonical, currentStagingDirectory, ) } + await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) // Step 2: Delegate backup + commit to safeWriteText with the pre-written From 52628eb868ba41e2651f13b94a2a568fb7e846c5 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:34:07 +0800 Subject: [PATCH 073/115] fix(#1918 r112): narrow four over-broad behaviours flagged in review Review round r112, one change per thread, in the order the findings came in: - ApplyPatchTool: forget a withdrawn observation only while the registry still holds the exact entry this tool published, so a failed apply_patch can no longer drop another writer's newer observation of the same file. - DiffViewProvider: the three teardown sites that already know the absolute path close only their own diff view; closeAllDiffViews stays as reset()'s fallback for the case where no path is known. - safeWriteText: create the target's parent directory before the private staging directory, so a self-staged write no longer publishes its target into a directory that only the staging mkdir made, with the staging mode. - safeWriteText.spec: the two win32 cases that hand in their own platform no longer skip themselves on a non-Windows host. - safeWriteJson.test: undo the proper-lockfile capture with vi.doUnmock, the runtime form, so a later import in the same file resolves the real module again. Specs: applyPatchTool.execute 26/26, DiffViewProvider 158/158, safeWriteText 68/68 (also 68/68 with process.platform reported as linux), safeWriteJson 27 passed / 4 skipped, safeWriteJson.stagingDirectory 7/7. Reverting each fix reddens exactly the test that names it: 4, 8, 1, 1 and one skipped case off Windows. --- src/core/tools/ApplyPatchTool.ts | 84 ++++++- .../__tests__/applyPatchTool.execute.spec.ts | 100 ++++++++ src/integrations/editor/DiffViewProvider.ts | 22 +- .../editor/__tests__/DiffViewProvider.spec.ts | 228 ++++++++++++++---- .../__tests__/safeWriteText.spec.ts | 114 ++++++--- src/services/file-safety/safeWriteText.ts | 20 +- src/utils/__tests__/safeWriteJson.test.ts | 24 +- 7 files changed, 496 insertions(+), 96 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index a36944e59b..b78a7ad6ad 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -6,6 +6,7 @@ import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { Task } from "../task/Task" +import type { FileObservation } from "../task/observationRegistry" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" @@ -58,6 +59,25 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const { patch } = params const { handleError, pushToolResult } = callbacks + // Every observation this call creates is tracked here by path -> the exact entry the + // registry stored. A hunk read is the tool read, not a model read: an exit that does + // not publish must withdraw what this call recorded, otherwise a later edit, + // search_replace, edit_file or apply_diff finds the leftover partial entry, the + // compare-and-swap matches, and the file publishes without the model ever reading it. + // Withdrawal is keyed to the stored entry, so an entry a publish re-earned or one a + // concurrent read replaced is never discarded here. Declared outside the try because + // the finally exit needs it. ApplyDiffTool enforces the same contract for its single + // read with forgetPartialObservationIfCurrent; apply_patch reads several files per + // call, so this side keeps one entry per path. + const toolObservations = new Map() + const forgetToolObservationsIfCurrent = (): void => { + for (const [observedPath, entry] of toolObservations) { + if (task.observationRegistry.get(observedPath) === entry) { + task.observationRegistry.forget(observedPath) + } + } + toolObservations.clear() + } try { // Validate required parameters if (!patch) { @@ -114,6 +134,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const complete = prior === undefined ? false : prior.complete === true && prior.version === preReadToken task.observationRegistry.observe(absolutePath, preReadToken, complete) + // Keep the stored entry so a non-publishing exit can withdraw exactly this + // observation later. + const storedObservation = task.observationRegistry.get(absolutePath) + if (storedObservation) { + toolObservations.set(absolutePath, storedObservation) + } } } return content @@ -148,13 +174,36 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (change.type === "add") { // Create new file - await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + await this.handleAddFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + toolObservations, + ) } else if (change.type === "delete") { // Delete file - await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + await this.handleDeleteFile( + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + toolObservations, + ) } else if (change.type === "update") { // Update file - await this.handleUpdateFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + await this.handleUpdateFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + toolObservations, + ) } } @@ -162,6 +211,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } catch (error) { await handleError("apply patch", error as Error) await task.diffViewProvider.reset() + } finally { + // Every exit that did not publish passes through here: processAllHunks throwing (the + // files read before the failure stay observed), a refused approval, a rooignore or + // outside-workspace refusal, and the outer catch. Paths that published were removed + // from the tracker at the publish site, so this withdraws only what no publish earned. + forgetToolObservationsIfCurrent() } } @@ -172,6 +227,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + toolObservations: Map, ): Promise { const { askApproval, pushToolResult } = callbacks @@ -277,6 +333,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Track file edit operation await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + // This call published the path, so the observation it carries is earned: the sweep in + // the finally block leaves it alone. + toolObservations.delete(absolutePath) task.didEditFile = true const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, true) @@ -291,6 +350,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + toolObservations: Map, ): Promise { const { askApproval, pushToolResult } = callbacks @@ -337,6 +397,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } + // The delete consumed the observation: the version the hunk read recorded is no longer + // reachable at this path, so there is nothing here for the sweep to withdraw. + toolObservations.delete(absolutePath) + task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) task.processQueuedMessages() @@ -349,6 +413,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + toolObservations: Map, ): Promise { const { askApproval, pushToolResult } = callbacks @@ -519,6 +584,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // had of the source, so carry the source's completeness instead of the // destination's stale one. task.observationRegistry.observe(moveAbsolutePath, destObs.version, false) + // Tracked like a hunk read: if the move does not publish, this partial entry must + // not survive as authority for a destination the model never read. + const storedDestObservation = task.observationRegistry.get(moveAbsolutePath) + if (storedDestObservation) { + toolObservations.set(moveAbsolutePath, storedDestObservation) + } } } await task.diffViewProvider.saveDirectly( @@ -549,6 +620,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) + // Both paths published: the destination through the save above and the source by being + // moved out of the way. Neither observation is left for the sweep to withdraw. + toolObservations.delete(moveAbsolutePath) + toolObservations.delete(absolutePath) } else { // Save changes to the same file if (isPreventFocusDisruptionEnabled) { @@ -582,6 +657,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + // The publish owns this path now, so the sweep in the finally block leaves its + // observation alone. + toolObservations.delete(absolutePath) } task.didEditFile = true diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 1cd14422ea..a64c196e22 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -188,6 +188,27 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { @@ -original file content +modified file content +*** End Patch` + const mismatchedSecondPatch = `*** Begin Patch +*** Update File: src/first.ts +@@ +-original file content ++modified first content +*** Update File: src/second.ts +@@ +-context the mocked file does not contain ++modified second content +*** End Patch` + + const twoUpdatePatch = `*** Begin Patch +*** Update File: src/first.ts +@@ +-original file content ++modified first content +*** Update File: src/second.ts +@@ +-original file content ++modified second content *** End Patch` beforeEach(() => { @@ -377,6 +398,85 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { "a full-file replacement needs the complete content; re-read the whole file, then retry.", ) }) + it("update: withdraws the hunk-read observation when the user denies the publish", async () => { + // apply_patch read the file to compute the hunk, so the tool owns that observation. + // The user said no, nothing published, and a leftover partial entry would let a later + // edit, search_replace, edit_file or apply_diff publish against a version the model + // never read. This is the exit that made apply_patch the remaining tool-read path. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + const reg = mockTask.observationRegistry + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") + expect(reg.has(key)).toBe(false) + }) + + it("update: withdraws the hunk-read observation when the publish throws", async () => { + // The outer-catch exit: the hunk read happened, the save blew up before publishing, + // and the observation must not survive as authority for a later write. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + const reg = mockTask.observationRegistry + mockSaveDirectly.mockRejectedValue(new Error("extension host went away")) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockHandleError).toHaveBeenCalled() + expect(reg.has(key)).toBe(false) + }) + + it("withdraws observations for files read before a hunk failure", async () => { + // processAllHunks throws on the second file context mismatch, so the first file was + // read, observed and then never published. Its leftover entry is the state a later + // targeted edit would wrongly accept. + const firstKey = path.resolve(mockTask.cwd, "src/first.ts") + const secondKey = path.resolve(mockTask.cwd, "src/second.ts") + const reg = mockTask.observationRegistry + + await tool.execute({ patch: mismatchedSecondPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Failed to process patch")) + expect(reg.has(firstKey)).toBe(false) + expect(reg.has(secondKey)).toBe(false) + }) + + it("keeps the observation of a path that published while withdrawing a denied one", async () => { + // Two updates in one patch: the first approves and publishes, the second is denied. + // Withdrawal is scoped by path and by the exact stored entry, so the published file + // keeps the observation its publish ran on (partial, as the hunk read recorded it) + // while the denied path is left with nothing. + const publishedKey = path.resolve(mockTask.cwd, "src/first.ts") + const deniedKey = path.resolve(mockTask.cwd, "src/second.ts") + const reg = mockTask.observationRegistry + mockAskApproval.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + await tool.execute({ patch: twoUpdatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(reg.has(publishedKey)).toBe(true) + expect(reg.get(publishedKey)?.complete).toBe(false) + expect(reg.has(deniedKey)).toBe(false) + }) it("update: observes the hunk read so the guarded publish is not unobserved", async () => { await tool.execute({ patch: updatePatch }, mockTask as Task, { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 8afc716b09..a61043d775 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -762,7 +762,12 @@ export class DiffViewProvider { await this.revertDocument(updatedDocument) } - await this.closeAllDiffViews() + // A save belongs to one task, so its post-publish cleanup closes only this provider's + // diff tab. closeAllDiffViews() would also close a concurrent task's clean diff view + // while that task still holds its activation listener and deferred scroll timer against + // a tab that is gone - the same reason the failure teardown above and reset() scope the + // close to this provider's own file. + await this.closeOwnDiffView(absolutePath) // Read auto-close preferences from state; fall back to defaults that // preserve the existing behavior when unset (saveTask was resolved above @@ -959,7 +964,9 @@ export class DiffViewProvider { await updatedDocument.save() } - await this.closeAllDiffViews() + // A rejection belongs to one task, so this teardown closes only this provider's + // diff tab rather than every clean diff view in the workbench. + await this.closeOwnDiffView(absolutePath) // The file was newly created for this edit; close its transiently // opened tab before deleting it from disk. await this.closeFileTab(absolutePath) @@ -986,7 +993,9 @@ export class DiffViewProvider { await vscode.workspace.applyEdit(edit) await updatedDocument.save() - await this.closeAllDiffViews() + // Same scoping as the create branch above: the revert is this task's, so only this + // provider's diff tab goes. + await this.closeOwnDiffView(absolutePath) // Read auto-close preferences from state; fall back to defaults that // preserve the existing behavior when unset. @@ -1032,6 +1041,13 @@ export class DiffViewProvider { } } + /** + * Close every clean Zoo diff tab in the workbench. After the per-task teardowns were + * scoped, only the fallback in reset() still calls this, for the case where the provider + * no longer knows which file it was editing; every teardown that has a path in scope + * closes just its own view, so one task's rejected or saved edit cannot disturb another + * task's open diff view. + */ private async closeAllDiffViews(): Promise { const closeOps = vscode.window.tabGroups.all .flatMap((group) => group.tabs) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 634950b580..7d6154b66b 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -780,6 +780,47 @@ describe("DiffViewProvider", () => { expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.pinEditor") }) }) + // Installs one of this provider's Zoo diff tabs in the mocked workbench and spies the + // real tab-close call, optionally holding that close open on a gate. Tests use it to + // wait for, and count, a teardown pass by the effect that matters - the tab actually + // going away - rather than by the name of the private helper that closes it. + const installDiffTabSpy = (absolutePath: string, hold?: Promise) => { + const tab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: absolutePath }, + }, + label: absolutePath + ": changes (Editable)", + isDirty: false, + } + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + // A second, foreign diff view sits in the same workbench so a teardown that sweeps every + // clean diff view is visible to the caller rather than invisible. + const otherTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: absolutePath.replace(/([^/]+)$/, "other-task.ts") }, + }, + label: "other-task.ts: changes (Editable)", + isDirty: false, + } + Object.setPrototypeOf(otherTab.input, vscode.TabInputTextDiff.prototype) + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [tab, otherTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation(async (closed) => { + closedTabs.push(closed) + if (hold) { + await hold + } + return true + }) + return { tab, otherTab, closedTabs } + } describe("closeAllDiffViews method", () => { it("should close diff views including those identified by label", async () => { @@ -1035,6 +1076,105 @@ describe("DiffViewProvider", () => { expect(closedTabs).toEqual([ownTab]) }) + // The flow-level version of the tab-scoping check above: a rejected edit is one task's + // business, so its teardown must not reach into another task's diff view. Asserted + // through revertChanges() and the tabs themselves rather than through the private + // helper the teardown now calls, so it stays true whichever helper is used. + it("revertChanges() leaves another task's diff view open", async () => { + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + label: `test.ts: changes (Editable)`, + isDirty: false, + } + const otherTaskTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + label: `other-task.ts: changes (Editable)`, + isDirty: false, + } + for (const tab of [ownTab, otherTaskTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "test.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["activeDiffEditor"] = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("content"), + positionAt: vi.fn().mockReturnValue({ line: 0, character: 0 }), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as vscode.TextEditor + // The tail steps are not what this assertion is about: stub them so the spy counts + // only the revert pass's own tab work. + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["reset"] = vi.fn().mockResolvedValue(undefined) + + await diffViewProvider.revertChanges() + + expect(closedTabs).toEqual([ownTab]) + }) + + // The one closeAllDiffViews() call site that survives is reset()'s fallback for when + // the provider no longer knows which file it was editing. That site is chosen, not + // overlooked: with no path to scope to, sweeping every clean diff view is all that is + // left, and this test is what says so out loud. + it("reset() without a relPath still closes every clean diff view", async () => { + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + const otherTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + isDirty: false, + } + for (const tab of [ownTab, otherTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + diffViewProvider["relPath"] = undefined + + await diffViewProvider["reset"]() + + expect(closedTabs).toEqual([ownTab, otherTab]) + }) + describe("saveDirectly method", () => { beforeEach(() => { // Mock vscode functions @@ -2541,6 +2681,15 @@ describe("DiffViewProvider", () => { revealRange: vi.fn(), } as unknown as vscode.TextEditor diffViewProvider["activeDiffEditor"] = cleanEditor + // Run the real close helpers and watch the tab itself, so the claim below does + // not depend on which private helper the post-save cleanup happens to call. The + // plain-file close and the preview restore are not this test's subject, so they + // stay stubbed. + Reflect.deleteProperty(diffViewProvider, "closeAllDiffViews") + Reflect.deleteProperty(diffViewProvider, "closeOwnDiffView") + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const { tab: diffTab, closedTabs } = installDiffTabSpy(`${mockCwd}/test.ts`) diffViewProvider.editType = "modify" // A partial observation must stay partial: adopting content that is already on // disk cannot upgrade it into authority for a full-file replacement. @@ -2560,7 +2709,10 @@ describe("DiffViewProvider", () => { // unlinked; the normal post-save close flow still ran. expect(fs.unlink).not.toHaveBeenCalled() expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() - expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + // Proven on the tab itself: the diff tab this save had is gone. Asserting on the + // close call rather than on the private helper keeps the claim true whichever + // helper the post-save cleanup uses. + expect(closedTabs).toEqual([diffTab]) }) it("does not adopt an autosaved match for an edit that was never authorized", async () => { @@ -3254,7 +3406,7 @@ describe("DiffViewProvider", () => { it("saveChanges() closes the file tab when the file was not open and untouched", async () => { const closeFileTab = vi.fn().mockResolvedValue(undefined) - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).closeOwnDiffView = vi.fn().mockResolvedValue(undefined) ;(diffViewProvider as any).closeFileTab = closeFileTab ;(diffViewProvider as any).relPath = "mock-target-file.ts" ;(diffViewProvider as any).documentWasOpen = false @@ -3277,8 +3429,7 @@ describe("DiffViewProvider", () => { // tabs are restored and the provider reset twice. const applyEdit = vi.mocked(vscode.workspace.applyEdit) applyEdit.mockResolvedValue(true) - const closeAllDiffViews = vi.fn().mockResolvedValue(undefined) - diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + const { closedTabs } = installDiffTabSpy(mockTargetPath) diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["relPath"] = "mock-target-file.ts" diffViewProvider["editType"] = "modify" @@ -3317,7 +3468,9 @@ describe("DiffViewProvider", () => { await Promise.all([first, second]) expect(applyEdit).toHaveBeenCalledTimes(1) - expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + // One pass over the tabs across both callers: counted on the tab actually + // closing, not on the helper the pass happens to call. + expect(closedTabs).toHaveLength(1) expect(restorePreviewTabs).toHaveBeenCalledTimes(1) expect(reset).toHaveBeenCalledTimes(1) }) @@ -3328,7 +3481,7 @@ describe("DiffViewProvider", () => { // tabs is duplicate cleanup, so the second caller waits for the first. const applyEdit = vi.mocked(vscode.workspace.applyEdit) applyEdit.mockResolvedValue(true) - diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + const { closedTabs } = installDiffTabSpy(mockTargetPath) diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["relPath"] = "mock-target-file.ts" diffViewProvider["editType"] = "modify" @@ -3347,6 +3500,8 @@ describe("DiffViewProvider", () => { const second = diffViewProvider.revertChanges() await Promise.all([first, second]) + // Only this provider's tab is touched, whichever caller ran the pass. + expect(new Set(closedTabs).size).toBe(1) expect(applyEdit).toHaveBeenCalledTimes(1) }) // The save's post-publish cleanup touches the same buffers and tabs a cancellation's @@ -3356,9 +3511,8 @@ describe("DiffViewProvider", () => { // A cancellation can reach revertChanges() while the publish is awaiting. That teardown // already closed the diff views and applied the auto-close preferences; a second pass // would close the same tabs and re-run the same document revert. - const closeAllDiffViews = vi.fn().mockResolvedValue(undefined) const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) - diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + const { closedTabs } = installDiffTabSpy(mockTargetPath) diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["restorePreviewTabs"] = restorePreviewTabs const applyEdit = vi.mocked(vscode.workspace.applyEdit) @@ -3399,7 +3553,8 @@ describe("DiffViewProvider", () => { // Exactly one teardown ran - the cancellation's - and the save added no cleanup of its // own on top of it. expect(applyEdit).toHaveBeenCalledTimes(1) - expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + // Only this provider's tab is touched, by either caller. + expect(new Set(closedTabs).size).toBe(1) expect(restorePreviewTabs).toHaveBeenCalledTimes(1) }) @@ -3410,12 +3565,7 @@ describe("DiffViewProvider", () => { const gate = new Promise((resolve) => { release = resolve }) - let cleanupEntered = false - const closeAllDiffViews = vi.fn().mockImplementation(async () => { - cleanupEntered = true - await gate - }) - diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + const { closedTabs } = installDiffTabSpy(mockTargetPath, gate) diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) const applyEdit = vi.mocked(vscode.workspace.applyEdit) @@ -3434,7 +3584,7 @@ describe("DiffViewProvider", () => { }) const save = diffViewProvider.saveChanges(false) - while (!cleanupEntered) { + while (closedTabs.length === 0) { await new Promise((resolve) => setImmediate(resolve)) } @@ -3448,7 +3598,8 @@ describe("DiffViewProvider", () => { await Promise.all([save, revert]) // One pass over the tabs, and the cancellation did not repeat the document revert. - expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + // Only this provider's tab is touched, by either caller. + expect(new Set(closedTabs).size).toBe(1) expect(applyEdit).not.toHaveBeenCalled() }) @@ -3460,12 +3611,7 @@ describe("DiffViewProvider", () => { const gate = new Promise((resolve) => { release = resolve }) - let cleanupEntered = false - const gatedCleanup = vi.fn().mockImplementation(async () => { - cleanupEntered = true - await gate - }) - diffViewProvider["closeAllDiffViews"] = gatedCleanup + const { closedTabs } = installDiffTabSpy(mockTargetPath, gate) diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) const reset = vi.fn().mockResolvedValue(undefined) @@ -3487,7 +3633,7 @@ describe("DiffViewProvider", () => { }) const save = diffViewProvider.saveChanges(false) - while (!cleanupEntered) { + while (closedTabs.length === 0) { await new Promise((resolve) => setImmediate(resolve)) } @@ -3515,14 +3661,8 @@ describe("DiffViewProvider", () => { const gate = new Promise((resolve) => { release = resolve }) - let cleanupEntered = false - const closeAllDiffViews = vi.fn().mockImplementation(async () => { - cleanupEntered = true - await gate - }) + const { closedTabs } = installDiffTabSpy(mockTargetPath, gate) const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) - diffViewProvider["closeAllDiffViews"] = closeAllDiffViews - diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["restorePreviewTabs"] = restorePreviewTabs const applyEdit = vi.mocked(vscode.workspace.applyEdit) @@ -3545,7 +3685,7 @@ describe("DiffViewProvider", () => { diffViewProvider["deferredScrollTimer"] = setTimeout(() => {}, 10_000) const save = diffViewProvider.saveChanges(false) - while (!cleanupEntered) { + while (closedTabs.length === 0) { await new Promise((resolve) => setImmediate(resolve)) } @@ -3565,7 +3705,10 @@ describe("DiffViewProvider", () => { // One pass over the tabs and buffers: the waiting cancellation repeated neither the // document revert nor the preview-tab restoration. expect(applyEdit).not.toHaveBeenCalled() - expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + // Both the save pass and the cancellation's session finalization close this + // provider's own tab; what must not happen is either of them touching another + // task's view. + expect(new Set(closedTabs).size).toBe(1) expect(restorePreviewTabs).toHaveBeenCalledTimes(1) // A session a cancellation is closing reports no completed save, so no caller can present @@ -3851,18 +3994,11 @@ describe("DiffViewProvider", () => { const gate = new Promise((resolve) => { release = resolve }) - let cleanupEntered = false - const closeAllDiffViews = vi.fn().mockImplementation(async () => { - cleanupEntered = true - await gate - }) + const { closedTabs } = installDiffTabSpy(mockTargetPath, gate) const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) - const closeOwnDiffView = vi.fn().mockResolvedValue(undefined) - diffViewProvider["closeAllDiffViews"] = closeAllDiffViews diffViewProvider["restorePreviewTabs"] = restorePreviewTabs - diffViewProvider["closeOwnDiffView"] = closeOwnDiffView - // reset() closes this provider's diff tab; stub it so closeOwnDiffView counts only the - // save's own cleanup pass. + // reset() also closes this provider's diff tab; stub it so the close spy counts + // only the pass under test. diffViewProvider["reset"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) const applyEdit = vi.mocked(vscode.workspace.applyEdit) @@ -3882,7 +4018,7 @@ describe("DiffViewProvider", () => { // The revert starts its pass first and is held inside it. const revert = diffViewProvider.revertChanges() - while (!cleanupEntered) { + while (closedTabs.length === 0) { await new Promise((resolve) => setImmediate(resolve)) } @@ -3901,7 +4037,9 @@ describe("DiffViewProvider", () => { // The save's cleanup never ran, so it restored nothing; the owning revert pass did the // restoring exactly once. - expect(closeOwnDiffView).not.toHaveBeenCalled() + // The rejected save ran no cleanup of its own: the only tab close in the whole + // exchange is the owning revert pass's own view going away. + expect(closedTabs).toHaveLength(1) expect(restorePreviewTabs).toHaveBeenCalledTimes(1) }) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index e6664e82fd..335fef875f 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -106,6 +106,49 @@ describe("safeWriteText", () => { }) describe("staging and cleanup", () => { + // The staging directory is private, and it must stay the only thing that is. Created inside + // a parent that does not exist yet, the recursive mkdirSync that makes it also makes that + // parent -- with the staging mode -- so the target ends up published into an owner-only + // directory it never asked for. What matters is the order of the two creations and the + // mode each one carries, recorded across both filesystem APIs this write uses. + it("creates the target's parent directory before the private staging directory", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const parentDir = path.dirname(targetPath) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const creations: Array<{ path: string; mode?: number }> = [] + try { + vi.mocked(fs.mkdir).mockImplementation(async (p: Parameters[0]) => { + creations.push({ path: String(p) }) + return undefined + }) + const syncMkdir = vi.mocked(fsSync.mkdirSync) + syncMkdir.mockImplementation(((p: string | Buffer | URL, options?: unknown) => { + creations.push({ + path: String(p), + mode: (options as { mode?: number }).mode, + }) + }) as unknown as typeof fsSync.mkdirSync) + + await safeWriteText(targetPath, "hello world", { platform: "linux" }) + + const parent = creations.findIndex((c) => c.path === parentDir) + const staging = creations.findIndex((c) => c.path.includes(".file-safety-staging")) + expect(parent).toBeGreaterThanOrEqual(0) + expect(staging).toBeGreaterThanOrEqual(0) + // The parent is made first, by the plain recursive mkdir, which carries no mode of + // its own and therefore gets the directory default. + expect(staging).toBeGreaterThan(parent) + expect(creations[parent].mode).toBeUndefined() + // The private mode is still applied -- to the staging directory alone. + expect(creations[staging].mode).toBe(0o700) + } finally { + vi.mocked(fs.mkdir).mockReset() + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fsSync.mkdirSync).mockReset() + } + }) + 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) @@ -630,17 +673,16 @@ describe("safeWriteText", () => { // ── 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) - }, - ) + // The case hands in the platform it wants the writer to believe, so it does not need a + // Windows host: the neighbouring cases in this describe already run on every lane. + it("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" @@ -689,31 +731,31 @@ describe("safeWriteText", () => { expect(warnings.filter((m) => m.includes("different access rights"))).toHaveLength(1) }) - it.skipIf(process.platform !== "win32")( - "bounds every icacls call with a finite timeout under the lock staleness window", - 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: "win32" }) - - const calls = vi.mocked(execFile).mock.calls - expect(calls).toHaveLength(2) - for (const call of calls) { - expect(call[0]).toBe("icacls") - // An execFile without a timeout waits for its child forever. While it waits, - // this write is open and - for a safeWriteJson caller - the per-file advisory - // lock is held while every other writer of the same file burns its retries. - const timeout = Number((call[2] as { timeout?: number } | undefined)?.timeout) - expect(Number.isFinite(timeout)).toBe(true) - expect(timeout).toBeGreaterThan(0) - // Below LOCK_STALE_MS (src/utils/fileLock.ts) so a hung helper cannot outlive - // the staleness window the lock protocol is written around. - expect(timeout).toBeLessThan(31_000) - } - }, - ) + // The platform here is an argument, not the host: this asserts what every icacls call is + // bounded by while the write is open, and the neighbouring cases below hand in the same + // option and already run on every lane, so skipping it off Windows only hid coverage. + it("bounds every icacls call with a finite timeout under the lock staleness window", 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: "win32" }) + + const calls = vi.mocked(execFile).mock.calls + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(call[0]).toBe("icacls") + // An execFile without a timeout waits for its child forever. While it waits, + // this write is open and - for a safeWriteJson caller - the per-file advisory + // lock is held while every other writer of the same file burns its retries. + const timeout = Number((call[2] as { timeout?: number } | undefined)?.timeout) + expect(Number.isFinite(timeout)).toBe(true) + expect(timeout).toBeGreaterThan(0) + // Below LOCK_STALE_MS (src/utils/fileLock.ts) so a hung helper cannot outlive + // the staleness window the lock protocol is written around. + expect(timeout).toBeLessThan(31_000) + } + }) it("win32: a timed-out icacls degrades to the designed skip-DACL path and still commits", async () => { const targetPath = "/tmp/test-dir/target.txt" diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index cb24e0e317..1eeea90704 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -346,15 +346,25 @@ export async function safeWriteText( // The caller's own path is used as given; only the check is canonical. tempPath = options.tempPath } else { + // The staging directory lives inside the target's own parent, so the parent has to exist + // before _stagingDir runs: mkdirSync's recursive creation applies its mode to every + // directory it creates and never chmods one that already exists, so a self-staged write + // into a missing parent would publish the target into a directory left at the private + // staging mode (0o700) instead of the directory default. Nothing validates a self-staged + // path ahead of this, so creating the parent here cannot create a directory on behalf of + // a write that is about to be rejected -- and it is the order safeWriteJson already uses + // for its own staging directory. + await fs.mkdir(dirPath, { recursive: true }) stagingDir = _stagingDir(dirPath) tempPath = _tempName(stagingDir, "safeWriteText") } - // The parent directory is created only after the staging path this write will actually use has - // been accepted (mirrors safeWriteJson behaviour). mkdir -p ahead of the caller-supplied checks - // let a *rejected* write create a directory in the target's own tree before anything was - // validated. A self-staged write has already created it through _stagingDir's - // mkdirSync(recursive:true). + // A caller-supplied staging path defers this until that path has been accepted (mirrors + // safeWriteJson behaviour): mkdir -p ahead of the caller-supplied checks let a *rejected* + // write create a directory in the target's own tree before anything was validated. For + // the self-staged branch above this re-runs on a directory that already exists -- a + // recursive mkdir never chmods an existing directory, so it cannot repair a mode inherited + // from the staging creation -- and fs.access still confirms the directory is usable. await fs.mkdir(dirPath, { recursive: true }) await fs.access(dirPath) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index e2a53af863..beb56ec959 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -639,16 +639,32 @@ describe("safeWriteJson", () => { expect.any(Error), ) } finally { - // Cleanup must run even when an assertion fails: a leaked mock - // registration or console spy changes later tests, and vi.unmock - // alone does not reset a module that already imported the mock. + // Cleanup must run even when an assertion fails: a leaked mock registration or a + // leftover console spy changes later tests. The undo has to be the runtime form: + // Vitest hoists vi.unmock above this file's imports, so an unmock written here is + // registered before the vi.doMock above runs and never removes this test's wrapper. realpathSpy.mockRestore() - vi.unmock("proper-lockfile") + vi.doUnmock("proper-lockfile") vi.resetModules() consoleErrorSpy.mockRestore() } }) + // The undo in the case above has to be visible from the other side of the file: a later + // test that imports proper-lockfile fresh must resolve the real module, not the wrapper + // that case installed for its own import graph. This is the case that reddens when the + // undo is written as vi.unmock -- Vitest hoists that call above the imports, so it is + // registered before the vi.doMock above runs, the wrapper is never removed, and it is + // what a later import resolves. + test("resolves the real proper-lockfile after the capture above is undone", async () => { + vi.resetModules() + const realLockfile = await vi.importActual("proper-lockfile") + const imported = await import("proper-lockfile") + + expect(imported.lock).toBe(realLockfile.lock) + expect(vi.isMockFunction(imported.lock)).toBe(false) + }) + test("rejects a confined write whose target is outside the confined directory", async () => { const scope = path.join(tempDir, "project") await fs.mkdir(scope) From 7f90fc2992a74ba172913bafac3e44a7672aa14f Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:47:13 +0800 Subject: [PATCH 074/115] fix(#1918 r112 follow-up): withdraw the source only if its unlink removed it CodeRabbit verification reply on rt4475729779 found the last non-publishing exit. The move publishes the destination, then a failed `fs.unlink` of the source is logged and tolerated, leaving the source on disk with its original bytes - while the tracking entry for that path had already been dropped, so the tool-owned partial observation survived as authority for a later edit. The source entry now leaves the tracking map only when that unlink succeeded; the destination entry still leaves it unconditionally, because the destination really did publish. Control: the new case `move: withdraws the source observation when its unlink fails after the destination published` fails at 52628eb86 (1 failed | 26 passed, `expected true to be false` on the leftover registry entry, and the follow-up guarded write rode on it instead of refusing) and passes here (27 passed (27)). Reverting only the implementation to blob b78a7ad6ad55 reddens exactly that case. --- src/core/tools/ApplyPatchTool.ts | 16 ++++++++--- .../__tests__/applyPatchTool.execute.spec.ts | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index b78a7ad6ad..74a22bf764 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -612,18 +612,26 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await fs.writeFile(moveAbsolutePath, newContent, "utf8") } - // Delete the original file + // Delete the original file. A failure here is logged and tolerated: the destination + // has already published, but the source is still sitting on disk with the bytes it + // had before the move, so nothing was published at that path. + let sourceRemoved = true try { await fs.unlink(absolutePath) } catch (error) { + sourceRemoved = false console.error(`Failed to delete original file after move: ${error}`) } await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) - // Both paths published: the destination through the save above and the source by being - // moved out of the way. Neither observation is left for the sweep to withdraw. + // The destination published through the save above, so its entry leaves the sweep. + // The source only counts as published once its own unlink succeeded: otherwise the + // partial observation this call recorded for it is withdrawn in the finally block, + // instead of authorizing a later edit on a file the model never read in full. toolObservations.delete(moveAbsolutePath) - toolObservations.delete(absolutePath) + if (sourceRemoved) { + toolObservations.delete(absolutePath) + } } else { // Save changes to the same file if (isPreventFocusDisruptionEnabled) { diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index a64c196e22..ff52ead16c 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -786,6 +786,33 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockHandleError).not.toHaveBeenCalled() }) + // The destination publishes before the source unlink is attempted, so a failed unlink + // leaves the source sitting on disk with its original bytes. Nothing was published at + // that path, and the only thing still able to authorize a later edit is the partial + // observation this call recorded for itself, so it has to be withdrawn here. + it("move: withdraws the source observation when its unlink fails after the destination published", async () => { + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const reg = mockTask.observationRegistry + mockedFsPromises.default.unlink.mockRejectedValueOnce( + Object.assign(new Error("EPERM: operation not permitted, unlink"), { code: "EPERM" }), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockSaveDirectly).toHaveBeenCalled() + expect(reg.has(sourceKey)).toBe(false) + // The consequence the finding names: with the source observation gone, a follow-up + // targeted write to the source needs a real read instead of riding on the leftover. + await expect(guardedWrite(mockTask as Task, "src/old.ts", "full replacement", "update")).rejects.toThrow( + "File already exists at src/old.ts and was not read before this write -- read the file first, then retry.", + ) + }) + it("update: surfaces the unobserved-existing remediation as a tool error", async () => { const guardError = new Error( "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", From a0208858d65e95b7f340ceb85a76590bb31f0926 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:08:24 +0800 Subject: [PATCH 075/115] test(tools): reset the write_to_file doubles in a finally block The outside-workspace resolution-failure test restores the realpath rejection and the outside-workspace flag after its assertions, so a failing assertion skips both resets. The guarded-write test above it already routes its flag reset through a finally for exactly that reason (writeToFileTool.spec.ts:515/:520 with the reason comment at :521-523); this puts the same discipline on the second test and names the mechanism in the comment: beforeEach at :125-126 calls vi.clearAllMocks(), which clears calls but not implementations, so only the flag is re-defaulted at :131 and a leaked realpath rejection would survive into the rest of the file. Measured at this head (19567036b6200fbc7e6a215b4563300f0ed89d51) with node v22.23.4 (.tmp-node22), vitest.core.config.ts, run from src: the file is 24 passed | 5 skipped (29) before and after the change. The control replaces the handleError assertion with a wrong label so the row must fail: head implementation 1 failed | 23 passed | 5 skipped, red at spec.ts:539; with the finally 1 failed | 23 passed | 5 skipped, red at spec.ts:540. Reporting the control as it measured: it reddens the mutated assertion and stops there - no later test in this file exercises a path that consumes a rejecting realpath, so the leak this guards against is not reachable at this head and the commit is hygiene matching the sibling row rather than a repair of a cross-test failure anyone can observe today. +13/-9 in this one spec file: 3 executable lines added (try, the finally opening, the closing brace), the 6 statements of the body and its two resets moved under the finally and re-indented, and 4 comment lines replacing 2. No assertion text changed, and no new assertion was added - the error-type claim in the review row is left to the owner because the conversion at the guarded-write call site is not measured here. eslint --prune-suppressions --max-warnings=0 exits 0 with src/eslint-suppressions.json restored byte-for-byte (the prune rewrote it against head with an identical parsed map), repo-root prettier --check exits 0, and tsc --noEmit from src reports only the pre-existing activate/registerCommands.ts TS2739 that main carries at 5319fe91f. --- .../tools/__tests__/writeToFileTool.spec.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index f961e257cf..0dd6a2b6c4 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -534,16 +534,20 @@ describe("writeToFileTool", () => { vi.mocked(realpath).mockRejectedValue({ code: "EPERM" }) mockedIsPathOutsideWorkspace.mockReturnValue(true) - await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) - - expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) - expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() - expect(mockCline.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + try { + await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) - // Reset both doubles: later tests in this file assume an in-workspace target that - // resolves to itself. - vi.mocked(realpath).mockImplementation(async (p) => String(p)) - mockedIsPathOutsideWorkspace.mockReturnValue(false) + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + } finally { + // Reset both doubles: later tests in this file assume an in-workspace target that + // resolves to itself. A reset after the assertions only runs when they pass, and + // beforeEach clears calls but not implementations, so a failing assertion here + // would leave realpath rejecting for every later test in the file. + vi.mocked(realpath).mockImplementation(async (p) => String(p)) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + } }) it("surfaces the unobserved-existing remediation as a tool error and publishes nothing", async () => { From 6222e2477cb0cb3d3c0a49b7cc617066076833e7 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:17:36 +0800 Subject: [PATCH 076/115] test(tools): pin the flag-off apply_patch move as the unguarded raw write The move branch publishes two ways: with preventFocusDisruption on it goes through the guarded saveDirectly with create kind (ApplyPatchTool.ts:543-:607), and with the flag off it does fs.mkdir then fs.writeFile (ApplyPatchTool.ts:610-:612). Nothing in the lane exercised the flag-off half. applyPatchTool.execute.spec.ts pins experiments: { preventFocusDisruption: true } in its beforeEach at :233, the two rows that turn the flag off (:853, :872) are both non-moves, and the fs/promises doubles at :30-:52 had no writeFile member even though that branch calls fs.writeFile, so the branch was unreachable in this harness as written. Two rows are added, with the writeFile double the mock factory was missing. They assert what the branch does today: fs.mkdir(path.dirname(destination), {recursive: true}) then fs.writeFile(destination, content, "utf8"), neither saveDirectly nor saveChanges reached, no access against the destination, no handleError. The comment above them states that they document current shipped behaviour - main's ApplyPatchTool (blob 2713e08ffe8d) writes the flag-off move with `await fs.writeFile(moveAbsolutePath, newContent, "utf8")` at :422 - and that they are not a claim that this is the contract the branch should keep. Routing the branch through the guarded publish would put the read-before-write and stale-version rules onto a population main writes unconditionally; that is the reason the behaviour is kept and pinned rather than changed, and it is the maintainer's call, not a verdict from this test. Negative control. With the guarded routing applied as a scratch patch - completeness carry hoisted, both shapes publishing through saveDirectly with create kind and only openFile differing - exactly these two rows redden and nothing else: the file is 2 failed | 27 passed (29) and the whole core/tools directory 2 failed | 837 passed | 5 skipped (844), red at spec.ts:916 (the mkdir expectation, because the routed shape does not mkdir) and spec.ts:947 (the access expectation, because the guard consults access). Against the shipped branch the file is 29 passed (29) and the directory is 33 files, 839 passed | 5 skipped (844). A future change that routes this branch through the guard is a deliberate improvement and should turn these rows red on purpose. +64/-0 in this one spec file - 41 executable lines and 23 comment lines, no production file touched. Measured with .tmp-node22 node v22.23.4 and vitest.core.config.ts, run from src. eslint --prune-suppressions --max-warnings=0 exits 0 with src/eslint-suppressions.json restored byte-for-byte, repo-root prettier --check exits 0, and tsc --noEmit from src reports only the pre-existing activate/registerCommands.ts TS2739 that main carries at 5319fe91f. --- .../__tests__/applyPatchTool.execute.spec.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index ff52ead16c..29cc2c7849 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -49,6 +49,9 @@ vi.mock("fs/promises", () => { realpath: vi.fn(async (p: string) => String(p)), // safeWriteText creates the backup directory before publishing. mkdir: vi.fn().mockResolvedValue(undefined), + // The flag-off move branch (ApplyPatchTool.ts:610-:612) publishes with a raw + // fs.writeFile, so the double set needs writeFile as well as mkdir. + writeFile: vi.fn().mockResolvedValue(undefined), } return { default: doubles, ...doubles } }) @@ -884,4 +887,65 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockHandleError).not.toHaveBeenCalled() }) + // The two rows below pin the flag-off move branch as it ships: a raw fs.mkdir plus + // fs.writeFile, with no guard, no completeness carry and no compare-and-swap. They + // document current behaviour - main's ApplyPatchTool (blob 2713e08ffe8d) writes the + // flag-off move with `await fs.writeFile(moveAbsolutePath, newContent, "utf8")` at + // :422 - and they are not a claim that this is the contract the branch should keep. + // Routing this branch through the guarded publish is a deliberate improvement and + // should turn these rows red; reading them as endorsement of the raw write would + // make that improvement look like a regression. + it("flag-off move: publishes through fs.mkdir then fs.writeFile, with no guard and no completeness carry", async () => { + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + // The cast above declares access/stat/unlink only because nothing else on the + // mocked module was reached before this branch was exercised; writeFile is read + // through its own narrow view of the doubles. + const writeDoubles = vi.mocked( + fsPromises as unknown as { + default: { writeFile: ReturnType; mkdir: ReturnType } + }, + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-plain-write", + }) + + expect(writeDoubles.default.mkdir).toHaveBeenCalledWith( + path.dirname(path.resolve("/workspace/project", "src/new.ts")), + { recursive: true }, + ) + expect(writeDoubles.default.writeFile).toHaveBeenCalledWith( + path.resolve("/workspace/project", "src/new.ts"), + "modified file content\n", + "utf8", + ) + // No guard runs here: the guarded publish lives behind saveDirectly, and this + // shape reaches neither the guarded save nor the diff-view save. + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockSaveChanges).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("flag-off move: an existing destination is never consulted before the write", async () => { + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-destination-present", + }) + + // The access double resolves, so the destination reads as present on disk, yet + // nothing asks: the branch does not read it, stat it, or guard against publishing + // over content the model never saw. + expect(mockedFsPromises.default.access).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalled() + }) }) From aef08edfefdd58b16a5d5adee02570fc9d35c925 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:38:24 +0800 Subject: [PATCH 077/115] test(utils): let the staging-directory leftover assertions see a staged file The "nothing left staged" assertions in these two files filter with name.startsWith(".new_"), and no staging file safeWriteJson produces can ever match that. createTempFilePath at safeWriteJson.ts:192-197 names the staged file ..new__.tmp - main carries the same template at its :54 - so a leftover beside settings.json is named .settings.json.new_<...>.tmp and begins with the target's own name. The prefix form matches only safeWriteText's own .new__.tmp names (safeWriteText.ts:92-94), which is a different producer that these two files never call. Seven sites filter this way: safeWriteJson.stagingDirectory.spec.ts:94, :95, :122, :213, :295 and :297, and safeWriteJson.lockKey.spec.ts:248. One defect across two files and seven sites, so all seven go in one commit with one explanation rather than being answered row by row. Matching the infix is not a new idiom either: safeWriteJson.interruption.test.ts:196 and :236 already filter these same files that way, so this is the suite agreeing with itself. Measured rather than read off the string. With the success path mutated so the commit copies instead of renaming - safeWriteJson.ts:402 becomes fs.copyFile, which leaves .settings.json.new_<...>.tmp on disk beside a target that has the right content - these two files are 13 passed (13) with the filters as they stand. That is the row's claim as a run: a staged leftover passes every one of the seven assertions. With the filters matching the infix, the same mutant gives 3 failed | 10 passed (13), red at spec.ts:104, :132 and :303 - the referent-side leftover assertions in "writes through an alias directory when the target does not exist yet", in "stages anyway when the guard's probe cannot answer", and in "follows the approved pathname when the directory is moved under it". Two of the seven sites are not exercised by that mutant: :213 here and :248 in the lock-key file sit on paths that refuse before staging, so nothing is left on them for any filter to find, and they are corrected for the same reason rather than proven by this run. With the filters fixed and production intact the two files are 13 passed (13). +27/-11 across the two spec files - 9 executable lines, 16 comment lines, 2 blank lines - no production file touched. A documented helper per file, with the seven filters replaced by it. The first version of this commit went out failing repo-root prettier --check on this file: with the shorter predicate, two expect() calls that had needed their three-line wrap no longer did, and prettier wanted them on one line. The rewrap is that fix and nothing else, and it is why the count against the parent reads +27/-11 rather than +27/-7. Measured with .tmp-node22 node v22.23.4 under vitest.unit.config.ts - these are utils/** specs, so that is the config whose include selects them - run from src. This tree already holds main 5319fe91fb0c as an ancestor, so the merged tree CI would typecheck for this branch is this tree, and tsc --noEmit in src produces the same single diagnostic with the parent's versions of these two files as with mine: activate/registerCommands.ts (88,102) TS2739, in a file this commit does not touch and which CI's own compile leg reports green, so it is a property of this machine's junction-resolved node_modules rather than of the pushed tree. eslint --prune-suppressions --max-warnings=0 on the two files exits 0 with src/eslint-suppressions.json restored byte-for-byte (blob f3714cd1f521 against f3714cd1f521), and repo-root prettier --check on both files exits 0. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 12 ++++++++- .../safeWriteJson.stagingDirectory.spec.ts | 26 ++++++++++++------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 2ef1eafeb8..7430330eca 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -59,6 +59,16 @@ const symlinkStat = (target: unknown) => }) as unknown as BigIntStats let currentLink = "" +/** + * safeWriteJson stages as ..new__.tmp - createTempFilePath at + * safeWriteJson.ts:192-197, the template main carries at its :54 - so a staged leftover never + * starts with ".new_": it starts with the target's own name. Matching the infix is what lets + * the "nothing left staged" assertions below see a leak at all; the startsWith form matches + * only safeWriteText's own ".new__.tmp" names (safeWriteText.ts:92-94), which this + * file never stages. safeWriteJson.interruption.test.ts:196 already filters by infix. + */ +const stagedFile = (name: string): boolean => name.includes(".new_") + describe("safeWriteJson lock key under a peer commit", () => { it("waits for the peer instead of rejecting, and locks the referent", async () => { const order: string[] = [] @@ -245,7 +255,7 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(released).toBe(true) // Nothing was staged: no target, and no .new_*.tmp left behind in the directory. await expect(fs.readFile(target, "utf8")).rejects.toThrow() - expect((await fs.readdir(dir)).filter((name) => name.startsWith(".new_"))).toEqual([]) + expect((await fs.readdir(dir)).filter(stagedFile)).toEqual([]) consoleError.mockRestore() }) diff --git a/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts index 85258ac5c3..3687dd48c0 100644 --- a/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts +++ b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts @@ -34,6 +34,16 @@ import * as fs from "fs/promises" * lexical path for a target that does not exist yet, and realpath reports the long name for that * very same directory. */ +/** + * safeWriteJson stages as ..new__.tmp - createTempFilePath at + * safeWriteJson.ts:192-197, the template main carries at its :54 - so a staged leftover never + * starts with ".new_": it starts with the target's own name. Matching the infix is what lets + * the "nothing left staged" assertions below see a leak at all; the startsWith form matches + * only safeWriteText's own ".new__.tmp" names (safeWriteText.ts:92-94), which this + * file never stages. safeWriteJson.interruption.test.ts:196 already filters by infix. + */ +const stagedFile = (name: string): boolean => name.includes(".new_") + describe("safeWriteJson staging directory drift", () => { let tmpRoot = "" @@ -91,8 +101,8 @@ describe("safeWriteJson staging directory drift", () => { const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") expect(JSON.parse(written)).toEqual({ preserved: true }) // Nothing may be left staged on either spelling of the same directory. - expect((await fs.readdir(referent)).filter((name) => name.startsWith(".new_"))).toEqual([]) - expect((await fs.readdir(alias)).filter((name) => name.startsWith(".new_"))).toEqual([]) + expect((await fs.readdir(referent)).filter(stagedFile)).toEqual([]) + expect((await fs.readdir(alias)).filter(stagedFile)).toEqual([]) }) it("stages anyway when the guard's probe cannot answer", async () => { @@ -119,7 +129,7 @@ describe("safeWriteJson staging directory drift", () => { expect(acted()).toBe(true) // the guard really asked, and really was not answered const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") expect(JSON.parse(written)).toEqual({ preserved: true }) - expect((await fs.readdir(referent)).filter((name) => name.startsWith(".new_"))).toEqual([]) + expect((await fs.readdir(referent)).filter(stagedFile)).toEqual([]) }) it("stages anyway under the task persistence suite's blanket missing stat", async () => { @@ -209,9 +219,7 @@ describe("safeWriteJson staging directory drift", () => { // target. (Its advisory lock directory is still there: the lock lives beside the target, and the // plant moved the target out from under the held lock, which is the state this case creates.) expect(await fs.readdir(elsewhere)).toEqual([]) - expect( - (await fs.readdir(moved)).filter((name) => name === "settings.json" || name.startsWith(".new_")), - ).toEqual([]) + expect((await fs.readdir(moved)).filter((name) => name === "settings.json" || stagedFile(name))).toEqual([]) }) it("fails as a failed write when the approved directory is really gone", async () => { @@ -291,9 +299,7 @@ describe("safeWriteJson staging directory drift", () => { expect(JSON.parse(written)).toEqual({ preserved: true }) // Nothing stranded: the renamed directory holds only the advisory lock directory that lives // beside the target, and neither spelling of it nor the new location holds a staged file. - expect( - (await fs.readdir(moved)).filter((name) => name === "settings.json" || name.startsWith(".new_")), - ).toEqual([]) - expect((await fs.readdir(elsewhere)).filter((name) => name.startsWith(".new_"))).toEqual([]) + expect((await fs.readdir(moved)).filter((name) => name === "settings.json" || stagedFile(name))).toEqual([]) + expect((await fs.readdir(elsewhere)).filter(stagedFile)).toEqual([]) }) }) From ceef734364ee43731d1852e62f85f25a98ea3548 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:42:22 +0800 Subject: [PATCH 078/115] test(tools): exercise observation-registry isolation through ReadFileTool rather than beside it The row this adds sits next to the existing "two separate Task-owned registries are independent" at readFileTool.spec.ts:2016-2025, and it is not that row's duplicate. The existing one builds two ObservationRegistry instances and calls observe and get on them; ReadFileTool is never invoked, so it proves the container keeps two entries apart and cannot notice a read that reported its observation into another task's registry. This row calls readFileTool.execute twice at one path, once per task, and asserts the observation lands in the registry of the task that made the call. Shape. Each task gets its own registry through createMockTask, the same path is read twice, and the second read runs under a different stat mock so its version token is distinguishable from the first. Assertions are on entries and not only call counts: task A's read calls observe on A's registry exactly once and on B's not at all (:2059-:2064), A holds the observation its own read wrote and B holds none at that key (:2065-:2067), and after B reads the same path B holds its own observation at the same key while A still holds the first one (:2086-:2095). The two production sites it covers are ReadFileTool.ts:243 and :891, which are both task.observationRegistry.observe(fullPath, preReadToken, ...). Control, run on the new row alone. A registry that is picked once per process - (_foreignRegistry ??= task.observationRegistry) at both sites, so the first task's registry serves every later read - puts task B's observation into task A's registry, which is the exact defect the review row names. With that mutant in place the row is 1 failed | 110 skipped, red at spec.ts:2086 with 'expected "observe" to be called 1 times, but got 0 times': B's own registry received nothing. Against shipped production the same filtered run is 1 passed, and the whole file with the row in it is 111 passed (111). The wider unfiltered run under the same mutant reddens 16 of the file's existing observation rows too, which says the file is not blind to a tool that stops writing to its caller's registry - what it had no coverage for is two live tasks in one read scenario, and that is the half this row adds. +69 lines in this one spec file - 52 executable, 11 comment, 6 blank - and no production file touched, so mutation-diff's counted surface grows by test lines only, which is disclosed rather than trimmed. Run with .tmp-node22 node v22.23.4 under vitest.core.config.ts from src. eslint --prune-suppressions --max-warnings=0 on the file exits 0 with src/eslint-suppressions.json restored byte-for-byte (blob f3714cd1f521 against f3714cd1f521) and repo-root prettier --check exits 0. tsc --noEmit in src exits 2 with one diagnostic, activate/registerCommands.ts(88,102) TS2739, and that is the identical error set the same command produced in the r260 round with this file at its head version, so the new row adds none; CI's compile leg reports that file green, so it is this machine's junction-resolved node_modules rather than the pushed tree. --- src/core/tools/__tests__/readFileTool.spec.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 43cc88f337..f763abe397 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -2023,6 +2023,75 @@ describe("ReadFileTool", () => { expect(regA.get("/shared.ts")!.version).toBe("v1") expect(regB.get("/shared.ts")!.version).toBe("v2") }) + it("reads from the same path land in each task's own registry, not the other task's", async () => { + // The row above proves two ObservationRegistry instances hold separate entries; it never calls the + // tool, so a read that reported its observation into another task's registry would still pass every + // line of it. This row drives ReadFileTool.execute twice, once per task, at one path, and asserts the + // observation lands in the registry of the task that made the call. + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + const taskA = createMockTask({ observationRegistry: regA }) + const taskB = createMockTask({ observationRegistry: regB }) + const callbacksA = createMockCallbacks() + const callbacksB = createMockCallbacks() + const observeA = vi.spyOn(regA, "observe") + const observeB = vi.spyOn(regB, "observe") + + const statForFirstRead = { + 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 + mockedFsStat.mockResolvedValue(statForFirstRead) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("isolated\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | isolated", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "isolated.ts" }, taskA as unknown as Task, callbacksA) + + // A's registry received the observation and B's was not reached at all. + expect(observeA).toHaveBeenCalledTimes(1) + expect(observeB).not.toHaveBeenCalled() + const [keyA, versionA] = observeA.mock.calls[0] + expect(regA.get(keyA)!.version).toBe(versionA) + expect(regB.get(keyA)).toBeUndefined() + + // The same path read by B under a different stat, so B's version token is distinguishable from + // A's. Both halves are pinned to entries rather than to call counts: B holds its own observation + // at the same key and A still holds the one its own read wrote. + const statForSecondRead = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(7), + size: BigInt(300), + mtimeNs: BigInt(9_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 + mockedFsStat.mockResolvedValue(statForSecondRead) + + await readFileTool.execute({ path: "isolated.ts" }, taskB as unknown as Task, callbacksB) + + expect(observeB).toHaveBeenCalledTimes(1) + const [keyB, versionB] = observeB.mock.calls[0] + expect(keyB).toBe(keyA) + expect(versionB).not.toBe(versionA) + expect(regB.get(keyB)!.version).toBe(versionB) + expect(regA.get(keyA)!.version).toBe(versionA) + expect(regA.size).toBe(1) + expect(regB.size).toBe(1) + }) }) describe("read completeness scope (S4b follow-up #46)", () => { From f06c6d90dcde75223bb2b29006ecc5ba59407077 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:47:54 +0800 Subject: [PATCH 079/115] fix(file-safety): compare staging containment by directory identity, not by spelling The caller-supplied staging guard compared the staging file's directory with the target's directory as two strings. A directory alias makes those strings differ for one directory: canonicalDirKey canonicalizes through fs.realpath, so a write reached through an alias root resolves dirPath in the referent's spelling while the caller still holds its own, and the compare refused a staging file that was physically inside the target's directory. The guard now stats both directories (bigint, for the same reason the two lstats beside it do: on NTFS/ReFS a file identity exceeds Number.MAX_SAFE_INTEGER and a rounded number makes two directories look alike or two alike directories look different) and accepts when dev and ino agree. The spelling compare stays as the fast path in front of it and as the answer when a stat cannot be made: only ENOENT means "not there", and any other stat failure is reported as its own refusal rather than read as containment, so the guard fails closed. Measured reproduction, on a junction alias: fs.stat on the alias and the referent returned the same identity (dev 673102249, ino 3940649686774504); resolvePublishTarget returned the referent spelling; safeWriteText with tempPath spelled through the alias threw StagingPathError naming the referent directory while the staged file sat inside it and the target kept its old content; the same write with the tempPath spelled through the referent completed. The verdict moved with the spelling the caller happened to hold, for one physical situation. Scope, measured rather than assumed: no production caller passes tempPath today. safeWriteText is called from guardedWrite.ts:207 and :290 with no options, and safeWriteJson.ts does not call it at all - it imports only resolveLockKey and resolvePublishTarget (safeWriteJson.ts:8) and stages through its own createTempFilePath (:344, :415) with its own rename (:402). So this is not a user-visible failure being fixed for an existing caller; it is a guard whose verdict was spelling-dependent being made identity-dependent, with the divergence proven inside the function by the reproduction above. Tests: safeWriteText.stagingAlias.spec.ts runs the real filesystem and creates the alias with whatever primitive the platform provides - a junction on win32 (Node creates it without developer-mode privileges) and a symlinked directory on POSIX - so both hosted lanes execute the same divergence rather than the one platform whose spelling convention produced it. It asserts the alias spelling is published, and, in the other direction, that a staging file in a genuinely different directory is still refused with the target untouched - containment was relaxed for aliases, not for distance. safeWriteText.spec.ts carries the mocked mirror: the alias-acceptance row asserts both directories are read with { bigint: true }, and the stat-failure row asserts an EACCES is reported instead of being decided from. Controls on the final content: with the compare reverted to strings the acceptance rows go red (safeWriteText.stagingAlias.spec.ts:69, safeWriteText.spec.ts:1381); with containment dropped the refusal rows go red (safeWriteText.stagingAlias.spec.ts:90 and :114, safeWriteText.spec.ts: 1404); with the shipped guard both files pass (73 tests). Note on tooling: the fs/promises mock in safeWriteText.spec.ts gains a stat stub whose default reports ENOENT, which is what those mocked paths already meant for these rows. No changeset and no CHANGELOG entry, per AGENTS.md. --- .../__tests__/safeWriteText.spec.ts | 41 ++++++ .../safeWriteText.stagingAlias.spec.ts | 123 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 56 +++++++- 3 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 335fef875f..169230d033 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -18,6 +18,10 @@ vi.mock("fs/promises", () => ({ realpath: vi.fn(), lstat: vi.fn(), readlink: vi.fn(), + // Production reads the two directories through fs.stat (bigint) when the caller's directory spelling + // differs from the target's, so the mock has to carry the method. The default reports "not there", which + // is what a mocked path outside the target's directory already meant for these rows. + stat: vi.fn(), })) // Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare @@ -85,6 +89,7 @@ function mockDefaults(): void { // Staged-file default: a regular file, not a link, so a caller-supplied // tempPath passes the location and file-type check by default. vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) + vi.mocked(fs.stat).mockRejectedValue(Object.assign(new Error("no such directory"), { code: "ENOENT" })) } function _stats(mode: number): fsSync.Stats { const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats @@ -1364,6 +1369,42 @@ describe("caller-supplied staging path", () => { expect(fs.rename).not.toHaveBeenCalled() }) + it("accepts a staging file spelled through an alias of the target's directory", async () => { + const aliasTarget = "/srv/alias/target.txt" + // An alias root: canonicalDirKey canonicalizes through realpath, so the write resolves to + // the referent while the caller still holds the alias spelling of the same directory. + vi.mocked(fs.realpath).mockResolvedValue("/srv/referent/target.txt") + vi.mocked(fsSync.openSync).mockReturnValue(1) + const directoryStats = _fileStatsWithIdentity(1234n, 9n) + vi.mocked(fs.stat).mockResolvedValue(directoryStats) + + await safeWriteText(aliasTarget, "data", { tempPath: "/srv/alias/x.tmp", platform: "linux" }) + + // The staging file was published, not refused: containment is about which directory it + // sits in, and these two spellings name one directory. + expect(fs.rename).toHaveBeenCalledWith("/srv/alias/x.tmp", "/srv/referent/target.txt") + // Both directories are read by identity, bigint for the same reason as the file stats. + const directoryLookups = vi.mocked(fs.stat).mock.calls.filter((c) => c[1] !== undefined) + expect(directoryLookups.length).toBe(2) + for (const c of directoryLookups) { + expect(c[1]).toEqual({ bigint: true }) + } + }) + + it("rejects when a staging directory stat fails for a reason other than it being absent", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // The spelling comparison is the fallback for a directory that is not there. An EACCES says + // nothing about containment, so it is reported instead of being read as a match or a + // mismatch: a guard that decides from an error it could not interpret has stopped guarding. + vi.mocked(fs.stat).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/other-dir/x.tmp", platform: "linux" }), + ).rejects.toThrow(/could not be compared/) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) it("creates no directory when a supplied staging path is rejected", async () => { const targetPath = "/tmp/test-dir/nested-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts b/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts new file mode 100644 index 0000000000..51f21f6c8a --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts @@ -0,0 +1,123 @@ +import * as fsSync from "fs" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { safeWriteText, StagingPathError } from "../safeWriteText" + +// Regression coverage for the caller-supplied staging guard (row 4507642578). The guard used to +// compare the supplied staging file's directory with the target's directory as two strings, and a +// directory alias makes those two strings differ for one directory: canonicalDirKey canonicalizes +// through fs.realpath, so a write whose target is reached via an alias root gets dirPath in the +// referent's spelling while the caller still holds its own, and the string compare refused a +// staging file that was physically inside the target's directory. + +// The alias is created with whatever primitive the platform provides, so the hosted Windows lane +// and the hosted Linux lane both run this file: a junction on win32 (a reparse point that Node can +// create without developer-mode privileges), a symlinked directory on POSIX. Both produce the same +// divergence, because realpath resolves either one. +const ALIAS_TYPE: "junction" | "dir" = process.platform === "win32" ? "junction" : "dir" + +function createAlias(referent: string, alias: string): void { + fsSync.symlinkSync(referent, alias, ALIAS_TYPE) +} + +// Removing the alias must not follow it: rmdir drops a junction without touching what it points at, +// and unlink drops a symlink. +function removeAlias(alias: string): void { + if (process.platform === "win32") { + fsSync.rmdirSync(alias) + } else { + fsSync.unlinkSync(alias) + } +} + +interface AliasFixture { + root: string + referent: string + alias: string + target: string +} + +function makeFixture(initialContent: string): AliasFixture { + const root = fsSync.mkdtempSync(path.join(os.tmpdir(), "fws-alias-")) + const referent = path.join(root, "referent") + const alias = path.join(root, "alias") + fsSync.mkdirSync(referent) + fsSync.writeFileSync(path.join(referent, "settings.json"), initialContent, "utf8") + createAlias(referent, alias) + return { root, referent, alias, target: path.join(alias, "settings.json") } +} + +function disposeFixture(fixture: AliasFixture): void { + try { + removeAlias(fixture.alias) + } catch { + // The alias is already gone; nothing to release. + } + fsSync.rmSync(fixture.root, { recursive: true, force: true }) +} + +describe("caller-supplied staging path through a directory alias (" + ALIAS_TYPE + ")", () => { + it("accepts a staging file spelled through the alias of the target's directory", async () => { + const fixture = makeFixture("initial") + try { + // The staging file sits in the alias spelling of the directory the write resolves to the + // referent of. Identity says one directory, and the guard has to agree. + const stagingPath = path.join(fixture.alias, ".staged.json") + await fs.writeFile(stagingPath, "aliased staging", "utf8") + await safeWriteText(fixture.target, "aliased staging", { tempPath: stagingPath }) + const published = await fs.readFile(path.join(fixture.referent, "settings.json"), "utf8") + expect(published).toBe("aliased staging") + // The commit consumed the staging file: it is the target now, wherever it is spelled. + const stagingRemains = await fs + .stat(stagingPath) + .then(() => true) + .catch(() => false) + expect(stagingRemains).toBe(false) + } finally { + disposeFixture(fixture) + } + }, 120000) + + it("still refuses a staging file that sits in a different directory", async () => { + const fixture = makeFixture("initial") + try { + const elsewhere = path.join(fixture.root, "elsewhere") + fsSync.mkdirSync(elsewhere) + const stagingPath = path.join(elsewhere, "unrelated.json") + await fs.writeFile(stagingPath, "unrelated payload", "utf8") + await expect(safeWriteText(fixture.target, "new content", { tempPath: stagingPath })).rejects.toThrow( + StagingPathError, + ) + // The guard still guards: containment was relaxed for aliases, not for distance, and an + // unrelated file must not be published onto the target. + const targetContent = await fs.readFile(path.join(fixture.referent, "settings.json"), "utf8") + expect(targetContent).toBe("initial") + const unrelatedContent = await fs.readFile(stagingPath, "utf8") + expect(unrelatedContent).toBe("unrelated payload") + } finally { + disposeFixture(fixture) + } + }, 120000) + + it("names the target's directory when it refuses", async () => { + const fixture = makeFixture("initial") + try { + const elsewhere = path.join(fixture.root, "elsewhere") + fsSync.mkdirSync(elsewhere) + const stagingPath = path.join(elsewhere, "unrelated.json") + await fs.writeFile(stagingPath, "unrelated payload", "utf8") + const failure = await safeWriteText(fixture.target, "new content", { + tempPath: stagingPath, + }).catch((error: unknown) => error as StagingPathError) + expect(failure).toBeInstanceOf(StagingPathError) + // The message quotes the referent spelling, which is the directory the write resolves to - + // the reason the old compare read an alias as containment failure. + expect(String(failure.message)).toContain(fixture.referent) + expect(failure.stagingPath).toBe(path.resolve(stagingPath)) + } finally { + disposeFixture(fixture) + } + }, 120000) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 1eeea90704..dbbfbbf805 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -199,6 +199,31 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise { + try { + return await fs.stat(directoryPath, { bigint: true }) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + return undefined + } + throw new StagingPathError( + `Staging directory could not be compared with the target's directory (${directoryPath})`, + supplied, + ) + } +} + /** * Distinguish "the target does not exist" from a real I/O failure (EACCES, * EIO, ...). The mode-preservation path may only fall back to the fresh-file @@ -304,11 +329,32 @@ export async function safeWriteText( // link points at, which is the same trust problem as writing through a // dangling symlink in resolvePublishTarget. const supplied = path.resolve(options.tempPath) - if (path.dirname(supplied) !== path.resolve(dirPath)) { - throw new StagingPathError( - `Staging file must sit in the target's directory (${dirPath}), got ${supplied}`, - supplied, - ) + const suppliedDir = path.dirname(supplied) + const targetDir = path.resolve(dirPath) + // Containment is a claim about directories, not about strings. An alias root - a junction on + // Windows, a symlinked directory on POSIX - makes canonicalDirKey hand dirPath in the referent's + // spelling while the caller still holds its own, and a string compare of the two then refuses a + // staging file that sits physically inside the target's directory. Compare the directories by + // identity, through fs.stat with bigint: true for the same reason the two lstats below use it: + // on NTFS and ReFS a file identity can exceed Number.MAX_SAFE_INTEGER, and a rounded number makes + // two directories look different or two different directories look alike. The spelling compare is + // kept as the fast path in front of it and as the answer when a stat cannot be made; a stat that + // fails for any reason other than the directory not being there propagates, because a guard that + // reads "could not tell" as "contained" has stopped guarding. + if (suppliedDir !== targetDir) { + const suppliedDirStat = await _statDirectoryForContainment(suppliedDir, supplied) + const targetDirStat = await _statDirectoryForContainment(targetDir, supplied) + const sameDirectory = + suppliedDirStat !== undefined && + targetDirStat !== undefined && + suppliedDirStat.dev === targetDirStat.dev && + suppliedDirStat.ino === targetDirStat.ino + if (!sameDirectory) { + throw new StagingPathError( + `Staging file must sit in the target's directory (${dirPath}), got ${supplied}`, + supplied, + ) + } } // BigInt stats: on NTFS/ReFS the file identity can exceed Number.MAX_SAFE_INTEGER, and // a rounded number makes two different files look identical (rejecting a valid staging From e5b4795c956fddacb16c2b6e01ea6ae9a90d21f1 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:50:12 +0800 Subject: [PATCH 080/115] fix(utils): create the JSON staging file with the publish target's mode The commit rename publishes the staging inode, so the mode the staging file is created with becomes the target's mode: on POSIX a rename does not carry the replaced file's permissions over. _streamDataToFile opened the staging file with fsSync.createWriteStream and no mode, which is the process default (0o666 & ~umask, normally 0o644), so updating a 0o600 JSON file through this path published it world-readable. The write now reads the publish target's mode through _readPublishTargetMode and hands it to the stream writer, so the inode that lands is created no more openly than the file it replaces. A target that is not there yet has no mode to carry over and the creation default stands; a stat failure that is not ENOENT is propagated, because guessing the mode of a private file is not this write's decision. Windows note: the creation mode there carries only the read-only bit, and per-user rights come from directory inheritance rather than from this call, so the mode assertions that compare a published file's mode are made on the POSIX lanes (it.runIf) while the assertion that the staging file is created to match the target's own reported mode runs everywhere, on both hosted runners. Row 4507642592 asked for the staging file to be created restrictively and for the target's mode to be preserved on publish. The two new rows in safeWriteJson.test.ts record what production passes to createWriteStream at the moment it passes it - Node's WriteStream fills a missing mode in on the options object it is handed, so the call log read afterwards is a normalized object and would have shown 0o666 for a call that passed nothing. The control: with the mode argument dropped from the call the row goes red with 'expected -1 to be 438', which is the row's own complaint, the staging file being created with no mode at all. One test-only repair rode along, and it is not incidental: safeWriteJson.stagingDirectory.spec.ts 'stages anyway under the task persistence suite's blanket missing stat' counted every fs.stat call and asserted zero, with a comment saying the count was about the approved directory. The mode read is now a legitimate call, and it names the publish target rather than a directory, so the row counts directory questions and asserts the single publish-target call. Its protective claim - an ordinary write under a blanket missing-stat double must not be rejected - is carried by the same row, and it still passes with the mode read present, because ENOENT means 'no mode to carry over' and not 'refuse the write'. No changeset and no CHANGELOG entry, per AGENTS.md. --- .../safeWriteJson.stagingDirectory.spec.ts | 16 +++- src/utils/__tests__/safeWriteJson.test.ts | 73 +++++++++++++++++++ src/utils/safeWriteJson.ts | 41 +++++++++-- 3 files changed, 120 insertions(+), 10 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts index 3687dd48c0..b45c3821d6 100644 --- a/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts +++ b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts @@ -140,13 +140,14 @@ describe("safeWriteJson staging directory drift", () => { // resolutions returned it and the unmocked fs.statSync reported the same inode. Measured on // the old guard, exactly one fs.stat call was enough to reject that write as drift. Here the // same blanket double sits over a write whose directory really is there: the guard must not - // consult it at all, and this case reddens if it ever starts to. + // consult it about the directory, and this case reddens if it ever starts to; the one stat call + // the write is allowed to make is its own publish-target mode read, counted below. const referent = path.join(tmpRoot, "referent-dir") await fs.mkdir(referent, { recursive: true }) const alias = path.join(tmpRoot, "alias-dir") await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") const target = path.join(alias, "settings.json") - let statCalls = 0 + let statPaths: string[] = [] const statSpy = vi.spyOn(fs, "stat").mockRejectedValue({ code: "ENOENT" }) let failure: unknown try { @@ -154,11 +155,18 @@ describe("safeWriteJson staging directory drift", () => { } catch (error) { failure = error } finally { - statCalls = statSpy.mock.calls.length + statPaths = statSpy.mock.calls.map((call) => String(call[0])) statSpy.mockRestore() } expect(failure).toBeUndefined() - expect(statCalls).toBe(0) // nothing on this path asks fs.stat about the approved directory + // Nothing asked fs.stat about the approved directory, which is the question this row pins. The + // one call that was made named the publish target: it is the mode read (_readPublishTargetMode + // in safeWriteJson.ts) that row 4507642592 added, and the same blanket double answers it here + // with "not there", which the mode read reads as "no mode to carry over" rather than as a + // reason to refuse the write. + const directoryQuestions = statPaths.filter((p) => path.basename(p) !== "settings.json") + expect(directoryQuestions.length).toBe(0) + expect(statPaths.length).toBe(1) const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") expect(JSON.parse(written)).toEqual({ preserved: true }) }) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 6f51068f2e..4f7f06207b 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -14,6 +14,12 @@ const fsPromisesActuals = vi.hoisted(() => ({ writeFile: undefined as (typeof import("fs/promises"))["writeFile"] | undefined, })) +// The fs mock delegates createWriteStream to the implementation captured here, so a test can +// replace the mock for one call and still let the write reach the real filesystem. +const fsSyncActuals = vi.hoisted(() => ({ + createWriteStream: undefined as (typeof import("fs"))["createWriteStream"] | undefined, +})) + vi.mock("fs/promises", async () => { const actual = await vi.importActual("fs/promises") fsPromisesActuals.rename = actual.rename @@ -42,6 +48,7 @@ vi.mock("fs/promises", async () => { // Mock the 'fs' module for fsSync.createWriteStream vi.mock("fs", async () => { const actualFs = await vi.importActual("fs") + fsSyncActuals.createWriteStream = actualFs.createWriteStream return { ...actualFs, // Spread actual implementations createWriteStream: vi.fn(actualFs.createWriteStream), // Default to actual, but mockable @@ -140,6 +147,72 @@ describe("safeWriteJson", () => { const content = await readFileContent(currentTestFilePath) expect(content).toEqual(newData) }) + // Row 4507642592: the commit rename publishes the staging inode, so the mode the staging file is + // created with becomes the target's mode. These rows pin that the staging file is created as + // open or as private as the file it replaces, instead of with the process default. + describe("staging file mode", () => { + // Node's WriteStream fills a missing mode in on the options object it is handed, so reading the + // call log afterwards shows a normalized object rather than what was passed. Record the mode at + // the moment production passes it, then hand the call to the real stream writer so the write + // still lands on disk. -1 in the recording stands for "no mode was passed". + function captureStagingModes(): number[] { + const modes: number[] = [] + const realCreateWriteStream = fsSyncActuals.createWriteStream! + vi.mocked(fsSyncActual.createWriteStream).mockImplementation(function ( + filePath: string | number | URL, + options?: string | Record | null, + ) { + const passed: unknown = options + const mode: unknown = + passed !== null && typeof passed === "object" ? (passed as { mode?: unknown }).mode : undefined + modes.push(mode === undefined ? -1 : Number(mode)) + const streamOptions: Parameters[1] = options as Parameters< + typeof realCreateWriteStream + >[1] + return realCreateWriteStream(filePath, streamOptions) + } as typeof fsSyncActual.createWriteStream) + return modes + } + + it("creates the staging stream with the publish target's mode", async () => { + await fs.chmod(currentTestFilePath, 0o600) + // Read back the mode the filesystem reports for the target instead of asserting a literal: + // Windows carries per-user rights in the DACL and reports a coarse mode, and the claim under + // test is that the staging file is created to match the target, on every platform. + const targetMode = (await fs.stat(currentTestFilePath)).mode & 0o777 + const modes = captureStagingModes() + + await safeWriteJson(currentTestFilePath, { updated: "content" }) + + expect(modes.length).toBe(1) + expect(modes[0]).toBe(targetMode) + expect(await readFileContent(currentTestFilePath)).toEqual({ updated: "content" }) + }) + + it("creates the staging stream with the creation default when the target does not exist yet", async () => { + const missingTarget = path.join(tempDir, "not-written-yet.json") + const modes = captureStagingModes() + + await safeWriteJson(missingTarget, { created: "content" }) + + // Nothing is being replaced, so there is no mode to carry over, and a mode invented here + // would be this write deciding how private a file it has not created yet should be. + expect(modes.length).toBe(1) + expect(modes[0]).toBe(-1) + expect(await fileExists(missingTarget)).toBe(true) + }) + + // The end-to-end mode assertion is made where modes mean what the assertion says they mean. + it.runIf(process.platform !== "win32")("keeps a 0o600 file private through an update", async () => { + await fs.chmod(currentTestFilePath, 0o600) + + await safeWriteJson(currentTestFilePath, { updated: "content" }) + + const published = await fs.stat(currentTestFilePath) + expect(published.mode & 0o777).toBe(0o600) + expect(await readFileContent(currentTestFilePath)).toEqual({ updated: "content" }) + }) + }) // Failure Scenarios test("should handle failure when writing to tempNewFilePath", async () => { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index ca69f69588..f2a6d1cabe 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -342,7 +342,15 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit // making a best-effort flush before committing the replacement. options?.trace?.({ stage: "write-start" }) actualTempNewFilePath = createTempFilePath(absoluteFilePath, "new") - + // The commit rename publishes the staging inode, so the file that lands at the target carries + // the staging file's mode rather than the target's: on POSIX a rename does not carry the + // replaced file's permissions over. A staging file created with the process default (0o666 & + // ~umask, normally 0o644) therefore turns an update of a 0o600 file into a world-readable one + // sitting in a traversable directory. Read the publish target's mode before staging and create + // the staging file with it. A target that is not there yet has no mode to carry over, and the + // creation default stands; any other stat failure is propagated, because the mode a private + // file is published with is not something this write gets to guess. + const publishTargetMode = await _readPublishTargetMode(resolvedTargetPath ?? absoluteFilePath) // The staging location was chosen from the parent directory resolved under the lock. Re-verify // that directory against itself before writing anything into it: a resolved pathname is not an // identity, and a parent that was replaced with a link pointing elsewhere in the meantime would @@ -390,7 +398,7 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit ) } - await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) + await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint, publishTargetMode) await _flushFileToDisk(actualTempNewFilePath) // Step 2: Commit the replacement with a single atomic rename. The @@ -488,6 +496,25 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit } } +/** + * The mode the staging file has to be created with, taken from the file it is about to replace. + * undefined means there is no such file yet and the creation default stands; a failure that is + * not "not there" is propagated, because guessing the mode of a private file is how a 0o600 + * settings file becomes 0o644 without anyone deciding it. + */ +async function _readPublishTargetMode(publishTargetPath: string): Promise { + let mode: number + try { + mode = (await fs.stat(publishTargetPath)).mode + } catch (error: unknown) { + if (getErrorCode(error) === "ENOENT") { + return undefined + } + throw error + } + return mode & 0o777 +} + /** * Helper function to stream JSON data to a file. * @param targetPath The path to write the stream to. @@ -495,10 +522,12 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit * @param prettyPrint Whether to format the JSON with indentation. * @returns Promise */ -async function _streamDataToFile(targetPath: string, data: unknown, prettyPrint = false): Promise { - // Stream data to avoid high memory usage for large JSON objects. - const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8" }) - +async function _streamDataToFile(targetPath: string, data: unknown, prettyPrint = false, mode?: number): Promise { + // Stream data to avoid high memory usage for large JSON objects. The mode is the publish + // target's own mode, so the inode that replaces it is not created more openly than the file it + // replaces; on Windows the creation mode carries only the read-only bit and per-user rights come + // from directory inheritance, which is why the end-to-end mode assertion is POSIX-shaped. + const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8", mode }) // JsonStreamStringify traverses the object and streams tokens directly // The 'spaces' parameter adds indentation during streaming, not via a separate pass // Convert undefined to null for valid JSON serialization (undefined is not valid JSON) From 273dd7b321ce467090cde3aafcfcf3e3ccfa36e1 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:51:33 +0800 Subject: [PATCH 081/115] test(file-safety,utils): keep the new staging rows typeclean tsc --noEmit in src reported three diagnostics the two new rows introduced and CI would have run them: the alias spec's catch arm typed the collected value as void | StagingPathError, so reading .message and .stagingPath after asserting instanceof was unsound for the compiler, and the JSON spec's capture callback declared a filePath parameter narrower than createWriteStream's PathLike. The alias row now collects the error in a try/catch, which is what the row actually claims, and the capture callback lets vi.mocked supply the parameter types instead of restating them and handing a narrower value to the real writer. After both edits tsc --noEmit in src reports exactly one diagnostic, activate/registerCommands.ts (88,102) TS2739, which is the junction artifact this worktree carries and which the same command reported before these commits; the error set is otherwise identical, so the commits add no diagnostics. Controls were re-run against the final content rather than quoted from the pre-edit tree: with the containment compare reverted to strings the alias-acceptance rows go red (safeWriteText.stagingAlias.spec.ts:69, safeWriteText.spec.ts:1381); with containment dropped the refusal rows go red (safeWriteText.stagingAlias.spec.ts:90 and :119); with the mode argument dropped from the staging call the JSON row goes red with 'expected -1 to be 438'. Intact: 73 tests across the two file-safety specs, 48 passed and 4 skipped in safeWriteJson.test.ts, 25 passed across the stagingDirectory, lockKey and interruption specs. No changeset and no CHANGELOG entry, per AGENTS.md. --- .../__tests__/safeWriteText.stagingAlias.spec.ts | 15 ++++++++++----- src/utils/__tests__/safeWriteJson.test.ts | 12 +++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts b/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts index 51f21f6c8a..24cbcd5015 100644 --- a/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts @@ -108,14 +108,19 @@ describe("caller-supplied staging path through a directory alias (" + ALIAS_TYPE fsSync.mkdirSync(elsewhere) const stagingPath = path.join(elsewhere, "unrelated.json") await fs.writeFile(stagingPath, "unrelated payload", "utf8") - const failure = await safeWriteText(fixture.target, "new content", { - tempPath: stagingPath, - }).catch((error: unknown) => error as StagingPathError) + // A resolved write is the failure this row is about, so the error is collected rather than + // awaited into a union of "nothing" and "the guard's error". + let failure: StagingPathError | undefined + try { + await safeWriteText(fixture.target, "new content", { tempPath: stagingPath }) + } catch (error: unknown) { + failure = error as StagingPathError + } expect(failure).toBeInstanceOf(StagingPathError) // The message quotes the referent spelling, which is the directory the write resolves to - // the reason the old compare read an alias as containment failure. - expect(String(failure.message)).toContain(fixture.referent) - expect(failure.stagingPath).toBe(path.resolve(stagingPath)) + expect(String(failure?.message)).toContain(fixture.referent) + expect(failure?.stagingPath).toBe(path.resolve(stagingPath)) } finally { disposeFixture(fixture) } diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 4f7f06207b..34523d604d 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -158,19 +158,13 @@ describe("safeWriteJson", () => { function captureStagingModes(): number[] { const modes: number[] = [] const realCreateWriteStream = fsSyncActuals.createWriteStream! - vi.mocked(fsSyncActual.createWriteStream).mockImplementation(function ( - filePath: string | number | URL, - options?: string | Record | null, - ) { + vi.mocked(fsSyncActual.createWriteStream).mockImplementation(function (filePath, options) { const passed: unknown = options const mode: unknown = passed !== null && typeof passed === "object" ? (passed as { mode?: unknown }).mode : undefined modes.push(mode === undefined ? -1 : Number(mode)) - const streamOptions: Parameters[1] = options as Parameters< - typeof realCreateWriteStream - >[1] - return realCreateWriteStream(filePath, streamOptions) - } as typeof fsSyncActual.createWriteStream) + return realCreateWriteStream(filePath, options) + }) return modes } From 2b38c338dd11ca0d4c418debe65625a39f81490b Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:07:14 +0800 Subject: [PATCH 082/115] fix(safeWriteJson): treat an empty confineTo as a declared scope (#1918 review 4507642597) The row is true about lines :233, :266 and :318 of src/utils/safeWriteJson.ts: all three confinement guards tested `if (options?.confineTo)`. An empty _resolveScopeRoot(:127) runs the option through path.resolve, so `""` is a caller declaring the process working directory as its scope - and truthiness dropped that scope, so a confined write to a path outside the working directory skipped all three checks and published anyway. The three guards now test presence (`confineTo !== undefined`) against one local read of the option. An absent confineTo still means no confinement; only the empty-string case changes, and it changes from unconfined to confined. Test: src/utils/__tests__/safeWriteJson.test.ts, "treats an empty confineTo as a declared scope rather than as no scope" - a temp target that is asserted to sit outside the process working directory, confineTo "", expecting ConfinedPathEscapeError and nothing written. Negative control: restoring `if (confineTo)` at the three guards reddens that row alone - `AssertionError: promise resolved "undefined" instead of rejecting` at the rejects.toBeInstanceOf line - which is the reason the row gives. Shipped tree: 50 passed | 4 skipped for the file. No suppression changed: src/eslint-suppressions.json is byte-identical to HEAD after the eslint --prune-suppressions run. prettier --write clean, eslint --prune-suppressions --max-warnings=0 exit 0 on both touched files. --- src/utils/__tests__/safeWriteJson.test.ts | 18 ++++++++++++++++++ src/utils/safeWriteJson.ts | 17 +++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 34523d604d..f52ff0aae4 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -1014,6 +1014,24 @@ describe("safeWriteJson", () => { expect(left.filter((entry) => entry.includes(".new_") || entry.endsWith(".lock"))).toEqual([]) }) + test("treats an empty confineTo as a declared scope rather than as no scope", async () => { + const cwd = process.cwd() + // An empty string is a scope declaration: _resolveScopeRoot runs it through + // path.resolve, which yields the process working directory. The temp target has + // to sit outside that directory for this test to be about the scope rather than + // about where the temp files happen to live, so the precondition is asserted. + const outside = path.join(tempDir, "empty-scope.json") + expect(path.resolve(outside).startsWith(cwd + path.sep)).toBe(false) + expect(path.resolve(outside)).not.toBe(cwd) + + await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: "" })).rejects.toBeInstanceOf( + ConfinedPathEscapeError, + ) + + // Rejected the way every other confinement rejection is: nothing written. + expect(await fs.readdir(tempDir)).not.toContain("empty-scope.json") + }) + test("rejects an out-of-scope target before the advisory lock is taken", async () => { const projectDir = path.join(tempDir, "scope") await fs.mkdir(projectDir) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index f2a6d1cabe..d60bc29499 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -224,14 +224,19 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit // the target when the resolution itself rejects. let resolvedTargetPath: string | undefined + // An empty confineTo is a declared scope, not the absence of one: `""` resolves to the + // process working directory, so the three checks below test for presence. A truthiness + // test would silently drop the scope a caller declared as an empty string. + const confineTo = options?.confineTo + // Confinement is checked before ANY filesystem side effect. mkdir -p through a // planted symlinked ancestor (projectDir/.roo -> /elsewhere) would create // directories outside the declared scope, and an unwritable referent would surface // the mkdir error instead of the ConfinedPathEscapeError the caller is entitled to. // _resolveScopeRoot resolves a not-yet-existing path through its nearest existing // ancestor, so this works for the target's missing parents too. - if (options?.confineTo) { - const scopeRoot = await _resolveScopeRoot(options.confineTo) + if (confineTo !== undefined) { + const scopeRoot = await _resolveScopeRoot(confineTo) const requested = await _resolveScopeRoot(absoluteFilePath) if (_escapesScope(scopeRoot, requested)) { throw new ConfinedPathEscapeError(absoluteFilePath, requested, scopeRoot) @@ -263,8 +268,8 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit // it is entitled to. The key is the symlink referent, so this check sees the same // destination the lock would be taken on. The under-lock check stays: it covers the // target re-resolved after a peer commits. - if (options?.confineTo) { - const scopeRoot = await _resolveScopeRoot(options.confineTo) + if (confineTo !== undefined) { + const scopeRoot = await _resolveScopeRoot(confineTo) if (_escapesScope(scopeRoot, await _resolveScopeRoot(lockKey))) { throw new ConfinedPathEscapeError(absoluteFilePath, lockKey, scopeRoot) } @@ -315,8 +320,8 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit // does not exist yet still carries the alias components of the path it was // given. This runs before the merge read and before anything is staged, so a // rejected write leaves nothing behind. - if (options?.confineTo) { - const scopeRoot = await _resolveScopeRoot(options.confineTo) + if (confineTo !== undefined) { + const scopeRoot = await _resolveScopeRoot(confineTo) if (_escapesScope(scopeRoot, await _resolveScopeRoot(resolvedTargetPath))) { throw new ConfinedPathEscapeError(absoluteFilePath, resolvedTargetPath, scopeRoot) } From a39df1d43c58cc32c0c926243c6807246e5f9484 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:07:14 +0800 Subject: [PATCH 083/115] test(safeWriteJson): cover the in-scope dangling link to an outside referent (#1918 review 4507646178) The row asks for the case the neighbouring row at safeWriteJson.test.ts:1050 does not reach: a link that sits INSIDE the confined directory, whose referent is outside it and does not exist. That is where the lock-key guard earns its keep - resolveLockKey falls back to the referent spelling, so the pre-lock check at safeWriteJson.ts:266 sees an outside directory and rejects before proper-lockfile writes a .lock next to that key. Test-only: no production line changes. The row asserts the ordering the row names (lockCalls empty, then the rejection type, then no .lock beside either the scope or the referent directory, and the link itself untouched). Fixture: junction on win32, file symlink elsewhere. An unprivileged Windows cannot create a file or directory symlink at all (measured EPERM here), while a junction needs no privilege and reports the pair the code branches on (lstat isSymbolicLink true, realpath ENOENT), so the row is written to run on both hosted lanes rather than gated to one platform. Negative control: comparing the requested alias instead of the resolved lock key in the pre-lock check reddens this row alone - `AssertionError: expected [ Array(1) ] to deeply equal []` at the lockCalls line - while the existing outside-path row at :1050 stays green, so the control isolates the guard the row is about. --- src/utils/__tests__/safeWriteJson.test.ts | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index f52ff0aae4..3428a5c0f5 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -1032,6 +1032,74 @@ describe("safeWriteJson", () => { expect(await fs.readdir(tempDir)).not.toContain("empty-scope.json") }) + test("rejects an in-scope dangling symlink to an outside referent before the advisory lock", async () => { + // The fixture needs a dangling link, and the two platforms build that shape + // differently: an unprivileged Windows cannot create a file or directory symlink + // at all (EPERM), while a junction needs no privilege, resolves through Node's + // reparse handling and reports as a symbolic link to a path that does not exist - + // the same lstat/realpath pair the POSIX file symlink produces. lstat of the + // junction here reports isSymbolicLink true and realpath reports ENOENT, which is + // the branch resolvePublishTarget and the lock-key walk take on either platform, + // so the row runs everywhere instead of only where a file link is permitted. + const projectDir = path.join(tempDir, "dangling-scope") + await fs.mkdir(projectDir) + const outsideDir = path.join(tempDir, "dangling-outside") + await fs.mkdir(outsideDir) + const link = path.join(projectDir, "link.json") + // junction on win32, file symlink elsewhere; the type is what makes the same + // dangling-link shape buildable on both lanes. + const linkType: "junction" | "file" = process.platform === "win32" ? "junction" : "file" + await fs.symlink(path.join(outsideDir, "gone.json"), link, linkType) + + const realLockfile = await vi.importActual("proper-lockfile") + const lockCalls: string[] = [] + const lockMock = vi.fn(async (file: unknown, options?: unknown) => { + lockCalls.push(String(file)) + return realLockfile.lock(file as never, options as never) + }) + vi.doMock("proper-lockfile", () => ({ + ...realLockfile, + lock: lockMock as unknown as typeof realLockfile.lock, + })) + + vi.resetModules() + const { safeWriteJson: reimported, ConfinedPathEscapeError: ReimportedError } = await import("../safeWriteJson") + + try { + const outcome = await reimported(link, { mcpServers: {} }, { confineTo: projectDir }).then( + function (value: unknown) { + return { ok: true as const, value: value, error: undefined as unknown } + }, + function (error: unknown) { + return { ok: false as const, value: undefined as unknown, error: error } + }, + ) + // A dangling link resolves to its referent for the lock key, and the referent + // is outside the declared scope, so the second confinement check rejects + // before the lock file - which proper-lockfile writes NEXT TO the key - is + // ever attempted. Asserted first, because the point of the row is the + // ordering: a rejection that arrives after a lock is a rejection that already + // wrote outside the scope. + expect(lockCalls).toEqual([]) + expect(outcome.ok).toBe(false) + expect(outcome.error).toBeInstanceOf(ReimportedError) + expect( + (await fs.readdir(outsideDir)).filter(function (entry: string) { + return entry.endsWith(".lock") + }), + ).toEqual([]) + expect( + (await fs.readdir(projectDir)).filter(function (entry: string) { + return entry.endsWith(".lock") + }), + ).toEqual([]) + expect((await fs.readdir(projectDir)).sort()).toEqual(["link.json"]) + } finally { + vi.doUnmock("proper-lockfile") + vi.resetModules() + } + }) + test("rejects an out-of-scope target before the advisory lock is taken", async () => { const projectDir = path.join(tempDir, "scope") await fs.mkdir(projectDir) From 06dbe77fa552842e7cdc46f8e25f487046f1d141 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:08:34 +0800 Subject: [PATCH 084/115] fix(readFileTool): stop the clipped-line notice indenting the first numbered line (#1918 review 4507646138) The row is true about src/core/tools/ReadFileTool.ts:377 (review anchor). In the branch that adds the clipped-lines notice without a truncation notice, the response was built as a multi-line template literal whose continuation line was indented to the assignment. Node puts that indentation in the string, so the notice was followed by a line of tabs and then the numbered read: the measured response line was four tabs before "1 | a", and a tab-indented line number is exactly what an exact-match edit has to reproduce from a rendering that shows none. The sibling truncated-notice shapes (:322, :351) join with literal \t separators that the model can see in the notice text itself, so they are left alone; this row is about the one branch where the indent belongs to the source layout rather than to the message. The join is now explicit: [notice, result.content].join("\n"). Notice text, wording and ordering are unchanged; only the separator between the notice and the read changes. Test: src/core/tools/__tests__/readFileTool.spec.ts, "native: the clipped notice leaves the first numbered line at column 0" - asserts the line after the notice is exactly "1 | a" and that no response line begins with a tab. Negative control: running the new row against ReadFileTool.ts at 273dd7b32 reddens it alone - `AssertionError: expected '\t\t\t\t1 | a' to be '1 | a'` - which is the row's own reason, while the six neighbouring clipped-line rows stay green (1 failed | 6 passed for the -t clipped selection). Shipped tree: 112 passed for the file. --- src/core/tools/ReadFileTool.ts | 9 +++-- src/core/tools/__tests__/readFileTool.spec.ts | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index d3faf05ab9..5634a1388d 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -373,8 +373,13 @@ export class ReadFileTool extends BaseTool<"read_file"> { const scope = coversWholeFile ? "The file was read in full, but the clipped lines were not shown in full." : `Lines ${covered} of ${result.totalLines} total lines were shown, and the clipped lines were not shown in full.` - output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. ${scope} - ${result.content}` + // The notice and the read are joined with a single newline: an indented template + // continuation here would put the indentation into the response itself, so the first + // numbered line would arrive carrying tabs an exact-match edit then has to reproduce. + output = [ + `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. ${scope}`, + result.content, + ].join("\n") } else if (result.returnedLines === 0 && result.totalLines === 0) { output = "Note: File is empty" } diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index f763abe397..6def4c7a22 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -2202,6 +2202,40 @@ describe("ReadFileTool", () => { expect(pushed).toContain("1 | a") }) + it("native: the clipped notice leaves the first numbered line at column 0", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + await readFileTool.execute({ path: "clipped-cols.ts" }, mockTask as unknown as Task, callbacks) + + const pushed = String(callbacks.pushToolResult.mock.calls[0][0]) + const responseLines = pushed.split("\n") + const noticeLine = responseLines.findIndex((line) => line.includes("clipped in this view")) + expect(noticeLine).toBeGreaterThanOrEqual(0) + // The read follows the notice on the very next line, at column 0. The + // previous shape joined the two with a template continuation line, which + // put this function's own indentation into the response: the first numbered + // line arrived indented, and an exact-match edit against a line number the + // model can see has to reproduce tabs it was never shown. + expect(responseLines[noticeLine + 1]).toBe("1 | a") + expect(responseLines[noticeLine + 2]).toBe("2 | b") + expect(pushed).not.toContain("\n\t") + }) + it("native: a clipped slice that started past line 1 reports the range it covered", async () => { // Not truncated does not mean complete: a slice that begins at offset 4 and // stops at the last line returned the tail only. Claiming "read in full" here From 7e00c5d65820714416b686e5b55e5c081c43d805 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:08:34 +0800 Subject: [PATCH 085/115] fix(diffView): reconcile the file document when a virtual document shares its fsPath (#1918 review 4507646161) The row is true about the two reconciliation lookups in src/integrations/editor/DiffViewProvider.ts - saveDirectly's conflict check (review anchor :1813) and the reopened-buffer check after the revert fallback. Both took the FIRST document whose fsPath matched the target. A diff editor lists its virtual side under the fsPath of the file it is comparing, and textDocuments can report that document first, so the reconciliation can be aimed at a document that was never stale: the dirty file buffer keeps its pre-publish bytes, saveDirectly reports success, and the buffer's next save republishes those stale bytes over what guardedWrite committed - the failure this reconciliation exists to prevent. Both lookups now go through one private findReconciliationDocument(): filter the path matches, prefer the document whose uri scheme is "file", and fall back to the first match so a document the host reports under another scheme is reconciled rather than silently skipped. The scheme preference is the whole change; the conflict, revert and reopen logic is untouched. Test: src/integrations/editor/__tests__/DiffViewProvider.spec.ts, "reconciles the file document when a virtual document with the same fsPath is listed first". The revert command takes no resource argument, so the document the reconciliation activates is the document that gets reverted; the double clears its dirty flag only when IT is activated, which makes a mis-aimed reconciliation visible instead of silently succeeding. Negative control: running the new row against DiffViewProvider.ts at 273dd7b32 reddens it alone - `AssertionError: expected "vi.fn()" to be called with arguments: [ { uri: { ... } ... ]` at the showTextDocument assertion - while the two pre-existing reconcile rows stay green (1 failed | 2 passed for the -t reconcile selection). Shipped tree: 160 passed for the file. --- src/integrations/editor/DiffViewProvider.ts | 27 +++++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 60 +++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 8638dca7b8..42630edae8 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -469,6 +469,25 @@ export class DiffViewProvider { } } + /** + * The open buffer that must be reconciled after a guarded publish. + * + * A diff editor keeps a virtual document whose fsPath is the file it compares, and + * textDocuments can list that document before the real file buffer, so a first + * match can land on the virtual side. The dirty file buffer then keeps its stale + * bytes and its next save republishes them over the committed content - which is + * what this reconciliation exists to prevent - while the reconciliation itself + * reports success. Prefer the file document; keep the first path match as the + * fallback so a document the host reports under another scheme is reconciled + * rather than skipped. + */ + private findReconciliationDocument(absolutePath: string): vscode.TextDocument | undefined { + const matches = vscode.workspace.textDocuments.filter((document) => + arePathsEqual(document.uri.fsPath, absolutePath), + ) + return matches.find((document) => document.uri.scheme === "file") ?? matches[0] + } + /** * Revert one document through the workbench command. The command takes no * resource argument: when the Open Editors view has focus with a selection it @@ -1808,9 +1827,7 @@ export class DiffViewProvider { // reverting reloads the published bytes and clears the dirty flag - so no // open buffer is left whose text differs from the publish. Reverting a // document that already matches the disk is a no-op. - const openTarget = vscode.workspace.textDocuments.find((document) => - arePathsEqual(document.uri.fsPath, absolutePath), - ) + const openTarget = this.findReconciliationDocument(absolutePath) if (openTarget !== undefined) { // A dirty buffer holds edits the user has not saved. The guarded publish wrote the // accepted content to disk, so reverting would discard those edits. Only a clean @@ -1836,9 +1853,7 @@ export class DiffViewProvider { // best-effort close: the reopened-document check below decides } await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) - const reopened = vscode.workspace.textDocuments.find((document) => - arePathsEqual(document.uri.fsPath, absolutePath), - ) + const reopened = this.findReconciliationDocument(absolutePath) if (reopened?.isDirty) { throw new Error( `saveDirectly: the open document at ${absolutePath} is still dirty after the guarded publish -- ` + diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 2663e74219..349c938a15 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1311,6 +1311,66 @@ describe("DiffViewProvider", () => { } }) + it("reconciles the file document when a virtual document with the same fsPath is listed first", async () => { + // A diff editor lists its virtual side under the fsPath of the file it is + // comparing, and textDocuments can report that document before the real + // buffer. The reconciliation has to pick the file document: aimed at the + // virtual side it reverts a document that was never stale, leaves the dirty + // buffer holding the pre-publish bytes, and reports success - and the buffer's + // next save republishes those stale bytes over what the guard committed. + const virtualDoc = { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "diff" }, + isDirty: false, + getText: () => "virtual side of the diff", + save: vi.fn().mockResolvedValue(undefined), + } + const openDirtyDoc = { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + isDirty: true, + getText: () => "new content", + save: vi.fn().mockResolvedValue(undefined), + } + const virtualDouble = virtualDoc as unknown as vscode.TextDocument // Structural double + const fileDouble = openDirtyDoc as unknown as vscode.TextDocument // Structural double + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.push(virtualDouble, fileDouble) + try { + // The revert command has no resource argument, so the document the + // reconciliation activates is the document that gets reverted. Clearing the + // dirty flag only for the document that was activated is what makes an aimed + // -at-the-virtual-side reconciliation visible in the assertions below. + const activated: unknown[] = [] + vi.mocked(vscode.window.showTextDocument).mockImplementation(async (document) => { + activated.push(document) + // Structural double: saveDirectly awaits the activation and never reads the + // returned editor, so the object carries only the document it activated. + return { document } as unknown as vscode.TextEditor + }) + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command: unknown) => { + if (command === "workbench.action.files.revert" && activated[activated.length - 1] === fileDouble) { + openDirtyDoc.isDirty = false + } + }) + + await diffViewProvider.saveDirectly("test.ts", "new content", false, false, 0) + + expect(vscode.window.showTextDocument).toHaveBeenCalledWith( + fileDouble, + expect.objectContaining({ preserveFocus: false }), + ) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.files.revert") + // The stale buffer is the one that was reconciled. + expect(openDirtyDoc.isDirty).toBe(false) + expect(virtualDoc.isDirty).toBe(false) + expect(virtualDoc.save).not.toHaveBeenCalled() + expect(openDirtyDoc.save).not.toHaveBeenCalled() + } finally { + documents.splice(documents.indexOf(virtualDouble), 1) + documents.splice(documents.indexOf(fileDouble), 1) + vi.mocked(vscode.window.showTextDocument).mockReset() + vi.mocked(vscode.commands.executeCommand).mockReset() + } + }) it("keeps a dirty buffer with different unsaved edits and reports the conflict", async () => { // The user has unsaved edits in the open target that differ from what the guard // published. Reverting would discard them, so the buffer is retained and the From 230463572fa82fbc135eb042322ceb7843d788ac Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:10:36 +0800 Subject: [PATCH 086/115] test(guardedWrite): show that a blocked publish does not hold other paths (#1918 review 4507760695) Test-only, src/core/tools/__tests__/guardedWrite.spec.ts. The row is about the row at :993 ("writes on different paths are independent"): that row awaits both writes and counts two publishes, so it says nothing about WHEN each publish ran. A single chain shared by every path would still publish twice, and still pass. The new row gates the first path's publish on a promise and asserts, while that gate is still closed, that the second path's publish has already happened - the per-path chain is what makes that true, and it is the thing the row asks to see. The settle loop replaces a sleep before the assertion; the assertion itself is the listing taken while the first publish is unfinished. Negative control: point pendingChains at one shared key in guardedWrite.ts:107 (enqueue) and the row reddens alone - `AssertionError: expected [] to deeply equal [ 'C:\test\workspace\gate-b.txt' ]` - while the pre-existing independence row at :993 stays green, which is what shows the new row is the one that carries the claim. Shipped tree: 69 passed for the file. --- src/core/tools/__tests__/guardedWrite.spec.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index ac792f6c90..380b5ed683 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -992,6 +992,43 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) }) + it("lets a write on another path publish while the first path's publish is blocked", async () => { + // The row is about progress during the blocked window, which a settled call + // count cannot show: with one chain shared by every path the second write would + // queue behind the first and this assertion would never see a publish at all. + const reg = new ObservationRegistry() + reg.observe(abs("gate-a.txt"), "v1") + reg.observe(abs("gate-b.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + let releaseGateA: () => void = () => {} + const gateA = new Promise((resolve) => { + releaseGateA = resolve + }) + const publishOrder: string[] = [] + mockedSafeWriteText.mockImplementation(async (target: string) => { + if (target === abs("gate-a.txt")) { + await gateA + } + publishOrder.push(target) + }) + + const writeA = guardedWrite(task, "gate-a.txt", "a", "update") + const writeB = guardedWrite(task, "gate-b.txt", "b", "update") + + // Settle until b has published; a is still inside its own publish here. + for (let tick = 0; tick < 200 && publishOrder.length === 0; tick++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + expect(publishOrder).toEqual([abs("gate-b.txt")]) + + releaseGateA() + await Promise.all([writeA, writeB]) + + expect(publishOrder).toEqual([abs("gate-b.txt"), abs("gate-a.txt")]) + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + }) }) describe("path resolution", () => { From 30e2ef8f6d5c0f326afc7d5dc9d115d431bb6f51 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:10:37 +0800 Subject: [PATCH 087/115] test(guardedWrite): cover resetChain while a link is still publishing (#1918 review 4507764608) Test-only, src/core/tools/__tests__/guardedWrite.spec.ts. The row is about the resetChain test at :1156: it calls resetChain() after a write that has already settled, so it exercises the map being empty rather than the hook detaching a pending tail. A no-op resetChain passes it. The new row holds the first publish inside its publish (gated), calls resetChain() while that link is in flight, and asserts the second write on the SAME path enters its publish before the gate is released. If the pending link stayed the chain tail the second write would queue behind a publish that cannot finish until the second one is awaited - the wedge resetChain exists to clear. Negative control: make resetChain (guardedWrite.ts:683) do nothing and the row reddens alone - `AssertionError: expected [ 'a' ] to deeply equal [ 'a', 'b' ]` - while the settled resetChain row at :1156 stays green. Shipped tree: 69 passed. --- src/core/tools/__tests__/guardedWrite.spec.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 380b5ed683..b1d48789a8 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -1195,6 +1195,49 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b") }) + it("detaches a link that is still publishing so the next write starts immediately", async () => { + // The settled result of the row above cannot tell a detached chain from an + // emptied one, because nothing was in flight there. Here the first publish is + // still awaiting its gate when resetChain runs: if resetChain left the pending + // link as the path's tail, the second write would queue behind a publish that + // cannot finish until the second one is already waited on - the wedge the hook + // exists to undo. + const reg = new ObservationRegistry() + reg.observe(abs("chain.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + let releaseGate: () => void = () => {} + const gate = new Promise((resolve) => { + releaseGate = resolve + }) + const entered: string[] = [] + mockedSafeWriteText.mockImplementation(async (_target: string, content: string | Uint8Array) => { + entered.push(String(content)) + if (content === "a") { + await gate + } + }) + + const firstWrite = guardedWrite(task, "chain.txt", "a", "update") + for (let tick = 0; tick < 200 && entered.length === 0; tick++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + // The first publish is in flight and unfinished. + expect(entered).toEqual(["a"]) + + resetChain() + const secondWrite = guardedWrite(task, "chain.txt", "b", "update") + for (let tick = 0; tick < 200 && entered.length < 2; tick++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + expect(entered).toEqual(["a", "b"]) + + releaseGate() + await Promise.all([firstWrite, secondWrite]) + expect(entered).toEqual(["a", "b"]) + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("chain.txt"), "b") + }) }) it("does not run a queued write once the owning task is cancelled", async () => { From 278f42871ab42bcc5b402a6b389cfdb67eea8070 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:10:37 +0800 Subject: [PATCH 088/115] test(safeWriteJson): show nothing is staged before the lock is held (#1918 review 4507764647) Test-only, src/utils/__tests__/safeWriteJson.lockKey.spec.ts. The row is about the lock-capture block at :103: the file's rows assert which key is locked and the order of the resolution calls, but no row shows that the caller does not stage or publish while acquireFileLock has not returned. The new row keeps acquisition pending inside the mock, lets the caller run in that window, and snapshots the directory from inside the window. Staging writes the ..new__.tmp file and the publish renames onto the target, both in that directory, so an empty listing while the lock is unheld is the claim itself. The settle is inside the pending window rather than a sleep before an assertion. Negative control: replace `releaseLock = await acquireFileLock(lockKey)` (safeWriteJson.ts:281) with an unawaited acquisition and the row reddens alone - `AssertionError: expected [ 'settings.json' ] to deeply equal []`, i.e. the publication happened before the lock was held - while the two pre-existing lock-key rows stay green. Shipped tree: 7 passed for the file. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 7430330eca..0693277d93 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -129,6 +129,47 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(JSON.parse(await fs.readFile(currentLink, "utf8"))).toEqual({ id: "task-1" }) }) + it("does not stage or publish while the lock acquisition has not returned", async () => { + const dir = await makeDir("lockkey-pending-") + const target = path.join(dir, "settings.json") + + // Staging uses the ..new__.tmp infix and the publish renames + // onto the target, both inside this directory, so the directory listing taken + // while acquisition is pending is the whole proof. + // ..new__.tmp infix and the publish renames onto the target, + // both inside this directory, so an empty directory is the whole proof. + mockedRealpath.mockImplementation(async (t) => String(t)) + mockedLstat.mockRejectedValue(enoent) + mockedReadlink.mockRejectedValue(new Error("not a link")) + + let acquiredKey: string | undefined + let duringAcquisition: string[] | undefined + let releaseAcquisition: () => void = () => {} + const acquisition = new Promise((resolve) => { + releaseAcquisition = resolve + }) + mockedAcquireFileLock.mockImplementation(async (file) => { + acquiredKey = String(file) + // The acquisition has been called and has not returned. Let the caller run + // in that window: if it stages or publishes before holding the lock, that + // work lands in this directory and the snapshot below catches it. A caller + // that waits for the lock has nothing to do here but wait. + await new Promise((resolve) => setTimeout(resolve, 25)) + duringAcquisition = await fs.readdir(dir) + await acquisition + return async () => {} + }) + + const write = safeWriteJson(target, { mcpServers: {} }) + releaseAcquisition() + await write + + expect(acquiredKey).toBe(target) + expect(duringAcquisition).toEqual([]) + expect(await fs.readdir(dir)).toEqual(["settings.json"]) + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ mcpServers: {} }) + }) + it("releases the lock when the resolution under the lock rejects", async () => { const order: string[] = [] let released = false From 0f603937d8192e964defb50c40ecb8d3d0c87867 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:10:37 +0800 Subject: [PATCH 089/115] test(writeToFileTool): assert the canonical target reaches both save paths (#1918 review 4507764611) Test-only, src/core/tools/__tests__/writeToFileTool.spec.ts. The row is about the outside-workspace rows: the row at :519 checks the seventh and eighth saveDirectly arguments (completeness override, approval flag) and the editor-path row checks the third saveChanges argument (write kind). Neither checks the canonical identity the tool captures at WriteToFileTool.ts:104 and forwards at :156 and :197. Two rows, one per save method. Each makes path.resolve return an alias whose path carries a linked directory component and makes realpath return the canonical path the resolver produces, then asserts the threaded argument is that canonical value - not the alias and not the caller's own spelling: saveDirectly index 8, saveChanges index 4 (with the approval flag at index 3). Negative control: replace both `approvedCanonicalTarget,` arguments with undefined in WriteToFileTool.ts and both new rows redden - `AssertionError: expected undefined to be 'C:\test\file.txt'` - while the pre-existing approval-flag row at :519 stays green. Shipped tree: 26 passed | 5 skipped for the file. --- .../tools/__tests__/writeToFileTool.spec.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 0dd6a2b6c4..9891b11b81 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -525,6 +525,58 @@ describe("writeToFileTool", () => { } }) + it("forwards the canonical target to saveDirectly alongside the approval flag", async () => { + // The lexical target carries a linked directory component; the resolver removes + // it, so the identity the tool has to forward is a different string from the + // path it resolved out of task.cwd. The ninth saveDirectly parameter is that + // identity: guardedWrite refuses a publish that no longer resolves to it. + const aliasPath = process.platform === "win32" ? "C:\\test\\link\\file.txt" : "/test/link/file.txt" + const canonicalTarget = absoluteFilePath + const { realpath } = await import("fs/promises") + mockedPathResolve.mockReturnValue(aliasPath) + vi.mocked(realpath).mockImplementation(async () => canonicalTarget) + mockedIsPathOutsideWorkspace.mockReturnValue(true) + + try { + await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[7]).toBe(true) + expect(args[8]).toBe(canonicalTarget) + expect(args[8]).not.toBe(aliasPath) + expect(args[8]).not.toBe(args[0]) + } finally { + // Both doubles are module-level: a reset after the assertions only runs when + // they pass, and beforeEach clears calls but not implementations. + vi.mocked(realpath).mockImplementation(async (p) => String(p)) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + mockedPathResolve.mockReturnValue(absoluteFilePath) + } + }) + + it("forwards the canonical target to saveChanges on the editor save path", async () => { + // The editor path saves through saveChanges, whose fifth parameter is the same + // identity. Checking only the focus-disruption path would let the approved target + // reach one save method and not the other. + const aliasPath = process.platform === "win32" ? "C:\\test\\link\\file.txt" : "/test/link/file.txt" + const canonicalTarget = absoluteFilePath + const { realpath } = await import("fs/promises") + mockedPathResolve.mockReturnValue(aliasPath) + vi.mocked(realpath).mockImplementation(async () => canonicalTarget) + mockedIsPathOutsideWorkspace.mockReturnValue(true) + + try { + await executeWriteFileTool({}, { fileExists: true }) + const args = mockCline.diffViewProvider.saveChanges.mock.calls.at(-1)! + expect(args[3]).toBe(true) + expect(args[4]).toBe(canonicalTarget) + expect(args[4]).not.toBe(aliasPath) + } finally { + vi.mocked(realpath).mockImplementation(async (p) => String(p)) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + mockedPathResolve.mockReturnValue(absoluteFilePath) + } + }) + it("routes an outside-workspace target that cannot be resolved through the tool's error handling", async () => { // The identity capture runs before the approval is asked, so a target whose // resolution fails must not escape the write flow: it has to reach handleError and From c9bdb014b8abc9736b579198848e31b6637793c4 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:21:00 +0800 Subject: [PATCH 090/115] fix(safe-write): answer the lock-key drift guard with lock identity, not spelling The drift guard compared two key spellings, so a writer whose key came from a name that did not exist yet rejected with LockKeyDriftError once the file appeared under the name its filesystem reports - SETTINGS.JSON against settings.json in a case-insensitive directory - although both spellings take the same lock. The row at safeWriteJson.ts:377 asked for identity without masking real destination changes; this lands that much. - lockPathFor(filePath) is exported from src/utils/fileLock.ts:22 and passed to proper-lockfile as lockfilePath (src/utils/fileLock.ts:48), so "the lock for this key" is one location one module names rather than two that happen to agree. - _locksAreOneLock (src/utils/safeWriteJson.ts:112) puts the question to the filesystem through fs.stat with bigint: true, the way safeWriteText.ts puts its staging-containment question, because on NTFS and ReFS a file identity can exceed Number.MAX_SAFE_INTEGER. It asks only about a divergence that could be a case alias: same directory, basenames differing by case. A missing artefact is the one failure that carries no information, so the keys are then treated as different locks and the original refusal stands; a stat that fails for any other reason propagates instead of being read as "different locks" (_statLockArtefact, :141). - Divergences in another directory are refused before any stat: a key whose parent was re-keyed under the writer resolves its own artefact spelling onto some other writer's artefact, and an identity match there names a lock this writer never took. Rows (src/utils/__tests__/safeWriteJson.lockKey.spec.ts), each with the mutant it fails under: - :137 refusal - the directory the lock was taken in is moved aside and re-keyed onto a peer's directory that holds the file and its own lock; expects LockKeyDriftError with nothing staged or published. Dropping the same-directory precondition reddens it: expected 'undefined' to be 'LockKeyDriftError'. - :183 acceptance - the reviewer's case: key keeps the spelling it was computed from, peer commits under the stored name, one artefact sits under both names. Against the pre-fix string compare: expected 'LockKeyDriftError' to be 'undefined'. - :460 fail closed - the artefact stat answers EACCES; the EACCES must propagate and nothing may be published. Under a swallow-all catch the refusal comes from a stat that was swallowed: expected 'undefined' to be 'EACCES'. - src/utils/__tests__/fileLock.test.ts:21 asserts the lockfilePath this module passes, which is what makes the guard's premise load-bearing rather than assumed. Test shape: no it.runIf(win32) gate anywhere in this commit. Rows 2 and 3 measure folding on the host they run on and model a realpath that reports the name the directory stores - what GetFinalPathNameByHandle does and what the reviewer's case depends on - because this Windows box's realpath preserves the caller's spelling, so without that model the divergence would never reach the guard on a case-folding host. On a case-sensitive filesystem (ext4, both Linux lanes) the same two facts - one artefact under two names, one file reachable from both spelling - are built with a file symlink and a hard link, so the identity branch executes on both hosted lanes. The listing assertion counts entries case-insensitively rather than asserting stored casing, because the commit rename itself restates which spelling a folded directory carries. --- src/utils/__tests__/fileLock.test.ts | 6 +- .../__tests__/safeWriteJson.lockKey.spec.ts | 206 +++++++++++++++++- src/utils/fileLock.ts | 13 ++ src/utils/safeWriteJson.ts | 67 +++++- 4 files changed, 286 insertions(+), 6 deletions(-) diff --git a/src/utils/__tests__/fileLock.test.ts b/src/utils/__tests__/fileLock.test.ts index 92637fe7a0..c06d994f80 100644 --- a/src/utils/__tests__/fileLock.test.ts +++ b/src/utils/__tests__/fileLock.test.ts @@ -1,5 +1,5 @@ import * as lockfile from "proper-lockfile" -import { acquireFileLock, LOCK_STALE_MS, withFileLock } from "../fileLock" +import { acquireFileLock, LOCK_STALE_MS, lockPathFor, withFileLock } from "../fileLock" vi.mock("proper-lockfile", () => ({ lock: vi.fn() })) @@ -15,6 +15,10 @@ describe("file lock protocol", () => { stale: LOCK_STALE_MS, update: 10000, realpath: false, + // The drift guard in safeWriteJson answers "is this the same lock?" by comparing + // the two artefacts lockPathFor names, which is only sound while this module + // locks exactly that location. + lockfilePath: lockPathFor("history.json"), retries: { retries: 35, factor: 1, minTimeout: 1000, maxTimeout: 1000 }, }), ) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 0693277d93..187578d59d 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -6,11 +6,16 @@ import type { BigIntStats } from "fs" import * as fs from "fs/promises" import { acquireFileLock } from "../fileLock" import { safeWriteJson } from "../safeWriteJson" +import { lockPathFor } from "../fileLock" import { resolveLockKey } from "../../services/file-safety/safeWriteText" -vi.mock("../fileLock", () => ({ - acquireFileLock: vi.fn(async () => async () => {}), -})) +vi.mock("../fileLock", async () => { + // The drift guard asks whether two keys name one lock, which is a question about the + // artefact location lockPathFor describes. Mocking this module must not replace that + // one pure function, or a row would be comparing a location of its own invention. + const actual = await vi.importActual("../fileLock") + return { ...actual, acquireFileLock: vi.fn(async () => async () => {}) } +}) vi.mock("fs/promises", async () => { const actual = await vi.importActual("fs/promises") @@ -129,6 +134,120 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(JSON.parse(await fs.readFile(currentLink, "utf8"))).toEqual({ id: "task-1" }) }) + it("refuses a resolution that lands in another directory even when its lock artefact is reachable", async () => { + const dir = await makeDir("lockkey-diralias-") + const referent = await makeDir("lockkey-referent-") + const alias = path.join(dir, "alias") + const peerFile = path.join(referent, "settings.json") + await fs.mkdir(alias) + const target = path.join(alias, "settings.json") + + // realpath and lstat delegate to the real filesystem: the two directories below are + // directories the operating system created, which is the only way to show the guard + // refusing a destination that is genuinely elsewhere. + const real = await vi.importActual("fs/promises") + mockedRealpath.mockImplementation(async (p) => await real.realpath(String(p))) + mockedLstat.mockImplementation(async (p) => await real.lstat(String(p), { bigint: true })) + mockedReadlink.mockRejectedValue(enoent) + + let acquiredKey: string | undefined + mockedAcquireFileLock.mockImplementation(async (file) => { + acquiredKey = String(file) + // This writer holds the lock for the key it computed, at the artefact + // acquireFileLock creates for it. The peer publishes the file under its own lock in + // another directory, moves the directory this writer's key names out of the way, and + // re-keys that name onto the peer's directory. The writer's artefact spelling now + // resolves through the alias onto the PEER's artefact: an identity match there would + // name a lock this writer never took. + await fs.mkdir(lockPathFor(String(file)), { recursive: true }) + await fs.writeFile(peerFile, JSON.stringify({ mcpServers: { peer: true } })) + await fs.mkdir(lockPathFor(peerFile), { recursive: true }) + await fs.rename(alias, path.join(dir, "stale")) + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + return async () => {} + }) + + const error = await safeWriteJson(target, { mcpServers: { mine: true } }).then( + () => undefined, + (caught: unknown) => caught as Error, + ) + expect(String(error && error.name)).toBe("LockKeyDriftError") + expect(String(acquiredKey)).toContain(path.join("alias", "settings.json")) + // Nothing staged or published: the referent directory holds only the peer's file and + // its own artefact, and the moved-aside directory holds only this writer's. + expect((await fs.readdir(referent)).sort()).toEqual(["settings.json", "settings.json.lock"]) + expect(JSON.parse(await fs.readFile(peerFile, "utf8"))).toEqual({ mcpServers: { peer: true } }) + expect((await fs.readdir(path.join(dir, "stale"))).sort()).toEqual(["settings.json.lock"]) + }) + + it("accepts the name the filesystem itself answers for the spelling the key was computed from", async () => { + const dir = await makeDir("lockkey-casefold-") + const lower = path.join(dir, "settings.json") + const upper = path.join(dir, "SETTINGS.JSON") + const real = await vi.importActual("fs/promises") + // The reviewer's case turns on realpath handing back the name a directory stores rather + // than the spelling it was given - which is what NTFS's GetFinalPathNameByHandle does. + // This double models exactly that, and is the identity function wherever the two already + // agree, so the row drives the guard on a filesystem that restates case and on one that + // cannot restate it at all. + const restatingRealpath = async (target: string): Promise => { + const resolved = await real.realpath(target) + const parent = path.dirname(resolved) + const name = path.basename(resolved) + const listed = await real.readdir(parent).catch(() => [] as string[]) + const stored = listed.find((entry: string) => entry.toLowerCase() === name.toLowerCase()) + return stored === undefined ? resolved : path.join(parent, stored) + } + const probeLower = path.join(dir, "probe.json") + await real.writeFile(probeLower, "{}") + const foldsCase = await restatingRealpath(path.join(dir, "PROBE.JSON")).then( + async (resolved: string) => path.basename(resolved) === path.basename(probeLower), + () => false, + ) + await real.rm(probeLower, { force: true }) + mockedRealpath.mockImplementation(async (p) => await restatingRealpath(String(p))) + mockedLstat.mockImplementation(async (p) => await real.lstat(String(p), { bigint: true })) + mockedReadlink.mockImplementation(async (p) => await real.readlink(String(p))) + + mockedAcquireFileLock.mockImplementation(async (file) => { + // The key was computed from the upper-case spelling while nothing of that name existed, + // so it kept the literal spelling. The file this write publishes to appears during + // acquisition, under the name the filesystem reports. + const ownArtefact = lockPathFor(String(file)) + await fs.writeFile(ownArtefact, "") + await fs.writeFile(lower, JSON.stringify({ mcpServers: { peer: true } })) + if (!foldsCase) { + // A case-sensitive filesystem cannot produce this divergence on its own - two + // spellings there are two names - so the same pair of facts is built by hand: the + // writer's name linked onto the peer's file, and the two artefact names joined by a + // hard link, which is one artefact under two names. + await fs.symlink(lower, upper) + await fs.link(ownArtefact, lockPathFor(lower)) + } + return async () => {} + }) + + const error = await safeWriteJson(upper, { mcpServers: { mine: true } }).then( + () => undefined, + (caught: unknown) => caught as Error, + ) + // The claim is the guard's answer: the two names reach one artefact and one file, so this + // writer holds the lock the other spelling would take and the write is not refused. + expect(String(error && error.name)).toBe("undefined") + expect(JSON.parse(await fs.readFile(lower, "utf8"))).toEqual({ mcpServers: { mine: true } }) + const entries = await fs.readdir(dir) + // A case-folding filesystem holds one file and one artefact under the two spellings, so it + // lists two entries; a case-sensitive one holds two distinct files and two distinct + // artefacts, and lists four. Names are compared case-insensitively because on a folding + // filesystem the commit rename itself restates which spelling the file carries. + expect(entries.map((name: string) => name.toLowerCase()).sort()).toEqual( + foldsCase + ? ["settings.json", "settings.json.lock"] + : ["settings.json", "settings.json.lock", "settings.json", "settings.json.lock"], + ) + expect(entries.length).toBe(foldsCase ? 2 : 4) + }) + it("does not stage or publish while the lock acquisition has not returned", async () => { const dir = await makeDir("lockkey-pending-") const target = path.join(dir, "settings.json") @@ -338,4 +457,85 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(await resolveLockKey(file)).toBe(path.join(canonicalDir, "history_item.json")) }) + it("propagates a lock-artefact stat that fails for a reason other than the artefact being absent", async () => { + const dir = await makeDir("lockkey-artefactstat-") + const lower = path.join(dir, "settings.json") + const upper = path.join(dir, "SETTINGS.JSON") + const real = await vi.importActual("fs/promises") + const restatingRealpath = async (target: string): Promise => { + const resolved = await real.realpath(target) + const parent = path.dirname(resolved) + const name = path.basename(resolved) + const listed = await real.readdir(parent).catch(() => [] as string[]) + const stored = listed.find((entry: string) => entry.toLowerCase() === name.toLowerCase()) + return stored === undefined ? resolved : path.join(parent, stored) + } + const probeLower = path.join(dir, "probe.json") + await real.writeFile(probeLower, "{}") + const foldsCase = await restatingRealpath(path.join(dir, "PROBE.JSON")).then( + async (resolved: string) => path.basename(resolved) === path.basename(probeLower), + () => false, + ) + await real.rm(probeLower, { force: true }) + + // A promise re-import, so the failing stat does not leak into another row. Only stat + // fails, and only on a lock artefact: the write path's own stat calls (mode preservation) + // keep working, so the EACCES this row sees came from the guard's comparison and nowhere + // else. + let statCalls = 0 + vi.doMock("fs/promises", async () => { + const actual = await vi.importActual("fs/promises") + const refusal = Object.assign(new Error("EACCES: permission denied, stat"), { code: "EACCES" }) + return { + ...actual, + realpath: vi.fn(async (p: string) => await restatingRealpath(String(p))), + lstat: vi.fn(async (p: string) => await actual.lstat(String(p), { bigint: true })), + readlink: vi.fn(async (p: string) => await actual.readlink(String(p))), + stat: vi.fn(async (p: string, options: unknown) => { + if (String(p).endsWith(".lock")) { + statCalls++ + throw refusal + } + return await actual.stat(String(p), options as { bigint: true }) + }), + } + }) + vi.doMock("../fileLock", async () => { + const actual = await vi.importActual("../fileLock") + return { + ...actual, + acquireFileLock: vi.fn(async (file: string) => { + // The divergence the guard is asked about, reached the same way as in the row + // above: this writer's key keeps the spelling it was computed from, and the name + // the filesystem reports for the file appears under it during acquisition. + const ownArtefact = actual.lockPathFor(String(file)) + await fs.writeFile(ownArtefact, "") + await fs.writeFile(lower, JSON.stringify({ mcpServers: { peer: true } })) + if (!foldsCase) { + await fs.symlink(lower, upper) + await fs.link(ownArtefact, actual.lockPathFor(lower)) + } + return async () => {} + }), + } + }) + vi.resetModules() + const safeWriteJsonReimported = (await import("../safeWriteJson")).safeWriteJson + + const error = await safeWriteJsonReimported(upper, { mcpServers: { mine: true } }).then( + () => undefined, + (caught: unknown) => caught as Error, + ) + + expect(statCalls).toBeGreaterThan(0) + expect(String(error && (error as { code?: string }).code)).toBe("EACCES") + expect(String(error && error.name)).not.toBe("LockKeyDriftError") + // Fail closed: the writer published nothing, and the file it would have replaced still + // holds the peer's content. + expect(JSON.parse(await fs.readFile(lower, "utf8"))).toEqual({ mcpServers: { peer: true } }) + expect((await fs.readdir(dir)).length).toBe(foldsCase ? 2 : 4) + vi.doUnmock("fs/promises") + vi.doUnmock("../fileLock") + vi.resetModules() + }) }) diff --git a/src/utils/fileLock.ts b/src/utils/fileLock.ts index 49807ea154..c98e9b1c67 100644 --- a/src/utils/fileLock.ts +++ b/src/utils/fileLock.ts @@ -10,6 +10,18 @@ import * as lockfile from "proper-lockfile" export const LOCK_STALE_MS = 31_000 const RECOVERY_RETRY_MS = 1000 + +/** + * The advisory lock for a path is the filesystem artefact at this location. + * proper-lockfile's default is `.lock`; `acquireFileLock` below passes the + * value explicitly so the two cannot drift apart, and `safeWriteJson`'s lock-key drift + * guard answers "is this the same lock?" by comparing two artefacts. A caller that + * derived the location on its own would be comparing something other than what this + * module actually locks. + */ +export function lockPathFor(filePath: string): string { + return path.resolve(filePath) + ".lock" +} const RECOVERY_GRACE_MS = 4000 export interface FileLockOptions { @@ -33,6 +45,7 @@ export async function acquireFileLock(filePath: string, options?: FileLockOption return await lockfile.lock(absoluteFilePath, { stale: LOCK_STALE_MS, update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long + lockfilePath: lockPathFor(absoluteFilePath), realpath: false, // the file may not exist yet, which is acceptable retries: options?.immediate ? 0 diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index d60bc29499..bde212865c 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,7 +4,7 @@ import * as path from "path" import { JsonStreamStringify } from "json-stream-stringify" import { getErrorCode } from "./errorCode" -import { acquireFileLock } from "./fileLock" +import { acquireFileLock, lockPathFor } from "./fileLock" import { resolveLockKey, resolvePublishTarget } from "../services/file-safety/safeWriteText" import { recoverInterruptedReplacement } from "./jsonFileRecovery" @@ -88,6 +88,66 @@ class LockKeyDriftError extends Error { this.name = "LockKeyDriftError" } } +/** + * Whether two lock keys denote one lock. + * + * The drift guard further down exists because two writers holding two different locks can + * commit onto one file, each believing it owned it. That is a claim about the locks, and the + * lock for a key is the artefact at lockPathFor(key). On a case-folding filesystem (NTFS, + * APFS) `SETTINGS.JSON.lock` and `settings.json.lock` ARE one artefact, so a writer that + * computed its key from a name that did not exist yet and then meets that file's true name + * under the lock is holding the very lock the other spelling would take: nothing about its + * lock changed, and comparing the two spellings refuses a write that never needed refusing. + * + * The question is put to the filesystem through fs.stat with bigint: true, for the same + * reason safeWriteText.ts puts its staging-containment question that way: on NTFS and ReFS a + * file identity can exceed Number.MAX_SAFE_INTEGER, and a rounded number makes two artefacts + * look different from each other or two different artefacts look alike. The spelling + * comparison stays in front of this as the fast path, so a stat is only asked when the + * spellings already differ. A missing artefact is the one failure that carries no information + * about identity, so the keys are then treated as different locks and the original rejection + * stands. Any other stat failure propagates: a guard that answered "different locks" from an + * EACCES would be refusing for a reason it never measured. + */ +async function _locksAreOneLock(firstKey: string, secondKey: string): Promise { + // A divergence in another directory is not this question. A key whose parent was + // re-keyed under the writer can resolve its own artefact spelling onto some other + // writer's artefact, and an identity match there says nothing about the lock this + // writer took. Only a name that differs from its counterpart by case, within the + // same directory, can be one lock under two spellings - and even then the + // filesystem, not the spelling, is what answers. + if (path.dirname(firstKey) !== path.dirname(secondKey)) { + return false + } + if (path.basename(firstKey).toLowerCase() !== path.basename(secondKey).toLowerCase()) { + return false + } + const first = await _statLockArtefact(lockPathFor(firstKey)) + if (first === undefined) { + return false + } + const second = await _statLockArtefact(lockPathFor(secondKey)) + if (second === undefined) { + return false + } + return first.dev === second.dev && first.ino === second.ino +} + +/** + * Stat one lock artefact. undefined means the artefact is not there - the only failure that + * says something about this comparison. Anything else is the guard losing its ability to + * decide, and it propagates rather than being read as "not the same lock". + */ +async function _statLockArtefact(lockPath: string): Promise { + try { + return await fs.stat(lockPath, { bigint: true }) + } catch (error: unknown) { + if (getErrorCode(error) === "ENOENT") { + return undefined + } + throw error + } +} /** * Thrown when the directory this write was approved to stage into no longer names that @@ -311,7 +371,10 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit // the lock of the one it is about to publish, which is two commits onto one file each // believing it owned it. Nothing has been staged or merged yet, so rejecting here leaves // every file untouched and the caller can retry against the key it resolves to now. - if ((await resolveLockKey(resolvedTargetPath)) !== lockKey) { + const underLockKey = await resolveLockKey(resolvedTargetPath) + // Different spellings are not yet evidence of a different lock: the pair is refused + // only when the two lock artefacts are not the same filesystem object. + if (underLockKey !== lockKey && !(await _locksAreOneLock(lockKey, underLockKey))) { throw new LockKeyDriftError(absoluteFilePath, lockKey, resolvedTargetPath) } From 6d85182cf3a87bb3c9badf34a525c438b4a3f36d Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:21:11 +0800 Subject: [PATCH 091/115] docs(safe-write): record what the confineTo presence test decides, and what it does not The presence test at the `const confineTo = options?.confineTo` line is the whole decision: an empty confineTo is a declared scope (the working directory), not the absence of one, and the three gates below read that one value. The comment now says the two things that follow from it: no production call site passes confineTo at all - the option is read nowhere outside this module - so nothing in the tree changes behaviour under either reading; and if a maintainer decides an empty string should mean unset instead, that is a one-line change to this presence test with a test row of its own, not a redesign. Comment only; no behaviour change, no test change. --- src/utils/safeWriteJson.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index bde212865c..c64266da40 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -285,8 +285,13 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit let resolvedTargetPath: string | undefined // An empty confineTo is a declared scope, not the absence of one: `""` resolves to the - // process working directory, so the three checks below test for presence. A truthiness - // test would silently drop the scope a caller declared as an empty string. + // process working directory, so the checks below test for presence, and the presence + // decision happens once, here. A truthiness test would silently drop a scope a caller + // declared as an empty string. No production call site passes confineTo at all - the + // option is read nowhere outside this module - so nothing in the tree changes behaviour + // under either reading. If a maintainer decides an empty string should mean "unset" + // instead, that is a one-line change to this presence test with a test row of its own, + // not a redesign. const confineTo = options?.confineTo // Confinement is checked before ANY filesystem side effect. mkdir -p through a From 84b8b3a30fd59af04d6fc06b908ef88c538a75fb Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:23:17 +0800 Subject: [PATCH 092/115] test(file-safety): compare the aliased directory by identity, not by spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row at src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts asked whether the guard's refusal names the target's directory by testing whether the message contains `fixture.referent` - the spelling fs.mkdtempSync handed the test. On the hosted Windows lane os.tmpdir() is an 8.3 short name (C:\Users\RUNNER~1\AppData\Local\Temp) while canonicalDirKey reports the long spelling through fs.realpath, so the row failed there at 0f603937d on platform-unit-test (windows-latest), services lane: AssertionError: expected 'Staging file must sit in the target\'…' to contain 'C:\Users\RUNNER~1\AppData\Local\Temp\…' Expected: "C:\Users\RUNNER~1\AppData\Local\Temp\fws-alias-onXyY1\referent" Received: "Staging file must sit in the target's directory (C:\Users\runneradmin\AppData\Local\Temp\fws-alias-onXyY1\referent), got ..." The assertion now canonicalizes the directory before comparing it, because what the row is about is which directory the message names, not which spelling the operating system handed the fixture. Negative control on the shipped tree: pointing the expectation at path.join(fixture.root, "alias") reddens it - Expected "C:\Users\eason\AppData\Local\Temp\fws-alias-5wkqPA\alias", Received "Staging file must sit in the target's directory (C:\Users\eason\AppData\Local\Temp\fws-alias-5wkqPA\referent), got ...\elsewhere\unrelated.json" - so the row still pins the referent directory rather than any directory. Test-only; no production change and no it.runIf gate added. --- .../__tests__/safeWriteText.stagingAlias.spec.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts b/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts index 24cbcd5015..a845f77ba7 100644 --- a/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts @@ -118,8 +118,14 @@ describe("caller-supplied staging path through a directory alias (" + ALIAS_TYPE } expect(failure).toBeInstanceOf(StagingPathError) // The message quotes the referent spelling, which is the directory the write resolves to - - // the reason the old compare read an alias as containment failure. - expect(String(failure?.message)).toContain(fixture.referent) + // the reason the old compare read an alias as containment failure. The directory is + // canonicalized before it is compared, because the row is about which directory the + // message names, not about which spelling the operating system handed the test: on the + // hosted Windows runner os.tmpdir() is an 8.3 short name (C:\Users\RUNNER~1\AppData\ + // Local\Temp) while canonicalDirKey reports the long spelling through realpath, and the + // two spellings name one directory. Comparing the raw mkdtemp output against the message + // made that row fail on a spelling. + expect(String(failure?.message)).toContain(await fs.realpath(fixture.referent)) expect(failure?.stagingPath).toBe(path.resolve(stagingPath)) } finally { disposeFixture(fixture) From 8b394b35c99afa388d7cbf0987b528f8947896a2 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:26:56 +0800 Subject: [PATCH 093/115] fix(safe-write): keep the validated staging file protected through the open and the commit A caller-supplied staging path is validated once - contained in the target's directory, a regular file rather than a link, not the target itself - and then used twice: opened with r+ so the target's mode can be applied to it, and renamed onto the target. Both uses resolved the path again, so a process that replaced the path after the validation put this write's chmod and this write's publish on a file the validation had never checked: openSync follows a link, so fchmodSync would change the permissions of somebody else's file and the commit rename would publish the link, or whatever the path had come to name, in place of the content staged here. Both uses now re-read the identity that was checked. src/services/file-safety/safeWriteText.ts:521 - inside the fd's try and BEFORE the fchmodSync below it, so the refusal prevents the permission change instead of reporting it after the fact: fsSync.fstatSync(fd, { bigint: true }) compared with the stat hoisted at :419. A fstatSync that fails propagates: the fd is ours, and a stat we cannot take is not evidence that the path still names our file. src/services/file-safety/safeWriteText.ts:663 - immediately before the commit rename: fs.lstat(tempPath, { bigint: true }) against the same identity. Any failure there refuses, including ENOENT: there is then nothing at the path this write staged into, and a rename cannot publish an absence. _stagingIdentityDiffers (:239) reports a swap only when both stats carry a bigint identity - production asks for { bigint: true } at every call site that feeds it - so a stat without one is treated as not shown to be different rather than as a swap; refusing on that shape would refuse every caller-staged write in the mock-driven spec for a shape no real filesystem produces. A rename has no handle to go through, so the pre-commit look narrows the window rather than closing it: it is the last look, not a lock. The failure handler still unlinks the path it was given, which for a swapped path means unlinking whatever is there now; this commit does not change that. Rows (src/services/file-safety/__tests__/safeWriteText.spec.ts, describe "caller-staged file protected through publication") and the mutant each was made to fail under: refusal at the open -> post-open check deleted -> promise resolved "undefined" instead of rejecting, with the other four rows green. ordinary path still accepted and published (fchmod + rename asserted, both bigint reads of the staging path asserted) -> pre-commit check deleted -> expected [ [ .. ] ] to have a length of 2 but got 1. replaced after the open -> pre-commit check deleted -> promise resolved "undefined" instead of rejecting. pre-commit stat answers EACCES / ENOENT -> pre-commit check deleted -> promise resolved "undefined" instead of rejecting (both). reading a missing bigint identity as a swap -> 8 failed | 67 passed, including the acceptance row, which is why the helper declines rather than refuses on that shape. Shipped tree: services/file-safety 78 passed; utils safeWriteJson+fileLock 69 passed | 4 skipped; guardedWrite/writeToFileTool/readFileTool 207 passed | 5 skipped; DiffViewProvider.spec 160 passed; eslint --prune-suppressions --max-warnings=0 exit 0 on both touched files with src/eslint-suppressions.json left identical to HEAD; prettier --check clean; tsc --noEmit -p . from src reports only the known local junction diagnostic activate/registerCommands.ts(88,102): TS2739. No it.runIf(win32) gate was added: every new row passes platform: "linux" to the SUT, the way the neighbouring staging rows do, so both hosted lanes execute them. --- .../__tests__/safeWriteText.spec.ts | 136 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 66 +++++++++ 2 files changed, 202 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 169230d033..1f10b094d4 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -34,6 +34,7 @@ vi.mock("fs", () => ({ fsyncSync: vi.fn(), chmodSync: vi.fn(), fchmodSync: vi.fn(), + fstatSync: vi.fn(), statSync: vi.fn(), Stats: class Stats {}, })) @@ -89,6 +90,12 @@ function mockDefaults(): void { // Staged-file default: a regular file, not a link, so a caller-supplied // tempPath passes the location and file-type check by default. vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) + // The caller-staged branch re-confirms the file its fd landed on through fs.fstatSync. The + // default carries no bigint identity, which is the shape production never hands this compare + // (it asks for { bigint: true }), so the guard declines to read it as a swap and the rows below + // keep the meaning they had before the re-confirm existed. Rows that care about identity say so + // by handing both sides one. + vi.mocked(fsSync.fstatSync).mockReturnValue(_fileStats(false)) vi.mocked(fs.stat).mockRejectedValue(Object.assign(new Error("no such directory"), { code: "ENOENT" })) } function _stats(mode: number): fsSync.Stats { @@ -1605,3 +1612,132 @@ describe("resolveLockKey when the parent directory does not exist yet", () => { await expect(resolveLockKey(target)).rejects.toThrow("EACCES") }) }) +describe("caller-staged file protected through publication", () => { + beforeEach(() => mockDefaults()) + + // After validation the guard reads the staging path twice more: through the fd it just opened, + // and immediately before the commit rename. Reporting a different identity at one of those + // looks is how these rows say "the path was replaced after that check" - a real filesystem + // offers no way to schedule a swap between two calls inside one function, and replacing the + // whole call would not show which look the refusal came from. + function _staged( + customTempPath: string, + targetPath: string, + looks: { open?: fsSync.BigIntStats; commit?: fsSync.BigIntStats | Error }, + ): void { + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(5) + const stagingIdentity = _fileStatsWithIdentity(8n, 3n) + const targetIdentity = _fileStatsWithIdentity(11n, 3n) + let stagingLooks = 0 + vi.mocked(fs.lstat).mockImplementation(async (p) => { + const s = String(p) + if (s.endsWith("target.txt")) { + return targetIdentity + } + // The first look is the validation; the second is the one right before the rename. The + // two arrive under different spellings (the resolved one and the caller's own), which is + // why the match is on the basename and not on a path prefix. + stagingLooks += 1 + if (stagingLooks === 1) { + return stagingIdentity + } + const next = looks.commit + if (next instanceof Error) { + throw next + } + return next ?? stagingIdentity + }) + vi.mocked(fsSync.fstatSync).mockReturnValue(looks.open ?? stagingIdentity) + } + + it("refuses when the fd names a different file than the one validation checked", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + // Validation saw (8,3); the fd came back naming (9,3) - the path was swapped for a link or + // another file in between. openSync on a link follows it, so the fchmod below would change + // the permissions of a file this write never staged, and the rename would publish it. + _staged(customTempPath, targetPath, { open: _fileStatsWithIdentity(9n, 3n) }) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + + await expect( + safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow(/replaced between validation and open/) + // The refusal came before fchmodSync, so no permission was changed on the substituted file. + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + // Nothing was published. + expect(fs.rename).not.toHaveBeenCalled() + // The fd read asked for bigint stats: on NTFS/ReFS an identity can exceed + // Number.MAX_SAFE_INTEGER and a rounded number makes two files look alike. + const fdLooks = vi.mocked(fsSync.fstatSync).mock.calls + expect(fdLooks).toHaveLength(1) + expect(fdLooks[0]?.[1]).toEqual({ bigint: true }) + }) + + it("accepts a staging file the open confirmed, and still publishes it", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + // The ordinary case: the same file at every look. A guard that refused this one would stop + // every caller-staged write. + _staged(customTempPath, targetPath, {}) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + + await safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }) + + expect(fsSync.fchmodSync).toHaveBeenCalledWith(5, 0o600) + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + // Two bigint reads of the staging path: the validation and the one immediately before the + // commit. The second is what this commit adds, so it is asserted as a call, not only as a + // decision. + const stagingLooks = vi + .mocked(fs.lstat) + .mock.calls.filter((c) => String(c[0]).endsWith("custom-temp.tmp") && c[1] !== undefined) + expect(stagingLooks).toHaveLength(2) + for (const c of stagingLooks) { + expect(c[1]).toEqual({ bigint: true }) + } + }) + + it("refuses when the staging path was replaced after the file was opened", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + // The open confirmed (8,3); the look immediately before the rename reports (9,3). Without + // that look the rename publishes whatever the path came to name, not the content staged + // here. + _staged(customTempPath, targetPath, { commit: _fileStatsWithIdentity(9n, 3n) }) + + await expect( + safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow(/replaced between validation and the commit/) + expect(fs.rename).not.toHaveBeenCalled() + // The failure handler still unlinks the path it was given. Whether that is the right + // cleanup for a path someone else replaced is a separate question from publishing it, and + // this commit does not change the answer. + expect(fs.unlink).toHaveBeenCalledWith(customTempPath) + }) + + it("refuses when the pre-commit look fails with something other than the file being gone", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + _staged(customTempPath, targetPath, { commit: Object.assign(new Error("EACCES"), { code: "EACCES" }) }) + + await expect( + safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow(/could not be re-checked before the commit \(EACCES\)/) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("refuses when the staging file is gone by the time the commit would run", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + // Missing is the one answer a stat can give that means "do not publish": there is nothing + // at the path this write staged its content into. The spelling would be the fallback for a + // directory comparison; here it would be a rename that cannot happen. + _staged(customTempPath, targetPath, { commit: Object.assign(new Error("ENOENT"), { code: "ENOENT" }) }) + + await expect( + safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow(/could not be re-checked before the commit \(ENOENT\)/) + expect(fs.rename).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index dbbfbbf805..8e0b704293 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -224,6 +224,28 @@ async function _statDirectoryForContainment( } } +/** +/** + * Whether the two stats name DIFFERENT files, judged the way the compares around it judge + * one directory: as bigints, because on NTFS and ReFS a file identity can exceed + * Number.MAX_SAFE_INTEGER and a rounded value makes two files look alike or two spellings of + * one file look different. The helper reports a swap only when both stats actually carry a + * bigint identity - production asks for { bigint: true } at every call site that feeds it, so + * a stat without one is a double that was never asked to answer identity, and calling that a + * swap would refuse every staged write for a shape no real filesystem produces. A stat that + * fails outright is handled by its call site, not here: this helper only ever answers + * "different" or "not shown to be different". + */ +function _stagingIdentityDiffers(checked: fsSync.BigIntStats, current: fsSync.BigIntStats): boolean { + return ( + typeof checked.ino === "bigint" && + typeof current.ino === "bigint" && + typeof checked.dev === "bigint" && + typeof current.dev === "bigint" && + (checked.ino !== current.ino || checked.dev !== current.dev) + ) +} + /** * Distinguish "the target does not exist" from a real I/O failure (EACCES, * EIO, ...). The mode-preservation path may only fall back to the fresh-file @@ -319,6 +341,10 @@ export async function safeWriteText( // write created so its cleanup removes its own directory, not a shared one. let stagingDir: string | null = null let tempPath: string + // Non-null only for a caller-supplied staging file: the identity the validation above + // reported, plus the spelling a refusal should quote back. The open and the commit rename + // each re-confirm the path against it, because validation is a statement about one moment. + let validatedStaging: { stats: fsSync.BigIntStats; supplied: string } | null = null if (options?.tempPath) { // A caller-supplied staging file is only safe when it is the file this // write is staging, not an arbitrary path. Two properties are checked: @@ -390,6 +416,9 @@ export async function safeWriteText( throw new StagingPathError("Staging file must not be the target itself", supplied) } // The caller's own path is used as given; only the check is canonical. + // Everything above is a statement about the path at one moment. Keep the identity that + // was checked so the open and the commit rename can each re-confirm it. + validatedStaging = { stats: stagingStat, supplied } tempPath = options.tempPath } else { // The staging directory lives inside the target's own parent, so the parent has to exist @@ -481,6 +510,21 @@ export async function safeWriteText( } const fd = fsSync.openSync(tempPath, "r+") try { + if (validatedStaging) { + // Re-confirm the file the open actually landed on, BEFORE the chmod below: an fd + // opened on a path that was swapped for a link in the meantime would change the + // permissions of somebody else's file, and a swap to a different file would let the + // commit publish that file instead of the content staged here. A fstatSync that + // fails propagates: the fd is ours, and a stat we cannot take is not evidence that + // the path still names our file. + const opened = fsSync.fstatSync(fd, { bigint: true }) + if (_stagingIdentityDiffers(validatedStaging.stats, opened)) { + throw new StagingPathError( + "Staging file was replaced between validation and open", + validatedStaging.supplied, + ) + } + } if (targetMode !== null) { fsSync.fchmodSync(fd, targetMode) } @@ -601,6 +645,28 @@ export async function safeWriteText( } // -- Step 4: atomic rename temp -> target --------------------- + // The rename publishes whatever the staging path names at the moment it runs. One step + // before it, confirm the path still names the file this write staged and chmodded: a + // path swapped for a link or for another file in the meantime would otherwise be + // published in place of the content written here. A stat that fails - the file gone, or + // a failure that is not about it being gone - is a refusal, because the rename cannot + // then be shown to be publishing our content. The window between this look and the + // rename is narrowed, not closed: a rename has no handle to go through, so this is the + // last look, not a lock. + if (validatedStaging) { + const beforeCommit = await fs.lstat(tempPath, { bigint: true }).catch((error: unknown) => { + throw new StagingPathError( + `Staging file could not be re-checked before the commit (${errorCode(error) ?? "unknown error"}): ${tempPath}`, + validatedStaging.supplied, + ) + }) + if (_stagingIdentityDiffers(validatedStaging.stats, beforeCommit)) { + throw new StagingPathError( + "Staging file was replaced between validation and the commit", + validatedStaging.supplied, + ) + } + } await fs.rename(tempPath, targetPath) committed = true From 599a10b195016db39a9a245203779440615766c5 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:28:55 +0800 Subject: [PATCH 094/115] fix(diff-view): close the tab the opener recognized, using the same comparison closeOwnDiffView() matched tabs by spelling - path.resolve(tab.input.modified.fsPath) === target and path.resolve(uri.fsPath) === target - while openDiffEditor() reuses a diff tab through arePathsEqual (src/integrations/editor/DiffViewProvider.ts:1487 and the sibling looks at :1527 and :1549). On Windows those two comparisons disagree about casing, so the tab the opener was willing to reuse, and did hand to this provider, could be missed by the closure: a clean diff tab stayed open after reset() cleared the provider state, with this provider's activation listener and deferred scroll timer still attached to a tab nobody was closing. Both looks in the closer now use the shared helper - src/integrations/editor/ DiffViewProvider.ts:1127 for the scheme-identified tab and :1141 for the label-identified one. The closer gets no case rule of its own: arePathsEqual folds case only on win32, so the closer is exactly as eager as the opener on every lane, which is the property the row asked for - 'use the same path comparison for closure' - rather than a second, wider matcher bolted onto the closing side. The label gate is untouched: a tab whose URI is the target but whose label is another task's still is not this provider's tab. Rows (src/integrations/editor/__tests__/DiffViewProvider.spec.ts, describe "closeOwnDiffView method"): the expectation is stated as agreement with arePathsEqual rather than as a hard-coded casing outcome, because which two spellings count as one file is a property of the platform's path comparison. The Windows lane therefore requires the fold (the case-differing tab has to be closed); a case-sensitive lane requires the closer not to be more eager than the opener (the same tab has to stay open). Each row also carries a tab that must never close - another task's file, and another task's label - so widening the comparison cannot pass by closing more than its own tab. spelling compare back at :1127 -> casing row: AssertionError: expected false to be true // Object.is equality (the tab the shared comparison calls the same file was not closed). spelling compare back at :1141 -> label row: same assertion, same reason. closer changed to an unconditional toLowerCase() compare -> green on this Windows lane by construction (this lane folds anyway) and red on a case-sensitive lane, where arePathsEqual returns false while the mutant closes the tab. Disclosed rather than glossed: the second half of that was measured by reading the helper's win32 branch, not by running ext4 here. Shipped tree: DiffViewProvider.spec 162 passed; the editor folder 170 passed; guardedWrite/writeToFileTool/readFileTool 207 passed | 5 skipped; eslint --prune-suppressions --max-warnings=0 exit 0 on both touched files with src/eslint-suppressions.json left identical to HEAD; prettier --check clean; tsc --noEmit -p . from src shows only the known local junction diagnostic activate/registerCommands.ts(88,102): TS2739. No it.runIf(win32) gate was added: both rows run on every lane and assert the same agreement property. --- src/integrations/editor/DiffViewProvider.ts | 11 ++- .../editor/__tests__/DiffViewProvider.spec.ts | 79 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 42630edae8..1582aec780 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1120,8 +1120,11 @@ export class DiffViewProvider { // Only Zoo's own diff tabs, not a Source Control diff the user has open // for the same file. return ( + // The comparison is the shared one, not a spelling compare: openDiffEditor() reuses a + // tab through arePathsEqual, so a tab it handed this provider can come back under the + // other casing on Windows and still have to be found here. tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && - path.resolve(tab.input.modified.fsPath) === target + arePathsEqual(tab.input.modified.fsPath, target) ) } // A diff tab for a file that was already open is identified by its label @@ -1130,8 +1133,12 @@ export class DiffViewProvider { // URI points at this provider's target. const uri = (tab.input as { uri?: { fsPath?: string } })?.uri return ( + // The tab's own URI decides, through the same comparison the opener used to pick this + // tab up: arePathsEqual normalizes and, on Windows, folds case - which is how a tab + // ends up carrying a different spelling of the file the provider is tracking. The + // closer gets no independent case rule: it may not be more eager than the opener. typeof uri?.fsPath === "string" && - path.resolve(uri.fsPath) === target && + arePathsEqual(uri.fsPath, target) && typeof tab.label === "string" && tab.label.startsWith(`${path.basename(target)}: ${DIFF_VIEW_LABEL_CHANGES}`) ) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 349c938a15..fe1f9c2051 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -21,6 +21,7 @@ import { safeWriteText } from "../../../services/file-safety/safeWriteText" import { withFileLock } from "../../../utils/fileLock" import { createDirectoriesForFile } from "../../../utils/fs" import { ObservationRegistry } from "../../../core/task/observationRegistry" +import { arePathsEqual } from "../../../utils/path" import type { Task } from "../../../core/task/Task" // Mock delay @@ -1026,6 +1027,84 @@ describe("DiffViewProvider", () => { expect(closedTabs).toEqual([ownTab]) }) + + // The closer has to recognise the tab the opener handed it. openDiffEditor() reuses a diff tab + // through arePathsEqual while closeOwnDiffView() compared spellings, so on Windows the opener + // could pick up a tab the closure then missed, leaving it open after reset() cleared the + // provider state. Which two spellings count as one file is a property of the platform's path + // comparison, so the expectation states agreement with that comparison rather than hard-coding + // what Windows does: the Windows lane requires the fold, a case-sensitive lane requires the + // closer not to be more eager than the opener. + const targetOf = (): string => path.resolve(path.join(mockCwd, "test.ts")) + const otherCasingOf = (p: string): string => p.replace(/([^\\/]+)$/, (last) => last.toUpperCase()) + const makeOwnDiffTabForClosure = (modifiedFsPath: string) => { + const tab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: modifiedFsPath }, + }, + isDirty: false, + } + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + return tab + } + + it("closes its own tab whichever casing of the target that tab carries", async () => { + const target = targetOf() + const sameSpellingTab = makeOwnDiffTabForClosure(target) + const otherSpellingTab = makeOwnDiffTabForClosure(otherCasingOf(target)) + const otherTaskTab = makeOwnDiffTabForClosure(path.join(path.dirname(target), "other-task.ts")) + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [sameSpellingTab, otherSpellingTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((closed) => { + closedTabs.push(closed) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toContain(sameSpellingTab) + // Another task's file is still another task's file, casing or no casing. + expect(closedTabs).not.toContain(otherTaskTab) + expect(closedTabs.includes(otherSpellingTab)).toBe( + arePathsEqual(otherSpellingTab.input.modified.fsPath, target), + ) + }) + + it("recognises the label-identified tab through the same comparison it uses for scheme tabs", async () => { + const target = targetOf() + const ownLabel = `${path.basename(target)}: ${DIFF_VIEW_LABEL_CHANGES} (Editable)` + const labelTab = { + input: { uri: { fsPath: path.join(otherCasingOf(path.dirname(target)), path.basename(target)) } }, + label: ownLabel, + isDirty: false, + } + const otherTaskLabelTab = { + input: { uri: { fsPath: target } }, + label: `other-task.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [labelTab, otherTaskLabelTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((closed) => { + closedTabs.push(closed) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs.includes(labelTab)).toBe(arePathsEqual(labelTab.input.uri.fsPath, target)) + // The label still gates: sharing the comparison does not turn every tab that points at the + // target into this provider's tab. + expect(closedTabs).not.toContain(otherTaskLabelTab) + }) }) it("reset() closes only this provider's tab, not another task's", async () => { From 714988adc53dd947f3c66f8cd83372fc4d66ee17 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:33:01 +0800 Subject: [PATCH 095/115] fix(apply_patch): refuse a file whose bytes cannot be decoded instead of publishing replacements The hunk read is the content the guarded publish republishes. Reading it with "utf8" is lossy: byte sequences that are not valid UTF-8 come back as U+FFFD, and the write then puts those characters into the file. An ASCII hunk matches such a file happily because the bytes at fault are elsewhere - so before this change a tail nobody mentioned was silently rewritten by a patch that never touched it, and the version recorded as observed was the version of the substituted text. src/core/tools/ApplyPatchTool.ts:124-151 - the read now takes the bytes (fs.readFile without an encoding) and decodes them with TextDecoder("utf-8", { fatal: true }). A refusal is an ApplyPatchError naming the file and the byte the decoder refused on; the offset comes from the lossy reading substituting from exactly that byte, so re-encoding the text before its first replacement lands on the offending sequence. The refusal happens before the post-read stat and before the observe, because the version observed here is the version a later full-file write is permitted to publish. Narrowing, not a blanket refusal: apply.ts gains PatchReadIntent - "publish" for the reads whose bytes get written back (UpdateFile, including a move, whose destination is written from the source's bytes) and "record" for the read that only snapshots (DeleteFile, which publishes by unlinking). A delete of a file whose bytes nobody can name still deletes it; refusing there would mean a workspace cannot remove a binary file, and nothing this tool does rewrites that file's bytes. The callback gains a parameter, and a one-argument reader still satisfies it, so the standalone apply-patch specs are untouched. Tests (src/core/tools/__tests__/applyPatchTool.execute.spec.ts): refuses a file it cannot decode - non-fatal decode -> expected 'Saved file' to contain 'not valid UTF-8' (the substituted text published); observe before refusing -> expected { ... } to be undefined (the substituted version became an observation). a file that legitimately contains U+FFFD (EF BF BD) still publishes - replacing the byte-level check with decoded.includes("\uFFFD") -> the row fails: that check refuses a file that is valid UTF-8 for no reason. undecodable file is still deleted - refuse regardless of intent -> unlink never called: expected "vi.fn()" to be called with arguments: [ 'C:\workspace\project\src\thing.ts' ]. Disclosure: the fs/promises readFile double in this spec now returns a Buffer because the SUT reads without an encoding; that is what fs.readFile(path) actually returns. No existing row was written to expect U+FFFD - grep for FFFD, 'invalid utf' and 'replacement character' across src/core, src/services/file-safety, src/integrations/editor and src/utils returns no match - and all 29 pre-existing rows in this spec pass unchanged. Shipped tree: applyPatchTool.execute 32 passed (29 pre-existing + 3 new); applyDiffTool failureReporting + guardedWrite + apply-patch 89 passed; guardedWrite/writeToFileTool/ readFileTool/DiffViewProvider 369 passed | 5 skipped; safeWriteJson + fileLock 69 passed | 4 skipped; eslint --prune-suppressions --max-warnings=0 exit 0 on the four touched files with src/eslint-suppressions.json left identical to HEAD; prettier --check clean; tsc --noEmit -p . from src shows only the known local junction diagnostic activate/registerCommands.ts(88,102): TS2739. No it.runIf(win32) gate was added. --- src/core/tools/ApplyPatchTool.ts | 39 +++++++- .../__tests__/applyPatchTool.execute.spec.ts | 98 ++++++++++++++++++- src/core/tools/apply-patch/apply.ts | 18 +++- src/core/tools/apply-patch/index.ts | 2 +- 4 files changed, 147 insertions(+), 10 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 74a22bf764..7ebdb5d3df 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -16,8 +16,8 @@ import { versionTokenOfStat } from "../../utils/versionToken" import { canonicalizeForApproval, GuardRejectedError, errorCode } from "./guardedWrite" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" -import { parsePatch, ParseError, processAllHunks } from "./apply-patch" -import type { ApplyPatchFileChange } from "./apply-patch" +import { ApplyPatchError, parsePatch, ParseError, processAllHunks } from "./apply-patch" +import type { ApplyPatchFileChange, PatchReadIntent } from "./apply-patch" interface ApplyPatchParams { patch: string @@ -114,10 +114,41 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // write and the composed chat-diff default rejects it ("File already // exists ... and was not read before this write") even though this tool // just read the exact content the patch was applied to. - const readFile = async (filePath: string): Promise => { + const readFile = async (filePath: string, intent: PatchReadIntent): Promise => { const absolutePath = path.resolve(task.cwd, filePath) const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) - const content: string = await fs.readFile(absolutePath, "utf8") + // Read the bytes, not a string: readFile with "utf8" has already substituted + // the sequences a strict decoder would refuse on, so the check below could not + // tell a file that contains a real replacement character from one whose bytes + // were replaced on the way in. + const raw = await fs.readFile(absolutePath) + let content: string + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(raw) + } catch { + if (intent !== "publish") { + // Nothing is republished from this reading - a delete publishes by unlinking - + // so a file whose bytes nobody can name may still be deleted. The snapshot + // stays the lossy reading it always was. + content = raw.toString("utf8") + } else { + // Refuse before any observation is recorded: the version observed here is the + // version a later full-file write would be allowed to publish, and publishing + // it means rewriting the bytes this read could not name. + // The lossy reading substitutes from exactly the byte the strict decoder + // refused on, so re-encoding the text before its first replacement lands on + // the offending sequence. + const lossy = raw.toString("utf8") + const firstSubstitution = lossy.indexOf("\uFFFD") + const offset = + firstSubstitution < 0 + ? raw.length + : Buffer.byteLength(lossy.slice(0, firstSubstitution), "utf8") + throw new ApplyPatchError( + `Cannot apply patch to ${filePath}: the file is not valid UTF-8 (byte ${offset} cannot be decoded). apply_patch refuses rather than rewriting bytes the patch never touched.`, + ) + } + } const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 29cc2c7849..796567be30 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -18,6 +18,7 @@ const mockedFsPromises = vi.mocked( fsPromises as unknown as { default: { access: MockedFunction + readFile: ReturnType stat: ReturnType unlink: MockedFunction } @@ -31,7 +32,9 @@ vi.mock("fs/promises", () => { // The target exists on disk, so the completeness gate applies to the // follow-up guarded write in the partial-observation test. access: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockResolvedValue("original file content\n"), + // Raw bytes: the SUT reads without an encoding so a strict decoder can see the file as + // the file is, and this double has to answer the same way the real readFile() does. + readFile: vi.fn().mockResolvedValue(Buffer.from("original file content\n")), // Stable on-disk version for the S2 self-read observation (the hunk // read now stats before and after; equal tokens record the observe). stat: vi.fn().mockResolvedValue({ @@ -948,4 +951,97 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockHandleError).not.toHaveBeenCalled() expect(mockPushToolResult).toHaveBeenCalled() }) + + // The hunk read is the content the guarded publish republishes. An ASCII hunk can match a + // file whose bytes somewhere else cannot be decoded, and reading those bytes as UTF-8 + // substitutes them, so the write would change bytes the patch never mentioned. The refusal has + // to land before the read is recorded as an observation, or the substituted version becomes + // the version a later full-file write is allowed to publish. + const undecodableTail = (): Buffer => + Buffer.concat([Buffer.from("original file content\n"), Buffer.from([0xc3, 0x28])]) + const undecodableUpdatePatch = `*** Begin Patch +*** Update File: src/thing.ts +@@ +-original file content ++modified file content +*** End Patch` + const deleteThingPatch = `*** Begin Patch +*** Delete File: src/thing.ts +*** End Patch` + const runPatch = async (patch: string): Promise => { + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + } + + it("update: refuses a file it cannot decode rather than publishing replacement characters", async () => { + mockedFsPromises.default.readFile.mockResolvedValue(undecodableTail()) + + await runPatch(undecodableUpdatePatch) + + const pushed = String(mockPushToolResult.mock.calls[0]?.[0] ?? "") + expect(pushed).toContain("not valid UTF-8") + // 0xC3 0x28 is not a valid sequence and it sits at byte 22 - past the line the hunk + // matched. The hunk is ASCII and matched fine, which is the case the row is about: the + // bytes at fault are ones the patch never mentions. + expect(pushed).toContain("byte 22") + expect(pushed).toContain("src/thing.ts") + // Nothing published and nothing asked: the refusal happens while the hunks are still + // being processed, before any publish prompt exists. + expect(mockAskApproval).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockSaveChanges).not.toHaveBeenCalled() + // The read must not survive as an observation: the version it saw is the version a + // later full-file write would be permitted to publish. + expect(mockTask.observationRegistry.get(path.resolve("/workspace/project", "src/thing.ts"))).toBeUndefined() + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.didEditFile).toBe(false) + }) + + it("update: a file that legitimately contains a replacement character still publishes", async () => { + // U+FFFD written as EF BF BD is valid UTF-8. The refusal is about bytes that cannot be + // decoded, not about a character appearing in the text - a check on the decoded string + // would refuse this file for no reason. + // EF BF BD is U+FFFD written as bytes: the file legitimately contains the character, and + // those bytes are valid UTF-8. (A \uFFFD... escape here would be ambiguous - FFFE is a + // legal 4-digit escape - so the sequence is spelled in bytes on purpose.) + const replacementCharacterIsLegitimate = Buffer.concat([ + Buffer.from("original file content\n"), + Buffer.from([0xef, 0xbf, 0xbd]), + Buffer.from("end\n"), + ]) + mockedFsPromises.default.readFile.mockResolvedValue(replacementCharacterIsLegitimate) + + await runPatch(undecodableUpdatePatch) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n\uFFFDend\n", + // The tail the patch never mentioned travels through the publish unchanged: + // that is what a file that legitimately contains a replacement character looks like. + false, + true, + 1000, + "edit", + undefined, + false, + undefined, + ) + expect(mockTask.consecutiveMistakeCount).toBe(0) + }) + + it("delete: an undecodable file is still deleted, because nothing republishes its bytes", async () => { + // Narrowing, not blanket refusal: the delete publishes by unlinking, so the reading is a + // snapshot and the bytes nobody can name are not rewritten by anything this tool does. + mockedFsPromises.default.readFile.mockResolvedValue(undecodableTail()) + + await runPatch(deleteThingPatch) + + expect(mockedFsPromises.default.unlink).toHaveBeenCalledWith(path.resolve("/workspace/project", "src/thing.ts")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + expect(mockTask.consecutiveMistakeCount).toBe(0) + }) }) diff --git a/src/core/tools/apply-patch/apply.ts b/src/core/tools/apply-patch/apply.ts index 4ab377f732..b6eb0e7613 100644 --- a/src/core/tools/apply-patch/apply.ts +++ b/src/core/tools/apply-patch/apply.ts @@ -16,6 +16,16 @@ export class ApplyPatchError extends Error { } } +/** + * Why a hunk wants the file read. + * + * "publish": the string handed back here is the string the tool writes back (an + * update, or a move's destination), so a read that cannot represent the file's bytes + * faithfully must not become the published content. "record": nothing is republished + * from it (a delete, whose publish is an unlink), so the reading is a snapshot. + */ +export type PatchReadIntent = "publish" | "record" + /** * Result of applying a patch to a file. */ @@ -150,7 +160,7 @@ export function applyChunksToContent(originalContent: string, filePath: string, */ export async function processHunk( hunk: Hunk, - readFile: (path: string) => Promise, + readFile: (path: string, intent: PatchReadIntent) => Promise, ): Promise { switch (hunk.type) { case "AddFile": @@ -161,7 +171,7 @@ export async function processHunk( } case "DeleteFile": { - const content = await readFile(hunk.path) + const content = await readFile(hunk.path, "record") return { type: "delete", path: hunk.path, @@ -170,7 +180,7 @@ export async function processHunk( } case "UpdateFile": { - const originalContent = await readFile(hunk.path) + const originalContent = await readFile(hunk.path, "publish") const newContent = applyChunksToContent(originalContent, hunk.path, hunk.chunks) return { type: "update", @@ -192,7 +202,7 @@ export async function processHunk( */ export async function processAllHunks( hunks: Hunk[], - readFile: (path: string) => Promise, + readFile: (path: string, intent: PatchReadIntent) => Promise, ): Promise { const changes: ApplyPatchFileChange[] = [] diff --git a/src/core/tools/apply-patch/index.ts b/src/core/tools/apply-patch/index.ts index fb69325104..1f7c8d8e01 100644 --- a/src/core/tools/apply-patch/index.ts +++ b/src/core/tools/apply-patch/index.ts @@ -11,4 +11,4 @@ export type { Hunk, UpdateFileChunk, ApplyPatchArgs } from "./parser" export { seekSequence } from "./seek-sequence" export { applyChunksToContent, processHunk, processAllHunks, ApplyPatchError } from "./apply" -export type { ApplyPatchFileChange } from "./apply" +export type { ApplyPatchFileChange, PatchReadIntent } from "./apply" From dfa3032a210e37a3a6d33df6e950eeb0f9fa6d44 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:36:50 +0800 Subject: [PATCH 096/115] fix(diff-view): carry a failed teardown to the cancellation that joined it runTeardown tracked its pass behind a gate that could only resolve: the finally released it whether or not the cleanup threw, and a caller that joined that pass awaited the gate, got a clean resume, and walked on to claim the session close (revertChanges reaches finalizeSession(() => this.reset()) when it does not own the pass). The pass that failed had left the buffer dirty and its own tab open, so the waiter discarded state describing a session nobody had tidied, and the rejected content stayed open where a later save could publish it. src/integrations/editor/DiffViewProvider.ts:1191-1222 - the gate now carries the pass's outcome: the owner records the failure and rejects the gate with it instead of resolving, so the joiner's await at :1174 throws and it never reaches the finalizeSession claim at :1052. The owner's own behaviour is unchanged - it rethrows what its cleanup threw. A no-op handler is attached to the gate (:1200) because the pass that owns it never awaits it, so a failed pass with no waiter would otherwise leave the rejection unhandled; waiters await the gate itself and still receive the rejection. This does not make the pass discard the buffer. The row also asks to preserve cleanup ownership until the buffer is discarded; after this change a failed pass stops the waiter from closing a session it did not tidy, but who tidies it afterwards is the caller's decision - and on the disposal path that decision is currently a log line (src/core/task/Task.ts:3799 swallows revertChanges with .catch(console.error)), while the abort path (Task.ts:4168) propagates it. Surfacing that split rather than choosing it. Test (src/integrations/editor/__tests__/DiffViewProvider.spec.ts): 'carries a failed cleanup to the caller that joined the pass' - gate that resolves on every outcome -> AssertionError: promise resolved "undefined" instead of rejecting; the waiter swallowing the rejection it now receives -> same failure. Both assertions (the waiter rejects with the owner's error, and reset is never called and the finalization stays unclaimed) are what the row says should happen. Measurement, not proof: deleting the defensive handler at :1200 leaves all 163 rows green, so this suite does not construct the pass-with-no-waiter shape; the handler is kept for that shape and is not covered by a row here. No existing assertion was re-written. First read of the suite under this change failed 20 rows, and those 20 were a duplicated cleanup block in my own patch (the old try/finally survived the splice, so each pass ran its cleanup twice) - not callers defending the swallow. After removing the duplicate, DiffViewProvider 163 passed (162 pre-existing + 1 new), every revertChanges caller's spec (writeToFileTool, guardedWrite, editFileTool, editTool, searchReplaceTool, applyDiffTool guardedWrite + failureReporting, apply-patch) 271 passed | 5 skipped, and ask-abort-race + Task.abort-reason-race 9 passed. eslint --prune-suppressions --max-warnings=0 exit 0 on both files with src/eslint-suppressions.json left identical to HEAD; prettier --check clean; tsc --noEmit -p . shows only the known local junction diagnostic activate/registerCommands.ts(88,102): TS2739. No it.runIf(win32) gate. --- src/integrations/editor/DiffViewProvider.ts | 27 ++++++++++++- .../editor/__tests__/DiffViewProvider.spec.ts | 39 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 1582aec780..602db26149 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1172,6 +1172,9 @@ export class DiffViewProvider { // mid-edit if that pass turns out not to finalize it. this.teardownCancellationRequested = true await this.teardownInFlight + // A rejection here is the owning pass telling this caller its cleanup failed: the + // content it was waiting behind may still be dirty and its tab still open, so this + // caller must not claim the close as though the session had been tidied. // Not ours: the caller that started this teardown owns everything that // belongs to it, including the steps that follow the callback. return false @@ -1181,20 +1184,40 @@ export class DiffViewProvider { // A pass starts clean: a cancellation recorded for an earlier pass must not leak into // this one. this.teardownCancellationRequested = false + // The gate carries the pass's outcome, not only its completion. A caller that joined a + // pass whose cleanup threw has to learn that the buffer may still be dirty and the tab + // still open, instead of resuming as though the session had been tidied and claiming the + // close of a session it never tidied. let release!: () => void - const tracked = new Promise((resolve) => { + let failGate!: (error: unknown) => void + const tracked = new Promise((resolve, reject) => { release = resolve + failGate = reject }) + // The pass that owns the gate never awaits it, so a failed pass with no waiter would + // leave this rejection unhandled. Attaching a handler here does not change what a + // waiter sees: they await `tracked` itself, and it still rejects for them. + tracked.catch(() => {}) this.teardownInFlight = tracked this.teardownPasses++ + let failure: unknown + let cleanedUp = false try { await cleanup() if (finalize) { await finalize() } + cleanedUp = true + } catch (error) { + failure = error + throw error } finally { this.teardownInFlight = undefined - release() + if (cleanedUp) { + release() + } else { + failGate(failure) + } } return true } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index fe1f9c2051..833f31b200 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -4239,6 +4239,45 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["userTouchedDocument"]).toBe(true) }) + // A cancellation that joins a teardown waits behind a pass it does not own. When that + // pass's cleanup throws, the buffer can still be dirty and this provider's tab still + // open, so the waiter has to hear about the failure instead of resuming as though the + // session had been tidied - and it must not close a session it never tidied. + it("revertChanges() carries a failed cleanup to the caller that joined the pass", async () => { + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const cleanupFailure = new Error("tab close rejected mid-teardown") + // The owning pass fails partway through its cleanup: the tab is still open and the + // document still holds the edit when the pass ends. + diffViewProvider["closeOwnDiffView"] = vi.fn().mockRejectedValue(cleanupFailure) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["reset"] = reset + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + const owner = diffViewProvider.revertChanges() + const joiner = diffViewProvider.revertChanges() + + await expect(owner).rejects.toThrow("tab close rejected mid-teardown") + // The gate carries the pass's outcome, so the waiter receives the same failure the + // owner received instead of a clean resume. + await expect(joiner).rejects.toThrow(cleanupFailure) + // Resetting here would clear the state of a session that is still mid-edit: the pass + // that owns the session has to answer for it, which it does through its own caller. + expect(reset).not.toHaveBeenCalled() + expect(diffViewProvider["sessionFinalizationClaimed"]).toBe(false) + }) }) describe("userTouchedDiffEditor keep/close behavior", () => { From 9334a304439ea6cbdf4d3d05ca9c293a26b47733 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:45:19 +0800 Subject: [PATCH 097/115] S4b (#1375): a committed publish is not undone by a cancellation The guarded path already treats the commit as the point of no return - that is what CommittedWriteBufferConflictError names - but revertChanges() did not. The modify branch wrote originalContent back through an unguarded updatedDocument.save(), the one writer left using the call the comment above saveChanges says every other path avoids, and the create branch unlinked a file the session had just published with no version check on either. A cancellation that landed after the publish therefore replaced accepted content with the pre-edit bytes, or deleted the accepted file, and the caller was told neither. revertChanges() now keeps a committed publish: it clears the dirty flag from the published bytes only while the buffer still holds exactly what landed (text typed after the accept survives), tidies the session as before, and reports CommittedWriteBufferConflictError with a detail line naming the window. The same error now carries that detail at the two saveDirectly sites, the diff-view save and the move, so one name and one set of no-retry instructions covers every committed-write conflict instead of three windows describing it three ways. saveChanges() stopped discarding revertDocument's verdict. A buffer that stays dirty after close-and-reopen holds the bytes the guard published, so the save closes it, reopens from the published bytes and reports the conflict instead of proceeding into the save-over and returning ordinary success. saveDirectly already honoured the verdict; the asymmetry was the bug. open() creates the placeholder with wx: the absence above it is a classification, not a reservation, and a plain write truncated a file that won the race, after which the stat-matched observation described content open() never wrote. A lost race is reported rather than absorbed. apply_patch names the half-applied move: the destination committed, the source unlink never ran, so both paths appear in the report and the shared no-retry guidance stays attached, because the model's next instinct is to re-apply the patch. Gates: DiffViewProvider 168 passed (5 rows new), applyPatchTool.execute 33 passed (1 new), revertChanges callers (writeToFileTool, editFileTool, editTool, searchReplaceTool) 113 passed | 5 skipped, applyDiff guardedWrite + failureReporting + apply-patch + guardedWrite 116 passed, task abort races 9 passed. No it.runIf(win32) gate anywhere. eslint --prune-suppressions --max-warnings=0 exit 0 on all five touched files with src/eslint-suppressions.json identical to HEAD; prettier --check clean; tsc --noEmit -p . shows only the local junction diagnostic (activate/registerCommands.ts(88,102) TS2739). Controls, each failing for the reason its row describes: committed rule forced off -> the two new revertChanges rows report 'expected undefined to be an instance of CommittedWriteBufferConflictError' and the three teardown-ownership rows report 'expected fulfilled to be rejected'; rule forced on -> the pre-commit restore row plus 12 rows that depend on the restore path fail; verdict observed but ignored -> only the new saveChanges row fails; placeholder written without wx -> the four placeholder rows fail on the call shape, and dropping only the EEXIST translation fails the new race row on the message; move wrapper removed -> the new apply_patch row fails on the missing source path. Three pre-existing rows changed by design rather than to make them green: the three teardown-ownership rows now hold the cancellation's outcome (Promise.allSettled plus an instanceof check) because their scenario is a cancellation landing after the publish; their serialization, preview-restore and single-finalization assertions are untouched. Three open() rows assert the wx call shape for the same reason. Surfaced for the owner, not fixed here: Task.ts:3799 swallows revertChanges with .catch(console.error) on the disposal path while Task.ts:4168 propagates it on the abort path, so disposal will not see this signal. Whether disposal should swallow a 'committed, not undone' report is the disposal contract owner's call. Review rows 4507760734, 4507764622, 4507851689, 4507851696, 4507851698, 4507855182. --- src/core/tools/ApplyPatchTool.ts | 43 ++-- .../__tests__/applyPatchTool.execute.spec.ts | 39 ++++ src/integrations/editor/DiffViewProvider.ts | 108 +++++++++- .../editor/__tests__/DiffViewProvider.spec.ts | 199 +++++++++++++++++- .../committedWriteBufferConflictError.ts | 12 +- 5 files changed, 372 insertions(+), 29 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 7ebdb5d3df..dd4d0572df 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -14,6 +14,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import { versionTokenOfStat } from "../../utils/versionToken" import { canonicalizeForApproval, GuardRejectedError, errorCode } from "./guardedWrite" +import { CommittedWriteBufferConflictError } from "../../integrations/editor/committedWriteBufferConflictError" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { ApplyPatchError, parsePatch, ParseError, processAllHunks } from "./apply-patch" @@ -623,19 +624,35 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } } } - await task.diffViewProvider.saveDirectly( - change.movePath, - newContent, - false, - diagnosticsEnabled, - writeDelayMs, - "create", - sourceComplete, - // Guaranteed by the refusal above: this destination is inside a workspace root, - // so the guard's own containment check is what applies and no approval is claimed. - false, - undefined, - ) + try { + await task.diffViewProvider.saveDirectly( + change.movePath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "create", + sourceComplete, + // Guaranteed by the refusal above: this destination is inside a workspace root, + // so the guard's own containment check is what applies and no approval is claimed. + false, + undefined, + ) + } catch (error) { + if (error instanceof CommittedWriteBufferConflictError) { + // The destination committed and the source unlink below never runs, so the move is + // half-applied: the content is at the new path and the source is still on disk. A model + // that re-applies the patch duplicates the change at the destination while a third path + // still holds the pre-move bytes, so both paths are named and the shared error keeps its + // no-retry guidance. + throw new CommittedWriteBufferConflictError( + moveAbsolutePath, + `apply_patch move: ${change.movePath} committed but ${change.path} was not removed, so the move ` + + "is incomplete: read both paths before continuing.", + ) + } + throw error + } } else { // Write to new path and delete old file const parentDir = path.dirname(moveAbsolutePath) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 796567be30..47352f27c4 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -10,6 +10,7 @@ import type { Task } from "../../task/Task" import { ObservationRegistry } from "../../task/observationRegistry" import { guardedWrite } from "../guardedWrite" import { ApplyPatchTool } from "../ApplyPatchTool" +import { CommittedWriteBufferConflictError } from "../../../integrations/editor/committedWriteBufferConflictError" // The vi.mock factory exposes the fs/promises functions under a `default` // property (matching the SUT's default import), which the static module type @@ -737,6 +738,44 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(reg.get(destKey)?.complete).toBe(true) expect(mockHandleError).not.toHaveBeenCalled() }) + // Row 4507855182 (measured): the destination commits and the source unlink below never runs, + // so a move that hits a buffer conflict is half-applied. The report has to name both paths + // and keep the shared error's no-retry guidance, because the model's next instinct is to + // re-apply the patch - which would duplicate the change at the destination. + it("move: a committed destination with the source still on disk names both paths", async () => { + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockSaveDirectly.mockRejectedValueOnce( + new CommittedWriteBufferConflictError( + destKey, + "saveDirectly: the buffer held unsaved edits that differ from the content the guard published.", + ), + ) + mockedFsPromises.default.unlink.mockClear() + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ name: "CommittedWriteBufferConflictError" }), + ) + const reported = (mockHandleError.mock.calls[0]?.[1] ?? new Error("")) as Error + expect(reported.message).toContain("src/new.ts committed but") + expect(reported.message).toContain("src/old.ts was not removed") + expect(reported.message).toContain("do not retry") + // The half-applied state is stated, not implied: the source was never unlinked, and the + // destination's tool-recorded observation is withdrawn by the finally sweep, so it cannot + // authorize a later edit of content the model never read. + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(reg.get(destKey)).toBeUndefined() + }) it("move: the destination cannot claim completeness when the source was never observed", async () => { // The hunk read records no observation when its stat fails, so the model has diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 602db26149..97e934ba2f 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -26,7 +26,7 @@ import type { FileObservation } from "../../core/task/observationRegistry" import { versionTokenOfStat } from "../../utils/versionToken" import { withFileLock } from "../../utils/fileLock" import { resolveLockKey } from "../../services/file-safety/safeWriteText" -import { guardedWrite, GuardRejectedError, type GuardedWriteKind } from "../../core/tools/guardedWrite" +import { errorCode, guardedWrite, GuardRejectedError, type GuardedWriteKind } from "../../core/tools/guardedWrite" import { DecorationController } from "./DecorationController" @@ -128,6 +128,15 @@ export class DiffViewProvider { * never unlinked. */ private placeholderVersion: string | undefined = undefined + /** + * The publish this session committed: the target and the exact text that landed. A + * cancellation that reaches revertChanges() after a commit must not resurrect + * originalContent through an unguarded document.save(), and must not unlink a file the + * save had already published: the commit is the point of no return, which is what + * CommittedWriteBufferConflictError already names for the no-diff path. Cleared by + * reset(), so a later session cannot inherit it. + */ + private committedPublish: { path: string; content: string } | undefined /** * The observation this path had BEFORE open() recorded the preview's own * version token: null when there was none, undefined when this provider never @@ -237,7 +246,23 @@ export class DiffViewProvider { // Make sure the file exists before we open it. if (!fileExists) { - await fs.writeFile(absolutePath, "") + // Created exclusively. Absence was classified above, and a plain write would silently + // truncate whatever another process created in that window: the placeholder token + // recorded below would then describe the other writer's file, and the guard would + // accept a truncated baseline as the version this edit was built on. A lost race is + // reported, not absorbed. + try { + await fs.writeFile(absolutePath, "", { flag: "wx" }) + } catch (error: unknown) { + if (errorCode(error) === "EEXIST") { + throw new Error( + `Cannot open ${absolutePath} for editing: the file already exists. ` + + "It was absent when this session classified it and another create won the race; " + + "read the file before editing it.", + ) + } + throw error + } // S4b follow-up (#44 / epic #1375): the empty placeholder is fully // known (empty), but verify it with the same S2 stat-matched contract // as the modify branch above: a stat-mismatched or non-empty read @@ -637,6 +662,9 @@ export class DiffViewProvider { approvedCanonicalTarget, additionalRoots: this.additionalWorkspaceRoots(), }) + // From here the session holds a committed write. A cancellation arriving after this + // point reverts the session, not the publish - see revertChanges. + this.committedPublish = { path: absolutePath, content: editedContent } } catch (error) { // Autosave can publish the modified side of the diff before the user // accepts, so the bytes on disk may already be exactly what this save @@ -778,7 +806,25 @@ export class DiffViewProvider { // buffer that moved on stays dirty: the close helpers skip dirty tabs and // the user's text survives in the editor. if (updatedDocument.isDirty && updatedDocument.getText() === editedContent) { - await this.revertDocument(updatedDocument) + const reconciled = await this.revertDocument(updatedDocument) + if (!reconciled) { + // The verdict is load-bearing here for the same reason it is in saveDirectly below: + // a buffer that stays dirty holds the bytes the guard just published, and an ordinary + // save - VS Code's own file service, or a later write - can put them back over a newer + // file. Close and reopen from the published bytes; if that still leaves the buffer + // dirty, report the committed-write conflict instead of proceeding into the save-over + // and returning ordinary success, so the caller cannot treat a committed publish as an + // edit it is free to re-apply. + await this.closeFileTab(absolutePath) + await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) + const reopened = this.findReconciliationDocument(absolutePath) + if (reopened?.isDirty) { + throw new CommittedWriteBufferConflictError( + absolutePath, + "saveChanges: the diff-view save committed its content but could not clear the open buffer.", + ) + } + } } // A save belongs to one task, so its post-publish cleanup closes only this provider's @@ -976,8 +1022,37 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() + // A publish that already committed is not undone by a cancellation. The modify branch below + // would put originalContent back through an unguarded document.save() - the one writer left + // using the call the comment above saveChanges says every other path avoids - and the create + // branch would unlink a file the save had just published, which also discards whatever a + // third party built on it. Same name for the same state as the no-diff path uses: the commit + // is the point of no return, and the caller that asked for the undo is told so. + const committedAfterPublish = + this.committedPublish !== undefined && arePathsEqual(this.committedPublish.path, absolutePath) + const committedContent = this.committedPublish?.content const ownedTeardown = await this.runTeardown( async () => { + if (committedAfterPublish) { + // Clear the dirty flag from the published bytes rather than writing over them, and only + // while the buffer still holds exactly what landed: text typed after the accept survives. + if (updatedDocument.isDirty && updatedDocument.getText() === committedContent) { + await this.revertDocument(updatedDocument) + } + await this.closeOwnDiffView(absolutePath) + // The tab rules are the modify branch's, because the tab situation is the same: a + // committed file the user may or may not have touched. + const committedState = await this.taskRef.deref()?.providerRef.deref()?.getState() + await this.keepOrCloseEditedFile( + absolutePath, + false, + committedState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, + committedState?.autoCloseZooOpenedFilesAfterUserEdited ?? + DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + committedState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + ) + return + } if (!fileExists) { if (updatedDocument.isDirty) { await updatedDocument.save() @@ -1058,6 +1133,16 @@ export class DiffViewProvider { // gets here first claims it; a second waiter joins the claim instead of resetting again. await this.finalizeSession(() => this.reset()) } + if (committedAfterPublish) { + // Reported after the pass so the tab work, the preview restore and the reset all still + // happen: a cancellation that lands on a committed publish closes the session, it just + // cannot roll the content back. Both the owner and any caller that joined the pass see the + // same signal, because both were waiting on this outcome. + throw new CommittedWriteBufferConflictError( + absolutePath, + "revertChanges: a cancellation arrived after the guarded publish had committed.", + ) + } } /** @@ -1788,6 +1873,7 @@ export class DiffViewProvider { this.snapshotPreviewTabs = [] this.placeholderVersion = undefined this.preOpenObservation = undefined + this.committedPublish = undefined } /** @@ -1848,6 +1934,9 @@ export class DiffViewProvider { approvedCanonicalTarget, additionalRoots: this.additionalWorkspaceRoots(), }) + // The publish committed: a later revertChanges on this session reports the committed-write + // conflict instead of rolling the content back. + this.committedPublish = { path: absolutePath, content } // The guarded publish wrote the bytes to disk directly. If the target // document is already open, its buffer still holds the pre-publish text: @@ -1867,7 +1956,10 @@ export class DiffViewProvider { // The publish already committed: report the committed-write buffer conflict. // A generic error would tell the model to retry a non-idempotent edit // (SearchReplaceTool/EditTool would re-read and duplicate the change). - throw new CommittedWriteBufferConflictError(absolutePath) + throw new CommittedWriteBufferConflictError( + absolutePath, + "saveDirectly: the buffer held unsaved edits that differ from the content the guard published.", + ) } const reconciled = await this.revertDocument(openTarget).catch(() => false) if (!reconciled) { @@ -1885,10 +1977,14 @@ export class DiffViewProvider { await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) const reopened = this.findReconciliationDocument(absolutePath) if (reopened?.isDirty) { - throw new Error( + // Same error the branch above uses: the publish committed, so what the caller must not + // do is re-apply the edit. The stale-bytes hazard is named in the detail line, and the + // no-retry guidance comes from the shared error. + throw new CommittedWriteBufferConflictError( + absolutePath, `saveDirectly: the open document at ${absolutePath} is still dirty after the guarded publish -- ` + "reverting and reopening it did not reconcile the buffer, so its stale bytes could overwrite " + - "the published content on the next save; the write is reported as failed.", + "the published content on the next save.", ) } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 833f31b200..c62b026c2c 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2130,7 +2130,9 @@ describe("DiffViewProvider", () => { const placeholderToken = versionTokenOfStat(previewStats) // the placeholder is written, stat-matched, observed (replacing the // vanished file's stale token), and remembered for cleanup - expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "") + // Created exclusively (#new row 4507851689): a plain write would truncate a file another + // process created after the absence was classified. + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "", { flag: "wx" }) expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) expect(mockTask.observationRegistry.get(`${mockCwd}/t3-create.ts`)?.version).toBe(placeholderToken) expect(diffViewProvider["placeholderVersion"]).toBe(placeholderToken) @@ -2157,7 +2159,9 @@ describe("DiffViewProvider", () => { await diffViewProvider.open("t3-dead-task.ts") - expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-dead-task.ts`, "") + // Created exclusively (#new row 4507851689): a plain write would truncate a file another + // process created after the absence was classified. + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-dead-task.ts`, "", { flag: "wx" }) // no live task: nothing observed, but the cleanup token is still // captured (provider state) so the fail-closed save rejection can // remove the placeholder instead of leaking it @@ -2188,13 +2192,46 @@ describe("DiffViewProvider", () => { await diffViewProvider.open("contested.ts") - expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/contested.ts`, "") + // Created exclusively (#new row 4507851689): a plain write would truncate a file another + // process created after the absence was classified. + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/contested.ts`, "", { flag: "wx" }) // nothing recorded, and the vanished file's prior observation was // not replaced with the writer's token expect(mockTask.observationRegistry.get(`${mockCwd}/contested.ts`)?.version).toBe("model-token") expect(diffViewProvider["placeholderVersion"]).toBeUndefined() }) + // Row 4507851689 (measured): the placeholder is a create, and the absence above is a + // classification, not a reservation. A plain write would truncate a file that won the race, + // and the stat-matched observation below would then describe content open() never wrote. + it("open() creates the placeholder exclusively and reports a lost create race", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/race.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/race.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + const write = vi.mocked(fs.writeFile) + write.mockRejectedValueOnce( + Object.assign(new Error(`EEXIST: file already exists, open '${mockCwd}/race.ts'`), { code: "EEXIST" }), + ) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + const failure = await diffViewProvider.open("race.ts").catch((error: unknown) => error) + expect(write).toHaveBeenCalledWith(`${mockCwd}/race.ts`, "", { flag: "wx" }) + expect(failure).toBeInstanceOf(Error) + expect((failure as Error).message).toContain("another create won the race") + // Nothing was observed and no cleanup token was taken, so a later rejected save cannot + // unlink the winner's file. + expect(mockTask.observationRegistry.get(`${mockCwd}/race.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + it("open() does not record the placeholder when the post-stat fails after the write", async () => { // The bracketing stats must both succeed: a failed post-stat means // the read is not trustworthy, so nothing is recorded. @@ -3696,7 +3733,13 @@ describe("DiffViewProvider", () => { expect(applyEdit).not.toHaveBeenCalled() release() - await Promise.all([save, revert]) + // The publish had already committed when this cancellation arrived, so the revert now + // reports the committed-write conflict instead of restoring originalContent. Held rather + // than swallowed: the serialization property below is about who touched the tabs. + const settled = await Promise.allSettled([save, revert]) + expect(settled[0].status).toBe("fulfilled") + expect(settled[1].status).toBe("rejected") + expect((settled[1] as PromiseRejectedResult).reason).toBeInstanceOf(CommittedWriteBufferConflictError) // One pass over the tabs, and the cancellation did not repeat the document revert. // Only this provider's tab is touched, by either caller. @@ -3742,7 +3785,11 @@ describe("DiffViewProvider", () => { const revert = diffViewProvider.revertChanges() await new Promise((resolve) => setImmediate(resolve)) release() - await Promise.all([save, revert]) + // Same post-commit cancellation as the row above: the conflict report is awaited, not + // swallowed, so this row keeps testing tab and reset counts on its own. + const settled = await Promise.allSettled([save, revert]) + expect(settled[0].status).toBe("fulfilled") + expect((settled[1] as PromiseRejectedResult).reason).toBeInstanceOf(CommittedWriteBufferConflictError) // The owning pass restored the preview tabs exactly once, and the waiting caller // finalized the session exactly once - the pass that ran the tabs does not reset, so @@ -3794,7 +3841,10 @@ describe("DiffViewProvider", () => { const revert = diffViewProvider.revertChanges() await new Promise((resolve) => setImmediate(resolve)) release() - const [saveResult] = await Promise.all([save, revert]) + // The publish committed before Task.disposeOnce() reached revertChanges(), so the + // cancellation closes the session and reports the committed-write conflict instead of + // writing originalContent back over it. + const settled = await Promise.allSettled([save, revert]) // The session is closed: no edit flag, no retained editor, listeners and timer gone. expect(diffViewProvider.isEditing).toBe(false) @@ -3814,11 +3864,146 @@ describe("DiffViewProvider", () => { // A session a cancellation is closing reports no completed save, so no caller can present // diagnostics or user-edit output for provider state that is already gone. - expect(saveResult).toEqual({ + expect(settled[0].status).toBe("fulfilled") + expect( + (settled[0] as PromiseFulfilledResult>>).value, + ).toEqual({ newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined, }) + // The caller that asked for the undo is told which of the two it got. + expect(settled[1].status).toBe("rejected") + expect((settled[1] as PromiseRejectedResult).reason).toBeInstanceOf(CommittedWriteBufferConflictError) + }) + + // Row 4507760734 (measured): the guarded path treats the commit as the point of no return, + // which is why CommittedWriteBufferConflictError exists at all. The modify branch's + // updatedDocument.save() is the one writer left that republishes through VS Code's unguarded + // file service - the call the comment above saveChanges says every other path avoids - so a + // cancellation that lands after the publish has to leave the published bytes and say so. + it("revertChanges() after a committed publish leaves the published content and reports the conflict", async () => { + const saveDoc = vi.fn().mockResolvedValue(undefined) + const document = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: saveDoc, + }) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + applyEdit.mockClear() + vi.mocked(fs.unlink).mockClear() + + const outcome = await diffViewProvider.revertChanges().catch((error: unknown) => error) + expect(outcome).toBeInstanceOf(CommittedWriteBufferConflictError) + // No originalContent write, and no unguarded document.save() over the commit. + expect(applyEdit).not.toHaveBeenCalled() + expect(saveDoc).not.toHaveBeenCalled() + expect(diffViewProvider.isEditing).toBe(false) + expect(diffViewProvider["committedPublish"]).toBeUndefined() + }) + + // The other direction, without which the fix above could quietly delete the restore: + // a cancellation that lands before any publish still puts the file back. + it("revertChanges() before any publish still restores the original content", async () => { + const saveDoc = vi.fn().mockResolvedValue(undefined) + const document = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: saveDoc, + }) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + expect(diffViewProvider["committedPublish"]).toBeUndefined() + + await expect(diffViewProvider.revertChanges()).resolves.toBeUndefined() + expect(applyEdit).toHaveBeenCalledTimes(1) + expect(saveDoc).toHaveBeenCalledTimes(1) + expect(diffViewProvider.isEditing).toBe(false) + }) + + // Row 4507851696 (measured): the create branch's fs.unlink is the delete that can take a + // newly published file with it - including whatever another writer built on that path in + // the meantime, since the unlink is not version-checked. + it("revertChanges() after a committed create does not unlink the published file", async () => { + const document = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "create" + diffViewProvider["originalContent"] = "" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + vi.mocked(fs.unlink).mockClear() + + const outcome = await diffViewProvider.revertChanges().catch((error: unknown) => error) + expect(outcome).toBeInstanceOf(CommittedWriteBufferConflictError) + expect(fs.unlink).not.toHaveBeenCalledWith(mockTargetPath) + expect(diffViewProvider.isEditing).toBe(false) + }) + + // Row 4507764622 (measured): the post-publish cleanup discarded revertDocument's verdict. + // A buffer that stays dirty after the reopen still holds the bytes the guard published, so + // the save reports the committed-write conflict instead of proceeding into the save-over + // and returning ordinary success - the same name the no-diff path uses for the same state. + it("saveChanges() reports the committed-write conflict when it cannot clear the buffer it published", async () => { + const document = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }) + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.length = 0 + documents.push(document) + diffViewProvider["revertDocument"] = vi.fn().mockResolvedValue(false) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + + const failure = await diffViewProvider.saveChanges(false).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CommittedWriteBufferConflictError) + // The detail names this window; the guidance is the shared one, not a new vocabulary. + expect((failure as Error).message).toContain("saveChanges: the diff-view save committed its content") + expect((failure as Error).message).toContain("do not retry") + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + expect(diffViewProvider["closeFileTab"]).toHaveBeenCalledTimes(1) + documents.length = 0 }) // The same gap on the other teardown owner: a rejected publish's discard cleanup never resets diff --git a/src/integrations/editor/committedWriteBufferConflictError.ts b/src/integrations/editor/committedWriteBufferConflictError.ts index 41a4dd739e..4f2c4641cf 100644 --- a/src/integrations/editor/committedWriteBufferConflictError.ts +++ b/src/integrations/editor/committedWriteBufferConflictError.ts @@ -4,14 +4,20 @@ * committed: callers must not retry the same (possibly non-idempotent) edit, * because re-reading and re-applying it would duplicate the change. * + * Each site adds a `detail` line naming the window it hit - the save that could not + * clear a dirty buffer, the cancellation that arrived after a commit, the move whose + * source unlink never ran - while the guidance below stays identical everywhere, so one + * name and one set of instructions covers every committed-write conflict. + * * Kept in its own vscode-free module so tool-level tests can construct it * without loading the DiffViewProvider's editor dependencies. */ export class CommittedWriteBufferConflictError extends Error { - constructor(absolutePath: string) { + constructor(absolutePath: string, detail?: string) { super( - `saveDirectly: the guarded publish committed the accepted content to ${absolutePath}, but the file is ` + - "open with unsaved edits that differ from it; the buffer was kept so those edits are not lost. " + + (detail ? `${detail} ` : "") + + `The guarded publish committed the accepted content to ${absolutePath}, but an open buffer holds ` + + "unsaved edits that differ from it; the buffer was kept so those edits are not lost. " + "The write is already committed to disk -- do not retry this edit. Save or discard the buffer, " + "then read the file again before making a separate edit.", ) From 26afbd971aeee6bdbad51031a89d99e7c6ab63a9 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:50:53 +0800 Subject: [PATCH 098/115] S4b (#1375): a committed write that is not durable keeps its authorization The rename is the point of no return for the content, and the observation was not following it. When the commit landed but the parent-directory fsync did not, PostCommitDurabilityError unwound past guardedWrite's post-publish refresh, so the observation stayed at the version the read recorded. The next edit of that same file - content the tool itself had just written - was then refused as stale against bytes that no longer existed, and ApplyPatchTool's finally sweep withdrew the read entirely because the publish had not returned. Withdrawal is the right disposition for a publish that did not happen and the wrong one for content that is on disk. PostCommitDurabilityError now carries committedVersion: the version token the commit landed, read best-effort with the same derivation every guard uses, and the message says what to do with it - "committed, not durable: do not re-issue the write; read the file again before editing it" - naming the version so the instruction is checkable rather than a style preference. When the post-commit stat itself fails, the field stays undefined and the message says the same thing without inventing a version. guardedWrite treats that error as a publish that succeeded with a caveat: the token the error carries feeds the same refresh a successful publish gets - keeping a partial observation partial, because the branch that sets that never ran - and the durability failure is thrown after the refresh, so the caller sees the accepted state and the caveat together. A failure before the rename still unwinds untouched: nothing was committed to record. That ordering is what ApplyPatchTool's sweep depends on. It forgets a tool-recorded read only when the stored entry is still the one it recorded, so an entry the refresh re-earned survives, and the withdrawal still applies to every read whose publish never landed. ApplyPatchTool needed no production change for that - the sweep was already keyed to the entry rather than the path - so its half of this commit is the row that pins the property. This commit touches services/file-safety/safeWriteText.ts and core/tools/guardedWrite.ts in production, and three spec lanes. It is deliberately one commit: the token is meaningless without the refresh that reads it, and the refresh is meaningless without the error that carries it. Gates: safeWriteText 77 passed (2 rows new), guardedWrite 73 passed (4 rows new), applyPatchTool.execute 34 passed (1 row new), applyPatchTool.partial and the applyDiff/apply-patch lanes green, write-path callers (writeToFileTool, editFileTool, editTool, searchReplaceTool) green, task abort races 9 passed, DiffViewProvider 168 passed, safeWriteJson + fileLock 81 passed | 4 skipped. Together the blast-radius run is 14 files, 530 passed | 5 skipped. eslint --max-warnings=0 exit 0 on all seven touched files; --prune-suppressions run afterwards and src/eslint-suppressions.json restored to HEAD (a narrow prune pass also drops records for files outside the selection, so it is never committed). prettier --check clean. tsc --noEmit -p . shows only activate/registerCommands.ts(88,102) TS2739, the local junction artifact. No it.runIf(win32) gate added anywhere; the parent-directory fsync path is POSIX-only by construction and the rows drive it with the platform option, not with an OS gate. Controls, each failing only for the reason its row describes: not recognizing the committed durability error -> the three durability rows report 'expected v1 to be v2'; refreshing but swallowing the failure -> the same three rows report 'promise resolved instead of rejecting'; not mirroring the partial flag -> only the partial row, 'expected true to be false'; not reading the committed version -> only the token row, 'expected undefined to be 7:8:4:9:10'; keying the sweep to the path instead of the entry -> only the apply_patch row, 'expected undefined to be committed-token'. The row that pins the pre-commit case (a publish refused before the rename leaves the read's version) passes under every one of those controls, which is the point of writing it as its own row. Three spec files had to expose the real error class through their module mock of safeWriteText (guardedWrite.spec, DiffViewProvider.spec and this lane's stubs): without it 37 provider-lane rows failed with 'No "PostCommitDurabilityError" export is defined on the mock', and a locally defined stand-in class would have let rows pass against a shape production does not build, so the factories now pull the real class through importOriginal while keeping the publish primitive stubbed. Review rows 4507764645 and 4507851715. --- .../__tests__/applyPatchTool.execute.spec.ts | 35 ++++ src/core/tools/__tests__/guardedWrite.spec.ts | 99 ++++++++++- src/core/tools/guardedWrite.ts | 157 +++++++++++------- .../editor/__tests__/DiffViewProvider.spec.ts | 15 +- .../__tests__/safeWriteText.spec.ts | 64 +++++++ src/services/file-safety/safeWriteText.ts | 33 +++- 6 files changed, 327 insertions(+), 76 deletions(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 47352f27c4..8b4d0f77a7 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -11,6 +11,7 @@ import { ObservationRegistry } from "../../task/observationRegistry" import { guardedWrite } from "../guardedWrite" import { ApplyPatchTool } from "../ApplyPatchTool" import { CommittedWriteBufferConflictError } from "../../../integrations/editor/committedWriteBufferConflictError" +import { PostCommitDurabilityError } from "../../../services/file-safety/safeWriteText" // The vi.mock factory exposes the fs/promises functions under a `default` // property (matching the SUT's default import), which the static module type @@ -310,6 +311,40 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockHandleError).not.toHaveBeenCalled() }) + // Row 4507764645: the finally sweep withdraws the read behind a publish that never + // landed. When the guarded commit landed and only the durability step failed, the guard + // re-records at the committed token, and the sweep must leave that entry alone - the + // stored entry is no longer the one the tool recorded, so the withdrawal does not fit. + it("update: a publish that committed without durability keeps the re-recorded observation", async () => { + const targetKey = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + // The double stands in for the guard's own pair of actions - re-record at the + // committed token, then report the durability failure - so this row tests the sweep's + // identity key, which is ApplyPatchTool's half of the rule. + mockSaveDirectly.mockImplementationOnce(async () => { + reg.observe(targetKey, "committed-token", false) + throw new PostCommitDurabilityError( + targetKey, + new Error("EBADF: bad file descriptor"), + null, + "committed-token", + ) + }) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ name: "PostCommitDurabilityError" }), + ) + expect(reg.get(targetKey)?.version).toBe("committed-token") + expect(mockPushToolResult).not.toHaveBeenCalled() + }) it("update: keeps a partial observation partial so a later full-file write is still rejected", async () => { // The hunk read is the tool's own read, not a model read. Upgrading the // model's partial view to complete here would let a later write_to_file diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index b1d48789a8..8fa8793a1f 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -15,7 +15,7 @@ import * as path from "path" import { describe, expect, it, beforeEach, vi } from "vitest" import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain, GuardRejectedError } from "../guardedWrite" -import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { PostCommitDurabilityError, safeWriteText } from "../../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../../utils/versionToken" import { withFileLock } from "../../../utils/fileLock" import { resolveLockKey } from "../../../services/file-safety/safeWriteText" @@ -35,10 +35,17 @@ vi.mock("../../../utils/versionToken", () => ({ computeVersionToken: vi.fn(), })) -vi.mock("../../../services/file-safety/safeWriteText", () => ({ - safeWriteText: vi.fn(), - resolveLockKey: vi.fn(async (p: string) => p), -})) +vi.mock("../../../services/file-safety/safeWriteText", async (importOriginal) => { + // The durability branch is matched by name, so the class has to be the real one - a + // local stand-in would let the rows below pass against a shape production does not + // build. The publish primitive stays a stub. + const actual = await importOriginal() + return { + safeWriteText: vi.fn(), + resolveLockKey: vi.fn(async (p: string) => p), + PostCommitDurabilityError: actual.PostCommitDurabilityError, + } +}) vi.mock("../../../utils/fileLock", () => ({ withFileLock: vi.fn(), @@ -1332,4 +1339,86 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(staleError?.path).toBe("src/thing.ts") expect(mockedSafeWriteText).not.toHaveBeenCalled() }) + + describe("post-commit durability failure", () => { + // Row 4507764645 / 4507851715: the rename is the point of no return for the + // observation as well as for the content. A write whose commit landed but whose + // parent-directory fsync did not has to leave the caller authorized against the bytes + // that are actually on disk, while the durability failure is still reported. + const durabilityError = (target: string, committedVersion?: string): PostCommitDurabilityError => + new PostCommitDurabilityError(target, new Error("EBADF: bad file descriptor"), null, committedVersion) + + it("keeps the observation on the committed version and still reports the failure", async () => { + const target = abs("durability.txt") + const task = createMockTask() + task.observationRegistry.observe(target, "v1", true) + mockedComputeVersionToken.mockResolvedValue("v1") // the version check passes + const failure = durabilityError(target, "v2") + mockedSafeWriteText.mockRejectedValueOnce(failure) + + await expect(guardedWrite(task, "durability.txt", "new", "update")).rejects.toBe(failure) + + // The content on disk is v2's bytes; recording v1 would refuse the next edit of a + // file this very write changed, and recording nothing would withdraw authority the + // tool earned by writing the content itself. + expect(task.observationRegistry.get(target)?.version).toBe("v2") + expect(failure.message).toContain("committed, not durable") + expect(failure.committedVersion).toBe("v2") + }) + + it("accepts a later write that matches the committed version", async () => { + const target = abs("second-write.txt") + const task = createMockTask() + task.observationRegistry.observe(target, "v1", true) + mockedComputeVersionToken.mockResolvedValue("v1") + mockedSafeWriteText.mockRejectedValueOnce(durabilityError(target, "v2")) + + const first = await guardedWrite(task, "second-write.txt", "first", "update").catch( + (error: unknown) => error, + ) + expect(first).toBeInstanceOf(PostCommitDurabilityError) + + // The second write compares against the recorded version, which the refresh moved + // to the committed token: it runs, and its own publish is not refused as stale. + mockedComputeVersionToken.mockResolvedValue("v2") + mockedSafeWriteText.mockResolvedValue(undefined) + await guardedWrite(task, "second-write.txt", "second", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(task.observationRegistry.get(target)?.version).toBe("v2") + }) + + it("leaves the observation at the read version when nothing was committed", async () => { + const target = abs("pre-commit.txt") + const task = createMockTask() + task.observationRegistry.observe(target, "v1", true) + mockedComputeVersionToken.mockResolvedValue("v1") + const enospc = Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC" }) + mockedSafeWriteText.mockRejectedValueOnce(enospc) + + await expect(guardedWrite(task, "pre-commit.txt", "new", "update")).rejects.toBe(enospc) + + // The other half of the rule: only a commit re-records. A failure before the rename + // leaves the read's version as the only true one. + expect(task.observationRegistry.get(target)?.version).toBe("v1") + }) + + it("keeps a partial observation partial when the publish is remembered", async () => { + const target = abs("partial-durability.txt") + const task = createMockTask() + task.observationRegistry.observe(target, "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + mockedSafeWriteText.mockRejectedValueOnce(durabilityError(target, "v2")) + + await expect(guardedWrite(task, "partial-durability.txt", "new", "edit")).rejects.toBeInstanceOf( + PostCommitDurabilityError, + ) + + // The branch that sets this never ran on the durability path: an edit authorized by + // a slice must not become authority for a full-file replacement of content the model + // never read. + expect(task.observationRegistry.get(target)?.complete).toBe(false) + expect(task.observationRegistry.get(target)?.version).toBe("v2") + }) + }) }) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index baa581a064..cdbb704359 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -24,7 +24,7 @@ import * as fs from "fs/promises" import * as path from "path" -import { safeWriteText } from "../../services/file-safety/safeWriteText" +import { PostCommitDurabilityError, safeWriteText } from "../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../utils/versionToken" import { withFileLock } from "../../utils/fileLock" import { resolveLockKey } from "../../services/file-safety/safeWriteText" @@ -591,72 +591,95 @@ export async function guardedWrite( // this write published rather than a peer writer's. let publishedToken: string | undefined - if (kind === "edit") { - // Edit-style writes require a prior read: no observation, no write. - // A targeted edit only authorizes the view the model saw, so a - // partial observation is valid for the edit itself; the version - // check still rejects a file that moved since the read. - if (obs === undefined) { - await unobservedEditGuard(absolutePath, displayPath) + // Set when the commit landed and only the durability step failed: the publish is + // kept, the observation is refreshed at the token the error carries, and the + // failure is reported after that refresh so the caller sees both facts. + let committedButNotDurable: PostCommitDurabilityError | undefined + try { + if (kind === "edit") { + // Edit-style writes require a prior read: no observation, no write. + // A targeted edit only authorizes the view the model saw, so a + // partial observation is valid for the edit itself; the version + // check still rejects a file that moved since the read. + if (obs === undefined) { + await unobservedEditGuard(absolutePath, displayPath) + } else { + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + verifyTarget, + ) + staysPartial = obs.complete === false + } } else { - publishedToken = await replaceIfVersion( - absolutePath, - obs.version, - content, - displayPath, - () => task.abort, - verifyTarget, - ) - staysPartial = obs.complete === false - } - } else { - // "create" or "update" publish a full file built on the model's - // content. A replacement of an existing target -- an "update", or a - // "create" whose target is still on disk -- therefore requires a - // complete observation: a slice, range, truncated, or - // indentation-block read only authorizes the view the model saw, and - // publishing over the existing file would silently drop everything - // the model never read, so the guard fails closed with a - // re-read-the-whole-file remediation. A fresh create (absent target) - // needs no prior read and stays allowed. - const absent = kind === "create" && (await fileIsAbsent(absolutePath)) - if (!absent && obs !== undefined && obs.complete === false) { - throw new GuardRejectedError( - "File was only partially read (line slice, range, truncated view, or indentation block) -- " + - "a full-file replacement needs the complete content; re-read the whole file, then retry.", - displayPath, - ) - } + // "create" or "update" publish a full file built on the model's + // content. A replacement of an existing target -- an "update", or a + // "create" whose target is still on disk -- therefore requires a + // complete observation: a slice, range, truncated, or + // indentation-block read only authorizes the view the model saw, and + // publishing over the existing file would silently drop everything + // the model never read, so the guard fails closed with a + // re-read-the-whole-file remediation. A fresh create (absent target) + // needs no prior read and stays allowed. + const absent = kind === "create" && (await fileIsAbsent(absolutePath)) + if (!absent && obs !== undefined && obs.complete === false) { + throw new GuardRejectedError( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + displayPath, + ) + } - if (obs === undefined) { - // Never read: only an absent target may be created. - publishedToken = await createIfAbsent( - absolutePath, - content, - displayPath, - () => task.abort, - verifyTarget, - ) - } else if (absent) { - // A "create" on a file that vanished after the read recreates it. - publishedToken = await createIfAbsent( - absolutePath, - content, - displayPath, - () => task.abort, - verifyTarget, - ) + if (obs === undefined) { + // Never read: only an absent target may be created. + publishedToken = await createIfAbsent( + absolutePath, + content, + displayPath, + () => task.abort, + verifyTarget, + ) + } else if (absent) { + // A "create" on a file that vanished after the read recreates it. + publishedToken = await createIfAbsent( + absolutePath, + content, + displayPath, + () => task.abort, + verifyTarget, + ) + } else { + // The version recorded at read time must still match the on-disk + // token. + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + verifyTarget, + ) + } + } + } catch (error: unknown) { + // The rename committed; only the parent-directory fsync failed. The content on disk + // is the content this write published, so the token the error carries authorizes the + // same refresh any successful publish gets below, and the durability failure is + // rethrown after it. Withdrawing the observation here would leave the caller holding + // a token for bytes that are no longer on disk and refusing the next edit of a file + // this very write changed. Anything else - a guard refusal, a pre-commit I/O failure - + // unwinds untouched, because there is nothing committed to record. + if (error instanceof PostCommitDurabilityError && typeof error.committedVersion === "string") { + publishedToken = error.committedVersion + committedButNotDurable = error + // The branch that would have set this never ran: an edit built on a partial + // observation stays partial even when the publish is remembered. + staysPartial = kind === "edit" && obs?.complete === false } else { - // The version recorded at read time must still match the on-disk - // token. - publishedToken = await replaceIfVersion( - absolutePath, - obs.version, - content, - displayPath, - () => task.abort, - verifyTarget, - ) + throw error } } @@ -673,6 +696,12 @@ export async function guardedWrite( // publish content built from the slice alone. task.observationRegistry.observe(absolutePath, publishedToken, completeOverride ?? !staysPartial) } + + // The refresh above is what keeps the committed content authorized; the durability + // caveat is not swallowed by it. + if (committedButNotDurable !== undefined) { + throw committedButNotDurable + } }) } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index c62b026c2c..ac802d35a1 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -48,10 +48,17 @@ vi.mock("fs/promises", () => ({ })) // Mock safeWriteText (used by saveDirectly) -vi.mock("../../../services/file-safety/safeWriteText", () => ({ - safeWriteText: vi.fn().mockResolvedValue(undefined), - resolveLockKey: vi.fn(async (p: string) => p), -})) +vi.mock("../../../services/file-safety/safeWriteText", async (importOriginal) => { + // guardedWrite matches the durability branch by name, so the class has to come from + // the real module: a stand-in would let rows pass against a shape production does not + // build. The publish primitive stays a stub. + const actual = await importOriginal() + return { + ...actual, + safeWriteText: vi.fn(), + resolveLockKey: vi.fn(async (p: string) => p), + } +}) // Mock the S1 version token (used by the S4 guarded write); the real // computeVersionToken needs fs.stat, which is not part of the fs/promises mock above. diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 1f10b094d4..f8b19131f7 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1231,6 +1231,70 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) + // Row 4507851715: the guidance this error gives - do not re-issue the write - is only + // checkable against a version, so the error has to name the version it committed. + it("carries the version the commit landed so the caller can say what is on disk", 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") + }) + // A BigIntStats stand-in carrying the five fields the version token renders. + // Double assertion: the runtime value is the stand-in, the type is the bigint variant. + const committed = Object.create(fsSync.Stats.prototype) as unknown as fsSync.BigIntStats + committed.dev = 7n + committed.ino = 8n + committed.size = 4n + committed.mtimeNs = 9n + committed.ctimeNs = 10n + vi.mocked(fs.stat).mockResolvedValue(committed) + + let thrown: unknown + try { + await safeWriteText(targetPath, "data", { platform: "linux" }) + } catch (error: unknown) { + thrown = error + } + + expect(thrown).toBeInstanceOf(PostCommitDurabilityError) + const failure = thrown as PostCommitDurabilityError + // dev:ino:size:mtimeNs:ctimeNs, the same derivation every guard compares against. + expect(failure.committedVersion).toBe("7:8:4:9:10") + expect(failure.message).toContain("version 7:8:4:9:10") + expect(failure.message).toContain("committed, not durable") + expect(failure.message).toContain("do not re-issue the write") + expect(failure.targetPath).toBe(targetPath) + }) + + it("still says not to re-issue when the committed version cannot be read", 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") + }) + // fs.stat defaults to ENOENT here, which is the shape of a target that vanished + // between the commit and the version read. + + let thrown: unknown + try { + await safeWriteText(targetPath, "data", { platform: "linux" }) + } catch (error: unknown) { + thrown = error + } + + expect(thrown).toBeInstanceOf(PostCommitDurabilityError) + const failure = thrown as PostCommitDurabilityError + expect(failure.committedVersion).toBeUndefined() + expect(failure.message).toContain("committed, not durable: do not re-issue it") + expect(failure.message).not.toContain("undefined") + // The commit is not undone by the failed read: the content is still at the target. + 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" }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 8e0b704293..b9386c62ac 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -1,6 +1,8 @@ import * as fs from "fs/promises" import * as fsSync from "fs" import * as path from "path" + +import { computeVersionToken } from "../../utils/versionToken" import { execFile } from "child_process" export interface SafeWriteTextOptions { @@ -65,6 +67,11 @@ export class StagingPathError extends Error { * directory entry is not known to be durable. The content is at the target; the * caller cannot assume it survives a crash. Reported as its own error so a * successful return never claims durability the filesystem did not grant. + * + * It also carries the version token the commit landed. The guidance this error gives + * - do not re-issue the write - is only checkable against a version, and a guarded + * write that wants to keep authorizing the content it just published has to record + * the version that is on disk rather than the one its read observed. */ export class PostCommitDurabilityError extends Error { readonly targetPath: string @@ -75,15 +82,26 @@ export class PostCommitDurabilityError extends Error { * deliberately keeps. */ readonly backupPath: string | null + /** + * The version token of the content the commit landed, derived the way every guard + * derives one (`computeVersionToken`), read best-effort after the commit. Undefined + * when the post-commit stat failed: the commit stands either way, and a caller that + * cannot name the version says so rather than guessing. + */ + readonly committedVersion: string | undefined - constructor(targetPath: string, cause: unknown, backupPath: string | null = null) { + constructor(targetPath: string, cause: unknown, backupPath: string | null = null, committedVersion?: string) { super( - "The rename committed but the parent directory could not be fsynced -- the content is at the target path reported on this error, and the directory entry may not be durable.", + "The rename committed but the parent directory could not be fsynced -- the content is at the target path reported on this error, and the directory entry may not be durable. " + + (committedVersion !== undefined + ? `The content this write committed is version ${committedVersion} -- committed, not durable: do not re-issue the write; read the file again before editing it.` + : "This write is committed, not durable: do not re-issue it; read the file again before editing it."), { cause }, ) this.name = "PostCommitDurabilityError" this.targetPath = targetPath this.backupPath = backupPath + this.committedVersion = committedVersion } } // -- helpers --------------------------------------------------------------- @@ -690,7 +708,16 @@ export async function safeWriteText( // The catch block keeps the backup on this path, so hand the caller the // path of the copy it is keeping - it is a hidden random name and is // otherwise unrecoverable. - throw new PostCommitDurabilityError(targetPath, error, releaseBackupOnSuccess ? backupPath : null) + // Read the version the commit landed, the same derivation every guard compares + // against, so a caller can record which content is on disk and a later write can + // be authorized against it. Best-effort: a failed stat does not undo the commit. + const committedVersion = await computeVersionToken(targetPath).catch(() => undefined) + throw new PostCommitDurabilityError( + targetPath, + error, + releaseBackupOnSuccess ? backupPath : null, + committedVersion, + ) } } From d9213084f8be7dd5f93793047db18141c45102ac Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:01:42 +0800 Subject: [PATCH 099/115] S4b (#1375): name the rejection the committed-publish rows were asserting The three teardown-ownership rows moved to Promise.allSettled when the cancellation they describe started landing after the guarded publish, and allSettled plus a status check is a fine way to watch a promise reject - it is also how a row ends up asserting that something rejected somewhere. Each of the three now binds the reason and asserts both halves of it: the class, and the message that identifies the window the row describes - a cancellation that arrived after the guarded publish had committed. A conflict from a different window satisfies the class alone and would have let these rows pass for the wrong reason. The durability row in guardedWrite.spec gains the other direction of the same state: a full-file publish whose parent-directory fsync failed records a complete observation (complete === true), because staysPartial is true only for an edit authorized by a partial read - which is what the partial row beside it pins. A caveat about durability has to be distinguishable from a half-applied write, and reporting the wrong one of the two would have made the error nicer without making it true. Test-only. DiffViewProvider 168 passed, guardedWrite 73 passed, eslint exit 0 and prettier clean on both files, src/eslint-suppressions.json unchanged. --- src/core/tools/__tests__/guardedWrite.spec.ts | 4 ++++ .../editor/__tests__/DiffViewProvider.spec.ts | 24 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 8fa8793a1f..48bd18f87e 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -1362,6 +1362,10 @@ describe("guardedWrite (S4a, epic #1375)", () => { // file this very write changed, and recording nothing would withdraw authority the // tool earned by writing the content itself. expect(task.observationRegistry.get(target)?.version).toBe("v2") + // The other direction of the same state: this publish replaced a complete file the + // model had read, so a caveat about durability must not report the write as + // half-applied - a consumer has to tell 'landed, not durable' from 'half-applied'. + expect(task.observationRegistry.get(target)?.complete).toBe(true) expect(failure.message).toContain("committed, not durable") expect(failure.committedVersion).toBe("v2") }) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index ac802d35a1..94b4f1e036 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3746,7 +3746,13 @@ describe("DiffViewProvider", () => { const settled = await Promise.allSettled([save, revert]) expect(settled[0].status).toBe("fulfilled") expect(settled[1].status).toBe("rejected") - expect((settled[1] as PromiseRejectedResult).reason).toBeInstanceOf(CommittedWriteBufferConflictError) + const rejection = (settled[1] as PromiseRejectedResult).reason as CommittedWriteBufferConflictError + expect(rejection).toBeInstanceOf(CommittedWriteBufferConflictError) + // Not merely that something rejected: this is the window the row describes - a + // cancellation that arrived after the guarded publish had committed. + expect(rejection.message).toContain( + "revertChanges: a cancellation arrived after the guarded publish had committed.", + ) // One pass over the tabs, and the cancellation did not repeat the document revert. // Only this provider's tab is touched, by either caller. @@ -3796,7 +3802,13 @@ describe("DiffViewProvider", () => { // swallowed, so this row keeps testing tab and reset counts on its own. const settled = await Promise.allSettled([save, revert]) expect(settled[0].status).toBe("fulfilled") - expect((settled[1] as PromiseRejectedResult).reason).toBeInstanceOf(CommittedWriteBufferConflictError) + const rejection = (settled[1] as PromiseRejectedResult).reason as CommittedWriteBufferConflictError + expect(rejection).toBeInstanceOf(CommittedWriteBufferConflictError) + // Not merely that something rejected: this is the window the row describes - a + // cancellation that arrived after the guarded publish had committed. + expect(rejection.message).toContain( + "revertChanges: a cancellation arrived after the guarded publish had committed.", + ) // The owning pass restored the preview tabs exactly once, and the waiting caller // finalized the session exactly once - the pass that ran the tabs does not reset, so @@ -3881,7 +3893,13 @@ describe("DiffViewProvider", () => { }) // The caller that asked for the undo is told which of the two it got. expect(settled[1].status).toBe("rejected") - expect((settled[1] as PromiseRejectedResult).reason).toBeInstanceOf(CommittedWriteBufferConflictError) + const rejection = (settled[1] as PromiseRejectedResult).reason as CommittedWriteBufferConflictError + expect(rejection).toBeInstanceOf(CommittedWriteBufferConflictError) + // Not merely that something rejected: this is the window the row describes - a + // cancellation that arrived after the guarded publish had committed. + expect(rejection.message).toContain( + "revertChanges: a cancellation arrived after the guarded publish had committed.", + ) }) // Row 4507760734 (measured): the guarded path treats the commit as the point of no return, From 43d859d9c4cd16b407b3b5e5e6046d21e63d981b Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:01:43 +0800 Subject: [PATCH 100/115] S4b (#1375): create the directory the lock needs before taking the lock proper-lockfile creates the lock artefact with a non-recursive mkdir. A guarded write into a directory that did not exist yet therefore failed inside acquireFileLock with ENOENT - before the publish primitive ran, and so before the rollback the publish owns could do anything about it - and left the empty tree behind for DiffViewProvider.ts:1843-:1845 and safeWriteText.ts:437-:443 to defend against. The directory the artefact needs is now created first, and the artefact itself is still derived only from the guarded path. The empirical answer on the alternative, measured on a real temporary tree rather than argued: writer A took .lock, created the missing parent under it, and writer B - arriving after the parent existed, for the file A was about to write - acquired /nested/state.json.lock while A still held its lock. Those are different locations, so B did not contend at all. Widening the lock to an ancestor means the artefact depends on what happens to exist at the moment each writer derives it, which is the divergence class the repair exists to close. With the shipped derivation the same late writer is refused: 'Lock file is already being held'. That is why the mkdir moved before the lock rather than the lock moving to the ancestor. 'Only the directories this call created' is answered by asking the filesystem to create and reading the answer, not by statting: recursive mkdir resolves with the first directory it actually made, and with nothing when every level already existed. Measured on this host, that answer arrives with an extended-length prefix (\\?\C:\...); comparing it with the chain unnormalised made a rollback climb past the tree it had built and remove directories that were already there. It is normalised before it is compared, the rollback runs deepest- first and swallows its own failures, and the acquisition error reaches the caller unchanged. The first version of this walked the chain with fs.stat, and that was wrong for a reason I only found by running the neighbours: it reddened two safeWriteJson rows, including safeWriteJson.stagingDirectory.spec.ts's 'stages anyway under the task persistence suite's blanket missing stat', whose whole point is that a write must not start asking fs.stat questions about the directory it is allowed to write into - the task-persistence suites mock fs.stat to reject for every path. The create-shape asks no such question and both rows are green again. There is deliberately no fs.stat call in the chain builder. Rows: fileLock.test.ts 2 (the parent exists before the lock is taken and the artefact stayed at lockPathFor(path); a failed acquisition gives back only what it created, and a directory that already existed survives it). fileLockContention.test.ts 2, against the real proper-lockfile on real directories (a writer arriving after the directory exists contends with the writer that created it; two writers racing into the same missing directory leave exactly one holding the path, the loser refused with 'already being held', and no lock artefact leaks). fileLockChain.test.ts 2, where the doubles hold Node's recursive-mkdir contract and the assertions read the calls: nothing is claimed when nothing was created, and only the reported levels are given back, deepest first. Controls, each failing only for the reason its row describes: skipping the create fails the two creation rows and both contention rows ('ENOENT ... mkdir .lock', 'expected [] to have a length of 1'); claiming something even when the create reported nothing fails the nothing-claimed row alone ('expected vi.fn() to not be called at all, but actually been called 1 times'); claiming one level above what was created fails both only-what-it-created rows ('ENOENT ... stat /one'); keeping what was created fails the two rollback rows; moving the artefact to the parent directory fails both contention rows and the artefact-location assertion, and also the pre-existing drift-guard row, which is right to fail when the artefact stops being lockPathFor(path); comparing the create's answer without normalising it fails the claim row alone. The control that reports the rollback instead of the acquisition failure reddens six rows rather than one, because it changes the identity of every acquisition failure - the two rollback rows and the claim rows are the ones that name it, and one pre-existing safeWriteJson row reports it too; stated rather than hidden. Lanes: fileLock 4, contention 2, chain 2, safeWriteJson + stagingDirectory + lockKey 57 passed | 4 skipped, and the wide run over every utils/ and services/ lane is 190 files passed | 11 failed - the same eleven that fail at HEAD fileLock.ts on this host (5 Windows symlink EPERM rows in rules, 4 'TelemetryService not initialized' rows in MarketplaceManager, 2 rows in code-index parser), so nothing moved in either direction. DiffViewProvider 168 and guardedWrite 73 green. eslint --max-warnings=0 exit 0 on all six touched files; --prune-suppressions run afterwards and src/eslint-suppressions.json restored to HEAD and verified by an empty diff (a narrow prune rewrites 1326 of 1326 lines and drops records for files outside the selection, so it is a check and never a commit). prettier --check clean. tsc --noEmit -p . shows only activate/registerCommands.ts(88,102) TS2739, the local junction artifact. No it.runIf(win32) anywhere in the change: the rows run on every platform, and the extended-length prefix is handled by normalising what Windows reports rather than by gating a test. Review row 4507646158. --- src/utils/__tests__/fileLock.test.ts | 54 ++++++++++++ src/utils/__tests__/fileLockChain.test.ts | 48 ++++++++++ .../__tests__/fileLockContention.test.ts | 57 ++++++++++++ src/utils/fileLock.ts | 88 ++++++++++++++++++- 4 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/utils/__tests__/fileLockChain.test.ts create mode 100644 src/utils/__tests__/fileLockContention.test.ts diff --git a/src/utils/__tests__/fileLock.test.ts b/src/utils/__tests__/fileLock.test.ts index c06d994f80..240fc987df 100644 --- a/src/utils/__tests__/fileLock.test.ts +++ b/src/utils/__tests__/fileLock.test.ts @@ -1,3 +1,6 @@ +import * as fsp from "fs/promises" +import * as os from "os" +import * as path from "path" import * as lockfile from "proper-lockfile" import { acquireFileLock, LOCK_STALE_MS, lockPathFor, withFileLock } from "../fileLock" @@ -35,4 +38,55 @@ describe("file lock protocol", () => { ).rejects.toBe(error) expect(release).toHaveBeenCalledOnce() }) + + // The lock artefact sits beside the file it guards, and proper-lockfile's own mkdir of + // that artefact is not recursive. A write into a directory that did not exist yet failed + // here with ENOENT - before the publish primitive, and so before any rollback it would + // have owned - and left the empty tree behind. These rows run against the real filesystem + // for the directory behaviour; the lock itself stays stubbed because the question here is + // which directories exist afterwards, not who holds what. + it("creates the directory the lock artefact needs before taking the lock", async () => { + const base = await fsp.mkdtemp(path.join(os.tmpdir(), "filelock-chain-")) + const target = path.join(base, "missing", "nested", "history.json") + const release = vi.fn(async () => {}) + vi.mocked(lockfile.lock).mockResolvedValue(release) + try { + await acquireFileLock(target) + expect((await fsp.stat(path.join(base, "missing", "nested"))).isDirectory()).toBe(true) + // The artefact did not move to the ancestor that happened to exist: every writer to + // this path, arriving before or after the directory, is locking the same location. + expect(lockfile.lock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ lockfilePath: lockPathFor(target) }), + ) + await release() + // Releasing does not undo the creation: the write the lock was taken for follows it. + expect((await fsp.stat(path.join(base, "missing", "nested"))).isDirectory()).toBe(true) + } finally { + await fsp.rm(base, { recursive: true, force: true }) + } + }) + + it("removes only the directories it created when the lock cannot be acquired", async () => { + const base = await fsp.mkdtemp(path.join(os.tmpdir(), "filelock-rollback-")) + const existing = path.join(base, "existing") + await fsp.mkdir(existing) + // A directory somebody else already made is not this call's to remove, even though it + // sits inside the chain this call was walking. + const madeByAnotherWriter = path.join(existing, "one") + await fsp.mkdir(madeByAnotherWriter) + const target = path.join(madeByAnotherWriter, "two", "history.json") + const lockFailure = Object.assign(new Error("EEXIST: file already exists"), { code: "EEXIST" }) + vi.mocked(lockfile.lock).mockRejectedValue(lockFailure) + try { + // A rollback that reported its own failure instead would hide which of the two + // failures the caller has to act on. + await expect(acquireFileLock(target)).rejects.toBe(lockFailure) + expect((await fsp.stat(existing)).isDirectory()).toBe(true) + expect((await fsp.stat(madeByAnotherWriter)).isDirectory()).toBe(true) + await expect(fsp.stat(path.join(madeByAnotherWriter, "two"))).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + await fsp.rm(base, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/__tests__/fileLockChain.test.ts b/src/utils/__tests__/fileLockChain.test.ts new file mode 100644 index 0000000000..a9c0da8a33 --- /dev/null +++ b/src/utils/__tests__/fileLockChain.test.ts @@ -0,0 +1,48 @@ +import * as fsp from "fs/promises" +import * as path from "path" +import * as lockfile from "proper-lockfile" +import { acquireFileLock } from "../fileLock" + +// Only the two filesystem calls this module makes are doubled, and the lock itself: an +// unexpected fs.stat call would fail the row instead of quietly resolving, which matters +// because the directory question is the one the task-persistence suites forbid asking. +vi.mock("fs/promises", () => ({ mkdir: vi.fn(), rmdir: vi.fn() })) +vi.mock("proper-lockfile", () => ({ lock: vi.fn() })) + +// The doubles hold Node's recursive-mkdir contract, measured on this host rather than +// inferred from the docs: the call resolves with the first directory it actually created - +// on Windows reported with an extended-length \\?\ prefix - and with nothing when every +// level already existed. That answer is the only thing this module uses to decide what a +// failed acquisition may give back, so these rows keep the answer fixed and read the calls +// the module made, instead of asking a real filesystem to lose a race on schedule. +describe("what a failed lock acquisition gives back", () => { + const lockRefused = (): Error => + Object.assign(new Error("EEXIST: file already exists, lock held"), { code: "EEXIST" }) + + beforeEach(() => vi.clearAllMocks()) + + it("claims nothing when every level of the chain already existed", async () => { + const refusal = lockRefused() + vi.mocked(fsp.mkdir).mockResolvedValue(undefined) + vi.mocked(lockfile.lock).mockRejectedValue(refusal) + await expect(acquireFileLock(path.join("C:", "tree", "state.json"))).rejects.toBe(refusal) + // Nothing was created, so nothing may be removed: the directories here belong to + // whoever put them there, and one of them may be somebody else's working tree. + expect(fsp.rmdir).not.toHaveBeenCalled() + }) + + it("gives back only the levels the create reported, deepest first", async () => { + const refusal = lockRefused() + const firstLevel = path.join("C:", "tree", "one") + // Node's answer on Windows arrives prefixed; the module normalises it before comparing. + vi.mocked(fsp.mkdir).mockResolvedValue("\\\\?\\" + firstLevel) + vi.mocked(lockfile.lock).mockRejectedValue(refusal) + const target = path.join(firstLevel, "two", "state.json") + await expect(acquireFileLock(target)).rejects.toBe(refusal) + expect(vi.mocked(fsp.mkdir).mock.calls.map((call) => String(call[0]))).toEqual([path.join(firstLevel, "two")]) + expect(vi.mocked(fsp.rmdir).mock.calls.map((call) => String(call[0]))).toEqual([ + path.join(firstLevel, "two"), + firstLevel, + ]) + }) +}) diff --git a/src/utils/__tests__/fileLockContention.test.ts b/src/utils/__tests__/fileLockContention.test.ts new file mode 100644 index 0000000000..51242433ce --- /dev/null +++ b/src/utils/__tests__/fileLockContention.test.ts @@ -0,0 +1,57 @@ +import * as fsp from "fs/promises" +import * as os from "os" +import * as path from "path" +import { acquireFileLock, lockPathFor } from "../fileLock" + +// These rows run against the real proper-lockfile on real temporary directories. The +// property is which artefact two writers reach, and a stubbed lock cannot answer it: +// a stub reports whatever shape the test hands it, including a shape production does +// not build. +describe("file lock contention where the directory has to be created first", () => { + it("a writer that arrives after the directory exists contends with the writer that made it", async () => { + const base = await fsp.mkdtemp(path.join(os.tmpdir(), "filelock-contend-")) + const target = path.join(base, "missing", "nested", "state.json") + try { + const releaseFirst = await acquireFileLock(target) + expect((await fsp.stat(path.dirname(target))).isDirectory()).toBe(true) + // The second writer arrives while the first still holds and after the directory + // appeared. Under ancestor-first locking it would have derived a different + // artefact and walked straight in; the artefact is derived from the guarded path, + // so it contends. + // proper-lockfile reports a held artefact as its own error rather than the raw + // mkdir collision, so the row names what the caller actually sees. + await expect(acquireFileLock(target, { immediate: true })).rejects.toThrow(/already being held/) + await releaseFirst() + const releaseSecond = await acquireFileLock(target, { immediate: true }) + expect((await fsp.stat(lockPathFor(target))).isDirectory()).toBe(true) + await releaseSecond() + await expect(fsp.stat(lockPathFor(target))).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + await fsp.rm(base, { recursive: true, force: true }) + } + }) + + it("two writers racing into the same missing directory leave exactly one holding the path", async () => { + const base = await fsp.mkdtemp(path.join(os.tmpdir(), "filelock-race-")) + const target = path.join(base, "missing", "nested", "state.json") + const attempts = await Promise.allSettled([ + acquireFileLock(target, { immediate: true }), + acquireFileLock(target, { immediate: true }), + ]) + const holding = attempts.filter((attempt) => attempt.status === "fulfilled") + const refused = attempts.filter((attempt) => attempt.status === "rejected") + try { + expect(holding).toHaveLength(1) + expect(refused).toHaveLength(1) + expect((refused[0] as PromiseRejectedResult).reason).toBeInstanceOf(Error) + expect(String((refused[0] as PromiseRejectedResult).reason)).toMatch(/already being held/) + // One artefact, held by one writer: the refused attempt creates no lock of its own + // and leaks nothing behind. + expect((await fsp.stat(lockPathFor(target))).isDirectory()).toBe(true) + await holding[0]!.value() + await expect(fsp.stat(lockPathFor(target))).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + await fsp.rm(base, { recursive: true, force: true }) + } + }) +}) diff --git a/src/utils/fileLock.ts b/src/utils/fileLock.ts index c98e9b1c67..d4251f0078 100644 --- a/src/utils/fileLock.ts +++ b/src/utils/fileLock.ts @@ -1,3 +1,4 @@ +import * as fs from "fs/promises" import * as path from "path" import { tracePersistenceEvent } from "../core/task-persistence/persistenceTrace" import * as lockfile from "proper-lockfile" @@ -24,6 +25,74 @@ export function lockPathFor(filePath: string): string { } const RECOVERY_GRACE_MS = 4000 +/** + * Create the directory that holds a path's lock artefact, and report only the levels this + * call actually created. proper-lockfile's own mkdir of the artefact is not recursive, so a + * write into a directory that did not exist yet failed here with ENOENT - before the publish + * primitive, and so before any rollback it would have owned - and left the empty tree behind. + * + * This asks the filesystem to create, and lets the answer say what was made: recursive mkdir + * resolves with the first directory it actually created, and with nothing when every level + * already existed. There is deliberately no fs.stat here. The task-persistence suites mock + * fs.stat to reject for every path, and safeWriteJson.stagingDirectory.spec.ts pins that the + * write must not start asking fs.stat questions about the directory it is allowed to write + * into; a stat-driven walk would reintroduce exactly that question, and did. + */ +async function createLockDirectoryChain(lockfilePath: string): Promise { + const parent = path.dirname(lockfilePath) + // Measured on this host, not inferred from the docs: Node reports the first directory it + // created with an extended-length prefix (\\?\C:\...), so the answer is normalised before + // it is compared with anything. Comparing it raw is what made a rollback reach above the + // tree this call had built. + const firstMade = await fs.mkdir(parent, { recursive: true }) + if (firstMade === undefined || firstMade === null) { + return [] + } + const made = path.resolve(withoutExtendedLengthPrefix(String(firstMade))) + const tail = path.relative(made, parent) + if (tail.startsWith("..")) { + // The answer is not a location this call can account for; claim the parent and no + // more, which is the conservative reading of "only what we created". + return [parent] + } + const chain: string[] = [made] + let cursor = made + if (tail !== "") { + for (const segment of tail.split(path.sep)) { + cursor = path.join(cursor, segment) + chain.push(cursor) + } + } + return chain +} + +/** Strip the extended-length prefix Node reports on Windows, leaving other paths alone. */ +function withoutExtendedLengthPrefix(candidate: string): string { + if (candidate.startsWith("\\\\?\\UNC\\")) { + return "\\\\" + candidate.slice(8) + } + if (candidate.startsWith("\\\\?\\")) { + return candidate.slice(4) + } + return candidate +} +/** + * Undo the directories a failed acquisition created, deepest first and best-effort. A + * directory somebody else populated is not this call's to take away, and a rollback + * failure must not arrive dressed as the error that caused the rollback. + */ +async function removeCreatedDirectories(created: string[]): Promise { + for (let index = created.length - 1; index >= 0; index--) { + try { + await fs.rmdir(created[index]) + } catch (rollbackError: unknown) { + // Includes ENOTEMPTY: the directory is in use, which is a reason to leave it, + // not a reason to report a second failure over the first. + void rollbackError + } + } +} + export interface FileLockOptions { /** Recovery may wait out a crashed writer's staleness window without stealing a live lock. */ waitForStaleLock?: boolean @@ -41,11 +110,23 @@ export interface FileLockOptions { export async function acquireFileLock(filePath: string, options?: FileLockOptions): Promise<() => Promise> { const absoluteFilePath = path.resolve(filePath) if (options?.waitForStaleLock) tracePersistenceEvent("json-lock-wait", { filePath: absoluteFilePath }) + const lockfilePath = lockPathFor(absoluteFilePath) + // Created before the lock is taken, and removed again only if the acquisition + // fails: see createLockDirectoryChain. A failure here is rolled back before it is + // reported, so a refused write does not litter the tree it refused to enter. + let created: string[] = [] + try { + created = await createLockDirectoryChain(lockfilePath) + } catch (mkdirError: unknown) { + await removeCreatedDirectories(created) + console.error(`Failed to prepare the lock directory for ${absoluteFilePath}:`, mkdirError) + throw mkdirError + } try { return await lockfile.lock(absoluteFilePath, { stale: LOCK_STALE_MS, update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long - lockfilePath: lockPathFor(absoluteFilePath), + lockfilePath, realpath: false, // the file may not exist yet, which is acceptable retries: options?.immediate ? 0 @@ -68,7 +149,10 @@ export async function acquireFileLock(filePath: string, options?: FileLockOption throw err }, }) - } catch (lockError) { + } catch (lockError: unknown) { + // The parent directories this call made are the only state it + // owns at this point; give them back, then report the acquisition failure unchanged. + await removeCreatedDirectories(created) console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) throw lockError } From 3dc514019c7b45144a4236fe74fa316c2fdc9230 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:11:45 +0800 Subject: [PATCH 101/115] test(S4b #1375): pin the publish-side claims the review rows named Rows only - no production line moves. Every row was run against a one-line control on the behaviour it claims, and where the control reached further than the row, that is said here rather than left for the reader to discover. applyPatchTool.execute.spec.ts: the outside-workspace move refusal is pinned on both sides of the experiment flag, because the refusal sits above the flag branch (ApplyPatchTool.ts:575) and the raw publish path never sees such a destination either. The refused destination is not canonicalised and not published: no realpath, no saveDirectly, no saveChanges, no unlink, and the diff view is reset. Controls: removing the refusal gate reddens exactly the two refusal rows; putting canonicalisation back in front of the refusal reddens exactly the flag-on row. The completeness argument - the seventh publish argument, computed at :584 and passed at :635 - is pinned true for a full source read and false for the only case that passes false, a partial source view onto an absent destination. Hardcoding it true reddens that row plus the shipped row that pins false for an unobserved source; hardcoding it false reddens exactly the complete-source row. The mistake counter is deliberately not asserted: the refusal raises it at :565 and the end-of-loop statement at :242 clears it again inside the same call, so reading the counter here would pin :242 rather than the refusal. The isPathOutsideWorkspace double is now reset in beforeEach - clearAllMocks keeps implementations, and the module-level double was leaking its behaviour between rows. guardedWrite.spec.ts: the second publication does not start while the first is still running. Call order is not the same claim as non-overlap, so the first publish is held open on a promise the row controls and the invocation list is read while that hold is open. Control: dropping the per-path FIFO chain's wait on the previous tail (guardedWrite.ts:108) reddens exactly this row. safeWriteJson.test.ts: an EACCES and an EIO on the target-mode stat (safeWriteJson.ts:578-:589) reach the caller unchanged, no staging stream is opened, no rename happens and the target keeps its bytes. The injected failure is matched against the publish target itself, which is the spelling the writer stats, and the stat list is asserted as well: that read is the only stat this writer performs before it fails, and the failure lands after the write-start trace - the mode read sits there, not before it. Control: making _readPublishTargetMode return undefined for every stat error, the regression the row names, reddens exactly these two rows. fileLockChain.test.ts: a created directory another writer populated is left in place, and the caller still receives the lock refusal rather than the rollback's ENOTEMPTY. Controls: rethrowing the rollback error reddens this row and the existing give-back row, because both assert which error the caller sees; swapping rmdir for a recursive rm reddens only the two give-back rows in this file. fileLockContention.test.ts: the real-filesystem racing row now also reads the directory the losing writer may have created and the holder's artefact inside it, after the loser's rollback and before the holder releases. Measured limit on that last row: which writer creates the directory and which writer loses the lock is a race, and a populated directory is protected by ENOTEMPTY whichever way the rollback asks for it, so the lane is evidence about the residual rather than a control. On this host: 5 of 5 baseline runs green; the recursive-rm control reddened 2 of 5 runs when the assertions stood in their own row and 0 of 5 after they were folded into the existing racing row. The deterministic control for the give-back shape is the doubles lane above. No platform gate is introduced here: every row in this commit runs on every lane, and nothing here is verified only on ubuntu. --- .../__tests__/applyPatchTool.execute.spec.ts | 128 ++++++++++++++++++ src/core/tools/__tests__/guardedWrite.spec.ts | 41 ++++++ src/utils/__tests__/fileLockChain.test.ts | 24 ++++ .../__tests__/fileLockContention.test.ts | 6 +- src/utils/__tests__/safeWriteJson.test.ts | 89 ++++++++++++ 5 files changed, 287 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 8b4d0f77a7..3b4f2fedaa 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -23,6 +23,8 @@ const mockedFsPromises = vi.mocked( readFile: ReturnType stat: ReturnType unlink: MockedFunction + // Added for the outside-workspace refusal row: the refused destination must not be canonicalised. + realpath: ReturnType } }, ) @@ -224,6 +226,10 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { mockedFileExistsAtPath.mockResolvedValue(true) + // The outside-workspace double is module-level, and vi.clearAllMocks() keeps implementations, + // so a row that flips it has to find it back at false. Restored here rather than per row so the + // next row does not have to know a neighbour changed it. + vi.mocked(isPathOutsideWorkspace).mockReturnValue(false) mockSaveDirectly = vi.fn().mockResolvedValue({ newProblemsMessage: "", userEdits: undefined, @@ -753,6 +759,41 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { ) }) + it("move: refuses a destination outside every workspace root without canonicalising or publishing it", async () => { + // Row 4507855163. The refusal at ApplyPatchTool.ts:564 sits above the experiment-flag branch at :575, + // so both modes share one code path and the flag-off twin below is the other half of that claim. + // Only the destination reads as outside a root: leaving the source inside means a canonicalisation + // of the source cannot stand in for the destination being canonicalised. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const outside = vi.mocked(isPathOutsideWorkspace) + outside.mockImplementation((candidate: string) => candidate === destKey) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-outside-workspace-destination", + }) + + expect(outside).toHaveBeenCalledWith(destKey) + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move file to path outside workspace: src/new.ts"), + ) + // The refused destination is never canonicalised. realpathNearest used to run at this point, so a + // destination whose name could not be resolved surfaced an exception where the user should get the + // tool error above. + expect(mockedFsPromises.default.realpath).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockSaveChanges).not.toHaveBeenCalled() + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + // The mistake counter is deliberately not asserted here. The refusal raises it at :565 and the + // end-of-loop statement at :242 clears it again inside the same call, so reading the counter + // after this call would pin :242 rather than the refusal. + expect(mockHandleError).not.toHaveBeenCalled() + }) + it("move: a complete source read keeps the destination complete", async () => { const sourceKey = path.resolve("/workspace/project", "src/old.ts") const destKey = path.resolve("/workspace/project", "src/new.ts") @@ -773,6 +814,62 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(reg.get(destKey)?.complete).toBe(true) expect(mockHandleError).not.toHaveBeenCalled() }) + // Rows 4507760687 follow-up (4507979054): the rows above pin what the registry holds at publish time. + // These two pin the value the tool hands the guard as its completeness authorization - the seventh + // argument - which the registry rows cannot see because a mocked save does not consult it. That is + // the whole case for keeping both: one row reads the state, these read the argument that state came + // from, and the false case below is the only situation where production passes false at all. + it("move: authorizes the destination publish as complete when the source was read in full", async () => { + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + mockTask.observationRegistry.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-complete-source-argument", + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + true, + false, + undefined, + ) + }) + + it("move: authorizes the destination publish as partial when the source view was partial and the destination was absent", async () => { + // The create guard permits publishing onto an absent path, so the partial source view has to travel + // with the publish instead of being refused: this is the case the argument exists to express, and + // it is the one case in which the shipped code passes false (ApplyPatchTool.ts:584, :635). + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockedFsPromises.default.access.mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-partial-source-absent-destination-argument", + }) + + // The destination's own stale completeness is not what authorizes the publish - the source view is - + // so the argument is false even though the destination had been read as complete before it vanished. + expect(mockSaveDirectly.mock.calls[0]?.[6]).toBe(false) + expect(mockSaveDirectly.mock.calls[0]?.[5]).toBe("create") + expect(mockHandleError).not.toHaveBeenCalled() + }) + // Row 4507855182 (measured): the destination commits and the source unlink below never runs, // so a move that hits a buffer conflict is half-applied. The report has to name both paths // and keep the shared error's no-retry guidance, because the model's next instinct is to @@ -1026,6 +1123,37 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockPushToolResult).toHaveBeenCalled() }) + it("flag-off move: refuses a destination outside every workspace root before mkdir or writeFile", async () => { + // The twin of the guarded row above. The refusal is above the experiment-flag branch, so the raw + // publish path never sees an outside-workspace destination either: no directory is created, nothing + // is written, and the source is left where it was. + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + const destKey = path.resolve("/workspace/project", "src/new.ts") + vi.mocked(isPathOutsideWorkspace).mockImplementation((candidate: string) => candidate === destKey) + const writeDoubles = vi.mocked( + fsPromises as unknown as { + default: { writeFile: ReturnType; mkdir: ReturnType } + }, + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-outside-workspace-destination", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move file to path outside workspace: src/new.ts"), + ) + expect(writeDoubles.default.mkdir).not.toHaveBeenCalled() + expect(writeDoubles.default.writeFile).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + expect(mockHandleError).not.toHaveBeenCalled() + }) + // The hunk read is the content the guarded publish republishes. An ASCII hunk can match a // file whose bytes somewhere else cannot be decoded, and reading those bytes as UTF-8 // substitutes them, so the write would change bytes the patch never mentioned. The refusal has diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index 48bd18f87e..794c31836c 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -940,6 +940,47 @@ describe("guardedWrite (S4a, epic #1375)", () => { expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("absent.txt"), "second") }) + it("the second publication does not start while the first is still running", async () => { + // Row 4507855167. Call order and non-overlap are different claims: NthCalledWith says which + // publish ran first, and a second publish that began while the first was still inside the publish + // primitive satisfies it. The first publish is held open on a promise this row controls, the + // invocation list is read while that hold is open, and only then is the hold released. + const reg = new ObservationRegistry() + reg.observe(abs("overlap.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + const started: string[] = [] + let releaseFirst: () => void = () => {} + const firstHold = new Promise((resolve) => { + releaseFirst = resolve + }) + mockedSafeWriteText + .mockImplementationOnce(async () => { + started.push("first") + await firstHold + }) + .mockImplementationOnce(async () => { + started.push("second") + }) + + const p1 = guardedWrite(task, "overlap.txt", "first", "update") + const p2 = guardedWrite(task, "overlap.txt", "second", "update") + + // One macrotask tick is enough for every microtask-only step ahead of a publish to run for both + // writes, so a second link that was not waiting on the first would already have published here. + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + expect(started).toEqual(["first"]) + + releaseFirst() + const [r1, r2] = await Promise.allSettled([p1, p2]) + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(started).toEqual(["first", "second"]) + }) + it("the chain settles after a rejection - a later matching write still runs", async () => { const reg = new ObservationRegistry() reg.observe(abs("settle.txt"), "v1") diff --git a/src/utils/__tests__/fileLockChain.test.ts b/src/utils/__tests__/fileLockChain.test.ts index a9c0da8a33..8242ac738c 100644 --- a/src/utils/__tests__/fileLockChain.test.ts +++ b/src/utils/__tests__/fileLockChain.test.ts @@ -31,6 +31,30 @@ describe("what a failed lock acquisition gives back", () => { expect(fsp.rmdir).not.toHaveBeenCalled() }) + // Row 4507646158 follow-up. The real-filesystem rows cannot pin this: whether the writer that + // created the directory is also the writer that loses the lock is a race, and a populated + // directory is protected by ENOTEMPTY whichever way the rollback asks for it. So the claim - a + // rollback that cannot remove a directory leaves it, and does not report its own refusal over + // the failure that caused it - is made here, where the create answer and the rmdir answer are + // both fixed. + it("leaves a created directory another writer populated and still reports the lock refusal", async () => { + const refusal = lockRefused() + const createdLevel = path.join("C:", "tree", "one") + vi.mocked(fsp.mkdir).mockResolvedValue("\\\\?\\" + createdLevel) + vi.mocked(lockfile.lock).mockRejectedValue(refusal) + // The directory this call created is not empty by the time the rollback looks: another writer + // has put something in it, and rmdir refuses a directory in use. + vi.mocked(fsp.rmdir).mockRejectedValue( + Object.assign(new Error("ENOTEMPTY: directory not empty, " + createdLevel), { code: "ENOTEMPTY" }), + ) + const target = path.join(createdLevel, "state.json") + // The caller gets the lock refusal it asked about. A rollback failure on the way out must not + // arrive dressed as the error that caused the rollback. + await expect(acquireFileLock(target)).rejects.toBe(refusal) + // The populated directory is asked for once and left there, not retried into its parent. + expect(vi.mocked(fsp.rmdir).mock.calls.map((call) => String(call[0]))).toEqual([createdLevel]) + }) + it("gives back only the levels the create reported, deepest first", async () => { const refusal = lockRefused() const firstLevel = path.join("C:", "tree", "one") diff --git a/src/utils/__tests__/fileLockContention.test.ts b/src/utils/__tests__/fileLockContention.test.ts index 51242433ce..939e57f294 100644 --- a/src/utils/__tests__/fileLockContention.test.ts +++ b/src/utils/__tests__/fileLockContention.test.ts @@ -46,7 +46,11 @@ describe("file lock contention where the directory has to be created first", () expect((refused[0] as PromiseRejectedResult).reason).toBeInstanceOf(Error) expect(String((refused[0] as PromiseRejectedResult).reason)).toMatch(/already being held/) // One artefact, held by one writer: the refused attempt creates no lock of its own - // and leaks nothing behind. + // and leaks nothing behind. The refused attempt has already run its rollback here, and one of + // the two created the directory the artefact stands in: the directory and the live artefact + // both have to outlive that rollback, and the holder has to be able to release. (Row + // 4507646158 follow-up: this is the window the reviewer's rollback claim is about.) + expect((await fsp.stat(path.dirname(target))).isDirectory()).toBe(true) expect((await fsp.stat(lockPathFor(target))).isDirectory()).toBe(true) await holding[0]!.value() await expect(fsp.stat(lockPathFor(target))).rejects.toMatchObject({ code: "ENOENT" }) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 3428a5c0f5..837da3dd11 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -196,6 +196,95 @@ describe("safeWriteJson", () => { expect(await fileExists(missingTarget)).toBe(true) }) + // Row 4507855186. The target-mode read is the one stat in this writer whose failure has to stop the + // write. A target that is not there yet has no mode to carry over, so absence is a verdict; any other + // failure means the mode of the file being replaced is unknown, and defaulting on it publishes a + // private target with the creation default. These rows inject the two non-absence codes at the + // publish target itself - the writer stats the resolved path, so the match is made against both + // spellings - and delegate every other stat to the real filesystem. + async function blockTargetModeRead(code: string): Promise<{ + failure: Error + statPaths: string[] + restore: () => void + }> { + const actual = await vi.importActual("fs/promises") + const statPaths: string[] = [] + const spellings = new Set([currentTestFilePath]) + try { + spellings.add(await actual.realpath(currentTestFilePath)) + } catch { + // A target that cannot be realpath'd is matched by its own spelling only. + } + const failure = Object.assign(new Error(code + ": the target-mode stat could not read the mode"), { + code, + }) as Error + const realStat = actual.stat + vi.mocked(fs.stat).mockImplementation(async (...args: Parameters) => { + const keyed = String(args[0]) + statPaths.push(keyed) + if (spellings.has(keyed)) { + throw failure + } + return (realStat as typeof fs.stat)(...args) + }) + // restoreAllMocks does not bring back an implementation that a test installed over a factory + // double, so the delegation back to the real stat is put back here rather than left to the hook. + return { + failure, + statPaths, + restore: () => { + vi.mocked(fs.stat).mockImplementation(((...args: Parameters) => + Reflect.apply(actual.stat, actual, args)) as typeof fs.stat) + }, + } + } + + async function runAgainstUnreadableMode(code: string): Promise { + const injected = await blockTargetModeRead(code) + // Call counts are read as a delta: this suite restores mock implementations between rows but + // the doubles keep the history of earlier rows, so an absolute not-to-have-been-called would be a + // claim about the whole file rather than about this write. + const streamsBefore = vi.mocked(fsSyncActual.createWriteStream).mock.calls.length + const renamesBefore = vi.mocked(fs.rename).mock.calls.length + const before = await readFileContent(currentTestFilePath) + const stages: string[] = [] + let failure: unknown + try { + await safeWriteJson( + currentTestFilePath, + { updated: "content" }, + { + trace: (event: { stage: string }) => { + stages.push(event.stage) + }, + }, + ) + } catch (error: unknown) { + failure = error + } finally { + injected.restore() + } + expect(failure).toBe(injected.failure) + // The stage list records where the writer stopped. It announces the write before reading the mode, + // so write-start is present; the load-bearing facts are that the staging stream is never opened + // and the target is never renamed onto. The stat list is the proof that the injected failure landed + // on the target-mode read and not on some other stat: that read is the only one this writer makes + // before it fails. + expect(stages).toEqual(["write-start"]) + expect(injected.statPaths).toEqual([currentTestFilePath]) + expect(vi.mocked(fsSyncActual.createWriteStream).mock.calls.length).toBe(streamsBefore) + expect(vi.mocked(fs.rename).mock.calls.length).toBe(renamesBefore) + expect(await readFileContent(currentTestFilePath)).toEqual(before) + } + + it("propagates an EACCES on the target-mode stat and publishes nothing", async () => { + await runAgainstUnreadableMode("EACCES") + }) + + it("propagates an EIO on the target-mode stat and publishes nothing", async () => { + await runAgainstUnreadableMode("EIO") + }) + // The end-to-end mode assertion is made where modes mean what the assertion says they mean. it.runIf(process.platform !== "win32")("keeps a 0o600 file private through an update", async () => { await fs.chmod(currentTestFilePath, 0o600) From d75ed9bdeb547cf9dd0ec5921cf816a81af252e6 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:18:27 +0800 Subject: [PATCH 102/115] test(S4b #1375): make the two lane-red rows ask the same question on either platform The platform lanes at 3dc514019 came back red, and both reds were rows from the previous commit, so the cause is read out of the job logs rather than inferred. platform-unit-test (ubuntu-latest): test:coverage:misc failed 4 of 2248 tests. fileLockChain.test.ts:55 (the give-back row added in the previous commit) and :66 (the row landed in the head before that) both compared the rollback's calls against path.join("C:", "tree", "one"), which is an absolute path only on Windows; on the ubuntu runner the module resolved it against the working directory and reported /home/runner/work/Zoo-Code/Zoo-Code/src/C:/tree/one. The same group also failed safeWriteJson.lockKey.spec.ts:237 and :530; those rows belong to the lock-key guard and are left for a separate decision rather than quietly patched here. platform-unit-test (windows-latest): the lane failed in test:misc on safeWriteJson.test.ts - the two new target-mode rows asserted that the stat the writer performs names the target exactly as os.tmpdir spelled it, and on that runner realpath answers a different name for the same directory. The job was cancelled after that step, so its conclusion reads cancelled with the step recorded as failed. Both fixes keep the claim and drop the platform-shaped premise. The chain fixtures now take their absolute path from path.resolve instead of a Windows drive letter; the answer the double reports is still prefixed with the extended-length marker, because that is the Windows half of the row, and normalising it has to land back on the absolute path the call already knows on either platform. The target-mode rows keep the count - one stat, the one that threw, since only a stat of the publish target raises the injected error - and drop the claim about spelling. Honest limit: the ubuntu half of this cannot be verified from a Windows host. What was verified here is that the files are green locally (chain 3, safeWriteJson 52 passed with 4 skipped, contention 2, fileLock 4), that the fixture is absolute on both platforms by construction, and that eslint, prettier and tsc are unchanged. The lane is the verification of the ubuntu half, and the number will be reported when it lands. --- src/utils/__tests__/fileLockChain.test.ts | 8 ++++++-- src/utils/__tests__/safeWriteJson.test.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/utils/__tests__/fileLockChain.test.ts b/src/utils/__tests__/fileLockChain.test.ts index 8242ac738c..05aa0bfccb 100644 --- a/src/utils/__tests__/fileLockChain.test.ts +++ b/src/utils/__tests__/fileLockChain.test.ts @@ -39,7 +39,7 @@ describe("what a failed lock acquisition gives back", () => { // both fixed. it("leaves a created directory another writer populated and still reports the lock refusal", async () => { const refusal = lockRefused() - const createdLevel = path.join("C:", "tree", "one") + const createdLevel = path.resolve(path.join("tree", "one")) vi.mocked(fsp.mkdir).mockResolvedValue("\\\\?\\" + createdLevel) vi.mocked(lockfile.lock).mockRejectedValue(refusal) // The directory this call created is not empty by the time the rollback looks: another writer @@ -57,7 +57,11 @@ describe("what a failed lock acquisition gives back", () => { it("gives back only the levels the create reported, deepest first", async () => { const refusal = lockRefused() - const firstLevel = path.join("C:", "tree", "one") + // Resolved rather than spelled out: the fixture has to be an absolute path on every lane, and + // "C:" is absolute only on Windows. The report below is still prefixed, which is the half of + // the row that is about Windows - normalising it has to land back on the absolute path the call + // already knows, on either platform. + const firstLevel = path.resolve(path.join("tree", "one")) // Node's answer on Windows arrives prefixed; the module normalises it before comparing. vi.mocked(fsp.mkdir).mockResolvedValue("\\\\?\\" + firstLevel) vi.mocked(lockfile.lock).mockRejectedValue(refusal) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 837da3dd11..b5c1513d57 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -268,10 +268,13 @@ describe("safeWriteJson", () => { // The stage list records where the writer stopped. It announces the write before reading the mode, // so write-start is present; the load-bearing facts are that the staging stream is never opened // and the target is never renamed onto. The stat list is the proof that the injected failure landed - // on the target-mode read and not on some other stat: that read is the only one this writer makes - // before it fails. + // on the target-mode read and not on some other stat: that read is the only one this writer + // makes before it fails, and it is the read that threw, because only a stat of the publish + // target raises the injected error. The spelling is deliberately not asserted - on the hosted + // lanes the temporary directory is reported under a different name by realpath than os.tmpdir + // returned, which is a fact about the runner and not about this writer. expect(stages).toEqual(["write-start"]) - expect(injected.statPaths).toEqual([currentTestFilePath]) + expect(injected.statPaths.length).toBe(1) expect(vi.mocked(fsSyncActual.createWriteStream).mock.calls.length).toBe(streamsBefore) expect(vi.mocked(fs.rename).mock.calls.length).toBe(renamesBefore) expect(await readFileContent(currentTestFilePath)).toEqual(before) From a15991d33af34ec5f1a7f717c71c3dc3319a2a27 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:24:26 +0800 Subject: [PATCH 103/115] test(S4b #1375): put the lock-key rows on the question the guard actually answers The two remaining required-leg failures on this lane are both in this file, and they exist because of the funding condition that the test must run on both hosted lanes: running it on both produced a Linux red. That is the requirement working - a row that only ever executed on Windows would have shipped a POSIX assertion nobody ran. What was wrong was the fixture's subject, not the product. _locksAreOneLock (safeWriteJson.ts:119-:124) puts its identity question only to two keys in one directory whose basenames differ by case alone, and a case-only pair is something only a case-folding filesystem produces by itself. On ext4 the two spellings are two entries, so the guard is inert there by design. The old case-sensitive branch therefore manufactured a divergence with a symlink from the writer's name onto the peer's file, which is a different situation - the guard never claimed to cover a link that resolves into another file - and then asserted what a folding filesystem does with the result. The acceptance half read the peer's file for this writer's content, and the commit rename lands on the path the caller was given (safeWriteJson.ts:483), so on a case-sensitive filesystem the writer's own spelling is what receives the content and the referent keeps the peer's. The propagation half counted the guard's stat and got zero, because nothing reached the comparison. The rows now ask the same question on both platforms: - the case restating that produces the name pair is pinned to be deterministic. It was readdir-order dependent on a directory holding both spellings: the double now prefers the entry whose name differs from the request in case alone, and stays the identity function where the two already agree. On a case-folding filesystem the restating is the filesystem's own; on a case-sensitive one it is the double's, and the row says so. - the one-object half is a filesystem fact on both instead of a simulation: the two artefact names are joined by hard link, and the writer's spelling is created by hard link too. Hard link replaced the symlink on purpose - it is one object under two names on NTFS and on ext4 alike, it asks for no privilege on either hosted lane, and object identity is what the guard answers from. - the acceptance row reads the identity back off the filesystem (dev and ino of both artefact spellings) instead of taking it from the fixture, and asserts the writer's spelling holds this writer's content on either platform. The referent keeping the peer's content is asserted only where the platform has two entries, with the line that causes it named. - the propagation row's injected non-ENOENT stat failure now has a comparison to propagate from on either lane, so the stat count means what the row says it means. Verification, stated with its limits: safeWriteJson.lockKey.spec.ts is green here, 10 passed, which is the case-folding branch. The case-sensitive branch cannot execute on this host - measured, not assumed: a hard link onto the other-case name answers EEXIST and readdir still lists one entry, because NTFS will not hold the two spellings as two names, and a symlink cannot be created here at all (EPERM). Both hosted lanes hold two entries (ubuntu) or restate case (windows), so the branch that this commit rewrites is executed by the lanes and not locally, and no platform gate was introduced to hide it: no it.runIf was added, both rows still run on both lanes. Test-only. No product line moved, no suppression count changed (src/eslint-suppressions .json is back to its HEAD content), prettier --check clean, and tsc reports only the known activate/registerCommands.ts(88,102) TS2739 junction artefact. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 187578d59d..3d8b9b1a37 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -188,14 +188,23 @@ describe("safeWriteJson lock key under a peer commit", () => { // The reviewer's case turns on realpath handing back the name a directory stores rather // than the spelling it was given - which is what NTFS's GetFinalPathNameByHandle does. // This double models exactly that, and is the identity function wherever the two already - // agree, so the row drives the guard on a filesystem that restates case and on one that - // cannot restate it at all. + // agree. Where the filesystem does the restating and where the double does it is what the + // probe below measures, because the subject of this row is a pair that differs by basename + // case inside one directory, and _locksAreOneLock (safeWriteJson.ts:119-:124) puts its + // question to no other pair. A case-only pair is something only a case-folding filesystem + // produces on its own; on ext4 the two spellings are two entries and the guard is inert + // there by design, so the double supplies the restating and the row asserts the identity + // and propagation half of what the code promises on that platform. const restatingRealpath = async (target: string): Promise => { const resolved = await real.realpath(target) const parent = path.dirname(resolved) const name = path.basename(resolved) const listed = await real.readdir(parent).catch(() => [] as string[]) - const stored = listed.find((entry: string) => entry.toLowerCase() === name.toLowerCase()) + // Prefer the entry whose name differs from the request in case alone. On a folding + // filesystem that is the one name the directory stores; on a case-sensitive one the + // directory holds both spellings and the match has to be pinned to the other one, or the + // divergence this row is built on would depend on readdir order. + const stored = listed.find((entry: string) => entry !== name && entry.toLowerCase() === name.toLowerCase()) return stored === undefined ? resolved : path.join(parent, stored) } const probeLower = path.join(dir, "probe.json") @@ -217,11 +226,13 @@ describe("safeWriteJson lock key under a peer commit", () => { await fs.writeFile(ownArtefact, "") await fs.writeFile(lower, JSON.stringify({ mcpServers: { peer: true } })) if (!foldsCase) { - // A case-sensitive filesystem cannot produce this divergence on its own - two - // spellings there are two names - so the same pair of facts is built by hand: the - // writer's name linked onto the peer's file, and the two artefact names joined by a - // hard link, which is one artefact under two names. - await fs.symlink(lower, upper) + // A case-sensitive filesystem cannot produce the name pair on its own, so the entry in + // the writer's spelling is made here and the two artefact names are joined by a hard + // link. Hard link rather than symlink, deliberately: a hard link is one object under + // two names on NTFS and on ext4 alike, it asks for no privilege on either hosted lane, + // and the thing the guard answers from is object identity, which a hard link supplies + // and a link that resolves somewhere else only imitates. + await fs.link(lower, upper) await fs.link(ownArtefact, lockPathFor(lower)) } return async () => {} @@ -231,10 +242,19 @@ describe("safeWriteJson lock key under a peer commit", () => { () => undefined, (caught: unknown) => caught as Error, ) - // The claim is the guard's answer: the two names reach one artefact and one file, so this - // writer holds the lock the other spelling would take and the write is not refused. + // The claim is the guard's answer, and it is the same claim on either filesystem: the two + // names reach one artefact, so this writer holds the lock the other spelling would take and + // the write is not refused. Where the two platforms differ is in how many names exist, which + // is a property of the directory and not of the guard, so only the counts branch. expect(String(error && error.name)).toBe("undefined") - expect(JSON.parse(await fs.readFile(lower, "utf8"))).toEqual({ mcpServers: { mine: true } }) + expect(JSON.parse(await fs.readFile(upper, "utf8"))).toEqual({ mcpServers: { mine: true } }) + if (!foldsCase) { + // The commit rename lands on the path the caller was given (safeWriteJson.ts:483), and on + // a case-sensitive filesystem that names the other entry: the referent keeps the peer's + // content and the writer's own spelling becomes a regular file. That is what the shipped + // order does, so the row states it instead of asserting a result the platform never gives. + expect(JSON.parse(await fs.readFile(lower, "utf8"))).toEqual({ mcpServers: { peer: true } }) + } const entries = await fs.readdir(dir) // A case-folding filesystem holds one file and one artefact under the two spellings, so it // lists two entries; a case-sensitive one holds two distinct files and two distinct @@ -246,6 +266,12 @@ describe("safeWriteJson lock key under a peer commit", () => { : ["settings.json", "settings.json.lock", "settings.json", "settings.json.lock"], ) expect(entries.length).toBe(foldsCase ? 2 : 4) + // The guard answered from object identity, so the identity is read back off the filesystem + // rather than taken on trust from the fixture: two names, one artefact, on either platform. + const ownArtefactRead = await fs.stat(lockPathFor(upper)) + const peerArtefactRead = await fs.stat(lockPathFor(lower)) + expect(peerArtefactRead.dev).toBe(ownArtefactRead.dev) + expect(peerArtefactRead.ino).toBe(ownArtefactRead.ino) }) it("does not stage or publish while the lock acquisition has not returned", async () => { @@ -467,7 +493,11 @@ describe("safeWriteJson lock key under a peer commit", () => { const parent = path.dirname(resolved) const name = path.basename(resolved) const listed = await real.readdir(parent).catch(() => [] as string[]) - const stored = listed.find((entry: string) => entry.toLowerCase() === name.toLowerCase()) + // Prefer the entry whose name differs from the request in case alone. On a folding + // filesystem that is the one name the directory stores; on a case-sensitive one the + // directory holds both spellings and the match has to be pinned to the other one, or the + // divergence this row is built on would depend on readdir order. + const stored = listed.find((entry: string) => entry !== name && entry.toLowerCase() === name.toLowerCase()) return stored === undefined ? resolved : path.join(parent, stored) } const probeLower = path.join(dir, "probe.json") @@ -505,14 +535,18 @@ describe("safeWriteJson lock key under a peer commit", () => { return { ...actual, acquireFileLock: vi.fn(async (file: string) => { - // The divergence the guard is asked about, reached the same way as in the row - // above: this writer's key keeps the spelling it was computed from, and the name - // the filesystem reports for the file appears under it during acquisition. + // The divergence the guard is asked about, reached the same way as in the row above: + // this writer's key keeps the spelling it was computed from, the name the canonicalizer + // answers for the file appears under it during acquisition, and the two artefact names + // are one object by hard link - on either lane, so the stat that fails below has + // somewhere to fail from on either lane. const ownArtefact = actual.lockPathFor(String(file)) await fs.writeFile(ownArtefact, "") await fs.writeFile(lower, JSON.stringify({ mcpServers: { peer: true } })) if (!foldsCase) { - await fs.symlink(lower, upper) + // Hard link rather than symlink, for the reason given in the acceptance row above: + // one object under two names on either hosted lane, with no privilege asked for. + await fs.link(lower, upper) await fs.link(ownArtefact, actual.lockPathFor(lower)) } return async () => {} From 2efa9bd12c20aa3071e7e7f69e88472003f1f3e9 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:29:56 +0800 Subject: [PATCH 104/115] test(S4b #1375): sort the acceptance row's four-name list and make its geometry assert itself Two fixes in one file, both found by running things rather than by reading them. The case-sensitive branch of the acceptance row asserted a lowercased entry list in insertion order while the assertion itself sorts what the directory returns, so on a case-sensitive filesystem the two orders differ and the row failed on the count comparison even though every content assertion above it passed. The list is now in sorted order, which is what the comparison asked for all along. The fact that the content assertions ahead of it passed on ubuntu is the evidence that the rename-target reading in the previous commit was right: the writer's own spelling carries this writer's content, and the row's earlier failure there was the row asking the wrong file. The acceptance row now also asserts its own geometry before it asserts anything about the guard: the key handed to acquireFileLock has to be the spelling the writer asked in with (the near half of the divergence), and the writer's own spelling has to exist by the time acquisition returns (the far half). Both are recorded from inside the acquisition seam, not inferred from the fixture. That self-check earned its place immediately. Installed into the propagation row as well, it came back with an empty capture array on this host: that row's doMocked ../fileLock seam never runs here, so the pair the row says it builds during acquisition is not being built by that seam, and the stat count the row reports comes from the folding filesystem's own restating rather than from the fixture. A diagnostic that says "your double is not running" is not a verdict about the guard, so the capture and its assertions were removed from that row and the stat-count assertion keeps a message that says what a zero means. The propagation row's case-sensitive half therefore stays unverified, and the question of which seam should supply the peer's appearance during acquisition is open - it is a design choice about the lock machinery, not a line I land on my own. Local at this head: safeWriteJson.lockKey.spec.ts 10 passed (the case-folding branch; the case-sensitive branch cannot execute on a case-insensitive host - a hard link onto the other-case name answers EEXIST and readdir still lists one entry, measured). eslint --prune-suppressions --max-warnings=0 exits 0 with src/eslint-suppressions.json back equal to HEAD, prettier --check clean, tsc reports only the known activate/registerCommands.ts(88,102) TS2739 junction artefact. Test-only, no platform gate added. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 3d8b9b1a37..3a76933214 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -182,6 +182,12 @@ describe("safeWriteJson lock key under a peer commit", () => { it("accepts the name the filesystem itself answers for the spelling the key was computed from", async () => { const dir = await makeDir("lockkey-casefold-") + // The fixture's own geometry, recorded during acquisition rather than assumed: the key the product + // hands to acquireFileLock is the spelling its pre-lock resolution answered with, and whether the + // writer's own spelling exists by the time acquisition returns is the far half of the divergence the + // guard is then asked about. Both rows assert these before asserting anything about the guard, so a + // platform that stops building the pair fails here instead of somewhere harder to read. + const acquisitionFacts: Array<{ key: string; spellingPresentDuringAcquisition: boolean }> = [] const lower = path.join(dir, "settings.json") const upper = path.join(dir, "SETTINGS.JSON") const real = await vi.importActual("fs/promises") @@ -235,6 +241,13 @@ describe("safeWriteJson lock key under a peer commit", () => { await fs.link(lower, upper) await fs.link(ownArtefact, lockPathFor(lower)) } + acquisitionFacts.push({ + key: String(file), + spellingPresentDuringAcquisition: await fs.lstat(upper).then( + () => true, + () => false, + ), + }) return async () => {} }) @@ -246,6 +259,17 @@ describe("safeWriteJson lock key under a peer commit", () => { // names reach one artefact, so this writer holds the lock the other spelling would take and // the write is not refused. Where the two platforms differ is in how many names exist, which // is a property of the directory and not of the guard, so only the counts branch. + // Geometry before verdict. + expect( + acquisitionFacts.map((fact: { key: string }) => path.basename(fact.key)), + "the lock key handed to acquisition was not this writer's spelling, so no divergence reached the guard", + ).toEqual(["SETTINGS.JSON"]) + expect( + acquisitionFacts.map( + (fact: { spellingPresentDuringAcquisition: boolean }) => fact.spellingPresentDuringAcquisition, + ), + "the writer's spelling never appeared during acquisition, so the under-lock resolution had nothing to restate", + ).toEqual([true]) expect(String(error && error.name)).toBe("undefined") expect(JSON.parse(await fs.readFile(upper, "utf8"))).toEqual({ mcpServers: { mine: true } }) if (!foldsCase) { @@ -263,7 +287,7 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(entries.map((name: string) => name.toLowerCase()).sort()).toEqual( foldsCase ? ["settings.json", "settings.json.lock"] - : ["settings.json", "settings.json.lock", "settings.json", "settings.json.lock"], + : ["settings.json", "settings.json", "settings.json.lock", "settings.json.lock"], ) expect(entries.length).toBe(foldsCase ? 2 : 4) // The guard answered from object identity, so the identity is read back off the filesystem @@ -549,7 +573,6 @@ describe("safeWriteJson lock key under a peer commit", () => { await fs.link(lower, upper) await fs.link(ownArtefact, actual.lockPathFor(lower)) } - return async () => {} }), } }) @@ -561,7 +584,10 @@ describe("safeWriteJson lock key under a peer commit", () => { (caught: unknown) => caught as Error, ) - expect(statCalls).toBeGreaterThan(0) + expect( + statCalls, + "the guard never put its identity question to the filesystem on this platform", + ).toBeGreaterThan(0) expect(String(error && (error as { code?: string }).code)).toBe("EACCES") expect(String(error && error.name)).not.toBe("LockKeyDriftError") // Fail closed: the writer published nothing, and the file it would have replaced still From a6a11564e0dc1992988bc5743132f9022d23e105 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:40:23 +0800 Subject: [PATCH 105/115] test(utils): pin withFileLock's release-on-rejection contract, including a failing release fileLock.ts:181-:186 is the rejection path: withFileLock releases the lock, and if that release itself throws it logs the release failure and still rethrows the caller's operation error (:186). The release helper is the one at :153-:155, and the chain the acquisition rolled back on a failed lock is at :183-:185 territory on the acquisition side, so this row is about what a caller can rely on when the operation rejects while the lock is held. fileLock.test.ts:30 ("releases the lock when the operation fails") already pins release-on- rejection, so this row is not a second assertion of that half. What it adds is the secondary failure: the release call rejects with its own EPERM, the caller still receives the cancellation object as the rejection (toBe, not a message match), the release failure is reported through console.error with the lock path in the message, and the directories the acquisition chain created are left in place rather than pruned on the failure path. The header comment names the window this row does not cover: an abort that lands after lockfile.lock resolves but before the caller observes the returned release function leaves the lock held with no caller to release it, and that case is guarded only by proper-lockfile's staleness handling, not by anything in withFileLock. Negative control: deleting the release call from the rejection path (the `await releaseLock()` inside fileLock.ts's catch) reddens exactly the two rows that assert the contract - fileLock.test.ts:30 and this one - with "expected \"vi.fn()\" to be called once, but got 0 times", and leaves the other three rows and fileLockChain.test.ts green. fileLock.ts was restored byte-identical after the control. Test-only. No product change, no it.runIf gate added, and no new platform branch. The console.error spy is restored in the row's own finally because this file has no afterEach. Lane read: utils/__tests__/fileLock.test.ts and utils/__tests__/fileLockChain.test.ts - 8 passed locally on Windows. --- src/utils/__tests__/fileLock.test.ts | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/utils/__tests__/fileLock.test.ts b/src/utils/__tests__/fileLock.test.ts index 240fc987df..f937e290a3 100644 --- a/src/utils/__tests__/fileLock.test.ts +++ b/src/utils/__tests__/fileLock.test.ts @@ -89,4 +89,42 @@ describe("file lock protocol", () => { await fsp.rm(base, { recursive: true, force: true }) } }) + + // The contract that exists on the release path, pinned rather than assumed. A cancellation that surfaces as + // a rejection after acquisition does release the lock (fileLock.ts:181-:186), and the caller keeps their + // own error even when the release behind it fails: :183-:185 logs the release failure and rethrows the + // operation's, which is the half a well-meaning fix flips by accident. The directory chain this call + // made stays, because it belongs to the artefact rather than to the outcome - :153-:155 owns created + // directories only when acquisition itself failed - and the next acquisition reuses them instead of + // paying for them twice. The window nothing in this module observes is an abort between lockfile.lock + // resolving and the caller observing the returned release function; that one is covered by the + // staleness contract, and no row here should pretend the release path closes it. + it("releases the lock after a cancelled operation, keeps the cancellation as the caller's error, and leaves the chain the lock owned", async () => { + const base = await fsp.mkdtemp(path.join(os.tmpdir(), "filelock-cancel-")) + const target = path.join(base, "missing", "nested", "history.json") + const releaseFailure = Object.assign(new Error("EPERM: operation not permitted, unlock"), { code: "EPERM" }) + const release = vi.fn(async () => { + throw releaseFailure + }) + vi.mocked(lockfile.lock).mockResolvedValue(release) + const cancellation = Object.assign(new Error("The task was cancelled"), { name: "AbortError" }) + // A failed release is reported, not rethrown. Captured so the row can say which of the two it is. + // Restored inline: this file has no afterEach to do it, and a leaked spy would mute the next row's + // real logging. + const reported = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect( + withFileLock(target, async () => { + throw cancellation + }), + ).rejects.toBe(cancellation) + expect(release).toHaveBeenCalledOnce() + expect(reported).toHaveBeenCalledWith(expect.stringContaining("Failed to release lock for"), releaseFailure) + // The chain the lock was taken for is still there, including the level the artefact needed. + expect((await fsp.stat(path.join(base, "missing", "nested"))).isDirectory()).toBe(true) + } finally { + reported.mockRestore() + await fsp.rm(base, { recursive: true, force: true }) + } + }) }) From bee03bbe5d9c736ef5e49fb36f5ab2ccd1bf41b8 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:40:29 +0800 Subject: [PATCH 106/115] test(utils): make the artefact-stat propagation row assert that its own seam ran The row asks what safeWriteJson does when the guard's stat of a lock artefact fails for some reason other than the artefact being absent (safeWriteJson.ts:141-:150 turns ENOENT into "no artefact" and rethrows anything else). It was green on a case-folding filesystem and red on a case-sensitive one with "the guard never put its identity question to the filesystem on this platform: expected 0 to be greater than 0". Instrumenting it answered the question about the row rather than the guard: a capture placed inside the acquisition double the row had installed with vi.doMock came back empty, so the double was never the one the code under test used. vi.resetModules plus a re-import binds the re-imported module to this file's hoisted module-level vi.mock factories, not to the row's doMock pairs, so the writer was being served by the file's own doubles the whole time - NTFS's case restating supplying the key divergence, and a real fs.stat supplying the zeros. That diagnostic is evidence about the test, not about the guard: nothing measured here says the guard behaves differently on either platform. The repair is at the top of the file rather than inside the row. The fs/promises factory gains a stat double that defaults to a pass-through, because the acceptance rows read the artefact identity back through fs.stat, and the propagation row re-arms realpath, lstat, readlink and acquireFileLock for itself and restores them in its own finally. No vi.doMock, no vi.resetModules, no re-import, so there is no second set of doubles for the module to ignore. A row that asserts through an injected seam has to assert that the seam ran. The capture is asserted non-empty first, and asserted to hold this writer's own spelling, with the comment above it saying why it is there: if a later change stops the row from driving acquisition, it reddens against the fixture instead of returning a zero that reads like a verdict about the code. The acquisition double then reports which key it was handed and whether the artefact it built is present, and only after that does the row read the guard's answer. The fixture geometry is the one the acceptance row already argued for: hard link rather than symlink, because fs.symlink on a file answers EPERM on the Windows lane while a hard link gives one object under two names on NTFS and ext4 alike with no privilege asked for; the peer's file and the two artefact spellings are joined so the failing stat has a comparison to fail from on either lane; and the folding question is probed with a throwaway file rather than assumed. On a case-sensitive filesystem the writer's key keeps the spelling it was computed from and the peer arrives under the other name; on a folding one the filesystem restates the key itself. Test-only. No product change, no it.runIf gate added, no platform branch, and no new gate. Reads: utils/__tests__/safeWriteJson.lockKey.spec.ts - 10 passed locally on Windows with a non-empty capture, which is the half that proves the seam runs. The ext4 half is the one that tests the guard's answer, and it is executed by the ubuntu coverage lane rather than verified locally, because the case-sensitive branch cannot execute on this host. Wider lock-family read: safeWriteJson.test.ts, safeWriteJson.lockKey.spec.ts, fileLock.test.ts, fileLockChain.test.ts, fileLockContention.test.ts and jsonFileRecovery.test.ts - 82 passed, 4 skipped. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 165 ++++++++++++------ 1 file changed, 111 insertions(+), 54 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 3a76933214..03790e15e0 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -19,13 +19,21 @@ vi.mock("../fileLock", async () => { vi.mock("fs/promises", async () => { const actual = await vi.importActual("fs/promises") - return { ...actual, realpath: vi.fn(), lstat: vi.fn(), readlink: vi.fn() } + return { + ...actual, + realpath: vi.fn(), + lstat: vi.fn(), + readlink: vi.fn(), + // stat stays a pass-through by default. The acceptance rows read the artefact identity back + // through it, and the one row that overrides it puts this back when it finishes. + stat: vi.fn(actual.stat), + } }) - const mockedRealpath = vi.mocked(fs.realpath) const mockedLstat = vi.mocked(fs.lstat) const mockedReadlink = vi.mocked(fs.readlink) const mockedAcquireFileLock = vi.mocked(acquireFileLock) +const mockedStat = vi.mocked(fs.stat) const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) @@ -512,6 +520,20 @@ describe("safeWriteJson lock key under a peer commit", () => { const lower = path.join(dir, "settings.json") const upper = path.join(dir, "SETTINGS.JSON") const real = await vi.importActual("fs/promises") + // fs.stat is overloaded - bigint stats are their own overload - so the pass-through and the + // injected variant are built from one rest signature here instead of being asserted at each + // use. Arguments and results go straight through in both directions. + const realStatPassThrough = async ( + ...args: Parameters + ): Promise>> => await real.stat(...args) + const injectArtefactStat = + (fail: () => never) => + async (...args: Parameters): Promise>> => { + if (String(args[0]).endsWith(".lock")) { + fail() + } + return await real.stat(...args) + } const restatingRealpath = async (target: string): Promise => { const resolved = await real.realpath(target) const parent = path.dirname(resolved) @@ -532,58 +554,96 @@ describe("safeWriteJson lock key under a peer commit", () => { ) await real.rm(probeLower, { force: true }) - // A promise re-import, so the failing stat does not leak into another row. Only stat - // fails, and only on a lock artefact: the write path's own stat calls (mode preservation) - // keep working, so the EACCES this row sees came from the guard's comparison and nowhere - // else. + // The instrument this row owes its reader. It used to install its own fs and fileLock mocks with + // vi.doMock and re-import safeWriteJson through vi.resetModules, and it was green on a + // case-folding filesystem while reporting zero artefact stats on a case-sensitive one. The + // reason was neither the guard nor the platform: asserted from inside the acquisition double it + // thought it had installed, the capture came back empty, which means the re-import never used + // that double and the row was being served by this file's module-level doubles - NTFS's own + // case restating supplying the divergence, and a real stat supplying the zeros. A row that + // injects a seam has to assert that the seam ran, because a green row that measures nothing is + // indistinguishable from a passing test, and that is what happened here. The doubles are the + // module-level ones now, re-armed for this row, with no resetModules and no re-import, and the + // capture is asserted first below so a scoping regression cannot go silent again. + const acquisitionFacts: Array<{ key: string; artefactBuilt: boolean }> = [] let statCalls = 0 - vi.doMock("fs/promises", async () => { - const actual = await vi.importActual("fs/promises") - const refusal = Object.assign(new Error("EACCES: permission denied, stat"), { code: "EACCES" }) - return { - ...actual, - realpath: vi.fn(async (p: string) => await restatingRealpath(String(p))), - lstat: vi.fn(async (p: string) => await actual.lstat(String(p), { bigint: true })), - readlink: vi.fn(async (p: string) => await actual.readlink(String(p))), - stat: vi.fn(async (p: string, options: unknown) => { - if (String(p).endsWith(".lock")) { - statCalls++ - throw refusal - } - return await actual.stat(String(p), options as { bigint: true }) - }), - } - }) - vi.doMock("../fileLock", async () => { - const actual = await vi.importActual("../fileLock") - return { - ...actual, - acquireFileLock: vi.fn(async (file: string) => { - // The divergence the guard is asked about, reached the same way as in the row above: - // this writer's key keeps the spelling it was computed from, the name the canonicalizer - // answers for the file appears under it during acquisition, and the two artefact names - // are one object by hard link - on either lane, so the stat that fails below has - // somewhere to fail from on either lane. - const ownArtefact = actual.lockPathFor(String(file)) - await fs.writeFile(ownArtefact, "") - await fs.writeFile(lower, JSON.stringify({ mcpServers: { peer: true } })) - if (!foldsCase) { - // Hard link rather than symlink, for the reason given in the acceptance row above: - // one object under two names on either hosted lane, with no privilege asked for. - await fs.link(lower, upper) - await fs.link(ownArtefact, actual.lockPathFor(lower)) - } - }), + const refusal = Object.assign(new Error("EACCES: permission denied, stat"), { code: "EACCES" }) + mockedRealpath.mockImplementation( + async (target: Parameters[0]) => await restatingRealpath(String(target)), + ) + // lstat and readlink pass through for this row. resolvePublishTarget follows aliases through + // them, and an un-armed double would answer undefined where this row needs the real answer. + // They reject the way the filesystem rejects, which is the half the comment at the top of this + // module warns about. + mockedLstat.mockImplementation( + async (target: Parameters[0]) => await real.lstat(String(target)), + ) + mockedReadlink.mockImplementation( + async (target: Parameters[0]) => await real.readlink(String(target)), + ) + mockedAcquireFileLock.mockImplementation(async (file) => { + // The divergence the guard is asked about, reached the way a writer reaches it: this writer's + // key keeps the spelling it was computed from, the name the canonicalizer answers for the + // file appears under it while the lock is held, and the two artefact names are one object by + // hard link on either lane, so the stat below has a comparison to fail from on either lane. + // Hard link rather than symlink, for the reason given in the acceptance row above: one object + // under two names on NTFS and on ext4 alike, with no privilege asked for. + const ownArtefact = lockPathFor(String(file)) + await real.writeFile(ownArtefact, "") + await real.writeFile(lower, JSON.stringify({ mcpServers: { peer: true } })) + if (!foldsCase) { + await real.link(lower, upper) + await real.link(ownArtefact, lockPathFor(lower)) } + acquisitionFacts.push({ + key: String(file), + artefactBuilt: await real.stat(ownArtefact).then( + () => true, + () => false, + ), + }) + return async () => {} }) - vi.resetModules() - const safeWriteJsonReimported = (await import("../safeWriteJson")).safeWriteJson - - const error = await safeWriteJsonReimported(upper, { mcpServers: { mine: true } }).then( - () => undefined, - (caught: unknown) => caught as Error, + // Only a lock artefact fails, so the EACCES this row reports is the guard's comparison and + // nowhere else: the write path's own stat calls, which preserve the target mode, keep working. + mockedStat.mockImplementation( + injectArtefactStat(() => { + statCalls++ + throw refusal + }), ) + let error: Error | undefined + try { + error = await safeWriteJson(upper, { mcpServers: { mine: true } }).then( + () => undefined, + (caught: unknown) => caught as Error, + ) + } finally { + // Restored whatever the row concludes, so the injected failure cannot reach another row. + mockedStat.mockImplementation(realStatPassThrough) + mockedRealpath.mockReset() + mockedLstat.mockReset() + mockedReadlink.mockReset() + mockedAcquireFileLock.mockReset() + } + + // The seam ran, and it was handed this writer's spelling. Both facts are about the fixture + // rather than about the guard, which is why they come before the guard's answer: if a later + // change stops this row from driving acquisition, it has to say so here instead of turning + // the count back into a zero that reads like a verdict about the code. On a case-folding + // filesystem the key arrives restated by the filesystem itself and the writer's spelling is the + // name the directory already stores; on a case-sensitive one the key keeps the spelling this + // writer asked in with, and the peer's file is what sits under the other name. + expect( + acquisitionFacts.length, + "the acquisition seam this row injects never ran, so nothing below it was driven by the fixture", + ).toBe(1) + expect(acquisitionFacts[0].artefactBuilt).toBe(true) + expect( + acquisitionFacts.map((fact: { key: string }) => path.basename(fact.key)), + "acquisition was not asked to lock this writer's spelling, so no divergence reached the guard", + ).toEqual([path.basename(upper)]) expect( statCalls, "the guard never put its identity question to the filesystem on this platform", @@ -592,10 +652,7 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(String(error && error.name)).not.toBe("LockKeyDriftError") // Fail closed: the writer published nothing, and the file it would have replaced still // holds the peer's content. - expect(JSON.parse(await fs.readFile(lower, "utf8"))).toEqual({ mcpServers: { peer: true } }) - expect((await fs.readdir(dir)).length).toBe(foldsCase ? 2 : 4) - vi.doUnmock("fs/promises") - vi.doUnmock("../fileLock") - vi.resetModules() + expect(JSON.parse(await real.readFile(lower, "utf8"))).toEqual({ mcpServers: { peer: true } }) + expect((await real.readdir(dir)).length).toBe(foldsCase ? 2 : 4) }) }) From 0a76a7a7b1c91ec1c7ac05c0554295c312925172 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:41:59 +0800 Subject: [PATCH 107/115] fix(utils): take the delete lock on the key the writer publishes under deleteJsonFile locked the path it was handed. Every other writer-side entry point resolves first: guardedWrite.ts:192 and :257 and DiffViewProvider.ts:722 call withFileLock(await resolveLockKey(...), ...), and safeWriteJson.ts:346 acquires on a key that came from resolveLockKey. jsonFileRecovery.ts:80 was the odd one out, and lock identity here is lexical - acquireFileLock derives the artefact from the path with lockPathFor - so a delete asked for through a link was contending for .lock while the writer held .lock. Two spellings, two locks, and no exclusion at all. The measurement that frames this: on this host a file symlink answers EPERM, so the fixture fell back to a hard link, and resolveLockKey answered two different keys for the pair (one key :: false), a writer holding the referent lock did not block the alias delete (it proceeded, and removed the alias spelling while the referent survived - readdir :: ["history_item.json"]), and the same delete asked for by the referent's own name was refused ELOCKED. The alias only becomes contention when there is a referent to resolve, which is the half this host cannot build for a file. What the alignment costs, stated as the tradeoff it is: a delete through an alias now removes the object the alias refers to and leaves the caller's own spelling standing as a dangling symlink, and the leftover-prefix scan runs against the canonical basename too. That is exclusion bought by deleting the canonical object instead of the named link. The alternative shape exists and is not implemented here: lock on the canonical key and unlink path.resolve(filePath), which keeps today's unlink semantics while still contending correctly. Choosing it is the maintainer of the public src/utils/jsonFileRecovery.ts surface, not me, because it changes what a caller of a documented helper gets. No caller in this repository exercises the difference: deleteJsonFile is referenced in three files - the module itself, its test, and TaskHistoryStore.ts - and every TaskHistoryStore call site (delete :285, deleteMany :315, removeDelegationRepairIntent :738) passes path.join(tasksDir, taskId, GlobalFileNames.historyItem), a plain name under path.join(basePath, "tasks"), never a link. Reaching the key is fail-closed, matching the write paths: resolveLockKey's catch rethrows a realpath failure that is not "not there yet", and the await sits outside withFileLock, so a filesystem that will not answer the alias question means no lock and no delete. The three TaskHistoryStore call sites swallow anything that is not ENOENT with console.warn (:286-:290, :316-:320, :739-:741), so in practice that becomes a logged best-effort skip rather than a surfaced failure - which is also what makes delete()'s existing JSDoc promise at :270-:275 ("the unlink runs under the same per-file advisory lock as safeWriteJson") literally true. recoverMissingJsonFile (jsonFileRecovery.ts:45-:46) locks the unresolved path for the same reason and is left alone here: it is the adjacent observation, named and untouched, and its rows are about a reader recovering a canonical file rather than about alias contention. Three rows, all gated with it.runIf(process.platform !== "win32"): fs.symlink on a file answers EPERM on the Windows lane (needs privilege or Developer Mode, and the lane has neither), a hard link has no referent for resolveLockKey to follow, and a junction links directories, so no Windows spelling can express what an alias delete does. They are executed by the ubuntu coverage lane, not verified locally - locally they report as 3 skipped. The gate is declared here rather than assumed. Control, and where it is executable: reverting just the key argument reddens the two alias rows on a POSIX host - the first fails where it expects ELOCKED, because .lock is free while the writer holds .lock, and the second fails because the link it expects to survive as a dangling reference is what gets unlinked - and leaves the referent-side row green, which is the point of that row: nothing about a delete asked for by the referent's own name changes. That control is likewise only executable on a POSIX host, and this commit does not claim it was run. Reads: utils/__tests__/jsonFileRecovery.test.ts - 10 passed, 3 skipped locally on Windows. The callers' lanes were run because the change is under them: TaskHistoryStore.spec.ts, .deleteSemantics, .realConcurrency, .reconciliation, .crossInstance, importRooTaskHistory, the four ClineProvider history specs, the three Task specs, safeWriteJson.interruption, abandonSubtask and delegation-concurrent - 18 files, 361 passed. tsc --noEmit -p . reports only the known junction artefact at activate/registerCommands.ts(88,102); eslint --prune-suppressions --max-warnings=0 exits 0 with src/eslint-suppressions.json left equal to HEAD. --- src/utils/__tests__/jsonFileRecovery.test.ts | 67 ++++++++++++++++++++ src/utils/jsonFileRecovery.ts | 17 ++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/jsonFileRecovery.test.ts b/src/utils/__tests__/jsonFileRecovery.test.ts index 1bd90d8935..798c1dd2f3 100644 --- a/src/utils/__tests__/jsonFileRecovery.test.ts +++ b/src/utils/__tests__/jsonFileRecovery.test.ts @@ -5,6 +5,7 @@ import * as path from "path" import { acquireFileLock } from "../fileLock" import { setPersistenceTraceSink } from "../../core/task-persistence/persistenceTrace" import { deleteJsonFile, recoverMissingJsonFile } from "../jsonFileRecovery" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" import { readFileWithMissingRetry } from "../../core/task-persistence/readFileWithMissingRetry" vi.mock("fs/promises", async () => { @@ -101,6 +102,72 @@ describe("reader recovery", () => { await expect(fs.access(backup)).resolves.toBeUndefined() }) + // The three rows below need a file symlink. `fs.symlink` on a file answers EPERM on the Windows + // lane, which has neither privilege nor Developer Mode, and no other Windows spelling carries the + // meaning: a hard link has no referent for resolveLockKey to follow, and a junction links + // directories. So these run on the non-Windows lanes - executed there by the ubuntu coverage run, + // not verified locally - and the claim they pin is a POSIX claim about what an alias delete does. + const itPosix = it.runIf(process.platform !== "win32") + + itPosix("contends with the writer holding the referent lock when asked for through an alias", async () => { + const alias = path.join(dir, "history_item_alias.json") + await fs.writeFile(target, JSON.stringify({ committed: true })) + await fs.symlink(target, alias) + // The row is only about the key. If the two spellings did not resolve to the same lock, the + // delete below would be contending for a name no writer ever takes, and the refusal would + // prove nothing about the alignment. + expect(await resolveLockKey(alias)).toBe(await resolveLockKey(target)) + const release = await acquireFileLock(target) + try { + await expect(deleteJsonFile(alias)).rejects.toMatchObject({ code: "ELOCKED" }) + // Refused, so it touched nothing: not the referent, and not the backup beside it. + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + expect(JSON.parse(await fs.readFile(backup, "utf8"))).toEqual({ committed: true }) + expect((await fs.lstat(alias)).isSymbolicLink()).toBe(true) + } finally { + await release() + } + // Once the writer is gone the same call goes through, and it removes the referent. + await deleteJsonFile(alias) + await expect(fs.stat(target)).rejects.toMatchObject({ code: "ENOENT" }) + }) + + itPosix("removes the referent an alias names, leaving the link standing and unreadable", async () => { + const alias = path.join(dir, "history_item_alias.json") + await fs.writeFile(target, JSON.stringify({ committed: true })) + await fs.symlink(target, alias) + await deleteJsonFile(alias) + // This is what the alignment costs, and it is a change from unlinking the name the caller + // passed: the spelling the caller asked about is still there, and reading through it now + // answers ENOENT because what it pointed at is gone. The leftover prefixes come from the + // referent's basename too, so the backup is removed under either spelling. + expect((await fs.lstat(alias)).isSymbolicLink()).toBe(true) + await expect(fs.stat(alias)).rejects.toMatchObject({ code: "ENOENT" }) + await expect(fs.readFile(alias, "utf8")).rejects.toMatchObject({ code: "ENOENT" }) + await expect(fs.access(backup)).rejects.toMatchObject({ code: "ENOENT" }) + }) + + itPosix("asks the referent-side delete for the same lock it asked for before", async () => { + const alias = path.join(dir, "history_item_alias.json") + await fs.writeFile(target, JSON.stringify({ committed: true })) + await fs.symlink(target, alias) + // Asked for by the referent's own name nothing here is new: that delete already contended + // with the referent lock, because its key was always the referent. The alignment is only + // visible from the alias side, which is why the row asserts both halves - the contention that + // was already there, and the outcome that is unchanged by the change. + const release = await acquireFileLock(target) + try { + await expect(deleteJsonFile(target)).rejects.toMatchObject({ code: "ELOCKED" }) + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + } finally { + await release() + } + await deleteJsonFile(target) + await expect(fs.stat(target)).rejects.toMatchObject({ code: "ENOENT" }) + expect((await fs.lstat(alias)).isSymbolicLink()).toBe(true) + await expect(fs.readFile(alias, "utf8")).rejects.toMatchObject({ code: "ENOENT" }) + }) + it("does not create a missing task directory", async () => { await fs.rm(dir, { recursive: true }) await expect(recoverMissingJsonFile(target)).resolves.toBe(false) diff --git a/src/utils/jsonFileRecovery.ts b/src/utils/jsonFileRecovery.ts index 2350f30685..cacb6ca31f 100644 --- a/src/utils/jsonFileRecovery.ts +++ b/src/utils/jsonFileRecovery.ts @@ -5,6 +5,7 @@ import { getErrorCode } from "./errorCode" import { tracePersistenceEvent } from "../core/task-persistence/persistenceTrace" import { withFileLock } from "./fileLock" +import { resolveLockKey } from "../services/file-safety/safeWriteText" function getTempFilePrefix(absoluteFilePath: string, kind: "new" | "bak"): string { return `.${path.basename(absoluteFilePath)}.${kind}_` @@ -75,9 +76,23 @@ export async function recoverMissingJsonFile( * Delete recoverable leftovers before the canonical file under the writer's * lock. Failure preserves the canonical copy, rather than authorizing recovery * of a stale backup after a successful deletion. Missing files are harmless. + * + * The lock is taken on the key `safeWriteJson` publishes under - `resolveLockKey`, + * which follows an existing symlink to the file it refers to - so a delete asked for + * through an alias contends with the writer that holds the referent's lock, and what + * the unlink removes is the referent: the link the caller named survives as a dangling + * symlink, and a caller that wants its own spelling removed has to remove it itself. + * Computing that key is fail-closed. If the filesystem will not answer what the path + * refers to, the delete does not happen at all and the caller receives that error + * rather than an unlocked deletion under a key that names something else. */ export async function deleteJsonFile(filePath: string): Promise { - await withFileLock(filePath, async (absoluteFilePath) => { + // Resolved before the lock is taken, the way guardedWrite.ts:192 and DiffViewProvider.ts:722 + // resolve for a write. The key below is the one the writer holds, so the contention is real, + // and everything the loop unlinks is named from it: for an alias that is the referent, its + // leftovers, and the backup prefix built from the referent's basename rather than the + // spelling the caller passed in. + await withFileLock(await resolveLockKey(path.resolve(filePath)), async (absoluteFilePath) => { let entries: string[] try { entries = await fs.readdir(path.dirname(absoluteFilePath)) From 7ae59a8ef5b6e8d86a4de71e5db1ef1ff82bc6fe Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:44:05 +0800 Subject: [PATCH 108/115] fix(utils): apply the publish target's mode to the staging fd, past the umask Row 4507851727 is true of this file and only of this file. safeWriteJson handed the publish target's mode to fsSync.createWriteStream (was :603), and createWriteStream opens the file - an open's creation mode is narrowed by the process umask, so an existing 0o664 target under a 0o022 umask is staged at 0o644 and the commit rename publishes that, taking the group write access away. safeWriteText.ts already carries the fix for the same hazard: :495-:503 opens the staging file with the wanted mode and then fchmodSync's the descriptor to exactly it when a target existed, and :529-:547 does the same on the caller-staged path. jsonFileRecovery's writer sibling here was the one that stopped at the create call. _streamDataToFile now opens the descriptor itself (safeWriteJson.ts:613), applies one fchmod to it when a publish target's mode was read (:614-:616), and hands the open descriptor to createWriteStream (:628), which closes it on finish or error exactly as it closed the one it opened itself. Two details are deliberate. The chmod is bound to the descriptor rather than the path, for the reason safeWriteText.ts:519-:521 already gives: a path chmod is a second lookup, and an fd chmod cannot be retargeted by another writer moving something underneath the staging name. And a mode that was never read - no publish target yet - means no fchmod, so a fresh file keeps the creation default instead of this writer deciding how private a file it has not created should be. The open is closed if the chmod fails, so a write that is going to report a failure does not also leak the handle it opened to make it. One consequence to put in front of a reviewer rather than bury: an fchmod that the filesystem refuses now aborts the write, where before no chmod ran at all and the write proceeded at the narrowed mode. That is the fail-closed direction, and it matches the choice safeWriteText.ts:502 already makes for text writes, but it is a new way for a JSON write to fail on a hostile filesystem rather than a new way for it to succeed quietly. Tests. The file's "fs" mock gains openSync and fchmodSync doubles that default to the real calls (:62-:63), because the mode rows read what production passed and the write still has to land on disk. captureStagingModes becomes captureStagingModeCalls (:172), recording both halves - the mode argument the open was given and the mode argument the chmod was given - and the two existing mode rows follow the new shape rather than adding neighbours: a missing mode now reads as no chmod call at all, which is the assertion that pins the fresh-target rule. The landed-mode row is new and gated with it.runIf(process.platform !== "win32"): on Windows the creation mode carries only the read-only bit, so 0o664 is not a mode a landed file can report. It is executed by the ubuntu coverage lane, not verified locally, and locally it reports as skipped; the file already gated its 0o600 landed-mode row the same way, and no other row gained a gate. The row sets the umask for itself rather than inheriting the runner's, and restores it in a finally. Negative control, measured where it is executable: deleting the fchmod (the if-block at :614-:616) reddens exactly one of 52 rows - the capture row, with "expected [] to deeply equal [438]", the chmod recording gone empty - and leaves the other 51 green, including the content rows. The capture row runs on every platform, so that control was measured locally; the landed-mode row's own control is POSIX-only for the same reason the row is, and this commit does not claim it ran. Reads: utils/__tests__/safeWriteJson.test.ts 52 passed, 5 skipped; the seven writer-family lanes (safeWriteJson, safeWriteJson.interruption, safeWriteJson.lockKey.spec, safeWriteText.spec, fileLock, fileLockChain, jsonFileRecovery) 169 passed, 8 skipped. Consumers run on top of the change: integrations/editor/__tests__/DiffViewProvider.spec.ts 168 passed, __tests__/extension.spec.ts 24 passed, core/tools/__tests__/guardedWrite.spec.ts and TaskHistoryStore.spec.ts green. tsc --noEmit -p . reports only the known junction artefact at activate/registerCommands.ts(88,102); eslint --prune-suppressions --max-warnings=0 exits 0 with src/eslint-suppressions.json left equal to HEAD; prettier --check clean. --- src/utils/__tests__/safeWriteJson.test.ts | 106 +++++++++++++++++----- src/utils/safeWriteJson.ts | 27 +++++- 2 files changed, 108 insertions(+), 25 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index b5c1513d57..f6eeaa4631 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -18,6 +18,8 @@ const fsPromisesActuals = vi.hoisted(() => ({ // replace the mock for one call and still let the write reach the real filesystem. const fsSyncActuals = vi.hoisted(() => ({ createWriteStream: undefined as (typeof import("fs"))["createWriteStream"] | undefined, + openSync: undefined as (typeof import("fs"))["openSync"] | undefined, + fchmodSync: undefined as (typeof import("fs"))["fchmodSync"] | undefined, })) vi.mock("fs/promises", async () => { @@ -49,9 +51,16 @@ vi.mock("fs/promises", async () => { vi.mock("fs", async () => { const actualFs = await vi.importActual("fs") fsSyncActuals.createWriteStream = actualFs.createWriteStream + // openSync and fchmodSync are doubled for the same reason createWriteStream is: the mode rows + // below read what production passed, and the default delegates to the real call so the write + // still lands on disk. + fsSyncActuals.openSync = actualFs.openSync + fsSyncActuals.fchmodSync = actualFs.fchmodSync return { ...actualFs, // Spread actual implementations createWriteStream: vi.fn(actualFs.createWriteStream), // Default to actual, but mockable + openSync: vi.fn(actualFs.openSync), + fchmodSync: vi.fn(actualFs.fchmodSync), } }) @@ -151,48 +160,74 @@ describe("safeWriteJson", () => { // created with becomes the target's mode. These rows pin that the staging file is created as // open or as private as the file it replaces, instead of with the process default. describe("staging file mode", () => { - // Node's WriteStream fills a missing mode in on the options object it is handed, so reading the - // call log afterwards shows a normalized object rather than what was passed. Record the mode at - // the moment production passes it, then hand the call to the real stream writer so the write - // still lands on disk. -1 in the recording stands for "no mode was passed". - function captureStagingModes(): number[] { - const modes: number[] = [] - const realCreateWriteStream = fsSyncActuals.createWriteStream! - vi.mocked(fsSyncActual.createWriteStream).mockImplementation(function (filePath, options) { - const passed: unknown = options - const mode: unknown = - passed !== null && typeof passed === "object" ? (passed as { mode?: unknown }).mode : undefined - modes.push(mode === undefined ? -1 : Number(mode)) - return realCreateWriteStream(filePath, options) + // Production states the mode twice, in the two places it has to be stated: the open, whose + // creation mode the process umask narrows, and one fchmod on the descriptor, which is what + // lands the mode exactly. Node's WriteStream also fills a missing mode in on the options + // object it is handed, so a call log read afterwards shows a normalized object rather than + // what was passed; both arguments here are recorded at the moment production passes them and + // then handed to the real call, so the write still lands on disk. -1 in the open recording + // stands for "no mode was passed"; no entry in the fchmod recording stands for "there was + // no publish target to copy a mode from". + type StagingModeCalls = { openedWith: number[]; chmoddedTo: number[] } + function captureStagingModeCalls(): StagingModeCalls { + const openedWith: number[] = [] + const chmoddedTo: number[] = [] + const realOpenSync = fsSyncActuals.openSync! + const realFchmodSync = fsSyncActuals.fchmodSync! + vi.mocked(fsSyncActual.openSync).mockImplementation(function (filePath, flags, mode) { + const passed: unknown = mode + openedWith.push( + passed === undefined + ? -1 + : typeof passed === "string" + ? Number.parseInt(passed, 8) + : Number(passed), + ) + return realOpenSync( + filePath as Parameters[0], + flags as Parameters[1], + mode, + ) }) - return modes + vi.mocked(fsSyncActual.fchmodSync).mockImplementation(function (filePath, mode) { + chmoddedTo.push(Number(mode)) + return realFchmodSync(filePath, mode) + }) + return { openedWith, chmoddedTo } } - it("creates the staging stream with the publish target's mode", async () => { + it("opens the staging file at the publish target's mode and chmods it to exactly that", async () => { await fs.chmod(currentTestFilePath, 0o600) // Read back the mode the filesystem reports for the target instead of asserting a literal: // Windows carries per-user rights in the DACL and reports a coarse mode, and the claim under - // test is that the staging file is created to match the target, on every platform. + // test is that the staging file is brought to the target's mode, on every platform. const targetMode = (await fs.stat(currentTestFilePath)).mode & 0o777 - const modes = captureStagingModes() + const modeCalls = captureStagingModeCalls() await safeWriteJson(currentTestFilePath, { updated: "content" }) - expect(modes.length).toBe(1) - expect(modes[0]).toBe(targetMode) + expect(modeCalls.openedWith.length).toBe(1) + expect(modeCalls.openedWith[0]).toBe(targetMode) + // The fchmod is the half that survives the umask: the open alone can only ask for this + // mode, and the filesystem answers at most what the mask leaves. One call, on the + // descriptor the write is about to use. + expect(modeCalls.chmoddedTo).toEqual([targetMode]) expect(await readFileContent(currentTestFilePath)).toEqual({ updated: "content" }) }) - it("creates the staging stream with the creation default when the target does not exist yet", async () => { + it("opens the staging file with the creation default when the target does not exist yet", async () => { const missingTarget = path.join(tempDir, "not-written-yet.json") - const modes = captureStagingModes() + const modeCalls = captureStagingModeCalls() await safeWriteJson(missingTarget, { created: "content" }) // Nothing is being replaced, so there is no mode to carry over, and a mode invented here - // would be this write deciding how private a file it has not created yet should be. - expect(modes.length).toBe(1) - expect(modes[0]).toBe(-1) + // would be this write deciding how private a file it has not created yet should be. No + // publish target means no fchmod either, which is the same rule safeWriteText applies to a + // fresh target. + expect(modeCalls.openedWith.length).toBe(1) + expect(modeCalls.openedWith[0]).toBe(-1) + expect(modeCalls.chmoddedTo).toEqual([]) expect(await fileExists(missingTarget)).toBe(true) }) @@ -298,6 +333,29 @@ describe("safeWriteJson", () => { expect(published.mode & 0o777).toBe(0o600) expect(await readFileContent(currentTestFilePath)).toEqual({ updated: "content" }) }) + // Row 4507851727. The direction the row above cannot see: a mask only takes bits away, so a + // restrictive target lands unchanged whether or not the mode was applied exactly, and the + // assertion that separates the two shapes is a group-writable target under a narrowing mask. + // The mask is set for the row rather than relied on, because the value that narrows 0o664 to + // 0o644 is the process's own and not something this row should inherit from a runner. + // Executed by the ubuntu coverage lane, not verified locally: on Windows the creation mode + // carries only the read-only bit, so 0o664 is not a mode a landed file can report and the + // row answers as skipped there. + it.runIf(process.platform !== "win32")( + "keeps a 0o664 target group-writable through an update under a 0o022 umask", + async () => { + await fs.chmod(currentTestFilePath, 0o664) + const umaskBefore = process.umask(0o022) + try { + await safeWriteJson(currentTestFilePath, { updated: "content" }) + const published = await fs.stat(currentTestFilePath) + expect(published.mode & 0o777).toBe(0o664) + expect(await readFileContent(currentTestFilePath)).toEqual({ updated: "content" }) + } finally { + process.umask(umaskBefore) + } + }, + ) }) // Failure Scenarios diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index c64266da40..7b6ba8451c 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -600,7 +600,32 @@ async function _streamDataToFile(targetPath: string, data: unknown, prettyPrint // target's own mode, so the inode that replaces it is not created more openly than the file it // replaces; on Windows the creation mode carries only the read-only bit and per-user rights come // from directory inheritance, which is why the end-to-end mode assertion is POSIX-shaped. - const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8", mode }) + // The mode cannot ride on the create call alone. createWriteStream opens the file, and an + // open's creation mode is narrowed by the process umask, so an existing 0o664 target would be + // published as 0o644 under a 0o022 umask - the group write it had would go missing with the + // rename. One fchmod on the descriptor states the mode exactly. It is bound to the descriptor + // rather than to the path, as safeWriteText.ts:499-:503 and :529-:547 do for text writes: the + // staging name is this writer's own, and an fd-bound chmod cannot be retargeted by another + // writer moving a path underneath it. A missing mode means there was no publish target to + // copy from, and the creation default is left to stand, un-enforced as before. + let stagingFd: number | undefined + try { + stagingFd = fsSync.openSync(targetPath, "w", mode) + if (mode !== undefined) { + fsSync.fchmodSync(stagingFd, mode) + } + } catch (stageError: unknown) { + // An fchmod that fails leaves a descriptor behind, and a write that is going to report a + // failure should not also leak the handle it opened to make it. + if (stagingFd !== undefined) { + fsSync.closeSync(stagingFd) + } + throw stageError + } + // The descriptor is already open, so the stream takes it and closes it when the write ends, + // as it did when it opened the file itself. Passing the path alongside the fd is for the + // error message Node attaches to a stream failure; Node ignores the path once an fd is given. + const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8", fd: stagingFd }) // JsonStreamStringify traverses the object and streams tokens directly // The 'spaces' parameter adds indentation during streaming, not via a separate pass // Convert undefined to null for valid JSON serialization (undefined is not valid JSON) From 304db07bfdb93922d7f1c4583d22c7a7f25822e8 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:50:28 +0800 Subject: [PATCH 109/115] test(utils): gate the artefact-stat propagation row on a measured volume capability The row was the sole failure on the ubuntu coverage lane at 7ae59a8ef: utils/__tests__/safeWriteJson.lockKey.spec.ts reported statCalls 0 with "the guard never put its identity question to the filesystem on this platform". That is not the fixture failing and not the guard failing. On a case-sensitive volume the artefact the acquisition builds and the artefact the guard computes from the key are one name, so there is no divergence for the guard to ask about, so it never stats the artefact, so the injected .lock EACCES is never reached. The row's premise - that the guard has an identity question to put - is a property of the volume, and the row was asking it of a volume that does not have it. The gate is therefore a measured capability rather than a platform label: CASE_FOLDING_VOLUME writes one file into a real temp directory, asks whether a differently-cased name opens, and requires the directory to list exactly one spelling of it. It is loud on purpose. A failure that is not "not there" throws and stops the file from collecting, because a probe that defaulted on an unexpected error would report the capability as absent and turn the gated row into a skip that reads like a pass, which is the one failure mode a gate has. A second control row, ungated, re-measures the same property at run time through the promise surface and compares it against the collection-time answer, so a wrong probe answer shows up as a failure rather than as an absence. What the gate does not claim: on a case-sensitive volume there is no artefact divergence to ask about, so no EACCES-propagation coverage is being skipped there - there is no such stat to fail. The false-positive direction (a probe reporting folding where the volume does not fold) is caught by the gated row's own statCalls assertion rather than by the control row, and that direction could not be demonstrated on this host because this host folds. Gate declaration: this commit adds exactly one it.runIf, on the row that was red; no other row gained a gate. Expectation for the lanes, not a measured CI result yet: the ubuntu coverage lane skips this row and runs the control row, the windows test lane runs the gated row as it does locally. Locally: 11 passed, gated row included. Control, measured: forcing the probe to report false while this volume folds reddens exactly one row - the control row, "the run-time measurement and the collection-time probe disagree about this volume: expected true to be false" - and leaves 9 passed and 1 skipped, which is the gated row going quiet; the gated row's own assertions stay green because they never ran. Restored, 11 passed. Diff shape: the gate adds a nesting level around the row, so prettier re-indented its body. The assertions and the fixture inside that row are unchanged apart from indentation; the only added code is the probe, the control row, and the gate. tsc --noEmit -p . reports only the known junction artefact at activate/registerCommands.ts(88,102); eslint --prune-suppressions --max-warnings=0 exits 0 and src/eslint-suppressions.json was restored to HEAD; prettier --check clean. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 363 +++++++++++------- 1 file changed, 228 insertions(+), 135 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 03790e15e0..3f07d3e1da 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -2,12 +2,13 @@ import * as os from "os" import path from "path" -import type { BigIntStats } from "fs" +import { type BigIntStats, closeSync, mkdtempSync, openSync, readdirSync, rmSync, writeFileSync } from "fs" import * as fs from "fs/promises" import { acquireFileLock } from "../fileLock" import { safeWriteJson } from "../safeWriteJson" import { lockPathFor } from "../fileLock" import { resolveLockKey } from "../../services/file-safety/safeWriteText" +import { getErrorCode } from "../errorCode" vi.mock("../fileLock", async () => { // The drift guard asks whether two keys name one lock, which is a question about the @@ -37,8 +38,47 @@ const mockedStat = vi.mocked(fs.stat) const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) -// Each test creates a real temp directory so the real fs calls still work. -// doubles between tests so an implementation from one test cannot carry over. +// Whether this volume answers a name with a different spelling than the one its directory stores. +// The gate further down needs the capability rather than the platform label, so it is measured +// from a real directory: write one file, ask whether a differently-cased name opens, and require +// the directory to list exactly one spelling of it. A failure that is not "not there" throws and +// stops the file from collecting - a probe that defaulted on an unexpected error would report the +// capability as absent and turn the gated row into a skip that reads like a pass. The row that +// gates on this value re-measures the same property at run time through the promise surface and +// compares, so a wrong answer here fails loudly rather than quietly reducing coverage. +const CASE_FOLDING_VOLUME = (() => { + const dir = mkdtempSync(path.join(os.tmpdir(), "lockkey-foldprobe-")) + try { + const stored = path.join(dir, "probe.json") + writeFileSync(stored, "{}") + // The probe's own easy question first: a volume that will not open the file under the name it + // was written with is not in a position to answer the hard one. + closeSync(openSync(stored, "r")) + const listed = readdirSync(dir) + if (listed.length !== 1) { + throw new Error("case-folding probe directory holds " + String(listed.length) + " entries, expected 1") + } + let opensUnderOtherCase = false + try { + closeSync(openSync(path.join(dir, "PROBE.JSON"), "r")) + opensUnderOtherCase = true + } catch (error: unknown) { + const code = getErrorCode(error) + // "Not there" is the verdict this gate wants: a volume that stores one spelling and does + // not find the other is case-sensitive. Anything else is a question the filesystem refused + // to answer, which is not an answer. + if (code !== "ENOENT") { + throw new Error("case-folding probe could not ask the filesystem: " + String(code ?? error)) + } + } + return opensUnderOtherCase + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() + +// Each test creates a real temp directory so the real fs calls still work, +// and clears the module-level doubles between tests so an implementation from one test cannot carry over. const createdDirs: string[] = [] async function makeDir(prefix: string): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)) @@ -515,144 +555,197 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(await resolveLockKey(file)).toBe(path.join(canonicalDir, "history_item.json")) }) - it("propagates a lock-artefact stat that fails for a reason other than the artefact being absent", async () => { - const dir = await makeDir("lockkey-artefactstat-") - const lower = path.join(dir, "settings.json") - const upper = path.join(dir, "SETTINGS.JSON") + it("measures the same case-folding answer at run time that the collection-time probe reported", async () => { + // This row is the probe's control rather than another assertion about the writer. The gate on + // the row below skips work on the strength of a value computed while this file was collected, + // and a probe that reported the capability as absent would turn that row into a skip that + // reads like a pass. Re-measuring at run time, through the promise surface, in a directory + // this row creates, means a wrong probe answer shows up here as a failure instead of as an + // absence. Its own control is hard-coding CASE_FOLDING_VOLUME to false at collection time: + // on a folding volume this row reddens, which is the direction that could otherwise go quiet. + const dir = await makeDir("lockkey-foldcheck-") const real = await vi.importActual("fs/promises") - // fs.stat is overloaded - bigint stats are their own overload - so the pass-through and the - // injected variant are built from one rest signature here instead of being asserted at each - // use. Arguments and results go straight through in both directions. - const realStatPassThrough = async ( - ...args: Parameters - ): Promise>> => await real.stat(...args) - const injectArtefactStat = - (fail: () => never) => - async (...args: Parameters): Promise>> => { - if (String(args[0]).endsWith(".lock")) { - fail() + const stored = path.join(dir, "probe.json") + await real.writeFile(stored, "{}") + const ownName = await real.open(stored, "r").catch(() => undefined) + await ownName?.close() + expect(ownName, "the file this row created did not open under its own name").not.toBeUndefined() + expect(await real.readdir(dir)).toHaveLength(1) + const otherCase = await real.open(path.join(dir, "PROBE.JSON"), "r").then( + async (opened: Awaited>) => { + await opened.close() + return true + }, + (error: unknown) => { + const code = getErrorCode(error) + // Same rule the probe follows: absence is an answer, anything else is a measurement + // that did not happen and must not be recorded as one. + if (code !== "ENOENT") { + throw new Error( + "run-time case-folding check could not ask the filesystem: " + String(code ?? error), + ) } - return await real.stat(...args) - } - const restatingRealpath = async (target: string): Promise => { - const resolved = await real.realpath(target) - const parent = path.dirname(resolved) - const name = path.basename(resolved) - const listed = await real.readdir(parent).catch(() => [] as string[]) - // Prefer the entry whose name differs from the request in case alone. On a folding - // filesystem that is the one name the directory stores; on a case-sensitive one the - // directory holds both spellings and the match has to be pinned to the other one, or the - // divergence this row is built on would depend on readdir order. - const stored = listed.find((entry: string) => entry !== name && entry.toLowerCase() === name.toLowerCase()) - return stored === undefined ? resolved : path.join(parent, stored) - } - const probeLower = path.join(dir, "probe.json") - await real.writeFile(probeLower, "{}") - const foldsCase = await restatingRealpath(path.join(dir, "PROBE.JSON")).then( - async (resolved: string) => path.basename(resolved) === path.basename(probeLower), - () => false, - ) - await real.rm(probeLower, { force: true }) - - // The instrument this row owes its reader. It used to install its own fs and fileLock mocks with - // vi.doMock and re-import safeWriteJson through vi.resetModules, and it was green on a - // case-folding filesystem while reporting zero artefact stats on a case-sensitive one. The - // reason was neither the guard nor the platform: asserted from inside the acquisition double it - // thought it had installed, the capture came back empty, which means the re-import never used - // that double and the row was being served by this file's module-level doubles - NTFS's own - // case restating supplying the divergence, and a real stat supplying the zeros. A row that - // injects a seam has to assert that the seam ran, because a green row that measures nothing is - // indistinguishable from a passing test, and that is what happened here. The doubles are the - // module-level ones now, re-armed for this row, with no resetModules and no re-import, and the - // capture is asserted first below so a scoping regression cannot go silent again. - const acquisitionFacts: Array<{ key: string; artefactBuilt: boolean }> = [] - let statCalls = 0 - const refusal = Object.assign(new Error("EACCES: permission denied, stat"), { code: "EACCES" }) - mockedRealpath.mockImplementation( - async (target: Parameters[0]) => await restatingRealpath(String(target)), - ) - // lstat and readlink pass through for this row. resolvePublishTarget follows aliases through - // them, and an un-armed double would answer undefined where this row needs the real answer. - // They reject the way the filesystem rejects, which is the half the comment at the top of this - // module warns about. - mockedLstat.mockImplementation( - async (target: Parameters[0]) => await real.lstat(String(target)), + return false + }, ) - mockedReadlink.mockImplementation( - async (target: Parameters[0]) => await real.readlink(String(target)), + expect(otherCase, "the run-time measurement and the collection-time probe disagree about this volume").toBe( + CASE_FOLDING_VOLUME, ) - mockedAcquireFileLock.mockImplementation(async (file) => { - // The divergence the guard is asked about, reached the way a writer reaches it: this writer's - // key keeps the spelling it was computed from, the name the canonicalizer answers for the - // file appears under it while the lock is held, and the two artefact names are one object by - // hard link on either lane, so the stat below has a comparison to fail from on either lane. - // Hard link rather than symlink, for the reason given in the acceptance row above: one object - // under two names on NTFS and on ext4 alike, with no privilege asked for. - const ownArtefact = lockPathFor(String(file)) - await real.writeFile(ownArtefact, "") - await real.writeFile(lower, JSON.stringify({ mcpServers: { peer: true } })) - if (!foldsCase) { - await real.link(lower, upper) - await real.link(ownArtefact, lockPathFor(lower)) + }) + + // The gate below is a measured capability rather than a platform label: this row can only drive + // the guard's identity question on a volume where one file answers under two spellings, and + // that is a property of the volume, not of the runner's name. CASE_FOLDING_VOLUME is computed + // at collection time from a real directory and throws when the measurement cannot be trusted, + // instead of defaulting, because a probe that quietly answered "no" would make this row a skip + // that reads like a pass. What the gate does not claim: on a case-sensitive volume the artefact + // the acquisition builds and the artefact the guard computes from the key are one name, so + // there is no divergence for the guard to ask about and no artefact stat to fail. The gate + // skips a row that cannot drive the guard there; it does not skip a question about EACCES + // propagation, because on such a volume there is no such propagation to cover. + it.runIf(CASE_FOLDING_VOLUME)( + "propagates a lock-artefact stat that fails for a reason other than the artefact being absent", + async () => { + const dir = await makeDir("lockkey-artefactstat-") + const lower = path.join(dir, "settings.json") + const upper = path.join(dir, "SETTINGS.JSON") + const real = await vi.importActual("fs/promises") + // fs.stat is overloaded - bigint stats are their own overload - so the pass-through and the + // injected variant are built from one rest signature here instead of being asserted at each + // use. Arguments and results go straight through in both directions. + const realStatPassThrough = async ( + ...args: Parameters + ): Promise>> => await real.stat(...args) + const injectArtefactStat = + (fail: () => never) => + async (...args: Parameters): Promise>> => { + if (String(args[0]).endsWith(".lock")) { + fail() + } + return await real.stat(...args) + } + const restatingRealpath = async (target: string): Promise => { + const resolved = await real.realpath(target) + const parent = path.dirname(resolved) + const name = path.basename(resolved) + const listed = await real.readdir(parent).catch(() => [] as string[]) + // Prefer the entry whose name differs from the request in case alone. On a folding + // filesystem that is the one name the directory stores; on a case-sensitive one the + // directory holds both spellings and the match has to be pinned to the other one, or the + // divergence this row is built on would depend on readdir order. + const stored = listed.find( + (entry: string) => entry !== name && entry.toLowerCase() === name.toLowerCase(), + ) + return stored === undefined ? resolved : path.join(parent, stored) } - acquisitionFacts.push({ - key: String(file), - artefactBuilt: await real.stat(ownArtefact).then( - () => true, - () => false, - ), + const probeLower = path.join(dir, "probe.json") + await real.writeFile(probeLower, "{}") + const foldsCase = await restatingRealpath(path.join(dir, "PROBE.JSON")).then( + async (resolved: string) => path.basename(resolved) === path.basename(probeLower), + () => false, + ) + await real.rm(probeLower, { force: true }) + + // The instrument this row owes its reader. It used to install its own fs and fileLock mocks with + // vi.doMock and re-import safeWriteJson through vi.resetModules, and it was green on a + // case-folding filesystem while reporting zero artefact stats on a case-sensitive one. The + // reason was neither the guard nor the platform: asserted from inside the acquisition double it + // thought it had installed, the capture came back empty, which means the re-import never used + // that double and the row was being served by this file's module-level doubles - NTFS's own + // case restating supplying the divergence, and a real stat supplying the zeros. A row that + // injects a seam has to assert that the seam ran, because a green row that measures nothing is + // indistinguishable from a passing test, and that is what happened here. The doubles are the + // module-level ones now, re-armed for this row, with no resetModules and no re-import, and the + // capture is asserted first below so a scoping regression cannot go silent again. + const acquisitionFacts: Array<{ key: string; artefactBuilt: boolean }> = [] + let statCalls = 0 + const refusal = Object.assign(new Error("EACCES: permission denied, stat"), { code: "EACCES" }) + mockedRealpath.mockImplementation( + async (target: Parameters[0]) => await restatingRealpath(String(target)), + ) + // lstat and readlink pass through for this row. resolvePublishTarget follows aliases through + // them, and an un-armed double would answer undefined where this row needs the real answer. + // They reject the way the filesystem rejects, which is the half the comment at the top of this + // module warns about. + mockedLstat.mockImplementation( + async (target: Parameters[0]) => await real.lstat(String(target)), + ) + mockedReadlink.mockImplementation( + async (target: Parameters[0]) => await real.readlink(String(target)), + ) + mockedAcquireFileLock.mockImplementation(async (file) => { + // The divergence the guard is asked about, reached the way a writer reaches it: this writer's + // key keeps the spelling it was computed from, the name the canonicalizer answers for the + // file appears under it while the lock is held, and the two artefact names are one object by + // hard link on either lane, so the stat below has a comparison to fail from on either lane. + // Hard link rather than symlink, for the reason given in the acceptance row above: one object + // under two names on NTFS and on ext4 alike, with no privilege asked for. + const ownArtefact = lockPathFor(String(file)) + await real.writeFile(ownArtefact, "") + await real.writeFile(lower, JSON.stringify({ mcpServers: { peer: true } })) + if (!foldsCase) { + await real.link(lower, upper) + await real.link(ownArtefact, lockPathFor(lower)) + } + acquisitionFacts.push({ + key: String(file), + artefactBuilt: await real.stat(ownArtefact).then( + () => true, + () => false, + ), + }) + return async () => {} }) - return async () => {} - }) - // Only a lock artefact fails, so the EACCES this row reports is the guard's comparison and - // nowhere else: the write path's own stat calls, which preserve the target mode, keep working. - mockedStat.mockImplementation( - injectArtefactStat(() => { - statCalls++ - throw refusal - }), - ) - - let error: Error | undefined - try { - error = await safeWriteJson(upper, { mcpServers: { mine: true } }).then( - () => undefined, - (caught: unknown) => caught as Error, + // Only a lock artefact fails, so the EACCES this row reports is the guard's comparison and + // nowhere else: the write path's own stat calls, which preserve the target mode, keep working. + mockedStat.mockImplementation( + injectArtefactStat(() => { + statCalls++ + throw refusal + }), ) - } finally { - // Restored whatever the row concludes, so the injected failure cannot reach another row. - mockedStat.mockImplementation(realStatPassThrough) - mockedRealpath.mockReset() - mockedLstat.mockReset() - mockedReadlink.mockReset() - mockedAcquireFileLock.mockReset() - } - // The seam ran, and it was handed this writer's spelling. Both facts are about the fixture - // rather than about the guard, which is why they come before the guard's answer: if a later - // change stops this row from driving acquisition, it has to say so here instead of turning - // the count back into a zero that reads like a verdict about the code. On a case-folding - // filesystem the key arrives restated by the filesystem itself and the writer's spelling is the - // name the directory already stores; on a case-sensitive one the key keeps the spelling this - // writer asked in with, and the peer's file is what sits under the other name. - expect( - acquisitionFacts.length, - "the acquisition seam this row injects never ran, so nothing below it was driven by the fixture", - ).toBe(1) - expect(acquisitionFacts[0].artefactBuilt).toBe(true) - expect( - acquisitionFacts.map((fact: { key: string }) => path.basename(fact.key)), - "acquisition was not asked to lock this writer's spelling, so no divergence reached the guard", - ).toEqual([path.basename(upper)]) - expect( - statCalls, - "the guard never put its identity question to the filesystem on this platform", - ).toBeGreaterThan(0) - expect(String(error && (error as { code?: string }).code)).toBe("EACCES") - expect(String(error && error.name)).not.toBe("LockKeyDriftError") - // Fail closed: the writer published nothing, and the file it would have replaced still - // holds the peer's content. - expect(JSON.parse(await real.readFile(lower, "utf8"))).toEqual({ mcpServers: { peer: true } }) - expect((await real.readdir(dir)).length).toBe(foldsCase ? 2 : 4) - }) + let error: Error | undefined + try { + error = await safeWriteJson(upper, { mcpServers: { mine: true } }).then( + () => undefined, + (caught: unknown) => caught as Error, + ) + } finally { + // Restored whatever the row concludes, so the injected failure cannot reach another row. + mockedStat.mockImplementation(realStatPassThrough) + mockedRealpath.mockReset() + mockedLstat.mockReset() + mockedReadlink.mockReset() + mockedAcquireFileLock.mockReset() + } + + // The seam ran, and it was handed this writer's spelling. Both facts are about the fixture + // rather than about the guard, which is why they come before the guard's answer: if a later + // change stops this row from driving acquisition, it has to say so here instead of turning + // the count back into a zero that reads like a verdict about the code. On a case-folding + // filesystem the key arrives restated by the filesystem itself and the writer's spelling is the + // name the directory already stores; on a case-sensitive one the key keeps the spelling this + // writer asked in with, and the peer's file is what sits under the other name. + expect( + acquisitionFacts.length, + "the acquisition seam this row injects never ran, so nothing below it was driven by the fixture", + ).toBe(1) + expect(acquisitionFacts[0].artefactBuilt).toBe(true) + expect( + acquisitionFacts.map((fact: { key: string }) => path.basename(fact.key)), + "acquisition was not asked to lock this writer's spelling, so no divergence reached the guard", + ).toEqual([path.basename(upper)]) + expect( + statCalls, + "the guard never put its identity question to the filesystem on this platform", + ).toBeGreaterThan(0) + expect(String(error && (error as { code?: string }).code)).toBe("EACCES") + expect(String(error && error.name)).not.toBe("LockKeyDriftError") + // Fail closed: the writer published nothing, and the file it would have replaced still + // holds the peer's content. + expect(JSON.parse(await real.readFile(lower, "utf8"))).toEqual({ mcpServers: { peer: true } }) + expect((await real.readdir(dir)).length).toBe(foldsCase ? 2 : 4) + }, + ) }) From c72de0c149712b2c86378a24509499d7dbf7019e Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:54:05 +0800 Subject: [PATCH 110/115] fix(file-safety): resolveLockKey stops handing callers a key it could not establish resolveLockKey had three ways to end without an answer and still return a key, and all three are the same defect: a lock key derived from the caller's own spelling after the filesystem refused to say what that path refers to - the divergence class this lane has been closing. The fallback was entered for any failure (safeWriteText.ts:331), any readlink failure ended the walk (its .catch(() => undefined) at :337), and the depth bound returned the prefix the walk stopped at (:336, :341). The shape now: the fallback is the fallback for the ancestors-missing case, so ENOENT-shaped failures take it and everything else propagates; a readlink answer that is not ENOENT-or-EINVAL propagates rather than ending the walk - "not there" and "not a link" are answers, a refusal is not - and reaching the bound rejects instead of returning. The bound is a named constant, LOCK_KEY_WALK_BOUND, and the refusal is its own error, LockKeyWalkIncompleteError, carrying the path, the bound and the last spelling the walk reached, in the same vocabulary as StagingPathError and PostCommitDurabilityError: the operation was not refused by a guard, it never got as far as one. resolvePublishTarget and canonicalDirKey are unchanged, and safeWriteText does not call its own resolveLockKey, so this lands on the callers: guardedWrite.ts:192 and :257 (the two publish helpers), safeWriteJson.ts:326 (key before the lock) and :379 (key re-resolved under the lock), jsonFileRecovery.ts:95 (deleteJsonFile, aligned to this key in 0a76a7a7b), DiffViewProvider.ts:722 (new-file placeholder cleanup). recoverMissingJsonFile takes its lock on the path itself and is untouched. That is the full census: seven call sites in four files, no others. Said plainly, because it is the consequence that matters more here than in any earlier row: fail-closed means some operations that used to succeed by accident now fail loudly. A transient EACCES or EIO on a live link, or a link chain that stops at the bound, used to produce a silent alias-keyed write and a silent alias-keyed delete - the operation went ahead on a key nobody had established, which is the case where two writers each take a different lock for one file. Now each of those is a refused write or a refused delete, and nothing is staged, unlinked or renamed on the way out. Callers checked for a rejection that would be fatal to an otherwise-healthy operation: the six in guardedWrite, safeWriteJson and jsonFileRecovery reject the operation the caller asked for, which is the outcome. The one that could have been fatal is DiffViewProvider.ts:722, inside the new-file teardown, and it is already wrapped by the best-effort catch at :770-:772: a refusal there costs the placeholder cleanup - the placeholder stays, which is what a lock that cannot be taken already meant at :721 - and not the save. Nothing to absorb, nothing to escalate. One thing this commit corrects in my own earlier claim: jsonFileRecovery.ts:85-87 has said since 0a76a7a7b that computing that key is fail-closed. Until this commit it was not, for an EACCES or EIO realpath or a refused readlink mid-walk; the claim was ahead of the code. Tests. Three controls, each routed through a caller rather than the function, all in jsonFileRecovery.test.ts against the delete path: a realpath that answers EACCES, a refused readlink while the referent is missing, and a chain that reaches the bound. Each asserts the error the caller receives is the same error the filesystem threw (or the named bound error), that the walk ran the number of times the row names, that fs.unlink was never called, and that the file and its backup are still there. The link geometry in the latter two comes from spies because this lane cannot create a file symlink for real - fs.symlink answers EPERM here, as the alias rows in the same file say; the errors and the delete path they travel through are real. The row in safeWriteText.spec.ts that pinned the old shape ("terminates on a two-link cycle" resolving to the reached key) now pins the refusal and keeps its readlink-call count. Controls measured, each a temporary revert of one boundary: outer catch opened to every failure - 1 failed | 226 passed | 8 skipped: the row that names the fallback ("refuses the delete when the filesystem will not say what the path refers to"), readlink failures ending the walk again - 1 failed | 226 passed | 8 skipped: exactly the row that names the readlink rule ("refuses the delete when reading the link itself is refused"), the bound returning the key - 2 failed | 225 passed | 8 skipped: the bound row in jsonFileRecovery.test.ts and the cycle row in safeWriteText.spec.ts, the two rows that name the bound and nothing else. Restored: 227 passed | 8 skipped, and the product file was byte-identical to the controlled text. Test-side correction this change forced: four doubles in safeWriteJson.lockKey.spec.ts answered "not a link" with a bare Error carrying no code. That was indistinguishable from a refusal only because the walk swallowed every readlink failure, and two rows there ("waits for the peer", "releases the lock when the resolution under the lock rejects") went red the moment the refusal started travelling. They now answer EINVAL, the code the real call carries. That is a double being made accurate, not a product exemption. Lanes: 9 writer-family files - safeWriteText.spec, safeWriteText.stagingAlias.spec, jsonFileRecovery.test, safeWriteJson.test, safeWriteJson.lockKey.spec, guardedWrite.spec, applyPatchTool.execute.spec, DiffViewProvider.spec, TaskHistoryStore.spec - 472 passed | 8 skipped (480); tsc --noEmit -p . reports only the known junction artefact at activate/registerCommands.ts(88,102); eslint --prune-suppressions --max-warnings=0 exits 0 on all four touched files and src/eslint-suppressions.json is restored to HEAD; prettier --check clean. No it.runIf added or removed by this commit. --- .../__tests__/safeWriteText.spec.ts | 30 ++++- src/services/file-safety/safeWriteText.ts | 76 +++++++++++-- src/utils/__tests__/jsonFileRecovery.test.ts | 103 +++++++++++++++++- .../__tests__/safeWriteJson.lockKey.spec.ts | 14 ++- 4 files changed, 201 insertions(+), 22 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index f8b19131f7..45080a17eb 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -4,7 +4,13 @@ import { execFile } from "child_process" import type { ChildProcess } from "child_process" import * as path from "path" -import { PostCommitDurabilityError, resolveLockKey, safeWriteText, StagingPathError } from "../safeWriteText" +import { + LockKeyWalkIncompleteError, + PostCommitDurabilityError, + resolveLockKey, + safeWriteText, + StagingPathError, +} from "../safeWriteText" // Full mock for fs/promises — all methods are vi.fn() stubs vi.mock("fs/promises", () => ({ @@ -1409,17 +1415,29 @@ describe("resolveLockKey", () => { expect(fs.readlink).toHaveBeenCalledTimes(2) }) - it("terminates on a two-link cycle instead of walking forever", async () => { + it("refuses a two-link cycle instead of handing back the key the walk stopped at", async () => { const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) vi.mocked(fs.realpath).mockRejectedValue(enoent) vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) - // Every readlink answers with the same link, so an unbounded walk would - // never end; the bounded walk returns the key it actually reached. + // Every readlink answers with another link, so an unbounded walk would never end and the + // bound is what stops it. What the bound must not do is hand back the spelling it stopped at + // as if it were the answer: that key comes from the caller's own path after the filesystem + // stopped answering about it, which is the divergence a lock key exists to remove. vi.mocked(fs.readlink).mockImplementation(async () => "a.json") - await expect(resolveLockKey("/tmp/linkdir/a.json")).resolves.toBe( - path.resolve(path.join("/tmp/linkdir", "a.json")), + const failure = await resolveLockKey("/tmp/linkdir/a.json").then( + () => undefined, + (error: unknown) => error as Error, ) + expect(failure).toBeInstanceOf(LockKeyWalkIncompleteError) + if (!(failure instanceof LockKeyWalkIncompleteError)) { + throw new Error("the cycle must reject with LockKeyWalkIncompleteError, not " + String(failure)) + } + // The bound and the spelling the walk stopped at travel with the refusal, so a caller can + // report which chain gave up without re-walking it. + expect(failure.bound).toBe(8) + expect(failure.absoluteFilePath).toBe("/tmp/linkdir/a.json") + expect(failure.lastKey).toBe(path.resolve(path.join("/tmp/linkdir", "a.json"))) expect(fs.readlink).toHaveBeenCalledTimes(8) }) }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index b9386c62ac..9115854370 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -104,6 +104,31 @@ export class PostCommitDurabilityError extends Error { this.committedVersion = committedVersion } } + +/** + * The lock-key walk followed links as far as it was allowed to and never received an answer: + * every step reported another link, so the chain was either longer than the bound or circular. + * Reported as its own error because the operation has not been refused by a guard - it never got + * as far as a guard - and because the caller's spelling is not a lock key: the writer that would + * have taken this lock is the one whose key the filesystem refused to name. + */ +export class LockKeyWalkIncompleteError extends Error { + readonly absoluteFilePath: string + readonly bound: number + /** The last key the walk reached before it stopped. Carried because the caller can report which + * spelling it stopped at without re-walking the chain. */ + readonly lastKey: string + + constructor(absoluteFilePath: string, bound: number, lastKey: string) { + super( + `Lock key for ${absoluteFilePath} did not resolve within ${bound} symlink steps (last key ${lastKey}); refusing to take a lock derived from the spelling the walk stopped at.`, + ) + this.name = "LockKeyWalkIncompleteError" + this.absoluteFilePath = absoluteFilePath + this.bound = bound + this.lastKey = lastKey + } +} // -- helpers --------------------------------------------------------------- /** Generate a unique temp file name in the given directory. */ @@ -145,6 +170,10 @@ function _fsyncFile(fd: number): void { * the warning - and it stays below LOCK_STALE_MS (31_000) so a hung helper cannot outlive the * staleness window the lock protocol is written around. */ +// How far resolveLockKey will follow links when the path is not there yet. Two links that +// point at each other never answer "not there" or "not a link", so the walk needs a bound; it is +// a limit on the work, not a claim about how deep a real chain goes. +const LOCK_KEY_WALK_BOUND = 8 const DACL_COMMAND_TIMEOUT_MS = 20_000 /** Save the DACL of *srcPath* to a dump file on Windows. @@ -242,7 +271,6 @@ async function _statDirectoryForContainment( } } -/** /** * Whether the two stats name DIFFERENT files, judged the way the compares around it judge * one directory: as bigints, because on NTFS and ReFS a file identity can exceed @@ -322,23 +350,49 @@ async function canonicalDirKey(absoluteFilePath: string): Promise { * tolerates a dangling link, because the lock key has to be computable while a * peer writer is mid-commit (a publish renames the staged file onto the referent, * and backup mode keeps a copy beside it). - * The walk is bounded so a two-link cycle terminates, and every key it returns is - * canonicalized through canonicalDirKey. + * + * It is fail-closed about the other reason a resolution can end without an answer. + * The fallback walk runs only for ENOENT - the path is not there yet, which is also + * what a dangling link reports - because that is the one case where a key can still + * be derived without guessing. A failure that says the filesystem refused to answer + * (EACCES on a link the caller may not read, ENOTDIR, EIO, or the ELOOP that + * resolvePublishTarget reports for a cycle) propagates: a key built from the caller's + * own spelling after a refusal is the same divergence class this function exists to + * remove, and locking the wrong file is worse than refusing the operation. Reaching + * the walk's depth bound is the third way an answer fails to arrive - the chain went + * as far as it was allowed to and stopped - so it rejects rather than returning the + * prefix the walk happened to stop at. + * Every key this function returns is canonicalized through canonicalDirKey. */ export async function resolveLockKey(absoluteFilePath: string): Promise { try { return await canonicalDirKey(await resolvePublishTarget(absoluteFilePath)) - } catch { - // A real readlink throws for anything that is not a link, so a normal chain - // ends the walk. Two links that point at each other never would, so the - // walk is bounded and callers use the key they actually reached. + } catch (error: unknown) { + // Only a missing path falls back. Everything else is a refusal, and a refusal is not + // evidence about what the path refers to - see the note above. + if (errorCode(error) !== "ENOENT") { + throw error + } + // A real readlink also answers for cases that are not failures: "not there" and "not a + // link" (EINVAL, which is what a plain file and a Windows junction report) end the walk + // at the key it reached, which is the answer for a dangling link and for an alias that + // lands on a real file. Two links that point at each other answer neither, so the walk is + // bounded, and the bound is a refusal rather than an answer. let key = absoluteFilePath - for (let depth = 0; depth < 8; depth++) { - const target = await fs.readlink(key).catch(() => undefined) - if (target === undefined) return await canonicalDirKey(key) + for (let depth = 0; depth < LOCK_KEY_WALK_BOUND; depth++) { + const target = await fs.readlink(key).catch((readlinkError: unknown) => { + const code = errorCode(readlinkError) + if (code === "ENOENT" || code === "EINVAL") { + return undefined + } + throw readlinkError + }) + if (target === undefined) { + return await canonicalDirKey(key) + } key = await canonicalDirKey(path.resolve(path.dirname(key), target)) } - return await canonicalDirKey(key) + throw new LockKeyWalkIncompleteError(absoluteFilePath, LOCK_KEY_WALK_BOUND, key) } } diff --git a/src/utils/__tests__/jsonFileRecovery.test.ts b/src/utils/__tests__/jsonFileRecovery.test.ts index 798c1dd2f3..c2dc4726b4 100644 --- a/src/utils/__tests__/jsonFileRecovery.test.ts +++ b/src/utils/__tests__/jsonFileRecovery.test.ts @@ -1,11 +1,12 @@ import * as fs from "fs/promises" import * as os from "os" import * as path from "path" +import * as fsSync from "fs" import { acquireFileLock } from "../fileLock" import { setPersistenceTraceSink } from "../../core/task-persistence/persistenceTrace" import { deleteJsonFile, recoverMissingJsonFile } from "../jsonFileRecovery" -import { resolveLockKey } from "../../services/file-safety/safeWriteText" +import { LockKeyWalkIncompleteError, resolveLockKey } from "../../services/file-safety/safeWriteText" import { readFileWithMissingRetry } from "../../core/task-persistence/readFileWithMissingRetry" vi.mock("fs/promises", async () => { @@ -102,6 +103,106 @@ describe("reader recovery", () => { await expect(fs.access(backup)).resolves.toBeUndefined() }) + // The three rows below put resolveLockKey's fail-closed rule in front of a caller rather than + // asserting it at the function: this file's own delete, which computes the key before it takes + // the lock and so is the operation that either refuses or proceeds on a key nobody agreed to. + // The link geometry they need - a referent that is not there, a readlink that is refused, a + // chain that never answers - is supplied by spies, because this lane cannot build a file symlink + // for real (fs.symlink answers EPERM here, and the alias rows above say so). Everything else + // runs on the real filesystem: the lock machinery, the canonicalization inside the walk, and + // the files the rows assert on. + it("refuses the delete when the filesystem will not say what the path refers to", async () => { + await fs.writeFile(target, JSON.stringify({ committed: true })) + // This spec's unlink double is a vi.fn shared across rows and afterEach restores spies without + // resetting vi.fn history, so the row clears it before asking whether the delete touched a file. + vi.mocked(fs.unlink).mockClear() + const refusal = Object.assign(new Error("EACCES: permission denied, realpath '" + target + "'"), { + code: "EACCES", + }) + const realpathSpy = vi.spyOn(fs, "realpath").mockRejectedValueOnce(refusal) + try { + await expect(deleteJsonFile(target)).rejects.toBe(refusal) + // The refusal was the first question the resolution asked, and it is the error the caller + // receives rather than a wrapped one, so a caller that logs the code still sees EACCES. + // A restored spy keeps no call history, so the count is read while it is installed. + expect(realpathSpy).toHaveBeenCalledTimes(1) + } finally { + realpathSpy.mockRestore() + } + // The key is computed before the lock is taken, so the delete stops before it starts instead + // of locking a key derived from the caller's own spelling. Nothing was unlinked. + expect(fs.unlink).not.toHaveBeenCalled() + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + expect(JSON.parse(await fs.readFile(backup, "utf8"))).toEqual({ committed: true }) + }) + + it("refuses the delete when reading the link itself is refused", async () => { + await fs.writeFile(target, JSON.stringify({ committed: true })) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.unlink).mockClear() + // The referent is not there - the window a peer's commit rename opens - so the resolution + // falls to the walk, and the walk's first readlink is refused. The refusal used to be read as + // "not a link", which ends the walk and hands back whatever spelling it reached. + const linkStats = Object.assign(Object.create(fsSync.Stats.prototype) as fsSync.Stats, { + isSymbolicLink: () => true, + }) + const refusal = Object.assign(new Error("EACCES: permission denied, readlink '" + target + "'"), { + code: "EACCES", + }) + const realpathSpy = vi.spyOn(fs, "realpath").mockRejectedValueOnce(enoent) + const lstatSpy = vi.spyOn(fs, "lstat").mockResolvedValueOnce(linkStats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockRejectedValueOnce(refusal) + try { + await expect(deleteJsonFile(target)).rejects.toBe(refusal) + // One readlink, and it is the one that was refused: the walk stopped because the filesystem + // refused to answer, not because it decided the answer was "not a link". Read while the + // spy is still installed - restoring it clears the call history. + expect(readlinkSpy).toHaveBeenCalledTimes(1) + } finally { + readlinkSpy.mockRestore() + lstatSpy.mockRestore() + realpathSpy.mockRestore() + } + expect(fs.unlink).not.toHaveBeenCalled() + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + }) + + it("refuses the delete when the link chain stops at the walk's depth bound", async () => { + await fs.writeFile(target, JSON.stringify({ committed: true })) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + const linkStats = Object.assign(Object.create(fsSync.Stats.prototype) as fsSync.Stats, { + isSymbolicLink: () => true, + }) + vi.mocked(fs.unlink).mockClear() + const realpathSpy = vi.spyOn(fs, "realpath").mockRejectedValueOnce(enoent) + const lstatSpy = vi.spyOn(fs, "lstat").mockResolvedValueOnce(linkStats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async () => "alias.json") + let failure: unknown + try { + failure = await deleteJsonFile(target).then( + () => undefined, + (error: unknown) => error, + ) + // Eight links answered and none of them was an answer. The count is the seam running: a + // shorter count would mean the row never reached the bound it names. Read before the + // restores below, which clear the spy's call history. + expect(readlinkSpy).toHaveBeenCalledTimes(8) + } finally { + readlinkSpy.mockRestore() + lstatSpy.mockRestore() + realpathSpy.mockRestore() + } + expect(failure).toBeInstanceOf(LockKeyWalkIncompleteError) + if (!(failure instanceof LockKeyWalkIncompleteError)) { + throw new Error("the depth bound must reject, not resolve :: " + String(failure)) + } + expect(failure.bound).toBe(8) + expect(failure.absoluteFilePath).toBe(path.resolve(target)) + expect(path.basename(failure.lastKey)).toBe("alias.json") + expect(fs.unlink).not.toHaveBeenCalled() + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + }) + // The three rows below need a file symlink. `fs.symlink` on a file answers EPERM on the Windows // lane, which has neither privilege nor Developer Mode, and no other Windows spelling carries the // meaning: a hard link has no referent for resolveLockKey to follow, and a junction links diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 3f07d3e1da..a68d922c19 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -38,6 +38,12 @@ const mockedStat = vi.mocked(fs.stat) const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) +// "Not a link" is not a bare error. readlink reports it as EINVAL, and the lock-key walk now has +// to tell that answer apart from a refusal, so the double carries the code the real call carries. +// It used not to, and nothing noticed: the walk swallowed every readlink failure, so a codeless +// rejection read the same as the answer "this is not a link". +const notALink = (): Error => Object.assign(new Error("EINVAL: invalid argument, not a symlink"), { code: "EINVAL" }) + // Whether this volume answers a name with a different spelling than the one its directory stores. // The gate further down needs the capability rather than the platform label, so it is measured // from a real directory: write one file, ask whether a differently-cased name opens, and require @@ -148,7 +154,7 @@ describe("safeWriteJson lock key under a peer commit", () => { return symlinkStat(target) }) mockedReadlink.mockImplementation(async (target) => - target === currentLink ? referent : Promise.reject(new Error("not a link")), + target === currentLink ? referent : Promise.reject(notALink()), ) mockedAcquireFileLock.mockImplementation(async () => { order.push("lock") @@ -357,7 +363,7 @@ describe("safeWriteJson lock key under a peer commit", () => { // both inside this directory, so an empty directory is the whole proof. mockedRealpath.mockImplementation(async (t) => String(t)) mockedLstat.mockRejectedValue(enoent) - mockedReadlink.mockRejectedValue(new Error("not a link")) + mockedReadlink.mockRejectedValue(notALink()) let acquiredKey: string | undefined let duringAcquisition: string[] | undefined @@ -406,7 +412,7 @@ describe("safeWriteJson lock key under a peer commit", () => { return symlinkStat(target) }) mockedReadlink.mockImplementation(async (target) => - target === currentLink ? referent : Promise.reject(new Error("not a link")), + target === currentLink ? referent : Promise.reject(notALink()), ) mockedAcquireFileLock.mockImplementation(async () => { order.push("lock") @@ -439,7 +445,7 @@ describe("safeWriteJson lock key under a peer commit", () => { mockedRealpath.mockImplementation(async (target) => (target === currentLink ? referent : String(target))) mockedLstat.mockImplementation(async (target) => symlinkStat(target)) mockedReadlink.mockImplementation(async (target) => - target === currentLink ? referent : Promise.reject(new Error("not a link")), + target === currentLink ? referent : Promise.reject(notALink()), ) mockedAcquireFileLock.mockImplementation(async (lockedPath) => { order.push("lock:" + path.basename(lockedPath)) From f653f86f0258f73f15b97685d7dbe340215d006e Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:04:44 +0800 Subject: [PATCH 111/115] fix(apply-patch): refuse a move whose destination names the file it moves from The move branch compared change.path with change.movePath as strings and, finding two strings, published the destination and then unlinked the source. Under an alias the two strings name one file: the write lands, the unlink at the end of the branch removes what was just written, and the bookkeeping below counts both paths as published, so the tool reports a move that deleted the file. Nothing in a patch says a move onto the file it came from is a delete, so the pair is now refused at ApplyPatchTool.ts:594-:643, between the outside-workspace refusal (:584-:592) and the destination save (:645). The order is the point: the refusal lands before the content is published, before the unlink, and before the tracker or the write result hears about the file, which leaves every file exactly as it was found. Sameness is asked of the filesystem, not of the strings. identityOf (:618) takes a bigint stat of each spelling and sorts a failure into absent (ENOENT) or refused (everything else); _sameFileIdentity (:36) compares dev+ino as bigints, the pair the write owner compares at safeWriteText.ts:247-:265, and requires both fields to actually be bigints before comparing. The bigints are why the compare is not numeric: 2^53 and 2^53+1 are the same double, so a large inode can make two files look alike or one file look like two. The two spellings are deliberately not canonicalized first - canonicalizing answers "which file does this name refer to", which is the question the write owner asks under its own lock, and folding the alias here would leave the identity question unasked while refusing moves that ought to happen. What changes for a caller: - a same-file move is a tool error naming both identities ("... the two paths name the same file (dev .., ino ..), so the move would publish onto it and then unlink what it just published"), with the mistake counter, the tool-error record, the error say, the tool result and the diff-view reset: the shape the outside-workspace refusal above it already uses. - a spelling the filesystem will not stat is also a refusal, and that is new surface: nothing used to ask, so nothing could refuse. A refusal is not read as "different files" - guessing is what produced the deletion. Neither stat is allowed to throw through this handler, because an exception here would replace the tool error the user should get. - an absent source (ENOENT) is not this row's claim and its handling is unchanged. Tests. Five rows in applyPatchTool.execute.spec.ts: the three consequences of a same-file move (nothing is written, the source is not unlinked, one refusal is reported rather than two published files), the alias case that must still move (a junction directory on the destination side over two different inodes - destination published, source unlinked, file context tracked), and an identity refusal (the destination's stat answers EACCES: the move is a tool error and the provider's handleError is never reached). A fifth row guards the numeric range: it seeds BASE_INO = 2n ** 53n and NEIGHBOUR_INO = BASE_INO + 1n and asserts before the move that Number(BASE_INO) equals Number(NEIGHBOUR_INO), so the fixture announces if it ever stops demonstrating a rounded collision instead of passing quietly. Three corrections the rows forced, all in the doubles: - the fs/promises double answered one identity for every path. That was a question nothing was asking. It now answers per spelling, and the five destination observation-token seeds that carried the source's inode (4242) carry the destination's own (4343): those seeds had been asserting the collision by accident. - the guarded-write beforeEach re-arms the default stat implementation, because vi.clearAllMocks() keeps implementations and a row that installs its own would leak forward. - writeFile was added to the mockedFsPromises cast (ReturnType, reason in the comment: the module's writeFile is an overload set a double cannot express). Controls measured, each a temporary revert of one boundary against the shipped text: identity taken from the two strings being equal - 3 failed | 41 passed: exactly the three consequence rows, a stat refusal read as "absent" - 1 failed | 43 passed: exactly the row that names the refusal, identity compared as rounded Numbers - 1 failed | 43 passed: exactly the safe-integer row. A fourth variant is recorded for what it does not prove: dropping the ino half of the compare reddened 13 rows, because fixture pairs that collide on inode have nothing to do with identity. That is a whole-feature revert rather than a control of this boundary, and it is not counted. Restored: 44 passed (44), product file byte-identical to the controlled text. Said plainly: the typeof-bigint guards in _sameFileIdentity are exercised by no row. A stat taken with { bigint: true } always answers bigints; the guards make a double that answers numbers fail the identity test loudly rather than match loosely. Lanes: measured on the tree this push carries - 13 writer-family files (applyPatchTool.execute, applyPatchTool.partial, guardedWrite, DiffViewProvider, safeWriteText, safeWriteText.stagingAlias, safeWriteJson.lockKey, safeWriteJson.stagingDirectory and the five TaskHistoryStore specs) - 518 passed (518), of which applyPatchTool.execute.spec.ts is 44 passed (44). tsc --noEmit -p . reports only the junction artefact at activate/registerCommands.ts(88,102); eslint --prune-suppressions --max-warnings=0 exits 0 on both touched files and src/eslint-suppressions.json equals HEAD; prettier --check clean on both. No it.runIf added or removed by this commit, and nothing here is gated on a platform label - the ubuntu and windows lanes execute both suites. The alias row's geometry is the double's inode table, not a link on disk: this lane cannot create a file symlink (fs.symlink answers EPERM here, as the alias rows in safeWriteText.spec record) and a mocked stat would not be exercised by a real junction either. What the row proves is that the compare is asked about identities rather than spellings; the link itself is proven by the rows that take the filesystem for real. --- src/core/tools/ApplyPatchTool.ts | 71 +++++ .../__tests__/applyPatchTool.execute.spec.ts | 257 +++++++++++++++++- 2 files changed, 314 insertions(+), 14 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index dd4d0572df..b6f30190b2 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -1,4 +1,5 @@ import fs from "fs/promises" +import type { BigIntStats } from "fs" import path from "path" import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" @@ -24,6 +25,25 @@ interface ApplyPatchParams { patch: string } +/** + * Whether two stats name one file, judged the way the compares around this handler judge it: as + * bigints, because on NTFS and ReFS a file identity can exceed Number.MAX_SAFE_INTEGER and a + * rounded value makes two files look alike or two spellings of one file look different. Both sides + * have to actually carry a bigint identity - a stat that does not is a double that was never asked + * to answer identity, and reading it as "the same file" would refuse ordinary moves for a shape no + * real filesystem produces. + */ +function _sameFileIdentity(left: BigIntStats, right: BigIntStats): boolean { + return ( + typeof left.ino === "bigint" && + typeof right.ino === "bigint" && + typeof left.dev === "bigint" && + typeof right.dev === "bigint" && + left.ino === right.ino && + left.dev === right.dev + ) +} + export class ApplyPatchTool extends BaseTool<"apply_patch"> { readonly name = "apply_patch" as const @@ -571,6 +591,57 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } + // A move whose destination names the same file as its source publishes onto that file and + // then unlinks it: the write lands, the unlink at the end of this branch removes what was + // just written, and the bookkeeping below counts both paths as published. No reading of a + // move patch makes deleting the file you just wrote a correct outcome, so the pair is refused + // here - before the save, before the unlink, before the tracker call - which leaves every file + // exactly as it was found. + // + // Sameness is asked of the filesystem rather than of the two strings. Under an alias two + // spellings name one file, and across an alias one spelling names two files, so a string + // compare is wrong in both directions: it would refuse the alias-spelled move that ought to + // happen and allow the two-names-one-file move that ought not. The identity is read as bigints + // for the reason the compare beside it gives (safeWriteText.ts:247-:265): on NTFS and ReFS a + // file identity can exceed Number.MAX_SAFE_INTEGER, and a rounded value makes two files look + // alike or one file look like two. + // + // Both stats are taken on the spellings the patch named, resolved against the workspace and + // deliberately not canonicalized: canonicalizing first would fold an alias's two spellings + // into one string here and leave the identity question unasked. + // + // A refusal to answer is not read as "different files". Neither stat is allowed to throw + // through this handler: an exception here would replace the tool error the user should get, + // which is the mistake the note above the outside-workspace refusal records for canonicalising + // a destination that was about to be refused. Absence is read as what it means - a destination + // that is not there is not the same file as the source - and a refusal refuses the move. + const identityOf = async (target: string): Promise => { + try { + return await fs.stat(target, { bigint: true }) + } catch (error: unknown) { + return errorCode(error) === "ENOENT" ? "absent" : "refused" + } + } + const sourceIdentity = await identityOf(absolutePath) + const destinationIdentity = await identityOf(moveAbsolutePath) + const moveRefusal = + sourceIdentity === "refused" || destinationIdentity === "refused" + ? "the filesystem would not say what either path refers to, so the tool cannot tell whether the two names are one file" + : sourceIdentity !== "absent" && + destinationIdentity !== "absent" && + _sameFileIdentity(sourceIdentity, destinationIdentity) + ? `the two paths name the same file (dev ${String(sourceIdentity.dev)}, ino ${String(sourceIdentity.ino)}), so the move would publish onto it and then unlink what it just published` + : undefined + if (moveRefusal !== undefined) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `Cannot move ${change.path} onto ${change.movePath}: ${moveRefusal}` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + await task.diffViewProvider.reset() + return + } + // Save new content to the new path if (isPreventFocusDisruptionEnabled) { // The destination content is the source file plus one targeted hunk, so it can diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 3b4f2fedaa..1b20361b9e 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -25,6 +25,10 @@ const mockedFsPromises = vi.mocked( unlink: MockedFunction // Added for the outside-workspace refusal row: the refused destination must not be canonicalised. realpath: ReturnType + // Added for the same-file move rows, which ask whether any write landed at the destination. + // Typed loosely because fsPromises.writeFile carries an overload set that MockedFunction + // would collapse to one arbitrary member; the rows only ever ask whether it was called. + writeFile: ReturnType } }, ) @@ -39,15 +43,10 @@ vi.mock("fs/promises", () => { // Raw bytes: the SUT reads without an encoding so a strict decoder can see the file as // the file is, and this double has to answer the same way the real readFile() does. readFile: vi.fn().mockResolvedValue(Buffer.from("original file content\n")), - // Stable on-disk version for the S2 self-read observation (the hunk - // read now stats before and after; equal tokens record the observe). - stat: vi.fn().mockResolvedValue({ - dev: 7n, - ino: 4242n, - size: 1234n, - mtimeNs: 1_700_000_000_123_456_789n, - ctimeNs: 1_700_000_000_789_999_999n, - }), + // On-disk identity for the S2 self-read observation (the hunk read stats before and after; + // equal tokens record the observe) and for the move handler's same-file check. Answered per + // path by _identityOf below. + stat: vi.fn((target: unknown) => Promise.resolve(_identityOf(String(target)))), unlink: vi.fn().mockResolvedValue(undefined), // resolveLockKey canonicalizes the lock key, so the guard needs these // even when no symlink is involved. @@ -77,6 +76,43 @@ vi.mock("../../../utils/pathUtils", () => ({ isPathOutsideWorkspace: vi.fn().mockReturnValue(false), })) +// The identity a path answers with. It used to be ONE identity for every path (ino 4242 +// everywhere), which was harmless only because nothing asked the question: two names on a +// filesystem are two files unless something links them, and the move handler now asks which of the +// two its source and its destination are. The double distinguishes exactly the pair the move rows +// publish onto - src/new.ts, and the same basename reached through a link of its directory - and +// leaves every other path the identity the registry token strings below spell out. A row that wants +// two names for one file re-arms stat and says so in its own comment. +const _MOVE_SOURCE_INO = 4242n +const _MOVE_DESTINATION_INO = 4343n +const _MOVE_ALIAS_DESTINATION_INO = 4444n + +interface FileIdentity { + dev: bigint + ino: bigint + size: bigint + mtimeNs: bigint + ctimeNs: bigint +} + +function _identityOf(target: string): FileIdentity { + const resolvedNew = path.resolve("/workspace/project", "src/new.ts") + const resolvedAliasNew = path.resolve("/workspace/project", "src/aliasDir/new.ts") + const ino = + target === resolvedNew + ? _MOVE_DESTINATION_INO + : target === resolvedAliasNew + ? _MOVE_ALIAS_DESTINATION_INO + : _MOVE_SOURCE_INO + return { + dev: 7n, + ino, + size: 1234n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + } +} + describe("ApplyPatchTool.execute - delete file success path", () => { const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction @@ -199,6 +235,16 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { -original file content +modified file content *** End Patch` + // The destination spelled through a link of the same directory, naming its own file: the other + // direction of the pair the same-file rows pin below. + const moveThroughAliasPatch = `*** Begin Patch +*** Update File: src/old.ts +*** Move to: src/aliasDir/new.ts +@@ +-original file content ++modified file content +*** End Patch` + const mismatchedSecondPatch = `*** Begin Patch *** Update File: src/first.ts @@ @@ -230,6 +276,12 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { // so a row that flips it has to find it back at false. Restored here rather than per row so the // next row does not have to know a neighbour changed it. vi.mocked(isPathOutsideWorkspace).mockReturnValue(false) + // vi.clearAllMocks() keeps implementations, and rows below re-arm stat with a one-file-two- + // names answer. Restoring the default here means a row does not have to know what a neighbour + // installed. + mockedFsPromises.default.stat.mockImplementation((target: unknown) => + Promise.resolve(_identityOf(String(target))), + ) mockSaveDirectly = vi.fn().mockResolvedValue({ newProblemsMessage: "", userEdits: undefined, @@ -670,6 +722,183 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { ) }) + it("move: refuses a destination that names the same file as its source, and writes nothing to it", async () => { + // One file under two names: on a real filesystem that is a hard link, or the destination + // spelled through a link of the source's own directory. The filesystem in this spec is its + // doubles, so the row states the geometry outright - one identity armed for both spellings - + // and the row below pins the opposite geometry, a destination spelled through an alias that + // is its own file. Which of the two it is cannot be read off the strings. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + mockedFsPromises.default.stat.mockImplementation(() => Promise.resolve(_identityOf(sourceKey))) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-destination-is-the-source", + }) + + // Consequence one, the file at the shared path: the patched content never lands there. The + // save is what would have written it and the refusal runs before the save, so the file keeps + // the bytes it had before the patch and the tool claims no edit. + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockSaveChanges).not.toHaveBeenCalled() + expect(mockedFsPromises.default.writeFile).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("the two paths name the same file")) + expect(mockHandleError).not.toHaveBeenCalled() + // Both spellings were put to the filesystem: the check asks about the pair, not about one of + // them while taking the strings' word for the other. + expect(mockedFsPromises.default.stat).toHaveBeenCalledWith(sourceKey, { bigint: true }) + expect(mockedFsPromises.default.stat).toHaveBeenCalledWith(destKey, { bigint: true }) + }) + + it("move: does not unlink the source when the destination names the same file", async () => { + // Consequence two. The unlink at the end of this branch is the statement that deleted what + // the publish had just written to the same inode; with the pair refused before the publish, + // the file is still where both names point and nothing has been removed. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + mockedFsPromises.default.stat.mockImplementation(() => Promise.resolve(_identityOf(sourceKey))) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-destination-is-the-source-no-unlink", + }) + + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + // The opened diff view is discarded rather than left open over an un-published file. + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + }) + + it("move: reports one refusal rather than two published files for one changed file", async () => { + // Consequence three, and the half a reviewer can check without a filesystem: the tool's own + // report. pushToolWriteResult builds the published-files message and trackFileContext is what + // records each published path as edited, so a move that published onto the source and then + // unlinked it would have reported two files for the one file the patch changed. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + mockedFsPromises.default.stat.mockImplementation(() => Promise.resolve(_identityOf(sourceKey))) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-destination-is-the-source-report", + }) + + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move src/old.ts onto src/new.ts"), + ) + expect(mockTask.diffViewProvider.pushToolWriteResult).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + // The mistake counter is deliberately not asserted. This refusal raises it and the end-of- + // loop statement at ApplyPatchTool.ts:242 clears it again inside the same call, so reading + // the counter after the call would pin :242 rather than the refusal. + }) + + it("move: two files whose identities differ above the safe-integer range are not treated as one", async () => { + // Why the identity is read as bigints rather than numbers. An NTFS or ReFS file identity can + // sit above Number.MAX_SAFE_INTEGER, and two such files one apart round to the same + // Number: 2^53 and 2^53 + 1 are one double. A compare that goes through Number would call these + // two files one file and refuse this move; the compare beside this row keeps them apart. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const BASE_INO = 2n ** 53n + const NEIGHBOUR_INO = BASE_INO + 1n + mockedFsPromises.default.stat.mockImplementation((target: unknown) => + Promise.resolve( + String(target) === destKey + ? { ..._identityOf(destKey), ino: NEIGHBOUR_INO } + : { ..._identityOf(sourceKey), ino: BASE_INO }, + ), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-identities-above-safe-integer-range", + }) + + expect(Number(BASE_INO), "the fixture no longer demonstrates a rounded collision").toBe(Number(NEIGHBOUR_INO)) + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + false, + false, + undefined, + ) + expect(mockedFsPromises.default.unlink).toHaveBeenCalledWith(sourceKey) + }) + + it("move: publishes a destination spelled through a link of the source's directory when it is its own file", async () => { + // The other direction of the same rule, and the one a string compare gets wrong: + // src/aliasDir/new.ts is its own file even though its spelling runs through a link of the + // source's directory, so the move has to happen: content published at the destination, the + // source unlinked, a report produced. Refusing this pair would be the spelling-instead-of- + // identity bug in reverse, and it is why the check asks the filesystem. + await tool.execute({ patch: moveThroughAliasPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-destination-through-directory-alias", + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/aliasDir/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + false, + false, + undefined, + ) + expect(mockedFsPromises.default.unlink).toHaveBeenCalledWith(path.resolve("/workspace/project", "src/old.ts")) + expect(mockTask.didEditFile).toBe(true) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Saved file")) + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("src/aliasDir/new.ts", "roo_edited") + }) + + it("move: refuses with a tool error when the filesystem will not say what the destination refers to", async () => { + // The failure surface the identity check adds, pinned where it is visible. A destination + // whose identity the filesystem withholds used to be moved blindly; now the move is refused, + // and refused as a tool error - the shape the note above the outside-workspace refusal says a + // destination read failure should produce, rather than an exception reaching handleError. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const eacces = Object.assign(new Error("EACCES: permission denied, stat '" + destKey + "'"), { + code: "EACCES", + }) + mockedFsPromises.default.stat.mockImplementation((target: unknown) => + String(target) === destKey ? Promise.reject(eacces) : Promise.resolve(_identityOf(String(target))), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-destination-identity-refused", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("the filesystem would not say what either path refers to"), + ) + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + it("move: rejects a partial source onto an observed destination before changing any state", async () => { // The destination content is the source file plus one hunk, so it can only be // as complete as the view the model had of the source. Rejecting before the @@ -680,7 +909,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { const destKey = path.resolve("/workspace/project", "src/new.ts") const reg = mockTask.observationRegistry reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) - reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + reg.observe(destKey, "7:4343:1234:1700000000123456789:1700000000789999999", true) await tool.execute({ patch: movePatch }, mockTask as Task, { askApproval: mockAskApproval, @@ -710,7 +939,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { const destKey = path.resolve("/workspace/project", "src/new.ts") const reg = mockTask.observationRegistry reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) - reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + reg.observe(destKey, "7:4343:1234:1700000000123456789:1700000000789999999", true) mockedFsPromises.default.access.mockRejectedValueOnce( Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), ) @@ -739,7 +968,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { const destKey = path.resolve("/workspace/project", "src/new.ts") const reg = mockTask.observationRegistry reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) - reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + reg.observe(destKey, "7:4343:1234:1700000000123456789:1700000000789999999", true) mockedFsPromises.default.access.mockRejectedValueOnce( Object.assign(new Error("ELOOP: too many symbolic links"), { code: "ELOOP" }), ) @@ -800,7 +1029,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { const reg = mockTask.observationRegistry reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) mockSaveDirectly.mockImplementationOnce(async () => { - reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + reg.observe(destKey, "7:4343:1234:1700000000123456789:1700000000789999999", true) return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } }) @@ -851,7 +1080,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { const destKey = path.resolve("/workspace/project", "src/new.ts") const reg = mockTask.observationRegistry reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) - reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + reg.observe(destKey, "7:4343:1234:1700000000123456789:1700000000789999999", true) mockedFsPromises.default.access.mockRejectedValueOnce( Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), ) From 42077a2b465fa03f3b0c38535980eab807dec5ad Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:04:57 +0800 Subject: [PATCH 112/115] fix(diff-view): the committed-revert teardown says which state it ended in revertChanges()'s committed branch asked for the buffer's verdict and threw the answer away: it called revertDocument at DiffViewProvider.ts:1040 (the text before this commit) and ignored the boolean, while the save teardown a screen away honours the same boolean, reconciles the buffer with closeFileTab + openTextDocument + findReconciliationDocument (:810-:826) and reports CommittedWriteBufferConflictError when the buffer will not clear. The verdict is not a claim about the command - revertDocument returns !document.isDirty (:542), a read-back of the buffer - so the branch was discarding the only evidence it had about the state it was leaving behind. A buffer that stays dirty after the revert still holds the bytes the guard published, and an ordinary save (VS Code's own file service, or a later write) can put them back over a newer file. The branch knew that: it went on to report a cancellation arriving after the commit, which is true and not the whole state. It now does what the sibling does. On a refusal it closes and reopens from the published bytes - the second chance :818-:820 gives - and reads the state back (:1044-:1071): - reopen comes back clean: the ordinary report, "a cancellation arrived after the guarded publish had committed", - reopen comes back dirty, or the reopen itself refuses: the detail says "the publish committed and the open buffer could not be cleared from its content", and the flag is set before the attempt, because a second chance that could not run left the buffer uncleared just as surely as one that ran and came back dirty, and nothing here can prove otherwise. The reconciliation is inside a try for the same reason: this is a teardown whose job is to leave the session finalized, and a pass that stopped at its own reconciliation would abandon a session whose caller is waiting for it to finalize. Nothing is thrown from inside the pass - the reason is recorded and reported where the existing comment already puts it, after the tab work, the preview restore and the reset (:1167-:1181). Which caller sees the new report. Nine call sites await revertChanges: the seven tool cancellation paths (ApplyPatchTool.ts:350 and :544, ApplyDiffTool.ts:264, EditTool.ts:212, EditFileTool.ts:437, WriteToFileTool.ts:185, SearchReplaceTool.ts:208) each sit inside the tool's own execution try/catch, so the report goes to that tool's handleError (WriteToFileTool.ts:217-:221 is the nearest one) and now carries the buffer state in its text; Task.ts:4168 propagates it to the abort path; Task.ts:3799 keeps swallowing it in .catch(console.error) and is deliberately not touched here. No caller gains a new rejection: the only thing that can reject this method is the throw after the pass, which was already there, and wrapping the reconciliation is what keeps that true - a refused reopen would otherwise have reached those nine callers as a new failure. The shared guidance string in committedWriteBufferConflictError.ts is untouched; only the detail line differs, and the class doc now names this window beside the three it already named. Tests. Four rows in DiffViewProvider.spec.ts, all driven through saveChanges(false) so the committed marker is the real one: the buffer that will not clear (reconciliation ran, the detail names the buffer, the published file survives, the session still finalizes), the reopen that came back clean (the ordinary report, and the buffer reason explicitly absent), the case where the revert already cleared the buffer (nothing was closed or reopened - the second chance is a response to a refusal, not a step every cancellation takes), and the reopen that is refused (the report is still the committed-write conflict rather than the reopen's EACCES, and the tab work and the preview restore still ran). The buffer is a structural double held under { isDirty: boolean } and cast to the interface once at the boundary, because the factory's object carries a mutable isDirty and the interface declares it read-only. The refusal row rejects the reopen once, so the module double's own default is what the rest of the file keeps getting, and clears the tab and reopen counts before the pass under test: saveChanges ran its own teardown on the way in. Controls measured, each a temporary revert of one boundary against the shipped text: verdict thrown away again - 3 failed | 169 passed: exactly the three rows that depend on the reconciliation, reconciliation unconditional - 1 failed | 171 passed: exactly the row that pins "does not reopen a buffer the revert already cleared", buffer reason reported on every committed cancellation - 5 failed | 167 passed: the two new rows that name the ordinary report plus the three rows in this file that pin the committed-cancellation message text, i.e. every row that names the message and nothing else, reopen refusal allowed to propagate out of the pass - 1 failed | 171 passed: exactly the row that names it, flag set only after a successful reopen - 1 failed | 171 passed: the same row. Restored: 172 passed (172), product file byte-identical to the controlled text. Said plainly about what these rows do not measure: they drive the provider against this spec's vscode doubles. That a revert command, a tab close and a reopen really do leave a clean buffer in a VS Code host is the assumption the reconciliation rests on, and no row here proves it; the rows prove which report the teardown gives for each verdict it reads back. Lanes: measured on the tree this push carries - the same 13 writer-family files - 518 passed (518), of which DiffViewProvider.spec.ts is 172 passed (172). tsc --noEmit -p . reports only the junction artefact at activate/registerCommands.ts(88,102); eslint --prune-suppressions --max-warnings=0 exits 0 on all three touched files and src/eslint-suppressions.json equals HEAD; prettier --check clean on all five files this push touches. No it.runIf added or removed by this commit, and nothing here is gated on a platform label. --- src/integrations/editor/DiffViewProvider.ts | 41 +++- .../editor/__tests__/DiffViewProvider.spec.ts | 216 ++++++++++++++++++ .../committedWriteBufferConflictError.ts | 6 +- 3 files changed, 259 insertions(+), 4 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 97e934ba2f..23ca93cf52 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1031,13 +1031,44 @@ export class DiffViewProvider { const committedAfterPublish = this.committedPublish !== undefined && arePathsEqual(this.committedPublish.path, absolutePath) const committedContent = this.committedPublish?.content + + // Set inside the pass when the committed buffer survives every attempt to clear it; read by the + // report after the pass. A local, because only this cancellation's teardown can establish it. + let committedBufferUncleared = false const ownedTeardown = await this.runTeardown( async () => { if (committedAfterPublish) { // Clear the dirty flag from the published bytes rather than writing over them, and only // while the buffer still holds exactly what landed: text typed after the accept survives. if (updatedDocument.isDirty && updatedDocument.getText() === committedContent) { - await this.revertDocument(updatedDocument) + const reconciled = await this.revertDocument(updatedDocument) + if (!reconciled) { + // The verdict is load-bearing here for the same reason it is in the save teardown above + // (:811-:826): a buffer that stays dirty holds the bytes the guard published, and an + // ordinary save - VS Code's own file service, or a later write - can put them back over a + // newer file. Close and reopen from the published bytes, which is the same second chance + // that path gives, and remember whether it left the buffer dirty. + // + // The reason is recorded rather than thrown here: the tab work, the preview restore and + // the reset still have to run, and the report below is already placed after the pass for + // that reason. Throwing from inside the pass would leave a cancellation waiting on a + // teardown that had stopped half-way through the tabs. + // Fail-closed on the reopen itself. A second chance that could not run left the buffer + // uncleared just as surely as one that ran and came back dirty, and nothing here can + // prove otherwise, so the flag is set before the attempt rather than only after it. + committedBufferUncleared = true + try { + await this.closeFileTab(absolutePath) + await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) + committedBufferUncleared = + this.findReconciliationDocument(absolutePath)?.isDirty === true + } catch { + // best-effort: the flag already says the only thing the failure proves, and the pass + // has to go on to the tab work, the preview restore and the reset. A teardown that + // stopped at its own reconciliation would abandon a session whose caller is waiting + // for it to finalize. + } + } } await this.closeOwnDiffView(absolutePath) // The tab rules are the modify branch's, because the tab situation is the same: a @@ -1140,7 +1171,13 @@ export class DiffViewProvider { // same signal, because both were waiting on this outcome. throw new CommittedWriteBufferConflictError( absolutePath, - "revertChanges: a cancellation arrived after the guarded publish had committed.", + // The detail says which of the two things happened: the cancellation arriving late, or the + // buffer surviving the attempt to clear it. They are different states and the caller's next + // step differs - one is settled content the caller must not reapply, the other is settled + // content with a live buffer that can still be saved over it. + committedBufferUncleared + ? "revertChanges: the publish committed and the open buffer could not be cleared from its content." + : "revertChanges: a cancellation arrived after the guarded publish had committed.", ) } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 94b4f1e036..d1b150193e 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -4031,6 +4031,222 @@ describe("DiffViewProvider", () => { documents.length = 0 }) + // The same verdict on the other teardown owner. This branch called revertDocument and threw the + // answer away: a buffer that stays dirty after the revert still holds the bytes the guard + // published, and the row below is the save teardown's version of the same state. The teardown + // now gives the buffer the same second chance the save teardown gives - close, reopen from the + // bytes) and reports which of the two outcomes it ended in, because the caller's next step + // differs: settled content with a live buffer still needs the buffer saved or discarded. + it("revertChanges() after a committed create reports the buffer it could not clear", async () => { + // A structural double: the buffer holds exactly the bytes the guard published, and the rows + // below make it dirty the way the state they describe does. The factory's object carries a + // mutable isDirty; the interface declares it read-only, so the double is held under the one + // property the rows write and cast to the interface once, at the boundary. + const buffer: { isDirty: boolean } = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }) + const document = buffer as unknown as vscode.TextDocument // Structural double: the teardown reads getText, isDirty and save only. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.length = 0 + documents.push(document) + // The revert command ran and the buffer came back dirty: the double answers with the verdict + // revertDocument reads off isDirty, and the fixture keeps isDirty true so the two agree. + diffViewProvider["revertDocument"] = vi.fn().mockResolvedValue(false) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const reopen = vi.mocked(vscode.workspace.openTextDocument) + reopen.mockClear() + reopen.mockResolvedValue(document) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "create" + diffViewProvider["originalContent"] = "" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + // The save met a clean buffer; the cancellation meets the published bytes in a dirty one, which + // is the state this branch is entered with. + buffer.isDirty = true + vi.mocked(fs.unlink).mockClear() + + const failure = await diffViewProvider.revertChanges().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CommittedWriteBufferConflictError) + // The detail names the state this teardown ended in; the guidance stays the shared one. + expect((failure as Error).message).toContain("could not be cleared") + expect((failure as Error).message).toContain("do not retry") + // The reconciliation ran: the buffer was closed and reopened from the published bytes + // before the teardown decided what to report. + expect(reopen).toHaveBeenCalledTimes(1) + expect(diffViewProvider["closeFileTab"]).toHaveBeenCalled() + // The published file survives the cancellation, and the session still finalizes. + expect(fs.unlink).not.toHaveBeenCalledWith(mockTargetPath) + expect(diffViewProvider.isEditing).toBe(false) + documents.length = 0 + }) + + // The other answer the verdict can produce. The reopen came back clean, so the buffer is no + // longer live over the published bytes and the only thing left to say is what the cancellation + // always means after a commit: the content is on disk and the edit must not be reapplied. + it("revertChanges() after a committed create reports the late cancellation when the reopen came back clean", async () => { + // A structural double: the buffer holds exactly the bytes the guard published, and the rows + // below make it dirty the way the state they describe does. The factory's object carries a + // mutable isDirty; the interface declares it read-only, so the double is held under the one + // property the rows write and cast to the interface once, at the boundary. + const buffer: { isDirty: boolean } = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }) + const document = buffer as unknown as vscode.TextDocument // Structural double: the teardown reads getText, isDirty and save only. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.length = 0 + documents.push(document) + diffViewProvider["revertDocument"] = vi.fn().mockResolvedValue(false) + // Closing the tab discards the buffer, so the reopened document is the file's own clean + // buffer. This is the case the reconciliation exists to produce. + const closeFileTab = vi.fn().mockImplementation(async () => { + buffer.isDirty = false + }) + diffViewProvider["closeFileTab"] = closeFileTab + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const reopen = vi.mocked(vscode.workspace.openTextDocument) + reopen.mockClear() + reopen.mockResolvedValue(document) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "create" + diffViewProvider["originalContent"] = "" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + // The save met a clean buffer; the cancellation meets the published bytes in a dirty one. + buffer.isDirty = true + + const failure = await diffViewProvider.revertChanges().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CommittedWriteBufferConflictError) + expect((failure as Error).message).toContain( + "a cancellation arrived after the guarded publish had committed", + ) + expect((failure as Error).message).not.toContain("could not be cleared") + expect(reopen).toHaveBeenCalledTimes(1) + documents.length = 0 + }) + + // And the case where the verdict says the buffer is already clear. The second chance is a + // response to a refusal, not a step every cancellation takes: nothing was closed and reopened + // here, and the report is the ordinary one. + it("revertChanges() does not reopen the buffer the revert already cleared", async () => { + // A structural double: the buffer holds exactly the bytes the guard published, and the rows + // below make it dirty the way the state they describe does. The factory's object carries a + // mutable isDirty; the interface declares it read-only, so the double is held under the one + // property the rows write and cast to the interface once, at the boundary. + const buffer: { isDirty: boolean } = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }) + const document = buffer as unknown as vscode.TextDocument // Structural double: the teardown reads getText, isDirty and save only. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.length = 0 + documents.push(document) + // The revert succeeded, which in revertDocument's terms means the buffer is no longer dirty. + diffViewProvider["revertDocument"] = vi.fn().mockImplementation(async () => { + buffer.isDirty = false + return true + }) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const reopen = vi.mocked(vscode.workspace.openTextDocument) + reopen.mockClear() + reopen.mockResolvedValue(document) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "create" + diffViewProvider["originalContent"] = "" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + // The save met a clean buffer; the cancellation meets the published bytes in a dirty one. + buffer.isDirty = true + + const failure = await diffViewProvider.revertChanges().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CommittedWriteBufferConflictError) + expect(reopen).not.toHaveBeenCalled() + expect((failure as Error).message).toContain( + "a cancellation arrived after the guarded publish had committed", + ) + expect((failure as Error).message).not.toContain("could not be cleared") + documents.length = 0 + }) + + // The second chance failing on its own. This is a VS Code call inside a teardown whose whole + // job is to leave the session finalized, so a refusal here must not abort the pass. The flag is + // set before the attempt rather than only after it: a second chance that could not run left the + // buffer uncleared just as surely as one that ran and came back dirty. + it("revertChanges() reports the buffer it could not clear when the reopen itself is refused", async () => { + const buffer: { isDirty: boolean } = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }) + const document = buffer as unknown as vscode.TextDocument // Structural double: the teardown reads getText, isDirty and save only. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.length = 0 + documents.push(document) + diffViewProvider["revertDocument"] = vi.fn().mockResolvedValue(false) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + const reopen = vi.mocked(vscode.workspace.openTextDocument) + reopen.mockClear() + // Once, so the module double's own default is what the rest of the file keeps getting. + reopen.mockRejectedValueOnce( + Object.assign(new Error("EACCES: permission denied, open 'mock-target-file.ts'"), { code: "EACCES" }), + ) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "create" + diffViewProvider["originalContent"] = "" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + // The save met a clean buffer; the cancellation meets the published bytes in a dirty one. + buffer.isDirty = true + // saveChanges ran its own teardown on the way in, so the counts below are taken from the + // point the cancellation pass starts at. + closeFileTab.mockClear() + restorePreviewTabs.mockClear() + reopen.mockClear() + + const failure = await diffViewProvider.revertChanges().catch((error: unknown) => error) + // The report is the committed-write conflict, not the reopen's refusal: the pass survived its + // own reconciliation and still told the caller what it knows. + expect(failure).toBeInstanceOf(CommittedWriteBufferConflictError) + expect((failure as Error).message).toContain("could not be cleared") + expect((failure as Error).message).not.toContain("EACCES") + expect(reopen).toHaveBeenCalledTimes(1) + expect(closeFileTab).toHaveBeenCalled() + // The tab work and the finalization still ran rather than being abandoned mid-pass. + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(diffViewProvider.isEditing).toBe(false) + documents.length = 0 + }) + // The same gap on the other teardown owner: a rejected publish's discard cleanup never resets // (the tool caller's error handling owns that), so a cancellation that only waited for it left // the session mid-edit with nobody finalizing it. diff --git a/src/integrations/editor/committedWriteBufferConflictError.ts b/src/integrations/editor/committedWriteBufferConflictError.ts index 4f2c4641cf..61ac8a6da7 100644 --- a/src/integrations/editor/committedWriteBufferConflictError.ts +++ b/src/integrations/editor/committedWriteBufferConflictError.ts @@ -5,8 +5,10 @@ * because re-reading and re-applying it would duplicate the change. * * Each site adds a `detail` line naming the window it hit - the save that could not - * clear a dirty buffer, the cancellation that arrived after a commit, the move whose - * source unlink never ran - while the guidance below stays identical everywhere, so one + * clear a dirty buffer, the cancellation that arrived after a commit, the cancellation whose + * revert left the buffer dirty after the reopen, the move whose source unlink never ran - + * while the guidance below stays identical everywhere, so one name and one set of + * instructions covers every committed-write conflict. * name and one set of instructions covers every committed-write conflict. * * Kept in its own vscode-free module so tool-level tests can construct it From 3159d0abcf87c728c53731c22f2bafc354e9a79b Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:11:56 +0800 Subject: [PATCH 113/115] test(apply-patch): cover the move refusal under both preventFocusDisruption settings The reviewer on the move-refusal thread asked for a regression covering both `preventFocusDisruption` settings and confirming that the rejection preserves the source file. The product was re-measured first, because the answer is not the one the escalation that funded this implied: the refusal (ApplyPatchTool.ts:594-:643) sits above the `isPreventFocusDisruptionEnabled` split - guarded `saveDirectly` at :646, raw `fs.mkdir` + `fs.writeFile` at :727 - so both settings already refuse, and the wording cannot diverge because there is one statement of the condition. What was missing was coverage: every row in this file ran with the flag armed on, which left the symmetry a claim about line numbers rather than something a test had checked. Three rows, all with the flag off: - a destination that names the source's file is refused, and neither the mkdir, the writeFile nor the unlink runs; - an ordinary flag-off move still publishes through mkdir + fs.writeFile + unlink with the saveDirectly double unused - that is what pins which branch ran, and it says the refusal has not become a ban on flag-off moves; - a destination that is not there reads as "absent", which is not the source, and the move proceeds. The harness arms the flag on, and the provider stub captures the getState function by value, so a row has to change what that function resolves to (`mockGetState.mockResolvedValue`) rather than re-point the `mockGetState` binding. Re-pointing it compiles, runs green and exercises the guarded branch; the two must-move rows were green-but-wrong that way until the mechanism was measured, and it is recorded here because a flag-off row that is secretly flag-on is the kind of green that survives a review. `mkdir` joins `writeFile` in the `mockedFsPromises` cast, loosely typed for the same stated reason - both carry overload sets that MockedFunction would collapse. Controls, as run counts: - gating the refusal on the flag (`moveRefusal !== undefined && isPreventFocusDisruptionEnabled`), the asymmetry the row objects to, reddens 1 of 47 and the row it reddens is the flag-off refusal row. That is a boundary control for this claim. - removing the refusal statement reddens 5 of 47 (the three consequence rows, the withheld-identity row and this file's flag-off row). That is a whole-feature revert, not a control, and it is counted as such. Shipped tree: 47 passed (47), exit 0. `tsc --noEmit -p .` reports one error, the junction artefact at activate/registerCommands.ts(88,102), which is not this change. eslint --prune-suppressions --max-warnings=0 exits 0 on the touched file; the prune also rewrote unrelated entries elsewhere in src/eslint-suppressions.json, which is reverted here rather than shipped, because cleanup with nothing to do with these rows belongs in its own commit. prettier --check clean. No it.runIf added or removed, and nothing claimed about the platform: the geometry these rows drive is the doubles' inode table, as in the rows landed in f653f86f0, so they run wherever the suite runs. This does not close the separate finding that the flag-off destination write bypasses `guardedWrite` altogether (thread bwM6z8Hy8): the identity refusal is not that guard, and closing that changes write semantics for every flag-off user, which is the owner's call. --- .../__tests__/applyPatchTool.execute.spec.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 1b20361b9e..9111de3ccd 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -29,6 +29,10 @@ const mockedFsPromises = vi.mocked( // Typed loosely because fsPromises.writeFile carries an overload set that MockedFunction // would collapse to one arbitrary member; the rows only ever ask whether it was called. writeFile: ReturnType + // Added for the flag-off move rows: the raw branch mkdirs the destination's parent before + // writing, and the rows ask whether that mkdir ran. Typed loosely for the same reason as + // writeFile above - fsPromises.mkdir is overloaded and the rows only ever ask about calls. + mkdir: ReturnType } }, ) @@ -899,6 +903,109 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockTask.didEditFile).toBe(false) }) + it("move: refuses a same-file destination on the raw write path too, not only the guarded one", async () => { + // The refusal sits above the isPreventFocusDisruptionEnabled split (:646 vs. :727), so a + // flag-off run refuses for the same reason a flag-on run does. Until this row every test in + // this file ran with the flag on, which made that symmetry a claim about line numbers rather + // than something anyone had checked. The flag-off branch publishes with a raw mkdir plus + // fs.writeFile rather than through the diff view, so those are the calls this row expects not + // to see. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + // The harness arms preventFocusDisruption on for every row. The binding holds the function + // object the provider stub captured, so a row has to change what that function resolves + // to rather than re-point the binding. + mockGetState.mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: false }, + }) + + mockedFsPromises.default.stat.mockImplementation(() => Promise.resolve(_identityOf(sourceKey))) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-destination-is-the-source", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("the two paths name the same file")) + expect(mockedFsPromises.default.writeFile).not.toHaveBeenCalled() + expect(mockedFsPromises.default.mkdir).not.toHaveBeenCalled() + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + // Both spellings went to the filesystem before the refusal, here as on the other path. + expect(mockedFsPromises.default.stat).toHaveBeenCalledWith(sourceKey, { bigint: true }) + expect(mockedFsPromises.default.stat).toHaveBeenCalledWith(destKey, { bigint: true }) + }) + + it("move: publishes through the raw write path when the flag is off and the two spellings name different files", async () => { + // The other half of the coverage, and the half that says the refusal did not become a ban: + // with different inodes this is an ordinary move, and the branch that answers for it is the + // mkdir + fs.writeFile one. The saveDirectly double staying unused is what pins the branch. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + // The harness arms preventFocusDisruption on for every row. The binding holds the function + // object the provider stub captured, so a row has to change what that function resolves + // to rather than re-point the binding. + mockGetState.mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: false }, + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-between-different-files", + }) + + expect(mockedFsPromises.default.mkdir).toHaveBeenCalledWith(path.dirname(destKey), { recursive: true }) + expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith(destKey, "modified file content\n", "utf8") + expect(mockedFsPromises.default.unlink).toHaveBeenCalledWith(sourceKey) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Saved file")) + expect(mockTask.didEditFile).toBe(true) + }) + + it("move: treats a destination that is not there as not-the-source on the raw write path", async () => { + // Absence is answered the same way on both paths: a destination that is not there is not the + // source, so the identity read returns "absent" and the move proceeds. The row that arms an + // EACCES destination pins the other answer the filesystem can give. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const enoent = Object.assign(new Error("ENOENT: no such file or directory, stat '" + destKey + "'"), { + code: "ENOENT", + }) + // The harness arms preventFocusDisruption on for every row. The binding holds the function + // object the provider stub captured, so a row has to change what that function resolves + // to rather than re-point the binding. + mockGetState.mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: false }, + }) + + mockedFsPromises.default.stat.mockImplementation((target: unknown) => + String(target) === destKey ? Promise.reject(enoent) : Promise.resolve(_identityOf(String(target))), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-destination-absent", + }) + + expect(mockTask.recordToolError).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith(destKey, "modified file content\n", "utf8") + expect(mockedFsPromises.default.unlink).toHaveBeenCalledWith(sourceKey) + }) + it("move: rejects a partial source onto an observed destination before changing any state", async () => { // The destination content is the source file plus one hunk, so it can only be // as complete as the view the model had of the source. Rejecting before the From 15d9f8e62b686cff6d624f7710ee74268cb100ce Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:38:56 +0800 Subject: [PATCH 114/115] test(task-history): the delete rows compare files, not path spellings The windows platform lane at 3159d0abc failed on four rows of core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts (the two read-back rows at :145 and :146, the withFileLock argument at :159 and the deleteMany batch row at :255), every one of them ENOENT or an argument mismatch under C:\Users\RUNNER~1\AppData\Local\Temp\task-history-delete-semantics-. The same head is green on ubuntu and the spec was last touched on main by bb6cbee59 (#2297), so a file this branch never changed went red only on this branch. The cause is ours: deleteJsonFile (utils/jsonFileRecovery.ts:89) now calls withFileLock(await resolveLockKey(path.resolve(filePath)), ...) at :95 and unlinks the callback's canonical path, so a canonical spelling crosses the mock boundary into rows whose own expected paths are built from the spelling os.tmpdir() answered. On the hosted Windows runner those two are the 8.3 form of the profile directory and its long name, so === on paths stopped recognising the injected EACCES target, the real unlink ran, and the read-back rows saw ENOENT. Ubuntu never sees it because there the two spellings are one string. The repair is in the comparison rather than the canonicalisation: reverting the canonicalisation would undo the exclusion the reviewer asked for while fixing nothing about the underlying fact. - the two "%s preserves the canonical file if backup cleanup fails" rows and the deleteMany "continues the batch after a failed unlink" row inject through sameFileAs now, which resolves both sides before comparing; - "unlinks under the shared per-file lock, evicts cache and mtime, and writes through once" pins the acquired key to the file inside the directory object the row's path resolves to (canonical parent plus basename), because the delete removes the file and after that neither realpath nor stat can answer for it. This is a main-authored row updated because our change deliberately moved a canonical path across a mock boundary, not because the row was ever wrong about what it wanted to pin. Reproduced on this machine rather than inferred from a log: pointing TMP/TEMP at a junction alias of a real directory (link spelling against target spelling, verified different through realpath) produced the same four failures before this change and 10 passed (10) after; the ordinary temp root also runs 10 passed (10). The 8.3 spelling itself is only observable on a hosted runner, so the hosted case stays lane-only evidence and is labelled that way. Control: restoring the spelling comparison inside sameFileAs reddens 3 of 10 under the junction root (the two backup-cleanup rows and the deleteMany batch row); the fourth row is decided by the parent-directory comparison, which that control does not touch. Blast radius for the file, whole core group at this head: 199 files / 4426 passed | 9 skipped (4435), exit 0 - those 9 skips are the windows-gated rows the lane reports too. eslint --prune-suppressions --max-warnings=0 exit 0, with the prune's unrelated rewrites elsewhere in src/eslint-suppressions.json reverted rather than shipped; tsc --noEmit -p . reports only the junction artefact at activate/registerCommands.ts (88,102); prettier --check clean. No product change, no it.runIf added or removed. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 875c7dcb9a..3505f439cf 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -36,6 +36,20 @@ vi.mock("fs/promises", async () => { const actualFs = await vi.importActual("fs/promises") const actualFileLock = await vi.importActual("../../../utils/fileLock") +// The delete path hands fs.unlink and the per-file lock a canonical spelling - it comes out of +// resolveLockKey (services/file-safety/safeWriteText.ts:367), which follows links - while this file's +// own expected paths come from the spelling os.tmpdir() answered. On a hosted Windows runner those +// two spellings name the same file while differing as strings (the 8.3 form of the profile directory +// against its long name), so comparing paths with === pins a spelling rather than a file: the +// injected EACCES stops recognising its own target, the real unlink runs, and the read-back rows +// see ENOENT. Reproduced on this machine by pointing TMP/TEMP at a junction alias of a real +// directory, which produced the same four failures the hosted Windows lane reported. +async function sameFileAs(candidate: unknown, expected: string): Promise { + const resolved = await fs.realpath(String(candidate)).catch(() => String(candidate)) + const expectedCanonical = await fs.realpath(expected).catch(() => expected) + return resolved === expectedCanonical +} + function makeHistoryItem(overrides: Partial = {}): HistoryItem { return { id: `task-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, @@ -137,7 +151,8 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { const backup = path.join(path.dirname(filePath), `.${path.basename(filePath)}.bak_1_saved.tmp`) await fs.writeFile(backup, JSON.stringify(history)) vi.mocked(fs.unlink).mockImplementation(async (target) => { - if (target === backup) throw Object.assign(new Error("Cleanup failed"), { code: "EACCES" }) + if (await sameFileAs(target, backup)) + throw Object.assign(new Error("Cleanup failed"), { code: "EACCES" }) return actualFs.unlink(target) }) if (operation === "delete") await store.delete(history.id) @@ -156,10 +171,28 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { await expect(store.delete("locked-delete")).resolves.toBeUndefined() - expect(vi.mocked(withFileLock)).toHaveBeenCalledWith( - historyFilePath(storagePath, "locked-delete"), - expect.any(Function), - ) + // Production hands withFileLock the canonical spelling of the key (it comes out of + // resolveLockKey, which follows links), while historyFilePath builds this row's path from + // the spelling os.tmpdir() answered. On a hosted Windows runner those two name the same + // file and differ as strings - the 8.3 profile directory against its long name - so a string + // match pins a spelling. This row pins the file instead: the key the store locked must name + // this file inside the directory object this row's path resolves to. The parent directory is + // what gets canonicalised rather than the file, because the delete removes the file and after + // that neither realpath nor stat can answer for it. + const expectedFile = historyFilePath(storagePath, "locked-delete") + const expectedDir = await fs.realpath(path.dirname(expectedFile)).catch(() => path.dirname(expectedFile)) + let matchingKeys = 0 + let matchedHandlerType = "" + for (const call of vi.mocked(withFileLock).mock.calls) { + const acquired = String(call[0]) + const acquiredDir = await fs.realpath(path.dirname(acquired)).catch(() => path.dirname(acquired)) + if (acquiredDir === expectedDir && path.basename(acquired) === path.basename(expectedFile)) { + matchingKeys++ + matchedHandlerType = typeof call[1] + } + } + expect(matchingKeys).toBeGreaterThan(0) + expect(matchedHandlerType).toBe("function") await expect(fs.access(historyFilePath(storagePath, "locked-delete"))).rejects.toMatchObject({ code: "ENOENT", }) @@ -242,7 +275,7 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { onWrite.mockClear() vi.mocked(fs.unlink).mockImplementation(async (p) => { - if (p === historyFilePath(storagePath, "batch-b")) { + if (await sameFileAs(p, historyFilePath(storagePath, "batch-b"))) { throw Object.assign(new Error("EACCES: permission denied, unlink"), { code: "EACCES" }) } return actualFs.unlink(p) From dc4c84867c0a4d579eccee6c9dfacda2bcbfbbc7 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:45:10 +0800 Subject: [PATCH 115/115] Stop the placeholder identity coming from the path it stats (upstream #1918 review, DiffViewProvider.ts:299). The exclusive create now keeps its handle open and the bracketing identity reads go through that handle (placeholderHandle.stat) instead of fs.stat(absolutePath), so a rename landing between the create and the identity read can no longer make the version token describe a different file. What stays equal: the token formula and every one of its components (versionTokenOfStat over dev:ino:size:mtimeNs:ctimeNs), the S2 stat-matched verification (bracketing identities must agree and the content must be exactly the empty placeholder), the EEXIST reporting, the placeholder-token replacement of a prior observation, the accept-time CAS semantics, and the cleanup token being captured whether or not the owning task is live. Only the source of the identity moves - from the path to the file that was opened - and nothing on this path compares a path spelling, because the writer hands canonical paths to the lock. Test side: two new rows pin the source (handle identity and path identity deliberately disagree, so the recorded token is the handle one) and its control (handle and path naming the same file gives exactly the token the placeholder rows already pin). The harness mocks fs/promises wholesale, so a rename physically landing between the create and the identity read cannot be expressed here; the row states the divergence the rename produces rather than the rename itself, and says so in its comment. Five assertion lines in main-authored rows moved from the writeFile double to the open double (:2142, :2171, :2204 and the lost-create-race row at :2225-:2233): they pinned which double the create used, and this change deliberately moves the create from fs.writeFile(flag:wx) to fs.open("wx"). None of them was wrong about what it wanted to pin - exclusivity and the lost-race report are still pinned - and the fs/promises mock now supplies an open() whose stat delegates to the same stat mock, so every placeholder row still sees the same token values. DiffViewProvider spec: 174 passed (174). No changeset, no CHANGELOG. tsc over src reports only the pre-existing activate/registerCommands.ts(88,102) TS2739. --- src/integrations/editor/DiffViewProvider.ts | 80 +++++++++--------- .../editor/__tests__/DiffViewProvider.spec.ts | 83 +++++++++++++++++-- 2 files changed, 117 insertions(+), 46 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 23ca93cf52..4d2cfd7a01 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -251,8 +251,44 @@ export class DiffViewProvider { // recorded below would then describe the other writer's file, and the guard would // accept a truncated baseline as the version this edit was built on. A lost race is // reported, not absorbed. + // Opened exclusively, and held open until its identity has been taken: the version token below + // comes from this handle rather than from a stat of the path, so a rename landing after the + // create cannot make the token describe a different file. The token formula is unchanged + // (versionTokenOfStat over dev:ino:size:mtimeNs:ctimeNs) and the verification is the same S2 + // stat-matched contract as the modify branch above: a mismatched identity or a non-empty read + // means another writer touched the placeholder in the window after the create wrote it, and that + // writer's token must not be observed as complete - observing it would claim we saw content we + // never read, and the token-guarded cleanup would unlink their file - so nothing is recorded and + // the save fails closed instead. When the placeholder is verified empty, its token replaces any + // prior observation for the path: a prior observation describes a file that no longer exists, and + // keeping it would make the accept-time CAS (the placeholder token on disk against the vanished + // file's token) fail every time. The cleanup token is captured whether or not the task is still + // live: a rejected save must not leave the placeholder behind even when the owning task has been + // collected. + let placeholderHandle: Awaited> | undefined try { - await fs.writeFile(absolutePath, "", { flag: "wx" }) + placeholderHandle = await fs.open(absolutePath, "wx") + const placeholderPreStats = await placeholderHandle.stat({ bigint: true }).catch(() => undefined) + if (placeholderPreStats) { + const placeholderContent = await fs.readFile(absolutePath, "utf-8").catch(() => undefined) + const placeholderPostStats = await placeholderHandle.stat({ bigint: true }).catch(() => undefined) + const placeholderToken = versionTokenOfStat(placeholderPreStats) + // Stat-matched (S2): the read is trusted only while the bracketing identity reads agree and + // the content is exactly the empty placeholder. + if ( + placeholderPostStats && + placeholderToken === versionTokenOfStat(placeholderPostStats) && + placeholderContent === "" + ) { + // Remember the placeholder token so a rejected save can remove the placeholder only + // while it is still the exact file the create opened. + this.placeholderVersion = placeholderToken + const displayTask = this.taskRef.deref() + if (displayTask) { + displayTask.observationRegistry.observe(absolutePath, placeholderToken, true) + } + } + } } catch (error: unknown) { if (errorCode(error) === "EEXIST") { throw new Error( @@ -262,45 +298,9 @@ export class DiffViewProvider { ) } throw error - } - // S4b follow-up (#44 / epic #1375): the empty placeholder is fully - // known (empty), but verify it with the same S2 stat-matched contract - // as the modify branch above: a stat-mismatched or non-empty read - // means another writer touched the placeholder in the window after - // open() wrote it, and that writer's token must not be observed as - // complete (observing it would claim we saw content we never read, and - // the token-guarded cleanup would unlink their file) - nothing is - // recorded and the save fails closed instead. When the placeholder is - // verified empty, its token replaces any prior observation for the - // path: a prior observation describes a file that no longer exists, and - // keeping it would make the accept-time CAS (the placeholder token on - // disk vs. the vanished file's token) fail every time, so recreating - // the file would always fail. The placeholder token is the correct - // baseline for the new file: an external change to the placeholder - // before the accept moves the on-disk token and fails the CAS. The - // cleanup token is captured whether or not the task is still live: - // a rejected save must not leave the placeholder behind even when the - // owning task has been collected. - const placeholderPreStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) - if (placeholderPreStats) { - const placeholderContent = await fs.readFile(absolutePath, "utf-8").catch(() => undefined) - const placeholderPostStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) - const placeholderToken = versionTokenOfStat(placeholderPreStats) - // Stat-matched (S2): the read is trusted only while the bracketing - // stats agree and the content is exactly the empty placeholder. - if ( - placeholderPostStats && - placeholderToken === versionTokenOfStat(placeholderPostStats) && - placeholderContent === "" - ) { - // Remember the placeholder token so a rejected save can remove - // the placeholder only while it is still the exact file open() - // wrote. - this.placeholderVersion = placeholderToken - const displayTask = this.taskRef.deref() - if (displayTask) { - displayTask.observationRegistry.observe(absolutePath, placeholderToken, true) - } + } finally { + if (placeholderHandle) { + await placeholderHandle.close().catch(() => {}) } } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index d1b150193e..5eb648683d 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -44,6 +44,13 @@ vi.mock("fs/promises", () => ({ // guardedWrite resolves the workspace root (and the nearest existing ancestor of the // target) before publishing. The double returns the path unchanged so the containment // check sees the same spelling the rest of the test uses. + open: vi.fn(async (targetPath: string) => { + const mocked = await import("fs/promises") + return { + stat: (options?: Parameters[1]) => mocked.stat(targetPath, options), + close: () => Promise.resolve(), + } + }), realpath: vi.fn(async (p: string) => p), })) @@ -2139,7 +2146,7 @@ describe("DiffViewProvider", () => { // vanished file's stale token), and remembered for cleanup // Created exclusively (#new row 4507851689): a plain write would truncate a file another // process created after the absence was classified. - expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "", { flag: "wx" }) + expect(vi.mocked(fs.open)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "wx") expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) expect(mockTask.observationRegistry.get(`${mockCwd}/t3-create.ts`)?.version).toBe(placeholderToken) expect(diffViewProvider["placeholderVersion"]).toBe(placeholderToken) @@ -2168,7 +2175,7 @@ describe("DiffViewProvider", () => { // Created exclusively (#new row 4507851689): a plain write would truncate a file another // process created after the absence was classified. - expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-dead-task.ts`, "", { flag: "wx" }) + expect(vi.mocked(fs.open)).toHaveBeenCalledWith(`${mockCwd}/t3-dead-task.ts`, "wx") // no live task: nothing observed, but the cleanup token is still // captured (provider state) so the fail-closed save rejection can // remove the placeholder instead of leaking it @@ -2201,7 +2208,7 @@ describe("DiffViewProvider", () => { // Created exclusively (#new row 4507851689): a plain write would truncate a file another // process created after the absence was classified. - expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/contested.ts`, "", { flag: "wx" }) + expect(vi.mocked(fs.open)).toHaveBeenCalledWith(`${mockCwd}/contested.ts`, "wx") // nothing recorded, and the vanished file's prior observation was // not replaced with the writer's token expect(mockTask.observationRegistry.get(`${mockCwd}/contested.ts`)?.version).toBe("model-token") @@ -2222,15 +2229,15 @@ describe("DiffViewProvider", () => { vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) vi.mocked(fs.stat).mockResolvedValue(previewStats) vi.mocked(fs.readFile).mockResolvedValue("") - const write = vi.mocked(fs.writeFile) - write.mockRejectedValueOnce( + const create = vi.mocked(fs.open) + create.mockRejectedValueOnce( Object.assign(new Error(`EEXIST: file already exists, open '${mockCwd}/race.ts'`), { code: "EEXIST" }), ) diffViewProvider.editType = "create" mockTask.observationRegistry.clear() const failure = await diffViewProvider.open("race.ts").catch((error: unknown) => error) - expect(write).toHaveBeenCalledWith(`${mockCwd}/race.ts`, "", { flag: "wx" }) + expect(create).toHaveBeenCalledWith(`${mockCwd}/race.ts`, "wx") expect(failure).toBeInstanceOf(Error) expect((failure as Error).message).toContain("another create won the race") // Nothing was observed and no cleanup token was taken, so a later rejected save cannot @@ -2294,6 +2301,70 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["placeholderVersion"]).toBeUndefined() }) + it("open() records the placeholder token from the handle it created, not from the path it stats", async () => { + // Where the identity comes from, pinned. The doubles make the two sources disagree: the handle + // reports the identity of the file the exclusive create opened, and a path stat reports another file + // - which is the state a rename landing between the create and the identity read produces on a real + // filesystem. The token formula is unchanged (versionTokenOfStat over dev:ino:size:mtimeNs:ctimeNs); + // only its source moves. Nothing here compares a path spelling, because this path hands canonical + // paths to the lock, so a spelling pin would be a pin on the mock rather than on the file. + const handleStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(77), + size: BigInt(0), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const mockEditor = mockTextEditor(`${mockCwd}/handle-identity.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/handle-identity.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + vi.mocked(fs.open).mockResolvedValue({ + stat: vi.fn().mockResolvedValue(handleStats), + close: vi.fn().mockResolvedValue(undefined), + // structural double; the cast matches the placeholder rows above it + } as unknown as Awaited>) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + await diffViewProvider.open("handle-identity.ts") + expect(diffViewProvider["placeholderVersion"]).toBe(versionTokenOfStat(handleStats)) + expect(diffViewProvider["placeholderVersion"]).not.toBe(versionTokenOfStat(previewStats)) + expect(vi.mocked(fs.open)).toHaveBeenCalledWith(`${mockCwd}/handle-identity.ts`, "wx") + }) + + // The control: with the handle and the path naming the same file, the token is the same one the + // placeholder rows above already pin, so the row above is sensitive to the source of the identity and + // not to an unrelated property of the double. + it("the placeholder token is unchanged when the handle and the path name the same file", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/same-file.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/same-file.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + vi.mocked(fs.open).mockResolvedValue({ + stat: vi.fn().mockResolvedValue(previewStats), + close: vi.fn().mockResolvedValue(undefined), + // structural double; the cast matches the placeholder rows above it + } as unknown as Awaited>) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + await diffViewProvider.open("same-file.ts") + expect(diffViewProvider["placeholderVersion"]).toBe(versionTokenOfStat(previewStats)) + expect(vi.mocked(fs.open)).toHaveBeenCalledWith(`${mockCwd}/same-file.ts`, "wx") + }) + it("saveChanges() accepts a recreate after a prior read - the accept-time CAS checks the placeholder token", async () => { // The exact recreate-always-failed trace: the model read the file // (observed "v1" by the outer beforeEach), the file then vanished,