Files
gori-agent/test/instance.test.ts
2026-08-18 18:48:47 +08:00

133 lines
7.3 KiB
TypeScript

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 { configDigest, writeConfigFile } from "../src/cli/config-file.js";
import { assertSetupPidSafe, instanceDirectory, instanceRunnerPath, matchesInstanceRunner, runInstanceCommand } from "../src/cli/instance.js";
import { assertRunnerConfigSnapshot, assertRunnerWorkspaceSafety, 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 binds startup to the validated config snapshot and repeats workspace checks", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-runner-safety-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const parent = path.join(root, "workspace");
const child = path.join(parent, "child");
fs.mkdirSync(child, { recursive: true });
const config = parseConfig({
configVersion: 3,
bot: { id: "runner-bot", workspace: child, persona: "test", agent: { id: "kimi", command: "kimi", args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
const configFile = path.join(root, "instances", "runner-bot", "config.json");
writeConfigFile(configFile, config);
assert.doesNotThrow(() => assertRunnerConfigSnapshot(config, configDigest(config)));
assert.throws(() => assertRunnerConfigSnapshot(config, "changed"), /config changed/);
assert.throws(() => assertRunnerWorkspaceSafety(config, path.join(root, "wrong.json"), root), /directory contract/);
const peer = parseConfig({ ...config, bot: { ...config.bot, id: "peer-bot", workspace: parent } });
writeConfigFile(path.join(root, "instances", "peer-bot", "config.json"), peer);
assert.throws(() => assertRunnerWorkspaceSafety(config, configFile, root), /identical, parent, or child workspace/);
});
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/);
});