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; }