diff --git a/src/hooks/write-existing-file-guard/hook.ts b/src/hooks/write-existing-file-guard/hook.ts index 2def362d2..9b3aee2c3 100644 --- a/src/hooks/write-existing-file-guard/hook.ts +++ b/src/hooks/write-existing-file-guard/hook.ts @@ -1,50 +1,212 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" -import { existsSync } from "fs" -import { resolve, isAbsolute, join, normalize, sep } from "path" +import { existsSync, realpathSync } from "fs" +import { basename, dirname, isAbsolute, join, normalize, resolve, sep } from "path" import { log } from "../../shared" +type GuardArgs = { + filePath?: string + path?: string + file_path?: string + overwrite?: boolean | string +} + +const MAX_TRACKED_SESSIONS = 256 + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined + } + + return value as Record +} + +function getPathFromArgs(args: GuardArgs | undefined): string | undefined { + return args?.filePath ?? args?.path ?? args?.file_path +} + +function resolveInputPath(ctx: PluginInput, inputPath: string): string { + return normalize(isAbsolute(inputPath) ? inputPath : resolve(ctx.directory, inputPath)) +} + +function toCanonicalPath(absolutePath: string): string { + let canonicalPath = absolutePath + + if (existsSync(absolutePath)) { + try { + canonicalPath = realpathSync.native(absolutePath) + } catch { + canonicalPath = absolutePath + } + } else { + const absoluteDir = dirname(absolutePath) + const resolvedDir = existsSync(absoluteDir) ? realpathSync.native(absoluteDir) : absoluteDir + canonicalPath = join(resolvedDir, basename(absolutePath)) + } + + // Preserve canonical casing from the filesystem to avoid collapsing distinct + // files on case-sensitive volumes (supported on all major OSes). + return normalize(canonicalPath) +} + +function isOverwriteEnabled(value: boolean | string | undefined): boolean { + if (value === true) { + return true + } + + if (typeof value === "string") { + return value.toLowerCase() === "true" + } + + return false +} + export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { + const readPermissionsBySession = new Map>() + const sessionLastAccess = new Map() + const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory)) + const sisyphusRoot = join(canonicalSessionRoot, ".sisyphus") + sep + + const touchSession = (sessionID: string): void => { + sessionLastAccess.set(sessionID, Date.now()) + } + + const evictLeastRecentlyUsedSession = (): void => { + let oldestSessionID: string | undefined + let oldestSeen = Number.POSITIVE_INFINITY + + for (const [sessionID, lastSeen] of sessionLastAccess.entries()) { + if (lastSeen < oldestSeen) { + oldestSeen = lastSeen + oldestSessionID = sessionID + } + } + + if (!oldestSessionID) { + return + } + + readPermissionsBySession.delete(oldestSessionID) + sessionLastAccess.delete(oldestSessionID) + } + + const ensureSessionReadSet = (sessionID: string): Set => { + let readSet = readPermissionsBySession.get(sessionID) + if (!readSet) { + if (readPermissionsBySession.size >= MAX_TRACKED_SESSIONS) { + evictLeastRecentlyUsedSession() + } + + readSet = new Set() + readPermissionsBySession.set(sessionID, readSet) + } + + touchSession(sessionID) + return readSet + } + + const registerReadPermission = (sessionID: string, canonicalPath: string): void => { + const readSet = ensureSessionReadSet(sessionID) + readSet.add(canonicalPath) + } + + const consumeReadPermission = (sessionID: string, canonicalPath: string): boolean => { + const readSet = readPermissionsBySession.get(sessionID) + if (!readSet || !readSet.has(canonicalPath)) { + return false + } + + readSet.delete(canonicalPath) + touchSession(sessionID) + return true + } + + const invalidateOtherSessions = (canonicalPath: string, writingSessionID?: string): void => { + for (const [sessionID, readSet] of readPermissionsBySession.entries()) { + if (writingSessionID && sessionID === writingSessionID) { + continue + } + + if (readSet.delete(canonicalPath)) { + touchSession(sessionID) + } + } + } + return { "tool.execute.before": async (input, output) => { const toolName = input.tool?.toLowerCase() - if (toolName !== "write") { + if (toolName !== "write" && toolName !== "read") { return } - const args = output.args as - | { filePath?: string; path?: string; file_path?: string } - | undefined - const filePath = args?.filePath ?? args?.path ?? args?.file_path + const argsRecord = asRecord(output.args) + const args = argsRecord as GuardArgs | undefined + const filePath = getPathFromArgs(args) if (!filePath) { return } - const resolvedPath = normalize( - isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath) - ) + const resolvedPath = resolveInputPath(ctx, filePath) + const canonicalPath = toCanonicalPath(resolvedPath) - if (existsSync(resolvedPath)) { - const sisyphusRoot = join(ctx.directory, ".sisyphus") + sep - const isSisyphusMarkdown = - resolvedPath.startsWith(sisyphusRoot) && resolvedPath.endsWith(".md") - if (isSisyphusMarkdown) { - log("[write-existing-file-guard] Allowing .sisyphus/*.md overwrite", { - sessionID: input.sessionID, - filePath, - }) + if (toolName === "read") { + if (!existsSync(resolvedPath) || !input.sessionID) { return } - log("[write-existing-file-guard] Blocking write to existing file", { + registerReadPermission(input.sessionID, canonicalPath) + return + } + + const overwriteEnabled = isOverwriteEnabled(args?.overwrite) + + if (argsRecord && "overwrite" in argsRecord) { + delete argsRecord.overwrite + } + + if (!existsSync(resolvedPath)) { + return + } + + const isSisyphusPath = canonicalPath.startsWith(sisyphusRoot) + if (isSisyphusPath) { + log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", { + sessionID: input.sessionID, + filePath, + }) + invalidateOtherSessions(canonicalPath, input.sessionID) + return + } + + if (overwriteEnabled) { + log("[write-existing-file-guard] Allowing overwrite flag bypass", { sessionID: input.sessionID, filePath, resolvedPath, }) - - throw new Error("File already exists. Use edit tool instead.") + invalidateOtherSessions(canonicalPath, input.sessionID) + return } + + if (input.sessionID && consumeReadPermission(input.sessionID, canonicalPath)) { + log("[write-existing-file-guard] Allowing overwrite after read", { + sessionID: input.sessionID, + filePath, + resolvedPath, + }) + invalidateOtherSessions(canonicalPath, input.sessionID) + return + } + + log("[write-existing-file-guard] Blocking write to existing file", { + sessionID: input.sessionID, + filePath, + resolvedPath, + }) + + throw new Error("File already exists. Use edit tool instead.") }, } } diff --git a/src/hooks/write-existing-file-guard/index.test.ts b/src/hooks/write-existing-file-guard/index.test.ts index ff5aba1ff..a2700de0b 100644 --- a/src/hooks/write-existing-file-guard/index.test.ts +++ b/src/hooks/write-existing-file-guard/index.test.ts @@ -1,305 +1,343 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" + import { createWriteExistingFileGuardHook } from "./index" -import * as fs from "fs" -import * as path from "path" -import * as os from "os" + +const BLOCK_MESSAGE = "File already exists. Use edit tool instead." + +type Hook = ReturnType + +function isCaseInsensitiveFilesystem(directory: string): boolean { + const probeName = `CaseProbe_${Date.now()}_A.txt` + const upperPath = join(directory, probeName) + const lowerPath = join(directory, probeName.toLowerCase()) + + writeFileSync(upperPath, "probe") + try { + return existsSync(lowerPath) + } finally { + rmSync(upperPath, { force: true }) + } +} describe("createWriteExistingFileGuardHook", () => { - let tempDir: string - let ctx: { directory: string } - let hook: ReturnType + let tempDir = "" + let hook: Hook + let callCounter = 0 + + const createFile = (relativePath: string, content = "existing content"): string => { + const absolutePath = join(tempDir, relativePath) + mkdirSync(dirname(absolutePath), { recursive: true }) + writeFileSync(absolutePath, content) + return absolutePath + } + + const invoke = async (args: { + tool: string + sessionID?: string + outputArgs: Record + }): Promise<{ args: Record }> => { + callCounter += 1 + const output = { args: args.outputArgs } + + await hook["tool.execute.before"]?.( + { + tool: args.tool, + sessionID: args.sessionID ?? "ses_default", + callID: `call_${callCounter}`, + } as never, + output as never + ) + + return output + } beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "write-guard-test-")) - ctx = { directory: tempDir } - hook = createWriteExistingFileGuardHook(ctx as any) + tempDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-")) + hook = createWriteExistingFileGuardHook({ directory: tempDir } as never) + callCounter = 0 }) afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }) + rmSync(tempDir, { recursive: true, force: true }) }) - describe("tool.execute.before", () => { - test("allows write to non-existing file", async () => { - //#given - const nonExistingFile = path.join(tempDir, "new-file.txt") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: nonExistingFile, content: "hello" } } + test("#given non-existing file #when write executes #then allows", async () => { + await expect( + invoke({ + tool: "write", + outputArgs: { filePath: join(tempDir, "new-file.txt"), content: "new content" }, + }) + ).resolves.toBeDefined() + }) - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) + test("#given existing file without read or overwrite #when write executes #then blocks", async () => { + const existingFile = createFile("existing.txt") - //#then - await expect(result).resolves.toBeUndefined() + await expect( + invoke({ + tool: "write", + outputArgs: { filePath: existingFile, content: "new content" }, + }) + ).rejects.toThrow(BLOCK_MESSAGE) + }) + + test("#given same-session read #when write executes #then allows once and consumes permission", async () => { + const existingFile = createFile("consume-once.txt") + const sessionID = "ses_consume" + + await invoke({ + tool: "read", + sessionID, + outputArgs: { filePath: existingFile }, }) - test("blocks write to existing file", async () => { - //#given - const existingFile = path.join(tempDir, "existing-file.txt") - fs.writeFileSync(existingFile, "existing content") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: existingFile, content: "new content" } } + await expect( + invoke({ + tool: "write", + sessionID, + outputArgs: { filePath: existingFile, content: "first overwrite" }, + }) + ).resolves.toBeDefined() - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) + await expect( + invoke({ + tool: "write", + sessionID, + outputArgs: { filePath: existingFile, content: "second overwrite" }, + }) + ).rejects.toThrow(BLOCK_MESSAGE) + }) - //#then - await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") + test("#given read in another session #when write executes #then blocks", async () => { + const existingFile = createFile("cross-session.txt") + + await invoke({ + tool: "read", + sessionID: "ses_reader", + outputArgs: { filePath: existingFile }, }) - test("blocks write tool (lowercase) to existing file", async () => { - //#given - const existingFile = path.join(tempDir, "existing-file.txt") - fs.writeFileSync(existingFile, "existing content") - const input = { tool: "write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: existingFile, content: "new content" } } + await expect( + invoke({ + tool: "write", + sessionID: "ses_writer", + outputArgs: { filePath: existingFile, content: "new content" }, + }) + ).rejects.toThrow(BLOCK_MESSAGE) + }) - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) + test("#given overwrite true boolean #when write executes #then bypasses guard and strips overwrite", async () => { + const existingFile = createFile("overwrite-boolean.txt") - //#then - await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") + const output = await invoke({ + tool: "write", + outputArgs: { + filePath: existingFile, + content: "new content", + overwrite: true, + }, }) - test("ignores non-write tools", async () => { - //#given - const existingFile = path.join(tempDir, "existing-file.txt") - fs.writeFileSync(existingFile, "existing content") - const input = { tool: "Edit", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: existingFile, content: "new content" } } + expect(output.args.overwrite).toBeUndefined() + }) - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) + test("#given overwrite true string #when write executes #then bypasses guard and strips overwrite", async () => { + const existingFile = createFile("overwrite-string.txt") - //#then - await expect(result).resolves.toBeUndefined() + const output = await invoke({ + tool: "write", + outputArgs: { + filePath: existingFile, + content: "new content", + overwrite: "true", + }, }) - test("ignores tools without any file path arg", async () => { - //#given - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { command: "ls" } } + expect(output.args.overwrite).toBeUndefined() + }) - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) + test("#given two sessions read same file #when one writes #then other session is invalidated", async () => { + const existingFile = createFile("invalidate.txt") - //#then - await expect(result).resolves.toBeUndefined() + await invoke({ + tool: "read", + sessionID: "ses_a", + outputArgs: { filePath: existingFile }, + }) + await invoke({ + tool: "read", + sessionID: "ses_b", + outputArgs: { filePath: existingFile }, }) - describe("alternative arg names", () => { - test("blocks write using 'path' arg to existing file", async () => { - //#given - const existingFile = path.join(tempDir, "existing-file.txt") - fs.writeFileSync(existingFile, "existing content") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { path: existingFile, content: "new content" } } + await expect( + invoke({ + tool: "write", + sessionID: "ses_b", + outputArgs: { filePath: existingFile, content: "updated by B" }, + }) + ).resolves.toBeDefined() - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) + await expect( + invoke({ + tool: "write", + sessionID: "ses_a", + outputArgs: { filePath: existingFile, content: "updated by A" }, + }) + ).rejects.toThrow(BLOCK_MESSAGE) + }) - //#then - await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") + test("#given existing file under .sisyphus #when write executes #then always allows", async () => { + const existingFile = createFile(".sisyphus/plans/plan.txt") + + await expect( + invoke({ + tool: "write", + outputArgs: { filePath: existingFile, content: "new plan" }, + }) + ).resolves.toBeDefined() + }) + + test("#given file arg variants #when read then write executes #then supports all variants", async () => { + const existingFile = createFile("variants.txt") + const variants: Array<"filePath" | "path" | "file_path"> = [ + "filePath", + "path", + "file_path", + ] + + for (const variant of variants) { + const sessionID = `ses_${variant}` + await invoke({ + tool: "read", + sessionID, + outputArgs: { [variant]: existingFile }, }) - test("blocks write using 'file_path' arg to existing file", async () => { - //#given - const existingFile = path.join(tempDir, "existing-file.txt") - fs.writeFileSync(existingFile, "existing content") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { file_path: existingFile, content: "new content" } } + await expect( + invoke({ + tool: "write", + sessionID, + outputArgs: { [variant]: existingFile, content: `overwrite via ${variant}` }, + }) + ).resolves.toBeDefined() + } + }) - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) + test("#given relative read and absolute write #when same session writes #then allows", async () => { + createFile("relative-absolute.txt") + const sessionID = "ses_relative_absolute" + const relativePath = "relative-absolute.txt" + const absolutePath = resolve(tempDir, relativePath) - //#then - await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") - }) - - test("allows write using 'path' arg to non-existing file", async () => { - //#given - const nonExistingFile = path.join(tempDir, "new-file.txt") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { path: nonExistingFile, content: "hello" } } - - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).resolves.toBeUndefined() - }) - - test("allows write using 'file_path' arg to non-existing file", async () => { - //#given - const nonExistingFile = path.join(tempDir, "new-file.txt") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { file_path: nonExistingFile, content: "hello" } } - - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).resolves.toBeUndefined() - }) + await invoke({ + tool: "read", + sessionID, + outputArgs: { filePath: relativePath }, }) - describe("relative path resolution using ctx.directory", () => { - test("blocks write to existing file using relative path", async () => { - //#given - const existingFile = path.join(tempDir, "existing-file.txt") - fs.writeFileSync(existingFile, "existing content") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: "existing-file.txt", content: "new content" } } - - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") + await expect( + invoke({ + tool: "write", + sessionID, + outputArgs: { filePath: absolutePath, content: "updated" }, }) + ).resolves.toBeDefined() + }) - test("allows write to non-existing file using relative path", async () => { - //#given - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: "new-file.txt", content: "hello" } } + test("#given case-different read path #when writing canonical path #then follows platform behavior", async () => { + const canonicalFile = createFile("CaseFile.txt") + const lowerCasePath = join(tempDir, "casefile.txt") + const sessionID = "ses_case" + const isCaseInsensitiveFs = isCaseInsensitiveFilesystem(tempDir) - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).resolves.toBeUndefined() - }) - - test("blocks write to nested relative path when file exists", async () => { - //#given - const subDir = path.join(tempDir, "subdir") - fs.mkdirSync(subDir) - const existingFile = path.join(subDir, "existing.txt") - fs.writeFileSync(existingFile, "existing content") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: "subdir/existing.txt", content: "new content" } } - - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") - }) - - test("uses ctx.directory not process.cwd for relative path resolution", async () => { - //#given - const existingFile = path.join(tempDir, "test-file.txt") - fs.writeFileSync(existingFile, "content") - const differentCtx = { directory: tempDir } - const differentHook = createWriteExistingFileGuardHook(differentCtx as any) - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: "test-file.txt", content: "new" } } - - //#when - const result = differentHook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") - }) - - describe(".sisyphus/*.md exception", () => { - test("allows write to existing .sisyphus/plans/plan.md", async () => { - //#given - const sisyphusDir = path.join(tempDir, ".sisyphus", "plans") - fs.mkdirSync(sisyphusDir, { recursive: true }) - const planFile = path.join(sisyphusDir, "plan.md") - fs.writeFileSync(planFile, "# Existing Plan") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: planFile, content: "# Updated Plan" } } - - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).resolves.toBeUndefined() - }) - - test("allows write to existing .sisyphus/notes.md", async () => { - //#given - const sisyphusDir = path.join(tempDir, ".sisyphus") - fs.mkdirSync(sisyphusDir, { recursive: true }) - const notesFile = path.join(sisyphusDir, "notes.md") - fs.writeFileSync(notesFile, "# Notes") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: notesFile, content: "# Updated Notes" } } - - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).resolves.toBeUndefined() - }) - - test("allows write to existing .sisyphus/*.md using relative path", async () => { - //#given - const sisyphusDir = path.join(tempDir, ".sisyphus") - fs.mkdirSync(sisyphusDir, { recursive: true }) - const planFile = path.join(sisyphusDir, "plan.md") - fs.writeFileSync(planFile, "# Plan") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: ".sisyphus/plan.md", content: "# Updated" } } - - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).resolves.toBeUndefined() - }) - - test("blocks write to existing .sisyphus/file.txt (non-markdown)", async () => { - //#given - const sisyphusDir = path.join(tempDir, ".sisyphus") - fs.mkdirSync(sisyphusDir, { recursive: true }) - const textFile = path.join(sisyphusDir, "file.txt") - fs.writeFileSync(textFile, "content") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: textFile, content: "new content" } } - - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") - }) - - test("blocks write when .sisyphus is in parent path but not under ctx.directory", async () => { - //#given - const fakeSisyphusParent = path.join(os.tmpdir(), ".sisyphus", "evil-project") - fs.mkdirSync(fakeSisyphusParent, { recursive: true }) - const evilFile = path.join(fakeSisyphusParent, "plan.md") - fs.writeFileSync(evilFile, "# Evil Plan") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: evilFile, content: "# Hacked" } } - - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") - - // cleanup - fs.rmSync(path.join(os.tmpdir(), ".sisyphus"), { recursive: true, force: true }) - }) - - test("blocks write to existing regular file (not in .sisyphus)", async () => { - //#given - const regularFile = path.join(tempDir, "regular.md") - fs.writeFileSync(regularFile, "# Regular") - const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } - const output = { args: { filePath: regularFile, content: "# Updated" } } - - //#when - const result = hook["tool.execute.before"]?.(input as any, output as any) - - //#then - await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") - }) + await invoke({ + tool: "read", + sessionID, + outputArgs: { filePath: lowerCasePath }, }) - }) -}) + + const writeAttempt = invoke({ + tool: "write", + sessionID, + outputArgs: { filePath: canonicalFile, content: "updated" }, + }) + + if (isCaseInsensitiveFs) { + await expect(writeAttempt).resolves.toBeDefined() + return + } + + await expect(writeAttempt).rejects.toThrow(BLOCK_MESSAGE) + }) + + test("#given read via symlink #when write via real path #then allows overwrite", async () => { + const targetFile = createFile("real/target.txt") + const symlinkPath = join(tempDir, "linked-target.txt") + const sessionID = "ses_symlink" + + try { + symlinkSync(targetFile, symlinkPath) + } catch { + return + } + + await invoke({ + tool: "read", + sessionID, + outputArgs: { filePath: symlinkPath }, + }) + + await expect( + invoke({ + tool: "write", + sessionID, + outputArgs: { filePath: targetFile, content: "updated via symlink read" }, + }) + ).resolves.toBeDefined() + }) + + test("#given recently active session #when lru evicts #then keeps recent session permission", async () => { + const existingFile = createFile("lru.txt") + const hotSession = "ses_hot" + + await invoke({ + tool: "read", + sessionID: hotSession, + outputArgs: { filePath: existingFile }, + }) + + for (let index = 0; index < 255; index += 1) { + await invoke({ + tool: "read", + sessionID: `ses_${index}`, + outputArgs: { filePath: existingFile }, + }) + } + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 2)) + + await invoke({ + tool: "read", + sessionID: hotSession, + outputArgs: { filePath: existingFile }, + }) + + await invoke({ + tool: "read", + sessionID: "ses_overflow", + outputArgs: { filePath: existingFile }, + }) + + await expect( + invoke({ + tool: "write", + sessionID: hotSession, + outputArgs: { filePath: existingFile, content: "hot session write" }, + }) + ).resolves.toBeDefined() + }) })