import assert from "node:assert/strict"; import fs from "node:fs"; import { EventEmitter } from "node:events"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { writeConfigFile } from "../src/cli/config-file.js"; import { assertSetupPidSafe, instanceDirectory, instanceRunnerPath, matchesInstanceRunner, runInstanceCommand } from "../src/cli/instance.js"; import { runInstanceRunner, waitForShutdown } from "../src/cli/instance-runner.js"; import { parseConfig } from "../src/config.js"; test("instance paths reject traversal and config identity mismatch", async () => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-instances-")); const previous = process.env.GORI_AGENT_ROOT; process.env.GORI_AGENT_ROOT = root; try { assert.throws(() => instanceDirectory("../escape"), /Invalid bot ID/); assert.throws(() => instanceDirectory("Uppercase"), /Invalid bot ID/); await assert.rejects(runInstanceCommand("list", ["extra"]), /does not accept/); await assert.rejects(runInstanceCommand("status", ["one", "two"]), /exactly one/); await assert.rejects(runInstanceCommand("unknown", ["bot"]), /Unknown command/); await assert.rejects(runInstanceCommand("status", ["missing-bot"]), /Runtime config does not exist/); const directory = instanceDirectory("directory-bot"); const config = parseConfig({ configVersion: 3, bot: { id: "different-bot", workspace: "/tmp", persona: "", agent: { id: "kimi", command: "kimi", args: ["acp"] }, permissions: { mode: "deny" } }, gateway: { platform: { type: "webhook", secret: "secret" } }, runtime: {} }); writeConfigFile(path.join(directory, "config.json"), config); await assert.rejects(runInstanceCommand("status", ["directory-bot"]), /does not match config bot\.id/); const privateDirectory = instanceDirectory("private-bot"); writeConfigFile(path.join(privateDirectory, "config.json"), parseConfig({ configVersion: 3, bot: { id: "private-bot", workspace: "/tmp", persona: "", agent: { id: "kimi", command: "kimi", args: ["acp"] }, permissions: { mode: "deny" } }, gateway: { platform: { type: "webhook", secret: "secret" } }, runtime: {} })); await fs.promises.chmod(privateDirectory, 0o755); await assert.rejects(runInstanceCommand("status", ["private-bot"]), /mode must be 0700/); } finally { if (previous === undefined) delete process.env.GORI_AGENT_ROOT; else process.env.GORI_AGENT_ROOT = previous; } }); test("setup rejects both an owned runner PID and a foreign PID", () => { assert.throws(() => assertSetupPidSafe("safe-bot", { kind: "running", pid: 123 }), /instance 'safe-bot' is running/); assert.throws(() => assertSetupPidSafe("safe-bot", { kind: "foreign", pid: 456 }), /unrelated live process/); assert.doesNotThrow(() => assertSetupPidSafe("safe-bot", { kind: "absent" })); assert.doesNotThrow(() => assertSetupPidSafe("safe-bot", { kind: "stale" })); }); test("runner identity requires exact executable, runner, and config arguments", () => { const configFile = path.resolve("/tmp/test-instance-config.json"); const commandLine = [process.execPath, instanceRunnerPath(), configFile]; assert.equal(matchesInstanceRunner(commandLine, process.execPath, configFile), true); assert.equal(matchesInstanceRunner([...commandLine, "extra"], process.execPath, configFile), false); assert.equal(matchesInstanceRunner([process.execPath, path.resolve("dist/cli.js"), configFile], process.execPath, configFile), false); assert.equal(matchesInstanceRunner(commandLine, "/bin/sh", configFile), false); assert.equal(matchesInstanceRunner(commandLine, process.execPath, `${configFile}.other`), false); }); test("instance runner rejects unsafe argument and config paths before starting a server", async () => { await assert.rejects(runInstanceRunner([]), /exactly one config path/); await assert.rejects(runInstanceRunner(["one", "two"]), /exactly one config path/); await assert.rejects(runInstanceRunner([path.join(os.tmpdir(), `missing-runner-${Date.now()}.json`)]), /Runtime config does not exist/); }); test("instance runner gracefully shuts down once on SIGTERM", async () => { const signals = new EventEmitter(); let shutdowns = 0; const result = waitForShutdown({ shutdown: async () => { shutdowns++; } }, signals); signals.emit("SIGTERM"); signals.emit("SIGINT"); assert.equal(await result, 0); assert.equal(shutdowns, 1); assert.equal(signals.listenerCount("SIGINT"), 0); assert.equal(signals.listenerCount("SIGTERM"), 0); }); test("setup refuses a foreign live PID and unsafe PID file", async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-pid-")); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const previous = process.env.GORI_AGENT_ROOT; process.env.GORI_AGENT_ROOT = root; t.after(() => { if (previous === undefined) delete process.env.GORI_AGENT_ROOT; else process.env.GORI_AGENT_ROOT = previous; }); const directory = instanceDirectory("safe-bot"); writeConfigFile(path.join(directory, "config.json"), parseConfig({ configVersion: 3, bot: { id: "safe-bot", workspace: os.tmpdir(), persona: "", agent: { id: "node", command: process.execPath, args: ["acp"] }, permissions: { mode: "deny" } }, gateway: { platform: { type: "webhook", secret: "test-secret" } }, runtime: {} })); fs.mkdirSync(path.join(directory, "state"), { mode: 0o700 }); fs.mkdirSync(path.join(directory, "logs"), { mode: 0o700 }); const pidFile = path.join(directory, "state", "gori-agent.pid"); fs.writeFileSync(pidFile, `${process.pid}\n`, { mode: 0o600 }); await assert.rejects(runInstanceCommand("setup", ["safe-bot"]), /unrelated live process/); fs.chmodSync(pidFile, 0o644); await assert.rejects(runInstanceCommand("setup", ["safe-bot"]), /PID file mode must be 0600/); });