This commit is contained in:
+313
-38
@@ -1,3 +1,5 @@
|
||||
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';
|
||||
@@ -5,6 +7,7 @@ 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';
|
||||
@@ -17,6 +20,9 @@ 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';
|
||||
@@ -26,20 +32,29 @@ 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 { requireAuth, requireAdmin } from './auth.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';
|
||||
@@ -47,28 +62,38 @@ 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 { mountAppHarnessApi } from './app-harness-api.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, buildProxyProbe } from '../engine/backend-probes.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 { setupA2aSubsystem } from './a2a-subsystem.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;
|
||||
@@ -76,6 +101,15 @@ export interface CoreServerOptions {
|
||||
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;
|
||||
@@ -83,6 +117,23 @@ export interface CoreServerOptions {
|
||||
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;
|
||||
@@ -99,6 +150,8 @@ export interface CoreServerOptions {
|
||||
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;
|
||||
@@ -110,6 +163,15 @@ export interface CoreServerOptions {
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +193,8 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
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
|
||||
@@ -158,6 +222,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// 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) {
|
||||
@@ -201,6 +266,16 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// 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).
|
||||
@@ -210,6 +285,9 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
configManager: opts.configManager,
|
||||
localEnabled,
|
||||
tlsEnabled: serverCfg.tls.enabled,
|
||||
beforeApiGuards: appHarnessAuthMiddleware(),
|
||||
sessionSecret,
|
||||
sessionSecretPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,6 +295,34 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
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 });
|
||||
@@ -230,7 +336,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
mountUsersPickableApi(app, repo, authActive);
|
||||
|
||||
// --- Share API (public + authenticated routes) ---
|
||||
mountShareApi(app, repo);
|
||||
mountShareApi(app);
|
||||
|
||||
// --- 公開アプリ共有 API(read-only・認証なし)---
|
||||
// space-api と同じワークスペース解決(loadConfig().worktreeDir)を使う。
|
||||
@@ -248,15 +354,63 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// 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,
|
||||
callbackBaseUrl: deriveCallbackBaseUrl(opts.authConfig),
|
||||
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,
|
||||
@@ -266,6 +420,8 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
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.
|
||||
@@ -293,13 +449,26 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// --- 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 側(認証有効時のみ)で配線。
|
||||
@@ -308,12 +477,20 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
}
|
||||
|
||||
// --- A2A OAuth2 Authorization Server (see a2a-subsystem.ts; no-op unless a2a.enabled) ---
|
||||
const { limiter: a2aLimiter } = setupA2aSubsystem(app, { repo, authActive });
|
||||
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, a2aLimiter });
|
||||
setupChatSubsystem(app, { repo, authActive, getA2aLimiter: () => a2aHandle.getLimiter() });
|
||||
|
||||
// --- Subtask files API (listing MUST come before wildcard) ---
|
||||
mountSubtaskFilesApi(app, repo);
|
||||
@@ -335,6 +512,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
configManager: opts.configManager,
|
||||
authActive,
|
||||
listenPort: opts.listenPort,
|
||||
getA2aState: () => a2aHandle.getState(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -392,6 +570,8 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
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) => {
|
||||
@@ -404,15 +584,30 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
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 });
|
||||
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 ?? {})
|
||||
? 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));
|
||||
@@ -474,7 +669,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
|
||||
// App-harness token-auth middleware (Task 5).
|
||||
// Must run BEFORE space-api so that req.user is populated before viewerOf() reads it.
|
||||
mountAppHarnessApi(app);
|
||||
if (!authActive) mountAppHarnessApi(app);
|
||||
|
||||
// スペース(個人 / 案件)CRUD REST API。
|
||||
// NOTE: ここでグローバルな express.json() は付けない。デフォルトの ~100kb 上限が
|
||||
@@ -483,9 +678,10 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
app.use('/api/local/spaces', createSpaceApi({
|
||||
repo,
|
||||
dataRoot: userFolderRoot,
|
||||
worktreeDir: loadConfig().worktreeDir ?? './data/worktrees',
|
||||
worktreeDir: worktreeDir ?? loadConfig().worktreeDir ?? './data/worktrees',
|
||||
authActive,
|
||||
getPythonPackagesConfig: () => loadConfig().pythonPackages,
|
||||
getSafetyConfig: () => loadConfig().safety,
|
||||
webhookService: opts.webhookService ?? null,
|
||||
}));
|
||||
|
||||
@@ -499,13 +695,16 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
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,
|
||||
}));
|
||||
@@ -520,6 +719,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
authActive,
|
||||
listenPort: opts.listenPort,
|
||||
});
|
||||
opts.onGatewayMount?.(gatewayMount);
|
||||
|
||||
// Wire user-folder tool deps so RunUserScript / ListUserAssets can decrypt sessions.
|
||||
setUserFolderToolDeps({
|
||||
@@ -552,7 +752,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
const authorizeNovncSession: import('./novnc-proxy.js').NovncSessionAuthorizer =
|
||||
buildNovncSessionAuthorizer(repo);
|
||||
|
||||
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive, serverConfig: serverCfg };
|
||||
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
|
||||
@@ -587,28 +787,87 @@ export function finalizeServer(app: express.Application): express.Application {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Same precedence as the listener (PORT env > server.port > default) so the
|
||||
// derived OAuth callback origin matches the port the bridge actually binds.
|
||||
const port = resolveListenPort(process.env.PORT, loadConfig().server?.port);
|
||||
return `http://localhost:${port}`;
|
||||
/**
|
||||
* 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),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function startCoreServer(opts: CoreServerOptions, port: number = 9876): void {
|
||||
/**
|
||||
* タスクのファイルのごみ箱の掃除に渡す設定を組み立てる (#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,
|
||||
@@ -619,6 +878,7 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
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
|
||||
@@ -642,11 +902,13 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
// 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 — enabling auth/TLS before exposing it is the operator's
|
||||
// responsibility (see docs/docker.md, SECURITY.md). Restore local-only by
|
||||
// pinning the compose mapping back to "127.0.0.1:9876:9876".
|
||||
const host = process.env['HOST'] ?? '127.0.0.1';
|
||||
const isLoopbackBind = host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
||||
// 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.
|
||||
@@ -729,6 +991,12 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
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,
|
||||
@@ -740,6 +1008,8 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
}
|
||||
|
||||
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
|
||||
@@ -765,10 +1035,15 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user