481 lines
21 KiB
TypeScript
481 lines
21 KiB
TypeScript
/**
|
|
* Hono app assembly: middleware + route mounting + static hosting +
|
|
* error handling.
|
|
*
|
|
* `createApp(deps)` is pure assembly (does not listen on a port): tests inject requests via
|
|
* `app.request()`; `buildAppDeps(config)` assembles all services from config (test doubles
|
|
* like SessionLoader can be injected). The startup entry point is in index.ts.
|
|
*/
|
|
import fs from "node:fs";
|
|
import fsp from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { Hono } from "hono";
|
|
import type { Context } from "hono";
|
|
import { bodyLimit } from "hono/body-limit";
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
import type { ServerConfig } from "./config.js";
|
|
import { openDatabase } from "./db/database.js";
|
|
import { AgentsRepo } from "./db/repos/agents.js";
|
|
import { AuthSessionsRepo } from "./db/repos/auth-sessions.js";
|
|
import { ErrorsRepo } from "./db/repos/errors.js";
|
|
import { MembersRepo } from "./db/repos/members.js";
|
|
import { ProjectsRepo } from "./db/repos/projects.js";
|
|
import { GoalsRepo } from "./db/repos/goals.js";
|
|
import { SchedulesRepo } from "./db/repos/schedules.js";
|
|
import { SessionsRepo } from "./db/repos/sessions.js";
|
|
import { UiPrefsRepo } from "./db/repos/ui-prefs.js";
|
|
import { UsageRepo } from "./db/repos/usage.js";
|
|
import { UsersRepo } from "./db/repos/users.js";
|
|
import type { UserRow } from "./db/repos/users.js";
|
|
import { authMiddleware, jsonOnlyWrites } from "./auth/middleware.js";
|
|
import type { AppEnv } from "./auth/middleware.js";
|
|
import { AuthService } from "./auth/service.js";
|
|
import { handleError, HttpError, errorBody } from "./http/errors.js";
|
|
import { adminUsersRoutes } from "./http/routes/admin.js";
|
|
import { authRoutes } from "./http/routes/auth.js";
|
|
import { meRoutes } from "./http/routes/me.js";
|
|
import { eventsRoutes, userChannelKey } from "./http/routes/events.js";
|
|
import { projectsRoutes } from "./http/routes/projects.js";
|
|
import { membersRoutes } from "./http/routes/members.js";
|
|
import { modelsRoutes } from "./http/routes/models.js";
|
|
import { vaultRoutes } from "./http/routes/vault.js";
|
|
import { scheduleRoutes } from "./http/routes/schedules.js";
|
|
import { benchmarksRoutes } from "./http/routes/benchmarks.js";
|
|
import { agentSkillsRoutes, skillLibraryRoutes } from "./http/routes/skills.js";
|
|
import { agentTransferRoutes } from "./http/routes/agent-transfer.js";
|
|
import { agentsRoutes } from "./http/routes/agents.js";
|
|
import { dirsRoutes } from "./http/routes/dirs.js";
|
|
import { agentConfigRoutes } from "./http/routes/agent-config.js";
|
|
import { agentTracesRoutes } from "./http/routes/agent-traces.js";
|
|
import { usageRoutes } from "./http/routes/usage.js";
|
|
import { agentSessionsRoutes, sessionsRoutes } from "./http/routes/sessions.js";
|
|
import { versionRoutes } from "./http/routes/version.js";
|
|
import { ChannelHub } from "./runtime/channel.js";
|
|
import { ErrorRecorder } from "./runtime/error-recorder.js";
|
|
import { createCoreSessionLoader, SessionManager } from "./runtime/session-manager.js";
|
|
import { SessionSources } from "./runtime/session-sources.js";
|
|
import type { SessionLoader } from "./runtime/session-manager.js";
|
|
import { Scheduler } from "./runtime/scheduler.js";
|
|
import { TitleGenerator } from "./runtime/title-generator.js";
|
|
import type { TitleNotifier } from "./runtime/title-generator.js";
|
|
import { UsageRecorder } from "./runtime/usage-recorder.js";
|
|
import { AdminService } from "./services/admin-service.js";
|
|
import { AgentConfigService } from "./services/agent-config-service.js";
|
|
import { AgentService } from "./services/agent-service.js";
|
|
import { BenchmarkService } from "./services/benchmark-service.js";
|
|
import { SnapshotService } from "./services/snapshot-service.js";
|
|
import { ProjectConfigService } from "./services/project-config-service.js";
|
|
import { ProjectService } from "./services/project-service.js";
|
|
import { SessionService } from "./services/session-service.js";
|
|
import { TraceService } from "./services/trace-service.js";
|
|
import { UpdateCheckService } from "./services/update-check-service.js";
|
|
import { UsageService } from "./services/usage-service.js";
|
|
import { WorkspaceFilesService } from "./services/workspace-files-service.js";
|
|
import {
|
|
createPreviewTokenSigner,
|
|
hostOnly,
|
|
loopbackHostRoles,
|
|
requestAuthority,
|
|
} from "./services/preview-token.js";
|
|
import type { PreviewTokenSigner } from "./services/preview-token.js";
|
|
import { previewRoutes } from "./http/routes/preview.js";
|
|
|
|
/** Request body size limit (tasks may carry data: images): 20MB. */
|
|
const MAX_BODY_BYTES = 20 * 1024 * 1024;
|
|
|
|
export interface AppDeps {
|
|
config: ServerConfig;
|
|
db: DatabaseSync;
|
|
sessionsRepo: SessionsRepo;
|
|
prefsRepo: UiPrefsRepo;
|
|
authService: AuthService;
|
|
adminService: AdminService;
|
|
projectService: ProjectService;
|
|
projectConfigService: ProjectConfigService;
|
|
agentService: AgentService;
|
|
agentConfigService: AgentConfigService;
|
|
sessionService: SessionService;
|
|
traceService: TraceService;
|
|
usageService: UsageService;
|
|
/** GitHub latest-release lookup for the web UI's update reminder (cached, fail-soft). */
|
|
updateCheck: UpdateCheckService;
|
|
workspaceFiles: WorkspaceFilesService;
|
|
/** Signs/verifies short-lived Workspace preview tokens (separate preview origin). */
|
|
previewTokens: PreviewTokenSigner;
|
|
benchmarks: BenchmarkService;
|
|
snapshots: SnapshotService;
|
|
schedulesRepo: SchedulesRepo;
|
|
goalsRepo: GoalsRepo;
|
|
errorsRepo: ErrorsRepo;
|
|
scheduler: Scheduler;
|
|
channels: ChannelHub;
|
|
manager: SessionManager;
|
|
/** Session-origin registry derived from session_meta (single source of truth; no DB column). */
|
|
sessionSources: SessionSources;
|
|
/** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */
|
|
errors: ErrorRecorder;
|
|
/** Request log output (minimal one-liner); tests inject a noop. */
|
|
log: (line: string) => void;
|
|
}
|
|
|
|
export interface BuildDepsOverrides {
|
|
/** Test double: session-manager's underlying loader (avoids the real LLM/SDK path). */
|
|
loader?: SessionLoader;
|
|
/** Test double: Session title generator (avoids real LLM requests). */
|
|
titles?: TitleNotifier;
|
|
/** Test double: update-check service with a stubbed fetch/clock (avoids real network calls). */
|
|
updateCheck?: UpdateCheckService;
|
|
log?: (line: string) => void;
|
|
now?: () => Date;
|
|
}
|
|
|
|
/** Assemble all services from config (shared by production and tests; tests pass dbPath=":memory:" and a temp root). */
|
|
export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides = {}): AppDeps {
|
|
const db = openDatabase(config.dbPath);
|
|
const log = overrides.log ?? ((line: string) => console.log(line));
|
|
|
|
const usersRepo = new UsersRepo(db);
|
|
const authSessionsRepo = new AuthSessionsRepo(db);
|
|
const projectsRepo = new ProjectsRepo(db);
|
|
const membersRepo = new MembersRepo(db);
|
|
const agentsRepo = new AgentsRepo(db);
|
|
const sessionsRepo = new SessionsRepo(db);
|
|
const usageRepo = new UsageRepo(db);
|
|
const errorsRepo = new ErrorsRepo(db);
|
|
const prefsRepo = new UiPrefsRepo(db);
|
|
const schedulesRepo = new SchedulesRepo(db);
|
|
const goalsRepo = new GoalsRepo(db);
|
|
|
|
const projectConfigService = new ProjectConfigService(config.root);
|
|
const agentConfigService = new AgentConfigService(config.root);
|
|
const agentService = new AgentService(config.root, agentsRepo, agentConfigService);
|
|
const traceService = new TraceService(config.root);
|
|
const workspaceFiles = new WorkspaceFilesService();
|
|
// Per-process secret: preview tokens are short-lived, so losing them on restart is
|
|
// harmless and there is nothing to persist or rotate.
|
|
const previewTokens = createPreviewTokenSigner();
|
|
const benchmarks = new BenchmarkService(config.root, workspaceFiles);
|
|
const snapshots = new SnapshotService(config.root);
|
|
const usageService = new UsageService(
|
|
usageRepo,
|
|
errorsRepo,
|
|
(projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
|
|
overrides.now ?? (() => new Date()),
|
|
);
|
|
const updateCheck =
|
|
overrides.updateCheck ?? new UpdateCheckService(overrides.now ? { now: overrides.now } : {});
|
|
|
|
// Channel idle reclamation skips active Sessions (running/compacting can go a long time
|
|
// without a publish, e.g. while waiting for approval).
|
|
// manager is created after channels: use a lazy predicate (managerRef is assigned by the
|
|
// time the sweep timer fires).
|
|
let managerRef: SessionManager | undefined;
|
|
const channels = new ChannelHub({
|
|
isActive: (key) => managerRef !== undefined && managerRef.statusOf(key) !== "idle",
|
|
});
|
|
const recorder = new UsageRecorder(usageRepo, overrides.now ?? (() => new Date()));
|
|
const errors = new ErrorRecorder(errorsRepo, overrides.now ?? (() => new Date()));
|
|
const titles =
|
|
overrides.titles ??
|
|
new TitleGenerator({ sessions: sessionsRepo, channels, recorder, errors, log });
|
|
// Session-origin registry: session_meta is the single source of truth (no DB column);
|
|
// shared by the manager (subagent registration), the loader (self-heal rebuild) and
|
|
// SessionService (creation / adoption / lazy list resolution).
|
|
const sessionSources = new SessionSources();
|
|
const manager = new SessionManager({
|
|
sessions: sessionsRepo,
|
|
channels,
|
|
loader: overrides.loader ?? createCoreSessionLoader(config.root, sessionSources),
|
|
sources: sessionSources,
|
|
recorder,
|
|
errors,
|
|
titles,
|
|
log,
|
|
goals: goalsRepo,
|
|
});
|
|
managerRef = manager;
|
|
|
|
const projectService = new ProjectService({
|
|
root: config.root,
|
|
users: usersRepo,
|
|
projects: projectsRepo,
|
|
members: membersRepo,
|
|
agents: agentsRepo,
|
|
sessions: sessionsRepo,
|
|
usage: usageRepo,
|
|
errors: errorsRepo,
|
|
schedules: schedulesRepo,
|
|
goals: goalsRepo,
|
|
projectConfig: projectConfigService,
|
|
manager,
|
|
});
|
|
const authService = new AuthService({
|
|
users: usersRepo,
|
|
authSessions: authSessionsRepo,
|
|
provisionInitialProject: (user, isAdmin) =>
|
|
projectService.provisionInitialProject(user, isAdmin),
|
|
sessionTtlMs: config.authSessionTtlMs,
|
|
sessionRenewMs: config.authSessionRenewMs,
|
|
...(overrides.now ? { now: overrides.now } : {}),
|
|
});
|
|
const adminService = new AdminService({
|
|
users: usersRepo,
|
|
authSessions: authSessionsRepo,
|
|
projects: projectsRepo,
|
|
projectService,
|
|
...(overrides.now ? { now: overrides.now } : {}),
|
|
});
|
|
const sessionService = new SessionService({
|
|
root: config.root,
|
|
sessions: sessionsRepo,
|
|
manager,
|
|
projectConfig: projectConfigService,
|
|
sources: sessionSources,
|
|
});
|
|
// Schedule scheduler: active only while the server is running. Only
|
|
// assembled here; start() is called in index.ts (tests drive it via tickOnce, no real timer).
|
|
const scheduler = new Scheduler({
|
|
root: config.root,
|
|
repo: schedulesRepo,
|
|
projects: projectsRepo,
|
|
sessions: sessionsRepo,
|
|
runner: manager,
|
|
sessionCreator: sessionService,
|
|
errors,
|
|
notify: (userId, event) => {
|
|
channels.get(userChannelKey(userId)).publish(event, "server_event");
|
|
},
|
|
...(overrides.now ? { now: () => overrides.now!().getTime() } : {}),
|
|
});
|
|
|
|
return {
|
|
config,
|
|
db,
|
|
sessionsRepo,
|
|
prefsRepo,
|
|
authService,
|
|
adminService,
|
|
projectService,
|
|
projectConfigService,
|
|
agentService,
|
|
agentConfigService,
|
|
sessionService,
|
|
traceService,
|
|
usageService,
|
|
updateCheck,
|
|
workspaceFiles,
|
|
previewTokens,
|
|
benchmarks,
|
|
snapshots,
|
|
schedulesRepo,
|
|
goalsRepo,
|
|
errorsRepo,
|
|
scheduler,
|
|
channels,
|
|
manager,
|
|
sessionSources,
|
|
errors,
|
|
log,
|
|
};
|
|
}
|
|
|
|
/** Assembles the Hono app (does not listen on a port). */
|
|
export function createApp(deps: AppDeps): Hono<AppEnv> {
|
|
const app = new Hono<AppEnv>();
|
|
|
|
// Error recording is layered in a lambda wrapping onError: handleError stays a
|
|
// pure function with unchanged behavior (HttpError is mapped as-is, unknown
|
|
// exceptions are logged with a stack trace and collapsed to 500), and recording
|
|
// to the DB is just a side-effect layered on top.
|
|
app.onError((err, c) => {
|
|
const projectId = attributedProjectId(c, deps);
|
|
deps.errors.record({
|
|
source: "http",
|
|
err,
|
|
...(projectId !== undefined ? { ctx: { projectId } } : {}),
|
|
});
|
|
return handleError(err, c);
|
|
});
|
|
app.notFound((c) => c.json(errorBody("not_found", "Endpoint does not exist."), 404));
|
|
|
|
// Request logging: a minimal one-liner (method path status ms).
|
|
app.use("*", async (c, next) => {
|
|
const start = performance.now();
|
|
await next();
|
|
const ms = Math.round(performance.now() - start);
|
|
deps.log(`${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms`);
|
|
});
|
|
|
|
// Canonical-host guard (loopback binds only): the App is served on one loopback name and
|
|
// previews on its counterpart, but the SAME process answers on both. Without this, Agent-
|
|
// written preview HTML on the preview host could call /api same-origin and — if a session
|
|
// cookie had ever been set on that host — act as the user. So the preview host serves ONLY
|
|
// /preview/*: /api answers 401 (it never sets or honors a cookie there, closing both the
|
|
// login and the stale-cookie paths), and everything else 302s to the canonical App host.
|
|
// See design § "Workspace 文件预览". Off when PENGUIN_PREVIEW_ORIGIN is set: previews then
|
|
// use that origin rather than the loopback counterpart, so 127.0.0.1 is an ordinary App
|
|
// access point and must not be locked down — deployments enforce the equivalent at the
|
|
// reverse proxy (route only /preview/* to the App on the preview origin).
|
|
const previewRoles = deps.config.previewOrigin ? null : loopbackHostRoles(deps.config.host);
|
|
if (previewRoles) {
|
|
app.use("*", async (c, next) => {
|
|
const host = hostOnly(requestAuthority(c.req.url, c.req.header("host"))).toLowerCase();
|
|
if (host === previewRoles.preview && !c.req.path.startsWith("/preview/")) {
|
|
if (c.req.path.startsWith("/api/")) {
|
|
throw new HttpError(401, "unauthorized", "The API is not served on the preview host.");
|
|
}
|
|
const url = new URL(c.req.url);
|
|
url.hostname = previewRoles.app;
|
|
return c.redirect(url.toString(), 302);
|
|
}
|
|
await next();
|
|
});
|
|
}
|
|
|
|
// API common defenses: request body size cap (20MB) and write-request Content-Type (one of the CSRF MVP defenses).
|
|
//
|
|
// The cap has to be measured, not read: a chunked request carries no `content-length` at all,
|
|
// so a header check alone passes a body of any size — the sinks behind it (task input images,
|
|
// file attachments, Trace import) then decode whatever arrives. hono's bodyLimit keeps the
|
|
// header fast path when the length is declared and otherwise counts bytes off the stream,
|
|
// aborting the moment the total crosses the cap.
|
|
app.use(
|
|
"/api/*",
|
|
bodyLimit({
|
|
maxSize: MAX_BODY_BYTES,
|
|
// Its default is a bare text/plain 413; throw the App's own error instead so the response
|
|
// stays the documented `payload_too_large` body that every client already handles.
|
|
onError: () => {
|
|
throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
|
|
},
|
|
}),
|
|
);
|
|
app.use("/api/*", jsonOnlyWrites);
|
|
|
|
// Public routes (no login required).
|
|
app.route("/api/auth", authRoutes(deps));
|
|
|
|
// Protected routes: cookie -> auth_session -> user.
|
|
const auth = authMiddleware(deps.authService);
|
|
app.use("/api/*", auth);
|
|
app.route("/api/me", meRoutes(deps));
|
|
app.route("/api/version", versionRoutes(deps));
|
|
app.route("/api/admin/users", adminUsersRoutes(deps));
|
|
app.route("/api/events", eventsRoutes(deps));
|
|
// Skill library listing: readable once logged in, not nested under a Project prefix.
|
|
app.route("/api/skills", skillLibraryRoutes());
|
|
app.route("/api/projects", projectsRoutes(deps));
|
|
app.route("/api/projects/:projectId/members", membersRoutes(deps));
|
|
app.route("/api/projects/:projectId/models", modelsRoutes(deps));
|
|
app.route("/api/projects/:projectId/agents", agentsRoutes(deps));
|
|
app.route("/api/projects/:projectId/dirs", dirsRoutes(deps));
|
|
app.route("/api/projects/:projectId/agents/:agentId/config", agentConfigRoutes(deps));
|
|
app.route("/api/projects/:projectId/agents/:agentId/vault", vaultRoutes(deps));
|
|
app.route("/api/projects/:projectId/agents/:agentId/schedules", scheduleRoutes(deps));
|
|
app.route("/api/projects/:projectId/agents/:agentId/benchmarks", benchmarksRoutes(deps));
|
|
app.route("/api/projects/:projectId/agents/:agentId/skills", agentSkillsRoutes(deps));
|
|
app.route("/api/projects/:projectId/agents/:agentId", agentTransferRoutes(deps));
|
|
app.route("/api/projects/:projectId/agents/:agentId/traces", agentTracesRoutes(deps));
|
|
app.route("/api/projects/:projectId/agents/:agentId/sessions", agentSessionsRoutes(deps));
|
|
app.route("/api/projects/:projectId/usage", usageRoutes(deps));
|
|
app.route("/api/sessions", sessionsRoutes(deps));
|
|
|
|
// Workspace HTML preview on the separate preview origin: deliberately outside /api and
|
|
// outside the auth middleware — that origin never receives the session cookie, so the
|
|
// signed token in the path is the only credential. Mounted before static hosting so the
|
|
// SPA fallback cannot swallow it. See design § "Workspace 文件预览".
|
|
app.route("/preview", previewRoutes(deps));
|
|
|
|
// Static hosting (production): serves the frontend build output when webDist exists, with SPA fallback to index.html.
|
|
if (fs.existsSync(deps.config.webDist)) {
|
|
registerStaticRoutes(app, deps.config.webDist);
|
|
}
|
|
|
|
return app;
|
|
}
|
|
|
|
/**
|
|
* The Project an error is attributed to: only
|
|
* attributed when the URL has a `:projectId` **and** the requester genuinely has
|
|
* access to that Project; otherwise recorded as unattributed (`project_id IS
|
|
* NULL`, visible only to admins).
|
|
*
|
|
* onError also has to handle requests that **haven't passed permission checks
|
|
* yet** — a 401 from being logged out, a 404 from not being a member, both get
|
|
* recorded here. Attributing directly from the URL parameter would let anyone
|
|
* (not necessarily a member of that Project, or even logged in) pick a projectId
|
|
* and hammer it repeatedly to pollute another user's Project with error stats.
|
|
* Traces that can't be attributed simply fall into the admin view (unattributed
|
|
* errors are only visible to admins by design anyway), which is exactly where
|
|
* unauthorized probing belongs.
|
|
*
|
|
* Two defenses here, because this code runs on the error-handling path:
|
|
* - `c.var.user`'s static type is non-null, but authMiddleware never sets it
|
|
* **before** throwing the 401 when logged out, so at runtime it may actually be
|
|
* undefined — it can only be read safely, never destructured directly.
|
|
* - Exceptions are swallowed entirely: throwing here would break onError itself
|
|
* (possibly recursively); any judgment failure falls back to unattributed.
|
|
*/
|
|
function attributedProjectId(c: Context<AppEnv>, deps: AppDeps): string | undefined {
|
|
try {
|
|
const projectId = c.req.param("projectId");
|
|
if (projectId === undefined) return undefined;
|
|
const user = c.get("user") as UserRow | undefined;
|
|
if (user === undefined) return undefined;
|
|
return deps.projectService.canAccess(user.userId, projectId) ? projectId : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
const CONTENT_TYPES: Record<string, string> = {
|
|
".html": "text/html; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".json": "application/json",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".ico": "image/x-icon",
|
|
".map": "application/json",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".woff2": "font/woff2",
|
|
};
|
|
|
|
/** Minimal static file server (avoiding an extra dependency): path traversal protection + SPA fallback. */
|
|
function registerStaticRoutes(app: Hono<AppEnv>, webDist: string): void {
|
|
app.get("*", async (c) => {
|
|
const reqPath = decodeURIComponent(c.req.path);
|
|
if (reqPath.startsWith("/api/")) {
|
|
return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
|
|
}
|
|
const rel = reqPath.replace(/^\/+/, "");
|
|
const resolved = path.resolve(webDist, rel === "" ? "index.html" : rel);
|
|
// Guard against path traversal: once resolved, it must still be inside webDist.
|
|
const base = path.resolve(webDist);
|
|
const target =
|
|
resolved === base || resolved.startsWith(base + path.sep)
|
|
? resolved
|
|
: path.join(base, "index.html");
|
|
let file = target;
|
|
try {
|
|
const stat = await fsp.stat(file);
|
|
if (stat.isDirectory()) file = path.join(file, "index.html");
|
|
await fsp.access(file);
|
|
} catch {
|
|
file = path.join(base, "index.html"); // SPA fallback
|
|
}
|
|
let content: Buffer;
|
|
try {
|
|
content = await fsp.readFile(file);
|
|
} catch {
|
|
return c.json(errorBody("not_found", "Resource does not exist."), 404);
|
|
}
|
|
const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
return new Response(new Uint8Array(content), {
|
|
status: 200,
|
|
headers: { "Content-Type": type },
|
|
});
|
|
});
|
|
}
|