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
+4
View File
@@ -106,6 +106,10 @@ jobs:
FEISHU_APP_ID=cli_smoke \ FEISHU_APP_ID=cli_smoke \
FEISHU_APP_SECRET=smoke_secret \ FEISHU_APP_SECRET=smoke_secret \
FEISHU_BOT_OPEN_ID=ou_smoke \ FEISHU_BOT_OPEN_ID=ou_smoke \
HOST=127.0.0.1 \
HUB_PROJECT_WORKSPACE_ROOT=/tmp/cph-hub-smoke-workspaces \
HUB_PUBLIC_BASE_URL=https://hub.example.test \
HUB_SESSION_SECRET=smoke-session-secret-with-at-least-32-bytes \
HUB_FEISHU_LISTENER_ENABLED=false \ HUB_FEISHU_LISTENER_ENABLED=false \
npm start & npm start &
pid=$! pid=$!
@@ -1,7 +1,7 @@
# Provision a runnable non-root Hub service # Provision a runnable non-root Hub service
Type: task Type: task
Status: open Status: resolved
## Question ## Question
@@ -11,3 +11,30 @@ configuration, honor the configured bind host, and fail preflight before unit
installation when any required value or prerequisite is invalid. The persistent installation when any required value or prerequisite is invalid. The persistent
workspace root must be outside all release trees, and preflight must reject any workspace root must be outside all release trees, and preflight must reject any
path that overlaps rsync, release switching, rollback, or release pruning. path that overlaps rsync, release switching, rollback, or release pruning.
## Answer
The installer now creates or strictly validates a dedicated non-root,
non-login service identity with no supplementary groups; provisions its
home/state/cache/workspace directories at mode `0750`; and refuses persistent
paths that overlap the deployment tree after both lexical and physical path
resolution. A missing production environment file is seeded completely and
exits with status 78; invalid configuration or prerequisites fail before the
unit is written or enabled.
Preflight validates the configured bind host/port, authenticated PostgreSQL
access, Node/cph/Prisma, configured and canonical path traversal, and a real
bubblewrap namespace. The post-provision phase runs the built Hub dependency
graph, a database query, cph and bubblewrap as the service user under
`no_new_privs`, matching the systemd security context. HTTP binding is awaited
before the Feishu listener starts, so bind failure rejects startup instead of
leaving a listener-only process alive.
Verification evidence:
- `npm run check`, `npm run build`, and `npm run prisma:validate`
- 166 unit tests and 86 integration tests passed; one external-model test skipped
- `bash -n deploy/install_service.sh` and `shellcheck deploy/install_service.sh`
- `prisma migrate deploy`: 9 migrations found, none pending
- production-mode HTTP smoke test on `127.0.0.1:8878` returned `/api/healthz`
- final Standards and Spec reviews found no remaining Issue 10 gap
@@ -44,6 +44,7 @@ rollback and recovery, and no known critical security or data-integrity gaps.
- [Audit the accepted product surface for production completeness](issues/07-audit-product-surface-completeness.md) — retain the guarded Organization APIs and single-app Feishu vertical slice, but require explicit Organization-scoped Feishu/provider connections and external-identity namespaces, private platform and Organization admin control planes, and the full ADR-0022 capacity surface before release. - [Audit the accepted product surface for production completeness](issues/07-audit-product-surface-completeness.md) — retain the guarded Organization APIs and single-app Feishu vertical slice, but require explicit Organization-scoped Feishu/provider connections and external-identity namespaces, private platform and Organization admin control planes, and the full ADR-0022 capacity surface before release.
- [Decide the platform-administrator identity and audit boundary](issues/42-decide-platform-admin-identity-audit.md) — use a dedicated platform Feishu issuer, one peer administrator role, guarded bootstrap and identity-bound invitations, revocable server-side sessions, atomic append-only Platform Audit, and dual-factor offline recovery without a standing break-glass account. - [Decide the platform-administrator identity and audit boundary](issues/42-decide-platform-admin-identity-audit.md) — use a dedicated platform Feishu issuer, one peer administrator role, guarded bootstrap and identity-bound invitations, revocable server-side sessions, atomic append-only Platform Audit, and dual-factor offline recovery without a standing break-glass account.
- [Restore the checker formatting gate](issues/09-restore-checker-format-gate.md) — pin local and CI Rust/rustfmt/clippy to 1.92.0, apply the canonical workspace format, and keep the full fmt + clippy + 53-test checker gate green. - [Restore the checker formatting gate](issues/09-restore-checker-format-gate.md) — pin local and CI Rust/rustfmt/clippy to 1.92.0, apply the canonical workspace format, and keep the full fmt + clippy + 53-test checker gate green.
- [Provision a runnable non-root Hub service](issues/10-provision-nonroot-service.md) — install a strictly validated service identity and persistent layout, reject release/workspace overlap, validate complete production configuration and real bind settings, and execute Hub/Prisma/cph/bubblewrap probes as the service user under `no_new_privs` before writing the unit.
## Fog ## Fog
+2 -1
View File
@@ -41,7 +41,8 @@ crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/R
cph-check / cph-cli ← checker 本体 + `cph` 命令行 cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005) render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入 examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
(hub/ exporter/ …) ← 将来的其他部件,平级于 spec/。尚未创建 hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 spec/
``` ```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。 `spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
+14 -6
View File
@@ -1,5 +1,8 @@
# Hub runtime configuration. Copy to .env and fill in. # Hub runtime configuration. Copy to .env and fill in.
# Production preflight requires the explicit production runtime mode.
NODE_ENV="production"
# PostgreSQL connection string. # PostgreSQL connection string.
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm" 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. # Agent run policy (optional). Defaults to 25.
# HUB_AGENT_MAX_TURNS=25 # HUB_AGENT_MAX_TURNS=25
# System-managed root for project workspaces created from org admin / Feishu onboarding. # Persistent system-managed root for project workspaces. Production must use an
HUB_PROJECT_WORKSPACE_ROOT="./data/project-workspaces" # 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 (lark) bot credentials.
FEISHU_APP_ID="" FEISHU_APP_ID=""
FEISHU_APP_SECRET="" FEISHU_APP_SECRET=""
FEISHU_BOT_OPEN_ID="" FEISHU_BOT_OPEN_ID=""
# Path to the `cph` binary (ADR-0016). Defaults to `cph` on PATH. # Absolute path to the `cph` binary (ADR-0016). Production preflight requires
# CPH_BIN="/usr/local/bin/cph" # 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 PORT=8788
# --- Org admin web (ADR-0021) --------------------------------------------- # --- Org admin web (ADR-0021) ---------------------------------------------
@@ -40,7 +48,7 @@ PORT=8788
# redirect_uri = ${HUB_PUBLIC_BASE_URL}/auth/feishu/callback # redirect_uri = ${HUB_PUBLIC_BASE_URL}/auth/feishu/callback
# Configure the same callback URL in the Feishu developer console under # Configure the same callback URL in the Feishu developer console under
# Security Settings → Redirect URLs. # 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: # HMAC secret for signed session + OAuth state cookies. Generate with:
# openssl rand -base64 32 # 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) Description=Curriculum Project Hub (Feishu agent + cph)
After=network-online.target postgresql.service After=network-online.target postgresql.service
Wants=network-online.target 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] [Service]
Type=simple Type=simple
User=__SERVICE_USER__ User=__SERVICE_USER__
Group=__SERVICE_GROUP__
WorkingDirectory=__HUB_DIR__ WorkingDirectory=__HUB_DIR__
EnvironmentFile=__ENV_FILE__ 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. # 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 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 ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js
@@ -16,9 +26,6 @@ RestartSec=5
# Graceful shutdown: lark ws close + in-flight runs. # Graceful shutdown: lark ws close + in-flight runs.
KillSignal=SIGTERM KillSignal=SIGTERM
TimeoutStopSec=30 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 — # Hardening: no new privileges. ProtectSystem/ProtectHome are NOT set here —
# they would break cph's render-cache extraction (~/.cache/cph/) and the # 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 # 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_PORT optional, defaults to 22
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub # 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 # 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 # The target host must have Node.js 20+, npm, rsync, PostgreSQL client tools
# (`bwrap`, for the ADR-0018 agent sandbox), and a writable $PLATFORM_DEPLOY_BASE. # (`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 # Use a dedicated `deploy` user that has passwordless sudo for systemctl and
# writing /etc/systemd/system/cph-hub.service through deploy/install_service.sh. # writing /etc/systemd/system/cph-hub.service through deploy/install_service.sh.
set -euo pipefail set -euo pipefail
+215 -33
View File
@@ -1,76 +1,258 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Install the cph-hub systemd service on a host. Idempotent. # Provision and install the cph-hub systemd service on a supported Linux host.
# # The script is idempotent: existing identities/directories are validated and
# Prerequisites already on the host: Node.js, npm, PostgreSQL, systemd. # never silently rewritten. An absent environment file is seeded completely,
# The Hub repo is expected at HUB_DIR (default /srv/curriculum-project-hub/hub) # then the script exits EX_CONFIG so no unit is installed with placeholders.
# with `npm ci` + `npm run build` already run by deploy_platform.sh.
# #
# Usage: # 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 set -euo pipefail
BASE="${BASE:-/srv/curriculum-project-hub}" BASE="${BASE:-/srv/curriculum-project-hub}"
HUB_DIR="${HUB_DIR:-$BASE/hub}" HUB_DIR="${HUB_DIR:-$BASE/hub}"
SERVICE_USER="${SERVICE_USER:-cph-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}" HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8788}" PORT="${PORT:-8788}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}" 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)}" 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)" 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 fail() {
echo "node must be on PATH, or set NODE_BIN." >&2 echo "[install] error: $*" >&2
exit 1 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 fi
if [ ! -d "$HUB_DIR" ]; then if [[ ! "$SERVICE_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
echo "HUB_DIR not found: $HUB_DIR (set HUB_DIR or clone the repo there)." >&2 fail "invalid SERVICE_USER: $SERVICE_USER"
exit 1
fi fi
if [ ! -f "$HUB_DIR/dist/server.js" ]; then if [[ ! "$SERVICE_GROUP" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2 fail "invalid SERVICE_GROUP: $SERVICE_GROUP"
exit 1
fi fi
if [ ! -f "$SCRIPT_DIR/cph-hub.service" ]; then if [ "$SERVICE_USER" = "root" ] || [ "$SERVICE_GROUP" = "root" ]; then
echo "service template missing: $SCRIPT_DIR/cph-hub.service" >&2 fail "the Hub service identity must not be root"
exit 1
fi fi
# Seed the env file if absent. Secrets stay on the server, never in the repo. for command in getent groupadd useradd install stat systemctl id; do
if [ ! -f "$ENV_FILE" ]; then require_command "$command"
install -d -m 0700 "$(dirname "$ENV_FILE")" 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 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 NODE_ENV=production
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm DATABASE_URL=
# Claude Code SDK auth via OpenRouter's Anthropic Skin (ADR-0017).
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key; ANTHROPIC_API_KEY must be empty.
ANTHROPIC_BASE_URL=https://openrouter.ai/api ANTHROPIC_BASE_URL=https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN= ANTHROPIC_AUTH_TOKEN=
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
FEISHU_APP_ID= FEISHU_APP_ID=
FEISHU_APP_SECRET= FEISHU_APP_SECRET=
FEISHU_BOT_OPEN_ID= FEISHU_BOT_OPEN_ID=
PORT=$PORT CPH_BIN=$CPH_BIN_DEFAULT
HOST=$HOST 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 EOF
chmod 0600 "$ENV_FILE" chmod 0600 "$ENV_FILE"
echo "Seeded $ENV_FILE — edit it to fill in ANTHROPIC_AUTH_TOKEN and Feishu creds, then re-run." echo "[install] seeded complete production configuration: $ENV_FILE" >&2
exit 0 echo "[install] fill every required blank, install cph at CPH_BIN, then rerun" >&2
exit 78
fi fi
[ -f "$ENV_FILE" ] || fail "environment file is not a regular file: $ENV_FILE"
# Render the unit with concrete paths. PREFLIGHT_ARGS=(
UNIT=/etc/systemd/system/cph-hub.service "$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)" TMP_UNIT="$(mktemp)"
trap 'rm -f "$TMP_UNIT"' EXIT
sed \ sed \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \ -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|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__BASE_DIR__|$BASE|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \ -e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|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" "$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
install -m 0644 "$TMP_UNIT" "$UNIT" install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
rm -f "$TMP_UNIT"
systemctl daemon-reload systemctl daemon-reload
systemctl enable cph-hub.service systemctl enable cph-hub.service
echo "Installed cph-hub.service. Start with: systemctl start cph-hub" echo "[install] installed cph-hub.service for $SERVICE_USER:$SERVICE_GROUP"
echo "Check health: curl http://127.0.0.1:$PORT/api/healthz" 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 * HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL
*/ */
import "dotenv/config"; import "dotenv/config";
import Fastify from "fastify"; import { startHub } from "./hub.js";
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";
function requireEnv(name: string): string { startHub().catch((error: unknown) => {
const v = process.env[name]; console.error("[hub] fatal:", error);
if (v === undefined || v === "") { process.exitCode = 1;
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);
}); });
+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",
);
});
});