sync: update from private repo (3385c80)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-08 05:00:15 +00:00
parent 38bd874366
commit ee062050e0
13 changed files with 295 additions and 44 deletions

View File

@ -224,8 +224,11 @@ subtasks:
# browser: # browser:
# page_timeout: 60000 # ms # page_timeout: 60000 # ms
# action_timeout: 30000 # ms # action_timeout: 30000 # ms
# captcha_solve: novnc # 'skip' (default) / 'novnc' # display_mode: novnc # 'headless' (default) / 'novnc'
# max_captcha_pages: 5 # # novnc = Browser タブのライブ表示 / InteractiveBrowse /
# # CAPTCHA 解決を有効化 (ホストに Xvfb/x11vnc/websockify 必須)。
# # 旧 'captcha_solve' は自動で display_mode に移行される (非推奨)。
# max_captcha_pages: 5 # CAPTCHA Pool の同時ページ上限 (display_mode: novnc 時のみ)
# channel: chrome # 'chromium' (default) / 'chrome' / 'msedge' # channel: chrome # 'chromium' (default) / 'chrome' / 'msedge'
# executable_path: /usr/bin/google-chrome # channel と排他 # executable_path: /usr/bin/google-chrome # channel と排他

View File

@ -242,7 +242,7 @@ BrowseWithSession({
### 制約 ### 制約
- `InteractiveBrowse`**ローカルタスク経由のジョブ** でのみ使える(`taskId` が必要。Gitea Issue 直接実行や taskId が立たない subtask root では使えない - `InteractiveBrowse`**ローカルタスク経由のジョブ** でのみ使える(`taskId` が必要。Gitea Issue 直接実行や taskId が立たない subtask root では使えない
- noVNC が orchestrator にインストール / 設定されていない環境ではエラーXvfb / x11vnc / websockify が必要、`config.yaml``browser.captcha_solve: novnc` 設定 - noVNC が orchestrator にインストール / 設定されていない環境ではエラーXvfb / x11vnc / websockify が必要、`config.yaml``browser.display_mode: novnc` 設定。旧 `captcha_solve` は自動移行
- ユーザーが release を押さない限りジョブは進まない。長時間放置すると `browser.auth_timeout`(デフォルト 10 分)で timeout - ユーザーが release を押さない限りジョブは進まない。長時間放置すると `browser.auth_timeout`(デフォルト 10 分)で timeout
### 既存の Browser Sessions 機能との違い ### 既存の Browser Sessions 機能との違い

View File

@ -64,24 +64,25 @@ else
fi fi
fi fi
# 5. config.yaml に captcha_solve 設定を追加(まだなければ) # 5. config.yaml に display_mode 設定を追加(まだなければ)
echo "" echo ""
echo "[5/5] config.yaml 確認..." echo "[5/5] config.yaml 確認..."
CONFIG="$(cd "$(dirname "$0")/.." && pwd)/config.yaml" CONFIG="$(cd "$(dirname "$0")/.." && pwd)/config.yaml"
if [ -f "$CONFIG" ]; then if [ -f "$CONFIG" ]; then
if grep -q "captcha_solve" "$CONFIG"; then if grep -qE "display_mode|captcha_solve" "$CONFIG"; then
echo " ✓ captcha_solve 設定は既に存在します" # 旧 captcha_solve があっても normalizeConfig が display_mode に自動移行するのでそのまま
echo " ✓ browser display_mode (または旧 captcha_solve) 設定は既に存在します"
else else
echo " → browser セクションを追加中..." echo " → browser セクションを追加中..."
# browser セクションが存在するか確認 # browser セクションが存在するか確認
if grep -q "^browser:" "$CONFIG"; then if grep -q "^browser:" "$CONFIG"; then
# 既存 browser セクションに追加 # 既存 browser セクションに追加
sed -i '/^browser:/a\ captcha_solve: novnc\n max_captcha_pages: 5' "$CONFIG" sed -i '/^browser:/a\ display_mode: novnc\n max_captcha_pages: 5' "$CONFIG"
else else
# browser セクションを新規追加 # browser セクションを新規追加
printf '\nbrowser:\n captcha_solve: novnc\n max_captcha_pages: 5\n' >> "$CONFIG" printf '\nbrowser:\n display_mode: novnc\n max_captcha_pages: 5\n' >> "$CONFIG"
fi fi
echo " ✓ captcha_solve: novnc を追加しました" echo " ✓ display_mode: novnc を追加しました"
fi fi
else else
echo " ⚠ config.yaml が見つかりません。config.yaml.example からコピーして設定してください" echo " ⚠ config.yaml が見つかりません。config.yaml.example からコピーして設定してください"

View File

@ -3,7 +3,7 @@ import express from 'express';
import request from 'supertest'; import request from 'supertest';
import { Repository } from '../db/repository.js'; import { Repository } from '../db/repository.js';
import { runMigrations } from '../db/migrate.js'; import { runMigrations } from '../db/migrate.js';
import { createBrowserApi } from './browser-api.js'; import { createBrowserApi, unavailableReason } from './browser-api.js';
import { import {
type SessionManager, type SessionManager,
type BrowserSession, type BrowserSession,
@ -23,6 +23,17 @@ vi.mock('./novnc-proxy.js', async () => {
}); });
const novncProxyMock = await import('./novnc-proxy.js'); const novncProxyMock = await import('./novnc-proxy.js');
// browser.displayMode を per-test で切り替えられるようにする。
// createBrowserApi の各ハンドラは loadConfig().browser?.displayMode を読む。
let mockDisplayMode: 'headless' | 'novnc' | undefined = 'novnc';
vi.mock('../config.js', async () => {
const actual = await vi.importActual<typeof import('../config.js')>('../config.js');
return {
...actual,
loadConfig: vi.fn(() => ({ browser: { displayMode: mockDisplayMode } })),
};
});
/** /**
* 2026-05 redesign: API CAPTCHA Pool (admin only) Task Session * 2026-05 redesign: API CAPTCHA Pool (admin only) Task Session
* (visibility ) kind / taskId / captchaPending * (visibility ) kind / taskId / captchaPending
@ -115,6 +126,7 @@ describe('Browser API', () => {
const dbPath = './_test_browser_api.db'; const dbPath = './_test_browser_api.db';
beforeEach(() => { beforeEach(() => {
mockDisplayMode = 'novnc'; // 既存テストは session が見える前提なので novnc 既定
repo = new Repository(dbPath); repo = new Repository(dbPath);
runMigrations(repo.getDb()); runMigrations(repo.getDb());
}); });
@ -130,7 +142,7 @@ describe('Browser API', () => {
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' }); const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get('/api/local/browser/sessions/captcha-pool'); const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false }); expect(res.body).toEqual({ available: false, reason: 'no_session' });
}); });
it('returns pool info to admin', async () => { it('returns pool info to admin', async () => {
@ -180,7 +192,7 @@ describe('Browser API', () => {
const app = makeApp(sm, repo, { id: 'alice', role: 'user' }); const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`); const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false }); expect(res.body).toEqual({ available: false, reason: 'no_session' });
}); });
it('owner can see their own task session', async () => { it('owner can see their own task session', async () => {
@ -201,7 +213,7 @@ describe('Browser API', () => {
const app = makeApp(sm, repo, { id: 'bob', role: 'user' }); const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`); const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false }); expect(res.body).toEqual({ available: false, reason: 'no_session' });
}); });
it('admin can see any task session', async () => { it('admin can see any task session', async () => {
@ -240,6 +252,108 @@ describe('Browser API', () => {
}); });
}); });
describe('unavailableReason helper', () => {
it('returns headless_mode when displayMode is not novnc', () => {
expect(unavailableReason('headless', true)).toBe('headless_mode');
expect(unavailableReason('headless', false)).toBe('headless_mode');
expect(unavailableReason(undefined, true)).toBe('headless_mode'); // default headless
});
it('returns display_unavailable when novnc but sessionManager absent', () => {
expect(unavailableReason('novnc', false)).toBe('display_unavailable');
});
it('returns no_session when novnc and sessionManager present', () => {
expect(unavailableReason('novnc', true)).toBe('no_session');
});
});
describe('reason codes on available:false responses', () => {
it('headless mode → task-session returns reason:headless_mode (even with sessionManager)', async () => {
mockDisplayMode = 'headless';
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false, reason: 'headless_mode' });
});
it('headless mode → captcha-pool returns reason:headless_mode', async () => {
mockDisplayMode = 'headless';
const sm = new FakeSessionManager();
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false, reason: 'headless_mode' });
});
it('novnc + sessionManager null → task-session returns reason:display_unavailable', async () => {
mockDisplayMode = 'novnc';
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: unknown }).user = {
id: 'alice', role: 'user', status: 'active', orgIds: [],
email: 'a@example.com', name: 'alice', avatarUrl: null,
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
next();
});
app.use('/api/local/browser/sessions', createBrowserApi(null, repo));
const taskId = await createTask(repo, 'alice');
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false, reason: 'display_unavailable' });
});
it('novnc + sessionManager null → captcha-pool returns reason:display_unavailable', async () => {
mockDisplayMode = 'novnc';
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: unknown }).user = {
id: 'admin-1', role: 'admin', status: 'active', orgIds: [],
email: 'a@example.com', name: 'admin', avatarUrl: null,
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
next();
});
app.use('/api/local/browser/sessions', createBrowserApi(null, repo));
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false, reason: 'display_unavailable' });
});
it('headless + sessionManager null → reason:headless_mode', async () => {
mockDisplayMode = 'headless';
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: unknown }).user = {
id: 'admin-1', role: 'admin', status: 'active', orgIds: [],
email: 'a@example.com', name: 'admin', avatarUrl: null,
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
next();
});
app.use('/api/local/browser/sessions', createBrowserApi(null, repo));
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false, reason: 'headless_mode' });
});
it('novnc + sessionManager present + no session → reason:no_session', async () => {
mockDisplayMode = 'novnc';
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false, reason: 'no_session' });
});
});
describe('POST /task-session/:taskId/release', () => { describe('POST /task-session/:taskId/release', () => {
it('owner can release their own task session', async () => { it('owner can release their own task session', async () => {
const sm = new FakeSessionManager(); const sm = new FakeSessionManager();

View File

@ -4,6 +4,25 @@ import type { Repository } from '../db/repository.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { buildNovncPath, isNovncStaticInstalled } from './novnc-proxy.js'; import { buildNovncPath, isNovncStaticInstalled } from './novnc-proxy.js';
import { canUserSeeTask, canEditEntity } from './visibility.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 ( * 2026-05 redesign: CAPTCHA Pool (admin ) Task Session (
@ -102,10 +121,12 @@ export function createBrowserApi(sessionManager: SessionManager | null, repo: Re
// は available: false を返したい。503 にしてしまうと UI 側でエラー扱いされてしまう。 // は available: false を返したい。503 にしてしまうと UI 側でエラー扱いされてしまう。
if (!sessionManager) { if (!sessionManager) {
router.get('/captcha-pool', (_req: Request, res: Response) => { router.get('/captcha-pool', (_req: Request, res: Response) => {
res.json({ available: false }); const reason = unavailableReason(loadConfig().browser?.displayMode, false);
res.json({ available: false, reason });
}); });
router.get('/task-session/:taskId', (_req: Request, res: Response) => { router.get('/task-session/:taskId', (_req: Request, res: Response) => {
res.json({ available: false }); const reason = unavailableReason(loadConfig().browser?.displayMode, false);
res.json({ available: false, reason });
}); });
router.all('*', (_req: Request, res: Response) => { router.all('*', (_req: Request, res: Response) => {
res.status(503).json({ error: 'Browser sessions not available (missing system dependencies)' }); res.status(503).json({ error: 'Browser sessions not available (missing system dependencies)' });
@ -122,7 +143,8 @@ export function createBrowserApi(sessionManager: SessionManager | null, repo: Re
} }
const pool = sessionManager.getSession(CAPTCHA_POOL_SESSION_ID); const pool = sessionManager.getSession(CAPTCHA_POOL_SESSION_ID);
if (!pool) { if (!pool) {
res.json({ available: false }); const reason = unavailableReason(loadConfig().browser?.displayMode, true);
res.json({ available: false, reason });
return; return;
} }
if (!isNovncStaticInstalled()) { if (!isNovncStaticInstalled()) {
@ -162,12 +184,15 @@ export function createBrowserApi(sessionManager: SessionManager | null, repo: Re
.listSessions() .listSessions()
.find((s) => s.kind === 'task' && s.taskId === taskId); .find((s) => s.kind === 'task' && s.taskId === taskId);
if (!session) { if (!session) {
res.json({ available: false }); const reason = unavailableReason(loadConfig().browser?.displayMode, true);
res.json({ available: false, reason });
return; return;
} }
if (!(await canViewSession(req, session, repo))) { if (!(await canViewSession(req, session, repo))) {
// 認可失敗は available: false にして session 存在情報を漏らさない // 認可失敗は available: false にして session 存在情報を漏らさない。
res.json({ available: false }); // reason も session が無いとき相当 (存在を漏らさない) で返す。
const reason = unavailableReason(loadConfig().browser?.displayMode, true);
res.json({ available: false, reason });
return; return;
} }
if (!isNovncStaticInstalled()) { if (!isNovncStaticInstalled()) {

View File

@ -545,3 +545,30 @@ describe('normalizeConfig — backwards compat with loadConfig', () => {
expect(out.storage?.worktreeDir).toBe('/home/op/explicit-v1-value'); expect(out.storage?.worktreeDir).toBe('/home/op/explicit-v1-value');
}); });
}); });
describe('browser.display_mode migration (captcha_solve rename)', () => {
it('legacy captchaSolve=novnc → displayMode=novnc, captchaSolve removed', () => {
const out = normalizeConfig({ browser: { captchaSolve: 'novnc' } });
expect(out.browser?.displayMode).toBe('novnc');
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
});
it('legacy captchaSolve=skip → displayMode=headless, captchaSolve removed', () => {
const out = normalizeConfig({ browser: { captchaSolve: 'skip' } });
expect(out.browser?.displayMode).toBe('headless');
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
});
it('displayMode wins over captchaSolve when both present; captchaSolve removed', () => {
const out = normalizeConfig({ browser: { displayMode: 'novnc', captchaSolve: 'skip' } });
expect(out.browser?.displayMode).toBe('novnc');
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
});
it('neither key → browser block unchanged (displayMode stays undefined)', () => {
const out = normalizeConfig({ browser: { maxSessions: 3 } });
expect(out.browser?.displayMode).toBeUndefined();
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
expect(out.browser?.maxSessions).toBe(3);
});
});

View File

@ -103,10 +103,54 @@ export function normalizeConfig(raw: unknown): AppConfig {
backfillV2Blocks(out); backfillV2Blocks(out);
} }
// Browser display-mode migration runs for both v1 and v2 inputs: the
// legacy `browser.captcha_solve` key can appear in either shape.
migrateBrowserDisplayMode(out);
out.configVersion = 2; out.configVersion = 2;
return out as unknown as AppConfig; return out as unknown as AppConfig;
} }
/**
* Backward-compat for the `browser.captcha_solve` `browser.display_mode`
* rename (2026-06). `captcha_solve` (values skip|novnc) was a misnamed master
* switch for the whole headed-browser + noVNC subsystem, not just CAPTCHA.
*
* Operates in camelCase (transformKeys already ran):
* - `displayMode` set wins. If `captchaSolve` also present, drop it + warn.
* - `captchaSolve` set (no displayMode) derive displayMode, drop captchaSolve, warn.
* - neither leave as-is (default 'headless' applied at read sites).
*
* The legacy key is deleted every load so it never round-trips into a save.
*/
function migrateBrowserDisplayMode(out: Record<string, unknown>): void {
if (out.browser === null || typeof out.browser !== 'object' || Array.isArray(out.browser)) {
return;
}
const browser = out.browser as Record<string, unknown>;
const hasDisplayMode = browser.displayMode !== undefined;
const hasCaptchaSolve = browser.captchaSolve !== undefined;
if (hasDisplayMode) {
if (hasCaptchaSolve) {
delete browser.captchaSolve;
logger.warn(
'[config] browser.captcha_solve is ignored because browser.display_mode is set; remove the deprecated captcha_solve key',
);
}
return;
}
if (hasCaptchaSolve) {
const value = browser.captchaSolve === 'novnc' ? 'novnc' : 'headless';
browser.displayMode = value;
delete browser.captchaSolve;
logger.warn(
`[config] browser.captcha_solve is deprecated; migrated to browser.display_mode='${value}'. Update config.yaml (or run scripts/migrate-config.sh).`,
);
}
}
function resolveConfigVersion(raw: unknown): 1 | 2 { function resolveConfigVersion(raw: unknown): 1 | 2 {
if (raw === undefined || raw === null) return 1; if (raw === undefined || raw === null) return 1;
if (typeof raw !== 'number' || !Number.isInteger(raw)) { if (typeof raw !== 'number' || !Number.isInteger(raw)) {

View File

@ -275,6 +275,12 @@ export interface BrowserConfig {
vncBasePort?: number; // default 5900 vncBasePort?: number; // default 5900
sessionDataDir?: string; // default './data/browser-sessions' sessionDataDir?: string; // default './data/browser-sessions'
maxSessions?: number; // default 5 (CAPTCHA Pool は別枠でカウントしない) maxSessions?: number; // default 5 (CAPTCHA Pool は別枠でカウントしない)
/**
* Master switch for the headed-browser + noVNC subsystem (Browser tab live
* view, InteractiveBrowse, CAPTCHA pool). default 'headless'.
*/
displayMode?: 'headless' | 'novnc';
/** @deprecated use displayMode */
captchaSolve?: 'skip' | 'novnc'; // default 'skip' captchaSolve?: 'skip' | 'novnc'; // default 'skip'
maxCaptchaPages?: number; // default 5 maxCaptchaPages?: number; // default 5
/** Task Session が job 完了から何秒アイドルしたら GC するか (default 300) */ /** Task Session が job 完了から何秒アイドルしたら GC するか (default 300) */

View File

@ -256,7 +256,7 @@ export { getSessionManager };
// 2. Task Session (kind='task'): タスクごとに分離された noVNC session。 // 2. Task Session (kind='task'): タスクごとに分離された noVNC session。
// BrowseWeb / InteractiveBrowse が ctx.taskId をキーに取得・再利用する。 // BrowseWeb / InteractiveBrowse が ctx.taskId をキーに取得・再利用する。
// タスク visibility に基づき認可される。LRU 退避 + idle GC 対象。 // タスク visibility に基づき認可される。LRU 退避 + idle GC 対象。
// 3. Headless 共有 (skip mode): config の captchaSolve != 'novnc' の場合、 // 3. Headless 共有 (headless mode): config の displayMode != 'novnc' の場合、
// または noVNC が立ち上げられない fallback 経路で使う single Browser。 // または noVNC が立ち上げられない fallback 経路で使う single Browser。
let _headlessBrowser: Browser | null = null; let _headlessBrowser: Browser | null = null;
@ -298,7 +298,7 @@ async function getHeadlessBrowser(): Promise<Browser> {
*/ */
export async function getCaptchaPoolBrowser(): Promise<Browser> { export async function getCaptchaPoolBrowser(): Promise<Browser> {
const config = loadConfig(); const config = loadConfig();
if (config.browser?.captchaSolve === 'novnc') { if (config.browser?.displayMode === 'novnc') {
const sm = getSessionManager(); const sm = getSessionManager();
if (sm) { if (sm) {
try { try {
@ -325,7 +325,7 @@ export async function getCaptchaPoolBrowser(): Promise<Browser> {
*/ */
export async function getTaskSessionBrowser(ctx: ToolContext): Promise<Browser> { export async function getTaskSessionBrowser(ctx: ToolContext): Promise<Browser> {
const config = loadConfig(); const config = loadConfig();
if (config.browser?.captchaSolve === 'novnc' && ctx.taskId) { if (config.browser?.displayMode === 'novnc' && ctx.taskId) {
const sm = getSessionManager(); const sm = getSessionManager();
if (sm) { if (sm) {
try { try {
@ -557,7 +557,7 @@ async function getJobContext(
ctx: ToolContext, ctx: ToolContext,
): Promise<BrowserContext> { ): Promise<BrowserContext> {
const config = loadConfig(); const config = loadConfig();
if (config.browser?.captchaSolve === 'novnc' && ctx.taskId) { if (config.browser?.displayMode === 'novnc' && ctx.taskId) {
const sm = getSessionManager(); const sm = getSessionManager();
if (sm) { if (sm) {
try { try {

View File

@ -532,13 +532,12 @@ async function searchViaBrowser(
query: string, query: string,
limit: number, limit: number,
pageTimeout: number, pageTimeout: number,
captchaSolve: 'skip' | 'novnc' = 'skip', useNovnc: boolean = false,
maxCaptchaPages: number = 5, maxCaptchaPages: number = 5,
): Promise<{ results: SearchResult[]; captcha: boolean }> { ): Promise<{ results: SearchResult[]; captcha: boolean }> {
const url = engine.buildUrl(query, limit); const url = engine.buildUrl(query, limit);
logger.debug(`[WebSearch] ${engine.name} browser search: url=${url}`); logger.debug(`[WebSearch] ${engine.name} browser search: url=${url}`);
const useNovnc = captchaSolve === 'novnc';
// WebSearch は CAPTCHA Pool の Browser を共有して使う。これにより admin が // WebSearch は CAPTCHA Pool の Browser を共有して使う。これにより admin が
// 一度 CAPTCHA を解けば Cookie が persistentContexts に残り、別タスクの // 一度 CAPTCHA を解けば Cookie が persistentContexts に残り、別タスクの
// WebSearch も同じ Cookie で続行できる (タスク隔離が必要な BrowseWeb と // WebSearch も同じ Cookie で続行できる (タスク隔離が必要な BrowseWeb と
@ -640,7 +639,7 @@ async function executeWebSearch(
const { loadConfig } = await import('../../config.js'); const { loadConfig } = await import('../../config.js');
const appConfig = loadConfig(); const appConfig = loadConfig();
const captchaSolve = appConfig.browser?.captchaSolve ?? 'skip'; const useNovnc = (appConfig.browser?.displayMode ?? 'headless') === 'novnc';
const maxCaptchaPages = appConfig.browser?.maxCaptchaPages ?? 5; const maxCaptchaPages = appConfig.browser?.maxCaptchaPages ?? 5;
// --- ブラウザ検索: Google → Brave → Yahoo の順に試行 --- // --- ブラウザ検索: Google → Brave → Yahoo の順に試行 ---
@ -652,7 +651,7 @@ async function executeWebSearch(
const methodName = engine.name.toLowerCase(); const methodName = engine.name.toLowerCase();
try { try {
const { results, captcha } = await searchViaBrowser(engine, query, limit, pageTimeout, captchaSolve, maxCaptchaPages); const { results, captcha } = await searchViaBrowser(engine, query, limit, pageTimeout, useNovnc, maxCaptchaPages);
if (captcha) { if (captcha) {
lastBrowserError = `${engine.name} が CAPTCHA を要求しました`; lastBrowserError = `${engine.name} が CAPTCHA を要求しました`;
@ -673,7 +672,7 @@ async function executeWebSearch(
resultCount: results.length, outcome: 'success', fallback: isFallback, resultCount: results.length, outcome: 'success', fallback: isFallback,
}); });
// 検索が通った = Pool は CAPTCHA を抜けている。フラグを下ろす // 検索が通った = Pool は CAPTCHA を抜けている。フラグを下ろす
if (captchaSolve === 'novnc') markPoolCaptchaPending(false); if (useNovnc) markPoolCaptchaPending(false);
return { output: formatted, isError: false }; return { output: formatted, isError: false };
} }

View File

@ -6,7 +6,7 @@ import { SaveRecordingButton } from '../../browser/SaveRecordingButton.js';
interface TaskSessionInfo { interface TaskSessionInfo {
available: boolean; available: boolean;
reason?: 'novnc_not_installed'; reason?: 'novnc_not_installed' | 'headless_mode' | 'display_unavailable' | 'no_session';
sessionId?: string; sessionId?: string;
novncPath?: string; novncPath?: string;
display?: string; display?: string;
@ -93,12 +93,31 @@ export function BrowserTab({ taskId }: { taskId: number }) {
</div> </div>
); );
} }
if (data?.reason === 'headless_mode') {
return (
<div className="bg-canvas border border-amber-300 rounded-md p-6 text-sm text-slate-700">
<p className="font-medium text-amber-800 dark:text-amber-300 mb-2">headless Browser 使</p>
<p className="text-xs leading-relaxed">
Settings Tools Browser Runtime Browser Session Mode novnc Xvfb/x11vnc/websockify
</p>
</div>
);
}
if (data?.reason === 'display_unavailable') {
return (
<div className="bg-canvas border border-amber-300 rounded-md p-6 text-sm text-slate-700">
<p className="font-medium text-amber-800 dark:text-amber-300 mb-2"></p>
<p className="text-xs leading-relaxed">
Xvfb/x11vnc/websockify
</p>
</div>
);
}
return ( return (
<div className="bg-canvas border border-hairline rounded-md p-6 text-center text-sm text-slate-600"> <div className="bg-canvas border border-hairline rounded-md p-6 text-center text-sm text-slate-600">
<p className="font-medium text-slate-800 mb-1"></p> <p className="font-medium text-slate-800 mb-1"></p>
<p className="text-xs leading-relaxed"> <p className="text-xs leading-relaxed">
BrowseWeb / InteractiveBrowse BrowseWeb (5 )
noVNC (5 )
</p> </p>
</div> </div>
); );

View File

@ -49,17 +49,18 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Sessions (CDP)</h3> <h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Sessions (CDP)</h3>
<div> <div>
<FieldLabel>CAPTCHA Solve Mode</FieldLabel> <FieldLabel>Browser Session Mode</FieldLabel>
<select value={browser.captchaSolve ?? 'skip'} <select value={browser.displayMode ?? 'headless'}
onChange={e => onChange('browser.captchaSolve', e.target.value)} onChange={e => onChange('browser.displayMode', e.target.value)}
className="w-full h-9 px-2 text-[13px] border border-hairline rounded-md"> className="w-full h-9 px-2 text-[13px] border border-hairline rounded-md">
<option value="skip">skip (headless fallback, default)</option> <option value="headless">Headless</option>
<option value="novnc">novnc (shared CAPTCHA Pool, solve in CAPTCHA tab)</option> <option value="novnc">noVNCBrowser/CAPTCHA </option>
</select> </select>
<HelpText> <HelpText>
<code>novnc</code> WebSearch CAPTCHA noVNC CAPTCHA Pool <code>novnc</code>
<strong>CAPTCHA </strong> <code>Xvfb</code> / <code>x11vnc</code> / <code>websockify</code> <strong>Browser InteractiveBrowseCAPTCHA </strong>
headless <code>skip</code> CAPTCHA <code>Xvfb</code> / <code>x11vnc</code> / <code>websockify</code> headless
<code>headless</code><strong>使</strong>
</HelpText> </HelpText>
</div> </div>

View File

@ -5,6 +5,7 @@ import { PipButton } from '../components/browser/PipButton.js';
interface CaptchaPoolInfo { interface CaptchaPoolInfo {
available: boolean; available: boolean;
reason?: 'headless_mode' | 'display_unavailable' | 'no_session';
sessionId?: string; sessionId?: string;
novncPath?: string; novncPath?: string;
display?: string; display?: string;
@ -144,10 +145,21 @@ export function AdminCaptchaPage({ isAdmin }: { isAdmin: boolean }) {
<div className="h-full bg-canvas border border-hairline rounded-md flex items-center justify-center p-8"> <div className="h-full bg-canvas border border-hairline rounded-md flex items-center justify-center p-8">
<div className="max-w-md text-center text-sm text-slate-600"> <div className="max-w-md text-center text-sm text-slate-600">
<p className="font-medium text-slate-800 mb-1">Pool </p> <p className="font-medium text-slate-800 mb-1">Pool </p>
<p> {data?.reason === 'headless_mode' ? (
WebSearch CAPTCHA admin <p>
noVNC (5 ) headless CAPTCHA Pool 使Settings Tools Browser Runtime
</p> <strong> Browser Session Mode novnc</strong> Xvfb/x11vnc/websockify
</p>
) : data?.reason === 'display_unavailable' ? (
<p>
Xvfb/x11vnc/websockify
</p>
) : (
<p>
WebSearch CAPTCHA admin
noVNC (5 )
</p>
)}
</div> </div>
</div> </div>
)} )}