import { Router, Request, Response } from 'express'; import { SessionManager, type BrowserSession, CAPTCHA_POOL_SESSION_ID } from '../engine/browser-session.js'; import type { Repository } from '../db/repository.js'; import { logger } from '../logger.js'; import { buildNovncPath, isNovncStaticInstalled } from './novnc-proxy.js'; import { canUserSeeTask, canEditEntity } from './visibility.js'; import { loadConfig } from '../config.js'; /** * `available: false` なレスポンスに添える reason コードを決める純関数。 * 優先順位: * 1. displayMode !== 'novnc' → 'headless_mode' (config で機能 OFF) * 2. SessionManager が null → 'display_unavailable' (noVNC スタック未導入) * 3. それ以外 → 'no_session' (まだ session が無いだけ) * * どのバイナリ (Xvfb/x11vnc/websockify) が欠けているかは漏らさない。 */ export function unavailableReason( displayMode: string | undefined, sessionManagerPresent: boolean, ): 'headless_mode' | 'display_unavailable' | 'no_session' { if ((displayMode ?? 'headless') !== 'novnc') return 'headless_mode'; if (!sessionManagerPresent) return 'display_unavailable'; return 'no_session'; } /** * 2026-05 redesign: CAPTCHA Pool (admin 専用) と Task Session (タスク * visibility ベース) を分離した API。 * * - GET /captcha-pool : admin only. Pool の noVNC パス + captchaPending * - DELETE /captcha-pool : admin only. Pool を destroy (次の CAPTCHA で再生成) * - GET /task-session/:taskId : visibility 通過なら novncPath、なければ available:false * - POST /task-session/:taskId/release: owner or admin. その taskId の session を destroy * - GET / : 自分が見えるタスクの task session 一覧 (Pool は除外) * - GET /:id : 直 sessionId 指定 (admin / 旧来 owner)。kind=='task' のときは taskId 経由を推奨 * - DELETE /:id : admin or task owner * * 旧 /search-session, POST / は廃止 (Plan の clean break)。 */ function isAdmin(req: Request): boolean { const user = req.user as Express.User | undefined; return user?.role === 'admin'; } function getUser(req: Request): Express.User | undefined { return req.user as Express.User | undefined; } /** auth 未設定 (dev モード) では req.user が undefined。その場合は全許可で互換維持 */ function isUnauthenticatedDev(req: Request): boolean { return getUser(req) === undefined; } function serializeTaskSession(session: BrowserSession) { return { id: session.id, kind: session.kind, taskId: session.taskId, state: session.state, novncPath: buildNovncPath(session.id), lockedByJobId: session.lockedByJobId, createdAt: session.createdAt.toISOString(), lastActiveAt: session.lastActiveAt.toISOString(), }; } /** * 指定 task session に user がアクセスできるかを判定する (visibility ベース)。 * Pool は admin only。dev モードは全許可。 */ async function canViewSession( req: Request, session: BrowserSession, repo: Repository, ): Promise { if (isUnauthenticatedDev(req)) return true; const user = getUser(req)!; if (user.role === 'admin') return true; if (session.kind === 'pool') return false; if (session.kind === 'task' && session.taskId) { const taskIdNum = Number(session.taskId); if (!Number.isFinite(taskIdNum)) return false; const task = await repo.getLocalTask(taskIdNum); return task ? canUserSeeTask(user, task) : false; } // 旧来 (kind 未設定): owner だけ return session.userId === user.id; } /** * 指定 task session を user が destroy / release できるかを判定する。 * - admin: 常に可 * - task owner: 可 * - dev モード: 可 */ async function canControlSession( req: Request, session: BrowserSession, repo: Repository, ): Promise { if (isUnauthenticatedDev(req)) return true; const user = getUser(req)!; if (user.role === 'admin') return true; if (session.kind === 'pool') return false; if (session.kind === 'task' && session.taskId) { const taskIdNum = Number(session.taskId); if (!Number.isFinite(taskIdNum)) return false; const task = await repo.getLocalTask(taskIdNum); if (!task) return false; return canEditEntity(user, task); } return session.userId === user.id; } export function createBrowserApi(sessionManager: SessionManager | null, repo: Repository): Router { const router = Router(); // sessionManager が null (Xvfb 等が無い環境) でも /captcha-pool / /task-session // は available: false を返したい。503 にしてしまうと UI 側でエラー扱いされてしまう。 if (!sessionManager) { router.get('/captcha-pool', (_req: Request, res: Response) => { const reason = unavailableReason(loadConfig().browser?.displayMode, false); res.json({ available: false, reason }); }); router.get('/task-session/:taskId', (_req: Request, res: Response) => { const reason = unavailableReason(loadConfig().browser?.displayMode, false); res.json({ available: false, reason }); }); router.all('*', (_req: Request, res: Response) => { res.status(503).json({ error: 'Browser sessions not available (missing system dependencies)' }); }); return router; } // --- CAPTCHA Pool (admin only) --- router.get('/captcha-pool', (req: Request, res: Response) => { if (!isUnauthenticatedDev(req) && !isAdmin(req)) { res.status(403).json({ error: 'Admin role required' }); return; } const pool = sessionManager.getSession(CAPTCHA_POOL_SESSION_ID); if (!pool) { const reason = unavailableReason(loadConfig().browser?.displayMode, true); res.json({ available: false, reason }); return; } if (!isNovncStaticInstalled()) { res.json({ available: false, reason: 'novnc_not_installed' }); return; } res.json({ available: true, sessionId: pool.id, novncPath: buildNovncPath(pool.id), display: pool.display, captchaPending: pool.captchaPending === true, createdAt: pool.createdAt.toISOString(), }); }); router.delete('/captcha-pool', async (req: Request, res: Response) => { if (!isUnauthenticatedDev(req) && !isAdmin(req)) { res.status(403).json({ error: 'Admin role required' }); return; } // Pool destroy 時は web.ts の persistentContexts も連動して破棄する // (Cookie の生残りで認証状態が混乱するのを防ぐ) try { const webMod = await import('../engine/tools/web.js') as { clearPersistentContexts?: () => void }; webMod.clearPersistentContexts?.(); } catch { /* ignore */ } await sessionManager.destroySession(CAPTCHA_POOL_SESSION_ID); res.json({ ok: true }); }); // --- Task Session (visibility-aware) --- router.get('/task-session/:taskId', async (req: Request, res: Response) => { const taskId = req.params.taskId; const session = sessionManager .listSessions() .find((s) => s.kind === 'task' && s.taskId === taskId); if (!session) { const reason = unavailableReason(loadConfig().browser?.displayMode, true); res.json({ available: false, reason }); return; } if (!(await canViewSession(req, session, repo))) { // 認可失敗は available: false にして session 存在情報を漏らさない。 // reason も session が無いとき相当 (存在を漏らさない) で返す。 const reason = unavailableReason(loadConfig().browser?.displayMode, true); res.json({ available: false, reason }); return; } if (!isNovncStaticInstalled()) { // session は存在するが、iframe で読む vnc.html が配置されていない。 // UI 側で「scripts/setup-novnc.sh を実行してください」と案内する。 res.json({ available: false, reason: 'novnc_not_installed' }); return; } res.json({ available: true, sessionId: session.id, novncPath: buildNovncPath(session.id), display: session.display, state: session.state, lockedByJobId: session.lockedByJobId, createdAt: session.createdAt.toISOString(), lastActiveAt: session.lastActiveAt.toISOString(), }); }); router.post('/task-session/:taskId/release', async (req: Request, res: Response) => { const taskId = req.params.taskId; const session = sessionManager .listSessions() .find((s) => s.kind === 'task' && s.taskId === taskId); if (!session) { res.status(404).json({ error: 'Task session not found' }); return; } if (!(await canControlSession(req, session, repo))) { res.status(403).json({ error: 'Forbidden' }); return; } await sessionManager.destroySession(session.id); res.json({ ok: true }); }); // --- Generic list / detail (task sessions only; pool excluded) --- router.get('/', async (req: Request, res: Response) => { const taskSessions = sessionManager.listSessions().filter((s) => s.kind === 'task'); const visible: BrowserSession[] = []; for (const s of taskSessions) { if (await canViewSession(req, s, repo)) visible.push(s); } res.json({ sessions: visible.map(serializeTaskSession) }); }); router.get('/:id', async (req: Request, res: Response) => { const session = sessionManager.getSession(req.params.id); if (!session || session.kind === 'pool') { // Pool は /captcha-pool 経由でのみアクセスさせる (id 直指定では不可) res.status(404).json({ error: 'Session not found' }); return; } if (!(await canViewSession(req, session, repo))) { res.status(404).json({ error: 'Session not found' }); return; } res.json(serializeTaskSession(session)); }); router.delete('/:id', async (req: Request, res: Response) => { const session = sessionManager.getSession(req.params.id); if (!session || session.kind === 'pool') { res.status(404).json({ error: 'Session not found' }); return; } if (!(await canControlSession(req, session, repo))) { res.status(404).json({ error: 'Session not found' }); return; } await sessionManager.destroySession(session.id); res.json({ ok: true }); }); router.post('/:id/release', async (req: Request, res: Response) => { const session = sessionManager.getSession(req.params.id); if (!session || session.kind === 'pool') { res.status(404).json({ error: 'Session not found' }); return; } if (!(await canControlSession(req, session, repo))) { res.status(404).json({ error: 'Session not found' }); return; } sessionManager.releaseToAgent(session.id); if (session.lockedByJobId) { try { await repo.updateJob(session.lockedByJobId, { status: 'queued', waitReason: null, }); } catch (err) { logger.warn(`[browser-api] failed to re-queue job ${session.lockedByJobId}: ${(err as Error).message}`); } } res.json({ ok: true, state: 'agent_controlled' }); }); return router; }