diff --git a/CHANGELOG.md b/CHANGELOG.md index fc543b5..4018ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Running the e2e suite no longer deletes your real `~/.gitmem`. `cursor-cross-tool.test.ts` ran + `gitmem uninstall --all` with your real HOME, and `--all` removes the memory store (session + registry, per-session files, local `threads.json`, closing payloads). Those tests now run against + a scratch HOME, and every e2e helper is guarded: any gitmem CLI or server process whose store + resolves outside `os.tmpdir()` is refused before it starts. The other e2e files that used your + real store (`pro-fresh`, `pro-mature`, `user-journey`, `organic-discovery`, `git-120`) are + sandboxed too, and the cursor tests assert the GIT-115 layout. Test-only change; the product + and `uninstall --all` are unchanged. (GIT-123) - `session_close` no longer reports every open thread as failed when a session is closed a second time (or closed after the server restarted). Threads stored on the session row come back as JSON strings, and they were handed to the Supabase thread sync without being parsed, so each one had no diff --git a/tests/e2e/cursor-cross-tool.test.ts b/tests/e2e/cursor-cross-tool.test.ts index 72fe925..1929c1d 100644 --- a/tests/e2e/cursor-cross-tool.test.ts +++ b/tests/e2e/cursor-cross-tool.test.ts @@ -28,6 +28,14 @@ import { tmpdir } from "os"; const execFile = promisify(execFileCb); const GITMEM_BIN = join(__dirname, "../../bin/gitmem.js"); +/** + * GIT-123: every call runs against a scratch HOME inside its own cwd, so + * `init`/`uninstall --all` act on /.home/.gitmem and never on the + * developer's ~/.gitmem. The store (learnings, threads, ...) lives there; the + * repo's own .gitmem/ holds config.json and hooks only (GIT-115 layout). + */ +const storeOf = (cwd: string) => join(cwd, ".home", ".gitmem"); + /** * Run a gitmem CLI command as a child process */ @@ -44,8 +52,12 @@ async function runGitmem( SUPABASE_SERVICE_ROLE_KEY: "", GITMEM_TIER: "free", NO_COLOR: "1", + ...(options.cwd ? { HOME: join(options.cwd, ".home") } : {}), + GITMEM_HOME: "", + GITMEM_DIR: "", ...options.env, }; + if (options.cwd) mkdirSync(join(options.cwd, ".home"), { recursive: true }); try { const { stdout, stderr } = await execFile("node", [GITMEM_BIN, ...args], { @@ -92,16 +104,16 @@ describe("Cursor: Init Clean Room", () => { ); expect(exitCode).toBe(0); - expect(stdout).toContain("Setup for Cursor"); - expect(stdout).toContain("Step 1/5"); - expect(stdout).toContain("Step 5/5"); - // No step 6 (no permissions step for Cursor) - expect(stdout).not.toContain("Step 6/"); + expect(stdout).toContain("Setting up for Cursor"); + expect(stdout).toContain("Created ~/.gitmem"); + expect(stdout).toContain("Created .cursorrules"); + expect(stdout).toContain("Added automatic memory hooks"); - // .gitmem/ created with starter scars + // Starter scars land in the store (GIT-115: ~/.gitmem, here the sandbox HOME); + // the repo's .gitmem/ holds config and hooks only. expect(existsSync(join(TEST_DIR, ".gitmem"))).toBe(true); - expect(existsSync(join(TEST_DIR, ".gitmem", "learnings.json"))).toBe(true); - const learnings = readJson(join(TEST_DIR, ".gitmem", "learnings.json")); + expect(existsSync(join(storeOf(TEST_DIR), "learnings.json"))).toBe(true); + const learnings = readJson(join(storeOf(TEST_DIR), "learnings.json")); expect(learnings.length).toBeGreaterThan(0); // .cursor/mcp.json created @@ -179,11 +191,12 @@ describe("Cursor: Init Clean Room", () => { ); expect(exitCode).toBe(0); - // Every step should say "Already configured" or "Skipping" - expect(stdout).toContain("Already configured"); + // Every step should say it is already set up + expect(stdout).toContain("already set up"); + expect(stdout).toContain("already configured"); // Should not create anything new - expect(stdout).not.toContain("Created .gitmem/"); - expect(stdout).not.toContain("Added gitmem entry"); + expect(stdout).not.toMatch(/Created .*\.gitmem/); + expect(stdout).not.toContain("Configured MCP server"); expect(stdout).not.toContain("Created .cursorrules"); }); @@ -221,9 +234,7 @@ describe("Claude: Init Control Group", () => { ); expect(exitCode).toBe(0); - expect(stdout).toContain("Setup for Claude Code"); - expect(stdout).toContain("Step 1/6"); - expect(stdout).toContain("Step 6/6"); + expect(stdout).toContain("Setting up for Claude Code"); // .mcp.json at project root const mcpConfig = readJson(join(TEST_DIR, ".mcp.json")); @@ -312,7 +323,7 @@ describe("Cross-Contamination Prevention", () => { // Single shared .gitmem/ directory expect(existsSync(join(TEST_DIR, ".gitmem"))).toBe(true); - expect(existsSync(join(TEST_DIR, ".gitmem", "learnings.json"))).toBe(true); + expect(existsSync(join(storeOf(TEST_DIR), "learnings.json"))).toBe(true); // Verify each config is correct format const cursorHooks = readJson(join(TEST_DIR, ".cursor", "hooks.json")); @@ -338,8 +349,8 @@ describe("Cross-Contamination Prevention", () => { // Init Cursor in DIR_B await runGitmem(["init", "--client", "cursor", "--yes"], { cwd: DIR_B }); - const scarsA = readJson(join(DIR_A, ".gitmem", "learnings.json")); - const scarsB = readJson(join(DIR_B, ".gitmem", "learnings.json")); + const scarsA = readJson(join(storeOf(DIR_A), "learnings.json")); + const scarsB = readJson(join(storeOf(DIR_B), "learnings.json")); // Same number of starter scars expect(scarsA.length).toBe(scarsB.length); @@ -367,7 +378,7 @@ describe("Client Auto-Detection", () => { expect(exitCode).toBe(0); expect(stdout).toContain("auto-detected"); - expect(stdout).toContain("Setup for Cursor"); + expect(stdout).toContain("Setting up for Cursor"); // Should create Cursor files expect(existsSync(join(TEST_DIR, ".cursor", "mcp.json"))).toBe(true); expect(existsSync(join(TEST_DIR, ".cursorrules"))).toBe(true); @@ -385,7 +396,7 @@ describe("Client Auto-Detection", () => { expect(exitCode).toBe(0); expect(stdout).toContain("auto-detected"); - expect(stdout).toContain("Setup for Claude Code"); + expect(stdout).toContain("Setting up for Claude Code"); expect(existsSync(join(TEST_DIR, ".mcp.json"))).toBe(true); expect(existsSync(join(TEST_DIR, "CLAUDE.md"))).toBe(true); } finally { @@ -402,7 +413,7 @@ describe("Client Auto-Detection", () => { const { stdout, exitCode } = await runGitmem(["init", "--yes"], { cwd: TEST_DIR }); expect(exitCode).toBe(0); - expect(stdout).toContain("Setup for Cursor"); + expect(stdout).toContain("Setting up for Cursor"); } finally { rmSync(TEST_DIR, { recursive: true, force: true }); } @@ -417,7 +428,7 @@ describe("Client Auto-Detection", () => { const { stdout, exitCode } = await runGitmem(["init", "--yes"], { cwd: TEST_DIR }); expect(exitCode).toBe(0); - expect(stdout).toContain("Setup for Claude Code"); + expect(stdout).toContain("Setting up for Claude Code"); } finally { rmSync(TEST_DIR, { recursive: true, force: true }); } @@ -431,7 +442,7 @@ describe("Client Auto-Detection", () => { const { stdout, exitCode } = await runGitmem(["init", "--yes"], { cwd: TEST_DIR }); expect(exitCode).toBe(0); - expect(stdout).toContain("Setup for Claude Code"); + expect(stdout).toContain("Setting up for Claude Code"); } finally { rmSync(TEST_DIR, { recursive: true, force: true }); } @@ -449,8 +460,8 @@ describe("Client Auto-Detection", () => { ); expect(exitCode).toBe(0); - expect(stdout).toContain("via --client flag"); - expect(stdout).toContain("Setup for Cursor"); + expect(stdout).not.toContain("auto-detected"); + expect(stdout).toContain("Setting up for Cursor"); // Should create Cursor files despite .claude/ existing expect(existsSync(join(TEST_DIR, ".cursor", "mcp.json"))).toBe(true); } finally { @@ -464,7 +475,7 @@ describe("Client Auto-Detection", () => { try { const { stderr, exitCode } = await runGitmem( - ["init", "--client", "vscode", "--yes"], + ["init", "--client", "emacs", "--yes"], { cwd: TEST_DIR } ); @@ -504,13 +515,15 @@ describe("Cursor: Uninstall", () => { ); expect(exitCode).toBe(0); - expect(stdout).toContain("Uninstall (Cursor)"); - expect(stdout).toContain("Uninstall complete"); + expect(stdout).toContain("Removing gitmem from Cursor"); + expect(stdout).toContain("gitmem-mcp has been removed"); // .cursorrules removed (was gitmem-only) expect(existsSync(join(TEST_DIR, ".cursorrules"))).toBe(false); - // .gitmem/ deleted (--all flag) + // The memory store is deleted (--all) — the sandbox one — and the repo's + // .gitmem/ (config + hooks) goes with it. + expect(existsSync(storeOf(TEST_DIR))).toBe(false); expect(existsSync(join(TEST_DIR, ".gitmem"))).toBe(false); }); @@ -523,10 +536,8 @@ describe("Cursor: Uninstall", () => { { cwd: TEST_DIR } ); - // Cursor has 4 steps (no permissions), Claude has 5 - expect(stdout).toContain("Step 4/4"); - expect(stdout).not.toContain("Step 5/"); - // Cursor uninstall has no permissions step — 4 steps total (verified above) + // Cursor has no permissions step + expect(stdout).toContain("Not needed for Cursor"); }); }); diff --git a/tests/e2e/git-120-hooks-merge.test.ts b/tests/e2e/git-120-hooks-merge.test.ts index 4cc995d..9409a0a 100644 --- a/tests/e2e/git-120-hooks-merge.test.ts +++ b/tests/e2e/git-120-hooks-merge.test.ts @@ -23,7 +23,7 @@ async function gitmem(args: string[], cwd: string, home: string) { try { const { stdout, stderr } = await execFile("node", [GITMEM_BIN, ...args], { cwd, - env: { ...process.env, HOME: home, NO_COLOR: "1", SUPABASE_URL: "", SUPABASE_SERVICE_ROLE_KEY: "", GITMEM_TIER: "free" }, + env: { ...process.env, HOME: home, GITMEM_DIR: "", GITMEM_HOME: "", NO_COLOR: "1", SUPABASE_URL: "", SUPABASE_SERVICE_ROLE_KEY: "", GITMEM_TIER: "free" }, timeout: 30_000, }); return { stdout, stderr, code: 0 }; diff --git a/tests/e2e/mcp-client.ts b/tests/e2e/mcp-client.ts index a4d28c7..fcc4c91 100644 --- a/tests/e2e/mcp-client.ts +++ b/tests/e2e/mcp-client.ts @@ -11,6 +11,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { spawn, type ChildProcess } from "child_process"; +import { assertSandboxedStore } from "../helpers/e2e-store-guard.js"; import { join } from "path"; /** @@ -57,6 +58,9 @@ export async function createMcpClient( NODE_ENV: "test", }; + // GIT-123: never start a server whose store is the developer's own ~/.gitmem + assertSandboxedStore("node", [serverPath], serverEnv); + // Create transport that spawns the server const transport = new StdioClientTransport({ command: "node", diff --git a/tests/e2e/organic-discovery/organic-discovery.test.ts b/tests/e2e/organic-discovery/organic-discovery.test.ts index 1c7f949..961de37 100644 --- a/tests/e2e/organic-discovery/organic-discovery.test.ts +++ b/tests/e2e/organic-discovery/organic-discovery.test.ts @@ -326,7 +326,7 @@ function setupTestDir(configId: string, runNumber: number, hooksConfig: HooksCon gitmem: { command: "node", args: [join(GITMEM_ROOT, "dist/index.js")], - env: { GITMEM_TIER: "free" }, + env: { GITMEM_TIER: "free", GITMEM_DIR: join(testDir, ".store") }, }, }, }; @@ -380,6 +380,13 @@ async function runConfigChain( const testDir = setupTestDir(config.id, runNumber, hooksConfig); const memoryMdPath = join(testDir, "MEMORY.md"); + // GIT-123: the Claude session, its MCP server and its hooks use a store inside + // the test dir, never the developer's ~/.gitmem. The session inherits + // process.env, so it is set for the chain and restored in the finally. + const savedStoreEnv = { dir: process.env.GITMEM_DIR, home: process.env.GITMEM_HOME }; + process.env.GITMEM_DIR = join(testDir, ".store"); + process.env.GITMEM_HOME = ""; + const sessionResults: SessionMetrics[] = []; try { @@ -414,6 +421,8 @@ async function runConfigChain( sessionResults.push(metrics); } } finally { + if (savedStoreEnv.dir === undefined) delete process.env.GITMEM_DIR; else process.env.GITMEM_DIR = savedStoreEnv.dir; + if (savedStoreEnv.home === undefined) delete process.env.GITMEM_HOME; else process.env.GITMEM_HOME = savedStoreEnv.home; cleanupTestDir(testDir); } diff --git a/tests/e2e/pro-fresh.test.ts b/tests/e2e/pro-fresh.test.ts index 3673962..53c9019 100644 --- a/tests/e2e/pro-fresh.test.ts +++ b/tests/e2e/pro-fresh.test.ts @@ -26,7 +26,8 @@ import { } from "./mcp-client.js"; import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; import { Client } from "pg"; -import { readFileSync } from "fs"; +import { readFileSync, mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; import { join } from "path"; // This test requires Docker - probe for a working container runtime @@ -42,6 +43,7 @@ try { describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Fresh Install E2E", () => { let container: StartedPostgreSqlContainer; let pgClient: Client; + let sandboxHome = ""; let mcpClient: McpTestClient; beforeAll(async () => { @@ -80,7 +82,12 @@ describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Fresh Install E2E", () => { console.log("[e2e] Schema loaded and starter scars seeded"); // Create MCP client pointing to the test database + // GIT-123: the server's store is a scratch dir, never the developer's ~/.gitmem + sandboxHome = mkdtempSync(join(tmpdir(), "gitmem-pro-home-")); mcpClient = await createMcpClient({ + HOME: sandboxHome, + GITMEM_HOME: "", + GITMEM_DIR: join(sandboxHome, ".gitmem"), SUPABASE_URL: container.getConnectionUri(), SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", GITMEM_TIER: "pro", @@ -92,6 +99,7 @@ describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Fresh Install E2E", () => { if (mcpClient) { await mcpClient.cleanup(); } + if (sandboxHome) rmSync(sandboxHome, { recursive: true, force: true }); if (pgClient) { await pgClient.end(); } diff --git a/tests/e2e/pro-mature.test.ts b/tests/e2e/pro-mature.test.ts index ec52411..f80b299 100644 --- a/tests/e2e/pro-mature.test.ts +++ b/tests/e2e/pro-mature.test.ts @@ -22,7 +22,8 @@ import { } from "./mcp-client.js"; import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; import { Client } from "pg"; -import { readFileSync } from "fs"; +import { readFileSync, mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; import { join } from "path"; import { BASELINES } from "../performance/baselines.js"; @@ -39,6 +40,7 @@ try { describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Mature System E2E", () => { let container: StartedPostgreSqlContainer; let pgClient: Client; + let sandboxHome = ""; let mcpClient: McpTestClient; beforeAll(async () => { @@ -80,7 +82,12 @@ describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Mature System E2E", () => { await pgClient.query("ANALYZE gitmem_decisions"); // Create MCP client + // GIT-123: the server's store is a scratch dir, never the developer's ~/.gitmem + sandboxHome = mkdtempSync(join(tmpdir(), "gitmem-pro-home-")); mcpClient = await createMcpClient({ + HOME: sandboxHome, + GITMEM_HOME: "", + GITMEM_DIR: join(sandboxHome, ".gitmem"), SUPABASE_URL: container.getConnectionUri(), SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", GITMEM_TIER: "pro", @@ -92,6 +99,7 @@ describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Mature System E2E", () => { if (mcpClient) { await mcpClient.cleanup(); } + if (sandboxHome) rmSync(sandboxHome, { recursive: true, force: true }); if (pgClient) { await pgClient.end(); } diff --git a/tests/e2e/setup-store-guard.ts b/tests/e2e/setup-store-guard.ts new file mode 100644 index 0000000..08ac568 --- /dev/null +++ b/tests/e2e/setup-store-guard.ts @@ -0,0 +1,42 @@ +/** + * GIT-123: applies the e2e store guard to every child process an e2e test + * starts, whatever helper it uses to start it. + */ + +import cp from "child_process"; +import { syncBuiltinESMExports } from "module"; +import { promisify } from "util"; +import { assertSandboxedStore } from "../helpers/e2e-store-guard.js"; + +type AnyFn = (...a: any[]) => any; + +/** Split spawn-style arguments into command, args, env. */ +function parts(kind: "spawn" | "exec" | "fork", a: any[]): { command: string; args: string[]; env?: Record } { + const args = Array.isArray(a[1]) ? (a[1] as string[]) : []; + const options = [a[1], a[2], a[3]].find((x) => x && typeof x === "object" && !Array.isArray(x)); + const env = options?.env; + if (kind === "fork") return { command: process.execPath, args: [String(a[0]), ...args], env }; + if (kind === "exec") return { command: String(a[0]), args: [], env }; + return { command: String(a[0]), args, env }; +} + +function guarded(name: string, kind: "spawn" | "exec" | "fork"): void { + const original = (cp as any)[name] as AnyFn; + const check = (a: any[]) => { + const { command, args, env } = parts(kind, a); + // exec/execSync take one command string: match on the whole string. + assertSandboxedStore(kind === "exec" ? "gitmem-shell" : command, kind === "exec" ? [command] : args, env); + }; + const wrapped: AnyFn = (...a) => { check(a); return original(...a); }; + const custom = (original as any)[promisify.custom]; + if (custom) (wrapped as any)[promisify.custom] = (...a: any[]) => { + try { check(a); } catch (e) { return Promise.reject(e); } + return custom(...a); + }; + (cp as any)[name] = wrapped; +} + +for (const n of ["spawn", "spawnSync", "execFile", "execFileSync"]) guarded(n, "spawn"); +for (const n of ["exec", "execSync"]) guarded(n, "exec"); +guarded("fork", "fork"); +syncBuiltinESMExports(); diff --git a/tests/e2e/store-guard.test.ts b/tests/e2e/store-guard.test.ts new file mode 100644 index 0000000..739b315 --- /dev/null +++ b/tests/e2e/store-guard.test.ts @@ -0,0 +1,22 @@ +/** GIT-123: through the installed child_process guard, an unsandboxed gitmem call fails before it spawns. */ +import { describe, it, expect } from "vitest"; +import { execFile as execFileCb, execFileSync, spawn } from "child_process"; +import { promisify } from "util"; +import { homedir } from "os"; +import { join } from "path"; + +const BIN = join(__dirname, "../../bin/gitmem.js"); + +describe("e2e child-process store guard (GIT-123)", () => { + it("execFileSync with the real HOME throws, and runs nothing", () => { + expect(() => execFileSync("node", [BIN, "--version"], { env: { ...process.env, HOME: homedir(), GITMEM_DIR: "", GITMEM_HOME: "" } })).toThrow(/e2e store guard/); + }); + + it("promisified execFile with the real HOME rejects", async () => { + await expect(promisify(execFileCb)("node", [BIN, "--version"], { env: { ...process.env, HOME: homedir(), GITMEM_DIR: "", GITMEM_HOME: "" } })).rejects.toThrow(/e2e store guard/); + }); + + it("spawn of the server with the real HOME throws", () => { + expect(() => spawn("node", [join(__dirname, "../../dist/index.js")], { env: { ...process.env, HOME: homedir(), GITMEM_DIR: "" } })).toThrow(/e2e store guard/); + }); +}); diff --git a/tests/e2e/user-journey.test.ts b/tests/e2e/user-journey.test.ts index 48f0534..8477108 100644 --- a/tests/e2e/user-journey.test.ts +++ b/tests/e2e/user-journey.test.ts @@ -153,10 +153,17 @@ describe.skipIf(!claudeAvailable)( "User Journey: Claude Agent SDK with GitMem", () => { const TEST_DIR = join(tmpdir(), `gitmem-journey-${Date.now()}`); + // GIT-123: the CLI, the MCP server and the hooks of the Claude session all + // use this store, never the developer's ~/.gitmem. process.env is inherited + // by the session, so it is set for this block and restored after. + const STORE = join(TEST_DIR, ".store"); + const savedStoreEnv = { dir: process.env.GITMEM_DIR, home: process.env.GITMEM_HOME }; beforeAll(async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); + process.env.GITMEM_DIR = STORE; + process.env.GITMEM_HOME = ""; // 1. gitmem init — creates .gitmem/ + .claude/settings.json with permissions await execFile("node", [GITMEM_BIN, "init", "--yes"], { @@ -166,6 +173,8 @@ describe.skipIf(!claudeAvailable)( SUPABASE_URL: "", SUPABASE_SERVICE_ROLE_KEY: "", GITMEM_TIER: "free", + GITMEM_DIR: STORE, + GITMEM_HOME: "", }, }); @@ -175,7 +184,7 @@ describe.skipIf(!claudeAvailable)( gitmem: { command: "node", args: [join(GITMEM_ROOT, "dist/index.js")], - env: { GITMEM_TIER: "free" }, + env: { GITMEM_TIER: "free", GITMEM_DIR: STORE }, }, }, }; @@ -203,12 +212,14 @@ describe.skipIf(!claudeAvailable)( if (!settings.hooks?.SessionStart) { throw new Error("Setup failed: hooks not in settings.json"); } - if (!existsSync(join(TEST_DIR, ".gitmem", "learnings.json"))) { - throw new Error("Setup failed: .gitmem/learnings.json missing"); + if (!existsSync(join(STORE, "learnings.json"))) { + throw new Error("Setup failed: learnings.json missing from the store"); } }, 30_000); afterAll(() => { + if (savedStoreEnv.dir === undefined) delete process.env.GITMEM_DIR; else process.env.GITMEM_DIR = savedStoreEnv.dir; + if (savedStoreEnv.home === undefined) delete process.env.GITMEM_HOME; else process.env.GITMEM_HOME = savedStoreEnv.home; if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); diff --git a/tests/helpers/e2e-store-guard.ts b/tests/helpers/e2e-store-guard.ts new file mode 100644 index 0000000..fa8a99d --- /dev/null +++ b/tests/helpers/e2e-store-guard.ts @@ -0,0 +1,65 @@ +/** + * GIT-123: no e2e test may run a gitmem process whose store is the developer's. + * + * `uninstall --all` deletes the store, and the server and `init` write to it, at + * GITMEM_DIR, else (GITMEM_HOME || HOME)/.gitmem (bin/gitmem-root.js, + * src/services/gitmem-dir.ts). A test that spawns one of them without + * overriding those variables acts on the real ~/.gitmem. On 2026-10-04 that + * deleted a developer's store. CI runners are ephemeral, which is why it only + * showed on a real machine. + * + * The guard resolves the store root a child would see and refuses unless it is + * under os.tmpdir(). It is installed for every e2e worker by + * tests/e2e/setup-store-guard.ts, and createMcpClient calls it directly. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +type Env = Record; + +/** The store root a gitmem process started with this env would use. */ +export function effectiveStoreRoot(env: Env): string | null { + if (env.GITMEM_DIR) return env.GITMEM_DIR; + const base = env.GITMEM_HOME || env.HOME; + return base ? path.join(base, ".gitmem") : null; +} + +/** realpath of the deepest existing ancestor, so /var/folders and /private/var compare equal. */ +function resolveReal(p: string): string { + let current = path.resolve(p); + const rest: string[] = []; + while (!fs.existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) break; + rest.unshift(path.basename(current)); + current = parent; + } + return path.join(fs.realpathSync(current), ...rest); +} + +export function isUnderTmpdir(p: string): boolean { + const tmp = resolveReal(os.tmpdir()); + const target = resolveReal(p); + return target === tmp || target.startsWith(tmp + path.sep); +} + +const GITMEM_ARG = /(^|[\\/])bin[\\/]gitmem\.js$|(^|[\\/])dist[\\/]index\.js$|gitmem-mcp/; + +/** Is this command a gitmem CLI or server (as opposed to git, bash, ...)? */ +export function isGitmemInvocation(command: string, args: readonly string[]): boolean { + return GITMEM_ARG.test(command) || /(^|[\\/])gitmem$/.test(command) || args.some((a) => GITMEM_ARG.test(a)); +} + +/** Throws unless the store this gitmem process would use is under os.tmpdir(). */ +export function assertSandboxedStore(command: string, args: readonly string[], env: Env | undefined): void { + if (!isGitmemInvocation(command, args)) return; + const root = effectiveStoreRoot(env ?? process.env); + if (root && isUnderTmpdir(root)) return; + throw new Error( + `[e2e store guard] refusing to run "${path.basename(command)} ${args.map((a) => path.basename(String(a))).join(" ")}": ` + + `its store would be ${root ?? "(no HOME, GITMEM_HOME or GITMEM_DIR)"}, which is not under ${os.tmpdir()}. ` + + `Pass a sandbox HOME, GITMEM_HOME and GITMEM_DIR in the env (GIT-123).` + ); +} diff --git a/tests/unit/e2e-store-guard.test.ts b/tests/unit/e2e-store-guard.test.ts new file mode 100644 index 0000000..779a18c --- /dev/null +++ b/tests/unit/e2e-store-guard.test.ts @@ -0,0 +1,52 @@ +/** GIT-123: the e2e store guard refuses any gitmem process whose store is not under os.tmpdir(). */ +import { describe, it, expect } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assertSandboxedStore, effectiveStoreRoot, isGitmemInvocation } from "../helpers/e2e-store-guard.js"; + +const BIN = "/repo/bin/gitmem.js"; +const sandbox = () => fs.mkdtempSync(path.join(os.tmpdir(), "gitmem-guard-")); + +describe("e2e store guard (GIT-123)", () => { + it("refuses the CLI when HOME is the developer's", () => { + expect(() => assertSandboxedStore("node", [BIN, "uninstall", "--all"], { HOME: os.homedir() })).toThrow(/not under/); + }); + + it("refuses when GITMEM_HOME points outside tmp", () => { + expect(() => assertSandboxedStore("node", [BIN], { HOME: sandbox(), GITMEM_HOME: os.homedir() })).toThrow(/e2e store guard/); + }); + + it("refuses when GITMEM_DIR points outside tmp, even with a sandbox HOME", () => { + expect(() => assertSandboxedStore("node", [BIN], { HOME: sandbox(), GITMEM_DIR: path.join(os.homedir(), ".gitmem") })).toThrow(); + }); + + it("refuses the server (dist/index.js) the same way", () => { + expect(() => assertSandboxedStore("node", ["/repo/dist/index.js"], { HOME: os.homedir() })).toThrow(); + }); + + it("refuses when no store root can be derived", () => { + expect(() => assertSandboxedStore("node", [BIN], {})).toThrow(/no HOME/); + }); + + it("allows a sandbox HOME under tmpdir, including one that does not exist yet", () => { + const home = sandbox(); + expect(() => assertSandboxedStore("node", [BIN], { HOME: home })).not.toThrow(); + expect(() => assertSandboxedStore("node", [BIN], { HOME: path.join(home, "not", "yet") })).not.toThrow(); + }); + + it("allows an explicit GITMEM_DIR under tmpdir; GITMEM_DIR wins over HOME", () => { + const dir = sandbox(); + expect(effectiveStoreRoot({ GITMEM_DIR: dir, HOME: os.homedir() })).toBe(dir); + expect(() => assertSandboxedStore("node", [BIN], { GITMEM_DIR: dir, HOME: os.homedir() })).not.toThrow(); + }); + + it("an empty GITMEM_DIR/GITMEM_HOME falls through to HOME, as the CLI does", () => { + expect(effectiveStoreRoot({ GITMEM_DIR: "", GITMEM_HOME: "", HOME: "/h" })).toBe(path.join("/h", ".gitmem")); + }); + + it("does not touch commands that are not gitmem", () => { + expect(isGitmemInvocation("git", ["init"])).toBe(false); + expect(() => assertSandboxedStore("git", ["init"], { HOME: os.homedir() })).not.toThrow(); + }); +}); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 90063fd..346670a 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -26,6 +26,9 @@ export default defineConfig({ }, }, + // GIT-123: refuse any gitmem child process whose store is not under os.tmpdir() + setupFiles: ["tests/e2e/setup-store-guard.ts"], + // Environment environment: "node",