@ait-co/devtools 0.1.129 → 0.1.131
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.
- package/dist/{attach-orchestrator-DvrgUIx7.js → attach-orchestrator-BiaYiNOr.js} +31 -13
- package/dist/{attach-orchestrator-DvrgUIx7.js.map → attach-orchestrator-BiaYiNOr.js.map} +1 -1
- package/dist/{attach-orchestrator-RhOdO3DS.js → attach-orchestrator-Cjdhir2U.js} +31 -13
- package/dist/{attach-orchestrator-RhOdO3DS.js.map → attach-orchestrator-Cjdhir2U.js.map} +1 -1
- package/dist/{attach-orchestrator-Bdt5Zdo9.js → attach-orchestrator-ca34r46y.js} +31 -13
- package/dist/{attach-orchestrator-Bdt5Zdo9.js.map → attach-orchestrator-ca34r46y.js.map} +1 -1
- package/dist/{cell-Ba9lCI3B.js → cell-Bi8MAPA8.js} +11 -7
- package/dist/cell-Bi8MAPA8.js.map +1 -0
- package/dist/{cell-DA_lcpDI.js → cell-Cw4fVOl2.js} +11 -7
- package/dist/cell-Cw4fVOl2.js.map +1 -0
- package/dist/{cell-DLRv4iG6.js → cell-rr6gL9AT.js} +11 -7
- package/dist/cell-rr6gL9AT.js.map +1 -0
- package/dist/{debug-server-Bu3pCG9c.js → debug-server-BSbfnFR5.js} +9 -9
- package/dist/debug-server-BSbfnFR5.js.map +1 -0
- package/dist/{debug-server-e1qPe0ZB.js → debug-server-CD4zYBq4.js} +4 -4
- package/dist/{debug-server-e1qPe0ZB.js.map → debug-server-CD4zYBq4.js.map} +1 -1
- package/dist/{debug-server--Y0QZVJS.js → debug-server-qRSRG7YZ.js} +15 -11
- package/dist/debug-server-qRSRG7YZ.js.map +1 -0
- package/dist/in-app/auto.js +15 -0
- package/dist/in-app/auto.js.map +1 -1
- package/dist/in-app/index.d.ts +27 -0
- package/dist/in-app/index.d.ts.map +1 -1
- package/dist/in-app/index.js +15 -0
- package/dist/in-app/index.js.map +1 -1
- package/dist/mcp/cli.js +143 -35
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.js +11 -1
- package/dist/panel/index.js.map +1 -1
- package/dist/{pool-C0pc7T2p.d.ts → pool-BaSCZALv.d.ts} +9 -1
- package/dist/{pool-C0pc7T2p.d.ts.map → pool-BaSCZALv.d.ts.map} +1 -1
- package/dist/{qr-http-server-HANhOFwI.js → qr-http-server-BdNGPRoL.js} +76 -6
- package/dist/qr-http-server-BdNGPRoL.js.map +1 -0
- package/dist/{qr-http-server-CrGohOwC.js → qr-http-server-Bq9zR7-z.js} +76 -6
- package/dist/qr-http-server-Bq9zR7-z.js.map +1 -0
- package/dist/{qr-http-server-DM2R25jP.cjs → qr-http-server-BxoRp2w7.cjs} +76 -6
- package/dist/qr-http-server-BxoRp2w7.cjs.map +1 -0
- package/dist/{qr-http-server-Bt_RAxFn.cjs → qr-http-server-CV6TA9ZN.cjs} +76 -6
- package/dist/qr-http-server-CV6TA9ZN.cjs.map +1 -0
- package/dist/{qr-http-server-s1iW_5VQ.js → qr-http-server-DCbjORFa.js} +76 -6
- package/dist/qr-http-server-DCbjORFa.js.map +1 -0
- package/dist/{qr-http-server-BH0UFeUn.js → qr-http-server-cKbciY5k.js} +76 -6
- package/dist/qr-http-server-cKbciY5k.js.map +1 -0
- package/dist/{relay-factory-Ck3vBonr.js → relay-factory-Cloog1jb.js} +33 -13
- package/dist/relay-factory-Cloog1jb.js.map +1 -0
- package/dist/{relay-worker-Bt0QYKQH.js → relay-worker-CCAHLC4a.js} +9 -5
- package/dist/relay-worker-CCAHLC4a.js.map +1 -0
- package/dist/test-runner/bin.js +45 -19
- package/dist/test-runner/bin.js.map +1 -1
- package/dist/test-runner/config.d.ts +15 -9
- package/dist/test-runner/config.d.ts.map +1 -1
- package/dist/test-runner/config.js +1 -1
- package/dist/test-runner/pool.d.ts +1 -1
- package/dist/test-runner/pool.js +1 -1
- package/dist/test-runner/pool.js.map +1 -1
- package/dist/test-runner/relay-factory.d.ts +22 -8
- package/dist/test-runner/relay-factory.d.ts.map +1 -1
- package/dist/test-runner/relay-factory.js +32 -12
- package/dist/test-runner/relay-factory.js.map +1 -1
- package/dist/test-runner/relay-worker.js +1 -1
- package/dist/{tunnel-DyEV6WwL.cjs → tunnel-B2UCKAc7.cjs} +4 -3
- package/dist/{tunnel-DyEV6WwL.cjs.map → tunnel-B2UCKAc7.cjs.map} +1 -1
- package/dist/{tunnel-BFwOSwT4.js → tunnel-C-rQ34dM.js} +4 -3
- package/dist/{tunnel-BFwOSwT4.js.map → tunnel-C-rQ34dM.js.map} +1 -1
- package/dist/unplugin/index.cjs +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/unplugin/tunnel.cjs +3 -2
- package/dist/unplugin/tunnel.cjs.map +1 -1
- package/dist/unplugin/tunnel.d.cts.map +1 -1
- package/dist/unplugin/tunnel.d.ts.map +1 -1
- package/dist/unplugin/tunnel.js +3 -2
- package/dist/unplugin/tunnel.js.map +1 -1
- package/package.json +1 -1
- package/dist/cell-Ba9lCI3B.js.map +0 -1
- package/dist/cell-DA_lcpDI.js.map +0 -1
- package/dist/cell-DLRv4iG6.js.map +0 -1
- package/dist/debug-server--Y0QZVJS.js.map +0 -1
- package/dist/debug-server-Bu3pCG9c.js.map +0 -1
- package/dist/qr-http-server-BH0UFeUn.js.map +0 -1
- package/dist/qr-http-server-Bt_RAxFn.cjs.map +0 -1
- package/dist/qr-http-server-CrGohOwC.js.map +0 -1
- package/dist/qr-http-server-DM2R25jP.cjs.map +0 -1
- package/dist/qr-http-server-HANhOFwI.js.map +0 -1
- package/dist/qr-http-server-s1iW_5VQ.js.map +0 -1
- package/dist/relay-factory-Ck3vBonr.js.map +0 -1
- package/dist/relay-worker-Bt0QYKQH.js.map +0 -1
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"qr-http-server-s1iW_5VQ.js","names":[],"sources":["../src/i18n/en.ts","../src/i18n/ko.ts","../src/i18n/index.ts","../src/mcp/dashboard.generated.ts","../src/mcp/qr-http-server.ts"],"sourcesContent":["import type { StringKey } from './ko.js';\n\n// English translations. Mirrors every key in `ko.ts`; missing keys fall back to\n// the key string at runtime (see `t()` in index.ts), but the `Record<StringKey,\n// string>` type below means a missing key will typecheck-fail.\n\nexport const en: Record<StringKey, string> = {\n // Panel chrome\n 'panel.title': 'AIT DevTools',\n 'panel.toggle.title': 'AIT DevTools',\n 'panel.close': 'Close',\n 'panel.editMode.on': 'EDIT',\n 'panel.editMode.off': 'READ-ONLY',\n 'panel.editMode.toggleTitle': 'Toggle panel edit mode',\n 'panel.tabError': 'Error rendering \"{tab}\" tab.',\n\n // Tab names\n 'panel.tab.env': 'Environment',\n 'panel.tab.presets': 'Presets',\n 'panel.tab.viewport': 'Viewport',\n 'panel.tab.permissions': 'Permissions',\n 'panel.tab.notifications': 'Notifications',\n 'panel.tab.location': 'Location',\n 'panel.tab.device': 'Device',\n 'panel.tab.iap': 'IAP',\n 'panel.tab.ads': 'Ads',\n 'panel.tab.events': 'Events',\n 'panel.tab.analytics': 'Analytics',\n 'panel.tab.storage': 'Storage',\n\n // Common\n 'common.readOnly': 'Read-only — mock responses are controlled at build time.',\n\n // Environment tab\n 'env.section.platform': 'Platform',\n 'env.row.os': 'OS',\n 'env.row.appVersion': 'App Version',\n 'env.row.environment': 'Environment',\n 'env.row.locale': 'Locale',\n 'env.section.network': 'Network',\n 'env.row.networkStatus': 'Status',\n 'env.section.safeArea': 'Safe Area Insets',\n 'env.row.safeArea.top': 'Top',\n 'env.row.safeArea.bottom': 'Bottom',\n 'env.section.navigation': 'Navigation',\n 'env.row.iosSwipeGesture': 'iOS swipe-back',\n 'env.value.iosSwipeGesture.unset': 'not called',\n 'env.value.iosSwipeGesture.enabled': 'enabled',\n 'env.value.iosSwipeGesture.disabled': 'disabled',\n 'env.hint.iosSwipeGesture':\n 'Last value passed to setIosSwipeGestureEnabled. Switching Environment to toss lets a toss-gated guard toggle this.',\n\n // Environment > Language toggle\n 'env.section.language': 'Language',\n 'env.language.row': 'Language',\n 'env.language.ko': '한국어',\n 'env.language.en': 'English',\n\n // Permissions tab\n 'permissions.section.device': 'Device Permissions',\n\n // Location tab\n 'location.section.current': 'Current Location',\n 'location.row.latitude': 'Latitude',\n 'location.row.longitude': 'Longitude',\n 'location.row.accuracy': 'Accuracy',\n\n // Device tab\n 'device.section.modes': 'Device API Modes',\n 'device.row.camera': 'Camera',\n 'device.row.photos': 'Photos',\n 'device.row.location': 'Location',\n 'device.row.network': 'Network',\n 'device.row.clipboard': 'Clipboard',\n 'device.section.mockImages': 'Mock Images ({count})',\n 'device.btn.add': '+ Add',\n 'device.btn.useDefaults': 'Use defaults',\n 'device.btn.clear': 'Clear',\n 'device.prompt.camera.title': 'Camera Prompt — Select an image',\n 'device.prompt.photos.title': 'Photos Prompt — Select images',\n 'device.prompt.location.title': 'Location Prompt — Enter coordinates',\n 'device.prompt.locationUpdate.title': 'Location Update — Send coordinates',\n 'device.prompt.fallbackTitle': 'Prompt: {type}',\n 'device.prompt.label.lat': 'Lat',\n 'device.prompt.label.lng': 'Lng',\n 'device.prompt.send': 'Send',\n 'device.prompt.cancel': 'Cancel',\n\n // Device tab — Haptic section\n 'device.section.haptic': 'Haptic',\n 'device.haptic.lastCall': 'Last haptic',\n 'device.haptic.noneYet': '(none yet)',\n 'device.haptic.trigger': 'Trigger haptic',\n\n // Viewport tab\n 'viewport.section.device': 'Device',\n 'viewport.row.preset': 'Preset',\n 'viewport.row.orientation': 'Orientation',\n 'viewport.row.notchSide': 'Notch side',\n 'viewport.section.custom': 'Custom size',\n 'viewport.row.width': 'Width (px)',\n 'viewport.row.height': 'Height (px)',\n 'viewport.section.appearance': 'Appearance',\n 'viewport.row.showFrame': 'Show frame',\n 'viewport.row.showAitNavBar': 'Show Apps in Toss nav bar',\n 'viewport.row.navBarType': 'Nav bar type',\n 'viewport.status.noConstraint': 'No viewport constraint — body fills the window.',\n 'viewport.status.cssPhysical': 'CSS / physical',\n 'viewport.status.safeArea': 'Safe area',\n 'viewport.status.aitNavBar': 'AIT nav bar',\n 'viewport.status.aitNavBarValue': '{height}px → SafeArea top · {type}',\n 'viewport.orientation.autoSuffix': '{orient} (auto)',\n\n // IAP tab\n 'iap.section.simulator': 'IAP Simulator',\n 'iap.row.nextResult': 'Next Purchase Result',\n 'iap.section.tossPay': 'TossPay',\n 'iap.row.tossPayResult': 'Next Payment Result',\n 'iap.section.pending': 'Pending Orders ({count})',\n 'iap.empty.pending': '(no pending orders)',\n 'iap.section.completed': 'Completed Orders ({count})',\n 'iap.empty.completed': '(no completed orders)',\n 'iap.btn.complete': 'Complete',\n 'iap.label.pending': 'PENDING',\n\n // Events tab\n 'events.section.navigation': 'Navigation Events',\n 'events.btn.triggerBack': 'Trigger Back Event',\n 'events.btn.triggerHome': 'Trigger Home Event',\n 'events.section.login': 'Login',\n 'events.row.loggedIn': 'Logged In',\n 'events.row.tossLoginIntegrated': 'Toss Login Integrated',\n\n // Analytics tab\n 'analytics.section.log': 'Analytics Log ({count})',\n 'analytics.btn.clear': 'Clear',\n 'analytics.calls.section': 'SDK Calls ({count})',\n 'analytics.calls.btn.clear': 'Clear',\n 'analytics.calls.empty': '(no SDK calls yet)',\n\n // Storage tab\n 'storage.section.title': 'Storage ({count} items)',\n 'storage.btn.clearAll': 'Clear All',\n 'storage.empty': 'No items in storage',\n\n // Presets tab\n 'presets.section.builtIn': 'Built-in scenarios',\n 'presets.section.saved': 'Saved presets ({count})',\n 'presets.section.save': 'Save',\n 'presets.save.description': 'Capture network / permissions / auth / IAP / ads / payment slices.',\n 'presets.btn.saveCurrent': 'Save current as preset',\n 'presets.btn.apply': 'Apply',\n 'presets.btn.reApply': 'Re-apply',\n 'presets.btn.delete': 'Delete',\n 'presets.empty.saved': 'No saved presets yet.',\n 'presets.empty.builtIn': 'No built-in presets.',\n 'presets.prompt.label': 'Preset label?',\n 'presets.confirm.delete': 'Delete preset \"{label}\"?',\n\n // Ads tab\n 'ads.section.state': 'Ads State',\n 'ads.row.isLoaded': 'isLoaded',\n 'ads.row.forceNoFill': 'Force \"no fill\"',\n 'ads.empty.events': 'No events yet',\n 'ads.section.googleAdMob': 'GoogleAdMob',\n 'ads.section.tossAds': 'TossAds',\n 'ads.section.fullScreenAd': 'FullScreenAd',\n 'ads.btn.load': 'Load',\n 'ads.btn.show': 'Show',\n 'ads.section.tossAdsBanner': 'TossAds Banner',\n 'ads.row.rewardUnitType': 'Reward unit type',\n 'ads.row.rewardAmount': 'Reward amount',\n 'ads.btn.render': 'Render',\n 'ads.btn.noFill': 'No-fill',\n 'ads.btn.click': 'Click',\n 'ads.btn.destroy': 'Destroy',\n\n // Notifications tab\n 'notifications.section.title': 'requestNotificationAgreement',\n 'notifications.option.newAgreement': 'newAgreement (first-time agree)',\n 'notifications.option.alreadyAgreed': 'alreadyAgreed (already opted-in)',\n 'notifications.option.agreementRejected': 'agreementRejected (user declined)',\n\n // qr-http-server — lang switcher (dashboard / attach pages)\n 'dashboard.lang.ko': '한국어',\n 'dashboard.lang.en': 'English',\n\n // qr-http-server — dashboard page (server-side, Node, per-request)\n 'dashboard.title': 'AIT Debug Dashboard',\n 'dashboard.updated': 'Last updated: {ts}',\n 'dashboard.tunnel.section': 'Tunnel status',\n 'dashboard.tunnel.up': 'Connected',\n 'dashboard.tunnel.down': 'Disconnected',\n 'dashboard.attach.section': 'Attach QR',\n 'dashboard.attach.hint': 'Call the start_attach MCP tool to show the QR here.',\n 'dashboard.attach.tunnelDown':\n 'Relay disconnected — this QR is no longer valid. Restart the relay, then regenerate the QR.',\n 'dashboard.pages.section': 'Connected Pages',\n 'dashboard.pages.empty': 'No attached pages',\n\n // qr-http-server — url-box copy button\n 'dashboard.url.copy': 'Copy',\n 'dashboard.url.copied': 'Copied',\n\n // qr-http-server — inspector open link (#503)\n 'dashboard.inspector.section': 'Inspector',\n 'dashboard.inspector.open': 'Open DevTools',\n 'dashboard.inspector.waiting': 'Attach a page to enable the \"Open DevTools\" button',\n\n // qr-http-server — /inspector stable entry (issue #530)\n 'inspector.error.noTarget': 'No page attached. Attach a device and try again.',\n 'inspector.error.relayDown': 'Relay is not active. Start a relay session first.',\n\n // qr-http-server — SSE watchdog fallback UI (#681)\n // Shown when SSE refresh stops (server shutdown); attempts window.close() first.\n 'dashboard.watchdog.title': 'Server has shut down',\n 'dashboard.watchdog.body':\n 'The MCP server has stopped. This session is no longer active. You may close this tab.',\n 'dashboard.watchdog.close': 'Close tab',\n\n // qr-http-server — attach page (server-side, Node, per-request)\n // Copy branches per session mode into sandbox (env 2) / intoss (env 3) families (#468).\n 'attach.title': 'AIT Debug Session — QR Scan',\n 'attach.deployment': 'deployment: {label}',\n 'attach.steps.section': 'How to scan',\n 'attach.faq.section': 'Troubleshooting checklist',\n 'attach.url.section': 'URL (fallback)',\n\n // qr-http-server — attach page mode label (environment visibility, #468)\n 'attach.mode.sandbox': 'env 2 — AITC Sandbox App (PWA)',\n 'attach.mode.intossDev': 'env 3 — intoss-private relay dev',\n\n // attach page — sandbox family (env 2: launcher PWA; no Toss app / _deploymentId concepts)\n 'attach.sandbox.step1':\n 'Launch the launcher PWA icon on your home screen (if the Safari address bar is visible, it is not standalone).',\n 'attach.sandbox.step2':\n 'Scan this QR code with <strong>\"Scan QR with camera\"</strong> inside the launcher.',\n 'attach.sandbox.step3':\n 'The mini-app opens fullscreen and the debug session attaches automatically.',\n 'attach.sandbox.faq.notInstalled':\n '<strong>Launcher is not installed</strong> — open <code>devtools.aitc.dev/launcher/</code> once and add it to your home screen',\n 'attach.sandbox.faq.cameraApp':\n '<strong>Scanning with the camera app opens a Safari tab (bottom tab bar visible)</strong> — relaunch from the launcher icon and use the in-app scanner',\n 'attach.sandbox.faq.totp':\n '<strong>QR expired (TOTP — 30-second step, ±6 steps (~3 min) accepted)</strong> — scan a fresh QR code',\n 'attach.sandbox.faq.chii':\n '<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import',\n\n // attach page — intoss family (env 3: Toss app deep-link)\n 'attach.intoss.step1': 'Open the Toss app.',\n 'attach.intoss.step2': 'Scan the QR code with your phone camera app.',\n 'attach.intoss.step3': 'Tap <strong>\"Open in Toss\"</strong> when the popup appears.',\n 'attach.intoss.step4': 'The mini-app opens and the debug session attaches automatically.',\n 'attach.intoss.faq.appNotOpen':\n '<strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)',\n 'attach.intoss.faq.prepare':\n '<strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter',\n 'attach.intoss.faq.chii':\n '<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import',\n 'attach.intoss.faq.totp':\n '<strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server',\n // Launcher PWA\n 'launcher.title': 'AITC DevTools Launcher',\n 'launcher.description': 'Scan the terminal QR code or paste the tunnel URL.',\n 'launcher.installCta': 'Install launcher to your phone',\n 'launcher.urlPlaceholder': 'https://example.trycloudflare.com',\n 'launcher.openBtn': 'Open',\n 'launcher.scanBtn': 'Scan QR with camera',\n 'launcher.noCamera': 'No camera available — paste the URL instead.',\n 'launcher.cameraError': 'Could not access the camera — paste the URL instead.',\n 'launcher.invalidUrlHttps': 'Enter a valid https:// URL (the tunnel URL from your terminal).',\n 'launcher.invalidUrl': 'Enter a valid http(s):// URL.',\n 'launcher.debugAuthFailed': 'Debug connection authentication failed',\n 'launcher.debugAuthFailedHint': 'The QR code may have expired. Scan a fresh QR code.',\n 'launcher.debugAuthExpiredHint':\n 'The debug session has expired. Scan a fresh QR from the attach page on your Mac.',\n 'launcher.debugAuthRescanCta': 'Scan a new QR',\n 'launcher.diagTitle': 'Viewport diagnostics',\n 'launcher.diagYes': 'yes',\n 'launcher.diagNo': 'no',\n 'launcher.letterboxDetected':\n 'An iOS viewport constraint may clip the bottom {pt}pt — rotating to landscape and back to portrait may resolve it.',\n 'launcher.letterboxClipped':\n 'An iOS viewport bug makes the bottom {pt}pt unusable — rotating to landscape and back to portrait may recover it.',\n // #536: verdict reason labels for diag panel\n 'launcher.diagVerdictLabel': 'Verdict reason',\n 'launcher.diagSafeAreaTrace': 'top re-measure trace',\n 'launcher.diagVerdict.detected': '✓ letterbox correction',\n 'launcher.diagVerdict.notStandalone': 'not standalone',\n 'launcher.diagVerdict.landscape': 'landscape',\n 'launcher.diagVerdict.shortfallTooSmall': 'shortfall too small',\n 'launcher.diagVerdict.safeAreaTopZero': 'top=0 (env() stale?)',\n // Nav-bar emulation (#495/#510)\n 'launcher.navbar.defaultTitle': 'Mini App',\n 'launcher.navbar.back': 'Back',\n 'launcher.navbar.menu': 'Menu',\n 'launcher.navbar.close': 'Close',\n 'launcher.navbar.menuRescan': 'Rescan',\n 'launcher.navbar.menuDiag': 'Viewport diagnostics',\n 'launcher.navbar.menuLanguage': 'Language',\n};\n","// Korean string catalog (source of truth — keys are typed from this file).\n// Keys follow `<area>.<purpose>` convention. Variable interpolation uses\n// `{name}` placeholders resolved by `t(key, { name: value })`.\n//\n// Some chrome (button labels like \"Load\", \"Show\", \"Clear\", \"Apply\") is left as\n// English in both locales because the panel is an internal devtools surface\n// and these terms are universally recognised by developers in both locales.\n\nexport const ko = {\n // Panel chrome\n 'panel.title': 'AIT DevTools',\n 'panel.toggle.title': 'AIT DevTools',\n 'panel.close': 'Close',\n 'panel.editMode.on': 'EDIT',\n 'panel.editMode.off': 'READ-ONLY',\n 'panel.editMode.toggleTitle': '패널 편집 모드 전환',\n 'panel.tabError': '\"{tab}\" 탭 렌더링 중 오류가 발생했습니다.',\n\n // Tab names\n 'panel.tab.env': 'Environment',\n 'panel.tab.presets': 'Presets',\n 'panel.tab.viewport': 'Viewport',\n 'panel.tab.permissions': 'Permissions',\n 'panel.tab.notifications': 'Notifications',\n 'panel.tab.location': 'Location',\n 'panel.tab.device': 'Device',\n 'panel.tab.iap': 'IAP',\n 'panel.tab.ads': 'Ads',\n 'panel.tab.events': 'Events',\n 'panel.tab.analytics': 'Analytics',\n 'panel.tab.storage': 'Storage',\n\n // Common\n 'common.readOnly': '읽기 전용 — mock 응답은 빌드 타임에 고정됩니다.',\n\n // Environment tab\n 'env.section.platform': 'Platform',\n 'env.row.os': 'OS',\n 'env.row.appVersion': 'App Version',\n 'env.row.environment': 'Environment',\n 'env.row.locale': 'Locale',\n 'env.section.network': 'Network',\n 'env.row.networkStatus': 'Status',\n 'env.section.safeArea': 'Safe Area Insets',\n 'env.row.safeArea.top': 'Top',\n 'env.row.safeArea.bottom': 'Bottom',\n 'env.section.navigation': 'Navigation',\n 'env.row.iosSwipeGesture': 'iOS swipe-back',\n 'env.value.iosSwipeGesture.unset': '미호출',\n 'env.value.iosSwipeGesture.enabled': 'enabled',\n 'env.value.iosSwipeGesture.disabled': 'disabled',\n 'env.hint.iosSwipeGesture':\n 'setIosSwipeGestureEnabled의 마지막 호출값. Environment를 toss로 바꾸면 toss-gated 가드가 이 값을 토글합니다.',\n\n // Environment > Language toggle\n 'env.section.language': 'Language',\n 'env.language.row': 'Language',\n 'env.language.ko': '한국어',\n 'env.language.en': 'English',\n\n // Permissions tab\n 'permissions.section.device': 'Device Permissions',\n\n // Location tab\n 'location.section.current': 'Current Location',\n 'location.row.latitude': 'Latitude',\n 'location.row.longitude': 'Longitude',\n 'location.row.accuracy': 'Accuracy',\n\n // Device tab\n 'device.section.modes': 'Device API Modes',\n 'device.row.camera': 'Camera',\n 'device.row.photos': 'Photos',\n 'device.row.location': 'Location',\n 'device.row.network': 'Network',\n 'device.row.clipboard': 'Clipboard',\n 'device.section.mockImages': 'Mock Images ({count})',\n 'device.btn.add': '+ Add',\n 'device.btn.useDefaults': 'Use defaults',\n 'device.btn.clear': 'Clear',\n 'device.prompt.camera.title': 'Camera Prompt — 이미지를 선택하세요',\n 'device.prompt.photos.title': 'Photos Prompt — 이미지를 선택하세요',\n 'device.prompt.location.title': 'Location Prompt — 좌표 입력',\n 'device.prompt.locationUpdate.title': 'Location Update — 좌표 전송',\n 'device.prompt.fallbackTitle': 'Prompt: {type}',\n 'device.prompt.label.lat': 'Lat',\n 'device.prompt.label.lng': 'Lng',\n 'device.prompt.send': 'Send',\n 'device.prompt.cancel': 'Cancel',\n\n // Device tab — Haptic section\n 'device.section.haptic': 'Haptic',\n 'device.haptic.lastCall': '마지막 haptic',\n 'device.haptic.noneYet': '(아직 없음)',\n 'device.haptic.trigger': 'Haptic 트리거',\n\n // Viewport tab\n 'viewport.section.device': 'Device',\n 'viewport.row.preset': 'Preset',\n 'viewport.row.orientation': 'Orientation',\n 'viewport.row.notchSide': 'Notch side',\n 'viewport.section.custom': 'Custom size',\n 'viewport.row.width': 'Width (px)',\n 'viewport.row.height': 'Height (px)',\n 'viewport.section.appearance': 'Appearance',\n 'viewport.row.showFrame': 'Show frame',\n 'viewport.row.showAitNavBar': 'Apps in Toss 내비게이션 바 표시',\n 'viewport.row.navBarType': 'Nav bar type',\n 'viewport.status.noConstraint': '뷰포트 제약 없음 — body가 창을 가득 채웁니다.',\n 'viewport.status.cssPhysical': 'CSS / physical',\n 'viewport.status.safeArea': 'Safe area',\n 'viewport.status.aitNavBar': 'AIT nav bar',\n 'viewport.status.aitNavBarValue': '{height}px → SafeArea top · {type}',\n 'viewport.orientation.autoSuffix': '{orient} (auto)',\n\n // IAP tab\n 'iap.section.simulator': 'IAP Simulator',\n 'iap.row.nextResult': 'Next Purchase Result',\n 'iap.section.tossPay': 'TossPay',\n 'iap.row.tossPayResult': 'Next Payment Result',\n 'iap.section.pending': 'Pending Orders ({count})',\n 'iap.empty.pending': '(대기 중인 주문 없음)',\n 'iap.section.completed': 'Completed Orders ({count})',\n 'iap.empty.completed': '(완료된 주문 없음)',\n 'iap.btn.complete': 'Complete',\n 'iap.label.pending': 'PENDING',\n\n // Events tab\n 'events.section.navigation': 'Navigation Events',\n 'events.btn.triggerBack': 'Back 이벤트 발생',\n 'events.btn.triggerHome': 'Home 이벤트 발생',\n 'events.section.login': 'Login',\n 'events.row.loggedIn': 'Logged In',\n 'events.row.tossLoginIntegrated': 'Toss Login Integrated',\n\n // Analytics tab\n 'analytics.section.log': 'Analytics Log ({count})',\n 'analytics.btn.clear': 'Clear',\n 'analytics.calls.section': 'SDK Calls ({count})',\n 'analytics.calls.btn.clear': 'Clear',\n 'analytics.calls.empty': '(아직 SDK 호출 없음)',\n\n // Storage tab\n 'storage.section.title': 'Storage ({count} items)',\n 'storage.btn.clearAll': 'Clear All',\n 'storage.empty': '저장된 항목이 없습니다',\n\n // Presets tab\n 'presets.section.builtIn': 'Built-in scenarios',\n 'presets.section.saved': 'Saved presets ({count})',\n 'presets.section.save': 'Save',\n 'presets.save.description':\n 'network / permissions / auth / IAP / ads / payment 슬라이스를 캡처합니다.',\n 'presets.btn.saveCurrent': '현재 상태를 프리셋으로 저장',\n 'presets.btn.apply': 'Apply',\n 'presets.btn.reApply': 'Re-apply',\n 'presets.btn.delete': 'Delete',\n 'presets.empty.saved': '저장된 프리셋이 아직 없습니다.',\n 'presets.empty.builtIn': '내장 프리셋이 없습니다.',\n 'presets.prompt.label': '프리셋 라벨을 입력하세요',\n 'presets.confirm.delete': '\"{label}\" 프리셋을 삭제할까요?',\n\n // Ads tab\n 'ads.section.state': 'Ads State',\n 'ads.row.isLoaded': 'isLoaded',\n 'ads.row.forceNoFill': '강제 \"no fill\"',\n 'ads.empty.events': '아직 이벤트가 없습니다',\n 'ads.section.googleAdMob': 'GoogleAdMob',\n 'ads.section.tossAds': 'TossAds',\n 'ads.section.fullScreenAd': 'FullScreenAd',\n 'ads.btn.load': 'Load',\n 'ads.btn.show': 'Show',\n 'ads.section.tossAdsBanner': 'TossAds 배너',\n 'ads.row.rewardUnitType': '리워드 단위 타입',\n 'ads.row.rewardAmount': '리워드 수량',\n 'ads.btn.render': 'Render',\n 'ads.btn.noFill': 'No-fill',\n 'ads.btn.click': 'Click',\n 'ads.btn.destroy': 'Destroy',\n\n // Notifications tab\n 'notifications.section.title': 'requestNotificationAgreement',\n 'notifications.option.newAgreement': 'newAgreement (최초 동의)',\n 'notifications.option.alreadyAgreed': 'alreadyAgreed (이미 동의됨)',\n 'notifications.option.agreementRejected': 'agreementRejected (사용자 거절)',\n\n // qr-http-server — lang switcher (dashboard / attach pages)\n 'dashboard.lang.ko': '한국어',\n 'dashboard.lang.en': 'English',\n\n // qr-http-server — dashboard page (server-side, Node, per-request)\n 'dashboard.title': 'AIT 디버그 Dashboard',\n 'dashboard.updated': '마지막 갱신: {ts}',\n 'dashboard.tunnel.section': '터널 상태',\n 'dashboard.tunnel.up': '연결됨',\n 'dashboard.tunnel.down': '끊어짐',\n 'dashboard.attach.section': 'Attach QR',\n 'dashboard.attach.hint': 'start_attach MCP tool을 호출하면 QR이 여기에 표시됩니다.',\n 'dashboard.attach.tunnelDown':\n 'relay 연결이 끊겼습니다 — 이 QR은 더 이상 유효하지 않습니다. relay를 재시작한 뒤 QR을 다시 생성하세요.',\n 'dashboard.pages.section': '연결된 Pages',\n 'dashboard.pages.empty': 'attach된 페이지 없음',\n\n // qr-http-server — url-box copy button\n 'dashboard.url.copy': '복사',\n 'dashboard.url.copied': '복사됨',\n\n // qr-http-server — inspector open link (#503)\n 'dashboard.inspector.section': '인스펙터',\n 'dashboard.inspector.open': '디버그 툴 열기',\n 'dashboard.inspector.waiting': '페이지를 attach하면 \"디버그 툴 열기\" 버튼이 표시됩니다',\n\n // qr-http-server — /inspector stable entry (issue #530)\n 'inspector.error.noTarget': '연결된 페이지가 없습니다. 기기를 attach한 후 다시 시도하세요.',\n 'inspector.error.relayDown': 'relay가 활성화되지 않았습니다. start_debug로 relay를 기동하세요.',\n\n // qr-http-server — SSE watchdog fallback UI (#681)\n // SSE 주기 갱신이 끊기면(server 종료) 탭 자동 닫기 시도 후 보여주는 안내 화면.\n 'dashboard.watchdog.title': '서버가 종료되었습니다',\n 'dashboard.watchdog.body':\n 'MCP server가 종료되어 이 세션이 더 이상 유효하지 않습니다. 이 탭을 닫아도 됩니다.',\n 'dashboard.watchdog.close': '탭 닫기',\n\n // qr-http-server — attach page (server-side, Node, per-request)\n // 카피는 세션 mode별로 sandbox(환경 2) / intoss(환경 3) family로 분기한다 (#468).\n 'attach.title': 'AIT 디버그 세션 — QR 스캔',\n 'attach.deployment': 'deployment: {label}',\n 'attach.steps.section': '스캔 절차',\n 'attach.faq.section': '진단 체크리스트',\n 'attach.url.section': 'URL (fallback)',\n\n // qr-http-server — attach page mode 라벨 (환경 가시화, #468)\n 'attach.mode.sandbox': '환경 2 — AITC Sandbox App (PWA)',\n 'attach.mode.intossDev': '환경 3 — intoss-private relay dev',\n\n // attach page — sandbox family (환경 2: launcher PWA, 토스 앱·_deploymentId 개념 없음)\n 'attach.sandbox.step1':\n '홈 화면의 launcher PWA 아이콘으로 실행하세요 (Safari 주소창이 보이면 standalone이 아닙니다).',\n 'attach.sandbox.step2':\n 'launcher 안의 <strong>\"QR 카메라로 스캔\"</strong>으로 이 QR 코드를 스캔하세요.',\n 'attach.sandbox.step3': '미니앱이 풀스크린으로 열리고 디버그 세션이 자동으로 attach됩니다.',\n 'attach.sandbox.faq.notInstalled':\n '<strong>launcher가 설치돼 있지 않은 경우</strong> — <code>devtools.aitc.dev/launcher/</code>를 한 번 열어 홈 화면에 추가하세요',\n 'attach.sandbox.faq.cameraApp':\n '<strong>카메라 앱으로 스캔하면 Safari 탭으로 열립니다 (하단 탭 바 노출)</strong> — launcher 아이콘으로 다시 실행해 인앱 스캔을 사용하세요',\n 'attach.sandbox.faq.totp':\n '<strong>QR이 만료된 경우 (TOTP — 코드 1개는 30초 창, 만료 후 ~3분(±6 step) 이내 소급 허용)</strong> — 새 QR을 다시 스캔하세요',\n 'attach.sandbox.faq.chii':\n '<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인',\n\n // attach page — intoss family (환경 3: 토스 앱 deep-link)\n 'attach.intoss.step1': '토스 앱을 실행하세요.',\n 'attach.intoss.step2': '폰 카메라 앱으로 QR 코드를 스캔하세요.',\n 'attach.intoss.step3': '팝업이 뜨면 <strong>\"토스로 열기\"</strong>를 탭하세요.',\n 'attach.intoss.step4': '미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.',\n 'attach.intoss.faq.appNotOpen':\n '<strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)',\n 'attach.intoss.faq.prepare':\n '<strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인',\n 'attach.intoss.faq.chii':\n '<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인',\n 'attach.intoss.faq.totp':\n '<strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인',\n // Launcher PWA\n 'launcher.title': 'AITC DevTools Launcher',\n 'launcher.description': '터미널 QR을 스캔하거나 URL을 입력하세요.',\n 'launcher.installCta': '폰에 런처 설치하기',\n 'launcher.urlPlaceholder': 'https://example.trycloudflare.com',\n 'launcher.openBtn': 'Open',\n 'launcher.scanBtn': 'QR 카메라로 스캔',\n 'launcher.noCamera': '카메라를 사용할 수 없습니다 — URL을 직접 붙여넣으세요.',\n 'launcher.cameraError': '카메라에 접근할 수 없습니다 — URL을 직접 붙여넣으세요.',\n 'launcher.invalidUrlHttps': '올바른 https:// URL을 입력하세요 (터미널의 터널 URL).',\n 'launcher.invalidUrl': '올바른 http(s):// URL을 입력하세요.',\n 'launcher.debugAuthFailed': '디버그 연결 인증 실패',\n 'launcher.debugAuthFailedHint': 'QR 코드가 만료되었을 수 있어요. 새 QR을 다시 스캔하세요.',\n 'launcher.debugAuthExpiredHint':\n '디버그 세션이 만료됐어요. Mac의 attach 페이지에서 새 QR을 스캔하세요.',\n 'launcher.debugAuthRescanCta': '새 QR 스캔하기',\n 'launcher.diagTitle': '뷰포트 진단',\n 'launcher.diagYes': '예',\n 'launcher.diagNo': '아니요',\n 'launcher.letterboxDetected':\n 'iOS 뷰포트 제약으로 화면 아래 {pt}pt가 잘릴 수 있습니다 — 기기를 가로로 돌렸다 세로로 복귀하면 해소될 수 있어요.',\n 'launcher.letterboxClipped':\n 'iOS 뷰포트 버그로 화면 아래 {pt}pt를 쓸 수 없습니다 — 기기를 가로로 돌렸다 세로로 돌리면 복구될 수 있어요.',\n // #536: verdict reason labels for diag panel\n 'launcher.diagVerdictLabel': '판정 사유',\n 'launcher.diagSafeAreaTrace': 'top 재측정 추이',\n 'launcher.diagVerdict.detected': '✓ letterbox 보정',\n 'launcher.diagVerdict.notStandalone': '홈 화면 앱 아님',\n 'launcher.diagVerdict.landscape': '가로 모드',\n 'launcher.diagVerdict.shortfallTooSmall': '높이 차이 미달',\n 'launcher.diagVerdict.safeAreaTopZero': 'top=0 (env() stale?)',\n // Nav-bar emulation (#495/#510)\n 'launcher.navbar.defaultTitle': '미니앱',\n 'launcher.navbar.back': '뒤로가기',\n 'launcher.navbar.menu': '메뉴',\n 'launcher.navbar.close': '닫기',\n 'launcher.navbar.menuRescan': '다시 스캔',\n 'launcher.navbar.menuDiag': '뷰포트 진단',\n 'launcher.navbar.menuLanguage': '언어',\n} as const;\n\nexport type StringKey = keyof typeof ko;\n","/**\n * Vanilla TS i18n for the floating DevTools panel.\n *\n * Public surface:\n * - `t(key, vars?)` — look up a UI string, with `{name}` placeholder\n * interpolation. Falls back to the key itself if a translation is missing.\n * - `getLocale()` / `setLocale(locale)` — read/persist the active locale.\n * `setLocale` dispatches `__ait:localechange` so the panel can remount.\n * - `detectLocale()` — first-run heuristic from `navigator.language`.\n *\n * `ko` is the source of truth (keys are typed from it). `en` is also a full\n * `Record<StringKey, string>` (devtools is developer-facing, en is a real\n * audience). The `Partial` lookup table preserves the runtime `?? key` safety\n * net even though we ship complete catalogs today.\n */\n\nimport { en } from './en.js';\nimport { ko, type StringKey } from './ko.js';\n\nexport type Locale = 'ko' | 'en';\n\nconst LOCALE_STORAGE_KEY = '__ait_locale';\nconst LOCALE_CHANGE_EVENT = '__ait:localechange';\n\nconst tables: Record<Locale, Partial<Record<StringKey, string>>> = { ko, en };\n\nlet currentLocale: Locale | null = null;\n\nfunction safeReadStorage(): Locale | null {\n if (typeof localStorage === 'undefined') return null;\n try {\n const raw = localStorage.getItem(LOCALE_STORAGE_KEY);\n if (raw === 'ko' || raw === 'en') return raw;\n } catch {\n /* localStorage can throw in privacy modes — fall back silently */\n }\n return null;\n}\n\nfunction safeWriteStorage(locale: Locale): void {\n if (typeof localStorage === 'undefined') return;\n try {\n localStorage.setItem(LOCALE_STORAGE_KEY, locale);\n } catch {\n /* ignore quota / privacy errors */\n }\n}\n\n/**\n * Decide a locale from a BCP-47 language tag. `ko` (and `ko-*`) → `'ko'`,\n * everything else → `'en'`. Shared by the browser (`navigator.language`) and\n * Node (`Accept-Language` header) paths so both resolve identically.\n */\nfunction localeFromLanguageTag(lang: string): Locale {\n return /^ko\\b/i.test(lang) ? 'ko' : 'en';\n}\n\n/**\n * Read `navigator.language` and decide a locale. `ko` (and `ko-*`) → `'ko'`,\n * everything else → `'en'`. Pure function; does not touch storage.\n */\nexport function detectLocale(): Locale {\n if (typeof navigator === 'undefined') return 'en';\n return localeFromLanguageTag(navigator.language ?? '');\n}\n\n/**\n * Decide a locale from an HTTP `Accept-Language` header value. The Node-served\n * surfaces (e.g. the qr-http-server dashboard) have no `navigator`, so the\n * request header is the only language signal. Reads the FIRST language tag\n * (highest priority, ignoring `q=` weights — good enough for ko/en) and feeds\n * it through the same `ko`-vs-`en` heuristic `detectLocale` uses. Returns `'ko'`\n * for an empty/missing header (ko is the primary locale).\n */\nexport function parseAcceptLanguage(header: string | undefined | null): Locale {\n if (!header) return 'ko';\n const first = header.split(',')[0]?.trim().split(';')[0]?.trim() ?? '';\n return localeFromLanguageTag(first);\n}\n\n/**\n * A locale-bound string resolver for surfaces that can't use the in-memory\n * `getLocale()` cache — notably the Node HTTP server, which resolves locale\n * per-request from `Accept-Language` rather than from a process-global. Returns\n * a `t`-compatible closure over the SAME `ko`/`en` tables (single source of\n * truth), so the dashboard/attach HTML shares the exact 169-key catalog the\n * browser surfaces use. The `key: StringKey` signature keeps compile-time key\n * safety on the Node path identical to `t()`.\n */\nexport function resolveLocaleStrings(\n locale: Locale,\n): (key: StringKey, vars?: Record<string, string | number>) => string {\n const table = tables[locale];\n return (key, vars) => {\n const raw = table[key] ?? key;\n if (!vars) return raw;\n return raw.replace(/\\{(\\w+)\\}/g, (match, name: string) => {\n const value = vars[name];\n return value === undefined ? match : String(value);\n });\n };\n}\n\n/**\n * Resolve the active locale, in order:\n * 1. previously set in-memory value (set by `setLocale`)\n * 2. localStorage `__ait_locale`\n * 3. `detectLocale()` from navigator\n */\nexport function getLocale(): Locale {\n if (currentLocale) return currentLocale;\n const stored = safeReadStorage();\n currentLocale = stored ?? detectLocale();\n return currentLocale;\n}\n\n/**\n * Persist a locale choice and notify listeners. The panel listens for\n * `__ait:localechange` and re-mounts so every string re-evaluates.\n */\nexport function setLocale(locale: Locale): void {\n currentLocale = locale;\n safeWriteStorage(locale);\n if (typeof window !== 'undefined') {\n window.dispatchEvent(new CustomEvent(LOCALE_CHANGE_EVENT));\n }\n}\n\n/**\n * Look up a UI string for the current locale. Falls back to the key if missing,\n * so a forgotten key surfaces visibly rather than rendering empty.\n */\nexport function t(key: StringKey, vars?: Record<string, string | number>): string {\n const raw = tables[getLocale()][key] ?? key;\n if (!vars) return raw;\n return raw.replace(/\\{(\\w+)\\}/g, (match, name: string) => {\n const value = vars[name];\n return value === undefined ? match : String(value);\n });\n}\n\nexport type { StringKey };\nexport { LOCALE_CHANGE_EVENT, LOCALE_STORAGE_KEY };\n\n/**\n * Test-only escape hatch — resets the cached in-memory locale so subsequent\n * `getLocale()` calls re-read storage / re-detect. Production code never needs\n * this; tests use it between cases.\n */\nexport function _resetLocaleCacheForTests(): void {\n currentLocale = null;\n}\n","/**\n * dashboard.generated.ts\n *\n * AUTO-GENERATED by scripts/build-dashboard-html.ts — DO NOT EDIT BY HAND.\n * Regenerate: pnpm build:dashboard-html\n *\n * Exports precompiled HTML chrome strings for each locale. Per-request\n * dynamic values are inserted by qr-http-server.ts at runtime via simple\n * string replacement of __PLACEHOLDER__ tokens.\n *\n * Token map (dashboard chrome):\n * __TUNNEL_CLASS__ CSS class: \"status-up\" | \"status-down\"\n * __TUNNEL_STATUS__ localised tunnel status label\n * __ATTACH_SECTION__ QR img+url-box HTML, or hint text\n * __INSPECTOR_SECTION__ inspector link <a> or waiting hint <span> (#503)\n * __PAGES_SECTION__ pages <section> block, or empty string\n * __NOW__ ISO timestamp of current render\n * __LANG_SWITCHER__ ko/en toggle links (href preserves existing query params)\n *\n * Token map (attach chrome — precompiled per locale × copy family, #468):\n * __QR_DATA_URL__ base64 data URL for the QR image\n * __SAFE_LABEL__ HTML-escaped deploymentId label (intoss family only)\n * __SAFE_ATTACH_URL__ HTML-escaped attach URL\n * __MODE_LABEL__ environment badge (<p class=\"mode-label\">…</p>), or empty\n * __LANG_SWITCHER__ ko/en toggle links (href preserves existing query params)\n *\n * SECRET-HANDLING: wssUrl MUST NOT appear here. If it does, the build script's\n * assertion would have caught it — this file should be react-free and secret-free.\n */\n\nimport type { Locale } from '../i18n/index.js';\n\n/** Copy family of the attach page chrome (#468) — env 2 vs env 3/4. */\nexport type AttachChromeFamily = 'sandbox' | 'intoss';\n\n// ── locale: ko ──────────────────────────────────────────────────────\n\nexport const dashboardChromeHtmlKo =\n`<!DOCTYPE html>\n<html lang=\"ko\"><head><meta charSet=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><title>AIT 디버그 Dashboard</title><style>\n*, *::before, *::after { box-sizing: border-box; }\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #0d1117; color: #c9d1d9;\n display: flex; flex-direction: column; align-items: center;\n min-height: 100vh; margin: 0; padding: 2rem 1rem;\n gap: 1.5rem;\n}\nh1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }\n.updated { font-size: 0.75rem; opacity: 0.4; font-family: monospace; margin: 0; }\nsection { width: 100%; max-width: 520px; }\nh2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }\n.status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }\n.status-up { background: #238636; color: #fff; }\n.status-down { background: #6e7681; color: #fff; }\nimg.qr {\n width: min(80vw, 300px); height: auto;\n image-rendering: pixelated;\n background: #fff; padding: 0.75rem; border-radius: 10px;\n display: block; margin: 0.5rem auto;\n}\n.url-row {\n display: flex; align-items: stretch; gap: 0; margin: 0.5rem 0 0;\n border-radius: 6px; border: 1px solid #30363d; overflow: hidden;\n}\n.url-box {\n font-family: monospace; font-size: 0.7rem;\n word-break: break-all; opacity: 0.45;\n background: #161b22; padding: 0.6rem 0.85rem;\n flex: 1; cursor: pointer; border: none; border-radius: 0;\n}\n.url-box:hover { opacity: 0.65; }\n.copy-btn {\n flex-shrink: 0; padding: 0.4rem 0.7rem;\n background: #21262d; border: none; border-left: 1px solid #30363d;\n color: #58a6ff; font-size: 0.7rem; cursor: pointer; white-space: nowrap;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n}\n.copy-btn:hover { background: #30363d; }\n.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }\n.hint.error { color: #f85149; opacity: 1; font-weight: 600; }\n.inspector-link {\n display: inline-block; margin-top: 0.5rem;\n padding: 0.45rem 1rem; border-radius: 6px;\n background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;\n text-decoration: none; text-align: center;\n}\n.inspector-link:hover { background: #388bfd; }\n.inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }\nul { margin: 0; padding-left: 1.25rem; }\nli { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }\nli.empty { opacity: 0.4; list-style: none; padding-left: 0; }\n.page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }\n.page-url { word-break: break-all; }\nhr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }\n.lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }\n.lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }\n.lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }\n</style></head><body><h1>AIT 디버그 Dashboard</h1>__LANG_SWITCHER__<p class=\"updated\" id=\"updated\">마지막 갱신: __NOW__</p><section><h2>터널 상태</h2><span class=\"status __TUNNEL_CLASS__\" id=\"tunnel-status\">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id=\"attach-section\">__ATTACH_SECTION__</div></section><hr/><section id=\"inspector-section\"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section>__PAGES_SECTION__</body></html>`;\n\nexport const attachChromeHtmlKoSandbox =\n`<!DOCTYPE html>\n<html lang=\"ko\"><head><meta charSet=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><link rel=\"preload\" as=\"image\" href=\"__QR_DATA_URL__\"/><title>AIT 디버그 세션 — QR 스캔</title><style>\n*, *::before, *::after { box-sizing: border-box; }\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #0d1117; color: #c9d1d9;\n display: flex; flex-direction: column; align-items: center;\n min-height: 100vh; margin: 0; padding: 2rem 1rem;\n gap: 1.5rem;\n}\nh1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }\n.mode-label {\n font-size: 0.78rem; font-weight: 600; color: #79c0ff;\n background: #161b22; border: 1px solid #30363d; border-radius: 999px;\n padding: 0.25rem 0.75rem; margin: 0;\n}\n.label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }\nimg.qr {\n width: min(90vw, 360px); height: auto;\n image-rendering: pixelated;\n background: #fff; padding: 1rem; border-radius: 12px;\n display: block; margin: 0 auto;\n}\nsection { width: 100%; max-width: 480px; }\n.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }\n.hint.error { color: #f85149; opacity: 1; font-weight: 600; }\nh2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }\nol, ul { margin: 0; padding-left: 1.25rem; }\nli { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }\n.url-row {\n display: flex; align-items: stretch; gap: 0;\n border-radius: 6px; border: 1px solid #30363d; overflow: hidden;\n}\n.url-box {\n font-family: monospace; font-size: 0.72rem;\n word-break: break-all; opacity: 0.4;\n background: #161b22; padding: 0.75rem 1rem;\n flex: 1; cursor: pointer; border: none; border-radius: 0;\n}\n.url-box:hover { opacity: 0.6; }\n.copy-btn {\n flex-shrink: 0; padding: 0.5rem 0.8rem;\n background: #21262d; border: none; border-left: 1px solid #30363d;\n color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n}\n.copy-btn:hover { background: #30363d; }\nhr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }\n.lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }\n.lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }\n.lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }\n.inspector-link {\n display: inline-block; margin-top: 0.5rem;\n padding: 0.45rem 1rem; border-radius: 6px;\n background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;\n text-decoration: none; text-align: center;\n}\n.inspector-link:hover { background: #388bfd; }\n.inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }\n</style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<div id=\"attach-section\"><img class=\"qr\" src=\"__QR_DATA_URL__\" alt=\"attach QR\"/></div><section><h2>스캔 절차</h2><ol><li>홈 화면의 launcher PWA 아이콘으로 실행하세요 (Safari 주소창이 보이면 standalone이 아닙니다).</li><li>launcher 안의 <strong>\"QR 카메라로 스캔\"</strong>으로 이 QR 코드를 스캔하세요.</li><li>미니앱이 풀스크린으로 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>launcher가 설치돼 있지 않은 경우</strong> — <code>devtools.aitc.dev/launcher/</code>를 한 번 열어 홈 화면에 추가하세요</li><li><strong>카메라 앱으로 스캔하면 Safari 탭으로 열립니다 (하단 탭 바 노출)</strong> — launcher 아이콘으로 다시 실행해 인앱 스캔을 사용하세요</li><li><strong>QR이 만료된 경우 (TOTP — 코드 1개는 30초 창, 만료 후 ~3분(±6 step) 이내 소급 허용)</strong> — 새 QR을 다시 스캔하세요</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li></ul></section><hr/><section id=\"url-section\"><h2>URL (fallback)</h2><div class=\"url-row\"><p class=\"url-box\" id=\"url-box\">__SAFE_ATTACH_URL__</p><button class=\"copy-btn\" id=\"copy-btn\" type=\"button\" aria-label=\"복사\">복사</button></div></section><hr/><section id=\"inspector-section\"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section></body></html>`;\n\nexport const attachChromeHtmlKoIntoss =\n`<!DOCTYPE html>\n<html lang=\"ko\"><head><meta charSet=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><link rel=\"preload\" as=\"image\" href=\"__QR_DATA_URL__\"/><title>AIT 디버그 세션 — QR 스캔</title><style>\n*, *::before, *::after { box-sizing: border-box; }\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #0d1117; color: #c9d1d9;\n display: flex; flex-direction: column; align-items: center;\n min-height: 100vh; margin: 0; padding: 2rem 1rem;\n gap: 1.5rem;\n}\nh1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }\n.mode-label {\n font-size: 0.78rem; font-weight: 600; color: #79c0ff;\n background: #161b22; border: 1px solid #30363d; border-radius: 999px;\n padding: 0.25rem 0.75rem; margin: 0;\n}\n.label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }\nimg.qr {\n width: min(90vw, 360px); height: auto;\n image-rendering: pixelated;\n background: #fff; padding: 1rem; border-radius: 12px;\n display: block; margin: 0 auto;\n}\nsection { width: 100%; max-width: 480px; }\n.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }\n.hint.error { color: #f85149; opacity: 1; font-weight: 600; }\nh2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }\nol, ul { margin: 0; padding-left: 1.25rem; }\nli { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }\n.url-row {\n display: flex; align-items: stretch; gap: 0;\n border-radius: 6px; border: 1px solid #30363d; overflow: hidden;\n}\n.url-box {\n font-family: monospace; font-size: 0.72rem;\n word-break: break-all; opacity: 0.4;\n background: #161b22; padding: 0.75rem 1rem;\n flex: 1; cursor: pointer; border: none; border-radius: 0;\n}\n.url-box:hover { opacity: 0.6; }\n.copy-btn {\n flex-shrink: 0; padding: 0.5rem 0.8rem;\n background: #21262d; border: none; border-left: 1px solid #30363d;\n color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n}\n.copy-btn:hover { background: #30363d; }\nhr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }\n.lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }\n.lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }\n.lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }\n.inspector-link {\n display: inline-block; margin-top: 0.5rem;\n padding: 0.45rem 1rem; border-radius: 6px;\n background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;\n text-decoration: none; text-align: center;\n}\n.inspector-link:hover { background: #388bfd; }\n.inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }\n</style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<p class=\"label\">deployment: __SAFE_LABEL__</p><div id=\"attach-section\"><img class=\"qr\" src=\"__QR_DATA_URL__\" alt=\"attach QR\"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>\"토스로 열기\"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li></ul></section><hr/><section id=\"url-section\"><h2>URL (fallback)</h2><div class=\"url-row\"><p class=\"url-box\" id=\"url-box\">__SAFE_ATTACH_URL__</p><button class=\"copy-btn\" id=\"copy-btn\" type=\"button\" aria-label=\"복사\">복사</button></div></section><hr/><section id=\"inspector-section\"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section></body></html>`;\n\n// ── locale: en ──────────────────────────────────────────────────────\n\nexport const dashboardChromeHtmlEn =\n`<!DOCTYPE html>\n<html lang=\"en\"><head><meta charSet=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><title>AIT Debug Dashboard</title><style>\n*, *::before, *::after { box-sizing: border-box; }\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #0d1117; color: #c9d1d9;\n display: flex; flex-direction: column; align-items: center;\n min-height: 100vh; margin: 0; padding: 2rem 1rem;\n gap: 1.5rem;\n}\nh1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }\n.updated { font-size: 0.75rem; opacity: 0.4; font-family: monospace; margin: 0; }\nsection { width: 100%; max-width: 520px; }\nh2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }\n.status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }\n.status-up { background: #238636; color: #fff; }\n.status-down { background: #6e7681; color: #fff; }\nimg.qr {\n width: min(80vw, 300px); height: auto;\n image-rendering: pixelated;\n background: #fff; padding: 0.75rem; border-radius: 10px;\n display: block; margin: 0.5rem auto;\n}\n.url-row {\n display: flex; align-items: stretch; gap: 0; margin: 0.5rem 0 0;\n border-radius: 6px; border: 1px solid #30363d; overflow: hidden;\n}\n.url-box {\n font-family: monospace; font-size: 0.7rem;\n word-break: break-all; opacity: 0.45;\n background: #161b22; padding: 0.6rem 0.85rem;\n flex: 1; cursor: pointer; border: none; border-radius: 0;\n}\n.url-box:hover { opacity: 0.65; }\n.copy-btn {\n flex-shrink: 0; padding: 0.4rem 0.7rem;\n background: #21262d; border: none; border-left: 1px solid #30363d;\n color: #58a6ff; font-size: 0.7rem; cursor: pointer; white-space: nowrap;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n}\n.copy-btn:hover { background: #30363d; }\n.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }\n.hint.error { color: #f85149; opacity: 1; font-weight: 600; }\n.inspector-link {\n display: inline-block; margin-top: 0.5rem;\n padding: 0.45rem 1rem; border-radius: 6px;\n background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;\n text-decoration: none; text-align: center;\n}\n.inspector-link:hover { background: #388bfd; }\n.inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }\nul { margin: 0; padding-left: 1.25rem; }\nli { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }\nli.empty { opacity: 0.4; list-style: none; padding-left: 0; }\n.page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }\n.page-url { word-break: break-all; }\nhr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }\n.lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }\n.lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }\n.lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }\n</style></head><body><h1>AIT Debug Dashboard</h1>__LANG_SWITCHER__<p class=\"updated\" id=\"updated\">Last updated: __NOW__</p><section><h2>Tunnel status</h2><span class=\"status __TUNNEL_CLASS__\" id=\"tunnel-status\">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id=\"attach-section\">__ATTACH_SECTION__</div></section><hr/><section id=\"inspector-section\"><h2>Inspector</h2>__INSPECTOR_SECTION__</section>__PAGES_SECTION__</body></html>`;\n\nexport const attachChromeHtmlEnSandbox =\n`<!DOCTYPE html>\n<html lang=\"en\"><head><meta charSet=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><link rel=\"preload\" as=\"image\" href=\"__QR_DATA_URL__\"/><title>AIT Debug Session — QR Scan</title><style>\n*, *::before, *::after { box-sizing: border-box; }\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #0d1117; color: #c9d1d9;\n display: flex; flex-direction: column; align-items: center;\n min-height: 100vh; margin: 0; padding: 2rem 1rem;\n gap: 1.5rem;\n}\nh1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }\n.mode-label {\n font-size: 0.78rem; font-weight: 600; color: #79c0ff;\n background: #161b22; border: 1px solid #30363d; border-radius: 999px;\n padding: 0.25rem 0.75rem; margin: 0;\n}\n.label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }\nimg.qr {\n width: min(90vw, 360px); height: auto;\n image-rendering: pixelated;\n background: #fff; padding: 1rem; border-radius: 12px;\n display: block; margin: 0 auto;\n}\nsection { width: 100%; max-width: 480px; }\n.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }\n.hint.error { color: #f85149; opacity: 1; font-weight: 600; }\nh2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }\nol, ul { margin: 0; padding-left: 1.25rem; }\nli { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }\n.url-row {\n display: flex; align-items: stretch; gap: 0;\n border-radius: 6px; border: 1px solid #30363d; overflow: hidden;\n}\n.url-box {\n font-family: monospace; font-size: 0.72rem;\n word-break: break-all; opacity: 0.4;\n background: #161b22; padding: 0.75rem 1rem;\n flex: 1; cursor: pointer; border: none; border-radius: 0;\n}\n.url-box:hover { opacity: 0.6; }\n.copy-btn {\n flex-shrink: 0; padding: 0.5rem 0.8rem;\n background: #21262d; border: none; border-left: 1px solid #30363d;\n color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n}\n.copy-btn:hover { background: #30363d; }\nhr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }\n.lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }\n.lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }\n.lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }\n.inspector-link {\n display: inline-block; margin-top: 0.5rem;\n padding: 0.45rem 1rem; border-radius: 6px;\n background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;\n text-decoration: none; text-align: center;\n}\n.inspector-link:hover { background: #388bfd; }\n.inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }\n</style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<div id=\"attach-section\"><img class=\"qr\" src=\"__QR_DATA_URL__\" alt=\"attach QR\"/></div><section><h2>How to scan</h2><ol><li>Launch the launcher PWA icon on your home screen (if the Safari address bar is visible, it is not standalone).</li><li>Scan this QR code with <strong>\"Scan QR with camera\"</strong> inside the launcher.</li><li>The mini-app opens fullscreen and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Launcher is not installed</strong> — open <code>devtools.aitc.dev/launcher/</code> once and add it to your home screen</li><li><strong>Scanning with the camera app opens a Safari tab (bottom tab bar visible)</strong> — relaunch from the launcher icon and use the in-app scanner</li><li><strong>QR expired (TOTP — 30-second step, ±6 steps (~3 min) accepted)</strong> — scan a fresh QR code</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li></ul></section><hr/><section id=\"url-section\"><h2>URL (fallback)</h2><div class=\"url-row\"><p class=\"url-box\" id=\"url-box\">__SAFE_ATTACH_URL__</p><button class=\"copy-btn\" id=\"copy-btn\" type=\"button\" aria-label=\"Copy\">Copy</button></div></section><hr/><section id=\"inspector-section\"><h2>Inspector</h2>__INSPECTOR_SECTION__</section></body></html>`;\n\nexport const attachChromeHtmlEnIntoss =\n`<!DOCTYPE html>\n<html lang=\"en\"><head><meta charSet=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><link rel=\"preload\" as=\"image\" href=\"__QR_DATA_URL__\"/><title>AIT Debug Session — QR Scan</title><style>\n*, *::before, *::after { box-sizing: border-box; }\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #0d1117; color: #c9d1d9;\n display: flex; flex-direction: column; align-items: center;\n min-height: 100vh; margin: 0; padding: 2rem 1rem;\n gap: 1.5rem;\n}\nh1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }\n.mode-label {\n font-size: 0.78rem; font-weight: 600; color: #79c0ff;\n background: #161b22; border: 1px solid #30363d; border-radius: 999px;\n padding: 0.25rem 0.75rem; margin: 0;\n}\n.label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }\nimg.qr {\n width: min(90vw, 360px); height: auto;\n image-rendering: pixelated;\n background: #fff; padding: 1rem; border-radius: 12px;\n display: block; margin: 0 auto;\n}\nsection { width: 100%; max-width: 480px; }\n.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }\n.hint.error { color: #f85149; opacity: 1; font-weight: 600; }\nh2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }\nol, ul { margin: 0; padding-left: 1.25rem; }\nli { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }\n.url-row {\n display: flex; align-items: stretch; gap: 0;\n border-radius: 6px; border: 1px solid #30363d; overflow: hidden;\n}\n.url-box {\n font-family: monospace; font-size: 0.72rem;\n word-break: break-all; opacity: 0.4;\n background: #161b22; padding: 0.75rem 1rem;\n flex: 1; cursor: pointer; border: none; border-radius: 0;\n}\n.url-box:hover { opacity: 0.6; }\n.copy-btn {\n flex-shrink: 0; padding: 0.5rem 0.8rem;\n background: #21262d; border: none; border-left: 1px solid #30363d;\n color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n}\n.copy-btn:hover { background: #30363d; }\nhr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }\n.lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }\n.lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }\n.lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }\n.inspector-link {\n display: inline-block; margin-top: 0.5rem;\n padding: 0.45rem 1rem; border-radius: 6px;\n background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;\n text-decoration: none; text-align: center;\n}\n.inspector-link:hover { background: #388bfd; }\n.inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }\n</style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<p class=\"label\">deployment: __SAFE_LABEL__</p><div id=\"attach-section\"><img class=\"qr\" src=\"__QR_DATA_URL__\" alt=\"attach QR\"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>\"Open in Toss\"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li></ul></section><hr/><section id=\"url-section\"><h2>URL (fallback)</h2><div class=\"url-row\"><p class=\"url-box\" id=\"url-box\">__SAFE_ATTACH_URL__</p><button class=\"copy-btn\" id=\"copy-btn\" type=\"button\" aria-label=\"Copy\">Copy</button></div></section><hr/><section id=\"inspector-section\"><h2>Inspector</h2>__INSPECTOR_SECTION__</section></body></html>`;\n\n/** Map from Locale to the precompiled dashboard chrome string. */\nexport const dashboardChromeByLocale: Record<Locale, string> = {\n ko: dashboardChromeHtmlKo,\n en: dashboardChromeHtmlEn,\n};\n\n/** Map from Locale × copy family to the precompiled attach page chrome string (#468). */\nexport const attachChromeByLocale: Record<Locale, Record<AttachChromeFamily, string>> = {\n ko: { sandbox: attachChromeHtmlKoSandbox, intoss: attachChromeHtmlKoIntoss },\n en: { sandbox: attachChromeHtmlEnSandbox, intoss: attachChromeHtmlEnIntoss },\n};\n","/**\n * 로컬 HTTP 서버 — QR 페이지를 `http://127.0.0.1:<port>` 에서 서빙한다.\n *\n * file:// origin 대신 HTTP origin을 쓰는 이유: 브라우저 보안 정책상 file://에서\n * 로드된 페이지는 외부 fetch/script가 전부 차단되며, file:// 절대 경로를 <img src>에\n * 넣으면 브라우저에 따라 빈 화면이 된다. 127.0.0.1 HTTP는 modern 브라우저가 fully trust.\n *\n * INSTALL-GRAPH INVARIANT:\n * 이 모듈은 react/react-dom을 절대 import하지 않는다. dashboard/attach HTML은\n * scripts/build-dashboard-html.ts가 빌드 타임에 precompile해 dashboard.generated.ts\n * (plain string exports)로 커밋한다. 이 모듈은 그 생성된 string만 import한다.\n * check-mcp-react-free.sh 가드가 dist/mcp/cli.js·server.js의 react 유입을 기계적으로 검증.\n *\n * HTML 조립 전략 (token-fill vs runtime builder):\n * - static chrome (head/style/섹션 레이블) → 빌드타임 precompile, dashboard.generated.ts\n * - 동적 부분 → 런타임 string 조립:\n * __NOW__ : per-request ISO timestamp\n * __TUNNEL_CLASS__ : \"status-up\" | \"status-down\"\n * __TUNNEL_STATUS__ : 로컬라이즈된 tunnel 상태 레이블\n * __ATTACH_SECTION__ : QR img+url-box, 또는 hint 텍스트\n * __PAGES_SECTION__ : pages <section> 블록, 또는 빈 문자열 (null → '')\n * - inline SSE <script> → 런타임 suffix로 append (localised string 포함)\n *\n * i18n:\n * GET / 와 GET /attach 라우트에서 req.headers['accept-language']를 읽어\n * parseAcceptLanguage()로 locale 결정. resolveLocaleStrings()로 동적 부분의\n * localised 문자열을 해결. navigator 없음, React hook 없음 (Node 표면).\n *\n * SECRET-HANDLING:\n * - 127.0.0.1 바인딩만 — 외부 노출 0.\n * - attachUrl은 HTML 본문과 /qr.png query에만 들어간다 (의도된 전달 경로).\n * - wssUrl은 dashboard HTML에 절대 들어가지 않는다. tunnel.up boolean만 사용.\n * - stdout/stderr/로그에 별도 출력하지 않는다.\n * - tmp 파일 만들지 않음 — 모든 응답을 메모리에서 생성.\n * - TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — SSE payload나 page 목록 등\n * 다른 필드에 TOTP 코드를 평문으로 싣지 않는다.\n */\n\nimport type { IncomingMessage, Server, ServerResponse } from 'node:http';\nimport { createServer } from 'node:http';\nimport { parseAcceptLanguage, resolveLocaleStrings } from '../i18n/index.js';\nimport {\n type AttachChromeFamily,\n attachChromeByLocale,\n dashboardChromeByLocale,\n} from './dashboard.generated.js';\nimport type { McpEnvironment } from './environment.js';\n\n/** dashboard에 노출되는 현재 상태 스냅샷. */\nexport interface DashboardState {\n /** 현재 터널 상태 — up/down + wssUrl. SECRET: wssUrl은 로그 출력 금지. */\n tunnel: { up: boolean; wssUrl: string | null };\n /**\n * 현재 연결된 page 목록 (id/url만).\n *\n * - `Array<…>` — env 3/4(MCP): relay에 attach된 페이지를 라이브 조회한 목록.\n * 빈 배열 `[]`은 \"attach된 페이지 없음\"으로 정직하게 표시한다.\n * - `null` — env 2(unplugin 터널): 플러그인 핸들이 connected target을 노출하지\n * 않아 라이브 page 목록을 알 수 없다. 거짓 빈 목록을 보여주느니 \"연결된 Pages\"\n * 섹션 자체를 숨긴다(#411). 정적 렌더와 SSE 갱신 양쪽에서 섹션이 사라진다.\n */\n pages: Array<{ id: string; url: string }> | null;\n /** 마지막으로 생성된 attachUrl (없으면 null). TOTP at= 코드는 이 안에 캡슐화. */\n attachUrl: string | null;\n /**\n * 현재 세션의 Chii 인스펙터 URL — 살아있는 세션 기준 DevTools 진입점 (#503).\n *\n * - `string` — relay up + 페이지 attached → `buildChiiInspectorUrl`로 조립된 URL.\n * TOTP at= 코드는 이 URL 안에 캡슐화. 대시보드 HTML 내 렌더는 의도된 transport.\n * - `null` — relay up이지만 페이지 미첨부, 또는 relay down, 또는 env 1(mock).\n *\n * SECRET-HANDLING: 이 URL은 relay host + TOTP at= 코드를 담을 수 있다.\n * 대시보드 HTML 본문에 렌더되는 건 의도된 transport(attachUrl과 동일 취급)이지만,\n * stdout/stderr/로그/에러 메시지에는 절대 출력하지 않는다.\n */\n inspectorUrl?: string | null;\n /**\n * 현재 세션 환경 — /attach 스캔 절차·체크리스트 카피 분기 + 상단 환경 라벨 (#468).\n *\n * - `'relay-mobile'` → sandbox family (환경 2: launcher PWA 절차, 토스 앱·_deploymentId 없음)\n * - `'relay-dev'` → intoss family (환경 3: 토스 앱 deep-link 절차)\n * - `'mock'` / 미지정 → intoss family, 환경 라벨 없음 (환경 1은 /attach 표면이\n * 없어 사실상 도달 불가 — legacy 카피 유지 fallback)\n * - `relay-live` (환경 4) 제거 (#665) — positive-allowlist kill-switch.\n *\n * 호출처는 자기 mode를 명시적으로 전달한다: debug-server는 active connection에서\n * `deriveEnvironment(...)`로 파생, unplugin tunnel 대시보드는 `'relay-mobile'` 고정.\n */\n mode?: McpEnvironment;\n}\n\n/** mode → 어느 precompiled attach chrome family를 쓰는가 (#468). */\nfunction attachFamilyForMode(mode: McpEnvironment | undefined): AttachChromeFamily {\n return mode === 'relay-mobile' ? 'sandbox' : 'intoss';\n}\n\n/**\n * mode → 페이지 상단 환경 라벨 HTML (`__MODE_LABEL__` 토큰 채움, #468).\n * 사용자가 fidelity 사다리의 어느 겹에 있는지 즉시 알게 하는 환경 가시화 배지.\n * mode 미지정/'mock'은 빈 문자열 — 알 수 없는 환경을 거짓으로 라벨링하지 않는다.\n */\nfunction buildModeLabel(\n mode: McpEnvironment | undefined,\n s: ReturnType<typeof resolveLocaleStrings>,\n): string {\n let label: string;\n switch (mode) {\n case 'relay-mobile':\n label = s('attach.mode.sandbox');\n break;\n case 'relay-dev':\n label = s('attach.mode.intossDev');\n break;\n // relay-live (env 4) removed (#665) — positive-allowlist kill-switch.\n case 'mock':\n case undefined:\n return '';\n }\n return `<p class=\"mode-label\">${escapeHtml(label)}</p>`;\n}\n\nexport interface QrHttpServer {\n port: number;\n /** `http://127.0.0.1:<port>/attach?u=<encoded>` URL 생성 헬퍼. */\n buildAttachPageUrl(attachUrl: string): string;\n /**\n * 안정 인스펙터 진입점 URL — `http://127.0.0.1:<port>/inspector` (issue #530).\n * 클릭 시점에 TOTP를 mint하고 302 redirect하므로 URL 자체에 시크릿이 없다.\n * 대시보드/stdout/로그 어디든 출력 가능.\n */\n readonly inspectorStableUrl: string;\n /**\n * 상태 변경 시 호출 — SSE 구독자에게 최신 상태를 push한다.\n * `getDashboardState`가 주입돼 있지 않으면 no-op.\n */\n notifyStateChange(): void;\n close(): Promise<void>;\n}\n\n/** HTML 특수문자를 이스케이프한다. */\nfunction escapeHtml(s: string): string {\n return s.replace(/[<>&\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\n/**\n * 현재 path+query에서 lang 파라미터만 교체한 ko/en 토글 링크를 생성한다.\n *\n * SECRET-HANDLING: u= (attachUrl, TOTP at= 캡슐 포함) 등 기존 query를 보존한다.\n * lang= 만 덮어쓴다. 링크 href에 at= 코드가 들어가는 건 의도된 전달 경로.\n */\nfunction buildLangSwitcher(\n path: string,\n existingParams: URLSearchParams,\n locale: 'ko' | 'en',\n s: ReturnType<typeof resolveLocaleStrings>,\n): string {\n function switcherHref(targetLang: 'ko' | 'en'): string {\n const p = new URLSearchParams(existingParams);\n p.set('lang', targetLang);\n return `${escapeHtml(path)}?${p.toString()}`;\n }\n const koLabel = escapeHtml(s('dashboard.lang.ko'));\n const enLabel = escapeHtml(s('dashboard.lang.en'));\n const koClass = locale === 'ko' ? 'active' : '';\n const enClass = locale === 'en' ? 'active' : '';\n return `<div class=\"lang-switcher\"><a href=\"${switcherHref('ko')}\" class=\"${koClass}\">${koLabel}</a><a href=\"${switcherHref('en')}\" class=\"${enClass}\">${enLabel}</a></div>`;\n}\n\n/**\n * Dashboard HTML — precompiled chrome에 per-request 동적 값을 채워 완성한다.\n *\n * 토큰 채우기 순서:\n * 1. chrome string(locale별 precompile)을 가져온다.\n * 2. 동적 부분을 단순 replaceAll로 채운다 (토큰이 HTML context 밖에 있으므로 안전).\n * 3. inline SSE <script>를 </body> 직전에 주입한다.\n *\n * 동적 파트 분류:\n * - \"token-fill\": 단일 값 교체 (__NOW__, __TUNNEL_CLASS__, __TUNNEL_STATUS__,\n * __ATTACH_SECTION__, __INSPECTOR_SECTION__)\n * - \"runtime builder\": 가변 길이 구조 (__PAGES_SECTION__ — 조건부 렌더 + 가변 rows)\n * - \"suffix\": inline SSE <script> (빌드 파이프라인 없는 클라이언트 스크립트, locale\n * aware 문자열 포함)\n *\n * SECRET-HANDLING:\n * - attachUrl은 url-box 안에서만 노출 (TOTP at= 코드 캡슐 그대로).\n * - inspectorUrl은 anchor href 안에서만 노출 (TOTP at= 코드 캡슐 그대로).\n * relay host + TOTP 코드가 담길 수 있으나 대시보드 HTML은 의도된 transport.\n * - tunnel wssUrl은 \"터널 연결됨\" 상태 표시에서 UP/DOWN만 노출.\n * wssUrl 값 자체는 dashboard HTML에 넣지 않는다.\n */\nfunction buildDashboardHtml(\n state: DashboardState,\n qrDataUrl: string | null,\n locale: 'ko' | 'en',\n path = '/',\n params = new URLSearchParams(),\n /**\n * /devtools/ 진입로 URL (issue #248).\n *\n * 주입 시: relay 연결 active + pagesAttached 이면 이 URL로 가는 \"DevTools 열기\" 링크를 렌더한다.\n * null: getDirectInspectorUrl 미주입 (relay 세션 없는 mode) → 링크를 숨기고 hint 표시.\n *\n * /devtools/ 는 relay host + TOTP at= 가 없는 안정 경로이므로 href 노출 가능.\n * SECRET-HANDLING: 링크 클릭 후 302 응답의 Location에만 relay host·TOTP가 담긴다.\n */\n devtoolsEntryUrl: string | null = null,\n /**\n * SSE watchdog timeout 계산에 쓰이는 server refresh 주기 (ms, #681).\n * 미지정 시 기본값 90_000 적용 → watchdog timeout = 90_000 × 2 + 30_000 = 210_000ms.\n */\n sseRefreshIntervalMs = 90_000,\n): string {\n const s = resolveLocaleStrings(locale);\n const now = new Date().toISOString();\n\n const tunnelStatus = state.tunnel.up ? s('dashboard.tunnel.up') : s('dashboard.tunnel.down');\n const tunnelClass = state.tunnel.up ? 'status-up' : 'status-down';\n\n // attachSection: QR img + url-row(url-box + 복사 버튼), tunnel-down 에러, or hint.\n // dashboard 표면에서 SSE 재렌더 시에도 동일 구조를 유지해 복사 버튼이 생존한다.\n //\n // 렌더 게이트는 attachUrl 유무 외에 tunnel.up도 본다 (issue #631): 터널이\n // 죽으면 attachUrl/QR이 인코딩한 wss·TOTP가 이미 무효이므로, 스캔 가능한\n // 죽은 QR을 계속 그리는 대신 에러 상태로 교체한다. attachUrl이 남아 있어도\n // tunnel.up=false면 QR을 그리지 않는다.\n let attachSection: string;\n if (!state.tunnel.up) {\n attachSection = `<p class=\"hint error\">${escapeHtml(s('dashboard.attach.tunnelDown'))}</p>`;\n } else if (qrDataUrl && state.attachUrl) {\n const safeAttachUrl = escapeHtml(state.attachUrl);\n const copyLabel = escapeHtml(s('dashboard.url.copy'));\n attachSection =\n `<img class=\"qr\" src=\"${qrDataUrl}\" alt=\"attach QR\" />` +\n `<div class=\"url-row\">` +\n `<p class=\"url-box\" id=\"url-box\">${safeAttachUrl}</p>` +\n `<button class=\"copy-btn\" id=\"copy-btn\" type=\"button\" aria-label=\"${copyLabel}\">${copyLabel}</button>` +\n `</div>`;\n } else {\n attachSection = `<p class=\"hint\">${escapeHtml(s('dashboard.attach.hint'))}</p>`;\n }\n\n // inspectorSection — \"DevTools 열기\" 링크 또는 대기 힌트 (#503, gate 보정 #544, #248).\n //\n // 게이트: relay active(devtoolsEntryUrl 주입됨) + pages.length > 0 양쪽 모두 true 일 때만 링크 활성.\n // - devtoolsEntryUrl null → getDirectInspectorUrl 미주입(relay 세션 없는 server mode) → hint 표시.\n // - relay active이지만 pages 미attach → 버튼을 보여봤자 502 noTarget — hint로 대기 안내.\n //\n // href는 /devtools/ 안정 경로 (issue #248) — relay host·TOTP at= 를 담지 않아 노출 가능.\n // 클릭 시 302 → Location에만 relay host·TOTP가 담긴다(의도된 transport).\n //\n // SSE push 시 inspectorUrl 필드를 기반으로 #inspector-link를 갱신하는 스크립트는 그대로 유지.\n // (SSE에서 inspectorUrl이 null → hint로, non-null + pages > 0 → /devtools/ 링크로 갱신.)\n const pagesAttached = Array.isArray(state.pages) && state.pages.length > 0;\n let inspectorSection: string;\n if (pagesAttached && devtoolsEntryUrl) {\n const safeUrl = escapeHtml(devtoolsEntryUrl);\n const label = escapeHtml(s('dashboard.inspector.open'));\n inspectorSection = `<a class=\"inspector-link\" id=\"inspector-link\" href=\"${safeUrl}\" target=\"_blank\" rel=\"noopener noreferrer\">${label}</a>`;\n } else {\n const hint = escapeHtml(s('dashboard.inspector.waiting'));\n inspectorSection = `<span class=\"inspector-hint\" id=\"inspector-link\">${hint}</span>`;\n }\n\n // pagesSection — \"연결된 Pages\" 섹션: env 3/4(pages: Array)에서만 렌더한다.\n // env 2(pages: null)는 라이브 page 목록을 알 수 없어 섹션 자체를 숨긴다(#411).\n // runtime builder: 조건부 블록 + 가변 row 목록이라 token-fill로는 불충분.\n const pagesSection =\n state.pages === null\n ? ''\n : `<hr /><section id=\"pages-section\"><h2>${escapeHtml(s('dashboard.pages.section'))}</h2><ul id=\"pages-list\">${\n state.pages.length > 0\n ? state.pages\n .map((p) => {\n const safeId = escapeHtml(p.id);\n const safeUrl = escapeHtml(p.url.slice(0, 120));\n return `<li><span class=\"page-id\">${safeId}</span> <span class=\"page-url\">${safeUrl}</span></li>`;\n })\n .join('\\n')\n : `<li class=\"empty\">${escapeHtml(s('dashboard.pages.empty'))}</li>`\n }</ul></section>`;\n\n // locale-aware strings for the inline SSE client script\n // watchdog timeout: server refresh 주기보다 넉넉히 크게 잡아 일시적 끊김을 오탐하지 않는다.\n // - 기본(90s): 90s × 2 + 30s = 210s — 주기 1회 놓쳐도 정상 케이스 배제.\n // - 짧은 주기(테스트용, < 5000ms): 주기 × 3 — 절대 30s를 더하지 않아 빠른 검증 가능.\n const watchdogTimeoutMs =\n sseRefreshIntervalMs < 5_000 ? sseRefreshIntervalMs * 3 : sseRefreshIntervalMs * 2 + 30_000;\n const sseStrings: SseScriptStrings = {\n tunnelUp: JSON.stringify(s('dashboard.tunnel.up')),\n tunnelDown: JSON.stringify(s('dashboard.tunnel.down')),\n pagesEmpty: JSON.stringify(s('dashboard.pages.empty')),\n attachHint: JSON.stringify(s('dashboard.attach.hint')),\n attachTunnelDown: JSON.stringify(s('dashboard.attach.tunnelDown')),\n copyLabel: JSON.stringify(s('dashboard.url.copy')),\n copiedLabel: JSON.stringify(s('dashboard.url.copied')),\n inspectorOpenLabel: JSON.stringify(s('dashboard.inspector.open')),\n inspectorWaitingLabel: JSON.stringify(s('dashboard.inspector.waiting')),\n dashboardSurface: true,\n watchdogTimeoutMs,\n watchdogTitle: JSON.stringify(s('dashboard.watchdog.title')),\n watchdogBody: JSON.stringify(s('dashboard.watchdog.body')),\n watchdogCloseLabel: JSON.stringify(s('dashboard.watchdog.close')),\n };\n\n const langSwitcher = buildLangSwitcher(path, params, locale, s);\n\n // Fill token placeholders in the precompiled chrome.\n // replaceAll is safe because these __TOKEN__ strings cannot appear in\n // any legitimate user-facing value (they are sentinel strings).\n const chrome = dashboardChromeByLocale[locale];\n const filled = chrome\n .replaceAll('__LANG_SWITCHER__', langSwitcher)\n .replaceAll('__NOW__', escapeHtml(now))\n .replaceAll('__TUNNEL_CLASS__', tunnelClass)\n .replaceAll('__TUNNEL_STATUS__', escapeHtml(tunnelStatus))\n .replaceAll('__ATTACH_SECTION__', attachSection)\n .replaceAll('__INSPECTOR_SECTION__', inspectorSection)\n .replaceAll('__PAGES_SECTION__', pagesSection);\n\n // Append the inline SSE <script> suffix directly before </body>.\n // This keeps the client script out of the precompiled chrome (it references\n // locale-aware strings resolved per-request) while staying self-contained.\n const sseScript = buildSseScript(sseStrings);\n return filled.replace('</body>', `${sseScript}\\n</body>`);\n}\n\ninterface SseScriptStrings {\n tunnelUp: string;\n tunnelDown: string;\n pagesEmpty: string;\n attachHint: string;\n /** 터널 드롭 시 attach-section에 표시할 에러 카피 (JSON.stringify로 이미 escape됨, #631). */\n attachTunnelDown: string;\n /** 복사 버튼 기본 라벨 (JSON.stringify로 이미 escape됨). */\n copyLabel: string;\n /** 복사 완료 피드백 라벨 (JSON.stringify로 이미 escape됨). */\n copiedLabel: string;\n /** \"인스펙터 열기\" 링크 라벨 (JSON.stringify로 이미 escape됨, #503). */\n inspectorOpenLabel: string;\n /** 인스펙터 URL 대기 힌트 (JSON.stringify로 이미 escape됨, #503). */\n inspectorWaitingLabel: string;\n /**\n * true: dashboard 표면 — `#attach-section` innerHTML 전체 교체 방식 유지.\n * url-box 텍스트도 innerHTML 교체로 갱신됨.\n * false: /attach 표면 — img src만 교체, url-box는 `#url-box` textContent만 갱신.\n * 이 분기가 url-box 이중 표시 결함을 방지한다.\n */\n dashboardSurface: boolean;\n /**\n * SSE watchdog — server 무응답 판정 임계값 (ms). (#681)\n *\n * server의 SSE refresh 주기(`sseRefreshIntervalMs`, 기본 90s)보다 넉넉히 크게 잡아\n * 한 번 놓쳐도 정상인 경우를 배제한다. token-fill 패턴으로 server refresh 주기를\n * 기반으로 계산한 값을 주입한다 (주기 × 2 + 30s 여유).\n *\n * 테스트에서는 짧은 `sseRefreshIntervalMs`를 주입하면 이 값도 짧아져 빠르게 검증 가능.\n */\n watchdogTimeoutMs: number;\n /** watchdog 발화 후 폴백 UI 제목 (JSON.stringify로 이미 escape됨). */\n watchdogTitle: string;\n /** watchdog 발화 후 폴백 UI 본문 (JSON.stringify로 이미 escape됨). */\n watchdogBody: string;\n /** watchdog 폴백 UI의 \"탭 닫기\" 버튼 라벨 (JSON.stringify로 이미 escape됨). */\n watchdogCloseLabel: string;\n}\n\n/**\n * Inline SSE client <script> — injected into the dashboard HTML at runtime.\n *\n * Subscribes to /events and updates the DOM without a build pipeline.\n * client side: attachUrl은 DOM에 렌더링, wssUrl은 절대 렌더링하지 않는다.\n * pages === null 이면 섹션을 건드리지 않는다 (#411).\n *\n * 두 표면(dashboard / attach) 분기:\n * - dashboard (dashboardSurface=true): #attach-section innerHTML 전체 교체 방식 유지.\n * url-box도 innerHTML 재렌더 안에 포함되어 갱신됨.\n * - /attach (dashboardSurface=false): #attach-section의 img src만 교체하고,\n * url-box는 #url-box textContent만 갱신한다. (#attach-section에 url-box가 없으므로\n * innerHTML 교체 시 url-box가 새로 생겨 이중 표시되는 결함을 방지 — #458 결함 수정.)\n *\n * 복사 기능: 이벤트 위임으로 document에 단일 핸들러. innerHTML 재렌더 후에도 생존.\n * - .url-box 클릭 또는 .copy-btn 클릭 → 현재 #url-box textContent 복사.\n * - clipboard: navigator.clipboard.writeText → 실패/부재 시 textarea execCommand fallback.\n * - 피드백: 버튼 라벨이 COPIED_LABEL로 ~1.5초 전환 후 COPY_LABEL로 복귀.\n *\n * SSE watchdog (#681): SSE 주기 갱신이 `watchdogTimeoutMs` 동안 수신되지 않으면\n * server가 종료된 것으로 판단한다. `window.close()`를 시도하고, 닫히지 않으면\n * 폴백 안내 화면으로 교체해 stale QR이 계속 보이는 오해를 항상 방지한다.\n *\n * 문자열 인자는 빌드타임에 ko/en 테이블에서 가져와 JSON.stringify로 이미 escape됨.\n *\n * SECRET-HANDLING: URL 값을 console.log 등으로 출력하지 않는다.\n */\nfunction buildSseScript(strings: SseScriptStrings): string {\n const isDashboard = strings.dashboardSurface;\n return `<script>\n // SSE — /events 구독해 상태 자동 갱신. 빌드 파이프라인 없는 인라인 스크립트.\n (function () {\n var TUNNEL_UP = ${strings.tunnelUp};\n var TUNNEL_DOWN = ${strings.tunnelDown};\n var PAGES_EMPTY = ${strings.pagesEmpty};\n var ATTACH_HINT = ${strings.attachHint};\n var ATTACH_TUNNEL_DOWN = ${strings.attachTunnelDown};\n var COPY_LABEL = ${strings.copyLabel};\n var COPIED_LABEL = ${strings.copiedLabel};\n var INSPECTOR_OPEN_LABEL = ${strings.inspectorOpenLabel};\n var INSPECTOR_WAITING_LABEL = ${strings.inspectorWaitingLabel};\n\n // ── SSE watchdog 상수 (#681) ──────────────────────────────────────────\n // server의 SSE refresh 주기(기본 90s)보다 넉넉히 큰 값 — 주기 × 2 + 30s 여유.\n // token-fill 패턴으로 server에서 계산해 주입한다.\n var WATCHDOG_TIMEOUT_MS = ${strings.watchdogTimeoutMs};\n var WATCHDOG_TITLE = ${strings.watchdogTitle};\n var WATCHDOG_BODY = ${strings.watchdogBody};\n var WATCHDOG_CLOSE_LABEL = ${strings.watchdogCloseLabel};\n\n // ── 클립보드 복사 헬퍼 ────────────────────────────────────────────────\n function copyText(text) {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n return navigator.clipboard.writeText(text);\n }\n // fallback: textarea + execCommand\n return new Promise(function (resolve, reject) {\n var ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.opacity = '0';\n document.body.appendChild(ta);\n ta.focus();\n ta.select();\n try {\n document.execCommand('copy') ? resolve() : reject(new Error('execCommand failed'));\n } catch (err) {\n reject(err);\n } finally {\n document.body.removeChild(ta);\n }\n });\n }\n\n // ── 복사 피드백 ───────────────────────────────────────────────────────\n var copyTimer = null;\n function triggerCopy() {\n var urlBox = document.getElementById('url-box');\n if (!urlBox) return;\n var text = urlBox.textContent || '';\n if (!text) return;\n copyText(text).then(function () {\n var btn = document.getElementById('copy-btn');\n if (btn) {\n btn.textContent = COPIED_LABEL;\n if (copyTimer) clearTimeout(copyTimer);\n copyTimer = setTimeout(function () {\n btn.textContent = COPY_LABEL;\n copyTimer = null;\n }, 1500);\n }\n }).catch(function () { /* 복사 실패 시 조용히 무시 */ });\n }\n\n // ── 이벤트 위임 — document 레벨에서 단일 핸들러 (innerHTML 재렌더 후에도 생존) ──\n document.addEventListener('click', function (e) {\n var target = e.target;\n if (!target) return;\n // .copy-btn 또는 .url-box 클릭 시 복사\n if (target.closest && (target.closest('.copy-btn') || target.closest('.url-box'))) {\n triggerCopy();\n }\n });\n\n // ── SSE watchdog (#681) ───────────────────────────────────────────────\n // 마지막으로 SSE 메시지를 수신한 시각. 스크립트 로드 시각으로 초기화한다.\n // (서버가 /events 연결 즉시 초기 상태를 push하므로 onmessage가 곧 갱신한다.)\n var lastSeen = Date.now();\n // watchdog이 한 번 발화하면 타이머를 clear해 중복 실행 방지.\n var watchdogFired = false;\n var watchdogTimer = null;\n\n // 폴백 UI — window.close()가 무시되면 stale QR 대신 안내 화면을 표시한다.\n function showWatchdogFallback() {\n if (document.body) {\n document.body.innerHTML =\n '<div style=\"display:flex;flex-direction:column;align-items:center;justify-content:center;' +\n 'height:100vh;font-family:sans-serif;text-align:center;padding:2rem;box-sizing:border-box;\">' +\n '<p style=\"font-size:1.4rem;font-weight:bold;margin-bottom:1rem;\">' + WATCHDOG_TITLE + '</p>' +\n '<p style=\"color:#555;margin-bottom:2rem;\">' + WATCHDOG_BODY + '</p>' +\n '<button onclick=\"window.close()\" ' +\n 'style=\"padding:0.6rem 1.4rem;font-size:1rem;cursor:pointer;border:1px solid #ccc;' +\n 'border-radius:6px;background:#f5f5f5;\">' + WATCHDOG_CLOSE_LABEL + '</button>' +\n '</div>';\n }\n }\n\n function fireWatchdog() {\n if (watchdogFired) return;\n watchdogFired = true;\n if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; }\n // 폴백 UI를 먼저 표시한다 — close가 성공하면 어차피 페이지가 닫혀 보이지 않는다.\n // close 실패(opener 없는 탭)에 대비해 stale QR 대신 안내 화면을 항상 보장한다.\n showWatchdogFallback();\n // window.close() 시도 — OS가 연 탭은 opener가 없어 무시될 수 있다.\n window.close();\n }\n\n // 주기적으로 lastSeen을 검사 — WATCHDOG_TIMEOUT_MS 초과 시 발화.\n // 검사 간격: WATCHDOG_TIMEOUT_MS / 6, 단 최소 1s 이상이되 timeout 자체보다 크지 않게 한다.\n // (sseRefreshIntervalMs가 작을 때 최솟값 1s가 timeout을 초과하는 경우 방지 — 테스트 격리.)\n var checkIntervalMs = Math.min(\n Math.max(1000, Math.round(WATCHDOG_TIMEOUT_MS / 6)),\n WATCHDOG_TIMEOUT_MS,\n );\n watchdogTimer = setInterval(function () {\n if (watchdogFired) return;\n if (Date.now() - lastSeen > WATCHDOG_TIMEOUT_MS) {\n fireWatchdog();\n }\n }, checkIntervalMs);\n\n // ── SSE 구독 ──────────────────────────────────────────────────────────\n var src = new EventSource('/events');\n src.onopen = function () {\n // 연결 복구 시 lastSeen 갱신 — 일시적 끊김이 watchdog을 발화시키지 않게.\n // watchdog이 이미 발화했으면 갱신하지 않는다(폴백 UI를 그대로 유지).\n if (!watchdogFired) { lastSeen = Date.now(); }\n };\n src.onmessage = function (e) {\n // SSE 메시지 수신 — lastSeen 갱신으로 watchdog 리셋.\n // watchdog 발화 후라면 갱신해도 타이머가 이미 clear됐으므로 효과 없음.\n if (!watchdogFired) { lastSeen = Date.now(); }\n try {\n var s = JSON.parse(e.data);\n // 터널 상태 갱신\n var el = document.getElementById('tunnel-status');\n if (el) {\n el.textContent = s.tunnel && s.tunnel.up ? TUNNEL_UP : TUNNEL_DOWN;\n el.className = 'status ' + (s.tunnel && s.tunnel.up ? 'status-up' : 'status-down');\n }\n // page 목록 갱신 — pages === null(env 2)이면 섹션 자체를 숨긴 채 둔다.\n // 정적 렌더가 #pages-section을 아예 안 그렸으므로 여기서도 손대지 않아\n // SSE push 때 섹션이 되살아나지 않는다(#411). 배열일 때만 목록을 채운다.\n if (s.pages !== null && s.pages !== undefined) {\n var ul = document.getElementById('pages-list');\n if (ul) {\n if (s.pages.length === 0) {\n ul.innerHTML = '<li class=\"empty\">' + PAGES_EMPTY + '</li>';\n } else {\n ul.innerHTML = s.pages.map(function (p) {\n var sid = String(p.id || '').slice(0, 36).replace(/[<>&\"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });\n var su = String(p.url || '').slice(0, 120).replace(/[<>&\"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });\n return '<li><span class=\"page-id\">' + sid + '</span> <span class=\"page-url\">' + su + '</span></li>';\n }).join('');\n }\n }\n }\n // attachUrl QR + url-box 갱신\n // SECRET-HANDLING: URL 값을 로그로 출력하지 않는다.\n // 터널이 죽으면(s.tunnel.up=false) attachUrl이 남아 있어도 QR을\n // 그리지 않고 에러 상태로 교체한다 — 죽은 QR이 스캔되는 것을 막는다(#631).\n var sec = document.getElementById('attach-section');\n if (sec) {\n if (!(s.tunnel && s.tunnel.up)) {\n sec.innerHTML = '<p class=\\\\\"hint error\\\\\">' + ATTACH_TUNNEL_DOWN + '</p>';\n } else if (s.attachUrl) {\n var encoded = encodeURIComponent(s.attachUrl);\n var safeUrl = String(s.attachUrl).slice(0, 2000).replace(/[<>&\"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });\n ${\n isDashboard\n ? `// dashboard: #attach-section innerHTML 전체 교체 (img + url-row).\n // url-box id=\"url-box\" 를 포함해 복사 핸들러가 계속 동작함.\n sec.innerHTML =\n '<img class=\"qr\" src=\"/qr.png?u=' + encoded + '\" alt=\"attach QR\" />' +\n '<div class=\\\\\"url-row\\\\\">' +\n '<p class=\\\\\"url-box\\\\\" id=\\\\\"url-box\\\\\">' + safeUrl + '</p>' +\n '<button class=\\\\\"copy-btn\\\\\" id=\\\\\"copy-btn\\\\\" type=\\\\\"button\\\\\" aria-label=\\\\\"' + COPY_LABEL + '\\\\\">' + COPY_LABEL + '</button>' +\n '</div>';`\n : `// /attach: img src만 교체 — url-box는 별도 #url-section에서 관리해 이중 표시 방지(#458).\n // QR img src 교체: img가 있으면 src만 갱신, 없으면 img 요소 생성.\n var img = sec.querySelector('img.qr');\n if (img) {\n img.src = '/qr.png?u=' + encoded;\n } else {\n sec.innerHTML = '<img class=\\\\\"qr\\\\\" src=\\\\\"/qr.png?u=' + encoded + '\\\\\" alt=\\\\\"attach QR\\\\\" />';\n }\n // url-box textContent만 갱신 (innerHTML 교체하지 않아 복사 버튼/핸들러 생존).\n var ub = document.getElementById('url-box');\n if (ub) ub.textContent = s.attachUrl;`\n }\n } else {\n ${\n isDashboard\n ? `sec.innerHTML = '<p class=\\\\\"hint\\\\\">' + ATTACH_HINT + '</p>';`\n : `// /attach에서 hint가 필요한 경우는 없으나 방어 처리.\n sec.innerHTML = '<p class=\\\\\"hint\\\\\">' + ATTACH_HINT + '</p>';`\n }\n }\n }\n // 인스펙터 링크 갱신 — #inspector-link (#503, gate 보정 #544).\n // 게이트: pages.length > 0 (페이지 attach 여부) — inspectorUrl 존재 여부가 아님.\n // #530 이후 inspectorUrl은 항상 안정 URL이므로 null 게이트는 사실상 항상 활성이었다.\n // pages.length > 0 으로 바꿔 미attach 시 대기 힌트를 보여주도록 수정.\n // SECRET-HANDLING: inspectorUrl을 console.log 등으로 출력하지 않는다.\n var insp = document.getElementById('inspector-link');\n if (insp) {\n var pagesAttachedSse = Array.isArray(s.pages) && s.pages.length > 0;\n if (pagesAttachedSse && s.inspectorUrl) {\n var safeInspUrl = String(s.inspectorUrl).slice(0, 2000).replace(/[<>&\"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });\n insp.outerHTML = '<a class=\\\\\"inspector-link\\\\\" id=\\\\\"inspector-link\\\\\" href=\\\\\"' + safeInspUrl + '\\\\\" target=\\\\\"_blank\\\\\" rel=\\\\\"noopener noreferrer\\\\\">' + INSPECTOR_OPEN_LABEL + '</a>';\n } else {\n insp.outerHTML = '<span class=\\\\\"inspector-hint\\\\\" id=\\\\\"inspector-link\\\\\">' + INSPECTOR_WAITING_LABEL + '</span>';\n }\n }\n // 갱신 시각 (dashboard만 #updated 요소 있음)\n var upd = document.getElementById('updated');\n if (upd) upd.textContent = upd.textContent.replace(/[^ ]+$/, new Date().toISOString());\n } catch (_) { /* 파싱 오류 무시 */ }\n };\n src.onerror = function () {\n // 재연결은 EventSource가 자동 처리 (spec 기본 동작).\n };\n })();\n </script>`;\n}\n\n/**\n * Attach 페이지 HTML — precompiled chrome에 per-request 동적 값을 채워 완성한다.\n *\n * 동적 파트:\n * - __QR_DATA_URL__ : base64 data URL (QR 이미지)\n * - __SAFE_LABEL__ : HTML-escaped deploymentId label (intoss family에만 존재)\n * - __SAFE_ATTACH_URL__ : HTML-escaped attach URL (TOTP at= 코드 포함 — 의도된 전달)\n * - __MODE_LABEL__ : 환경 배지 (`<p class=\"mode-label\">…</p>` 또는 빈 문자열, #468)\n * - __INSPECTOR_SECTION__ : \"디버그 툴 열기\" 버튼 또는 대기 힌트 (#544)\n *\n * mode-aware 분기 (#468): mode가 `relay-mobile`이면 sandbox family chrome(launcher\n * PWA 절차), 그 외는 intoss family chrome(토스 앱 절차)을 선택한다.\n * relay-live (env 4) 제거 (#665) — positive-allowlist kill-switch.\n *\n * SSE 스크립트도 주입 — `#attach-section` hook이 있으면 `/events` push 때 QR이\n * `/qr.png?u=<fresh attachUrl>`로 자동 갱신된다. `#inspector-link`도 SSE push로\n * pages.length > 0 게이트에 따라 활성/비활성 전환된다 (#544).\n *\n * SECRET-HANDLING: TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — 의도된 transport.\n * inspectorStableUrl은 /inspector 안정 URL (127.0.0.1, 시크릿 없음) — 노출 가능.\n */\nfunction buildAttachHtml(\n qrDataUrl: string,\n safeLabel: string,\n safeAttachUrl: string,\n locale: 'ko' | 'en',\n path = '/attach',\n params = new URLSearchParams(),\n mode?: McpEnvironment,\n pagesAttached = false,\n inspectorStableUrl: string | null = null,\n /**\n * SSE watchdog timeout 계산에 쓰이는 server refresh 주기 (ms, #681).\n * 미지정 시 기본값 90_000 적용.\n */\n sseRefreshIntervalMs = 90_000,\n): string {\n const s = resolveLocaleStrings(locale);\n const langSwitcher = buildLangSwitcher(path, params, locale, s);\n const family = attachFamilyForMode(mode);\n\n // inspector 섹션 — pages.length > 0 게이트 (#544).\n // inspectorStableUrl은 /inspector 안정 URL (시크릿 없음) — href 노출 가능.\n let inspectorSection: string;\n if (pagesAttached && inspectorStableUrl) {\n const safeUrl = escapeHtml(inspectorStableUrl);\n const label = escapeHtml(s('dashboard.inspector.open'));\n inspectorSection = `<a class=\"inspector-link\" id=\"inspector-link\" href=\"${safeUrl}\" target=\"_blank\" rel=\"noopener noreferrer\">${label}</a>`;\n } else {\n const hint = escapeHtml(s('dashboard.inspector.waiting'));\n inspectorSection = `<span class=\"inspector-hint\" id=\"inspector-link\">${hint}</span>`;\n }\n\n const chrome = attachChromeByLocale[locale][family];\n const filled = chrome\n .replaceAll('__LANG_SWITCHER__', langSwitcher)\n .replaceAll('__MODE_LABEL__', buildModeLabel(mode, s))\n .replaceAll('__QR_DATA_URL__', qrDataUrl)\n .replaceAll('__SAFE_LABEL__', safeLabel)\n .replaceAll('__SAFE_ATTACH_URL__', safeAttachUrl)\n .replaceAll('__INSPECTOR_SECTION__', inspectorSection);\n\n // Inject SSE script so QR auto-refreshes on each /events push,\n // and #inspector-link updates via pages.length > 0 gate on state change.\n // dashboardSurface: false → /attach 표면 분기 (img src 교체, url-box textContent만 갱신).\n // watchdog timeout: buildDashboardHtml와 동일 공식 (#681).\n const watchdogTimeoutMs =\n sseRefreshIntervalMs < 5_000 ? sseRefreshIntervalMs * 3 : sseRefreshIntervalMs * 2 + 30_000;\n const sseStrings: SseScriptStrings = {\n tunnelUp: JSON.stringify(s('dashboard.tunnel.up')),\n tunnelDown: JSON.stringify(s('dashboard.tunnel.down')),\n pagesEmpty: JSON.stringify(s('dashboard.pages.empty')),\n attachHint: JSON.stringify(s('dashboard.attach.hint')),\n attachTunnelDown: JSON.stringify(s('dashboard.attach.tunnelDown')),\n copyLabel: JSON.stringify(s('dashboard.url.copy')),\n copiedLabel: JSON.stringify(s('dashboard.url.copied')),\n // /attach 페이지의 #inspector-link SSE 갱신에 쓰인다 (#544).\n inspectorOpenLabel: JSON.stringify(s('dashboard.inspector.open')),\n inspectorWaitingLabel: JSON.stringify(s('dashboard.inspector.waiting')),\n // /attach 표면: img src만 교체, #url-box textContent만 갱신 → url-box 이중 표시 방지(#458).\n dashboardSurface: false,\n watchdogTimeoutMs,\n watchdogTitle: JSON.stringify(s('dashboard.watchdog.title')),\n watchdogBody: JSON.stringify(s('dashboard.watchdog.body')),\n watchdogCloseLabel: JSON.stringify(s('dashboard.watchdog.close')),\n };\n const sseScript = buildSseScript(sseStrings);\n return filled.replace('</body>', `${sseScript}\\n</body>`);\n}\n\nexport interface QrHttpServerOptions {\n /**\n * SSE 주기 갱신 간격 (ms). 기본값 90_000 (90초).\n *\n * SSE 구독자가 있는 동안 이 간격마다 `notifyStateChange()`와 동일한 push를 수행한다.\n * `getDashboardState()`가 호출 시점에 `at=` TOTP 코드를 재발급하므로, push 자체가\n * 열린 탭의 인스펙터 링크를 신선하게 유지한다. 90s 주기 < relay gate 허용창 ~3분\n * (±6 TOTP steps)이므로 탭이 열려 있는 한 링크가 항상 유효하다 (issue #509).\n *\n * 테스트에서 짧은 값(예: 50ms)을 주입해 검증한다. `undefined`이면 기본값 90_000.\n */\n sseRefreshIntervalMs?: number;\n /**\n * GET /inspector 라우트에서 클릭 시점 직접 인스펙터 URL을 조립하는 getter.\n *\n * getDashboardState().inspectorUrl(= /inspector 자기 자신)로 redirect하면 무한 루프가\n * 발생하므로, /inspector 라우트 내부는 이 getter로 직접 chii front_end URL을 조립한다.\n * 매 요청마다 호출되므로 TOTP를 요청 시점에 mint한다.\n *\n * - 미주입 → 기존 503 응답 유지.\n * - `ok: false, reason: 'relayDown'` → 502 (relay 미활성).\n * - `ok: false, reason: 'noTarget'` → 502 (relay up이지만 페이지 미attach).\n * - `ok: false, reason: 'totpUnavailable'` → 502 (TOTP secret 미설정, fail-closed).\n * - `ok: true` → 302 Location: url (Cache-Control: no-store).\n *\n * SECRET-HANDLING: ok:true 시 url 안에 relay host + TOTP at= 코드가 담긴다.\n * Location 헤더로 전달되는 건 의도된 transport. 로그/stdout 출력 금지.\n */\n getDirectInspectorUrl?: () =>\n | { ok: true; url: string }\n | { ok: false; reason: 'relayDown' | 'noTarget' | 'totpUnavailable' };\n}\n\n/**\n * 로컬 HTTP 서버를 127.0.0.1 random port(또는 `AIT_DEBUG_HTTP_PORT` env)로 시작한다.\n * MCP debug server 생애주기에 묶어 사용 — `runDebugServer` shutdown 시 `close()`로 정리.\n *\n * @param getDashboardState - dashboard 상태를 반환하는 클로저. 주입 시 `GET /` dashboard와\n * `GET /events` SSE 스트림이 활성화된다. 미주입 시 두 라우트는 204/서비스 없음으로 응답.\n * @param options - 서버 옵션. `sseRefreshIntervalMs`로 idle 탭 TOTP 만료 방지 주기를 조정.\n * `getDirectInspectorUrl`로 /inspector 라우트에서 직접 조립 URL을 제공해 redirect 루프를 방지.\n */\nexport async function startQrHttpServer(\n getDashboardState?: () => DashboardState,\n options?: QrHttpServerOptions,\n): Promise<QrHttpServer> {\n const { default: QRCode } = await import('qrcode');\n\n /** SSE 활성 연결 목록 — `notifyStateChange()` 시 전체 push. */\n const sseClients: ServerResponse[] = [];\n\n // SSE refresh 주기 — 핸들러 클로저 안에서 buildDashboardHtml/buildAttachHtml에 전달하기 위해\n // createServer 앞에서 선언한다 (temporal dead zone 방지, #681).\n const refreshIntervalMs = options?.sseRefreshIntervalMs ?? 90_000;\n\n /** SSE 연결 하나에 상태 이벤트를 flush한다. */\n function pushStateToClient(res: ServerResponse, state: DashboardState): void {\n const payload = JSON.stringify({\n tunnel: { up: state.tunnel.up, wssUrl: state.tunnel.wssUrl },\n pages: state.pages,\n // attachUrl은 캡슐 그대로 전달 — TOTP at= 코드 분리 없음 (의도된 설계).\n attachUrl: state.attachUrl,\n // inspectorUrl: relay + 페이지 attached 시 살아있는 인스펙터 URL (#503).\n // SECRET-HANDLING: URL(relay host + TOTP at=)은 SSE payload 전달이 의도된 transport.\n // 단 stdout/로그/에러에는 절대 출력하지 않는다.\n inspectorUrl: state.inspectorUrl ?? null,\n });\n // SSE frame: \"data: <json>\\n\\n\"\n res.write(`data: ${payload}\\n\\n`);\n }\n\n const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {\n const rawUrl = req.url ?? '/';\n const [path, query = ''] = rawUrl.split('?', 2) as [string, string | undefined];\n const params = new URLSearchParams(query ?? '');\n\n // per-request locale — ?lang= query param이 있으면 우선 적용, 없으면 Accept-Language header에서 결정.\n const langParam = params.get('lang');\n const locale =\n langParam === 'ko' || langParam === 'en'\n ? langParam\n : parseAcceptLanguage(req.headers['accept-language']);\n\n // ── GET / — dashboard 루트 ─────────────────────────────────────────────\n if (path === '/') {\n if (!getDashboardState) {\n res.writeHead(204, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end();\n return;\n }\n const state = getDashboardState();\n let qrDataUrl: string | null = null;\n if (state.attachUrl) {\n try {\n qrDataUrl = await QRCode.toDataURL(state.attachUrl, {\n type: 'image/png',\n errorCorrectionLevel: 'M',\n });\n } catch {\n // QR 생성 실패 시 null 유지 — dashboard는 텍스트 fallback 표시\n }\n }\n // devtoolsEntryUrl — getDirectInspectorUrl 주입 시만 /devtools/ 링크를 활성화.\n // 미주입이면 /devtools/ → 503이므로 dashboard에서 링크를 숨긴다.\n // /devtools/ 는 안정 경로 (relay host·TOTP 없음) — stdout/로그 출력 가능.\n const devtoolsEntryUrl: string | null = (() => {\n if (!options?.getDirectInspectorUrl) return null;\n const addr = server.address();\n if (!addr || typeof addr === 'string') return null;\n return `http://127.0.0.1:${addr.port}/devtools/`;\n })();\n const html = buildDashboardHtml(\n state,\n qrDataUrl,\n locale,\n path,\n params,\n devtoolsEntryUrl,\n refreshIntervalMs,\n );\n res.writeHead(200, {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Cache-Control': 'no-store',\n });\n res.end(html);\n return;\n }\n\n // ── GET /events — SSE 스트림 ──────────────────────────────────────────\n if (path === '/events') {\n if (!getDashboardState) {\n res.writeHead(204, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end();\n return;\n }\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n 'X-Accel-Buffering': 'no',\n });\n // 즉시 현재 상태를 한 번 push — 페이지 로드 시 최신 상태 보장.\n const initialState = getDashboardState();\n pushStateToClient(res, initialState);\n\n sseClients.push(res);\n\n // 연결 끊기면 목록에서 제거.\n req.once('close', () => {\n const idx = sseClients.indexOf(res);\n if (idx !== -1) sseClients.splice(idx, 1);\n });\n return;\n }\n\n if (path === '/attach') {\n const encodedU = params.get('u') ?? '';\n let attachUrl: string;\n try {\n attachUrl = decodeURIComponent(encodedU);\n } catch {\n res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end('잘못된 u 파라미터입니다.');\n return;\n }\n\n // deploymentId 라벨 — attachUrl에서 _deploymentId 파라미터만 추출 (at= 노출 방지).\n let deploymentIdLabel = 'attach';\n try {\n const dpMatch = attachUrl.match(/[?&]_deploymentId=([^&]+)/);\n if (dpMatch?.[1]) {\n deploymentIdLabel = decodeURIComponent(dpMatch[1]).slice(0, 36);\n }\n } catch {\n // best-effort\n }\n\n // 현재 세션 mode + pages 상태 — 카피 분기(#468), inspector 게이트(#544).\n // getDashboardState 미주입(legacy) 시 undefined → intoss family + 환경 라벨 없음 fallback.\n const currentState = getDashboardState?.();\n const mode = currentState?.mode;\n const pagesAttached =\n Array.isArray(currentState?.pages) && (currentState?.pages.length ?? 0) > 0;\n // inspectorStableUrl: /inspector 안정 URL (시크릿 없음) — getDirectInspectorUrl 주입 시만 활성.\n // 서버 주소는 listen 후에만 확정되므로 server.address()로 런타임에 읽는다.\n // (요청은 listen 완료 후 들어오므로 address()는 항상 non-null이다.)\n const inspectorStableUrlForAttach: string | null = (() => {\n if (!options?.getDirectInspectorUrl) return null;\n const addr = server.address();\n if (!addr || typeof addr === 'string') return null;\n return `http://127.0.0.1:${addr.port}/inspector`;\n })();\n\n // QR을 base64 data URL로 인라인 생성 — 외부 fetch 없이 self-contained HTML.\n QRCode.toDataURL(attachUrl, { type: 'image/png', errorCorrectionLevel: 'M' })\n .then((dataUrl: string) => {\n const safeLabel = escapeHtml(deploymentIdLabel);\n const safeAttachUrl = escapeHtml(attachUrl);\n const html = buildAttachHtml(\n dataUrl,\n safeLabel,\n safeAttachUrl,\n locale,\n path,\n params,\n mode,\n pagesAttached,\n inspectorStableUrlForAttach,\n refreshIntervalMs,\n );\n res.writeHead(200, {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Cache-Control': 'no-store',\n });\n res.end(html);\n })\n .catch(() => {\n res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end('QR 생성에 실패했습니다.');\n });\n return;\n }\n\n // ── GET /inspector — 안정 인스펙터 진입점 (issue #530) ───────────────────\n // ── GET /devtools/ — chii DevTools UI 진입로 (issue #248 옵션 A) ──────────\n //\n // 두 라우트는 동일한 핸들러를 공유한다:\n // - relay 연결 active + attached target 있음 → chii front_end/chii_app.html?ws=… 302.\n // - relay down 또는 target 없음 → 502 (사용자에게 원인 + 다음 단계 안내).\n // - getDirectInspectorUrl 미주입 (relay 연결 없는 server mode) → 503.\n //\n // /inspector: dashboard의 \"디버그 툴 열기\" 링크가 이 URL을 가리키며, qrServer.inspectorStableUrl로 노출된다.\n // /devtools/: `/ait debug` 문서·가이드에서 직접 참조 가능한 고정 경로 (issue #248).\n // 이 경로가 존재함으로써 사용자가 dashboard를 열지 않고도 직접 DevTools UI에 접근할 수 있다.\n //\n // getDashboardState().inspectorUrl(= /inspector 자기 자신)을 쓰면 무한 루프 → getDirectInspectorUrl로 분리.\n // SECRET-HANDLING: redirect Location(relay host + at=)은 HTTP 응답으로만 전달.\n // 로그에 Location 값 출력 금지.\n if (path === '/inspector' || path === '/devtools' || path === '/devtools/') {\n const getDirectInspectorUrl = options?.getDirectInspectorUrl;\n if (!getDirectInspectorUrl) {\n // /inspector: 기존 영문 메시지를 유지한다.\n // 이 경로는 공개 안정 경로(#530)이고 외부 스크립트가 메시지를 파싱할 수 있어\n // 계약을 깨지 않도록 원문 그대로 유지한다.\n // /devtools[/]: issue #248에서 새로 추가된 경로. 한국어 안내를 반환한다.\n const body =\n path === '/inspector'\n ? 'Inspector endpoint is not available in this server mode.'\n : 'relay 연결 세션에서만 DevTools UI를 열 수 있습니다.';\n res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end(body);\n return;\n }\n // 매 요청마다 getter 호출 — TOTP를 요청 시점에 mint.\n const result = getDirectInspectorUrl();\n const s = resolveLocaleStrings(locale);\n if (!result.ok) {\n const msgKey =\n result.reason === 'noTarget' ? 'inspector.error.noTarget' : 'inspector.error.relayDown';\n const msg = s(msgKey);\n const body =\n `<!DOCTYPE html><html lang=\"${locale}\"><head>` +\n `<meta charset=\"utf-8\"><title>Inspector</title></head><body>` +\n `<p>${escapeHtml(msg)}</p>` +\n `<p style=\"font-size:0.9em;color:#666\">` +\n (locale === 'ko'\n ? '(<a href=\"/\">대시보드로 돌아가기</a>)'\n : '(<a href=\"/\">Back to dashboard</a>)') +\n `</p></body></html>`;\n res.writeHead(502, {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Cache-Control': 'no-store',\n });\n res.end(body);\n return;\n }\n // ok: true — 302 redirect. Location에 relay host + TOTP at= 포함.\n // SECRET-HANDLING: Location 값은 HTTP 응답으로만 — 로그/stdout 출력 금지.\n res.writeHead(302, {\n Location: result.url,\n 'Cache-Control': 'no-store',\n });\n res.end();\n return;\n }\n\n if (path === '/qr.png') {\n const encodedU = params.get('u') ?? '';\n\n // SECONDARY FIX (devtools#714): an empty or missing `u` means the attach\n // URL is not yet available (tunnel still booting, dashboard in WAITING\n // state). Return 204 No Content instead of passing an empty string to\n // QRCode.toBuffer which would reject and produce a confusing 500.\n // The dashboard SSE will push a refresh once the attach URL is minted.\n if (encodedU === '') {\n res.writeHead(204, { 'Cache-Control': 'no-store' });\n res.end();\n return;\n }\n\n let attachUrl: string;\n try {\n attachUrl = decodeURIComponent(encodedU);\n } catch {\n res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end('잘못된 u 파라미터입니다.');\n return;\n }\n\n // Guard against an empty decoded string (e.g. `?u=` encoded as `%00`\n // or other edge cases) — same 204 treatment as the missing-u path above.\n if (attachUrl === '') {\n res.writeHead(204, { 'Cache-Control': 'no-store' });\n res.end();\n return;\n }\n\n QRCode.toBuffer(attachUrl, { type: 'png', errorCorrectionLevel: 'M' })\n .then((buf: Buffer) => {\n res.writeHead(200, {\n 'Content-Type': 'image/png',\n 'Cache-Control': 'no-store',\n 'Content-Length': String(buf.length),\n });\n res.end(buf);\n })\n .catch(() => {\n res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end('QR PNG 생성에 실패했습니다.');\n });\n return;\n }\n\n res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end('Not Found');\n });\n\n const listenPort = Number(process.env.AIT_DEBUG_HTTP_PORT ?? 0);\n\n await new Promise<void>((resolve, reject) => {\n server.listen(listenPort, '127.0.0.1', () => resolve());\n server.once('error', reject);\n });\n\n const address = server.address();\n if (!address || typeof address === 'string') {\n throw new Error('qr-http-server: server.address()가 예상하지 못한 형태입니다.');\n }\n const port = address.port;\n\n /** idle 탭 TOTP 만료 방지용 주기 SSE 갱신 interval. */\n function notifyStateChangeInternal(): void {\n if (!getDashboardState) return;\n const state = getDashboardState();\n for (const client of sseClients) {\n try {\n pushStateToClient(client, state);\n } catch {\n // 연결이 이미 끊어진 경우 — 무시 (close 핸들러가 목록에서 제거함).\n }\n }\n }\n\n // 주기 SSE 갱신 — getDashboardState() 호출 시점에 TOTP at=가 재발급되므로\n // push 자체가 열린 탭의 인스펙터 링크를 신선하게 유지한다 (issue #509).\n // .unref()로 프로세스 종료를 막지 않는다.\n // refreshIntervalMs는 createServer 앞에서 이미 선언됨 (#681 — 핸들러 클로저에서 참조).\n const refreshHandle = setInterval(() => {\n if (sseClients.length > 0 && getDashboardState) {\n notifyStateChangeInternal();\n }\n }, refreshIntervalMs).unref();\n\n return {\n port,\n buildAttachPageUrl(_attachUrl: string): string {\n // 사용자 대면 URL을 루트 `/`로 수렴 (#595).\n // 같은 데몬이 attachUrl을 이미 server-state(getDashboardState)로 보유하므로\n // `/attach?u=<encoded>` 쿼리는 redundant하다.\n // SECRET-HANDLING: 브라우저에 열리는 URL에서 tunnel host·relay wss·TOTP at= 제거.\n // /attach?u= 라우트 자체는 back-compat으로 유지(기존 인쇄된 링크 보호).\n return `http://127.0.0.1:${port}/`;\n },\n // 안정 인스펙터 진입점 URL (issue #530) — 클릭 시 302 redirect (TOTP 클릭 시점 mint).\n // URL 자체에 시크릿 없음 → 대시보드/stdout/로그 어디든 출력 가능.\n get inspectorStableUrl(): string {\n return `http://127.0.0.1:${port}/inspector`;\n },\n notifyStateChange(): void {\n notifyStateChangeInternal();\n },\n close(): Promise<void> {\n clearInterval(refreshHandle);\n return new Promise((resolve, reject) => {\n server.close((err) => (err ? reject(err) : resolve()));\n });\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"relay-factory-Ck3vBonr.js","names":[],"sources":["../src/test-runner/relay-factory.ts"],"sourcesContent":["/**\n * Relay connection factory for the Vitest custom pool (devtools#696).\n *\n * The Vitest pool (`pool.ts`) takes a {@link RelayConnectionFactory} that knows\n * how to `open()` a live CDP relay connection (boot relay → QR → phone scan →\n * cell inject → enableDomains) and `close()` it. Before this module that exact\n * assembly lived only inside the `devtools-test` CLI's `main()`; the standalone\n * CLI and the Vitest pool would otherwise each hand-roll it and drift.\n *\n * This is the single source of that assembly. `cli.ts` is refactored to call\n * `createRelayConnectionFactory(...).open()`, and `definePhoneVitestConfig`\n * (config.ts) exposes it so a downstream `vitest.config.ts` can wire the pool:\n *\n * import { createRelayConnectionFactory } from '@ait-co/devtools/test-runner';\n * const connection = createRelayConnectionFactory({\n * schemeUrl,\n * cell,\n * // REQUIRED — own the stdout decision (the chunks carry the relay wss +\n * // TOTP code). Suppress on non-interactive stdout; print otherwise.\n * onQrContent: (chunks) => {\n * if (!process.stdout.isTTY) return;\n * for (const c of chunks) process.stdout.write(`${c}\\n`);\n * },\n * });\n * export default defineConfig({ test: definePhoneVitestConfig({ connection }) });\n *\n * The heavy boot graph (chii relay, cloudflared, ws, debug-server) is pulled via\n * DYNAMIC import inside `open()` — so merely importing this module (or the\n * `@ait-co/devtools/test-runner` barrel) does NOT statically drag that graph in.\n *\n * SECRET-HANDLING: relay wss URLs, scheme URLs, and the TOTP secret/code are\n * never logged. `open()` reads the project-local `.ait_relay` secret read-only\n * (never mints) and the minted TOTP code rides only inside the QR `at=` param.\n *\n * Node-only. react-free (CdpConnection + lazily-imported MCP boot helpers only).\n */\n\nimport type { AttachDeps, AttachUrlParts } from '../mcp/attach-orchestrator.js';\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport type { QrHttpServer } from '../mcp/qr-http-server.js';\nimport type { RelayConnectionFactory } from './pool.js';\n\n// NOTE: every value import below is a DYNAMIC import inside `open()`. This module\n// keeps ONLY type-level static imports so that re-exporting it from the\n// `@ait-co/devtools/test-runner` barrel (config.ts) does NOT statically drag the\n// heavy MCP graph (cell.ts → attach-orchestrator.ts → tools.ts → server-lock,\n// plus chii/cloudflared via debug-server) onto that Node-config entry.\n\n/** Options for {@link createRelayConnectionFactory}. */\nexport interface RelayConnectionFactoryOptions {\n /**\n * intoss-private:// scheme URL from `ait deploy --scheme-only` (env3). The\n * phone cold-loads the candidate bundle this URL points at. SECRET-HANDLING:\n * never logged.\n */\n schemeUrl: string;\n /**\n * Project root for the `.ait_relay` secret lookup (read-only). Defaults to\n * `process.cwd()`.\n */\n projectRoot?: string;\n /**\n * Attach wait timeout in ms — how long `open()` waits for the phone to scan\n * the QR and attach. Defaults to 600 000 (10 min) to give time for a manual\n * scan. (The per-file evaluate timeout is separate, passed via the pool's\n * `run` options.)\n */\n timeoutMs?: number;\n /** Disable browser auto-open of the QR dashboard (text QR only). */\n headless?: boolean;\n /**\n * Cell axes injected as `__AIT_CELL__` before the first test bundle runs, so\n * sdk-example's capture picks up the correct sdkLine/platform. Optional — when\n * omitted no cell is injected. The values are not secrets.\n */\n cell?: { sdkLine: string; platform: string };\n /**\n * Receives the QR/attach render content (text chunks) from\n * `renderAndMaybeWait`, so the caller decides whether to print them — e.g.\n * suppress on non-interactive stdout.\n *\n * REQUIRED (not optional) on purpose: these chunks contain the QR payload\n * (which encodes the relay wss + TOTP `at=` code) and the attach JSON block.\n * Making the hook mandatory means the factory never falls back to printing\n * them itself — a downstream `vitest.config.ts` consumer that wires this via\n * the `@ait-co/devtools/test-runner` barrel is forced to make an explicit\n * stdout decision rather than silently leaking the secret-bearing block.\n *\n * SECRET-HANDLING: when a non-interactive caller is detected the hook MUST\n * suppress the WHOLE chunk (not just `attachUrl`), since `relayUrl` rides in\n * the same block.\n */\n onQrContent: (textChunks: string[]) => void;\n}\n\n/**\n * Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay\n * connection.\n *\n * `open()` performs the full attach lifecycle and BLOCKS for tens of seconds (up\n * to `timeoutMs`) while a human scans the rendered QR with their phone — there\n * is no way around the manual scan for env3. It resolves with the live\n * `CdpConnection` once a matching page attaches; `close()` tears the relay\n * family down.\n *\n * The factory holds the booted relay family in a closure so `close()` can stop\n * it. A second `open()` on the same factory boots a fresh family (the previous\n * one should have been `close()`d first).\n */\nexport function createRelayConnectionFactory(\n opts: RelayConnectionFactoryOptions,\n): RelayConnectionFactory {\n const projectRoot = opts.projectRoot ?? process.cwd();\n const timeoutMs = opts.timeoutMs ?? 600_000;\n const headless = opts.headless === true;\n\n // Captured so close() can stop the family that open() booted.\n let family: { connection: CdpConnection; stop(): void } | undefined;\n // QR HTTP server — started during open() when not headless, closed in close().\n let qrServer: QrHttpServer | undefined;\n\n return {\n async open(): Promise<CdpConnection> {\n // Dynamic imports: keep the chii/cloudflared/debug-server graph OFF the\n // static import graph of this module (and the test-runner config barrel).\n const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import(\n '../mcp/attach-orchestrator.js'\n );\n const { injectDebugIndicator, injectGlobals } = await import('./cell.js');\n const { loadRelaySecretReadOnly } = await import('../mcp/relay-secret-store.js');\n const { bootRelayFamily, buildRelayVerifyAuth } = await import('../mcp/debug-server.js');\n\n // Load the project-local .ait_relay secret into AIT_DEBUG_TOTP_SECRET\n // BEFORE booting the relay so assertRelayAuthConfigured()/buildRelayVerifyAuth()\n // at the boot site see it. Read-only — never mints. SECRET-HANDLING: the\n // value is never logged here.\n await loadRelaySecretReadOnly({ projectRoot });\n\n // PRIMARY FIX (devtools#714): bootRelayFamily starts the cloudflared tunnel\n // as a background promise and returns immediately with tunnel.up === false.\n // We must NOT call prepareAttach until the tunnel is up — it fails fast when\n // tunnel.up is false (attach-orchestrator.ts tunnel-down guard).\n //\n // Wire onWssUrl (mirroring the MCP daemon path in debug-server.ts) to:\n // 1. resolve a caller-held tunnel-ready promise so open() can await it, and\n // 2. re-push dashboard SSE on late tunnel-up events (notifyStateChange).\n //\n // SECRET-HANDLING: onWssUrl receives the relay wss URL — never log it.\n let resolveTunnelUp!: () => void;\n const tunnelReady = new Promise<void>((resolve) => {\n resolveTunnelUp = resolve;\n });\n\n const booted = await bootRelayFamily({\n verifyAuth: buildRelayVerifyAuth(),\n onWssUrl: () => {\n // Resolve the tunnel-ready gate so the prepareAttach call below is\n // unblocked. qrServer may not be set yet at call time (it's started\n // after bootRelayFamily), so we use optional chaining — if the tunnel\n // happens to come up after qrServer is started, notifyStateChange pushes\n // the freshly minted attachUrl to any waiting dashboard SSE clients.\n // SECRET-HANDLING: wssUrl is NOT forwarded here — the value travels only\n // inside the closure via getTunnelStatus().wssUrl (used by mintAttachUrl).\n resolveTunnelUp();\n qrServer?.notifyStateChange();\n },\n });\n family = booted;\n\n // If the tunnel is already up (extremely fast boot or test double), resolve\n // immediately so we don't stall on a promise that will never fire.\n if (booted.getTunnelStatus?.().up) {\n resolveTunnelUp();\n }\n\n // Track the last-captured attach parts so getDashboardState can mint a\n // fresh attach URL on every dashboard request/SSE push (fresh TOTP at=).\n // SECRET-HANDLING: parts contain the relay wss + scheme URL — never logged.\n let lastAttachParts: AttachUrlParts | undefined;\n\n // Assemble AttachDeps. We set qrHttpServer and onAttachUrlBuilt below\n // (before prepareAttach) after optionally starting the web-QR server.\n const attachDeps: AttachDeps = {\n getTunnelStatus: booted.getTunnelStatus ?? (() => ({ up: false, wssUrl: null })),\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n qrHttpServer: undefined, // filled in below if web-QR server started\n onAttachUrlBuilt: undefined, // filled in below\n canOpenBrowser: () => !headless,\n };\n\n // Web-QR server: reuse the same loopback HTTP dashboard that the MCP\n // start_attach path uses (src/mcp/qr-http-server.ts). This makes the QR\n // scannable even when stdout is non-interactive (Claude Code `!` / CI),\n // because the browser is opened by URL — not via captured stdout.\n //\n // Headless decision: we start the server even in --headless mode so the\n // printed stderr URL can be opened manually. The existing\n // canOpenBrowser: () => !headless gate inside renderAndMaybeWait prevents\n // the auto-open; headless users see only the stderr URL.\n //\n // On failure we fall back gracefully to the text-QR path — do NOT crash.\n // SECRET-HANDLING: only http://127.0.0.1:<port>/ (no secrets) goes to stderr.\n try {\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n\n const getDashboardState = () => ({\n tunnel: attachDeps.getTunnelStatus(),\n pages: null, // CLI/pool: no page-list introspection needed\n attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,\n mode: 'relay-dev' as const,\n });\n\n qrServer = await startQrHttpServer(getDashboardState);\n\n // Wire the QR server into attachDeps BEFORE prepareAttach is called so\n // renderAndMaybeWait sees it and takes Path 2/3 (web-QR) instead of Path 4.\n attachDeps.qrHttpServer = qrServer;\n\n // Capture attach parts via onAttachUrlBuilt so getDashboardState can\n // mint a fresh URL (fresh TOTP at= code) on every dashboard render.\n attachDeps.onAttachUrlBuilt = (parts: AttachUrlParts) => {\n lastAttachParts = parts;\n qrServer?.notifyStateChange();\n };\n\n // Print the loopback dashboard URL to stderr — it carries no secrets\n // (TOTP codes and relay wss live only in the in-memory HTTP response).\n process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\\n`);\n } catch {\n // startQrHttpServer failed (e.g. port conflict, import error). Fall back\n // to the existing text-QR path by leaving qrHttpServer: undefined.\n // qrServer remains undefined; close() handles that with optional chaining.\n }\n\n // PRIMARY FIX (devtools#714): await tunnel readiness before calling\n // prepareAttach. Race: a 15 s timeout is generous — cloudflared typically\n // comes up in < 5 s. On timeout we still call prepareAttach; it will hit\n // the tunnel-down guard and throw a secret-free error (same as before,\n // but now with a clear diagnostic instead of a silent WAITING freeze).\n //\n // Implementation: Promise.race against a 15 000 ms timeout signal. We do\n // NOT use a timer-based early-exit because the existing code already\n // surface-fails on tunnel-down inside prepareAttach with a secret-free\n // message. The timeout here is purely a \"give the tunnel a fair chance\"\n // gate — not a correctness boundary.\n const TUNNEL_BOOT_TIMEOUT_MS = 15_000;\n await Promise.race([\n tunnelReady,\n new Promise<void>((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS)),\n ]);\n\n const prep = await prepareAttach(\n attachDeps,\n 'relay-dev',\n { scheme_url: opts.schemeUrl },\n booted.connection,\n );\n if (!prep.ok) {\n booted.stop();\n family = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the failure path\n // so the loopback port listener does not leak. The normal-exit path is\n // handled by close(); this mirrors that cleanup for the error path.\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: do NOT surface `prep.error.content` in the thrown\n // message. Some prep error paths build their text from attach\n // components, and the CLI catch writes `e.message` to stderr — embedding\n // that text risks leaking the scheme/relay wss URL. The detailed\n // diagnostic is the daemon/dashboard's job; the factory throws a\n // secret-free message only.\n throw new Error(\n 'createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up',\n );\n }\n\n // Render the QR + wait for the phone to attach. SECRET-HANDLING: this\n // function never logs scheme/wss/TOTP values.\n const waitResult = await renderAndMaybeWait(\n attachDeps,\n prep,\n true,\n timeoutMs,\n booted.connection,\n );\n if (waitResult.isError) {\n booted.stop();\n family = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the timeout path\n // (mirrors the prep.ok failure path above).\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING (BLOCKER fix): `waitResult.content` is the timeout\n // result built from `buildTimeoutError(baseText, ...)`, and `baseText`\n // is `JSON.stringify({ attachUrl, relayUrl, ... })` — `attachUrl` carries\n // the TOTP `at=` code and `relayUrl` is the relay `wss://` URL. The CLI\n // catch writes `e.message` to stderr, so extracting that text here would\n // leak the relay wss + TOTP code on every timeout. Throw a secret-free\n // message with only the timeout duration.\n const timeoutSec = Math.round(timeoutMs / 1000);\n throw new Error(\n `createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`,\n );\n }\n\n // Surface the QR/attach render content to the caller, which owns the\n // stdout decision (suppress on non-interactive stdout). There is no\n // fallback that prints here itself: `onQrContent` is a required option so\n // the secret-bearing block (attachUrl TOTP + relayUrl wss) can never be\n // emitted without an explicit caller decision. SECRET-HANDLING.\n const qrChunks = waitResult.content\n .filter((c): c is { type: 'text'; text: string } => c.type === 'text')\n .map((c) => c.text);\n opts.onQrContent(qrChunks);\n\n // PAGE-READY GATE (devtools#720, fix (a)+(b)):\n //\n // FIX (a) — ORDER: enableDomains() MUST run before injectDebugIndicator()\n // and injectGlobals(). Both inject calls use Runtime.evaluate via\n // sendCommand(), which guards with `if (!this.ws) reject(\"Call\n // enableDomains() first\")`. With the old ordering (inject → inject →\n // enableDomains) injectDebugIndicator's throw was swallowed by cell.ts\n // try/catch but injectGlobals (no try/catch) propagated fatally — when\n // --cell was set open() hard-threw before enableDomains ever ran and the\n // CLI exited with 0 files executed.\n //\n // FIX (b) — BOUNDED RETRY: the window between waitForFirstTarget (non-\n // empty /targets) and enableDomains (page-level WS open) is where a\n // Cloudflare edge idle-drop can disconnect the phone. enableDomains()\n // calls refreshTargets() internally; if the target list is empty at that\n // point it throws \"No mini-app page attached\". We absorb up to\n // PAGE_READY_RETRIES transient failures by waiting briefly and retrying\n // the refreshTargets+enableDomains sequence. enableDomains() is\n // idempotent (concurrent callers share the in-flight promise), so retries\n // are safe. Only after all retries fail do we surface a secret-free\n // error. This is the CLI equivalent of the MCP daemon's soft-fail\n // cushion in relay-worker.ts.\n //\n // Neither fix touches chii-connection.ts or the MCP daemon path.\n const PAGE_READY_RETRIES = 3;\n const PAGE_READY_RETRY_DELAY_MS = 1_500;\n\n let lastEnableError: Error | undefined;\n for (let attempt = 1; attempt <= PAGE_READY_RETRIES; attempt++) {\n try {\n // FIX (a): enableDomains first — opens the page-level CDP websocket.\n await booted.connection.enableDomains();\n lastEnableError = undefined;\n break;\n } catch (err) {\n lastEnableError = err instanceof Error ? err : new Error(String(err));\n if (attempt < PAGE_READY_RETRIES) {\n // FIX (b): brief pause then re-poll /targets before retrying\n // enableDomains. Use a non-leaking approach: wait, then fall\n // through to the next loop iteration.\n await new Promise<void>((resolve) => setTimeout(resolve, PAGE_READY_RETRY_DELAY_MS));\n }\n }\n }\n\n if (lastEnableError !== undefined) {\n booted.stop();\n family = undefined;\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: message contains only a duration + attempt count.\n // No relay wss URL, scheme URL, or TOTP code is included.\n throw new Error(\n `createRelayConnectionFactory: page did not become ready after ${PAGE_READY_RETRIES} attempts (${Math.round((PAGE_READY_RETRIES * PAGE_READY_RETRY_DELAY_MS) / 1000)}s) — the mini-app page may have disconnected before enableDomains() could open the CDP websocket`,\n );\n }\n\n // FIX (a): inject AFTER enableDomains so the page-level CDP websocket is\n // open and sendCommand() will not hit the ws===null guard.\n\n // Show the on-phone \"Debugger Connected\" badge. injectDebugIndicator\n // swallows its own errors (cell.ts try/catch), so a badge failure is\n // non-fatal — test execution proceeds regardless.\n await injectDebugIndicator(booted.connection);\n\n // Inject the cell globals before any test bundle runs (session-global).\n // injectGlobals() does NOT swallow errors — a genuine type/eval failure\n // here surfaces clearly instead of silently skipping cell injection.\n if (opts.cell !== undefined) {\n await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });\n }\n\n return booted.connection;\n },\n\n async close(_connection: CdpConnection): Promise<void> {\n // family.stop() is synchronous best-effort: closes the CDP connection and\n // shuts down the relay + cloudflared child.\n family?.stop();\n family = undefined;\n // Close the web-QR HTTP server if one was started during open().\n // close() is idempotent via optional chaining + reassignment to undefined.\n await qrServer?.close();\n qrServer = undefined;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"relay-worker-Bt0QYKQH.js","names":[],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-connection.ts","../src/test-runner/relay-worker.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Production `CdpConnection` backed by the local Chii relay.\n *\n * Topology (debug mode):\n * phone target.js --WS--> Chii relay :9100 <--WS-- this connection\n *\n * The phone connects to the relay as a `target`; this module connects as a\n * `client` (the role a CDP frontend would take) so CDP events the page emits\n * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent\n * events in ring buffers the tool layer reads via `getBufferedEvents`.\n *\n * Node-only: imports `ws`. Never bundled into the browser/in-app entries.\n *\n * Attach reliability (#281):\n * `refreshTargets()` emits an internal 'target:attached' event whenever a\n * new target is added to the relay. `waitForFirstTarget()` awaits that event\n * (with a polling-interval fallback) so `start_attach`'s attach wait\n * resolves deterministically rather than racing between polling rounds.\n */\n\nimport { EventEmitter } from 'node:events';\nimport { WebSocket } from 'ws';\nimport { RELAY_AUTH_REJECT_CLOSE_CODE } from '../shared/relay-auth-close.js';\nimport type {\n CdpCommandMap,\n CdpCommandName,\n CdpConnection,\n CdpEventMap,\n CdpEventName,\n CdpTarget,\n} from './cdp-connection.js';\nimport { logInfo } from './log.js';\nimport { generateTotp } from './totp.js';\n\n/** Max events retained per domain ring buffer. */\nconst DEFAULT_BUFFER_SIZE = 500;\n\n/**\n * Substrings that mark a \"relay websocket is dead\" class error, as produced by\n * `handleDisconnect('relay WebSocket 연결이 끊겼습니다')` (ws close handler) and\n * the fail-fast `sendCommand` rejection (`relay에 연결되어 있지 않습니다 (...)`).\n *\n * Exported so callers outside this module (e.g. `test-runner/relay-worker.ts`)\n * can detect the same error class without hardcoding a fragile full-sentence\n * match — mirrors the `EVALUATE_TIMEOUT_MARKER` precedent in\n * `test-runner/relay-worker.ts`. Kept in sync with `classifyToolError`'s\n * `relayDisconnectError` branch in `mcp/errors.ts`.\n */\nexport const RELAY_DISCONNECT_MARKERS = [\n 'relay WebSocket',\n 'relay에 연결되어 있지 않습니다',\n] as const;\n\n/**\n * Returns true when `message` matches the relay-websocket-dead error class\n * (socket closed, socket errored, or fail-fast \"not connected\" rejection).\n */\nexport function isRelayDisconnectMessage(message: string): boolean {\n return RELAY_DISCONNECT_MARKERS.some((marker) => message.includes(marker));\n}\n\n/** A CDP message arriving over the relay websocket. */\ninterface CdpInboundMessage {\n id?: number;\n method?: string;\n params?: unknown;\n result?: unknown;\n error?: { message: string };\n}\n\n/**\n * Events emitted by `ChiiCdpConnection` for crash / lifecycle notifications.\n * Consumers (e.g. the tool layer) can subscribe with `.onLifecycle(cb)`.\n */\nexport interface TargetLifecycleEvent {\n /**\n * 'crashed' → Inspector.targetCrashed\n * 'destroyed' → Target.targetDestroyed\n * 'detached' → Target.detachedFromTarget\n * 'replaced' → evicted by single-attach model (last-attach wins)\n */\n kind: 'crashed' | 'destroyed' | 'detached' | 'replaced';\n targetId: string | null;\n /** ISO timestamp of detection. */\n detectedAt: string;\n}\n\nfunction isObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\nfunction parseInbound(raw: string): CdpInboundMessage | null {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return null;\n }\n if (!isObject(parsed)) return null;\n const message: CdpInboundMessage = {};\n if (typeof parsed.id === 'number') message.id = parsed.id;\n if (typeof parsed.method === 'string') message.method = parsed.method;\n if ('params' in parsed) message.params = parsed.params;\n if ('result' in parsed) message.result = parsed.result;\n if (isObject(parsed.error) && typeof parsed.error.message === 'string') {\n message.error = { message: parsed.error.message };\n }\n return message;\n}\n\nconst PHASE_1_EVENTS: readonly CdpEventName[] = [\n 'Runtime.consoleAPICalled',\n 'Network.requestWillBeSent',\n 'Network.responseReceived',\n];\n\n/**\n * Ring buffer size for `Runtime.exceptionThrown`.\n *\n * Exceptions are rarer than console messages but each is heavier (stack\n * trace). 50 is generous enough to cover a crash scenario while keeping\n * memory bounded.\n *\n * **Lifecycle note**: the exception buffer intentionally survives `replaced` /\n * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target\n * transitions. Rationale: an exception fired just before a crash is exactly\n * the signal we want to preserve for root-cause analysis. The buffer\n * represents \"exceptions seen in this MCP session\", not \"exceptions in the\n * current page\".\n */\nconst EXCEPTION_BUFFER_SIZE = 50;\n\nexport interface ChiiCdpConnectionOptions {\n /** Base URL of the local Chii relay HTTP/WS server, e.g. `http://127.0.0.1:9100`. */\n relayBaseUrl: string;\n /** Per-domain ring buffer size. */\n bufferSize?: number;\n /**\n * Default per-command timeout in milliseconds.\n * Override via env `AIT_CDP_COMMAND_TIMEOUT_MS`.\n * Defaults to 30 000 ms (30s).\n */\n commandTimeoutMs?: number;\n /**\n * Hex-encoded TOTP secret (the SECRET, never a code). When set, each client WS\n * (re)connect mints a fresh `at=` code so it lands inside the relay's 90s\n * acceptance window. Leave undefined when the relay has TOTP disabled.\n * SECRET-HANDLING: stored privately, never logged; the minted code rides only\n * in the WS URL query.\n */\n totpSecret?: string;\n}\n\n/** Default per-command timeout if neither option nor env var is set. */\nconst DEFAULT_COMMAND_TIMEOUT_MS = 30_000;\n\n/**\n * Production CDP connection. Polls the relay for the first attached target,\n * opens a client websocket to it, enables Phase 1 domains, and buffers events.\n */\nexport class ChiiCdpConnection implements CdpConnection {\n /** Authoritative connection kind (issue #348) — relay-backed. */\n readonly kind = 'relay' as const;\n\n private readonly relayBaseUrl: string;\n private readonly bufferSize: number;\n private readonly commandTimeoutMs: number;\n private readonly totpSecret: string | undefined;\n private readonly emitter = new EventEmitter();\n private readonly buffers = new Map<CdpEventName, unknown[]>();\n private readonly targets = new Map<string, CdpTarget>();\n\n private ws: WebSocket | null = null;\n private connectionState: 'idle' | 'connected' | 'disconnected' = 'idle';\n private nextCommandId = 1;\n /**\n * The single active target id under the single-attach model.\n * Updated by `refreshTargets()` whenever a non-null target is present.\n * Used to detect a new (different) target attach and evict the previous one.\n */\n private activeTargetId: string | null = null;\n /** In-flight enableDomains() promise — concurrent callers share it. */\n private enablingPromise: Promise<void> | null = null;\n /** Pending request→response commands keyed by CDP message id. */\n private readonly pending = new Map<\n number,\n { resolve: (result: unknown) => void; reject: (err: Error) => void }\n >();\n\n /**\n * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,\n * or `null` if no crash has been detected since the last `enableDomains()`.\n */\n private lastCrashDetectedAt: number | null = null;\n\n /**\n * Per-target last-seen timestamp (ms since epoch). Updated on any inbound\n * CDP message carrying data from a target. Keyed by target id.\n */\n private readonly targetLastSeenAt = new Map<string, number>();\n\n /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */\n private heartbeatHandle: ReturnType<typeof setInterval> | null = null;\n\n /** Lifecycle event listeners (crash / destroyed / detached). */\n private readonly lifecycleListeners: Array<(event: TargetLifecycleEvent) => void> = [];\n\n constructor(options: ChiiCdpConnectionOptions) {\n this.relayBaseUrl = options.relayBaseUrl.replace(/\\/$/, '');\n this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;\n this.totpSecret = options.totpSecret;\n const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS\n ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS)\n : undefined;\n this.commandTimeoutMs =\n (envMs !== undefined && Number.isFinite(envMs) && envMs > 0 ? envMs : undefined) ??\n options.commandTimeoutMs ??\n DEFAULT_COMMAND_TIMEOUT_MS;\n for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);\n // Exception buffer initialized separately — its per-event size cap\n // (EXCEPTION_BUFFER_SIZE=50) is enforced in handleMessage below.\n this.buffers.set('Runtime.exceptionThrown', []);\n // EventEmitter caps listeners at 10 by default; the tool layer may add\n // several short-lived subscriptions, so lift the cap.\n this.emitter.setMaxListeners(0);\n }\n\n /** Refresh the attached-target list from the relay's `GET /targets`. */\n async refreshTargets(): Promise<CdpTarget[]> {\n // When TOTP is active, append a freshly-minted code as `?at=<code>` so the\n // relay's /targets gate (issue #474) accepts this daemon poll. Mirrors the\n // existing /client WS pattern (~line 412). `generateTotp` defaults `when` to\n // Date.now() — rely on that default; never hand-compute the time.\n // SECRET-HANDLING: never log `code` or `this.totpSecret`; the code rides only\n // in the at= param.\n let targetsUrl = `${this.relayBaseUrl}/targets`;\n if (this.totpSecret) {\n const code = generateTotp(this.totpSecret);\n targetsUrl += `?at=${encodeURIComponent(code)}`;\n }\n const res = await fetch(targetsUrl);\n if (!res.ok) {\n throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);\n }\n const body: unknown = await res.json();\n const list = isObject(body) && Array.isArray(body.targets) ? body.targets : [];\n\n // Single-attach model: find the \"newest\" target id from the relay response.\n // The relay may return multiple targets if the previous session did not cleanly\n // detach. We keep only the last entry (last-attach wins) and evict the previous\n // active target if it differs.\n let newestTargetId: string | null = null;\n for (const item of list) {\n if (!isObject(item) || typeof item.id !== 'string') continue;\n newestTargetId = item.id; // last wins\n }\n\n // Evict previous active target when a genuinely new targetId arrives.\n if (\n newestTargetId !== null &&\n this.activeTargetId !== null &&\n newestTargetId !== this.activeTargetId\n ) {\n const prevId = this.activeTargetId;\n // SECRET-HANDLING: prevTargetId is a Chii internal ID (not a secret) but\n // keep it short — no URL or credentials logged here.\n logInfo('page.detached', { prevTargetId: prevId });\n this.evictTarget(prevId);\n }\n\n // Rebuild the targets map with at most the single newest target.\n this.targets.clear();\n for (const item of list) {\n if (!isObject(item) || typeof item.id !== 'string') continue;\n // Single-attach model: only register the newest target.\n if (item.id !== newestTargetId) continue;\n this.targets.set(item.id, {\n id: item.id,\n title: typeof item.title === 'string' ? item.title : '',\n url: typeof item.url === 'string' ? item.url : '',\n });\n }\n\n if (newestTargetId !== null) {\n this.activeTargetId = newestTargetId;\n } else {\n this.activeTargetId = null;\n }\n\n const result = [...this.targets.values()];\n\n // Emit 'target:attached' for every newly-seen target so waitForFirstTarget()\n // can race against the next refreshTargets() polling round.\n if (newestTargetId !== null) {\n this.emitter.emit('target:attached', result);\n }\n\n return result;\n }\n\n listTargets(): CdpTarget[] {\n return [...this.targets.values()];\n }\n\n /**\n * Waits until at least one target matching `filterFn` is attached, then\n * resolves with the full target list at that moment.\n *\n * Resolution happens on whichever comes first:\n * (a) a `'target:attached'` event from `refreshTargets()` (triggered by\n * the /targets poll finding a new target), OR\n * (b) a `'target:attached'` event from `handleMessage()` (triggered by\n * the first inbound CDP message from a target — confirms the relay\n * websocket has data from the phone, not just a target entry in the map).\n *\n * This dual-signal approach eliminates the polling race that previously\n * caused `wait_for_attach` to resolve before the first CDP message arrived.\n *\n * Falls back to checking `listTargets()` every `pollIntervalMs` in case the\n * EventEmitter is missed (defensive belt-and-suspenders).\n *\n * @param filterFn - Predicate that the returned targets must satisfy.\n * @param timeoutMs - Reject after this many ms (default 90 000).\n * @param pollIntervalMs - Fallback poll interval (default 500ms).\n */\n waitForFirstTarget(\n filterFn: (targets: CdpTarget[]) => boolean,\n timeoutMs = 90_000,\n pollIntervalMs = 500,\n ): Promise<CdpTarget[]> {\n // Fast path: already attached.\n const current = this.listTargets();\n if (filterFn(current)) return Promise.resolve(current);\n\n return new Promise<CdpTarget[]>((resolve, reject) => {\n let settled = false;\n let pollHandle: ReturnType<typeof setInterval> | null = null;\n\n const settle = (targets: CdpTarget[]): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timeoutHandle);\n if (pollHandle !== null) {\n clearInterval(pollHandle);\n pollHandle = null;\n }\n this.emitter.off('target:attached', onAttach);\n resolve(targets);\n };\n\n const onAttach = (targets: CdpTarget[]): void => {\n if (filterFn(targets)) settle(targets);\n };\n\n const timeoutHandle = setTimeout(() => {\n if (settled) return;\n settled = true;\n if (pollHandle !== null) {\n clearInterval(pollHandle);\n pollHandle = null;\n }\n this.emitter.off('target:attached', onAttach);\n reject(\n new Error(\n `waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`,\n ),\n );\n }, timeoutMs);\n\n // Primary: event-driven path.\n this.emitter.on('target:attached', onAttach);\n\n // Fallback: polling path — also calls refreshTargets() to keep the in-memory\n // target map up-to-date. This ensures the polling path works even without\n // a live WebSocket (pre-enableDomains) and catches targets that appear\n // between 'target:attached' events.\n pollHandle = setInterval(() => {\n // Refresh from relay, then check. Errors are ignored — we keep polling.\n this.refreshTargets().then(\n (targets) => {\n if (filterFn(targets)) settle(targets);\n },\n () => {\n // Relay temporarily unreachable — keep polling.\n },\n );\n }, pollIntervalMs);\n });\n }\n\n /**\n * Timestamp (ms since epoch) of the most recent crash/destroy/detach event\n * detected since the last `enableDomains()` call, or `null` if none.\n */\n getLastCrashDetectedAt(): number | null {\n return this.lastCrashDetectedAt;\n }\n\n /**\n * Last-seen timestamp (ms since epoch) for a given target id, or `null` if\n * the target is unknown / no message has been received from it yet.\n */\n getTargetLastSeenAt(targetId: string): number | null {\n return this.targetLastSeenAt.get(targetId) ?? null;\n }\n\n /** Subscribe to target lifecycle events (crash / destroyed / detached). */\n onLifecycle(listener: (event: TargetLifecycleEvent) => void): () => void {\n this.lifecycleListeners.push(listener);\n return () => {\n const idx = this.lifecycleListeners.indexOf(listener);\n if (idx !== -1) this.lifecycleListeners.splice(idx, 1);\n };\n }\n\n /**\n * Connect a client websocket to the first attached target and enable Phase 1\n * domains. Resolves once the socket is open and enable commands are sent.\n */\n async enableDomains(): Promise<void> {\n if (this.ws && this.ws.readyState === WebSocket.OPEN) return;\n // If a connect attempt is already in-flight, await it rather than racing\n // to open a second websocket that would overwrite `this.ws` and leak the first.\n if (this.enablingPromise) return this.enablingPromise;\n this.enablingPromise = this._doEnableDomains().finally(() => {\n this.enablingPromise = null;\n });\n return this.enablingPromise;\n }\n\n private async _doEnableDomains(): Promise<void> {\n const targets = await this.refreshTargets();\n const target = targets[0];\n if (!target) {\n throw new Error('No mini-app page attached to the Chii relay yet.');\n }\n\n const wsBase = this.relayBaseUrl.replace(/^http/, 'ws');\n const clientId = `devtools-mcp-${Date.now()}`;\n let clientUrl = `${wsBase}/client/${clientId}?target=${encodeURIComponent(target.id)}`;\n // Append a freshly-minted TOTP code so the relay's WS upgrade gate\n // (chii-relay.ts verifyAuth) accepts this /client upgrade. Minted per-connect\n // so reconnects stay inside the 90s acceptance window. generateTotp defaults\n // `when` to Date.now() — rely on that default; never hand-compute the time.\n // SECRET-HANDLING: never log `code` or `this.totpSecret`; the code rides only\n // in the URL query.\n if (this.totpSecret) {\n const code = generateTotp(this.totpSecret);\n clientUrl += `&at=${encodeURIComponent(code)}`;\n }\n const ws = new WebSocket(clientUrl);\n this.ws = ws;\n\n await new Promise<void>((resolve, reject) => {\n ws.once('open', () => resolve());\n ws.once('error', (err: Error) => reject(err));\n // Issue #478: the relay rejects auth with accept-then-close (4401)\n // instead of a raw 401 destroy, so a rejected dial no longer surfaces\n // as an 'error' event. 'open' always precedes the close frame, making\n // this reject a settled-promise no-op in practice — kept as a defensive\n // boundary (and for any relay that closes before open). The post-open\n // 4401 is recognised by the persistent close handler below.\n ws.once('close', (code: number) => {\n if (code === RELAY_AUTH_REJECT_CLOSE_CODE) {\n reject(\n new Error(\n 'relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다.',\n ),\n );\n }\n });\n });\n\n // Reset crash state when a new connection is established.\n this.lastCrashDetectedAt = null;\n this.targetLastSeenAt.clear();\n // activeTargetId is already set by refreshTargets() above; don't reset here.\n\n this.connectionState = 'connected';\n ws.on('message', (data: WebSocket.RawData) => this.handleMessage(data.toString()));\n // Issue #478: close 4401 is the relay's named TOTP rejection\n // (accept-then-close) — name it as an auth failure instead of a generic\n // drop. #439's per-connect fresh code mint means this should not happen in\n // practice; defensive alignment with the relay contract.\n ws.on('close', (code: number) =>\n this.handleDisconnect(\n code === RELAY_AUTH_REJECT_CLOSE_CODE\n ? 'relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)'\n : 'relay WebSocket 연결이 끊겼습니다',\n ),\n );\n ws.on('error', (err: Error) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));\n\n this.sendFireAndForget('Runtime.enable');\n this.sendFireAndForget('Network.enable');\n // DOM/Page domains back the Phase 2 command tools; Chii answers their\n // request→response commands once enabled.\n this.sendFireAndForget('DOM.enable');\n this.sendFireAndForget('Page.enable');\n // Subscribe to page-level crash and target lifecycle events.\n // Inspector.targetCrashed fires when a page OOM/JS-crash/native-bridge crash.\n // Target.setDiscoverTargets enables Target.targetDestroyed + Target.detachedFromTarget.\n this.sendFireAndForget('Inspector.enable');\n this.sendFireAndForget('Target.setDiscoverTargets', { discover: true });\n\n // Optional heartbeat: env AIT_CDP_HEARTBEAT_MS=N enables a ping loop.\n this.startHeartbeat(target.id);\n }\n\n /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */\n private sendFireAndForget(method: string, params: Record<string, unknown> = {}): void {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;\n const id = this.nextCommandId++;\n this.ws.send(JSON.stringify({ id, method, params }));\n }\n\n /**\n * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP\n * error frame or when no websocket is open (no page attached yet).\n */\n send<M extends CdpCommandName>(\n method: M,\n params?: CdpCommandMap[M]['params'],\n ): Promise<CdpCommandMap[M]['result']> {\n return this.sendCommand(method, (params ?? {}) as Record<string, unknown>) as Promise<\n CdpCommandMap[M]['result']\n >;\n }\n\n /**\n * Issue an arbitrary request→response command over the relay and resolve with\n * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3\n * `AIT.*` methods, forwarded over the same Chii channel) build on this.\n *\n * Rejects immediately if the connection is disconnected (fail-fast — no\n * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to\n * reattach.\n *\n * Times out after `commandTimeoutMs` (default 30s, env\n * `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up\n * and the promise rejects with a descriptive Korean error.\n */\n sendCommand(method: string, params: Record<string, unknown> = {}): Promise<unknown> {\n // Fail-fast: connection already known to be dead — don't write into a dead socket.\n if (this.connectionState === 'disconnected') {\n return Promise.reject(\n new Error(\n `relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`,\n ),\n );\n }\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n return Promise.reject(\n new Error('No mini-app page attached to the Chii relay yet. Call enableDomains() first.'),\n );\n }\n const id = this.nextCommandId++;\n const ws = this.ws;\n const timeoutMs = this.commandTimeoutMs;\n return new Promise<unknown>((resolve, reject) => {\n const handle = setTimeout(() => {\n this.pending.delete(id);\n reject(\n new Error(\n `CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). ` +\n '폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. ' +\n 'list_pages로 attach 상태를 확인하세요.',\n ),\n );\n }, timeoutMs);\n this.pending.set(id, {\n resolve: (v) => {\n clearTimeout(handle);\n resolve(v);\n },\n reject: (e) => {\n clearTimeout(handle);\n reject(e);\n },\n });\n ws.send(JSON.stringify({ id, method, params }));\n });\n }\n\n /**\n * Called on WebSocket `close` or `error` after a successful connection.\n * Rejects all pending commands and marks the connection as disconnected so\n * subsequent `sendCommand` calls fail fast (no auto-reconnect).\n */\n private handleDisconnect(reason: string): void {\n if (this.connectionState === 'disconnected') return; // already handled\n this.connectionState = 'disconnected';\n this.ws = null;\n this.stopHeartbeat();\n const err = new Error(\n `${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`,\n );\n for (const waiter of this.pending.values()) {\n waiter.reject(err);\n }\n this.pending.clear();\n }\n\n /**\n * Evict a previously active target under the single-attach model.\n * Rejects pending commands with a 'replaced-by-new-attach' reason and emits\n * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific\n * targetId. The caller is responsible for rebuilding the targets map afterwards.\n *\n * The error message uses 'replaced-by-new-attach' so test assertions can match it.\n */\n private evictTarget(targetId: string): void {\n const detectedAt = new Date().toISOString();\n this.targets.delete(targetId);\n this.targetLastSeenAt.delete(targetId);\n\n const err = new Error(\n `[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). ` +\n 'list_pages로 현재 attach 상태를 확인하세요.',\n );\n for (const waiter of this.pending.values()) {\n waiter.reject(err);\n }\n this.pending.clear();\n\n const event: TargetLifecycleEvent = { kind: 'replaced', targetId, detectedAt };\n for (const listener of this.lifecycleListeners) {\n try {\n listener(event);\n } catch {\n // Listeners must not crash the connection.\n }\n }\n }\n\n /**\n * Handle a page-level crash or target destruction event.\n * Removes the target from the in-memory map, rejects all pending commands,\n * and emits a lifecycle event.\n *\n * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'\n * @param targetId - The target ID from the event params (may be null for\n * Inspector.targetCrashed which has no targetId in the params).\n */\n private handleTargetGone(kind: TargetLifecycleEvent['kind'], targetId: string | null): void {\n const detectedAt = new Date().toISOString();\n this.lastCrashDetectedAt = Date.now();\n\n // Remove matching target(s) from the in-memory map.\n if (targetId !== null) {\n this.targets.delete(targetId);\n this.targetLastSeenAt.delete(targetId);\n // Also clear activeTargetId when the active target is gone.\n if (this.activeTargetId === targetId) {\n this.activeTargetId = null;\n }\n } else {\n // Inspector.targetCrashed carries no targetId — clear all targets.\n this.targets.clear();\n this.targetLastSeenAt.clear();\n this.activeTargetId = null;\n }\n\n // Reject pending commands with a descriptive Korean error.\n const label =\n kind === 'crashed'\n ? 'page crash (Inspector.targetCrashed)'\n : kind === 'destroyed'\n ? 'target 종료 (Target.targetDestroyed)'\n : 'target detach (Target.detachedFromTarget)';\n const err = new Error(\n `[ait-debug] ${label} 감지됨 — relay에서 제거됐습니다. ` +\n '새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).',\n );\n for (const waiter of this.pending.values()) {\n waiter.reject(err);\n }\n this.pending.clear();\n\n // Notify lifecycle listeners.\n const event: TargetLifecycleEvent = { kind, targetId, detectedAt };\n for (const listener of this.lifecycleListeners) {\n try {\n listener(event);\n } catch {\n // Listeners must not crash the connection.\n }\n }\n }\n\n /**\n * Start the optional CDP heartbeat loop.\n *\n * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval\n * we send `Runtime.evaluate({expression: '1'})` to each active target. If\n * the command times out (2 s hard deadline) or errors, we treat the target\n * as dead and call `handleTargetGone`.\n *\n * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws\n * even when the phone app has crashed, so the ws-level disconnect (#252) won't\n * fire. The heartbeat catches this gap.\n *\n * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.\n */\n private startHeartbeat(initialTargetId: string): void {\n this.stopHeartbeat(); // clear any previous interval\n\n const envMs = process.env.AIT_CDP_HEARTBEAT_MS\n ? Number(process.env.AIT_CDP_HEARTBEAT_MS)\n : undefined;\n if (envMs === undefined || !Number.isFinite(envMs) || envMs <= 0) return;\n\n const PING_TIMEOUT_MS = 2_000;\n\n this.heartbeatHandle = setInterval(() => {\n // Take a snapshot of current targets to avoid mutation during iteration.\n const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];\n for (const targetId of targetIds) {\n // Issue a lightweight eval with a 2 s deadline.\n const pingPromise = this.sendCommand('Runtime.evaluate', {\n expression: '1',\n returnByValue: true,\n timeout: PING_TIMEOUT_MS,\n });\n const timeoutPromise = new Promise<never>((_, reject) =>\n setTimeout(\n () => reject(new Error('heartbeat timeout')),\n PING_TIMEOUT_MS + 500, // slightly longer than the CDP timeout\n ),\n );\n Promise.race([pingPromise, timeoutPromise]).catch(() => {\n // Ping failed: mark target as dead if it still exists in the map.\n if (this.targets.has(targetId)) {\n this.handleTargetGone('destroyed', targetId);\n }\n });\n }\n }, envMs) as unknown as ReturnType<typeof setInterval>;\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatHandle !== null) {\n clearInterval(this.heartbeatHandle);\n this.heartbeatHandle = null;\n }\n }\n\n private handleMessage(raw: string): void {\n const message = parseInbound(raw);\n if (!message) return;\n\n // Command response (has an id matching a pending request).\n if (typeof message.id === 'number' && this.pending.has(message.id)) {\n const waiter = this.pending.get(message.id);\n this.pending.delete(message.id);\n if (waiter) {\n if (message.error) waiter.reject(new Error(message.error.message));\n else waiter.resolve(message.result);\n }\n return;\n }\n\n // Any inbound message implies the connection is active — update lastSeenAt\n // for whichever target we currently know about (single-target model).\n // Also emit 'target:attached' on the first inbound message from a target\n // (targetLastSeenAt unset) so waitForFirstTarget() resolves on first CDP\n // message, not just on the next /targets poll.\n const now = Date.now();\n let firstMessageSeen = false;\n for (const targetId of this.targets.keys()) {\n if (!this.targetLastSeenAt.has(targetId)) {\n firstMessageSeen = true;\n }\n this.targetLastSeenAt.set(targetId, now);\n }\n if (firstMessageSeen && this.targets.size > 0) {\n this.emitter.emit('target:attached', [...this.targets.values()]);\n }\n\n if (typeof message.method !== 'string') return;\n\n // --- Target lifecycle events ---\n\n // Inspector.targetCrashed: page OOM / JS exception / native bridge crash.\n // Params are usually empty; no targetId field in the event.\n if (message.method === 'Inspector.targetCrashed') {\n this.handleTargetGone('crashed', null);\n return;\n }\n\n // Target.targetDestroyed: params = { targetId: string }\n if (message.method === 'Target.targetDestroyed') {\n const targetId =\n isObject(message.params) && typeof message.params.targetId === 'string'\n ? message.params.targetId\n : null;\n this.handleTargetGone('destroyed', targetId);\n return;\n }\n\n // Target.detachedFromTarget: params = { sessionId, targetId? }\n if (message.method === 'Target.detachedFromTarget') {\n const targetId =\n isObject(message.params) && typeof message.params.targetId === 'string'\n ? message.params.targetId\n : null;\n this.handleTargetGone('detached', targetId);\n return;\n }\n\n // --- Phase 1 event stream (buffered ring-buffer) ---\n if (!this.buffers.has(message.method as CdpEventName)) return;\n const event = message.method as CdpEventName;\n const buffer = this.buffers.get(event);\n if (!buffer) return;\n buffer.push(message.params);\n // Runtime.exceptionThrown uses a dedicated smaller cap (50); all other\n // Phase 1 events use the default bufferSize (500).\n const cap = event === 'Runtime.exceptionThrown' ? EXCEPTION_BUFFER_SIZE : this.bufferSize;\n if (buffer.length > cap) buffer.shift();\n this.emitter.emit(event, message.params);\n }\n\n getBufferedEvents<E extends CdpEventName>(event: E): ReadonlyArray<CdpEventMap[E]> {\n const buffer = this.buffers.get(event);\n return (buffer ?? []) as ReadonlyArray<CdpEventMap[E]>;\n }\n\n on<E extends CdpEventName>(event: E, listener: (payload: CdpEventMap[E]) => void): () => void {\n this.emitter.on(event, listener as (payload: unknown) => void);\n return () => this.emitter.off(event, listener as (payload: unknown) => void);\n }\n\n /** Close the relay client websocket and reject any in-flight commands. */\n close(): void {\n const ws = this.ws;\n this.stopHeartbeat();\n // handleDisconnect clears this.ws and pending; call it first so the 'close'\n // event from ws.close() below is a no-op (already disconnected).\n this.handleDisconnect('Chii relay connection closed');\n ws?.close();\n }\n}\n","/**\n * Orchestrator: runs a list of test files sequentially over a CDP relay.\n *\n * Each file goes through: bundle → inject → run → collect.\n * This is the transport layer: it does NOT integrate with Vitest's pool or the\n * MCP surface. The Vitest custom pool (`pool.ts`) and the `run_tests` MCP tool\n * are separate callers that build on this orchestrator.\n *\n * Single-attach constraint: only one page is active at a time. Files run\n * sequentially; parallel execution across targets is out of scope.\n *\n * The per-file timeout is inherited from `injectAndRunBundle` (default 60 s).\n * For suites that exceed it, split the file into smaller pieces.\n *\n * SECRET-HANDLING: file paths are surfaced in reports; relay URLs are not.\n */\n\nimport type { CdpConnection, ConsoleApiCalledEvent } from '../mcp/cdp-connection.js';\nimport { isRelayDisconnectMessage } from '../mcp/chii-connection.js';\nimport { type BundleOptions, bundleTestFile } from './bundle.js';\nimport { type AitCaptureLine, parseCaptureLines } from './capture.js';\nimport { injectAndRunBundle } from './rpc.js';\nimport type { RunReport, TestResult } from './runtime.js';\n\n/** Per-file result in the aggregate `RunReport`. */\nexport interface FileResult {\n /** Absolute or relative path to the test file. */\n file: string;\n /** Full run report for this file, or an error if bundling/injection failed. */\n result: RunReport | { error: string };\n}\n\n/** Aggregate report returned by `runTestFilesOverRelay`. */\nexport interface RelayRunReport {\n /** ISO timestamp of when the run started. */\n startedAt: string;\n /** Total elapsed wall-clock milliseconds. */\n duration: number;\n /** Per-file results in execution order. */\n files: FileResult[];\n /** Flattened totals across all files. */\n totals: {\n passed: number;\n failed: number;\n skipped: number;\n total: number;\n };\n /**\n * `__AIT_CAPTURE__` lines harvested from the page console during the run\n * (additive field, devtools#696). Empty unless `collectCaptures` was set.\n * Each entry is opaque (`{ category, json }`) — devtools does not interpret\n * the record shape, only forwards it for downstream 2.x↔3.0 diffing.\n *\n * SECRET-HANDLING: only lines matching the `__AIT_CAPTURE__ ` allowlist\n * prefix reach here; relay/wss/scheme noise lines are dropped in\n * `parseCaptureLines`.\n */\n captures: AitCaptureLine[];\n}\n\n/** Options for `runTestFilesOverRelay`. */\nexport interface RelayRunOptions {\n /**\n * Options forwarded to `bundleTestFile` for each file.\n */\n bundleOptions?: BundleOptions;\n /**\n * Per-file evaluate timeout in milliseconds. Defaults to 60 000 (rpc.ts\n * DEFAULT_TIMEOUT_MS). Increase for long-running suites or split the file.\n */\n timeoutMs?: number;\n /**\n * When `true`, registers a live `Runtime.consoleAPICalled` listener for the\n * duration of the run and harvests `__AIT_CAPTURE__` lines into\n * `RelayRunReport.captures`. Defaults to **false** so the build-only\n * eval/e2e path (and the Vitest pool) pay no listener/state overhead —\n * `captures` is then an empty array.\n *\n * The listener is registered just BEFORE the first file runs and removed in a\n * `finally` so it never accumulates across runs (no ring-buffer drain — the\n * default 500-entry buffer would silently drop lines via `shift`).\n */\n collectCaptures?: boolean;\n}\n\n/**\n * Sentinel string embedded in the error message by `injectAndRunBundle` when\n * the per-file evaluate race hits the timeout. Used by the retry guard so only\n * genuine timeouts get a second attempt — not bundle errors or parse failures.\n *\n * Exported for unit tests that assert the retry path is taken.\n */\nexport const EVALUATE_TIMEOUT_MARKER = 'rpc: evaluate timed out after';\n\n/**\n * Runs all `files` sequentially over the given CDP `connection`.\n *\n * For each file:\n * 1. Bundle with esbuild (includes SDK shim + runtime).\n * 2. Inject into the attached page via `Runtime.evaluate`.\n * 3. Await the `RunReport` JSON response.\n * 4. Accumulate results.\n *\n * Returns a `RelayRunReport` with per-file results and flattened totals.\n *\n * This function does NOT open or manage the relay connection — the caller\n * is responsible for attaching and closing it.\n *\n * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here\n * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.\n *\n * @param connection - Active CDP connection (relay or local kind).\n * @param files - Absolute paths to test files, run in order.\n * @param opts - Optional per-run overrides.\n */\nexport async function runTestFilesOverRelay(\n connection: CdpConnection,\n files: string[],\n opts?: RelayRunOptions,\n): Promise<RelayRunReport> {\n const wallStart = Date.now();\n const startedAt = new Date(wallStart).toISOString();\n const fileResults: FileResult[] = [];\n\n // Enable CDP domains ONCE up front. Without this the relay connection has not\n // opened its client websocket, so the very first `Runtime.evaluate` (in\n // `injectAndRunBundle`) explodes AND `Runtime.consoleAPICalled` never streams\n // — the console capture harvest would be structurally 0. `enableDomains()` is\n // idempotent (see chii-connection: early-returns when the ws is already OPEN,\n // and shares an in-flight promise), so calling it here is safe even when a\n // caller (the MCP `run_tests` path) already enabled it.\n //\n // Failure here (e.g. no target attached yet) must NOT throw the whole run: the\n // per-file inject below produces a structured error result instead. We warn on\n // stderr (secret-free) and continue. SECRET-HANDLING: the message names the\n // failure only — no relay/wss URL.\n let domainsEnabled = false;\n try {\n await connection.enableDomains();\n domainsEnabled = true;\n } catch (e) {\n process.stderr.write(\n `relay-worker: enableDomains() failed before run — console capture may be empty (${\n e instanceof Error ? e.message : String(e)\n })\\n`,\n );\n }\n\n // Live console capture (#696): when requested, accumulate every\n // `Runtime.consoleAPICalled` event into a local array via a LIVE listener\n // registered just before the run. We deliberately do NOT drain\n // `getBufferedEvents` after the fact — that ring buffer caps at 500 and\n // `shift()`s older entries, so a chatty run would silently lose capture lines.\n // The listener is removed in `finally` so it never bleeds into the next run.\n //\n // Gate the listener on `domainsEnabled`: if enableDomains() soft-failed, the\n // client ws never opened so `Runtime.consoleAPICalled` cannot stream —\n // registering would only add a dead listener that never fires. (The `finally`\n // unsubscribe is an undefined no-op in that case, so this stays safe.)\n const collectCaptures = opts?.collectCaptures === true;\n const liveConsole: ConsoleApiCalledEvent[] = [];\n let unsubscribeConsole: (() => void) | undefined;\n if (collectCaptures && domainsEnabled) {\n unsubscribeConsole = connection.on('Runtime.consoleAPICalled', (event) => {\n liveConsole.push(event);\n });\n }\n\n // Tracks whether the PREVIOUS file's final result was a WS-dead-class error\n // (relay disconnect). When true, we attempt one `enableDomains()` reconnect\n // before processing the NEXT file — a single dropped socket (e.g. Cloudflare\n // edge idle-drop mid-evaluate) should not cascade into every remaining file\n // failing instantly (#731). `enableDomains()` is idempotent (early-returns\n // when the ws is already OPEN, shares an in-flight promise), so calling it\n // speculatively here is safe.\n let pendingReconnectCheck = false;\n\n /**\n * Attempts one `enableDomains()` reconnect. Logs the attempt and outcome to\n * stderr. Never throws — a failed reconnect just means the caller proceeds\n * as today (each remaining file / the retry will fail-fast on the still-dead\n * socket).\n *\n * `context` selects the fixed log literal: 'next-file' for the between-files\n * case (a file's FINAL result was a WS-dead-class error — the primary #731\n * scenario), 'retry-precheck' for the defensive reconnect before retrying a\n * timed-out file (cheap no-op via `enableDomains()`'s idempotency when the\n * socket never actually died).\n *\n * SECRET-HANDLING: fixed literals only — no relay wss URL, TOTP code, or\n * tunnel host.\n */\n const attemptReconnect = async (context: 'next-file' | 'retry-precheck'): Promise<void> => {\n process.stderr.write(\n context === 'next-file'\n ? 'relay-worker: relay connection lost — attempting reconnect before next file\\n'\n : 'relay-worker: evaluate timed out — attempting reconnect before retry\\n',\n );\n try {\n await connection.enableDomains();\n process.stderr.write('relay-worker: reconnect succeeded\\n');\n } catch (e) {\n process.stderr.write(\n `relay-worker: reconnect failed (${\n e instanceof Error ? e.message : String(e)\n }) — continuing, remaining files may fail\\n`,\n );\n }\n };\n\n try {\n for (const file of files) {\n if (pendingReconnectCheck) {\n await attemptReconnect('next-file');\n pendingReconnectCheck = false;\n }\n\n let fileEntry: FileResult;\n try {\n const { code } = await bundleTestFile(file, opts?.bundleOptions);\n\n /**\n * Runs one evaluate attempt and returns a FileResult, or `null` when the\n * result is a genuine timeout and the caller should retry.\n *\n * We need to distinguish:\n * - `rpcResult.ok = false` + timeout error → retry candidate (`return null`)\n * - `rpcResult.ok = false` + other error → final error, no retry\n * - `injectAndRunBundle` throws → CDP exceptionDetails OR a\n * relay-disconnect rejection from `connection.send()` (page engine\n * threw, or the ws died mid-evaluate); treated as a final\n * (non-retryable) error either way.\n *\n * The Promise.race timeout in rpc.ts RETURNS `{ok:false, error: '…'}` (it\n * does NOT throw/reject). Only genuine CDP `exceptionDetails` (or a dead\n * `connection.send()`) cause a throw. This distinction is what makes the\n * EVALUATE_TIMEOUT_MARKER gate below reachable — the timeout result\n * surfaces as `rpcResult.ok=false` with the marker string, not as a\n * caught exception.\n */\n const attempt = async (): Promise<FileResult | null> => {\n let rpcResult: Awaited<ReturnType<typeof injectAndRunBundle>>;\n try {\n rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);\n } catch (e) {\n // injectAndRunBundle throws for CDP exceptionDetails OR a relay ws\n // death mid-evaluate (connection.send() fail-fast rejection) —\n // either way this attempt is final (non-retryable). The\n // WS-dead-class check below (after `fileEntry` is assigned)\n // arranges the between-files reconnect (#731).\n return {\n file,\n result: { error: e instanceof Error ? e.message : String(e) },\n };\n }\n\n if (rpcResult.ok) {\n return { file, result: rpcResult.report };\n }\n\n // Timed-out evaluates get one retry; other errors are final.\n if (rpcResult.error.includes(EVALUATE_TIMEOUT_MARKER)) {\n return null; // signal \"retry\"\n }\n return { file, result: { error: rpcResult.error } };\n };\n\n const firstResult = await attempt();\n if (firstResult !== null) {\n fileEntry = firstResult;\n } else {\n // First attempt timed out — the retry-precheck case (#731): the\n // dead-air during a long timeout is exactly what let the Cloudflare\n // edge idle-drop the relay ws in the observed run (permissions:\n // attempt 1 timed out, the retry then hit an already-dead socket).\n // `enableDomains()` is idempotent (no-op when the ws is already\n // OPEN), so a defensive reconnect attempt here is safe even when\n // the socket never actually died — it only costs a wasted round\n // trip on the common \"genuinely just slow\" case, and it's the only\n // way to catch this class since `CdpConnection` does not expose a\n // public \"is the socket alive\" probe.\n await attemptReconnect('retry-precheck');\n // First attempt timed out — retry once. A transient native dialog\n // (camera picker, location permission sheet, GPS cold-fix) may have\n // cleared by now.\n process.stderr.write(`relay-worker: evaluate timed out for ${file} — retrying once\\n`);\n const retryResult = await attempt();\n if (retryResult !== null) {\n fileEntry = retryResult;\n } else {\n // Second timeout: build a final error entry.\n fileEntry = {\n file,\n result: {\n // This fallback must equal rpc.ts DEFAULT_TIMEOUT_MS (60_000)\n // so the displayed budget matches what was actually used when\n // the caller omitted opts.timeoutMs (#731).\n error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 60_000}ms (after retry)`,\n },\n };\n }\n }\n } catch (e) {\n // Capture bundle errors per-file so subsequent files still run.\n fileEntry = {\n file,\n result: { error: e instanceof Error ? e.message : String(e) },\n };\n }\n\n // If this file's FINAL result is a WS-dead-class error, arrange for a\n // reconnect attempt before the NEXT file (#731) — do not abort the loop.\n if ('error' in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) {\n pendingReconnectCheck = true;\n }\n\n fileResults.push(fileEntry);\n }\n } finally {\n // Always remove the live listener — leaking it would accumulate across runs\n // on a reused connection (the Vitest pool keeps one connection for the whole\n // run; the MCP daemon keeps one for the session).\n unsubscribeConsole?.();\n }\n\n // Convert the accumulated console events to `__AIT_CAPTURE__` lines. Each\n // event's args are rendered to a single line text (inlined console rendering —\n // no tools.ts import, to keep this module off the heavy MCP graph), then the\n // allowlist-prefix parser keeps only genuine capture lines.\n const captures = collectCaptures\n ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) })))\n : [];\n\n const totals = fileResults.reduce(\n (acc, { result }) => {\n if ('error' in result) {\n // Treat whole-file errors as a single failure.\n acc.failed += 1;\n acc.total += 1;\n } else {\n acc.passed += result.passed;\n acc.failed += result.failed;\n acc.skipped += result.skipped;\n acc.total += result.passed + result.failed + result.skipped;\n }\n return acc;\n },\n { passed: 0, failed: 0, skipped: 0, total: 0 },\n );\n\n return {\n startedAt,\n duration: Date.now() - wallStart,\n files: fileResults,\n totals,\n captures,\n };\n}\n\n/**\n * Renders one `Runtime.consoleAPICalled` event to a single line of text, the\n * same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-\n * joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,\n * which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)\n * onto the test-runner entry.\n *\n * SECRET-HANDLING: this only stringifies console args; the caller's\n * allowlist-prefix parser then discards everything that is not a genuine\n * `__AIT_CAPTURE__` line.\n */\nfunction renderConsoleLineText(event: ConsoleApiCalledEvent): string {\n return event.args\n .map((arg) => {\n if (arg.value !== undefined) {\n if (typeof arg.value === 'string') return arg.value;\n try {\n return JSON.stringify(arg.value);\n } catch {\n return String(arg.value);\n }\n }\n if (arg.description !== undefined) return arg.description;\n if (arg.className !== undefined) return arg.className;\n return arg.subtype ?? arg.type;\n })\n .join(' ');\n}\n\n/**\n * Flattens all test results from a `RelayRunReport` into a single array.\n * Files that errored during bundle/inject produce a synthetic failed entry.\n */\nexport function flattenResults(report: RelayRunReport): Array<TestResult & { file: string }> {\n const out: Array<TestResult & { file: string }> = [];\n for (const { file, result } of report.files) {\n if ('error' in result) {\n out.push({\n file,\n name: `<bundle/inject error>`,\n status: 'fail',\n duration: 0,\n error: result.error,\n });\n } else {\n for (const t of result.tests) {\n out.push({ ...t, file });\n }\n }\n }\n return out;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,+BAA+B;;;;;;AAO5C,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;ACLxC,MAAM,sBAAsB;;;;;;;;;;;;AAa5B,MAAa,2BAA2B,CACtC,mBACA,sBACD;;;;;AAMD,SAAgB,yBAAyB,SAA0B;AACjE,QAAO,yBAAyB,MAAM,WAAW,QAAQ,SAAS,OAAO,CAAC;;AA6B5E,SAAS,SAAS,OAAkD;AAClE,QAAO,OAAO,UAAU,YAAY,UAAU;;AAGhD,SAAS,aAAa,KAAuC;CAC3D,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;SAClB;AACN,SAAO;;AAET,KAAI,CAAC,SAAS,OAAO,CAAE,QAAO;CAC9B,MAAM,UAA6B,EAAE;AACrC,KAAI,OAAO,OAAO,OAAO,SAAU,SAAQ,KAAK,OAAO;AACvD,KAAI,OAAO,OAAO,WAAW,SAAU,SAAQ,SAAS,OAAO;AAC/D,KAAI,YAAY,OAAQ,SAAQ,SAAS,OAAO;AAChD,KAAI,YAAY,OAAQ,SAAQ,SAAS,OAAO;AAChD,KAAI,SAAS,OAAO,MAAM,IAAI,OAAO,OAAO,MAAM,YAAY,SAC5D,SAAQ,QAAQ,EAAE,SAAS,OAAO,MAAM,SAAS;AAEnD,QAAO;;AAGT,MAAM,iBAA0C;CAC9C;CACA;CACA;CACD;;;;;;;;;;;;;;;AAgBD,MAAM,wBAAwB;;AAwB9B,MAAM,6BAA6B;;;;;AAMnC,IAAa,oBAAb,MAAwD;;CAEtD,OAAgB;CAEhB;CACA;CACA;CACA;CACA,UAA2B,IAAI,cAAc;CAC7C,0BAA2B,IAAI,KAA8B;CAC7D,0BAA2B,IAAI,KAAwB;CAEvD,KAA+B;CAC/B,kBAAiE;CACjE,gBAAwB;;;;;;CAMxB,iBAAwC;;CAExC,kBAAgD;;CAEhD,0BAA2B,IAAI,KAG5B;;;;;CAMH,sBAA6C;;;;;CAM7C,mCAAoC,IAAI,KAAqB;;CAG7D,kBAAiE;;CAGjE,qBAAoF,EAAE;CAEtF,YAAY,SAAmC;AAC7C,OAAK,eAAe,QAAQ,aAAa,QAAQ,OAAO,GAAG;AAC3D,OAAK,aAAa,QAAQ,cAAc;AACxC,OAAK,aAAa,QAAQ;EAC1B,MAAM,QAAQ,QAAQ,IAAI,6BACtB,OAAO,QAAQ,IAAI,2BAA2B,GAC9C,KAAA;AACJ,OAAK,oBACF,UAAU,KAAA,KAAa,OAAO,SAAS,MAAM,IAAI,QAAQ,IAAI,QAAQ,KAAA,MACtE,QAAQ,oBACR;AACF,OAAK,MAAM,SAAS,eAAgB,MAAK,QAAQ,IAAI,OAAO,EAAE,CAAC;AAG/D,OAAK,QAAQ,IAAI,2BAA2B,EAAE,CAAC;AAG/C,OAAK,QAAQ,gBAAgB,EAAE;;;CAIjC,MAAM,iBAAuC;EAO3C,IAAI,aAAa,GAAG,KAAK,aAAa;AACtC,MAAI,KAAK,YAAY;GACnB,MAAM,OAAO,aAAa,KAAK,WAAW;AAC1C,iBAAc,OAAO,mBAAmB,KAAK;;EAE/C,MAAM,MAAM,MAAM,MAAM,WAAW;AACnC,MAAI,CAAC,IAAI,GACP,OAAM,IAAI,MAAM,qCAAqC,IAAI,OAAO,GAAG,IAAI,aAAa;EAEtF,MAAM,OAAgB,MAAM,IAAI,MAAM;EACtC,MAAM,OAAO,SAAS,KAAK,IAAI,MAAM,QAAQ,KAAK,QAAQ,GAAG,KAAK,UAAU,EAAE;EAM9E,IAAI,iBAAgC;AACpC,OAAK,MAAM,QAAQ,MAAM;AACvB,OAAI,CAAC,SAAS,KAAK,IAAI,OAAO,KAAK,OAAO,SAAU;AACpD,oBAAiB,KAAK;;AAIxB,MACE,mBAAmB,QACnB,KAAK,mBAAmB,QACxB,mBAAmB,KAAK,gBACxB;GACA,MAAM,SAAS,KAAK;AAGpB,WAAQ,iBAAiB,EAAE,cAAc,QAAQ,CAAC;AAClD,QAAK,YAAY,OAAO;;AAI1B,OAAK,QAAQ,OAAO;AACpB,OAAK,MAAM,QAAQ,MAAM;AACvB,OAAI,CAAC,SAAS,KAAK,IAAI,OAAO,KAAK,OAAO,SAAU;AAEpD,OAAI,KAAK,OAAO,eAAgB;AAChC,QAAK,QAAQ,IAAI,KAAK,IAAI;IACxB,IAAI,KAAK;IACT,OAAO,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ;IACrD,KAAK,OAAO,KAAK,QAAQ,WAAW,KAAK,MAAM;IAChD,CAAC;;AAGJ,MAAI,mBAAmB,KACrB,MAAK,iBAAiB;MAEtB,MAAK,iBAAiB;EAGxB,MAAM,SAAS,CAAC,GAAG,KAAK,QAAQ,QAAQ,CAAC;AAIzC,MAAI,mBAAmB,KACrB,MAAK,QAAQ,KAAK,mBAAmB,OAAO;AAG9C,SAAO;;CAGT,cAA2B;AACzB,SAAO,CAAC,GAAG,KAAK,QAAQ,QAAQ,CAAC;;;;;;;;;;;;;;;;;;;;;;;CAwBnC,mBACE,UACA,YAAY,KACZ,iBAAiB,KACK;EAEtB,MAAM,UAAU,KAAK,aAAa;AAClC,MAAI,SAAS,QAAQ,CAAE,QAAO,QAAQ,QAAQ,QAAQ;AAEtD,SAAO,IAAI,SAAsB,SAAS,WAAW;GACnD,IAAI,UAAU;GACd,IAAI,aAAoD;GAExD,MAAM,UAAU,YAA+B;AAC7C,QAAI,QAAS;AACb,cAAU;AACV,iBAAa,cAAc;AAC3B,QAAI,eAAe,MAAM;AACvB,mBAAc,WAAW;AACzB,kBAAa;;AAEf,SAAK,QAAQ,IAAI,mBAAmB,SAAS;AAC7C,YAAQ,QAAQ;;GAGlB,MAAM,YAAY,YAA+B;AAC/C,QAAI,SAAS,QAAQ,CAAE,QAAO,QAAQ;;GAGxC,MAAM,gBAAgB,iBAAiB;AACrC,QAAI,QAAS;AACb,cAAU;AACV,QAAI,eAAe,MAAM;AACvB,mBAAc,WAAW;AACzB,kBAAa;;AAEf,SAAK,QAAQ,IAAI,mBAAmB,SAAS;AAC7C,2BACE,IAAI,MACF,6BAA6B,UAAU,iCACxC,CACF;MACA,UAAU;AAGb,QAAK,QAAQ,GAAG,mBAAmB,SAAS;AAM5C,gBAAa,kBAAkB;AAE7B,SAAK,gBAAgB,CAAC,MACnB,YAAY;AACX,SAAI,SAAS,QAAQ,CAAE,QAAO,QAAQ;aAElC,GAGP;MACA,eAAe;IAClB;;;;;;CAOJ,yBAAwC;AACtC,SAAO,KAAK;;;;;;CAOd,oBAAoB,UAAiC;AACnD,SAAO,KAAK,iBAAiB,IAAI,SAAS,IAAI;;;CAIhD,YAAY,UAA6D;AACvE,OAAK,mBAAmB,KAAK,SAAS;AACtC,eAAa;GACX,MAAM,MAAM,KAAK,mBAAmB,QAAQ,SAAS;AACrD,OAAI,QAAQ,GAAI,MAAK,mBAAmB,OAAO,KAAK,EAAE;;;;;;;CAQ1D,MAAM,gBAA+B;AACnC,MAAI,KAAK,MAAM,KAAK,GAAG,eAAe,UAAU,KAAM;AAGtD,MAAI,KAAK,gBAAiB,QAAO,KAAK;AACtC,OAAK,kBAAkB,KAAK,kBAAkB,CAAC,cAAc;AAC3D,QAAK,kBAAkB;IACvB;AACF,SAAO,KAAK;;CAGd,MAAc,mBAAkC;EAE9C,MAAM,UADU,MAAM,KAAK,gBAAgB,EACpB;AACvB,MAAI,CAAC,OACH,OAAM,IAAI,MAAM,mDAAmD;EAKrE,IAAI,YAAY,GAFD,KAAK,aAAa,QAAQ,SAAS,KAAK,CAE7B,UADT,gBAAgB,KAAK,KAAK,GACE,UAAU,mBAAmB,OAAO,GAAG;AAOpF,MAAI,KAAK,YAAY;GACnB,MAAM,OAAO,aAAa,KAAK,WAAW;AAC1C,gBAAa,OAAO,mBAAmB,KAAK;;EAE9C,MAAM,KAAK,IAAI,UAAU,UAAU;AACnC,OAAK,KAAK;AAEV,QAAM,IAAI,SAAe,SAAS,WAAW;AAC3C,MAAG,KAAK,cAAc,SAAS,CAAC;AAChC,MAAG,KAAK,UAAU,QAAe,OAAO,IAAI,CAAC;AAO7C,MAAG,KAAK,UAAU,SAAiB;AACjC,QAAI,SAAA,KACF,wBACE,IAAI,MACF,4EACD,CACF;KAEH;IACF;AAGF,OAAK,sBAAsB;AAC3B,OAAK,iBAAiB,OAAO;AAG7B,OAAK,kBAAkB;AACvB,KAAG,GAAG,YAAY,SAA4B,KAAK,cAAc,KAAK,UAAU,CAAC,CAAC;AAKlF,KAAG,GAAG,UAAU,SACd,KAAK,iBACH,SAAA,OACI,gDACA,4BACL,CACF;AACD,KAAG,GAAG,UAAU,QAAe,KAAK,iBAAiB,uBAAuB,IAAI,UAAU,CAAC;AAE3F,OAAK,kBAAkB,iBAAiB;AACxC,OAAK,kBAAkB,iBAAiB;AAGxC,OAAK,kBAAkB,aAAa;AACpC,OAAK,kBAAkB,cAAc;AAIrC,OAAK,kBAAkB,mBAAmB;AAC1C,OAAK,kBAAkB,6BAA6B,EAAE,UAAU,MAAM,CAAC;AAGvE,OAAK,eAAe,OAAO,GAAG;;;CAIhC,kBAA0B,QAAgB,SAAkC,EAAE,EAAQ;AACpF,MAAI,CAAC,KAAK,MAAM,KAAK,GAAG,eAAe,UAAU,KAAM;EACvD,MAAM,KAAK,KAAK;AAChB,OAAK,GAAG,KAAK,KAAK,UAAU;GAAE;GAAI;GAAQ;GAAQ,CAAC,CAAC;;;;;;CAOtD,KACE,QACA,QACqC;AACrC,SAAO,KAAK,YAAY,QAAS,UAAU,EAAE,CAA6B;;;;;;;;;;;;;;;CAkB5E,YAAY,QAAgB,SAAkC,EAAE,EAAoB;AAElF,MAAI,KAAK,oBAAoB,eAC3B,QAAO,QAAQ,uBACb,IAAI,MACF,wBAAwB,OAAO,yDAChC,CACF;AAEH,MAAI,CAAC,KAAK,MAAM,KAAK,GAAG,eAAe,UAAU,KAC/C,QAAO,QAAQ,uBACb,IAAI,MAAM,+EAA+E,CAC1F;EAEH,MAAM,KAAK,KAAK;EAChB,MAAM,KAAK,KAAK;EAChB,MAAM,YAAY,KAAK;AACvB,SAAO,IAAI,SAAkB,SAAS,WAAW;GAC/C,MAAM,SAAS,iBAAiB;AAC9B,SAAK,QAAQ,OAAO,GAAG;AACvB,2BACE,IAAI,MACF,qBAAqB,OAAO,IAAI,UAAU,iFAG3C,CACF;MACA,UAAU;AACb,QAAK,QAAQ,IAAI,IAAI;IACnB,UAAU,MAAM;AACd,kBAAa,OAAO;AACpB,aAAQ,EAAE;;IAEZ,SAAS,MAAM;AACb,kBAAa,OAAO;AACpB,YAAO,EAAE;;IAEZ,CAAC;AACF,MAAG,KAAK,KAAK,UAAU;IAAE;IAAI;IAAQ;IAAQ,CAAC,CAAC;IAC/C;;;;;;;CAQJ,iBAAyB,QAAsB;AAC7C,MAAI,KAAK,oBAAoB,eAAgB;AAC7C,OAAK,kBAAkB;AACvB,OAAK,KAAK;AACV,OAAK,eAAe;EACpB,MAAM,sBAAM,IAAI,MACd,GAAG,OAAO,wDACX;AACD,OAAK,MAAM,UAAU,KAAK,QAAQ,QAAQ,CACxC,QAAO,OAAO,IAAI;AAEpB,OAAK,QAAQ,OAAO;;;;;;;;;;CAWtB,YAAoB,UAAwB;EAC1C,MAAM,8BAAa,IAAI,MAAM,EAAC,aAAa;AAC3C,OAAK,QAAQ,OAAO,SAAS;AAC7B,OAAK,iBAAiB,OAAO,SAAS;EAEtC,MAAM,sBAAM,IAAI,MACd,+EAA+E,SAAS,qCAEzF;AACD,OAAK,MAAM,UAAU,KAAK,QAAQ,QAAQ,CACxC,QAAO,OAAO,IAAI;AAEpB,OAAK,QAAQ,OAAO;EAEpB,MAAM,QAA8B;GAAE,MAAM;GAAY;GAAU;GAAY;AAC9E,OAAK,MAAM,YAAY,KAAK,mBAC1B,KAAI;AACF,YAAS,MAAM;UACT;;;;;;;;;;;CAeZ,iBAAyB,MAAoC,UAA+B;EAC1F,MAAM,8BAAa,IAAI,MAAM,EAAC,aAAa;AAC3C,OAAK,sBAAsB,KAAK,KAAK;AAGrC,MAAI,aAAa,MAAM;AACrB,QAAK,QAAQ,OAAO,SAAS;AAC7B,QAAK,iBAAiB,OAAO,SAAS;AAEtC,OAAI,KAAK,mBAAmB,SAC1B,MAAK,iBAAiB;SAEnB;AAEL,QAAK,QAAQ,OAAO;AACpB,QAAK,iBAAiB,OAAO;AAC7B,QAAK,iBAAiB;;EAUxB,MAAM,sBAAM,IAAI,MACd,eANA,SAAS,YACL,yCACA,SAAS,cACP,uCACA,4CAEe,iFAEtB;AACD,OAAK,MAAM,UAAU,KAAK,QAAQ,QAAQ,CACxC,QAAO,OAAO,IAAI;AAEpB,OAAK,QAAQ,OAAO;EAGpB,MAAM,QAA8B;GAAE;GAAM;GAAU;GAAY;AAClE,OAAK,MAAM,YAAY,KAAK,mBAC1B,KAAI;AACF,YAAS,MAAM;UACT;;;;;;;;;;;;;;;;CAoBZ,eAAuB,iBAA+B;AACpD,OAAK,eAAe;EAEpB,MAAM,QAAQ,QAAQ,IAAI,uBACtB,OAAO,QAAQ,IAAI,qBAAqB,GACxC,KAAA;AACJ,MAAI,UAAU,KAAA,KAAa,CAAC,OAAO,SAAS,MAAM,IAAI,SAAS,EAAG;EAElE,MAAM,kBAAkB;AAExB,OAAK,kBAAkB,kBAAkB;GAEvC,MAAM,YAAY,KAAK,QAAQ,OAAO,IAAI,CAAC,GAAG,KAAK,QAAQ,MAAM,CAAC,GAAG,CAAC,gBAAgB;AACtF,QAAK,MAAM,YAAY,WAAW;IAEhC,MAAM,cAAc,KAAK,YAAY,oBAAoB;KACvD,YAAY;KACZ,eAAe;KACf,SAAS;KACV,CAAC;IACF,MAAM,iBAAiB,IAAI,SAAgB,GAAG,WAC5C,iBACQ,uBAAO,IAAI,MAAM,oBAAoB,CAAC,EAC5C,kBAAkB,IACnB,CACF;AACD,YAAQ,KAAK,CAAC,aAAa,eAAe,CAAC,CAAC,YAAY;AAEtD,SAAI,KAAK,QAAQ,IAAI,SAAS,CAC5B,MAAK,iBAAiB,aAAa,SAAS;MAE9C;;KAEH,MAAM;;CAGX,gBAA8B;AAC5B,MAAI,KAAK,oBAAoB,MAAM;AACjC,iBAAc,KAAK,gBAAgB;AACnC,QAAK,kBAAkB;;;CAI3B,cAAsB,KAAmB;EACvC,MAAM,UAAU,aAAa,IAAI;AACjC,MAAI,CAAC,QAAS;AAGd,MAAI,OAAO,QAAQ,OAAO,YAAY,KAAK,QAAQ,IAAI,QAAQ,GAAG,EAAE;GAClE,MAAM,SAAS,KAAK,QAAQ,IAAI,QAAQ,GAAG;AAC3C,QAAK,QAAQ,OAAO,QAAQ,GAAG;AAC/B,OAAI,OACF,KAAI,QAAQ,MAAO,QAAO,OAAO,IAAI,MAAM,QAAQ,MAAM,QAAQ,CAAC;OAC7D,QAAO,QAAQ,QAAQ,OAAO;AAErC;;EAQF,MAAM,MAAM,KAAK,KAAK;EACtB,IAAI,mBAAmB;AACvB,OAAK,MAAM,YAAY,KAAK,QAAQ,MAAM,EAAE;AAC1C,OAAI,CAAC,KAAK,iBAAiB,IAAI,SAAS,CACtC,oBAAmB;AAErB,QAAK,iBAAiB,IAAI,UAAU,IAAI;;AAE1C,MAAI,oBAAoB,KAAK,QAAQ,OAAO,EAC1C,MAAK,QAAQ,KAAK,mBAAmB,CAAC,GAAG,KAAK,QAAQ,QAAQ,CAAC,CAAC;AAGlE,MAAI,OAAO,QAAQ,WAAW,SAAU;AAMxC,MAAI,QAAQ,WAAW,2BAA2B;AAChD,QAAK,iBAAiB,WAAW,KAAK;AACtC;;AAIF,MAAI,QAAQ,WAAW,0BAA0B;GAC/C,MAAM,WACJ,SAAS,QAAQ,OAAO,IAAI,OAAO,QAAQ,OAAO,aAAa,WAC3D,QAAQ,OAAO,WACf;AACN,QAAK,iBAAiB,aAAa,SAAS;AAC5C;;AAIF,MAAI,QAAQ,WAAW,6BAA6B;GAClD,MAAM,WACJ,SAAS,QAAQ,OAAO,IAAI,OAAO,QAAQ,OAAO,aAAa,WAC3D,QAAQ,OAAO,WACf;AACN,QAAK,iBAAiB,YAAY,SAAS;AAC3C;;AAIF,MAAI,CAAC,KAAK,QAAQ,IAAI,QAAQ,OAAuB,CAAE;EACvD,MAAM,QAAQ,QAAQ;EACtB,MAAM,SAAS,KAAK,QAAQ,IAAI,MAAM;AACtC,MAAI,CAAC,OAAQ;AACb,SAAO,KAAK,QAAQ,OAAO;EAG3B,MAAM,MAAM,UAAU,4BAA4B,wBAAwB,KAAK;AAC/E,MAAI,OAAO,SAAS,IAAK,QAAO,OAAO;AACvC,OAAK,QAAQ,KAAK,OAAO,QAAQ,OAAO;;CAG1C,kBAA0C,OAAyC;AAEjF,SADe,KAAK,QAAQ,IAAI,MAAM,IACpB,EAAE;;CAGtB,GAA2B,OAAU,UAAyD;AAC5F,OAAK,QAAQ,GAAG,OAAO,SAAuC;AAC9D,eAAa,KAAK,QAAQ,IAAI,OAAO,SAAuC;;;CAI9E,QAAc;EACZ,MAAM,KAAK,KAAK;AAChB,OAAK,eAAe;AAGpB,OAAK,iBAAiB,+BAA+B;AACrD,MAAI,OAAO;;;;;;;;;;;;AC5uBf,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;AAuBvC,eAAsB,sBACpB,YACA,OACA,MACyB;CACzB,MAAM,YAAY,KAAK,KAAK;CAC5B,MAAM,YAAY,IAAI,KAAK,UAAU,CAAC,aAAa;CACnD,MAAM,cAA4B,EAAE;CAcpC,IAAI,iBAAiB;AACrB,KAAI;AACF,QAAM,WAAW,eAAe;AAChC,mBAAiB;UACV,GAAG;AACV,UAAQ,OAAO,MACb,mFACE,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,CAC3C,KACF;;CAcH,MAAM,kBAAkB,MAAM,oBAAoB;CAClD,MAAM,cAAuC,EAAE;CAC/C,IAAI;AACJ,KAAI,mBAAmB,eACrB,sBAAqB,WAAW,GAAG,6BAA6B,UAAU;AACxE,cAAY,KAAK,MAAM;GACvB;CAUJ,IAAI,wBAAwB;;;;;;;;;;;;;;;;CAiB5B,MAAM,mBAAmB,OAAO,YAA2D;AACzF,UAAQ,OAAO,MACb,YAAY,cACR,kFACA,yEACL;AACD,MAAI;AACF,SAAM,WAAW,eAAe;AAChC,WAAQ,OAAO,MAAM,sCAAsC;WACpD,GAAG;AACV,WAAQ,OAAO,MACb,mCACE,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,CAC3C,4CACF;;;AAIL,KAAI;AACF,OAAK,MAAM,QAAQ,OAAO;AACxB,OAAI,uBAAuB;AACzB,UAAM,iBAAiB,YAAY;AACnC,4BAAwB;;GAG1B,IAAI;AACJ,OAAI;IACF,MAAM,EAAE,SAAS,MAAM,eAAe,MAAM,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;IAqBhE,MAAM,UAAU,YAAwC;KACtD,IAAI;AACJ,SAAI;AACF,kBAAY,MAAM,mBAAmB,YAAY,MAAM,MAAM,UAAU;cAChE,GAAG;AAMV,aAAO;OACL;OACA,QAAQ,EAAE,OAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,EAAE;OAC9D;;AAGH,SAAI,UAAU,GACZ,QAAO;MAAE;MAAM,QAAQ,UAAU;MAAQ;AAI3C,SAAI,UAAU,MAAM,SAAA,gCAAiC,CACnD,QAAO;AAET,YAAO;MAAE;MAAM,QAAQ,EAAE,OAAO,UAAU,OAAO;MAAE;;IAGrD,MAAM,cAAc,MAAM,SAAS;AACnC,QAAI,gBAAgB,KAClB,aAAY;SACP;AAWL,WAAM,iBAAiB,iBAAiB;AAIxC,aAAQ,OAAO,MAAM,wCAAwC,KAAK,oBAAoB;KACtF,MAAM,cAAc,MAAM,SAAS;AACnC,SAAI,gBAAgB,KAClB,aAAY;SAGZ,aAAY;MACV;MACA,QAAQ,EAIN,OAAO,GAAG,wBAAwB,GAAG,MAAM,aAAa,IAAO,mBAChE;MACF;;YAGE,GAAG;AAEV,gBAAY;KACV;KACA,QAAQ,EAAE,OAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,EAAE;KAC9D;;AAKH,OAAI,WAAW,UAAU,UAAU,yBAAyB,UAAU,OAAO,MAAM,CACjF,yBAAwB;AAG1B,eAAY,KAAK,UAAU;;WAErB;AAIR,wBAAsB;;CAOxB,MAAM,WAAW,kBACb,kBAAkB,YAAY,KAAK,OAAO,EAAE,MAAM,sBAAsB,EAAE,EAAE,EAAE,CAAC,GAC/E,EAAE;CAEN,MAAM,SAAS,YAAY,QACxB,KAAK,EAAE,aAAa;AACnB,MAAI,WAAW,QAAQ;AAErB,OAAI,UAAU;AACd,OAAI,SAAS;SACR;AACL,OAAI,UAAU,OAAO;AACrB,OAAI,UAAU,OAAO;AACrB,OAAI,WAAW,OAAO;AACtB,OAAI,SAAS,OAAO,SAAS,OAAO,SAAS,OAAO;;AAEtD,SAAO;IAET;EAAE,QAAQ;EAAG,QAAQ;EAAG,SAAS;EAAG,OAAO;EAAG,CAC/C;AAED,QAAO;EACL;EACA,UAAU,KAAK,KAAK,GAAG;EACvB,OAAO;EACP;EACA;EACD;;;;;;;;;;;;;AAcH,SAAS,sBAAsB,OAAsC;AACnE,QAAO,MAAM,KACV,KAAK,QAAQ;AACZ,MAAI,IAAI,UAAU,KAAA,GAAW;AAC3B,OAAI,OAAO,IAAI,UAAU,SAAU,QAAO,IAAI;AAC9C,OAAI;AACF,WAAO,KAAK,UAAU,IAAI,MAAM;WAC1B;AACN,WAAO,OAAO,IAAI,MAAM;;;AAG5B,MAAI,IAAI,gBAAgB,KAAA,EAAW,QAAO,IAAI;AAC9C,MAAI,IAAI,cAAc,KAAA,EAAW,QAAO,IAAI;AAC5C,SAAO,IAAI,WAAW,IAAI;GAC1B,CACD,KAAK,IAAI;;;;;;AAOd,SAAgB,eAAe,QAA8D;CAC3F,MAAM,MAA4C,EAAE;AACpD,MAAK,MAAM,EAAE,MAAM,YAAY,OAAO,MACpC,KAAI,WAAW,OACb,KAAI,KAAK;EACP;EACA,MAAM;EACN,QAAQ;EACR,UAAU;EACV,OAAO,OAAO;EACf,CAAC;KAEF,MAAK,MAAM,KAAK,OAAO,MACrB,KAAI,KAAK;EAAE,GAAG;EAAG;EAAM,CAAC;AAI9B,QAAO"}
|