deckide 3.5.36 → 3.5.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs/promises';
3
3
  import fsSync from 'node:fs';
4
- import net from 'node:net';
5
4
  import os from 'node:os';
6
5
  import path from 'node:path';
7
6
  import { WebSocket } from 'ws';
@@ -23,6 +22,22 @@ function sleep(ms) {
23
22
  function isOpen(socket) {
24
23
  return socket.readyState === WebSocket.OPEN;
25
24
  }
25
+ function waitForSocketOpen(socket, timeoutMs = 10_000) {
26
+ return new Promise((resolve, reject) => {
27
+ const timer = setTimeout(() => {
28
+ reject(new Error('Timed out connecting to Chrome DevTools WebSocket'));
29
+ }, timeoutMs);
30
+ timer.unref?.();
31
+ socket.once('open', () => {
32
+ clearTimeout(timer);
33
+ resolve();
34
+ });
35
+ socket.once('error', (error) => {
36
+ clearTimeout(timer);
37
+ reject(error instanceof Error ? error : new Error(String(error)));
38
+ });
39
+ });
40
+ }
26
41
  function coercePositiveInt(value, fallback, min, max) {
27
42
  if (typeof value !== 'number' || !Number.isFinite(value)) {
28
43
  return fallback;
@@ -105,35 +120,57 @@ async function findChromeExecutable() {
105
120
  }
106
121
  throw new Error(`AGENT_BROWSER_CHROME does not exist: ${process.env.AGENT_BROWSER_CHROME}`);
107
122
  }
108
- const playwrightChrome = await findPlaywrightChromium();
109
- if (playwrightChrome) {
110
- return playwrightChrome;
111
- }
112
- return findInPath([
123
+ // 本物の Google Chrome を最優先(指紋が自然でボット判定を通りやすい)。
124
+ // Playwright の Chromium はフォールバックに回す。
125
+ const systemChrome = await findInPath([
113
126
  'google-chrome',
114
127
  'google-chrome-stable',
115
128
  'chromium',
116
129
  'chromium-browser',
117
130
  'chrome',
118
131
  ]);
132
+ if (systemChrome) {
133
+ return systemChrome;
134
+ }
135
+ return findPlaywrightChromium();
119
136
  }
120
- function getFreePort() {
121
- return new Promise((resolve, reject) => {
122
- const server = net.createServer();
123
- server.unref();
124
- server.once('error', reject);
125
- server.listen(0, '127.0.0.1', () => {
126
- const address = server.address();
127
- server.close(() => {
128
- if (address && typeof address === 'object') {
129
- resolve(address.port);
130
- }
131
- else {
132
- reject(new Error('Unable to allocate a local port'));
133
- }
134
- });
135
- });
136
- });
137
+ // MCP(chrome-devtools-mcp 等)が --browserUrl で確実に相乗りできるよう、
138
+ // CDP ポートは固定にする。AGENT_BROWSER_CDP_PORT で上書き可、既定 9222。
139
+ const DEFAULT_CDP_PORT = 9222;
140
+ function resolveCdpPort() {
141
+ const raw = process.env.AGENT_BROWSER_CDP_PORT;
142
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
143
+ if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535) {
144
+ return parsed;
145
+ }
146
+ return DEFAULT_CDP_PORT;
147
+ }
148
+ /**
149
+ * Chrome の起動コマンドを解決する。
150
+ * ボット判定対策として「実 Chrome を headful」で動かしたい。Linux サーバ等で
151
+ * ディスプレイが無い場合は xvfb-run で仮想ディスプレイ上の headful を使う。
152
+ * xvfb-run も無ければやむを得ず headless にフォールバックする。
153
+ */
154
+ function resolveLaunchCommand(executable, chromeArgs) {
155
+ const hasDisplay = Boolean(process.env.DISPLAY);
156
+ if (process.platform === 'linux' && !hasDisplay) {
157
+ const xvfbRun = ['/usr/bin/xvfb-run', '/usr/local/bin/xvfb-run'].find((p) => fsSync.existsSync(p));
158
+ if (xvfbRun) {
159
+ return {
160
+ command: xvfbRun,
161
+ args: [
162
+ '-a',
163
+ '--server-args=-screen 0 1280x720x24 -nolisten tcp',
164
+ executable,
165
+ ...chromeArgs,
166
+ ],
167
+ };
168
+ }
169
+ // ディスプレイも xvfb-run も無い → headless で動かす(Cloudflare は通りにくい)。
170
+ return { command: executable, args: ['--headless=new', ...chromeArgs] };
171
+ }
172
+ // ディスプレイがある(mac/win/Linux+X)→ そのまま headful 起動。
173
+ return { command: executable, args: chromeArgs };
137
174
  }
138
175
  async function fetchJson(url, options = {}, timeoutMs = 5000) {
139
176
  const controller = new AbortController();
@@ -243,10 +280,27 @@ export class AgentBrowserService {
243
280
  launching = null;
244
281
  port = null;
245
282
  executablePath = null;
246
- cdp = null;
247
- cdpSocket = null;
248
- cdpConnecting = null;
283
+ // Browser-level CDP connection used only to discover tabs (page targets) and
284
+ // drive their lifecycle (create/close/activate). One per running Chrome.
285
+ browserCdp = null;
286
+ browserCdpSocket = null;
287
+ browserCdpConnecting = null;
288
+ // CDP connection to the *active* tab's page target — the only tab that streams
289
+ // frames and receives input. Switching tabs tears this down and reconnects to
290
+ // the new target.
291
+ activeCdp = null;
292
+ activeCdpSocket = null;
293
+ activeConnecting = null;
294
+ // Bumped on every tab switch. A slower in-flight connect compares against it
295
+ // and bails if it lost the race, so it can't clobber the newer active tab.
296
+ activeGeneration = 0;
249
297
  screencastStarted = false;
298
+ // Known page targets (tabs) keyed by targetId, in discovery/creation order.
299
+ tabs = new Map();
300
+ activeTargetId = null;
301
+ // True once the first active tab is established. Gates popup auto-follow so
302
+ // targets discovered during launch don't hijack focus before we've picked one.
303
+ ready = false;
250
304
  // Raw JPEG bytes of the most recent screencast frame, kept so reconnecting or
251
305
  // late-joining clients can be shown the current page immediately. Frames are
252
306
  // pushed to clients as binary WebSocket messages (no base64) to cut ~33% size
@@ -257,13 +311,12 @@ export class AgentBrowserService {
257
311
  frameFlushTimer = null;
258
312
  clients = new Set();
259
313
  viewport = { ...DEFAULT_VIEWPORT };
260
- pageUrl = null;
261
- pageTitle = null;
262
314
  lastError;
263
315
  audioRelay = new BrowserAudioRelay();
264
316
  profileDir = process.env.AGENT_BROWSER_PROFILE_DIR || DEFAULT_PROFILE_DIR;
265
317
  outputDir = process.env.AGENT_BROWSER_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
266
318
  async getStatus() {
319
+ const active = this.activeTargetId ? this.tabs.get(this.activeTargetId) : null;
267
320
  return {
268
321
  running: this.isRunning(),
269
322
  launching: Boolean(this.launching),
@@ -271,15 +324,17 @@ export class AgentBrowserService {
271
324
  outputDir: this.outputDir,
272
325
  executablePath: this.executablePath ?? await findChromeExecutable().catch(() => null),
273
326
  cdpUrl: this.port ? `http://127.0.0.1:${this.port}` : null,
274
- pageUrl: this.pageUrl,
275
- pageTitle: this.pageTitle,
327
+ pageUrl: active?.url ?? null,
328
+ pageTitle: active?.title ?? null,
329
+ tabs: [...this.tabs.values()].map((tab) => ({ ...tab })),
330
+ activeTabId: this.activeTargetId,
276
331
  audio: await this.audioRelay.getStatus(),
277
332
  error: this.lastError,
278
333
  };
279
334
  }
280
335
  async start() {
281
336
  if (this.isRunning()) {
282
- await this.ensureCdp();
337
+ await this.ensureSession();
283
338
  if (this.clients.size > 0) {
284
339
  await this.startScreencast();
285
340
  }
@@ -309,12 +364,14 @@ export class AgentBrowserService {
309
364
  }
310
365
  }
311
366
  async stop() {
312
- this.closeCdp();
367
+ this.disconnectActiveCdp();
368
+ this.closeBrowserCdp();
313
369
  const proc = this.chromeProcess;
314
370
  this.chromeProcess = null;
315
371
  this.port = null;
316
- this.pageUrl = null;
317
- this.pageTitle = null;
372
+ this.tabs.clear();
373
+ this.activeTargetId = null;
374
+ this.ready = false;
318
375
  this.screencastStarted = false;
319
376
  this.lastFrameBuffer = null;
320
377
  this.pendingFrames.clear();
@@ -372,12 +429,62 @@ export class AgentBrowserService {
372
429
  async navigate(input) {
373
430
  const url = normalizeBrowserUrl(input);
374
431
  await this.start();
375
- await this.ensureCdp();
376
- await this.cdp?.send('Page.navigate', { url });
377
- this.pageUrl = url;
378
- await this.refreshPageInfo();
432
+ await this.ensureActiveCdp();
433
+ await this.activeCdp?.send('Page.navigate', { url });
434
+ const tab = this.activeTargetId ? this.tabs.get(this.activeTargetId) : null;
435
+ if (tab) {
436
+ tab.url = url;
437
+ }
438
+ await this.refreshActivePageInfo();
439
+ await this.broadcastStatus();
440
+ }
441
+ // 履歴を delta だけ移動(-1=戻る, +1=進む)。端なら何もしない。
442
+ async goHistory(delta) {
443
+ await this.start();
444
+ await this.ensureActiveCdp();
445
+ const cdp = this.activeCdp;
446
+ if (!cdp)
447
+ return;
448
+ const history = await cdp.send('Page.getNavigationHistory');
449
+ const targetIndex = history.currentIndex + delta;
450
+ if (targetIndex < 0 || targetIndex >= history.entries.length) {
451
+ return; // これ以上戻る/進む先がない
452
+ }
453
+ await cdp.send('Page.navigateToHistoryEntry', { entryId: history.entries[targetIndex].id });
454
+ await this.refreshActivePageInfo();
379
455
  await this.broadcastStatus();
380
456
  }
457
+ async newTab(input) {
458
+ await this.start();
459
+ await this.connectBrowserCdp();
460
+ const url = input ? normalizeBrowserUrl(input) : 'about:blank';
461
+ const result = await this.browserCdp?.send('Target.createTarget', { url });
462
+ const targetId = result?.targetId;
463
+ if (!targetId) {
464
+ throw new Error('Failed to open a new tab');
465
+ }
466
+ if (!this.tabs.has(targetId)) {
467
+ this.tabs.set(targetId, { id: targetId, url: url === 'about:blank' ? '' : url, title: '' });
468
+ }
469
+ await this.setActiveTab(targetId);
470
+ }
471
+ async closeTab(targetId) {
472
+ await this.connectBrowserCdp();
473
+ await this.browserCdp?.send('Target.closeTarget', { targetId }).catch(() => undefined);
474
+ this.removeTab(targetId);
475
+ // Never leave the pane with zero tabs — reopen a blank one like a real
476
+ // browser keeping a minimum of one tab.
477
+ if (this.tabs.size === 0 && this.isRunning()) {
478
+ await this.newTab().catch(() => undefined);
479
+ }
480
+ }
481
+ async activateTab(targetId) {
482
+ if (!this.tabs.has(targetId)) {
483
+ return;
484
+ }
485
+ await this.setActiveTab(targetId);
486
+ await this.browserCdp?.send('Target.activateTarget', { targetId }).catch(() => undefined);
487
+ }
381
488
  async launch() {
382
489
  this.lastError = undefined;
383
490
  await fs.mkdir(this.profileDir, { recursive: true });
@@ -387,10 +494,11 @@ export class AgentBrowserService {
387
494
  throw new Error('Chromium or Chrome was not found. Set AGENT_BROWSER_CHROME to a browser executable.');
388
495
  }
389
496
  this.executablePath = executable;
390
- this.port = await getFreePort();
497
+ this.port = resolveCdpPort();
391
498
  this.openLogStream();
392
- const args = [
393
- '--headless=new',
499
+ // headless フラグは付けない(headful を既定にする)。ディスプレイ無し環境では
500
+ // resolveLaunchCommand が xvfb-run で仮想ディスプレイを用意する。
501
+ const chromeArgs = [
394
502
  `--remote-debugging-address=127.0.0.1`,
395
503
  `--remote-debugging-port=${this.port}`,
396
504
  `--user-data-dir=${this.profileDir}`,
@@ -398,13 +506,23 @@ export class AgentBrowserService {
398
506
  '--no-default-browser-check',
399
507
  '--disable-dev-shm-usage',
400
508
  '--autoplay-policy=no-user-gesture-required',
509
+ // GPU の無いサーバ + Xvfb 上で headful 起動すると、別プロセスの GPU が
510
+ // 起動失敗して "GPU process isn't usable. Goodbye." で即クラッシュする。
511
+ // GPU を browser プロセス内で動かし、GPU サンドボックスを外して回避する。
512
+ '--in-process-gpu',
513
+ '--disable-gpu-sandbox',
514
+ // ボット判定(Cloudflare の Managed Challenge 等)で誤検知されにくくする。
515
+ // navigator.webdriver を立てる自動化フラグを無効化する。残りの偽装は CDP 側。
516
+ '--disable-blink-features=AutomationControlled',
517
+ '--lang=ja-JP',
401
518
  '--window-size=1280,720',
402
- 'about:blank',
403
519
  ];
404
520
  if (process.getuid?.() === 0) {
405
- args.splice(args.length - 1, 0, '--no-sandbox');
521
+ chromeArgs.push('--no-sandbox');
406
522
  }
407
- const child = spawn(executable, args, {
523
+ chromeArgs.push('about:blank');
524
+ const { command, args } = resolveLaunchCommand(executable, chromeArgs);
525
+ const child = spawn(command, args, {
408
526
  stdio: ['ignore', 'pipe', 'pipe'],
409
527
  env: process.env,
410
528
  });
@@ -415,7 +533,11 @@ export class AgentBrowserService {
415
533
  if (this.chromeProcess === child) {
416
534
  this.chromeProcess = null;
417
535
  this.port = null;
418
- this.closeCdp();
536
+ this.disconnectActiveCdp();
537
+ this.closeBrowserCdp();
538
+ this.tabs.clear();
539
+ this.activeTargetId = null;
540
+ this.ready = false;
419
541
  this.closeLogStream();
420
542
  this.lastError = code === 0 ? undefined : `Browser exited (${signal ?? code ?? 'unknown'})`;
421
543
  void this.broadcastStatus();
@@ -426,8 +548,8 @@ export class AgentBrowserService {
426
548
  void this.broadcastStatus();
427
549
  });
428
550
  await this.waitForChrome();
429
- await this.ensureCdp();
430
- await this.refreshPageInfo();
551
+ await this.connectBrowserCdp();
552
+ await this.ensureActiveTab();
431
553
  await this.broadcastStatus();
432
554
  }
433
555
  async waitForChrome() {
@@ -455,7 +577,7 @@ export class AgentBrowserService {
455
577
  async openForClient(socket) {
456
578
  try {
457
579
  await this.start();
458
- await this.ensureCdp();
580
+ await this.ensureActiveCdp();
459
581
  await this.sendStatus(socket);
460
582
  // Replay the most recent frame so a reconnecting or late-joining client
461
583
  // sees the current page immediately. Screencast only emits frames on
@@ -479,80 +601,258 @@ export class AgentBrowserService {
479
601
  const proc = this.chromeProcess;
480
602
  return Boolean(proc && proc.exitCode == null && proc.signalCode == null);
481
603
  }
482
- async ensureCdp() {
483
- if (this.cdp && this.cdpSocket && isOpen(this.cdpSocket)) {
604
+ // Make sure the browser-level connection and an active tab (+ its CDP) exist.
605
+ async ensureSession() {
606
+ await this.connectBrowserCdp();
607
+ await this.ensureActiveTab();
608
+ }
609
+ async connectBrowserCdp() {
610
+ if (this.browserCdp && this.browserCdpSocket && isOpen(this.browserCdpSocket)) {
484
611
  return;
485
612
  }
486
- if (this.cdpConnecting) {
487
- return this.cdpConnecting;
613
+ if (this.browserCdpConnecting) {
614
+ return this.browserCdpConnecting;
488
615
  }
489
- this.cdpConnecting = this.connectCdp();
616
+ this.browserCdpConnecting = this.doConnectBrowserCdp();
490
617
  try {
491
- await this.cdpConnecting;
618
+ await this.browserCdpConnecting;
492
619
  }
493
620
  finally {
494
- this.cdpConnecting = null;
621
+ this.browserCdpConnecting = null;
495
622
  }
496
623
  }
497
- async connectCdp() {
624
+ async doConnectBrowserCdp() {
498
625
  if (!this.port) {
499
626
  throw new Error('Browser is not running');
500
627
  }
501
- const target = await this.getOrCreatePageTarget();
502
- if (!target.webSocketDebuggerUrl) {
503
- throw new Error('Chrome page target has no DevTools WebSocket URL');
628
+ const version = await fetchJson(`http://127.0.0.1:${this.port}/json/version`);
629
+ if (!version.webSocketDebuggerUrl) {
630
+ throw new Error('Chrome has no browser-level DevTools endpoint');
504
631
  }
505
- this.closeCdp();
506
- const socket = new WebSocket(target.webSocketDebuggerUrl);
507
- await new Promise((resolve, reject) => {
508
- const timer = setTimeout(() => {
509
- reject(new Error('Timed out connecting to Chrome DevTools WebSocket'));
510
- }, 10_000);
511
- socket.once('open', () => {
512
- clearTimeout(timer);
513
- resolve();
514
- });
515
- socket.once('error', (error) => {
516
- clearTimeout(timer);
517
- reject(error);
518
- });
632
+ this.closeBrowserCdp();
633
+ const socket = new WebSocket(version.webSocketDebuggerUrl);
634
+ await waitForSocketOpen(socket);
635
+ const cdp = new CdpConnection(socket);
636
+ cdp.onEvent = (message) => this.handleBrowserEvent(message);
637
+ this.browserCdp = cdp;
638
+ this.browserCdpSocket = socket;
639
+ socket.on('close', () => {
640
+ if (this.browserCdpSocket === socket) {
641
+ this.browserCdp = null;
642
+ this.browserCdpSocket = null;
643
+ }
519
644
  });
645
+ // Emits Target.targetCreated for every existing target, then keeps us posted
646
+ // on creation/url/title changes and destruction.
647
+ await cdp.send('Target.setDiscoverTargets', { discover: true });
648
+ }
649
+ async ensureActiveTab() {
650
+ await this.connectBrowserCdp();
651
+ // Discovery events normally populate `tabs`; if they haven't landed yet (or
652
+ // none exist), fall back to the HTTP target list, then create one.
653
+ if (this.tabs.size === 0) {
654
+ const targets = await fetchJson(`http://127.0.0.1:${this.port}/json/list`)
655
+ .catch(() => []);
656
+ for (const target of targets) {
657
+ if (target.type === 'page' && !target.url.startsWith('devtools://')) {
658
+ this.tabs.set(target.id, { id: target.id, url: target.url, title: target.title ?? '' });
659
+ }
660
+ }
661
+ }
662
+ if (this.tabs.size === 0) {
663
+ const created = await this.browserCdp?.send('Target.createTarget', {
664
+ url: 'about:blank',
665
+ });
666
+ const targetId = created?.targetId;
667
+ if (!targetId) {
668
+ throw new Error('Failed to open a browser tab');
669
+ }
670
+ this.tabs.set(targetId, { id: targetId, url: '', title: '' });
671
+ await this.setActiveTab(targetId);
672
+ this.ready = true;
673
+ return;
674
+ }
675
+ if (!this.activeTargetId || !this.tabs.has(this.activeTargetId)) {
676
+ const first = this.tabs.keys().next().value;
677
+ await this.setActiveTab(first);
678
+ }
679
+ else if (!this.activeCdp || !this.activeCdpSocket || !isOpen(this.activeCdpSocket)) {
680
+ await this.connectActiveCdp(this.activeTargetId);
681
+ }
682
+ this.ready = true;
683
+ }
684
+ async ensureActiveCdp() {
685
+ if (this.activeCdp && this.activeCdpSocket && isOpen(this.activeCdpSocket)) {
686
+ return;
687
+ }
688
+ if (this.activeConnecting) {
689
+ return this.activeConnecting;
690
+ }
691
+ this.activeConnecting = (async () => {
692
+ if (!this.activeTargetId || !this.tabs.has(this.activeTargetId)) {
693
+ await this.ensureActiveTab();
694
+ }
695
+ else {
696
+ await this.connectActiveCdp(this.activeTargetId);
697
+ }
698
+ })();
699
+ try {
700
+ await this.activeConnecting;
701
+ }
702
+ finally {
703
+ this.activeConnecting = null;
704
+ }
705
+ }
706
+ async setActiveTab(targetId) {
707
+ this.activeGeneration += 1;
708
+ const generation = this.activeGeneration;
709
+ this.activeTargetId = targetId;
710
+ this.clearActiveFrame();
711
+ // Drop the stale page from every client while the new tab's first frame is
712
+ // still in flight.
713
+ this.broadcast({ type: 'clear' });
714
+ try {
715
+ await this.connectActiveCdp(targetId, generation);
716
+ }
717
+ catch (error) {
718
+ if (generation === this.activeGeneration) {
719
+ this.lastError = error instanceof Error ? error.message : String(error);
720
+ }
721
+ }
722
+ await this.broadcastStatus();
723
+ }
724
+ async connectActiveCdp(targetId, generation = this.activeGeneration) {
725
+ if (!this.port) {
726
+ throw new Error('Browser is not running');
727
+ }
728
+ this.disconnectActiveCdp();
729
+ const socket = new WebSocket(`ws://127.0.0.1:${this.port}/devtools/page/${targetId}`);
730
+ try {
731
+ await waitForSocketOpen(socket);
732
+ }
733
+ catch (error) {
734
+ try {
735
+ socket.close();
736
+ }
737
+ catch {
738
+ // ignore
739
+ }
740
+ throw error;
741
+ }
742
+ // A newer switch started while we were connecting — discard this one.
743
+ if (generation !== this.activeGeneration) {
744
+ try {
745
+ socket.close();
746
+ }
747
+ catch {
748
+ // ignore
749
+ }
750
+ return;
751
+ }
520
752
  const cdp = new CdpConnection(socket);
521
- cdp.onEvent = (message) => this.handleCdpEvent(message);
522
- this.cdp = cdp;
523
- this.cdpSocket = socket;
753
+ cdp.onEvent = (message) => this.handleActiveEvent(message);
754
+ this.activeCdp = cdp;
755
+ this.activeCdpSocket = socket;
524
756
  this.screencastStarted = false;
525
- this.pageUrl = target.url || this.pageUrl;
526
- this.pageTitle = target.title || this.pageTitle;
527
757
  socket.on('close', () => {
528
- if (this.cdp === cdp) {
529
- this.cdp = null;
530
- this.cdpSocket = null;
758
+ if (this.activeCdpSocket === socket) {
759
+ this.activeCdp = null;
760
+ this.activeCdpSocket = null;
531
761
  this.screencastStarted = false;
532
762
  }
533
763
  });
534
764
  await cdp.send('Page.enable');
535
765
  await cdp.send('Runtime.enable');
766
+ await this.applyAntiDetection(cdp);
767
+ if (generation !== this.activeGeneration) {
768
+ this.disconnectActiveCdp();
769
+ return;
770
+ }
536
771
  await this.applyViewport();
772
+ if (this.clients.size > 0) {
773
+ await this.startScreencast();
774
+ }
537
775
  }
538
- async getOrCreatePageTarget() {
539
- if (!this.port) {
540
- throw new Error('Browser is not running');
776
+ /**
777
+ * ヘッドレス Chrome がボット判定(Cloudflare の Managed Challenge / Turnstile 等)で
778
+ * 誤検知されるのを減らす。完全な突破は保証できないが、代表的な自動化シグナルを消す:
779
+ * - UA から "HeadlessChrome" を除去(実バージョンに一致させる)
780
+ * - navigator.webdriver / languages / chrome / plugins / WebGL ベンダを通常ブラウザ相当に
781
+ * 偽装スクリプトは addScriptToEvaluateOnNewDocument で「以後の」ページ生成時に毎回適用される。
782
+ */
783
+ async applyAntiDetection(cdp) {
784
+ try {
785
+ // 実際のブラウザ版に一致した UA を使う(バージョン不一致自体が検知シグナルになるため)。
786
+ // ページセッションでは Browser.* が使えないことがあるので /json/version を優先。
787
+ let userAgent = '';
788
+ try {
789
+ const info = await fetchJson(`http://127.0.0.1:${this.port}/json/version`);
790
+ userAgent = (info['User-Agent'] || '').replace(/HeadlessChrome/gi, 'Chrome');
791
+ }
792
+ catch {
793
+ // フォールバック: ページの navigator.userAgent を取得して整形。
794
+ const r = await cdp.send('Runtime.evaluate', {
795
+ expression: 'navigator.userAgent',
796
+ returnByValue: true,
797
+ });
798
+ userAgent = String(r.result?.value ?? '').replace(/HeadlessChrome/gi, 'Chrome');
799
+ }
800
+ const major = (userAgent.match(/Chrome\/(\d+)/) || [])[1] || '120';
801
+ if (userAgent) {
802
+ await cdp.send('Emulation.setUserAgentOverride', {
803
+ userAgent,
804
+ acceptLanguage: 'ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7',
805
+ platform: 'Linux x86_64',
806
+ userAgentMetadata: {
807
+ brands: [
808
+ { brand: 'Not_A Brand', version: '24' },
809
+ { brand: 'Chromium', version: major },
810
+ { brand: 'Google Chrome', version: major },
811
+ ],
812
+ fullVersion: (userAgent.match(/Chrome\/([\d.]+)/) || [])[1] || `${major}.0.0.0`,
813
+ platform: 'Linux',
814
+ platformVersion: '',
815
+ architecture: 'x86',
816
+ model: '',
817
+ mobile: false,
818
+ },
819
+ });
820
+ }
821
+ const stealthSource = `
822
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
823
+ if (!window.chrome) { window.chrome = { runtime: {} }; }
824
+ Object.defineProperty(navigator, 'languages', { get: () => ['ja-JP', 'ja', 'en-US', 'en'] });
825
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
826
+ try {
827
+ const _q = window.navigator.permissions && window.navigator.permissions.query;
828
+ if (_q) {
829
+ window.navigator.permissions.query = (p) =>
830
+ p && p.name === 'notifications'
831
+ ? Promise.resolve({ state: Notification.permission })
832
+ : _q(p);
833
+ }
834
+ } catch (e) { /* ignore */ }
835
+ try {
836
+ const getParam = WebGLRenderingContext.prototype.getParameter;
837
+ WebGLRenderingContext.prototype.getParameter = function (p) {
838
+ if (p === 37445) return 'Intel Inc.';
839
+ if (p === 37446) return 'Intel Iris OpenGL Engine';
840
+ return getParam.call(this, p);
841
+ };
842
+ } catch (e) { /* ignore */ }
843
+ `;
844
+ await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: stealthSource });
541
845
  }
542
- const baseUrl = `http://127.0.0.1:${this.port}`;
543
- const targets = await fetchJson(`${baseUrl}/json/list`);
544
- const page = targets.find((target) => target.type === 'page' && target.webSocketDebuggerUrl);
545
- if (page) {
546
- return page;
846
+ catch {
847
+ // 反検知設定は best-effort。失敗してもブラウジング自体は続行する。
547
848
  }
548
- return fetchJson(`${baseUrl}/json/new?about:blank`, { method: 'PUT' });
549
849
  }
550
850
  async startScreencast() {
551
- if (this.screencastStarted || !this.cdp) {
851
+ if (this.screencastStarted || !this.activeCdp) {
552
852
  return;
553
853
  }
554
854
  await this.applyViewport();
555
- await this.cdp.send('Page.startScreencast', {
855
+ await this.activeCdp.send('Page.startScreencast', {
556
856
  format: 'jpeg',
557
857
  quality: 72,
558
858
  maxWidth: this.viewport.width,
@@ -562,11 +862,11 @@ export class AgentBrowserService {
562
862
  this.screencastStarted = true;
563
863
  }
564
864
  async stopScreencast() {
565
- if (!this.screencastStarted || !this.cdp) {
865
+ if (!this.screencastStarted || !this.activeCdp) {
566
866
  return;
567
867
  }
568
868
  try {
569
- await this.cdp.send('Page.stopScreencast');
869
+ await this.activeCdp.send('Page.stopScreencast');
570
870
  }
571
871
  catch {
572
872
  // ignore
@@ -576,10 +876,10 @@ export class AgentBrowserService {
576
876
  }
577
877
  }
578
878
  async applyViewport() {
579
- if (!this.cdp) {
879
+ if (!this.activeCdp) {
580
880
  return;
581
881
  }
582
- await this.cdp.send('Emulation.setDeviceMetricsOverride', {
882
+ await this.activeCdp.send('Emulation.setDeviceMetricsOverride', {
583
883
  width: this.viewport.width,
584
884
  height: this.viewport.height,
585
885
  deviceScaleFactor: 1,
@@ -597,12 +897,12 @@ export class AgentBrowserService {
597
897
  return;
598
898
  }
599
899
  this.viewport = next;
600
- if (!this.cdp) {
900
+ if (!this.activeCdp) {
601
901
  return;
602
902
  }
603
903
  await this.applyViewport();
604
904
  if (this.screencastStarted) {
605
- await this.cdp.send('Page.stopScreencast').catch(() => undefined);
905
+ await this.activeCdp.send('Page.stopScreencast').catch(() => undefined);
606
906
  this.screencastStarted = false;
607
907
  await this.startScreencast();
608
908
  }
@@ -628,11 +928,37 @@ export class AgentBrowserService {
628
928
  return;
629
929
  }
630
930
  await this.start();
631
- await this.ensureCdp();
931
+ if (message.type === 'new-tab') {
932
+ await this.newTab(message.url);
933
+ return;
934
+ }
935
+ if (message.type === 'close-tab') {
936
+ await this.closeTab(message.targetId);
937
+ return;
938
+ }
939
+ if (message.type === 'activate-tab') {
940
+ await this.activateTab(message.targetId);
941
+ return;
942
+ }
943
+ await this.ensureActiveCdp();
632
944
  if (message.type === 'navigate') {
633
945
  await this.navigate(message.url);
634
946
  return;
635
947
  }
948
+ if (message.type === 'back') {
949
+ await this.goHistory(-1);
950
+ return;
951
+ }
952
+ if (message.type === 'forward') {
953
+ await this.goHistory(1);
954
+ return;
955
+ }
956
+ if (message.type === 'reload') {
957
+ await this.activeCdp?.send('Page.reload', {});
958
+ await this.refreshActivePageInfo();
959
+ await this.broadcastStatus();
960
+ return;
961
+ }
636
962
  if (message.type === 'mouse') {
637
963
  await this.dispatchMouse(message);
638
964
  return;
@@ -649,9 +975,9 @@ export class AgentBrowserService {
649
975
  }
650
976
  }
651
977
  async dispatchMouse(message) {
652
- if (!this.cdp)
978
+ if (!this.activeCdp)
653
979
  return;
654
- await this.cdp.send('Input.dispatchMouseEvent', {
980
+ await this.activeCdp.send('Input.dispatchMouseEvent', {
655
981
  type: message.eventType,
656
982
  x: coercePositiveInt(message.x, 0, 0, MAX_VIEWPORT.width),
657
983
  y: coercePositiveInt(message.y, 0, 0, MAX_VIEWPORT.height),
@@ -663,10 +989,10 @@ export class AgentBrowserService {
663
989
  });
664
990
  }
665
991
  async dispatchKey(message) {
666
- if (!this.cdp)
992
+ if (!this.activeCdp)
667
993
  return;
668
994
  const windowsVirtualKeyCode = getWindowsVirtualKeyCode(message.key);
669
- await this.cdp.send('Input.dispatchKeyEvent', {
995
+ await this.activeCdp.send('Input.dispatchKeyEvent', {
670
996
  type: message.eventType,
671
997
  key: message.key,
672
998
  code: message.code,
@@ -677,13 +1003,14 @@ export class AgentBrowserService {
677
1003
  modifiers: message.modifiers ?? 0,
678
1004
  });
679
1005
  }
680
- handleCdpEvent(message) {
1006
+ // Events from the active tab's page CDP connection.
1007
+ handleActiveEvent(message) {
681
1008
  if (message.method === 'Page.screencastFrame') {
682
1009
  const params = message.params;
683
1010
  const data = typeof params.data === 'string' ? params.data : null;
684
1011
  const sessionId = typeof params.sessionId === 'number' ? params.sessionId : null;
685
1012
  if (sessionId != null) {
686
- void this.cdp?.send('Page.screencastFrameAck', { sessionId }).catch(() => undefined);
1013
+ void this.activeCdp?.send('Page.screencastFrameAck', { sessionId }).catch(() => undefined);
687
1014
  }
688
1015
  if (data) {
689
1016
  const buffer = Buffer.from(data, 'base64');
@@ -695,32 +1022,104 @@ export class AgentBrowserService {
695
1022
  if (message.method === 'Page.frameNavigated') {
696
1023
  const frame = (message.params?.frame ?? null);
697
1024
  if (frame && !frame.parentId && frame.url) {
698
- this.pageUrl = frame.url;
699
- void this.refreshPageInfo().finally(() => {
1025
+ const tab = this.activeTargetId ? this.tabs.get(this.activeTargetId) : null;
1026
+ if (tab) {
1027
+ tab.url = frame.url;
1028
+ }
1029
+ void this.refreshActivePageInfo().finally(() => {
700
1030
  void this.broadcastStatus();
701
1031
  });
702
1032
  }
703
1033
  return;
704
1034
  }
705
1035
  if (message.method === 'Page.loadEventFired') {
706
- void this.refreshPageInfo().finally(() => {
1036
+ void this.refreshActivePageInfo().finally(() => {
707
1037
  void this.broadcastStatus();
708
1038
  });
709
1039
  }
710
1040
  }
711
- async refreshPageInfo() {
712
- if (!this.cdp) {
1041
+ // Events from the browser-level CDP connection: tab lifecycle.
1042
+ handleBrowserEvent(message) {
1043
+ const method = message.method;
1044
+ if (method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') {
1045
+ const info = message.params?.targetInfo;
1046
+ if (info) {
1047
+ this.upsertTab(info);
1048
+ }
1049
+ return;
1050
+ }
1051
+ if (method === 'Target.targetDestroyed') {
1052
+ const targetId = typeof message.params?.targetId === 'string' ? message.params.targetId : null;
1053
+ if (targetId) {
1054
+ this.removeTab(targetId);
1055
+ }
1056
+ }
1057
+ }
1058
+ upsertTab(info) {
1059
+ if (info.type !== 'page') {
1060
+ return;
1061
+ }
1062
+ const url = info.url ?? '';
1063
+ if (url.startsWith('devtools://')) {
1064
+ return;
1065
+ }
1066
+ const prev = this.tabs.get(info.targetId);
1067
+ const isNew = !prev;
1068
+ const title = info.title ?? '';
1069
+ const changed = !prev || prev.url !== url || prev.title !== title;
1070
+ this.tabs.set(info.targetId, { id: info.targetId, url, title });
1071
+ if (isNew && this.ready && this.activeTargetId !== info.targetId) {
1072
+ // A page spawned a new tab/popup (target=_blank, window.open) — follow it
1073
+ // the way a normal browser does.
1074
+ void this.setActiveTab(info.targetId);
1075
+ return;
1076
+ }
1077
+ if (changed) {
1078
+ void this.broadcastStatus();
1079
+ }
1080
+ }
1081
+ removeTab(targetId) {
1082
+ if (!this.tabs.has(targetId)) {
1083
+ return;
1084
+ }
1085
+ const ids = [...this.tabs.keys()];
1086
+ const idx = ids.indexOf(targetId);
1087
+ this.tabs.delete(targetId);
1088
+ if (this.activeTargetId === targetId) {
1089
+ this.activeTargetId = null;
1090
+ this.disconnectActiveCdp();
1091
+ this.clearActiveFrame();
1092
+ this.broadcast({ type: 'clear' });
1093
+ const nextId = ids[idx + 1] ?? ids[idx - 1] ?? null;
1094
+ if (nextId && this.tabs.has(nextId)) {
1095
+ void this.setActiveTab(nextId);
1096
+ return; // setActiveTab broadcasts status
1097
+ }
1098
+ }
1099
+ void this.broadcastStatus();
1100
+ }
1101
+ clearActiveFrame() {
1102
+ this.lastFrameBuffer = null;
1103
+ this.pendingFrames.clear();
1104
+ }
1105
+ async refreshActivePageInfo() {
1106
+ if (!this.activeCdp || !this.activeTargetId) {
713
1107
  return;
714
1108
  }
715
1109
  try {
716
- const result = await this.cdp.send('Runtime.evaluate', {
1110
+ const result = await this.activeCdp.send('Runtime.evaluate', {
717
1111
  expression: '({ title: document.title, url: location.href })',
718
1112
  returnByValue: true,
719
1113
  });
720
1114
  const value = result.result?.value;
721
- if (value) {
722
- this.pageTitle = typeof value.title === 'string' ? value.title : this.pageTitle;
723
- this.pageUrl = typeof value.url === 'string' ? value.url : this.pageUrl;
1115
+ const tab = this.tabs.get(this.activeTargetId);
1116
+ if (value && tab) {
1117
+ if (typeof value.title === 'string') {
1118
+ tab.title = value.title;
1119
+ }
1120
+ if (typeof value.url === 'string') {
1121
+ tab.url = value.url;
1122
+ }
724
1123
  }
725
1124
  }
726
1125
  catch {
@@ -841,11 +1240,11 @@ export class AgentBrowserService {
841
1240
  }
842
1241
  }
843
1242
  }
844
- closeCdp() {
845
- const cdp = this.cdp;
846
- const socket = this.cdpSocket;
847
- this.cdp = null;
848
- this.cdpSocket = null;
1243
+ disconnectActiveCdp() {
1244
+ const cdp = this.activeCdp;
1245
+ const socket = this.activeCdpSocket;
1246
+ this.activeCdp = null;
1247
+ this.activeCdpSocket = null;
849
1248
  this.screencastStarted = false;
850
1249
  if (cdp) {
851
1250
  cdp.close();
@@ -859,6 +1258,23 @@ export class AgentBrowserService {
859
1258
  }
860
1259
  }
861
1260
  }
1261
+ closeBrowserCdp() {
1262
+ const cdp = this.browserCdp;
1263
+ const socket = this.browserCdpSocket;
1264
+ this.browserCdp = null;
1265
+ this.browserCdpSocket = null;
1266
+ if (cdp) {
1267
+ cdp.close();
1268
+ }
1269
+ else if (socket) {
1270
+ try {
1271
+ socket.close();
1272
+ }
1273
+ catch {
1274
+ // ignore
1275
+ }
1276
+ }
1277
+ }
862
1278
  openLogStream() {
863
1279
  this.closeLogStream();
864
1280
  const logPath = path.join(this.outputDir, 'deckide-browser.log');