Files
maestro/src/bridge/server.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

1050 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createTranscriptionApi } from './transcription-api.js';
import { attachTranscriptionWs } from './transcription-ws.js';
import express, { Request, Response, NextFunction, RequestHandler } from 'express';
import { existsSync } from 'fs';
import { join } from 'path';
import { Repository, BrowserSessionRepo } from '../db/repository.js';
import { logger } from '../logger.js';
import { loadConfig } from '../config.js';
import { ConfigManager } from '../config-manager.js';
import { resolveUserConcurrencyLimits } from '../user-concurrency.js';
import { mountSetupAndConfigApis } from './setup-config-mount.js';
import { mountPiecesApi } from './pieces-api.js';
import { resolveSpaceFolder } from '../spaces/folder-resolver.js';
import { mountToolsApi } from './tools-api.js';
import { mountSkillsApi } from './skills-api.js';
import { mountNotificationsApi } from './notifications-api.js';
import { mountScheduledTasksApi } from './scheduled-tasks-api.js';
import { mountBrandingApi } from './branding-api.js';
import { createBrowserApi } from './browser-api.js';
import { createBrowserSessionApi } from './browser-session-api.js';
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
import { createDelegateRunsRouter } from './delegate-runs-api.js';
import { createExecutionConversationsRouter } from './execution-conversations-api.js';
import { createLlmMetricsRouter } from './llm-metrics-api.js';
import { createReasoningRouter } from './reasoning-api.js';
import { createUsageRouter } from './usage-api.js';
import { createLlmWorkersRouter } from './llm-workers-api.js';
import { SessionManager } from '../engine/browser-session.js';
import { setupNovncWebSocketProxy } from './novnc-proxy.js';
import { mountNovncStatic, buildNovncSessionAuthorizer } from './novnc-mount.js';
import { setSessionManager } from '../engine/tools/browser.js';
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
import { setSkillToolDeps } from '../engine/tools/skills.js';
import { setAppDocsDeps } from '../engine/tools/app-docs.js';
import { DEFAULT_SESSION_SECRET_PATH, resolveSessionSecret, requireAuth, requireAdmin } from './auth.js';
import { resolveAuthActivation, mountAuthPipeline } from './auth-subsystem.js';
import { isLoopbackHost, resolveBindHost } from './initial-admin.js';
import { mountAdminApi } from './admin-api.js';
import { mountSystemAgentsApi } from './system-agents-api.js';
import { resolveSystemAgentsLocation } from '../system-agents.js';
import { mountAdminEventLogApi } from './admin-event-log-api.js';
import { mountAdminAuditLogApi } from './admin-audit-log-api.js';
import { mountAdminGatewayKeysApi } from './admin-gateway-keys-mount.js';
import { mountUsersApi, mountUsersPickableApi } from './users-api.js';
import { mountShareApi } from './share-api.js';
import { mountAppShareApi } from './app-share-api.js';
import { mountMetaApi } from './meta-api.js';
import { securityHeadersMiddleware } from './security-headers.js';
import { registerBrowserEgress } from './egress-csp.js';
import { getEgressPolicy } from '../security/egress-policy.js';
import { mountLocalTasksApi } from './local-tasks-api.js';
import { mountJobsApi } from './jobs-api.js';
import { mountUiStatic } from './ui-static.js';
import { findPieceFile } from './pieces-api.js';
import { mountLocalFilesApi } from './local-files-api.js';
import { mountLocalFileHistoryApi } from './local-file-history-api.js';
import { mountLocalFilesTrashApi } from './local-files-trash-api.js';
import { mountSubtaskFilesApi } from './subtask-files-api.js';
import { createUserFolderApi } from './user-folder-api.js';
import { createMemoryApi } from './memory-api.js';
import { createReflectionApi } from './reflection-api.js';
import { createDashboardApi } from './dashboard-api.js';
import { createSpaceApi } from './space-api.js';
import { setupMcpSubsystem } from './mcp-subsystem.js';
import { setupFileStoreSubsystem } from './filestore-subsystem.js';
import { setupSshSubsystem, type SshConsoleDeps } from './ssh-subsystem.js';
import { appHarnessAuthMiddleware, mountAppHarnessApi } from './app-harness-api.js';
import { createCrossCalendarApi } from './cross-calendar-api.js';
import { createActivityApi } from './activity-api.js';
import { registerShutdownHook, installSignalHandlers } from './shutdown.js';
import { createBackendStatusRegistry, type BackendStatusRegistry } from '../engine/backend-status-registry.js';
import type { WorkerMetrics } from '../metrics/worker-metrics.js';
import { setupMetricsSubsystem } from './metrics-subsystem.js';
import { buildDirectProbe, buildExternalApiProbe, buildProxyProbe } from '../engine/backend-probes.js';
import { startTrashCleanup } from '../user-folder/trash-cleanup.js';
import { listOrphanTaskTrashDirs, resolveTaskTrashDir, startTaskTrashCleanup } from '../workspace-trash.js';
import { listOrphanLlmCallLogDirs, startLlmCallLogCleanup } from '../engine/agent-loop/llm-call-log-retention.js';
import { NotificationCleanupWorker } from '../notifications/cleanup-worker.js';
import { userPiecesDir } from '../user-folder/paths.js';
import { startReflectionRetentionSweep } from '../engine/reflection/retention.js';
import type { AuthConfig } from '../config.js';
import { attachConsoleWs } from './console-ws-api.js';
import type { GatewayMountHandle } from './gateway-mount.js';
import { mountBridgeGateway } from './bridge-gateway-mount.js';
import { mountA2aSubsystem } from './a2a-subsystem.js';
import { setupChatSubsystem } from './chat/chat-subsystem.js';
import { createServer as createHttpsServer } from 'https';
import { X509Certificate } from 'crypto';
import { mergeServerConfig, resolveListenPort } from '../server/config.js';
import { resolvePublicBaseUrl, type PublicBaseUrlResolver } from '../server/public-base-url.js';
import { resolveTlsOptions } from '../net/tls-options.js';
import { createHttpRedirectServer } from '../net/http-redirect.js';
import { createDictionaryApi, type DictionaryLookup } from './dictionary-api.js';
import { createDocumentChatApiLifecycle, type DocumentChatApiOptions } from './document-chat-api.js';
import type { DocumentChatRunner } from '../document-chat/runner.js';
import { DocumentChatSessionStore } from '../document-chat/session-store.js';
export interface CoreServerOptions {
repo: Repository;
worktreeDir?: string;
configuredRepos?: string[];
generateTitle?: (body: string, ownerId?: string) => Promise<string>;
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
dictionaryLookup?: DictionaryLookup;
documentChatRunner?: DocumentChatRunner;
documentChatWorker?: import('../document-chat/types.js').AnswerWorkerMetadata;
documentChatRuntimeFactory?: DocumentChatApiOptions['createSessionRuntime'];
documentChatStore?: DocumentChatSessionStore;
documentChatLimits?: {
maxConcurrentPerUser: number;
maxConcurrentTotal: number;
};
/** On-demand prompt coach for the create dialog (stateless draft evaluation). */
evaluatePrompt?: (req: {
instruction: string;
piece?: string;
userId: string;
signal?: AbortSignal;
}) => Promise<unknown>;
/**
* LLM Router (Phase 2): forces the cheap title/classification worker to
* pick a tier (worker+effort) for a new job. Undefined when no cheap
* worker is configured (mirrors selectPiece/evaluatePrompt). Even when
* set, it resolves to `null` (no routing) unless `router.enabled` is
* true at CALL time — see `getRouterConfig`.
*/
routeJobFn?: (
input: import('../engine/llm-router.js').RouterPromptInput,
ctx: { userId?: string; signal?: AbortSignal },
) => Promise<import('../engine/llm-router.js').RouteDecision | null>;
/**
* Runtime accessor for the router config section (so `router.enabled`
* takes effect without a restart). Undefined when no ConfigManager is
* wired (mirrors the getLlmWorkers pattern below).
*/
getRouterConfig?: () => import('../config.js').RouterConfig | undefined;
configManager?: ConfigManager;
piecesDir?: string;
customPiecesDir?: string;
scheduler?: import('../scheduler.js').Scheduler;
authConfig?: AuthConfig;
/** Directory for user-uploaded branding assets (logos, favicons). Must be gitignored. */
brandingDir?: string;
workerManager?: import('../worker-manager.js').WorkerManager;
/** SkillCatalog instance for the /api/skills CRUD endpoints. */
skillCatalog?: import('../engine/skills.js').SkillCatalog;
/** Notifications V2 — PushService instance. null disables push API endpoints
* (they return 503), but the routes are still mounted so the UI can
* distinguish "feature off" from "endpoint missing". */
pushService?: import('../push-service.js').PushService | null;
/** Notifications V2 — VAPID key store. Required when pushService is set. */
vapidStore?: import('../vapid-store.js').VapidKeyStore | null;
/** Durable notification workers are active. Defaults to the server env flag. */
notificationEventPipelineEnabled?: boolean;
/** Workspace webhook notifications (issue #797). null disables test-send
* (route returns 503) but the CRUD routes remain mounted. */
webhookService?: import('../webhook-service.js').WebhookDeliveryService | null;
/**
* TCP port the bridge will listen on. Forwarded to admin endpoints
* (e.g. /api/admin/gateway/status) so the UI can label the gateway
* mount with the correct port. Threaded through from
* `startCoreServer(opts, port)` so a non-default port doesn't show
* up as the PORT env-var guess (which can be stale or unset).
*/
listenPort?: number;
/** Exposes the process singleton for best-effort per-call metrics attribution. */
onBackendStatusRegistry?: (registry: BackendStatusRegistry) => void;
/**
* Exposes the same-process gateway mount handle. `total_slots` attribution
* uses it to tell "the gateway running inside this process" (which shares
* `listenPort` with the bridge) from a separate gateway process that merely
* happens to be reachable — see llm/total-slots.ts.
*/
onGatewayMount?: (mount: GatewayMountHandle | null) => void;
}
export function createCoreServer(opts: CoreServerOptions): {
app: express.Application;
browserSessionManager: SessionManager | null;
authenticateUpgrade?: import('./auth.js').UpgradeAuthChecker;
authorizeNovncSession: import('./novnc-proxy.js').NovncSessionAuthorizer;
sshConsole: SshConsoleDeps | null;
/** Singleton NodeStatus cache used by the Side Info Panel's node-status widget. */
backendStatusRegistry: BackendStatusRegistry;
/** Phase 3b: Prometheus metrics handle (null when provider.metrics.enabled=false). */
workerMetrics: WorkerMetrics | null;
/**
* Phase 3c — same-process AAO Gateway mount handle. Null when no
* ConfigManager was supplied (no hot-reload available). Use this to
* read current state for the admin status endpoint.
*/
gatewayMount: GatewayMountHandle | null;
/** True when an OAuth provider or local auth is active. False = no-auth mode. */
authActive: boolean;
/** Stops document-chat GC and clears all ephemeral sessions. */
documentChatCleanup: () => void;
/**
* Resolved server config snapshot — computed once with the real listen port
* so both the cookie-secure decision (inside createCoreServer) and the
* listener branch (inside startCoreServer) share the SAME object.
* A config hot-reload between the two would otherwise cause tls.enabled to
* disagree between the cookie flag and the actual listener type.
*/
serverConfig: ReturnType<typeof mergeServerConfig>;
} {
const { repo, worktreeDir } = opts;
const app = express();
// Don't advertise the framework/version (minor info-leak, free to drop).
app.disable('x-powered-by');
// Baseline hardening headers on every response (nosniff, X-Frame-Options,
// Referrer-Policy, and HSTS over TLS). File-serving endpoints add
// `Content-Security-Policy: sandbox` on top (see setUntrustedFileResponseHeaders)
// to neutralize stored XSS from agent/user-authored workspace files.
//
// HSTS policy is read lazily: serverCfg (with the resolved tls block) is built
// further down, but the middleware only runs the thunk per request — long
// after serverCfg is assigned. Default 'clear' until then. HSTS is opt-in
// (server.tls.hsts); when off we emit `max-age=0` to un-pin browsers that an
// earlier build pinned (see security-headers.ts).
let hstsPolicy: import('./security-headers.js').HstsPolicy = 'clear';
app.use(securityHeadersMiddleware(() => hstsPolicy));
registerBrowserEgress(app, getEgressPolicy);
// リバースプロキシ背後で secure cookie / X-Forwarded-Proto を正しく処理
if (opts.authConfig?.secureCookie) {
app.set('trust proxy', 1);
}
// Phase 3b: Prometheus /metrics endpoint. Mounted BEFORE any auth
// middleware so the standard Prometheus scrape job works without a
// session cookie. Restrict scrape access at the reverse proxy /
// firewall layer (see help doc). Disabled by
// provider.metrics.enabled=false in config.yaml.
const { workerMetrics, sharedPromRegistry } = setupMetricsSubsystem(app, opts.configManager);
// JSON body parser for Config/Pieces/Memory/Reflection API endpoints
app.use('/api/config', express.json());
app.use('/api/pieces', express.json());
app.use('/api/local/memory', express.json());
app.use('/api/local/reflection', express.json());
// === Auth setup === (see auth-subsystem.ts)
// Fail-closed activation decision: throws on a partially configured OAuth
// provider; seeds the synthetic 'local' user in no-auth mode.
const { authActive, localEnabled } = resolveAuthActivation(repo, opts.authConfig);
let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
// Resolve server config ONCE here with the real listen port (threaded in via
// opts.listenPort by startCoreServer). Both the cookie-secure decision below
// and the listener branch in startCoreServer consume this same snapshot so a
// config hot-reload between the two calls cannot produce a mismatch between
// tls.enabled and the cookie secure flag.
const serverCfg = mergeServerConfig(loadConfig().server, {
freshInstall: false,
// startCoreServer threads the resolved port in via listenPort; the fallback
// (for callers that bypass it) applies the same PORT-env > config precedence.
httpsPort: opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port),
});
// Now that the TLS block is resolved, set the HSTS policy the lazy
// securityHeadersMiddleware thunk reads. Only 'enable' when HTTPS is on AND
// the operator opted in; otherwise 'clear' (emit max-age=0) so a previously
// pinned browser recovers the next time it loads the app over HTTPS.
hstsPolicy = serverCfg.tls.enabled && serverCfg.tls.hsts ? 'enable' : 'clear';
// === セッション鍵の解決はここ 1 回だけ(#1261)===
// 設定の auth.session_secret が最優先、無ければ鍵ファイル(無ければ生成)。
// 判定は resolveSessionSecret(auth.ts)だけが持ち、鍵を要る側——セッション
// ミドルウェア・監査ログ API のカーソル署名・a2a の OIDC クッキー——には
// 解決済みの値を配る。以前は 3 か所がそれぞれ鍵を求めていて、a2a だけが
// 設定値を見ずに鍵ファイルを直読みしていたため、設定に鍵を書いた環境
// (=鍵ファイルが存在しない)で a2a が起動できなかった。
const sessionSecretPath = loadConfig()?.secrets?.sessionSecretPath ?? DEFAULT_SESSION_SECRET_PATH;
const sessionSecret = resolveSessionSecret(opts.authConfig, sessionSecretPath);
if (authActive) {
// Session/passport middleware + /auth routes + /api/* guards
// (see auth-subsystem.ts — registered here, BEFORE all later mounts).
authenticateUpgrade = mountAuthPipeline(app, {
repo,
authConfig: opts.authConfig,
configManager: opts.configManager,
localEnabled,
tlsEnabled: serverCfg.tls.enabled,
beforeApiGuards: appHarnessAuthMiddleware(),
sessionSecret,
sessionSecretPath,
});
}
// Admin user management API (always mounted; protected by requireAdmin when auth is active)
app.use('/api/admin', express.json());
mountAdminApi(app, repo, authActive, loadConfig()?.userFolderRoot ?? './data/users');
// System-wide admin instructions (issue #1017): read = any authed user, write = admin.
// The body limit is route-local and deliberately larger than the 64KB content
// limit: JSON escaping (`\n`, `\uXXXX`) can inflate a 64KB document several
// times over, and the global 100KB default rejected it with a bodiless 413
// that the settings screen could only show as a raw status line.
app.use('/api/system-agents', express.json({ limit: '1mb' }));
mountSystemAgentsApi(app, {
repo,
authActive,
systemAgents: resolveSystemAgentsLocation(
loadConfig()?.storage?.systemAgentsPath,
loadConfig()?.userFolderRoot,
),
});
mountAdminEventLogApi(app, {
repo,
cursorSecret: sessionSecret,
requireAuth: authActive
? requireAuth
: (req, _res, next) => {
(req as unknown as { user: { id: string; role: string } }).user = { id: 'local', role: 'admin' };
next();
},
});
// issue #1153 段 1-a: 監査記録の読み出し(管理者のみ)。書き込み側は触らない。
mountAdminAuditLogApi(app, { repo, authActive });
// AAO Gateway Phase 2a: admin-only CRUD over gateway_virtual_keys
// (see admin-gateway-keys-mount.ts — refuses to mount when auth is off).
mountAdminGatewayKeysApi(app, { repo, authActive });
// /api/users/me/* routes — current viewer info (gated by requireAuth when auth is active)
mountUsersApi(app, repo, authActive);
// /api/users/pickable — active-user picker for space-member invites.
// Gated by requireAuth (NOT requireAdmin): any authed user may invite. Returns
// [] in no-auth mode (no other users to invite).
mountUsersPickableApi(app, repo, authActive);
// --- Share API (public + authenticated routes) ---
mountShareApi(app);
// --- 公開アプリ共有 API(read-only・認証なし)---
// space-api と同じワークスペース解決(loadConfig().worktreeDir)を使う。
mountAppShareApi(app, repo, worktreeDir ?? loadConfig().worktreeDir ?? './data/worktrees');
// Root redirect + UI static hosting + app-harness page (see ui-static.ts).
mountUiStatic(app);
// Version + repos info endpoints (see meta-api.ts). Mounted here to preserve
// ordering relative to the `/api/repos` requireAuth guard registered above.
mountMetaApi(app, { repo, configuredRepos: opts.configuredRepos });
// Per-user browser session profile repo (envelope-encrypted storageState).
// Created up-front so APIs that bind a profile to a task (local + scheduled)
// can run an owner-scoped check before persisting.
const sessRepo = new BrowserSessionRepo(repo.getDb());
// 折り返し先の土台はここで 1 つだけ作り、MCP とファイル置き場の両方へ同じ係を渡す。
const getCallbackBaseUrl = createCallbackBaseUrlResolver(opts.authConfig);
const mcpCatalogDeps = setupMcpSubsystem(app, {
repo,
authActive,
workerManager: opts.workerManager,
getCallbackBaseUrl,
});
// 外部ファイル置き場(SharePoint / OneDrive)。issue #1204 第 1 段・第 2 段。
// workerManager を渡すのは第 2 段の一覧ツールのため——資格情報の管理を
// ワーカー側で作り直すと、更新の取り合いを止める仕掛けが 2 つに割れる。
setupFileStoreSubsystem(app, {
repo,
authActive,
workerManager: opts.workerManager,
getCallbackBaseUrl,
});
const sshConsole = setupSshSubsystem(app, { repo, authActive, authenticateUpgrade });
/**
* 辞書引きと資料への質問が共有する「辞書」の枠(issue #1191 段 2)。
*
* 呼ばれるたびに設定を読み直すので、設定画面での変更が再起動なしで効く。
* 認証が無効なときは `resolveUserConcurrencyLimits` が null を返し、両方とも
* この機能を入れる前の歯止め(辞書は 1 台あたり 2 本、資料への質問は
* `GenerationLimiter`)へそのまま戻る。
*/
const getUserConcurrencyLimits = () =>
resolveUserConcurrencyLimits(
opts.configManager?.getConfig() ?? loadConfig(),
authActive,
);
// --- Markdown preview dictionary API ---
app.use('/api/dictionary', createDictionaryApi({
dictionaryLookup: opts.dictionaryLookup,
authActive,
getUserConcurrencyLimits,
}));
// mountAuthPipeline above owns production authentication and intentionally runs
// before this router's 1 MiB JSON parser. Inside the isolated router, decision
// schema validation remains before its route-level auth guard (design §判断API).
const documentChatLifecycle = createDocumentChatApiLifecycle({
store: opts.documentChatStore ?? new DocumentChatSessionStore(),
runner: opts.documentChatRunner,
worker: opts.documentChatWorker,
createSessionRuntime: opts.documentChatRuntimeFactory,
authActive,
limits: opts.documentChatLimits,
getUserConcurrencyLimits,
});
app.use('/api/document-chat', documentChatLifecycle.router);
// --- Local tasks API ---
mountLocalTasksApi(app, {
repo,
worktreeDir,
userFolderRoot: loadConfig().userFolderRoot ?? './data/users',
authActive,
generateTitle: opts.generateTitle,
selectPiece: opts.selectPiece,
evaluatePrompt: opts.evaluatePrompt,
routeJobFn: opts.routeJobFn,
getRouterConfig: opts.getRouterConfig,
pieceExists: opts.piecesDir
? (name: string, ownerId?: string) => {
// Mirror the worker's per-user → global-custom → builtin resolution order.
// 1. Owner's per-user dir (matches what worker uses at job run time).
// No-auth tasks (ownerId null) fall back to 'local', mirroring the worker.
const ufl = loadConfig().userFolderRoot ?? './data/users';
const ownerForPieces = ownerId ?? 'local';
if (existsSync(join(userPiecesDir(ufl, ownerForPieces), `${name}.yaml`))) return true;
// 2. Global-custom + builtin via existing helper.
return findPieceFile(name, opts.piecesDir!, opts.customPiecesDir) !== null;
}
: undefined,
sessRepo,
getMaxUploadMb: opts.configManager
? () => opts.configManager!.getConfig().tools?.taskUploadMaxSizeMb ?? 50
: () => loadConfig().tools?.taskUploadMaxSizeMb ?? 50,
// Package-request approvals install into the task's space overlay.
getPythonPackagesConfig: () => loadConfig().pythonPackages,
// LLM 選択 Phase 1: タスク作成/PATCH の validateLlmSelection に渡す worker 一覧。
getLlmWorkers: opts.configManager
? () => opts.configManager!.getConfig().provider?.workers ?? []
: () => loadConfig().provider?.workers ?? [],
});
// --- Local files API ---
mountLocalFilesApi(app, repo, { authActive });
// --- File history API (#989: 版の一覧 / 中身 / 差分 / 復元) ---
mountLocalFileHistoryApi(app, repo, { authActive });
// --- File trash API (#1530 第 2 段: 消したものの一覧 / 復元 / 完全削除) ---
mountLocalFilesTrashApi(app, repo, { authActive });
// --- Subtask activity API ---
app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
app.use('/api/local/tasks', createExecutionConversationsRouter(repo, { worktreeDir: worktreeDir ?? loadConfig().worktreeDir ?? './data/worktrees' }));
app.use('/api/local/tasks', createLlmMetricsRouter(repo));
app.use('/api/local/tasks', createReasoningRouter(repo));
app.use('/api/usage', createUsageRouter(repo, { authActive }));
// 横断(全スペース)カレンダー: GET /api/calendar/cross
app.use('/api/calendar', createCrossCalendarApi({ repo, authActive }));
// 横断アクティビティ(#1360 段 E): GET /api/activity/chats
app.use('/api/activity', createActivityApi({ repo, authActive }));
app.use('/api/transcription', createTranscriptionApi({ db: repo.getDb(), repo, authActive, secureOrigin: serverCfg.tls.enabled || !!opts.authConfig?.secureCookie, getTools: () => opts.configManager?.getConfig().tools ?? loadConfig().tools }));
// LLM ワーカー選択 API(タスクへの pin 用): GET /api/llm/workers
// 実行ロール(auto/fast/quality)を持つワーカーのみ・秘密値非返却。
// requireAuth は auth-subsystem.ts 側(認証有効時のみ)で配線。
if (opts.configManager) {
app.use('/api/llm/workers', createLlmWorkersRouter(opts.configManager));
}
// --- A2A OAuth2 Authorization Server (see a2a-subsystem.ts; no-op unless a2a.enabled) ---
const { handle: a2aHandle } = mountA2aSubsystem(app, {
repo,
authActive,
configManager: opts.configManager,
// 鍵は上で 1 回解決したものを渡す(a2a 側では解決しない・生成しない)。
sessionSecret,
sessionSecretPath,
});
registerShutdownHook('a2a', () => a2aHandle.stop());
// --- External chat connector (see chat/chat-subsystem.ts; no-op unless chat.enabled) ---
// Reuses the A2A limiter instance above so governance counters are shared
// per-grant across the JSON-RPC A2A path and the chat path.
setupChatSubsystem(app, { repo, authActive, getA2aLimiter: () => a2aHandle.getLimiter() });
// --- Subtask files API (listing MUST come before wildcard) ---
mountSubtaskFilesApi(app, repo);
// --- Job detail API (see jobs-api.ts) ---
mountJobsApi(app, { repo, authActive });
// NOTE: bridge `/health` (`{status:'ok'}`) is intentionally registered
// LATER — see the "bridge /health fallback" block below the gateway
// mount. Express matches handlers in registration order, so the
// gateway gate middleware must register before this handler to be
// able to dispatch `/health` to the gateway sub-app when running
// (LiteLLM-shape JSON — CRITICAL-3 fix).
// === Browser setup wizard (fresh no-auth install onboarding) + Config API ===
// (see setup-config-mount.ts)
if (opts.configManager) {
mountSetupAndConfigApis(app, {
configManager: opts.configManager,
authActive,
listenPort: opts.listenPort,
getA2aState: () => a2aHandle.getState(),
});
}
// Branding 公開 API (GET は認証不要)。アップロード系は admin のみ。
// 保存先は data/branding/ 配下で .gitignore 済み → git pull 時にユーザーのカスタム資産が失われない。
const brandingDir = opts.brandingDir ?? join(process.cwd(), 'data', 'branding');
const brandingGuard: RequestHandler = authActive ? requireAdmin : (_req, _res, next) => next();
mountBrandingApi(app, opts.configManager, { brandingDir, adminGuard: brandingGuard });
if (opts.piecesDir) {
const piecesUserFolderRoot = loadConfig().userFolderRoot ?? './data/users';
mountPiecesApi(app, {
piecesDir: opts.piecesDir,
customPiecesDir: opts.customPiecesDir,
userPiecesRootDir: piecesUserFolderRoot,
// Spaces foundation: resolve a request's spaceId to its pieces folder.
// No spaceId on the request => legacy per-user resolution (unchanged).
resolveSpaceFolder: async (spaceId: string, viewer) => {
const space = await repo.getSpace(spaceId, { viewer: viewer as Express.User | undefined });
return space ? resolveSpaceFolder(space, piecesUserFolderRoot) : null;
},
});
}
mountToolsApi(app, {
authActive,
requireAuth,
mcp: mcpCatalogDeps,
});
if (opts.skillCatalog) {
const skillsUserFolderRoot = loadConfig().userFolderRoot ?? './data/users';
mountSkillsApi(app, {
skillCatalog: opts.skillCatalog,
requireAuth,
requireAdmin,
authActive,
auditLog: async (jobId, action, actor, detail) => {
try { await repo.addAuditLog(jobId, action, actor, detail); } catch {}
},
// Spaces foundation: resolve a request's spaceId to its skills folder,
// gated on the viewer's visibility of the space. No spaceId => legacy
// per-user resolution (unchanged).
resolveSpaceFolder: async (spaceId, viewer) => {
const space = await repo.getSpace(spaceId, { viewer });
return space ? resolveSpaceFolder(space, skillsUserFolderRoot) : null;
},
});
}
// Notifications V2 (Web Push). Always mounted — the route returns 503
// when pushService is null so the UI can render a clear status.
// Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md.
mountNotificationsApi(app, {
repo,
pushService: opts.pushService ?? null,
vapidStore: opts.vapidStore ?? null,
eventPipelineEnabled: opts.notificationEventPipelineEnabled
?? process.env.MAESTRO_NOTIFICATION_EVENT_PIPELINE_ENABLED !== 'false',
requireAuth: authActive
? requireAuth
: (_req, _res, next) => {
// Auth disabled (local mode): synthesize a 'local' user so
// the per-user state machine still functions.
(_req as unknown as { user: { id: string; role: string } }).user = {
id: 'local',
role: 'admin',
};
next();
},
});
const notificationCleanup = new NotificationCleanupWorker(repo).start();
registerShutdownHook('notification-cleanup', () => notificationCleanup.stop());
if (opts.scheduler) {
app.use('/api/scheduled-tasks', express.json());
mountScheduledTasksApi(app, repo, opts.scheduler, {
sessRepo,
authActive,
// Issue #974: worker pin の validateLlmSelection に渡す worker 一覧
// (local-tasks API の getLlmWorkers と同じ供給元)。
getLlmWorkers: opts.configManager
? () => opts.configManager!.getConfig().provider?.workers ?? []
: () => loadConfig().provider?.workers ?? [],
});
}
// Browser session API
const browserSessionManager = SessionManager.isAvailable()
? new SessionManager(loadConfig().browser ?? {}, {
// 許可した宛先もブラウザの設定と同じ経路で渡す (SessionManager 側で
// loadConfig を直に呼ばないため)。**値ではなく関数**で渡す: 値で固定すると
// 許可を取り消しても再起動まで効かない。上の getLlmWorkers と同じ形
allowedHosts: () => loadConfig().tools?.webfetchAllowedHosts ?? [],
})
: null;
setSessionManager(browserSessionManager);
app.use('/api/local/browser/sessions', express.json(), createBrowserApi(browserSessionManager, repo));
// Per-user browser session profile CRUD (envelope-encrypted storageState).
// Distinct from /api/local/browser/sessions (which manages live noVNC sessions).
const masterKeyPath = loadConfig().secrets?.masterKeyPath ?? './data/secrets/master.key';
app.use(
'/api/browser-sessions',
express.json(),
createBrowserSessionApi({
sessRepo,
sessionManager: browserSessionManager,
masterKeyPath,
authActive,
spaceAccess: {
// Mirror the MCP/folder wiring's no-auth handling. Under no-auth the
// browser-session API mints a synthetic 'local' req.user that carries no
// orgIds — passing it straight to buildVisibilityWhere crashes, and even
// normalized it cannot see the null-owner local space (non-admin, no
// membership row). So when auth is OFF, always resolve as a synthetic
// admin local viewer. When auth is ON, pass the real viewer through
// (defaulting orgIds to [] defensively).
getSpace: (spaceId, viewer) =>
repo.getSpace(spaceId, {
viewer: authActive
? (viewer && !(viewer as { orgIds?: string[] }).orgIds
? ({ ...viewer, orgIds: [] } as unknown as Express.User)
: viewer)
: ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User),
}),
getSpaceMemberRole: (spaceId, userId) => repo.getSpaceMemberRole(spaceId, userId),
},
}),
);
// Per-user folder REST API: list / read / write / delete with trash.
const userFolderRoot = loadConfig().userFolderRoot ?? './data/users';
// Spaces foundation: resolve a request's spaceId to its folder, gated on the
// viewer's visibility of that space. Returns null when the space is not found
// or not visible to the viewer (callers then return 404 — no silent fallback).
// No spaceId on a request => callers use the legacy per-user folder (unchanged).
const resolveSpaceFolderForViewer = async (
spaceId: string,
viewer?: Express.User,
): Promise<{ rootDir: string; leafId: string } | null> => {
const space = await repo.getSpace(spaceId, { viewer });
return space ? resolveSpaceFolder(space, userFolderRoot) : null;
};
app.use('/api/users/me', createUserFolderApi({ userFolderRoot, sessRepo, masterKeyPath, authActive, resolveSpaceFolder: resolveSpaceFolderForViewer }));
// Per-user memory entries REST API (GET/PUT/DELETE).
app.use('/api/local/memory', createMemoryApi({ dataDir: userFolderRoot, authActive, resolveSpaceFolder: resolveSpaceFolderForViewer }));
// Per-user reflection history REST API (GET/POST).
app.use('/api/local/reflection', createReflectionApi({ dataDir: userFolderRoot, repo, authActive }));
// App-harness token-auth middleware (Task 5).
// Must run BEFORE space-api so that req.user is populated before viewerOf() reads it.
if (!authActive) mountAppHarnessApi(app);
// スペース(個人 / 案件)CRUD REST API。
// NOTE: ここでグローバルな express.json() は付けない。デフォルトの ~100kb 上限が
// upload ルートの大きい json parser より前に body を消費し、>100kb のアップロードが
// 413 になるため。各ルートが自前の json parser を宣言する(space-api.ts 側)。
app.use('/api/local/spaces', createSpaceApi({
repo,
dataRoot: userFolderRoot,
worktreeDir: worktreeDir ?? loadConfig().worktreeDir ?? './data/worktrees',
authActive,
getPythonPackagesConfig: () => loadConfig().pythonPackages,
getSafetyConfig: () => loadConfig().safety,
webhookService: opts.webhookService ?? null,
}));
// BackendStatusRegistry singleton — probes every configured worker
// (direct llama-server or proxy LiteLLM) at a fixed cadence so the
// Side Info Panel's node-status widget can paint without hammering
// the upstream per page-render. We pull workers from loadConfig() on
// each tick so YAML edits propagate without needing a ConfigManager
// event subscription here.
const backendStatusRegistry = createBackendStatusRegistry({
getWorkers: () => loadConfig().provider.workers ?? [],
probeDirect: buildDirectProbe(),
probeProxy: buildProxyProbe(),
probeExternalApi: buildExternalApiProbe(),
});
opts.onBackendStatusRegistry?.(backendStatusRegistry);
backendStatusRegistry.start();
// Worker / node (GPU) status REST API.
app.use('/api/local/dashboard', express.json(), createDashboardApi({
repo,
getWorkers: () => loadConfig().provider.workers,
getConfig: () => loadConfig(),
authActive,
backendStatusRegistry,
}));
// Phase 3c — same-process AAO Gateway mount + bridge /health fallback +
// admin gateway status endpoint (see bridge-gateway-mount.ts; the
// gateway gate must register BEFORE /health — order preserved inside).
const gatewayMount: GatewayMountHandle | null = mountBridgeGateway(app, {
repo,
configManager: opts.configManager,
sharedPromRegistry,
authActive,
listenPort: opts.listenPort,
});
opts.onGatewayMount?.(gatewayMount);
// Wire user-folder tool deps so RunUserScript / ListUserAssets can decrypt sessions.
setUserFolderToolDeps({
sessRepo,
masterKeyPath,
userFolderRoot,
auditLog: (action, detail, jobId) => {
// Best-effort: never let an audit failure surface to the tool caller.
repo.addAuditLog(jobId ?? null, action, 'tool', detail).catch(err => {
logger.warn(`[user-folder-tool] audit log failed action=${action} err=${err}`);
});
},
});
// Wire skill tool deps so InstallSkill / InstallSkillFromDir can audit-log.
setSkillToolDeps({
auditLog: (action, detail, jobId) => {
repo.addAuditLog(jobId ?? null, action, 'tool', detail).catch(err => {
logger.warn(`[skill-tool] audit log failed action=${action} err=${err}`);
});
},
userFolderRoot,
});
// Wire app-docs tool deps so GetMyOrchestratorState can introspect DB.
setAppDocsDeps({ db: repo.getDb(), userFolderRoot });
// noVNC static files + session authorizer (see novnc-mount.ts)
mountNovncStatic(app);
const authorizeNovncSession: import('./novnc-proxy.js').NovncSessionAuthorizer =
buildNovncSessionAuthorizer(repo);
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive, documentChatCleanup: documentChatLifecycle.cleanup, serverConfig: serverCfg };
}
// Definition moved to auth-subsystem.ts; re-exported here so existing
// importers of bridge/server.js keep working unchanged.
export { computeEffectiveSecureCookie } from './auth-subsystem.js';
/** Heuristic: native TLS + the proxy signal (secure_cookie) likely means a
* reverse proxy is also terminating TLS → double-TLS misconfiguration. */
export function shouldWarnDoubleTls(tlsEnabled: boolean, secureCookie: boolean): boolean {
return tlsEnabled && secureCookie;
}
export function finalizeServer(app: express.Application): express.Application {
app.use((_req: Request, res: Response) => {
res.status(404).json({ error: 'Not found' });
});
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
// body-parser signals an exceeded json({ limit }) with type
// 'entity.too.large'. Surface it as the 413 it is — collapsing it into a
// generic 500 hides the actual problem ("attachment over the configured
// limit") from API clients and the UI.
if ((err as { type?: string }).type === 'entity.too.large') {
logger.warn(`Request body too large: ${err.message}`);
res.status(413).json({ error: 'Request body too large' });
return;
}
logger.error(`Unhandled error: ${err.message}`);
res.status(500).json({ error: 'Internal server error' });
});
return app;
}
/**
* OAuth の折り返し先の土台(公開アドレス)を返す係を 1 つ作る。
*
* ここが唯一の出どころ。ファイル置き場・MCP の送信側も、画面に出す表示側も
* 同じ係を通す(issue #1245: 表示と送信が別々に組み立てられていて食い違った)。
* 要求ごとに `loadConfig()` を読み直すので、`server.public_base_url` の変更は
* 再起動なしで効く(loadConfig は mtime でキャッシュ済み)。
*/
function createCallbackBaseUrlResolver(authConfig: AuthConfig | undefined): PublicBaseUrlResolver {
const authCallbackUrls = Object.values(
(authConfig?.providers ?? {}) as Record<string, { callbackUrl?: string } | undefined>,
).map((p) => p?.callbackUrl);
return (req) => {
const cfg = loadConfig();
return resolvePublicBaseUrl(req, {
configured: cfg.server?.publicBaseUrl,
authCallbackUrls,
// Same precedence as the listener (PORT env > server.port > default) so the
// derived OAuth callback origin matches the port the bridge actually binds.
fallbackPort: resolveListenPort(process.env.PORT, cfg.server?.port),
warn: (msg) => logger.warn(msg),
});
};
}
/**
* タスクのファイルのごみ箱の掃除に渡す設定を組み立てる (#1530 第 2 段)。
*
* 起動の数行に切り出してあるのは、そこを試験から掴むため。掃除の関数そのものは
* 単体試験が厚いのに、呼ぶ側(保存日数をどの鍵から読むか・どのフォルダを集めるか)
* が無防備だと、鍵名を打ち間違えても誰も気づかない。
*
* 数える置き場は 2 種類。DB に残っているタスクの分と、タスク行が消えたあとも
* `local/` `ephemeral/` `space/{id}/runs/` の `.file-trash/` に残る置き去りの分。
* 後者を混ぜないと、タスクを消した瞬間にごみ箱が誰からも辿れなくなって永久に残る。
*/
export function taskTrashCleanupOptions(args: {
repo: { listLocalTasks: () => Promise<Array<{ id: number; workspacePath?: string | null; runtimeDir?: string | null }>> };
cfg: { storage?: { fileTrashRetentionDays?: number }; worktreeDir?: string };
worktreeDir?: string;
}): { retentionDays: number; listTrashDirs: () => Promise<string[]> } {
const trashWorktreeDir = args.worktreeDir ?? args.cfg.worktreeDir ?? './data/worktrees';
return {
retentionDays: args.cfg.storage?.fileTrashRetentionDays ?? 30,
listTrashDirs: async () => {
const tasks = await args.repo.listLocalTasks();
const dirs = tasks
.filter((t): t is typeof t & { workspacePath: string } => !!t.workspacePath)
.map(t => resolveTaskTrashDir({ taskId: t.id, workspacePath: t.workspacePath, runtimeDir: t.runtimeDir }));
return [...new Set([...dirs, ...listOrphanTaskTrashDirs(trashWorktreeDir)])];
},
};
}
/**
* 送信の全文の置き場(#1558 段 1)の掃除の設定。既定 30 日、`0` で期限なし。
* 置き場はタスクの記録ディレクトリ(`runtimeDir ?? {workspace}/logs`)の中なので、
* ごみ箱の掃除と同じ一覧の引き方をする。
*/
export function llmCallLogCleanupOptions(args: {
repo: { listLocalTasks: () => Promise<Array<{ id: number; workspacePath?: string | null; runtimeDir?: string | null }>> };
cfg: { storage?: { llmCallLogRetentionDays?: number }; worktreeDir?: string };
worktreeDir?: string;
}): { retentionDays: number; listLogDirs: () => Promise<string[]> } {
const worktreeDir = args.worktreeDir ?? args.cfg.worktreeDir ?? './data/worktrees';
return {
retentionDays: args.cfg.storage?.llmCallLogRetentionDays ?? 30,
listLogDirs: async () => {
const tasks = await args.repo.listLocalTasks();
const dirs = tasks
.filter((t): t is typeof t & { workspacePath: string } => !!t.workspacePath)
.map(t => t.runtimeDir ? t.runtimeDir : join(t.workspacePath, 'logs'));
// タスクの一覧から辿れるのは生きているローカルタスクだけ。サブタスク・
// Gitea の issue のジョブ・行を消したあとに残る置き場は、ここで足さないと
// 期限が来ても永久に残る(ごみ箱の掃除と同じ形)。
return [...new Set([...dirs, ...listOrphanLlmCallLogDirs(worktreeDir)])];
},
};
}
export function startCoreServer(opts: CoreServerOptions, port: number = 9876): WorkerMetrics | null {
const {
app,
browserSessionManager,
authenticateUpgrade,
authorizeNovncSession,
sshConsole,
backendStatusRegistry,
workerMetrics,
gatewayMount,
authActive,
documentChatCleanup,
serverConfig,
// Forward the actual port to createCoreServer so the admin gateway
// status endpoint reports the real bind port (not the PORT env
// guess). See `listenPort` doc on CoreServerOptions.
} = createCoreServer({ ...opts, listenPort: port });
// Phase 3c: parking the handle here so a future status endpoint or
// probe can read it. Currently unused at top level (the bridge
// gateway-status admin endpoint reads via the createCoreServer return).
void gatewayMount;
// Phase 3b: fan the worker metrics handle out to every Worker so the
// job lifecycle / LLM / tool counters fire. WorkerManager.setWorkerMetrics
// is the documented hook for this — it also propagates on the next
// rebuild so a config change doesn't blank metrics for new workers.
if (workerMetrics && opts.workerManager) {
opts.workerManager.setWorkerMetrics(workerMetrics);
}
const finalApp = finalizeServer(app);
// Bind host: loopback unless HOST is set explicitly. The agent API includes
// the Bash tool, so an unauthenticated instance reachable on the LAN is
// effectively unauthenticated RCE. Bare-metal / systemd installs inherit this
// loopback default. The Docker image sets HOST=0.0.0.0 (it must bind all
// interfaces inside the container) and docker-compose.yml publishes the port
// on all host interfaces by default, so a Docker deployment is LAN-reachable
// out of the box — which is why a FRESH install on a non-loopback bind now
// boots with a generated admin login (#1024, initial-admin.ts). Restore
// local-only by pinning the compose mapping back to "127.0.0.1:9876:9876".
// Both values come from initial-admin.ts so the bootstrap decision and the
// listener can never disagree about which interface is reachable.
const host = resolveBindHost();
const isLoopbackBind = isLoopbackHost(host);
// Use the config snapshot already resolved in createCoreServer (same port,
// same loadConfig() call) — no second read so a hot-reload between the two
// cannot make the cookie-secure flag disagree with the listener type.
const tls = serverConfig.tls;
let server: import('http').Server | import('https').Server;
if (tls.enabled) {
// Augment the self-signed SAN list with the redirect target host and the
// (non-wildcard) bind host so that browsers following the HTTP→HTTPS redirect
// always land on a hostname that is covered by the certificate. Provided-cert
// deployments are unaffected because resolveTlsOptions ignores selfSignedHosts
// when cert_file/key_file are set.
const extraSan = [
tls.redirectHost,
host && host !== '0.0.0.0' && host !== '::' ? host : null,
].filter((h): h is string => !!h);
const tlsForResolve = extraSan.length
? { ...tls, selfSignedHosts: [...tls.selfSignedHosts, ...extraSan] }
: tls;
const resolved = resolveTlsOptions(tlsForResolve); // fatal throw on bad operator cert
server = createHttpsServer({ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion }, finalApp);
server.listen(port, host, () => {
const source = tls.certFile ? `provided(${tls.certFile})` : 'self-signed';
const fp = new X509Certificate(resolved.cert).fingerprint256;
logger.info(`Core server listening on https://${host}:${port} cert=${source} sha256=${fp}`);
if (shouldWarnDoubleTls(true, !!opts.authConfig?.secureCookie)) {
logger.warn(
`[security] server.tls.enabled is ON while auth.secure_cookie is also ON (reverse-proxy signal). ` +
`If a TLS-terminating proxy is in front of this app, set server.tls.enabled: false to avoid double TLS.`,
);
}
});
if (tls.httpRedirect) {
const isWildcardBind = host === '0.0.0.0' || host === '::';
// With a wildcard bind and no explicit redirect_host there is no single
// correct host to pin (the bind address itself, 0.0.0.0, is not browser-
// reachable). Derive the redirect target from the client's own Host header
// instead — a scheme upgrade to the host they already used. 'localhost' is
// only the last-resort pin for a request with no usable Host header.
const preferRequestHost = tls.redirectHost == null && isWildcardBind;
if (preferRequestHost) {
logger.info(
`[server] HTTP->HTTPS redirect derives its host from the request Host header ` +
`(wildcard bind ${host}, no server.tls.redirect_host set). Set redirect_host to pin it explicitly.`,
);
}
const pinnedHost = tls.redirectHost ?? (isWildcardBind || isLoopbackBind ? 'localhost' : host);
// One listener per configured redirect port (e.g. 80 plus a high port).
for (const rport of tls.httpRedirectPorts) {
const redirector = createHttpRedirectServer({ httpsPort: port, pinnedHost, preferRequestHost });
redirector.on('error', (e) =>
logger.warn(`[server] HTTP redirect listener error on :${rport}: ${(e as Error).message}`),
);
redirector.listen(rport, host, () =>
logger.info(`HTTP->HTTPS redirect listening on http://${host}:${rport}`),
);
}
}
} else {
server = finalApp.listen(port, host, () => {
logger.info(`Core server listening on ${host}:${port}`);
if (!isLoopbackBind && !authActive) {
logger.warn(
`[security] Listening on ${host} with authentication DISABLED. The agent API ` +
`(including the Bash tool) is reachable by anyone who can reach this host — ` +
`this is effectively unauthenticated remote code execution. Enable auth in ` +
`config.yaml (auth.local or an OAuth provider) before exposing a non-loopback ` +
`interface, or unset HOST to bind 127.0.0.1.`,
);
}
});
}
// 起動と同時に CAPTCHA Pool の idle GC を回す (task session を 5 分アイドルで GC)
if (browserSessionManager) browserSessionManager.startIdleGc();
// User folder の trash を定期的に sweep (デフォルト 30 日)
{
const cfg = loadConfig();
const userFolderRoot = cfg.userFolderRoot ?? './data/users';
const retentionDays = cfg.tools?.trashRetentionDays ?? 30;
startTrashCleanup({ userFolderRoot, retentionDays });
// タスクのファイルのごみ箱の掃除 (#1530 第 2 段。既定 30 日)。
startTaskTrashCleanup(taskTrashCleanupOptions({ repo: opts.repo, cfg, worktreeDir: opts.worktreeDir }));
// 送信の全文の置き場の掃除 (#1558 段 1。既定 30 日、0 で期限なし)。
startLlmCallLogCleanup(llmCallLogCleanupOptions({ repo: opts.repo, cfg, worktreeDir: opts.worktreeDir }));
// Reflection snapshot retention sweep (daily, same schedule as trash)
startReflectionRetentionSweep({
dataDir: userFolderRoot,
config: {
snapshotRetentionDays: cfg.reflection.snapshotRetentionDays,
snapshotMaxBytesPerUser: cfg.reflection.snapshotMaxBytesPerUser,
},
});
}
setupNovncWebSocketProxy(server, () => browserSessionManager, authenticateUpgrade, authorizeNovncSession);
const stopTranscriptionWs = attachTranscriptionWs(server, {authActive, secureOrigin: serverConfig.tls.enabled || !!opts.authConfig?.secureCookie, authenticateUpgrade, getTools: () => opts.configManager?.getConfig().tools ?? loadConfig().tools});
registerShutdownHook('transcription', async () => stopTranscriptionWs());
// Phase 5 (SSH Console): attach the WS upgrade handler now that we
// have the http.Server. attachConsoleWs only handles upgrades that
// match /api/local/tasks/:id/console/ws; all other upgrades fall
// through to noVNC (or are dropped).
if (sshConsole) {
attachConsoleWs(server, sshConsole);
// Graceful shutdown: close all live console sessions so we don't
// leak channels / DEK references. The registry tears down the
// sweep timer too. Errors are caught inside the shutdown driver
// (see ./shutdown.ts) — never block the signal.
registerShutdownHook('ssh-console', async () => {
await sshConsole.registry.shutdown();
});
}
// Tear down the BackendStatusRegistry's polling timer on shutdown so
// tests / restart loops don't leak handles. stop() is async — it
// aborts the in-flight probe cycle (via AbortController) and awaits
// settlement so pending fetches don't outlive the signal. Without
// this, SIGTERM could block process exit for up to ~3s per probe.
registerShutdownHook('backend-status-registry', async () => {
await backendStatusRegistry.stop();
});
registerShutdownHook('document-chat', async () => {
documentChatCleanup();
});
// Install SIGTERM / SIGINT listeners exactly once. All previous
// subsystem-local handler pairs (one per Phase) have been folded
// into the single registry above — this avoids hitting Node's
// default MaxListeners cap (10) as Phase D adds more cleanup paths.
installSignalHandlers();
return workerMetrics;
}