sync: update from private repo (3385c80)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
38bd874366
commit
ee062050e0
@ -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 と排他
|
||||||
|
|
||||||
|
|||||||
@ -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 機能との違い
|
||||||
|
|||||||
@ -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 からコピーして設定してください"
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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()) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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) */
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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">noVNC(Browser/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 タブのライブ表示・InteractiveBrowse・CAPTCHA 解決</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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
{data?.reason === 'headless_mode' ? (
|
||||||
|
<p>
|
||||||
|
headless モードでは CAPTCHA Pool は使えません。Settings → Tools → Browser Runtime で
|
||||||
|
<strong> Browser Session Mode を novnc</strong> に切り替えてください(ホストに Xvfb/x11vnc/websockify が必要)。
|
||||||
|
</p>
|
||||||
|
) : data?.reason === 'display_unavailable' ? (
|
||||||
|
<p>
|
||||||
|
ライブ表示スタック(Xvfb/x11vnc/websockify)がホストに導入されていません。管理者に導入を依頼してください。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<p>
|
<p>
|
||||||
WebSearch が CAPTCHA を踏むか、admin が手動でセッションを起動した時点で
|
WebSearch が CAPTCHA を踏むか、admin が手動でセッションを起動した時点で
|
||||||
noVNC が表示されます (5 秒ポーリング中)。
|
noVNC が表示されます (5 秒ポーリング中)。
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user