Refactor runtime around isolated bot instances
This commit is contained in:
+15
-6
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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]/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Vendored
+10
-2
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user