import { afterEach, describe, expect, test } from "bun:test"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { cleanupRuntime, ensureManagedHome, purgeManagedHomeUnlocked, setupRuntime, stageUpgrade, uninstallManagedRuntime, } from "../runtime/index.js"; const roots: string[] = []; const configBin = path.resolve(import.meta.dir, "../bin/gstack-config"); const gstackBin = path.resolve(import.meta.dir, "../bin/gstack"); const updateCheckBin = path.resolve(import.meta.dir, "../bin/gstack-update-check"); async function root() { const result = await fs.mkdtemp(path.join(os.tmpdir(), "gstack2-safety-config-")); roots.push(result); return result; } afterEach(async () => { await Promise.all(roots.splice(0).map((entry) => fs.rm(entry, { recursive: true, force: true }))); }); describe("managed-home destructive boundary", () => { test("rejects symlinked tmp and versions directories before runtime mutation", async () => { if (process.platform === "win32") return; const base = await root(); const home = path.join(base, "home"); const outside = path.join(base, "outside"); await ensureManagedHome(home); await fs.mkdir(outside); await fs.writeFile(path.join(outside, "keep"), "keep\n"); await fs.symlink(outside, path.join(home, "tmp"), "dir"); await expect(uninstallManagedRuntime(home)).rejects.toMatchObject({ code: "MANAGED_HOME_SUBDIRECTORY_UNSAFE" }); await fs.rm(path.join(home, "tmp")); const source = path.join(base, "source"); await fs.mkdir(source); await fs.writeFile(path.join(source, "payload"), "payload\n"); await fs.symlink(outside, path.join(home, "versions"), "dir"); await expect(stageUpgrade({ home, sourceDir: source, version: "1.0.0" })) .rejects.toMatchObject({ code: "MANAGED_HOME_SUBDIRECTORY_UNSAFE" }); expect(await fs.readFile(path.join(outside, "keep"), "utf8")).toBe("keep\n"); expect(await fs.readdir(outside)).toEqual(["keep"]); }); test("claims only a new or empty directory and leaves nonempty input untouched", async () => { const base = await root(); const occupied = path.join(base, "occupied"); await fs.mkdir(occupied); await fs.writeFile(path.join(occupied, "keep.txt"), "keep\n"); await expect(ensureManagedHome(occupied)).rejects.toMatchObject({ code: "MANAGED_HOME_UNOWNED" }); expect(await fs.readdir(occupied)).toEqual(["keep.txt"]); const empty = path.join(base, "empty"); await fs.mkdir(empty); expect((await ensureManagedHome(empty)).created).toBe(true); expect((await ensureManagedHome(empty)).created).toBe(false); }); test("cleanup refuses an unowned directory even when names match runtime scratch", async () => { const base = await root(); const home = path.join(base, "unowned"); const scratch = path.join(home, "tmp", "install-11111111-1111-4111-8111-111111111111"); await fs.mkdir(scratch, { recursive: true }); await expect(cleanupRuntime(home, { olderThanMs: 0 })).rejects.toMatchObject({ code: "MANAGED_HOME_UNOWNED" }); expect((await fs.stat(scratch)).isDirectory()).toBe(true); }); test("purge removes managed state but preserves unrelated entries", async () => { const base = await root(); const home = path.join(base, "owned"); await ensureManagedHome(home); await fs.mkdir(path.join(home, "projects", "fixture"), { recursive: true }); await fs.writeFile(path.join(home, "unrelated.txt"), "keep\n"); const result = await purgeManagedHomeUnlocked(home); expect(result.preserved).toEqual(["unrelated.txt"]); expect(await fs.readFile(path.join(home, "unrelated.txt"), "utf8")).toBe("keep\n"); expect(await fs.stat(path.join(home, ".gstack-managed-home.json")).catch(() => null)).toBeNull(); }); test("recognized legacy config is adopted without purging pre-existing state", async () => { const base = await root(); const home = path.join(base, ".gstack"); await fs.mkdir(path.join(home, "projects", "legacy-project"), { recursive: true }); await fs.writeFile(path.join(home, "config.yaml"), "telemetry: off\n"); await fs.writeFile(path.join(home, "projects", "legacy-project", "notes.md"), "keep\n"); const ownership = await ensureManagedHome(home); expect(ownership.sentinel).toMatchObject({ adoptedLegacy: true, preexistingTopLevel: ["config.yaml", "projects"], }); await fs.writeFile(path.join(home, "config.json"), "{}\n"); const result = await purgeManagedHomeUnlocked(home); expect(result.preserved.sort()).toEqual(["config.yaml", "projects"]); expect(await fs.readFile(path.join(home, "projects", "legacy-project", "notes.md"), "utf8")).toBe("keep\n"); expect(await fs.readFile(path.join(home, "config.yaml"), "utf8")).toBe("telemetry: off\n"); expect(await fs.stat(path.join(home, "config.json")).catch(() => null)).toBeNull(); }); test("recognized legacy artifacts repo is adopted but near-misses remain unowned", async () => { const base = await root(); const home = path.join(base, "artifacts"); await fs.mkdir(path.join(home, ".git"), { recursive: true }); await fs.writeFile(path.join(home, ".gitignore"), "# gstack-artifacts sync via .brain-allowlist\n*\n"); await fs.writeFile(path.join(home, ".brain-allowlist"), "projects/*/learnings.jsonl\nretros/*.md\n"); await fs.writeFile(path.join(home, ".brain-privacy-map.json"), JSON.stringify([ { pattern: "projects/*/learnings.jsonl", class: "artifact" }, ])); await fs.writeFile(path.join(home, ".gitattributes"), "*.jsonl merge=jsonl-append\n"); await fs.mkdir(path.join(home, "projects", "legacy-project"), { recursive: true }); await fs.writeFile(path.join(home, "projects", "legacy-project", "learnings.jsonl"), "{\"keep\":true}\n"); const ownership = await ensureManagedHome(home); expect(ownership.sentinel).toMatchObject({ adoptedLegacy: true, preexistingTopLevel: [ ".brain-allowlist", ".brain-privacy-map.json", ".git", ".gitattributes", ".gitignore", "projects", ], }); const purged = await purgeManagedHomeUnlocked(home); expect(purged.preserved.sort()).toEqual([ ".brain-allowlist", ".brain-privacy-map.json", ".git", ".gitattributes", ".gitignore", "projects", ]); expect(await fs.readFile(path.join(home, "projects", "legacy-project", "learnings.jsonl"), "utf8")).toBe("{\"keep\":true}\n"); const nearMiss = path.join(base, "near-miss"); await fs.mkdir(path.join(nearMiss, ".git"), { recursive: true }); await fs.writeFile(path.join(nearMiss, ".brain-allowlist"), "projects/*/learnings.jsonl\nretros/*.md\n"); await expect(ensureManagedHome(nearMiss)).rejects.toMatchObject({ code: "MANAGED_HOME_UNOWNED" }); expect((await fs.readdir(nearMiss)).sort()).toEqual([".brain-allowlist", ".git"]); }); test("setup and purge serialize across the complete managed-home mutation", async () => { const base = await root(); const home = path.join(base, "state"); const project = path.join(base, "project"); await fs.mkdir(project); let enteredResolve!: () => void; let releaseResolve!: () => void; const entered = new Promise((resolve) => { enteredResolve = resolve; }); const release = new Promise((resolve) => { releaseResolve = resolve; }); const git = async (args: string[]) => { const operation = args.at(-1); if (operation === "--show-toplevel") { enteredResolve(); await release; return project; } return path.join(project, ".git"); }; const settingUp = setupRuntime({ home, cwd: project, git }); await entered; let purgeFinished = false; const purging = uninstallManagedRuntime(home, { purge: true }).then((result) => { purgeFinished = true; return result; }); await new Promise((resolve) => setTimeout(resolve, 40)); expect(purgeFinished).toBe(false); expect(await fs.stat(path.join(home, ".gstack-managed-home.json"))).toBeTruthy(); releaseResolve(); await settingUp; await purging; expect(await fs.stat(path.join(home, ".gstack-managed-home.json")).catch(() => null)).toBeNull(); }); }); describe("one config authority", () => { test("Agent Skills owns updates and passive GStack release requests are off", async () => { const base = await root(); const home = path.join(base, "state"); const remoteVersion = path.join(base, "remote-version"); await fs.writeFile(remoteVersion, "999.0.0\n"); const config = spawnSync(process.execPath, [configBin, "get", "update_check"], { encoding: "utf8", env: { ...process.env, GSTACK_HOME: home }, }); expect(config.status).toBe(0); expect(config.stdout).toBe("false"); const passive = spawnSync(updateCheckBin, [], { encoding: "utf8", env: { ...process.env, GSTACK_HOME: home, GSTACK_REMOTE_URL: `file://${remoteVersion}`, }, }); expect(passive.status).toBe(0); expect(passive.stdout).toBe(""); expect(await fs.stat(path.join(home, "last-update-check")).catch(() => null)).toBeNull(); }); test("compatibility helper and runtime config share config.json", async () => { const home = path.join(await root(), "state"); const run = (args: string[]) => spawnSync(process.execPath, [configBin, ...args], { encoding: "utf8", env: { ...process.env, GSTACK_HOME: home }, }); expect(run(["set", "telemetry", "anonymous"]).status).toBe(0); expect(run(["get", "telemetry"]).stdout).toBe("anonymous"); expect(JSON.parse(await fs.readFile(path.join(home, "config.json"), "utf8")).telemetry).toBe("anonymous"); expect(await fs.stat(path.join(home, "config.yaml")).catch(() => null)).toBeNull(); }); test("public config set claims a managed home before writing and remains setup-compatible", async () => { const base = await root(); const home = path.join(base, "state"); const project = path.join(base, "project"); await fs.mkdir(project); const run = (args: string[]) => spawnSync(process.execPath, [gstackBin, ...args], { cwd: project, encoding: "utf8", env: { ...process.env, GSTACK_HOME: home }, }); const set = run(["config", "set", "telemetry", "anonymous"]); expect(set.status).toBe(0); expect(JSON.parse(await fs.readFile(path.join(home, ".gstack-managed-home.json"), "utf8"))).toMatchObject({ kind: "gstack-managed-home", home, }); expect(JSON.parse(await fs.readFile(path.join(home, "config.json"), "utf8")).telemetry).toBe("anonymous"); const setup = run(["setup"]); expect(setup.status).toBe(0); expect(setup.stdout).toContain("GStack state initialized"); expect(setup.stdout).toContain("optional runtime: unchanged"); }); test("generic config writes cannot create an incoherent browser selection", async () => { const base = await root(); const home = path.join(base, "state"); const project = path.join(base, "project"); await fs.mkdir(project); const run = (args: string[]) => spawnSync(process.execPath, [gstackBin, ...args], { cwd: project, encoding: "utf8", env: { ...process.env, GSTACK_HOME: home }, }); for (const key of ["browser", "browser.provider", "browser.executablePath"]) { const result = run(["config", "set", key, "installed"]); expect(result.status).toBe(1); expect(result.stderr).toContain("gstack config browser"); } expect(await fs.stat(path.join(home, "config.json")).catch(() => null)).toBeNull(); }); test("legacy YAML is read-only migration input and JSON takes authority on write", async () => { const home = path.join(await root(), "legacy"); await fs.mkdir(home); await fs.writeFile(path.join(home, "config.yaml"), "telemetry: community\n"); const get = spawnSync(process.execPath, [configBin, "get", "telemetry"], { encoding: "utf8", env: { ...process.env, GSTACK_HOME: home }, }); expect(get.status).toBe(0); expect(get.stdout).toBe("community"); const set = spawnSync(process.execPath, [configBin, "set", "telemetry", "off"], { encoding: "utf8", env: { ...process.env, GSTACK_HOME: home }, }); expect(set.status).toBe(0); expect(await fs.readFile(path.join(home, "config.yaml"), "utf8")).toBe("telemetry: community\n"); expect(JSON.parse(await fs.readFile(path.join(home, "config.json"), "utf8")).telemetry).toBe("off"); const reread = spawnSync(process.execPath, [configBin, "get", "telemetry"], { encoding: "utf8", env: { ...process.env, GSTACK_HOME: home }, }); expect(reread.stdout).toBe("off"); expect(JSON.parse(await fs.readFile(path.join(home, ".gstack-managed-home.json"), "utf8"))).toMatchObject({ adoptedLegacy: true, preexistingTopLevel: ["config.yaml"], }); }); test("shipped runtime helper surface excludes host-specific skill installers", async () => { const install = await fs.readFile(path.resolve(import.meta.dir, "../runtime/install.js"), "utf8"); const config = await fs.readFile(configBin, "utf8"); expect(install).not.toContain('"gstack-team-init": helper'); expect(config).not.toMatch(/\.claude\/skills|gstack-relink|gen:skill-docs:user/); }); test("runtime remediation never assumes a host-specific skill path or legacy setup script", async () => { const sources = [ "runtime/install.js", "make-pdf/src/setup.ts", "make-pdf/src/browseClient.ts", "make-pdf/src/diagram-prepass.ts", ]; for (const relative of sources) { const source = await fs.readFile(path.resolve(import.meta.dir, "..", relative), "utf8"); expect(source, relative).not.toMatch(/(?:re-?run|run)\s+`?\.\/setup/i); expect(source, relative).not.toMatch(/(?:to fix|missing|unsafe|invalid)[\s\S]{0,240}(?:~\/)?\.claude\/skills/i); } const pdfSetup = await fs.readFile(path.resolve(import.meta.dir, "../make-pdf/src/setup.ts"), "utf8"); expect(pdfSetup).toContain("gstack doctor --skill-api 2.0"); expect(pdfSetup).toContain("--capability pdf"); }); });