From fe7bbd0631795147df670b0f359efc10650ecaae Mon Sep 17 00:00:00 2001 From: zenord Date: Mon, 17 Aug 2026 00:49:29 +0800 Subject: [PATCH] Simplify instance lifecycle commands --- AGENTS.md | 31 +++++---- README.md | 45 +++++-------- gori-agent.sh | 33 ++++----- install.sh | 2 +- package.json | 6 -- src/cli.ts | 134 ++++++++----------------------------- src/cli/instance-runner.ts | 49 ++++++++++++++ src/cli/instance.ts | 81 ++++++++++++++++------ src/cli/print.ts | 14 ---- src/cli/setup.ts | 24 ++++--- test/cli.test.ts | 61 +++++++++++++---- test/instance.test.ts | 68 ++++++++++++++++++- test/setup.test.ts | 74 ++++++++++++++++++-- 13 files changed, 385 insertions(+), 237 deletions(-) create mode 100644 src/cli/instance-runner.ts delete mode 100644 src/cli/print.ts diff --git a/AGENTS.md b/AGENTS.md index db0914d..d8635bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,23 +42,26 @@ ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances// - 实例目录、`logs/`、`state/` 为 `0700`。 - `config.json`、PID、state 为 `0600`。 - 多实例必须使用不同 `gateway.server.port` 和平台 credentials。 -- `instance init` / `setup` 必须交互询问并校验 server host/port;扫描其他 Config v3 的声明端口,冲突时警告,新实例建议下一个未声明端口,已有配置不得静默改端口。 +- `init` / `setup` 必须交互询问并校验 server host/port;扫描其他 Config v3 的声明端口,冲突时警告,新实例建议下一个未声明端口,已有配置不得静默改端口。 +- `setup ` 只重配已有实例:校验目录/config/PID,运行中或 foreign PID 拒绝,固定 Bot ID,保留 agent/skills/permissions/policy/runtime/secrets/publicBaseUrl,确认写入后运行 doctor。 - setup 的声明冲突提示不替代实际 bind 检查;`start` 对缺配置、ID 不匹配、错误权限、占位符、运行 PID、端口冲突 fail closed。 +- start 只 spawn 构建后的内部 `dist/cli/instance-runner.js `;PID identity 必须精确匹配 Node executable、runner 和唯一 config 参数。 -日常入口: +公开入口仅有: ```bash -gori-agent instance init -gori-agent instance start -gori-agent instance stop -gori-agent instance restart -gori-agent instance status -gori-agent instance logs -gori-agent instance list -gori-agent instance doctor +gori-agent init +gori-agent setup +gori-agent start +gori-agent stop +gori-agent restart +gori-agent status +gori-agent logs +gori-agent doctor +gori-agent list ``` -保留 `start --config`、`status --config`、`doctor --config` 等用于本地调试,但运行配置不存在时不得回退 example。 +不兼容旧 `instance` 前缀;不公开 debug 命令、`--config` 或 `--json`。 ## 3. 核心代码结构 @@ -91,6 +94,8 @@ gori-agent instance doctor - fail-closed 加载、example seed 与原子 `0600` 配置写入。 - `src/cli/instance.ts` - 实例目录、PID/log、端口和 lifecycle 管理。 +- `src/cli/instance-runner.ts` + - 非公开进程入口;加载单实例配置、启动 server,并处理 SIGINT/SIGTERM 优雅关闭。 - `src/cli/doctor.ts` - v3、Bot、agent、platform、state、permission 和 placeholder 检查。 - `src/agents/*`、`src/core/session-store.ts` @@ -213,11 +218,11 @@ QQ WebSocket 仅在 qq + websocket 模式启动。`GET /health` 只输出 config 1. 读取本文件、`config.example.json` 和 `src/config.ts`。 2. 明确 Bot ID、workspace、persona、平台、skills 和最小 permission policy。 3. 确认 ACP agent executable 与 ACP 可用。 -4. 使用 `instance init` 或 Coding Agent 生成实例 `config.json`;不要把 example 当运行配置。 +4. 使用 `gori-agent init ` 或 Coding Agent 生成实例 `config.json`;不要把 example 当运行配置。 5. 交互确认并严格校验 `gateway.server.host/port`;对其他实例的声明端口冲突给出警告和建议,但仍由 `start` 做实际 bind 检查。 6. 写配置必须同目录临时文件 + fsync + atomic rename,最终 `0600`;目录 `0700`。 7. setup 的 secret/token 输入必须不回显;secrets 不在对话、日志、测试输出、diff 中回显。 -8. 运行 `instance doctor`,不发送真实平台消息。 +8. 运行 `gori-agent doctor `,不发送真实平台消息。 9. 只有用户明确授权时才启动/停止真实 Bot。 旧仓库本地 `config.json`、备份和 state 可供人工回退;改造实例时不要删除或覆盖。 diff --git a/README.md b/README.md index 33f1c67..d7dc325 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ npm run build kimi --version kimi doctor kimi acp --help -gori-agent discover-backends ``` 模型和 provider 由 ACP agent 自身配置,例如 Kimi Code 使用 `~/.kimi-code/config.toml`。gori-agent 不复制模型凭据。 @@ -53,44 +52,36 @@ gori-agent discover-backends ## 实例管理 ```bash -gori-agent instance init -gori-agent instance doctor -gori-agent instance start -gori-agent instance status -gori-agent instance logs -gori-agent instance restart -gori-agent instance stop -gori-agent instance list +gori-agent init +gori-agent setup +gori-agent doctor +gori-agent start +gori-agent status +gori-agent logs +gori-agent restart +gori-agent stop +gori-agent list ``` -- `init` 交互生成 Config v3,本身不启动 Bot;它会询问并校验 `gateway.server.host/port`,扫描统一实例目录中其他 Config v3 的声明端口,并在默认端口冲突时建议下一个未占用端口。已有配置通过 `setup` 重跑时可保留或修改 host/port,不会静默改端口。 +- `init` 交互生成 Config v3,本身不启动 Bot;它会询问并校验 `gateway.server.host/port`,扫描统一实例目录中其他 Config v3 的声明端口,并在默认端口冲突时建议下一个未占用端口。 +- `setup` 只重配已有实例。它校验目录、配置身份、配置/PID 权限,并拒绝运行中实例或指向其他活进程的 PID;固定 `bot.id`,保留既有 agent(含 args/env)、skills、permissions、gateway policy、runtime、平台 secrets 与 publicBaseUrl,确认写入后自动运行 doctor。 - 平台 secret/token 使用不回显输入,空 generic webhook/weixin secret 会随机生成。 - `start` 会检查配置存在、目录名匹配 `bot.id`、目录/配置权限、模板占位符、workspace、agent executable、PID identity 和端口,并等待 `/health` 返回匹配的 Bot/platform identity;任一不满足即 fail closed。 -- `status` 核对 PID 对应的完整 `start --config` 身份以及 `/health` 返回的 Bot/platform identity。 +- 实例进程由内部 `dist/cli/instance-runner.js` 承载;`status` 和 lifecycle 命令精确核对 Node executable、runner 路径及唯一 config 参数,再核对 `/health` 的 Bot/platform identity。 - 每个实例必须使用不同的 `gateway.server.port` 和平台凭据;setup 的声明冲突提示不替代 `start` 的实际端口检查。 -- `stop` 发送 `SIGTERM` 并等待最多 10 秒,不会自动 `SIGKILL`。 +- `stop` 发送 `SIGTERM`,runner 会优雅关闭 server,并等待最多 10 秒;不会自动 `SIGKILL`。 + +公开 CLI 不兼容旧 `instance` 前缀,也不提供 debug 命令、`--config` 或 `--json`。 可用 `GORI_AGENT_ROOT` 改变统一根目录: ```bash -GORI_AGENT_ROOT=/srv/gori-agent gori-agent instance list +GORI_AGENT_ROOT=/srv/gori-agent gori-agent list ``` ## `config.example.json` 的定位 -`config.example.json` 是仓库内唯一可提交、无密钥的配置说明,不是运行配置。它可被 Config v3 schema 解析,但保留 `BOT_ID`、`QQ_APP_ID` 等明显占位符。 - -`start`、`status`、`doctor`、`print` 找不到本地配置时会失败,绝不会回退到 example。只有 `setup` 和 `instance init` 会读取 example 作为初始化种子。 - -底层调试入口仍可使用: - -```bash -gori-agent setup --config /absolute/path/config.json -gori-agent start --config /absolute/path/config.json -gori-agent status --config /absolute/path/config.json -gori-agent doctor --config /absolute/path/config.json -gori-agent print feishu --config /absolute/path/config.json -``` +`config.example.json` 是仓库内唯一可提交、无密钥的配置说明,不是运行配置。它可被 Config v3 schema 解析,但保留 `BOT_ID`、`QQ_APP_ID` 等明显占位符。只有 `init` 会读取 example 作为初始化种子;其他命令只按 `` 加载统一实例目录中的 `config.json`,缺失即失败。 `writeConfigFile()` 使用同目录临时文件、`fsync` 和原子 rename,并强制最终文件为 `0600`。 @@ -230,7 +221,7 @@ Bot fingerprint 包含 Bot ID、workspace、persona、agent、permissions、skil ## Doctor 与安全纪律 -`instance doctor` / `doctor --config` 检查: +`gori-agent doctor ` 检查: - Config v3 与 example placeholder。 - 配置权限 `0600`。 diff --git a/gori-agent.sh b/gori-agent.sh index c0d6f83..0e959a3 100755 --- a/gori-agent.sh +++ b/gori-agent.sh @@ -7,23 +7,16 @@ usage() { cat <<'EOF' Usage: gori-agent [arguments] -Instance commands: - instance init - instance start - instance stop - instance restart - instance status - instance logs - instance doctor - instance list - -Debug commands: - setup [--config path] - discover-backends [--json] - start --config path - status --config path - doctor --config path - print feishu --config path +Commands: + init + setup + start + stop + restart + status + logs + doctor + list Instances live under ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/. config.example.json is documentation only and is never used as runtime config. @@ -31,8 +24,8 @@ EOF } ensure_build() { - if [[ ! -f "$ROOT_DIR/dist/cli.js" ]]; then - echo "dist/cli.js not found; building..." + if [[ ! -f "$ROOT_DIR/dist/cli.js" || ! -f "$ROOT_DIR/dist/cli/instance-runner.js" ]]; then + echo "CLI build output not found; building..." npm run build --prefix "$ROOT_DIR" fi } @@ -43,7 +36,7 @@ if [[ $# -eq 0 ]]; then fi case "$1" in - --help|-h|help) + --help|-h) usage exit 0 ;; diff --git a/install.sh b/install.sh index 0a1b6d1..cfee8d0 100755 --- a/install.sh +++ b/install.sh @@ -34,4 +34,4 @@ fi echo "Installed gori-agent to $BIN_DIR/gori-agent" echo "Project root: $PROJECT_ROOT" echo "Instances root: $INSTANCES_DIR" -echo "Run 'source $PROFILE_FILE' or open a new terminal, then use: gori-agent instance list" +echo "Run 'source $PROFILE_FILE' or open a new terminal, then use: gori-agent list" diff --git a/package.json b/package.json index 142702d..e88928d 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "start": "node dist/server.js", - "dev": "tsx src/server.ts", "gori-agent": "node dist/cli.js", - "setup": "node dist/cli.js setup", - "discover-backends": "node dist/cli.js discover-backends", - "discover-agents": "node dist/cli.js discover-agents", - "doctor": "node dist/cli.js doctor", "test": "tsx --test test/*.test.ts" }, "engines": { diff --git a/src/cli.ts b/src/cli.ts index 7c4b865..f353258 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,122 +1,46 @@ #!/usr/bin/env node import process from "node:process"; -import { discoverBackends } from "./acp/discovery.js"; -import { assertOperationalConfig, loadConfigFile } from "./cli/config-file.js"; -import { runDoctor } from "./cli/doctor.js"; import { runInstanceCommand } from "./cli/instance.js"; -import { localBaseUrl } from "./cli/net.js"; -import { printFeishu } from "./cli/print.js"; -import { runSetup } from "./cli/setup.js"; -import { defaultStateFile } from "./config.js"; -import { startServer } from "./server.js"; -interface ParsedArgs { command?: string; rest: string[]; configPath?: string; json: boolean; help: boolean } +const COMMANDS = ["init", "setup", "start", "stop", "restart", "status", "logs", "doctor", "list"] as const; +type Command = typeof COMMANDS[number]; async function main(argv: string[]): Promise { - const parsed = parseArgs(argv); - if (parsed.help || !parsed.command) { printHelp(); return 0; } - validateCommandArgs(parsed); - if (parsed.command === "instance") return runInstanceCommand(parsed.rest[0], parsed.rest.slice(1)); - if (parsed.command === "setup") { await runSetup(parsed.configPath); return 0; } - if (parsed.command === "discover-backends" || parsed.command === "discover-agents") { - const backends = await discoverBackends(); - if (parsed.json) console.log(JSON.stringify(backends, null, 2)); - else for (const backend of backends) { - const version = backend.version ? ` (${backend.version})` : ""; - const reason = backend.reason ? ` - ${backend.reason}` : ""; - console.log(`${backend.id}\t${backend.status}\t${backend.command} ${backend.args.join(" ")}${version}${reason}`.trim()); - } + if (argv.length === 0 || (argv.length === 1 && ["--help", "-h"].includes(argv[0]))) { + printHelp(); return 0; } - if (parsed.command === "start") { - const loaded = loadConfigFile(parsed.configPath); - assertOperationalConfig(loaded.config, loaded.path); - const running = await startServer(loaded.config); - return await new Promise((resolve) => { - let stopping = false; - const stop = (signal: string): void => { - if (stopping) return; - stopping = true; - console.log(`Received ${signal}; shutting down...`); - void running.shutdown().then(() => resolve(0), (error) => { console.error(error); resolve(1); }); - }; - process.once("SIGINT", () => stop("SIGINT")); - process.once("SIGTERM", () => stop("SIGTERM")); - }); - } - if (parsed.command === "status") { await printStatus(parsed.configPath); return 0; } - if (parsed.command === "doctor") { const loaded = loadConfigFile(parsed.configPath); return runDoctor(loaded.config, loaded.path); } - if (parsed.command === "print") { - const loaded = loadConfigFile(parsed.configPath); - await printFeishu(loaded.config); - return 0; - } - throw new Error(`Unknown command: ${parsed.command}`); -} -function parseArgs(argv: string[]): ParsedArgs { - const rest: string[] = []; let command: string | undefined; let configPath: string | undefined; let json = false; let help = false; - for (let index = 0; index < argv.length; index++) { - const arg = argv[index]; - if (arg === "--help" || arg === "-h") help = true; - else if (arg === "--json") json = true; - else if (arg === "--config") { - if (configPath !== undefined) throw new Error("--config may only be specified once"); - const value = argv[++index]; - if (!value || value.startsWith("-")) throw new Error("--config requires a path"); - configPath = value; - } else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`); - else if (!command) command = arg; - else rest.push(arg); + const [command, ...args] = argv; + const option = argv.find((arg) => arg.startsWith("-")); + if (option) throw new Error(`Unknown option: ${option}`); + if (!COMMANDS.includes(command as Command)) throw new Error(`Unknown command: ${command}`); + if (command === "list") { + if (args.length !== 0) throw new Error("list does not accept "); + } else if (args.length !== 1) { + throw new Error(`${command} requires exactly one `); } - return { command, rest, configPath, json, help }; -} - -function validateCommandArgs(parsed: ParsedArgs): void { - const { command, rest, configPath, json } = parsed; - if (command === "instance") { - if (configPath || json) throw new Error("instance commands do not accept --config or --json"); - return; - } - if (command === "setup") { - if (json || rest.length > 0) throw new Error("setup accepts only --config "); - return; - } - if (command === "discover-backends" || command === "discover-agents") { - if (configPath || rest.length > 0) throw new Error(`${command} accepts only --json`); - return; - } - if (["start", "status", "doctor"].includes(command || "")) { - if (json || rest.length > 0) throw new Error(`${command} accepts only --config `); - return; - } - if (command === "print") { - if (json || rest.length !== 1 || rest[0] !== "feishu") throw new Error("print requires exactly the topic 'feishu'"); - return; - } - throw new Error(`Unknown command: ${command}`); -} - -async function printStatus(configPath?: string): Promise { - const loaded = loadConfigFile(configPath); const baseUrl = localBaseUrl(loaded.config); - console.log(`Config: ${loaded.path}`); - console.log(`Config version: ${loaded.config.configVersion}`); - console.log(`Bot: ${loaded.config.bot.id}`); - console.log(`Agent: ${loaded.config.bot.agent.id}`); - console.log(`Platform: ${loaded.config.gateway.platform.type}`); - console.log(`Server: ${loaded.config.gateway.server.host}:${loaded.config.gateway.server.port}`); - console.log(`Workspace: ${loaded.config.bot.workspace}`); - console.log(`State file: ${defaultStateFile(loaded.config)}`); - try { - const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1_000) }); - console.log(`Health probe: HTTP ${response.status} ${await response.text()}`); - } catch { console.log("Health probe: not reachable on local URL"); } + return runInstanceCommand(command, args); } function printHelp(): void { - console.log(`gori-agent - single-Bot ACP gateway\n\nUsage:\n gori-agent instance init \n gori-agent instance start \n gori-agent instance stop \n gori-agent instance restart \n gori-agent instance status \n gori-agent instance logs \n gori-agent instance doctor \n gori-agent instance list\n\nDebug commands:\n gori-agent setup [--config path]\n gori-agent discover-backends [--json]\n gori-agent start --config path\n gori-agent status --config path\n gori-agent doctor --config path\n gori-agent print feishu --config path\n\nInstances root: \${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances`); + console.log(`gori-agent - single-Bot ACP gateway + +Usage: + gori-agent init + gori-agent setup + gori-agent start + gori-agent stop + gori-agent restart + gori-agent status + gori-agent logs + gori-agent doctor + gori-agent list + +Instances root: \${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances`); } main(process.argv.slice(2)).then((code) => { if (Number.isInteger(code)) process.exitCode = code; }).catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; }); diff --git a/src/cli/instance-runner.ts b/src/cli/instance-runner.ts new file mode 100644 index 0000000..51aa3bf --- /dev/null +++ b/src/cli/instance-runner.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { assertOperationalConfig, loadConfigFile } from "./config-file.js"; +import { startServer } from "../server.js"; + +interface ShutdownTarget { shutdown(): Promise } +interface SignalEmitter { + once(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown; + removeListener(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown; +} + +export async function runInstanceRunner(argv: string[]): Promise { + if (argv.length !== 1) throw new Error("instance-runner requires exactly one config path"); + const loaded = loadConfigFile(argv[0]); + assertOperationalConfig(loaded.config, loaded.path); + return waitForShutdown(await startServer(loaded.config)); +} + +export function waitForShutdown(running: ShutdownTarget, signals: SignalEmitter = process): Promise { + return new Promise((resolve) => { + let stopping = false; + const finish = (code: number): void => { + signals.removeListener("SIGINT", onSigint); + signals.removeListener("SIGTERM", onSigterm); + resolve(code); + }; + const stop = (signal: string): void => { + if (stopping) return; + stopping = true; + console.log(`Received ${signal}; shutting down...`); + void running.shutdown().then(() => finish(0), (error) => { + console.error(error); + finish(1); + }); + }; + const onSigint = (): void => stop("SIGINT"); + const onSigterm = (): void => stop("SIGTERM"); + signals.once("SIGINT", onSigint); + signals.once("SIGTERM", onSigterm); + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runInstanceRunner(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/src/cli/instance.ts b/src/cli/instance.ts index 68b8a33..f2725df 100644 --- a/src/cli/instance.ts +++ b/src/cli/instance.ts @@ -4,6 +4,7 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; import process from "node:process"; +import { fileURLToPath } from "node:url"; import { defaultStateFile } from "../config.js"; import { assertOperationalConfig, loadConfigFile } from "./config-file.js"; import { runDoctor } from "./doctor.js"; @@ -27,24 +28,25 @@ export function instanceConfigPath(botId: string): string { return path.join(ins export async function runInstanceCommand(action: string | undefined, args: string[]): Promise { if (action === "list") { - if (args.length !== 0) throw new Error("instance list does not accept "); + if (args.length !== 0) throw new Error("list does not accept "); return listInstances(); } - if (!action || !["init", "start", "stop", "restart", "status", "logs", "doctor"].includes(action)) { - throw new Error(`Unknown instance command: ${action || "(missing)"}`); + if (!action || !["init", "setup", "start", "stop", "restart", "status", "logs", "doctor"].includes(action)) { + throw new Error(`Unknown command: ${action || "(missing)"}`); } - if (args.length !== 1) throw new Error(`instance ${action} requires exactly one `); + if (args.length !== 1) throw new Error(`${action} requires exactly one `); const botId = args[0]; validateBotId(botId); switch (action) { case "init": return initInstance(botId); + case "setup": return setupInstance(botId); case "start": return startInstance(botId); case "stop": return stopInstance(botId); case "restart": await stopInstance(botId); return startInstance(botId); case "status": return statusInstance(botId); case "logs": return logsInstance(botId); case "doctor": return doctorInstance(botId); - default: throw new Error(`Unknown instance command: ${action}`); + default: throw new Error(`Unknown command: ${action}`); } } @@ -61,10 +63,22 @@ async function initInstance(botId: string): Promise { } } +async function setupInstance(botId: string): Promise { + const loaded = loadInstance(botId); + assertConfigMode(loaded.path); + const paths = runtimePaths(botId); + ensureInstanceDirectories(paths.home, false); + const current = inspectPid(paths.pidFile, loaded.path); + assertSetupPidSafe(botId, current); + if (current.kind === "stale") removeStalePid(paths.pidFile, current); + const written = await runSetup(loaded.path, { botId, instancesDirectory: instancesRoot() }); + if (!written) return 0; + return doctorInstance(botId); +} + async function startInstance(botId: string): Promise { const loaded = loadInstance(botId); - assertOperationalConfig(loaded.config); - assertConfigMode(loaded.path); + assertOperationalConfig(loaded.config, loaded.path); const paths = runtimePaths(botId); ensureInstanceDirectories(paths.home, false); const current = inspectPid(paths.pidFile, loaded.path); @@ -75,12 +89,12 @@ async function startInstance(botId: string): Promise { const logFd = fs.openSync(paths.logFile, "a", 0o600); fs.chmodSync(paths.logFile, 0o600); - const cliFile = path.join(path.resolve(new URL("../..", import.meta.url).pathname), "dist", "cli.js"); + const runnerFile = instanceRunnerPath(); let child: ReturnType | undefined; try { - child = spawn(process.execPath, [cliFile, "start", "--config", loaded.path], { + child = spawn(process.execPath, [runnerFile, loaded.path], { cwd: paths.home, - env: { ...process.env, GORI_AGENT_HOME: paths.home, GORI_GATEWAY_CONFIG: loaded.path }, + env: { ...process.env, GORI_AGENT_HOME: paths.home }, detached: true, stdio: ["ignore", logFd, logFd] }); @@ -186,6 +200,7 @@ function listInstances(): number { function loadInstance(botId: string): ReturnType { const configFile = instanceConfigPath(botId); if (fs.existsSync(path.dirname(configFile))) ensurePrivateDirectory(path.dirname(configFile), false); + if (fs.existsSync(configFile)) assertConfigMode(configFile); const loaded = loadConfigFile(configFile); if (loaded.config.bot.id !== botId) throw new Error(`Instance directory '${botId}' does not match config bot.id '${loaded.config.bot.id}'`); return loaded; @@ -230,28 +245,54 @@ function validateBotId(botId: string): void { function assertConfigMode(configFile: string): void { const stat = fs.lstatSync(configFile); - if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Refusing to start: config must be a regular file"); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Config must be a regular file"); const mode = stat.mode & 0o777; - if (mode !== 0o600) throw new Error(`Refusing to start: config file mode must be 0600, got 0${mode.toString(8)}`); + if (mode !== 0o600) throw new Error(`Config file mode must be 0600, got 0${mode.toString(8)}`); + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error("Config file is not owned by the current user"); +} + +export function instanceRunnerPath(): string { + const moduleFile = fileURLToPath(import.meta.url); + const projectRoot = path.resolve(path.dirname(moduleFile), "../.."); + return path.join(projectRoot, "dist", "cli", "instance-runner.js"); +} + +export function matchesInstanceRunner(commandLine: string[], executable: string, configFile: string): boolean { + if (commandLine.length !== 3) return false; + try { + return fs.realpathSync(executable) === fs.realpathSync(process.execPath) + && path.resolve(commandLine[1]) === instanceRunnerPath() + && path.resolve(commandLine[2]) === path.resolve(configFile); + } catch { return false; } } type PidInspection = { kind: "absent" } | { kind: "stale"; dev?: number; ino?: number } | { kind: "running" | "foreign"; pid: number }; +export function assertSetupPidSafe(botId: string, current: PidInspection): void { + if (current.kind === "running") throw new Error(`Refusing to setup: instance '${botId}' is running (pid ${current.pid})`); + if (current.kind === "foreign") throw new Error(`Refusing to setup: PID file references unrelated live process ${current.pid}`); +} + function inspectPid(pidFile: string, configFile: string): PidInspection { let text: string; let stat: fs.Stats; - try { stat = fs.lstatSync(pidFile); text = fs.readFileSync(pidFile, "utf8").trim(); } - catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "absent" } : { kind: "stale" }; } + try { + stat = fs.lstatSync(pidFile); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`PID file must be a regular file: ${pidFile}`); + const mode = stat.mode & 0o777; + if (mode !== 0o600) throw new Error(`PID file mode must be 0600: ${pidFile} has 0${mode.toString(8)}`); + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error(`PID file is not owned by the current user: ${pidFile}`); + text = fs.readFileSync(pidFile, "utf8").trim(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" }; + throw error; + } const pid = Number(text); if (!Number.isSafeInteger(pid) || pid <= 1 || !isPidRunning(pid)) return { kind: "stale", dev: stat.dev, ino: stat.ino }; try { const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean); - const configIndex = commandLine.indexOf("--config"); - const matches = commandLine[2] === "start" - && commandLine[1]?.endsWith("/dist/cli.js") - && configIndex >= 0 - && path.resolve(commandLine[configIndex + 1] || "") === path.resolve(configFile); - return { kind: matches ? "running" : "foreign", pid }; + const executable = fs.readlinkSync(`/proc/${pid}/exe`); + return { kind: matchesInstanceRunner(commandLine, executable, configFile) ? "running" : "foreign", pid }; } catch { return isPidRunning(pid) ? { kind: "foreign", pid } : { kind: "stale", dev: stat.dev, ino: stat.ino }; } } diff --git a/src/cli/print.ts b/src/cli/print.ts deleted file mode 100644 index 9316029..0000000 --- a/src/cli/print.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { AppConfig } from "../config.js"; -import { webhookBaseUrl } from "./net.js"; - -export async function printFeishu(config: AppConfig): Promise { - if (config.gateway.platform.type !== "feishu") throw new Error("Current instance platform is not Feishu"); - const baseUrl = await webhookBaseUrl(config); - console.log("Feishu/Lark setup instructions"); - console.log(""); - console.log(`Webhook URL: ${baseUrl}/webhook/feishu`); - console.log("Event subscription: im.message.receive_v1"); - console.log("Required config fields: gateway.platform.appId, appSecret, verificationToken, botNames"); - console.log("Do not paste appSecret into chats or logs."); - if (!config.gateway.server.publicBaseUrl) console.log("Warning: gateway.server.publicBaseUrl is empty; configure your public HTTPS URL before production use."); -} diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 2642baf..5943020 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -17,11 +17,14 @@ export interface SetupOptions { log?: (message: string) => void; } -export async function runSetup(configPath?: string, options: SetupOptions = {}): Promise { +export async function runSetup(configPath?: string, options: SetupOptions = {}): Promise { const target = path.resolve(configPath || "config.json"); const configExists = fs.existsSync(target); if (options.requireNew && configExists) throw new Error(`Config already exists: ${target}`); const current = configExists ? loadConfigFile(target).config : loadExampleConfig(); + if (configExists && options.botId && options.botId !== current.bot.id) { + throw new Error(`Cannot change existing bot.id '${current.bot.id}' to '${options.botId}'`); + } const prompt = options.prompt || createPromptSession(); const discoverAgents = options.discoverAgents || discoverBackends; const log = options.log || console.log; @@ -31,10 +34,10 @@ export async function runSetup(configPath?: string, options: SetupOptions = {}): const discovered = await discoverAgents(); for (const agent of discovered) log(`- ${agent.id}: ${agent.status}${agent.version ? ` (${agent.version})` : ""}${agent.reason ? ` - ${agent.reason}` : ""}`); const ready = discovered.filter((agent) => agent.status === "ready"); - if (ready.length === 0) throw new Error("No ACP agent is available"); - const selected = ready.find((agent) => agent.id === current.bot.agent.id) || ready[0]; + if (!configExists && ready.length === 0) throw new Error("No ACP agent is available"); + const selected = configExists ? current.bot.agent : ready.find((agent) => agent.id === current.bot.agent.id) || ready[0]; - const botId = options.botId || await prompt.ask("Bot ID", current.bot.id === "BOT_ID" ? undefined : current.bot.id); + const botId = configExists ? current.bot.id : options.botId || await prompt.ask("Bot ID"); const workspace = path.resolve(await prompt.ask("Bot workspace", current.bot.workspace.startsWith("/absolute/") ? projectRoot() : current.bot.workspace)); const persona = await prompt.ask("Bot persona", current.bot.persona); const usedPorts = collectUsedInstancePorts(options.instancesDirectory || defaultInstancesDirectory(), target); @@ -54,11 +57,11 @@ export async function runSetup(configPath?: string, options: SetupOptions = {}): id: botId, workspace, persona, - agent: { - id: selected.id, - command: selected.command, - args: selected.args, - env: !isTemplate && selected.id === current.bot.agent.id ? current.bot.agent.env : {} + agent: configExists ? current.bot.agent : { + id: selected!.id, + command: selected!.command, + args: selected!.args, + env: {} }, skills: isTemplate ? [] : current.bot.skills, permissions: isTemplate ? { mode: "deny", allowedTools: [], allowedCommandPatterns: [] } : current.bot.permissions @@ -67,9 +70,10 @@ export async function runSetup(configPath?: string, options: SetupOptions = {}): runtime: current.runtime }; const shouldWrite = options.writeWithoutConfirmation || await prompt.askBoolean(`Write Config v3 to ${target}`, false); - if (!shouldWrite) { log("No changes written."); return; } + if (!shouldWrite) { log("No changes written."); return false; } writeConfigFile(target, next); log(`Wrote ${target} with mode 0600`); + return true; } finally { prompt.close(); } } diff --git a/test/cli.test.ts b/test/cli.test.ts index 7ff9cb0..9d44343 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,26 +1,59 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import test from "node:test"; -function runCli(args: string[]) { +function runCli(args: string[], root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-cli-"))) { return spawnSync(process.execPath, ["--import", "tsx", path.resolve("src/cli.ts"), ...args], { cwd: path.resolve("."), encoding: "utf8", - env: { ...process.env, GORI_GATEWAY_CONFIG: "" } + env: { ...process.env, GORI_AGENT_ROOT: root, 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/); +test("CLI help exposes only direct instance commands", () => { + const result = runCli(["--help"]); + assert.equal(result.status, 0); + for (const command of ["init", "setup", "start", "stop", "restart", "status", "logs", "doctor"]) { + assert.match(result.stdout, new RegExp(`gori-agent ${command} `)); + } + assert.match(result.stdout, /gori-agent list/); + assert.doesNotMatch(result.stdout, /gori-agent instance|--config|--json|discover-backends|print feishu/); +}); + +test("CLI dispatches direct commands and rejects removed entry points", () => { + assert.equal(runCli(["list"]).status, 0); + + for (const command of ["setup", "start", "stop", "restart", "status", "logs", "doctor"]) { + const result = runCli([command, "missing-bot"]); + assert.equal(result.status, 1); + assert.match(result.stderr, /Runtime config does not exist/); + } + const init = runCli(["init", "INVALID"]); + assert.equal(init.status, 1); + assert.match(init.stderr, /Invalid bot ID/); + + for (const removed of [["instance", "list"], ["discover-backends"], ["discover-agents"], ["print", "feishu"]]) { + const result = runCli(removed); + assert.equal(result.status, 1); + assert.match(result.stderr, /Unknown command/); + } +}); + +test("CLI rejects options and invalid command argument counts", () => { + for (const args of [["status", "bot", "--config", "config.json"], ["list", "--json"], ["start", "--bogus"]]) { + const result = runCli(args); + assert.equal(result.status, 1); + assert.match(result.stderr, /Unknown option/); + } + + const missing = runCli(["setup"]); + assert.equal(missing.status, 1); + assert.match(missing.stderr, /setup requires exactly one /); + + const extra = runCli(["doctor", "one", "two"]); + assert.equal(extra.status, 1); + assert.match(extra.stderr, /doctor requires exactly one /); }); diff --git a/test/instance.test.ts b/test/instance.test.ts index 7d3ce30..b393c4c 100644 --- a/test/instance.test.ts +++ b/test/instance.test.ts @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; import fs from "node:fs"; +import { EventEmitter } from "node:events"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { writeConfigFile } from "../src/cli/config-file.js"; -import { instanceDirectory, runInstanceCommand } from "../src/cli/instance.js"; +import { assertSetupPidSafe, instanceDirectory, instanceRunnerPath, matchesInstanceRunner, runInstanceCommand } from "../src/cli/instance.js"; +import { runInstanceRunner, waitForShutdown } from "../src/cli/instance-runner.js"; import { parseConfig } from "../src/config.js"; test("instance paths reject traversal and config identity mismatch", async () => { @@ -16,7 +18,7 @@ test("instance paths reject traversal and config identity mismatch", async () => 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("unknown", ["bot"]), /Unknown command/); await assert.rejects(runInstanceCommand("status", ["missing-bot"]), /Runtime config does not exist/); const directory = instanceDirectory("directory-bot"); @@ -43,3 +45,65 @@ test("instance paths reject traversal and config identity mismatch", async () => else process.env.GORI_AGENT_ROOT = previous; } }); + +test("setup rejects both an owned runner PID and a foreign PID", () => { + assert.throws(() => assertSetupPidSafe("safe-bot", { kind: "running", pid: 123 }), /instance 'safe-bot' is running/); + assert.throws(() => assertSetupPidSafe("safe-bot", { kind: "foreign", pid: 456 }), /unrelated live process/); + assert.doesNotThrow(() => assertSetupPidSafe("safe-bot", { kind: "absent" })); + assert.doesNotThrow(() => assertSetupPidSafe("safe-bot", { kind: "stale" })); +}); + +test("runner identity requires exact executable, runner, and config arguments", () => { + const configFile = path.resolve("/tmp/test-instance-config.json"); + const commandLine = [process.execPath, instanceRunnerPath(), configFile]; + assert.equal(matchesInstanceRunner(commandLine, process.execPath, configFile), true); + assert.equal(matchesInstanceRunner([...commandLine, "extra"], process.execPath, configFile), false); + assert.equal(matchesInstanceRunner([process.execPath, path.resolve("dist/cli.js"), configFile], process.execPath, configFile), false); + assert.equal(matchesInstanceRunner(commandLine, "/bin/sh", configFile), false); + assert.equal(matchesInstanceRunner(commandLine, process.execPath, `${configFile}.other`), false); +}); + +test("instance runner rejects unsafe argument and config paths before starting a server", async () => { + await assert.rejects(runInstanceRunner([]), /exactly one config path/); + await assert.rejects(runInstanceRunner(["one", "two"]), /exactly one config path/); + await assert.rejects(runInstanceRunner([path.join(os.tmpdir(), `missing-runner-${Date.now()}.json`)]), /Runtime config does not exist/); +}); + +test("instance runner gracefully shuts down once on SIGTERM", async () => { + const signals = new EventEmitter(); + let shutdowns = 0; + const result = waitForShutdown({ shutdown: async () => { shutdowns++; } }, signals); + signals.emit("SIGTERM"); + signals.emit("SIGINT"); + assert.equal(await result, 0); + assert.equal(shutdowns, 1); + assert.equal(signals.listenerCount("SIGINT"), 0); + assert.equal(signals.listenerCount("SIGTERM"), 0); +}); + +test("setup refuses a foreign live PID and unsafe PID file", async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-setup-pid-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const previous = process.env.GORI_AGENT_ROOT; + process.env.GORI_AGENT_ROOT = root; + t.after(() => { + if (previous === undefined) delete process.env.GORI_AGENT_ROOT; + else process.env.GORI_AGENT_ROOT = previous; + }); + + const directory = instanceDirectory("safe-bot"); + writeConfigFile(path.join(directory, "config.json"), parseConfig({ + configVersion: 3, + bot: { id: "safe-bot", workspace: os.tmpdir(), persona: "", agent: { id: "node", command: process.execPath, args: ["acp"] }, permissions: { mode: "deny" } }, + gateway: { platform: { type: "webhook", secret: "test-secret" } }, + runtime: {} + })); + fs.mkdirSync(path.join(directory, "state"), { mode: 0o700 }); + fs.mkdirSync(path.join(directory, "logs"), { mode: 0o700 }); + const pidFile = path.join(directory, "state", "gori-agent.pid"); + fs.writeFileSync(pidFile, `${process.pid}\n`, { mode: 0o600 }); + await assert.rejects(runInstanceCommand("setup", ["safe-bot"]), /unrelated live process/); + + fs.chmodSync(pidFile, 0o644); + await assert.rejects(runInstanceCommand("setup", ["safe-bot"]), /PID file mode must be 0600/); +}); diff --git a/test/setup.test.ts b/test/setup.test.ts index 19d86b8..60a5f26 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -70,16 +70,21 @@ function existingConfig(host = "0.0.0.0", port = 8787) { 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: [] } + 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: [], allowedChats: [], requireMentionInGroup: true }, + policy: { allowedUsers: ["allowed-user"], allowedChats: ["allowed-chat"], requireMentionInGroup: false }, platform: { type: "webhook", secret: testSecret } }, - runtime: {} + runtime: { acp: { promptTimeoutMs: 123456, maxProcesses: 3 } } }); } @@ -145,6 +150,17 @@ test("existing setup can change host and port while preserving public URL and se 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"))); @@ -175,3 +191,51 @@ test("existing setup keeps host and port when defaults are accepted", async (t) 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/); +});