diff --git a/extensions/memory-wiki/src/unsafe-local.test.ts b/extensions/memory-wiki/src/unsafe-local.test.ts index b797cfed1e8..a49e4ed7b83 100644 --- a/extensions/memory-wiki/src/unsafe-local.test.ts +++ b/extensions/memory-wiki/src/unsafe-local.test.ts @@ -74,7 +74,7 @@ describe("syncMemoryWikiUnsafeLocalSources", () => { expect(second.removedCount).toBe(0); }); - it("prunes stale unsafe-local pages when configured files disappear", async () => { + it("prunes stale unsafe-local pages from an available configured directory", async () => { const privateDir = await createPrivateDir("private-prune"); const secretPath = path.join(privateDir, "secret.md"); @@ -86,7 +86,7 @@ describe("syncMemoryWikiUnsafeLocalSources", () => { vaultMode: "unsafe-local", unsafeLocal: { allowPrivateMemoryCoreAccess: true, - paths: [secretPath], + paths: [privateDir], }, }, }); @@ -108,6 +108,70 @@ describe("syncMemoryWikiUnsafeLocalSources", () => { ); }); + it("preserves pages and human notes for unavailable configured paths", async () => { + const unavailableDir = await createPrivateDir("temporarily-unavailable"); + const availableDir = await createPrivateDir("still-available"); + const unavailableSource = path.join(unavailableDir, "keep.md"); + const availableSource = path.join(availableDir, "delete.md"); + await fs.writeFile(unavailableSource, "# keep me\n", "utf8"); + await fs.writeFile(availableSource, "# delete me\n", "utf8"); + + const { rootDir: vaultDir, config } = await createVault({ + rootDir: nextCaseRoot("unavailable-vault"), + config: { + vaultMode: "unsafe-local", + unsafeLocal: { + allowPrivateMemoryCoreAccess: true, + paths: [unavailableDir, availableDir], + }, + }, + }); + + const first = await syncMemoryWikiUnsafeLocalSources(config); + const pageContents = await Promise.all( + first.pagePaths.map(async (pagePath) => ({ + pagePath, + content: await fs.readFile(path.join(vaultDir, pagePath), "utf8"), + })), + ); + const unavailablePage = pageContents.find((page) => page.content.includes("# keep me")); + const availablePage = pageContents.find((page) => page.content.includes("# delete me")); + expect(unavailablePage).toBeDefined(); + expect(availablePage).toBeDefined(); + await fs.writeFile( + path.join(vaultDir, unavailablePage!.pagePath), + unavailablePage!.content.replace( + "\n", + "\nremember this\n", + ), + "utf8", + ); + + const offlinePath = `${unavailableDir}.offline`; + await fs.rename(unavailableDir, offlinePath); + await fs.rm(availableSource); + const duringOutage = await syncMemoryWikiUnsafeLocalSources(config); + + expect(duringOutage.artifactCount).toBe(0); + expect(duringOutage.removedCount).toBe(1); + await expect( + fs.readFile(path.join(vaultDir, unavailablePage!.pagePath), "utf8"), + ).resolves.toContain("remember this"); + await expect(fs.stat(path.join(vaultDir, availablePage!.pagePath))).rejects.toHaveProperty( + "code", + "ENOENT", + ); + + await fs.rename(offlinePath, unavailableDir); + const afterRecovery = await syncMemoryWikiUnsafeLocalSources(config); + + expect(afterRecovery.skippedCount).toBe(1); + expect(afterRecovery.removedCount).toBe(0); + await expect( + fs.readFile(path.join(vaultDir, unavailablePage!.pagePath), "utf8"), + ).resolves.toContain("remember this"); + }); + it("caps composed unsafe-local filenames to the filesystem component limit", async () => { const privateDir = await createPrivateDir(`${"漢".repeat(50)}-private`); const nestedDir = path.join(privateDir, `${"語".repeat(50)}-nested`); diff --git a/extensions/memory-wiki/src/unsafe-local.ts b/extensions/memory-wiki/src/unsafe-local.ts index 0491934a1e4..9762282d327 100644 --- a/extensions/memory-wiki/src/unsafe-local.ts +++ b/extensions/memory-wiki/src/unsafe-local.ts @@ -30,6 +30,11 @@ type UnsafeLocalArtifact = { relativePath: string; }; +type UnsafeLocalArtifactCollection = { + artifacts: UnsafeLocalArtifact[]; + unavailableConfiguredPaths: string[]; +}; + const DIRECTORY_TEXT_EXTENSIONS = new Set([".json", ".jsonl", ".md", ".txt", ".yaml", ".yml"]); const UNSAFE_LOCAL_SYNC_CONCURRENCY = 16; @@ -48,7 +53,7 @@ function detectFenceLanguage(filePath: string): string { } async function listAllowedFilesRecursive(rootDir: string): Promise { - const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []); + const entries = await fs.readdir(rootDir, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const fullPath = path.join(rootDir, entry.name); @@ -68,41 +73,52 @@ async function listAllowedFilesRecursive(rootDir: string): Promise { async function collectUnsafeLocalArtifacts( configuredPaths: string[], -): Promise { +): Promise { const artifacts: UnsafeLocalArtifact[] = []; + const unavailableConfiguredPaths: string[] = []; for (const configuredPath of configuredPaths) { const absoluteConfiguredPath = path.resolve(configuredPath); - const stat = await fs.stat(absoluteConfiguredPath).catch(() => null); - if (!stat) { - continue; - } - if (stat.isDirectory()) { - const files = await listAllowedFilesRecursive(absoluteConfiguredPath); - for (const absolutePath of files) { - artifacts.push({ - syncKey: await resolveArtifactKey(absolutePath), + const scopedArtifacts: UnsafeLocalArtifact[] = []; + try { + const stat = await fs.stat(absoluteConfiguredPath); + if (stat.isDirectory()) { + const files = await listAllowedFilesRecursive(absoluteConfiguredPath); + for (const absolutePath of files) { + scopedArtifacts.push({ + syncKey: await resolveArtifactKey(absolutePath), + configuredPath: absoluteConfiguredPath, + absolutePath, + relativePath: path.relative(absoluteConfiguredPath, absolutePath).replace(/\\/g, "/"), + }); + } + } else if (stat.isFile()) { + scopedArtifacts.push({ + syncKey: await resolveArtifactKey(absoluteConfiguredPath), configuredPath: absoluteConfiguredPath, - absolutePath, - relativePath: path.relative(absoluteConfiguredPath, absolutePath).replace(/\\/g, "/"), + absolutePath: absoluteConfiguredPath, + relativePath: path.basename(absoluteConfiguredPath), }); } + } catch { + unavailableConfiguredPaths.push(absoluteConfiguredPath); continue; } - if (stat.isFile()) { - artifacts.push({ - syncKey: await resolveArtifactKey(absoluteConfiguredPath), - configuredPath: absoluteConfiguredPath, - absolutePath: absoluteConfiguredPath, - relativePath: path.basename(absoluteConfiguredPath), - }); - } + artifacts.push(...scopedArtifacts); } const deduped = new Map(); for (const artifact of artifacts) { deduped.set(artifact.syncKey, artifact); } - return [...deduped.values()]; + return { artifacts: [...deduped.values()], unavailableConfiguredPaths }; +} + +function isSourceWithinConfiguredPath(sourcePath: string, configuredPath: string): boolean { + const relative = path.relative(configuredPath, sourcePath); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); } function resolveUnsafeLocalPagePath(params: { configuredPath: string; absolutePath: string }): { @@ -216,14 +232,28 @@ export async function syncMemoryWikiUnsafeLocalSources( }; } - const artifacts = await collectUnsafeLocalArtifacts(config.unsafeLocal.paths); + const { artifacts, unavailableConfiguredPaths } = await collectUnsafeLocalArtifacts( + config.unsafeLocal.paths, + ); const state = await readMemoryWikiSourceSyncState(config.vault.path); + const activeKeys = new Set(); + for (const [syncKey, entry] of Object.entries(state.entries)) { + if ( + entry.group === "unsafe-local" && + unavailableConfiguredPaths.some((configuredPath) => + isSourceWithinConfiguredPath(entry.sourcePath, configuredPath), + ) + ) { + // A configured source scope remains authoritative until it is readable again or removed + // from config. Treating an unreadable mount as empty would permanently delete human notes. + activeKeys.add(syncKey); + } + } assertMemoryWikiSourceSyncStateCapacity({ state, group: "unsafe-local", - incomingCount: artifacts.length, + incomingCount: new Set([...artifacts.map((artifact) => artifact.syncKey), ...activeKeys]).size, }); - const activeKeys = new Set(); const results = await pMap( artifacts, async (artifact) => {