@ait-co/devtools 0.1.56 → 0.1.57

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/mcp/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "../totp-D0a8VwoR.js";
2
3
  import { createRequire } from "node:module";
3
4
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { argv } from "node:process";
@@ -12,8 +13,8 @@ import { createServer } from "node:http";
12
13
  import { spawn } from "node:child_process";
13
14
  import net from "node:net";
14
15
  import { homedir, platform } from "node:os";
15
- import { join } from "node:path";
16
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
16
+ import { dirname, join } from "node:path";
17
+ import { randomBytes } from "node:crypto";
17
18
  import { Tunnel, bin, install } from "cloudflared";
18
19
  //#region \0rolldown/runtime.js
19
20
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
@@ -1582,12 +1583,71 @@ async function launchChromium(options = {}) {
1582
1583
  /**
1583
1584
  * 로컬 HTTP 서버를 127.0.0.1 random port(또는 `AIT_DEBUG_HTTP_PORT` env)로 시작한다.
1584
1585
  * MCP debug server 생애주기에 묶어 사용 — `runDebugServer` shutdown 시 `close()`로 정리.
1586
+ *
1587
+ * @param getDashboardState - dashboard 상태를 반환하는 클로저. 주입 시 `GET /` dashboard와
1588
+ * `GET /events` SSE 스트림이 활성화된다. 미주입 시 두 라우트는 204/서비스 없음으로 응답.
1585
1589
  */
1586
- async function startQrHttpServer() {
1590
+ async function startQrHttpServer(getDashboardState) {
1587
1591
  const { default: QRCode } = await import("qrcode");
1588
- const server = createServer((req, res) => {
1592
+ /** SSE 활성 연결 목록 — `notifyStateChange()` 전체 push. */
1593
+ const sseClients = [];
1594
+ /** SSE 연결 하나에 상태 이벤트를 flush한다. */
1595
+ function pushStateToClient(res, state) {
1596
+ const payload = JSON.stringify({
1597
+ tunnel: {
1598
+ up: state.tunnel.up,
1599
+ wssUrl: state.tunnel.wssUrl
1600
+ },
1601
+ pages: state.pages,
1602
+ attachUrl: state.attachUrl
1603
+ });
1604
+ res.write(`data: ${payload}\n\n`);
1605
+ }
1606
+ const server = createServer(async (req, res) => {
1589
1607
  const [path, query = ""] = (req.url ?? "/").split("?", 2);
1590
1608
  const params = new URLSearchParams(query ?? "");
1609
+ if (path === "/") {
1610
+ if (!getDashboardState) {
1611
+ res.writeHead(204, { "Content-Type": "text/plain; charset=utf-8" });
1612
+ res.end();
1613
+ return;
1614
+ }
1615
+ const state = getDashboardState();
1616
+ let qrDataUrl = null;
1617
+ if (state.attachUrl) try {
1618
+ qrDataUrl = await QRCode.toDataURL(state.attachUrl, {
1619
+ type: "image/png",
1620
+ errorCorrectionLevel: "M"
1621
+ });
1622
+ } catch {}
1623
+ const html = buildDashboardHtml(state, qrDataUrl);
1624
+ res.writeHead(200, {
1625
+ "Content-Type": "text/html; charset=utf-8",
1626
+ "Cache-Control": "no-store"
1627
+ });
1628
+ res.end(html);
1629
+ return;
1630
+ }
1631
+ if (path === "/events") {
1632
+ if (!getDashboardState) {
1633
+ res.writeHead(204, { "Content-Type": "text/plain; charset=utf-8" });
1634
+ res.end();
1635
+ return;
1636
+ }
1637
+ res.writeHead(200, {
1638
+ "Content-Type": "text/event-stream",
1639
+ "Cache-Control": "no-cache",
1640
+ Connection: "keep-alive",
1641
+ "X-Accel-Buffering": "no"
1642
+ });
1643
+ pushStateToClient(res, getDashboardState());
1644
+ sseClients.push(res);
1645
+ req.once("close", () => {
1646
+ const idx = sseClients.indexOf(res);
1647
+ if (idx !== -1) sseClients.splice(idx, 1);
1648
+ });
1649
+ return;
1650
+ }
1591
1651
  if (path === "/attach") {
1592
1652
  const encodedU = params.get("u") ?? "";
1593
1653
  let attachUrl;
@@ -1661,6 +1721,13 @@ async function startQrHttpServer() {
1661
1721
  buildAttachPageUrl(attachUrl) {
1662
1722
  return `http://127.0.0.1:${port}/attach?u=${encodeURIComponent(attachUrl)}`;
1663
1723
  },
1724
+ notifyStateChange() {
1725
+ if (!getDashboardState) return;
1726
+ const state = getDashboardState();
1727
+ for (const client of sseClients) try {
1728
+ pushStateToClient(client, state);
1729
+ } catch {}
1730
+ },
1664
1731
  close() {
1665
1732
  return new Promise((resolve, reject) => {
1666
1733
  server.close((err) => err ? reject(err) : resolve());
@@ -1669,6 +1736,147 @@ async function startQrHttpServer() {
1669
1736
  };
1670
1737
  }
1671
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
+ /**
1672
1880
  * QR 스캔 페이지 HTML 본문.
1673
1881
  * dark theme, inline style, 외부 fetch 없음.
1674
1882
  */
@@ -1745,6 +1953,112 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl) {
1745
1953
  </html>`;
1746
1954
  }
1747
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
+ //#endregion
1748
2062
  //#region src/mcp/server-lock.ts
1749
2063
  /**
1750
2064
  * Single debug session lock for the `devtools-mcp` debug server.
@@ -2085,186 +2399,6 @@ function warnPassthrough(name) {
2085
2399
  }
2086
2400
  SIGNATURES.map((s) => s.name);
2087
2401
  //#endregion
2088
- //#region src/mcp/totp.ts
2089
- /**
2090
- * RFC 6238 TOTP implementation (Node.js, node:crypto only).
2091
- *
2092
- * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
2093
- * to keep the dependency surface minimal. This hand-roll is ~30 lines and
2094
- * covers exactly what relay-side auth needs.
2095
- *
2096
- * Algorithm summary (RFC 6238 + RFC 4226):
2097
- * T = floor(now / 30) — 30-second time step counter
2098
- * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
2099
- * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
2100
- * offset = MAC[19] & 0x0f
2101
- * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
2102
- *
2103
- * Security note (keep this comment accurate):
2104
- * The baked-in secret in a dogfood build is extractable from the bundle by a
2105
- * determined reverse engineer. This mechanism raises the bar from
2106
- * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
2107
- * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
2108
- * blocked; deliberate reverse engineering is not. See threat model in
2109
- * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
2110
- *
2111
- * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
2112
- * log, error message, or string visible outside this module. Only boolean
2113
- * pass/fail and reason enum values are safe to surface.
2114
- */
2115
- /** Time step window in seconds (RFC 6238 default). */
2116
- const TIME_STEP = 30;
2117
- /** Number of digits in the generated code. */
2118
- const DIGITS = 6;
2119
- /**
2120
- * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
2121
- * clock time.
2122
- *
2123
- * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
2124
- * bytes). Must be the output of `generateAttachToken()` or compatible.
2125
- * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
2126
- * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
2127
- */
2128
- function generateTotp(secret, when = Date.now()) {
2129
- const key = Buffer.from(secret, "hex");
2130
- const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
2131
- const counterBuf = Buffer.alloc(8);
2132
- const hi = Math.floor(counter / 4294967296);
2133
- const lo = counter >>> 0;
2134
- counterBuf.writeUInt32BE(hi, 0);
2135
- counterBuf.writeUInt32BE(lo, 4);
2136
- const mac = createHmac("sha1", key).update(counterBuf).digest();
2137
- const offset = mac[19] & 15;
2138
- return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
2139
- }
2140
- /**
2141
- * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
2142
- * tolerate clock drift between the relay host and the client device.
2143
- *
2144
- * Uses `timingSafeEqual` for constant-time comparison to prevent timing
2145
- * side-channel attacks.
2146
- *
2147
- * @param secret - Hex-encoded shared secret.
2148
- * @param code - The 6-digit code to verify (string or numeric).
2149
- * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
2150
- * @param skew - Number of adjacent steps to accept on either side. Default 1
2151
- * (accepts T-1, T, T+1 — a 90-second acceptance window).
2152
- * @returns `true` if the code matches any accepted step, `false` otherwise.
2153
- */
2154
- function verifyTotp(secret, code, when = Date.now(), skew = 1) {
2155
- const normalised = String(code).padStart(DIGITS, "0");
2156
- if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
2157
- const candidateBuf = Buffer.from(normalised, "utf8");
2158
- for (let delta = -skew; delta <= skew; delta++) {
2159
- const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
2160
- if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
2161
- }
2162
- return false;
2163
- }
2164
- /**
2165
- * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.
2166
- *
2167
- * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,
2168
- * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key
2169
- * we are willing to gate a public relay behind. `generateAttachToken()` emits
2170
- * 64 hex chars (32 bytes), comfortably above this bar.
2171
- */
2172
- const MIN_SECRET_HEX_CHARS = 32;
2173
- /** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */
2174
- const HEX_RE = /^[0-9a-fA-F]+$/;
2175
- /**
2176
- * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.
2177
- *
2178
- * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and
2179
- * how to mint one. It NEVER echoes the configured value, its length, or any
2180
- * fragment derived from it — see {@link assertRelayAuthConfigured}.
2181
- *
2182
- * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`
2183
- * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be
2184
- * silently mis-decoded and every TOTP code would fail to match, so the minting
2185
- * command emits hex.
2186
- */
2187
- const RELAY_AUTH_SECRET_MISSING_MESSAGE = [
2188
- "[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.",
2189
- "발급: openssl rand -hex 32",
2190
- "자세히: https://docs.aitc.dev/guides/relay-auth-totp"
2191
- ].join("\n");
2192
- /**
2193
- * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at
2194
- * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd
2195
- * length would have its trailing nibble silently dropped by `Buffer.from(...,
2196
- * 'hex')`, weakening the key without warning).
2197
- *
2198
- * Pure predicate so callers can test the validation independently of the
2199
- * fail-fast side effect in {@link assertRelayAuthConfigured}.
2200
- *
2201
- * SECRET-HANDLING: returns only a boolean — the input value is never returned,
2202
- * logged, or echoed.
2203
- */
2204
- function isValidRelayAuthSecret(secret) {
2205
- if (secret === void 0 || secret === "") return false;
2206
- if (secret.length < MIN_SECRET_HEX_CHARS) return false;
2207
- if (secret.length % 2 !== 0) return false;
2208
- return HEX_RE.test(secret);
2209
- }
2210
- /**
2211
- * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before
2212
- * a public-internet-exposed relay is booted (issue #250).
2213
- *
2214
- * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes
2215
- * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third
2216
- * party attach a debugger to a dogfood/live mini-app. Without a secret the relay
2217
- * comes up unauthenticated, so this guard is called at every relay-boot site —
2218
- * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),
2219
- * both eager and lazy. Local-only sessions never boot a relay and so never reach
2220
- * this guard, matching the issue's exemption for non-relay debugging.
2221
- *
2222
- * Throws when the secret is unset, empty, too short, or not a valid hex string.
2223
- * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)
2224
- * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.
2225
- *
2226
- * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean
2227
- * predicate, and never logged. The thrown message names the requirement, never
2228
- * the value, its length, or any derived fragment.
2229
- *
2230
- * @param env - Environment to read from. Defaults to `process.env`; injectable
2231
- * for tests so they never mutate the real process environment.
2232
- */
2233
- function assertRelayAuthConfigured(env = process.env) {
2234
- if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);
2235
- }
2236
- /**
2237
- * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
2238
- * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
2239
- *
2240
- * The predicate checks the `at` query parameter against the current and
2241
- * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.
2242
- *
2243
- * Returns `undefined` when the env var is not set — callers treat that as
2244
- * "auth disabled" (no predicate registered on the relay). Note that since
2245
- * issue #250 the secret is MANDATORY at every relay-boot site (enforced by
2246
- * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production
2247
- * this never returns `undefined` for a relay that actually boots; the
2248
- * `undefined` branch only matters for the no-relay local path and tests.
2249
- *
2250
- * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the
2251
- * same gate without importing the heavy MCP server module graph. Re-exported
2252
- * from `debug-server.ts` for back-compat.
2253
- *
2254
- * SECRET-HANDLING: The secret value read from env is captured in a closure and
2255
- * is NEVER written to any log, error message, or process output.
2256
- */
2257
- function buildRelayVerifyAuth(env = process.env) {
2258
- const secret = env.AIT_DEBUG_TOTP_SECRET;
2259
- if (!secret) return void 0;
2260
- return (req) => {
2261
- const rawUrl = req.url ?? "";
2262
- const qIndex = rawUrl.indexOf("?");
2263
- const queryStr = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
2264
- return verifyTotp(secret, new URLSearchParams(queryStr).get("at") ?? "");
2265
- };
2266
- }
2267
- //#endregion
2268
2402
  //#region src/mcp/tools.ts
2269
2403
  /** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
2270
2404
  const DEBUG_TOOL_DEFINITIONS = [
@@ -2300,13 +2434,13 @@ const DEBUG_TOOL_DEFINITIONS = [
2300
2434
  },
2301
2435
  {
2302
2436
  name: "build_attach_url",
2303
- description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Builds a self-attaching deep link for the active relay environment and returns a QR code. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Call list_pages first to confirm the relay/tunnel is up. If the tunnel is not up, restart: `npx @ait-co/devtools devtools-mcp`.\n\nEnvironment-specific behaviour:\n • env 3 / staging (start_debug mode=\"staging\"): requires scheme_url — the intoss-private://…?_deploymentId=<uuid> URL from `ait deploy --scheme-only`. Splices debug=1 + relay URL into the scheme URL to produce a self-attach deep link.\n • env 2 / mobile (start_debug mode=\"mobile\"): scheme_url is NOT used. Instead, reads AIT_TUNNEL_BASE_URL (the https://*.trycloudflare.com app tunnel from `tunnel:{cdp:true}`) and builds a launcher PWA deep-link (https://devtools.aitc.dev/launcher/?url=…&debug=1&relay=…). Scan the QR with the phone to open the launcher, which frames the tunnel URL and attaches CDP.\n\nSet wait_for_attach=true to block until a page attaches (polls up to 30 s). On timeout, call build_attach_url again to resume polling. When open_in_browser=true (default), saves the QR as a PNG and opens it in the OS default browser — only works when the MCP server runs on a local GUI machine (not headless/remote containers). \n\nTOTP auth: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the returned attachUrl automatically includes the current one-time code (at=<code>) — the URL is single-use for that 30-second step. The response includes a `totp` field with `expiresAt` (ISO timestamp). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh one-time URL. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.",
2437
+ description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Builds a self-attaching deep link for the active relay environment and returns a QR code. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Call list_pages first to confirm the relay/tunnel is up. If the tunnel is not up, restart: `npx @ait-co/devtools devtools-mcp`.\n\nEnvironment-specific behaviour:\n • env 3 / relay-staging (start_debug mode=\"relay-staging\"): requires scheme_url — the intoss-private://…?_deploymentId=<uuid> URL from `ait deploy --scheme-only`. Splices debug=1 + relay URL into the scheme URL to produce a self-attach deep link.\n • env 2 / relay-sandbox (start_debug mode=\"relay-sandbox\"): scheme_url is NOT used. Instead, reads AIT_TUNNEL_BASE_URL (the https://*.trycloudflare.com app tunnel from `tunnel:{cdp:true}`) and builds a launcher PWA deep-link (https://devtools.aitc.dev/launcher/?url=…&debug=1&relay=…). Scan the QR with the phone to open the launcher, which frames the tunnel URL and attaches CDP.\n\nSet wait_for_attach=true to block until a page attaches (polls up to 30 s). On timeout, call build_attach_url again to resume polling. When open_in_browser=true (default), saves the QR as a PNG and opens it in the OS default browser — only works when the MCP server runs on a local GUI machine (not headless/remote containers). \n\nTOTP auth: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the returned attachUrl automatically includes the current one-time code (at=<code>) — the URL is single-use for that 30-second step. The response includes a `totp` field with `expiresAt` (ISO timestamp). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh one-time URL. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.",
2304
2438
  inputSchema: {
2305
2439
  type: "object",
2306
2440
  properties: {
2307
2441
  scheme_url: {
2308
2442
  type: "string",
2309
- description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). Required for env 3/staging mode. Not used in env 2/mobile mode (use AIT_TUNNEL_BASE_URL instead). The authority (host) must be the app name (e.g. intoss-private://aitc-sdk-example?_deploymentId=…). Generic values like \"web\" or an empty host indicate a malformed URL."
2443
+ description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). Required for env 3/relay-staging mode. Not used in env 2/relay-sandbox mode (use AIT_TUNNEL_BASE_URL instead). The authority (host) must be the app name (e.g. intoss-private://aitc-sdk-example?_deploymentId=…). Generic values like \"web\" or an empty host indicate a malformed URL."
2310
2444
  },
2311
2445
  wait_for_attach: {
2312
2446
  type: "boolean",
@@ -2449,23 +2583,27 @@ const DEBUG_TOOL_DEFINITIONS = [
2449
2583
  },
2450
2584
  {
2451
2585
  name: "start_debug",
2452
- description: "Switches the active debug environment in-place (issue #348) — no Claude Code restart and no MCP re-handshake. One daemon holds both a local (env 1, mock SDK in a Chromium) and a relay (env 3/4, real-device Toss WebView over the Chii relay + cloudflared tunnel) connection at once; this tool flips which one every other tool reads from, lazily booting the requested family's infra on first use and keeping the inactive one warm so an existing attach survives the switch. After switching it emits notifications/tools/list_changed — call tools/list again to see the updated tool surface for the new environment.\n\nmodes:\n local — env 1: desktop Chromium with the MOCK SDK and a local CDP attach. Side-effect tools (call_sdk/evaluate) run unguarded against the mock; nothing touches a real device or real users. No prerequisites — the default, always-available environment for state/contract and visual-layout work.\n mobile — env 2: a real-device PWA (real WebKit engine, MOCK SDK) over an external Chii relay that the unplugin already started with tunnel:{cdp:true}. CDP covers real-device WebKit DOM, console, exceptions, and safe-area observation; call_sdk still hits the mock (SDK fidelity needs staging). liveIntent off — dev-intent, LIVE guard inactive, side-effect tools run unguarded against the mock. Prerequisites: AIT_RELAY_BASE_URL env var set + unplugin running with tunnel:{cdp:true} so the relay tunnel is already up.\n staging — env 3: a real-device Toss WebView dogfood build with the REAL SDK over the intoss-private relay. The first environment where call_sdk exercises the genuine native bridge. Side-effect tools run unguarded (dogfood, not released to real users). Prerequisite: a deployed dogfood candidate bundle + the device cold-loaded via the intoss-private deep-link/QR relay injection.\n live — env 4: the REVIEW-PASSED, released production runtime with the REAL SDK over the intoss relay — real end users are on the other side. Read-only debugging is the intent: the LIVE guard is armed, so call_sdk/evaluate require confirm:true per call, and ENTERING live ALSO requires confirm:true on this call. Use it only to observe a shipped regression; verify fixes in staging first.\n\nSwitching back to local automatically disarms the LIVE guard.",
2586
+ description: "Switches the active debug environment in-place (issue #348) — no Claude Code restart and no MCP re-handshake. One daemon holds both a local (env 1, mock SDK in a Chromium) and a relay (env 3/4, real-device Toss WebView over the Chii relay + cloudflared tunnel) connection at once; this tool flips which one every other tool reads from, lazily booting the requested family's infra on first use and keeping the inactive one warm so an existing attach survives the switch. After switching it emits notifications/tools/list_changed — call tools/list again to see the updated tool surface for the new environment.\n\nmodes:\n local-browser — env 1: desktop Chromium with the MOCK SDK and a local CDP attach. Side-effect tools (call_sdk/evaluate) run unguarded against the mock; nothing touches a real device or real users. No prerequisites — the default, always-available environment for state/contract and visual-layout work.\n relay-sandbox — env 2: a real-device PWA (real WebKit engine, MOCK SDK) over an external Chii relay that the unplugin already started with tunnel:{cdp:true}. CDP covers real-device WebKit DOM, console, exceptions, and safe-area observation; call_sdk still hits the mock (SDK fidelity needs relay-staging). liveIntent off — dev-intent, LIVE guard inactive, side-effect tools run unguarded against the mock. Prerequisites: AIT_RELAY_BASE_URL env var set + unplugin running with tunnel:{cdp:true} so the relay tunnel is already up.\n relay-staging — env 3: a real-device Toss WebView dogfood build with the REAL SDK over the intoss-private relay. The first environment where call_sdk exercises the genuine native bridge. Side-effect tools run unguarded (dogfood, not released to real users). Prerequisite: a deployed dogfood candidate bundle + the device cold-loaded via the intoss-private deep-link/QR relay injection.\n relay-live — env 4: the REVIEW-PASSED, released production runtime with the REAL SDK over the intoss relay — real end users are on the other side. Read-only debugging is the intent: the LIVE guard is armed, so call_sdk/evaluate require confirm:true per call, and ENTERING relay-live ALSO requires confirm:true on this call. Use it only to observe a shipped regression; verify fixes in relay-staging first.\n\nSwitching back to local-browser automatically disarms the LIVE guard.\n\nFor a relay mode (relay-sandbox/relay-staging/relay-live), also pass projectRoot — the absolute mini-app project root — so the daemon can read the relay auth secret from <projectRoot>/.ait_relay (read-only; the daemon never mints it). Omit it for local-browser.",
2453
2587
  inputSchema: {
2454
2588
  type: "object",
2455
2589
  properties: {
2456
2590
  mode: {
2457
2591
  type: "string",
2458
2592
  enum: [
2459
- "local",
2460
- "mobile",
2461
- "staging",
2462
- "live"
2593
+ "local-browser",
2594
+ "relay-sandbox",
2595
+ "relay-staging",
2596
+ "relay-live"
2463
2597
  ],
2464
- description: "Target environment to switch to. mode=live additionally requires confirm: true (and arms the read-only LIVE guard)."
2598
+ description: "Target environment to switch to. mode=relay-live additionally requires confirm: true (and arms the read-only LIVE guard)."
2465
2599
  },
2466
2600
  confirm: {
2467
2601
  type: "boolean",
2468
- description: "Required when mode=live — set true to acknowledge entering LIVE (env 4) debugging that can affect real users. Ignored for the other modes."
2602
+ description: "Required when mode=relay-live — set true to acknowledge entering LIVE (env 4) debugging that can affect real users. Ignored for the other modes."
2603
+ },
2604
+ projectRoot: {
2605
+ type: "string",
2606
+ description: "Absolute path to the mini-app project root (the directory containing its package.json and .ait_relay). The daemon reads the relay auth secret from <projectRoot>/.ait_relay (read-only) when switching to a relay environment (relay-staging/relay-live/relay-sandbox). Pass this because the daemon's own cwd is fixed at launch and may not be the project being debugged. Omit for mode=local-browser (no secret needed)."
2469
2607
  }
2470
2608
  },
2471
2609
  required: ["mode"]
@@ -3294,7 +3432,7 @@ async function readMcpSdkVersion() {
3294
3432
  * some test environments that skip the build step).
3295
3433
  */
3296
3434
  function readDevtoolsVersion() {
3297
- return "0.1.56";
3435
+ return "0.1.57";
3298
3436
  }
3299
3437
  /**
3300
3438
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -3712,13 +3850,13 @@ function extractDeploymentId(schemeUrl) {
3712
3850
  }
3713
3851
  }
3714
3852
  /**
3715
- * Returns `true` when the mode routes to a relay connection (`mobile`,
3716
- * `staging`, or `live`). `mobile` is an external-PWA relay; `staging`/`live`
3717
- * are intoss-private relays — but all three surface the Tier B / relay-only
3718
- * tool set.
3853
+ * Returns `true` when the mode routes to a relay connection (`relay-sandbox`,
3854
+ * `relay-staging`, or `relay-live`). `relay-sandbox` is an external-PWA relay;
3855
+ * `relay-staging`/`relay-live` are intoss-private relays — but all three surface
3856
+ * the Tier B / relay-only tool set.
3719
3857
  */
3720
3858
  function isRelayMode(mode) {
3721
- return mode === "mobile" || mode === "staging" || mode === "live";
3859
+ return mode === "relay-sandbox" || mode === "relay-staging" || mode === "relay-live";
3722
3860
  }
3723
3861
  /**
3724
3862
  * Waits for the first target matching `filterFn` to attach, using the
@@ -3774,14 +3912,15 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
3774
3912
  * naturally via `enableDomains`). The tier only controls visibility.
3775
3913
  */
3776
3914
  function createDebugServer(deps) {
3777
- const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret } = deps;
3915
+ const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret, onAttachUrlBuilt } = deps;
3916
+ const getTotpSecret = deps.getTotpSecret ?? (() => totpSecret);
3778
3917
  const router = routerDep ?? makeSingleConnectionRouter(connection);
3779
3918
  const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin));
3780
3919
  const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},liveIntent=${getLiveIntent()}`);
3781
3920
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
3782
3921
  const server = new Server({
3783
3922
  name: "ait-debug",
3784
- version: "0.1.56"
3923
+ version: "0.1.57"
3785
3924
  }, { capabilities: { tools: { listChanged: true } } });
3786
3925
  server.setRequestHandler(ListToolsRequestSchema, () => {
3787
3926
  const conn = router.active;
@@ -3803,10 +3942,12 @@ function createDebugServer(deps) {
3803
3942
  if (name === "start_debug") {
3804
3943
  const rawMode = request.params.arguments?.mode;
3805
3944
  const mode = normalizeStartDebugMode(rawMode);
3806
- if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local' | 'mobile' | 'staging' | 'live' 중 하나를 전달하세요 (deprecated 별칭 'local-browser-dev'/'local-browser-cdp'/'relay-mobile'/'relay-dev'/'relay-live'도 수용).");
3945
+ if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser' | 'relay-sandbox' | 'relay-staging' | 'relay-live' 중 하나를 전달하세요.");
3807
3946
  const confirm = request.params.arguments?.confirm === true;
3947
+ const rawProjectRoot = request.params.arguments?.projectRoot;
3948
+ const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
3808
3949
  try {
3809
- return jsonResult$1(await router.switchMode(mode, confirm));
3950
+ return jsonResult$1(await router.switchMode(mode, confirm, projectRoot));
3810
3951
  } catch (err) {
3811
3952
  return errorResult(err, name);
3812
3953
  }
@@ -3858,11 +3999,12 @@ function createDebugServer(deps) {
3858
3999
  if (tunnelHttpUrl === "") return mcpError("build_attach_url(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. unplugin tunnel:{cdp:true} 배너에 출력되는 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 전달하세요.");
3859
4000
  const tunnelStatus = getTunnelStatus();
3860
4001
  if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return mcpError("build_attach_url(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.");
4002
+ const secret = getTotpSecret();
3861
4003
  let totpCode;
3862
4004
  let totpMeta;
3863
- if (totpSecret !== void 0 && totpSecret !== "") {
4005
+ if (secret !== void 0 && secret !== "") {
3864
4006
  const now = Date.now();
3865
- totpCode = generateTotp(totpSecret, now);
4007
+ totpCode = generateTotp(secret, now);
3866
4008
  const STEP_SECONDS = 30;
3867
4009
  const currentStep = Math.floor(now / 1e3 / STEP_SECONDS);
3868
4010
  totpMeta = {
@@ -3872,6 +4014,7 @@ function createDebugServer(deps) {
3872
4014
  };
3873
4015
  }
3874
4016
  const attachUrl = buildLauncherAttachUrl(tunnelHttpUrl, tunnelStatus.wssUrl, totpCode);
4017
+ onAttachUrlBuilt?.(attachUrl);
3875
4018
  const relayUrl = tunnelStatus.wssUrl;
3876
4019
  const totp = totpMeta;
3877
4020
  const isMatchingPage = (pages) => pages.length > 0;
@@ -4040,7 +4183,8 @@ function createDebugServer(deps) {
4040
4183
  return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
4041
4184
  };
4042
4185
  try {
4043
- const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, getTunnelStatus(), totpSecret);
4186
+ const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, getTunnelStatus(), getTotpSecret());
4187
+ onAttachUrlBuilt?.(attachUrl);
4044
4188
  const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
4045
4189
  const header = "This tool result is shown to the user directly — do NOT re-print the QR below in your reply (it wastes output tokens). Just tell the user to scan the QR in this output (Ctrl+O to expand if collapsed).";
4046
4190
  const guiAvailable = canOpenBrowser();
@@ -4245,26 +4389,14 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
4245
4389
  }
4246
4390
  /**
4247
4391
  * Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or
4248
- * `null` when the value is not one of the accepted modes.
4249
- *
4250
- * Accepts the 4 canonical modes + 5 deprecated aliases (back-compat for
4251
- * pinned .mcp.json / docs / QA runbooks that still emit old strings):
4252
- * 'local' → 'local' (canonical)
4253
- * 'mobile' → 'mobile' (canonical)
4254
- * 'staging' → 'staging' (canonical)
4255
- * 'live' → 'live' (canonical)
4256
- * 'local-browser-dev' → 'local' (deprecated alias)
4257
- * 'local-browser-cdp' → 'local' (deprecated alias)
4258
- * 'relay-mobile' → 'mobile' (deprecated alias)
4259
- * 'relay-dev' → 'staging' (deprecated alias)
4260
- * 'relay-live' → 'live' (deprecated alias)
4392
+ * `null` when the value is not one of the four accepted modes:
4393
+ * 'local-browser' | 'relay-sandbox' | 'relay-staging' | 'relay-live'
4394
+ *
4395
+ * Hard rename (issue #398): the older `local`/`mobile`/`staging`/`live` names
4396
+ * and their aliases are no longer accepted — pre-1.0, no back-compat.
4261
4397
  */
4262
4398
  function normalizeStartDebugMode(raw) {
4263
- if (raw === "local" || raw === "mobile" || raw === "staging" || raw === "live") return raw;
4264
- if (raw === "local-browser-dev" || raw === "local-browser-cdp") return "local";
4265
- if (raw === "relay-mobile") return "mobile";
4266
- if (raw === "relay-dev") return "staging";
4267
- if (raw === "relay-live") return "live";
4399
+ if (raw === "local-browser" || raw === "relay-sandbox" || raw === "relay-staging" || raw === "relay-live") return raw;
4268
4400
  return null;
4269
4401
  }
4270
4402
  /**
@@ -4282,11 +4414,11 @@ function makeSingleConnectionRouter(connection) {
4282
4414
  return connection;
4283
4415
  },
4284
4416
  activeRelayOrigin: void 0,
4285
- switchMode(mode, confirm) {
4286
- if (mode === "mobile") return Promise.reject(/* @__PURE__ */ new Error("start_debug: 이 세션은 단일 연결만 보유합니다 — 'mobile'(환경 2 PWA, 외부 relay)로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 mobile 모드로 재시작하세요."));
4417
+ switchMode(mode, confirm, _projectRoot) {
4418
+ if (mode === "relay-sandbox") return Promise.reject(/* @__PURE__ */ new Error("start_debug: 이 세션은 단일 연결만 보유합니다 — 'relay-sandbox'(환경 2 PWA, 외부 relay)로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 relay-sandbox 모드로 재시작하세요."));
4287
4419
  if (isRelayMode(mode) !== (connection.kind === "relay")) return Promise.reject(/* @__PURE__ */ new Error(`start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — '${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 원하는 모드로 재시작하세요.`));
4288
- if (mode === "live" && !confirm) return Promise.reject(/* @__PURE__ */ new Error("start_debug: live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요."));
4289
- setLiveIntent(mode === "live");
4420
+ if (mode === "relay-live" && !confirm) return Promise.reject(/* @__PURE__ */ new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요."));
4421
+ setLiveIntent(mode === "relay-live");
4290
4422
  const environment = deriveEnvironment(connection.kind, getLiveIntent());
4291
4423
  return Promise.resolve({
4292
4424
  mode,
@@ -4426,8 +4558,9 @@ function startParentWatcher(onOrphaned, opts) {
4426
4558
  * Factory that constructs a `ChiiCdpConnection` for the given relay base URL.
4427
4559
  *
4428
4560
  * Introduced as a named seam so PR-2 (dual-connection, #348) can defer
4429
- * construction to first-activation time by moving or replacing this call
4430
- * without changing the current eager construction order at startup.
4561
+ * construction to first-activation time by moving or replacing this call. Since
4562
+ * #396 every family (relay included) is constructed lazily on its first
4563
+ * `start_debug`, so this is always called from the lazy boot path.
4431
4564
  *
4432
4565
  * The relay base URL is only available after `startChiiRelay()` resolves, so
4433
4566
  * the factory is called right after that point (same as before this refactor).
@@ -4455,11 +4588,9 @@ var RoutingAitSource = class extends ChiiAitSource {
4455
4588
  * `--remote-debugging-port` and returns a `LocalCdpConnection` attached to it,
4456
4589
  * plus a `stop()` that kills both.
4457
4590
  *
4458
- * Used two ways:
4459
- * - `runDebugServer` (relay-eager): the dual router's lazy callback, booted at
4460
- * most once on the first `start_debug({ mode: 'local-*' })`.
4461
- * - `runLocalDebugServer` (local-eager, #356): the eager family booted at
4462
- * startup.
4591
+ * Booted lazily via the dual router's `bootLazyFor('local-browser')` callback,
4592
+ * at most once on the first `start_debug({ mode: 'local-browser' })` (all-lazy,
4593
+ * #396 no run function boots a family at startup anymore).
4463
4594
  */
4464
4595
  async function bootLocalFamily() {
4465
4596
  const chromium = await launchChromium({
@@ -4484,10 +4615,10 @@ async function bootLocalFamily() {
4484
4615
  * `getTunnelStatus()` reflects the live tunnel (it flips up once the background
4485
4616
  * tunnel resolves and follows reissues).
4486
4617
  *
4487
- * Used two ways (symmetry with {@link bootLocalFamily}):
4488
- * - `runDebugServer` (relay-eager): booted at startup.
4489
- * - `runLocalDebugServer` (local-eager, #356): the dual router's lazy
4490
- * callback, booted at most once on the first `start_debug({ mode: 'relay-*' })`.
4618
+ * Booted lazily via the dual router's `bootLazyFor('relay-intoss')` callback
4619
+ * (symmetry with {@link bootLocalFamily}), at most once on the first
4620
+ * `start_debug({ mode: 'relay-staging' | 'relay-live' })` (all-lazy, #396 every
4621
+ * relay boot now flows through `switchMode` after the project-local secret load).
4491
4622
  *
4492
4623
  * The relay base URL is only known after `startChiiRelay()` resolves, so the
4493
4624
  * `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside
@@ -4592,14 +4723,15 @@ async function bootExternalRelayFamily(relayBaseUrl) {
4592
4723
  }
4593
4724
  /**
4594
4725
  * Maps a `StartDebugMode` to the {@link FamilyKey} that serves it (issue #378).
4595
- * local → 'local'; mobile → 'relay-external'; staging/live → 'relay-intoss'.
4726
+ * local-browser → 'local-browser'; relay-sandbox → 'relay-sandbox';
4727
+ * relay-staging/relay-live → 'relay-intoss' (the shared physical slot).
4596
4728
  */
4597
4729
  function familyKeyForMode(mode) {
4598
4730
  switch (mode) {
4599
- case "local": return "local";
4600
- case "mobile": return "relay-external";
4601
- case "staging":
4602
- case "live": return "relay-intoss";
4731
+ case "local-browser": return "local-browser";
4732
+ case "relay-sandbox": return "relay-sandbox";
4733
+ case "relay-staging":
4734
+ case "relay-live": return "relay-intoss";
4603
4735
  }
4604
4736
  }
4605
4737
  /** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */
@@ -4621,28 +4753,55 @@ function readMobileRelayBaseUrl(env = process.env) {
4621
4753
  return value;
4622
4754
  }
4623
4755
  /**
4756
+ * Sentinel connection returned by {@link DualConnectionRouter.active} before the
4757
+ * first `start_debug` boots a family (all-lazy, issue #396). It satisfies the
4758
+ * full {@link CdpConnection} interface but holds nothing: `listTargets()` is
4759
+ * empty, every command rejects with a clear "call start_debug first" message,
4760
+ * and all event/teardown members are safe no-ops. Callers that read tools before
4761
+ * any switchMode therefore get an honest empty/down state instead of an NPE.
4762
+ */
4763
+ const NULL_CDP_CONNECTION = {
4764
+ kind: "local",
4765
+ enableDomains: () => Promise.resolve(),
4766
+ listTargets: () => [],
4767
+ getBufferedEvents: () => [],
4768
+ on: () => () => {},
4769
+ send: () => Promise.reject(/* @__PURE__ */ new Error("no family booted yet — call start_debug first")),
4770
+ close: () => {}
4771
+ };
4772
+ /**
4624
4773
  * Production `ConnectionRouter` (issues #348, #356, #378 — DUAL-CONNECTION-COEXIST).
4625
4774
  *
4626
- * Holds one eagerly-booted family plus a keyed set of lazily-booted families
4627
- * ({@link FamilyKey} → `BootedFamily`, issue #378), an `active` pointer, and the
4775
+ * Holds a keyed set of lazily-booted families ({@link FamilyKey} →
4776
+ * `BootedFamily`, issue #378) with NO family active at startup (issue #396); the
4777
+ * first `start_debug` boots and activates one. Plus an `active` pointer and the
4628
4778
  * single attach watcher armed on the active connection. The router is
4629
- * **direction-neutral** (#356): any family can be the eager one, so a
4779
+ * **direction-neutral** (#356): any family can be the first one booted, so a
4630
4780
  * `--target=local` session can hot-switch into relay (and vice versa) without
4631
4781
  * restarting the MCP server.
4632
4782
  *
4633
- * Why a KEYED map and not a single lazy slot (#378): `mobile` (env-2 external
4634
- * relay) and `staging`/`live` (intoss relay) are BOTH `kind: 'relay'`. A single
4635
- * "opposite-kind" slot could not warm-keep both at once — they would collide.
4636
- * The three `FamilyKey`s (`local` / `relay-intoss` / `relay-external`) give each
4637
- * its own warm slot.
4783
+ * Why a KEYED map and not a single lazy slot (#378): `relay-sandbox` (env-2
4784
+ * external relay) and `relay-staging`/`relay-live` (intoss relay) are BOTH
4785
+ * `kind: 'relay'`. A single "opposite-kind" slot could not warm-keep both at
4786
+ * once they would collide. The three `FamilyKey`s
4787
+ * (`local-browser` / `relay-intoss` / `relay-sandbox`) give each its own warm
4788
+ * slot — `relay-staging` and `relay-live` deliberately share the one
4789
+ * `relay-intoss` slot (wire-identical, distinguished only by `liveIntent`).
4790
+ *
4791
+ * Why all-lazy (#396): the relay TOTP secret now lives in a project-local
4792
+ * `.ait_relay` file loaded read-only by `switchMode` BEFORE a relay family boots.
4793
+ * Booting any family eagerly at startup would bypass that load. With NO eager
4794
+ * boot every relay boot flows through `switchMode → loadRelaySecretReadOnly`, so
4795
+ * the secret is always populated before `assertRelayAuthConfigured()` /
4796
+ * `buildRelayVerifyAuth()` run at the boot site.
4638
4797
  *
4639
4798
  * `switchMode`:
4640
- * 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed `live`;
4641
- * 2. resolves the requested mode's `FamilyKey`: equals `eagerKey` → reuse
4642
- * eager; else `lazyFamilies.get(key) ?? (boot via bootLazyFor(key), store)`;
4799
+ * 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed `relay-live`;
4800
+ * 2. resolves the requested mode's `FamilyKey`:
4801
+ * `lazyFamilies.get(key) ?? (boot via bootLazyFor(key), store)`;
4643
4802
  * 3. flips `active` (the MCP `Server` never re-handshakes — it reads through
4644
4803
  * `active` per request);
4645
- * 4. sets `liveIntent` (true only for `live`; `mobile` is dev-intent → false);
4804
+ * 4. sets `liveIntent` (true only for `relay-live`; `relay-sandbox` is dev-intent → false);
4646
4805
  * 5. stops the old attach watcher and re-arms one on the new connection
4647
4806
  * (the watcher self-clears, so re-arm is mandatory);
4648
4807
  * 6. emits `tools/list_changed`.
@@ -4653,37 +4812,37 @@ function readMobileRelayBaseUrl(env = process.env) {
4653
4812
  */
4654
4813
  var DualConnectionRouter = class {
4655
4814
  deps;
4656
- /** Non-eager families, booted lazily and warm-kept per {@link FamilyKey} (#378). */
4815
+ /** Families, booted lazily and warm-kept per {@link FamilyKey} (#378, #396). */
4657
4816
  lazyFamilies = /* @__PURE__ */ new Map();
4658
- activeFamily;
4817
+ /** `null` until the first `start_debug` boots a family (all-lazy, #396). */
4818
+ activeFamily = null;
4659
4819
  server = null;
4660
4820
  attachWatcher = null;
4661
4821
  swapInFlight = false;
4662
4822
  constructor(deps) {
4663
4823
  this.deps = deps;
4664
- this.activeFamily = deps.eager;
4665
4824
  }
4666
4825
  get active() {
4667
- return this.activeFamily.connection;
4826
+ return this.activeFamily ? this.activeFamily.connection : NULL_CDP_CONNECTION;
4668
4827
  }
4669
4828
  /** Relay origin of the currently-active family (issue #378). */
4670
4829
  get activeRelayOrigin() {
4671
- return this.activeFamily.relayOrigin;
4830
+ return this.activeFamily?.relayOrigin;
4672
4831
  }
4673
- /** Every booted family (for unified shutdown). */
4832
+ /** Every booted family (for unified shutdown). All families are lazy (#396). */
4674
4833
  bootedFamilies() {
4675
- return [this.deps.eager, ...this.lazyFamilies.values()];
4834
+ return [...this.lazyFamilies.values()];
4676
4835
  }
4677
4836
  /**
4678
4837
  * Live tunnel status of the active relay family (issues #356, #378). Reads
4679
- * the ACTIVE family's tunnel when it has one (so `mobile` surfaces the
4680
- * external relay wss and `staging`/`live` the intoss relay wss); otherwise
4838
+ * the ACTIVE family's tunnel when it has one (so `relay-sandbox` surfaces the
4839
+ * external relay wss and `relay-staging`/`relay-live` the intoss relay wss); otherwise
4681
4840
  * falls back to the first booted family that has a tunnel. Returns "down"
4682
- * until any relay family is booted (local-eager sessions before the first
4683
- * relay switch) — the correct signal for `build_attach_url` (no tunnel yet).
4841
+ * until any relay family is booted (any session before the first relay
4842
+ * start_debug) — the correct signal for `build_attach_url` (no tunnel yet).
4684
4843
  */
4685
4844
  relayTunnelStatus() {
4686
- if (this.activeFamily.getTunnelStatus) return this.activeFamily.getTunnelStatus();
4845
+ if (this.activeFamily?.getTunnelStatus) return this.activeFamily.getTunnelStatus();
4687
4846
  for (const family of this.bootedFamilies()) if (family.getTunnelStatus) return family.getTunnelStatus();
4688
4847
  return {
4689
4848
  up: false,
@@ -4691,8 +4850,9 @@ var DualConnectionRouter = class {
4691
4850
  };
4692
4851
  }
4693
4852
  /**
4694
- * Binds the MCP `Server` and arms the initial attach watcher on the active
4695
- * connection. Called once after `createDebugServer` + `connect`.
4853
+ * Binds the MCP `Server`; the attach watcher is armed by the first
4854
+ * `start_debug` since no family is active at startup (all-lazy, #396). Called
4855
+ * once after `createDebugServer` + `connect`.
4696
4856
  */
4697
4857
  start(server) {
4698
4858
  this.server = server;
@@ -4707,32 +4867,36 @@ var DualConnectionRouter = class {
4707
4867
  armWatcher() {
4708
4868
  const server = this.server;
4709
4869
  if (!server) return;
4710
- this.attachWatcher = startAttachWatcher(this.activeFamily.connection, server, this.deps.attachWatcherIntervalMs ?? 1e3, () => {
4870
+ const activeFamily = this.activeFamily;
4871
+ if (!activeFamily) return;
4872
+ this.attachWatcher = startAttachWatcher(activeFamily.connection, server, this.deps.attachWatcherIntervalMs ?? 1e3, () => {
4711
4873
  this.deps.diagnosticsCollector.recordAttach();
4712
- if (this.activeFamily.connection.kind === "relay") this.deps.devtoolsOpener.open(this.relayTunnelStatus().wssUrl, deriveEnvironment(this.activeFamily.connection.kind, getLiveIntent(), this.activeFamily.relayOrigin));
4874
+ this.deps.onPageAttach?.();
4875
+ if (activeFamily.connection.kind === "relay") this.deps.devtoolsOpener.open(this.relayTunnelStatus().wssUrl, deriveEnvironment(activeFamily.connection.kind, getLiveIntent(), activeFamily.relayOrigin));
4713
4876
  });
4714
4877
  }
4715
4878
  /**
4716
- * Resolves the `BootedFamily` for `key`: the eager family when `key` matches
4717
- * `eagerKey`, otherwise the warm lazy family (booting + storing it once on
4718
- * first use). Only ever asks `bootLazyFor` for non-eager keys.
4879
+ * 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.
4719
4883
  */
4720
4884
  async familyFor(key) {
4721
- if (key === this.deps.eagerKey) return this.deps.eager;
4722
4885
  const warm = this.lazyFamilies.get(key);
4723
4886
  if (warm) return warm;
4724
4887
  const booted = await this.deps.bootLazyFor(key);
4725
4888
  this.lazyFamilies.set(key, booted);
4726
4889
  return booted;
4727
4890
  }
4728
- async switchMode(mode, confirm) {
4891
+ async switchMode(mode, confirm, projectRoot) {
4729
4892
  if (this.swapInFlight) throw new Error("start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.");
4730
- if (mode === "live" && !confirm) throw new Error("start_debug: live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요.");
4893
+ if (mode === "relay-live" && !confirm) throw new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요.");
4731
4894
  this.swapInFlight = true;
4732
4895
  try {
4896
+ if (isRelayMode(mode)) await loadRelaySecretReadOnly({ projectRoot });
4733
4897
  const target = await this.familyFor(familyKeyForMode(mode));
4734
4898
  this.activeFamily = target;
4735
- setLiveIntent(mode === "live");
4899
+ setLiveIntent(mode === "relay-live");
4736
4900
  this.stopWatcher();
4737
4901
  this.armWatcher();
4738
4902
  this.server?.sendToolListChanged();
@@ -4759,27 +4923,39 @@ var DualConnectionRouter = class {
4759
4923
  */
4760
4924
  async function runDebugServer(options = {}) {
4761
4925
  const lockHandle = acquireLock({ force: options.force ?? false });
4762
- const verifyAuth = buildRelayVerifyAuth();
4763
- const relayFamily = await bootRelayFamily({
4764
- relayPort: options.relayPort,
4765
- verifyAuth,
4766
- onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
4767
- });
4768
4926
  const devtoolsOpener = new AutoDevtoolsOpener();
4769
4927
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4770
4928
  const router = new DualConnectionRouter({
4771
- eager: relayFamily,
4772
- eagerKey: "relay-intoss",
4773
- bootLazyFor: (key) => key === "relay-external" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : bootLocalFamily(),
4929
+ bootLazyFor: (key) => key === "relay-sandbox" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
4930
+ relayPort: options.relayPort,
4931
+ verifyAuth: buildRelayVerifyAuth(),
4932
+ onWssUrl: (wssUrl) => {
4933
+ lockHandle.updateWssUrl(wssUrl);
4934
+ qrServer?.notifyStateChange();
4935
+ }
4936
+ }),
4774
4937
  diagnosticsCollector,
4775
- devtoolsOpener
4938
+ devtoolsOpener,
4939
+ onPageAttach: () => qrServer?.notifyStateChange()
4776
4940
  });
4777
4941
  const aitSource = new RoutingAitSource(() => {
4778
4942
  return router.active;
4779
4943
  });
4944
+ let lastAttachUrl = null;
4945
+ const getDashboardState = () => ({
4946
+ tunnel: {
4947
+ up: router.relayTunnelStatus().up,
4948
+ wssUrl: router.relayTunnelStatus().wssUrl
4949
+ },
4950
+ pages: router.active.listTargets().map((t) => ({
4951
+ id: t.id,
4952
+ url: t.url
4953
+ })),
4954
+ attachUrl: lastAttachUrl
4955
+ });
4780
4956
  let qrServer;
4781
4957
  try {
4782
- qrServer = await startQrHttpServer();
4958
+ qrServer = await startQrHttpServer(getDashboardState);
4783
4959
  } catch (err) {
4784
4960
  logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
4785
4961
  }
@@ -4792,7 +4968,11 @@ async function runDebugServer(options = {}) {
4792
4968
  return qrServer;
4793
4969
  },
4794
4970
  diagnosticsCollector,
4795
- ...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
4971
+ getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
4972
+ onAttachUrlBuilt: (url) => {
4973
+ lastAttachUrl = url;
4974
+ qrServer?.notifyStateChange();
4975
+ }
4796
4976
  });
4797
4977
  const transport = new StdioServerTransport();
4798
4978
  let closed = false;
@@ -4853,13 +5033,15 @@ async function runDebugServer(options = {}) {
4853
5033
  }
4854
5034
  }
4855
5035
  /**
4856
- * Boots the local-browser debug stack and serves it over stdio:
4857
- * 1. launch a local Chromium with `--remote-debugging-port=<port>`,
4858
- * 2. attach a `LocalCdpConnection` to the first non-blank page target,
4859
- * 3. expose the debug tools through the SAME direction-neutral
4860
- * `DualConnectionRouter` that `runDebugServer` uses (issue #356) — the
4861
- * local family is eager, the relay family is lazy-booted on the first
4862
- * `start_debug({ mode: 'relay-*' })`.
5036
+ * Serves the debug stack over stdio with the local browser as the default
5037
+ * target. Since #396 NOTHING boots at startup — every family (including the
5038
+ * local Chromium) is lazy-booted on its first `start_debug`:
5039
+ * 1. `start_debug({ mode: 'local-browser' })` launches a local Chromium with
5040
+ * `--remote-debugging-port=<port>` and attaches a `LocalCdpConnection`;
5041
+ * 2. the intoss/external relay families lazy-boot on the first
5042
+ * `start_debug({ mode: 'relay-staging' | 'relay-live' | 'relay-sandbox' })`;
5043
+ * 3. all of this runs through the SAME direction-neutral
5044
+ * `DualConnectionRouter` that `runDebugServer` uses (issue #356).
4863
5045
  *
4864
5046
  * Symmetry with `runDebugServer` (#356): starting with `--target=local` no
4865
5047
  * longer pins a single-connection router. A `--target=local` session can
@@ -4881,38 +5063,55 @@ async function runDebugServer(options = {}) {
4881
5063
  */
4882
5064
  async function runLocalDebugServer(options = {}) {
4883
5065
  const lockHandle = acquireLock({ force: options.force ?? false });
4884
- const chromium = await launchChromium({
4885
- port: options.cdpPort ?? 0,
4886
- devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
4887
- });
4888
- await new Promise((r) => setTimeout(r, 800));
4889
- const localConnection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
4890
- const localFamily = {
4891
- connection: localConnection,
4892
- stop() {
4893
- localConnection.close();
4894
- chromium.stop();
4895
- }
5066
+ const cdpPort = options.cdpPort ?? 0;
5067
+ const devUrl = options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173";
5068
+ const bootLocalFamilyForEntry = async () => {
5069
+ const chromium = await launchChromium({
5070
+ port: cdpPort,
5071
+ devUrl
5072
+ });
5073
+ await new Promise((r) => setTimeout(r, 800));
5074
+ const localConnection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
5075
+ return {
5076
+ connection: localConnection,
5077
+ stop() {
5078
+ localConnection.close();
5079
+ chromium.stop();
5080
+ }
5081
+ };
4896
5082
  };
4897
- const verifyAuth = buildRelayVerifyAuth();
4898
5083
  const devtoolsOpener = new AutoDevtoolsOpener();
4899
5084
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4900
5085
  const router = new DualConnectionRouter({
4901
- eager: localFamily,
4902
- eagerKey: "local",
4903
- bootLazyFor: (key) => key === "relay-external" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : bootRelayFamily({
4904
- verifyAuth,
4905
- onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
5086
+ bootLazyFor: (key) => key === "relay-sandbox" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : key === "local-browser" ? bootLocalFamilyForEntry() : bootRelayFamily({
5087
+ verifyAuth: buildRelayVerifyAuth(),
5088
+ onWssUrl: (wssUrl) => {
5089
+ lockHandle.updateWssUrl(wssUrl);
5090
+ qrServer?.notifyStateChange();
5091
+ }
4906
5092
  }),
4907
5093
  diagnosticsCollector,
4908
- devtoolsOpener
5094
+ devtoolsOpener,
5095
+ onPageAttach: () => qrServer?.notifyStateChange()
4909
5096
  });
4910
5097
  const aitSource = new RoutingAitSource(() => {
4911
5098
  return router.active;
4912
5099
  });
5100
+ let lastAttachUrl = null;
5101
+ const getDashboardState = () => ({
5102
+ tunnel: {
5103
+ up: router.relayTunnelStatus().up,
5104
+ wssUrl: router.relayTunnelStatus().wssUrl
5105
+ },
5106
+ pages: router.active.listTargets().map((t) => ({
5107
+ id: t.id,
5108
+ url: t.url
5109
+ })),
5110
+ attachUrl: lastAttachUrl
5111
+ });
4913
5112
  let qrServer;
4914
5113
  try {
4915
- qrServer = await startQrHttpServer();
5114
+ qrServer = await startQrHttpServer(getDashboardState);
4916
5115
  } catch (err) {
4917
5116
  logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
4918
5117
  }
@@ -4925,7 +5124,11 @@ async function runLocalDebugServer(options = {}) {
4925
5124
  return qrServer;
4926
5125
  },
4927
5126
  diagnosticsCollector,
4928
- ...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
5127
+ getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
5128
+ onAttachUrlBuilt: (url) => {
5129
+ lastAttachUrl = url;
5130
+ qrServer?.notifyStateChange();
5131
+ }
4929
5132
  });
4930
5133
  const transport = new StdioServerTransport();
4931
5134
  let closed = false;
@@ -4956,7 +5159,7 @@ async function runLocalDebugServer(options = {}) {
4956
5159
  logError("tool.error", {
4957
5160
  msg: `uncaughtException: ${String(err)}`,
4958
5161
  errorKind: "uncaught",
4959
- mode: "local"
5162
+ mode: "local-browser"
4960
5163
  });
4961
5164
  shutdown();
4962
5165
  process.exit(1);
@@ -4965,7 +5168,7 @@ async function runLocalDebugServer(options = {}) {
4965
5168
  logError("tool.error", {
4966
5169
  msg: `unhandledRejection: ${String(reason)}`,
4967
5170
  errorKind: "unhandled-rejection",
4968
- mode: "local"
5171
+ mode: "local-browser"
4969
5172
  });
4970
5173
  shutdown();
4971
5174
  process.exit(1);
@@ -4988,8 +5191,10 @@ async function runLocalDebugServer(options = {}) {
4988
5191
  }
4989
5192
  }
4990
5193
  /**
4991
- * Boots the env-2 (real-device PWA) debug stack and serves it over stdio
4992
- * (issue #378). The external Chii relay is the EAGER family here.
5194
+ * Serves the env-2 (real-device PWA) debug stack over stdio with the external
5195
+ * Chii relay as the default target (issue #378). Since #396 NOTHING boots at
5196
+ * startup — the external relay family is lazy-booted on the first
5197
+ * `start_debug({ mode: 'relay-sandbox' })`.
4993
5198
  *
4994
5199
  * Unlike `runDebugServer` (which starts its own relay + cloudflared tunnel),
4995
5200
  * `runMobileDebugServer` attaches to a relay the unplugin ALREADY brought up
@@ -4997,11 +5202,13 @@ async function runLocalDebugServer(options = {}) {
4997
5202
  * opens a CDP client against that external relay — it never starts or tears down
4998
5203
  * a relay or a tunnel it did not own (see {@link bootExternalRelayFamily}).
4999
5204
  *
5000
- * Symmetry with `runDebugServer` / `runLocalDebugServer` (#356, #378): the env-2
5001
- * external relay is eager; the local family and the intoss relay family are
5002
- * lazy-booted on the first `start_debug({ mode: 'local' | 'staging' | 'live' })`,
5003
- * so a `--target=mobile` session can hot-switch without a restart. The active
5004
- * env derives to `relay-mobile` (external-PWA origin, liveIntent off).
5205
+ * Symmetry with `runDebugServer` / `runLocalDebugServer` (#356, #378, #396): all
5206
+ * three families are lazy-booted the env-2 external relay on the first
5207
+ * `start_debug({ mode: 'relay-sandbox' })`, the local family on `local-browser`,
5208
+ * the intoss relay on `relay-staging`/`relay-live` — so a `--target=mobile`
5209
+ * session can hot-switch
5210
+ * without a restart. The active env derives to `relay-mobile` (external-PWA
5211
+ * origin, liveIntent off).
5005
5212
  *
5006
5213
  * SECRET-HANDLING: `AIT_RELAY_BASE_URL` is read once here via
5007
5214
  * {@link readMobileRelayBaseUrl}; when unset it throws
@@ -5013,26 +5220,38 @@ async function runLocalDebugServer(options = {}) {
5013
5220
  async function runMobileDebugServer(options = {}) {
5014
5221
  const relayBaseUrl = readMobileRelayBaseUrl();
5015
5222
  const lockHandle = acquireLock({ force: options.force ?? false });
5016
- const externalRelayFamily = await bootExternalRelayFamily(relayBaseUrl);
5017
- const verifyAuth = buildRelayVerifyAuth();
5018
5223
  const devtoolsOpener = new AutoDevtoolsOpener();
5019
5224
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
5020
5225
  const router = new DualConnectionRouter({
5021
- eager: externalRelayFamily,
5022
- eagerKey: "relay-external",
5023
- bootLazyFor: (key) => key === "local" ? bootLocalFamily() : bootRelayFamily({
5024
- verifyAuth,
5025
- onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
5226
+ bootLazyFor: (key) => key === "relay-sandbox" ? bootExternalRelayFamily(relayBaseUrl) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
5227
+ verifyAuth: buildRelayVerifyAuth(),
5228
+ onWssUrl: (wssUrl) => {
5229
+ lockHandle.updateWssUrl(wssUrl);
5230
+ qrServer?.notifyStateChange();
5231
+ }
5026
5232
  }),
5027
5233
  diagnosticsCollector,
5028
- devtoolsOpener
5234
+ devtoolsOpener,
5235
+ onPageAttach: () => qrServer?.notifyStateChange()
5029
5236
  });
5030
5237
  const aitSource = new RoutingAitSource(() => {
5031
5238
  return router.active;
5032
5239
  });
5240
+ let lastAttachUrl = null;
5241
+ const getDashboardState = () => ({
5242
+ tunnel: {
5243
+ up: router.relayTunnelStatus().up,
5244
+ wssUrl: router.relayTunnelStatus().wssUrl
5245
+ },
5246
+ pages: router.active.listTargets().map((t) => ({
5247
+ id: t.id,
5248
+ url: t.url
5249
+ })),
5250
+ attachUrl: lastAttachUrl
5251
+ });
5033
5252
  let qrServer;
5034
5253
  try {
5035
- qrServer = await startQrHttpServer();
5254
+ qrServer = await startQrHttpServer(getDashboardState);
5036
5255
  } catch (err) {
5037
5256
  logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
5038
5257
  }
@@ -5045,7 +5264,11 @@ async function runMobileDebugServer(options = {}) {
5045
5264
  return qrServer;
5046
5265
  },
5047
5266
  diagnosticsCollector,
5048
- ...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
5267
+ getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
5268
+ onAttachUrlBuilt: (url) => {
5269
+ lastAttachUrl = url;
5270
+ qrServer?.notifyStateChange();
5271
+ }
5049
5272
  });
5050
5273
  const transport = new StdioServerTransport();
5051
5274
  let closed = false;
@@ -5076,7 +5299,7 @@ async function runMobileDebugServer(options = {}) {
5076
5299
  logError("tool.error", {
5077
5300
  msg: `uncaughtException: ${String(err)}`,
5078
5301
  errorKind: "uncaught",
5079
- mode: "mobile"
5302
+ mode: "relay-sandbox"
5080
5303
  });
5081
5304
  shutdown();
5082
5305
  process.exit(1);
@@ -5085,7 +5308,7 @@ async function runMobileDebugServer(options = {}) {
5085
5308
  logError("tool.error", {
5086
5309
  msg: `unhandledRejection: ${String(reason)}`,
5087
5310
  errorKind: "unhandled-rejection",
5088
- mode: "mobile"
5311
+ mode: "relay-sandbox"
5089
5312
  });
5090
5313
  shutdown();
5091
5314
  process.exit(1);
@@ -5535,7 +5758,7 @@ function createDevServer(deps = {}) {
5535
5758
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
5536
5759
  const server = new Server({
5537
5760
  name: "ait-devtools",
5538
- version: "0.1.56"
5761
+ version: "0.1.57"
5539
5762
  }, { capabilities: { tools: {} } });
5540
5763
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
5541
5764
  server.setRequestHandler(CallToolRequestSchema, async (request) => {