@ait-co/devtools 0.1.66 → 0.1.68

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 (47) hide show
  1. package/dist/chii-relay-BNd3G3UG.js +152 -0
  2. package/dist/chii-relay-BNd3G3UG.js.map +1 -0
  3. package/dist/chii-relay-DngjQ2_A.cjs +151 -0
  4. package/dist/chii-relay-DngjQ2_A.cjs.map +1 -0
  5. package/dist/in-app/index.d.ts +24 -6
  6. package/dist/in-app/index.d.ts.map +1 -1
  7. package/dist/in-app/index.js +27 -8
  8. package/dist/in-app/index.js.map +1 -1
  9. package/dist/mcp/cli.js +495 -68
  10. package/dist/mcp/cli.js.map +1 -1
  11. package/dist/mcp/server.js +2 -2
  12. package/dist/mcp/server.js.map +1 -1
  13. package/dist/panel/index.js +56 -20
  14. package/dist/panel/index.js.map +1 -1
  15. package/dist/{qr-http-server-BuyQnaS6.js → qr-http-server-CyVQphTM.js} +376 -45
  16. package/dist/qr-http-server-CyVQphTM.js.map +1 -0
  17. package/dist/{qr-http-server-CLtsKfPF.js → qr-http-server-DKEca8J3.js} +376 -45
  18. package/dist/qr-http-server-DKEca8J3.js.map +1 -0
  19. package/dist/{qr-http-server-CMJmKkb8.cjs → qr-http-server-DR__VNnX.cjs} +376 -45
  20. package/dist/qr-http-server-DR__VNnX.cjs.map +1 -0
  21. package/dist/{qr-http-server-CQwQumPJ.cjs → qr-http-server-DnQSQ3hC.cjs} +376 -45
  22. package/dist/qr-http-server-DnQSQ3hC.cjs.map +1 -0
  23. package/dist/{tunnel-BpllDsRw.cjs → tunnel-BMY7KgO5.cjs} +4 -3
  24. package/dist/{tunnel-BpllDsRw.cjs.map → tunnel-BMY7KgO5.cjs.map} +1 -1
  25. package/dist/{tunnel-fm4hDfV-.js → tunnel-DIN5Vvbo.js} +4 -3
  26. package/dist/{tunnel-fm4hDfV-.js.map → tunnel-DIN5Vvbo.js.map} +1 -1
  27. package/dist/unplugin/index.cjs +10 -3
  28. package/dist/unplugin/index.cjs.map +1 -1
  29. package/dist/unplugin/index.d.cts.map +1 -1
  30. package/dist/unplugin/index.d.ts.map +1 -1
  31. package/dist/unplugin/index.js +10 -3
  32. package/dist/unplugin/index.js.map +1 -1
  33. package/dist/unplugin/tunnel.cjs +3 -2
  34. package/dist/unplugin/tunnel.cjs.map +1 -1
  35. package/dist/unplugin/tunnel.d.cts.map +1 -1
  36. package/dist/unplugin/tunnel.d.ts.map +1 -1
  37. package/dist/unplugin/tunnel.js +3 -2
  38. package/dist/unplugin/tunnel.js.map +1 -1
  39. package/package.json +1 -1
  40. package/dist/chii-relay-57BfqF_5.cjs +0 -88
  41. package/dist/chii-relay-57BfqF_5.cjs.map +0 -1
  42. package/dist/chii-relay-itXOz7kS.js +0 -89
  43. package/dist/chii-relay-itXOz7kS.js.map +0 -1
  44. package/dist/qr-http-server-BuyQnaS6.js.map +0 -1
  45. package/dist/qr-http-server-CLtsKfPF.js.map +0 -1
  46. package/dist/qr-http-server-CMJmKkb8.cjs.map +0 -1
  47. package/dist/qr-http-server-CQwQumPJ.cjs.map +0 -1
