diff --git a/docs/installation.md b/docs/installation.md index cb4fea01d4..328c41324f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -423,8 +423,13 @@ rea uninstall --purge-data ``` Uninstall preserves Hopper, Node.js, Evidence files, captures, unrelated skills -and other MCP servers. Purging removes only REA's cache and state under -`~/.rea`. A client configuration that is malformed, unreadable, or at an unsafe +and other MCP servers. Purging removes REA's legacy cache and state under +`~/.rea`, the default Kuna cache (`~/Library/Caches/rea/kuna` on macOS or +`${XDG_CACHE_HOME:-~/.cache}/rea/kuna` on Linux), and Linux Hopper release +archives under `${XDG_CACHE_HOME:-~/.cache}/rea/hopper-releases`. Other cache +namespaces remain intact. A caller-selected `REA_KUNA_CACHE_DIR` is retained +and reported because REA cannot establish ownership of its entire contents. +A client configuration that is malformed, unreadable, or at an unsafe path stops the operation before anything is removed, and a client that fails while being updated stops the remaining removals. Uninstall reports each candidate `configuration_path` with its outcome. A client with multiple files diff --git a/src/application/LinuxHopperCacheDirectory.ts b/src/application/LinuxHopperCacheDirectory.ts new file mode 100644 index 0000000000..2e773f225c --- /dev/null +++ b/src/application/LinuxHopperCacheDirectory.ts @@ -0,0 +1,40 @@ +import { isAbsolute, join } from "node:path"; +import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { HopperReleaseError } from "./HopperReleaseFeed.js"; + +/** One owner for the selected cache location, shared with the approval plan. */ +export const linuxHopperReleaseCacheDirectory = ( + environment: Readonly, + selectedHome?: string, +): Result => { + try { + const supplied = environment.XDG_CACHE_HOME || undefined; + if (supplied !== undefined && !isAbsolute(supplied)) + return err( + new HopperReleaseError( + "verification_unavailable", + "XDG_CACHE_HOME must be an absolute filesystem path.", + ), + ); + return ok( + join( + supplied ?? + join( + selectedHome ?? homeDirectoryFromEnvironment(environment, "linux"), + ".cache", + ), + "rea", + "hopper-releases", + ), + ); + } catch (cause: unknown) { + return err( + new HopperReleaseError( + "verification_unavailable", + "The Hopper release cache location could not be resolved.", + { cause }, + ), + ); + } +}; diff --git a/src/application/LinuxHopperPublisher.ts b/src/application/LinuxHopperPublisher.ts index efbae7a2f2..7b6eb5573a 100644 --- a/src/application/LinuxHopperPublisher.ts +++ b/src/application/LinuxHopperPublisher.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { copyFile, lstat, mkdir, rename, writeFile } from "node:fs/promises"; -import { isAbsolute, join } from "node:path"; +import { join } from "node:path"; import { z } from "zod"; import { @@ -12,7 +12,7 @@ import { ArtifactReaderFailure, artifactPrimaryCleanupLeases, } from "../artifacts/ArtifactReader.js"; -import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; +import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperCacheDirectory.js"; import { err, ok, type Result } from "../domain/result.js"; import { hashStableFile, @@ -95,38 +95,6 @@ const retainProof = (cache: Map, key: string, proof: T): void => { if (cache.size > 8 && oldest !== undefined) cache.delete(oldest); }; -/** One owner for the selected cache location, shared with the approval plan. */ -export const linuxHopperReleaseCacheDirectory = ( - environment: Readonly, -): Result => { - try { - const supplied = environment.XDG_CACHE_HOME || undefined; - if (supplied !== undefined && !isAbsolute(supplied)) - return err( - new HopperReleaseError( - "verification_unavailable", - "XDG_CACHE_HOME must be an absolute filesystem path.", - ), - ); - return ok( - join( - supplied ?? - join(homeDirectoryFromEnvironment(environment, "linux"), ".cache"), - "rea", - "hopper-releases", - ), - ); - } catch (cause: unknown) { - return err( - new HopperReleaseError( - "verification_unavailable", - "The Hopper release cache location could not be resolved.", - { cause }, - ), - ); - } -}; - /** Authenticated package facts shared by setup, doctor, and the launch boundary. */ export class LinuxHopperPublisher { readonly #resources = new ArtifactResourceScope(); diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 63ef19616f..4f273455fe 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -26,7 +26,7 @@ import { type LinuxHopperInstallHost, } from "./LinuxHopper.js"; import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js"; -import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperPublisher.js"; +import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperCacheDirectory.js"; import { clientEvidencePaths, supportedClients, diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index a59620aa89..d17ef79f12 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -16,7 +16,9 @@ import { err, ok, type Result } from "../domain/result.js"; import { copyFile, lstat, realpath, rm, rmdir } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; +import { defaultKunaCacheDirectory } from "../config/kunaCacheDirectory.js"; +import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperCacheDirectory.js"; import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js"; import { z } from "zod"; @@ -347,7 +349,7 @@ export const systemUninstallHost = ( platform, skillWriteBlocker, ), - purgeData: () => purgeManagedData(join(home, ".rea"), fileSystem), + purgeData: () => purgeSelectedData(home, environment, platform, fileSystem), close: async () => { try { await configurationOperations.close(); @@ -753,11 +755,51 @@ const isOwnedRegistration = (value: unknown, client: SetupClient): boolean => { ); }; +/** Purge the same default cache namespaces selected by their producers. */ +const purgeSelectedData = async ( + home: string, + environment: Readonly, + platform: NodeJS.Platform, + fileSystem: UninstallFileSystem, +): Promise => { + const items = [ + ...(await purgeManagedData(join(home, ".rea"), fileSystem, [ + "cache", + "state", + ])), + ]; + const directories: string[] = []; + if (platform === "darwin" || platform === "linux") { + directories.push(defaultKunaCacheDirectory(home, environment, platform)); + const custom = environment.REA_KUNA_CACHE_DIR; + if (custom !== undefined && custom !== directories[0]) + items.push( + item( + "kuna-custom-cache", + "retained", + `Caller-selected Kuna cache ${custom} was retained because purge does not establish ownership of that entire directory.`, + ), + ); + } + if (platform === "linux") { + const hopper = linuxHopperReleaseCacheDirectory(environment, home); + if (hopper.ok) directories.push(hopper.value); + else items.push(item("hopper-releases", "failed", hopper.error.message)); + } + for (const directory of directories) + items.push( + ...(await purgeManagedData(dirname(directory), fileSystem, [ + basename(directory), + ])), + ); + return items; +}; + const purgeManagedData = async ( root: string, fileSystem: UninstallFileSystem, + names: readonly string[], ): Promise => { - const names = ["cache", "state"]; try { const stats = await fileSystem.stat(root); const linked = stats.isSymbolicLink(); diff --git a/src/config/kunaCacheDirectory.ts b/src/config/kunaCacheDirectory.ts new file mode 100644 index 0000000000..c6f0cd623a --- /dev/null +++ b/src/config/kunaCacheDirectory.ts @@ -0,0 +1,17 @@ +import { isAbsolute, join } from "node:path"; + +/** Per-user cache directory that REA owns for Kuna on this host. */ +export const defaultKunaCacheDirectory = ( + home: string, + environment: Readonly, + platform: NodeJS.Platform, +): string => { + if (platform === "darwin") + return join(home, "Library", "Caches", "rea", "kuna"); + const xdg = environment.XDG_CACHE_HOME; + return join( + xdg !== undefined && isAbsolute(xdg) ? xdg : join(home, ".cache"), + "rea", + "kuna", + ); +}; diff --git a/src/kuna/KunaConfiguration.ts b/src/kuna/KunaConfiguration.ts index e09b524896..4375e3b0f9 100644 --- a/src/kuna/KunaConfiguration.ts +++ b/src/kuna/KunaConfiguration.ts @@ -1,5 +1,6 @@ -import { isAbsolute, join } from "node:path"; +import { isAbsolute } from "node:path"; +import { defaultKunaCacheDirectory } from "../config/kunaCacheDirectory.js"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { providerStartupTimeoutSchema } from "../config/providerStartupTimeout.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -87,22 +88,6 @@ const positiveInteger = (value: string): number | undefined => ? Number(value) : undefined; -/** Per-user cache directory that REA owns for Kuna on this host. */ -const defaultCacheRoot = ( - environment: Readonly, - platform: NodeJS.Platform, -): string => { - const home = homeDirectoryFromEnvironment(environment, platform); - if (platform === "darwin") - return join(home, "Library", "Caches", "rea", "kuna"); - const xdg = environment.XDG_CACHE_HOME; - return join( - xdg !== undefined && isAbsolute(xdg) ? xdg : join(home, ".cache"), - "rea", - "kuna", - ); -}; - /** Parse the Kuna settings without touching the filesystem or the executable. */ export const readKunaConfiguration = ( environment: Readonly, @@ -175,7 +160,13 @@ export const readKunaConfiguration = ( return ok({ command, - cacheRoot: cacheRoot ?? defaultCacheRoot(environment, platform), + cacheRoot: + cacheRoot ?? + defaultKunaCacheDirectory( + homeDirectoryFromEnvironment(environment, platform), + environment, + platform, + ), cacheMaxBytes, projectTimeoutMs: timeout?.data ?? DEFAULT_PROJECT_TIMEOUT_MS, functionTimeoutMs: functionTimeout?.data ?? DEFAULT_FUNCTION_TIMEOUT_MS, diff --git a/tests/boundary/filesystem/managedDataPurge.test.ts b/tests/boundary/filesystem/managedDataPurge.test.ts new file mode 100644 index 0000000000..7fad45e752 --- /dev/null +++ b/tests/boundary/filesystem/managedDataPurge.test.ts @@ -0,0 +1,116 @@ +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each(["darwin", "linux"] as const)( + "purges producer-owned default cache namespaces on %s and preserves their siblings", + async (platform) => { + const home = await createTestTempDirectory("rea-managed-cache-purge-"); + const cache = join( + home, + platform === "darwin" ? "Library/Caches" : "xdg-cache", + ); + const directories = [ + join(home, ".rea", "cache"), + join(home, ".rea", "state"), + join(cache, "rea", "kuna"), + ...(platform === "linux" ? [join(cache, "rea", "hopper-releases")] : []), + ]; + for (const directory of directories) { + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "cached"), "REA cache"); + } + const sibling = join(cache, "rea", "caller-evidence"); + await mkdir(sibling); + await writeFile(join(sibling, "retained"), "caller evidence"); + const host = systemUninstallHost( + home, + undefined, + { XDG_CACHE_HOME: cache }, + platform, + ); + try { + const result = await host.purgeData(); + expect(result.filter(({ status }) => status === "removed")).toHaveLength( + directories.length, + ); + for (const directory of directories) + await expect(readFile(join(directory, "cached"))).rejects.toMatchObject( + { code: "ENOENT" }, + ); + expect(await readFile(join(sibling, "retained"), "utf8")).toBe( + "caller evidence", + ); + expect( + (await host.purgeData()).every(({ status }) => status === "skipped"), + ).toBe(true); + } finally { + await host.close?.(); + } + }, +); + +it("retains an arbitrary caller-selected Kuna cache and refuses a linked managed namespace", async () => { + const home = await createTestTempDirectory("rea-managed-cache-link-"); + const custom = join(home, "caller-data"); + await mkdir(custom); + await writeFile(join(custom, "evidence"), "retained"); + const cache = join(home, "cache"); + await mkdir(cache); + await symlink(custom, join(cache, "rea"), "dir"); + const host = systemUninstallHost( + home, + undefined, + { + XDG_CACHE_HOME: cache, + REA_KUNA_CACHE_DIR: custom, + }, + "linux", + ); + try { + expect(await host.purgeData()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "kuna-custom-cache", + status: "retained", + }), + expect.objectContaining({ name: "kuna", status: "retained" }), + expect.objectContaining({ + name: "hopper-releases", + status: "retained", + }), + ]), + ); + expect(await readFile(join(custom, "evidence"), "utf8")).toBe("retained"); + } finally { + await host.close?.(); + } +}); + +it("reports the Hopper producer's invalid cache selection rather than silently falling back", async () => { + const home = await createTestTempDirectory("rea-managed-cache-invalid-"); + const fallback = join(home, ".cache", "rea", "hopper-releases"); + await mkdir(fallback, { recursive: true }); + await writeFile(join(fallback, "release"), "retained"); + const host = systemUninstallHost( + home, + undefined, + { XDG_CACHE_HOME: "relative" }, + "linux", + ); + try { + expect(await host.purgeData()).toContainEqual( + expect.objectContaining({ + name: "hopper-releases", + status: "failed", + detail: expect.stringContaining("absolute"), + }), + ); + expect(await readFile(join(fallback, "release"), "utf8")).toBe("retained"); + } finally { + await host.close?.(); + } +}); diff --git a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts index ecb76d4af1..d29cdfd69e 100644 --- a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts +++ b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts @@ -1,3 +1,4 @@ +import { linuxHopperReleaseCacheDirectory } from "../../../src/application/LinuxHopperCacheDirectory.js"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { once } from "node:events"; @@ -24,7 +25,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { LinuxHopperPublisher, - linuxHopperReleaseCacheDirectory, retryLinuxHopperPublisherCleanup, } from "../../../src/application/LinuxHopperPublisher.js"; import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";