135 lines
6.2 KiB
TypeScript
135 lines
6.2 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import type { AppConfig } from "../config.js";
|
|
import { loadConfigFromPath, parseConfig } from "../config.js";
|
|
|
|
export interface LoadedConfigFile {
|
|
path: string;
|
|
config: AppConfig;
|
|
source: "explicit" | "env" | "local";
|
|
}
|
|
|
|
export function projectRoot(): string {
|
|
return path.resolve(new URL("../..", import.meta.url).pathname);
|
|
}
|
|
|
|
export function resolveCliConfigPath(configPath?: string): { path: string; source: LoadedConfigFile["source"] } {
|
|
if (configPath) return { path: path.resolve(configPath), source: "explicit" };
|
|
if (process.env.GORI_GATEWAY_CONFIG) return { path: path.resolve(process.env.GORI_GATEWAY_CONFIG), source: "env" };
|
|
return { path: path.resolve("config.json"), source: "local" };
|
|
}
|
|
|
|
export function loadConfigFile(configPath?: string): LoadedConfigFile {
|
|
const resolved = resolveCliConfigPath(configPath);
|
|
if (!fs.existsSync(resolved.path)) {
|
|
throw new Error(`Runtime config does not exist: ${resolved.path}; config.example.json is documentation only`);
|
|
}
|
|
return { path: resolved.path, config: loadConfigFromPath(resolved.path), source: resolved.source };
|
|
}
|
|
|
|
export function loadExampleConfig(): AppConfig {
|
|
const examplePath = path.join(projectRoot(), "config.example.json");
|
|
return parseConfig(JSON.parse(fs.readFileSync(examplePath, "utf8")) as unknown);
|
|
}
|
|
|
|
export function configDigest(config: AppConfig): string {
|
|
return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
|
}
|
|
|
|
export function writeConfigFile(configPath: string, config: AppConfig): void {
|
|
const parsed = parseConfig(config);
|
|
const directory = path.dirname(configPath);
|
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
const temp = path.join(directory, `.${path.basename(configPath)}.${process.pid}.${Date.now()}.tmp`);
|
|
let fd: number | undefined;
|
|
try {
|
|
fd = fs.openSync(temp, "wx", 0o600);
|
|
fs.writeFileSync(fd, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
|
|
fs.fsyncSync(fd);
|
|
fs.closeSync(fd);
|
|
fd = undefined;
|
|
fs.renameSync(temp, configPath);
|
|
fs.chmodSync(configPath, 0o600);
|
|
const dirFd = fs.openSync(directory, "r");
|
|
try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
|
|
} catch (error) {
|
|
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* ignore cleanup failure */ }
|
|
try { fs.unlinkSync(temp); } catch { /* ignore cleanup failure */ }
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function operationalConfigProblems(config: AppConfig): string[] {
|
|
const problems: string[] = [];
|
|
if (config.bot.id === "BOT_ID") problems.push("bot.id is still the example placeholder");
|
|
if (isPlaceholder(config.bot.workspace) || config.bot.workspace.includes("/absolute/path/")) problems.push("bot.workspace is still a placeholder");
|
|
if (isPlaceholder(config.bot.agent.command) || config.bot.agent.command.includes("/home/USER/")) problems.push("bot.agent.command is still a placeholder");
|
|
else if (!isExecutable(config.bot.agent.command)) problems.push("bot.agent.command is not executable");
|
|
if (config.bot.agent.args.length !== 1 || config.bot.agent.args[0] !== "acp") problems.push("bot.agent.args must be exactly ['acp'] so the no-tool side profile cannot be bypassed");
|
|
try {
|
|
if (!fs.statSync(config.bot.workspace).isDirectory()) problems.push("bot.workspace must be an existing directory");
|
|
} catch { problems.push("bot.workspace must be an existing directory"); }
|
|
|
|
const platform = config.gateway.platform;
|
|
switch (platform.type) {
|
|
case "qq":
|
|
if (missingOrPlaceholder(platform.appId)) problems.push("QQ appId is missing or placeholder");
|
|
if (missingOrPlaceholder(platform.clientSecret)) problems.push("QQ clientSecret is missing or placeholder");
|
|
if (platform.botNames.some(isPlaceholder)) problems.push("QQ botNames contains a placeholder");
|
|
break;
|
|
case "feishu":
|
|
if (missingOrPlaceholder(platform.appId)) problems.push("Feishu appId is missing or placeholder");
|
|
if (missingOrPlaceholder(platform.appSecret)) problems.push("Feishu appSecret is missing or placeholder");
|
|
break;
|
|
case "wecom":
|
|
if (missingOrPlaceholder(platform.corpId)) problems.push("WeCom corpId is missing or placeholder");
|
|
if (missingOrPlaceholder(platform.agentId)) problems.push("WeCom agentId is missing or placeholder");
|
|
if (missingOrPlaceholder(platform.secret)) problems.push("WeCom secret is missing or placeholder");
|
|
break;
|
|
case "webhook":
|
|
if (missingOrPlaceholder(platform.secret)) problems.push("Generic webhook secret is missing or placeholder");
|
|
break;
|
|
case "weixin":
|
|
if (missingOrPlaceholder(platform.secret)) problems.push("Weixin bridge secret is missing or placeholder");
|
|
break;
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
export function assertOperationalConfig(config: AppConfig, configFile?: string): void {
|
|
const problems = operationalConfigProblems(config);
|
|
if (configFile) {
|
|
const stat = fs.lstatSync(configFile);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) problems.push("config must be a regular file");
|
|
const mode = stat.mode & 0o777;
|
|
if (mode !== 0o600) problems.push(`config file mode must be 0600, got 0${mode.toString(8)}`);
|
|
}
|
|
if (problems.length > 0) throw new Error(`Refusing to start: ${problems.join("; ")}`);
|
|
}
|
|
|
|
function missingOrPlaceholder(value: string): boolean { return !value || isPlaceholder(value); }
|
|
|
|
function isExecutable(command: string): boolean {
|
|
try {
|
|
if (command.includes(path.sep)) {
|
|
fs.accessSync(command, fs.constants.X_OK);
|
|
return fs.statSync(command).isFile();
|
|
}
|
|
return (process.env.PATH || "").split(path.delimiter).some((directory) => {
|
|
try { fs.accessSync(path.join(directory, command), fs.constants.X_OK); return fs.statSync(path.join(directory, command)).isFile(); }
|
|
catch { return false; }
|
|
});
|
|
} catch { return false; }
|
|
}
|
|
|
|
function isPlaceholder(value: string): boolean {
|
|
return /^(?:BOT_ID|QQ_(?:APP_ID|CLIENT_SECRET|BOT_NAME)|(?:FEISHU|WECOM|WEBHOOK|WEIXIN)_[A-Z0-9_]+)$/.test(value)
|
|
|| value.includes("<") || value.includes(">");
|
|
}
|
|
|
|
export function readConfigJson(configPath?: string): unknown {
|
|
return loadConfigFile(configPath).config;
|
|
}
|