package/dist/mcp/cli.js CHANGED
@@ -721,11 +721,26 @@ var ChiiCdpConnection = class {
721
721
  * entries.
722
722
  *
723
723
  * TOTP auth (relay-side, authoritative gate):
724
- * When `verifyAuth` is provided, this module registers an HTTP upgrade
725
- * listener on the server BEFORE calling `chii.start({server})`. Node's
726
- * `http.Server` allows multiple 'upgrade' listeners; the first to call
727
- * `socket.destroy()` wins. Invalid auth → 401 + destroy (chii never sees
728
- * the connection). Valid auth → return without side-effect (chii handles it).
724
+ * When `verifyAuth` is provided, this module registers HTTP 'upgrade' and
725
+ * 'request' listeners on the server BEFORE calling `chii.start({server})`.
726
+ * Node's `http.Server` calls listeners in registration order; the first to
727
+ * call `socket.destroy()` (upgrade) or `res.end()` (request) wins. Invalid
728
+ * auth → 401 + destroy (chii never sees the connection). Valid auth →
729
+ * return without side-effect (chii handles it).
730
+ *
731
+ * TOTP code transports (issue #466) — two equivalent ways to carry the code:
732
+ * 1. Query param `at=<code>` — used by the daemon-side `/client` connection
733
+ * (`chii-connection.ts` appends it; it holds the secret).
734
+ * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's
735
+ * stock `target.js` derives its WS endpoint from the script `src`
736
+ * (`scriptEl.src.replace('target.js','')`), so the only way for the
737
+ * phone to carry a code is to embed it in the script URL path. The
738
+ * in-app attach injects `https://<host>/at/<code>/target.js`; both the
739
+ * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS
740
+ * dial then carry the prefix. The listeners below rewrite the prefix
741
+ * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`
742
+ * before chii's own handlers (registered later) parse it — chii only
743
+ * ever sees the stripped URL.
729
744
  *
730
745
  * Threat model: "URL leak" — someone obtains the tunnel URL (Slack paste, QR
731
746
  * screenshot, shoulder-surfing) but does not have the shared TOTP secret.
@@ -744,6 +759,33 @@ function loadChiiServer() {
744
759
  throw new Error("chii server module did not expose start()");
745
760
  }
746
761
  /**
762
+ * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent
763
+ * query-based form, e.g.:
764
+ *
765
+ * `/at/123456/target.js` → `/target.js?at=123456`
766
+ * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`
767
+ * `/at/123456/` → `/?at=123456`
768
+ *
769
+ * Returns `null` when the URL does not carry the prefix (including an empty
770
+ * code segment) — callers fall back to the unmodified URL and the existing
771
+ * query-based auth path.
772
+ *
773
+ * Pure string surgery — this function knows nothing about secrets or code
774
+ * validity; verification stays inside the caller-provided `verifyAuth`
775
+ * predicate (which parses the query). The raw path segment is appended
776
+ * verbatim to the query: both path segments and query values are
777
+ * percent-decoded exactly once by their consumers, so no re-encoding is
778
+ * needed (TOTP codes are 6 digits and never percent-encoded in practice).
779
+ */
780
+ function rewriteAtPathPrefix(rawUrl) {
781
+ const match = /^\/at\/([^/?]+)(\/[^?]*)?(\?.*)?$/.exec(rawUrl);
782
+ if (match === null) return null;
783
+ const code = match[1];
784
+ const path = match[2] === void 0 || match[2] === "" ? "/" : match[2];
785
+ const query = match[3] ?? "";
786
+ return `${path}${query}${query === "" ? "?" : "&"}at=${code}`;
787
+ }
788
+ /**
747
789
  * Starts the Chii relay and resolves once listening.
748
790
  *
749
791
  * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral
@@ -762,15 +804,36 @@ function loadChiiServer() {
762
804
  async function startChiiRelay(options = {}) {
763
805
  const requestedPort = options.port ?? 0;
764
806
  const host = options.host ?? "127.0.0.1";
765
- const { verifyAuth } = options;
807
+ const { verifyAuth, onAuthReject } = options;
766
808
  const httpServer = createServer();
767
- if (verifyAuth) httpServer.on("upgrade", (req, socket) => {
768
- if (!verifyAuth(req)) {
769
- socket.write("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n");
770
- socket.destroy();
771
- return;
772
- }
773
- });
809
+ const notifyAuthReject = (kind) => {
810
+ if (onAuthReject === void 0) return;
811
+ try {
812
+ onAuthReject({ kind });
813
+ } catch {}
814
+ };
815
+ if (verifyAuth) {
816
+ httpServer.on("upgrade", (req, socket) => {
817
+ const rewritten = rewriteAtPathPrefix(req.url ?? "");
818
+ if (rewritten !== null) req.url = rewritten;
819
+ if (!verifyAuth(req)) {
820
+ socket.write("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n");
821
+ socket.destroy();
822
+ notifyAuthReject("ws-upgrade");
823
+ return;
824
+ }
825
+ });
826
+ httpServer.on("request", (req, res) => {
827
+ const rewritten = rewriteAtPathPrefix(req.url ?? "");
828
+ if (rewritten === null) return;
829
+ req.url = rewritten;
830
+ if (!verifyAuth(req)) {
831
+ res.statusCode = 401;
832
+ res.end();
833
+ notifyAuthReject("http-request");
834
+ }
835
+ });
836
+ }
774
837
  await loadChiiServer().start({
775
838
  server: httpServer,
776
839
  domain: `${host}:${requestedPort}`,
@@ -1832,19 +1895,32 @@ const en = {
1832
1895
  "dashboard.attach.hint": "Call the build_attach_url MCP tool to show the QR here.",
1833
1896
  "dashboard.pages.section": "Connected Pages",
1834
1897
  "dashboard.pages.empty": "No attached pages",
1898
+ "dashboard.url.copy": "Copy",
1899
+ "dashboard.url.copied": "Copied",
1835
1900
  "attach.title": "AIT Debug Session — QR Scan",
1836
1901
  "attach.deployment": "deployment: {label}",
1837
1902
  "attach.steps.section": "How to scan",
1838
- "attach.step1": "Open the Toss app.",
1839
- "attach.step2": "Scan the QR code with your phone camera app.",
1840
- "attach.step3": "Tap <strong>\"Open in Toss\"</strong> when the popup appears.",
1841
- "attach.step4": "The mini-app opens and the debug session attaches automatically.",
1842
1903
  "attach.faq.section": "Troubleshooting checklist",
1843
- "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)",
1844
- "attach.faq.prepare": "<strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter",
1845
- "attach.faq.chii": "<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import",
1846
- "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",
1847
1904
  "attach.url.section": "URL (fallback)",
1905
+ "attach.mode.sandbox": "Env 2 — AITC Sandbox PWA",
1906
+ "attach.mode.intossDev": "Env 3 — intoss-private relay dev",
1907
+ "attach.mode.intossLive": "Env 4 — intoss live relay debug",
1908
+ "attach.sandbox.step1": "Launch the launcher PWA icon on your home screen (if the Safari address bar is visible, it is not standalone).",
1909
+ "attach.sandbox.step2": "Scan this QR code with <strong>\"Scan QR with camera\"</strong> inside the launcher.",
1910
+ "attach.sandbox.step3": "The mini-app opens fullscreen and the debug session attaches automatically.",
1911
+ "attach.sandbox.faq.notInstalled": "<strong>Launcher is not installed</strong> — open <code>devtools.aitc.dev/launcher/</code> once and add it to your home screen",
1912
+ "attach.sandbox.faq.cameraApp": "<strong>Scanning with the camera app opens a Safari tab (bottom tab bar visible)</strong> — relaunch from the launcher icon and use the in-app scanner",
1913
+ "attach.sandbox.faq.totp": "<strong>QR expired (TOTP 30 s)</strong> — scan a fresh QR code",
1914
+ "attach.sandbox.faq.chii": "<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import",
1915
+ "attach.intoss.step1": "Open the Toss app.",
1916
+ "attach.intoss.step2": "Scan the QR code with your phone camera app.",
1917
+ "attach.intoss.step3": "Tap <strong>\"Open in Toss\"</strong> when the popup appears.",
1918
+ "attach.intoss.step4": "The mini-app opens and the debug session attaches automatically.",
1919
+ "attach.intoss.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)",
1920
+ "attach.intoss.faq.prepare": "<strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter",
1921
+ "attach.intoss.faq.chii": "<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import",
1922
+ "attach.intoss.faq.totp": "<strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server",
1923
+ "attach.intoss.faq.liveReadOnly": "<strong>LIVE session is read-only</strong> — <code>call_sdk</code>/<code>evaluate</code> require an explicit <code>confirm</code>",
1848
1924
  "launcher.title": "AITC DevTools Launcher",
1849
1925
  "launcher.description": "Scan the terminal QR code or paste the tunnel URL.",
1850
1926
  "launcher.installCta": "Install launcher to your phone",
@@ -1858,7 +1934,12 @@ const en = {
1858
1934
  "launcher.invalidUrl": "Enter a valid http(s):// URL.",
1859
1935
  "launcher.debugAuthFailed": "Debug connection authentication failed",
1860
1936
  "launcher.debugAuthFailedHint": "The QR code may have expired. Scan a fresh QR code.",
1861
- "launcher.debugAuthRescanCta": "Scan a new QR"
1937
+ "launcher.debugAuthRescanCta": "Scan a new QR",
1938
+ "launcher.diagFab": "Diag",
1939
+ "launcher.diagTitle": "Viewport diagnostics",
1940
+ "launcher.diagYes": "yes",
1941
+ "launcher.diagNo": "no",
1942
+ "launcher.letterboxDetected": "Display area is {pt}pt short — likely an iOS standalone letterbox. Removing and re-adding the launcher to the home screen may fix it."
1862
1943
  };
1863
1944
  //#endregion
1864
1945
  //#region src/i18n/index.ts
@@ -2057,19 +2138,32 @@ const tables = {
2057
2138
  "dashboard.attach.hint": "build_attach_url MCP tool을 호출하면 QR이 여기에 표시됩니다.",
2058
2139
  "dashboard.pages.section": "연결된 Pages",
2059
2140
  "dashboard.pages.empty": "attach된 페이지 없음",
2141
+ "dashboard.url.copy": "복사",
2142
+ "dashboard.url.copied": "복사됨",
2060
2143
  "attach.title": "AIT 디버그 세션 — QR 스캔",
2061
2144
  "attach.deployment": "deployment: {label}",
2062
2145
  "attach.steps.section": "스캔 절차",
2063
- "attach.step1": "토스 앱을 실행하세요.",
2064
- "attach.step2": "폰 카메라 앱으로 QR 코드를 스캔하세요.",
2065
- "attach.step3": "팝업이 뜨면 <strong>\"토스로 열기\"</strong>를 탭하세요.",
2066
- "attach.step4": "미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.",
2067
2146
  "attach.faq.section": "진단 체크리스트",
2068
- "attach.faq.appNotOpen": "<strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)",
2069
- "attach.faq.prepare": "<strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인",
2070
- "attach.faq.chii": "<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인",
2071
- "attach.faq.totp": "<strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인",
2072
2147
  "attach.url.section": "URL (fallback)",
2148
+ "attach.mode.sandbox": "환경 2 — AITC Sandbox PWA",
2149
+ "attach.mode.intossDev": "환경 3 — intoss-private relay dev",
2150
+ "attach.mode.intossLive": "환경 4 — intoss live relay debug",
2151
+ "attach.sandbox.step1": "홈 화면의 launcher PWA 아이콘으로 실행하세요 (Safari 주소창이 보이면 standalone이 아닙니다).",
2152
+ "attach.sandbox.step2": "launcher 안의 <strong>\"QR 카메라로 스캔\"</strong>으로 이 QR 코드를 스캔하세요.",
2153
+ "attach.sandbox.step3": "미니앱이 풀스크린으로 열리고 디버그 세션이 자동으로 attach됩니다.",
2154
+ "attach.sandbox.faq.notInstalled": "<strong>launcher가 설치돼 있지 않은 경우</strong> — <code>devtools.aitc.dev/launcher/</code>를 한 번 열어 홈 화면에 추가하세요",
2155
+ "attach.sandbox.faq.cameraApp": "<strong>카메라 앱으로 스캔하면 Safari 탭으로 열립니다 (하단 탭 바 노출)</strong> — launcher 아이콘으로 다시 실행해 인앱 스캔을 사용하세요",
2156
+ "attach.sandbox.faq.totp": "<strong>QR이 만료된 경우 (TOTP 30초)</strong> — 새 QR을 다시 스캔하세요",
2157
+ "attach.sandbox.faq.chii": "<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인",
2158
+ "attach.intoss.step1": "토스 앱을 실행하세요.",
2159
+ "attach.intoss.step2": "폰 카메라 앱으로 QR 코드를 스캔하세요.",
2160
+ "attach.intoss.step3": "팝업이 뜨면 <strong>\"토스로 열기\"</strong>를 탭하세요.",
2161
+ "attach.intoss.step4": "미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.",
2162
+ "attach.intoss.faq.appNotOpen": "<strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)",
2163
+ "attach.intoss.faq.prepare": "<strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인",
2164
+ "attach.intoss.faq.chii": "<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인",
2165
+ "attach.intoss.faq.totp": "<strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인",
2166
+ "attach.intoss.faq.liveReadOnly": "<strong>LIVE 세션은 read-only입니다</strong> — <code>call_sdk</code>/<code>evaluate</code> 실행에는 명시적 <code>confirm</code>이 필요합니다",
2073
2167
  "launcher.title": "AITC DevTools Launcher",
2074
2168
  "launcher.description": "터미널 QR을 스캔하거나 URL을 입력하세요.",
2075
2169
  "launcher.installCta": "폰에 런처 설치하기",
@@ -2083,7 +2177,12 @@ const tables = {
2083
2177
  "launcher.invalidUrl": "올바른 http(s):// URL을 입력하세요.",
2084
2178
  "launcher.debugAuthFailed": "디버그 연결 인증 실패",
2085
2179
  "launcher.debugAuthFailedHint": "QR 코드가 만료되었을 수 있어요. 새 QR을 다시 스캔하세요.",
2086
- "launcher.debugAuthRescanCta": "새 QR 스캔하기"
2180
+ "launcher.debugAuthRescanCta": "새 QR 스캔하기",
2181
+ "launcher.diagFab": "진단",
2182
+ "launcher.diagTitle": "뷰포트 진단",
2183
+ "launcher.diagYes": "예",
2184
+ "launcher.diagNo": "아니요",
2185
+ "launcher.letterboxDetected": "표시 영역이 {pt}pt 부족합니다 — iOS standalone letterbox로 보입니다. 런처를 홈 화면에서 제거 후 다시 설치하면 해소될 수 있어요."
2087
2186
  },
2088
2187
  en
2089
2188
  };
@@ -2152,12 +2251,24 @@ img.qr {
2152
2251
  background: #fff; padding: 0.75rem; border-radius: 10px;
2153
2252
  display: block; margin: 0.5rem auto;
2154
2253
  }
2254
+ .url-row {
2255
+ display: flex; align-items: stretch; gap: 0; margin: 0.5rem 0 0;
2256
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
2257
+ }
2155
2258
  .url-box {
2156
2259
  font-family: monospace; font-size: 0.7rem;
2157
2260
  word-break: break-all; opacity: 0.45;
2158
2261
  background: #161b22; padding: 0.6rem 0.85rem;
2159
- border-radius: 6px; border: 1px solid #30363d; margin: 0.5rem 0 0;
2262
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
2160
2263
  }
2264
+ .url-box:hover { opacity: 0.65; }
2265
+ .copy-btn {
2266
+ flex-shrink: 0; padding: 0.4rem 0.7rem;
2267
+ background: #21262d; border: none; border-left: 1px solid #30363d;
2268
+ color: #58a6ff; font-size: 0.7rem; cursor: pointer; white-space: nowrap;
2269
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2270
+ }
2271
+ .copy-btn:hover { background: #30363d; }
2161
2272
  .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
2162
2273
  ul { margin: 0; padding-left: 1.25rem; }
2163
2274
  li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
@@ -2169,7 +2280,57 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
2169
2280
  .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2170
2281
  .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2171
2282
  </style></head><body><h1>AIT 디버그 Dashboard</h1>__LANG_SWITCHER__<p class="updated" id="updated">마지막 갱신: __NOW__</p><section><h2>터널 상태</h2><span class="status __TUNNEL_CLASS__" id="tunnel-status">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id="attach-section">__ATTACH_SECTION__</div></section>__PAGES_SECTION__</body></html>`;
2172
- const attachChromeHtmlKo = `<!DOCTYPE html>
2283
+ const attachChromeHtmlKoSandbox = `<!DOCTYPE html>
2284
+ <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>
2285
+ *, *::before, *::after { box-sizing: border-box; }
2286
+ body {
2287
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2288
+ background: #0d1117; color: #c9d1d9;
2289
+ display: flex; flex-direction: column; align-items: center;
2290
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
2291
+ gap: 1.5rem;
2292
+ }
2293
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
2294
+ .mode-label {
2295
+ font-size: 0.78rem; font-weight: 600; color: #79c0ff;
2296
+ background: #161b22; border: 1px solid #30363d; border-radius: 999px;
2297
+ padding: 0.25rem 0.75rem; margin: 0;
2298
+ }
2299
+ .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
2300
+ img.qr {
2301
+ width: min(90vw, 360px); height: auto;
2302
+ image-rendering: pixelated;
2303
+ background: #fff; padding: 1rem; border-radius: 12px;
2304
+ display: block; margin: 0 auto;
2305
+ }
2306
+ section { width: 100%; max-width: 480px; }
2307
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
2308
+ ol, ul { margin: 0; padding-left: 1.25rem; }
2309
+ li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
2310
+ .url-row {
2311
+ display: flex; align-items: stretch; gap: 0;
2312
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
2313
+ }
2314
+ .url-box {
2315
+ font-family: monospace; font-size: 0.72rem;
2316
+ word-break: break-all; opacity: 0.4;
2317
+ background: #161b22; padding: 0.75rem 1rem;
2318
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
2319
+ }
2320
+ .url-box:hover { opacity: 0.6; }
2321
+ .copy-btn {
2322
+ flex-shrink: 0; padding: 0.5rem 0.8rem;
2323
+ background: #21262d; border: none; border-left: 1px solid #30363d;
2324
+ color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;
2325
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2326
+ }
2327
+ .copy-btn:hover { background: #30363d; }
2328
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
2329
+ .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
2330
+ .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2331
+ .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2332
+ </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>홈 화면의 launcher PWA 아이콘으로 실행하세요 (Safari 주소창이 보이면 standalone이 아닙니다).</li><li>launcher 안의 <strong>"QR 카메라로 스캔"</strong>으로 이 QR 코드를 스캔하세요.</li><li>미니앱이 풀스크린으로 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>launcher가 설치돼 있지 않은 경우</strong> — <code>devtools.aitc.dev/launcher/</code>를 한 번 열어 홈 화면에 추가하세요</li><li><strong>카메라 앱으로 스캔하면 Safari 탭으로 열립니다 (하단 탭 바 노출)</strong> — launcher 아이콘으로 다시 실행해 인앱 스캔을 사용하세요</li><li><strong>QR이 만료된 경우 (TOTP 30초)</strong> — 새 QR을 다시 스캔하세요</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section></body></html>`;
2333
+ const attachChromeHtmlKoIntoss = `<!DOCTYPE html>
2173
2334
  <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>
2174
2335
  *, *::before, *::after { box-sizing: border-box; }
2175
2336
  body {
@@ -2180,6 +2341,11 @@ body {
2180
2341
  gap: 1.5rem;
2181
2342
  }
2182
2343
  h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
2344
+ .mode-label {
2345
+ font-size: 0.78rem; font-weight: 600; color: #79c0ff;
2346
+ background: #161b22; border: 1px solid #30363d; border-radius: 999px;
2347
+ padding: 0.25rem 0.75rem; margin: 0;
2348
+ }
2183
2349
  .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
2184
2350
  img.qr {
2185
2351
  width: min(90vw, 360px); height: auto;
@@ -2191,17 +2357,29 @@ section { width: 100%; max-width: 480px; }
2191
2357
  h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
2192
2358
  ol, ul { margin: 0; padding-left: 1.25rem; }
2193
2359
  li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
2360
+ .url-row {
2361
+ display: flex; align-items: stretch; gap: 0;
2362
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
2363
+ }
2194
2364
  .url-box {
2195
2365
  font-family: monospace; font-size: 0.72rem;
2196
2366
  word-break: break-all; opacity: 0.4;
2197
2367
  background: #161b22; padding: 0.75rem 1rem;
2198
- border-radius: 6px; border: 1px solid #30363d;
2368
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
2199
2369
  }
2370
+ .url-box:hover { opacity: 0.6; }
2371
+ .copy-btn {
2372
+ flex-shrink: 0; padding: 0.5rem 0.8rem;
2373
+ background: #21262d; border: none; border-left: 1px solid #30363d;
2374
+ color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;
2375
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2376
+ }
2377
+ .copy-btn:hover { background: #30363d; }
2200
2378
  hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
2201
2379
  .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
2202
2380
  .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2203
2381
  .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2204
- </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li></ul></section><hr/><section><h2>URL (fallback)</h2><p class="url-box">__SAFE_ATTACH_URL__</p></section></body></html>`;
2382
+ </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li>__LIVE_FAQ__</ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section></body></html>`;
2205
2383
  const dashboardChromeHtmlEn = `<!DOCTYPE html>
2206
2384
  <html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>AIT Debug Dashboard</title><style>
2207
2385
  *, *::before, *::after { box-sizing: border-box; }
@@ -2225,12 +2403,24 @@ img.qr {
2225
2403
  background: #fff; padding: 0.75rem; border-radius: 10px;
2226
2404
  display: block; margin: 0.5rem auto;
2227
2405
  }
2406
+ .url-row {
2407
+ display: flex; align-items: stretch; gap: 0; margin: 0.5rem 0 0;
2408
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
2409
+ }
2228
2410
  .url-box {
2229
2411
  font-family: monospace; font-size: 0.7rem;
2230
2412
  word-break: break-all; opacity: 0.45;
2231
2413
  background: #161b22; padding: 0.6rem 0.85rem;
2232
- border-radius: 6px; border: 1px solid #30363d; margin: 0.5rem 0 0;
2414
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
2233
2415
  }
2416
+ .url-box:hover { opacity: 0.65; }
2417
+ .copy-btn {
2418
+ flex-shrink: 0; padding: 0.4rem 0.7rem;
2419
+ background: #21262d; border: none; border-left: 1px solid #30363d;
2420
+ color: #58a6ff; font-size: 0.7rem; cursor: pointer; white-space: nowrap;
2421
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2422
+ }
2423
+ .copy-btn:hover { background: #30363d; }
2234
2424
  .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
2235
2425
  ul { margin: 0; padding-left: 1.25rem; }
2236
2426
  li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
@@ -2242,7 +2432,7 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
2242
2432
  .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2243
2433
  .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2244
2434
  </style></head><body><h1>AIT Debug Dashboard</h1>__LANG_SWITCHER__<p class="updated" id="updated">Last updated: __NOW__</p><section><h2>Tunnel status</h2><span class="status __TUNNEL_CLASS__" id="tunnel-status">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id="attach-section">__ATTACH_SECTION__</div></section>__PAGES_SECTION__</body></html>`;
2245
- const attachChromeHtmlEn = `<!DOCTYPE html>
2435
+ const attachChromeHtmlEnSandbox = `<!DOCTYPE html>
2246
2436
  <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>
2247
2437
  *, *::before, *::after { box-sizing: border-box; }
2248
2438
  body {
@@ -2253,6 +2443,11 @@ body {
2253
2443
  gap: 1.5rem;
2254
2444
  }
2255
2445
  h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
2446
+ .mode-label {
2447
+ font-size: 0.78rem; font-weight: 600; color: #79c0ff;
2448
+ background: #161b22; border: 1px solid #30363d; border-radius: 999px;
2449
+ padding: 0.25rem 0.75rem; margin: 0;
2450
+ }
2256
2451
  .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
2257
2452
  img.qr {
2258
2453
  width: min(90vw, 360px); height: auto;
@@ -2264,29 +2459,123 @@ section { width: 100%; max-width: 480px; }
2264
2459
  h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
2265
2460
  ol, ul { margin: 0; padding-left: 1.25rem; }
2266
2461
  li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
2462
+ .url-row {
2463
+ display: flex; align-items: stretch; gap: 0;
2464
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
2465
+ }
2267
2466
  .url-box {
2268
2467
  font-family: monospace; font-size: 0.72rem;
2269
2468
  word-break: break-all; opacity: 0.4;
2270
2469
  background: #161b22; padding: 0.75rem 1rem;
2271
- border-radius: 6px; border: 1px solid #30363d;
2470
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
2471
+ }
2472
+ .url-box:hover { opacity: 0.6; }
2473
+ .copy-btn {
2474
+ flex-shrink: 0; padding: 0.5rem 0.8rem;
2475
+ background: #21262d; border: none; border-left: 1px solid #30363d;
2476
+ color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;
2477
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2272
2478
  }
2479
+ .copy-btn:hover { background: #30363d; }
2273
2480
  hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
2274
2481
  .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
2275
2482
  .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2276
2483
  .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2277
- </style></head><body><h1>AIT Debug Session — QR Scan</h1>__LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>"Open in Toss"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li></ul></section><hr/><section><h2>URL (fallback)</h2><p class="url-box">__SAFE_ATTACH_URL__</p></section></body></html>`;
2484
+ </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Launch the launcher PWA icon on your home screen (if the Safari address bar is visible, it is not standalone).</li><li>Scan this QR code with <strong>"Scan QR with camera"</strong> inside the launcher.</li><li>The mini-app opens fullscreen and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Launcher is not installed</strong> — open <code>devtools.aitc.dev/launcher/</code> once and add it to your home screen</li><li><strong>Scanning with the camera app opens a Safari tab (bottom tab bar visible)</strong> — relaunch from the launcher icon and use the in-app scanner</li><li><strong>QR expired (TOTP 30 s)</strong> — scan a fresh QR code</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section></body></html>`;
2485
+ const attachChromeHtmlEnIntoss = `<!DOCTYPE html>
2486
+ <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>
2487
+ *, *::before, *::after { box-sizing: border-box; }
2488
+ body {
2489
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2490
+ background: #0d1117; color: #c9d1d9;
2491
+ display: flex; flex-direction: column; align-items: center;
2492
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
2493
+ gap: 1.5rem;
2494
+ }
2495
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
2496
+ .mode-label {
2497
+ font-size: 0.78rem; font-weight: 600; color: #79c0ff;
2498
+ background: #161b22; border: 1px solid #30363d; border-radius: 999px;
2499
+ padding: 0.25rem 0.75rem; margin: 0;
2500
+ }
2501
+ .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
2502
+ img.qr {
2503
+ width: min(90vw, 360px); height: auto;
2504
+ image-rendering: pixelated;
2505
+ background: #fff; padding: 1rem; border-radius: 12px;
2506
+ display: block; margin: 0 auto;
2507
+ }
2508
+ section { width: 100%; max-width: 480px; }
2509
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
2510
+ ol, ul { margin: 0; padding-left: 1.25rem; }
2511
+ li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
2512
+ .url-row {
2513
+ display: flex; align-items: stretch; gap: 0;
2514
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
2515
+ }
2516
+ .url-box {
2517
+ font-family: monospace; font-size: 0.72rem;
2518
+ word-break: break-all; opacity: 0.4;
2519
+ background: #161b22; padding: 0.75rem 1rem;
2520
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
2521
+ }
2522
+ .url-box:hover { opacity: 0.6; }
2523
+ .copy-btn {
2524
+ flex-shrink: 0; padding: 0.5rem 0.8rem;
2525
+ background: #21262d; border: none; border-left: 1px solid #30363d;
2526
+ color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;
2527
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2528
+ }
2529
+ .copy-btn:hover { background: #30363d; }
2530
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
2531
+ .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
2532
+ .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2533
+ .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2534
+ </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>"Open in Toss"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li>__LIVE_FAQ__</ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section></body></html>`;
2278
2535
  /** Map from Locale to the precompiled dashboard chrome string. */
2279
2536
  const dashboardChromeByLocale = {
2280
2537
  ko: dashboardChromeHtmlKo,
2281
2538
  en: dashboardChromeHtmlEn
2282
2539
  };
2283
- /** Map from Locale to the precompiled attach page chrome string. */
2540
+ /** Map from Locale × copy family to the precompiled attach page chrome string (#468). */
2284
2541
  const attachChromeByLocale = {
2285
- ko: attachChromeHtmlKo,
2286
- en: attachChromeHtmlEn
2542
+ ko: {
2543
+ sandbox: attachChromeHtmlKoSandbox,
2544
+ intoss: attachChromeHtmlKoIntoss
2545
+ },
2546
+ en: {
2547
+ sandbox: attachChromeHtmlEnSandbox,
2548
+ intoss: attachChromeHtmlEnIntoss
2549
+ }
2287
2550
  };
2288
2551
  //#endregion
2289
2552
  //#region src/mcp/qr-http-server.ts
2553
+ /** mode → 어느 precompiled attach chrome family를 쓰는가 (#468). */
2554
+ function attachFamilyForMode(mode) {
2555
+ return mode === "relay-mobile" ? "sandbox" : "intoss";
2556
+ }
2557
+ /**
2558
+ * mode → 페이지 상단 환경 라벨 HTML (`__MODE_LABEL__` 토큰 채움, #468).
2559
+ * 사용자가 fidelity 사다리의 어느 겹에 있는지 즉시 알게 하는 환경 가시화 배지.
2560
+ * mode 미지정/'mock'은 빈 문자열 — 알 수 없는 환경을 거짓으로 라벨링하지 않는다.
2561
+ */
2562
+ function buildModeLabel(mode, s) {
2563
+ let label;
2564
+ switch (mode) {
2565
+ case "relay-mobile":
2566
+ label = s("attach.mode.sandbox");
2567
+ break;
2568
+ case "relay-dev":
2569
+ label = s("attach.mode.intossDev");
2570
+ break;
2571
+ case "relay-live":
2572
+ label = s("attach.mode.intossLive");
2573
+ break;
2574
+ case "mock":
2575
+ case void 0: return "";
2576
+ }
2577
+ return `<p class="mode-label">${escapeHtml(label)}</p>`;
2578
+ }
2290
2579
  /** HTML 특수문자를 이스케이프한다. */
2291
2580
  function escapeHtml(s) {
2292
2581
  return s.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
@@ -2335,8 +2624,11 @@ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new U
2335
2624
  const tunnelStatus = state.tunnel.up ? s("dashboard.tunnel.up") : s("dashboard.tunnel.down");
2336
2625
  const tunnelClass = state.tunnel.up ? "status-up" : "status-down";
2337
2626
  let attachSection;
2338
- if (qrDataUrl && state.attachUrl) attachSection = `<img class="qr" src="${qrDataUrl}" alt="attach QR" /><p class="url-box">${escapeHtml(state.attachUrl)}</p>`;
2339
- else attachSection = `<p class="hint">${escapeHtml(s("dashboard.attach.hint"))}</p>`;
2627
+ if (qrDataUrl && state.attachUrl) {
2628
+ const safeAttachUrl = escapeHtml(state.attachUrl);
2629
+ const copyLabel = escapeHtml(s("dashboard.url.copy"));
2630
+ attachSection = `<img class="qr" src="${qrDataUrl}" alt="attach QR" /><div class="url-row"><p class="url-box" id="url-box">${safeAttachUrl}</p><button class="copy-btn" id="copy-btn" type="button" aria-label="${copyLabel}">${copyLabel}</button></div>`;
2631
+ } else attachSection = `<p class="hint">${escapeHtml(s("dashboard.attach.hint"))}</p>`;
2340
2632
  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) => {
2341
2633
  return `<li><span class="page-id">${escapeHtml(p.id)}</span> <span class="page-url">${escapeHtml(p.url.slice(0, 120))}</span></li>`;
2342
2634
  }).join("\n") : `<li class="empty">${escapeHtml(s("dashboard.pages.empty"))}</li>`}</ul></section>`;
@@ -2344,7 +2636,10 @@ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new U
2344
2636
  tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
2345
2637
  tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
2346
2638
  pagesEmpty: JSON.stringify(s("dashboard.pages.empty")),
2347
- attachHint: JSON.stringify(s("dashboard.attach.hint"))
2639
+ attachHint: JSON.stringify(s("dashboard.attach.hint")),
2640
+ copyLabel: JSON.stringify(s("dashboard.url.copy")),
2641
+ copiedLabel: JSON.stringify(s("dashboard.url.copied")),
2642
+ dashboardSurface: true
2348
2643
  };
2349
2644
  const langSwitcher = buildLangSwitcher(path, params, locale, s);
2350
2645
  const filled = dashboardChromeByLocale[locale].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__NOW__", escapeHtml(now)).replaceAll("__TUNNEL_CLASS__", tunnelClass).replaceAll("__TUNNEL_STATUS__", escapeHtml(tunnelStatus)).replaceAll("__ATTACH_SECTION__", attachSection).replaceAll("__PAGES_SECTION__", pagesSection);
@@ -2358,9 +2653,24 @@ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new U
2358
2653
  * client side: attachUrl은 DOM에 렌더링, wssUrl은 절대 렌더링하지 않는다.
2359
2654
  * pages === null 이면 섹션을 건드리지 않는다 (#411).
2360
2655
  *
2656
+ * 두 표면(dashboard / attach) 분기:
2657
+ * - dashboard (dashboardSurface=true): #attach-section innerHTML 전체 교체 방식 유지.
2658
+ * url-box도 innerHTML 재렌더 안에 포함되어 갱신됨.
2659
+ * - /attach (dashboardSurface=false): #attach-section의 img src만 교체하고,
2660
+ * url-box는 #url-box textContent만 갱신한다. (#attach-section에 url-box가 없으므로
2661
+ * innerHTML 교체 시 url-box가 새로 생겨 이중 표시되는 결함을 방지 — #458 결함 수정.)
2662
+ *
2663
+ * 복사 기능: 이벤트 위임으로 document에 단일 핸들러. innerHTML 재렌더 후에도 생존.
2664
+ * - .url-box 클릭 또는 .copy-btn 클릭 → 현재 #url-box textContent 복사.
2665
+ * - clipboard: navigator.clipboard.writeText → 실패/부재 시 textarea execCommand fallback.
2666
+ * - 피드백: 버튼 라벨이 COPIED_LABEL로 ~1.5초 전환 후 COPY_LABEL로 복귀.
2667
+ *
2361
2668
  * 문자열 인자는 빌드타임에 ko/en 테이블에서 가져와 JSON.stringify로 이미 escape됨.
2669
+ *
2670
+ * SECRET-HANDLING: URL 값을 console.log 등으로 출력하지 않는다.
2362
2671
  */
2363
2672
  function buildSseScript(strings) {
2673
+ const isDashboard = strings.dashboardSurface;
2364
2674
  return `<script>
