sync: update from private repo (ddadfd71)
CI / build-and-test (push) Waiting to run

This commit is contained in:
oss-sync
2026-07-08 23:35:00 +00:00
parent b1292e34b2
commit 77ee3bc426
187 changed files with 19918 additions and 10938 deletions
+69 -456
View File
@@ -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,