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(); closeCount = 0; constructor( private readonly answers: Record = {}, private readonly secretAnswer = testSecret ) {} async ask(question: string, defaultValue?: string): Promise { this.defaults.set(question, defaultValue); const queued = this.answers[question]; return queued && queued.length > 0 ? queued.shift()! : defaultValue || ""; } async askSecret(): Promise { return this.secretAnswer; } async askBoolean(_question: string, defaultValue = false): Promise { return defaultValue; } async askList(_question: string, defaultValues: string[] = []): Promise { return defaultValues; } async choose(_question: string, choices: Choice[], defaultIndex = 0): Promise { 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: "custom-kimi", command: readyAgent.command, args: ["-m", "gori-gpt/gpt-5.5", "acp"], env: { TEST_AGENT_SETTING: "preserved" } }, skills: [{ id: "test-skill", file: path.join(os.tmpdir(), "test-skill.md"), maxBytes: 1234 }], permissions: { mode: "allowlist", allowedTools: ["Read"], allowedCommandPatterns: ["^true$"] } }, gateway: { server: { host, port, publicBaseUrl: "https://public.example.test" }, policy: { allowedUsers: ["allowed-user"], allowedChats: ["allowed-chat"], requireMentionInGroup: false }, platform: { type: "webhook", secret: testSecret } }, runtime: { acp: { promptTimeoutMs: 123456, maxProcesses: 3 } } }); } 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.deepEqual(config.bot.agent, { id: "custom-kimi", command: readyAgent.command, args: ["-m", "gori-gpt/gpt-5.5", "acp"], env: { TEST_AGENT_SETTING: "preserved" } }); assert.deepEqual(config.bot.skills, [{ id: "test-skill", file: path.join(os.tmpdir(), "test-skill.md"), maxBytes: 1234 }]); assert.deepEqual(config.bot.permissions, { mode: "allowlist", allowedTools: ["Read"], allowedCommandPatterns: ["^true$"] }); assert.deepEqual(config.gateway.policy, { allowedUsers: ["allowed-user"], allowedChats: ["allowed-chat"], requireMentionInGroup: false }); assert.equal(config.runtime.acp.promptTimeoutMs, 123456); assert.equal(config.runtime.acp.maxProcesses, 3); 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); }); test("existing setup succeeds without a ready discovered agent and preserves its agent", async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-no-ready-")); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const target = path.join(root, "existing-bot", "config.json"); writeConfigFile(target, existingConfig()); await runSetup(target, { botId: "existing-bot", writeWithoutConfirmation: true, prompt: new FakePrompt({}, ""), discoverAgents: async () => [{ id: "kimi", command: "kimi", args: ["acp"], status: "not-found" }], instancesDirectory: root, log: () => undefined }); assert.deepEqual(loadConfigFile(target).config.bot.agent, existingConfig().bot.agent); }); test("new setup still fails when no ACP agent is ready", async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-new-no-ready-")); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const prompt = new FakePrompt(); await assert.rejects(runSetup(path.join(root, "new-bot", "config.json"), { botId: "new-bot", requireNew: true, writeWithoutConfirmation: true, prompt, discoverAgents: async () => [], instancesDirectory: root, log: () => undefined }), /No ACP agent is available/); assert.equal(prompt.closeCount, 1); }); test("existing setup rejects attempts to change bot identity", async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-identity-")); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const target = path.join(root, "existing-bot", "config.json"); writeConfigFile(target, existingConfig()); await assert.rejects(runSetup(target, { botId: "different-bot", prompt: new FakePrompt(), discoverAgents: async () => [] }), /Cannot change existing bot\.id/); });