Refactor runtime around isolated bot instances

This commit is contained in:
zenord
2026-08-16 23:45:09 +08:00
parent 914579a94c
commit f9fb4ef776
51 changed files with 2355 additions and 1459 deletions
+15 -6
View File
@@ -3,9 +3,9 @@ import test from "node:test";
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
import { decidePermission } from "../src/acp/client.js";
const request = (rawInput: unknown): RequestPermissionRequest => ({
const request = (rawInput: unknown, overrides: Partial<RequestPermissionRequest["toolCall"]> = {}): RequestPermissionRequest => ({
sessionId: "session",
toolCall: { toolCallId: "call", title: "bash git status", kind: "execute", name: "bash", rawInput },
toolCall: { toolCallId: "call", title: "bash git status", kind: "execute", name: "bash", rawInput, ...overrides },
options: [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" }
@@ -13,11 +13,20 @@ const request = (rawInput: unknown): RequestPermissionRequest => ({
});
test("permission deny and allowlist fail closed", () => {
assert.equal(decidePermission(request("git status"), { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] }).outcome.outcome, "selected");
const allowed = decidePermission(request("git status"), { permissionMode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.equal(decidePermission(request("git status"), { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }).outcome.outcome, "selected");
const allowed = decidePermission(request("git status"), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(allowed.outcome, { outcome: "selected", optionId: "allow" });
const missingDetail = decidePermission(request(undefined), { permissionMode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
const objectInput = decidePermission(request({ command: "git status", timeout: 60 }), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(objectInput.outcome, { outcome: "selected", optionId: "allow" });
const missingDetail = decidePermission(request(undefined), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(missingDetail.outcome, { outcome: "selected", optionId: "reject" });
const destructive = decidePermission(request("rm -rf /"), { permissionMode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
const destructive = decidePermission(request("rm -rf /"), { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(destructive.outcome, { outcome: "selected", optionId: "reject" });
const unanchored = { mode: "allowlist" as const, allowedTools: ["bash"], allowedCommandPatterns: ["git status"] };
assert.deepEqual(decidePermission(request("git status"), unanchored).outcome, { outcome: "selected", optionId: "allow" });
assert.deepEqual(decidePermission(request("git status; rm -rf /"), unanchored).outcome, { outcome: "selected", optionId: "reject" });
assert.deepEqual(decidePermission(request("git status", { name: "python", title: "bash git status" }), unanchored).outcome, { outcome: "selected", optionId: "reject" });
assert.deepEqual(decidePermission(request("git status", { name: undefined, title: "bash git status" }), unanchored).outcome, { outcome: "selected", optionId: "reject" });
assert.deepEqual(decidePermission(request({ command: "git status", env: { PATH: "/tmp" } }), unanchored).outcome, { outcome: "selected", optionId: "reject" });
});
+52 -13
View File
@@ -3,27 +3,31 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { AcpBackendRegistry } from "../src/acp/backend-registry.js";
import { AcpSessionManager } from "../src/acp/session-manager.js";
import { parseConfig } from "../src/config.js";
import { DurableSessionStore } from "../src/core/durable-session-store.js";
import { RoleRegistry } from "../src/roles/role-registry.js";
import { BotProfileResolver } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
function config(stateFile: string, logFile: string) {
function config(stateFile: string, logFile: string, persona = "test", agentEnv: Record<string, string> = {}) {
return parseConfig({
configVersion: 2, acp: { stateFile, promptTimeoutMs: 2_000, cancelGraceMs: 100, idleTimeoutMs: 100, sweepIntervalMs: 20, maxProcesses: 2 },
backends: [{ id: "kimi", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile } }],
skills: [], defaultRole: "assistant",
roles: [{ id: "assistant", backend: "kimi", workspace: path.resolve("."), persona: "test", skills: [], policy: { permissionMode: "deny" } }],
platforms: {}
configVersion: 3,
bot: {
id: "test-bot", workspace: path.resolve("."), persona,
agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile, ...agentEnv } },
skills: [], permissions: { mode: "deny" }
},
gateway: { platform: { type: "qq", appId: "id", clientSecret: "secret", botNames: ["bot"] } },
runtime: { acp: { stateFile, promptTimeoutMs: 2_000, cancelGraceMs: 100, idleTimeoutMs: 100, sweepIntervalMs: 20, maxProcesses: 2 } }
});
}
async function runtime(stateFile: string, logFile: string) {
const cfg = config(stateFile, logFile); const store = new DurableSessionStore(stateFile); await store.open();
return { cfg, store, manager: new AcpSessionManager(cfg.acp, new AcpBackendRegistry(cfg.backends), new RoleRegistry(cfg), store) };
async function runtime(stateFile: string, logFile: string, persona = "test", agentEnv: Record<string, string> = {}) {
const cfg = config(stateFile, logFile, persona, agentEnv);
const store = new DurableSessionStore(stateFile, { botId: cfg.bot.id, platform: cfg.gateway.platform.type }); await store.open();
const bot = new BotProfileResolver(cfg).bot;
return { cfg, store, bot, manager: new AcpSessionManager(cfg.runtime.acp, bot, store) };
}
test("creates, persists, idles, and resumes the same native session", async () => {
@@ -31,7 +35,7 @@ test("creates, persists, idles, and resumes the same native session", async () =
const first = await runtime(state, log);
const response = await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "one" });
const sessionId = response.text.split(":")[1];
assert.match(response.text, /reply:fake-/);
assert.equal(response.botId, "test-bot"); assert.match(response.text, /reply:fake-/);
await new Promise((resolve) => setTimeout(resolve, 180));
await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "two" });
await first.manager.shutdown(); await first.store.close();
@@ -44,7 +48,33 @@ test("creates, persists, idles, and resumes the same native session", async () =
await second.manager.shutdown(); await second.store.close();
});
test("cancel reaches a hanging ACP prompt and new unbinds", async () => {
test("resume falls back to load and failed restore creates a new session", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-restore-"));
const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
const resumeFail = path.join(dir, "resume-fail"); const loadFail = path.join(dir, "load-fail");
const agentEnv = { FAKE_ACP_RESUME_FAIL_FILE: resumeFail, FAKE_ACP_LOAD_FAIL_FILE: loadFail };
const first = await runtime(state, log, "test", agentEnv);
const initial = await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "one" });
const initialSession = initial.text.split(":")[1];
await first.manager.shutdown(); await first.store.close();
await fs.promises.writeFile(resumeFail, "1");
const fallback = await runtime(state, log, "test", agentEnv);
const loaded = await fallback.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "two" });
assert.equal(loaded.text, `reply:${initialSession}:two`);
await fallback.manager.shutdown(); await fallback.store.close();
await fs.promises.writeFile(loadFail, "1");
const replacement = await runtime(state, log, "test", agentEnv);
const fresh = await replacement.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "three" });
assert.notEqual(fresh.text.split(":")[1], initialSession);
await replacement.manager.shutdown(); await replacement.store.close();
const entries = (await fs.promises.readFile(log, "utf8")).trim().split("\n").map(JSON.parse);
assert.ok(entries.some((entry) => entry.method === "session/load" && entry.sessionId === initialSession));
});
test("cancel reaches hanging prompt, reset unbinds, and fingerprint changes session", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-cancel-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
const current = await runtime(state, log);
await current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "ready" });
@@ -55,4 +85,13 @@ test("cancel reaches a hanging ACP prompt and new unbinds", async () => {
await current.manager.reset("qq", "chat");
assert.equal(current.store.stats().bindings, 0);
await current.manager.shutdown(); await current.store.close();
const original = await runtime(state, log, "one");
const first = await original.manager.prompt({ platform: "qq", chatId: "other", userId: "user", text: "first" });
const firstSession = first.text.split(":")[1];
await original.manager.shutdown(); await original.store.close();
const changed = await runtime(state, log, "two");
const second = await changed.manager.prompt({ platform: "qq", chatId: "other", userId: "user", text: "second" });
assert.notEqual(second.text.split(":")[1], firstSession);
await changed.manager.shutdown(); await changed.store.close();
});
+6 -8
View File
@@ -3,21 +3,19 @@ import path from "node:path";
import test from "node:test";
import { AcpWorker } from "../src/acp/worker.js";
import { parseConfig } from "../src/config.js";
import { RoleRegistry } from "../src/roles/role-registry.js";
import { BotProfileResolver } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
function makeWorker(promptTimeoutMs = 80) {
const config = parseConfig({
configVersion: 2,
acp: { promptTimeoutMs, cancelGraceMs: 30 },
backends: [{ id: "kimi", command: process.execPath, args: [fixture] }],
defaultRole: "assistant",
roles: [{ id: "assistant", backend: "kimi", workspace: path.resolve("."), policy: { permissionMode: "deny" } }],
platforms: {}
configVersion: 3,
bot: { id: "test-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: { acp: { promptTimeoutMs, cancelGraceMs: 30 } }
});
let crashes = 0;
const worker = new AcpWorker(config.backends[0], new RoleRegistry(config).get(), config.acp, () => { crashes++; });
const worker = new AcpWorker(new BotProfileResolver(config).bot, config.runtime.acp, () => { crashes++; });
return { worker, crashes: () => crashes };
}
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { parseConfig } from "../src/config.js";
import { assertOperationalConfig, loadConfigFile, writeConfigFile } from "../src/cli/config-file.js";
const config = parseConfig({
configVersion: 3,
bot: { id: "file-bot", workspace: "/tmp", persona: "", agent: { id: "node", command: process.execPath, args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
test("runtime config loading fails closed instead of seeding example", () => {
const missing = path.join(os.tmpdir(), `gori-missing-${Date.now()}`, "config.json");
assert.throws(() => loadConfigFile(missing), /documentation only/);
});
test("config writes are atomic and mode 0600", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-config-write-"));
const file = path.join(dir, "config.json");
writeConfigFile(file, config);
assert.equal((await fs.promises.stat(dir)).mode & 0o777, 0o700);
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
assert.equal(loadConfigFile(file).config.bot.id, "file-bot");
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
});
test("operational validation rejects placeholders for every credential family", () => {
assert.doesNotThrow(() => assertOperationalConfig(config));
const platforms = [
{ type: "qq", appId: "QQ_APP_ID", clientSecret: "QQ_CLIENT_SECRET", botNames: ["QQ_BOT_NAME"] },
{ type: "feishu", appId: "FEISHU_APP_ID", appSecret: "FEISHU_APP_SECRET" },
{ type: "wecom", corpId: "WECOM_CORP_ID", agentId: "WECOM_AGENT_ID", secret: "WECOM_SECRET" },
{ type: "webhook", secret: "WEBHOOK_SECRET" },
{ type: "weixin", secret: "WEIXIN_SECRET" }
];
for (const platform of platforms) {
const candidate = parseConfig({ ...config, gateway: { ...config.gateway, platform } });
assert.throws(() => assertOperationalConfig(candidate), /missing or placeholder/);
}
});
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import test from "node:test";
function runCli(args: string[]) {
return spawnSync(process.execPath, ["--import", "tsx", path.resolve("src/cli.ts"), ...args], {
cwd: path.resolve("."),
encoding: "utf8",
env: { ...process.env, GORI_GATEWAY_CONFIG: "" }
});
}
test("CLI rejects unknown flags and command-specific extra arguments", () => {
const unknown = runCli(["status", "--bogus"]);
assert.equal(unknown.status, 1);
assert.match(unknown.stderr, /Unknown option: --bogus/);
const extra = runCli(["doctor", "extra"]);
assert.equal(extra.status, 1);
assert.match(extra.stderr, /doctor accepts only --config/);
const instanceFlag = runCli(["instance", "list", "--config", "config.json"]);
assert.equal(instanceFlag.status, 1);
assert.match(instanceFlag.stderr, /instance commands do not accept/);
});
+57 -21
View File
@@ -1,31 +1,67 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { parseConfig } from "../src/config.js";
const platforms = {
qq: { enabled: true, appId: "id", clientSecret: "secret", connectionMode: "websocket" as const },
feishu: {}, wecom: {}, webhook: {}, weixin: {}
};
function raw(overrides: Record<string, unknown> = {}) {
return {
configVersion: 3,
bot: {
id: "test-bot", workspace: "/tmp", persona: "test",
agent: { id: "kimi", command: "kimi", args: ["acp"], env: {} },
skills: [], permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {},
...overrides
};
}
test("migrates a v1 Kimi config and preserves QQ fields", () => {
const config = parseConfig({
server: { port: 8787 }, policy: {}, defaultAgent: "kimi",
agents: [{ name: "kimi", command: "/home/ubuntu/.kimi-code/bin/kimi", args: ["-p"], cwd: "/tmp" }], platforms
});
assert.equal(config.configVersion, 2);
assert.deepEqual(config.backends[0].args, ["acp"]);
assert.equal(config.roles[0].workspace, "/tmp");
assert.equal(config.platforms.qq.clientSecret, "secret");
assert.equal(config.platforms.qq.connectionMode, "websocket");
test("parses Config v3 defaults for one Bot, agent, and platform", () => {
const config = parseConfig(raw());
assert.equal(config.configVersion, 3);
assert.equal(config.bot.id, "test-bot");
assert.equal(config.bot.agent.id, "kimi");
assert.equal(config.gateway.platform.type, "webhook");
assert.equal(config.runtime.acp.promptTimeoutMs, 7_200_000);
assert.equal(config.bot.permissions.mode, "deny");
});
test("does not silently migrate a non-Kimi CLI agent", () => {
assert.throws(() => parseConfig({ defaultAgent: "echo", agents: [{ name: "echo", command: "node" }], platforms }), /only supports a Kimi/);
test("explicitly rejects non-v3 configuration and unknown fields", () => {
assert.throws(() => parseConfig({ configVersion: 2 }), /requires Config v3/);
assert.throws(() => parseConfig({ defaultAgent: "kimi" }), /requires Config v3/);
assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), legacyRole: "ops" } })), /Unrecognized key/);
assert.throws(() => parseConfig(raw({ gateway: { platform: { type: "webhook", secret: "secret", enabled: true } } })), /Unrecognized key/);
});
test("validates role references and absolute workspace", () => {
assert.throws(() => parseConfig({
configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "a",
roles: [{ id: "a", backend: "missing", workspace: "relative" }], platforms
}), /workspace must be absolute/);
test("validates bot ID, absolute workspace, skills, and command regex", () => {
assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), id: "../bad" } })), /bot\.id/);
assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), workspace: "relative" } })), /workspace must be absolute/);
assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), skills: [{ id: "x", file: "relative" }] } })), /file must be absolute/);
assert.throws(() => parseConfig(raw({ bot: { ...(raw().bot as object), permissions: { mode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["["] } } })), /invalid command pattern/);
});
test("validates gateway server host and port", () => {
for (const host of ["localhost", "0.0.0.0", "::1", "gateway.example.com"]) {
assert.equal(parseConfig(raw({ gateway: { server: { host, port: 8787 }, platform: { type: "webhook", secret: "secret" } } })).gateway.server.host, host);
}
for (const host of ["http://localhost", "localhost:8787", "[::1]", "bad host", "host/path"]) {
assert.throws(() => parseConfig(raw({ gateway: { server: { host, port: 8787 }, platform: { type: "webhook", secret: "secret" } } })), /server host/);
}
for (const port of [0, 65_536, 1.5]) {
assert.throws(() => parseConfig(raw({ gateway: { server: { host: "localhost", port }, platform: { type: "webhook", secret: "secret" } } })), /gateway/);
}
});
test("config.example.json is a parseable, secret-free documentation template", () => {
const text = fs.readFileSync(path.resolve("config.example.json"), "utf8");
const config = parseConfig(JSON.parse(text) as unknown);
assert.equal(config.configVersion, 3);
assert.equal(config.bot.id, "BOT_ID");
assert.equal(config.bot.permissions.mode, "deny");
assert.equal(config.runtime.acp.promptTimeoutMs, 7_200_000);
assert.equal(config.gateway.platform.type, "qq");
assert.match(text, /QQ_CLIENT_SECRET/);
assert.doesNotMatch(text, /configVersion"\s*:\s*[12]/);
});
+39 -18
View File
@@ -5,37 +5,58 @@ import path from "node:path";
import test from "node:test";
import { DurableSessionStore } from "../src/core/durable-session-store.js";
test("persists chat role and native session across reopen", async () => {
const identity = { botId: "test-bot", platform: "qq" };
test("persists state v2 binding across reopen with 0600 atomic file", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-"));
const file = path.join(dir, "state.json");
const first = new DurableSessionStore(file);
const first = new DurableSessionStore(file, identity);
await first.open();
await first.setSelectedRole("qq:chat", "ops");
await first.setBinding({ chatKey: "qq:chat", roleId: "ops", backendId: "kimi", nativeSessionId: "native-1", workspace: "/tmp", roleFingerprint: "fp", createdAt: 1, updatedAt: 1 });
await first.setBinding({ chatKey: "qq:chat", agentId: "kimi", nativeSessionId: "native-1", workspace: "/tmp", botFingerprint: "fp", createdAt: 1, updatedAt: 1 });
await first.close();
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
assert.equal(persisted.version, 2); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
const second = new DurableSessionStore(file);
const second = new DurableSessionStore(file, identity);
await second.open();
assert.equal(second.getSelectedRole("qq:chat", "assistant"), "ops");
assert.equal(second.getBinding("qq:chat", "ops")?.nativeSessionId, "native-1");
assert.equal(second.getBinding("qq:chat")?.nativeSessionId, "native-1");
await second.close();
});
test("preserves corrupt state and fails explicitly", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-bad-"));
test("rejects old state and bot or platform identity mismatch without rewriting", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-id-"));
const file = path.join(dir, "state.json");
await fs.promises.writeFile(file, "not-json");
const store = new DurableSessionStore(file);
await assert.rejects(store.open(), /original file was preserved/);
assert.equal(await fs.promises.readFile(file, "utf8"), "not-json");
const old = '{"version":1,"bindings":{}}\n';
await fs.promises.writeFile(file, old);
await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v2/);
assert.equal(await fs.promises.readFile(file, "utf8"), old);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "other", platform: "qq", bindings: {} }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "feishu", bindings: {} }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", bindings: { "qq:chat": { chatKey: "qq:other" } } }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /invalid state v2 binding/);
});
test("refuses a second writer lock", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-lock-"));
test("preserves corrupt state and refuses a second writer lock", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-bad-"));
const badFile = path.join(dir, "bad.json");
await fs.promises.writeFile(badFile, "not-json");
await assert.rejects(new DurableSessionStore(badFile, identity).open(), /original file was preserved/);
assert.equal(await fs.promises.readFile(badFile, "utf8"), "not-json");
const file = path.join(dir, "state.json");
const first = new DurableSessionStore(file); await first.open();
const second = new DurableSessionStore(file);
await assert.rejects(second.open(), /locked by another/);
const first = new DurableSessionStore(file, identity); await first.open();
await assert.rejects(new DurableSessionStore(file, identity).open(), /locked by another/);
await first.close();
await fs.promises.writeFile(`${file}.lock`, "2147483647\n", { mode: 0o600 });
const recovered = new DurableSessionStore(file, identity);
await recovered.open();
await recovered.close();
assert.equal(fs.existsSync(`${file}.lock`), false);
});
+10 -2
View File
@@ -21,8 +21,16 @@ const app = acp.agent({ name: "fake-acp-agent" })
log({ method: "session/new", sessionId, cwd: params.cwd });
return { sessionId };
})
.onRequest(acp.methods.agent.session.load, ({ params }) => { log({ method: "session/load", sessionId: params.sessionId }); return {}; })
.onRequest(acp.methods.agent.session.resume, ({ params }) => { log({ method: "session/resume", sessionId: params.sessionId }); return {}; })
.onRequest(acp.methods.agent.session.load, ({ params }) => {
log({ method: "session/load", sessionId: params.sessionId });
if (process.env.FAKE_ACP_LOAD_FAIL === "1" || (process.env.FAKE_ACP_LOAD_FAIL_FILE && fs.existsSync(process.env.FAKE_ACP_LOAD_FAIL_FILE))) throw new Error("load failed");
return {};
})
.onRequest(acp.methods.agent.session.resume, ({ params }) => {
log({ method: "session/resume", sessionId: params.sessionId });
if (process.env.FAKE_ACP_RESUME_FAIL === "1" || (process.env.FAKE_ACP_RESUME_FAIL_FILE && fs.existsSync(process.env.FAKE_ACP_RESUME_FAIL_FILE))) throw new Error("resume failed");
return {};
})
.onRequest(acp.methods.agent.session.close, ({ params }) => { log({ method: "session/close", sessionId: params.sessionId }); return {}; })
.onRequest(acp.methods.agent.session.list, () => ({ sessions: [] }))
.onRequest(acp.methods.agent.session.prompt, async ({ params, client, signal }) => {
+12 -16
View File
@@ -1,32 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ConversationRuntime } from "../src/acp/types.js";
import { parseConfig } from "../src/config.js";
import type { PlatformAdapter } from "../src/core/adapter.js";
import { Gateway } from "../src/core/gateway.js";
import type { IncomingMessage } from "../src/core/types.js";
import { RoleRegistry } from "../src/roles/role-registry.js";
class FakeRuntime implements ConversationRuntime {
role = "assistant"; prompts = 0; cancelled = 0; resets = 0; release?: () => void;
async prompt() { this.prompts++; await new Promise<void>((resolve) => { this.release = resolve; }); return { text: "done", roleId: this.role, backendId: "kimi" }; }
prompts = 0; cancelled = 0; resets = 0; release?: () => void;
async prompt() { this.prompts++; await new Promise<void>((resolve) => { this.release = resolve; }); return { text: "done", botId: "test-bot", agentId: "kimi" }; }
async cancel() { this.cancelled++; this.release?.(); return true; }
async reset() { this.resets++; }
async selectRole(_p: string, _c: string, role: string) { this.role = role; }
selectedRole() { return this.role; }
status() { return { role: this.role, running: Boolean(this.release) }; }
status() { return { bot: "test-bot", agent: "kimi", workspace: "/tmp", running: Boolean(this.release) }; }
stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; }
async shutdown() {}
}
const cfg = parseConfig({ configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "assistant", roles: [
{ id: "assistant", backend: "kimi", workspace: "/tmp" }, { id: "ops", backend: "kimi", workspace: "/tmp" }
], platforms: {} });
const policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false };
const adapter: PlatformAdapter = { name: "test", async handleWebhook() { return {}; }, async sendMessage() {} };
const message = (text: string): IncomingMessage => ({ platform: "qq", chatId: "chat", userId: "user", text });
test("cancel bypasses the chat lock and new resets", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(cfg.policy, runtime, new RoleRegistry(cfg));
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime);
const turn = gateway.receive(message("work"), adapter, { synchronous: true });
await new Promise((resolve) => setTimeout(resolve, 10));
const cancelled = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
@@ -37,9 +31,11 @@ test("cancel bypasses the chat lock and new resets", async () => {
assert.equal(runtime.resets, 1);
});
test("role aliases, role selection, and status are routed", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(cfg.policy, runtime, new RoleRegistry(cfg));
assert.match((await gateway.receive(message("/agents"), adapter, { synchronous: true })).reply || "", /Deprecated alias/);
assert.equal((await gateway.receive(message("/role ops"), adapter, { synchronous: true })).reply, "Selected role: ops");
assert.match((await gateway.receive(message("/status"), adapter, { synchronous: true })).reply || "", /role=ops/);
test("fixed Bot status and retired role commands never reach ACP", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime);
for (const command of ["/roles", "/role ops", "/agents", "/agent ops"]) {
assert.match((await gateway.receive(message(command), adapter, { synchronous: true })).reply || "", /removed in Config v3/);
}
assert.match((await gateway.receive(message("/status"), adapter, { synchronous: true })).reply || "", /bot=test-bot/);
assert.equal(runtime.prompts, 0);
});
+45
View File
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { writeConfigFile } from "../src/cli/config-file.js";
import { instanceDirectory, runInstanceCommand } from "../src/cli/instance.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 instance 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;
}
});
+7 -6
View File
@@ -1,16 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseConfig } from "../src/config.js";
import type { QqConfig } from "../src/config.js";
import { QqAdapter } from "../src/platforms/qq/adapter.js";
const config = parseConfig({ configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "assistant", roles: [{ id: "assistant", backend: "kimi", workspace: "/tmp" }], platforms: { qq: {
enabled: true, connectionMode: "webhook", appId: "id", clientSecret: "secret", verifySignature: false, botNames: ["Bot"]
} } });
const config: QqConfig = {
type: "qq", connectionMode: "webhook", appId: "id", clientSecret: "secret", botSecret: "", verifySignature: false,
botNames: ["Bot"], intents: 33_554_432, shard: [0, 1]
};
test("normalizes GROUP and C2C author openid while ACK remains immediate", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); throw new Error("ACP failed"); } };
const adapter = new QqAdapter(config.platforms.qq, gateway as never);
const adapter = new QqAdapter(config, gateway as never);
const group = await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m1", group_openid: "g1", author: { user_openid: "u1" }, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never });
const c2c = await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m2", author: { user_openid: "u2" }, content: "hello" } }, headers: {}, query: {}, req: {} as never });
assert.deepEqual(group.body, { op: 12 }); assert.deepEqual(c2c.body, { op: 12 });
@@ -27,7 +28,7 @@ test("sendMessage uses nested author.user_openid for C2C endpoint", async () =>
return new Response(JSON.stringify({ id: "sent" }), { status: 200 });
}) as typeof fetch;
try {
const adapter = new QqAdapter(config.platforms.qq, { receive: async () => ({ ok: true }) } as never);
const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never);
await adapter.sendMessage({ target: { platform: "qq", chatId: "user:u2", raw: { author: { user_openid: "u2" } } }, text: "reply", replyTo: "m2" });
assert.ok(urls.some((url) => url.endsWith("/v2/users/u2/messages")));
} finally { globalThis.fetch = original; }
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import net from "node:net";
import path from "node:path";
import test from "node:test";
import { parseConfig } from "../src/config.js";
import { createGatewayRuntime, startServer } from "../src/server.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
test("server mounts only selected platform and exposes non-secret identity health", { concurrency: false }, async () => {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-server-"));
const previous = process.env.GORI_AGENT_HOME; process.env.GORI_AGENT_HOME = home;
const secret = "never-expose-this-secret";
const config = parseConfig({
configVersion: 3,
bot: { id: "route-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret } },
runtime: {}
});
const runtime = await createGatewayRuntime(config);
const server = runtime.app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
try {
const address = server.address(); assert.ok(address && typeof address !== "string");
const base = `http://127.0.0.1:${address.port}`;
const health = await (await fetch(`${base}/health`)).text();
assert.match(health, /"configVersion":3/); assert.match(health, /"botId":"route-bot"/); assert.match(health, /"platform":"webhook"/); assert.doesNotMatch(health, new RegExp(secret));
assert.equal((await fetch(`${base}/webhook/qq`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status, 404);
assert.notEqual((await fetch(`${base}/webhook/generic`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status, 404);
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await runtime.shutdown();
if (previous === undefined) delete process.env.GORI_AGENT_HOME; else process.env.GORI_AGENT_HOME = previous;
}
});
test("QQ websocket mode does not mount the QQ webhook route", { concurrency: false }, async () => {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-server-qq-"));
const previous = process.env.GORI_AGENT_HOME; process.env.GORI_AGENT_HOME = home;
const config = parseConfig({
configVersion: 3,
bot: { id: "qq-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "qq", connectionMode: "websocket", appId: "id", clientSecret: "secret", botNames: ["Bot"] } },
runtime: {}
});
const runtime = await createGatewayRuntime(config);
const server = runtime.app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
try {
const address = server.address(); assert.ok(address && typeof address !== "string");
const response = await fetch(`http://127.0.0.1:${address.port}/webhook/qq`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
assert.equal(response.status, 404);
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await runtime.shutdown();
if (previous === undefined) delete process.env.GORI_AGENT_HOME; else process.env.GORI_AGENT_HOME = previous;
}
});
test("listen failure releases the session state lock", { concurrency: false }, async () => {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-server-conflict-"));
const previous = process.env.GORI_AGENT_HOME; process.env.GORI_AGENT_HOME = home;
const occupied = net.createServer();
await new Promise<void>((resolve) => occupied.listen(0, "127.0.0.1", resolve));
const address = occupied.address(); assert.ok(address && typeof address !== "string");
const config = parseConfig({
configVersion: 3,
bot: { id: "conflict-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
gateway: { server: { host: "127.0.0.1", port: address.port }, platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
try {
await assert.rejects(startServer(config), /EADDRINUSE/);
const runtime = await createGatewayRuntime(config);
await runtime.shutdown();
} finally {
await new Promise<void>((resolve, reject) => occupied.close((error) => error ? reject(error) : resolve()));
if (previous === undefined) delete process.env.GORI_AGENT_HOME; else process.env.GORI_AGENT_HOME = previous;
}
});
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
collectUsedInstancePorts,
parseServerHost,
parseServerPort,
suggestAvailablePort
} from "../src/cli/setup-server.js";
test("server host parser accepts bind addresses and hostnames", () => {
for (const host of ["0.0.0.0", "127.0.0.1", "::", "2001:db8::1", "localhost", "gateway.example.com"]) {
assert.equal(parseServerHost(host), host);
}
});
test("server host parser rejects URLs, ports, paths, whitespace, and invalid labels", () => {
for (const host of ["", "http://localhost", "localhost:8787", "[::1]", "host/path", "bad host", "-bad.example", "bad-.example"]) {
assert.throws(() => parseServerHost(host), /server host/);
}
});
test("server port parser accepts only decimal ports in range", () => {
assert.equal(parseServerPort("1"), 1);
assert.equal(parseServerPort("8787"), 8787);
assert.equal(parseServerPort("65535"), 65_535);
for (const port of ["", "0", "65536", "1.5", "0x20", "8e3", "-1"]) {
assert.throws(() => parseServerPort(port), /server port/);
}
});
test("available port suggestion advances without wrapping", () => {
assert.equal(suggestAvailablePort(8787, new Set([8787, 8788])), 8789);
assert.throws(() => suggestAvailablePort(65_535, new Set([65_535])), /No unassigned instance port/);
});
test("instance port scan reads only valid Config v3 regular files and excludes target", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-ports-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const writePeer = (name: string, value: unknown): string => {
const directory = path.join(root, name);
fs.mkdirSync(directory);
const file = path.join(directory, "config.json");
fs.writeFileSync(file, JSON.stringify(value));
return file;
};
const peer = (port: number, configVersion: number = 3) => ({
configVersion,
bot: {
id: "peer-bot",
workspace: os.tmpdir(),
persona: "peer",
agent: { id: "test", command: process.execPath, args: [], env: {} },
skills: [],
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: {
server: { host: "127.0.0.1", port, publicBaseUrl: "" },
policy: { allowedUsers: [], allowedChats: [], requireMentionInGroup: true },
platform: { type: "webhook", secret: "test-secret" }
},
runtime: {}
});
writePeer("valid", peer(8787));
const excluded = writePeer("current", peer(8788));
writePeer("legacy", peer(8789, 2));
writePeer("out-of-range", peer(70_000));
writePeer("broken", "not an object");
fs.writeFileSync(path.join(root, "broken", "config.json"), "{broken");
const linkedDirectory = path.join(root, "linked");
fs.symlinkSync(path.join(root, "valid"), linkedDirectory, "dir");
const symlinkConfigDirectory = path.join(root, "symlink-config");
fs.mkdirSync(symlinkConfigDirectory);
fs.symlinkSync(path.join(root, "valid", "config.json"), path.join(symlinkConfigDirectory, "config.json"));
assert.deepEqual([...collectUsedInstancePorts(root, excluded)], [8787]);
});
+177
View File
@@ -0,0 +1,177 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { DiscoveredBackend } from "../src/acp/discovery.js";
import { loadConfigFile, writeConfigFile } from "../src/cli/config-file.js";
import type { Choice, PromptSession } from "../src/cli/prompt.js";
import { runSetup } from "../src/cli/setup.js";
import { parseConfig } from "../src/config.js";
const testSecret = "test-webhook-secret";
const readyAgent: DiscoveredBackend = {
id: "test-agent",
command: process.execPath,
args: ["test/fixtures/fake-acp-agent.mjs"],
status: "ready"
};
class FakePrompt implements PromptSession {
readonly defaults = new Map<string, string | undefined>();
closeCount = 0;
constructor(
private readonly answers: Record<string, string[]> = {},
private readonly secretAnswer = testSecret
) {}
async ask(question: string, defaultValue?: string): Promise<string> {
this.defaults.set(question, defaultValue);
const queued = this.answers[question];
return queued && queued.length > 0 ? queued.shift()! : defaultValue || "";
}
async askSecret(): Promise<string> { return this.secretAnswer; }
async askBoolean(_question: string, defaultValue = false): Promise<boolean> { return defaultValue; }
async askList(_question: string, defaultValues: string[] = []): Promise<string[]> { return defaultValues; }
async choose<T>(_question: string, choices: Choice<T>[], defaultIndex = 0): Promise<T> {
return (choices.find((choice) => choice.label === "Generic webhook") || choices[defaultIndex] || choices[0]).value;
}
close(): void { this.closeCount++; }
}
function peerConfig(port: number): unknown {
return {
configVersion: 3,
bot: {
id: "peer-bot",
workspace: os.tmpdir(),
persona: "peer",
agent: { id: readyAgent.id, command: readyAgent.command, args: readyAgent.args, env: {} },
skills: [],
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: {
server: { host: "127.0.0.1", port, publicBaseUrl: "" },
policy: { allowedUsers: [], allowedChats: [], requireMentionInGroup: true },
platform: { type: "webhook", secret: "peer-test-secret" }
},
runtime: {}
};
}
function existingConfig(host = "0.0.0.0", port = 8787) {
return parseConfig({
configVersion: 3,
bot: {
id: "existing-bot",
workspace: os.tmpdir(),
persona: "existing persona",
agent: { id: readyAgent.id, command: readyAgent.command, args: readyAgent.args, env: {} },
skills: [],
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: {
server: { host, port, publicBaseUrl: "https://public.example.test" },
policy: { allowedUsers: [], allowedChats: [], requireMentionInGroup: true },
platform: { type: "webhook", secret: testSecret }
},
runtime: {}
});
}
test("new setup suggests the next unassigned instance port", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-new-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
for (const [name, port] of [["peer-one", 8787], ["peer-two", 8788]] as const) {
const directory = path.join(root, name);
fs.mkdirSync(directory);
fs.writeFileSync(path.join(directory, "config.json"), JSON.stringify(peerConfig(port)));
}
const target = path.join(root, "new-bot", "config.json");
const prompt = new FakePrompt();
const messages: string[] = [];
await runSetup(target, {
botId: "new-bot",
requireNew: true,
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [readyAgent],
instancesDirectory: root,
log: (message) => messages.push(message)
});
const config = loadConfigFile(target).config;
assert.equal(prompt.defaults.get("Gateway server port"), "8789");
assert.equal(config.gateway.server.port, 8789);
assert.equal(config.gateway.server.host, "0.0.0.0");
assert.equal(fs.statSync(target).mode & 0o777, 0o600);
assert.equal(prompt.closeCount, 1);
assert.ok(messages.some((message) => message.includes("suggested port: 8789")));
assert.ok(messages.every((message) => !message.includes(testSecret)));
});
test("existing setup can change host and port while preserving public URL and secret", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-existing-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "existing-bot", "config.json");
writeConfigFile(target, existingConfig());
const peerDirectory = path.join(root, "peer");
fs.mkdirSync(peerDirectory);
fs.writeFileSync(path.join(peerDirectory, "config.json"), JSON.stringify(peerConfig(8787)));
const prompt = new FakePrompt({
"Gateway server host": ["http://bad-host", "127.0.0.1"],
"Gateway server port": ["not-a-port", "9001"]
}, "");
const messages: string[] = [];
await runSetup(target, {
botId: "existing-bot",
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [readyAgent],
instancesDirectory: root,
log: (message) => messages.push(message)
});
const config = loadConfigFile(target).config;
assert.equal(prompt.defaults.get("Gateway server port"), "8787");
assert.equal(config.gateway.server.host, "127.0.0.1");
assert.equal(config.gateway.server.port, 9001);
assert.equal(config.gateway.server.publicBaseUrl, "https://public.example.test");
assert.equal(config.gateway.platform.type, "webhook");
assert.equal(config.gateway.platform.secret, testSecret);
assert.equal(prompt.closeCount, 1);
assert.ok(messages.some((message) => message.startsWith("ERROR: server host")));
assert.ok(messages.some((message) => message.startsWith("ERROR: server port")));
assert.ok(messages.some((message) => message.includes("suggested port: 8788")));
assert.ok(messages.every((message) => !message.includes(testSecret)));
});
test("existing setup keeps host and port when defaults are accepted", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-keep-server-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "existing-bot", "config.json");
writeConfigFile(target, existingConfig("localhost", 9123));
const prompt = new FakePrompt({}, "");
await runSetup(target, {
botId: "existing-bot",
writeWithoutConfirmation: true,
prompt,
discoverAgents: async () => [readyAgent],
instancesDirectory: root,
log: () => undefined
});
const config = loadConfigFile(target).config;
assert.equal(prompt.defaults.get("Gateway server host"), "localhost");
assert.equal(prompt.defaults.get("Gateway server port"), "9123");
assert.equal(config.gateway.server.host, "localhost");
assert.equal(config.gateway.server.port, 9123);
assert.equal(prompt.closeCount, 1);
});