Files
gstack/test/gstack2-runtime-safety-config.test.ts
T

304 lines
13 KiB
TypeScript

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<void>((resolve) => { enteredResolve = resolve; });
const release = new Promise<void>((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("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");
});
});