@ait-co/devtools 0.1.58 → 0.1.60

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.
Files changed (79) hide show
  1. package/dist/deeplink-BONXxWEO.cjs +44 -0
  2. package/dist/deeplink-BONXxWEO.cjs.map +1 -0
  3. package/dist/deeplink-CCGiyoHq.cjs +44 -0
  4. package/dist/deeplink-CCGiyoHq.cjs.map +1 -0
  5. package/dist/deeplink-CaO6hZVG.js +44 -0
  6. package/dist/deeplink-CaO6hZVG.js.map +1 -0
  7. package/dist/deeplink-Cqli4qzm.js +44 -0
  8. package/dist/deeplink-Cqli4qzm.js.map +1 -0
  9. package/dist/devtools-opener-BbUXBzgA.js +65 -0
  10. package/dist/devtools-opener-BbUXBzgA.js.map +1 -0
  11. package/dist/devtools-opener-Bp671YXu.cjs +62 -0
  12. package/dist/devtools-opener-Bp671YXu.cjs.map +1 -0
  13. package/dist/devtools-opener-D84kZFtR.js +65 -0
  14. package/dist/devtools-opener-D84kZFtR.js.map +1 -0
  15. package/dist/devtools-opener-h6A-UjzC.cjs +62 -0
  16. package/dist/devtools-opener-h6A-UjzC.cjs.map +1 -0
  17. package/dist/mcp/cli.js +862 -403
  18. package/dist/mcp/cli.js.map +1 -1
  19. package/dist/mcp/server.js +5 -1
  20. package/dist/mcp/server.js.map +1 -1
  21. package/dist/panel/index.d.ts +15 -9
  22. package/dist/panel/index.d.ts.map +1 -1
  23. package/dist/panel/index.js +27705 -1965
  24. package/dist/panel/index.js.map +1 -1
  25. package/dist/qr-http-server-Byk0Yjk_.cjs +901 -0
  26. package/dist/qr-http-server-Byk0Yjk_.cjs.map +1 -0
  27. package/dist/qr-http-server-D_Aj5Vq6.cjs +901 -0
  28. package/dist/qr-http-server-D_Aj5Vq6.cjs.map +1 -0
  29. package/dist/qr-http-server-N4mX8GaC.js +901 -0
  30. package/dist/qr-http-server-N4mX8GaC.js.map +1 -0
  31. package/dist/qr-http-server-kYvmlXlg.js +901 -0
  32. package/dist/qr-http-server-kYvmlXlg.js.map +1 -0
  33. package/dist/relay-secret-store-5A7_7zOp.js +111 -0
  34. package/dist/relay-secret-store-5A7_7zOp.js.map +1 -0
  35. package/dist/{relay-secret-store-DqyUoeXy.js → relay-secret-store-C4QQN5NA.js} +3 -3
  36. package/dist/{relay-secret-store-DqyUoeXy.js.map → relay-secret-store-C4QQN5NA.js.map} +1 -1
  37. package/dist/{relay-secret-store-DnTNl-9z.cjs → relay-secret-store-CLkF8Pa0.cjs} +3 -2
  38. package/dist/{relay-secret-store-DnTNl-9z.cjs.map → relay-secret-store-CLkF8Pa0.cjs.map} +1 -1
  39. package/dist/relay-url-store-COG2dSql.cjs +113 -0
  40. package/dist/relay-url-store-COG2dSql.cjs.map +1 -0
  41. package/dist/relay-url-store-WKfo0VQV.js +112 -0
  42. package/dist/relay-url-store-WKfo0VQV.js.map +1 -0
  43. package/dist/relay-url-store-qaoe0zOD.js +118 -0
  44. package/dist/relay-url-store-qaoe0zOD.js.map +1 -0
  45. package/dist/totp-86i_CNqh.js +3 -0
  46. package/dist/{totp-D0a8VwoR.js → totp-BIrJHsQn.js} +1 -1
  47. package/dist/{totp-D0a8VwoR.js.map → totp-BIrJHsQn.js.map} +1 -1
  48. package/dist/{totp-BkP5yU2K.js → totp-BjtKFt88.js} +2 -2
  49. package/dist/{totp-BkP5yU2K.js.map → totp-BjtKFt88.js.map} +1 -1
  50. package/dist/totp-BxtxuEt4.js +64 -0
  51. package/dist/totp-BxtxuEt4.js.map +1 -0
  52. package/dist/totp-D9rndqg_.cjs +64 -0
  53. package/dist/totp-D9rndqg_.cjs.map +1 -0
  54. package/dist/{totp-DLgGbySX.cjs → totp-DA8vjAi7.cjs} +2 -1
  55. package/dist/{totp-DLgGbySX.cjs.map → totp-DA8vjAi7.cjs.map} +1 -1
  56. package/dist/{tunnel-nKYPtc-g.cjs → tunnel-GieyWa22.cjs} +114 -3
  57. package/dist/tunnel-GieyWa22.cjs.map +1 -0
  58. package/dist/{tunnel-CI61NvPI.js → tunnel-JuZ5_Pci.js} +114 -4
  59. package/dist/tunnel-JuZ5_Pci.js.map +1 -0
  60. package/dist/unplugin/index.cjs +116 -4
  61. package/dist/unplugin/index.cjs.map +1 -1
  62. package/dist/unplugin/index.d.cts +20 -1
  63. package/dist/unplugin/index.d.cts.map +1 -1
  64. package/dist/unplugin/index.d.ts +20 -1
  65. package/dist/unplugin/index.d.ts.map +1 -1
  66. package/dist/unplugin/index.js +116 -5
  67. package/dist/unplugin/index.js.map +1 -1
  68. package/dist/unplugin/tunnel.cjs +114 -2
  69. package/dist/unplugin/tunnel.cjs.map +1 -1
  70. package/dist/unplugin/tunnel.d.cts +62 -1
  71. package/dist/unplugin/tunnel.d.cts.map +1 -1
  72. package/dist/unplugin/tunnel.d.ts +62 -1
  73. package/dist/unplugin/tunnel.d.ts.map +1 -1
  74. package/dist/unplugin/tunnel.js +113 -3
  75. package/dist/unplugin/tunnel.js.map +1 -1
  76. package/package.json +11 -3
  77. package/dist/totp-CQFmgOhM.js +0 -3
  78. package/dist/tunnel-CI61NvPI.js.map +0 -1
  79. package/dist/tunnel-nKYPtc-g.cjs.map +0 -1
package/dist/mcp/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "../totp-D0a8VwoR.js";
2
+ import { t as loadRelaySecretReadOnly } from "../relay-secret-store-5A7_7zOp.js";
3
+ import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "../totp-BIrJHsQn.js";
3
4
  import { createRequire } from "node:module";
4
5
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
6
  import { argv } from "node:process";
@@ -13,12 +14,77 @@ import { createServer } from "node:http";
13
14
  import { spawn } from "node:child_process";
14
15
  import net from "node:net";
15
16
  import { homedir, platform } from "node:os";
16
- import { dirname, join } from "node:path";
17
+ import { join } from "node:path";
17
18
  import { randomBytes } from "node:crypto";
18
19
  import { Tunnel, bin, install } from "cloudflared";
19
20
  //#region \0rolldown/runtime.js
20
21
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
21
22
  //#endregion
23
+ //#region src/shared/parent-watcher.ts
24
+ /**
25
+ * Shared parent-PID watcher — used by both the MCP debug daemon and the
26
+ * unplugin tunnel path to self-terminate when the parent process (e.g. Claude
27
+ * Code, vite) has died or been reparented without sending SIGTERM/SIGHUP.
28
+ *
29
+ * Intentionally react-free and Node-stdlib-only so this module is safe to
30
+ * import from the MCP daemon bundle (`dist/mcp/cli.js`) without violating the
31
+ * install-graph invariant.
32
+ */
33
+ /**
34
+ * Returns `true` when the given PID refers to a running process.
35
+ *
36
+ * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process
37
+ * exists and we have permission to signal it; throws ESRCH when it doesn't exist.
38
+ */
39
+ function isPidAlive$1(pid) {
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ } catch (err) {
44
+ if (err.code === "EPERM") return true;
45
+ return false;
46
+ }
47
+ }
48
+ /**
49
+ * Starts a periodic watcher that detects when the parent process (e.g. Claude
50
+ * Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the
51
+ * daemon can self-terminate rather than running as a zombie.
52
+ *
53
+ * Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns
54
+ * `{ stop(): void }`, injectable deps for testability.
55
+ *
56
+ * @param onOrphaned - Called once when the parent is gone.
57
+ * @param opts.intervalMs - Poll interval in milliseconds (default 5 000).
58
+ * @param opts.initialPpid - Parent PID to watch (default `process.ppid`).
59
+ * @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).
60
+ * @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).
61
+ * Detects ppid changes as well as death.
62
+ * @param opts.log - Logger (default `process.stderr.write`).
63
+ *
64
+ * @returns `stop` — call during shutdown to clear the interval.
65
+ */
66
+ function startParentWatcher(onOrphaned, opts) {
67
+ const { intervalMs = 5e3, initialPpid = process.ppid, isAlive = isPidAlive$1, getPpid = () => process.ppid, log = (msg) => process.stderr.write(msg) } = opts ?? {};
68
+ if (initialPpid <= 1) {
69
+ log("[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\n");
70
+ return { stop() {} };
71
+ }
72
+ let fired = false;
73
+ const handle = setInterval(() => {
74
+ if (fired) return;
75
+ const currentPpid = getPpid();
76
+ if (currentPpid !== initialPpid || !isAlive(initialPpid)) {
77
+ fired = true;
78
+ clearInterval(handle);
79
+ log(`[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\n`);
80
+ onOrphaned();
81
+ }
82
+ }, intervalMs);
83
+ return { stop() {
84
+ clearInterval(handle);
85
+ } };
86
+ }
87
+ //#endregion
22
88
  //#region src/mcp/ait-chii-source.ts
23
89
  function isObject$4(value) {
24
90
  return typeof value === "object" && value !== null;
@@ -1579,7 +1645,746 @@ async function launchChromium(options = {}) {
1579
1645
  };
1580
1646
  }
1581
1647
  //#endregion
