feat: provision non-root Hub service

This commit is contained in:
2026-07-10 15:59:35 +08:00
parent 5f791c5ce4
commit 73fa28a178
19 changed files with 1608 additions and 134 deletions
+480
View File
@@ -0,0 +1,480 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import { constants } from "node:fs";
import { access, readFile, realpath, stat } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { PrismaClient } from "@prisma/client";
import { parse } from "dotenv";
import {
DeploymentPreflightError,
validateDeploymentPreflight,
type DeploymentEnv,
type DeploymentPreflightInput,
type DeploymentPreflightResult,
} from "./preflight.js";
const execFileAsync = promisify(execFile);
interface Options {
readonly envFile: string;
readonly nodeBin: string;
readonly runuserBin: string;
readonly setprivBin: string;
readonly serviceUser: string;
readonly baseDir: string;
readonly hubDir: string;
readonly serviceHome: string;
readonly stateDir: string;
readonly cacheDir: string;
readonly workspaceRoot: string;
readonly bwrapBin: string;
readonly pgIsReadyBin: string;
readonly serviceUid?: number;
readonly serviceGid?: number;
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const envFileContents = await readFile(options.envFile);
const env = parse(envFileContents) satisfies DeploymentEnv;
const rawInput = deploymentInput(options, env);
// Validate lexical paths and values before touching any external dependency.
validateDeploymentPreflight(rawInput);
const canonicalInput = await canonicalizeInput(rawInput);
const result = validateDeploymentPreflight(canonicalInput);
await validateSecretFile(options.envFile);
await validateNodeVersion(options.nodeBin);
await validateExecutable(options.runuserBin, "runuser");
await validateExecutable(options.setprivBin, "setpriv");
await validateExecutable(options.bwrapBin, "bubblewrap");
await validateExecutable(result.cphBin, "cph");
await validateExecutable(options.pgIsReadyBin, "pg_isready");
await validateCph(result.cphBin);
await validatePostgres(options.pgIsReadyBin, result);
await validatePostgresQuery(result.databaseUrl);
if (options.serviceUid !== undefined && options.serviceGid !== undefined) {
await validateProvisionedDirectories(canonicalInput, options.serviceUid, options.serviceGid);
await validateServiceRuntime(options, rawInput, canonicalInput, result);
}
console.log("[preflight] configuration and prerequisites valid");
}
function deploymentInput(options: Options, env: DeploymentEnv): DeploymentPreflightInput {
return {
baseDir: options.baseDir,
hubDir: options.hubDir,
serviceHome: options.serviceHome,
stateDir: options.stateDir,
cacheDir: options.cacheDir,
workspaceRoot: options.workspaceRoot,
env,
};
}
async function canonicalizeInput(input: DeploymentPreflightInput): Promise<DeploymentPreflightInput> {
const baseDir = await canonicalizeProspectivePath(input.baseDir);
const hubDir = await canonicalizeProspectivePath(input.hubDir);
const serviceHome = await canonicalizeProspectivePath(input.serviceHome);
const stateDir = await canonicalizeProspectivePath(input.stateDir);
const cacheDir = await canonicalizeProspectivePath(input.cacheDir);
const workspaceRoot = await canonicalizeProspectivePath(input.workspaceRoot);
return {
baseDir,
hubDir,
serviceHome,
stateDir,
cacheDir,
workspaceRoot,
env: { ...input.env, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot },
};
}
async function canonicalizeProspectivePath(value: string): Promise<string> {
const absolute = resolve(value);
const missingComponents: string[] = [];
let candidate = absolute;
while (true) {
try {
const existing = await realpath(candidate);
return resolve(existing, ...missingComponents.reverse());
} catch (error) {
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
const parent = dirname(candidate);
if (parent === candidate) throw error;
missingComponents.push(basename(candidate));
candidate = parent;
}
}
}
async function validateSecretFile(path: string): Promise<void> {
const metadata = await stat(path);
if (!metadata.isFile()) {
throw new Error(`environment file is not a regular file: ${path}`);
}
if ((metadata.mode & 0o077) !== 0) {
throw new Error(`environment file must not be accessible by group or others: ${path}`);
}
}
async function validateNodeVersion(nodeBin: string): Promise<void> {
let stdout: string;
try {
({ stdout } = await execFileAsync(nodeBin, ["--version"], { timeout: 10_000 }));
} catch (error) {
throw new Error(`Node.js version check failed: ${nodeBin} (${formatFailure(error)})`, { cause: error });
}
const version = stdout.trim().replace(/^v/, "");
const major = Number(version.split(".")[0]);
if (!Number.isInteger(major) || major < 20) {
throw new Error(`Node.js 20 or newer is required; found ${version || "unknown"}`);
}
console.log(`[preflight] Node.js: ${version}`);
}
async function validateExecutable(path: string, label: string): Promise<void> {
try {
await access(path, constants.X_OK);
} catch (error) {
throw new Error(
`${label} executable is missing or not executable: ${path} (${formatFailure(error)})`,
{ cause: error },
);
}
}
async function validateCph(cphBin: string): Promise<void> {
let stdout: string;
try {
({ stdout } = await execFileAsync(cphBin, ["--version"], { timeout: 10_000 }));
} catch (error) {
throw new Error(`cph --version failed: ${cphBin} (${formatFailure(error)})`, { cause: error });
}
if (!/^cph \d+\.\d+\.\d+(?:\s|$)/m.test(stdout)) {
throw new Error(`cph --version returned an unexpected value: ${cphBin}`);
}
console.log(`[preflight] cph: ${stdout.trim()}`);
}
async function validatePostgres(pgIsReadyBin: string, result: DeploymentPreflightResult): Promise<void> {
const url = new URL(result.databaseUrl);
const postgresEnv: NodeJS.ProcessEnv = {
...process.env,
PGCONNECT_TIMEOUT: "5",
PGHOST: url.hostname,
PGPORT: url.port || "5432",
PGUSER: decodeURIComponent(url.username),
PGPASSWORD: decodeURIComponent(url.password),
PGDATABASE: decodeURIComponent(url.pathname.replace(/^\//, "")),
};
const sslMode = url.searchParams.get("sslmode");
if (sslMode !== null) postgresEnv["PGSSLMODE"] = sslMode;
try {
await execFileAsync(pgIsReadyBin, ["--quiet"], { env: postgresEnv, timeout: 10_000 });
} catch (error) {
throw new Error(
`PostgreSQL is not accepting connections for DATABASE_URL (${formatFailure(error)})`,
{ cause: error },
);
}
console.log("[preflight] PostgreSQL is accepting connections");
}
async function validatePostgresQuery(databaseUrl: string): Promise<void> {
const client = new PrismaClient({
datasources: { db: { url: databaseUrl } },
log: [],
});
let queryFailure: unknown;
try {
await client.$queryRawUnsafe("SELECT 1");
} catch (error) {
queryFailure = error;
}
let disconnectFailure: unknown;
try {
await client.$disconnect();
} catch (error) {
disconnectFailure = error;
}
if (queryFailure !== undefined) {
const disconnectDetail =
disconnectFailure === undefined ? "" : `; disconnect also failed (${formatFailure(disconnectFailure)})`;
throw new Error(
`PostgreSQL authenticated query failed for DATABASE_URL (${formatFailure(queryFailure)})${disconnectDetail}`,
{ cause: queryFailure },
);
}
if (disconnectFailure !== undefined) {
throw new Error(
`PostgreSQL preflight disconnect failed (${formatFailure(disconnectFailure)})`,
{ cause: disconnectFailure },
);
}
console.log("[preflight] PostgreSQL authenticated query succeeded");
}
async function validateProvisionedDirectories(
input: DeploymentPreflightInput,
serviceUid: number,
serviceGid: number,
): Promise<void> {
const directories = [input.serviceHome, input.stateDir, input.cacheDir, input.workspaceRoot];
for (const directory of directories) {
const metadata = await stat(directory);
if (!metadata.isDirectory()) {
throw new Error(`provisioned path is not a directory: ${directory}`);
}
if (metadata.uid !== serviceUid || metadata.gid !== serviceGid) {
throw new Error(`provisioned directory has unexpected ownership: ${directory}`);
}
if ((metadata.mode & 0o777) !== 0o750) {
throw new Error(`provisioned directory must have mode 0750: ${directory}`);
}
}
console.log(`[preflight] service directories owned by ${serviceUid}:${serviceGid} with mode 0750`);
}
async function validateServiceRuntime(
options: Options,
configuredInput: DeploymentPreflightInput,
canonicalInput: DeploymentPreflightInput,
result: DeploymentPreflightResult,
): Promise<void> {
const serviceEnv: NodeJS.ProcessEnv = {
...configuredInput.env,
NODE_ENV: "production",
DATABASE_URL: result.databaseUrl,
HOME: configuredInput.serviceHome,
XDG_STATE_HOME: configuredInput.stateDir,
XDG_CACHE_HOME: configuredInput.cacheDir,
PATH: [dirname(options.nodeBin), dirname(options.bwrapBin), dirname(result.cphBin), "/usr/bin", "/bin"].join(":"),
};
const pathAccessRequirements = (input: DeploymentPreflightInput) => [
[input.hubDir, constants.R_OK | constants.X_OK],
[join(input.hubDir, "dist"), constants.R_OK | constants.X_OK],
[join(input.hubDir, "node_modules"), constants.R_OK | constants.X_OK],
[join(input.hubDir, "prisma"), constants.R_OK | constants.X_OK],
[input.serviceHome, constants.R_OK | constants.W_OK | constants.X_OK],
[input.stateDir, constants.R_OK | constants.W_OK | constants.X_OK],
[input.cacheDir, constants.R_OK | constants.W_OK | constants.X_OK],
[input.workspaceRoot, constants.R_OK | constants.W_OK | constants.X_OK],
[join(input.hubDir, "dist", "server.js"), constants.R_OK],
[join(input.hubDir, "dist", "deployment", "service-runtime-probe.js"), constants.R_OK],
[join(input.hubDir, "node_modules", "prisma", "build", "index.js"), constants.R_OK],
[join(input.hubDir, "prisma", "schema.prisma"), constants.R_OK],
] as const;
const accessRequirements = [
...pathAccessRequirements(configuredInput),
...pathAccessRequirements(canonicalInput),
[options.nodeBin, constants.X_OK],
[options.bwrapBin, constants.X_OK],
[options.setprivBin, constants.X_OK],
[result.cphBin, constants.X_OK],
] as const;
const accessProbe = [
'const { accessSync } = require("node:fs");',
"for (const [path, mode] of JSON.parse(process.argv[1])) accessSync(path, mode);",
].join("\n");
await runAsService(
options,
serviceEnv,
options.nodeBin,
["-e", accessProbe, JSON.stringify(accessRequirements)],
"release and persistent path access probe",
);
await runAsService(
options,
serviceEnv,
options.nodeBin,
["--check", join(configuredInput.hubDir, "dist", "server.js")],
"built Hub syntax probe",
);
await runAsService(
options,
serviceEnv,
options.nodeBin,
[join(configuredInput.hubDir, "node_modules", "prisma", "build", "index.js"), "--version"],
"Prisma runtime probe",
);
await runAsService(
options,
serviceEnv,
options.nodeBin,
[join(configuredInput.hubDir, "dist", "deployment", "service-runtime-probe.js")],
"Hub dependency and authenticated database probe",
);
await runAsService(options, serviceEnv, result.cphBin, ["--version"], "cph runtime probe");
await runAsService(
options,
serviceEnv,
options.bwrapBin,
[
"--unshare-all",
"--die-with-parent",
"--new-session",
"--ro-bind",
"/",
"/",
"--proc",
"/proc",
"--dev",
"/dev",
"/bin/true",
],
"bubblewrap user-namespace probe",
);
console.log(`[preflight] runtime prerequisites execute as service user ${options.serviceUser}`);
}
async function runAsService(
options: Options,
env: NodeJS.ProcessEnv,
command: string,
args: readonly string[],
label: string,
): Promise<void> {
try {
await execFileAsync(
options.runuserBin,
[
"-u",
options.serviceUser,
"--",
options.setprivBin,
"--no-new-privs",
command,
...args,
],
{ cwd: options.hubDir, env, timeout: 15_000, maxBuffer: 2 * 1024 * 1024 },
);
} catch (error) {
throw new Error(`${label} failed for service user ${options.serviceUser} (${formatFailure(error)})`, {
cause: error,
});
}
}
function parseArgs(argv: readonly string[]): Options {
const values = new Map<string, string>();
for (let index = 0; index < argv.length; index += 2) {
const key = argv[index];
const value = argv[index + 1];
if (key === undefined || value === undefined || !key.startsWith("--")) {
throw new Error("preflight arguments must be --name value pairs");
}
if (values.has(key)) throw new Error(`duplicate preflight argument: ${key}`);
values.set(key, value);
}
const allowed = new Set([
"--env-file",
"--node-bin",
"--runuser-bin",
"--setpriv-bin",
"--service-user",
"--base-dir",
"--hub-dir",
"--service-home",
"--state-dir",
"--cache-dir",
"--workspace-root",
"--bwrap-bin",
"--pg-isready-bin",
"--service-uid",
"--service-gid",
]);
for (const key of values.keys()) {
if (!allowed.has(key)) throw new Error(`unknown preflight argument: ${key}`);
}
const serviceUid = optionalId(values, "--service-uid");
const serviceGid = optionalId(values, "--service-gid");
if ((serviceUid === undefined) !== (serviceGid === undefined)) {
throw new Error("--service-uid and --service-gid must be supplied together");
}
return {
envFile: required(values, "--env-file"),
nodeBin: required(values, "--node-bin"),
runuserBin: required(values, "--runuser-bin"),
setprivBin: required(values, "--setpriv-bin"),
serviceUser: required(values, "--service-user"),
baseDir: required(values, "--base-dir"),
hubDir: required(values, "--hub-dir"),
serviceHome: required(values, "--service-home"),
stateDir: required(values, "--state-dir"),
cacheDir: required(values, "--cache-dir"),
workspaceRoot: required(values, "--workspace-root"),
bwrapBin: required(values, "--bwrap-bin"),
pgIsReadyBin: required(values, "--pg-isready-bin"),
...(serviceUid === undefined ? {} : { serviceUid, serviceGid: serviceGid! }),
};
}
function required(values: ReadonlyMap<string, string>, key: string): string {
const value = values.get(key);
if (value === undefined || value === "") throw new Error(`missing required preflight argument: ${key}`);
return value;
}
function optionalId(values: ReadonlyMap<string, string>, key: string): number | undefined {
const raw = values.get(key);
if (raw === undefined) return undefined;
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) throw new Error(`${key} must be a non-negative integer`);
return value;
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
function formatFailure(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const details: string[] = [];
const processError = error as NodeJS.ErrnoException & {
readonly signal?: NodeJS.Signals | null;
readonly stderr?: string | Buffer;
};
if (processError.code !== undefined) details.push(`exit=${String(processError.code)}`);
if (processError.signal !== undefined && processError.signal !== null) {
details.push(`signal=${processError.signal}`);
}
const stderr = processError.stderr?.toString().trim();
if (stderr !== undefined && stderr !== "") details.push(`stderr=${boundedDiagnostic(redactDiagnostic(stderr))}`);
if (details.length === 0) details.push(boundedDiagnostic(redactDiagnostic(error.message)));
return details.join(", ");
}
function boundedDiagnostic(value: string): string {
const limit = 2_000;
if (value.length <= limit) return value;
return `${value.slice(0, limit)}…[truncated ${value.length - limit} chars]`;
}
function redactDiagnostic(value: string): string {
return value
.replace(/\b(postgres(?:ql)?):\/\/([^:\s/@]+):([^@\s]+)@/gi, "$1://$2:[REDACTED]@")
.replace(/\b(password\s*[:=]\s*)\S+/gi, "$1[REDACTED]");
}
main().catch((error: unknown) => {
if (error instanceof DeploymentPreflightError) {
console.error(`[preflight] ${error.message}`);
} else {
console.error(`[preflight] failed: ${error instanceof Error ? error.message : String(error)}`);
}
process.exitCode = 1;
});
+203
View File
@@ -0,0 +1,203 @@
import { isAbsolute, normalize, relative } from "node:path";
import { readServerBinding, type ServerBinding, type ServerEnv } from "../settings/server.js";
export type DeploymentEnv = ServerEnv;
export interface DeploymentPreflightInput {
readonly baseDir: string;
readonly hubDir: string;
readonly serviceHome: string;
readonly stateDir: string;
readonly cacheDir: string;
readonly workspaceRoot: string;
readonly env: DeploymentEnv;
}
export interface DeploymentPreflightResult {
readonly bind: ServerBinding;
readonly databaseUrl: string;
readonly cphBin: string;
}
export class DeploymentPreflightError extends Error {
readonly problems: readonly string[];
constructor(problems: readonly string[]) {
super(`deployment preflight failed:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
this.name = "DeploymentPreflightError";
this.problems = problems;
}
}
/**
* Validate the production values shared by the installer and the Hub runtime.
* Inputs are required to be absolute normalized paths so callers can first
* resolve existing paths physically and make symlink traversal observable.
*/
export function validateDeploymentPreflight(input: DeploymentPreflightInput): DeploymentPreflightResult {
const problems: string[] = [];
const paths = [
["deployment root", input.baseDir],
["Hub directory", input.hubDir],
["service home", input.serviceHome],
["state directory", input.stateDir],
["cache directory", input.cacheDir],
["workspace root", input.workspaceRoot],
] as const;
for (const [label, value] of paths) {
if (!isAbsoluteNormalized(value)) {
problems.push(`${label} must be an absolute normalized path`);
}
}
if (isAbsoluteNormalized(input.baseDir) && isAbsoluteNormalized(input.hubDir)) {
if (!isStrictDescendant(input.baseDir, input.hubDir)) {
problems.push("Hub directory must be inside deployment root");
}
}
if (isAbsoluteNormalized(input.baseDir)) {
for (const [label, value] of paths.slice(2)) {
if (isAbsoluteNormalized(value) && pathsOverlap(input.baseDir, value)) {
problems.push(`${label} must not overlap deployment root`);
}
}
}
const envWorkspaceRoot = readRequired(input.env, "HUB_PROJECT_WORKSPACE_ROOT", problems);
if (envWorkspaceRoot !== undefined) {
if (!isAbsoluteNormalized(envWorkspaceRoot)) {
problems.push("HUB_PROJECT_WORKSPACE_ROOT must be an absolute normalized path");
} else if (isAbsoluteNormalized(input.workspaceRoot) && envWorkspaceRoot !== input.workspaceRoot) {
problems.push("HUB_PROJECT_WORKSPACE_ROOT must equal provisioned workspace root");
}
}
if (input.env["NODE_ENV"] !== "production") {
problems.push("NODE_ENV must equal production");
}
const databaseUrl = readRequired(input.env, "DATABASE_URL", problems);
if (databaseUrl !== undefined && !isPostgresUrl(databaseUrl)) {
problems.push("DATABASE_URL must be a valid postgresql:// or postgres:// URL");
}
const providerBaseUrl = readRequired(input.env, "ANTHROPIC_BASE_URL", problems);
if (providerBaseUrl !== undefined && !isHttpUrl(providerBaseUrl)) {
problems.push("ANTHROPIC_BASE_URL must be a valid http(s) URL");
}
readRequired(input.env, "ANTHROPIC_AUTH_TOKEN", problems);
requirePresentAndEmpty(input.env, "ANTHROPIC_API_KEY", problems);
readRequired(input.env, "FEISHU_APP_ID", problems);
readRequired(input.env, "FEISHU_APP_SECRET", problems);
readRequired(input.env, "FEISHU_BOT_OPEN_ID", problems);
const cphBin = readRequired(input.env, "CPH_BIN", problems);
if (cphBin !== undefined && !isAbsoluteNormalized(cphBin)) {
problems.push("CPH_BIN must be an absolute normalized path");
}
let bind: ServerBinding | undefined;
try {
bind = readServerBinding(input.env);
} catch (error) {
problems.push(error instanceof Error ? error.message : String(error));
}
const publicBaseUrl = readRequired(input.env, "HUB_PUBLIC_BASE_URL", problems);
if (publicBaseUrl !== undefined && !isHttpsUrl(publicBaseUrl)) {
problems.push("HUB_PUBLIC_BASE_URL must use https");
}
const sessionSecret = readRequired(input.env, "HUB_SESSION_SECRET", problems);
if (sessionSecret !== undefined && sessionSecret.length < 32) {
problems.push("HUB_SESSION_SECRET must contain at least 32 characters");
}
validateOptionalPositiveInteger(input.env, "HUB_AGENT_MAX_TURNS", problems);
validateOptionalBoolean(input.env, "HUB_FEISHU_LISTENER_ENABLED", problems);
if (problems.length > 0) {
throw new DeploymentPreflightError(problems);
}
return {
bind: bind!,
databaseUrl: databaseUrl!,
cphBin: cphBin!,
};
}
function readRequired(env: DeploymentEnv, name: string, problems: string[]): string | undefined {
const value = env[name]?.trim();
if (value === undefined || value === "") {
problems.push(`${name} is required`);
return undefined;
}
return value;
}
function requirePresentAndEmpty(env: DeploymentEnv, name: string, problems: string[]): void {
if (!Object.hasOwn(env, name) || env[name] === undefined || env[name]?.trim() !== "") {
problems.push(`${name} must be present and empty`);
}
}
function validateOptionalPositiveInteger(env: DeploymentEnv, name: string, problems: string[]): void {
const raw = env[name]?.trim();
if (raw === undefined || raw === "") return;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
problems.push(`${name} must be a positive integer`);
}
}
function validateOptionalBoolean(env: DeploymentEnv, name: string, problems: string[]): void {
const raw = env[name]?.trim().toLowerCase();
if (raw === undefined || raw === "") return;
if (!["1", "0", "true", "false", "yes", "no", "on", "off"].includes(raw)) {
problems.push(`${name} must be a boolean value`);
}
}
function isAbsoluteNormalized(value: string): boolean {
return isAbsolute(value) && normalize(value) === value;
}
function pathsOverlap(left: string, right: string): boolean {
return left === right || isStrictDescendant(left, right) || isStrictDescendant(right, left);
}
function isStrictDescendant(parent: string, child: string): boolean {
const candidate = relative(parent, child);
return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(candidate);
}
function isPostgresUrl(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "postgresql:" || url.protocol === "postgres:") && url.hostname !== "";
} catch {
return false;
}
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
} catch {
return false;
}
}
function isHttpsUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "https:" && url.hostname !== "" && url.username === "" && url.password === "";
} catch {
return false;
}
}
@@ -0,0 +1,31 @@
#!/usr/bin/env node
import "dotenv/config";
import "../hub.js";
import { prisma } from "../db.js";
async function main(): Promise<void> {
let queryFailure: unknown;
try {
await prisma.$queryRawUnsafe("SELECT 1");
} catch (error) {
queryFailure = error;
}
let disconnectFailure: unknown;
try {
await prisma.$disconnect();
} catch (error) {
disconnectFailure = error;
}
const failures = [queryFailure, disconnectFailure].filter((failure) => failure !== undefined);
if (failures.length > 0) {
throw new AggregateError(failures, "Hub dependency load or authenticated database query failed");
}
console.log("[service-runtime-probe] Hub dependencies and PostgreSQL query succeeded");
}
main().catch((error: unknown) => {
console.error("[service-runtime-probe] failed:", error);
process.exitCode = 1;
});
+79
View File
@@ -0,0 +1,79 @@
import Fastify from "fastify";
import { registerAdminPlugin } from "./admin/plugin.js";
import { prisma } from "./db.js";
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { makeTriggerHandler } from "./feishu/trigger.js";
import { triggerQueue } from "./feishu/triggerQueue.js";
import { createEnvRuntimeSettings } from "./settings/runtime.js";
import { readServerBinding } from "./settings/server.js";
function requireEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`missing required env: ${name}`);
}
return value;
}
function booleanEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (raw === undefined || raw === "") return fallback;
return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
}
/** Start the Hub after every import and runtime dependency has loaded. */
export async function startHub(): Promise<void> {
requireEnv("DATABASE_URL");
const runtimeSettings = createEnvRuntimeSettings();
await runtimeSettings.provider("openrouter");
const feishuAppId = requireEnv("FEISHU_APP_ID");
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const bind = readServerBinding();
const app = Fastify({ logger: true });
// Startup reset: clear stale locks + mark dead runs as FAILED.
await prisma.projectAgentLock.deleteMany({});
await prisma.agentRun.updateMany({
where: { status: "ACTIVE" },
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
});
app.log.info("startup: cleared stale locks + dead runs");
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
await registerAdminPlugin(app, {
prisma,
sessionSecret,
publicBaseUrl,
feishuAppId,
feishuAppSecret,
projectWorkspaceRoot,
});
const address = await app.listen(bind);
app.log.info({ address }, "hub listening");
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
if (feishuListenerEnabled) {
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
const larkClient = createLarkClient(feishuConfig);
const triggerQueuePurgeTimer = setInterval(() => {
const removed = triggerQueue.purgeExpired();
if (removed > 0) {
app.log.info({ removed }, "feishu trigger queue: purged expired triggers");
}
}, 60_000);
triggerQueuePurgeTimer.unref();
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
} else {
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
}
}
+4 -88
View File
@@ -14,93 +14,9 @@
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL
*/
import "dotenv/config";
import Fastify from "fastify";
import { registerAdminPlugin } from "./admin/plugin.js";
import { prisma } from "./db.js";
import { createEnvRuntimeSettings } from "./settings/runtime.js";
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { makeTriggerHandler } from "./feishu/trigger.js";
import { triggerQueue } from "./feishu/triggerQueue.js";
import { startHub } from "./hub.js";
function requireEnv(name: string): string {
const v = process.env[name];
if (v === undefined || v === "") {
console.error(`[hub] missing required env: ${name}`);
process.exit(1);
}
return v;
}
function booleanEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (raw === undefined || raw === "") return fallback;
return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
}
async function main(): Promise<void> {
const databaseUrl = requireEnv("DATABASE_URL");
void databaseUrl;
const runtimeSettings = createEnvRuntimeSettings();
await runtimeSettings.provider("openrouter");
const feishuAppId = requireEnv("FEISHU_APP_ID");
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const port = Number(process.env["PORT"] ?? "8788");
const app = Fastify({ logger: true });
// Startup reset: clear stale locks + mark dead runs as FAILED.
await prisma.projectAgentLock.deleteMany({});
await prisma.agentRun.updateMany({
where: { status: "ACTIVE" },
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
});
app.log.info("startup: cleared stale locks + dead runs");
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
await registerAdminPlugin(app, {
prisma,
sessionSecret,
publicBaseUrl,
feishuAppId,
feishuAppSecret,
projectWorkspaceRoot,
});
// --- Feishu listener ---
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
if (feishuListenerEnabled) {
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
const larkClient = createLarkClient(feishuConfig);
const triggerQueuePurgeTimer = setInterval(() => {
const removed = triggerQueue.purgeExpired();
if (removed > 0) {
app.log.info({ removed }, "feishu trigger queue: purged expired triggers");
}
}, 60_000);
triggerQueuePurgeTimer.unref();
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
} else {
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
}
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
if (err !== null) {
app.log.error({ err }, "listen failed");
process.exit(1);
}
app.log.info({ address }, "hub listening");
});
}
main().catch((e) => {
console.error("[hub] fatal:", e);
process.exit(1);
startHub().catch((error: unknown) => {
console.error("[hub] fatal:", error);
process.exitCode = 1;
});
+26
View File
@@ -0,0 +1,26 @@
import { isIP } from "node:net";
export type ServerEnv = Readonly<Record<string, string | undefined>>;
export interface ServerBinding {
readonly host: string;
readonly port: number;
}
const HOSTNAME = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*\.?$/;
/** Read and validate the address passed directly to Fastify/Node listen(). */
export function readServerBinding(env: ServerEnv = process.env): ServerBinding {
const host = env["HOST"]?.trim() || "127.0.0.1";
if (isIP(host) === 0 && !HOSTNAME.test(host)) {
throw new Error("HOST must be a hostname or IP address");
}
const rawPort = env["PORT"]?.trim() || "8788";
const port = Number(rawPort);
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error("PORT must be an integer between 1 and 65535");
}
return { host, port };
}