maestro/src/bridge/browser-api.ts
oss-sync ee062050e0
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (3385c80)
2026-06-08 05:00:15 +00:00

298 lines
11 KiB
TypeScript

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<boolean> {
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<boolean> {
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;
}