Simplify instance lifecycle commands
This commit is contained in:
@@ -42,23 +42,26 @@ ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
|
||||
- 实例目录、`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 <bot-id>` 只重配已有实例:校验目录/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 <config-path>`;PID identity 必须精确匹配 Node executable、runner 和唯一 config 参数。
|
||||
|
||||
日常入口:
|
||||
公开入口仅有:
|
||||
|
||||
```bash
|
||||
gori-agent instance init <bot-id>
|
||||
gori-agent instance start <bot-id>
|
||||
gori-agent instance stop <bot-id>
|
||||
gori-agent instance restart <bot-id>
|
||||
gori-agent instance status <bot-id>
|
||||
gori-agent instance logs <bot-id>
|
||||
gori-agent instance list
|
||||
gori-agent instance doctor <bot-id>
|
||||
gori-agent init <bot-id>
|
||||
gori-agent setup <bot-id>
|
||||
gori-agent start <bot-id>
|
||||
gori-agent stop <bot-id>
|
||||
gori-agent restart <bot-id>
|
||||
gori-agent status <bot-id>
|
||||
gori-agent logs <bot-id>
|
||||
gori-agent doctor <bot-id>
|
||||
gori-agent list
|
||||
```
|
||||
|
||||
保留 `start --config`、`status --config`、`doctor --config` 等用于本地调试,但运行配置不存在时不得回退 example。
|
||||
不兼容旧 `instance` 前缀;不公开 debug 命令、`--config` 或 `--json`。
|
||||
|
||||
## 3. 核心代码结构
|
||||
|
||||
@@ -91,6 +94,8 @@ gori-agent instance doctor <bot-id>
|
||||
- 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 <bot-id>` 或 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 <bot-id>`,不发送真实平台消息。
|
||||
9. 只有用户明确授权时才启动/停止真实 Bot。
|
||||
|
||||
旧仓库本地 `config.json`、备份和 state 可供人工回退;改造实例时不要删除或覆盖。
|
||||
|
||||
@@ -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 <bot-id>
|
||||
gori-agent instance doctor <bot-id>
|
||||
gori-agent instance start <bot-id>
|
||||
gori-agent instance status <bot-id>
|
||||
gori-agent instance logs <bot-id>
|
||||
gori-agent instance restart <bot-id>
|
||||
gori-agent instance stop <bot-id>
|
||||
gori-agent instance list
|
||||
gori-agent init <bot-id>
|
||||
gori-agent setup <bot-id>
|
||||
gori-agent doctor <bot-id>
|
||||
gori-agent start <bot-id>
|
||||
gori-agent status <bot-id>
|
||||
gori-agent logs <bot-id>
|
||||
gori-agent restart <bot-id>
|
||||
gori-agent stop <bot-id>
|
||||
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 作为初始化种子;其他命令只按 `<bot-id>` 加载统一实例目录中的 `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 <bot-id>` 检查:
|
||||
|
||||
- Config v3 与 example placeholder。
|
||||
- 配置权限 `0600`。
|
||||
|
||||
+13
-20
@@ -7,23 +7,16 @@ usage() {
|
||||
cat <<'EOF'
|
||||
Usage: gori-agent <command> [arguments]
|
||||
|
||||
Instance commands:
|
||||
instance init <bot-id>
|
||||
instance start <bot-id>
|
||||
instance stop <bot-id>
|
||||
instance restart <bot-id>
|
||||
instance status <bot-id>
|
||||
instance logs <bot-id>
|
||||
instance doctor <bot-id>
|
||||
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 <bot-id>
|
||||
setup <bot-id>
|
||||
start <bot-id>
|
||||
stop <bot-id>
|
||||
restart <bot-id>
|
||||
status <bot-id>
|
||||
logs <bot-id>
|
||||
doctor <bot-id>
|
||||
list
|
||||
|
||||
Instances live under ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>.
|
||||
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
|
||||
;;
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+29
-105
@@ -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<number> {
|
||||
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<number>((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 <bot-id>");
|
||||
} else if (args.length !== 1) {
|
||||
throw new Error(`${command} requires exactly one <bot-id>`);
|
||||
}
|
||||
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 <path>");
|
||||
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 <path>`);
|
||||
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<void> {
|
||||
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 <bot-id>\n gori-agent instance start <bot-id>\n gori-agent instance stop <bot-id>\n gori-agent instance restart <bot-id>\n gori-agent instance status <bot-id>\n gori-agent instance logs <bot-id>\n gori-agent instance doctor <bot-id>\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 <bot-id>
|
||||
gori-agent setup <bot-id>
|
||||
gori-agent start <bot-id>
|
||||
gori-agent stop <bot-id>
|
||||
gori-agent restart <bot-id>
|
||||
gori-agent status <bot-id>
|
||||
gori-agent logs <bot-id>
|
||||
gori-agent doctor <bot-id>
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -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<void> }
|
||||
interface SignalEmitter {
|
||||
once(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
|
||||
removeListener(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
|
||||
}
|
||||
|
||||
export async function runInstanceRunner(argv: string[]): Promise<number> {
|
||||
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<number> {
|
||||
return new Promise<number>((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;
|
||||
});
|
||||
}
|
||||
+61
-20
@@ -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<number> {
|
||||
if (action === "list") {
|
||||
if (args.length !== 0) throw new Error("instance list does not accept <bot-id>");
|
||||
if (args.length !== 0) throw new Error("list does not accept <bot-id>");
|
||||
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 <bot-id>`);
|
||||
if (args.length !== 1) throw new Error(`${action} requires exactly one <bot-id>`);
|
||||
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<number> {
|
||||
}
|
||||
}
|
||||
|
||||
async function setupInstance(botId: string): Promise<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
|
||||
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<typeof spawn> | 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<typeof loadConfigFile> {
|
||||
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 }; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { webhookBaseUrl } from "./net.js";
|
||||
|
||||
export async function printFeishu(config: AppConfig): Promise<void> {
|
||||
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.");
|
||||
}
|
||||
+14
-10
@@ -17,11 +17,14 @@ export interface SetupOptions {
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
export async function runSetup(configPath?: string, options: SetupOptions = {}): Promise<void> {
|
||||
export async function runSetup(configPath?: string, options: SetupOptions = {}): Promise<boolean> {
|
||||
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(); }
|
||||
}
|
||||
|
||||
|
||||
+47
-14
@@ -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} <bot-id>`));
|
||||
}
|
||||
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 <bot-id>/);
|
||||
|
||||
const extra = runCli(["doctor", "one", "two"]);
|
||||
assert.equal(extra.status, 1);
|
||||
assert.match(extra.stderr, /doctor requires exactly one <bot-id>/);
|
||||
});
|
||||
|
||||
+66
-2
@@ -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/);
|
||||
});
|
||||
|
||||
+69
-5
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user