/** * @file Tests for the S3 members' synchronous hook (scripts/member-guard.js) * and the AI-session counter it shares with spawnRun (server/lib/ai-procs.js): * which Bash commands count as a test run or a container command, and which * processes count as one top-level AI session, read from a fake /proc. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { isHeavy, guardTool } = require("../../scripts/member-guard.js"); const procs = require("../lib/ai-procs.js"); describe("member-guard: a test run or a container command", () => { const heavy = [ "npx vitest run", "vitest", "npm test", "npm t", "npm run test:unit", "pnpm test", "yarn test --watch=false", "bun test", "cd client && NODE_ENV=test npx vitest related src/a.ts --run", "timeout 300 npx jest", "./vendor/bin/phpunit --filter Foo", "php artisan test", "vendor/bin/pest", "python -m pytest -q", "pytest tests/", "go test ./...", "cargo test", "docker compose up -d", "docker-compose up", "podman run alpine", "node_modules/.bin/playwright test", 'bash -c "npm run e2e"', "make test", "npm run build && npm test 2>&1 | tail -20", ]; const fine = [ "npm run build", "npm install -D vitest", "git commit -m 'add vitest tests'", "rg -n docker src/", "cat tests/a.test.js", "ls node_modules/.bin/vitest", "echo run npm test later", "npx tsc -b", "npm run lint", "php artisan migrate:status", "go build ./...", "git log --grep test", "composer install", ]; for (const c of heavy) it(`refuses: ${c}`, () => assert.equal(isHeavy(c), true)); for (const c of fine) it(`allows: ${c}`, () => assert.equal(isHeavy(c), false)); it("answers Claude Code with a PreToolUse deny and its reason, and nothing for the rest", () => { const out = []; const write = process.stdout.write; process.stdout.write = (s) => out.push(String(s)); try { guardTool({ tool_name: "Bash", tool_input: { command: "npm test" } }); guardTool({ tool_name: "Bash", tool_input: { command: "npm run build" } }); guardTool({ tool_name: "Read", tool_input: { file_path: "/x" } }); } finally { process.stdout.write = write; } assert.equal(out.length, 1); const o = JSON.parse(out[0]).hookSpecificOutput; assert.equal(o.hookEventName, "PreToolUse"); assert.equal(o.permissionDecision, "deny"); assert.match(o.permissionDecisionReason, /own machine/); }); it("lets everything through once an admin allows tests", () => { const out = []; const write = process.stdout.write; process.stdout.write = (s) => out.push(String(s)); try { guardTool({ tool_name: "Bash", tool_input: { command: "npx vitest run" } }, true); } finally { process.stdout.write = write; } assert.equal(out.length, 0); }); }); /** A fake /proc: one dir per pid with stat, cmdline; owner is this process (uid). */ function fakeProc(list) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ah-proc-")); for (const p of list) { const d = path.join(root, String(p.pid)); fs.mkdirSync(d); // pid (comm) state ppid ... field 22 = starttime (index 19 after the ')') const fields = ["S", String(p.ppid), ...Array(17).fill("0"), String(p.start)]; fs.writeFileSync(path.join(d, "stat"), `${p.pid} (x y) ${fields.join(" ")}`); fs.writeFileSync(path.join(d, "cmdline"), p.argv.join("\0") + "\0"); } return root; } describe("member-policy: the admin's switch, read by the guard", () => { it("blocks tests without a file, and writes a world-readable one", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-app-")); const prev = process.env.AGENTHUB_APP_ROOT; process.env.AGENTHUB_APP_ROOT = dir; try { const policy = require("../lib/member-policy.js"); assert.equal(policy.read().allowTests, false); fs.writeFileSync(path.join(dir, policy.FILE), "not json"); assert.equal(policy.read().allowTests, false); assert.equal(policy.setAllowTests(true).allowTests, true); const f = path.join(dir, policy.FILE); assert.equal(fs.statSync(f).mode & 0o777, 0o644); assert.equal(policy.read(f).allowTests, true); assert.equal(policy.setAllowTests(false).allowTests, false); } finally { if (prev === undefined) delete process.env.AGENTHUB_APP_ROOT; else process.env.AGENTHUB_APP_ROOT = prev; } }); }); describe("ai-procs: a member's top-level AI sessions", () => { const uid = process.getuid(); const root = fakeProc([ { pid: 10, ppid: 1, start: 1, argv: ["tmux"] }, { pid: 11, ppid: 10, start: 5, argv: ["/opt/agenthub/tools/bin/claude", "--resume", "x"] }, { pid: 12, ppid: 11, start: 6, argv: ["/bin/bash", "-c", "claude -p hi"] }, { pid: 13, ppid: 12, start: 7, argv: ["claude", "-p", "hi"] }, // nested: not counted { pid: 20, ppid: 10, start: 3, argv: ["bash"] }, // a Terminal tab { pid: 21, ppid: 20, start: 4, argv: ["node", "/x/@openai/codex/bin/codex.js"] }, { pid: 22, ppid: 21, start: 4, argv: ["/x/codex", "--yolo"] }, // Codex's native child { pid: 30, ppid: 10, start: 9, argv: ["omp", "auth-gateway", "serve"] }, // a server { pid: 31, ppid: 10, start: 8, argv: ["vitest"] }, ]); it("counts each session once, oldest first, and no tool server", () => { assert.deepEqual( procs.sessionsOf(uid, root).map((s) => s.pid), [21, 11] ); }); it("finds the session a hook process runs under", () => { assert.equal(procs.sessionOf(13, root), 11); assert.equal(procs.sessionOf(31, root), null); }); it("reads the limit from server.conf, 4 without it, 0 = off", () => { const conf = path.join(root, "server.conf"); assert.equal(procs.sessionLimit(conf), 4); fs.writeFileSync(conf, "HOST_USER=x\nMAX_AI_SESSIONS=0\n"); assert.equal(procs.sessionLimit(conf), 0); }); });