1648
+ //#region src/i18n/en.ts
1649
+ const en = {
1650
+ "panel.title": "AIT DevTools",
1651
+ "panel.toggle.title": "AIT DevTools",
1652
+ "panel.close": "Close",
1653
+ "panel.editMode.on": "EDIT",
1654
+ "panel.editMode.off": "READ-ONLY",
1655
+ "panel.editMode.toggleTitle": "Toggle panel edit mode",
1656
+ "panel.tabError": "Error rendering \"{tab}\" tab.",
1657
+ "panel.tab.env": "Environment",
1658
+ "panel.tab.presets": "Presets",
1659
+ "panel.tab.viewport": "Viewport",
1660
+ "panel.tab.permissions": "Permissions",
1661
+ "panel.tab.notifications": "Notifications",
1662
+ "panel.tab.location": "Location",
1663
+ "panel.tab.device": "Device",
1664
+ "panel.tab.iap": "IAP",
1665
+ "panel.tab.ads": "Ads",
1666
+ "panel.tab.events": "Events",
1667
+ "panel.tab.analytics": "Analytics",
1668
+ "panel.tab.storage": "Storage",
1669
+ "common.readOnly": "Read-only — mock responses are controlled at build time.",
1670
+ "toast.consent.title": "Send anonymous usage stats?",
1671
+ "toast.consent.body": "We collect anonymous events only, to improve the tool. You can turn this off anytime in the Environment tab.",
1672
+ "toast.consent.learnMore": "Learn more",
1673
+ "toast.consent.accept": "Yes, send",
1674
+ "toast.consent.deny": "No, thanks",
1675
+ "env.section.platform": "Platform",
1676
+ "env.row.os": "OS",
1677
+ "env.row.appVersion": "App Version",
1678
+ "env.row.environment": "Environment",
1679
+ "env.row.locale": "Locale",
1680
+ "env.section.network": "Network",
1681
+ "env.row.networkStatus": "Status",
1682
+ "env.section.safeArea": "Safe Area Insets",
1683
+ "env.row.safeArea.top": "Top",
1684
+ "env.row.safeArea.bottom": "Bottom",
1685
+ "env.section.navigation": "Navigation",
1686
+ "env.row.iosSwipeGesture": "iOS swipe-back",
1687
+ "env.value.iosSwipeGesture.unset": "not called",
1688
+ "env.value.iosSwipeGesture.enabled": "enabled",
1689
+ "env.value.iosSwipeGesture.disabled": "disabled",
1690
+ "env.hint.iosSwipeGesture": "Last value passed to setIosSwipeGestureEnabled. Switching Environment to toss lets a toss-gated guard toggle this.",
1691
+ "env.telemetry.section": "Telemetry",
1692
+ "env.telemetry.t0Row": "Anonymous usage signal (Tier 0)",
1693
+ "env.telemetry.t0On": "On",
1694
+ "env.telemetry.t0Off": "Off",
1695
+ "env.telemetry.t0TurnOn": "Turn on",
1696
+ "env.telemetry.t0TurnOff": "Turn off",
1697
+ "env.telemetry.t0Desc": "Version + date only, no PII. Once per day. Helps improve the package.",
1698
+ "env.telemetry.row": "Extended telemetry (Tier 1)",
1699
+ "env.telemetry.on": "On",
1700
+ "env.telemetry.off": "Off",
1701
+ "env.telemetry.turnOn": "Turn on",
1702
+ "env.telemetry.turnOff": "Turn off",
1703
+ "env.telemetry.anonIdLabel": "anon_id: {value}",
1704
+ "env.telemetry.anonIdNotSet": "(not yet set)",
1705
+ "env.telemetry.anonIdCopyTitle": "Click to copy full anon_id",
1706
+ "env.telemetry.deleteBtn": "Delete my data",
1707
+ "env.telemetry.deleting": "Deleting…",
1708
+ "env.telemetry.deleted": "Deleted",
1709
+ "env.telemetry.deleteFailedRetry": "Delete failed (please retry)",
1710
+ "env.telemetry.deleteFailed": "Delete failed",
1711
+ "env.telemetry.privacyLink": "Privacy policy →",
1712
+ "env.section.language": "Language",
1713
+ "env.language.row": "Language",
1714
+ "env.language.ko": "한국어",
1715
+ "env.language.en": "English",
1716
+ "permissions.section.device": "Device Permissions",
1717
+ "location.section.current": "Current Location",
1718
+ "location.row.latitude": "Latitude",
1719
+ "location.row.longitude": "Longitude",
1720
+ "location.row.accuracy": "Accuracy",
1721
+ "device.section.modes": "Device API Modes",
1722
+ "device.row.camera": "Camera",
1723
+ "device.row.photos": "Photos",
1724
+ "device.row.location": "Location",
1725
+ "device.row.network": "Network",
1726
+ "device.row.clipboard": "Clipboard",
1727
+ "device.section.mockImages": "Mock Images ({count})",
1728
+ "device.btn.add": "+ Add",
1729
+ "device.btn.useDefaults": "Use defaults",
1730
+ "device.btn.clear": "Clear",
1731
+ "device.prompt.camera.title": "Camera Prompt — Select an image",
1732
+ "device.prompt.photos.title": "Photos Prompt — Select images",
1733
+ "device.prompt.location.title": "Location Prompt — Enter coordinates",
1734
+ "device.prompt.locationUpdate.title": "Location Update — Send coordinates",
1735
+ "device.prompt.fallbackTitle": "Prompt: {type}",
1736
+ "device.prompt.label.lat": "Lat",
1737
+ "device.prompt.label.lng": "Lng",
1738
+ "device.prompt.send": "Send",
1739
+ "device.prompt.cancel": "Cancel",
1740
+ "device.section.haptic": "Haptic",
1741
+ "device.haptic.lastCall": "Last haptic",
1742
+ "device.haptic.noneYet": "(none yet)",
1743
+ "device.haptic.trigger": "Trigger haptic",
1744
+ "viewport.section.device": "Device",
1745
+ "viewport.row.preset": "Preset",
1746
+ "viewport.row.orientation": "Orientation",
1747
+ "viewport.row.notchSide": "Notch side",
1748
+ "viewport.section.custom": "Custom size",
1749
+ "viewport.row.width": "Width (px)",
1750
+ "viewport.row.height": "Height (px)",
1751
+ "viewport.section.appearance": "Appearance",
1752
+ "viewport.row.showFrame": "Show frame",
1753
+ "viewport.row.showAitNavBar": "Show Apps in Toss nav bar",
1754
+ "viewport.row.navBarType": "Nav bar type",
1755
+ "viewport.status.noConstraint": "No viewport constraint — body fills the window.",
1756
+ "viewport.status.cssPhysical": "CSS / physical",
1757
+ "viewport.status.safeArea": "Safe area",
1758
+ "viewport.status.aitNavBar": "AIT nav bar",
1759
+ "viewport.status.aitNavBarValue": "{height}px → SafeArea top · {type}",
1760
+ "viewport.orientation.autoSuffix": "{orient} (auto)",
1761
+ "iap.section.simulator": "IAP Simulator",
1762
+ "iap.row.nextResult": "Next Purchase Result",
1763
+ "iap.section.tossPay": "TossPay",
1764
+ "iap.row.tossPayResult": "Next Payment Result",
1765
+ "iap.section.pending": "Pending Orders ({count})",
1766
+ "iap.empty.pending": "(no pending orders)",
1767
+ "iap.section.completed": "Completed Orders ({count})",
1768
+ "iap.empty.completed": "(no completed orders)",
1769
+ "iap.btn.complete": "Complete",
1770
+ "iap.label.pending": "PENDING",
1771
+ "events.section.navigation": "Navigation Events",
1772
+ "events.btn.triggerBack": "Trigger Back Event",
1773
+ "events.btn.triggerHome": "Trigger Home Event",
1774
+ "events.section.login": "Login",
1775
+ "events.row.loggedIn": "Logged In",
1776
+ "events.row.tossLoginIntegrated": "Toss Login Integrated",
1777
+ "analytics.section.log": "Analytics Log ({count})",
1778
+ "analytics.btn.clear": "Clear",
1779
+ "analytics.calls.section": "SDK Calls ({count})",
1780
+ "analytics.calls.btn.clear": "Clear",
1781
+ "analytics.calls.empty": "(no SDK calls yet)",
1782
+ "storage.section.title": "Storage ({count} items)",
1783
+ "storage.btn.clearAll": "Clear All",
1784
+ "storage.empty": "No items in storage",
1785
+ "presets.section.builtIn": "Built-in scenarios",
1786
+ "presets.section.saved": "Saved presets ({count})",
1787
+ "presets.section.save": "Save",
1788
+ "presets.save.description": "Capture network / permissions / auth / IAP / ads / payment slices.",
1789
+ "presets.btn.saveCurrent": "Save current as preset",
1790
+ "presets.btn.apply": "Apply",
1791
+ "presets.btn.reApply": "Re-apply",
1792
+ "presets.btn.delete": "Delete",
1793
+ "presets.empty.saved": "No saved presets yet.",
1794
+ "presets.empty.builtIn": "No built-in presets.",
1795
+ "presets.prompt.label": "Preset label?",
1796
+ "presets.confirm.delete": "Delete preset \"{label}\"?",
1797
+ "ads.section.state": "Ads State",
1798
+ "ads.row.isLoaded": "isLoaded",
1799
+ "ads.row.forceNoFill": "Force \"no fill\"",
1800
+ "ads.empty.events": "No events yet",
1801
+ "ads.section.googleAdMob": "GoogleAdMob",
1802
+ "ads.section.tossAds": "TossAds",
1803
+ "ads.section.fullScreenAd": "FullScreenAd",
1804
+ "ads.btn.load": "Load",
1805
+ "ads.btn.show": "Show",
1806
+ "ads.section.tossAdsBanner": "TossAds Banner",
1807
+ "ads.row.rewardUnitType": "Reward unit type",
1808
+ "ads.row.rewardAmount": "Reward amount",
1809
+ "ads.btn.render": "Render",
1810
+ "ads.btn.noFill": "No-fill",
1811
+ "ads.btn.click": "Click",
1812
+ "ads.btn.destroy": "Destroy",
1813
+ "notifications.section.title": "requestNotificationAgreement",
1814
+ "notifications.option.newAgreement": "newAgreement (first-time agree)",
1815
+ "notifications.option.alreadyAgreed": "alreadyAgreed (already opted-in)",
1816
+ "notifications.option.agreementRejected": "agreementRejected (user declined)",
1817
+ "dashboard.title": "AIT Debug Dashboard",
1818
+ "dashboard.updated": "Last updated: {ts}",
1819
+ "dashboard.tunnel.section": "Tunnel status",
1820
+ "dashboard.tunnel.up": "Connected",
1821
+ "dashboard.tunnel.down": "Disconnected",
1822
+ "dashboard.attach.section": "Attach QR",
1823
+ "dashboard.attach.hint": "Call the build_attach_url MCP tool to show the QR here.",
1824
+ "dashboard.pages.section": "Connected Pages",
1825
+ "dashboard.pages.empty": "No attached pages",
1826
+ "attach.title": "AIT Debug Session — QR Scan",
1827
+ "attach.deployment": "deployment: {label}",
1828
+ "attach.steps.section": "How to scan",
1829
+ "attach.step1": "Open the Toss app.",
1830
+ "attach.step2": "Scan the QR code with your phone camera app.",
1831
+ "attach.step3": "Tap <strong>\"Open in Toss\"</strong> when the popup appears.",
1832
+ "attach.step4": "The mini-app opens and the debug session attaches automatically.",
1833
+ "attach.faq.section": "Troubleshooting checklist",
1834
+ "attach.faq.appNotOpen": "<strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)",
1835
+ "attach.faq.prepare": "<strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter",
1836
+ "attach.faq.chii": "<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import",
1837
+ "attach.faq.totp": "<strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server",
1838
+ "attach.url.section": "URL (fallback)",
1839
+ "launcher.title": "AITC DevTools Launcher",
1840
+ "launcher.description": "Scan the terminal QR code or paste the tunnel URL.",
1841
+ "launcher.installCta": "Install launcher to your phone",
1842
+ "launcher.urlPlaceholder": "https://example.trycloudflare.com",
1843
+ "launcher.openBtn": "Open",
1844
+ "launcher.scanBtn": "Scan QR with camera",
1845
+ "launcher.rescanBtn": "Rescan",
1846
+ "launcher.noCamera": "No camera available — paste the URL instead.",
1847
+ "launcher.cameraError": "Could not access the camera — paste the URL instead.",
1848
+ "launcher.invalidUrlHttps": "Enter a valid https:// URL (the tunnel URL from your terminal).",
1849
+ "launcher.invalidUrl": "Enter a valid http(s):// URL."
1850
+ };
1851
+ //#endregion
1852
+ //#region src/i18n/index.ts
1853
+ /**
1854
+ * Vanilla TS i18n for the floating DevTools panel.
1855
+ *
1856
+ * Public surface:
1857
+ * - `t(key, vars?)` — look up a UI string, with `{name}` placeholder
1858
+ * interpolation. Falls back to the key itself if a translation is missing.
1859
+ * - `getLocale()` / `setLocale(locale)` — read/persist the active locale.
1860
+ * `setLocale` dispatches `__ait:localechange` so the panel can remount.
1861
+ * - `detectLocale()` — first-run heuristic from `navigator.language`.
1862
+ *
1863
+ * `ko` is the source of truth (keys are typed from it). `en` is also a full
1864
+ * `Record<StringKey, string>` (devtools is developer-facing, en is a real
1865
+ * audience). The `Partial` lookup table preserves the runtime `?? key` safety
1866
+ * net even though we ship complete catalogs today.
1867
+ */
1868
+ const tables = {
1869
+ ko: {
1870
+ "panel.title": "AIT DevTools",
1871
+ "panel.toggle.title": "AIT DevTools",
1872
+ "panel.close": "Close",
1873
+ "panel.editMode.on": "EDIT",
1874
+ "panel.editMode.off": "READ-ONLY",
1875
+ "panel.editMode.toggleTitle": "패널 편집 모드 전환",
1876
+ "panel.tabError": "\"{tab}\" 탭 렌더링 중 오류가 발생했습니다.",
1877
+ "panel.tab.env": "Environment",
1878
+ "panel.tab.presets": "Presets",
1879
+ "panel.tab.viewport": "Viewport",
1880
+ "panel.tab.permissions": "Permissions",
1881
+ "panel.tab.notifications": "Notifications",
1882
+ "panel.tab.location": "Location",
1883
+ "panel.tab.device": "Device",
1884
+ "panel.tab.iap": "IAP",
1885
+ "panel.tab.ads": "Ads",
1886
+ "panel.tab.events": "Events",
1887
+ "panel.tab.analytics": "Analytics",
1888
+ "panel.tab.storage": "Storage",
1889
+ "common.readOnly": "읽기 전용 — mock 응답은 빌드 타임에 고정됩니다.",
1890
+ "toast.consent.title": "익명 사용 통계를 보낼까요?",
1891
+ "toast.consent.body": "도구 개선을 위해 익명 이벤트만 수집해요. 언제든 환경 탭에서 끌 수 있어요.",
1892
+ "toast.consent.learnMore": "더 알아보기",
1893
+ "toast.consent.accept": "네, 보낼게요",
1894
+ "toast.consent.deny": "아니요",
1895
+ "env.section.platform": "Platform",
1896
+ "env.row.os": "OS",
1897
+ "env.row.appVersion": "App Version",
1898
+ "env.row.environment": "Environment",
1899
+ "env.row.locale": "Locale",
1900
+ "env.section.network": "Network",
1901
+ "env.row.networkStatus": "Status",
1902
+ "env.section.safeArea": "Safe Area Insets",
1903
+ "env.row.safeArea.top": "Top",
1904
+ "env.row.safeArea.bottom": "Bottom",
1905
+ "env.section.navigation": "Navigation",
1906
+ "env.row.iosSwipeGesture": "iOS swipe-back",
1907
+ "env.value.iosSwipeGesture.unset": "미호출",
1908
+ "env.value.iosSwipeGesture.enabled": "enabled",
1909
+ "env.value.iosSwipeGesture.disabled": "disabled",
1910
+ "env.hint.iosSwipeGesture": "setIosSwipeGestureEnabled의 마지막 호출값. Environment를 toss로 바꾸면 toss-gated 가드가 이 값을 토글합니다.",
1911
+ "env.telemetry.section": "Telemetry",
1912
+ "env.telemetry.t0Row": "익명 사용 신호 (Tier 0)",
1913
+ "env.telemetry.t0On": "On",
1914
+ "env.telemetry.t0Off": "Off",
1915
+ "env.telemetry.t0TurnOn": "Turn on",
1916
+ "env.telemetry.t0TurnOff": "Turn off",
1917
+ "env.telemetry.t0Desc": "버전·날짜만 수집, PII 없음. 하루 1회. 패키지 개선에 사용됩니다.",
1918
+ "env.telemetry.row": "확장 텔레메트리 (Tier 1)",
1919
+ "env.telemetry.on": "On",
1920
+ "env.telemetry.off": "Off",
1921
+ "env.telemetry.turnOn": "Turn on",
1922
+ "env.telemetry.turnOff": "Turn off",
1923
+ "env.telemetry.anonIdLabel": "anon_id: {value}",
1924
+ "env.telemetry.anonIdNotSet": "(not yet set)",
1925
+ "env.telemetry.anonIdCopyTitle": "전체 anon_id 복사",
1926
+ "env.telemetry.deleteBtn": "내 데이터 삭제",
1927
+ "env.telemetry.deleting": "삭제 중…",
1928
+ "env.telemetry.deleted": "삭제 완료",
1929
+ "env.telemetry.deleteFailedRetry": "삭제 실패 (다시 시도해주세요)",
1930
+ "env.telemetry.deleteFailed": "삭제 실패",
1931
+ "env.telemetry.privacyLink": "개인정보 처리방침 →",
1932
+ "env.section.language": "Language",
1933
+ "env.language.row": "Language",
1934
+ "env.language.ko": "한국어",
1935
+ "env.language.en": "English",
1936
+ "permissions.section.device": "Device Permissions",
1937
+ "location.section.current": "Current Location",
1938
+ "location.row.latitude": "Latitude",
1939
+ "location.row.longitude": "Longitude",
1940
+ "location.row.accuracy": "Accuracy",
1941
+ "device.section.modes": "Device API Modes",
1942
+ "device.row.camera": "Camera",
1943
+ "device.row.photos": "Photos",
1944
+ "device.row.location": "Location",
1945
+ "device.row.network": "Network",
1946
+ "device.row.clipboard": "Clipboard",
1947
+ "device.section.mockImages": "Mock Images ({count})",
1948
+ "device.btn.add": "+ Add",
1949
+ "device.btn.useDefaults": "Use defaults",
1950
+ "device.btn.clear": "Clear",
1951
+ "device.prompt.camera.title": "Camera Prompt — 이미지를 선택하세요",
1952
+ "device.prompt.photos.title": "Photos Prompt — 이미지를 선택하세요",
1953
+ "device.prompt.location.title": "Location Prompt — 좌표 입력",
1954
+ "device.prompt.locationUpdate.title": "Location Update — 좌표 전송",
1955
+ "device.prompt.fallbackTitle": "Prompt: {type}",
1956
+ "device.prompt.label.lat": "Lat",
1957
+ "device.prompt.label.lng": "Lng",
1958
+ "device.prompt.send": "Send",
1959
+ "device.prompt.cancel": "Cancel",
1960
+ "device.section.haptic": "Haptic",
1961
+ "device.haptic.lastCall": "마지막 haptic",
1962
+ "device.haptic.noneYet": "(아직 없음)",
1963
+ "device.haptic.trigger": "Haptic 트리거",
1964
+ "viewport.section.device": "Device",
1965
+ "viewport.row.preset": "Preset",
1966
+ "viewport.row.orientation": "Orientation",
1967
+ "viewport.row.notchSide": "Notch side",
1968
+ "viewport.section.custom": "Custom size",
1969
+ "viewport.row.width": "Width (px)",
1970
+ "viewport.row.height": "Height (px)",
1971
+ "viewport.section.appearance": "Appearance",
1972
+ "viewport.row.showFrame": "Show frame",
1973
+ "viewport.row.showAitNavBar": "Apps in Toss 내비게이션 바 표시",
1974
+ "viewport.row.navBarType": "Nav bar type",
1975
+ "viewport.status.noConstraint": "뷰포트 제약 없음 — body가 창을 가득 채웁니다.",
1976
+ "viewport.status.cssPhysical": "CSS / physical",
1977
+ "viewport.status.safeArea": "Safe area",
1978
+ "viewport.status.aitNavBar": "AIT nav bar",
1979
+ "viewport.status.aitNavBarValue": "{height}px → SafeArea top · {type}",
1980
+ "viewport.orientation.autoSuffix": "{orient} (auto)",
1981
+ "iap.section.simulator": "IAP Simulator",
1982
+ "iap.row.nextResult": "Next Purchase Result",
1983
+ "iap.section.tossPay": "TossPay",
1984
+ "iap.row.tossPayResult": "Next Payment Result",
1985
+ "iap.section.pending": "Pending Orders ({count})",
1986
+ "iap.empty.pending": "(대기 중인 주문 없음)",
1987
+ "iap.section.completed": "Completed Orders ({count})",
1988
+ "iap.empty.completed": "(완료된 주문 없음)",
1989
+ "iap.btn.complete": "Complete",
1990
+ "iap.label.pending": "PENDING",
1991
+ "events.section.navigation": "Navigation Events",
1992
+ "events.btn.triggerBack": "Back 이벤트 발생",
1993
+ "events.btn.triggerHome": "Home 이벤트 발생",
1994
+ "events.section.login": "Login",
1995
+ "events.row.loggedIn": "Logged In",
1996
+ "events.row.tossLoginIntegrated": "Toss Login Integrated",
1997
+ "analytics.section.log": "Analytics Log ({count})",
1998
+ "analytics.btn.clear": "Clear",
1999
+ "analytics.calls.section": "SDK Calls ({count})",
2000
+ "analytics.calls.btn.clear": "Clear",
2001
+ "analytics.calls.empty": "(아직 SDK 호출 없음)",
2002
+ "storage.section.title": "Storage ({count} items)",
2003
+ "storage.btn.clearAll": "Clear All",
2004
+ "storage.empty": "저장된 항목이 없습니다",
2005
+ "presets.section.builtIn": "Built-in scenarios",
2006
+ "presets.section.saved": "Saved presets ({count})",
2007
+ "presets.section.save": "Save",
2008
+ "presets.save.description": "network / permissions / auth / IAP / ads / payment 슬라이스를 캡처합니다.",
2009
+ "presets.btn.saveCurrent": "현재 상태를 프리셋으로 저장",
2010
+ "presets.btn.apply": "Apply",
2011
+ "presets.btn.reApply": "Re-apply",
2012
+ "presets.btn.delete": "Delete",
2013
+ "presets.empty.saved": "저장된 프리셋이 아직 없습니다.",
2014
+ "presets.empty.builtIn": "내장 프리셋이 없습니다.",
2015
+ "presets.prompt.label": "프리셋 라벨을 입력하세요",
2016
+ "presets.confirm.delete": "\"{label}\" 프리셋을 삭제할까요?",
2017
+ "ads.section.state": "Ads State",
2018
+ "ads.row.isLoaded": "isLoaded",
2019
+ "ads.row.forceNoFill": "강제 \"no fill\"",
2020
+ "ads.empty.events": "아직 이벤트가 없습니다",
2021
+ "ads.section.googleAdMob": "GoogleAdMob",
2022
+ "ads.section.tossAds": "TossAds",
2023
+ "ads.section.fullScreenAd": "FullScreenAd",
2024
+ "ads.btn.load": "Load",
2025
+ "ads.btn.show": "Show",
2026
+ "ads.section.tossAdsBanner": "TossAds 배너",
2027
+ "ads.row.rewardUnitType": "리워드 단위 타입",
2028
+ "ads.row.rewardAmount": "리워드 수량",
2029
+ "ads.btn.render": "Render",
2030
+ "ads.btn.noFill": "No-fill",
2031
+ "ads.btn.click": "Click",
2032
+ "ads.btn.destroy": "Destroy",
2033
+ "notifications.section.title": "requestNotificationAgreement",
2034
+ "notifications.option.newAgreement": "newAgreement (최초 동의)",
2035
+ "notifications.option.alreadyAgreed": "alreadyAgreed (이미 동의됨)",
2036
+ "notifications.option.agreementRejected": "agreementRejected (사용자 거절)",
2037
+ "dashboard.title": "AIT 디버그 Dashboard",
2038
+ "dashboard.updated": "마지막 갱신: {ts}",
2039
+ "dashboard.tunnel.section": "터널 상태",
2040
+ "dashboard.tunnel.up": "연결됨",
2041
+ "dashboard.tunnel.down": "끊어짐",
2042
+ "dashboard.attach.section": "Attach QR",
2043
+ "dashboard.attach.hint": "build_attach_url MCP tool을 호출하면 QR이 여기에 표시됩니다.",
2044
+ "dashboard.pages.section": "연결된 Pages",
2045
+ "dashboard.pages.empty": "attach된 페이지 없음",
2046
+ "attach.title": "AIT 디버그 세션 — QR 스캔",
2047
+ "attach.deployment": "deployment: {label}",
2048
+ "attach.steps.section": "스캔 절차",
2049
+ "attach.step1": "토스 앱을 실행하세요.",
2050
+ "attach.step2": "폰 카메라 앱으로 QR 코드를 스캔하세요.",
2051
+ "attach.step3": "팝업이 뜨면 <strong>\"토스로 열기\"</strong>를 탭하세요.",
2052
+ "attach.step4": "미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.",
2053
+ "attach.faq.section": "진단 체크리스트",
2054
+ "attach.faq.appNotOpen": "<strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)",
2055
+ "attach.faq.prepare": "<strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인",
2056
+ "attach.faq.chii": "<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인",
2057
+ "attach.faq.totp": "<strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인",
2058
+ "attach.url.section": "URL (fallback)",
2059
+ "launcher.title": "AITC DevTools Launcher",
2060
+ "launcher.description": "터미널 QR을 스캔하거나 URL을 입력하세요.",
2061
+ "launcher.installCta": "폰에 런처 설치하기",
2062
+ "launcher.urlPlaceholder": "https://example.trycloudflare.com",
2063
+ "launcher.openBtn": "Open",
2064
+ "launcher.scanBtn": "QR 카메라로 스캔",
2065
+ "launcher.rescanBtn": "Rescan",
2066
+ "launcher.noCamera": "카메라를 사용할 수 없습니다 — URL을 직접 붙여넣으세요.",
2067
+ "launcher.cameraError": "카메라에 접근할 수 없습니다 — URL을 직접 붙여넣으세요.",
2068
+ "launcher.invalidUrlHttps": "올바른 https:// URL을 입력하세요 (터미널의 터널 URL).",
2069
+ "launcher.invalidUrl": "올바른 http(s):// URL을 입력하세요."
2070
+ },
2071
+ en
2072
+ };
2073
+ /**
2074
+ * Decide a locale from a BCP-47 language tag. `ko` (and `ko-*`) → `'ko'`,
2075
+ * everything else → `'en'`. Shared by the browser (`navigator.language`) and
2076
+ * Node (`Accept-Language` header) paths so both resolve identically.
2077
+ */
2078
+ function localeFromLanguageTag(lang) {
2079
+ return /^ko\b/i.test(lang) ? "ko" : "en";
2080
+ }
2081
+ /**
2082
+ * Decide a locale from an HTTP `Accept-Language` header value. The Node-served
2083
+ * surfaces (e.g. the qr-http-server dashboard) have no `navigator`, so the
2084
+ * request header is the only language signal. Reads the FIRST language tag
2085
+ * (highest priority, ignoring `q=` weights — good enough for ko/en) and feeds
2086
+ * it through the same `ko`-vs-`en` heuristic `detectLocale` uses. Returns `'en'`
2087
+ * for an empty/missing header.
2088
+ */
2089
+ function parseAcceptLanguage(header) {
2090
+ if (!header) return "en";
2091
+ return localeFromLanguageTag(header.split(",")[0]?.trim().split(";")[0]?.trim() ?? "");
2092
+ }
2093
+ /**
2094
+ * A locale-bound string resolver for surfaces that can't use the in-memory
2095
+ * `getLocale()` cache — notably the Node HTTP server, which resolves locale
2096
+ * per-request from `Accept-Language` rather than from a process-global. Returns
2097
+ * a `t`-compatible closure over the SAME `ko`/`en` tables (single source of
2098
+ * truth), so the dashboard/attach HTML shares the exact 169-key catalog the
2099
+ * browser surfaces use. The `key: StringKey` signature keeps compile-time key
2100
+ * safety on the Node path identical to `t()`.
2101
+ */
2102
+ function resolveLocaleStrings(locale) {
2103
+ const table = tables[locale];
2104
+ return (key, vars) => {
2105
+ const raw = table[key] ?? key;
2106
+ if (!vars) return raw;
2107
+ return raw.replace(/\{(\w+)\}/g, (match, name) => {
2108
+ const value = vars[name];
2109
+ return value === void 0 ? match : String(value);
2110
+ });
2111
+ };
2112
+ }
2113
+ //#endregion
2114
+ //#region src/mcp/dashboard.generated.ts
2115
+ const dashboardChromeHtmlKo = `<!DOCTYPE html>
2116
+ <html lang="ko"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>AIT 디버그 Dashboard</title><style>
2117
+ *, *::before, *::after { box-sizing: border-box; }
2118
+ body {
2119
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2120
+ background: #0d1117; color: #c9d1d9;
2121
+ display: flex; flex-direction: column; align-items: center;
2122
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
2123
+ gap: 1.5rem;
2124
+ }
2125
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
2126
+ .updated { font-size: 0.75rem; opacity: 0.4; font-family: monospace; margin: 0; }
2127
+ section { width: 100%; max-width: 520px; }
2128
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
2129
+ .status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
2130
+ .status-up { background: #238636; color: #fff; }
2131
+ .status-down { background: #6e7681; color: #fff; }
2132
+ img.qr {
2133
+ width: min(80vw, 300px); height: auto;
2134
+ image-rendering: pixelated;
2135
+ background: #fff; padding: 0.75rem; border-radius: 10px;
2136
+ display: block; margin: 0.5rem auto;
2137
+ }
2138
+ .url-box {
2139
+ font-family: monospace; font-size: 0.7rem;
2140
+ word-break: break-all; opacity: 0.45;
2141
+ background: #161b22; padding: 0.6rem 0.85rem;
2142
+ border-radius: 6px; border: 1px solid #30363d; margin: 0.5rem 0 0;
2143
+ }
2144
+ .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
2145
+ ul { margin: 0; padding-left: 1.25rem; }
2146
+ li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
2147
+ li.empty { opacity: 0.4; list-style: none; padding-left: 0; }
2148
+ .page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }
2149
+ .page-url { word-break: break-all; }
2150
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
2151
+ </style></head><body><h1>AIT 디버그 Dashboard</h1><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>__PAGES_SECTION__</body></html>`;
2152
+ const attachChromeHtmlKo = `<!DOCTYPE html>
2153
+ <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>
2154
+ *, *::before, *::after { box-sizing: border-box; }
2155
+ body {
2156
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2157
+ background: #0d1117; color: #c9d1d9;
2158
+ display: flex; flex-direction: column; align-items: center;
2159
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
2160
+ gap: 1.5rem;
2161
+ }
2162
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
2163
+ .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
2164
+ img.qr {
2165
+ width: min(90vw, 360px); height: auto;
2166
+ image-rendering: pixelated;
2167
+ background: #fff; padding: 1rem; border-radius: 12px;
2168
+ }
2169
+ section { width: 100%; max-width: 480px; }
2170
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
2171
+ ol, ul { margin: 0; padding-left: 1.25rem; }
2172
+ li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
2173
+ .url-box {
2174
+ font-family: monospace; font-size: 0.72rem;
2175
+ word-break: break-all; opacity: 0.4;
2176
+ background: #161b22; padding: 0.75rem 1rem;
2177
+ border-radius: 6px; border: 1px solid #30363d;
2178
+ }
2179
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
2180
+ </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1><p class="label">deployment: __SAFE_LABEL__</p><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/><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><h2>URL (fallback)</h2><p class="url-box">__SAFE_ATTACH_URL__</p></section></body></html>`;
2181
+ const dashboardChromeHtmlEn = `<!DOCTYPE html>
2182
+ <html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>AIT Debug Dashboard</title><style>
2183
+ *, *::before, *::after { box-sizing: border-box; }
2184
+ body {
2185
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2186
+ background: #0d1117; color: #c9d1d9;
2187
+ display: flex; flex-direction: column; align-items: center;
2188
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
2189
+ gap: 1.5rem;
2190
+ }
2191
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
2192
+ .updated { font-size: 0.75rem; opacity: 0.4; font-family: monospace; margin: 0; }
2193
+ section { width: 100%; max-width: 520px; }
2194
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
2195
+ .status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
2196
+ .status-up { background: #238636; color: #fff; }
2197
+ .status-down { background: #6e7681; color: #fff; }
2198
+ img.qr {
2199
+ width: min(80vw, 300px); height: auto;
2200
+ image-rendering: pixelated;
2201
+ background: #fff; padding: 0.75rem; border-radius: 10px;
2202
+ display: block; margin: 0.5rem auto;
2203
+ }
2204
+ .url-box {
2205
+ font-family: monospace; font-size: 0.7rem;
2206
+ word-break: break-all; opacity: 0.45;
2207
+ background: #161b22; padding: 0.6rem 0.85rem;
2208
+ border-radius: 6px; border: 1px solid #30363d; margin: 0.5rem 0 0;
2209
+ }
2210
+ .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
2211
+ ul { margin: 0; padding-left: 1.25rem; }
2212
+ li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
2213
+ li.empty { opacity: 0.4; list-style: none; padding-left: 0; }
2214
+ .page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }
2215
+ .page-url { word-break: break-all; }
2216
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
2217
+ </style></head><body><h1>AIT Debug Dashboard</h1><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>__PAGES_SECTION__</body></html>`;
2218
+ const attachChromeHtmlEn = `<!DOCTYPE html>
2219
+ <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>
2220
+ *, *::before, *::after { box-sizing: border-box; }
2221
+ body {
2222
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2223
+ background: #0d1117; color: #c9d1d9;
2224
+ display: flex; flex-direction: column; align-items: center;
2225
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
2226
+ gap: 1.5rem;
2227
+ }
2228
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
2229
+ .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
2230
+ img.qr {
2231
+ width: min(90vw, 360px); height: auto;
2232
+ image-rendering: pixelated;
2233
+ background: #fff; padding: 1rem; border-radius: 12px;
2234
+ }
2235
+ section { width: 100%; max-width: 480px; }
2236
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
2237
+ ol, ul { margin: 0; padding-left: 1.25rem; }
2238
+ li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
2239
+ .url-box {
2240
+ font-family: monospace; font-size: 0.72rem;
2241
+ word-break: break-all; opacity: 0.4;
2242
+ background: #161b22; padding: 0.75rem 1rem;
2243
+ border-radius: 6px; border: 1px solid #30363d;
2244
+ }
2245
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
2246
+ </style></head><body><h1>AIT Debug Session — QR Scan</h1><p class="label">deployment: __SAFE_LABEL__</p><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/><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><h2>URL (fallback)</h2><p class="url-box">__SAFE_ATTACH_URL__</p></section></body></html>`;
2247
+ /** Map from Locale to the precompiled dashboard chrome string. */
2248
+ const dashboardChromeByLocale = {
2249
+ ko: dashboardChromeHtmlKo,
2250
+ en: dashboardChromeHtmlEn
2251
+ };
2252
+ /** Map from Locale to the precompiled attach page chrome string. */
2253
+ const attachChromeByLocale = {
2254
+ ko: attachChromeHtmlKo,
2255
+ en: attachChromeHtmlEn
2256
+ };
2257
+ //#endregion
1582
2258
  //#region src/mcp/qr-http-server.ts