2365
2675
  // SSE — /events 구독해 상태 자동 갱신. 빌드 파이프라인 없는 인라인 스크립트.
2366
2676
  (function () {
@@ -2368,6 +2678,64 @@ function buildSseScript(strings) {
2368
2678
  var TUNNEL_DOWN = ${strings.tunnelDown};
2369
2679
  var PAGES_EMPTY = ${strings.pagesEmpty};
2370
2680
  var ATTACH_HINT = ${strings.attachHint};
2681
+ var COPY_LABEL = ${strings.copyLabel};
2682
+ var COPIED_LABEL = ${strings.copiedLabel};
2683
+
2684
+ // ── 클립보드 복사 헬퍼 ────────────────────────────────────────────────
2685
+ function copyText(text) {
2686
+ if (navigator.clipboard && navigator.clipboard.writeText) {
2687
+ return navigator.clipboard.writeText(text);
2688
+ }
2689
+ // fallback: textarea + execCommand
2690
+ return new Promise(function (resolve, reject) {
2691
+ var ta = document.createElement('textarea');
2692
+ ta.value = text;
2693
+ ta.style.position = 'fixed';
2694
+ ta.style.opacity = '0';
2695
+ document.body.appendChild(ta);
2696
+ ta.focus();
2697
+ ta.select();
2698
+ try {
2699
+ document.execCommand('copy') ? resolve() : reject(new Error('execCommand failed'));
2700
+ } catch (err) {
2701
+ reject(err);
2702
+ } finally {
2703
+ document.body.removeChild(ta);
2704
+ }
2705
+ });
2706
+ }
2707
+
2708
+ // ── 복사 피드백 ───────────────────────────────────────────────────────
2709
+ var copyTimer = null;
2710
+ function triggerCopy() {
2711
+ var urlBox = document.getElementById('url-box');
2712
+ if (!urlBox) return;
2713
+ var text = urlBox.textContent || '';
2714
+ if (!text) return;
2715
+ copyText(text).then(function () {
2716
+ var btn = document.getElementById('copy-btn');
2717
+ if (btn) {
2718
+ btn.textContent = COPIED_LABEL;
2719
+ if (copyTimer) clearTimeout(copyTimer);
2720
+ copyTimer = setTimeout(function () {
2721
+ btn.textContent = COPY_LABEL;
2722
+ copyTimer = null;
2723
+ }, 1500);
2724
+ }
2725
+ }).catch(function () { /* 복사 실패 시 조용히 무시 */ });
2726
+ }
2727
+
2728
+ // ── 이벤트 위임 — document 레벨에서 단일 핸들러 (innerHTML 재렌더 후에도 생존) ──
2729
+ document.addEventListener('click', function (e) {
2730
+ var target = e.target;
2731
+ if (!target) return;
2732
+ // .copy-btn 또는 .url-box 클릭 시 복사
2733
+ if (target.closest && (target.closest('.copy-btn') || target.closest('.url-box'))) {
2734
+ triggerCopy();
2735
+ }
2736
+ });
2737
+
2738
+ // ── SSE 구독 ──────────────────────────────────────────────────────────
2371
2739
  var src = new EventSource('/events');
2372
2740
  src.onmessage = function (e) {
2373
2741
  try {
@@ -2395,23 +2763,37 @@ function buildSseScript(strings) {
2395
2763
  }
2396
2764
  }
2397
2765
  }
2398
- // attachUrl QR 갱신 attachUrl이 없으면 hint 표시.
2766
+ // attachUrl QR + url-box 갱신
2767
+ // SECRET-HANDLING: URL 값을 로그로 출력하지 않는다.
2399
2768
  var sec = document.getElementById('attach-section');
2400
2769
  if (sec) {
2401
2770
  if (s.attachUrl) {
2402
- // QR은 서버에서 새로 렌더한 /qr.png?u= 로 img src 교체.
2403
- // TOTP at= 코드는 attachUrl 안에 캡슐화 — 별도 노출 없음.
2404
- // wssUrl은 절대 DOM에 렌더하지 않는다 (SECRET-HANDLING).
2405
2771
  var encoded = encodeURIComponent(s.attachUrl);
2406
2772
  var safeUrl = String(s.attachUrl).slice(0, 2000).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
2773
+ ${isDashboard ? `// dashboard: #attach-section innerHTML 전체 교체 (img + url-row).
2774
+ // url-box id="url-box" 를 포함해 복사 핸들러가 계속 동작함.
2407
2775
  sec.innerHTML =
2408
2776
  '<img class="qr" src="/qr.png?u=' + encoded + '" alt="attach QR" />' +
2409
- '<p class="url-box">' + safeUrl + '</p>';
2777
+ '<div class=\\"url-row\\">' +
2778
+ '<p class=\\"url-box\\" id=\\"url-box\\">' + safeUrl + '</p>' +
2779
+ '<button class=\\"copy-btn\\" id=\\"copy-btn\\" type=\\"button\\" aria-label=\\"' + COPY_LABEL + '\\">' + COPY_LABEL + '</button>' +
2780
+ '</div>';` : `// /attach: img src만 교체 — url-box는 별도 #url-section에서 관리해 이중 표시 방지(#458).
2781
+ // QR img src 교체: img가 있으면 src만 갱신, 없으면 img 요소 생성.
2782
+ var img = sec.querySelector('img.qr');
2783
+ if (img) {
2784
+ img.src = '/qr.png?u=' + encoded;
2785
+ } else {
2786
+ sec.innerHTML = '<img class=\\"qr\\" src=\\"/qr.png?u=' + encoded + '\\" alt=\\"attach QR\\" />';
2787
+ }
2788
+ // url-box textContent만 갱신 (innerHTML 교체하지 않아 복사 버튼/핸들러 생존).
2789
+ var ub = document.getElementById('url-box');
2790
+ if (ub) ub.textContent = s.attachUrl;`}
2410
2791
  } else {
2411
- sec.innerHTML = '<p class="hint">' + ATTACH_HINT + '</p>';
2792
+ ${isDashboard ? `sec.innerHTML = '<p class=\\"hint\\">' + ATTACH_HINT + '</p>';` : `// /attach에서 hint가 필요한 경우는 없으나 방어 처리.
2793
+ sec.innerHTML = '<p class=\\"hint\\">' + ATTACH_HINT + '</p>';`}
2412
2794
  }
2413
2795
  }
2414
- // 갱신 시각
2796
+ // 갱신 시각 (dashboard만 #updated 요소 있음)
2415
2797
  var upd = document.getElementById('updated');
2416
2798
  if (upd) upd.textContent = upd.textContent.replace(/[^ ]+$/, new Date().toISOString());
2417
2799
  } catch (_) { /* 파싱 오류 무시 */ }
@@ -2427,8 +2809,14 @@ function buildSseScript(strings) {
2427
2809
  *
2428
2810
  * 동적 파트:
2429
2811
  * - __QR_DATA_URL__ : base64 data URL (QR 이미지)
2430
- * - __SAFE_LABEL__ : HTML-escaped deploymentId label
2812
+ * - __SAFE_LABEL__ : HTML-escaped deploymentId label (intoss family에만 존재)
2431
2813
  * - __SAFE_ATTACH_URL__ : HTML-escaped attach URL (TOTP at= 코드 포함 — 의도된 전달)
2814
+ * - __MODE_LABEL__ : 환경 배지 (`<p class="mode-label">…</p>` 또는 빈 문자열, #468)
2815
+ * - __LIVE_FAQ__ : 환경 4 LIVE read-only `<li>` 또는 빈 문자열 (intoss family에만 존재)
2816
+ *
2817
+ * mode-aware 분기 (#468): mode가 `relay-mobile`이면 sandbox family chrome(launcher
2818
+ * PWA 절차), 그 외는 intoss family chrome(토스 앱 절차)을 선택한다. `relay-live`는
2819
+ * intoss chrome에 LIVE read-only 라인을 추가한다.
2432
2820
  *
2433
2821
  * SSE 스크립트도 주입 — `#attach-section` hook이 있으면 `/events` push 때 QR이
2434
2822
  * `/qr.png?u=<fresh attachUrl>`로 자동 갱신된다. `#tunnel-status`·`#pages-list` 등
@@ -2436,15 +2824,20 @@ function buildSseScript(strings) {
2436
2824
  *
2437
2825
  * SECRET-HANDLING: TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — 의도된 transport.
2438
2826
  */
2439
- function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/attach", params = new URLSearchParams()) {
2827
+ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/attach", params = new URLSearchParams(), mode) {
2440
2828
  const s = resolveLocaleStrings(locale);
2441
2829
  const langSwitcher = buildLangSwitcher(path, params, locale, s);
2442
- const filled = attachChromeByLocale[locale].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl);
2830
+ const family = attachFamilyForMode(mode);
2831
+ const liveFaq = mode === "relay-live" ? `<li>${s("attach.intoss.faq.liveReadOnly")}</li>` : "";
2832
+ const filled = attachChromeByLocale[locale][family].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__MODE_LABEL__", buildModeLabel(mode, s)).replaceAll("__LIVE_FAQ__", liveFaq).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl);
2443
2833
  const sseScript = buildSseScript({
2444
2834
  tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
2445
2835
  tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
2446
2836
  pagesEmpty: JSON.stringify(s("dashboard.pages.empty")),
2447
- attachHint: JSON.stringify(s("dashboard.attach.hint"))
2837
+ attachHint: JSON.stringify(s("dashboard.attach.hint")),
2838
+ copyLabel: JSON.stringify(s("dashboard.url.copy")),
2839
+ copiedLabel: JSON.stringify(s("dashboard.url.copied")),
2840
+ dashboardSurface: false
2448
2841
  });
2449
2842
  return filled.replace("</body>", `${sseScript}\n</body>`);
2450
2843
  }
@@ -2533,11 +2926,12 @@ async function startQrHttpServer(getDashboardState) {
2533
2926
  const dpMatch = attachUrl.match(/[?&]_deploymentId=([^&]+)/);
2534
2927
  if (dpMatch?.[1]) deploymentIdLabel = decodeURIComponent(dpMatch[1]).slice(0, 36);
2535
2928
  } catch {}
2929
+ const mode = getDashboardState?.().mode;
2536
2930
  QRCode.toDataURL(attachUrl, {
2537
2931
  type: "image/png",
2538
2932
  errorCorrectionLevel: "M"
2539
2933
  }).then((dataUrl) => {
2540
- const html = buildAttachHtml(dataUrl, escapeHtml(deploymentIdLabel), escapeHtml(attachUrl), locale, path, params);
2934
+ const html = buildAttachHtml(dataUrl, escapeHtml(deploymentIdLabel), escapeHtml(attachUrl), locale, path, params, mode);
2541
2935
  res.writeHead(200, {
2542
2936
  "Content-Type": "text/html; charset=utf-8",
2543
2937
  "Cache-Control": "no-store"
@@ -3155,7 +3549,7 @@ const DEBUG_TOOL_DEFINITIONS = [
3155
3549
  },
3156
3550
  {
3157
3551
  name: "get_debug_status",
3158
- description: "Reports the current debug session state — which environment/mode is active, whether a page is attached, and a full diagnostic snapshot — in one call. Use this any time to answer \"what mode am I in right now?\" or \"why is this not working?\" without chaining tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (kind: mock|relay-dev|relay-live|relay-mobile, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active), serverLockHolder (pid + startedAt from the lock file, or null), nextRecommendedAction ({tool, reason} or null — the single next tool to call; in local-target mode tunnel.up=false is normal so \"restart\" is never recommended). All fields are nullable — missing data is null, not an error. debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. Tier C (both mock and relay).",
3552
+ description: "Reports the current debug session state — which environment/mode is active, whether a page is attached, and a full diagnostic snapshot — in one call. Use this any time to answer \"what mode am I in right now?\" or \"why is this not working?\" without chaining tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), authRejects ({count, lastAt} — relay TOTP 401 rejections, secret-free; count > 0 with empty pages means the phone reached the relay but its code was rejected), environment (kind: mock|relay-dev|relay-live|relay-mobile, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active), serverLockHolder (pid + startedAt from the lock file, or null), nextRecommendedAction ({tool, reason} or null — the single next tool to call; in local-target mode tunnel.up=false is normal so \"restart\" is never recommended). All fields are nullable — missing data is null, not an error. debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. Tier C (both mock and relay).",
3159
3553
  inputSchema: {
3160
3554
  type: "object",
3161
3555
  properties: { recent_errors_limit: {
@@ -3920,6 +4314,8 @@ var InMemoryDiagnosticsCollector = class {
3920
4314
  maxSize;
3921
4315
  lastAttachAt = null;
3922
4316
  lastDetachAt = null;
4317
+ authRejectCount = 0;
4318
+ lastAuthRejectAt = null;
3923
4319
  constructor(maxSize = DEFAULT_ERROR_BUFFER_SIZE) {
3924
4320
  this.maxSize = maxSize;
3925
4321
  }
@@ -3948,6 +4344,16 @@ var InMemoryDiagnosticsCollector = class {
3948
4344
  getLastDetachAt() {
3949
4345
  return this.lastDetachAt;
3950
4346
  }
4347
+ recordAuthReject() {
4348
+ this.authRejectCount += 1;
4349
+ this.lastAuthRejectAt = (/* @__PURE__ */ new Date()).toISOString();
4350
+ }
4351
+ getAuthRejects() {
4352
+ return {
4353
+ count: this.authRejectCount,
4354
+ lastAt: this.lastAuthRejectAt
4355
+ };
4356
+ }
3951
4357
  };
3952
4358
  /**
3953
4359
  * Returns the `@modelcontextprotocol/sdk` version baked in at build time via
@@ -3975,7 +4381,7 @@ async function readMcpSdkVersion() {
3975
4381
  * some test environments that skip the build step).
3976
4382
  */
3977
4383
  function readDevtoolsVersion() {
3978
- return "0.1.66";
4384
+ return "0.1.68";
3979
4385
  }
3980
4386
  /**
3981
4387
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -3984,13 +4390,18 @@ function readDevtoolsVersion() {
3984
4390
  * 0. tunnel.droppedAt non-null → restart (permanent tunnel drop — highest priority)
3985
4391
  * 1. tunnel.up === false AND env is relay → restart (relay needs a live tunnel)
3986
4392
  * 1b. tunnel.up === false AND env is mock → wait_for_page (local target: tunnel-less is normal)
4393
+ * 2a. authRejects.count > 0 AND pages empty → build_attach_url (relay TOTP 거부 관측 — QR 재스캔
4394
+ * 또는 target-side `at` 전달 확인. 일반 rule 2보다 구체적이므로 먼저 평가 — issue #467)
3987
4395
  * 2. tunnel.up, pages empty, env === relay → build_attach_url (start attach)
3988
4396
  * 3. pages has entry + crashDetectedAt non-null → build_attach_url (re-attach after crash)
3989
4397
  * 4. otherwise → null (session looks healthy)
3990
4398
  *
3991
4399
  * Pure — does not throw; receives the final assembled snapshot fields.
4400
+ *
4401
+ * SECRET-HANDLING: the auth-reject reason string carries only the count and
4402
+ * timestamp from {@link AuthRejectsSnapshot} — never a URL, code, or secret.
3992
4403
  */
3993
- function computeNextRecommendedAction(tunnel, pages, env) {
4404
+ function computeNextRecommendedAction(tunnel, pages, env, authRejects = null) {
3994
4405
  if (tunnel.droppedAt != null) return {
3995
4406
  tool: "restart",
3996
4407
  reason: `tunnel permanently dropped at ${tunnel.droppedAt} after ${tunnel.reissueAttempts} reissue attempt(s) — restart the MCP server (npx @ait-co/devtools devtools-mcp)`
@@ -4004,6 +4415,10 @@ function computeNextRecommendedAction(tunnel, pages, env) {
4004
4415
  tool: "restart",
4005
4416
  reason: "tunnel not up — run `npx @ait-co/devtools devtools-mcp` to restart"
4006
4417
  };
4418
+ if (authRejects !== null && authRejects.count > 0 && pages !== null && pages.pages.length === 0) return {
4419
+ tool: "build_attach_url",
4420
+ reason: `relay 인증(TOTP) 거부 ${authRejects.count}건 발생 (last ${authRejects.lastAt ?? "unknown"}) — QR을 다시 스캔해 새 코드로 attach하세요(코드는 30초 주기로 만료). 반복되면 폰 페이지 URL에 at 파라미터가 전달되는지(target-side TOTP 전달 경로)를 확인하세요`
4421
+ };
4007
4422
  if (isRelayEnv(env) && pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) return {
4008
4423
  tool: "build_attach_url",
4009
4424
  reason: "tunnel ready, no pages attached — call build_attach_url to generate the attach QR"
@@ -4047,7 +4462,13 @@ async function getDiagnostics(input) {
4047
4462
  } catch {}
4048
4463
  const limit = Math.min(Math.max(1, recentErrorsLimit), 50);
4049
4464
  const recentErrors = collector.getRecentErrors(limit);
4050
- const nextRecommendedAction = computeNextRecommendedAction(tunnelInfo, pages, env);
4465
+ const authRejects = collector.getAuthRejects();
4466
+ if (authRejects.count > 0) recentErrors.push({
4467
+ timestamp: authRejects.lastAt ?? (/* @__PURE__ */ new Date()).toISOString(),
4468
+ message: `WS upgrade auth-rejected (${authRejects.count} times, last ${authRejects.lastAt ?? "unknown"})`,
4469
+ category: "auth"
4470
+ });
4471
+ const nextRecommendedAction = computeNextRecommendedAction(tunnelInfo, pages, env, authRejects);
4051
4472
  return {
4052
4473
  mcpVersion,
4053
4474
  devtoolsVersion,
@@ -4056,6 +4477,7 @@ async function getDiagnostics(input) {
4056
4477
  lastAttachAt: collector.getLastAttachAt(),
4057
4478
  lastDetachAt: collector.getLastDetachAt(),
4058
4479
  recentErrors,
4480
+ authRejects,
4059
4481
  environment: {
4060
4482
  kind: env,
4061
4483
  env: toLegacyEnv(env),
@@ -4463,7 +4885,7 @@ function createDebugServer(deps) {
4463
4885
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
4464
4886
  const server = new Server({
4465
4887
  name: "ait-debug",
4466
- version: "0.1.66"
4888
+ version: "0.1.68"
4467
4889
  }, { capabilities: { tools: { listChanged: true } } });
4468
4890
  server.setRequestHandler(ListToolsRequestSchema, () => {
4469
4891
  const conn = router.active;
@@ -5172,7 +5594,8 @@ async function bootRelayFamily(options = {}) {
5172
5594
  const totpEnabled = options.verifyAuth !== void 0;
5173
5595
  const relay = await startChiiRelay({
5174
5596
  port: relayPort,
5175
- verifyAuth: options.verifyAuth
5597
+ verifyAuth: options.verifyAuth,
5598
+ onAuthReject: options.onAuthReject
5176
5599
  });
5177
5600
  logInfo("server.start", {
5178
5601
  port: relay.port,
@@ -5484,7 +5907,8 @@ async function runDebugServer(options = {}) {
5484
5907
  onWssUrl: (wssUrl) => {
5485
5908
  lockHandle.updateWssUrl(wssUrl);
5486
5909
  qrServer?.notifyStateChange();
5487
- }
5910
+ },
5911
+ onAuthReject: () => diagnosticsCollector.recordAuthReject()
5488
5912
  }),
5489
5913
  diagnosticsCollector,
5490
5914
  devtoolsOpener,
@@ -5503,7 +5927,8 @@ async function runDebugServer(options = {}) {
5503
5927
  id: t.id,
5504
5928
  url: t.url
5505
5929
  })),
5506
- attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null
5930
+ attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
5931
+ mode: deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin)
5507
5932
  });
5508
5933
  let qrServer;
5509
5934
  try {
@@ -5648,7 +6073,8 @@ async function runLocalDebugServer(options = {}) {
5648
6073
  onWssUrl: (wssUrl) => {
5649
6074
  lockHandle.updateWssUrl(wssUrl);
5650
6075
  qrServer?.notifyStateChange();
5651
- }
6076
+ },
6077
+ onAuthReject: () => diagnosticsCollector.recordAuthReject()
5652
6078
  }),
5653
6079
  diagnosticsCollector,
5654
6080
  devtoolsOpener,
@@ -5796,7 +6222,8 @@ async function runMobileDebugServer(options = {}) {
5796
6222
  onWssUrl: (wssUrl) => {
5797
6223
  lockHandle.updateWssUrl(wssUrl);
5798
6224
  qrServer?.notifyStateChange();
5799
- }
6225
+ },
6226
+ onAuthReject: () => diagnosticsCollector.recordAuthReject()
5800
6227
  }),
5801
6228
  diagnosticsCollector,
5802
6229
  devtoolsOpener,
@@ -6334,7 +6761,7 @@ function createDevServer(deps = {}) {
6334
6761
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
6335
6762
  const server = new Server({
6336
6763
  name: "ait-devtools",
6337
- version: "0.1.66"
6764
+ version: "0.1.68"
6338
6765
  }, { capabilities: { tools: {} } });
6339
6766
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
6340
6767
  server.setRequestHandler(CallToolRequestSchema, async (request) => {