This commit is contained in:
+69
-456
@@ -1,47 +1,42 @@
|
||||
import express, { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join, resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
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 { mountConfigApi } from './config-api.js';
|
||||
import {
|
||||
mountSetupApi,
|
||||
buildNoAuthConfigGate,
|
||||
newNoAuthConfigGateState,
|
||||
ensureSetupToken,
|
||||
clearSetupToken,
|
||||
} from './setup-api.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, resolveBranding } from './branding-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 { createUsageRouter } from './usage-api.js';
|
||||
import { SessionManager } from '../engine/browser-session.js';
|
||||
import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.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 { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, DEFAULT_SESSION_SECRET_PATH } from './auth.js';
|
||||
import { canUserSeeTask } from './visibility.js';
|
||||
import { requireAuth, requireAdmin } from './auth.js';
|
||||
import { resolveAuthActivation, mountAuthPipeline } from './auth-subsystem.js';
|
||||
import { mountAdminApi } from './admin-api.js';
|
||||
import { createAdminGatewayApi } from './admin-gateway-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 { 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 { mountSubtaskFilesApi } from './subtask-files-api.js';
|
||||
@@ -56,33 +51,23 @@ import { mountAppHarnessApi } from './app-harness-api.js';
|
||||
import { createCrossCalendarApi } from './cross-calendar-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 type { WorkerMetrics } from '../metrics/worker-metrics.js';
|
||||
import { setupMetricsSubsystem } from './metrics-subsystem.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 { attachConsoleWs } from './console-ws-api.js';
|
||||
import { mountGateway, type GatewayMountHandle } from './gateway-mount.js';
|
||||
import { readGatewayConfig } from '../gateway/config.js';
|
||||
import { createA2aOidcProvider, mountA2aOidc } from './a2a/oidc-provider.js';
|
||||
import { createA2aClientsAdminRouter } from './a2a/a2a-clients-admin-api.js';
|
||||
import { createA2aRouter, createSpaceA2aAdminRouter } from './a2a/a2a-api.js';
|
||||
import { mountA2aRequestHandler } from './a2a/request-handler.js';
|
||||
import { A2aTaskReconciler } from './a2a/reconciler.js';
|
||||
import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a/delegations-api.js';
|
||||
import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js';
|
||||
import type { GatewayMountHandle } from './gateway-mount.js';
|
||||
import { mountBridgeGateway } from './bridge-gateway-mount.js';
|
||||
import { setupA2aSubsystem } from './a2a-subsystem.js';
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { X509Certificate } from 'crypto';
|
||||
import { mergeServerConfig, resolveListenPort } from '../server/config.js';
|
||||
import { resolveTlsOptions } from '../net/tls-options.js';
|
||||
import { createHttpRedirectServer } from '../net/http-redirect.js';
|
||||
|
||||
const __filenameServer = fileURLToPath(import.meta.url);
|
||||
const __dirnameServer = dirname(__filenameServer);
|
||||
|
||||
export interface CoreServerOptions {
|
||||
repo: Repository;
|
||||
worktreeDir?: string;
|
||||
@@ -179,35 +164,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// 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)');
|
||||
}
|
||||
const { workerMetrics, sharedPromRegistry } = setupMetricsSubsystem(app, opts.configManager);
|
||||
|
||||
// JSON body parser for Config/Pieces/Memory/Reflection API endpoints
|
||||
app.use('/api/config', express.json());
|
||||
@@ -215,47 +172,10 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
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();
|
||||
}
|
||||
// === 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
|
||||
@@ -277,135 +197,24 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
hstsPolicy = serverCfg.tls.enabled && serverCfg.tls.hsts ? 'enable' : 'clear';
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Compose effective secureCookie: native TLS termination also requires
|
||||
// the secure flag on session cookies, even when no upstream proxy is
|
||||
// present. IMPORTANT: trust-proxy (line ~191) stays keyed on the
|
||||
// ORIGINAL opts.authConfig.secureCookie — native TLS must NOT enable it.
|
||||
const effectiveSecureCookie = computeEffectiveSecureCookie(
|
||||
!!opts.authConfig?.secureCookie,
|
||||
serverCfg.tls.enabled,
|
||||
);
|
||||
const authConfigForSetup = opts.authConfig
|
||||
? { ...opts.authConfig, secureCookie: effectiveSecureCookie }
|
||||
: opts.authConfig;
|
||||
|
||||
const auth = setupAuth(
|
||||
// Session/passport middleware + /auth routes + /api/* guards
|
||||
// (see auth-subsystem.ts — registered here, BEFORE all later mounts).
|
||||
authenticateUpgrade = mountAuthPipeline(app, {
|
||||
repo,
|
||||
authConfigForSetup!,
|
||||
() => {
|
||||
const b = resolveBranding(opts.configManager);
|
||||
return { appName: b.appName, loginPageTitle: b.loginPageTitle };
|
||||
},
|
||||
loadConfig()?.secrets?.sessionSecretPath ?? DEFAULT_SESSION_SECRET_PATH,
|
||||
);
|
||||
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);
|
||||
authConfig: opts.authConfig,
|
||||
configManager: opts.configManager,
|
||||
localEnabled,
|
||||
tlsEnabled: serverCfg.tls.enabled,
|
||||
});
|
||||
|
||||
// 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);
|
||||
app.use('/api/usage', requireAuth);
|
||||
// 横断カレンダー: 認証時はログイン必須。可視スペースの絞り込みは
|
||||
// Repository.getCrossSpaceCalendarMonth が viewer 単位で行う。
|
||||
app.use('/api/calendar', 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.',
|
||||
);
|
||||
}
|
||||
// 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);
|
||||
@@ -422,26 +231,8 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// space-api と同じワークスペース解決(loadConfig().worktreeDir)を使う。
|
||||
mountAppShareApi(app, repo, worktreeDir ?? loadConfig().worktreeDir ?? './data/worktrees');
|
||||
|
||||
// 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'));
|
||||
});
|
||||
// Headless test-harness page for workspace-app E2E (Task 5/6).
|
||||
// Must be registered BEFORE any SPA catch-all and BEFORE space-api routes
|
||||
// so that the app-harness auth middleware (mounted below) sees the request.
|
||||
// The harness JS/CSS chunks are served by the existing /ui static mount
|
||||
// because Vite builds them with base: '/ui/' → /ui/assets/… paths.
|
||||
app.get('/app-harness', (_req, res) => {
|
||||
res.sendFile(join(uiDistPath, 'app-harness.html'));
|
||||
});
|
||||
}
|
||||
// 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.
|
||||
@@ -486,6 +277,8 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
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,
|
||||
});
|
||||
|
||||
// --- Local files API ---
|
||||
@@ -498,75 +291,14 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// 横断(全スペース)カレンダー: GET /api/calendar/cross
|
||||
app.use('/api/calendar', createCrossCalendarApi({ repo, authActive }));
|
||||
|
||||
// --- A2A OAuth2 Authorization Server ---
|
||||
const a2aCfg = (loadConfig() as any).a2a ?? {};
|
||||
if (a2aCfg.enabled) {
|
||||
if (!authActive) {
|
||||
logger.warn('[a2a-oidc] a2a.enabled but auth is not active; consent flow requires login and will reject all requests');
|
||||
}
|
||||
try {
|
||||
const sessionSecretPath = loadConfig()?.secrets?.sessionSecretPath ?? DEFAULT_SESSION_SECRET_PATH;
|
||||
const cookieKeys = [readFileSync(sessionSecretPath, 'utf-8').trim()];
|
||||
const secretsDir = dirname(resolve(sessionSecretPath));
|
||||
const provider = createA2aOidcProvider({
|
||||
repo,
|
||||
secretsDir,
|
||||
issuer: a2aCfg.issuer,
|
||||
resourceAudience: a2aCfg.resourceAudience,
|
||||
cookieKeys,
|
||||
});
|
||||
mountA2aOidc(app, provider, { repo, resourceAudience: a2aCfg.resourceAudience });
|
||||
app.use('/api/admin/a2a/clients', express.json(), requireAdmin, createA2aClientsAdminRouter(repo, authActive));
|
||||
// Agent Card ルータ (base card = public, extended card = self-authenticates via Bearer)
|
||||
// baseUrl は resourceAudience の origin から導く(例: https://maestro.example.com/a2a → https://maestro.example.com)
|
||||
// version は '1.0.0' 固定(生成済みバージョンファイルが存在しないため定数を使用)
|
||||
const a2aBaseUrl = (() => {
|
||||
try { return new URL(String(a2aCfg.resourceAudience)).origin; } catch { return String(a2aCfg.issuer); }
|
||||
})();
|
||||
app.use(createA2aRouter({ provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0' }));
|
||||
// JSON-RPC エンドポイント /a2a(SDK DefaultRequestHandler 経由)
|
||||
// /a2a は /api 配下でないため requireAuth に掛からない。認証は UserBuilder が担う。
|
||||
mountA2aRequestHandler(app, { provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0' });
|
||||
// bridge は単一プロセス前提。複数プロセスが同一 DB を共有する構成では
|
||||
// lease が必要になる(将来課題)。
|
||||
const a2aReconciler = new A2aTaskReconciler({ repo });
|
||||
a2aReconciler.start();
|
||||
registerShutdownHook('a2a-reconciler', async () => { a2aReconciler.stop(); });
|
||||
logger.info('[a2a] task reconciler started');
|
||||
// スペース A2A スキル許可リスト管理 API (/api/local は requireAuth 配下)
|
||||
app.use('/api/local/spaces', createSpaceA2aAdminRouter(repo, authActive));
|
||||
// A2A 委任一覧・取消 API
|
||||
// /api/local は L329 で requireAuth 済みのため追加ゲート不要
|
||||
app.use('/api/local/a2a/delegations', express.json(), createUserDelegationsRouter(repo, authActive));
|
||||
app.use('/api/admin/a2a/delegations', express.json(), requireAdmin, createAdminDelegationsRouter(repo, authActive));
|
||||
logger.info('[a2a-oidc] a2a authorization server enabled');
|
||||
} catch (err) {
|
||||
logger.warn(`[a2a-oidc] failed to mount a2a authorization server: ${err}`);
|
||||
}
|
||||
}
|
||||
// --- A2A OAuth2 Authorization Server (see a2a-subsystem.ts; no-op unless a2a.enabled) ---
|
||||
setupA2aSubsystem(app, { repo, authActive });
|
||||
|
||||
// --- 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);
|
||||
// --- 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
|
||||
@@ -575,58 +307,14 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// 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) ===
|
||||
// Token lifecycle + endpoints. The no-auth `/api/config` mutation gate
|
||||
// (Codex P1 #3) must register BEFORE mountConfigApi so it intercepts
|
||||
// PUT/POST while a setup window is open. See setup-api.ts.
|
||||
// === Browser setup wizard (fresh no-auth install onboarding) + Config API ===
|
||||
// (see setup-config-mount.ts)
|
||||
if (opts.configManager) {
|
||||
const setupDataDir = join(process.cwd(), 'data');
|
||||
const setupListenPort =
|
||||
opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port);
|
||||
// Synchronously seed the gate state to 'pending' (fail-closed for auth
|
||||
// writes) BEFORE the async lifecycle below resolves, so a request arriving
|
||||
// in the boot window can't slip an auth bootstrap through (Codex P1 #2).
|
||||
const setupGateState = newNoAuthConfigGateState();
|
||||
|
||||
// Token lifecycle (needs the async setup-lib import). Created only on a
|
||||
// fresh no-auth install (needs-setup); cleared otherwise so existing
|
||||
// configured deployments keep their prior open behavior. On failure the
|
||||
// gate stays 'pending' (fail-closed), never silently open.
|
||||
void (async () => {
|
||||
try {
|
||||
if (authActive) {
|
||||
clearSetupToken(setupDataDir);
|
||||
return;
|
||||
}
|
||||
const { isLlmConfigured } = await import('../../scripts/setup-lib.mjs');
|
||||
const workers = opts.configManager?.getConfig().provider?.workers ?? [];
|
||||
if (!isLlmConfigured(workers)) {
|
||||
const token = ensureSetupToken(setupDataDir);
|
||||
setupGateState.mode = 'token-required';
|
||||
logger.info(`[setup] open the UI and enter this setup token: ${token}`);
|
||||
logger.info('[setup] llm configured=false → wizard active');
|
||||
} else {
|
||||
clearSetupToken(setupDataDir);
|
||||
setupGateState.mode = 'open';
|
||||
logger.info('[setup] llm configured=true → wizard off');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`[setup] token lifecycle failed: ${String(e)}`);
|
||||
}
|
||||
})();
|
||||
|
||||
// No-auth /api/config auth-write gate (only mounted in no-auth mode).
|
||||
if (!authActive) {
|
||||
app.use('/api/config', buildNoAuthConfigGate(setupDataDir, setupGateState));
|
||||
}
|
||||
|
||||
mountSetupApi(app, opts.configManager, {
|
||||
mountSetupAndConfigApis(app, {
|
||||
configManager: opts.configManager,
|
||||
authActive,
|
||||
listenPort: setupListenPort,
|
||||
dataDir: setupDataDir,
|
||||
listenPort: opts.listenPort,
|
||||
});
|
||||
|
||||
mountConfigApi(app, opts.configManager);
|
||||
}
|
||||
|
||||
// Branding 公開 API (GET は認証不要)。アップロード系は admin のみ。
|
||||
@@ -800,75 +488,17 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
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' });
|
||||
// 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,
|
||||
});
|
||||
|
||||
// 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 actualPort = opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port);
|
||||
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,
|
||||
@@ -895,37 +525,17 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// 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;
|
||||
const spaceAccessible = task.spaceId ? repo.userCanViewSpace(task.spaceId, user) : false;
|
||||
return canUserSeeTask(user, task, spaceAccessible);
|
||||
}
|
||||
// legacy: kind 未設定 / taskId なし → 旧来の owner-or-admin
|
||||
const isOwner = session.userId === user.id;
|
||||
return isOwner || user.role === 'admin';
|
||||
};
|
||||
// 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, serverConfig: serverCfg };
|
||||
}
|
||||
|
||||
/** Cookie `secure` must be set whenever the user-facing scheme is https —
|
||||
* via an upstream TLS proxy (secureCookie) OR native TLS termination. */
|
||||
export function computeEffectiveSecureCookie(secureCookie: boolean, tlsEnabled: boolean): boolean {
|
||||
return secureCookie || tlsEnabled;
|
||||
}
|
||||
// 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. */
|
||||
@@ -1004,12 +614,15 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
opts.workerManager.setWorkerMetrics(workerMetrics);
|
||||
}
|
||||
const finalApp = finalizeServer(app);
|
||||
// Safe-by-default bind: 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 is protected by the host-side
|
||||
// 127.0.0.1:9876 port mapping instead.
|
||||
// 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 — 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';
|
||||
// Use the config snapshot already resolved in createCoreServer (same port,
|
||||
|
||||
Reference in New Issue
Block a user