2259
+ /** HTML 특수문자를 이스케이프한다. */
2260
+ function escapeHtml(s) {
2261
+ return s.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
2262
+ }
2263
+ /**
2264
+ * Dashboard HTML — precompiled chrome에 per-request 동적 값을 채워 완성한다.
2265
+ *
2266
+ * 토큰 채우기 순서:
2267
+ * 1. chrome string(locale별 precompile)을 가져온다.
2268
+ * 2. 동적 부분을 단순 replaceAll로 채운다 (토큰이 HTML context 밖에 있으므로 안전).
2269
+ * 3. inline SSE <script>를 </body> 직전에 주입한다.
2270
+ *
2271
+ * 동적 파트 분류:
2272
+ * - "token-fill": 단일 값 교체 (__NOW__, __TUNNEL_CLASS__, __TUNNEL_STATUS__,
2273
+ * __ATTACH_SECTION__)
2274
+ * - "runtime builder": 가변 길이 구조 (__PAGES_SECTION__ — 조건부 렌더 + 가변 rows)
2275
+ * - "suffix": inline SSE <script> (빌드 파이프라인 없는 클라이언트 스크립트, locale
2276
+ * aware 문자열 포함)
2277
+ *
2278
+ * SECRET-HANDLING:
2279
+ * - attachUrl은 url-box 안에서만 노출 (TOTP at= 코드 캡슐 그대로).
2280
+ * - tunnel wssUrl은 "터널 연결됨" 상태 표시에서 UP/DOWN만 노출.
2281
+ * wssUrl 값 자체는 dashboard HTML에 넣지 않는다.
2282
+ */
2283
+ function buildDashboardHtml(state, qrDataUrl, locale) {
2284
+ const s = resolveLocaleStrings(locale);
2285
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2286
+ const tunnelStatus = state.tunnel.up ? s("dashboard.tunnel.up") : s("dashboard.tunnel.down");
2287
+ const tunnelClass = state.tunnel.up ? "status-up" : "status-down";
2288
+ let attachSection;
2289
+ if (qrDataUrl && state.attachUrl) attachSection = `<img class="qr" src="${qrDataUrl}" alt="attach QR" /><p class="url-box">${escapeHtml(state.attachUrl)}</p>`;
2290
+ else attachSection = `<p class="hint">${escapeHtml(s("dashboard.attach.hint"))}</p>`;
2291
+ const pagesSection = state.pages === null ? "" : `<hr /><section id="pages-section"><h2>${escapeHtml(s("dashboard.pages.section"))}</h2><ul id="pages-list">${state.pages.length > 0 ? state.pages.map((p) => {
2292
+ return `<li><span class="page-id">${escapeHtml(p.id)}</span> <span class="page-url">${escapeHtml(p.url.slice(0, 120))}</span></li>`;
2293
+ }).join("\n") : `<li class="empty">${escapeHtml(s("dashboard.pages.empty"))}</li>`}</ul></section>`;
2294
+ const sseStrings = {
2295
+ tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
2296
+ tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
2297
+ pagesEmpty: JSON.stringify(s("dashboard.pages.empty")),
2298
+ attachHint: JSON.stringify(s("dashboard.attach.hint"))
2299
+ };
2300
+ const filled = dashboardChromeByLocale[locale].replaceAll("__NOW__", escapeHtml(now)).replaceAll("__TUNNEL_CLASS__", tunnelClass).replaceAll("__TUNNEL_STATUS__", escapeHtml(tunnelStatus)).replaceAll("__ATTACH_SECTION__", attachSection).replaceAll("__PAGES_SECTION__", pagesSection);
2301
+ const sseScript = buildSseScript(sseStrings);
2302
+ return filled.replace("</body>", `${sseScript}\n</body>`);
2303
+ }
2304
+ /**
2305
+ * Inline SSE client <script> — injected into the dashboard HTML at runtime.
2306
+ *
2307
+ * Subscribes to /events and updates the DOM without a build pipeline.
2308
+ * client side: attachUrl은 DOM에 렌더링, wssUrl은 절대 렌더링하지 않는다.
2309
+ * pages === null 이면 섹션을 건드리지 않는다 (#411).
2310
+ *
2311
+ * 문자열 인자는 빌드타임에 ko/en 테이블에서 가져와 JSON.stringify로 이미 escape됨.
2312
+ */
2313
+ function buildSseScript(strings) {
2314
+ return `<script>
2315
+ // SSE — /events 구독해 상태 자동 갱신. 빌드 파이프라인 없는 인라인 스크립트.
2316
+ (function () {
2317
+ var TUNNEL_UP = ${strings.tunnelUp};
2318
+ var TUNNEL_DOWN = ${strings.tunnelDown};
2319
+ var PAGES_EMPTY = ${strings.pagesEmpty};
2320
+ var ATTACH_HINT = ${strings.attachHint};
2321
+ var src = new EventSource('/events');
2322
+ src.onmessage = function (e) {
2323
+ try {
2324
+ var s = JSON.parse(e.data);
2325
+ // 터널 상태 갱신
2326
+ var el = document.getElementById('tunnel-status');
2327
+ if (el) {
2328
+ el.textContent = s.tunnel && s.tunnel.up ? TUNNEL_UP : TUNNEL_DOWN;
2329
+ el.className = 'status ' + (s.tunnel && s.tunnel.up ? 'status-up' : 'status-down');
2330
+ }
2331
+ // page 목록 갱신 — pages === null(env 2)이면 섹션 자체를 숨긴 채 둔다.
2332
+ // 정적 렌더가 #pages-section을 아예 안 그렸으므로 여기서도 손대지 않아
2333
+ // SSE push 때 섹션이 되살아나지 않는다(#411). 배열일 때만 목록을 채운다.
2334
+ if (s.pages !== null && s.pages !== undefined) {
2335
+ var ul = document.getElementById('pages-list');
2336
+ if (ul) {
2337
+ if (s.pages.length === 0) {
2338
+ ul.innerHTML = '<li class="empty">' + PAGES_EMPTY + '</li>';
2339
+ } else {
2340
+ ul.innerHTML = s.pages.map(function (p) {
2341
+ var sid = String(p.id || '').slice(0, 36).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
2342
+ var su = String(p.url || '').slice(0, 120).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
2343
+ return '<li><span class="page-id">' + sid + '</span> <span class="page-url">' + su + '</span></li>';
2344
+ }).join('');
2345
+ }
2346
+ }
2347
+ }
2348
+ // attachUrl QR 갱신 — attachUrl이 없으면 hint 표시.
2349
+ var sec = document.getElementById('attach-section');
2350
+ if (sec) {
2351
+ if (s.attachUrl) {
2352
+ // QR은 서버에서 새로 렌더한 /qr.png?u= 로 img src 교체.
2353
+ // TOTP at= 코드는 attachUrl 안에 캡슐화 — 별도 노출 없음.
2354
+ // wssUrl은 절대 DOM에 렌더하지 않는다 (SECRET-HANDLING).
2355
+ var encoded = encodeURIComponent(s.attachUrl);
2356
+ var safeUrl = String(s.attachUrl).slice(0, 2000).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
2357
+ sec.innerHTML =
2358
+ '<img class="qr" src="/qr.png?u=' + encoded + '" alt="attach QR" />' +
2359
+ '<p class="url-box">' + safeUrl + '</p>';
2360
+ } else {
2361
+ sec.innerHTML = '<p class="hint">' + ATTACH_HINT + '</p>';
2362
+ }
2363
+ }
2364
+ // 갱신 시각
2365
+ var upd = document.getElementById('updated');
2366
+ if (upd) upd.textContent = upd.textContent.replace(/[^ ]+$/, new Date().toISOString());
2367
+ } catch (_) { /* 파싱 오류 무시 */ }
2368
+ };
2369
+ src.onerror = function () {
2370
+ // 재연결은 EventSource가 자동 처리 (spec 기본 동작).
2371
+ };
2372
+ })();
2373
+ <\/script>`;
2374
+ }
2375
+ /**
2376
+ * Attach 페이지 HTML — precompiled chrome에 per-request 동적 값을 채워 완성한다.
2377
+ *
2378
+ * 동적 파트:
2379
+ * - __QR_DATA_URL__ : base64 data URL (QR 이미지)
2380
+ * - __SAFE_LABEL__ : HTML-escaped deploymentId label
2381
+ * - __SAFE_ATTACH_URL__ : HTML-escaped attach URL (TOTP at= 코드 포함 — 의도된 전달)
2382
+ *
2383
+ * SECRET-HANDLING: TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — 의도된 transport.
2384
+ */
2385
+ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale) {
2386
+ return attachChromeByLocale[locale].replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl);
2387
+ }
1583
2388
  /**
1584
2389
  * 로컬 HTTP 서버를 127.0.0.1 random port(또는 `AIT_DEBUG_HTTP_PORT` env)로 시작한다.
1585
2390
  * MCP debug server 생애주기에 묶어 사용 — `runDebugServer` shutdown 시 `close()`로 정리.
@@ -1606,6 +2411,7 @@ async function startQrHttpServer(getDashboardState) {
1606
2411
  const server = createServer(async (req, res) => {
1607
2412
  const [path, query = ""] = (req.url ?? "/").split("?", 2);
1608
2413
  const params = new URLSearchParams(query ?? "");
2414
+ const locale = parseAcceptLanguage(req.headers["accept-language"]);
1609
2415
  if (path === "/") {
1610
2416
  if (!getDashboardState) {
1611
2417
  res.writeHead(204, { "Content-Type": "text/plain; charset=utf-8" });
@@ -1620,7 +2426,7 @@ async function startQrHttpServer(getDashboardState) {
1620
2426
  errorCorrectionLevel: "M"
1621
2427
  });
1622
2428
  } catch {}
1623
- const html = buildDashboardHtml(state, qrDataUrl);
2429
+ const html = buildDashboardHtml(state, qrDataUrl, locale);
1624
2430
  res.writeHead(200, {
1625
2431
  "Content-Type": "text/html; charset=utf-8",
1626
2432
  "Cache-Control": "no-store"
@@ -1667,7 +2473,7 @@ async function startQrHttpServer(getDashboardState) {
1667
2473
  type: "image/png",
1668
2474
  errorCorrectionLevel: "M"
1669
2475
  }).then((dataUrl) => {
1670
- const html = buildAttachHtml(dataUrl, deploymentIdLabel.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`), attachUrl.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`));
2476
+ const html = buildAttachHtml(dataUrl, escapeHtml(deploymentIdLabel), escapeHtml(attachUrl), locale);
1671
2477
  res.writeHead(200, {
1672
2478
  "Content-Type": "text/html; charset=utf-8",
1673
2479
  "Cache-Control": "no-store"
@@ -1735,329 +2541,6 @@ async function startQrHttpServer(getDashboardState) {
1735
2541
  }
1736
2542
  };
1737
2543
  }
1738
- /**
1739
- * Dashboard HTML — 터널/page/attachUrl 상태를 표시하고 SSE로 자동 갱신.
1740
- *
1741
- * SECRET-HANDLING:
1742
- * - attachUrl은 url-box 안에서만 노출 (TOTP at= 코드 캡슐 그대로).
1743
- * - tunnel wssUrl은 "터널 연결됨" 상태 표시에서 HOST가 아닌 UP/DOWN만 노출.
1744
- * wssUrl 값 자체는 dashboard HTML에 넣지 않는다 — 브라우저 탭이 보안 경계 밖에 있음.
1745
- * - inline <script>로 /events SSE 구독 — 빌드 파이프라인 추가 없음.
1746
- */
1747
- function buildDashboardHtml(state, qrDataUrl) {
1748
- const tunnelStatus = state.tunnel.up ? "연결됨" : "끊어짐";
1749
- const tunnelClass = state.tunnel.up ? "status-up" : "status-down";
1750
- const now = (/* @__PURE__ */ new Date()).toISOString();
1751
- const pagesHtml = state.pages.length > 0 ? state.pages.map((p) => {
1752
- return `<li><span class="page-id">${p.id.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`)}</span> <span class="page-url">${p.url.slice(0, 120).replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`)}</span></li>`;
1753
- }).join("\n") : "<li class=\"empty\">attach된 페이지 없음</li>";
1754
- let attachSection;
1755
- if (qrDataUrl && state.attachUrl) attachSection = `
1756
- <img class="qr" src="${qrDataUrl}" alt="attach QR" />
1757
- <p class="url-box">${state.attachUrl.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`)}</p>`;
1758
- else attachSection = "<p class=\"hint\">build_attach_url MCP tool을 호출하면 QR이 여기에 표시됩니다.</p>";
1759
- return `<!DOCTYPE html>
1760
- <html lang="ko">
1761
- <head>
1762
- <meta charset="utf-8" />
1763
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1764
- <title>AIT 디버그 Dashboard</title>
1765
- <style>
1766
- *, *::before, *::after { box-sizing: border-box; }
1767
- body {
1768
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1769
- background: #0d1117; color: #c9d1d9;
1770
- display: flex; flex-direction: column; align-items: center;
1771
- min-height: 100vh; margin: 0; padding: 2rem 1rem;
1772
- gap: 1.5rem;
1773
- }
1774
- h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
1775
- .updated { font-size: 0.75rem; opacity: 0.4; font-family: monospace; margin: 0; }
1776
- section { width: 100%; max-width: 520px; }
1777
- h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
1778
- .status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
1779
- .status-up { background: #238636; color: #fff; }
1780
- .status-down { background: #6e7681; color: #fff; }
1781
- img.qr {
1782
- width: min(80vw, 300px); height: auto;
1783
- image-rendering: pixelated;
1784
- background: #fff; padding: 0.75rem; border-radius: 10px;
1785
- display: block; margin: 0.5rem auto;
1786
- }
1787
- .url-box {
1788
- font-family: monospace; font-size: 0.7rem;
1789
- word-break: break-all; opacity: 0.45;
1790
- background: #161b22; padding: 0.6rem 0.85rem;
1791
- border-radius: 6px; border: 1px solid #30363d; margin: 0.5rem 0 0;
1792
- }
1793
- .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
1794
- ul { margin: 0; padding-left: 1.25rem; }
1795
- li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
1796
- li.empty { opacity: 0.4; list-style: none; padding-left: 0; }
1797
- .page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }
1798
- .page-url { word-break: break-all; }
1799
- hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
1800
- </style>
1801
- </head>
1802
- <body>
1803
- <h1>AIT 디버그 Dashboard</h1>
1804
- <p class="updated" id="updated">마지막 갱신: ${now}</p>
1805
-
1806
- <section>
1807
- <h2>터널 상태</h2>
1808
- <span class="status ${tunnelClass}" id="tunnel-status">${tunnelStatus}</span>
1809
- </section>
1810
-
1811
- <hr />
1812
-
1813
- <section>
1814
- <h2>Attach QR</h2>
1815
- <div id="attach-section">${attachSection}</div>
1816
- </section>
1817
-
1818
- <hr />
1819
-
1820
- <section>
1821
- <h2>연결된 Pages</h2>
1822
- <ul id="pages-list">${pagesHtml}</ul>
1823
- </section>
1824
-
1825
- <script>
1826
- // SSE — /events 구독해 상태 자동 갱신. 빌드 파이프라인 없는 인라인 스크립트.
1827
- (function () {
1828
- var src = new EventSource('/events');
1829
- src.onmessage = function (e) {
1830
- try {
1831
- var s = JSON.parse(e.data);
1832
- // 터널 상태 갱신
1833
- var el = document.getElementById('tunnel-status');
1834
- if (el) {
1835
- el.textContent = s.tunnel && s.tunnel.up ? '연결됨' : '끊어짐';
1836
- el.className = 'status ' + (s.tunnel && s.tunnel.up ? 'status-up' : 'status-down');
1837
- }
1838
- // page 목록 갱신
1839
- var ul = document.getElementById('pages-list');
1840
- if (ul) {
1841
- if (!s.pages || s.pages.length === 0) {
1842
- ul.innerHTML = '<li class="empty">attach된 페이지 없음</li>';
1843
- } else {
1844
- ul.innerHTML = s.pages.map(function (p) {
1845
- var sid = String(p.id || '').slice(0, 36).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
1846
- var su = String(p.url || '').slice(0, 120).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
1847
- return '<li><span class="page-id">' + sid + '</span> <span class="page-url">' + su + '</span></li>';
1848
- }).join('');
1849
- }
1850
- }
1851
- // attachUrl QR 갱신 — attachUrl이 없으면 hint 표시.
1852
- var sec = document.getElementById('attach-section');
1853
- if (sec) {
1854
- if (s.attachUrl) {
1855
- // QR은 서버에서 새로 렌더한 /qr.png?u= 로 img src 교체.
1856
- // TOTP at= 코드는 attachUrl 안에 캡슐화 — 별도 노출 없음.
1857
- var encoded = encodeURIComponent(s.attachUrl);
1858
- var safeUrl = String(s.attachUrl).slice(0, 2000).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
1859
- sec.innerHTML =
1860
- '<img class="qr" src="/qr.png?u=' + encoded + '" alt="attach QR" />' +
1861
- '<p class="url-box">' + safeUrl + '</p>';
1862
- } else {
1863
- sec.innerHTML = '<p class="hint">build_attach_url MCP tool을 호출하면 QR이 여기에 표시됩니다.</p>';
1864
- }
1865
- }
1866
- // 갱신 시각
1867
- var upd = document.getElementById('updated');
1868
- if (upd) upd.textContent = '마지막 갱신: ' + new Date().toISOString();
1869
- } catch (_) { /* 파싱 오류 무시 */ }
1870
- };
1871
- src.onerror = function () {
1872
- // 재연결은 EventSource가 자동 처리 (spec 기본 동작).
1873
- };
1874
- })();
1875
- <\/script>
1876
- </body>
1877
- </html>`;
1878
- }
1879
- /**
1880
- * QR 스캔 페이지 HTML 본문.
1881
- * dark theme, inline style, 외부 fetch 없음.
1882
- */
1883
- function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl) {
1884
- return `<!DOCTYPE html>
1885
- <html lang="ko">
1886
- <head>
1887
- <meta charset="utf-8" />
1888
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1889
- <title>AIT 디버그 세션 — QR 스캔</title>
1890
- <style>
1891
- *, *::before, *::after { box-sizing: border-box; }
1892
- body {
1893
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1894
- background: #0d1117; color: #c9d1d9;
1895
- display: flex; flex-direction: column; align-items: center;
1896
- min-height: 100vh; margin: 0; padding: 2rem 1rem;
1897
- gap: 1.5rem;
1898
- }
1899
- h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
1900
- .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
1901
- img.qr {
1902
- width: min(90vw, 360px); height: auto;
1903
- image-rendering: pixelated;
1904
- background: #fff; padding: 1rem; border-radius: 12px;
1905
- }
1906
- section { width: 100%; max-width: 480px; }
1907
- h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
1908
- ol, ul { margin: 0; padding-left: 1.25rem; }
1909
- li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
1910
- .url-box {
1911
- font-family: monospace; font-size: 0.72rem;
1912
- word-break: break-all; opacity: 0.4;
1913
- background: #161b22; padding: 0.75rem 1rem;
1914
- border-radius: 6px; border: 1px solid #30363d;
1915
- }
1916
- hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
1917
- </style>
1918
- </head>
1919
- <body>
1920
- <h1>AIT 디버그 세션 — QR 스캔</h1>
1921
- <p class="label">deployment: ${safeLabel}</p>
1922
- <img class="qr" src="${qrDataUrl}" alt="attach QR" />
1923
-
1924
- <section>
1925
- <h2>스캔 절차</h2>
1926
- <ol>
1927
- <li>토스 앱을 실행하세요.</li>
1928
- <li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li>
1929
- <li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li>
1930
- <li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li>
1931
- </ol>
1932
- </section>
1933
-
1934
- <hr />
1935
-
1936
- <section>
1937
- <h2>진단 체크리스트</h2>
1938
- <ul>
1939
- <li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li>
1940
- <li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li>
1941
- <li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li>
1942
- <li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li>
1943
- </ul>
1944
- </section>
1945
-
1946
- <hr />
1947
-
1948
- <section>
1949
- <h2>URL (fallback)</h2>
1950
- <p class="url-box">${safeAttachUrl}</p>
1951
- </section>
1952
- </body>
1953
- </html>`;
1954
- }
1955
- //#endregion
1956
- //#region src/mcp/relay-secret-store.ts
1957
- /**
1958
- * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to
1959
- * a project-local single file `.ait_relay`).
1960
- *
1961
- * Two surfaces, intentionally split by who is allowed to write:
1962
- *
1963
- * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin
1964
- * (env-2 relay boot). Mints a fresh secret on first run and persists it to
1965
- * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.
1966
- *
1967
- * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP
1968
- * daemon when switching into a relay environment. It NEVER mints, chmods, or
1969
- * creates anything: it only reads an already-existing `.ait_relay` and injects
1970
- * its value into `env`. A daemon that minted would defeat the #250 fail-fast
1971
- * (the daemon is the verifier side — a self-minted secret would let a leaked
1972
- * tunnel URL attach unauthenticated), so the daemon stays read-only.
1973
- *
1974
- * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot
1975
- * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is
1976
- * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace
1977
- * root (which always has a package.json). So the project root is supplied
1978
- * per-debug-session through `start_debug`.
1979
- *
1980
- * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and
1981
- * its length MUST NOT appear in any log, error message, stdout, stderr, or
1982
- * assertion output. Only boolean pass/fail signals are safe to surface, and the
1983
- * discovered file path is never logged either. The persist file is written mode
1984
- * 0600.
1985
- */
1986
- /** Project-local secret file name (single file, not a directory). */
1987
- const RELAY_SECRET_FILE_NAME = ".ait_relay";
1988
- /**
1989
- * Walks upward from `start` and returns the nearest directory that contains a
1990
- * `package.json`. Falls back to `start` itself when none is found (so a write
1991
- * still lands somewhere deterministic).
1992
- *
1993
- * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret
1994
- * minted by `pnpm dev` is found by the daemon: real mini-apps keep
1995
- * `vite.config.ts` and `package.json` in the same directory, so
1996
- * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is
1997
- * the package's own directory — the one the daemon can also reach via the
1998
- * per-session projectRoot.
1999
- *
2000
- * @param start - Directory to start the upward walk from.
2001
- * @param existsSyncFn - Injectable existence check (defaults to node:fs).
2002
- */
2003
- function nearestPackageJsonDir(start, existsSyncFn) {
2004
- let dir = start;
2005
- while (true) {
2006
- if (existsSyncFn(join(dir, "package.json"))) return dir;
2007
- const parent = dirname(dir);
2008
- if (parent === dir) return start;
2009
- dir = parent;
2010
- }
2011
- }
2012
- /**
2013
- * Absolute path to the project-local `.ait_relay` file for a given start
2014
- * directory (resolved against the nearest package.json directory).
2015
- *
2016
- * Exported so tests can compute the expected path without duplicating the
2017
- * resolution logic.
2018
- */
2019
- function relaySecretFilePath(start, existsSyncFn) {
2020
- return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);
2021
- }
2022
- /**
2023
- * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a
2024
- * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.
2025
- *
2026
- * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,
2027
- * chmods, or creates files/directories. The daemon must not mint because it is
2028
- * the relay verifier side — a self-minted secret would defeat the #250 fail-fast
2029
- * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is
2030
- * found the function leaves `env` untouched and returns without throwing, so the
2031
- * downstream `assertRelayAuthConfigured()` stays the single fail-fast.
2032
- *
2033
- * Resolution order:
2034
- * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).
2035
- * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject
2036
- * iff the contents pass {@link isValidRelayAuthSecret}.
2037
- * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.
2038
- *
2039
- * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before
2040
- * assignment; its value, length, and the discovered file path are never logged.
2041
- *
2042
- * @param deps - Optional dependency overrides for testing.
2043
- */
2044
- async function loadRelaySecretReadOnly(deps) {
2045
- const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};
2046
- const { isValidRelayAuthSecret } = await import("../totp-CQFmgOhM.js");
2047
- if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) return;
2048
- if (projectRoot === void 0) return;
2049
- const fs = fsDep ?? await import("node:fs");
2050
- const secretPath = relaySecretFilePath(projectRoot, existsSyncDep ?? fs.existsSync);
2051
- if (!fs.existsSync(secretPath)) return;
2052
- let stored;
2053
- try {
2054
- stored = fs.readFileSync(secretPath, "utf8").trim();
2055
- } catch {
2056
- return;
2057
- }
2058
- if (!isValidRelayAuthSecret(stored)) return;
2059
- env.AIT_DEBUG_TOTP_SECRET = stored;
2060
- }
2061
2544
  //#endregion
2062
2545
  //#region src/mcp/server-lock.ts
2063
2546
  /**
@@ -2124,18 +2607,10 @@ function ensureLockDir(lockPath) {
2124
2607
  /**
2125
2608
  * Returns `true` when the given PID refers to a running process.
2126
2609
  *
2127
- * Uses `process.kill(pid, 0)` a no-op signal that succeeds when the process
2128
- * exists and we have permission to signal it; throws ESRCH when it doesn't exist.
2610
+ * Re-exported from `../shared/parent-watcher` so external callers that
2611
+ * import from `./server-lock` keep working without an import-path change.
2129
2612
  */
2130
- function isPidAlive(pid) {
2131
- try {
2132
- process.kill(pid, 0);
2133
- return true;
2134
- } catch (err) {
2135
- if (err.code === "EPERM") return true;
2136
- return false;
2137
- }
2138
- }
2613
+ const isPidAlive = isPidAlive$1;
2139
2614
  function readLock(lockPath) {
2140
2615
  if (!existsSync(lockPath)) return null;
2141
2616
  try {
@@ -2449,6 +2924,10 @@ const DEBUG_TOOL_DEFINITIONS = [
2449
2924
  open_in_browser: {
2450
2925
  type: "boolean",
2451
2926
  description: "If true (default), render the QR as a PNG and open it in the OS default browser. Only works when the MCP server is running on a local GUI machine — headless or remote container environments should set this to false to use the text QR fallback."
2927
+ },
2928
+ projectRoot: {
2929
+ type: "string",
2930
+ description: "Absolute path to the mini-app project root (the directory containing its package.json and .ait_urls). When AIT_TUNNEL_BASE_URL is unset (env 2 / relay-mobile only), the daemon reads the app tunnel URL from <projectRoot>/.ait_urls written by the dev server (tunnel:{cdp:true}). Pass this because the daemon's own cwd is fixed at launch. Omit when AIT_TUNNEL_BASE_URL is set explicitly."
2452
2931
  }
2453
2932
  },
2454
2933
  required: []
@@ -3432,7 +3911,7 @@ async function readMcpSdkVersion() {
3432
3911
  * some test environments that skip the build step).
3433
3912
  */
3434
3913
  function readDevtoolsVersion() {
3435
- return "0.1.58";
3914
+ return "0.1.60";
3436
3915
  }
3437
3916
  /**
3438
3917
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -3920,7 +4399,7 @@ function createDebugServer(deps) {
3920
4399
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
3921
4400
  const server = new Server({
3922
4401
  name: "ait-debug",
3923
- version: "0.1.58"
4402
+ version: "0.1.60"
3924
4403
  }, { capabilities: { tools: { listChanged: true } } });
3925
4404
  server.setRequestHandler(ListToolsRequestSchema, () => {
3926
4405
  const conn = router.active;
@@ -3995,8 +4474,14 @@ function createDebugServer(deps) {
3995
4474
  const waitForAttach = request.params.arguments?.wait_for_attach === true;
3996
4475
  const openInBrowser = request.params.arguments?.open_in_browser !== false;
3997
4476
  if (env === "relay-mobile") {
3998
- const tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
3999
- if (tunnelHttpUrl === "") return mcpError("build_attach_url(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. unplugin tunnel:{cdp:true} 배너에 출력되는 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 전달하세요.");
4477
+ const rawBuildProjectRoot = request.params.arguments?.projectRoot;
4478
+ const buildProjectRoot = typeof rawBuildProjectRoot === "string" ? rawBuildProjectRoot : void 0;
4479
+ let tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
4480
+ if (tunnelHttpUrl === "" && buildProjectRoot !== void 0) {
4481
+ const { readRelayUrls } = await import("../relay-url-store-qaoe0zOD.js");
4482
+ tunnelHttpUrl = (await readRelayUrls({ projectRoot: buildProjectRoot }))?.tunnelBaseUrl ?? "";
4483
+ }
4484
+ if (tunnelHttpUrl === "") return mcpError("build_attach_url(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. 자동 발견이 되지 않을 경우 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 직접 전달하세요.");
4000
4485
  const tunnelStatus = getTunnelStatus();
4001
4486
  if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return mcpError("build_attach_url(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.");
4002
4487
  const secret = getTotpSecret();
@@ -4516,45 +5001,6 @@ function startAttachWatcher(connection, server, intervalMs = 1e3, onFirstAttach)
4516
5001
  } };
4517
5002
  }
4518
5003
  /**
4519
- * Starts a periodic watcher that detects when the parent process (e.g. Claude
4520
- * Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the
4521
- * daemon can self-terminate rather than running as a zombie.
4522
- *
4523
- * Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns
4524
- * `{ stop(): void }`, injectable deps for testability.
4525
- *
4526
- * @param onOrphaned - Called once when the parent is gone.
4527
- * @param opts.intervalMs - Poll interval in milliseconds (default 5 000).
4528
- * @param opts.initialPpid - Parent PID to watch (default `process.ppid`).
4529
- * @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).
4530
- * @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).
4531
- * Detects ppid changes as well as death.
4532
- * @param opts.log - Logger (default `process.stderr.write`).
4533
- *
4534
- * @returns `stop` — call during shutdown to clear the interval.
4535
- */
4536
- function startParentWatcher(onOrphaned, opts) {
4537
- const { intervalMs = 5e3, initialPpid = process.ppid, isAlive = isPidAlive, getPpid = () => process.ppid, log = (msg) => process.stderr.write(msg) } = opts ?? {};
4538
- if (initialPpid <= 1) {
4539
- log("[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\n");
4540
- return { stop() {} };
4541
- }
4542
- let fired = false;
4543
- const handle = setInterval(() => {
4544
- if (fired) return;
4545
- const currentPpid = getPpid();
4546
- if (currentPpid !== initialPpid || !isAlive(initialPpid)) {
4547
- fired = true;
4548
- clearInterval(handle);
4549
- log(`[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\n`);
4550
- onOrphaned();
4551
- }
4552
- }, intervalMs);
4553
- return { stop() {
4554
- clearInterval(handle);
4555
- } };
4556
- }
4557
- /**
4558
5004
  * Factory that constructs a `ChiiCdpConnection` for the given relay base URL.
4559
5005
  *
4560
5006
  * Introduced as a named seam so PR-2 (dual-connection, #348) can defer
@@ -4735,22 +5181,31 @@ function familyKeyForMode(mode) {
4735
5181
  }
4736
5182
  }
4737
5183
  /** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */
4738
- const MOBILE_RELAY_BASE_URL_MISSING_MESSAGE = "start_debug(mobile): AIT_RELAY_BASE_URL이 설정되지 않았습니다 unplugin이 tunnel:{cdp:true}로 띄운 relay base URL을 AIT_RELAY_BASE_URL 환경변수로 전달하세요. 환경 2(실기기 PWA) 진입은 외부 relay base가 필요합니다.";
5184
+ const MOBILE_RELAY_BASE_URL_MISSING_MESSAGE = "start_debug(mobile): AIT_RELAY_BASE_URL이 설정되지 않았습니다. dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. 자동 발견이 되지 않을 경우 relay base URL을 AIT_RELAY_BASE_URL 환경변수로 직접 전달하세요. 환경 2(실기기 PWA) 진입은 외부 relay base가 필요합니다.";
4739
5185
  /**
4740
- * Reads `AIT_RELAY_BASE_URL` from the environment for the env-2 (`mobile`) boot
4741
- * site (issue #378). Returns the trimmed value, or throws the precise
4742
- * {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} when unset/empty.
5186
+ * Reads the env-2 relay base URL for the `mobile` boot site (issue #378, #424).
4743
5187
  *
4744
- * SECRET-HANDLING: `AIT_RELAY_BASE_URL` carries the relay host (same class as a
4745
- * wss URL). On the missing path the thrown message describes the env var name
4746
- * and how to obtain it — it NEVER echoes any partial/garbled URL value. The
5188
+ * Resolution order (env wins file is the fallback):
5189
+ * 1. `env.AIT_RELAY_BASE_URL` set and non-empty return it (operator override).
5190
+ * 2. `projectRoot` given read `<nearest package.json dir>/.ait_urls`;
5191
+ * if `relayBaseUrl` is present → return it (auto-discovered from dev server).
5192
+ * 3. Neither → throw {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE}.
5193
+ *
5194
+ * SECRET-HANDLING: `AIT_RELAY_BASE_URL` and the file-discovered value carry the
5195
+ * relay host. On the missing path the thrown message names the env var and notes
5196
+ * that the dev server auto-publishes it — it NEVER echoes any URL value. The
4747
5197
  * present value is returned to the caller (the CDP client) but never logged.
4748
5198
  */
4749
- function readMobileRelayBaseUrl(env = process.env) {
5199
+ async function readMobileRelayBaseUrl(env = process.env, projectRoot) {
4750
5200
  const raw = env.AIT_RELAY_BASE_URL;
4751
- const value = typeof raw === "string" ? raw.trim() : "";
4752
- if (value === "") throw new Error(MOBILE_RELAY_BASE_URL_MISSING_MESSAGE);
4753
- return value;
5201
+ const envValue = typeof raw === "string" ? raw.trim() : "";
5202
+ if (envValue !== "") return envValue;
5203
+ if (projectRoot !== void 0) {
5204
+ const { readRelayUrls } = await import("../relay-url-store-qaoe0zOD.js");
5205
+ const stored = await readRelayUrls({ projectRoot });
5206
+ if (stored?.relayBaseUrl !== void 0) return stored.relayBaseUrl;
5207
+ }
5208
+ throw new Error(MOBILE_RELAY_BASE_URL_MISSING_MESSAGE);
4754
5209
  }
4755
5210
  /**
4756
5211
  * Sentinel connection returned by {@link DualConnectionRouter.active} before the
@@ -4877,14 +5332,18 @@ var DualConnectionRouter = class {
4877
5332
  }
4878
5333
  /**
4879
5334
  * Resolves the `BootedFamily` for `key`: the warm family if already booted,
4880
- * otherwise boots it via `bootLazyFor(key)` and stores it (once per key).
4881
- * Since #396 every family is lazy, so this is the single boot path for all
4882
- * three keys.
5335
+ * otherwise boots it via `bootLazyFor(key, projectRoot)` and stores it (once
5336
+ * per key). Since #396 every family is lazy, so this is the single boot path
5337
+ * for all three keys.
5338
+ *
5339
+ * `projectRoot` is forwarded to `bootLazyFor` so `relay-sandbox` boot can
5340
+ * fall back to `.ait_urls` file discovery (#424) when `AIT_RELAY_BASE_URL` is
5341
+ * not set in the environment.
4883
5342
  */
4884
- async familyFor(key) {
5343
+ async familyFor(key, projectRoot) {
4885
5344
  const warm = this.lazyFamilies.get(key);
4886
5345
  if (warm) return warm;
4887
- const booted = await this.deps.bootLazyFor(key);
5346
+ const booted = await this.deps.bootLazyFor(key, projectRoot);
4888
5347
  this.lazyFamilies.set(key, booted);
4889
5348
  return booted;
4890
5349
  }
@@ -4894,7 +5353,7 @@ var DualConnectionRouter = class {
4894
5353
  this.swapInFlight = true;
4895
5354
  try {
4896
5355
  if (isRelayMode(mode)) await loadRelaySecretReadOnly({ projectRoot });
4897
- const target = await this.familyFor(familyKeyForMode(mode));
5356
+ const target = await this.familyFor(familyKeyForMode(mode), projectRoot);
4898
5357
  this.activeFamily = target;
4899
5358
  setLiveIntent(mode === "relay-live");
4900
5359
  this.stopWatcher();
@@ -4926,7 +5385,7 @@ async function runDebugServer(options = {}) {
4926
5385
  const devtoolsOpener = new AutoDevtoolsOpener();
4927
5386
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4928
5387
  const router = new DualConnectionRouter({
4929
- bootLazyFor: (key) => key === "relay-sandbox" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
5388
+ bootLazyFor: async (key, projectRoot) => key === "relay-sandbox" ? bootExternalRelayFamily(await readMobileRelayBaseUrl(process.env, projectRoot)) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
4930
5389
  relayPort: options.relayPort,
4931
5390
  verifyAuth: buildRelayVerifyAuth(),
4932
5391
  onWssUrl: (wssUrl) => {
@@ -5083,7 +5542,7 @@ async function runLocalDebugServer(options = {}) {
5083
5542
  const devtoolsOpener = new AutoDevtoolsOpener();
5084
5543
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
5085
5544
  const router = new DualConnectionRouter({
5086
- bootLazyFor: (key) => key === "relay-sandbox" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : key === "local-browser" ? bootLocalFamilyForEntry() : bootRelayFamily({
5545
+ bootLazyFor: async (key, projectRoot) => key === "relay-sandbox" ? bootExternalRelayFamily(await readMobileRelayBaseUrl(process.env, projectRoot)) : key === "local-browser" ? bootLocalFamilyForEntry() : bootRelayFamily({
5087
5546
  verifyAuth: buildRelayVerifyAuth(),
5088
5547
  onWssUrl: (wssUrl) => {
5089
5548
  lockHandle.updateWssUrl(wssUrl);
@@ -5218,12 +5677,12 @@ async function runLocalDebugServer(options = {}) {
5218
5677
  * value). The present value is passed straight to the CDP client, never logged.
5219
5678
  */
5220
5679
  async function runMobileDebugServer(options = {}) {
5221
- const relayBaseUrl = readMobileRelayBaseUrl();
5680
+ const relayBaseUrl = await readMobileRelayBaseUrl(process.env, options.projectRoot ?? process.cwd());
5222
5681
  const lockHandle = acquireLock({ force: options.force ?? false });
5223
5682
  const devtoolsOpener = new AutoDevtoolsOpener();
5224
5683
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
5225
5684
  const router = new DualConnectionRouter({
5226
- bootLazyFor: (key) => key === "relay-sandbox" ? bootExternalRelayFamily(relayBaseUrl) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
5685
+ bootLazyFor: async (key) => key === "relay-sandbox" ? bootExternalRelayFamily(relayBaseUrl) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
5227
5686
  verifyAuth: buildRelayVerifyAuth(),
5228
5687
  onWssUrl: (wssUrl) => {
5229
5688
  lockHandle.updateWssUrl(wssUrl);
@@ -5758,7 +6217,7 @@ function createDevServer(deps = {}) {
5758
6217
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
5759
6218
  const server = new Server({
5760
6219
  name: "ait-devtools",
5761
- version: "0.1.58"
6220
+ version: "0.1.60"
5762
6221
  }, { capabilities: { tools: {} } });
5763
6222
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
5764
6223
  server.setRequestHandler(CallToolRequestSchema, async (request) => {