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
+14 -6
View File
@@ -1,5 +1,8 @@
# Hub runtime configuration. Copy to .env and fill in.
# Production preflight requires the explicit production runtime mode.
NODE_ENV="production"
# PostgreSQL connection string.
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
@@ -21,18 +24,23 @@ ANTHROPIC_API_KEY=""
# Agent run policy (optional). Defaults to 25.
# HUB_AGENT_MAX_TURNS=25
# System-managed root for project workspaces created from org admin / Feishu onboarding.
HUB_PROJECT_WORKSPACE_ROOT="./data/project-workspaces"
# Persistent system-managed root for project workspaces. Production must use an
# absolute path outside the deployment/release tree; install_service.sh defaults
# to this path and rejects any overlap before installing the unit.
HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# Feishu (lark) bot credentials.
FEISHU_APP_ID=""
FEISHU_APP_SECRET=""
FEISHU_BOT_OPEN_ID=""
# Path to the `cph` binary (ADR-0016). Defaults to `cph` on PATH.
# CPH_BIN="/usr/local/bin/cph"
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires
# the file to be executable and `cph --version` to succeed.
CPH_BIN="/usr/local/bin/cph"
# Hub server port. Defaults to 8788.
# Hub bind address and port. Production defaults to loopback for a local TLS
# reverse proxy; both values are validated and honored by the HTTP server.
HOST="127.0.0.1"
PORT=8788
# --- Org admin web (ADR-0021) ---------------------------------------------
@@ -40,7 +48,7 @@ PORT=8788
# redirect_uri = ${HUB_PUBLIC_BASE_URL}/auth/feishu/callback
# Configure the same callback URL in the Feishu developer console under
# Security Settings → Redirect URLs.
HUB_PUBLIC_BASE_URL="http://127.0.0.1:8788"
HUB_PUBLIC_BASE_URL="https://hub.example.com"
# HMAC secret for signed session + OAuth state cookies. Generate with:
# openssl rand -base64 32
+57
View File
@@ -0,0 +1,57 @@
# Hub service installation
The initial production target is a supported Linux host with systemd,
PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap
and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not
create or run the unit as root.
Build or deploy the Hub under `/srv/curriculum-project-hub`, then run:
```sh
sudo BASE=/srv/curriculum-project-hub \
HUB_DIR=/srv/curriculum-project-hub/hub \
bash /srv/curriculum-project-hub/hub/deploy/install_service.sh
```
On a clean host the first invocation creates
`/srv/curriculum-project-hub/.secrets/platform.env` with every supported
production key and exits with status 78. Fill every required blank, set
`CPH_BIN` to an executable compatible `cph`, keep the file mode at `0600`, and
run the same command again. No systemd unit is installed until configuration,
paths, Node, bubblewrap, `cph --version`, PostgreSQL connectivity and directory
ownership all pass preflight.
An existing service account is accepted only when its primary group, home and
non-login shell match the requested configuration and it has no supplementary
groups. After provisioning, the installer executes path-access, built Hub,
Prisma, an authenticated database query, `cph` and bubblewrap namespace probes
as that account with `no_new_privs` set. Inaccessible parents, UID-specific
database/network policy, or a host that forbids unprivileged user namespaces
therefore fail before unit installation.
The default persistent layout is deliberately outside every source/release
tree:
```text
/var/lib/cph-hub/home
/var/lib/cph-hub/state
/var/lib/cph-hub/workspaces
/var/cache/cph-hub
```
Custom paths are supported with `SERVICE_HOME`, `STATE_DIR`, `CACHE_DIR` and
`WORKSPACE_ROOT`, but all must be absolute. The installer rejects any persistent
path that is equal to, above, or below `BASE`; this keeps rsync, release
switching, rollback and pruning unable to traverse customer workspaces. Set the
same custom workspace in `HUB_PROJECT_WORKSPACE_ROOT` inside `platform.env`.
After a successful installation:
```sh
sudo systemctl start cph-hub.service
sudo systemctl status cph-hub.service
```
`HOST` and `PORT` in `platform.env` are passed to the real HTTP listener. The
default `127.0.0.1:8788` expects a local TLS reverse proxy; the public OAuth URL
must be an HTTPS `HUB_PUBLIC_BASE_URL`.
+10 -3
View File
@@ -2,12 +2,22 @@
Description=Curriculum Project Hub (Feishu agent + cph)
After=network-online.target postgresql.service
Wants=network-online.target
# ADR-0018: refuse to start when the configured OS sandbox binary disappears
# after installation. Deployment preflight validates the executable itself;
# this start-time assertion keeps later package/host changes fail-closed.
AssertPathExists=__BWRAP_BIN__
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_GROUP__
WorkingDirectory=__HUB_DIR__
EnvironmentFile=__ENV_FILE__
Environment=HOME=__SERVICE_HOME__
Environment=XDG_STATE_HOME=__STATE_DIR__
Environment=XDG_CACHE_HOME=__CACHE_DIR__
Environment=PATH=__RUNTIME_PATH__
UMask=0027
# Run prisma migrations before each start, then launch the Hub.
ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate deploy --schema __HUB_DIR__/prisma/schema.prisma
ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js
@@ -16,9 +26,6 @@ RestartSec=5
# Graceful shutdown: lark ws close + in-flight runs.
KillSignal=SIGTERM
TimeoutStopSec=30
# ADR-0018: the agent sandbox (bubblewrap) must be available on the host —
# failIfUnavailable makes the Hub refuse to run unsandboxed. Install bwrap.
AssertPathExists=/usr/bin/bwrap
# Hardening: no new privileges. ProtectSystem/ProtectHome are NOT set here —
# they would break cph's render-cache extraction (~/.cache/cph/) and the
# bwrap user-namespace setup. The OS-level sandbox (ADR-0018) is the hard
+4 -2
View File
@@ -8,8 +8,10 @@
# PLATFORM_DEPLOY_PORT optional, defaults to 22
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, bubblewrap
# (`bwrap`, for the ADR-0018 agent sandbox), and a writable $PLATFORM_DEPLOY_BASE.
# The target host must have Node.js 20+, npm, rsync, PostgreSQL client tools
# (`pg_isready`), systemd, bubblewrap (`bwrap`, for the ADR-0018 agent sandbox),
# and a compatible `cph` binary named by platform.env. The service installer
# provisions the dedicated non-root identity and persistent directories.
# Use a dedicated `deploy` user that has passwordless sudo for systemctl and
# writing /etc/systemd/system/cph-hub.service through deploy/install_service.sh.
set -euo pipefail
+215 -33
View File
@@ -1,76 +1,258 @@
#!/usr/bin/env bash
# Install the cph-hub systemd service on a host. Idempotent.
#
# Prerequisites already on the host: Node.js, npm, PostgreSQL, systemd.
# The Hub repo is expected at HUB_DIR (default /srv/curriculum-project-hub/hub)
# with `npm ci` + `npm run build` already run by deploy_platform.sh.
# Provision and install the cph-hub systemd service on a supported Linux host.
# The script is idempotent: existing identities/directories are validated and
# never silently rewritten. An absent environment file is seeded completely,
# then the script exits EX_CONFIG so no unit is installed with placeholders.
#
# Usage:
# sudo BASE=/srv/curriculum-project-hub bash deploy/install_service.sh
# sudo BASE=/srv/curriculum-project-hub \
# HUB_DIR=/srv/curriculum-project-hub/hub \
# bash deploy/install_service.sh
set -euo pipefail
BASE="${BASE:-/srv/curriculum-project-hub}"
HUB_DIR="${HUB_DIR:-$BASE/hub}"
SERVICE_USER="${SERVICE_USER:-cph-hub}"
SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/workspaces}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8788}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}"
PG_ISREADY_BIN="${PG_ISREADY_BIN:-$(command -v pg_isready || true)}"
RUNUSER_BIN="${RUNUSER_BIN:-$(command -v runuser || true)}"
SETPRIV_BIN="${SETPRIV_BIN:-$(command -v setpriv || true)}"
CPH_BIN_DEFAULT="${CPH_BIN_DEFAULT:-/usr/local/bin/cph}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PREFLIGHT_JS="$HUB_DIR/dist/deployment/preflight-cli.js"
RUNTIME_PATH="$(dirname "$NODE_BIN"):$(dirname "$BWRAP_BIN"):$(dirname "$SETPRIV_BIN"):/usr/local/bin:/usr/bin:/bin"
if [ -z "$NODE_BIN" ]; then
echo "node must be on PATH, or set NODE_BIN." >&2
fail() {
echo "[install] error: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "required command is missing: $1"
}
require_safe_unit_value() {
local label="$1"
local value="$2"
if [[ ! "$value" =~ ^[A-Za-z0-9_./:@+-]+$ ]]; then
fail "$label contains characters that cannot be rendered safely into the systemd unit: $value"
fi
}
if [ "$(id -u)" -ne 0 ]; then
fail "must run as root (use sudo)"
fi
if [ ! -d "$HUB_DIR" ]; then
echo "HUB_DIR not found: $HUB_DIR (set HUB_DIR or clone the repo there)." >&2
exit 1
if [[ ! "$SERVICE_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
fail "invalid SERVICE_USER: $SERVICE_USER"
fi
if [ ! -f "$HUB_DIR/dist/server.js" ]; then
echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2
exit 1
if [[ ! "$SERVICE_GROUP" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
fail "invalid SERVICE_GROUP: $SERVICE_GROUP"
fi
if [ ! -f "$SCRIPT_DIR/cph-hub.service" ]; then
echo "service template missing: $SCRIPT_DIR/cph-hub.service" >&2
exit 1
if [ "$SERVICE_USER" = "root" ] || [ "$SERVICE_GROUP" = "root" ]; then
fail "the Hub service identity must not be root"
fi
# Seed the env file if absent. Secrets stay on the server, never in the repo.
if [ ! -f "$ENV_FILE" ]; then
install -d -m 0700 "$(dirname "$ENV_FILE")"
for command in getent groupadd useradd install stat systemctl id; do
require_command "$command"
done
for pair in \
"NODE_BIN:$NODE_BIN" \
"BWRAP_BIN:$BWRAP_BIN" \
"PG_ISREADY_BIN:$PG_ISREADY_BIN"; do
label="${pair%%:*}"
value="${pair#*:}"
[ -n "$value" ] || fail "$label is required and its executable was not found on PATH"
[ -x "$value" ] || fail "$label is not executable: $value"
done
[ -n "$RUNUSER_BIN" ] || fail "RUNUSER_BIN is required and runuser was not found on PATH"
[ -x "$RUNUSER_BIN" ] || fail "RUNUSER_BIN is not executable: $RUNUSER_BIN"
[ -n "$SETPRIV_BIN" ] || fail "SETPRIV_BIN is required and setpriv was not found on PATH"
[ -x "$SETPRIV_BIN" ] || fail "SETPRIV_BIN is not executable: $SETPRIV_BIN"
[ -d "$HUB_DIR" ] || fail "HUB_DIR not found: $HUB_DIR"
[ -f "$HUB_DIR/dist/server.js" ] || fail "Hub build missing: $HUB_DIR/dist/server.js"
[ -f "$PREFLIGHT_JS" ] || fail "deployment preflight missing from Hub build: $PREFLIGHT_JS"
[ -f "$HUB_DIR/dist/deployment/service-runtime-probe.js" ] || fail "service runtime probe missing from Hub build"
[ -f "$HUB_DIR/node_modules/prisma/build/index.js" ] || fail "production dependencies missing: run npm ci in $HUB_DIR"
[ -f "$HUB_DIR/prisma/schema.prisma" ] || fail "Prisma schema missing: $HUB_DIR/prisma/schema.prisma"
[ -f "$SCRIPT_DIR/cph-hub.service" ] || fail "service template missing: $SCRIPT_DIR/cph-hub.service"
for pair in \
"BASE:$BASE" \
"HUB_DIR:$HUB_DIR" \
"SERVICE_HOME:$SERVICE_HOME" \
"STATE_DIR:$STATE_DIR" \
"CACHE_DIR:$CACHE_DIR" \
"WORKSPACE_ROOT:$WORKSPACE_ROOT" \
"ENV_FILE:$ENV_FILE" \
"SYSTEMD_UNIT_DIR:$SYSTEMD_UNIT_DIR" \
"NODE_BIN:$NODE_BIN" \
"BWRAP_BIN:$BWRAP_BIN" \
"RUNUSER_BIN:$RUNUSER_BIN" \
"SETPRIV_BIN:$SETPRIV_BIN" \
"RUNTIME_PATH:$RUNTIME_PATH"; do
require_safe_unit_value "${pair%%:*}" "${pair#*:}"
done
if [ -L "$ENV_FILE" ]; then
fail "environment file must not be a symlink: $ENV_FILE"
fi
if [ ! -e "$ENV_FILE" ]; then
install -d -o root -g root -m 0700 "$(dirname "$ENV_FILE")"
umask 077
cat >"$ENV_FILE" <<EOF
# cph-hub production configuration. Required blanks must be filled before
# rerunning install_service.sh; failed preflight never installs the unit.
NODE_ENV=production
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
# Claude Code SDK auth via OpenRouter's Anthropic Skin (ADR-0017).
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key; ANTHROPIC_API_KEY must be empty.
DATABASE_URL=
ANTHROPIC_BASE_URL=https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN=
ANTHROPIC_API_KEY=
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_BOT_OPEN_ID=
PORT=$PORT
CPH_BIN=$CPH_BIN_DEFAULT
HOST=$HOST
PORT=$PORT
HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT
HUB_PUBLIC_BASE_URL=
HUB_SESSION_SECRET=
HUB_AGENT_MAX_TURNS=25
HUB_FEISHU_LISTENER_ENABLED=true
CPH_SANDBOX_EXTRA_DENY_READ=$ENV_FILE
EOF
chmod 0600 "$ENV_FILE"
echo "Seeded $ENV_FILE — edit it to fill in ANTHROPIC_AUTH_TOKEN and Feishu creds, then re-run."
exit 0
echo "[install] seeded complete production configuration: $ENV_FILE" >&2
echo "[install] fill every required blank, install cph at CPH_BIN, then rerun" >&2
exit 78
fi
[ -f "$ENV_FILE" ] || fail "environment file is not a regular file: $ENV_FILE"
# Render the unit with concrete paths.
UNIT=/etc/systemd/system/cph-hub.service
PREFLIGHT_ARGS=(
"$NODE_BIN" "$PREFLIGHT_JS"
--env-file "$ENV_FILE"
--node-bin "$NODE_BIN"
--runuser-bin "$RUNUSER_BIN"
--setpriv-bin "$SETPRIV_BIN"
--service-user "$SERVICE_USER"
--base-dir "$BASE"
--hub-dir "$HUB_DIR"
--service-home "$SERVICE_HOME"
--state-dir "$STATE_DIR"
--cache-dir "$CACHE_DIR"
--workspace-root "$WORKSPACE_ROOT"
--bwrap-bin "$BWRAP_BIN"
--pg-isready-bin "$PG_ISREADY_BIN"
)
# Required configuration, path isolation, binaries and PostgreSQL must pass
# before identity/directory provisioning or unit installation begins.
"${PREFLIGHT_ARGS[@]}"
NOLOGIN_SHELL=""
for candidate in /usr/sbin/nologin /sbin/nologin /usr/bin/false /bin/false; do
if [ -x "$candidate" ]; then
NOLOGIN_SHELL="$candidate"
break
fi
done
[ -n "$NOLOGIN_SHELL" ] || fail "no non-login shell found"
group_entry="$(getent group "$SERVICE_GROUP" || true)"
if [ -z "$group_entry" ]; then
groupadd --system "$SERVICE_GROUP"
group_entry="$(getent group "$SERVICE_GROUP" || true)"
[ -n "$group_entry" ] || fail "created service group cannot be resolved: $SERVICE_GROUP"
fi
IFS=: read -r resolved_group _ SERVICE_GID _ <<<"$group_entry"
[ "$resolved_group" = "$SERVICE_GROUP" ] || fail "service group resolved to unexpected name: $resolved_group"
[[ "$SERVICE_GID" =~ ^[0-9]+$ ]] || fail "service group has invalid gid: $SERVICE_GID"
[ "$SERVICE_GID" -ne 0 ] || fail "service group must not have gid 0"
passwd_entry="$(getent passwd "$SERVICE_USER" || true)"
if [ -z "$passwd_entry" ]; then
useradd \
--system \
--gid "$SERVICE_GROUP" \
--home-dir "$SERVICE_HOME" \
--shell "$NOLOGIN_SHELL" \
--no-create-home \
"$SERVICE_USER"
passwd_entry="$(getent passwd "$SERVICE_USER" || true)"
[ -n "$passwd_entry" ] || fail "created service user cannot be resolved: $SERVICE_USER"
fi
IFS=: read -r resolved_user _ SERVICE_UID user_gid _ user_home user_shell <<<"$passwd_entry"
[ "$resolved_user" = "$SERVICE_USER" ] || fail "service user resolved to unexpected name: $resolved_user"
[[ "$SERVICE_UID" =~ ^[0-9]+$ ]] || fail "service user has invalid uid: $SERVICE_UID"
[ "$SERVICE_UID" -ne 0 ] || fail "service user must not have uid 0"
[ "$user_gid" = "$SERVICE_GID" ] || fail "service user primary gid $user_gid does not match $SERVICE_GROUP ($SERVICE_GID)"
[ "$user_home" = "$SERVICE_HOME" ] || fail "service user home is $user_home; expected $SERVICE_HOME"
case "$user_shell" in
/usr/sbin/nologin | /sbin/nologin | /usr/bin/false | /bin/false) ;;
*) fail "service user must have a non-login shell; found $user_shell" ;;
esac
service_gids="$(id -G "$SERVICE_USER")" || fail "cannot resolve groups for service user $SERVICE_USER"
for service_gid in $service_gids; do
if [ "$service_gid" != "$SERVICE_GID" ]; then
fail "service user $SERVICE_USER must not have supplementary groups; found gid $service_gid in: $service_gids"
fi
done
provision_directory() {
local path="$1"
if [ -L "$path" ]; then
fail "service directory must not be a symlink: $path"
fi
if [ -e "$path" ]; then
[ -d "$path" ] || fail "service path exists but is not a directory: $path"
actual="$(stat -c '%u:%g:%a' -- "$path")"
expected="$SERVICE_UID:$SERVICE_GID:750"
[ "$actual" = "$expected" ] || fail "service directory $path has $actual; expected $expected"
return
fi
install -d -o "$SERVICE_UID" -g "$SERVICE_GID" -m 0750 "$path"
}
provision_directory "$SERVICE_HOME"
provision_directory "$STATE_DIR"
provision_directory "$CACHE_DIR"
provision_directory "$WORKSPACE_ROOT"
# Resolve every provisioned path again and verify uid/gid/mode before writing
# the unit. This catches symlink or ownership changes between the two phases.
"${PREFLIGHT_ARGS[@]}" --service-uid "$SERVICE_UID" --service-gid "$SERVICE_GID"
UNIT="$SYSTEMD_UNIT_DIR/cph-hub.service"
install -d -o root -g root -m 0755 "$SYSTEMD_UNIT_DIR"
TMP_UNIT="$(mktemp)"
trap 'rm -f "$TMP_UNIT"' EXIT
sed \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__SERVICE_GROUP__|$SERVICE_GROUP|g" \
-e "s|__SERVICE_HOME__|$SERVICE_HOME|g" \
-e "s|__STATE_DIR__|$STATE_DIR|g" \
-e "s|__CACHE_DIR__|$CACHE_DIR|g" \
-e "s|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__BASE_DIR__|$BASE|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \
-e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \
-e "s|__RUNTIME_PATH__|$RUNTIME_PATH|g" \
"$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
install -m 0644 "$TMP_UNIT" "$UNIT"
rm -f "$TMP_UNIT"
install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
systemctl daemon-reload
systemctl enable cph-hub.service
echo "Installed cph-hub.service. Start with: systemctl start cph-hub"
echo "Check health: curl http://127.0.0.1:$PORT/api/healthz"
echo "[install] installed cph-hub.service for $SERVICE_USER:$SERVICE_GROUP"
echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR workspaces=$WORKSPACE_ROOT"
echo "[install] start with: systemctl start cph-hub.service"
+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 };
}
@@ -0,0 +1,249 @@
import { execFile } from "node:child_process";
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const execFileAsync = promisify(execFile);
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
describe("deployment preflight CLI", () => {
let root: string;
let baseDir: string;
let hubDir: string;
let persistentDir: string;
let marker: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "cph-deployment-preflight-"));
baseDir = join(root, "deploy");
hubDir = join(baseDir, "current", "hub");
persistentDir = join(root, "persistent");
marker = join(root, "external-check-ran");
await Promise.all([
mkdir(hubDir, { recursive: true }),
mkdir(join(persistentDir, "home"), { recursive: true }),
mkdir(join(persistentDir, "state"), { recursive: true }),
mkdir(join(persistentDir, "cache"), { recursive: true }),
mkdir(join(persistentDir, "workspaces"), { recursive: true }),
]);
await Promise.all([
chmod(join(persistentDir, "home"), 0o750),
chmod(join(persistentDir, "state"), 0o750),
chmod(join(persistentDir, "cache"), 0o750),
chmod(join(persistentDir, "workspaces"), 0o750),
]);
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it("exits before prerequisite checks when a persistent path overlaps the deployment tree", async () => {
const workspaceRoot = join(hubDir, "data", "workspaces");
await mkdir(workspaceRoot, { recursive: true });
const fixture = await createFixture({ workspaceRoot, marker });
const result = await runCli(fixture.args);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("workspace root must not overlap deployment root");
await expect(readFile(marker, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
});
it("returns success only after configuration and external prerequisites pass", async () => {
const workspaceRoot = join(persistentDir, "workspaces");
const fixture = await createFixture({ workspaceRoot, marker });
const result = await runCli(fixture.args);
expect(result).toMatchObject({ exitCode: 0, stderr: "" });
expect(result.stdout).toContain("[preflight] configuration and prerequisites valid");
expect(await readFile(marker, "utf8")).toContain("cph\npg_isready\n");
});
it("preserves the failed prerequisite exit code and stderr", async () => {
const workspaceRoot = join(persistentDir, "workspaces");
const fixture = await createFixture({
workspaceRoot,
marker,
pgIsReadyScript: "#!/bin/sh\nprintf 'socket permission denied\\n' >&2\nexit 23\n",
});
const result = await runCli(fixture.args);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("PostgreSQL is not accepting connections for DATABASE_URL");
expect(result.stderr).toContain("exit=23");
expect(result.stderr).toContain("socket permission denied");
});
it("rejects a database URL that accepts TCP connections but cannot execute a query", async () => {
const workspaceRoot = join(persistentDir, "workspaces");
const fixture = await createFixture({
workspaceRoot,
marker,
databaseUrl: "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_preflight_missing",
});
const result = await runCli(fixture.args);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("PostgreSQL authenticated query failed for DATABASE_URL");
});
it("loads the actual Hub dependency graph and queries PostgreSQL in the runtime probe", async () => {
const result = await execFileAsync(
resolve("node_modules/.bin/tsx"),
[resolve("src/deployment/service-runtime-probe.ts")],
{
env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL, NODE_ENV: "production" },
},
);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("Hub dependencies and PostgreSQL query succeeded");
});
it("probes the built runtime and bubblewrap as the service identity", async () => {
const configuredPersistentParent = join(root, "persistent-link");
await symlink(persistentDir, configuredPersistentParent);
const workspaceRoot = join(configuredPersistentParent, "workspaces");
const fixture = await createFixture({ workspaceRoot, marker });
const uid = process.getuid?.();
const gid = process.getgid?.();
if (uid === undefined || gid === undefined) throw new Error("service identity probe requires POSIX uid/gid");
const result = await runCli([
...fixture.args,
"--service-uid",
String(uid),
"--service-gid",
String(gid),
]);
expect(result).toMatchObject({ exitCode: 0, stderr: "" });
const probeLog = await readFile(marker, "utf8");
expect(probeLog).toContain(workspaceRoot);
expect(probeLog).toContain("service-setpriv\n");
expect(probeLog).toContain("service-bwrap\n");
expect(probeLog).toContain("service-prisma\n");
expect(probeLog).toContain("service-database\n");
});
async function createFixture(options: {
workspaceRoot: string;
marker: string;
pgIsReadyScript?: string;
databaseUrl?: string;
}): Promise<{ args: string[] }> {
const binDir = join(root, "bin");
const cphBin = join(binDir, "cph");
const bwrapBin = join(binDir, "bwrap");
const pgIsReadyBin = join(binDir, "pg_isready");
const runuserBin = join(binDir, "runuser");
const setprivBin = join(binDir, "setpriv");
const envFile = join(root, "platform.env");
await Promise.all([
mkdir(binDir, { recursive: true }),
mkdir(join(hubDir, "dist"), { recursive: true }),
mkdir(join(hubDir, "dist", "deployment"), { recursive: true }),
mkdir(join(hubDir, "node_modules", "prisma", "build"), { recursive: true }),
mkdir(join(hubDir, "prisma"), { recursive: true }),
]);
await Promise.all([
executable(cphBin, `#!/bin/sh\nprintf 'cph\\n' >> '${options.marker}'\nprintf 'cph 0.0.2\\n'\n`),
executable(bwrapBin, `#!/bin/sh\nprintf 'service-bwrap\\n' >> '${options.marker}'\nexit 0\n`),
executable(
runuserBin,
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${options.marker}'\n[ "$1" = "-u" ] || exit 91\nshift 2\n[ "$1" = "--" ] || exit 92\nshift\nexec "$@"\n`,
),
executable(
setprivBin,
`#!/bin/sh\n[ "$1" = "--no-new-privs" ] || exit 93\nshift\nprintf 'service-setpriv\\n' >> '${options.marker}'\nexec "$@"\n`,
),
executable(
pgIsReadyBin,
options.pgIsReadyScript ?? `#!/bin/sh\nprintf 'pg_isready\\n' >> '${options.marker}'\nexit 0\n`,
),
writeFile(join(hubDir, "dist", "server.js"), "export {};\n"),
writeFile(
join(hubDir, "dist", "deployment", "service-runtime-probe.js"),
`const { appendFileSync } = require("node:fs");\nappendFileSync(${JSON.stringify(options.marker)}, "service-database\\n");\n`,
),
writeFile(
join(hubDir, "node_modules", "prisma", "build", "index.js"),
`const { appendFileSync } = require("node:fs");\nappendFileSync(${JSON.stringify(options.marker)}, "service-prisma\\n");\n`,
),
writeFile(join(hubDir, "prisma", "schema.prisma"), "// fixture\n"),
]);
await writeFile(
envFile,
[
"NODE_ENV=production",
`DATABASE_URL=${options.databaseUrl ?? TEST_DATABASE_URL}`,
"ANTHROPIC_BASE_URL=https://openrouter.ai/api",
"ANTHROPIC_AUTH_TOKEN=provider-token",
"ANTHROPIC_API_KEY=",
"FEISHU_APP_ID=cli_app_id",
"FEISHU_APP_SECRET=feishu-app-secret",
"FEISHU_BOT_OPEN_ID=ou_bot",
`CPH_BIN=${cphBin}`,
"HOST=127.0.0.1",
"PORT=8788",
`HUB_PROJECT_WORKSPACE_ROOT=${options.workspaceRoot}`,
"HUB_PUBLIC_BASE_URL=https://hub.example.com",
"HUB_SESSION_SECRET=a-production-session-secret-with-32-bytes",
"",
].join("\n"),
{ mode: 0o600 },
);
return {
args: [
"--env-file",
envFile,
"--node-bin",
process.execPath,
"--runuser-bin",
runuserBin,
"--setpriv-bin",
setprivBin,
"--service-user",
process.env["USER"] ?? "test-user",
"--base-dir",
baseDir,
"--hub-dir",
hubDir,
"--service-home",
join(persistentDir, "home"),
"--state-dir",
join(persistentDir, "state"),
"--cache-dir",
join(persistentDir, "cache"),
"--workspace-root",
options.workspaceRoot,
"--bwrap-bin",
bwrapBin,
"--pg-isready-bin",
pgIsReadyBin,
],
};
}
});
async function executable(path: string, contents: string): Promise<void> {
await writeFile(path, contents);
await chmod(path, 0o755);
}
async function runCli(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
try {
const result = await execFileAsync(resolve("node_modules/.bin/tsx"), [resolve("src/deployment/preflight-cli.ts"), ...args]);
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
} catch (error) {
const failure = error as { code?: number; stdout?: string; stderr?: string };
return { exitCode: failure.code ?? 1, stdout: failure.stdout ?? "", stderr: failure.stderr ?? "" };
}
}
+61
View File
@@ -0,0 +1,61 @@
import { createServer } from "node:net";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { startHub } from "../../src/hub.js";
import { TEST_DATABASE_URL } from "./helpers.js";
const ENV_KEYS = [
"DATABASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_BOT_OPEN_ID",
"HUB_PROJECT_WORKSPACE_ROOT",
"HUB_SESSION_SECRET",
"HUB_PUBLIC_BASE_URL",
"HUB_FEISHU_LISTENER_ENABLED",
"HOST",
"PORT",
] as const;
describe("Hub startup", () => {
const previousEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]]));
afterEach(() => {
for (const [key, value] of previousEnv) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it("rejects startup before the Feishu listener when the HTTP bind fails", async () => {
const blocker = createServer();
blocker.listen(0, "127.0.0.1");
await once(blocker, "listening");
const address = blocker.address();
if (address === null || typeof address === "string") throw new Error("expected an INET test listener");
Object.assign(process.env, {
DATABASE_URL: TEST_DATABASE_URL,
ANTHROPIC_AUTH_TOKEN: "test-provider-token",
ANTHROPIC_API_KEY: "",
FEISHU_APP_ID: "test-app",
FEISHU_APP_SECRET: "test-secret",
FEISHU_BOT_OPEN_ID: "ou_test_bot",
HUB_PROJECT_WORKSPACE_ROOT: "/tmp/cph-hub-server-start-test",
HUB_SESSION_SECRET: "integration-session-secret-with-32-bytes",
HUB_PUBLIC_BASE_URL: "https://hub.example.test",
HUB_FEISHU_LISTENER_ENABLED: "false",
HOST: "127.0.0.1",
PORT: String(address.port),
});
try {
await expect(startHub()).rejects.toMatchObject({ code: "EADDRINUSE" });
} finally {
blocker.close();
await once(blocker, "close");
}
});
});
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
DeploymentPreflightError,
validateDeploymentPreflight,
type DeploymentPreflightInput,
} from "../../src/deployment/preflight.js";
const VALID_ENV = {
NODE_ENV: "production",
DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub",
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "provider-token",
ANTHROPIC_API_KEY: "",
FEISHU_APP_ID: "cli_app_id",
FEISHU_APP_SECRET: "feishu-app-secret",
FEISHU_BOT_OPEN_ID: "ou_bot",
CPH_BIN: "/srv/curriculum-project-hub/current/bin/cph",
HOST: "127.0.0.1",
PORT: "8788",
HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/workspaces",
HUB_PUBLIC_BASE_URL: "https://hub.example.com",
HUB_SESSION_SECRET: "a-production-session-secret-with-32-bytes",
} as const;
function input(overrides: Partial<DeploymentPreflightInput> = {}): DeploymentPreflightInput {
return {
baseDir: "/srv/curriculum-project-hub",
hubDir: "/srv/curriculum-project-hub/current/hub",
serviceHome: "/var/lib/cph-hub/home",
stateDir: "/var/lib/cph-hub/state",
cacheDir: "/var/cache/cph-hub",
workspaceRoot: "/var/lib/cph-hub/workspaces",
env: VALID_ENV,
...overrides,
};
}
describe("validateDeploymentPreflight", () => {
it("accepts a complete production configuration with persistent storage outside the deployment tree", () => {
expect(validateDeploymentPreflight(input())).toEqual({
bind: { host: "127.0.0.1", port: 8788 },
databaseUrl: "postgresql://hub:secret@127.0.0.1:5432/hub",
cphBin: "/srv/curriculum-project-hub/current/bin/cph",
});
});
it.each([
["inside the live Hub", "/srv/curriculum-project-hub/current/hub/data/workspaces"],
["inside future releases", "/srv/curriculum-project-hub/releases/workspaces"],
["an ancestor of the deployment root", "/srv"],
])("rejects a workspace %s", (_description, workspaceRoot) => {
expect(() =>
validateDeploymentPreflight(
input({
workspaceRoot,
env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot },
}),
),
).toThrow("must not overlap deployment root");
});
it("rejects a runtime workspace that differs from the provisioned path", () => {
expect(() =>
validateDeploymentPreflight(
input({ env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/other" } }),
),
).toThrow("HUB_PROJECT_WORKSPACE_ROOT must equal provisioned workspace root");
});
it("rejects a relative runtime workspace even if it resolves to the provisioned path", () => {
expect(() =>
validateDeploymentPreflight(
input({ env: { ...VALID_ENV, HUB_PROJECT_WORKSPACE_ROOT: "./workspaces" } }),
),
).toThrow("HUB_PROJECT_WORKSPACE_ROOT must be an absolute normalized path");
});
it("reports all invalid required production values in one failed preflight", () => {
expect.assertions(2);
try {
validateDeploymentPreflight(
input({
env: {
...VALID_ENV,
ANTHROPIC_AUTH_TOKEN: "",
FEISHU_APP_SECRET: "",
HUB_PUBLIC_BASE_URL: "http://hub.example.com",
HUB_SESSION_SECRET: "short",
},
}),
);
} catch (error) {
expect(error).toBeInstanceOf(DeploymentPreflightError);
expect((error as Error).message).toMatchInlineSnapshot(`
"deployment preflight failed:
- ANTHROPIC_AUTH_TOKEN is required
- FEISHU_APP_SECRET is required
- HUB_PUBLIC_BASE_URL must use https
- HUB_SESSION_SECRET must contain at least 32 characters"
`);
}
});
it("rejects relative operational paths", () => {
expect(() => validateDeploymentPreflight(input({ cacheDir: "./cache" }))).toThrow(
"cache directory must be an absolute normalized path",
);
});
it("rejects a non-empty ANTHROPIC_API_KEY that would conflict with auth-token mode", () => {
expect(() =>
validateDeploymentPreflight(
input({ env: { ...VALID_ENV, ANTHROPIC_API_KEY: "conflicting-api-key" } }),
),
).toThrow("ANTHROPIC_API_KEY must be present and empty");
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { readServerBinding } from "../../src/settings/server.js";
describe("readServerBinding", () => {
it("honors the configured bind host and port", () => {
expect(readServerBinding({ HOST: "127.0.0.2", PORT: "9100" })).toEqual({
host: "127.0.0.2",
port: 9100,
});
});
it.each(["0", "65536", "1.5", "not-a-port"])('rejects invalid PORT="%s"', (port) => {
expect(() => readServerBinding({ HOST: "127.0.0.1", PORT: port })).toThrow(
"PORT must be an integer between 1 and 65535",
);
});
it("rejects a bind URL in place of a host", () => {
expect(() => readServerBinding({ HOST: "https://127.0.0.1", PORT: "8788" })).toThrow(
"HOST must be a hostname or IP address",
);
});
});