From d5f8a79c75e74be66078ec8a9884a056ccc628bf Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 20:34:31 +0800 Subject: [PATCH 01/89] 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 aa0cdaba0a6741daab9f3c06cf1eea9ad16f481a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 20:49:29 +0800 Subject: [PATCH 02/89] 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 c4120b0578bb75756a24c7ed59fe331b590588ae Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 21:14:20 +0800 Subject: [PATCH 03/89] 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 97b599d2e69243e48ff14e434a11cd689278d2a9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:30:44 +0800 Subject: [PATCH 04/89] 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 435be8b96b7620eefbf799aebfcf7000d33cb1da Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:34:46 +0800 Subject: [PATCH 05/89] 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 79d08678a0..245af61910 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 625a976dc2bee3b7d3446e1fdaceaf4e2d4fc5ec Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:47:25 +0800 Subject: [PATCH 06/89] 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 583485c628..53ce332bb3 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1716,7 +1716,7 @@ }, "utils/safeWriteJson.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 3 } }, "utils/tts.ts": { From 4e2de13ff3a535db87802e97b7ea37e409dd0933 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:47:37 +0800 Subject: [PATCH 07/89] 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 60376ca186af0bd85b439f4825bc6db1f736fc7d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:34:05 +0800 Subject: [PATCH 08/89] 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 4de2b84590..5fbac2dd3d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -111,6 +111,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" @@ -286,6 +287,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 77eb0a48678b31fb72d9f1ea59a6587c64571035 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:48:10 +0800 Subject: [PATCH 09/89] rebuild unit u4 on the fixed chain --- src/core/tools/ReadFileTool.ts | 93 ++- src/core/tools/__tests__/readFileTool.spec.ts | 784 +++++++++++++++++- .../misc/__tests__/indentation-reader.spec.ts | 43 + src/integrations/misc/indentation-reader.ts | 12 +- 4 files changed, 922 insertions(+), 10 deletions(-) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..ba1be7deaf 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 6c9e177d38..34d69ed4cc 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -13,10 +13,16 @@ */ import path from "path" +import type { Stats } from "fs" + +import type { LegacyReadFileParams } from "@roo-code/types" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" +import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" +import { computeVersionToken } from "../../../utils/versionToken" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, @@ -136,6 +142,7 @@ interface MockTaskOptions { rooIgnoreAllowed?: boolean maxImageFileSize?: number maxTotalImageSize?: number + observationRegistry?: ObservationRegistry } function createMockTask(options: MockTaskOptions = {}) { @@ -143,6 +150,9 @@ function createMockTask(options: MockTaskOptions = {}) { return { cwd: "/test/workspace", + // Mirror Task: every task always owns an observation registry (A2, #1375). + // Tests asserting on observations pass their own instance via options. + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), api: { getModel: vi.fn().mockReturnValue({ info: { supportsImages }, @@ -187,7 +197,18 @@ describe("ReadFileTool", () => { vi.clearAllMocks() // Default mock implementations - mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) + // The stat default carries BigIntStats fields (A2, epic #1375): reads now + // token-ize the pre/post stats, so the default must look like a real bigint stat. + // Tests overriding it do so per-call with mockResolvedValue(Once). + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) mockedIsBinaryFile.mockResolvedValue(false) mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) mockedReadWithSlice.mockReturnValue({ @@ -839,7 +860,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, @@ -1489,5 +1510,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 d46cb54277..e9b27e7191 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, @@ -279,11 +280,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", () => { @@ -297,6 +332,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 aecabd5982..5cbd23d168 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 ──────────────────────────────────────────────────────────────── @@ -306,6 +308,7 @@ export function readWithIndentation(content: string, options: IndentationReadOpt totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -448,6 +451,7 @@ export function readWithSlice( totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -455,6 +459,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) @@ -465,5 +474,6 @@ export function readWithSlice( totalLines, returnedLines: selectedLines.length, wasTruncated, + hasClippedLines, } } From d7eab3d994f869077f3ab6f87667c9d2d24e1113 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:49:24 +0800 Subject: [PATCH 10/89] chore(lint): prune the readFileTool.spec suppression this unit earns The two any usages this entry covered are gone in the rewritten spec, so the count drops 98 -> 96. 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 53ce332bb3..d607509cdc 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -976,7 +976,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 98 + "count": 96 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { From ca636d6f97b8e9fa331e26252d9a3179c7c3a487 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:49:49 +0800 Subject: [PATCH 11/89] 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 45b791264cc492ea2c83a9d7292ba776c9dbead2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:12:08 +0800 Subject: [PATCH 12/89] 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 | 2056 ++++++++++++++++- 2 files changed, 2360 insertions(+), 182 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 aee88f4061..f1972efabd 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -5,6 +5,15 @@ import delay from "delay" 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,171 +1119,1677 @@ 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() + describe("guarded write (S4b, epic #1375)", () => { + const enoent = () => Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + it("rejects an unobserved write to an existing file with the read-first remediation", async () => { + mockTask.observationRegistry.clear() - const result = await diffViewProvider.saveChanges(true, 3000) + 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() + }) - // Verify delay was called with correct duration - expect(mockDelay).toHaveBeenCalledWith(3000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + it("creates an unobserved file when the target is absent", async () => { + mockTask.observationRegistry.clear() + vi.mocked(fs.access).mockRejectedValue(enoent()) - it("should skip diagnostics when diagnosticsEnabled is false", 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") + }) - const result = await diffViewProvider.saveChanges(false, 2000) + 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") - // Verify delay was NOT called and diagnostics were NOT checked - expect(mockDelay).not.toHaveBeenCalled() - expect(vscode.languages.getDiagnostics).not.toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + const result = diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - it("should use default values when no parameters provided", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + await expect(result).rejects.toThrow("Stale version") + await expect(result).rejects.toThrow("re-read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + }) - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + it("rejects an unobserved edit-kind write before any I/O", async () => { + mockTask.observationRegistry.clear() - const result = await diffViewProvider.saveChanges() + 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() + }) - // Verify default behavior (enabled=true, delay=2000ms) - expect(mockDelay).toHaveBeenCalledWith(1000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + 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") + }) - const result = 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.objectContaining({ start: { line: 30, character: 0 } }), - vscode.TextEditorRevealType.AtTop, + 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("saveChanges() does NOT call revealRange when preEditScrollLine is undefined", async () => { - const mockRevealRange = vi.fn() - const mockSavedEditor = { revealRange: mockRevealRange } + 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=2000ms) + expect(mockDelay).toHaveBeenCalledWith(1000) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("should handle custom delay values", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + const result = 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, + ) + }) + + it("saveChanges() does NOT call revealRange when preEditScrollLine is undefined", 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) @@ -1147,6 +2914,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) @@ -1664,6 +3459,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({ @@ -1675,6 +3475,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 22b6decf195e9880c96d798ef0b17e1c04d1a594 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:12:11 +0800 Subject: [PATCH 13/89] 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 56b2bf8909..4043faa3cf 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 42f5877fd61094ac11dd97c3d45f2953ab433da1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:42:02 +0800 Subject: [PATCH 14/89] 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 4043faa3cf..5bf980c98d 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 5dc556b2e071f811a1f3aeba0d1a7ec91ece3f11 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:43:20 +0800 Subject: [PATCH 15/89] 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 5fbac2dd3d..12406b80a4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -113,6 +113,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 { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" @@ -297,6 +298,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 @@ -407,6 +409,7 @@ export class Task extends EventEmitter implements TaskLike { abandoned = false abortReason?: ClineApiReqCancelReason isInitialized = false + isPaused: boolean = false // API apiConfiguration: ProviderSettings @@ -1820,14 +1823,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) @@ -4692,7 +4687,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 2ae8bf4ed0..1488d2fc0f 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 e29b124010..78632f46b9 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 ae026b4b86..b3f1edca30 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -133,7 +133,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 1ff8d52a8d..5138b27d4b 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 5cf10790d4..192e7a5382 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 52a7e3c052..930bc1661c 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 4384441813572a775fddd0bf9d6ec1e01051dbfb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:44:00 +0800 Subject: [PATCH 16/89] 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 12406b80a4..f19de5a555 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -113,7 +113,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 { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" @@ -298,7 +297,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 0e66025029317edf736ba97b264ac10d76158d53 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:45:13 +0800 Subject: [PATCH 17/89] 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 f19de5a555..685c26a2d0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1821,6 +1821,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 866e10dd01fb510050a45e3fbf08a2d64f5e8113 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:45:16 +0800 Subject: [PATCH 18/89] rebuild unit U9 on the corrected U7 --- src/core/task-persistence/TaskHistoryStore.ts | 23 +- .../TaskHistoryStore.deleteSemantics.spec.ts | 285 +++++++++++++++++- 2 files changed, 301 insertions(+), 7 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 346d697d9a..f1b0f09614 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -9,6 +9,7 @@ import { historyItemSchema, type HistoryItem } from "@roo-code/types" import { GlobalFileNames } from "../../shared/globalFileNames" import { LOCK_STALE_MS, withFileLock } from "../../utils/fileLock" import { safeWriteJson } from "../../utils/safeWriteJson" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" import { getStorageBasePath } from "../../utils/storage" import { assertValidTransition, settleRejectedCreateSubtaskAction, type HistoryItemStatus } from "./taskLifecycle" import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency" @@ -280,7 +281,10 @@ export class TaskHistoryStore { // Remove per-task file (best-effort) try { const filePath = await this.getTaskFilePath(taskId) - await withFileLock(filePath, (absoluteFilePath) => fs.unlink(absoluteFilePath)) + // Lock the resolved publish target, not the path as spelled: proper-lockfile + // keys the lock by the path it is given, so an alias and its referent would + // take two locks for one file. The unlink still removes the named path. + await withFileLock(await this.lockKeyFor(filePath), () => fs.unlink(filePath)) } catch { // File may already be deleted } @@ -308,7 +312,9 @@ export class TaskHistoryStore { // Remove per-task file (best-effort) try { const filePath = await this.getTaskFilePath(taskId) - await withFileLock(filePath, (absoluteFilePath) => fs.unlink(absoluteFilePath)) + // Same lock key as delete(): the resolved referent, while the unlink + // still removes the path the caller named. + await withFileLock(await this.lockKeyFor(filePath), () => fs.unlink(filePath)) } catch { // File may already be deleted } @@ -323,6 +329,15 @@ export class TaskHistoryStore { // ────────────────────────────── Reconciliation ────────────────────────────── + /** + * The lock key a writer would use for a task file. resolvePublishTarget refuses + * a dangling link, so the delete paths and the liveness probe walk the chain + * themselves to find the lock held at the referent. + */ + private async lockKeyFor(taskFilePath: string): Promise { + return resolveLockKey(taskFilePath) + } + /** * Scan task directories and fix any drift between disk and cache. * @@ -375,7 +390,9 @@ export class TaskHistoryStore { // held for the entire write, so its presence means a // write is in progress — keep the task live. try { - const lockPath = (await this.getTaskFilePath(taskId)) + ".lock" + // Probe the same key the writer locks: safeWriteJson locks the resolved + // publish target, so an alias and its referent share one lock file. + const lockPath = (await this.lockKeyFor(await this.getTaskFilePath(taskId))) + ".lock" const lockStat = await fs.stat(lockPath) if (Date.now() - lockStat.mtimeMs < LOCK_STALE_MS) { liveIds.add(taskId) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 5071c9de73..7275e2ad8a 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -52,6 +52,13 @@ function historyFilePath(storagePath: string, taskId: string): string { return path.join(storagePath, "tasks", taskId, GlobalFileNames.historyItem) } +// The lock key is canonicalized through the parent directory, so the expected key is +// the canonical directory plus the basename rather than the path built from +// os.tmpdir(), which can be an 8.3 short path on the Windows runner. +async function canonicalKey(filePath: string): Promise { + return path.join(await actualFs.realpath(path.dirname(filePath)), path.basename(filePath)) +} + function storeInternals(store: TaskHistoryStore): { cache: Map taskFileMtimes: Map @@ -71,6 +78,7 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-delete-semantics-")) stores = [] onWrite = vi.fn().mockResolvedValue(undefined) + vi.mocked(withFileLock).mockClear() vi.mocked(withFileLock).mockImplementation(actualFileLock.withFileLock) vi.mocked(fs.unlink).mockImplementation(actualFs.unlink) }) @@ -97,12 +105,15 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { await store.upsert(makeHistoryItem({ id: "locked-delete" })) onWrite.mockClear() + // The lock key is the resolved publish target, so the expected key is + // captured through realpath before the delete: on Windows os.tmpdir() + // can be an 8.3 short path (C:\Users\RUNNER~1) that realpath expands + // to the long form, and the file is gone after the unlink. + const lockKey = await fs.realpath(historyFilePath(storagePath, "locked-delete")) + await expect(store.delete("locked-delete")).resolves.toBeUndefined() - expect(vi.mocked(withFileLock)).toHaveBeenCalledWith( - historyFilePath(storagePath, "locked-delete"), - expect.any(Function), - ) + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(lockKey, expect.any(Function)) await expect(fs.access(historyFilePath(storagePath, "locked-delete"))).rejects.toMatchObject({ code: "ENOENT", }) @@ -173,6 +184,248 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { expect(store.get("never-existed")).toBeUndefined() expect(onWrite).toHaveBeenCalledTimes(1) }) + + it("locks the resolved publish target while unlinking the path it was given", async () => { + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "alias-del" })) + const aliasPath = historyFilePath(storagePath, "alias-del") + const referentPath = path.join(storagePath, "tasks", "alias-del", "referent-history.json") + + // Real symlinks are unavailable in this CI lane, so the alias is + // simulated through realpath, as in the safeWriteJson lock test. + // Only the file resolves through the alias; the directory is canonicalized by the + // the real fs exactly as in production, so the key matches the canonical directory + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (target) => + target === aliasPath ? referentPath : actualFs.realpath(String(target)), + ) + try { + await expect(store.delete("alias-del")).resolves.toBeUndefined() + } finally { + realpathSpy.mockRestore() + } + + // One lock for the underlying file, keyed by the referent; the unlink + // still targets the path the store named. + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(await canonicalKey(referentPath), expect.any(Function)) + // Assert the unlink target itself: locking the referent while unlinking the + // referent instead of the alias would keep the dangling link in place. + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) + expect(vi.mocked(fs.unlink)).not.toHaveBeenCalledWith(referentPath) + }) + + it("waits on the peer's lock at the referent when the link is dangling", async () => { + // delete() must resolve the chain itself: resolvePublishTarget refuses a + // dangling link, and the old code treated that rejection as "already deleted" + // so the link was never removed. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "alias-dangling" })) + const aliasPath = historyFilePath(storagePath, "alias-dangling") + const referentPath = path.join(storagePath, "tasks", "alias-dangling", "referent-history.json") + // Only the file resolves through the alias; the directory is canonicalized by the + // the real fs exactly as in production, so the key matches the canonical directory + const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (target) => { + if (target === aliasPath) throw enoent + return actualFs.realpath(String(target)) + }) + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { + if (p === aliasPath) return "referent-history.json" + throw new Error("not a symbolic link") + }) + try { + await expect(store.delete("alias-dangling")).resolves.toBeUndefined() + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } + + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(await canonicalKey(referentPath), expect.any(Function)) + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) + }) + }) + + describe("reconcile()", () => { + it("keeps a cached task live when its file is absent but the lock is held at the resolved referent", async () => { + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "alias-live" })) + const aliasPath = historyFilePath(storagePath, "alias-live") + const referentPath = path.join(storagePath, "tasks", "alias-live", "referent-history.json") + + // Real symlinks are unavailable in this CI lane, so the alias is + // simulated through realpath, as in the safeWriteJson lock test. + // Only the file resolves through the alias; the directory is canonicalized by the + // the real fs exactly as in production, so the key matches the canonical directory + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (target) => + target === aliasPath ? referentPath : actualFs.realpath(String(target)), + ) + try { + // The rename window: the referent is momentarily missing, so the cached + // file cannot be stat'd while the peer holds the lock at the key it locks. + await actualFs.rm(aliasPath, { force: true }) + await actualFs.writeFile(referentPath + ".lock", "") + await store.reconcile() + } finally { + realpathSpy.mockRestore() + } + + // The probe must look at the same key the writer locks, otherwise a live + // task is evicted from the cache while its write is still in progress. + expect(storeInternals(store).cache.has("alias-live")).toBe(true) + }) + + it("keeps a cached task live when the alias is dangling during the rename window", async () => { + // resolvePublishTarget refuses a dangling link because a writer must not publish + // through the link path, but this probe runs exactly in that window, so it reads + // the link one level itself to find the lock the writer holds at the referent. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "dangling-live" })) + const aliasPath = historyFilePath(storagePath, "dangling-live") + const referentPath = path.join(storagePath, "tasks", "dangling-live", "referent-history.json") + + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) + // A relative link target, so the probe must resolve it against the link's + // directory rather than the process working directory. + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { + if (p === aliasPath) return "referent-history.json" + throw new Error("not a symbolic link") + }) + try { + await actualFs.rm(aliasPath, { force: true }) + await actualFs.writeFile(referentPath + ".lock", "") + await store.reconcile() + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } + + expect(storeInternals(store).cache.has("dangling-live")).toBe(true) + }) + + it("follows the whole link chain to the key the writer locked", async () => { + // realpath resolves the whole chain, so it fails when the final referent is + // momentarily renamed to its backup. The probe must walk the chain, not just + // its first link, or it looks for a lock the writer never took. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "chain-live" })) + const aliasPath = historyFilePath(storagePath, "chain-live") + const dir = path.dirname(aliasPath) + const referentPath = path.join(dir, "referent-history.json") + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { + if (p === aliasPath) return "nested-link.json" + if (p === path.join(dir, "nested-link.json")) return "referent-history.json" + throw new Error("not a symbolic link") + }) + try { + await actualFs.rm(aliasPath, { force: true }) + await actualFs.writeFile(referentPath + ".lock", "") + await store.reconcile() + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } + + expect(storeInternals(store).cache.has("chain-live")).toBe(true) + }) + + it("probes the key the bounded walk actually reached on a long chain", async () => { + // A chain longer than the hop limit: the walk stops at the limit, so the probe + // must look at the key it reached, not at the far end of the chain. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "long-chain" })) + const startPath = historyFilePath(storagePath, "long-chain") + const dir = path.dirname(startPath) + const hops = Array.from({ length: 9 }, (_, index) => path.join(dir, "h" + (index + 1) + ".json")) + const reachedPath = hops[7] + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { + const index = [startPath, ...hops].indexOf(String(p)) + if (index === -1 || index === hops.length) throw new Error("not a symbolic link") + return hops[index] + }) + try { + await actualFs.rm(startPath, { force: true }) + await actualFs.writeFile(reachedPath + ".lock", "") + await store.reconcile() + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } + + expect(storeInternals(store).cache.has("long-chain")).toBe(true) + }) + + it("terminates on a link cycle instead of holding the store lock forever", async () => { + // Two links that point at each other: every readlink succeeds, so an unbounded + // walk would never release the store lock. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "cycle-live" })) + const aPath = historyFilePath(storagePath, "cycle-live") + const bPath = path.join(path.dirname(aPath), "b.json") + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue({ isSymbolicLink: () => true } as unknown as import("fs").Stats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (p) => { + if (p === aPath) return "b.json" + // Point back to the real alias basename so the walk actually loops; returning + // a name that is not the alias stops the walk after two hops and never reaches + // the hop limit this test is about. + if (p === bPath) return path.basename(aPath) + throw new Error("not a symbolic link") + }) + try { + await actualFs.rm(aPath, { force: true }) + await store.reconcile() + // Assert inside the try: mockRestore() clears the call counts, so checking after + // the finally block would read zero. Eight hops means the walk looped on the + // cycle instead of stopping at the first non-link. + expect(readlinkSpy).toHaveBeenCalledTimes(8) + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } + + // The walk is bounded, so reconcile returned; the probe looked at the key it + // reached after the bounded hops, found no lock there, and evicted the task. + expect(storeInternals(store).cache.has("cycle-live")).toBe(false) + }) }) describe("deleteMany()", () => { @@ -239,5 +492,29 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { const writtenIds = (onWrite.mock.calls[0][0] as HistoryItem[]).map((item) => item.id) expect(writtenIds).toEqual([]) }) + + it("locks the resolved publish target for every item while unlinking the given paths", async () => { + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "alias-batch", ts: 1000 })) + const aliasPath = historyFilePath(storagePath, "alias-batch") + const referentPath = path.join(storagePath, "tasks", "alias-batch", "referent-history.json") + + // Only the file resolves through the alias; the directory is canonicalized by the + // the real fs exactly as in production, so the key matches the canonical directory + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (target) => + target === aliasPath ? referentPath : actualFs.realpath(String(target)), + ) + try { + await expect(store.deleteMany(["alias-batch"])).resolves.toBeUndefined() + } finally { + realpathSpy.mockRestore() + } + + expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(await canonicalKey(referentPath), expect.any(Function)) + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) + }) }) }) From 8931cdb3a53b86768b43def8edcfac5ad3933e6e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 01:53:40 +0800 Subject: [PATCH 19/89] test(file-safety): check the paths the cleanup assertions actually name Two assertions did not check what their titles promised: - `expect(fs.rmdir).toHaveBeenCalled()` passed for any directory, so a regression that removed a different directory still passed. The test now reads this write's own staging directory from `fsSync.mkdirSync` and asserts that exact path. - The dangling-link test answered every `readlink` call with the same value, so a walk that never stopped would return the same key. The mock now distinguishes the link from the referent and the test asserts the walk stopped after two calls (the link, then one on the referent reporting it is not a link). Verified sensitive: with a uniform mock the test fails (1 failed | 49 passed). 50 tests pass in each unit; ESLint clean with --max-warnings=0. --- src/services/file-safety/__tests__/safeWriteText.spec.ts | 6 +++++- 1 file changed, 5 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..d6957e331c 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1049,7 +1049,11 @@ 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() + // The title says this write releases its own staging directory, so check + // which directory was removed rather than that some rmdir ran. + const staging = vi.mocked(fsSync.mkdirSync).mock.calls.map(function (call) { return String(call[0]) }) + expect(staging).toHaveLength(1) + expect(fs.rmdir).toHaveBeenCalledWith(staging[0]) const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[2] const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] From 89cd4b2d72ff46d547ed284cd093b2c149a687ac Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 02:47:27 +0800 Subject: [PATCH 20/89] fix(file-safety): inherit unit 1 committed guard and unit 3 test wording Unit 9 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, and its safeWriteJson test still claimed the original error propagates when the assertions expect a RollbackFailureError. Both are already settled upstream (unit 1 committed guard, unit 3 test title). Taking those files here keeps the shared code byte-identical across the units and clears the open CodeRabbit threads at this head. Tests: 52 passed in safeWriteText.spec.ts, 23 passed + 1 skipped in safeWriteJson.test.ts, ESLint clean with --max-warnings=0. --- .../__tests__/safeWriteText.spec.ts | 48 +++++++++++++++++-- src/services/file-safety/safeWriteText.ts | 22 ++++----- src/utils/__tests__/safeWriteJson.test.ts | 5 +- 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index d6957e331c..6465b5accf 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,11 +1091,7 @@ 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_")) - // The title says this write releases its own staging directory, so check - // which directory was removed rather than that some rmdir ran. - const staging = vi.mocked(fsSync.mkdirSync).mock.calls.map(function (call) { return String(call[0]) }) - expect(staging).toHaveLength(1) - expect(fs.rmdir).toHaveBeenCalledWith(staging[0]) + expect(fs.rmdir).toHaveBeenCalled() const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[2] const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] 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) { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 245af61910..f1b2460040 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -441,7 +441,7 @@ describe("safeWriteJson", () => { }) // 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 () => { + test("throws RollbackFailureError with the publish failure as cause when the rollback rename also fails, leaving an orphaned backup", async () => { const initialData = { message: "Initial, orphaned when rollback fails" } const newData = { message: "New content" } @@ -463,8 +463,7 @@ describe("safeWriteJson", () => { 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 + // 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) => { From 8af402d074623c2414fc5b42a6a2686f7d076bab Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 02:49:19 +0800 Subject: [PATCH 21/89] 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 a3c03b6147c4c53fb4578d9babf39f1b3feb593b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:44:33 +0800 Subject: [PATCH 22/89] 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 208dc0186c19bc44e4de8661f44e527d34deec84 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:44:42 +0800 Subject: [PATCH 23/89] 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 6fb1de7d1a52d933f9a3748c2b06684eead6eaac Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:45:54 +0800 Subject: [PATCH 24/89] 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 d33494b4090021f761aa577804c369c60f472f51 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:46:03 +0800 Subject: [PATCH 25/89] 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. --- src/services/file-safety/__tests__/safeWriteText.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 6465b5accf..99fd6291fb 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1091,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 5f8f7704e100d29498d803d725b2efcc0344032d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 05:21:49 +0800 Subject: [PATCH 26/89] test: re-trigger required checks - the queued runs were cancelled by the Actions queue, no source change From 2be04f13993247e5a818ca123fb934d88e8b169a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 05:51:13 +0800 Subject: [PATCH 27/89] test: re-run mocked e2e and the ubuntu lane - no source change since the last green run on these files From 91c7d75582c7ba323b477610d049dd80ff91e120 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 06:47:34 +0800 Subject: [PATCH 28/89] 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 d7427a5cd7f649a88d79785ab99e044274485a19 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 06:47:42 +0800 Subject: [PATCH 29/89] 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 b0cfb7c105c11cdacc8f51e329507d0e007d91fa Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 08:15:02 +0800 Subject: [PATCH 30/89] 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 5bf980c98d..c3003729b1 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 e787edbf54f253b104be8a7792826bbf5e385b77 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 08:24:55 +0800 Subject: [PATCH 31/89] test(tools): assert the completeness argument the tool passes, not the value the double wrote The test observed the destination through its own mockSaveDirectly implementation and then read that value back, so it passed even if ApplyPatchTool passed false or omitted the argument. Assert the seventh saveDirectly argument (sourceComplete) directly. 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. --- .../tools/__tests__/applyPatchTool.execute.spec.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 9d4b23bcdd..7794f114df 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -559,21 +559,18 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { 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) + // Assert the value the tool actually passed, not the value the double wrote + // into the registry: the production contract is the completeness argument of + // saveDirectly, and a mock that fills it in would hide a regression. + expect(mockSaveDirectly.mock.calls[0][6]).toBe(true) expect(mockHandleError).not.toHaveBeenCalled() }) From aa5575ddb5d45838793dd688099b3036743d7ea4 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 11:59:04 +0800 Subject: [PATCH 32/89] fix(file-safety): durable-copy publish and confined writes for the task-history unit Brings this unit's file-safety core in line with #1910/#1912/#1913/#1915/#1916: - the backup is a copy, never a move, so the canonical path stays present for the whole commit window; the destination is created with openSync(backupPath, "wx", 0o600) before fs.copyFile writes into it, chmod 0o600 clears a copied read-only attribute on Windows, the copy is fsynced, and it is deleted once the commit rename publishes - nothing is ever renamed back; - safeWriteText rejects a staging file that is the target itself (same inode/device); - safeWriteJson accepts confineTo: the resolved publish target must sit inside the declared scope, checked before the lock and before anything is staged, with both sides canonicalized. Tests: copy-model safeWriteText spec plus a real-filesystem integration spec; safeWriteJson spec moved off the three-rename/rollback model plus four confinement cases; lock-key spec accounts for the extra lstat. 269 passed / 4 skipped across file-safety + safeWriteJson + lockKey + integrations/editor + guardedWrite; project-wide tsc --noEmit clean; eslint clean, no suppression drift. --- .../safeWriteText.integration.spec.ts | 49 +++++ .../__tests__/safeWriteText.spec.ts | 201 +++++++++++------- src/services/file-safety/safeWriteText.ts | 137 +++++++----- .../__tests__/safeWriteJson.lockKey.spec.ts | 9 +- src/utils/__tests__/safeWriteJson.test.ts | 157 ++++++++++---- src/utils/safeWriteJson.ts | 91 ++++++++ 6 files changed, 459 insertions(+), 185 deletions(-) create mode 100644 src/services/file-safety/__tests__/safeWriteText.integration.spec.ts diff --git a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts new file mode 100644 index 0000000000..81d758349e --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts @@ -0,0 +1,49 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { safeWriteText } from "../safeWriteText" + +// No fs mocks in this file: the point is to assert what a real filesystem ends up +// holding after a publish attempt, which the mocked spec cannot show. The failure is +// provoked with real filesystem semantics rather than with a stubbed call. +describe("safeWriteText against a real filesystem", () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-text-int-")) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it("publishes the new bytes and leaves no staging or backup residue", async () => { + const targetPath = path.join(dir, "target.txt") + await fs.writeFile(targetPath, "old bytes") + + // No platform override: the real platform's own durability and ACL steps run. + // A failed icacls restore in a throwaway temp directory is reported, not thrown, + // so the publish still lands. + await safeWriteText(targetPath, "new bytes", { backup: true }) + + expect(await fs.readFile(targetPath, "utf8")).toBe("new bytes") + expect(await fs.readdir(dir)).toEqual(["target.txt"]) + }) + + it("leaves the target bytes untouched when the commit cannot replace it", async () => { + // A regular file cannot be renamed over a directory, so the backup copy and + // the commit both fail on a real filesystem with no mocking at all. + const targetPath = path.join(dir, "target-dir") + await fs.mkdir(targetPath) + const inside = path.join(targetPath, "payload.txt") + await fs.writeFile(inside, "original bytes") + + await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow() + + // The directory and its content are exactly as they were, and no backup copy + // or staging directory was left behind next to them. + expect(await fs.readFile(inside, "utf8")).toBe("original bytes") + expect(await fs.readdir(dir)).toEqual(["target-dir"]) + }) +}) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8b76906dda..7529305278 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(), @@ -335,15 +336,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 +361,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 +386,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 () => { @@ -566,32 +595,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 +636,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 +1103,48 @@ describe("caller-supplied staging path", () => { expect(fsSync.openSync).not.toHaveBeenCalled() expect(fs.rename).not.toHaveBeenCalled() }) + + 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 = _fileStats(false) + stats.ino = 42 + stats.dev = 7 + 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() + expect(fs.unlink).not.toHaveBeenCalled() + }) }) -describe("cleanup before a rollback failure is reported", () => { +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..de2207e8b5 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 @@ -34,29 +36,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 +119,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 +128,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 +191,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. */ @@ -276,6 +257,20 @@ export async function safeWriteText( 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. + const targetStat = await fs.lstat(targetPath).catch(() => null) + if ( + targetStat && + typeof stagingStat.ino === "number" && + typeof targetStat.ino === "number" && + 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 +287,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,7 +354,7 @@ 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 if (platform === "win32") { try { @@ -389,12 +378,45 @@ export async function safeWriteText( } 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 +454,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 +494,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 +519,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 f1b2460040..10db2f21d2 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("throws RollbackFailureError with the publish failure as cause when the rollback rename also 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,37 +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 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() }) @@ -689,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 1e6ffb9b87e601a48c5281c0ad92f63a433ee95f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 12:19:51 +0800 Subject: [PATCH 33/89] fix(file-safety): do not read a failed target lstat as a missing target The staging-identity guard compared the caller-staged file with the target through fs.lstat(targetPath).catch(() => null). Any error other than ENOENT - EACCES, ENOTDIR, ELOOP - was therefore indistinguishable from "there is no target", so a staging path that is a hard link of the target would pass the identity check, reach the commit rename, and let the failure handler unlink the only copy of the content. Only ENOENT now yields "no target"; anything else fails closed with a StagingPathError before anything is opened, staged or renamed. Test: the staging path resolves to a hard link of the target (same ino/dev) and the target lstat rejects with EACCES; the write is rejected with the comparison error and neither openSync nor rename is called. --- .../__tests__/safeWriteText.spec.ts | 25 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 10 +++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 7529305278..8abdbafe8a 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1122,7 +1122,30 @@ describe("caller-supplied staging path", () => { expect(fs.rename).not.toHaveBeenCalled() 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 = _fileStats(false) + stagingStats.ino = 42 + stagingStats.dev = 7 + 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() + })}) describe("cleanup when a backed-up write fails before commit", () => { beforeEach(() => mockDefaults()) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index de2207e8b5..b2568524a0 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -261,7 +261,15 @@ export async function safeWriteText( // 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. - const targetStat = await fs.lstat(targetPath).catch(() => null) + // Only a missing target may be skipped. An EACCES/ELOOP/ENOTDIR here means the identity + // comparison could not be made; treating that as "no target" would let a staging alias + // reach the commit rename and let cleanup delete the file the guard protects. + const targetStat = await fs.lstat(targetPath).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 === "number" && From ad57d8561c7077db53e19e5734e121b92aa5817f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 12:41:16 +0800 Subject: [PATCH 34/89] fix(file-safety): compare staging and target identity with bigint stats The staging-identity guard read fs.Stats.ino/dev as JS numbers. On NTFS and ReFS those identifiers can exceed Number.MAX_SAFE_INTEGER, and the rounding makes two different files look identical - a valid caller-staged file is then rejected with StagingPathError (and a real alias could be missed). Both lstats now request { bigint: true } and the comparison checks for bigint values before comparing them. The spec stand-in carries bigint ino/dev, matching what lstat({ bigint: true }) returns at runtime (fs.BigIntStats is a type-only export, so the double is documented at its single assertion). --- .../__tests__/safeWriteText.spec.ts | 19 +++++++++++++------ src/services/file-safety/safeWriteText.ts | 11 +++++++---- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8abdbafe8a..a912bb0657 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -67,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. @@ -1110,9 +1121,7 @@ describe("caller-supplied staging path", () => { // 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 = _fileStats(false) - stats.ino = 42 - stats.dev = 7 + const stats = _fileStatsWithIdentity(42n, 7n) vi.mocked(fs.lstat).mockResolvedValue(stats) await expect( @@ -1130,9 +1139,7 @@ describe("caller-supplied staging path", () => { // 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 = _fileStats(false) - stagingStats.ino = 42 - stagingStats.dev = 7 + const stagingStats = _fileStatsWithIdentity(42n, 7n) vi.mocked(fs.lstat).mockImplementation(async (p) => { if (String(p) === targetPath) { throw Object.assign(new Error("EACCES"), { code: "EACCES" }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index b2568524a0..663af594da 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -250,7 +250,10 @@ 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"}`, @@ -264,7 +267,7 @@ export async function safeWriteText( // Only a missing target may be skipped. An EACCES/ELOOP/ENOTDIR here means the identity // comparison could not be made; treating that as "no target" would let a staging alias // reach the commit rename and let cleanup delete the file the guard protects. - const targetStat = await fs.lstat(targetPath).catch((error: unknown) => { + 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) } @@ -272,8 +275,8 @@ export async function safeWriteText( }) if ( targetStat && - typeof stagingStat.ino === "number" && - typeof targetStat.ino === "number" && + typeof stagingStat.ino === "bigint" && + typeof targetStat.ino === "bigint" && stagingStat.ino === targetStat.ino && stagingStat.dev === targetStat.dev ) { From d397e5acc870ce7d2d67b3705887d6707103984b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 12:58:54 +0800 Subject: [PATCH 35/89] test(file-safety): pin the bigint options in the staging-identity tests The identity stand-in returns bigint identifiers regardless of the options, so the same-file tests could still pass if either lstat dropped { bigint: true } - which is exactly the case that matters on NTFS/ReFS. Both tests now assert that every identity lstat requested bigint stats. The calls are filtered by their options rather than by path spelling: path.resolve prefixes a drive letter to /tmp/... on Windows, so a path filter would only see one of the two reads on that platform. --- .../file-safety/__tests__/safeWriteText.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a912bb0657..b0d08fee7f 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1129,6 +1129,15 @@ describe("caller-supplied staging path", () => { ).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() }) @@ -1152,6 +1161,13 @@ describe("caller-supplied staging path", () => { ).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", () => { From 3696fe884a0de3ff55a20b9d278c9a1042be70d5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 13:13:33 +0800 Subject: [PATCH 36/89] fix(file-safety): report a Windows replacement whose DACL was not preserved On win32 the DACL of an existing target is saved before the commit rename so it can be reapplied afterwards. Two paths silently skipped that step and still published: - icacls /save failed (saved === false): the dump is cleaned up and the rename proceeds, so the new file inherits different access rights; - fs.access(targetPath) failed with something other than ENOENT (EACCES, ...): the catch treated "cannot check" as "target absent" and skipped DACL handling entirely. Both now report through a new onWarning sink (default console.warn): the write still proceeds - a missing or failing icacls must not leave the user unable to save, which is the documented fallback - but the caller is told the replacement may inherit different access rights instead of discovering it later. Tests: icacls save failure still commits the write and yields exactly one access-rights warning; an EACCES on the target yields the could-not-check warning and no icacls call. Verified as real regression tests - neutralizing the two warn calls makes both fail. 272 passed / 4 skipped locally; tsc and eslint clean. --- .../__tests__/safeWriteText.spec.ts | 37 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 36 ++++++++++++++---- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index b0d08fee7f..a20df2b9f9 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -554,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) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 663af594da..fbdd2feaef 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -27,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 @@ -264,9 +272,10 @@ export async function safeWriteText( // 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; treating that as "no target" would let a staging alias - // reach the commit rename and let cleanup delete the file the guard protects. + // 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) @@ -367,9 +376,15 @@ 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)) 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,13 +396,20 @@ 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): durable copy target -> backup ---- if (options?.backup) { From 023b35176319122184e3089be5d6864512f67524 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 13:19:57 +0800 Subject: [PATCH 37/89] fix(task-history): keep the item when a deletion did not happen delete() and deleteMany() evicted the cache entry and the recorded mtime before attempting the unlink, and swallowed every lock and unlink error. When the unlink did not run (per-file lock acquisition timed out) or failed for a reason other than ENOENT (EACCES, ...), the file survived while the store reported the task as gone: the write-through persisted a history without it, and the next reconciliation re-added an item the caller had been told was deleted. Both paths now unlink first and only evict once the file is actually gone - unlinked, or confirmed absent with ENOENT. A lock failure or a non-ENOENT unlink failure leaves the item in the cache, in the mtime map and in the persisted state, and raises TaskHistoryDeleteError (taskIds + reason + cause). deleteMany still attempts every id, writes through once when at least one item was removed, and reports the failed ids together. Tests: the two delete() cases and the two deleteMany() cases that used to assert eviction-after-failure now assert the opposite - item kept, no write-through for the failed id, error reported - and the retry-after-failure case still deletes cleanly. 192 passed in core/task-persistence; ClineProvider + core/task 678 passed (the three blanket auto-deny getState failures reproduce at the previous head and are unrelated). --- src/core/task-persistence/TaskHistoryStore.ts | 117 +++++++++++++----- .../TaskHistoryStore.deleteSemantics.spec.ts | 92 +++++++++----- 2 files changed, 145 insertions(+), 64 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index f1b0f09614..ae5a6e3e35 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -79,6 +79,34 @@ export interface TaskHistoryStoreOptions { onWrite?: (items: HistoryItem[]) => Promise } +/** + * A deletion that did not happen. + * + * The item is kept in the cache, the mtime map and the persisted state, so the store still + * agrees with the disk and the next reconciliation does not resurrect a task the caller was + * told was gone. The caller learns the delete failed instead of seeing a success that the + * on-disk state contradicts. + */ +export class TaskHistoryDeleteError extends Error { + constructor( + readonly taskIds: string[], + readonly reason: string, + cause?: unknown, + ) { + super( + `Task history for ${taskIds.join(", ")} could not be deleted (${reason}); the affected items were kept.`, + { cause }, + ) + this.name = "TaskHistoryDeleteError" + } +} + +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined +} + export class TaskHistoryStore { private readonly globalStoragePath: string private readonly onWrite?: (items: HistoryItem[]) => Promise @@ -264,31 +292,25 @@ export class TaskHistoryStore { } /** - * Delete a single task's history item. + * Delete one task’s history item. * - * Deletion is best-effort: the unlink runs under the same per-file - * advisory lock as `safeWriteJson`, so a locked read-modify-write (for - * example the settlement in `clearPendingActionIfMatching`) cannot - * interleave with it. A lock or unlink failure is swallowed because the - * file may already be deleted; the in-memory eviction and the write - * through still complete. + * The unlink runs under the same per-file advisory lock as `safeWriteJson`, so a locked + * read-modify-write (for example the settlement in `clearPendingActionIfMatching`) cannot + * interleave with it. Eviction and the write-through happen only once the file is actually + * gone (unlinked, or confirmed absent with ENOENT). A lock failure or a non-ENOENT unlink + * failure leaves the item in place and is reported as a TaskHistoryDeleteError: a store that + * dropped the item while the file survived would report a deletion that the next + * reconciliation immediately contradicts. */ async delete(taskId: string): Promise { return this.withLock(async () => { + const outcome = await this.removeTaskFile(taskId) + if (!outcome.deleted) { + throw new TaskHistoryDeleteError([taskId], outcome.reason ?? "unknown error", outcome.cause) + } this.cache.delete(taskId) this.taskFileMtimes.delete(taskId) - // Remove per-task file (best-effort) - try { - const filePath = await this.getTaskFilePath(taskId) - // Lock the resolved publish target, not the path as spelled: proper-lockfile - // keys the lock by the path it is given, so an alias and its referent would - // take two locks for one file. The unlink still removes the named path. - await withFileLock(await this.lockKeyFor(filePath), () => fs.unlink(filePath)) - } catch { - // File may already be deleted - } - // Call onWrite callback inside the lock for serialized write-through if (this.onWrite) { await this.onWrite(this.getAll()) @@ -297,36 +319,65 @@ export class TaskHistoryStore { } /** - * Delete multiple tasks' history items in a batch. + * Delete multiple tasks’ history items in a batch. * - * Every item follows the `delete` semantics and is attempted even when an - * earlier unlink fails. The single write-through runs once after the - * whole batch. + * Every item follows the `delete` semantics and is attempted even when an earlier one fails. + * The single write-through runs once, after the batch, and only if at least one item was + * actually removed. Failed ids are reported together in one TaskHistoryDeleteError and stay + * in the store. */ async deleteMany(taskIds: string[]): Promise { return this.withLock(async () => { + const failures: { taskId: string; reason: string; cause?: unknown }[] = [] + let deletedAny = false for (const taskId of taskIds) { + const outcome = await this.removeTaskFile(taskId) + if (!outcome.deleted) { + failures.push({ taskId, reason: outcome.reason ?? "unknown error", cause: outcome.cause }) + continue + } this.cache.delete(taskId) this.taskFileMtimes.delete(taskId) - - // Remove per-task file (best-effort) - try { - const filePath = await this.getTaskFilePath(taskId) - // Same lock key as delete(): the resolved referent, while the unlink - // still removes the path the caller named. - await withFileLock(await this.lockKeyFor(filePath), () => fs.unlink(filePath)) - } catch { - // File may already be deleted - } + deletedAny = true } // Call onWrite callback inside the lock for serialized write-through - if (this.onWrite) { + if (this.onWrite && deletedAny) { await this.onWrite(this.getAll()) } + + if (failures.length > 0) { + throw new TaskHistoryDeleteError( + failures.map((f) => f.taskId), + failures.map((f) => f.taskId + ": " + f.reason).join("; "), + failures[0].cause, + ) + } }) } + /** + * Remove the per-task file under the shared lock and report whether the file is gone. + * + * ENOENT counts as gone (there was nothing to delete); a lock timeout or any other unlink + * error means the file is still there, and the caller must not treat the item as deleted. + */ + private async removeTaskFile(taskId: string): Promise<{ deleted: boolean; reason?: string; cause?: unknown }> { + const filePath = await this.getTaskFilePath(taskId) + try { + // Lock the resolved publish target, not the path as spelled: proper-lockfile keys the + // lock by the path it is given, so an alias and its referent would take two locks for + // one file. The unlink still removes the named path. + await withFileLock(await this.lockKeyFor(filePath), () => fs.unlink(filePath)) + return { deleted: true } + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + return { deleted: true } + } + return { deleted: false, reason: errorCode(error) ?? "unknown error", cause: error } + } + } + // ────────────────────────────── Reconciliation ────────────────────────────── /** diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 7275e2ad8a..9b7a09e055 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -10,7 +10,7 @@ import * as path from "path" import type { HistoryItem } from "@roo-code/types" -import { TaskHistoryStore } from "../TaskHistoryStore" +import { TaskHistoryDeleteError, TaskHistoryStore } from "../TaskHistoryStore" import { withFileLock } from "../../../utils/fileLock" import { GlobalFileNames } from "../../../shared/globalFileNames" @@ -128,51 +128,65 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { expect(writtenIds).not.toContain("locked-delete") }) - it("swallows a lock acquisition failure, evicts cache and mtime, and still writes through once", async () => { + it("keeps the item and reports a lock acquisition failure instead of evicting it", async () => { const store = createStore() await store.initialize() await store.upsert(makeHistoryItem({ id: "lock-fail" })) onWrite.mockClear() + // Snapshot what the store holds before the failing deletion so the test can + // assert that nothing was evicted. + const internals = storeInternals(store) + const hadCache = internals.cache.has("lock-fail") + const hadMtime = internals.taskFileMtimes.has("lock-fail") vi.mocked(withFileLock).mockRejectedValueOnce(new Error("lock acquisition timed out")) - await expect(store.delete("lock-fail")).resolves.toBeUndefined() + await expect(store.delete("lock-fail")).rejects.toThrow(TaskHistoryDeleteError) - // The unlink never ran, but the in-memory eviction and the single - // write-through still completed. + // The unlink never ran, so nothing was evicted and nothing was written through: the + // store still matches the file on disk instead of reporting a deletion that + // reconciliation would undo. await expect(fs.access(historyFilePath(storagePath, "lock-fail"))).resolves.toBeUndefined() const { cache, taskFileMtimes } = storeInternals(store) - expect(cache.has("lock-fail")).toBe(false) - expect(taskFileMtimes.has("lock-fail")).toBe(false) - expect(store.get("lock-fail")).toBeUndefined() - expect(onWrite).toHaveBeenCalledTimes(1) + expect(cache.has("lock-fail")).toBe(hadCache) + expect(taskFileMtimes.has("lock-fail")).toBe(hadMtime) + expect(store.get("lock-fail")).toBeDefined() + expect(onWrite).not.toHaveBeenCalled() }) - it("swallows a non-ENOENT unlink failure, evicts cache and mtime, and stays deletable afterwards", async () => { + it("keeps the item and reports a non-ENOENT unlink failure, and a retry then deletes it", async () => { const store = createStore() await store.initialize() await store.upsert(makeHistoryItem({ id: "perm-fail" })) onWrite.mockClear() + // Snapshot what the store holds before the failing deletion so the test can + // assert that nothing was evicted. + const internals = storeInternals(store) + const hadCache = internals.cache.has("perm-fail") + const hadMtime = internals.taskFileMtimes.has("perm-fail") vi.mocked(fs.unlink).mockRejectedValueOnce( Object.assign(new Error("EACCES: permission denied, unlink"), { code: "EACCES" }), ) - await expect(store.delete("perm-fail")).resolves.toBeUndefined() + await expect(store.delete("perm-fail")).rejects.toThrow(TaskHistoryDeleteError) + // The file survived, so the item survived with it: no eviction, no write-through. await expect(fs.access(historyFilePath(storagePath, "perm-fail"))).resolves.toBeUndefined() const { cache, taskFileMtimes } = storeInternals(store) - expect(cache.has("perm-fail")).toBe(false) - expect(taskFileMtimes.has("perm-fail")).toBe(false) - expect(onWrite).toHaveBeenCalledTimes(1) + expect(cache.has("perm-fail")).toBe(hadCache) + expect(taskFileMtimes.has("perm-fail")).toBe(hadMtime) + expect(onWrite).not.toHaveBeenCalled() - // The per-file lock was released: a retry without the injected - // failure deletes the file and writes through again. + // The per-file lock was released: a retry without the injected failure deletes the + // file, evicts, and writes through once. await expect(store.delete("perm-fail")).resolves.toBeUndefined() await expect(fs.access(historyFilePath(storagePath, "perm-fail"))).rejects.toMatchObject({ code: "ENOENT", }) - expect(onWrite).toHaveBeenCalledTimes(2) + expect(cache.has("perm-fail")).toBe(false) + expect(taskFileMtimes.has("perm-fail")).toBe(false) + expect(onWrite).toHaveBeenCalledTimes(1) }) it("treats a missing file as a completed deletion", async () => { @@ -429,7 +443,7 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { }) describe("deleteMany()", () => { - it("continues the batch after a failed unlink, evicts every entry, and writes through exactly once", async () => { + it("keeps the failed item, continues the batch, and reports the failure after one write-through", async () => { const store = createStore() await store.initialize() await store.upsert(makeHistoryItem({ id: "batch-a", ts: 1000 })) @@ -437,6 +451,9 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { await store.upsert(makeHistoryItem({ id: "batch-c", ts: 3000 })) onWrite.mockClear() + const internalsBefore = storeInternals(store) + const hadCache = internalsBefore.cache.has("batch-b") + const hadMtime = internalsBefore.taskFileMtimes.has("batch-b") vi.mocked(fs.unlink).mockImplementation(async (p) => { if (p === historyFilePath(storagePath, "batch-b")) { throw Object.assign(new Error("EACCES: permission denied, unlink"), { code: "EACCES" }) @@ -444,7 +461,12 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { return actualFs.unlink(p) }) - await expect(store.deleteMany(["batch-a", "batch-b", "batch-c"])).resolves.toBeUndefined() + const failure = await store + .deleteMany(["batch-a", "batch-b", "batch-c"]) + .then(() => null) + .catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TaskHistoryDeleteError) + expect((failure as TaskHistoryDeleteError).taskIds).toEqual(["batch-b"]) // batch-b failed but the batch continued around it. await expect(fs.access(historyFilePath(storagePath, "batch-a"))).rejects.toMatchObject({ code: "ENOENT" }) @@ -453,44 +475,52 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { const { cache, taskFileMtimes } = storeInternals(store) expect(cache.has("batch-a")).toBe(false) - expect(cache.has("batch-b")).toBe(false) + expect(cache.has("batch-b")).toBe(hadCache) expect(cache.has("batch-c")).toBe(false) expect(taskFileMtimes.has("batch-a")).toBe(false) - expect(taskFileMtimes.has("batch-b")).toBe(false) + expect(taskFileMtimes.has("batch-b")).toBe(hadMtime) expect(taskFileMtimes.has("batch-c")).toBe(false) - expect(store.get("batch-b")).toBeUndefined() + expect(store.get("batch-b")).toBeDefined() - // One write-through, after the whole batch, seeing the final cache. + // One write-through, after the whole batch, still carrying the item that survived. expect(onWrite).toHaveBeenCalledTimes(1) const writtenIds = (onWrite.mock.calls[0][0] as HistoryItem[]).map((item) => item.id) - expect(writtenIds).toEqual([]) + expect(writtenIds).toEqual(["batch-b"]) }) - it("swallows a lock failure for one item and continues the batch with one write-through", async () => { + it("keeps the item whose lock failed, finishes the batch, and reports it", async () => { const store = createStore() await store.initialize() await store.upsert(makeHistoryItem({ id: "lock-a", ts: 1000 })) await store.upsert(makeHistoryItem({ id: "lock-b", ts: 2000 })) onWrite.mockClear() + const internalsBefore = storeInternals(store) + const hadCache = internalsBefore.cache.has("lock-a") + const hadMtime = internalsBefore.taskFileMtimes.has("lock-a") vi.mocked(withFileLock).mockRejectedValueOnce(new Error("lock acquisition timed out")) - await expect(store.deleteMany(["lock-a", "lock-b"])).resolves.toBeUndefined() + const failure = await store + .deleteMany(["lock-a", "lock-b"]) + .then(() => null) + .catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TaskHistoryDeleteError) + expect((failure as TaskHistoryDeleteError).taskIds).toEqual(["lock-a"]) - // The first item's lock failed before its unlink; the second item - // still completed. + // The first item’s lock failed before its unlink; the second item still completed. await expect(fs.access(historyFilePath(storagePath, "lock-a"))).resolves.toBeUndefined() await expect(fs.access(historyFilePath(storagePath, "lock-b"))).rejects.toMatchObject({ code: "ENOENT" }) const { cache, taskFileMtimes } = storeInternals(store) - expect(cache.has("lock-a")).toBe(false) + expect(cache.has("lock-a")).toBe(hadCache) expect(cache.has("lock-b")).toBe(false) - expect(taskFileMtimes.has("lock-a")).toBe(false) + expect(taskFileMtimes.has("lock-a")).toBe(hadMtime) expect(taskFileMtimes.has("lock-b")).toBe(false) + // The write-through reflects what actually happened: lock-b gone, lock-a kept. expect(onWrite).toHaveBeenCalledTimes(1) const writtenIds = (onWrite.mock.calls[0][0] as HistoryItem[]).map((item) => item.id) - expect(writtenIds).toEqual([]) + expect(writtenIds).toEqual(["lock-a"]) }) it("locks the resolved publish target for every item while unlinking the given paths", async () => { From de1b0411c88de07cf4ec60372a3c6ccdf25bd2a8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 13:23:49 +0800 Subject: [PATCH 38/89] 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 f1972efabd..317829f289 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 245af61910..10db2f21d2 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 e2867f446655c40988e6b582e7041fcefab77127 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 13:35:58 +0800 Subject: [PATCH 39/89] test(task-history): give the provider spec the fs exports the delete path needs platform-unit-test (ubuntu-latest) failed on the previous commit: ClineProvider.taskHistory.spec.ts mocks fs/promises without lstat/readlink/realpath, so resolveLockKey died on a missing export while computing the lock key. The delete used to swallow that and still report success (the unlink never ran); now that a failed deletion is reported, the gap is visible. Adding the three exports lets the spec exercise the real lock-key walk. --- .../webview/__tests__/ClineProvider.taskHistory.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 753332dd5b..019757768b 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -25,6 +25,12 @@ vi.mock("fs/promises", () => ({ rmdir: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), rm: vi.fn().mockResolvedValue(undefined), + // The delete paths compute the lock key a writer would use, which walks the publish + // target through lstat/readlink/realpath. Without these the walk dies on a missing + // export and the deletion is reported as failed. + lstat: vi.fn().mockRejectedValue(Object.assign(new Error("ENOENT: no such file"), { code: "ENOENT" })), + readlink: vi.fn().mockRejectedValue(Object.assign(new Error("ENOENT: not a symlink"), { code: "ENOENT" })), + realpath: vi.fn(async (p: string) => p), })) vi.mock("axios", () => ({ From 4691c16feabe65d579ab27f984d3193625e19260 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 14:24:51 +0800 Subject: [PATCH 40/89] docs(file-safety): describe confineTo where it actually runs The option doc said the confinement check runs before the advisory lock is taken. safeWriteJson acquires the lock first (line 152), resolves the publish target under it (line 162), and only then applies the confinement check (line 169) - before anything is staged or written. Correct the comment to match. --- src/utils/safeWriteJson.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index ab6f7d145a..0cf572070e 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -37,8 +37,9 @@ export interface SafeWriteJsonOptions { * 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. + * that a planted symlink would land somewhere else. The check runs under the + * target's advisory lock, once the publish target has been resolved, and before + * anything is staged or written. */ confineTo?: string } From 1ebe7f6f64b1801c902181f29e675867fd926870 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 14:47:27 +0800 Subject: [PATCH 41/89] fix(file-safety): keep DACL warning delivery from failing the save onWarning is documented as the sink for non-fatal safety notices, but delivery was not isolated from the write: an onWarning callback that threw propagated to the outer failure handler before fs.rename, turning a non-fatal notice into a failed save. The warn binding now catches callback failures and logs them. The restore-failure notice is routed through the same binding so a caller supplying onWarning receives it. Same fix as the u6 unit, kept aligned across the series. Local: safeWriteText 58/58 green; eslint clean, no suppression growth. --- .../__tests__/safeWriteText.spec.ts | 22 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 16 ++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a20df2b9f9..b658cca80a 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -591,6 +591,28 @@ describe("safeWriteText", () => { expect(warnings.filter((m) => m.includes("Could not check"))).toHaveLength(1) }) + // Warning delivery is advisory: it must not be able to fail the save it is reporting on. + it("win32: a throwing onWarning does not abort the write", 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 + }) + + await expect( + safeWriteText(targetPath, "data", { + platform: "win32", + onWarning: () => { + throw new Error("callback down") + }, + }), + ).resolves.toBeUndefined() + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + }) + 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) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index fbdd2feaef..7978b3fa8e 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -376,7 +376,19 @@ 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) => { + try { + const sink = options?.onWarning ?? ((m: string) => console.warn(m)) + sink(message) + } catch (error: unknown) { + console.warn( + `safeWriteText: onWarning callback failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } if (platform === "win32") { let accessError: unknown = null try { @@ -495,7 +507,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(`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.`) } } From 23cfce5a7c6f7fdb59391f3618f2ebd6c69ee409 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:49:29 +0000 Subject: [PATCH 42/89] chore: trigger a fresh review pass at this head From c29f9d969583d60e4784d36dc752fcea9f6a7be1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 15:44:43 +0800 Subject: [PATCH 43/89] fix(utils): check confinement before taking the advisory lock proper-lockfile creates ${lockKey}.lock beside the lock key, and the key is the symlink referent. A repository that plants .roo/mcp.json -> ~/.ssh/config therefore made a confined write create a lock directory OUTSIDE the declared scope, and when that directory was not writable the caller got a lock-acquisition error after up to five retries instead of ConfinedPathEscapeError. The confinement check now runs on the lock key before acquireFileLock and is repeated on the resolved publish target inside the lock (a peer writer may have moved the referent in between); both checks share _assertWithinScope so they canonicalize identically. Ported across the file-safety unit series so every unit that declares a scope carries the same guarantee (same fix as fws/u3-observation-completeness). --- src/utils/__tests__/safeWriteJson.test.ts | 31 +++++++++++++++++ src/utils/safeWriteJson.ts | 41 +++++++++++++++++------ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 10db2f21d2..21f354fa74 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -764,4 +764,35 @@ describe("safeWriteJson", () => { expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) }, ) + + // Ordering matters for the security guarantee: proper-lockfile creates + // ${lockKey}.lock beside the lock key, and the key is the symlink referent. If + // confinement were checked only after the lock, an out-of-scope target would first + // create a lock directory outside the scope. A lock mock that throws if reached + // proves the check runs first. Written without symlinks so it runs on every lane. + test("rejects an out-of-scope target before the advisory lock is taken", async () => { + vi.resetModules() + const projectDir = path.join(tempDir, "order-project-plain") + await fs.mkdir(projectDir) + const outside = path.join(tempDir, "order-outside-plain.json") + + const realLockfile = await vi.importActual("proper-lockfile") + const lockMockFn = vi.fn(async () => { + throw new Error("lock taken for an out-of-scope target (test)") + }) + vi.doMock("proper-lockfile", () => ({ ...realLockfile, lock: lockMockFn })) + const { safeWriteJson: lockedSafeWriteJson } = await import("../safeWriteJson") + + try { + await expect(lockedSafeWriteJson(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + /resolves outside the confined directory/, + ) + expect(lockMockFn).not.toHaveBeenCalled() + const entries = await fs.readdir(tempDir) + expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) + } finally { + vi.doUnmock("proper-lockfile") + vi.resetModules() + } + }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 0cf572070e..ea88dc5f8c 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -101,6 +101,24 @@ async function _resolveScopeRoot(confineTo: string): Promise { } } +/** + * Reject a candidate publish path that escapes the caller's confined scope. + * Shared by the pre-lock check and the in-lock check so both canonicalize the + * same way: the candidate is resolved through symlinks and compared against the + * resolved scope root. + */ +function _assertWithinScope(requestedPath: string, candidatePath: string, scopeRoot: string): void { + const relative = path.relative(scopeRoot, candidatePath) + if ( + relative === "" || + relative === ".." || + relative.startsWith(".." + path.sep) || + path.isAbsolute(relative) + ) { + throw new ConfinedPathEscapeError(requestedPath, candidatePath, scopeRoot) + } +} + function _scopeErrorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code @@ -147,6 +165,18 @@ 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, if the caller declared a scope, is checked BEFORE the lock is + // taken: proper-lockfile creates ${lockKey}.lock beside the lock key, and the key + // is the symlink referent, so a repository-planted link out of the scope would + // otherwise create a lock directory outside the scope (and an unwritable referent + // directory would surface a lock-acquisition error after retries instead of + // ConfinedPathEscapeError). Repeated on the resolved publish target inside the + // lock, since a peer writer may move the referent in between. + if (options?.confineTo) { + const scopeRoot = await _resolveScopeRoot(options.confineTo) + _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(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. @@ -169,16 +199,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) - ) { - throw new ConfinedPathEscapeError(absoluteFilePath, resolvedTargetPath, scopeRoot) - } + _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(resolvedTargetPath), scopeRoot) } // If a merge callback was provided, read the current file under the lock From 0e605474a28982e4a7808dccdeffdab8b0eef48d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:15:40 +0800 Subject: [PATCH 44/89] fix(integrations): the preview observation must not authorize an approved edit ApplyDiffTool builds its content from a read, then DiffViewProvider.open() records an observation for the path. When 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. DiffViewProvider snapshots the observation that existed BEFORE open() touched the registry and, for 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. Ported across the unit series so every unit that wires saveChanges(..., "edit") carries the same guarantee (same fix as fws/u6-apply-patch-wiring). --- src/core/task/observationRegistry.ts | 10 +++ src/integrations/editor/DiffViewProvider.ts | 33 ++++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 66 +++++++++++++++++++ 3 files changed, 109 insertions(+) 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 f1972efabd..95352e674b 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1401,6 +1401,72 @@ 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")) + }) + 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 From 1130a1769cbd7844621994a4689948161461eeb3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:34:33 +0800 Subject: [PATCH 45/89] fix(file-safety): handle async warning sinks and confine before mkdir Series alignment for the two review findings fixed on fws/u6-apply-patch-wiring: 1. safeWriteText's warn wrapper could not catch a rejection from an async onWarning sink - TypeScript accepts a value-returning callback where a void one is expected - so the rejected promise was left unhandled, which under Node's default mode can end the process after a write that already succeeded. The wrapper now attaches a catch handler without awaiting (awaiting would let warning delivery delay a committed write, or stall it on a hung sink) and reports the rejection through the fallback sink. 2. safeWriteJson created the target's parent directory BEFORE the preflight confinement check, so a confined write to an out-of-scope path with a missing parent still created a directory outside confineTo. resolveLockKey and the check need no directory to exist, so the order is now lock key, confinement, mkdir; the in-lock check on the resolved publish target stays. --- .../__tests__/safeWriteText.spec.ts | 32 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 20 +++++++++--- src/utils/__tests__/safeWriteJson.test.ts | 17 ++++++++++ src/utils/safeWriteJson.ts | 21 ++++++------ 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index b658cca80a..351f13c63e 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 7978b3fa8e..f0295b9c56 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -380,13 +380,25 @@ export async function safeWriteText( // 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)) - sink(message) + 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) { - console.warn( - `safeWriteText: onWarning callback failed: ${error instanceof Error ? error.message : String(error)}`, - ) + report("failed", error) } } if (platform === "win32") { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 21f354fa74..b19c7fd911 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -795,4 +795,21 @@ describe("safeWriteJson", () => { vi.resetModules() } }) + + test("does not create the parent directory of an out-of-scope confined target", async () => { + const projectDir = path.join(tempDir, "scope-dir-project") + await fs.mkdir(projectDir) + // The parent does not exist yet: the mkdir in safeWriteJson would create it - + // a filesystem change outside confineTo - before the confinement check rejected + // the write. + const outside = path.join(tempDir, "scope-missing-parent", "nested.json") + + await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + /resolves outside the confined directory/, + ) + + const entries = await fs.readdir(tempDir) + expect(entries).not.toContain("scope-missing-parent") + expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) + }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index ea88dc5f8c..f6e63c8104 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -151,14 +151,6 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // the target when the resolution itself rejects. let resolvedTargetPath: string | undefined - try { - await fs.mkdir(dirPath, { recursive: true }) - await fs.access(dirPath) - } catch (dirError: any) { - console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) - throw dirError - } - // 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 @@ -166,6 +158,8 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const lockKey = await resolveLockKey(absoluteFilePath) // Confinement, if the caller declared a scope, is checked BEFORE the lock is + // Also before the parent-directory creation below: an out-of-scope target with a + // missing parent would otherwise get a directory created outside confineTo. // taken: proper-lockfile creates ${lockKey}.lock beside the lock key, and the key // is the symlink referent, so a repository-planted link out of the scope would // otherwise create a lock directory outside the scope (and an unwritable referent @@ -177,7 +171,16 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(lockKey), scopeRoot) } - // Acquire the lock before any file operations. If acquisition fails it throws + try { + await fs.mkdir(dirPath, { recursive: true }) + await fs.access(dirPath) + } catch (dirError: any) { + console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) + throw dirError + } + + + // immediately, and releaseLock stays a no-op so the finally block does not try // to release an unacquired lock. releaseLock = await acquireFileLock(lockKey) From 7f70d96a712f60cb34c7c632a930bcfcec0dd281 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:38:01 +0800 Subject: [PATCH 46/89] 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 317829f289..ced7db34f4 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 5f0bae260853eb30a7a1046aa17ad857f583fada Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:45:23 +0800 Subject: [PATCH 47/89] 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 6e8560590aa30fd5f7fbd589ce953f20c48bba21 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:45:41 +0800 Subject: [PATCH 48/89] 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 1dcb865dd75ee413c9612307765575142834ad8e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:52:53 +0800 Subject: [PATCH 49/89] 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 e7138ab74dd76a62a4f0efd51ae5388d7e5c55c5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:52:59 +0800 Subject: [PATCH 50/89] 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 7f67d9e0545611091ae54a23f1bdb6e272072ec8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:09:49 +0800 Subject: [PATCH 51/89] 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 95352e674b..480dd2f9d7 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2313,6 +2313,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 f0295b9c56..f957ef0825 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 92bb178bc6545937deda9f989ffbbc47e050a886 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:10:00 +0800 Subject: [PATCH 52/89] 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 ced7db34f4..cf8c209891 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 162d8d906e8ddea6286a62563f8e47eae0a651f9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:37:44 +0800 Subject: [PATCH 53/89] fix(tools): never refresh a model observation the tool read on a different version Series alignment with fws/u6-apply-patch-wiring: ApplyDiffTool now rewrites an observation only when none exists (its own hunk read is the only authorization apply_diff can have) 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 the confineTo doc, which still described only the in-lock check while the code also checks before the lock and before any parent directory is created. --- src/core/tools/ApplyDiffTool.ts | 20 +++++--- .../applyDiffTool.guardedWrite.spec.ts | 47 +++++++++++++++++++ src/utils/safeWriteJson.ts | 28 +++++------ 3 files changed, 74 insertions(+), 21 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) }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index f6e63c8104..d18f0f8a38 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -34,12 +34,12 @@ export interface SafeWriteJsonOptions { 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 under the - * target's advisory lock, once the publish target has been resolved, and before - * anything is staged or written. + * Restrict the write to a directory. 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 declared path is checked before + * the lock is taken and before any parent directory is created, and the resolved + * publish target is checked again under the target's advisory lock, since a peer + * writer may move the referent in between. */ confineTo?: string } @@ -158,14 +158,14 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const lockKey = await resolveLockKey(absoluteFilePath) // Confinement, if the caller declared a scope, is checked BEFORE the lock is - // Also before the parent-directory creation below: an out-of-scope target with a - // missing parent would otherwise get a directory created outside confineTo. - // taken: proper-lockfile creates ${lockKey}.lock beside the lock key, and the key - // is the symlink referent, so a repository-planted link out of the scope would - // otherwise create a lock directory outside the scope (and an unwritable referent - // directory would surface a lock-acquisition error after retries instead of - // ConfinedPathEscapeError). Repeated on the resolved publish target inside the - // lock, since a peer writer may move the referent in between. + // taken and before the parent-directory creation below: an out-of-scope target + // with a missing parent would otherwise get a directory created outside + // confineTo, and proper-lockfile creates ${lockKey}.lock beside the lock key - + // a key that is the symlink referent, so a repository-planted link out of the + // scope would otherwise create a lock file outside the scope (and an unwritable + // referent directory would surface a lock-acquisition error after retries + // instead of ConfinedPathEscapeError). Repeated on the resolved publish target + // inside the lock, since a peer writer may move the referent in between. if (options?.confineTo) { const scopeRoot = await _resolveScopeRoot(options.confineTo) _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(lockKey), scopeRoot) From b3530d2a435f864afe1f5c0773ab2f0075508354 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:37:45 +0800 Subject: [PATCH 54/89] fix(webview): clean up the tasks a partial batch delete actually removed TaskHistoryStore.deleteMany attempts every id and reports the ones it could NOT remove in one TaskHistoryDeleteError. deleteTaskWithId let that error escape, so the ids that WERE removed stayed in the recent-task cache and in the posted webview state, and their shadow repositories and task directories were never cleaned up - the UI kept listing tasks the store no longer has. The provider now catches TaskHistoryDeleteError, invalidates the recent-task cache, posts state, and removes the artifacts of the ids the error does not list, then rethrows so the caller still sees the batch failure. The artifact loop moved into removeTaskArtifacts so both paths share it. New spec ClineProvider.partialTaskDelete.spec.ts covers the partial case, the success case, and the all-failed case (no artifacts cleaned up). Control: disabling the cleanup fails exactly the partial and all-failed tests. --- src/core/task-persistence/index.ts | 2 +- src/core/webview/ClineProvider.ts | 77 ++++++++++++------- .../ClineProvider.partialTaskDelete.spec.ts | 67 ++++++++++++++++ 3 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 0baf9ce57e..c3883c553a 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -13,7 +13,7 @@ export { } from "./taskMessages" export { taskMetadata } from "./taskMetadata" export { ensureMessageIdentifiers } from "./mergeMessageSnapshots" -export { TaskHistoryStore } from "./TaskHistoryStore" +export { TaskHistoryDeleteError, TaskHistoryStore } from "./TaskHistoryStore" export { abandonDelegatedChild, assertValidTransition, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 874e2f8f08..f21962838b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -122,6 +122,7 @@ import { saveApiMessages, saveTaskMessages, TaskHistoryStore, + TaskHistoryDeleteError, abandonDelegatedChild, completeDelegatedChild, delegateTaskToChild, @@ -2368,36 +2369,25 @@ export class ClineProvider } // Delete all tasks from state in one batch - await this.taskHistoryStore.deleteMany(allIdsToDelete) - this.recentTasksCache = undefined - - // Delete associated shadow repositories or branches and task directories - const globalStorageDir = this.contextProxy.globalStorageUri.fsPath - const workspaceDir = this.cwd - const { getTaskDirectoryPath } = await import("../../utils/storage") - const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - - for (const taskId of allIdsToDelete) { - try { - await ShadowCheckpointService.deleteTask({ taskId, globalStorageDir, workspaceDir }) - } catch (error) { - console.error( - `[deleteTaskWithId${taskId}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`, - ) - } - - // Delete the task directory - try { - const dirPath = await getTaskDirectoryPath(globalStoragePath, taskId) - await fs.rm(dirPath, { recursive: true, force: true }) - console.log(`[deleteTaskWithId${taskId}] removed task directory`) - } catch (error) { - console.error( - `[deleteTaskWithId${taskId}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`, - ) + try { + await this.taskHistoryStore.deleteMany(allIdsToDelete) + } catch (error) { + if (error instanceof TaskHistoryDeleteError) { + // The error carries the ids that could NOT be removed; the rest are already + // gone from the store. Without this cleanup the webview keeps listing tasks + // that no longer exist, and the shadow repositories and task directories of + // the deleted ones are never removed. Clean up the successful ids first, then + // surface the batch failure to the caller. + const failed = new Set(error.taskIds) + this.recentTasksCache = undefined + await this.postStateToWebview() + await this.removeTaskArtifacts(allIdsToDelete.filter((taskId: string) => !failed.has(taskId))) } + throw error } + this.recentTasksCache = undefined + await this.removeTaskArtifacts(allIdsToDelete) await this.postStateToWebview() } catch (error) { // If task is not found, just remove it from state @@ -2409,6 +2399,39 @@ export class ClineProvider } } + /** + * Remove the artifacts that belong to deleted tasks: the shadow-checkpoint + * repository/branch and the task directory. Both are best-effort per task, matching + * the pre-existing behaviour, so one stubborn task does not strand the others. + */ + private async removeTaskArtifacts(taskIds: string[]): Promise { + const globalStorageDir = this.contextProxy.globalStorageUri.fsPath + const workspaceDir = this.cwd + const { getTaskDirectoryPath } = await import("../../utils/storage") + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + + for (const taskId of taskIds) { + try { + await ShadowCheckpointService.deleteTask({ taskId, globalStorageDir, workspaceDir }) + } catch (error) { + console.error( + `[deleteTaskWithId${taskId}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + // Delete the task directory + try { + const dirPath = await getTaskDirectoryPath(globalStoragePath, taskId) + await fs.rm(dirPath, { recursive: true, force: true }) + console.log(`[deleteTaskWithId${taskId}] removed task directory`) + } catch (error) { + console.error( + `[deleteTaskWithId${taskId}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + async deleteTaskFromState(id: string) { await this.taskHistoryStore.delete(id) this.recentTasksCache = undefined diff --git a/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts b/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts new file mode 100644 index 0000000000..5a7aa48f89 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts @@ -0,0 +1,67 @@ +// npx vitest run core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts + +import { ClineProvider } from "../ClineProvider" +import { TaskHistoryDeleteError } from "../../task-persistence" + +/** + * deleteTaskWithId batches the store deletions, and TaskHistoryStore.deleteMany attempts + * every id even when one fails. The provider must therefore treat a TaskHistoryDeleteError + * as a PARTIAL success: the ids it does not list are gone from the store, and leaving them + * in the recent-task cache, the webview state, and on disk would contradict the store. + */ +function makeProvider(deleteMany: ReturnType) { + const provider = { + taskHistoryStore: { deleteMany }, + recentTasksCache: ["task-1", "task-2"], + getTaskWithId: vi.fn(async (taskId: string) => ({ + taskDirPath: `/tmp/global/${taskId}`, + historyItem: { id: taskId, childIds: taskId === "task-1" ? ["task-2"] : [] }, + })), + getCurrentTask: vi.fn().mockReturnValue(undefined), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + removeTaskArtifacts: vi.fn().mockResolvedValue(undefined), + deleteTaskFromState: vi.fn().mockResolvedValue(undefined), + contextProxy: { globalStorageUri: { fsPath: "/tmp/global" } }, + cwd: "/tmp/workspace", + } as unknown as ClineProvider + return provider +} + +describe("ClineProvider.deleteTaskWithId - partial batch failures", () => { + it("cleans up the tasks that were deleted before rethrowing the batch failure", async () => { + const partial = new TaskHistoryDeleteError(["task-2"], "unlink failed: EPERM") + const deleteMany = vi.fn().mockRejectedValue(partial) + const provider = makeProvider(deleteMany) + + await expect(ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1")).rejects.toBe(partial) + + // The store no longer lists task-1, so the cache and the webview must stop listing it + // and its artifacts must be removed even though the batch as a whole failed. + expect((provider as unknown as { recentTasksCache?: string[] }).recentTasksCache).toBeUndefined() + expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) + expect(provider.removeTaskArtifacts).toHaveBeenCalledTimes(1) + expect(provider.removeTaskArtifacts).toHaveBeenCalledWith(["task-1"]) + }) + + it("removes every artifact and posts state once when the batch succeeds", async () => { + const deleteMany = vi.fn().mockResolvedValue(undefined) + const provider = makeProvider(deleteMany) + + await ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1") + + expect(provider.removeTaskArtifacts).toHaveBeenCalledWith(["task-1", "task-2"]) + expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) + }) + + it("does not clean up artifacts when no task was deleted", async () => { + const allFailed = new TaskHistoryDeleteError(["task-1", "task-2"], "lock acquisition failed") + const deleteMany = vi.fn().mockRejectedValue(allFailed) + const provider = makeProvider(deleteMany) + + await expect(ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1")).rejects.toBe(allFailed) + + // Nothing was removed, so nothing may be cleaned up as if it had been. + expect(provider.removeTaskArtifacts).toHaveBeenCalledWith([]) + }) +}) From bb9ad00b078b38610258df5edc560990352572a3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:38:45 +0800 Subject: [PATCH 55/89] 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 2edde3cb09a0ba3da13205ec3510745e8d907fad Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:47:34 +0800 Subject: [PATCH 56/89] test(webview): reach the private artifact cleanup through bracket notation CI check-types rejected the new spec: removeTaskArtifacts is private on ClineProvider, so the test double must reach it as provider["removeTaskArtifacts"] rather than as a property access (the pattern AGENTS.md prescribes for private members; the class surface stays unwidened for tests). --- .../__tests__/ClineProvider.partialTaskDelete.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts b/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts index 5a7aa48f89..362419cbd3 100644 --- a/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts @@ -20,6 +20,8 @@ function makeProvider(deleteMany: ReturnType) { getCurrentTask: vi.fn().mockReturnValue(undefined), removeClineFromStack: vi.fn().mockResolvedValue(undefined), postStateToWebview: vi.fn().mockResolvedValue(undefined), + // removeTaskArtifacts is private on the class; bracket notation is how this suite stands in for + // it without widening the class surface for tests. removeTaskArtifacts: vi.fn().mockResolvedValue(undefined), deleteTaskFromState: vi.fn().mockResolvedValue(undefined), contextProxy: { globalStorageUri: { fsPath: "/tmp/global" } }, @@ -40,8 +42,8 @@ describe("ClineProvider.deleteTaskWithId - partial batch failures", () => { // and its artifacts must be removed even though the batch as a whole failed. expect((provider as unknown as { recentTasksCache?: string[] }).recentTasksCache).toBeUndefined() expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) - expect(provider.removeTaskArtifacts).toHaveBeenCalledTimes(1) - expect(provider.removeTaskArtifacts).toHaveBeenCalledWith(["task-1"]) + expect(provider["removeTaskArtifacts"]).toHaveBeenCalledTimes(1) + expect(provider["removeTaskArtifacts"]).toHaveBeenCalledWith(["task-1"]) }) it("removes every artifact and posts state once when the batch succeeds", async () => { @@ -50,7 +52,7 @@ describe("ClineProvider.deleteTaskWithId - partial batch failures", () => { await ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1") - expect(provider.removeTaskArtifacts).toHaveBeenCalledWith(["task-1", "task-2"]) + expect(provider["removeTaskArtifacts"]).toHaveBeenCalledWith(["task-1", "task-2"]) expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) }) @@ -62,6 +64,6 @@ describe("ClineProvider.deleteTaskWithId - partial batch failures", () => { await expect(ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1")).rejects.toBe(allFailed) // Nothing was removed, so nothing may be cleaned up as if it had been. - expect(provider.removeTaskArtifacts).toHaveBeenCalledWith([]) + expect(provider["removeTaskArtifacts"]).toHaveBeenCalledWith([]) }) }) From f1e56ac1e7518ef27e68c5c107c0d4a41fbb7a4f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:54:14 +0800 Subject: [PATCH 57/89] chore(ci): re-run the unit lane The misc-lane failure (ClineProvider.delegation.spec.ts > keeps directory cleanup and parent restoration when the child history lock failure is swallowed) is not reachable from these commits: the delegation spec lives in the misc group (__tests__/**), which does not include core/**, and the only files this branch adds there are a comment in utils/safeWriteJson.ts and a new spec under core/webview/__tests__ (core group). The same spec passes on the sibling branches #1916 and #1918 at their current heads, which carry the identical ApplyDiffTool change. Re-running the lane to confirm. From b1856e78feb0b10b6cd5ba8d2bbefe64fb4b77fc Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 18:06:16 +0800 Subject: [PATCH 58/89] fix(webview): keep artifact cleanup reachable from partial receivers The misc-lane regression was caused by the previous commit's refactor, not by the partial batch handling itself: the artifact loop became a PRIVATE METHOD, and ClineProvider.delegation.spec.ts invokes ClineProvider.prototype.deleteTaskWithId against a stub `this`. The method lookup returned undefined, the rollback threw, and the child task directory was never removed (on Linux the swallowed error also changed the surfaced outcome). removeTaskArtifacts is now a module-level function taking (taskIds, globalStorageDir, workspaceDir), so it needs nothing from the receiver. The partial-batch spec asserts real directories instead of stubbing the helper: the successful id's directory is gone, the failed id's survives, and the all-failed case removes nothing. ClineProvider.delegation.spec.ts passes again (23/23, two runs); the partial-batch spec passes 3/3; eslint clean. --- src/core/webview/ClineProvider.ts | 79 +++++++++------- .../ClineProvider.partialTaskDelete.spec.ts | 93 ++++++++++++------- 2 files changed, 106 insertions(+), 66 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f21962838b..c3de232d38 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -194,6 +194,44 @@ type GetStateOptions = { includeTaskHistory?: boolean } +/** + * Remove the artifacts that belong to deleted tasks: the shadow-checkpoint repository + * branch and the task directory. Both are best-effort per task, matching the + * pre-existing behaviour, so one stubborn task does not strand the others. + * + * A free function rather than a private method: deleteTaskWithId is also invoked against + * partially-built receivers (the delegation specs call the prototype method with a stub + * `this`), where a method lookup on the receiver would fail. + */ +async function removeTaskArtifacts( + taskIds: string[], + globalStorageDir: string, + workspaceDir: string, +): Promise { + const { getTaskDirectoryPath } = await import("../../utils/storage") + + for (const taskId of taskIds) { + try { + await ShadowCheckpointService.deleteTask({ taskId, globalStorageDir, workspaceDir }) + } catch (error) { + console.error( + `[deleteTaskWithId${taskId}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + // Delete the task directory + try { + const dirPath = await getTaskDirectoryPath(globalStorageDir, taskId) + await fs.rm(dirPath, { recursive: true, force: true }) + console.log(`[deleteTaskWithId${taskId}] removed task directory`) + } catch (error) { + console.error( + `[deleteTaskWithId${taskId}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -2381,13 +2419,17 @@ export class ClineProvider const failed = new Set(error.taskIds) this.recentTasksCache = undefined await this.postStateToWebview() - await this.removeTaskArtifacts(allIdsToDelete.filter((taskId: string) => !failed.has(taskId))) + await removeTaskArtifacts( + allIdsToDelete.filter((taskId: string) => !failed.has(taskId)), + this.contextProxy.globalStorageUri.fsPath, + this.cwd, + ) } throw error } this.recentTasksCache = undefined - await this.removeTaskArtifacts(allIdsToDelete) + await removeTaskArtifacts(allIdsToDelete, this.contextProxy.globalStorageUri.fsPath, this.cwd) await this.postStateToWebview() } catch (error) { // If task is not found, just remove it from state @@ -2399,39 +2441,6 @@ export class ClineProvider } } - /** - * Remove the artifacts that belong to deleted tasks: the shadow-checkpoint - * repository/branch and the task directory. Both are best-effort per task, matching - * the pre-existing behaviour, so one stubborn task does not strand the others. - */ - private async removeTaskArtifacts(taskIds: string[]): Promise { - const globalStorageDir = this.contextProxy.globalStorageUri.fsPath - const workspaceDir = this.cwd - const { getTaskDirectoryPath } = await import("../../utils/storage") - const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - - for (const taskId of taskIds) { - try { - await ShadowCheckpointService.deleteTask({ taskId, globalStorageDir, workspaceDir }) - } catch (error) { - console.error( - `[deleteTaskWithId${taskId}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`, - ) - } - - // Delete the task directory - try { - const dirPath = await getTaskDirectoryPath(globalStoragePath, taskId) - await fs.rm(dirPath, { recursive: true, force: true }) - console.log(`[deleteTaskWithId${taskId}] removed task directory`) - } catch (error) { - console.error( - `[deleteTaskWithId${taskId}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - } - async deleteTaskFromState(id: string) { await this.taskHistoryStore.delete(id) this.recentTasksCache = undefined diff --git a/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts b/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts index 362419cbd3..f7f1ef8d77 100644 --- a/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts @@ -1,69 +1,100 @@ // npx vitest run core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + import { ClineProvider } from "../ClineProvider" import { TaskHistoryDeleteError } from "../../task-persistence" /** * deleteTaskWithId batches the store deletions, and TaskHistoryStore.deleteMany attempts - * every id even when one fails. The provider must therefore treat a TaskHistoryDeleteError - * as a PARTIAL success: the ids it does not list are gone from the store, and leaving them - * in the recent-task cache, the webview state, and on disk would contradict the store. + * every id even when one fails, reporting the ones it could NOT remove. The provider must + * therefore treat a TaskHistoryDeleteError as a PARTIAL success: the ids it does not list + * are gone from the store, and leaving them in the recent-task cache, the posted webview + * state, and on disk would contradict the store. */ -function makeProvider(deleteMany: ReturnType) { +async function makeFixture(deleteMany: ReturnType) { + const storageDir = await fs.mkdtemp(path.join(os.tmpdir(), "partial-task-delete-")) + const taskDirs: Record = {} + for (const taskId of ["task-1", "task-2"]) { + const dir = path.join(storageDir, "tasks", taskId) + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "ui_messages.json"), "[]") + taskDirs[taskId] = dir + } const provider = { taskHistoryStore: { deleteMany }, recentTasksCache: ["task-1", "task-2"], getTaskWithId: vi.fn(async (taskId: string) => ({ - taskDirPath: `/tmp/global/${taskId}`, + taskDirPath: taskDirs[taskId], historyItem: { id: taskId, childIds: taskId === "task-1" ? ["task-2"] : [] }, })), getCurrentTask: vi.fn().mockReturnValue(undefined), removeClineFromStack: vi.fn().mockResolvedValue(undefined), postStateToWebview: vi.fn().mockResolvedValue(undefined), - // removeTaskArtifacts is private on the class; bracket notation is how this suite stands in for - // it without widening the class surface for tests. - removeTaskArtifacts: vi.fn().mockResolvedValue(undefined), deleteTaskFromState: vi.fn().mockResolvedValue(undefined), - contextProxy: { globalStorageUri: { fsPath: "/tmp/global" } }, - cwd: "/tmp/workspace", + contextProxy: { globalStorageUri: { fsPath: storageDir } }, + cwd: storageDir, } as unknown as ClineProvider - return provider + return { provider, storageDir, taskDirs } +} + +async function exists(dir: string): Promise { + try { + await fs.access(dir) + return true + } catch { + return false + } } describe("ClineProvider.deleteTaskWithId - partial batch failures", () => { it("cleans up the tasks that were deleted before rethrowing the batch failure", async () => { const partial = new TaskHistoryDeleteError(["task-2"], "unlink failed: EPERM") - const deleteMany = vi.fn().mockRejectedValue(partial) - const provider = makeProvider(deleteMany) + const { provider, storageDir, taskDirs } = await makeFixture(vi.fn().mockRejectedValue(partial)) - await expect(ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1")).rejects.toBe(partial) + try { + await expect(ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1")).rejects.toBe(partial) - // The store no longer lists task-1, so the cache and the webview must stop listing it - // and its artifacts must be removed even though the batch as a whole failed. - expect((provider as unknown as { recentTasksCache?: string[] }).recentTasksCache).toBeUndefined() - expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) - expect(provider["removeTaskArtifacts"]).toHaveBeenCalledTimes(1) - expect(provider["removeTaskArtifacts"]).toHaveBeenCalledWith(["task-1"]) + // The store no longer lists task-1, so its directory must be gone and the cache + // and webview must stop listing it; task-2 is still in the store, so its + // directory must survive. + expect(await exists(taskDirs["task-1"])).toBe(false) + expect(await exists(taskDirs["task-2"])).toBe(true) + expect((provider as unknown as { recentTasksCache?: string[] }).recentTasksCache).toBeUndefined() + expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) + } finally { + await fs.rm(storageDir, { recursive: true, force: true }).catch(() => {}) + } }) it("removes every artifact and posts state once when the batch succeeds", async () => { - const deleteMany = vi.fn().mockResolvedValue(undefined) - const provider = makeProvider(deleteMany) + const { provider, storageDir, taskDirs } = await makeFixture(vi.fn().mockResolvedValue(undefined)) - await ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1") + try { + await ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1") - expect(provider["removeTaskArtifacts"]).toHaveBeenCalledWith(["task-1", "task-2"]) - expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) + expect(await exists(taskDirs["task-1"])).toBe(false) + expect(await exists(taskDirs["task-2"])).toBe(false) + expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) + } finally { + await fs.rm(storageDir, { recursive: true, force: true }).catch(() => {}) + } }) - it("does not clean up artifacts when no task was deleted", async () => { + it("leaves every artifact in place when no task was deleted", async () => { const allFailed = new TaskHistoryDeleteError(["task-1", "task-2"], "lock acquisition failed") - const deleteMany = vi.fn().mockRejectedValue(allFailed) - const provider = makeProvider(deleteMany) + const { provider, storageDir, taskDirs } = await makeFixture(vi.fn().mockRejectedValue(allFailed)) - await expect(ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1")).rejects.toBe(allFailed) + try { + await expect(ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1")).rejects.toBe(allFailed) - // Nothing was removed, so nothing may be cleaned up as if it had been. - expect(provider["removeTaskArtifacts"]).toHaveBeenCalledWith([]) + // Nothing was removed from the store, so nothing may be cleaned up as if it had been. + expect(await exists(taskDirs["task-1"])).toBe(true) + expect(await exists(taskDirs["task-2"])).toBe(true) + } finally { + await fs.rm(storageDir, { recursive: true, force: true }).catch(() => {}) + } }) }) From 06f7b999f6841744fe6e72a26c784ae1994cd02c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 18:36:43 +0800 Subject: [PATCH 59/89] fix(webview): clean partial-delete artifacts before posting state In the TaskHistoryDeleteError branch the state post ran before the artifact cleanup. If getStateToPostToWebview() rejected, deleteTaskWithId left that branch early: the ids deleteMany had already removed from the store kept their shadow checkpoint repositories and task directories on disk, and the caller received the state error instead of the batch error that describes what actually happened. Cleanup now runs first and the post is best-effort (logged), so the batch error is what the caller sees. Test: with postStateToWebview rejecting, task-1's directory is still removed, task-2's (the id deleteMany could not remove) survives, and the rejection is the TaskHistoryDeleteError itself. Control: restoring the old order fails exactly that test with the state error surfacing. Also drops four unjustified "as unknown as void" double assertions in the apply_diff guarded write spec - execute returns Promise, so the casts added nothing. --- .../applyDiffTool.guardedWrite.spec.ts | 8 ++++---- src/core/webview/ClineProvider.ts | 14 +++++++++++++- .../ClineProvider.partialTaskDelete.spec.ts | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) 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/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c3de232d38..6e4ed31f06 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2418,12 +2418,24 @@ export class ClineProvider // surface the batch failure to the caller. const failed = new Set(error.taskIds) this.recentTasksCache = undefined - await this.postStateToWebview() + // Artifacts first: deleteMany already dropped the successful ids from the store, + // so a webview post that rejects must not strand their checkpoint repositories + // and task directories. The caller needs the batch error, not a state-post + // failure, so the post is best-effort here. await removeTaskArtifacts( allIdsToDelete.filter((taskId: string) => !failed.has(taskId)), this.contextProxy.globalStorageUri.fsPath, this.cwd, ) + try { + await this.postStateToWebview() + } catch (stateError) { + console.error( + `[deleteTaskWithId] failed to post state after a partial task delete: ${ + stateError instanceof Error ? stateError.message : String(stateError) + }`, + ) + } } throw error } diff --git a/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts b/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts index f7f1ef8d77..73b18f45d7 100644 --- a/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.partialTaskDelete.spec.ts @@ -83,6 +83,25 @@ describe("ClineProvider.deleteTaskWithId - partial batch failures", () => { } }) + it("still cleans up the deleted tasks when the webview state post rejects", async () => { + const partial = new TaskHistoryDeleteError(["task-2"], "unlink failed: EPERM") + const { provider, storageDir, taskDirs } = await makeFixture(vi.fn().mockRejectedValue(partial)) + const stateError = new Error("webview is gone") + provider.postStateToWebview = vi.fn().mockRejectedValue(stateError) + + try { + // The batch error is what the caller must see, not the state-post failure. + await expect(ClineProvider.prototype.deleteTaskWithId.call(provider, "task-1")).rejects.toBe(partial) + + // task-1 is gone from the store, so its artifacts must be gone too: cleaning + // after the post would strand them whenever the post rejects. + expect(await exists(taskDirs["task-1"])).toBe(false) + expect(await exists(taskDirs["task-2"])).toBe(true) + } finally { + await fs.rm(storageDir, { recursive: true, force: true }).catch(() => {}) + } + }) + it("leaves every artifact in place when no task was deleted", async () => { const allFailed = new TaskHistoryDeleteError(["task-1", "task-2"], "lock acquisition failed") const { provider, storageDir, taskDirs } = await makeFixture(vi.fn().mockRejectedValue(allFailed)) From 5239634aeceee930a97d05b2be35f5c9ac711181 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 17:13:45 +0800 Subject: [PATCH 60/89] refactor(task): stop re-adding the isPaused field upstream deleted The unit carried Task.isPaused and the '|| this.isPaused' continuation condition over from its content source, but upstream removed both in 9af61f87e (RSK-12, #1894) as dead code. The branch contains that commit, so the unit was silently reverting an upstream refactor: nothing in the repository sets Task.isPaused (the only other isPaused is the unrelated MessageQueueService interface field), and the loop has no pause check for the pushed empty item to reach, so the comment describing one was fiction. Restoring upstream's shape removes the untestable branch instead of adding coverage for a state no caller can set - the alternative the review offered. Local: core/task + __tests__/new-task-delegation.spec.ts 698 passed across 45 files; eslint clean on Task.ts; tsc unchanged from the 50-error local baseline (5 pre-existing in Task.ts). --- src/core/task/Task.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 685c26a2d0..5fbac2dd3d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -407,7 +407,6 @@ export class Task extends EventEmitter implements TaskLike { abandoned = false abortReason?: ClineApiReqCancelReason isInitialized = false - isPaused: boolean = false // API apiConfiguration: ProviderSettings @@ -4693,9 +4692,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 From f90142106601a3e51f01b338a5d5514c80a3b85b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 17:21:24 +0800 Subject: [PATCH 61/89] 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 685c26a2d0..5fbac2dd3d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -407,7 +407,6 @@ export class Task extends EventEmitter implements TaskLike { abandoned = false abortReason?: ClineApiReqCancelReason isInitialized = false - isPaused: boolean = false // API apiConfiguration: ProviderSettings @@ -4693,9 +4692,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 c3003729b1..334812dcf6 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 10db2f21d2..7b2d38745e 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 beda87ce714b3440577aa5441a161bee7a12112d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 17:37:53 +0800 Subject: [PATCH 62/89] 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 cf8c209891..cc9c11d91c 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 7b2d38745e..275a07d6c3 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 682dc9dd959fae09d7992c0b31731886ceeaa0a0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 17:54:03 +0800 Subject: [PATCH 63/89] 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 334812dcf6..4ea1e18ae2 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 1488d2fc0f..80a35f09aa 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 78632f46b9..b50a1c99c6 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 b3f1edca30..74481b45ad 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -142,6 +142,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { diagnosticsEnabled, writeDelayMs, "create", + isOutsideWorkspace, ) } else { if (!task.diffViewProvider.isEditing) { @@ -175,7 +176,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 5138b27d4b..21e1ff7799 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 192e7a5382..1308b968c8 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 930bc1661c..8f6a1fe52a 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 cc9c11d91c..48ee4d172b 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 174f3375d2653ac59c415c0f483dc5bb6535f330 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 18:03:49 +0800 Subject: [PATCH 64/89] fix(integrations): a denied preview must not stay authorized for the next edit open() records a stat-matched observation for a target the model never read so the accept-time compare-and-swap has an entry to check. Only saveChanges(..., "edit") revoked it. A user denial runs revertChanges() then reset(), and reset() cleared the preOpenObservation snapshot but left the registry entry behind, so the retry's open() found a non-null prior observation, saveChanges restored it, and guardedWrite accepted an edit against a version the model never read. The same leftover entry authorized a later saveDirectly(..., "edit"). open() now remembers the entry it wrote (path + token). reset() forgets it when the preview ended with no prior observation and the entry still holds that token - so a save that published (entry now carries the published token) and any other writer's entry are left alone. Test: preview an unread file, deny it (reset), retry, and assert the retry is rejected with the read-first remediation and nothing is published (pin - fails with this change stashed). Local: DiffViewProvider.spec 137 passed; core/tools + integrations/editor + core/task + services/file-safety + utils + core/webview 2898 passed / 10 skipped (151 files; the 3 blanket-auto-deny ClineProvider failures are pre-existing on this branch); tsc unchanged; eslint clean on both files. --- src/integrations/editor/DiffViewProvider.ts | 24 +++++++++++++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 20 ++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index e3e671562e..5101b3cc4d 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -105,6 +105,15 @@ export class DiffViewProvider { */ private preOpenObservation: { version: string; complete: boolean } | null | undefined = undefined + /** + * The registry entry this provider's own open() wrote (path + token), or null when it + * wrote none. reset() revokes exactly that entry when the preview ends without a save, + * so a denied or rejected preview cannot leave behind an observation that authorizes a + * later edit of a file the model never read. The token match keeps the revoke from + * touching an entry a save (or another writer) has already replaced. + */ + private previewObservation: { path: string; version: string } | null = null + constructor( private cwd: string, task: Task, @@ -172,6 +181,7 @@ export class DiffViewProvider { const displayToken = versionTokenOfStat(preStats) if (displayToken === versionTokenOfStat(postStats)) { displayTask.observationRegistry.observe(absolutePath, displayToken, false) + this.previewObservation = { path: absolutePath, version: displayToken } } } } else { @@ -1539,6 +1549,20 @@ export class DiffViewProvider { this.userTouchedDiffEditor = false this.snapshotPreviewTabs = [] this.placeholderVersion = undefined + + // A preview that never reached a save must not stay authorized: open() recorded a + // stat-matched entry for a target the model had not read. If it still holds that + // token, revoke it - a denial (revertChanges + reset) or a rejected save otherwise + // leaves it behind, and the next attempt would CAS against a version never read. + // When a save did publish, the entry carries the published token and is left alone. + if (this.preOpenObservation === null && this.previewObservation !== null) { + const task = this.taskRef.deref() + const entry = task?.observationRegistry.get(this.previewObservation.path) + if (task && entry && entry.version === this.previewObservation.version) { + task.observationRegistry.forget(this.previewObservation.path) + } + } + this.previewObservation = null this.preOpenObservation = undefined } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 4bf53a79a6..db08a33a47 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1524,6 +1524,26 @@ describe("DiffViewProvider", () => { expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.txt`, Buffer.from("new content")) }) + + it("does not let a denied preview authorize the retry of an unread edit", async () => { + // The model never read this file. open() records the preview token so the accept-time + // CAS has an entry to compare against; when the user denies, that entry must not + // survive into the next attempt - otherwise the retry publishes against a version the + // model never read, the exact invariant the save above enforces. + mockTask.observationRegistry.clear() + await openPreview() + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(true) + + // A denial ends in reset() (revertChanges() then reset()); the revoke lives in + // reset() so every path that ends without a save is covered. + await diffViewProvider.reset() + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(false) + + // The retry is still an edit of a file that was never read. + await openPreview() + await expect(diffViewProvider.saveChanges(false, 0, "edit")).rejects.toThrow(/File not read yet/) + expect(safeWriteText).not.toHaveBeenCalled() + }) 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 From 448492fd9c4e9d4a7fb5c240a2e45a9057419dea Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 01:20:59 +0800 Subject: [PATCH 65/89] fix(u9): one lock key whether the parent exists, and no cleanup a waiter does not own Persistence Integrity: 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. Regression Evidence: deleteMany() with every requested id failing (one at the lock, one at the unlink) - all ids are reported in TaskHistoryDeleteError, every cache and mtime entry and both files remain, and no write-through happens for a batch that changed nothing. Lifecycle Resource Cleanup: runTeardown() serialized the callback but revertChanges() still ran restorePreviewTabs() and reset() afterwards for every caller, so a cancellation that arrived while a rejected save was tearing down the same buffer restored tabs twice and reset state the owner was still using. runTeardown() now reports whether the caller owns the cleanup, and a waiter stops there. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 35 ++++++++++++++ src/integrations/editor/DiffViewProvider.ts | 15 ++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 8 ++++ .../__tests__/safeWriteText.spec.ts | 47 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 30 +++++++++++- 5 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 9b7a09e055..14e6b8c6c1 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -523,6 +523,41 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { expect(writtenIds).toEqual(["lock-a"]) }) + it("reports every id, keeps every entry and file, and skips the write-through when the whole batch failed", async () => { + // deletedAny stays false when nothing was removed: the store must not write a state + // it did not change, and every requested id has to be reported - a partial report + // would let the caller forget ids that are still on disk. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "all-a", ts: 1000 })) + await store.upsert(makeHistoryItem({ id: "all-b", ts: 2000 })) + onWrite.mockClear() + + const before = storeInternals(store) + // One id fails at the lock, the other at the unlink: both outcomes count as + // "not deleted", and neither may be treated as gone. + vi.mocked(withFileLock).mockRejectedValueOnce(new Error("lock acquisition timed out")) + vi.mocked(fs.unlink).mockRejectedValueOnce(Object.assign(new Error("EBUSY: resource busy"), { code: "EBUSY" })) + + const failure = await store + .deleteMany(["all-a", "all-b"]) + .then(() => null) + .catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TaskHistoryDeleteError) + expect((failure as TaskHistoryDeleteError).taskIds).toEqual(["all-a", "all-b"]) + + const { cache, taskFileMtimes } = storeInternals(store) + expect(cache.has("all-a")).toBe(before.cache.has("all-a")) + expect(cache.has("all-b")).toBe(before.cache.has("all-b")) + expect(taskFileMtimes.has("all-a")).toBe(before.taskFileMtimes.has("all-a")) + expect(taskFileMtimes.has("all-b")).toBe(before.taskFileMtimes.has("all-b")) + await expect(fs.access(historyFilePath(storagePath, "all-a"))).resolves.toBeUndefined() + await expect(fs.access(historyFilePath(storagePath, "all-b"))).resolves.toBeUndefined() + + // Nothing was deleted, so nothing was written through. + expect(onWrite).not.toHaveBeenCalled() + }) + it("locks the resolved publish target for every item while unlinking the given paths", async () => { const store = createStore() await store.initialize() diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 5101b3cc4d..820f3e3e72 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -876,7 +876,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() @@ -926,6 +926,12 @@ export class DiffViewProvider { ) } }) + if (!ownedTeardown) { + // The teardown's owner restores the preview tabs and resets the provider. + // Repeating them here would re-run tab work for tabs this caller never + // touched and reset state the other cleanup is still relying on. + return + } // Restore any preview tabs the diff evicted, reconstructing the user's // prior not-yet-edited tab state. await this.restorePreviewTabs() @@ -1017,10 +1023,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 + // 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() this.teardownInFlight = inFlight @@ -1029,6 +1037,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 db08a33a47..a760d83dfb 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3110,12 +3110,20 @@ describe("DiffViewProvider", () => { }), }) diffViewProvider["activeDiffEditor"] = editor + // The tail of revertChanges() - restoring the preview tabs the diff evicted and + // resetting the provider - belongs to the teardown, not to every caller. + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset const first = diffViewProvider.revertChanges() const second = diffViewProvider.revertChanges() await Promise.all([first, second]) expect(applyEdit).toHaveBeenCalledTimes(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) }) it("saveChanges() keeps the file open when the user touched it", async () => { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 14f65f7dae..d83813a314 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1384,3 +1384,50 @@ describe("resolvePublishTarget", () => { expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), path.resolve(targetPath)) }) }) + +// ── Lock key with a missing parent: one file, one lock ────────────────────── + +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 c3c7f9525a02d4e7923909e91a10b6bcc0f9d4f9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 01:48:54 +0800 Subject: [PATCH 66/89] fix(u7-port): no containment decision without a canonical workspace, and no observation without a baseline Port of the #1918 fix into this unit: this branch carries its own copy of guardedWrite (the unit branches are not cumulative), and the same defects are in it. 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. The bracketing-stat branches in apply_patch and apply_diff were also unproven here: the tool-level tests for a rejected pre-read stat (both tools) and a rejected post-read stat (apply_diff) are ported unchanged, so the read still runs and the publish still fails closed while nothing is observed. Identifiers, comments and test names are kept identical to fws/u7 so the merge resolves trivially. --- .../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 c643558584..eb8360684f 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 f697b230b6c3aeabf90edf2164fd270cf2377573 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 03:28:20 +0800 Subject: [PATCH 67/89] 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 d83813a314..ba4aaccdc3 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 8d7d056f150f5477c250ad835a25cd051e53f819 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 05:49:28 +0800 Subject: [PATCH 68/89] 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 9295b40b6e9f6d3104fa6151365026a3ef77b662 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 06:58:36 +0800 Subject: [PATCH 69/89] 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-u9. 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 820f3e3e72..cb2e6c882b 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 @@ -126,6 +132,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. @@ -686,35 +695,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) + } - // 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.closeAllDiffViews() - 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, - ) + // 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, + ) + + // 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 @@ -1031,6 +1054,7 @@ export class DiffViewProvider { return false } 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 a760d83dfb..4b769d5cb7 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3125,6 +3125,108 @@ describe("DiffViewProvider", () => { expect(restorePreviewTabs).toHaveBeenCalledTimes(1) expect(reset).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 5c54bb2ec8d6fbe48fce23402387139827d268a3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 08:10:49 +0800 Subject: [PATCH 70/89] 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-u9. CodeRabbit's Lifecycle row applies to every branch carrying a guarded publish, and each copy is verified in its own harness. This branch ALREADY carried the production half, from 448492fd9 ("no cleanup a waiter does not own", 2026-10-08): runTeardown already returned a boolean and revertChanges already returned before restorePreviewTabs()/reset(). What was missing was coverage, so this commit adds only the test and re-measures the mutations here. - 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. Measured coverage gap: the production behaviour was already right here, but nothing pinned it - removing the guard used to leave 0 tests failing for the ownership claim; now it fails 2. Negative controls, re-measured in THIS branch's harness (not copied from the owner): - waiter finalizing anyway (if (!ownedTeardown && false)) -> 2 failed (the new test plus the pre-existing save/revert serialization test, which also depends on the guard) - 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. --- .../editor/__tests__/DiffViewProvider.spec.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 4b769d5cb7..7882048e65 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3228,6 +3228,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 9c12e11e42e25c3e3a7dae5a4c23b711834a3574 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:04:31 +0800 Subject: [PATCH 71/89] 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-u9. 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 cb2e6c882b..62e67bfd48 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -629,7 +629,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) @@ -676,6 +676,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 7882048e65..08d2a59d43 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3279,6 +3279,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 eae8ea5739a27b64f4c4f317ee8891d63a715eff Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:32:50 +0800 Subject: [PATCH 72/89] fix(tools): pass the outside-workspace approval flag in its own position Port of the same defect fix to this unit (U9 carries the U7 tool wiring), so the two branches do not evolve the same call sites differently. 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 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 eight 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 50 error lines, this branch's baseline, none 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 4ea1e18ae2..89e110c061 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 80a35f09aa..35f270f581 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 b50a1c99c6..0c8f00aa83 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 74481b45ad..1e42c3d961 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -142,6 +142,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 eb8360684f..b18a155c25 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 21e1ff7799..73e87d7bb5 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() @@ -743,12 +744,25 @@ describe("editFileTool", () => { true, 1000, "create", + undefined, false, ) expect(mockTask.didEditFile).toBe(true) 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("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__/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 1308b968c8..8e3a0c0666 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 8f6a1fe52a..4ed63392ca 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 3a1e5a2d2cc3f8d8f9bf8b68737a624e47ff2b33 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:43:50 +0800 Subject: [PATCH 73/89] fix(guardedWrite): one unresolvable workspace folder must not veto every write Port of fws-u7-fix 34be98278 to this unit; the two branches' guardedWrite.ts is byte-identical again after this commit (git diff --no-index reports no difference), which is what #41 note 7 relies on. 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 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 - and a target that only the broken root could have covered still falls through to a refusal, with a message naming that case. When no root resolves the behaviour is unchanged. Tests: the two cases ported with the fix. Negative control, measured in this harness: restoring the per-root throw -> exactly 2 failed (both new cases), the other 60 pass. Restore byte-identical; baseline after the sweep 62 passed. tsc 50 error lines (this branch's baseline), 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 40254cb52c3b48e4df916ca6fcdda4a70e7ce062 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:51:51 +0800 Subject: [PATCH 74/89] test(task-history): make the failed-batch assertions say what they mean Inline 4225764602 on #1917 (Minor, Functional). storeInternals returns the store's live maps, so the four assertions in this test compared a map with itself: cache.has(id) === before.cache.has(id) holds for any mutation of either map, and the test would have passed even if deleteMany evicted both failed ids. Investigated before changing anything, because the finding implied a user-visible inconsistency (the list says gone, the disk says present). Measured in this harness: after the fully failed batch the cache still holds both ids, and taskFileMtimes is EMPTY - not evicted. taskFileMtimes is only written by the reconcile path (TaskHistoryStore.ts:433, external-change detection), never by upsert, so an un-reconciled store legitimately has an empty map there. There is no eviction bug; the assertions were simply vacuous, and CR's suggested "assert true" for taskFileMtimes would have asserted something that was never true. Fix: assert the real contract with teeth. The cache must still contain both ids after a failed delete. For taskFileMtimes, seed the two entries through the same internals the store uses, then require that the failed delete left them alone - without seeding, a presence assertion would test a state the store never reaches. Negative control, measured in this harness: make deleteMany's failure path evict both maps -> the test goes red (the strengthened assertions catch exactly the bug the old ones could not). Restore byte-identical; baseline after the sweep 15 passed. eslint --max-warnings=0 clean. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 14e6b8c6c1..299c7abd22 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -533,7 +533,8 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { await store.upsert(makeHistoryItem({ id: "all-b", ts: 2000 })) onWrite.mockClear() - const before = storeInternals(store) + storeInternals(store).taskFileMtimes.set("all-a", 1000) + storeInternals(store).taskFileMtimes.set("all-b", 2000) // One id fails at the lock, the other at the unlink: both outcomes count as // "not deleted", and neither may be treated as gone. vi.mocked(withFileLock).mockRejectedValueOnce(new Error("lock acquisition timed out")) @@ -547,10 +548,19 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { expect((failure as TaskHistoryDeleteError).taskIds).toEqual(["all-a", "all-b"]) const { cache, taskFileMtimes } = storeInternals(store) - expect(cache.has("all-a")).toBe(before.cache.has("all-a")) - expect(cache.has("all-b")).toBe(before.cache.has("all-b")) - expect(taskFileMtimes.has("all-a")).toBe(before.taskFileMtimes.has("all-a")) - expect(taskFileMtimes.has("all-b")).toBe(before.taskFileMtimes.has("all-b")) + // storeInternals returns the live maps, so comparing them against 'before' compared + // a map with itself and passed no matter what deleteMany did. A failed delete must + // leave both ids in both maps, so assert that directly. + // The cache is the store's own view: a failed delete must leave both ids in it. + expect(cache.has("all-a")).toBe(true) + expect(cache.has("all-b")).toBe(true) + // taskFileMtimes is only populated by the reconcile path (external-change + // detection), so a store that never reconciled has an empty map here - asserting + // presence without seeding it would assert something that was never true. Seed it + // through the same internals the store uses, then require that a failed delete left + // it alone. + expect(taskFileMtimes.has("all-a")).toBe(true) + expect(taskFileMtimes.has("all-b")).toBe(true) await expect(fs.access(historyFilePath(storagePath, "all-a"))).resolves.toBeUndefined() await expect(fs.access(historyFilePath(storagePath, "all-b"))).resolves.toBeUndefined() From 83a30b4a302c4afdc5b3ada7905a367eb3dd65e9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 10:44:22 +0800 Subject: [PATCH 75/89] fix(guardedWrite): bind an approved outside-workspace write to the identity approved Port of the same fix to this unit so the two branches do not evolve the approved path differently: guardedWrite.ts is byte-identical to the other unit's after this commit. 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. GuardedWriteOptions.approvedCanonical Target carries the identity captured before the approval was asked, canonicalizeForApproval is exported for the tool layer to capture it, and the guard now only compares - 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 353 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 89e110c061..ce1bed0eed 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 35f270f581..aa569a0dbd 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 0c8f00aa83..fbc00f5cb2 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 1e42c3d961..299b1ba88b 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -17,6 +17,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 @@ -87,6 +88,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", @@ -146,6 +153,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) { @@ -184,6 +192,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 b18a155c25..019a0143cb 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, ) }) @@ -708,7 +712,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() @@ -724,7 +728,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 73e87d7bb5..693f1238b2 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) @@ -746,6 +750,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 8e3a0c0666..d6bbf312ba 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 4ed63392ca..3aad584177 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 62e67bfd48..346f90c2b3 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -524,6 +524,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 @@ -580,6 +584,7 @@ export class DiffViewProvider { } await guardedWrite(saveTask, this.relPath, encodedContent, writeKind, undefined, { approvedOutsideWorkspace: approvedOutsideWorkspace === true, + approvedCanonicalTarget, additionalRoots: this.additionalWorkspaceRoots(), }) } catch (error) { @@ -1632,6 +1637,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 @@ -1658,6 +1667,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 08d2a59d43..914f9cf769 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 ac7fca4fcb6a3f7b7143ac576f486d42f43530e8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 11:16:58 +0800 Subject: [PATCH 76/89] 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. Baseline after the sweep: 354 passed / 5 skipped across the nine affected specs; --- src/integrations/editor/DiffViewProvider.ts | 54 +++++++++++++------ .../editor/__tests__/DiffViewProvider.spec.ts | 53 ++++++++++++++++++ 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 346f90c2b3..854569b966 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -634,7 +634,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) @@ -680,15 +680,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 } @@ -912,7 +915,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() @@ -961,19 +964,21 @@ 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 () => { // The teardown's owner restores the preview tabs and resets the provider. // Repeating them here would re-run tab work for tabs this caller never // touched and reset state the other cleanup is still relying on. - 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.restorePreviewTabs() await this.reset() + }, + ) } private async closeAllDiffViews(): Promise { @@ -1059,20 +1064,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 // 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 914f9cf769..8b5f0142b6 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3090,6 +3090,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 22a6ff73f2f59e39b3e558f9958684610c9fd3d4 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 11:44:37 +0800 Subject: [PATCH 77/89] 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 299b1ba88b..01cf0f68f3 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -88,13 +88,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), @@ -104,6 +97,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 3aad584177..259ba37d45 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 f9570d277319880daba661c10029d2623d19dcae Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 11:53:21 +0800 Subject: [PATCH 78/89] 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. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 7 ++++--- 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 ++++++++++------- 5 files changed, 44 insertions(+), 31 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 299c7abd22..e2542565c6 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -556,9 +556,10 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { expect(cache.has("all-b")).toBe(true) // taskFileMtimes is only populated by the reconcile path (external-change // detection), so a store that never reconciled has an empty map here - asserting - // presence without seeding it would assert something that was never true. Seed it - // through the same internals the store uses, then require that a failed delete left - // it alone. + // presence without seeding it would assert something that was never true. That is what + // the two set() calls above the delete are for: they seed the map through the same + // internals the store uses. What is asserted here is only that a failed delete left + // that seeding alone. expect(taskFileMtimes.has("all-a")).toBe(true) expect(taskFileMtimes.has("all-b")).toBe(true) await expect(fs.access(historyFilePath(storagePath, "all-a"))).resolves.toBeUndefined() diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 693f1238b2..b7742600e4 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -758,14 +758,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("surfaces the unobserved edit remediation as a tool error and publishes nothing", 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 d6bbf312ba..96c598bcd3 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 259ba37d45..efdc6285bd 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 76a56cda7d9782552011dc018619acdd4d5be072 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 13:20:06 +0800 Subject: [PATCH 79/89] test(observations): cover forget() and the per-task registry ownership ObservationRegistry.forget() was added so a caller can revoke an authorization it did not earn without discarding what other reads of the same task still rely on, and nothing covered it. Three cases: the removal and its boolean result, the absent path (false, and nothing changes), and that the other entries survive - the reason forget exists rather than clear. Plus a construction-level check on Task: two Tasks built the same way own different registry instances, and observing in one is invisible to the other. A shared registry would let a parent's read authorize a subtask's write, and one task's clear() would revoke the other's authorization. Negative controls measured in this harness: forget() reporting without removing -> 2 failed (the removal case and the other-entries case); one registry shared by every Task -> 1 failed (the ownership case). Both restores byte-identical. Baseline after the sweep: 29 passed across the two specs; tsc at its pre-existing 50-error baseline; eslint --max-warnings=0 clean on every touched file; src/eslint-suppressions.json untouched. --- src/core/task/__tests__/Task.dispose.test.ts | 27 ++++++++++++++ .../__tests__/observationRegistry.spec.ts | 37 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/core/task/__tests__/Task.dispose.test.ts b/src/core/task/__tests__/Task.dispose.test.ts index 472218fce5..cb4d8d2fd2 100644 --- a/src/core/task/__tests__/Task.dispose.test.ts +++ b/src/core/task/__tests__/Task.dispose.test.ts @@ -410,6 +410,33 @@ describe("Task.run() idempotency", () => { mockApiConfiguration = { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" } as ProviderSettings }) + test("each Task constructs its own observation registry", async () => { + // The guard authorizes a write from the observations of the task that made it. One + // shared registry would let a parent read authorize a subtask write, and one task + // clearing its registry would revoke the other task authorization. The field is an + // instance initializer, so this asserts the ownership rather than the type. + const first = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "first", + startTask: false, + }) + const second = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "second", + startTask: false, + }) + + expect(first.observationRegistry).not.toBe(second.observationRegistry) + first.observationRegistry.observe("/same/path.ts", "1:2:22:100:100", true) + expect(second.observationRegistry.get("/same/path.ts")).toBeUndefined() + expect(second.observationRegistry.size).toBe(0) + + await first.dispose() + await second.dispose() + }) + test("run() does not invoke startTask when task was already started by constructor", async () => { // Spy on the prototype before construction so we capture the constructor's call too. const startTaskSpy = vi.spyOn(Task.prototype as any, "startTask").mockResolvedValue(undefined) diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts index a3c55ebc6d..d65312b695 100644 --- a/src/core/task/__tests__/observationRegistry.spec.ts +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -70,6 +70,43 @@ describe("ObservationRegistry", () => { expect(regB.get("/shared.ts")!.version).toBe("v2") }) + describe("forget", () => { + it("removes the entry and reports that it removed one", () => { + const registry = new ObservationRegistry() + registry.observe("/a.ts", "1:1:1:1:1", true) + + expect(registry.forget("/a.ts")).toBe(true) + expect(registry.has("/a.ts")).toBe(false) + expect(registry.get("/a.ts")).toBeUndefined() + expect(registry.size).toBe(0) + }) + + it("reports false for a path it never observed, and changes nothing", () => { + const registry = new ObservationRegistry() + registry.observe("/a.ts", "1:1:1:1:1", true) + + expect(registry.forget("/missing.ts")).toBe(false) + expect(registry.size).toBe(1) + expect(registry.get("/a.ts")?.version).toBe("1:1:1:1:1") + }) + + it("leaves the other entries alone - that is why a caller uses forget and not clear", () => { + // A caller revoking an authorization it did not earn must not discard the + // observations other reads of the same task still rely on. + const registry = new ObservationRegistry() + registry.observe("/a.ts", "1:1:1:1:1", true) + registry.observe("/b.ts", "2:2:2:2:2", false) + + expect(registry.forget("/a.ts")).toBe(true) + expect(registry.get("/b.ts")).toEqual({ + version: "2:2:2:2:2", + observedAt: expect.any(Number), + complete: false, + }) + expect(registry.size).toBe(1) + }) + }) + describe("completeness scope (S4b follow-up #46)", () => { it("defaults to a complete observation when the read scope is not given", () => { const reg = new ObservationRegistry() From 39da48c9699eb9f1a417f9eda9765bf6cddc854f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 14:11:42 +0800 Subject: [PATCH 80/89] fix(tools): stop the apply_patch hunk read from refreshing a stale observation CodeRabbit thread 4226780785 (Major) on PR #1917. The apply_patch hunk read called observe(absolutePath, preReadToken, false) even when the prior observation described an OLDER version, so the tool refreshed a stale authorization onto the version it had just read. The guarded "edit" publish then passed its compare-and-swap against a version the model never read: a patch built against v1 could be published over an external v2 without the re-read remediation. ApplyDiffTool deliberately preserves the older observation, so the two tools enforced opposite stale policies. Mirror ApplyDiffTool: observe only when there is no prior entry, or when the prior entry's version equals the token just read. An older observation is left exactly as the model earned it, so the guarded save fails stale. Test: applyPatchTool.execute.spec.ts now asserts the seeded ...998 version is preserved with complete === true, and that the guarded save rejects STALE rather than merely partial. Negative control: relaxing the version guard so the read always refreshes turns that test red (1 failed | 21 passed). --- src/core/tools/ApplyPatchTool.ts | 23 +++++++++++-------- .../__tests__/applyPatchTool.execute.spec.ts | 23 +++++++++++++------ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index ce1bed0eed..941a9fab38 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -102,17 +102,20 @@ 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. 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 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. + // The tool's own hunk read, not a model read. With no prior observation the model has + // seen only the patch's hunks, so the read stays incomplete: it must not become authority + // for a later full-file replacement. A prior observation is carried - partial stays partial, + // complete stays complete - only while it still describes the version that was read. An + // OLDER observation is left exactly as the model earned it: refreshing it to the version this + // read published would hand the guarded save a compare-and-swap token for content the model + // never saw, so a patch built against v1 would overwrite an external v2 without the re-read + // remediation. Same stale policy as ApplyDiffTool's read. const prior = task.observationRegistry.get(absolutePath) - // 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) + if (prior === undefined) { + task.observationRegistry.observe(absolutePath, preReadToken, false) + } else if (prior.version === preReadToken) { + task.observationRegistry.observe(absolutePath, preReadToken, prior.complete === true) + } } } return content diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 019a0143cb..e956e063fb 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -350,10 +350,12 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { 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 - // replacement must still fail closed. + it("update: leaves an observation earned on an older version untouched so the guarded save fails stale", async () => { + // The model earned completeness on a version the patch helper never read: an external + // writer published in between. Refreshing the entry to the version this tool read would + // hand the guarded save a compare-and-swap token for content the model never saw, so the + // stale observation is left exactly as the model earned it (ApplyDiffTool's policy) and + // every later publish - this patch included - fails with the re-read remediation. const key = path.resolve("/workspace/project", "src/thing.ts") const reg = mockTask.observationRegistry reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999998", true) @@ -364,11 +366,18 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - expect(reg.get(key)?.complete).toBe(false) + // The seeded version survives the hunk read: the tool did not authorize a version the + // model never read, and it did not downgrade the completeness the model earned. + const observation = reg.get(key) + expect(observation?.version).toBe("7:4242:1234:1700000000123456789:1700000000789999998") + expect(observation?.complete).toBe(true) + // The save rejects STALE, not merely partial: the compare-and-swap runs against the + // version the model actually read. 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.", + "Stale version -- the file changed since you read it (expected " + + "7:4242:1234:1700000000123456789:1700000000789999998, " + + "current 7:4242:1234:1700000000123456789:1700000000789999999); re-read the file, then retry.", ) }) From 643d5860d0d5657f5fa2283a0885c7f4e69b36b0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 14:11:51 +0800 Subject: [PATCH 81/89] fix(safe-write-json): release the backup a post-commit directory fsync retained CodeRabbit thread 4226780789 (Minor) on PR #1917. safeWriteText now keeps the previous-content copy when the post-commit parent-directory fsync fails, and reports its location through PostCommitDurabilityError.backupPath, so the caller owns that file. safeWriteJson always passes backup:true but never handled the error: the generic catch logged "Operation failed" and rethrew, leaving a hidden .safeWriteText.bak_* copy beside the target for every affected write, and telling callers such as TaskHistoryStore that a write had failed when the new JSON was already committed at the target. Catch PostCommitDurabilityError around the safeWriteText call: release the reported backup best-effort, log the durability caveat, and return, because the content really is at the target. Every other error keeps the existing failure path. Test: new utils/__tests__/safeWriteJson.postCommitDurability.spec.ts drives a parent-directory open failure through safeWriteJson against a real temp directory, and asserts the caller's result, that the failure actually fired, and that no backup or staging residue is left; a second test pins that a pre-commit failure still rejects. Negative controls: dropping the backup release turns the first test red, and swallowing every error turns the second red (1 failed | 1 passed each). --- ...safeWriteJson.postCommitDurability.spec.ts | 123 ++++++++++++++++++ src/utils/safeWriteJson.ts | 25 +++- 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/safeWriteJson.postCommitDurability.spec.ts diff --git a/src/utils/__tests__/safeWriteJson.postCommitDurability.spec.ts b/src/utils/__tests__/safeWriteJson.postCommitDurability.spec.ts new file mode 100644 index 0000000000..39c82266d8 --- /dev/null +++ b/src/utils/__tests__/safeWriteJson.postCommitDurability.spec.ts @@ -0,0 +1,123 @@ +// npx vitest run utils/__tests__/safeWriteJson.postCommitDurability.spec.ts + +import * as fsSync from "fs" +import * as os from "os" +import path from "path" + +import * as fs from "fs/promises" + +import { safeWriteJson } from "../safeWriteJson" + +// Real filesystem throughout: what this spec has to prove is what the target's directory +// actually holds after a post-commit durability failure, and a fully mocked fs cannot show +// that. Only the sync openSync is wrapped, and it delegates to the real implementation for +// every path except the one a test deliberately fails. The real openSync is captured inside +// the mock factory, before any vi.fn() wrapper exists, so a test's implementation callback +// can delegate without recursing into itself. +type OpenSyncRest = (...args: unknown[]) => number +const actuals = vi.hoisted((): { openSync: OpenSyncRest | null } => ({ openSync: null })) + +vi.mock("fs", async () => { + const actual = await vi.importActual("fs") + // Node declares openSync as a set of overloads; capturing it as a rest-args function is + // what lets this spec forward every argument tuple unchanged instead of re-declaring them. + actuals.openSync = actual.openSync as OpenSyncRest + return { ...actual, openSync: vi.fn(actual.openSync) } +}) + +// The advisory lock is not under test here; the real proper-lockfile would add a lock +// directory to the very directory this spec inspects for residue. +vi.mock("../fileLock", () => ({ + acquireFileLock: vi.fn(async () => async () => {}), +})) + +describe("safeWriteJson post-commit durability (backup: true)", () => { + let dir: string + // safeWriteText derives its directory path from the RESOLVED target, so the injected + // failure has to be keyed to the same spelling realpath reports. + let dirKey: string + let targetPath: string + let originalPlatform: NodeJS.Platform + + beforeEach(async () => { + originalPlatform = process.platform + // safeWriteJson always passes backup:true and never overrides the platform, so the + // POSIX parent-directory fsync (safeWriteText step 4b) is only reachable when the + // process reports a POSIX platform. + Object.defineProperty(process, "platform", { value: "linux", configurable: true }) + + dir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-json-postcommit-")) + dirKey = await fs.realpath(dir) + targetPath = path.join(dirKey, "history_item.json") + await fs.writeFile(targetPath, JSON.stringify({ version: "old" })) + }) + + afterEach(async () => { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }) + vi.restoreAllMocks() + await fs.rm(dir, { recursive: true, force: true }) + }) + + it("releases the backup a failed post-commit directory fsync retained, and reports the write as committed", async () => { + // The commit rename already published the new JSON; only the durability of the + // directory entry is in doubt. safeWriteText keeps the previous-content copy on this + // path and hands its location to the caller through PostCommitDurabilityError, so + // safeWriteJson - the only caller that ever turns backup on through this path - owns + // that copy. Leaving it behind means every affected write leaks one more hidden full + // copy of the old content next to the target. + let dirOpenAttempted = false + const openSyncActual = actuals.openSync + if (openSyncActual === null) { + throw new Error("fs mock did not capture the real openSync") + } + vi.mocked(fsSync.openSync).mockImplementation((...args: Parameters) => { + if (String(args[0]) === dirKey) { + // Pin the injection: without this flag the test would also pass on a run where + // the failure never fired, which proves nothing about the retained backup. + dirOpenAttempted = true + throw Object.assign(new Error("EINVAL: invalid argument, open '" + dirKey + "'"), { code: "EINVAL" }) + } + return openSyncActual(...args) + }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + // The caller's result: the content is at the target, so the write is reported as done + // rather than as a failure that leaves in-memory state diverging from disk. + await safeWriteJson(targetPath, { version: "new" }) + + expect(dirOpenAttempted).toBe(true) + expect(JSON.parse(await fs.readFile(targetPath, "utf8"))).toEqual({ version: "new" }) + // The swallowed error is still reported to the log: the directory entry is not known + // to be durable, and a caller that cannot see that would over-claim the write. + expect(warnSpy).toHaveBeenCalledTimes(1) + // No backup copy, no staging temp, no staging directory: only the published file. + expect(await fs.readdir(dir)).toEqual(["history_item.json"]) + }) + + it("still rejects a failure before the commit rename, and leaves no backup residue", async () => { + // The post-commit handling must stay narrow: a failure that happens before the commit + // has not published anything, and swallowing it would report a write that never ran. + let tempOpenAttempted = false + const openSyncActual = actuals.openSync + if (openSyncActual === null) { + throw new Error("fs mock did not capture the real openSync") + } + vi.mocked(fsSync.openSync).mockImplementation((...args: Parameters) => { + // The staging file safeWriteJson wrote beside the target: safeWriteText opens it + // with "r+" to apply the target's mode before the commit rename. + if (String(args[0]).includes(".new_")) { + tempOpenAttempted = true + throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + } + return openSyncActual(...args) + }) + + await expect(safeWriteJson(targetPath, { version: "new" })).rejects.toThrow("EACCES: permission denied") + + expect(tempOpenAttempted).toBe(true) + // Nothing was published: the target still holds the previous content, and the backup + // copy safeWriteText took before the commit was removed by its own failure handler. + expect(JSON.parse(await fs.readFile(targetPath, "utf8"))).toEqual({ version: "old" }) + expect(await fs.readdir(dir)).toEqual(["history_item.json"]) + }) +}) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 9488227f5e..b96a2d31f4 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -5,6 +5,7 @@ import { JsonStreamStringify } from "json-stream-stringify" import { acquireFileLock } from "./fileLock" import { + PostCommitDurabilityError, resolveLockKey, resolvePublishTarget, safeWriteText, @@ -257,7 +258,29 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso backup: true, } - await safeWriteText(resolvedTargetPath, "", textOptions) + try { + await safeWriteText(resolvedTargetPath, "", textOptions) + } catch (error: unknown) { + // The commit rename already published the new JSON: only the durability of the + // directory entry is unproven. safeWriteText keeps the previous-content copy on this + // path and reports it on the error, so this caller - the one that always turns + // backup on - owns that copy. The write is reported as committed, because the content + // really is at the target and failing here would leave the caller's in-memory state + // diverging from what is on disk; the copy is released best-effort so no hidden + // .safeWriteText.bak_ file is left beside the target; and the durability caveat is + // logged instead of swallowed. Any other error keeps the existing failure path. + if (!(error instanceof PostCommitDurabilityError)) { + throw error + } + if (error.backupPath) { + await fs.unlink(error.backupPath).catch(() => {}) + } + console.warn( + `safeWriteJson: ${resolvedTargetPath ?? absoluteFilePath} is committed, but its directory entry may not be durable: ${ + error.cause instanceof Error ? error.cause.message : String(error.cause ?? error) + }`, + ) + } // If we reach here, the new file is successfully in place and any // backup has already been handled by safeWriteText. From a383eb20d91f871e6f2c5d000fd60ec351c2f7c9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 15:55:16 +0800 Subject: [PATCH 82/89] fix(diff-view): finalize the session when a cancellation lands during a teardown it does not own Port of `0974ad534` (U8, #1916) to U9. Same defect, open on four units at their current heads - #1915 (U6), #1916 (U8), #1917 (U9, this PR), #1918 (U7) - 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 | 142 passed. Green -> 146 passed. Negative control: removing the waiter finalization -> 4 failed | 142 passed, production file restored byte-exactly, post-restore run 146 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 | 45 +++++- .../editor/__tests__/DiffViewProvider.spec.ts | 153 +++++++++++++++++- 2 files changed, 189 insertions(+), 9 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 854569b966..5bc5497c9c 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 @@ -754,6 +768,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 @@ -915,7 +938,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() @@ -975,10 +998,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 { @@ -1074,6 +1110,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. @@ -1081,6 +1121,9 @@ 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: state 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 8b5f0142b6..664629c55a 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3281,9 +3281,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 @@ -3325,10 +3326,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() }) @@ -3368,7 +3502,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 @@ -3418,7 +3555,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 7067a7692f7d062baaa7fc1884100324c10cd7df Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 21:19:45 +0800 Subject: [PATCH 83/89] fix(task-history): a failed delete is reported by the stage that failed, not as a completed deletion Pre-merge row (Persistence Integrity, error) on this PR: `removeTaskFile` wrapped three different operations in one `try` and read any ENOENT out of it as "the file is gone". The lock-key resolution, the lock acquisition and the unlink all report ENOENT for different things, so a failure that left the file on disk evicted the cache entry and wrote the store through - the next reconciliation then contradicted the deletion the caller was told about. - The three stages are separated. A lock key that cannot be computed, and a lock that cannot be taken, are reported with a reason naming the stage; only the unlink's own result can prove the file is gone. - The unlink's outcome is captured inside the locked operation, because `withFileLock` rethrows the operation's error unchanged: a lock failure and an unlink failure otherwise arrive at the same place and cannot be told apart by their error code. - A lock that could not be taken at all is settled by asking the named path directly (lstat, which does not follow a symlink). proper-lockfile creates `.lock` with mkdir, and that reports ENOENT both when the directory that would hold the file is gone - nothing to delete - and when the key is an alias whose referent is missing while the named file is still right there. The lock's errno says nothing about the file, so it cannot be the evidence. - The cache entry and the write-through are preserved on every failure, and `deleteMany` still attempts the rest of the batch. Tests: two new cases - a lock key that cannot be resolved (a propagated, non-ENOENT realpath failure: `canonicalDirKey` walks up to the nearest existing ancestor for a missing directory, so "not there" is a key it can still compute) and an ENOENT from lock acquisition. Both assert the item stays in the cache, no write-through happens, and the reported reason names the stage. Red first: 2 failed | 15 passed. Green: 17 passed in the spec, 195 across `core/task-persistence`. Negative controls, one-to-one: treating a resolution failure as deleted -> exactly the resolution test fails; tolerating a lock-stage ENOENT -> exactly the lock test fails; removing the lstat evidence step -> the two pre-existing "missing file is a completed deletion" cases fail, which is what keeps the idempotent path honest; the whole method back to the original single-catch shape -> exactly the two new tests fail. Mutants restored byte-exactly and re-run at 17 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; `src/eslint-suppressions.json` untouched. --- src/core/task-persistence/TaskHistoryStore.ts | 99 +++++++++++++++++-- .../TaskHistoryStore.deleteSemantics.spec.ts | 63 ++++++++++++ 2 files changed, 152 insertions(+), 10 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index ae5a6e3e35..31aed12a13 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -297,10 +297,10 @@ export class TaskHistoryStore { * The unlink runs under the same per-file advisory lock as `safeWriteJson`, so a locked * read-modify-write (for example the settlement in `clearPendingActionIfMatching`) cannot * interleave with it. Eviction and the write-through happen only once the file is actually - * gone (unlinked, or confirmed absent with ENOENT). A lock failure or a non-ENOENT unlink - * failure leaves the item in place and is reported as a TaskHistoryDeleteError: a store that - * dropped the item while the file survived would report a deletion that the next - * reconciliation immediately contradicts. + * gone - unlinked, or reported absent by the unlink itself. A lock-key resolution failure, a + * lock that could not be taken, or any other unlink failure leaves the item in place and is + * reported as a TaskHistoryDeleteError: a store that dropped the item while the file survived + * would report a deletion that the next reconciliation immediately contradicts. */ async delete(taskId: string): Promise { return this.withLock(async () => { @@ -359,23 +359,102 @@ export class TaskHistoryStore { /** * Remove the per-task file under the shared lock and report whether the file is gone. * - * ENOENT counts as gone (there was nothing to delete); a lock timeout or any other unlink - * error means the file is still there, and the caller must not treat the item as deleted. + * Only the unlink's own ENOENT counts as gone: the file was there to delete and is not there + * now. Every other failure means the file may still exist and the item must stay in the store + * - a lock-key that cannot be resolved, a lock that could not be taken (proper-lockfile + * reports ENOENT for a missing key), a lock timeout, or any non-ENOENT unlink error. Those + * three stages reach the caller with a reason naming the stage, because "ENOENT" on its own + * does not say which of them happened, and a store that evicts an item whose file survived + * reports a deletion the next reconciliation contradicts. The one stage where an ENOENT may + * still close the deletion is a lock that could not be taken at all: there the verdict comes + * from an lstat of the named path, not from the lock's errno, so a file that reports itself + * absent is a completed deletion while a file that reports itself present is not. */ private async removeTaskFile(taskId: string): Promise<{ deleted: boolean; reason?: string; cause?: unknown }> { const filePath = await this.getTaskFilePath(taskId) + let lockKey: string try { // Lock the resolved publish target, not the path as spelled: proper-lockfile keys the // lock by the path it is given, so an alias and its referent would take two locks for // one file. The unlink still removes the named path. - await withFileLock(await this.lockKeyFor(filePath), () => fs.unlink(filePath)) + lockKey = await this.lockKeyFor(filePath) + } catch (resolutionError: unknown) { + // The key could not be computed, so the lock was never attempted and the unlink never + // ran. A dangling alias whose referent sits under a missing parent fails here with + // ENOENT while the alias itself is still on disk: that is not evidence of a deletion. + return { + deleted: false, + reason: `lock key could not be resolved (${errorCode(resolutionError) ?? "unknown error"})`, + cause: resolutionError, + } + } + + // The unlink's outcome is captured inside the locked operation rather than inferred from + // what withFileLock throws: that helper rethrows the operation's error unchanged, so a + // lock failure and an unlink failure arrive at the same place and cannot be told apart by + // their error code alone. + let unlinkOutcome: { outcome: "removed" | "absent" | "failed"; error?: unknown } | undefined + try { + await withFileLock(lockKey, async () => { + try { + await fs.unlink(filePath) + unlinkOutcome = { outcome: "removed" } + } catch (unlinkError: unknown) { + unlinkOutcome = { + outcome: errorCode(unlinkError) === "ENOENT" ? "absent" : "failed", + error: unlinkError, + } + } + }) + } catch (lockError: unknown) { + // The lock stage failed, so the unlink never ran. Its ENOENT cannot settle anything on + // its own: proper-lockfile creates .lock with mkdir, which reports ENOENT both when + // the directory that would hold the file is gone (nothing to delete) and when the key is + // an alias whose referent is missing (the named file may still be right there). Ask the + // named path itself rather than reading the lock's errno as a report about the file. + return await this._verdictAfterLockFailure(filePath, lockError) + } + + if (unlinkOutcome === undefined) { + // The lock was taken and released without the unlink running. Nothing was observed, + // so nothing may be reported as deleted. + return { deleted: false, reason: "the unlink did not run", cause: undefined } + } + if (unlinkOutcome.outcome === "removed" || unlinkOutcome.outcome === "absent") { return { deleted: true } - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") { + } + return { + deleted: false, + reason: errorCode(unlinkOutcome.error) ?? "unknown error", + cause: unlinkOutcome.error, + } + } + + /** + * What to report when the lock could not be taken at all. The file counts as gone only when + * the named path says so directly: lstat does not follow a symlink, so a dangling alias still + * reports itself present and the item stays in the store. Any other answer keeps the item and + * reports the lock failure, which is the only thing actually known. + */ + private async _verdictAfterLockFailure( + filePath: string, + lockError: unknown, + ): Promise<{ deleted: boolean; reason?: string; cause?: unknown }> { + const lockReason = `the per-file lock could not be taken (${errorCode(lockError) ?? "unknown error"})` + try { + await fs.lstat(filePath) + } catch (statError: unknown) { + if (errorCode(statError) === "ENOENT") { + // The named path is absent: there was nothing to delete, whatever the lock did. return { deleted: true } } - return { deleted: false, reason: errorCode(error) ?? "unknown error", cause: error } + return { + deleted: false, + reason: `${lockReason}; the file could not be checked (${errorCode(statError) ?? "unknown error"})`, + cause: statError, + } } + return { deleted: false, reason: lockReason, cause: lockError } } // ────────────────────────────── Reconciliation ────────────────────────────── diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index e2542565c6..94a1a157c7 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -264,6 +264,69 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { expect(vi.mocked(withFileLock)).toHaveBeenCalledWith(await canonicalKey(referentPath), expect.any(Function)) expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(aliasPath) }) + + it("keeps the item when the lock key cannot be resolved, instead of calling it deleted", async () => { + // The canonicalization inside resolveLockKey fails, so no lock key exists and no lock is + // ever attempted. Whatever the errno says, this call has not touched the file, and the + // item must stay in the store with a reason that names the stage that failed. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "alias-unresolvable" })) + const aliasPath = historyFilePath(storagePath, "alias-unresolvable") + const dirPath = path.dirname(aliasPath) + // The unlink, lock and write-through mocks are shared across cases, and the upsert + // above wrote through once, so clear them to make the assertions below about this delete. + vi.mocked(fs.unlink).mockClear() + vi.mocked(withFileLock).mockClear() + onWrite.mockClear() + // Not ENOENT: canonicalDirKey walks up to the nearest existing ancestor for a + // missing directory, so "not there" is a key it can still compute. Any other failure says + // nothing about the canonical form and is propagated, which is the resolution failure this + // path has to report rather than read as a deletion. + const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" }) + const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (target) => { + const targetPath = String(target) + if (targetPath === aliasPath || targetPath === dirPath) throw eacces + return actualFs.realpath(targetPath) + }) + let rejection: unknown + try { + await store.delete("alias-unresolvable") + } catch (error: unknown) { + rejection = error + } finally { + realpathSpy.mockRestore() + } + + expect(rejection).toBeInstanceOf(TaskHistoryDeleteError) + // No lock key, so no lock and no unlink: nothing this call did can account for the file + // being gone, and the reported reason has to say which stage failed. + expect(vi.mocked(withFileLock)).not.toHaveBeenCalled() + expect(fs.unlink).not.toHaveBeenCalled() + expect((rejection as TaskHistoryDeleteError).reason).toContain("lock key could not be resolved") + // The entry and its write-through state stay put: a later reconcile still sees the file. + expect(storeInternals(store).cache.has("alias-unresolvable")).toBe(true) + expect(onWrite).not.toHaveBeenCalled() + expect((rejection as TaskHistoryDeleteError).taskIds).toEqual(["alias-unresolvable"]) + }) + + it("does not treat an ENOENT from lock acquisition as a completed deletion", async () => { + // proper-lockfile creates .lock with mkdir and reports ENOENT when the directory + // that would hold it is missing. That is a failure to take the lock, not a report about + // the task file, so the named file has to be asked directly before anything is evicted. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "lock-enoent" })) + vi.mocked(fs.unlink).mockClear() + onWrite.mockClear() + vi.mocked(withFileLock).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + + await expect(store.delete("lock-enoent")).rejects.toThrow(TaskHistoryDeleteError) + + expect(fs.unlink).not.toHaveBeenCalled() + expect(storeInternals(store).cache.has("lock-enoent")).toBe(true) + expect(onWrite).not.toHaveBeenCalled() + }) }) describe("reconcile()", () => { From dffc5b3ea49ae9a7b6df66bec9528da1204a19fd Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 07:28:59 +0800 Subject: [PATCH 84/89] fix(test): clear the merge artifacts on the U7 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, from the duplicate import removal and main's own edits to that file); 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, in both directions). --- src/core/tools/__tests__/readFileTool.spec.ts | 1 - src/eslint-suppressions.json | 3462 ++++++++--------- .../indentation-reader-unicode.spec.ts | 6 + 3 files changed, 1737 insertions(+), 1732 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6b098f4b9a..1cb7eb0018 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -23,7 +23,6 @@ import type { Task } from "../../task/Task" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" -import type { Task } from "../../task/Task" import { ObservationRegistry } from "../../task/observationRegistry" import { computeVersionToken } from "../../../utils/versionToken" import { formatResponse } from "../../prompts/responses" diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 4f610a1960..53fc4cba92 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1,1732 +1,1732 @@ { - "__mocks__/fs/promises.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/ClineProvider.delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "__tests__/ClineProvider.history-resume-delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 70 - } - }, - "__tests__/abandonSubtask.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "__tests__/api-subtask.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/delegation-concurrent.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "__tests__/delegation-events.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "__tests__/migrateSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/nested-delegation-resume.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "__tests__/new-task-delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "activate/CodeActionProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "activate/__tests__/CodeActionProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "activate/__tests__/handleUri.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "activate/__tests__/registerCommands.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "activate/registerCodeActions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "activate/registerCommands.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "activate/registerTerminalActions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/anthropic-vertex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 31 - } - }, - "api/providers/__tests__/anthropic.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/base-openai-compatible-provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/base-provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/__tests__/bedrock-custom-arn.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "api/providers/__tests__/bedrock-error-handling.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/providers/__tests__/bedrock-inference-profiles.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "api/providers/__tests__/bedrock-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "api/providers/__tests__/bedrock-reasoning.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/bedrock.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 37 - } - }, - "api/providers/__tests__/deepseek.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "api/providers/__tests__/gemini-handler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "api/providers/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 33 - } - }, - "api/providers/__tests__/kenari.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/kimi-code.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/lite-llm.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 36 - } - }, - "api/providers/__tests__/lm-studio-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/__tests__/mimo.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "api/providers/__tests__/minimax.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/moonshot.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "api/providers/__tests__/native-ollama.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "api/providers/__tests__/openai-codex-native-tool-calls.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "api/providers/__tests__/openai-codex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "api/providers/__tests__/openai-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "api/providers/__tests__/openai-native-usage.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "api/providers/__tests__/openai-native.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 74 - } - }, - "api/providers/__tests__/openai-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/openai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/__tests__/opencode-go.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "api/providers/__tests__/poe.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/qwen-code-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/sambanova.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/unbound.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/vertex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/zai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/anthropic-vertex.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/anthropic.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/base-openai-compatible-provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/providers/base-provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/bedrock.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "api/providers/deepseek.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/fetchers/__tests__/kenari.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/kimi-code.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/lmstudio.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/fetchers/__tests__/moonshot.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/ollama.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/fetchers/__tests__/opencode-go.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/zoo-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/providers/fetchers/litellm.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/gemini.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/lite-llm.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/providers/lm-studio.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/mimo.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/moonshot.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/native-ollama.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/openai-codex.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 31 - } - }, - "api/providers/openai-native.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 28 - } - }, - "api/providers/openai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/openrouter.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/providers/poe.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/qwen-code.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/requesty.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/unbound.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/utils/__tests__/error-handler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "api/providers/utils/__tests__/image-generation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "api/providers/utils/__tests__/timeout-config.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/utils/error-handler.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/providers/xai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/transform/__tests__/ai-sdk.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/anthropic-filter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/transform/__tests__/bedrock-converse-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/gemini-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/mistral-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/__tests__/model-params.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/__tests__/openai-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 49 - } - }, - "api/transform/__tests__/r1-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/__tests__/reasoning.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/__tests__/responses-api-input.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/__tests__/responses-api-stream.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/zai-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/transform/ai-sdk.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/bedrock-converse-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/cache-strategy/__tests__/cache-strategy.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "api/transform/caching/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/gemini-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/openai-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/r1-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/responses-api-input.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/transform/responses-api-stream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/transform/zai-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/assistant-message/NativeToolCallParser.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/assistant-message/presentAssistantMessage.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/checkpoints/__tests__/checkpoint.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/checkpoints/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/condense/__tests__/condense.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/condense/__tests__/foldedFileContext.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "core/condense/__tests__/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 24 - } - }, - "core/config/ContextProxy.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/config/CustomModesManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/ProviderSettingsManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/config/__tests__/ContextProxy.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/__tests__/CustomModesManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/config/__tests__/ProviderSettingsManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/context-management/__tests__/context-management.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/context-tracking/__tests__/FileContextTracker.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/context/context-management/__tests__/context-error-handling.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/context/context-management/context-error-handling.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/diff/stats.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/environment/__tests__/getEnvironmentDetails.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/ignore/__tests__/RooIgnoreController.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/mentions/__tests__/processUserContentMentions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/mentions/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/mentions/processUserContentMentions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/message-manager/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "core/message-manager/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/prompts/__tests__/add-custom-instructions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/__tests__/get-prompt-component.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/__tests__/responses-rooignore.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "core/prompts/__tests__/system-prompt.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/prompts/sections/__tests__/custom-instructions-global.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "core/prompts/sections/__tests__/custom-instructions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 55 - } - }, - "core/prompts/sections/__tests__/system-info.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "core/prompts/tools/filter-tools-for-mode.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/tools/native-tools/__tests__/read_file.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/task-persistence/__tests__/TaskHistoryStore.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/task-persistence/__tests__/importRooTaskHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/task-persistence/__tests__/taskMessages.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/task-persistence/apiMessages.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/task/Task.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "core/task/__tests__/Task.dispose.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/Task.persistence.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/task/__tests__/Task.sticky-profile-race.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/Task.throttle.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 24 - } - }, - "core/task/__tests__/apiConversationHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 23 - } - }, - "core/task/__tests__/ask-clear-approval-buttons.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "core/task/__tests__/ask-queued-message-drain.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "core/task/__tests__/grace-retry-errors.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/task/__tests__/grounding-sources.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/task/__tests__/native-tools-filtering.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/new-task-isolation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/task/__tests__/reasoning-preservation.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "core/task/__tests__/task-tool-history.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/apiConversationHistory.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/tools/BaseTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/CodebaseSearchTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/GenerateImageTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/NewTaskTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/ReadFileTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/tools/ToolRepetitionDetector.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/UpdateTodoListTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/UseMcpToolTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/ReadCommandOutputTool.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/tools/__tests__/ToolRepetitionDetector.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/askFollowupQuestionTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "core/tools/__tests__/attemptCompletionTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 11 - } - }, - "core/tools/__tests__/editFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/tools/__tests__/editTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/__tests__/executeCommand.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/tools/__tests__/executeCommandTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/tools/__tests__/generateImageTool.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "core/tools/__tests__/listFilesTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "core/tools/__tests__/mcpServerRestriction.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "core/tools/__tests__/newTaskTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 26 - } - }, - "core/tools/__tests__/readFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 96 - } - }, - "core/tools/__tests__/runSlashCommandTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/searchReplaceTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/skillTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/updateTodoListTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/useMcpToolTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "core/tools/__tests__/validateToolUse.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/writeToFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/tools/helpers/toolResultFormatting.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/validateToolUse.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/webview/ClineProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "core/webview/__tests__/ClineProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 196 - } - }, - "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/webview/__tests__/ClineProvider.taskHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/webview/__tests__/checkpointRestoreHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/diagnosticsHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "core/webview/__tests__/messageEnhancer.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/webview/__tests__/skillsMessageHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/telemetrySettingsTracking.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/webviewMessageHandler.delete.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "core/webview/__tests__/webviewMessageHandler.edit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 13 - } - }, - "core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 56 - } - }, - "core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/webviewMessageHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "core/webview/messageEnhancer.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/webviewMessageHandler.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "extension/__tests__/api-delete-queued-message.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "extension/__tests__/api-send-message.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "extension/api.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "i18n/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "i18n/setup.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/editor/DiffViewProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/editor/__tests__/DiffViewProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 310 - } - }, - "integrations/editor/__tests__/EditorUtils.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "integrations/kimi-code/__tests__/oauth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/misc/__tests__/export-markdown.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/misc/__tests__/extract-text.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "integrations/misc/__tests__/line-counter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/misc/__tests__/open-file.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 11 - } - }, - "integrations/misc/__tests__/performance/processCarriageReturns.benchmark.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "integrations/terminal/__tests__/OutputInterceptor.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "integrations/terminal/__tests__/TerminalProcess.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "integrations/terminal/__tests__/TerminalProcess.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/TerminalProcessInterpretExitCode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "integrations/terminal/__tests__/TerminalProfile.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "integrations/terminal/__tests__/TerminalRegistry.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "integrations/terminal/__tests__/setupTerminalTests.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/bashStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/cmdStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/streamUtils/pwshStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/theme/getTheme.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "services/__tests__/zoo-code-auth.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/checkpoints/__tests__/ShadowCheckpointService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/__tests__/config-manager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/__tests__/manager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 84 - } - }, - "services/code-index/__tests__/orchestrator.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 23 - } - }, - "services/code-index/__tests__/service-factory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 43 - } - }, - "services/code-index/embedders/__tests__/bedrock.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "services/code-index/embedders/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/mistral.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/ollama.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 15 - } - }, - "services/code-index/embedders/__tests__/openai-compatible.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 28 - } - }, - "services/code-index/embedders/__tests__/openai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "services/code-index/embedders/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/bedrock.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/embedders/ollama.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/openai-compatible.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/openai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/openrouter.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/interfaces/vector-store.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/orchestrator.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/processors/__tests__/file-watcher.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "services/code-index/processors/__tests__/parser.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "services/code-index/processors/__tests__/scanner.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 26 - } - }, - "services/code-index/processors/file-watcher.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/processors/scanner.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/semble/__tests__/provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "services/code-index/semble/__tests__/semble-cli.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/semble/__tests__/semble-downloader.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 61 - } - }, - "services/code-index/semble/provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/semble/semble-cli.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/shared/__tests__/validation-helpers.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/shared/validation-helpers.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/code-index/vector-store/__tests__/qdrant-client.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 87 - } - }, - "services/code-index/vector-store/qdrant-client.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/glob/__tests__/gitignore-integration.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/glob/__tests__/gitignore-test.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/glob/__tests__/list-files-limit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "services/glob/__tests__/list-files.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "services/marketplace/MarketplaceManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/marketplace/SimpleInstaller.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "services/marketplace/__tests__/MarketplaceManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/marketplace/__tests__/SimpleInstaller.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "services/marketplace/__tests__/marketplace-setting-check.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/mcp/McpHub.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "services/mcp/McpOAuthClientProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/mcp/McpServerManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/mcp/__tests__/McpHub.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 150 - } - }, - "services/mcp/__tests__/McpOAuthClientProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "services/mcp/__tests__/SecretStorageService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/mcp/utils/__tests__/callbackServer.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/mcp/utils/__tests__/oauth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "services/mcp/utils/callbackServer.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/mcp/utils/oauth.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/mdm/__tests__/MdmService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/ripgrep/__tests__/diagnostic.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/roo-config/__tests__/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "services/roo-config/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/rules/__tests__/rules.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/search/__tests__/file-search.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/skills/__tests__/SkillsManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/tree-sitter/__tests__/helpers.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/tree-sitter/__tests__/markdownParser.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "shared/__tests__/api.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "shared/__tests__/embeddingModels.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/__tests__/modes-empty-prompt-component.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/api.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "shared/checkExistApiConfig.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/cost.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/parse-command.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/support-prompt.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "shared/tools.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/utils/__tests__/requesty.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/__tests__/autoImportSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "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 - } - }, - "utils/tts.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "vitest.setup.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - } -} + "__mocks__/fs/promises.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/ClineProvider.delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "__tests__/ClineProvider.history-resume-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 70 + } + }, + "__tests__/abandonSubtask.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "__tests__/api-subtask.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/delegation-concurrent.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "__tests__/delegation-events.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "__tests__/migrateSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/nested-delegation-resume.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "__tests__/new-task-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "activate/CodeActionProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "activate/__tests__/CodeActionProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "activate/__tests__/handleUri.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "activate/__tests__/registerCommands.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "activate/registerCodeActions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "activate/registerCommands.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "activate/registerTerminalActions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/anthropic-vertex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "api/providers/__tests__/anthropic.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/base-openai-compatible-provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/base-provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/__tests__/bedrock-custom-arn.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "api/providers/__tests__/bedrock-error-handling.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/providers/__tests__/bedrock-inference-profiles.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 27 + } + }, + "api/providers/__tests__/bedrock-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "api/providers/__tests__/bedrock-reasoning.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/bedrock.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 37 + } + }, + "api/providers/__tests__/deepseek.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "api/providers/__tests__/gemini-handler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "api/providers/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 33 + } + }, + "api/providers/__tests__/kenari.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/kimi-code.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/lite-llm.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 36 + } + }, + "api/providers/__tests__/lm-studio-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/mimo.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "api/providers/__tests__/minimax.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/moonshot.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "api/providers/__tests__/native-ollama.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "api/providers/__tests__/openai-codex-native-tool-calls.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "api/providers/__tests__/openai-codex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "api/providers/__tests__/openai-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "api/providers/__tests__/openai-native-usage.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "api/providers/__tests__/openai-native.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 74 + } + }, + "api/providers/__tests__/openai-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/openai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/__tests__/opencode-go.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "api/providers/__tests__/poe.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/qwen-code-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/sambanova.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/unbound.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/vertex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/zai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/anthropic-vertex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/anthropic.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/base-openai-compatible-provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/providers/base-provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/bedrock.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "api/providers/deepseek.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/fetchers/__tests__/kenari.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/kimi-code.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/lmstudio.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/fetchers/__tests__/moonshot.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/ollama.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/fetchers/__tests__/opencode-go.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/zoo-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/fetchers/litellm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/gemini.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/lite-llm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/lm-studio.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/mimo.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/moonshot.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/native-ollama.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/openai-codex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "api/providers/openai-native.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 28 + } + }, + "api/providers/openai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/openrouter.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/providers/poe.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/qwen-code.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/requesty.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/unbound.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/utils/__tests__/error-handler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "api/providers/utils/__tests__/image-generation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "api/providers/utils/__tests__/timeout-config.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/utils/error-handler.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/providers/xai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/transform/__tests__/ai-sdk.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/anthropic-filter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/__tests__/bedrock-converse-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/gemini-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/mistral-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/__tests__/model-params.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/__tests__/openai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 49 + } + }, + "api/transform/__tests__/r1-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/__tests__/reasoning.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/__tests__/responses-api-input.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/__tests__/responses-api-stream.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/zai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/transform/ai-sdk.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/bedrock-converse-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/cache-strategy/__tests__/cache-strategy.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "api/transform/caching/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/gemini-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/openai-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/r1-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/responses-api-input.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/responses-api-stream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/transform/zai-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/assistant-message/NativeToolCallParser.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/assistant-message/presentAssistantMessage.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/checkpoints/__tests__/checkpoint.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/checkpoints/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/condense/__tests__/condense.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/condense/__tests__/foldedFileContext.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "core/condense/__tests__/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 24 + } + }, + "core/config/ContextProxy.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/config/CustomModesManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/ProviderSettingsManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/config/__tests__/ContextProxy.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/__tests__/CustomModesManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/config/__tests__/ProviderSettingsManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/context-management/__tests__/context-management.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/context-tracking/__tests__/FileContextTracker.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/context/context-management/__tests__/context-error-handling.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/context/context-management/context-error-handling.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/diff/stats.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/environment/__tests__/getEnvironmentDetails.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/ignore/__tests__/RooIgnoreController.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/mentions/__tests__/processUserContentMentions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/mentions/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/mentions/processUserContentMentions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/message-manager/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "core/message-manager/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/prompts/__tests__/add-custom-instructions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/__tests__/get-prompt-component.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/__tests__/responses-rooignore.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "core/prompts/__tests__/system-prompt.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/prompts/sections/__tests__/custom-instructions-global.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "core/prompts/sections/__tests__/custom-instructions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 55 + } + }, + "core/prompts/sections/__tests__/system-info.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "core/prompts/tools/filter-tools-for-mode.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/tools/native-tools/__tests__/read_file.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/task-persistence/__tests__/TaskHistoryStore.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/task-persistence/__tests__/importRooTaskHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task-persistence/__tests__/taskMessages.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/task-persistence/apiMessages.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/task/Task.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "core/task/__tests__/Task.dispose.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/Task.persistence.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/task/__tests__/Task.sticky-profile-race.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/Task.throttle.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 24 + } + }, + "core/task/__tests__/apiConversationHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 23 + } + }, + "core/task/__tests__/ask-clear-approval-buttons.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "core/task/__tests__/ask-queued-message-drain.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "core/task/__tests__/grace-retry-errors.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/task/__tests__/grounding-sources.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/task/__tests__/native-tools-filtering.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/new-task-isolation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task/__tests__/reasoning-preservation.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "core/task/__tests__/task-tool-history.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/apiConversationHistory.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/tools/BaseTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/CodebaseSearchTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/GenerateImageTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/NewTaskTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/ReadFileTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/tools/ToolRepetitionDetector.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/UpdateTodoListTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/UseMcpToolTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/ReadCommandOutputTool.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/tools/__tests__/ToolRepetitionDetector.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/askFollowupQuestionTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "core/tools/__tests__/attemptCompletionTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "core/tools/__tests__/editFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/tools/__tests__/editTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/__tests__/executeCommand.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/tools/__tests__/executeCommandTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/tools/__tests__/generateImageTool.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "core/tools/__tests__/listFilesTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "core/tools/__tests__/mcpServerRestriction.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "core/tools/__tests__/newTaskTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "core/tools/__tests__/readFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 94 + } + }, + "core/tools/__tests__/runSlashCommandTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/searchReplaceTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/skillTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/updateTodoListTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/useMcpToolTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "core/tools/__tests__/validateToolUse.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/writeToFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/tools/helpers/toolResultFormatting.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/validateToolUse.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/webview/ClineProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "core/webview/__tests__/ClineProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 196 + } + }, + "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 27 + } + }, + "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/webview/__tests__/ClineProvider.taskHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/webview/__tests__/checkpointRestoreHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/diagnosticsHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/webview/__tests__/messageEnhancer.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/webview/__tests__/skillsMessageHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/telemetrySettingsTracking.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/webviewMessageHandler.delete.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "core/webview/__tests__/webviewMessageHandler.edit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 13 + } + }, + "core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 56 + } + }, + "core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/webviewMessageHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "core/webview/messageEnhancer.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/webviewMessageHandler.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "extension/__tests__/api-delete-queued-message.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "extension/__tests__/api-send-message.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "extension/api.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "i18n/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "i18n/setup.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/editor/DiffViewProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/editor/__tests__/DiffViewProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 310 + } + }, + "integrations/editor/__tests__/EditorUtils.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "integrations/kimi-code/__tests__/oauth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/misc/__tests__/export-markdown.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/misc/__tests__/extract-text.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "integrations/misc/__tests__/line-counter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/misc/__tests__/open-file.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "integrations/misc/__tests__/performance/processCarriageReturns.benchmark.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "integrations/terminal/__tests__/OutputInterceptor.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "integrations/terminal/__tests__/TerminalProcess.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "integrations/terminal/__tests__/TerminalProcess.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/TerminalProcessInterpretExitCode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "integrations/terminal/__tests__/TerminalProfile.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "integrations/terminal/__tests__/TerminalRegistry.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "integrations/terminal/__tests__/setupTerminalTests.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/bashStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/cmdStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/streamUtils/pwshStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/theme/getTheme.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "services/__tests__/zoo-code-auth.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/checkpoints/__tests__/ShadowCheckpointService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/__tests__/config-manager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/__tests__/manager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 84 + } + }, + "services/code-index/__tests__/orchestrator.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 23 + } + }, + "services/code-index/__tests__/service-factory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 43 + } + }, + "services/code-index/embedders/__tests__/bedrock.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "services/code-index/embedders/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/mistral.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/ollama.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 15 + } + }, + "services/code-index/embedders/__tests__/openai-compatible.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 28 + } + }, + "services/code-index/embedders/__tests__/openai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "services/code-index/embedders/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/bedrock.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/embedders/ollama.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/openai-compatible.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/openai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/openrouter.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/interfaces/vector-store.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/orchestrator.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/processors/__tests__/file-watcher.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "services/code-index/processors/__tests__/parser.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "services/code-index/processors/__tests__/scanner.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "services/code-index/processors/file-watcher.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/processors/scanner.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/semble/__tests__/provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "services/code-index/semble/__tests__/semble-cli.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/semble/__tests__/semble-downloader.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 61 + } + }, + "services/code-index/semble/provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/semble/semble-cli.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/shared/__tests__/validation-helpers.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/shared/validation-helpers.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/code-index/vector-store/__tests__/qdrant-client.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 87 + } + }, + "services/code-index/vector-store/qdrant-client.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/glob/__tests__/gitignore-integration.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/glob/__tests__/gitignore-test.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/glob/__tests__/list-files-limit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "services/glob/__tests__/list-files.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "services/marketplace/MarketplaceManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/marketplace/SimpleInstaller.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "services/marketplace/__tests__/MarketplaceManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/marketplace/__tests__/SimpleInstaller.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "services/marketplace/__tests__/marketplace-setting-check.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/mcp/McpHub.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "services/mcp/McpOAuthClientProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/mcp/McpServerManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/mcp/__tests__/McpHub.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 150 + } + }, + "services/mcp/__tests__/McpOAuthClientProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "services/mcp/__tests__/SecretStorageService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/mcp/utils/__tests__/callbackServer.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/mcp/utils/__tests__/oauth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "services/mcp/utils/callbackServer.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/mcp/utils/oauth.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/mdm/__tests__/MdmService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/ripgrep/__tests__/diagnostic.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/roo-config/__tests__/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "services/roo-config/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/rules/__tests__/rules.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/search/__tests__/file-search.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/skills/__tests__/SkillsManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/tree-sitter/__tests__/helpers.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/tree-sitter/__tests__/markdownParser.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "shared/__tests__/api.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "shared/__tests__/embeddingModels.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/__tests__/modes-empty-prompt-component.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/api.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "shared/checkExistApiConfig.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/cost.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/parse-command.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/support-prompt.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "shared/tools.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/utils/__tests__/requesty.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/__tests__/autoImportSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "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 + } + }, + "utils/tts.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "vitest.setup.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + } +} \ No newline at end of file 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 177c2816e186f4ac80fe634aee02f3a1330b47c9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 11:02:44 +0800 Subject: [PATCH 85/89] fix(apply-diff): carry the outside-workspace approval into both guarded save paths The finding, quoted: "apply_diff cannot publish an approved write outside the workspace. Both save paths now go through guardedWrite. apply_diff does not pass the outside-workspace approval flag or the canonical target that the guard checks." apply_diff asks the user for approval on both save paths - the focus-disruption path through saveDirectly, the diff-view path through saveChanges - and then handed neither save call the two arguments the guard reads. guardedWrite refuses a target outside every workspace root unless the tool layer says the user approved it, and it binds the publish to the canonical identity that was approved. So a write the user had just approved was rejected by the tool's own guard, on both paths, and apply_diff could not publish outside the workspace at all. Fix, mirroring the sibling tools (ApplyPatchTool, WriteToFileTool, EditTool, EditFileTool, SearchReplaceTool all already do this): - isOutsideWorkspace is computed from the resolved absolute target, and approvedCanonicalTarget is captured with canonicalizeForApproval BEFORE the approval is asked, so a name repointed between the approval and the publish is refused rather than publishing to whatever the name points at by then. It sits inside the existing try, so an unresolvable target goes through the tool's normal error handling and diff-view reset. - saveDirectly receives undefined for completeOverride (this call claims no completeness of its own), then the approval flag and the canonical identity; saveChanges receives the flag and the identity. - isOutsideWorkspace is also added to sharedMessageProps, as the sibling tools do, so the approval the user sees names an out-of-workspace target. Test changes: - New: carries the outside-workspace approval and its canonical identity to the guarded save (focus-disruption path) and carries the approval through the diff-view save path too - the two save paths are separate call sites, and the defect was in both. - Re-pointed: three existing assertions pinned the old argument lists (publishes through the guarded saveDirectly with edit kind, passes the edit kind to the diff-view save path, still performs the diff read when the pre-read stat fails). They now pin the full arity, including the two new arguments, so the shape they encode is the fixed contract rather than the defect. - The workspace-root test and the canonical identity are host and editor state, so they are doubled at their own module boundaries (isPathOutsideWorkspace, canonicalizeForApproval) and the assertions are about what the tool hands to the guarded save path. Measured: 11 passed in applyDiffTool.guardedWrite.spec.ts, and 186 passed / 5 skipped across the whole core/tools spec set (applyDiff, applyPatch, guardedWrite, writeToFile, editTool, editFileTool) with no fallout. Negative control - dropping the new arguments from both call sites, the pre-fix shape - turns exactly the two new tests red, together with the three re-pointed ones that now pin the fixed arity; the mutant was restored byte-exactly (35102047d2). tsc --noEmit with a local paths override: 0 errors. eslint . --ext=ts --max-warnings=0 exit 0, no suppression-count change. --- src/core/tools/ApplyDiffTool.ts | 29 ++++++- .../applyDiffTool.guardedWrite.spec.ts | 76 ++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 98e642a6c8..27c7b20790 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 { isPathOutsideWorkspace } from "../../utils/pathUtils" import { versionTokenOfStat } from "../../utils/versionToken" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" @@ -15,6 +16,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import type { ToolUse } from "../../shared/tools" +import { canonicalizeForApproval } from "./guardedWrite" import { BaseTool, ToolCallbacks } from "./BaseTool" interface ApplyDiffParams { @@ -165,10 +167,24 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { // Check if file is write-protected const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + // The guard in guardedWrite refuses a target outside every workspace root unless the tool + // layer says the user approved this one, and it binds the publish to the identity that was + // approved. apply_diff has both save paths behind an askApproval, so it has to carry both + // through; without them an approved write outside the workspace is rejected by its own guard. + 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. Inside the try so an unresolvable target goes through + // the tool's normal error handling and the diff-view reset. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined + const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", path: getReadablePath(task.cwd, relPath), diff: diffContent, + isOutsideWorkspace, } if (isPreventFocusDisruptionEnabled) { @@ -211,6 +227,11 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { 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, + approvedCanonicalTarget, ) } else { // Original behavior with diff view @@ -250,7 +271,13 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + "edit", + isOutsideWorkspace, + approvedCanonicalTarget, + ) } // Track file edit operation diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index ef2608dbc0..7ad8fcd1a2 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -7,6 +7,8 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import type { Task } from "../../task/Task" +import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import { canonicalizeForApproval } from "../guardedWrite" import { ApplyDiffTool } from "../ApplyDiffTool" import { ObservationRegistry } from "../../task/observationRegistry" @@ -37,6 +39,20 @@ vi.mock("../../prompts/responses", () => ({ }, })) +// The outside-workspace approval plumbing is what the last two tests exercise: whether a path is +// outside every workspace root, and the canonical identity of an approved target, are both host and +// editor state, so they are doubled here and the assertions are about what the tool hands to the +// guarded save path. +vi.mock("../../../utils/pathUtils", async () => { + const actual = await vi.importActual("../../../utils/pathUtils") + return { ...actual, isPathOutsideWorkspace: vi.fn(() => false) } +}) + +vi.mock("../guardedWrite", async () => { + const actual = await vi.importActual("../guardedWrite") + return { ...actual, canonicalizeForApproval: vi.fn(async () => "/canonical/outside/thing.ts") } +}) + vi.mock("../../diff/stats", () => ({ sanitizeUnifiedDiff: vi.fn((diff: string) => diff), computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), @@ -173,6 +189,10 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "edit", + // No completeness claim of its own, and an in-workspace target needs no approval flag. + undefined, + false, + undefined, ) expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockTask.didEditFile).toBe(true) @@ -217,7 +237,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit", false, undefined) expect(mockSaveDirectly).not.toHaveBeenCalled() expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockHandleError).not.toHaveBeenCalled() @@ -344,6 +364,9 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "edit", + undefined, + false, + undefined, ) expect(mockHandleError).not.toHaveBeenCalled() // Both bracketing stats were still attempted - the failure is not swallowed into @@ -371,4 +394,55 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockHandleError).not.toHaveBeenCalled() expect(stat).toHaveBeenCalledTimes(2) }) + +it("carries the outside-workspace approval and its canonical identity to the guarded save", async () => { + // guardedWrite refuses a target outside every workspace root unless the tool layer says the + // user approved this one, and it binds the publish to the identity that was approved. + // apply_diff asks for approval on both save paths, so an approved write has to reach the guard + // with both - otherwise the tool's own guard rejects what the user just approved. + vi.mocked(isPathOutsideWorkspace).mockReturnValue(true) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockAskApproval).toHaveBeenCalled() + // The identity is captured from the resolved target, not the caller's spelling. + expect(canonicalizeForApproval).toHaveBeenCalledWith(expect.stringContaining("thing.ts"), "src/thing.ts") + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + undefined, + true, + "/canonical/outside/thing.ts", + ) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("carries the approval through the diff-view save path too", async () => { + // The other save path is the one taken when the focus-disruption experiment is off; it needs + // the same two arguments or an approved write fails on half the configurations. + vi.mocked(isPathOutsideWorkspace).mockReturnValue(true) + 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", true, "/canonical/outside/thing.ts") + expect(mockSaveDirectly).not.toHaveBeenCalled() + }) + }) From eacfedcd8dbaea6d7f645d6cee91bd2aaa409035 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 11:18:36 +0800 Subject: [PATCH 86/89] refactor(safety,apply-patch): close six review threads on duplicated helpers, dead code and test hygiene Six of the seven open threads on this PR, each with the disposition the code justified rather than the row text assumed: 1. safeWriteJson reused a private copy of the errno-code test (_scopeErrorCode) and a third inline copy of the same check (isAbsent). errorCode is now exported from safeWriteText and used for both, so the question of whether an error is ENOENT has one answer in the tree. _resolveScopeRoot is deliberately NOT folded into canonicalDirKey: canonicalDirKey canonicalizes the PARENT of a file and re-joins its name, while the scope root is a directory canonicalized as itself, and at the filesystem root it falls back to the lexical spelling rather than throwing. Folding one into the other would change which spelling decides a scope. 2. ApplyPatchTool's move path computed moveCanonicalTarget only when the destination was outside every workspace root, and returned early on that same condition a few lines later. The value was therefore always undefined at the publish, and the second isPathOutsideWorkspace call at the publish was always false. Checked before deleting, because dead code and a missed wiring need different treatment: the early return is a deliberate policy (a move outside the workspace is refused with a tool error), so the capture was dead, not missing. It was also not harmless - canonicalizeForApproval can throw, so a destination whose name could not be resolved surfaced an exception where the user should have got the tool error. The capture is gone, the publish passes false and undefined, and the comment states the guarantee the code now relies on. 3. safeWriteJson.test.ts carried two tests whose titles duplicated earlier ones and whose scenarios were the same ordering checks written without symlinks - the comment claimed they existed to run on every lane, but the earlier pair is not symlink-based either (the symlink case is a separate test.skipIf on win32). Both duplicates removed. Note the it( count for this file goes DOWN by two: this is a duplicate removal, not lost coverage - the surviving pair asserts the same ordering, and the row asked for it. 4. TaskHistoryStore.deleteSemantics used mockRejectedValue where one rejection was meant; mockClear does not reset implementations, so the rejection outlived the test and later tests were decided by it. Now mockRejectedValueOnce. 5. writeToFileTool.spec restored the realpath and outside-workspace doubles after the assertions, so a failing assertion left realpath rejecting and every later test in the file failed for the wrong reason. Both restores moved into a finally, matching the test above it. 6. safeWriteText.integration.spec was titled for the commit rename but passed backup:true, so the run stopped one step earlier at the backup copy hitting the same EISDIR and the commit rename never ran. The backup flag is gone: the failure now happens where the title says it happens. Measured: 95 passed, 9 skipped across the six affected specs (safeWriteJson, safeWriteJson.lockKey, applyPatchTool.execute, writeToFileTool, TaskHistoryStore.deleteSemantics, safeWriteText.integration). tsc --noEmit with a local paths override: 0 errors. eslint . --ext=ts --max-warnings=0 exit 0, no suppression-count change. Coverage gap recorded rather than papered over: no test in this repo drives a move whose destination is outside every workspace root, so the failure path this change removes is verified by reading the control flow (the early return on the same condition, and the throw the capture could raise before the tool error), not by a test. A move-path test is worth its own row. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 4 +- src/core/tools/ApplyPatchTool.ts | 19 ++++---- .../tools/__tests__/writeToFileTool.spec.ts | 23 +++++---- .../safeWriteText.integration.spec.ts | 8 ++-- src/services/file-safety/safeWriteText.ts | 6 ++- src/utils/__tests__/safeWriteJson.test.ts | 47 ------------------- src/utils/safeWriteJson.ts | 18 ++----- 7 files changed, 40 insertions(+), 85 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 94a1a157c7..8aaf4a4c1b 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -319,7 +319,9 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { await store.upsert(makeHistoryItem({ id: "lock-enoent" })) vi.mocked(fs.unlink).mockClear() onWrite.mockClear() - vi.mocked(withFileLock).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + // Once, not for good: mockClear() does not reset implementations, and this test only needs + // the next lock attempt to fail - a mock still rejecting would decide later tests for us. + vi.mocked(withFileLock).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) await expect(store.delete("lock-enoent")).rejects.toThrow(TaskHistoryDeleteError) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 941a9fab38..815b5dcd65 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -460,12 +460,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") @@ -531,9 +531,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__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index efdc6285bd..fa71ffa8fb 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -528,17 +528,20 @@ describe("writeToFileTool", () => { const { realpath } = await import("fs/promises") vi.mocked(realpath).mockRejectedValue({ code: "EPERM" }) mockedIsPathOutsideWorkspace.mockReturnValue(true) + try { + await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) - 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) + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + } finally { + // Restore both doubles here rather than after the assertions: a failing assertion would + // leave realpath rejecting and the target flagged outside-workspace, and every later + // test in this file - which assumes an in-workspace target that resolves to itself - + // would then fail for the wrong reason. The test above this one uses the same pattern. + 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 () => { diff --git a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts index 81d758349e..b965765ac9 100644 --- a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts @@ -32,14 +32,16 @@ describe("safeWriteText against a real filesystem", () => { }) it("leaves the target bytes untouched when the commit cannot replace it", async () => { - // A regular file cannot be renamed over a directory, so the backup copy and - // the commit both fail on a real filesystem with no mocking at all. + // A regular file cannot be renamed over a directory, so the commit rename itself fails on a + // real filesystem with no mocking at all. No backup is asked for: with backup:true the run + // stopped one step earlier, at the backup copy hitting the same EISDIR, and the commit rename + // this test is named for never ran. const targetPath = path.join(dir, "target-dir") await fs.mkdir(targetPath) const inside = path.join(targetPath, "payload.txt") await fs.writeFile(inside, "original bytes") - await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow() + await expect(safeWriteText(targetPath, "new data")).rejects.toThrow() // The directory and its content are exactly as they were, and no backup copy // or staging directory was left behind next to them. diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 835289981b..b2bd0c46bd 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -183,7 +183,11 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise { }, ) - // Ordering matters for the security guarantee: proper-lockfile creates - // ${lockKey}.lock beside the lock key, and the key is the symlink referent. If - // confinement were checked only after the lock, an out-of-scope target would first - // create a lock directory outside the scope. A lock mock that throws if reached - // proves the check runs first. Written without symlinks so it runs on every lane. - test("rejects an out-of-scope target before the advisory lock is taken", async () => { - vi.resetModules() - const projectDir = path.join(tempDir, "order-project-plain") - await fs.mkdir(projectDir) - const outside = path.join(tempDir, "order-outside-plain.json") - - const realLockfile = await vi.importActual("proper-lockfile") - const lockMockFn = vi.fn(async () => { - throw new Error("lock taken for an out-of-scope target (test)") - }) - vi.doMock("proper-lockfile", () => ({ ...realLockfile, lock: lockMockFn })) - const { safeWriteJson: lockedSafeWriteJson } = await import("../safeWriteJson") - - try { - await expect(lockedSafeWriteJson(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( - /resolves outside the confined directory/, - ) - expect(lockMockFn).not.toHaveBeenCalled() - const entries = await fs.readdir(tempDir) - expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) - } finally { - vi.doUnmock("proper-lockfile") - vi.resetModules() - } - }) - - test("does not create the parent directory of an out-of-scope confined target", async () => { - const projectDir = path.join(tempDir, "scope-dir-project") - await fs.mkdir(projectDir) - // The parent does not exist yet: the mkdir in safeWriteJson would create it - - // a filesystem change outside confineTo - before the confinement check rejected - // the write. - const outside = path.join(tempDir, "scope-missing-parent", "nested.json") - - await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( - /resolves outside the confined directory/, - ) - - const entries = await fs.readdir(tempDir) - expect(entries).not.toContain("scope-missing-parent") - expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) - }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index b96a2d31f4..9dd7975e61 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -5,6 +5,7 @@ import { JsonStreamStringify } from "json-stream-stringify" import { acquireFileLock } from "./fileLock" import { + errorCode, PostCommitDurabilityError, resolveLockKey, resolvePublishTarget, @@ -77,7 +78,7 @@ async function _resolveScopeRoot(confineTo: string): Promise { // 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") { + if (errorCode(error) !== "ENOENT") { throw error } const missing: string[] = [] @@ -93,7 +94,7 @@ async function _resolveScopeRoot(confineTo: string): Promise { const real = await fs.realpath(ancestor) return path.join(real, ...missing.reverse()) } catch (innerError: unknown) { - if (_scopeErrorCode(innerError) !== "ENOENT") { + if (errorCode(innerError) !== "ENOENT") { throw innerError } } @@ -115,12 +116,6 @@ function _escapesScope(scopeRoot: string, candidate: string): boolean { ) } -function _scopeErrorCode(error: unknown): string | undefined { - return typeof error === "object" && error !== null && "code" in error - ? (error as { code?: string }).code - : undefined -} - /** * Safely writes JSON data to a file. * - Creates parent directories if they don't exist @@ -304,12 +299,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // 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) { + if (errorCode(cleanupError) !== "ENOENT") { console.error( `[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`, cleanupError, From d368a6eee31f84e63259d0d2aad94995298a35fa Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 11:19:47 +0800 Subject: [PATCH 87/89] style(eslint-suppressions): format the suppressions file as prettier wants it The compile job's Check formatting step fails on this file ([warn] src/eslint-suppressions.json). Verified on the commit CI actually checks - refs/pull/1917/merge, f32fee754 - where the file is byte-identical to the copy on this branch, so formatting it here clears the merge ref too. Formatting only: parsed before and after, every key and every count identical (346 keys, zero key diffs, same total suppressed count). No suppression count moved. --- src/eslint-suppressions.json | 3462 +++++++++++++++++----------------- 1 file changed, 1731 insertions(+), 1731 deletions(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 53fc4cba92..24d0d226d3 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1,1732 +1,1732 @@ { - "__mocks__/fs/promises.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/ClineProvider.delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "__tests__/ClineProvider.history-resume-delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 70 - } - }, - "__tests__/abandonSubtask.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "__tests__/api-subtask.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/delegation-concurrent.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "__tests__/delegation-events.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "__tests__/migrateSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/nested-delegation-resume.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "__tests__/new-task-delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "activate/CodeActionProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "activate/__tests__/CodeActionProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "activate/__tests__/handleUri.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "activate/__tests__/registerCommands.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "activate/registerCodeActions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "activate/registerCommands.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "activate/registerTerminalActions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/anthropic-vertex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 31 - } - }, - "api/providers/__tests__/anthropic.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/base-openai-compatible-provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/base-provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/__tests__/bedrock-custom-arn.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "api/providers/__tests__/bedrock-error-handling.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/providers/__tests__/bedrock-inference-profiles.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "api/providers/__tests__/bedrock-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "api/providers/__tests__/bedrock-reasoning.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/bedrock.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 37 - } - }, - "api/providers/__tests__/deepseek.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "api/providers/__tests__/gemini-handler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "api/providers/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 33 - } - }, - "api/providers/__tests__/kenari.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/kimi-code.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/lite-llm.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 36 - } - }, - "api/providers/__tests__/lm-studio-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/__tests__/mimo.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "api/providers/__tests__/minimax.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/moonshot.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "api/providers/__tests__/native-ollama.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "api/providers/__tests__/openai-codex-native-tool-calls.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "api/providers/__tests__/openai-codex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "api/providers/__tests__/openai-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "api/providers/__tests__/openai-native-usage.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "api/providers/__tests__/openai-native.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 74 - } - }, - "api/providers/__tests__/openai-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/openai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/__tests__/opencode-go.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "api/providers/__tests__/poe.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/qwen-code-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/sambanova.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/unbound.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/vertex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/zai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/anthropic-vertex.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/anthropic.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/base-openai-compatible-provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/providers/base-provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/bedrock.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "api/providers/deepseek.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/fetchers/__tests__/kenari.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/kimi-code.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/lmstudio.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/fetchers/__tests__/moonshot.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/ollama.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/fetchers/__tests__/opencode-go.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/zoo-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/providers/fetchers/litellm.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/gemini.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/lite-llm.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/providers/lm-studio.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/mimo.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/moonshot.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/native-ollama.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/openai-codex.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 31 - } - }, - "api/providers/openai-native.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 28 - } - }, - "api/providers/openai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/openrouter.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/providers/poe.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/qwen-code.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/requesty.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/unbound.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/utils/__tests__/error-handler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "api/providers/utils/__tests__/image-generation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "api/providers/utils/__tests__/timeout-config.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/utils/error-handler.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/providers/xai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/transform/__tests__/ai-sdk.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/anthropic-filter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/transform/__tests__/bedrock-converse-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/gemini-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/mistral-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/__tests__/model-params.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/__tests__/openai-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 49 - } - }, - "api/transform/__tests__/r1-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/__tests__/reasoning.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/__tests__/responses-api-input.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/__tests__/responses-api-stream.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/zai-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/transform/ai-sdk.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/bedrock-converse-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/cache-strategy/__tests__/cache-strategy.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "api/transform/caching/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/gemini-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/openai-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/r1-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/responses-api-input.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/transform/responses-api-stream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/transform/zai-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/assistant-message/NativeToolCallParser.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/assistant-message/presentAssistantMessage.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/checkpoints/__tests__/checkpoint.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/checkpoints/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/condense/__tests__/condense.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/condense/__tests__/foldedFileContext.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "core/condense/__tests__/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 24 - } - }, - "core/config/ContextProxy.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/config/CustomModesManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/ProviderSettingsManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/config/__tests__/ContextProxy.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/__tests__/CustomModesManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/config/__tests__/ProviderSettingsManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/context-management/__tests__/context-management.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/context-tracking/__tests__/FileContextTracker.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/context/context-management/__tests__/context-error-handling.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/context/context-management/context-error-handling.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/diff/stats.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/environment/__tests__/getEnvironmentDetails.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/ignore/__tests__/RooIgnoreController.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/mentions/__tests__/processUserContentMentions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/mentions/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/mentions/processUserContentMentions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/message-manager/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "core/message-manager/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/prompts/__tests__/add-custom-instructions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/__tests__/get-prompt-component.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/__tests__/responses-rooignore.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "core/prompts/__tests__/system-prompt.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/prompts/sections/__tests__/custom-instructions-global.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "core/prompts/sections/__tests__/custom-instructions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 55 - } - }, - "core/prompts/sections/__tests__/system-info.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "core/prompts/tools/filter-tools-for-mode.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/tools/native-tools/__tests__/read_file.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/task-persistence/__tests__/TaskHistoryStore.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/task-persistence/__tests__/importRooTaskHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/task-persistence/__tests__/taskMessages.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/task-persistence/apiMessages.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/task/Task.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "core/task/__tests__/Task.dispose.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/Task.persistence.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/task/__tests__/Task.sticky-profile-race.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/Task.throttle.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 24 - } - }, - "core/task/__tests__/apiConversationHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 23 - } - }, - "core/task/__tests__/ask-clear-approval-buttons.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "core/task/__tests__/ask-queued-message-drain.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "core/task/__tests__/grace-retry-errors.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/task/__tests__/grounding-sources.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/task/__tests__/native-tools-filtering.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/new-task-isolation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/task/__tests__/reasoning-preservation.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "core/task/__tests__/task-tool-history.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/apiConversationHistory.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/tools/BaseTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/CodebaseSearchTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/GenerateImageTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/NewTaskTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/ReadFileTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/tools/ToolRepetitionDetector.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/UpdateTodoListTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/UseMcpToolTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/ReadCommandOutputTool.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/tools/__tests__/ToolRepetitionDetector.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/askFollowupQuestionTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "core/tools/__tests__/attemptCompletionTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 11 - } - }, - "core/tools/__tests__/editFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/tools/__tests__/editTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/__tests__/executeCommand.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/tools/__tests__/executeCommandTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/tools/__tests__/generateImageTool.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "core/tools/__tests__/listFilesTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "core/tools/__tests__/mcpServerRestriction.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "core/tools/__tests__/newTaskTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 26 - } - }, - "core/tools/__tests__/readFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 94 - } - }, - "core/tools/__tests__/runSlashCommandTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/searchReplaceTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/skillTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/updateTodoListTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/useMcpToolTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "core/tools/__tests__/validateToolUse.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/writeToFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/tools/helpers/toolResultFormatting.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/validateToolUse.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/webview/ClineProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "core/webview/__tests__/ClineProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 196 - } - }, - "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/webview/__tests__/ClineProvider.taskHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/webview/__tests__/checkpointRestoreHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/diagnosticsHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "core/webview/__tests__/messageEnhancer.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/webview/__tests__/skillsMessageHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/telemetrySettingsTracking.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/webviewMessageHandler.delete.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "core/webview/__tests__/webviewMessageHandler.edit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 13 - } - }, - "core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 56 - } - }, - "core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/webviewMessageHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "core/webview/messageEnhancer.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/webviewMessageHandler.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "extension/__tests__/api-delete-queued-message.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "extension/__tests__/api-send-message.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "extension/api.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "i18n/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "i18n/setup.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/editor/DiffViewProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/editor/__tests__/DiffViewProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 310 - } - }, - "integrations/editor/__tests__/EditorUtils.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "integrations/kimi-code/__tests__/oauth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/misc/__tests__/export-markdown.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/misc/__tests__/extract-text.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "integrations/misc/__tests__/line-counter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/misc/__tests__/open-file.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 11 - } - }, - "integrations/misc/__tests__/performance/processCarriageReturns.benchmark.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "integrations/terminal/__tests__/OutputInterceptor.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "integrations/terminal/__tests__/TerminalProcess.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "integrations/terminal/__tests__/TerminalProcess.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/TerminalProcessInterpretExitCode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "integrations/terminal/__tests__/TerminalProfile.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "integrations/terminal/__tests__/TerminalRegistry.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "integrations/terminal/__tests__/setupTerminalTests.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/bashStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/cmdStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/streamUtils/pwshStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/theme/getTheme.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "services/__tests__/zoo-code-auth.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/checkpoints/__tests__/ShadowCheckpointService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/__tests__/config-manager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/__tests__/manager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 84 - } - }, - "services/code-index/__tests__/orchestrator.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 23 - } - }, - "services/code-index/__tests__/service-factory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 43 - } - }, - "services/code-index/embedders/__tests__/bedrock.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "services/code-index/embedders/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/mistral.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/ollama.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 15 - } - }, - "services/code-index/embedders/__tests__/openai-compatible.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 28 - } - }, - "services/code-index/embedders/__tests__/openai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "services/code-index/embedders/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/bedrock.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/embedders/ollama.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/openai-compatible.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/openai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/openrouter.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/interfaces/vector-store.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/orchestrator.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/processors/__tests__/file-watcher.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "services/code-index/processors/__tests__/parser.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "services/code-index/processors/__tests__/scanner.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 26 - } - }, - "services/code-index/processors/file-watcher.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/processors/scanner.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/semble/__tests__/provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "services/code-index/semble/__tests__/semble-cli.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/semble/__tests__/semble-downloader.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 61 - } - }, - "services/code-index/semble/provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/semble/semble-cli.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/shared/__tests__/validation-helpers.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/shared/validation-helpers.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/code-index/vector-store/__tests__/qdrant-client.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 87 - } - }, - "services/code-index/vector-store/qdrant-client.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/glob/__tests__/gitignore-integration.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/glob/__tests__/gitignore-test.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/glob/__tests__/list-files-limit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "services/glob/__tests__/list-files.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "services/marketplace/MarketplaceManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/marketplace/SimpleInstaller.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "services/marketplace/__tests__/MarketplaceManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/marketplace/__tests__/SimpleInstaller.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "services/marketplace/__tests__/marketplace-setting-check.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/mcp/McpHub.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "services/mcp/McpOAuthClientProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/mcp/McpServerManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/mcp/__tests__/McpHub.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 150 - } - }, - "services/mcp/__tests__/McpOAuthClientProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "services/mcp/__tests__/SecretStorageService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/mcp/utils/__tests__/callbackServer.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/mcp/utils/__tests__/oauth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "services/mcp/utils/callbackServer.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/mcp/utils/oauth.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/mdm/__tests__/MdmService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/ripgrep/__tests__/diagnostic.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/roo-config/__tests__/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "services/roo-config/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/rules/__tests__/rules.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/search/__tests__/file-search.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/skills/__tests__/SkillsManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/tree-sitter/__tests__/helpers.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/tree-sitter/__tests__/markdownParser.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "shared/__tests__/api.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "shared/__tests__/embeddingModels.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/__tests__/modes-empty-prompt-component.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/api.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "shared/checkExistApiConfig.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/cost.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/parse-command.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/support-prompt.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "shared/tools.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/utils/__tests__/requesty.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/__tests__/autoImportSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "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 - } - }, - "utils/tts.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "vitest.setup.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - } -} \ No newline at end of file + "__mocks__/fs/promises.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/ClineProvider.delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "__tests__/ClineProvider.history-resume-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 70 + } + }, + "__tests__/abandonSubtask.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "__tests__/api-subtask.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/delegation-concurrent.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "__tests__/delegation-events.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "__tests__/migrateSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/nested-delegation-resume.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "__tests__/new-task-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "activate/CodeActionProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "activate/__tests__/CodeActionProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "activate/__tests__/handleUri.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "activate/__tests__/registerCommands.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "activate/registerCodeActions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "activate/registerCommands.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "activate/registerTerminalActions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/anthropic-vertex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "api/providers/__tests__/anthropic.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/base-openai-compatible-provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/base-provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/__tests__/bedrock-custom-arn.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "api/providers/__tests__/bedrock-error-handling.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/providers/__tests__/bedrock-inference-profiles.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 27 + } + }, + "api/providers/__tests__/bedrock-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "api/providers/__tests__/bedrock-reasoning.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/bedrock.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 37 + } + }, + "api/providers/__tests__/deepseek.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "api/providers/__tests__/gemini-handler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "api/providers/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 33 + } + }, + "api/providers/__tests__/kenari.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/kimi-code.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/lite-llm.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 36 + } + }, + "api/providers/__tests__/lm-studio-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/mimo.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "api/providers/__tests__/minimax.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/moonshot.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "api/providers/__tests__/native-ollama.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "api/providers/__tests__/openai-codex-native-tool-calls.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "api/providers/__tests__/openai-codex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "api/providers/__tests__/openai-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "api/providers/__tests__/openai-native-usage.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "api/providers/__tests__/openai-native.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 74 + } + }, + "api/providers/__tests__/openai-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/openai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/__tests__/opencode-go.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "api/providers/__tests__/poe.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/qwen-code-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/sambanova.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/unbound.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/vertex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/zai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/anthropic-vertex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/anthropic.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/base-openai-compatible-provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/providers/base-provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/bedrock.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "api/providers/deepseek.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/fetchers/__tests__/kenari.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/kimi-code.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/lmstudio.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/fetchers/__tests__/moonshot.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/ollama.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/fetchers/__tests__/opencode-go.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/zoo-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/fetchers/litellm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/gemini.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/lite-llm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/lm-studio.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/mimo.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/moonshot.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/native-ollama.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/openai-codex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "api/providers/openai-native.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 28 + } + }, + "api/providers/openai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/openrouter.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/providers/poe.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/qwen-code.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/requesty.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/unbound.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/utils/__tests__/error-handler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "api/providers/utils/__tests__/image-generation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "api/providers/utils/__tests__/timeout-config.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/utils/error-handler.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/providers/xai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/transform/__tests__/ai-sdk.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/anthropic-filter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/__tests__/bedrock-converse-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/gemini-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/mistral-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/__tests__/model-params.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/__tests__/openai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 49 + } + }, + "api/transform/__tests__/r1-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/__tests__/reasoning.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/__tests__/responses-api-input.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/__tests__/responses-api-stream.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/zai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/transform/ai-sdk.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/bedrock-converse-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/cache-strategy/__tests__/cache-strategy.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "api/transform/caching/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/gemini-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/openai-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/r1-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/responses-api-input.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/responses-api-stream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/transform/zai-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/assistant-message/NativeToolCallParser.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/assistant-message/presentAssistantMessage.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/checkpoints/__tests__/checkpoint.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/checkpoints/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/condense/__tests__/condense.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/condense/__tests__/foldedFileContext.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "core/condense/__tests__/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 24 + } + }, + "core/config/ContextProxy.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/config/CustomModesManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/ProviderSettingsManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/config/__tests__/ContextProxy.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/__tests__/CustomModesManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/config/__tests__/ProviderSettingsManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/context-management/__tests__/context-management.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/context-tracking/__tests__/FileContextTracker.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/context/context-management/__tests__/context-error-handling.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/context/context-management/context-error-handling.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/diff/stats.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/environment/__tests__/getEnvironmentDetails.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/ignore/__tests__/RooIgnoreController.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/mentions/__tests__/processUserContentMentions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/mentions/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/mentions/processUserContentMentions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/message-manager/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "core/message-manager/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/prompts/__tests__/add-custom-instructions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/__tests__/get-prompt-component.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/__tests__/responses-rooignore.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "core/prompts/__tests__/system-prompt.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/prompts/sections/__tests__/custom-instructions-global.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "core/prompts/sections/__tests__/custom-instructions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 55 + } + }, + "core/prompts/sections/__tests__/system-info.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "core/prompts/tools/filter-tools-for-mode.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/tools/native-tools/__tests__/read_file.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/task-persistence/__tests__/TaskHistoryStore.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/task-persistence/__tests__/importRooTaskHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task-persistence/__tests__/taskMessages.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/task-persistence/apiMessages.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/task/Task.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "core/task/__tests__/Task.dispose.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/Task.persistence.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/task/__tests__/Task.sticky-profile-race.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/Task.throttle.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 24 + } + }, + "core/task/__tests__/apiConversationHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 23 + } + }, + "core/task/__tests__/ask-clear-approval-buttons.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "core/task/__tests__/ask-queued-message-drain.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "core/task/__tests__/grace-retry-errors.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/task/__tests__/grounding-sources.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/task/__tests__/native-tools-filtering.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/new-task-isolation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task/__tests__/reasoning-preservation.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "core/task/__tests__/task-tool-history.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/apiConversationHistory.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/tools/BaseTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/CodebaseSearchTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/GenerateImageTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/NewTaskTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/ReadFileTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/tools/ToolRepetitionDetector.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/UpdateTodoListTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/UseMcpToolTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/ReadCommandOutputTool.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/tools/__tests__/ToolRepetitionDetector.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/askFollowupQuestionTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "core/tools/__tests__/attemptCompletionTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "core/tools/__tests__/editFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/tools/__tests__/editTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/__tests__/executeCommand.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/tools/__tests__/executeCommandTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/tools/__tests__/generateImageTool.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "core/tools/__tests__/listFilesTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "core/tools/__tests__/mcpServerRestriction.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "core/tools/__tests__/newTaskTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "core/tools/__tests__/readFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 94 + } + }, + "core/tools/__tests__/runSlashCommandTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/searchReplaceTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/skillTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/updateTodoListTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/useMcpToolTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "core/tools/__tests__/validateToolUse.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/writeToFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/tools/helpers/toolResultFormatting.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/validateToolUse.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/webview/ClineProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "core/webview/__tests__/ClineProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 196 + } + }, + "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 27 + } + }, + "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/webview/__tests__/ClineProvider.taskHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/webview/__tests__/checkpointRestoreHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/diagnosticsHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/webview/__tests__/messageEnhancer.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/webview/__tests__/skillsMessageHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/telemetrySettingsTracking.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/webviewMessageHandler.delete.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "core/webview/__tests__/webviewMessageHandler.edit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 13 + } + }, + "core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 56 + } + }, + "core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/webviewMessageHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "core/webview/messageEnhancer.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/webviewMessageHandler.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "extension/__tests__/api-delete-queued-message.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "extension/__tests__/api-send-message.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "extension/api.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "i18n/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "i18n/setup.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/editor/DiffViewProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/editor/__tests__/DiffViewProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 310 + } + }, + "integrations/editor/__tests__/EditorUtils.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "integrations/kimi-code/__tests__/oauth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/misc/__tests__/export-markdown.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/misc/__tests__/extract-text.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "integrations/misc/__tests__/line-counter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/misc/__tests__/open-file.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "integrations/misc/__tests__/performance/processCarriageReturns.benchmark.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "integrations/terminal/__tests__/OutputInterceptor.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "integrations/terminal/__tests__/TerminalProcess.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "integrations/terminal/__tests__/TerminalProcess.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/TerminalProcessInterpretExitCode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "integrations/terminal/__tests__/TerminalProfile.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "integrations/terminal/__tests__/TerminalRegistry.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "integrations/terminal/__tests__/setupTerminalTests.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/bashStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/cmdStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/streamUtils/pwshStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/theme/getTheme.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "services/__tests__/zoo-code-auth.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/checkpoints/__tests__/ShadowCheckpointService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/__tests__/config-manager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/__tests__/manager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 84 + } + }, + "services/code-index/__tests__/orchestrator.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 23 + } + }, + "services/code-index/__tests__/service-factory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 43 + } + }, + "services/code-index/embedders/__tests__/bedrock.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "services/code-index/embedders/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/mistral.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/ollama.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 15 + } + }, + "services/code-index/embedders/__tests__/openai-compatible.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 28 + } + }, + "services/code-index/embedders/__tests__/openai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "services/code-index/embedders/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/bedrock.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/embedders/ollama.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/openai-compatible.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/openai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/openrouter.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/interfaces/vector-store.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/orchestrator.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/processors/__tests__/file-watcher.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "services/code-index/processors/__tests__/parser.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "services/code-index/processors/__tests__/scanner.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "services/code-index/processors/file-watcher.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/processors/scanner.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/semble/__tests__/provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "services/code-index/semble/__tests__/semble-cli.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/semble/__tests__/semble-downloader.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 61 + } + }, + "services/code-index/semble/provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/semble/semble-cli.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/shared/__tests__/validation-helpers.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/shared/validation-helpers.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/code-index/vector-store/__tests__/qdrant-client.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 87 + } + }, + "services/code-index/vector-store/qdrant-client.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/glob/__tests__/gitignore-integration.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/glob/__tests__/gitignore-test.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/glob/__tests__/list-files-limit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "services/glob/__tests__/list-files.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "services/marketplace/MarketplaceManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/marketplace/SimpleInstaller.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "services/marketplace/__tests__/MarketplaceManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/marketplace/__tests__/SimpleInstaller.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "services/marketplace/__tests__/marketplace-setting-check.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/mcp/McpHub.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "services/mcp/McpOAuthClientProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/mcp/McpServerManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/mcp/__tests__/McpHub.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 150 + } + }, + "services/mcp/__tests__/McpOAuthClientProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "services/mcp/__tests__/SecretStorageService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/mcp/utils/__tests__/callbackServer.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/mcp/utils/__tests__/oauth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "services/mcp/utils/callbackServer.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/mcp/utils/oauth.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/mdm/__tests__/MdmService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/ripgrep/__tests__/diagnostic.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/roo-config/__tests__/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "services/roo-config/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/rules/__tests__/rules.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/search/__tests__/file-search.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/skills/__tests__/SkillsManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/tree-sitter/__tests__/helpers.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/tree-sitter/__tests__/markdownParser.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "shared/__tests__/api.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "shared/__tests__/embeddingModels.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/__tests__/modes-empty-prompt-component.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/api.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "shared/checkExistApiConfig.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/cost.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/parse-command.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/support-prompt.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "shared/tools.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/utils/__tests__/requesty.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/__tests__/autoImportSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "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 + } + }, + "utils/tts.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "vitest.setup.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + } +} From ce747aa7617fcb366486e71bd7141fba3500fcad Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 12:07:51 +0800 Subject: [PATCH 88/89] style: format the seventeen files the formatting gate flags on this PR The compile job's Check formatting step runs 'prettier --check .' and lists 17 files on this PR's merge ref (job 114119333176, log read with the ANSI codes stripped - the [warn] lines are the file list, and 'Code style issues found in 17 files' is the count). Every one of them is inside this PR's own diff, so formatting them stays inside the boundary of what this PR may touch. The previous formatting commit covered only eslint-suppressions.json because the log excerpt that was passed along named that one file; the gate checks the full list, not the excerpt. Formatting only, and verified as such rather than asserted: prettier --check now passes on all 17; tsc --noEmit with the local paths override reports 0 errors; eslint . --ext=ts --max-warnings=0 exits 0; eslint-suppressions.json is untouched. Five tests fail in this worktree (DiffViewProvider saveChanges default write delay x2, ClineProvider blanket auto-deny x3). They were classified rather than waved at: the same two spec files were run with the formatting stashed and unstashed, and the failure set is identical before and after - same five names, same counts (5 failed / 347 passed both ways). The unit-test jobs are green at this head on CI, so these are local artifacts (the write-delay pair is the known DEFAULT_WRITE_DELAY_MS junction difference), not something this commit introduced or hides. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 4 +- src/core/task/__tests__/Task.dispose.test.ts | 24 +- src/core/tools/ApplyDiffTool.ts | 1 - .../applyDiffTool.guardedWrite.spec.ts | 31 +-- 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/core/webview/ClineProvider.ts | 6 +- src/integrations/editor/DiffViewProvider.ts | 226 +++++++++--------- .../editor/__tests__/DiffViewProvider.spec.ts | 21 +- .../__tests__/safeWriteText.spec.ts | 144 +++++------ src/services/file-safety/safeWriteText.ts | 24 +- .../__tests__/safeWriteJson.lockKey.spec.ts | 29 ++- src/utils/__tests__/safeWriteJson.test.ts | 39 +-- src/utils/safeWriteJson.ts | 9 +- 17 files changed, 340 insertions(+), 277 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 8aaf4a4c1b..883804285c 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -603,7 +603,9 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { // One id fails at the lock, the other at the unlink: both outcomes count as // "not deleted", and neither may be treated as gone. vi.mocked(withFileLock).mockRejectedValueOnce(new Error("lock acquisition timed out")) - vi.mocked(fs.unlink).mockRejectedValueOnce(Object.assign(new Error("EBUSY: resource busy"), { code: "EBUSY" })) + vi.mocked(fs.unlink).mockRejectedValueOnce( + Object.assign(new Error("EBUSY: resource busy"), { code: "EBUSY" }), + ) const failure = await store .deleteMany(["all-a", "all-b"]) diff --git a/src/core/task/__tests__/Task.dispose.test.ts b/src/core/task/__tests__/Task.dispose.test.ts index cb4d8d2fd2..b83dcc66a9 100644 --- a/src/core/task/__tests__/Task.dispose.test.ts +++ b/src/core/task/__tests__/Task.dispose.test.ts @@ -415,18 +415,18 @@ describe("Task.run() idempotency", () => { // shared registry would let a parent read authorize a subtask write, and one task // clearing its registry would revoke the other task authorization. The field is an // instance initializer, so this asserts the ownership rather than the type. - const first = new Task({ - provider: mockProvider as unknown as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "first", - startTask: false, - }) - const second = new Task({ - provider: mockProvider as unknown as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "second", - startTask: false, - }) + const first = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "first", + startTask: false, + }) + const second = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "second", + startTask: false, + }) expect(first.observationRegistry).not.toBe(second.observationRegistry) first.observationRegistry.observe("/same/path.ts", "1:2:22:100:100", true) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 27c7b20790..37f39b5c3a 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -98,7 +98,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/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 7ad8fcd1a2..d651395820 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -91,9 +91,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) @@ -255,9 +253,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) }) @@ -266,9 +262,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, @@ -379,9 +385,7 @@ 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" }), - ) + stat.mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { askApproval: mockAskApproval, @@ -395,7 +399,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { expect(stat).toHaveBeenCalledTimes(2) }) -it("carries the outside-workspace approval and its canonical identity to the guarded save", async () => { + it("carries the outside-workspace approval and its canonical identity to the guarded save", async () => { // guardedWrite refuses a target outside every workspace root unless the tool layer says the // user approved this one, and it binds the publish to the identity that was approved. // apply_diff asks for approval on both save paths, so an approved write has to reach the guard @@ -444,5 +448,4 @@ it("carries the outside-workspace approval and its canonical identity to the gua expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit", true, "/canonical/outside/thing.ts") expect(mockSaveDirectly).not.toHaveBeenCalled() }) - }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index b7742600e4..8c087217ce 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -759,7 +759,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 96c598bcd3..4447905b9f 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/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0ef680522b..d554eba758 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -205,11 +205,7 @@ type GetStateOptions = { * partially-built receivers (the delegation specs call the prototype method with a stub * `this`), where a method lookup on the receiver would fail. */ -async function removeTaskArtifacts( - taskIds: string[], - globalStorageDir: string, - workspaceDir: string, -): Promise { +async function removeTaskArtifacts(taskIds: string[], globalStorageDir: string, workspaceDir: string): Promise { const { getTaskDirectoryPath } = await import("../../utils/storage") for (const taskId of taskIds) { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 5bc5497c9c..1e5c8d0386 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -648,63 +648,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 @@ -759,7 +760,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, ) @@ -777,7 +779,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. @@ -938,71 +939,72 @@ 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, - ) - } - }, + // 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 () => { - // The teardown's owner restores the preview tabs and resets the provider. - // Repeating them here would re-run tab work for tabs this caller never - // touched and reset state the other cleanup is still relying on. - // 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() + // The teardown's owner restores the preview tabs and resets the provider. + // Repeating them here would re-run tab work for tabs this caller never + // touched and reset state the other cleanup is still relying on. + // 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() }, ) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index ca318756fe..99baf7408f 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1088,7 +1088,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") @@ -1477,7 +1487,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() } }) @@ -1506,9 +1518,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) @@ -1525,7 +1535,6 @@ describe("DiffViewProvider", () => { expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.txt`, Buffer.from("new content")) }) - it("does not let a denied preview authorize the retry of an unread edit", async () => { // The model never read this file. open() records the preview token so the accept-time // CAS has an entry to compare against; when the user denies, that entry must not diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index ba4aaccdc3..51998e3c5c 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,64 +677,64 @@ 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 + 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) }) - 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[] = [] - // 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" }) - } + expect(execFile).not.toHaveBeenCalled() + expect(warnings.filter((m) => m.includes("Could not check"))).toHaveLength(1) }) - const warnings: string[] = [] - await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + // Warning delivery is advisory: it must not be able to fail the save it is reporting on. + it("win32: a throwing onWarning does not abort the write", 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 + }) - expect(execFile).not.toHaveBeenCalled() - expect(warnings.filter((m) => m.includes("Could not check"))).toHaveLength(1) - }) + await expect( + safeWriteText(targetPath, "data", { + platform: "win32", + onWarning: () => { + throw new Error("callback down") + }, + }), + ).resolves.toBeUndefined() - // Warning delivery is advisory: it must not be able to fail the save it is reporting on. - it("win32: a throwing onWarning does not abort the write", 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 + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) }) - - await expect( - safeWriteText(targetPath, "data", { - platform: "win32", - onWarning: () => { - throw new Error("callback down") - }, - }), - ).resolves.toBeUndefined() - - expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) - }) it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => { const targetPath = "/tmp/test-dir/target.txt" @@ -1300,9 +1306,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 @@ -1317,7 +1323,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) @@ -1344,7 +1349,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()) @@ -1441,7 +1447,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 b2bd0c46bd..0ed4a19707 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) => { @@ -463,13 +467,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 { @@ -542,11 +550,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) } } @@ -564,7 +568,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 92ccef0f5b..98fc673fb6 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -707,18 +707,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() @@ -742,7 +744,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")( @@ -758,16 +764,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([]) }, ) @@ -784,7 +792,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" } }, @@ -827,5 +839,4 @@ describe("safeWriteJson", () => { expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) }, ) - }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 9dd7975e61..4d949338c3 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -108,12 +108,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) } /** @@ -272,7 +267,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } console.warn( `safeWriteJson: ${resolvedTargetPath ?? absoluteFilePath} is committed, but its directory entry may not be durable: ${ - error.cause instanceof Error ? error.cause.message : String(error.cause ?? error) + error.cause instanceof Error ? error.cause.message : String(error.cause ?? error) }`, ) } From 85f8b44d4915ec70741b500d6e99ba875af9e1dd Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 15:36:40 +0800 Subject: [PATCH 89/89] test(task-history): cover both answers the post-lock-failure check can give _verdictAfterLockFailure exists because an ENOENT from proper-lockfile is a report about the lock, not about the task file: mkdir reports ENOENT both when the directory that would hold the lock is gone and when the key is an alias whose referent is missing. So the named path is asked directly, and that question has three answers. Only one of them had a test. The absent case was already reached by an existing case, and now has one that arrives through the lock failure itself: the file is removed for real, the lock rejects with ENOENT, and the item is evicted and written through without it. The uncheckable case had nothing: with the check reporting EACCES, the item has to stay and the report has to carry both facts - which lock failed, and that the file could not be checked to settle it - with the check's own error as the cause. Reporting only the lock would read as a plain lock timeout and send the next operator after a peer that never existed. The third answer, the file is there, was already pinned. Negative controls, one per branch, measured in this harness against the production line: - make the ENOENT answer stop settling anything: 2 failed (the new case, and the pre-existing case that shares the branch). - report only the lock failure: 1 failed, exactly the new uncheckable case. - that second control run against the spec as it stood before this commit reddens nothing in 17 tests, which is the evidence that the branch really was uncovered rather than covered elsewhere. Baseline: 19 passed, up from 17; tsc unchanged at this branch's 124 error lines with 0 in the touched file; eslint --max-warnings=0 clean; src/eslint-suppressions.json untouched. --- .../TaskHistoryStore.deleteSemantics.spec.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 883804285c..f29ab737ee 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -329,6 +329,67 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { expect(storeInternals(store).cache.has("lock-enoent")).toBe(true) expect(onWrite).not.toHaveBeenCalled() }) + + it("reports a lock failure as a completed deletion when the named file is itself gone", async () => { + // The other half of the same verdict: an ENOENT from the lock says nothing about the + // file, so the file is asked directly - and here it really answers ENOENT. The answer + // comes from the filesystem rather than a mock, so the branch is reached the way + // production reaches it: a task file that was already removed while the lock was lost. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "lock-fail-gone" })) + onWrite.mockClear() + await actualFs.rm(historyFilePath(storagePath, "lock-fail-gone")) + vi.mocked(withFileLock).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + + await expect(store.delete("lock-fail-gone")).resolves.toBeUndefined() + + // The named path said it is absent, so the item is gone whatever the lock did: evicted, + // and the index written through without it. A verdict that kept the item here would leave + // an entry whose file can never be deleted again. + const { cache, taskFileMtimes } = storeInternals(store) + expect(cache.has("lock-fail-gone")).toBe(false) + expect(taskFileMtimes.has("lock-fail-gone")).toBe(false) + expect(store.get("lock-fail-gone")).toBeUndefined() + expect(onWrite).toHaveBeenCalledTimes(1) + const writtenIds = (onWrite.mock.calls[0][0] as HistoryItem[]).map((item) => item.id) + expect(writtenIds).not.toContain("lock-fail-gone") + }) + + it("reports the lock failure and the failed check when the named file cannot be stat'ed", async () => { + // The third answer the check can give is neither "gone" nor "there": EACCES, a transport + // error, an ENOTDIR on the way. Then nothing is known about the file, so the item stays + // and the report has to carry both facts - which lock failed, and that the file could not + // be checked to settle it. Reporting only the lock would read as a plain lock timeout and + // send the next operator to look for a peer that never existed. + const store = createStore() + await store.initialize() + await store.upsert(makeHistoryItem({ id: "lock-fail-uncheckable" })) + onWrite.mockClear() + vi.mocked(withFileLock).mockRejectedValueOnce(Object.assign(new Error("ELOCKED"), { code: "ELOCKED" })) + const statError = Object.assign(new Error("EACCES"), { code: "EACCES" }) + // Spied rather than forced through the real filesystem: an unreadable directory is not + // portable, and the branch is about the errno the check reports, not about how it got it. + const lstatSpy = vi.spyOn(fs, "lstat").mockRejectedValue(statError) + let rejection: unknown + try { + await store.delete("lock-fail-uncheckable") + } catch (error: unknown) { + rejection = error + } finally { + lstatSpy.mockRestore() + } + + expect(rejection).toBeInstanceOf(TaskHistoryDeleteError) + const deleteError = rejection as TaskHistoryDeleteError + expect(deleteError.reason).toContain("the per-file lock could not be taken (ELOCKED)") + expect(deleteError.reason).toContain("the file could not be checked (EACCES)") + // The check's own error is the cause, not the lock's: it is the last thing that was known. + expect(deleteError.cause).toBe(statError) + expect(storeInternals(store).cache.has("lock-fail-uncheckable")).toBe(true) + expect(store.get("lock-fail-uncheckable")).toBeDefined() + expect(onWrite).not.toHaveBeenCalled() + }) }) describe("reconcile()", () => {