deckide 3.5.36 → 3.5.37

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,46 @@ 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();
379
439
  await this.broadcastStatus();
380
440
  }
441
+ async newTab(input) {
442
+ await this.start();
443
+ await this.connectBrowserCdp();
444
+ const url = input ? normalizeBrowserUrl(input) : 'about:blank';
445
+ const result = await this.browserCdp?.send('Target.createTarget', { url });
446
+ const targetId = result?.targetId;
447
+ if (!targetId) {
448
+ throw new Error('Failed to open a new tab');
449
+ }
450
+ if (!this.tabs.has(targetId)) {
451
+ this.tabs.set(targetId, { id: targetId, url: url === 'about:blank' ? '' : url, title: '' });
452
+ }
453
+ await this.setActiveTab(targetId);
454
+ }
455
+ async closeTab(targetId) {
456
+ await this.connectBrowserCdp();
457
+ await this.browserCdp?.send('Target.closeTarget', { targetId }).catch(() => undefined);
458
+ this.removeTab(targetId);
459
+ // Never leave the pane with zero tabs — reopen a blank one like a real
460
+ // browser keeping a minimum of one tab.
461
+ if (this.tabs.size === 0 && this.isRunning()) {
462
+ await this.newTab().catch(() => undefined);
463
+ }
464
+ }
465
+ async activateTab(targetId) {
466
+ if (!this.tabs.has(targetId)) {
467
+ return;
468
+ }
469
+ await this.setActiveTab(targetId);
470
+ await this.browserCdp?.send('Target.activateTarget', { targetId }).catch(() => undefined);
471
+ }
381
472
  async launch() {
382
473
  this.lastError = undefined;
383
474
  await fs.mkdir(this.profileDir, { recursive: true });
@@ -387,10 +478,11 @@ export class AgentBrowserService {
387
478
  throw new Error('Chromium or Chrome was not found. Set AGENT_BROWSER_CHROME to a browser executable.');
388
479
  }
389
480
  this.executablePath = executable;
390
- this.port = await getFreePort();
481
+ this.port = resolveCdpPort();
391
482
  this.openLogStream();
392
- const args = [
393
- '--headless=new',
483
+ // headless フラグは付けない(headful を既定にする)。ディスプレイ無し環境では
484
+ // resolveLaunchCommand が xvfb-run で仮想ディスプレイを用意する。
485
+ const chromeArgs = [
394
486
  `--remote-debugging-address=127.0.0.1`,
395
487
  `--remote-debugging-port=${this.port}`,
396
488
  `--user-data-dir=${this.profileDir}`,
@@ -398,13 +490,23 @@ export class AgentBrowserService {
398
490
  '--no-default-browser-check',
399
491
  '--disable-dev-shm-usage',
400
492
  '--autoplay-policy=no-user-gesture-required',
493
+ // GPU の無いサーバ + Xvfb 上で headful 起動すると、別プロセスの GPU が
494
+ // 起動失敗して "GPU process isn't usable. Goodbye." で即クラッシュする。
495
+ // GPU を browser プロセス内で動かし、GPU サンドボックスを外して回避する。
496
+ '--in-process-gpu',
497
+ '--disable-gpu-sandbox',
498
+ // ボット判定(Cloudflare の Managed Challenge 等)で誤検知されにくくする。
499
+ // navigator.webdriver を立てる自動化フラグを無効化する。残りの偽装は CDP 側。
500
+ '--disable-blink-features=AutomationControlled',
501
+ '--lang=ja-JP',
401
502
  '--window-size=1280,720',
402
- 'about:blank',
403
503
  ];
404
504
  if (process.getuid?.() === 0) {
405
- args.splice(args.length - 1, 0, '--no-sandbox');
505
+ chromeArgs.push('--no-sandbox');
406
506
  }
407
- const child = spawn(executable, args, {
507
+ chromeArgs.push('about:blank');
508
+ const { command, args } = resolveLaunchCommand(executable, chromeArgs);
509
+ const child = spawn(command, args, {
408
510
  stdio: ['ignore', 'pipe', 'pipe'],
409
511
  env: process.env,
410
512
  });
@@ -415,7 +517,11 @@ export class AgentBrowserService {
415
517
  if (this.chromeProcess === child) {
416
518
  this.chromeProcess = null;
417
519
  this.port = null;
418
- this.closeCdp();
520
+ this.disconnectActiveCdp();
521
+ this.closeBrowserCdp();
522
+ this.tabs.clear();
523
+ this.activeTargetId = null;
524
+ this.ready = false;
419
525
  this.closeLogStream();
420
526
  this.lastError = code === 0 ? undefined : `Browser exited (${signal ?? code ?? 'unknown'})`;
421
527
  void this.broadcastStatus();
@@ -426,8 +532,8 @@ export class AgentBrowserService {
426
532
  void this.broadcastStatus();
427
533
  });
428
534
  await this.waitForChrome();
429
- await this.ensureCdp();
430
- await this.refreshPageInfo();
535
+ await this.connectBrowserCdp();
536
+ await this.ensureActiveTab();
431
537
  await this.broadcastStatus();
432
538
  }
433
539
  async waitForChrome() {
@@ -455,7 +561,7 @@ export class AgentBrowserService {
455
561
  async openForClient(socket) {
456
562
  try {
457
563
  await this.start();
458
- await this.ensureCdp();
564
+ await this.ensureActiveCdp();
459
565
  await this.sendStatus(socket);
460
566
  // Replay the most recent frame so a reconnecting or late-joining client
461
567
  // sees the current page immediately. Screencast only emits frames on
@@ -479,80 +585,258 @@ export class AgentBrowserService {
479
585
  const proc = this.chromeProcess;
480
586
  return Boolean(proc && proc.exitCode == null && proc.signalCode == null);
481
587
  }
482
- async ensureCdp() {
483
- if (this.cdp && this.cdpSocket && isOpen(this.cdpSocket)) {
588
+ // Make sure the browser-level connection and an active tab (+ its CDP) exist.
589
+ async ensureSession() {
590
+ await this.connectBrowserCdp();
591
+ await this.ensureActiveTab();
592
+ }
593
+ async connectBrowserCdp() {
594
+ if (this.browserCdp && this.browserCdpSocket && isOpen(this.browserCdpSocket)) {
484
595
  return;
485
596
  }
486
- if (this.cdpConnecting) {
487
- return this.cdpConnecting;
597
+ if (this.browserCdpConnecting) {
598
+ return this.browserCdpConnecting;
488
599
  }
489
- this.cdpConnecting = this.connectCdp();
600
+ this.browserCdpConnecting = this.doConnectBrowserCdp();
490
601
  try {
491
- await this.cdpConnecting;
602
+ await this.browserCdpConnecting;
492
603
  }
493
604
  finally {
494
- this.cdpConnecting = null;
605
+ this.browserCdpConnecting = null;
495
606
  }
496
607
  }
497
- async connectCdp() {
608
+ async doConnectBrowserCdp() {
498
609
  if (!this.port) {
499
610
  throw new Error('Browser is not running');
500
611
  }
501
- const target = await this.getOrCreatePageTarget();
502
- if (!target.webSocketDebuggerUrl) {
503
- throw new Error('Chrome page target has no DevTools WebSocket URL');
612
+ const version = await fetchJson(`http://127.0.0.1:${this.port}/json/version`);
613
+ if (!version.webSocketDebuggerUrl) {
614
+ throw new Error('Chrome has no browser-level DevTools endpoint');
504
615
  }
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
- });
616
+ this.closeBrowserCdp();
617
+ const socket = new WebSocket(version.webSocketDebuggerUrl);
618
+ await waitForSocketOpen(socket);
619
+ const cdp = new CdpConnection(socket);
620
+ cdp.onEvent = (message) => this.handleBrowserEvent(message);
621
+ this.browserCdp = cdp;
622
+ this.browserCdpSocket = socket;
623
+ socket.on('close', () => {
624
+ if (this.browserCdpSocket === socket) {
625
+ this.browserCdp = null;
626
+ this.browserCdpSocket = null;
627
+ }
519
628
  });
629
+ // Emits Target.targetCreated for every existing target, then keeps us posted
630
+ // on creation/url/title changes and destruction.
631
+ await cdp.send('Target.setDiscoverTargets', { discover: true });
632
+ }
633
+ async ensureActiveTab() {
634
+ await this.connectBrowserCdp();
635
+ // Discovery events normally populate `tabs`; if they haven't landed yet (or
636
+ // none exist), fall back to the HTTP target list, then create one.
637
+ if (this.tabs.size === 0) {
638
+ const targets = await fetchJson(`http://127.0.0.1:${this.port}/json/list`)
639
+ .catch(() => []);
640
+ for (const target of targets) {
641
+ if (target.type === 'page' && !target.url.startsWith('devtools://')) {
642
+ this.tabs.set(target.id, { id: target.id, url: target.url, title: target.title ?? '' });
643
+ }
644
+ }
645
+ }
646
+ if (this.tabs.size === 0) {
647
+ const created = await this.browserCdp?.send('Target.createTarget', {
648
+ url: 'about:blank',
649
+ });
650
+ const targetId = created?.targetId;
651
+ if (!targetId) {
652
+ throw new Error('Failed to open a browser tab');
653
+ }
654
+ this.tabs.set(targetId, { id: targetId, url: '', title: '' });
655
+ await this.setActiveTab(targetId);
656
+ this.ready = true;
657
+ return;
658
+ }
659
+ if (!this.activeTargetId || !this.tabs.has(this.activeTargetId)) {
660
+ const first = this.tabs.keys().next().value;
661
+ await this.setActiveTab(first);
662
+ }
663
+ else if (!this.activeCdp || !this.activeCdpSocket || !isOpen(this.activeCdpSocket)) {
664
+ await this.connectActiveCdp(this.activeTargetId);
665
+ }
666
+ this.ready = true;
667
+ }
668
+ async ensureActiveCdp() {
669
+ if (this.activeCdp && this.activeCdpSocket && isOpen(this.activeCdpSocket)) {
670
+ return;
671
+ }
672
+ if (this.activeConnecting) {
673
+ return this.activeConnecting;
674
+ }
675
+ this.activeConnecting = (async () => {
676
+ if (!this.activeTargetId || !this.tabs.has(this.activeTargetId)) {
677
+ await this.ensureActiveTab();
678
+ }
679
+ else {
680
+ await this.connectActiveCdp(this.activeTargetId);
681
+ }
682
+ })();
683
+ try {
684
+ await this.activeConnecting;
685
+ }
686
+ finally {
687
+ this.activeConnecting = null;
688
+ }
689
+ }
690
+ async setActiveTab(targetId) {
691
+ this.activeGeneration += 1;
692
+ const generation = this.activeGeneration;
693
+ this.activeTargetId = targetId;
694
+ this.clearActiveFrame();
695
+ // Drop the stale page from every client while the new tab's first frame is
696
+ // still in flight.
697
+ this.broadcast({ type: 'clear' });
698
+ try {
699
+ await this.connectActiveCdp(targetId, generation);
700
+ }
701
+ catch (error) {
702
+ if (generation === this.activeGeneration) {
703
+ this.lastError = error instanceof Error ? error.message : String(error);
704
+ }
705
+ }
706
+ await this.broadcastStatus();
707
+ }
708
+ async connectActiveCdp(targetId, generation = this.activeGeneration) {
709
+ if (!this.port) {
710
+ throw new Error('Browser is not running');
711
+ }
712
+ this.disconnectActiveCdp();
713
+ const socket = new WebSocket(`ws://127.0.0.1:${this.port}/devtools/page/${targetId}`);
714
+ try {
715
+ await waitForSocketOpen(socket);
716
+ }
717
+ catch (error) {
718
+ try {
719
+ socket.close();
720
+ }
721
+ catch {
722
+ // ignore
723
+ }
724
+ throw error;
725
+ }
726
+ // A newer switch started while we were connecting — discard this one.
727
+ if (generation !== this.activeGeneration) {
728
+ try {
729
+ socket.close();
730
+ }
731
+ catch {
732
+ // ignore
733
+ }
734
+ return;
735
+ }
520
736
  const cdp = new CdpConnection(socket);
521
- cdp.onEvent = (message) => this.handleCdpEvent(message);
522
- this.cdp = cdp;
523
- this.cdpSocket = socket;
737
+ cdp.onEvent = (message) => this.handleActiveEvent(message);
738
+ this.activeCdp = cdp;
739
+ this.activeCdpSocket = socket;
524
740
  this.screencastStarted = false;
525
- this.pageUrl = target.url || this.pageUrl;
526
- this.pageTitle = target.title || this.pageTitle;
527
741
  socket.on('close', () => {
528
- if (this.cdp === cdp) {
529
- this.cdp = null;
530
- this.cdpSocket = null;
742
+ if (this.activeCdpSocket === socket) {
743
+ this.activeCdp = null;
744
+ this.activeCdpSocket = null;
531
745
  this.screencastStarted = false;
532
746
  }
533
747
  });
534
748
  await cdp.send('Page.enable');
535
749
  await cdp.send('Runtime.enable');
750
+ await this.applyAntiDetection(cdp);
751
+ if (generation !== this.activeGeneration) {
752
+ this.disconnectActiveCdp();
753
+ return;
754
+ }
536
755
  await this.applyViewport();
756
+ if (this.clients.size > 0) {
757
+ await this.startScreencast();
758
+ }
537
759
  }
538
- async getOrCreatePageTarget() {
539
- if (!this.port) {
540
- throw new Error('Browser is not running');
760
+ /**
761
+ * ヘッドレス Chrome がボット判定(Cloudflare の Managed Challenge / Turnstile 等)で
762
+ * 誤検知されるのを減らす。完全な突破は保証できないが、代表的な自動化シグナルを消す:
763
+ * - UA から "HeadlessChrome" を除去(実バージョンに一致させる)
764
+ * - navigator.webdriver / languages / chrome / plugins / WebGL ベンダを通常ブラウザ相当に
765
+ * 偽装スクリプトは addScriptToEvaluateOnNewDocument で「以後の」ページ生成時に毎回適用される。
766
+ */
767
+ async applyAntiDetection(cdp) {
768
+ try {
769
+ // 実際のブラウザ版に一致した UA を使う(バージョン不一致自体が検知シグナルになるため)。
770
+ // ページセッションでは Browser.* が使えないことがあるので /json/version を優先。
771
+ let userAgent = '';
772
+ try {
773
+ const info = await fetchJson(`http://127.0.0.1:${this.port}/json/version`);
774
+ userAgent = (info['User-Agent'] || '').replace(/HeadlessChrome/gi, 'Chrome');
775
+ }
776
+ catch {
777
+ // フォールバック: ページの navigator.userAgent を取得して整形。
778
+ const r = await cdp.send('Runtime.evaluate', {
779
+ expression: 'navigator.userAgent',
780
+ returnByValue: true,
781
+ });
782
+ userAgent = String(r.result?.value ?? '').replace(/HeadlessChrome/gi, 'Chrome');
783
+ }
784
+ const major = (userAgent.match(/Chrome\/(\d+)/) || [])[1] || '120';
785
+ if (userAgent) {
786
+ await cdp.send('Emulation.setUserAgentOverride', {
787
+ userAgent,
788
+ acceptLanguage: 'ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7',
789
+ platform: 'Linux x86_64',
790
+ userAgentMetadata: {
791
+ brands: [
792
+ { brand: 'Not_A Brand', version: '24' },
793
+ { brand: 'Chromium', version: major },
794
+ { brand: 'Google Chrome', version: major },
795
+ ],
796
+ fullVersion: (userAgent.match(/Chrome\/([\d.]+)/) || [])[1] || `${major}.0.0.0`,
797
+ platform: 'Linux',
798
+ platformVersion: '',
799
+ architecture: 'x86',
800
+ model: '',
801
+ mobile: false,
802
+ },
803
+ });
804
+ }
805
+ const stealthSource = `
806
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
807
+ if (!window.chrome) { window.chrome = { runtime: {} }; }
808
+ Object.defineProperty(navigator, 'languages', { get: () => ['ja-JP', 'ja', 'en-US', 'en'] });
809
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
810
+ try {
811
+ const _q = window.navigator.permissions && window.navigator.permissions.query;
812
+ if (_q) {
813
+ window.navigator.permissions.query = (p) =>
814
+ p && p.name === 'notifications'
815
+ ? Promise.resolve({ state: Notification.permission })
816
+ : _q(p);
817
+ }
818
+ } catch (e) { /* ignore */ }
819
+ try {
820
+ const getParam = WebGLRenderingContext.prototype.getParameter;
821
+ WebGLRenderingContext.prototype.getParameter = function (p) {
822
+ if (p === 37445) return 'Intel Inc.';
823
+ if (p === 37446) return 'Intel Iris OpenGL Engine';
824
+ return getParam.call(this, p);
825
+ };
826
+ } catch (e) { /* ignore */ }
827
+ `;
828
+ await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: stealthSource });
541
829
  }
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;
830
+ catch {
831
+ // 反検知設定は best-effort。失敗してもブラウジング自体は続行する。
547
832
  }
548
- return fetchJson(`${baseUrl}/json/new?about:blank`, { method: 'PUT' });
549
833
  }
550
834
  async startScreencast() {
551
- if (this.screencastStarted || !this.cdp) {
835
+ if (this.screencastStarted || !this.activeCdp) {
552
836
  return;
553
837
  }
554
838
  await this.applyViewport();
555
- await this.cdp.send('Page.startScreencast', {
839
+ await this.activeCdp.send('Page.startScreencast', {
556
840
  format: 'jpeg',
557
841
  quality: 72,
558
842
  maxWidth: this.viewport.width,
@@ -562,11 +846,11 @@ export class AgentBrowserService {
562
846
  this.screencastStarted = true;
563
847
  }
564
848
  async stopScreencast() {
565
- if (!this.screencastStarted || !this.cdp) {
849
+ if (!this.screencastStarted || !this.activeCdp) {
566
850
  return;
567
851
  }
568
852
  try {
569
- await this.cdp.send('Page.stopScreencast');
853
+ await this.activeCdp.send('Page.stopScreencast');
570
854
  }
571
855
  catch {
572
856
  // ignore
@@ -576,10 +860,10 @@ export class AgentBrowserService {
576
860
  }
577
861
  }
578
862
  async applyViewport() {
579
- if (!this.cdp) {
863
+ if (!this.activeCdp) {
580
864
  return;
581
865
  }
582
- await this.cdp.send('Emulation.setDeviceMetricsOverride', {
866
+ await this.activeCdp.send('Emulation.setDeviceMetricsOverride', {
583
867
  width: this.viewport.width,
584
868
  height: this.viewport.height,
585
869
  deviceScaleFactor: 1,
@@ -597,12 +881,12 @@ export class AgentBrowserService {
597
881
  return;
598
882
  }
599
883
  this.viewport = next;
600
- if (!this.cdp) {
884
+ if (!this.activeCdp) {
601
885
  return;
602
886
  }
603
887
  await this.applyViewport();
604
888
  if (this.screencastStarted) {
605
- await this.cdp.send('Page.stopScreencast').catch(() => undefined);
889
+ await this.activeCdp.send('Page.stopScreencast').catch(() => undefined);
606
890
  this.screencastStarted = false;
607
891
  await this.startScreencast();
608
892
  }
@@ -628,7 +912,19 @@ export class AgentBrowserService {
628
912
  return;
629
913
  }
630
914
  await this.start();
631
- await this.ensureCdp();
915
+ if (message.type === 'new-tab') {
916
+ await this.newTab(message.url);
917
+ return;
918
+ }
919
+ if (message.type === 'close-tab') {
920
+ await this.closeTab(message.targetId);
921
+ return;
922
+ }
923
+ if (message.type === 'activate-tab') {
924
+ await this.activateTab(message.targetId);
925
+ return;
926
+ }
927
+ await this.ensureActiveCdp();
632
928
  if (message.type === 'navigate') {
633
929
  await this.navigate(message.url);
634
930
  return;
@@ -649,9 +945,9 @@ export class AgentBrowserService {
649
945
  }
650
946
  }
651
947
  async dispatchMouse(message) {
652
- if (!this.cdp)
948
+ if (!this.activeCdp)
653
949
  return;
654
- await this.cdp.send('Input.dispatchMouseEvent', {
950
+ await this.activeCdp.send('Input.dispatchMouseEvent', {
655
951
  type: message.eventType,
656
952
  x: coercePositiveInt(message.x, 0, 0, MAX_VIEWPORT.width),
657
953
  y: coercePositiveInt(message.y, 0, 0, MAX_VIEWPORT.height),
@@ -663,10 +959,10 @@ export class AgentBrowserService {
663
959
  });
664
960
  }
665
961
  async dispatchKey(message) {
666
- if (!this.cdp)
962
+ if (!this.activeCdp)
667
963
  return;
668
964
  const windowsVirtualKeyCode = getWindowsVirtualKeyCode(message.key);
669
- await this.cdp.send('Input.dispatchKeyEvent', {
965
+ await this.activeCdp.send('Input.dispatchKeyEvent', {
670
966
  type: message.eventType,
671
967
  key: message.key,
672
968
  code: message.code,
@@ -677,13 +973,14 @@ export class AgentBrowserService {
677
973
  modifiers: message.modifiers ?? 0,
678
974
  });
679
975
  }
680
- handleCdpEvent(message) {
976
+ // Events from the active tab's page CDP connection.
977
+ handleActiveEvent(message) {
681
978
  if (message.method === 'Page.screencastFrame') {
682
979
  const params = message.params;
683
980
  const data = typeof params.data === 'string' ? params.data : null;
684
981
  const sessionId = typeof params.sessionId === 'number' ? params.sessionId : null;
685
982
  if (sessionId != null) {
686
- void this.cdp?.send('Page.screencastFrameAck', { sessionId }).catch(() => undefined);
983
+ void this.activeCdp?.send('Page.screencastFrameAck', { sessionId }).catch(() => undefined);
687
984
  }
688
985
  if (data) {
689
986
  const buffer = Buffer.from(data, 'base64');
@@ -695,32 +992,104 @@ export class AgentBrowserService {
695
992
  if (message.method === 'Page.frameNavigated') {
696
993
  const frame = (message.params?.frame ?? null);
697
994
  if (frame && !frame.parentId && frame.url) {
698
- this.pageUrl = frame.url;
699
- void this.refreshPageInfo().finally(() => {
995
+ const tab = this.activeTargetId ? this.tabs.get(this.activeTargetId) : null;
996
+ if (tab) {
997
+ tab.url = frame.url;
998
+ }
999
+ void this.refreshActivePageInfo().finally(() => {
700
1000
  void this.broadcastStatus();
701
1001
  });
702
1002
  }
703
1003
  return;
704
1004
  }
705
1005
  if (message.method === 'Page.loadEventFired') {
706
- void this.refreshPageInfo().finally(() => {
1006
+ void this.refreshActivePageInfo().finally(() => {
707
1007
  void this.broadcastStatus();
708
1008
  });
709
1009
  }
710
1010
  }
711
- async refreshPageInfo() {
712
- if (!this.cdp) {
1011
+ // Events from the browser-level CDP connection: tab lifecycle.
1012
+ handleBrowserEvent(message) {
1013
+ const method = message.method;
1014
+ if (method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') {
1015
+ const info = message.params?.targetInfo;
1016
+ if (info) {
1017
+ this.upsertTab(info);
1018
+ }
1019
+ return;
1020
+ }
1021
+ if (method === 'Target.targetDestroyed') {
1022
+ const targetId = typeof message.params?.targetId === 'string' ? message.params.targetId : null;
1023
+ if (targetId) {
1024
+ this.removeTab(targetId);
1025
+ }
1026
+ }
1027
+ }
1028
+ upsertTab(info) {
1029
+ if (info.type !== 'page') {
1030
+ return;
1031
+ }
1032
+ const url = info.url ?? '';
1033
+ if (url.startsWith('devtools://')) {
1034
+ return;
1035
+ }
1036
+ const prev = this.tabs.get(info.targetId);
1037
+ const isNew = !prev;
1038
+ const title = info.title ?? '';
1039
+ const changed = !prev || prev.url !== url || prev.title !== title;
1040
+ this.tabs.set(info.targetId, { id: info.targetId, url, title });
1041
+ if (isNew && this.ready && this.activeTargetId !== info.targetId) {
1042
+ // A page spawned a new tab/popup (target=_blank, window.open) — follow it
1043
+ // the way a normal browser does.
1044
+ void this.setActiveTab(info.targetId);
1045
+ return;
1046
+ }
1047
+ if (changed) {
1048
+ void this.broadcastStatus();
1049
+ }
1050
+ }
1051
+ removeTab(targetId) {
1052
+ if (!this.tabs.has(targetId)) {
1053
+ return;
1054
+ }
1055
+ const ids = [...this.tabs.keys()];
1056
+ const idx = ids.indexOf(targetId);
1057
+ this.tabs.delete(targetId);
1058
+ if (this.activeTargetId === targetId) {
1059
+ this.activeTargetId = null;
1060
+ this.disconnectActiveCdp();
1061
+ this.clearActiveFrame();
1062
+ this.broadcast({ type: 'clear' });
1063
+ const nextId = ids[idx + 1] ?? ids[idx - 1] ?? null;
1064
+ if (nextId && this.tabs.has(nextId)) {
1065
+ void this.setActiveTab(nextId);
1066
+ return; // setActiveTab broadcasts status
1067
+ }
1068
+ }
1069
+ void this.broadcastStatus();
1070
+ }
1071
+ clearActiveFrame() {
1072
+ this.lastFrameBuffer = null;
1073
+ this.pendingFrames.clear();
1074
+ }
1075
+ async refreshActivePageInfo() {
1076
+ if (!this.activeCdp || !this.activeTargetId) {
713
1077
  return;
714
1078
  }
715
1079
  try {
716
- const result = await this.cdp.send('Runtime.evaluate', {
1080
+ const result = await this.activeCdp.send('Runtime.evaluate', {
717
1081
  expression: '({ title: document.title, url: location.href })',
718
1082
  returnByValue: true,
719
1083
  });
720
1084
  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;
1085
+ const tab = this.tabs.get(this.activeTargetId);
1086
+ if (value && tab) {
1087
+ if (typeof value.title === 'string') {
1088
+ tab.title = value.title;
1089
+ }
1090
+ if (typeof value.url === 'string') {
1091
+ tab.url = value.url;
1092
+ }
724
1093
  }
725
1094
  }
726
1095
  catch {
@@ -841,11 +1210,11 @@ export class AgentBrowserService {
841
1210
  }
842
1211
  }
843
1212
  }
844
- closeCdp() {
845
- const cdp = this.cdp;
846
- const socket = this.cdpSocket;
847
- this.cdp = null;
848
- this.cdpSocket = null;
1213
+ disconnectActiveCdp() {
1214
+ const cdp = this.activeCdp;
1215
+ const socket = this.activeCdpSocket;
1216
+ this.activeCdp = null;
1217
+ this.activeCdpSocket = null;
849
1218
  this.screencastStarted = false;
850
1219
  if (cdp) {
851
1220
  cdp.close();
@@ -859,6 +1228,23 @@ export class AgentBrowserService {
859
1228
  }
860
1229
  }
861
1230
  }
1231
+ closeBrowserCdp() {
1232
+ const cdp = this.browserCdp;
1233
+ const socket = this.browserCdpSocket;
1234
+ this.browserCdp = null;
1235
+ this.browserCdpSocket = null;
1236
+ if (cdp) {
1237
+ cdp.close();
1238
+ }
1239
+ else if (socket) {
1240
+ try {
1241
+ socket.close();
1242
+ }
1243
+ catch {
1244
+ // ignore
1245
+ }
1246
+ }
1247
+ }
862
1248
  openLogStream() {
863
1249
  this.closeLogStream();
864
1250
  const logPath = path.join(this.outputDir, 'deckide-browser.log');