import express, { Request, Response, NextFunction, RequestHandler } from 'express'; import { existsSync } from 'fs'; import { join, resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import { Repository, BrowserSessionRepo } from '../db/repository.js'; import { logger } from '../logger.js'; import { loadConfig } from '../config.js'; import { ConfigManager } from '../config-manager.js'; import { mountConfigApi } from './config-api.js'; import { mountPiecesApi } from './pieces-api.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, resolveBranding } 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 { SessionManager } from '../engine/browser-session.js'; import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.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 { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds } from './auth.js'; import { canUserSeeTask } from './visibility.js'; import { mountAdminApi } from './admin-api.js'; import { createAdminGatewayApi } from './admin-gateway-api.js'; import { mountUsersApi } from './users-api.js'; import { mountShareApi } from './share-api.js'; import { mountLocalTasksApi } from './local-tasks-api.js'; import { findPieceFile } from './pieces-api.js'; import { mountLocalFilesApi } from './local-files-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 { registerShutdownHook, installSignalHandlers } from './shutdown.js'; import { createBackendStatusRegistry, type BackendStatusRegistry } from '../engine/backend-status-registry.js'; import { createWorkerRegistry } from '../metrics/registry.js'; import { createWorkerMetrics, type WorkerMetrics } from '../metrics/worker-metrics.js'; import { createMetricsHandler } from '../metrics/http-handler.js'; import { buildDirectProbe, buildProxyProbe } from '../engine/backend-probes.js'; import { startTrashCleanup } from '../user-folder/trash-cleanup.js'; import { userPiecesDir } from '../user-folder/paths.js'; import { startReflectionRetentionSweep } from '../engine/reflection/retention.js'; import type { AuthConfig } from '../config.js'; import { isKeyConfigured } from '../mcp/crypto.js'; import { createRegistry } from '../mcp/registry.js'; import { createTokenManager } from '../mcp/token-manager.js'; import { createToolCache } from '../mcp/tool-cache.js'; import { createAggregator } from '../mcp/aggregator.js'; import { createMcpClient } from '../mcp/client-factory.js'; import { executeMcpCall } from '../mcp/tool-executor.js'; import { refreshAccessToken } from '../mcp/discovery.js'; import { setMcpAggregator } from '../engine/tools/index.js'; import { setMcpToolLookup } from '../engine/tools/docs.js'; import { setDashboardRepo } from '../engine/tools/dashboard.js'; import { createMcpOauthRouter } from '../mcp/oauth-routes.js'; import { createAdminRouter as createMcpAdminRouter, createUserRouter as createMcpUserRouter, createUserServersRouter as createMcpUserServersRouter } from './mcp-api.js'; import { mergeMcpConfig } from '../mcp/config.js'; import { mergeSshConfig } from '../ssh/config.js'; import { bootstrapSystemDek, verifySystemDek, encryptPrivateKey as sshEncryptPrivateKey, decryptPrivateKey as sshDecryptPrivateKey, computeKeyFingerprint as sshComputeKeyFingerprint, formatPublicKey as sshFormatPublicKey, generateKeypair as sshGenerateKeypair, type GeneratedKeyType as SshGeneratedKeyType, } from '../ssh/crypto.js'; import { createConnectionRepo } from '../ssh/connection-repo.js'; import { createGrantsRepo } from '../ssh/grants-repo.js'; import { createAuditRepo } from '../ssh/audit-repo.js'; import { createAbuseRepo } from '../ssh/abuse-repo.js'; import { createAccessResolver } from '../ssh/access.js'; import { maintenance as sshMaintenance } from '../ssh/maintenance.js'; import { createAdminRateLimiter, FORCE_UNLOCK_LIMIT } from '../ssh/admin-rate-limit.js'; import { sshTest, sshExec, sshUpload, sshDownload, openShellChannel, type ResolvedConnection as SshResolvedConnection, } from '../ssh/session.js'; import { SessionRegistry } from '../ssh/console-registry.js'; import { createSshUserRouter, createSshAdminRouter, type SshApiDeps } from './ssh-api.js'; import { setSshSubsystem, preflight as sshPreflight, type SshSubsystem } from '../engine/tools/ssh.js'; import { __setActiveSessionLookup } from '../engine/agent-loop.js'; import { attachConsoleWs, createConsoleStatusRouter, createConsoleSessionRouter, type SimpleTask, type SimpleUser, } from './console-ws-api.js'; import { createConsoleAdminRouter } from './console-admin-api.js'; import { NotesRepository } from '../notes/notes-repository.js'; import { NotesService } from '../notes/notes-service.js'; import { createNotesApi } from './notes-api.js'; import { mountGateway, type GatewayMountHandle } from './gateway-mount.js'; import { readGatewayConfig } from '../gateway/config.js'; import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js'; const __filenameServer = fileURLToPath(import.meta.url); const __dirnameServer = dirname(__filenameServer); export interface CoreServerOptions { repo: Repository; worktreeDir?: string; configuredRepos?: string[]; generateTitle?: (body: string) => Promise; selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise; 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; /** * 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; } export interface SshConsoleDeps { registry: SessionRegistry; resolveUserFromUpgrade: (req: import('http').IncomingMessage) => Promise; resolveTask: (taskId: string, user: SimpleUser) => Promise; resolveSshAccess: ( user: SimpleUser, session: import('../ssh/console-session.js').ConsoleSession, task: SimpleTask, ) => Promise; denyPatterns: import('./console-ws-api.js').DenyPatternProvider; } 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; } { const { repo, worktreeDir } = opts; const app = express(); // リバースプロキシ背後で 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 appConfig = (() => { try { return opts.configManager?.getConfig() ?? null; } catch { return null; } })(); const metricsCfg = appConfig?.provider?.metrics; let workerMetrics: WorkerMetrics | null = null; // Phase 3c: hoist the promRegistry handle out of the metrics-enabled // block so the same-process gateway mount below can register its // gateway_* counters into the same /metrics endpoint (one scrape // serves both worker and gateway). Null when metrics are disabled. let sharedPromRegistry: import('prom-client').Registry | null = null; if (metricsCfg?.enabled !== false) { const prefix = metricsCfg?.prefix ?? 'aao_worker'; sharedPromRegistry = createWorkerRegistry(prefix); workerMetrics = createWorkerMetrics(sharedPromRegistry, prefix); // Phase 3b post-review: gate /metrics on (1) bearer token if set, // (2) otherwise client-IP allowlist (default localhost). Labels // like `worker_id` / `backend_id` would leak otherwise. const metricsAuth = { bearerToken: metricsCfg?.bearerToken, allowedHosts: metricsCfg?.allowedHosts, }; app.get('/metrics', createMetricsHandler(sharedPromRegistry, metricsAuth)); const authMode = metricsAuth.bearerToken ? 'bearer' : `ip-allowlist=${(metricsAuth.allowedHosts ?? ['127.0.0.1', '::1', 'localhost']).join(',')}`; logger.info(`[bridge] worker metrics enabled prefix=${prefix} auth=${authMode}`); } else { logger.info('[bridge] worker metrics disabled (provider.metrics.enabled=false)'); } // 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 === // Auth activates when a provider is COMPLETELY configured (clientId + // clientSecret + callbackUrl, plus baseUrl for Gitea). // // FAIL CLOSED on a partial config: if the operator clearly INTENDED auth // (a provider has a client_id) but it's incomplete (typo'd / missing // secret/callback), we must NOT silently fall back to no-auth — that would // fail OPEN and expose /api/local, /api/config, etc. without authentication. // Refuse to start instead, with a clear message. (A bare `auth` block with no // client_id at all is treated as genuine no-auth mode.) const _authProviders = opts.authConfig?.providers; const authUsable = isProviderConfigured(_authProviders?.google, 'google') || isProviderConfigured(_authProviders?.gitea, 'gitea'); // "Intended" = ANY OAuth field is present on a provider (not just client_id). // Saving only client_secret/callback_url/base_url, or clearing client_id by // mistake, must still fail closed rather than drop to no-auth. const _hasAnyField = (p?: { clientId?: string; clientSecret?: string; callbackUrl?: string; baseUrl?: string }) => !!(p?.clientId || p?.clientSecret || p?.callbackUrl || p?.baseUrl); const authIntended = _hasAnyField(_authProviders?.google) || _hasAnyField(_authProviders?.gitea); // Local email+password is a first-class auth mode: when enabled, auth is // active even without any OAuth provider. const localEnabled = !!opts.authConfig && isLocalEnabled(opts.authConfig); // Fail closed on a partial OAuth config ONLY when it would otherwise drop to // no-auth. If local auth is on, auth is already active (no fail-open), so a // half-configured OAuth provider just stays inactive rather than aborting boot. if (authIntended && !authUsable && !localEnabled) { throw new Error( '[auth] auth is partially configured: a provider has a client_id but is missing ' + 'client_secret / callback_url (Gitea also needs base_url). Refusing to start in an ' + 'insecure no-auth state — complete the provider config or remove it from config.yaml.', ); } const authActive = authUsable || localEnabled; if (!authActive) { // No-auth single-user mode: per-user rows are owned by the synthetic // 'local' id, and several tables FK to users(id) (ssh_user_deks, // browser_session_profiles, …). Seed the 'local' user row so those inserts // succeed — without it, creating an SSH connection failed with create_failed. repo.ensureLocalUser(); } let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined; if (authActive) { // Idempotently seed the shared `local` system admin (id='local', the same // owner the no-auth path uses) so an existing single-user / no-auth // deployment gains a login mid-stream and keeps its `local`-owned data. const bootstrap = opts.authConfig?.local?.bootstrapAdmin; if (localEnabled && bootstrap?.email && bootstrap?.password) { repo.upsertLocalSystemAdmin({ email: bootstrap.email, password: bootstrap.password }); logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`); } const auth = setupAuth( repo, opts.authConfig!, () => { const b = resolveBranding(opts.configManager); return { appName: b.appName, loginPageTitle: b.loginPageTitle }; }, ); const { sessionMiddleware, passportInit, passportSession, authRouter } = auth; authenticateUpgrade = auth.authenticateUpgrade; // Global session + passport middleware (BEFORE all routes) app.use(sessionMiddleware); app.use(passportInit); app.use(passportSession); // Auth routes (unauthenticated access allowed) app.use('/auth', authRouter); // /api/auth/me endpoint app.get('/api/auth/me', requireAuth, (req: Request, res: Response) => { res.json(req.user); }); // Self-service password change for local accounts (see auth.ts). app.post('/api/auth/password', requireAuth, express.json(), buildChangePasswordHandler(repo)); // Protect all API routes (except /api/version and /health) app.use('/api/local', requireAuth); app.use('/api/repos', requireAuth); // /api/workers exposes endpoint URLs + proxy backend probing. Without // auth, unauthenticated callers could (a) enumerate worker endpoints // and (b) trigger upstream `/v1/models` fetches against any // attacker-influenced endpoint (SSRF amplifier). Gate behind requireAuth // — admin-only is unnecessary because the responses already strip // sensitive fields (apiKey), but anonymous access must be blocked. app.use('/api/workers', requireAuth); // Admin-only routes app.use('/api/config', requireAdmin); // /api/pieces: any authenticated user can GET (visibility-filtered); // per-piece write authz (built-in/global-custom → admin, user-custom → owner) // is enforced inside pieces-api.ts handlers. app.use('/api/pieces', requireAuth); // Scheduled tasks: any authenticated user can create/list (visibility-filtered). // PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14). app.use('/api/scheduled-tasks', requireAuth); } // 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'); // AAO Gateway Phase 2a: admin-only CRUD over gateway_virtual_keys. // Enabled regardless of gateway.enabled so an admin can prep keys // before flipping the gateway on. // // SECURITY: this endpoint mints `sk-aao-*` bearer tokens that grant // access to the LLM gateway. Unlike the generic admin-api (which // exposes user-management read paths that are safe to surface in // auth-disabled local dev), key issuance has direct production // impact. If we mounted it without `requireAdmin` when auth is off, // any caller reaching the server could POST to /api/admin/gateway/keys // and walk away with a valid gateway bearer. Refuse to mount instead // — operators who want key management MUST configure auth.providers. if (authActive) { app.use( '/api/admin/gateway/keys', express.json({ limit: '4kb' }), requireAdmin, createAdminGatewayApi({ repo, // Already gated above; pass a passthrough so the router doesn't // try to double-guard (which would just be a no-op anyway). requireAdmin: (_req, _res, next) => next(), getUserId: (req) => { const u = (req as import('express').Request & { user?: { id?: string }; }).user; return u?.id ?? null; }, // Phase 3a F4: when the admin server runs in the same process // as the gateway, gateway/bootstrap.ts hangs the shared cache // off the Repository so mutations invalidate live cache state. // Cross-process setups omit this; the cache's 5s TTL bounds // the worst-case stale window. keyCache: (repo as unknown as { __gatewayKeyCache?: import('../gateway/key-cache.js').KeyCache }) .__gatewayKeyCache, // Phase 3b post-review: same-process gateway hangs its metrics // handle on the Repository so admin mutations can drop the // per-key gauge labels (revoke/rotate/delete). No-op in // cross-process deploys (the gateway process owns its own // registry there). gatewayMetrics: (repo as unknown as { __gatewayMetrics?: import('../metrics/gateway-metrics.js').GatewayMetrics }) .__gatewayMetrics, }), ); } else { logger.warn( '[admin-gateway] /api/admin/gateway/keys NOT mounted (auth disabled). ' + 'Configure auth.providers in config.yaml to enable virtual key management.', ); } // /api/users/me/* routes — current viewer info (gated by requireAuth when auth is active) mountUsersApi(app, repo, authActive); // --- Share API (public + authenticated routes) --- mountShareApi(app, repo); // Redirect root to UI app.get('/', (_req: Request, res: Response) => { res.redirect('/ui'); }); const uiDistPath = resolve(join(__dirnameServer, '../../ui/dist')); if (existsSync(uiDistPath)) { app.use('/ui', express.static(uiDistPath)); app.get('/ui/*', (_req, res) => { res.sendFile(join(uiDistPath, 'index.html')); }); } // Version endpoint app.get('/api/version', async (_req: Request, res: Response) => { let version = 'dev'; try { const mod = await import('../generated/version.js'); version = mod.APP_VERSION; } catch { try { const { execSync } = await import('child_process'); version = execSync("TZ=UTC git log -1 --format=%cd --date=format:'%Y%m%d.%H%M%S'", { encoding: 'utf-8' }).trim(); } catch { // keep 'dev' } } res.json({ version }); }); app.get('/api/repos', (_req: Request, res: Response) => { try { const reposFromJobs = repo.getDistinctRepos(); const reposFromConfig = (opts.configuredRepos ?? []).filter(Boolean); const repos = Array.from(new Set([...reposFromConfig, ...reposFromJobs])).sort(); res.json({ repos }); } catch { res.status(500).json({ error: 'Failed to fetch repos' }); } }); // 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()); // Surfaces per-user MCP servers into /api/tools so the Piece allowed_tools // editor can include them. Populated by the MCP block below when the // subsystem initialises; remains null otherwise. let mcpCatalogDeps: import('./tools-api.js').McpCatalogDeps | null = null; // MCP subsystem (gated on MCP_ENCRYPTION_KEY) { if (isKeyConfigured()) { const mcpConfig = mergeMcpConfig(loadConfig().mcp); const mcpRegistry = createRegistry(repo.getDb()); const mcpTokenManager = createTokenManager(repo.getDb(), { doRefresh: async (serverId: string, refreshToken: string) => { const server = mcpRegistry.getDecrypted(serverId); if (!server || !server.tokenEndpoint) { throw new Error(`server or token endpoint missing for ${serverId}`); } return refreshAccessToken({ tokenEndpoint: server.tokenEndpoint, clientId: server.oauthClientId, clientSecret: server.oauthClientSecret, refreshToken, }); }, }); const mcpToolCache = createToolCache(repo.getDb(), mcpConfig.toolCacheTtlSeconds); const mcpAggregator = createAggregator({ registry: mcpRegistry, tokenManager: mcpTokenManager, toolCache: mcpToolCache, executeCall: async (args) => { const server = mcpRegistry.getDecrypted(args.serverId); if (!server) return { output: `未登録の MCP サーバー: ${args.serverId}`, isError: true }; const { client, close } = await createMcpClient(server, args.accessToken, { callTimeoutMs: mcpConfig.callTimeoutSeconds * 1000, allowPrivateAddresses: mcpConfig.allowPrivateAddresses, }); try { return await executeMcpCall({ client, serverId: args.serverId, toolName: args.toolName, input: args.input, ctx: args.ctx, }); } finally { await close(); } }, }); setMcpAggregator(mcpAggregator); setMcpToolLookup((serverId, toolName) => mcpToolCache.get(serverId, toolName)); setDashboardRepo(repo); opts.workerManager?.setMcpDeps({ tokenManager: mcpTokenManager }); // Expose MCP enumeration to /api/tools so the Piece allowed_tools editor // can list per-user MCP tools alongside builtin ones. Methods on the // registry/tokenManager/toolCache surfaces are already DB-backed and // safe to call per-request. mcpCatalogDeps = { registry: { listEnabledForUser: (uid) => mcpRegistry.listEnabledForUser(uid) }, tokenManager: { hasToken: (uid, sid) => mcpTokenManager.hasToken(uid, sid) }, toolCache: { getAllForServers: (ids) => mcpToolCache.getAllForServers(ids) }, }; const callbackBaseUrl = deriveCallbackBaseUrl(opts.authConfig); app.use( '/auth/mcp', createMcpOauthRouter({ db: repo.getDb(), registry: mcpRegistry, tokenManager: mcpTokenManager, pendingTtlMinutes: mcpConfig.oauthPendingTtlMinutes, getCallbackBaseUrl: () => callbackBaseUrl, getAuthenticatedUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null, resumeWaitingJobs: (uid, sid) => { repo.resumeMcpWaitingJobs(uid, sid); }, listToolsAfterAuth: async (serverId: string, accessToken: string) => { const server = mcpRegistry.getDecrypted(serverId); if (!server) return; const { client, close } = await createMcpClient(server, accessToken, { callTimeoutMs: mcpConfig.callTimeoutSeconds * 1000, allowPrivateAddresses: mcpConfig.allowPrivateAddresses, }); try { const list = (await client.listTools()) as { tools: Array<{ name: string; description?: string; inputSchema?: unknown }>; }; mcpToolCache.replaceForServer(serverId, list.tools); logger.info( `[mcp] auto list_tools after OAuth server=${serverId} count=${list.tools.length}`, ); } finally { await close(); } }, }), ); app.use('/api/mcp/servers', express.json(), createMcpAdminRouter({ db: repo.getDb(), registry: mcpRegistry, tokenManager: mcpTokenManager, toolCache: mcpToolCache, requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(), requireAuth: authActive ? requireAuth : (_req, _res, next) => next(), getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null, allowPrivateAddresses: mcpConfig.allowPrivateAddresses, })); app.use('/api/mcp/connections', express.json(), createMcpUserRouter({ db: repo.getDb(), registry: mcpRegistry, tokenManager: mcpTokenManager, toolCache: mcpToolCache, requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(), requireAuth: authActive ? requireAuth : (_req, _res, next) => next(), getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null, allowPrivateAddresses: mcpConfig.allowPrivateAddresses, })); app.use('/api/mcp/user-servers', express.json(), createMcpUserServersRouter({ db: repo.getDb(), registry: mcpRegistry, tokenManager: mcpTokenManager, toolCache: mcpToolCache, requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(), requireAuth: authActive ? requireAuth : (_req, _res, next) => next(), getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null, insecureLocalTestMode: false, allowPrivateAddresses: mcpConfig.allowPrivateAddresses, })); logger.info('[mcp] subsystem initialised'); } else { logger.warn('[mcp] MCP_ENCRYPTION_KEY not configured — MCP features disabled'); } } // SSH subsystem (gated on ssh.enabled AND MCP_ENCRYPTION_KEY) // Phase 5 (SSH Console): sshConsole is captured here so that // startCoreServer() can wire the WS upgrade hook to the http.Server // it eventually creates. null when SSH is disabled / failed init. let sshConsole: SshConsoleDeps | null = null; { const sshConfig = mergeSshConfig(loadConfig().ssh); if (!sshConfig.enabled) { setSshSubsystem(null); __setActiveSessionLookup(null); } else if (!isKeyConfigured()) { logger.warn('[ssh] MCP_ENCRYPTION_KEY not configured — SSH features disabled'); setSshSubsystem(null); __setActiveSessionLookup(null); } else { try { bootstrapSystemDek(repo.getDb()); verifySystemDek(repo.getDb()); const connectionRepo = createConnectionRepo(repo.getDb()); const grantsRepo = createGrantsRepo(repo.getDb()); const auditRepo = createAuditRepo(repo.getDb()); const abuseRepo = createAbuseRepo(repo.getDb(), { windowMinutes: sshConfig.abuseWindowMinutes, failureThreshold: sshConfig.abuseFailureThreshold, lockMinutes: sshConfig.abuseLockMinutes, }); const accessResolver = createAccessResolver(grantsRepo, { adminBypassesGrants: sshConfig.adminBypassesGrants, }); const forceUnlockLimiter = createAdminRateLimiter(FORCE_UNLOCK_LIMIT); // Hoisted above sshDeps so the grant-revocation hook can call // sessionRegistry.revokeAccessFor (introduced for Phase 5 hardening: // kick active WS viewers when their grant is deleted). const sessionRegistry = new SessionRegistry({ idleTimeoutMs: sshConfig.console.idleTimeoutSeconds * 1000, maxSessionDurationMs: sshConfig.console.maxSessionDurationSeconds * 1000, maxSessionsPerConnection: sshConfig.console.maxSessionsPerConnection, }); const sshDeps: SshApiDeps = { db: repo.getDb(), authActive, requireAuth: authActive ? requireAuth : (_req, _res, next) => next(), requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(), getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null, isAdmin: (req) => (req.user as { role?: string } | undefined)?.role === 'admin', getOrgIds: (req) => ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []), connectionRepo, grantsRepo, auditRepo, abuseRepo, accessResolver, maintenance: sshMaintenance, forceUnlockLimiter, encryptKeyMaterial: (ownerId, pem, passphrase) => { const { blob, keyVersion } = sshEncryptPrivateKey(repo.getDb(), ownerId, pem); const passphraseBlob = passphrase ? sshEncryptPrivateKey(repo.getDb(), ownerId, passphrase).blob : null; const fingerprint = sshComputeKeyFingerprint(pem, passphrase); const publicKey = sshFormatPublicKey(pem, passphrase); return { blob, passphraseBlob, keyVersion, fingerprint, publicKey }; }, decryptKeyMaterial: (ownerId, blob) => sshDecryptPrivateKey(repo.getDb(), ownerId, blob), decryptPassphrase: (ownerId, blob) => blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob) : null, generateKeypair: (keyType: SshGeneratedKeyType) => sshGenerateKeypair(keyType), derivePublicKey: (ownerId, blob, passphraseBlob) => { const pem = sshDecryptPrivateKey(repo.getDb(), ownerId, blob); const pass = passphraseBlob ? sshDecryptPrivateKey(repo.getDb(), ownerId, passphraseBlob) : null; try { return sshFormatPublicKey(pem, pass); } finally { pem.fill(0); if (pass) pass.fill(0); } }, sshTester: { async test({ connection, decryptedKey, passphrase, timeoutMs }) { const conn: SshResolvedConnection = { id: connection.id, ownerId: connection.ownerId, host: connection.host, port: connection.port, username: connection.username, privateKeyPem: decryptedKey, passphrase: passphrase ?? undefined, hostKeyB64: connection.hostKeyB64, hostKeyVerified: connection.hostKeyVerifiedAt !== null, allowPrivate: sshConfig.allowPrivateAddresses || connection.allowPrivateAddresses, }; return sshTest({ connection: conn, timeoutMs }); }, }, connectionTestTimeoutMs: sshConfig.callTimeoutSeconds * 1000, onAccessRevoked: ({ connectionId, userId }) => sessionRegistry.revokeAccessFor({ connectionId, userId, reason: 'access_revoked', }), }; app.use('/api/ssh/admin', express.json(), createSshAdminRouter(sshDeps)); app.use('/api/ssh', express.json(), createSshUserRouter(sshDeps)); // Phase 7: register the SSH tool subsystem so SshExec / SshUpload / // SshDownload tools can access the same repos / session primitives / // crypto wrappers that the HTTP layer uses. sessionRegistry is // constructed above (hoisted so sshDeps.onAccessRevoked can use it). // Captured in a local const (not just passed to setSshSubsystem) // so the user-initiated console-session REST endpoint can call the // shared openConsoleSession core with the EXACT same `sub` the // agent-facing console tools use — no second SshSubsystem. const sshSubsystem: SshSubsystem = { connectionRepo, auditRepo, abuseRepo, accessResolver, decryptKeyMaterial: (ownerId, blob) => sshDecryptPrivateKey(repo.getDb(), ownerId, blob), decryptPassphrase: (ownerId, blob) => blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob) : null, getUserAccess: (userId) => { const user = repo.getUserById(userId); const isAdmin = user?.role === 'admin'; const orgIds = resolveOrgIds(repo, userId); return { isAdmin, orgIds }; }, sshExec, sshUpload, sshDownload, maintenance: sshMaintenance, config: sshConfig, sessionRegistry, openShellChannel, }; setSshSubsystem(sshSubsystem); // Phase 4 (SSH Console): wire the registry into agent-loop so // buildSystemPrompt can auto-inject the live screen tail into // the LLM system prompt for movements that allow SshConsole*. __setActiveSessionLookup((taskId) => sessionRegistry.get(taskId)); // Phase 5 (SSH Console): start the periodic sweep so idle / // duration-cap sessions actually get closed. Without this, the // registry just holds sessions until shutdown. sessionRegistry.startSweepTimer(60_000); // Phase 5 (SSH Console): when SSH maintenance mode activates // (master-key rotation), close all live console sessions. They // would otherwise hold a decrypted DEK reference past the // rewrap window. The reason 'maintenance' is surfaced to the // WS client as the close cause. sshMaintenance.onEnter(async () => { const all = sessionRegistry.listAll(); for (const s of all) { await sessionRegistry.closeForTask(s.localTaskId, 'maintenance'); } }); // Capture WS / status deps for startCoreServer to wire up. const consoleDeps: SshConsoleDeps = { registry: sessionRegistry, resolveUserFromUpgrade: async (req) => { if (!authenticateUpgrade) { // No-auth single-user mode: synthesize a stable `local` admin // user so the Console terminal WS attaches (admin role makes // the null-owner no-auth task visible in resolveTask). Mirrors // the Console REST routers and notifications-api. return { id: 'local', role: 'admin' }; } const u = await authenticateUpgrade(req); return u ? { id: u.id, role: u.role } : null; }, resolveTask: async (taskId, user) => { const idNum = Number(taskId); if (!Number.isFinite(idNum)) return null; const viewer: Express.User = { id: user.id, email: '', name: null, avatarUrl: null, role: (user.role === 'admin' ? 'admin' : 'user'), status: 'active', orgIds: resolveOrgIds(repo, user.id), defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const task = await repo.getLocalTask(idNum, { viewer }); if (!task) return null; return { id: String(task.id), ownerId: task.ownerId ?? '', visibility: task.visibility, pieceName: task.pieceName, }; }, resolveSshAccess: async (user, session, task) => { const connection = connectionRepo.resolveConnection(session.connectionId); if (!connection) return false; const orgIds = resolveOrgIds(repo, user.id); const decision = accessResolver.resolveAccess({ connection, userId: user.id, isAdmin: user.role === 'admin', // Use the task's actual piece name so piece-specific grants in // ssh_connection_grants match (applies_to_all_pieces=0 case). // Bug pre-fix: hardcoded '' silently failed every piece-scoped grant. pieceName: task.pieceName, orgIds, }); return decision.allowed; }, denyPatterns: { async getPatterns(connectionId: string) { const c = connectionRepo.resolveConnection(connectionId); if (!c) return { deny: [], allow: [] }; const split = (s: string | null): string[] => s ? s.split('\n').map((x) => x.trim()).filter((x) => x.length > 0) : []; return { deny: split(c.commandDenyPatterns), allow: split(c.commandAllowPatterns), }; }, }, }; sshConsole = consoleDeps; // REST status endpoint: /api/local/tasks/:taskId/console/status app.use( '/api', createConsoleStatusRouter({ registry: sessionRegistry, authActive, requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(), resolveTask: consoleDeps.resolveTask, }), ); // REST user-initiated session-open endpoint: // POST /api/local/tasks/:taskId/console/session. Reuses the same // SshSubsystem + preflight the console tools use; the access gate // runs inside openConsoleSession against task.pieceName. app.use( '/api', express.json(), createConsoleSessionRouter({ sub: sshSubsystem, preflight: sshPreflight, authActive, requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(), resolveTask: consoleDeps.resolveTask, }), ); // Phase 6 (SSH Console): admin list + kill endpoints. The // `/api/admin` prefix already has `express.json()` mounted above // (see Admin user management API), so POST bodies parse correctly. app.use( '/api/admin', createConsoleAdminRouter({ registry: sessionRegistry, requireAdmin: authActive ? requireAdmin : (_req: Request, _res: Response, next: NextFunction) => next(), }), ); logger.info('[ssh] subsystem initialised'); } catch (e) { logger.error(`[ssh] init failed err=${String(e)}`); setSshSubsystem(null); __setActiveSessionLookup(null); } } } // --- Local tasks API --- mountLocalTasksApi(app, { repo, worktreeDir, authActive, generateTitle: opts.generateTitle, selectPiece: opts.selectPiece, 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, }); // --- Local files API --- mountLocalFilesApi(app, repo); // --- Subtask activity API --- app.use('/api/local/tasks', createSubtaskActivityRouter(repo)); // --- Subtask files API (listing MUST come before wildcard) --- mountSubtaskFilesApi(app, repo); // --- Job detail API --- // Gate on viewer: getJob returns null for jobs the caller cannot see (Task 10 + 16). // When auth is inactive, req.user is undefined → repository falls back to 1=1 (no filter). const jobDetailHandlers: express.RequestHandler[] = []; if (authActive) jobDetailHandlers.push(requireAuth); jobDetailHandlers.push(async (req: Request, res: Response) => { try { const viewer = (req.user as Express.User | undefined) ?? undefined; const job = await repo.getJob(req.params.jobId, viewer ? { viewer } : undefined); if (!job) { res.status(404).json({ error: 'Job not found' }); return; } res.json(job); } catch { res.status(500).json({ error: 'Failed to fetch job' }); } }); app.get('/api/jobs/:jobId', ...jobDetailHandlers); // 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). if (opts.configManager) { mountConfigApi(app, opts.configManager); } // 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) { mountPiecesApi(app, { piecesDir: opts.piecesDir, customPiecesDir: opts.customPiecesDir, userPiecesRootDir: loadConfig().userFolderRoot ?? './data/users', }); } mountToolsApi(app, { authActive, requireAuth, mcp: mcpCatalogDeps, }); if (opts.skillCatalog) { mountSkillsApi(app, { skillCatalog: opts.skillCatalog, requireAuth, requireAdmin, authActive, auditLog: async (jobId, action, actor, detail) => { try { await repo.addAuditLog(jobId, action, actor, detail); } catch {} }, }); } // 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, 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(); }, }); if (opts.scheduler) { app.use('/api/scheduled-tasks', express.json()); mountScheduledTasksApi(app, repo, opts.scheduler, { sessRepo, authActive }); } // Browser session API const browserSessionManager = SessionManager.isAvailable() ? new SessionManager(loadConfig().browser ?? {}) : 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 }), ); // Per-user folder REST API: list / read / write / delete with trash. const userFolderRoot = loadConfig().userFolderRoot ?? './data/users'; // Shared NotesService: one instance for the entire app lifetime. // getUserOrgIds uses the same repo.listUserGiteaOrgs pattern as SSH / MCP auth. const notesRepo = new NotesRepository(repo.getDb()); const notesService = new NotesService({ db: repo.getDb(), repo: notesRepo, userFolderRoot, getUserOrgIds: (userId) => repo.listUserGiteaOrgs(userId).map((o) => o.orgId), audit: (action, actor, target) => { try { repo.addAuditLog(null, action, actor, { target }); } catch (err) { logger.warn(`[notes-audit] failed: ${(err as Error).message}`); } }, }); app.use('/api/users/me', createUserFolderApi({ userFolderRoot, sessRepo, masterKeyPath, authActive, notesService })); // Notes knowledge-sharing REST API. app.use('/api/notes', createNotesApi({ service: notesService, authActive })); // Per-user memory entries REST API (GET/PUT/DELETE). app.use('/api/local/memory', createMemoryApi({ dataDir: userFolderRoot, authActive })); // Per-user reflection history REST API (GET/POST). app.use('/api/local/reflection', createReflectionApi({ dataDir: userFolderRoot, repo, authActive })); // 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(), }); backendStatusRegistry.start(); // Dashboard widget + worker status REST API. app.use('/api/local/dashboard', express.json(), createDashboardApi({ repo, getWorkers: () => loadConfig().provider.workers, authActive, backendStatusRegistry, })); // Phase 3c — same-process AAO Gateway mount. Always installs the // dynamic 404 gate; the gateway sub-app only comes alive when // `gateway.enabled: true` in config.yaml (or after an admin flips it // ON via Settings → Gateway Server). Pure no-op when no ConfigManager // is available (tests / scripts run without one). let gatewayMount: GatewayMountHandle | null = null; if (opts.configManager) { gatewayMount = mountGateway({ app, configManager: opts.configManager, repo, // Per CRITICAL-2: the gateway now owns its own BackendStatusRegistry // over gateway.backends[] rather than reusing the worker registry // (which probes provider.workers[] — different IDs). Same-host // double-probe is intentional and cheap. // Reuse the worker's prom-client registry so gateway counters // appear on the same /metrics endpoint (no port collision). promRegistry: sharedPromRegistry, metricsPrefix: 'aao_gateway', }); // Apply the boot-time config so a server starting with // `gateway.enabled: true` brings the gateway up immediately — // no need to re-save config from the UI. const bootGateway = readGatewayConfig(opts.configManager.getConfig()); gatewayMount.applyConfig(bootGateway).catch((e) => { logger.warn(`[bridge-gateway] initial applyConfig threw: ${e instanceof Error ? e.message : String(e)}`); }); // Drain in-flight gateway streams on process shutdown so SIGTERM // doesn't strand SSE clients with a half-written response. registerShutdownHook('bridge-gateway', async () => { try { await gatewayMount!.stop(); } catch { /* noop */ } }); } else { logger.info('[bridge-gateway] not mounted (no ConfigManager — hot reload unavailable)'); } // CRITICAL-3 fix: bridge `/health` fallback. Registered AFTER the // gateway gate so when gateway.enabled=true the gate dispatches // `/health` into the gateway sub-app (LiteLLM-compat // `healthy_endpoints` / `unhealthy_endpoints` JSON shape). When the // gateway is off, the gate falls through to here and the bridge // answers with the legacy `{status:'ok'}` shape ops scripts rely on. app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok' }); }); // Admin status endpoint for the Gateway Server UI. Read-only — the // form drives state changes through PUT /api/config (the // config-changed listener inside mountGateway picks them up). // Requires admin when auth is active; in auth-off dev mode the // endpoint is open like the rest of /api/admin/* health/status reads. { // Prefer the explicit listenPort threaded through CoreServerOptions // (startCoreServer always sets it) over the PORT env-var guess so // the status endpoint matches what the bridge actually bound. // env-var fallback is kept for callers that bypass startCoreServer. const envPortRaw = Number(process.env['PORT']); const envPort = Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : 9876; const actualPort = opts.listenPort ?? envPort; const statusRouter = createAdminGatewayStatusRouter({ mount: gatewayMount, configManager: opts.configManager ?? null, workerPort: actualPort, }); if (authActive) { app.use('/api/admin/gateway/status', requireAdmin, statusRouter); } else { app.use('/api/admin/gateway/status', statusRouter); } } // 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 app.use('/novnc', createNovncRouter()); // CAPTCHA Pool は admin、Task Session はタスク visibility で認可。 // novnc-proxy は Repository を直接知らずに済むよう、判定ロジックは // ここでクロージャに包んで渡す。 const authorizeNovncSession: import('./novnc-proxy.js').NovncSessionAuthorizer = async (session, user) => { if (session.kind === 'pool') { return user.role === 'admin'; } if (session.kind === 'task' && session.taskId) { const taskIdNum = Number(session.taskId); if (!Number.isFinite(taskIdNum)) return false; const task = await repo.getLocalTask(taskIdNum); if (!task) return false; return canUserSeeTask(user, task); } // legacy: kind 未設定 / taskId なし → 旧来の owner-or-admin const isOwner = session.userId === user.id; return isOwner || user.role === 'admin'; }; return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount }; } 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) => { logger.error(`Unhandled error: ${err.message}`); res.status(500).json({ error: 'Internal server error' }); }); return app; } function deriveCallbackBaseUrl(authConfig: AuthConfig | undefined): string { const providers = authConfig?.providers; if (providers) { for (const p of Object.values(providers) as Array<{ callbackUrl?: string } | undefined>) { if (!p) continue; const cb = p.callbackUrl; if (cb) { try { return new URL(cb).origin; } catch { // ignore malformed URL } } } } const port = Number(process.env.PORT ?? 9876); return `http://localhost:${port}`; } export function startCoreServer(opts: CoreServerOptions, port: number = 9876): void { const { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, // 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); const host = process.env['HOST'] ?? '0.0.0.0'; const server = finalApp.listen(port, host, () => { logger.info(`Core server listening on ${host}:${port}`); }); // 起動と同時に 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 }); // 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); // 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(); }); // 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(); }