@ait-co/devtools 0.1.55 → 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.
Files changed (51) hide show
  1. package/README.en.md +77 -32
  2. package/README.md +76 -31
  3. package/dist/chii-relay-57BfqF_5.cjs +88 -0
  4. package/dist/chii-relay-57BfqF_5.cjs.map +1 -0
  5. package/dist/chii-relay-itXOz7kS.js +89 -0
  6. package/dist/chii-relay-itXOz7kS.js.map +1 -0
  7. package/dist/in-app/index.d.ts +45 -12
  8. package/dist/in-app/index.d.ts.map +1 -1
  9. package/dist/in-app/index.js +38 -7
  10. package/dist/in-app/index.js.map +1 -1
  11. package/dist/mcp/cli.d.ts +8 -3
  12. package/dist/mcp/cli.d.ts.map +1 -1
  13. package/dist/mcp/cli.js +1160 -381
  14. package/dist/mcp/cli.js.map +1 -1
  15. package/dist/mcp/server.js +22 -11
  16. package/dist/mcp/server.js.map +1 -1
  17. package/dist/panel/index.js +2 -2
  18. package/dist/relay-secret-store-DnTNl-9z.cjs +140 -0
  19. package/dist/relay-secret-store-DnTNl-9z.cjs.map +1 -0
  20. package/dist/relay-secret-store-DqyUoeXy.js +140 -0
  21. package/dist/relay-secret-store-DqyUoeXy.js.map +1 -0
  22. package/dist/totp-BkP5yU2K.js +186 -0
  23. package/dist/totp-BkP5yU2K.js.map +1 -0
  24. package/dist/totp-CQFmgOhM.js +3 -0
  25. package/dist/totp-D0a8VwoR.js +187 -0
  26. package/dist/totp-D0a8VwoR.js.map +1 -0
  27. package/dist/totp-DLgGbySX.cjs +188 -0
  28. package/dist/totp-DLgGbySX.cjs.map +1 -0
  29. package/dist/{tunnel-D0_TwDNE.js → tunnel-CI61NvPI.js} +13 -5
  30. package/dist/tunnel-CI61NvPI.js.map +1 -0
  31. package/dist/{tunnel-BYP0yRBN.cjs → tunnel-nKYPtc-g.cjs} +13 -5
  32. package/dist/tunnel-nKYPtc-g.cjs.map +1 -0
  33. package/dist/unplugin/index.cjs +31 -3
  34. package/dist/unplugin/index.cjs.map +1 -1
  35. package/dist/unplugin/index.d.cts +11 -0
  36. package/dist/unplugin/index.d.cts.map +1 -1
  37. package/dist/unplugin/index.d.ts +12 -1
  38. package/dist/unplugin/index.d.ts.map +1 -1
  39. package/dist/unplugin/index.js +31 -3
  40. package/dist/unplugin/index.js.map +1 -1
  41. package/dist/unplugin/tunnel.cjs +11 -3
  42. package/dist/unplugin/tunnel.cjs.map +1 -1
  43. package/dist/unplugin/tunnel.d.cts +13 -1
  44. package/dist/unplugin/tunnel.d.cts.map +1 -1
  45. package/dist/unplugin/tunnel.d.ts +13 -1
  46. package/dist/unplugin/tunnel.d.ts.map +1 -1
  47. package/dist/unplugin/tunnel.js +11 -3
  48. package/dist/unplugin/tunnel.js.map +1 -1
  49. package/package.json +2 -2
  50. package/dist/tunnel-BYP0yRBN.cjs.map +0 -1
  51. package/dist/tunnel-D0_TwDNE.js.map +0 -1
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);
@@ -718,6 +719,157 @@ async function startChiiRelay(options = {}) {
718
719
  };
719
720
  }
720
721
  //#endregion
722
+ //#region src/mcp/deeplink.ts
723
+ /**
724
+ * URL of the AITC Sandbox launcher PWA.
725
+ *
726
+ * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the
727
+ * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy
728
+ * for the same reason — keep the two in sync when the URL changes.
729
+ */
730
+ const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
731
+ /**
732
+ * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).
733
+ *
734
+ * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport
735
+ * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the
736
+ * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js
737
+ * is injected. `&at=<totpCode>` is added only when a code is provided (same
738
+ * conditional as {@link buildDeepLinkAttachUrl}).
739
+ *
740
+ * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL
741
+ * via raw string manipulation), this function uses WHATWG `encodeURIComponent`
742
+ * because the target is a standard `https:` URL.
743
+ *
744
+ * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param
745
+ * only — never logged or returned separately. Callers must NOT log the result
746
+ * of this function to stdout/stderr.
747
+ *
748
+ * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL
749
+ * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.
750
+ * @param wssUrl - The `wss://` relay URL the framed page will attach to.
751
+ * @param totpCode - Optional current TOTP code (6 digits). When provided, it
752
+ * is appended as `at=<totpCode>`. Must be computed at call time — it rotates
753
+ * every 30 s. Omit when TOTP is disabled.
754
+ * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>
755
+ * [&at=<code>]` params.
756
+ */
757
+ function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode) {
758
+ let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}&debug=1&relay=${encodeURIComponent(wssUrl)}`;
759
+ if (totpCode !== void 0 && totpCode !== "") url += `&at=${encodeURIComponent(totpCode)}`;
760
+ return url;
761
+ }
762
+ /**
763
+ * Build a self-attaching dogfood deep link.
764
+ *
765
+ * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`
766
+ * URL that opens a dogfood bundle on a phone. The in-app debug gate
767
+ * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries
768
+ * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus
769
+ * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result
770
+ * as a QR code and scanning it with the phone camera opens the mini-app and
771
+ * attaches it to the live Chii relay. QR is the single entry path — it needs
772
+ * no USB cable, platform CLI, or driver, and works the same on iOS/Android.
773
+ *
774
+ * The Toss app propagates extra query params from the entry deep link into the
775
+ * mini-app WebView's `location.search` (confirmed behavior), so the gate reads
776
+ * them at attach time.
777
+ *
778
+ * TOTP `at=` param:
779
+ * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional
780
+ * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.
781
+ * The code must be computed by the caller at call time — do NOT pre-compute
782
+ * and cache it, because the 30-second window expires quickly. The in-app gate
783
+ * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.
784
+ *
785
+ * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.
786
+ * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query
787
+ * decomposition you can rely on across runtimes), so query manipulation via
788
+ * `url.searchParams` is not portable here. We splice the query string directly
789
+ * on the raw string instead, which keeps the scheme, authority, path, and any
790
+ * pre-existing params (notably `_deploymentId`) byte-for-byte intact.
791
+ */
792
+ /**
793
+ * Suspicious/generic authority values that indicate a malformed or placeholder
794
+ * scheme URL. These are host strings that will almost certainly cause the Toss
795
+ * app to fail with "bundle not found" silently.
796
+ *
797
+ * The expected form from `ait deploy --scheme-only` is:
798
+ * intoss-private://<appName>?_deploymentId=<uuid>
799
+ * where `<appName>` is a non-generic string like `aitc-sdk-example`.
800
+ */
801
+ const SUSPICIOUS_AUTHORITIES = new Set([
802
+ "",
803
+ "web",
804
+ "localhost",
805
+ "127.0.0.1",
806
+ "app"
807
+ ]);
808
+ /**
809
+ * Validates the authority (host) portion of a scheme URL.
810
+ *
811
+ * Returns a warning message if the authority is missing or looks like a
812
+ * placeholder, or `null` if the authority looks valid.
813
+ *
814
+ * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`
815
+ * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).
816
+ */
817
+ function validateSchemeAuthority(schemeUrl) {
818
+ const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//, "");
819
+ if (afterScheme === schemeUrl) return "scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). Use the URL printed by `ait deploy --scheme-only`.";
820
+ const authorityEnd = afterScheme.search(/[/?#]/);
821
+ const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);
822
+ if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) return `scheme_url authority ${authority === "" ? "(empty)" : `"${authority}"`} looks like a placeholder. Expected an app name like \`intoss-private://aitc-sdk-example?_deploymentId=<uuid>\`. Use the URL printed by \`ait deploy --scheme-only\` — it includes the correct app name as the host.`;
823
+ return null;
824
+ }
825
+ function stripExisting(query, key) {
826
+ if (query === "") return "";
827
+ return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
828
+ }
829
+ /**
830
+ * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a
831
+ * scheme URL's query string, preserving everything else (scheme, authority,
832
+ * path, hash, and the existing `_deploymentId` param). If any of the spliced
833
+ * params is already present it is replaced so the helper is idempotent.
834
+ *
835
+ * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed
836
+ * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B
837
+ * of the gate); this helper does not invent one.
838
+ * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the
839
+ * running debug MCP server's quick tunnel.
840
+ * @param totpCode - Optional current TOTP code (6 digits). When provided, it
841
+ * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates
842
+ * every 30 s. Pass `undefined` or omit when TOTP is disabled.
843
+ * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`
844
+ * appended.
845
+ * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so
846
+ * producing such a link would be a silent dead end).
847
+ */
848
+ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
849
+ let relay;
850
+ try {
851
+ relay = new URL(wssUrl);
852
+ } catch {
853
+ throw new Error(`relay URL is not a valid URL: ${wssUrl}`);
854
+ }
855
+ if (relay.protocol !== "wss:") throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);
856
+ const hashIndex = schemeUrl.indexOf("#");
857
+ const hash = hashIndex === -1 ? "" : schemeUrl.slice(hashIndex);
858
+ const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);
859
+ const queryIndex = beforeHash.indexOf("?");
860
+ const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
861
+ let query = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1);
862
+ const appended = [["debug", "1"], ["relay", wssUrl]];
863
+ if (totpCode !== void 0 && totpCode !== "") appended.push(["at", totpCode]);
864
+ query = stripExisting(query, "at");
865
+ for (const [key] of appended) query = stripExisting(query, key);
866
+ for (const [key, value] of appended) {
867
+ const pair = `${key}=${encodeURIComponent(value)}`;
868
+ query = query === "" ? pair : `${query}&${pair}`;
869
+ }
870
+ return `${base}?${query}${hash}`;
871
+ }
872
+ //#endregion
721
873
  //#region src/mcp/devtools-opener.ts
722
874
  /**
723
875
  * Base URL for the Chrome DevTools inspector hosted on appspot.
@@ -897,15 +1049,25 @@ function wrapEnvelope(data, ctx) {
897
1049
  //#endregion
898
1050
  //#region src/mcp/environment.ts
899
1051
  /**
900
- * Returns `true` when the environment is any relay variant (`relay-dev` or
901
- * `relay-live`). Use this instead of `env === 'relay'` for tier checks.
1052
+ * Returns `true` when the environment is any relay variant (`relay-dev`,
1053
+ * `relay-live`, or `relay-mobile`). Use this instead of `env === 'relay'` for
1054
+ * tier checks — every relay env surfaces the Tier B / relay-only tool set.
1055
+ *
1056
+ * Written as an exhaustive switch so a future `McpEnvironment` member that is
1057
+ * missing an arm is a TS compile error rather than a silent `false`.
902
1058
  */
903
1059
  function isRelayEnv(env) {
904
- return env === "relay-dev" || env === "relay-live";
1060
+ switch (env) {
1061
+ case "relay-dev":
1062
+ case "relay-live":
1063
+ case "relay-mobile": return true;
1064
+ case "mock": return false;
1065
+ }
905
1066
  }
906
1067
  /**
907
1068
  * Returns `true` when the environment is the LIVE relay (`relay-live`).
908
- * This is the guard condition for side-effect tool protection.
1069
+ * This is the guard condition for side-effect tool protection. `relay-mobile`
1070
+ * is a dev-intent env (env 2 PWA) and is NOT live.
909
1071
  */
910
1072
  function isLiveRelayEnv(env) {
911
1073
  return env === "relay-live";
@@ -913,25 +1075,38 @@ function isLiveRelayEnv(env) {
913
1075
  /**
914
1076
  * Maps the `McpEnvironment` union to the legacy two-value union
915
1077
  * (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
1078
+ * Every relay variant (incl. `relay-mobile`) collapses to `'relay'`.
916
1079
  */
917
1080
  function toLegacyEnv(env) {
918
1081
  if (env === "mock") return "mock";
919
1082
  return "relay";
920
1083
  }
921
1084
  /**
922
- * Reconstructs the three-value `McpEnvironment` output string from the two
923
- * orthogonal signals (issue #348):
1085
+ * Reconstructs the four-value `McpEnvironment` output string from the
1086
+ * orthogonal signals (issues #348, #378):
1087
+ *
1088
+ * - `kind === 'local'` → `'mock'`
1089
+ * - `kind === 'relay'` && liveIntent → `'relay-live'`
1090
+ * - `kind === 'relay'` && !liveIntent && origin 'external-pwa' → `'relay-mobile'`
1091
+ * - `kind === 'relay'` && !liveIntent && origin intoss/undefined → `'relay-dev'`
924
1092
  *
925
- * - `kind === 'local'` → `'mock'`
926
- * - `kind === 'relay'` && liveIntent → `'relay-live'`
927
- * - `kind === 'relay'` && !liveIntent → `'relay-dev'`
1093
+ * `relayOrigin` is the booted-family discriminator (NOT sniffed from the URL)
1094
+ * that distinguishes the env-2 external-PWA relay (`relay-mobile`) from the
1095
+ * intoss-private dogfood relay (`relay-dev`); both are `kind: 'relay'`.
928
1096
  *
929
1097
  * Pure — used at every output boundary (envelope `meta.env`, `get_diagnostics`,
930
1098
  * `measure_safe_area` provenance) so the surface never sniffs a URL again.
1099
+ *
1100
+ * Written switch-style so a missing arm is a TS compile error (never falls
1101
+ * through to a default).
931
1102
  */
932
- function deriveEnvironment(kind, liveIntent) {
933
- if (kind === "local") return "mock";
934
- return liveIntent ? "relay-live" : "relay-dev";
1103
+ function deriveEnvironment(kind, liveIntent, relayOrigin) {
1104
+ switch (kind) {
1105
+ case "local": return "mock";
1106
+ case "relay":
1107
+ if (liveIntent) return "relay-live";
1108
+ return relayOrigin === "external-pwa" ? "relay-mobile" : "relay-dev";
1109
+ }
935
1110
  }
936
1111
  /**
937
1112
  * Module-level `relay-dev` vs `relay-live` intent bit (issue #348).
@@ -1010,12 +1185,23 @@ function pageCrashError(toolName) {
1010
1185
  return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 build_attach_url → QR 스캔으로 재attach하세요.`);
1011
1186
  }
1012
1187
  /**
1013
- * 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다 (dogfood 빌드가 아님).
1014
- *
1015
- * call_sdk 호출 시 브리지가 없을 때.
1188
+ * 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다.
1189
+ *
1190
+ * call_sdk 호출 시 브리지가 없을 때. 같은 "브리지 부재"라도 다음 행동은
1191
+ * connection 종류에 따라 정반대다 (issue #360):
1192
+ * - relay(`--target` 없는 intoss / env-2): dogfood 빌드가 아니다 → dogfood
1193
+ * 채널로 재배포 후 QR 재스캔.
1194
+ * - local(`--target=local`, env 1 로컬 브라우저): 재배포가 아니라 dev 서버를
1195
+ * `pnpm dev`로 띄웠는지 + unplugin alias가 `@apps-in-toss/web-framework`를
1196
+ * devtools mock으로 resolve하는지 확인. dev 빌드면 `import.meta.env.DEV`
1197
+ * 경로로 `window.__sdkCall`이 자동 설치된다.
1198
+ *
1199
+ * `isLocal`이 생략되면 relay 안내(이전 동작)를 유지한다.
1016
1200
  */
1017
- function sdkAbsentError(toolName) {
1018
- return mcpError(`${toolName ? `${toolName}: ` : ""}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
1201
+ function sdkAbsentError(toolName, isLocal = false) {
1202
+ const prefix = toolName ? `${toolName}: ` : "";
1203
+ if (isLocal) return mcpError(`${prefix}window.__sdkCall이 주입되지 않았습니다 (로컬 dev 브리지 부재). sdk-example을 \`pnpm dev\`로 띄웠는지, 그리고 unplugin alias가 \`@apps-in-toss/web-framework\`를 devtools mock으로 resolve하는지 확인하세요. dev 빌드(\`import.meta.env.DEV\`)면 \`window.__sdkCall\`이 자동 설치됩니다.`);
1204
+ return mcpError(`${prefix}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
1019
1205
  }
1020
1206
  /**
1021
1207
  * relay-live 환경에서 side-effect 도구(`call_sdk`, `evaluate`)를 `confirm: true`
@@ -1048,10 +1234,10 @@ function relayDisconnectError(toolName) {
1048
1234
  * - 연결 끊김 패턴 (`relay에 연결되어 있지 않습니다`, `relay WebSocket`) → relayDisconnectError
1049
1235
  * - 그 외 (일반 에러) → 원본 메시지를 포함한 mcpError
1050
1236
  */
1051
- function classifyToolError(err, toolName) {
1237
+ function classifyToolError(err, toolName, isLocal = false) {
1052
1238
  const message = err instanceof Error ? err.message : String(err);
1053
1239
  if (message.startsWith("tunnel-down:") || message.includes("터널이 안 떠 있습니다")) return tunnelDownError();
1054
- if (message.startsWith("sdk-absent:") || message.includes("__sdkCall이 주입되지 않았습니다") || message.includes("window.__sdkCall is not available") || message.includes("__sdkCall") && message.includes("not available")) return sdkAbsentError(toolName);
1240
+ if (message.startsWith("sdk-absent:") || message.includes("__sdkCall이 주입되지 않았습니다") || message.includes("window.__sdkCall is not available") || message.includes("__sdkCall") && message.includes("not available")) return sdkAbsentError(toolName, isLocal);
1055
1241
  if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
1056
1242
  if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket")) return relayDisconnectError(toolName);
1057
1243
  return mcpError(`${toolName} 실패: ${message}\nlist_pages로 미니앱이 relay에 attach됐는지 확인하세요.`);
@@ -1397,12 +1583,71 @@ async function launchChromium(options = {}) {
1397
1583
  /**
1398
1584
  * 로컬 HTTP 서버를 127.0.0.1 random port(또는 `AIT_DEBUG_HTTP_PORT` env)로 시작한다.
1399
1585
  * MCP debug server 생애주기에 묶어 사용 — `runDebugServer` shutdown 시 `close()`로 정리.
1586
+ *
1587
+ * @param getDashboardState - dashboard 상태를 반환하는 클로저. 주입 시 `GET /` dashboard와
1588
+ * `GET /events` SSE 스트림이 활성화된다. 미주입 시 두 라우트는 204/서비스 없음으로 응답.
1400
1589
  */
1401
- async function startQrHttpServer() {
1590
+ async function startQrHttpServer(getDashboardState) {
1402
1591
  const { default: QRCode } = await import("qrcode");
1403
- 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) => {
1404
1607
  const [path, query = ""] = (req.url ?? "/").split("?", 2);
1405
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
+ }
1406
1651
  if (path === "/attach") {
1407
1652
  const encodedU = params.get("u") ?? "";
1408
1653
  let attachUrl;
@@ -1476,6 +1721,13 @@ async function startQrHttpServer() {
1476
1721
  buildAttachPageUrl(attachUrl) {
1477
1722
  return `http://127.0.0.1:${port}/attach?u=${encodeURIComponent(attachUrl)}`;
1478
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
+ },
1479
1731
  close() {
1480
1732
  return new Promise((resolve, reject) => {
1481
1733
  server.close((err) => err ? reject(err) : resolve());
@@ -1484,6 +1736,147 @@ async function startQrHttpServer() {
1484
1736
  };
1485
1737
  }
1486
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
+ /**
1487
1880
  * QR 스캔 페이지 HTML 본문.
1488
1881
  * dark theme, inline style, 외부 fetch 없음.
1489
1882
  */
@@ -1560,6 +1953,112 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl) {
1560
1953
  </html>`;
1561
1954
  }
1562
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
1563
2062
  //#region src/mcp/server-lock.ts
1564
2063
  /**
1565
2064
  * Single debug session lock for the `devtools-mcp` debug server.
@@ -1722,138 +2221,26 @@ function acquireLock(options = {}) {
1722
2221
  process.stderr.write(`[ait-debug] 기존 debug-mode 세션이 이미 실행 중 — PID=${existing.pid}, started ${existing.startedAt}, ${urlPart}\n[ait-debug] 회복: \`kill ${existing.pid}\` 또는 \`npx @ait-co/devtools devtools-mcp --force\`\n`);
1723
2222
  throw new ServerLockConflictError(existing.pid, existing.wssUrl, existing.startedAt);
1724
2223
  }
1725
- else process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
1726
- const data = {
1727
- pid: process.pid,
1728
- wssUrl: null,
1729
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
1730
- };
1731
- writeLock(lockPath, data);
1732
- let released = false;
1733
- return {
1734
- updateWssUrl(wssUrl) {
1735
- if (released) return;
1736
- data.wssUrl = wssUrl;
1737
- writeLock(lockPath, data);
1738
- },
1739
- release() {
1740
- if (released) return;
1741
- released = true;
1742
- removeLock(lockPath);
1743
- }
1744
- };
1745
- }
1746
- //#endregion
1747
- //#region src/mcp/deeplink.ts
1748
- /**
1749
- * Build a self-attaching dogfood deep link.
1750
- *
1751
- * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`
1752
- * URL that opens a dogfood bundle on a phone. The in-app debug gate
1753
- * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries
1754
- * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus
1755
- * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result
1756
- * as a QR code and scanning it with the phone camera opens the mini-app and
1757
- * attaches it to the live Chii relay. QR is the single entry path — it needs
1758
- * no USB cable, platform CLI, or driver, and works the same on iOS/Android.
1759
- *
1760
- * The Toss app propagates extra query params from the entry deep link into the
1761
- * mini-app WebView's `location.search` (confirmed behavior), so the gate reads
1762
- * them at attach time.
1763
- *
1764
- * TOTP `at=` param:
1765
- * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional
1766
- * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.
1767
- * The code must be computed by the caller at call time — do NOT pre-compute
1768
- * and cache it, because the 30-second window expires quickly. The in-app gate
1769
- * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.
1770
- *
1771
- * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.
1772
- * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query
1773
- * decomposition you can rely on across runtimes), so query manipulation via
1774
- * `url.searchParams` is not portable here. We splice the query string directly
1775
- * on the raw string instead, which keeps the scheme, authority, path, and any
1776
- * pre-existing params (notably `_deploymentId`) byte-for-byte intact.
1777
- */
1778
- /**
1779
- * Suspicious/generic authority values that indicate a malformed or placeholder
1780
- * scheme URL. These are host strings that will almost certainly cause the Toss
1781
- * app to fail with "bundle not found" silently.
1782
- *
1783
- * The expected form from `ait deploy --scheme-only` is:
1784
- * intoss-private://<appName>?_deploymentId=<uuid>
1785
- * where `<appName>` is a non-generic string like `aitc-sdk-example`.
1786
- */
1787
- const SUSPICIOUS_AUTHORITIES = new Set([
1788
- "",
1789
- "web",
1790
- "localhost",
1791
- "127.0.0.1",
1792
- "app"
1793
- ]);
1794
- /**
1795
- * Validates the authority (host) portion of a scheme URL.
1796
- *
1797
- * Returns a warning message if the authority is missing or looks like a
1798
- * placeholder, or `null` if the authority looks valid.
1799
- *
1800
- * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`
1801
- * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).
1802
- */
1803
- function validateSchemeAuthority(schemeUrl) {
1804
- const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//, "");
1805
- if (afterScheme === schemeUrl) return "scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). Use the URL printed by `ait deploy --scheme-only`.";
1806
- const authorityEnd = afterScheme.search(/[/?#]/);
1807
- const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);
1808
- if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) return `scheme_url authority ${authority === "" ? "(empty)" : `"${authority}"`} looks like a placeholder. Expected an app name like \`intoss-private://aitc-sdk-example?_deploymentId=<uuid>\`. Use the URL printed by \`ait deploy --scheme-only\` — it includes the correct app name as the host.`;
1809
- return null;
1810
- }
1811
- function stripExisting(query, key) {
1812
- if (query === "") return "";
1813
- return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
1814
- }
1815
- /**
1816
- * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a
1817
- * scheme URL's query string, preserving everything else (scheme, authority,
1818
- * path, hash, and the existing `_deploymentId` param). If any of the spliced
1819
- * params is already present it is replaced so the helper is idempotent.
1820
- *
1821
- * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed
1822
- * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B
1823
- * of the gate); this helper does not invent one.
1824
- * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the
1825
- * running debug MCP server's quick tunnel.
1826
- * @param totpCode - Optional current TOTP code (6 digits). When provided, it
1827
- * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates
1828
- * every 30 s. Pass `undefined` or omit when TOTP is disabled.
1829
- * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`
1830
- * appended.
1831
- * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so
1832
- * producing such a link would be a silent dead end).
1833
- */
1834
- function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
1835
- let relay;
1836
- try {
1837
- relay = new URL(wssUrl);
1838
- } catch {
1839
- throw new Error(`relay URL is not a valid URL: ${wssUrl}`);
1840
- }
1841
- if (relay.protocol !== "wss:") throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);
1842
- const hashIndex = schemeUrl.indexOf("#");
1843
- const hash = hashIndex === -1 ? "" : schemeUrl.slice(hashIndex);
1844
- const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);
1845
- const queryIndex = beforeHash.indexOf("?");
1846
- const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
1847
- let query = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1);
1848
- const appended = [["debug", "1"], ["relay", wssUrl]];
1849
- if (totpCode !== void 0 && totpCode !== "") appended.push(["at", totpCode]);
1850
- query = stripExisting(query, "at");
1851
- for (const [key] of appended) query = stripExisting(query, key);
1852
- for (const [key, value] of appended) {
1853
- const pair = `${key}=${encodeURIComponent(value)}`;
1854
- query = query === "" ? pair : `${query}&${pair}`;
1855
- }
1856
- return `${base}?${query}${hash}`;
2224
+ else process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
2225
+ const data = {
2226
+ pid: process.pid,
2227
+ wssUrl: null,
2228
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
2229
+ };
2230
+ writeLock(lockPath, data);
2231
+ let released = false;
2232
+ return {
2233
+ updateWssUrl(wssUrl) {
2234
+ if (released) return;
2235
+ data.wssUrl = wssUrl;
2236
+ writeLock(lockPath, data);
2237
+ },
2238
+ release() {
2239
+ if (released) return;
2240
+ released = true;
2241
+ removeLock(lockPath);
2242
+ }
2243
+ };
1857
2244
  }
1858
2245
  //#endregion
1859
2246
  //#region src/mcp/sdk-signatures.ts
@@ -2012,83 +2399,6 @@ function warnPassthrough(name) {
2012
2399
  }
2013
2400
  SIGNATURES.map((s) => s.name);
2014
2401
  //#endregion
2015
- //#region src/mcp/totp.ts
2016
- /**
2017
- * RFC 6238 TOTP implementation (Node.js, node:crypto only).
2018
- *
2019
- * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
2020
- * to keep the dependency surface minimal. This hand-roll is ~30 lines and
2021
- * covers exactly what relay-side auth needs.
2022
- *
2023
- * Algorithm summary (RFC 6238 + RFC 4226):
2024
- * T = floor(now / 30) — 30-second time step counter
2025
- * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
2026
- * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
2027
- * offset = MAC[19] & 0x0f
2028
- * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
2029
- *
2030
- * Security note (keep this comment accurate):
2031
- * The baked-in secret in a dogfood build is extractable from the bundle by a
2032
- * determined reverse engineer. This mechanism raises the bar from
2033
- * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
2034
- * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
2035
- * blocked; deliberate reverse engineering is not. See threat model in
2036
- * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
2037
- *
2038
- * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
2039
- * log, error message, or string visible outside this module. Only boolean
2040
- * pass/fail and reason enum values are safe to surface.
2041
- */
2042
- /** Time step window in seconds (RFC 6238 default). */
2043
- const TIME_STEP = 30;
2044
- /** Number of digits in the generated code. */
2045
- const DIGITS = 6;
2046
- /**
2047
- * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
2048
- * clock time.
2049
- *
2050
- * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
2051
- * bytes). Must be the output of `generateAttachToken()` or compatible.
2052
- * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
2053
- * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
2054
- */
2055
- function generateTotp(secret, when = Date.now()) {
2056
- const key = Buffer.from(secret, "hex");
2057
- const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
2058
- const counterBuf = Buffer.alloc(8);
2059
- const hi = Math.floor(counter / 4294967296);
2060
- const lo = counter >>> 0;
2061
- counterBuf.writeUInt32BE(hi, 0);
2062
- counterBuf.writeUInt32BE(lo, 4);
2063
- const mac = createHmac("sha1", key).update(counterBuf).digest();
2064
- const offset = mac[19] & 15;
2065
- 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");
2066
- }
2067
- /**
2068
- * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
2069
- * tolerate clock drift between the relay host and the client device.
2070
- *
2071
- * Uses `timingSafeEqual` for constant-time comparison to prevent timing
2072
- * side-channel attacks.
2073
- *
2074
- * @param secret - Hex-encoded shared secret.
2075
- * @param code - The 6-digit code to verify (string or numeric).
2076
- * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
2077
- * @param skew - Number of adjacent steps to accept on either side. Default 1
2078
- * (accepts T-1, T, T+1 — a 90-second acceptance window).
2079
- * @returns `true` if the code matches any accepted step, `false` otherwise.
2080
- */
2081
- function verifyTotp(secret, code, when = Date.now(), skew = 1) {
2082
- const normalised = String(code).padStart(DIGITS, "0");
2083
- if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
2084
- const candidateBuf = Buffer.from(normalised, "utf8");
2085
- for (let delta = -skew; delta <= skew; delta++) {
2086
- const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
2087
- if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
2088
- }
2089
- return false;
2090
- }
2091
- //#endregion
2092
2402
  //#region src/mcp/tools.ts
2093
2403
  /** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
2094
2404
  const DEBUG_TOOL_DEFINITIONS = [
@@ -2124,13 +2434,13 @@ const DEBUG_TOOL_DEFINITIONS = [
2124
2434
  },
2125
2435
  {
2126
2436
  name: "build_attach_url",
2127
- 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. Turns an `ait deploy --scheme-only` URL (intoss-private://…?_deploymentId=<uuid>) into a self-attaching deep link by splicing in debug=1 and the live relay URL for this session. Returns the deep link JSON and a unicode QR of that deep link. 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). Requires the tunnel to be up call list_pages first. If the tunnel is not up, restart the MCP server: `npx @ait-co/devtools devtools-mcp`. Set wait_for_attach=true to block until the phone scans and a page attaches (polls listTargets up to 30 s by default), then returns the attached page info too. 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). Requires MCP_ENV=relay-dev or relay-live (set automatically in debug-mode default).\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.",
2128
2438
  inputSchema: {
2129
2439
  type: "object",
2130
2440
  properties: {
2131
2441
  scheme_url: {
2132
2442
  type: "string",
2133
- description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). 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."
2134
2444
  },
2135
2445
  wait_for_attach: {
2136
2446
  type: "boolean",
@@ -2141,7 +2451,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2141
2451
  description: "If true (default), render the QR as a PNG and open it in the OS default browser. Only works when the MCP server is running on a local GUI machine — headless or remote container environments should set this to false to use the text QR fallback."
2142
2452
  }
2143
2453
  },
2144
- required: ["scheme_url"]
2454
+ required: []
2145
2455
  },
2146
2456
  availableIn: "relay"
2147
2457
  },
@@ -2219,7 +2529,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2219
2529
  },
2220
2530
  {
2221
2531
  name: "call_sdk",
2222
- description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 3/4 (real device relay) this hits the real SDK; on env 1 (local mock) it hits the mock SDK. (env 2 PWA does not inject the SDK call_sdk is not available there.) Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle) redeploy via dogfood channel: `ait build && aitcc app deploy`.\n\nSECURITY: method name, args, and result value are not redacted — never include secrets.\n\nLIVE guard: when running against a live/production relay (relay-live env, MCP_ENV=relay-live), this tool requires `confirm: true` to acknowledge that the SDK call may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
2532
+ description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 3/4 (real device relay) this hits the real SDK; on env 1 (local mock) and env 2 (PWA relay real WebKit, mock SDK) it hits the mock SDK. Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available — on relay (env 3/4) that means a non-dogfood bundle (redeploy via `ait build && aitcc app deploy`); on local (--target=local, env 1) it means the dev bridge is not installed (start the dev server with `pnpm dev`).\n\nSECURITY: method name, args, and result value are not redacted — never include secrets.\n\nLIVE guard: when running against a live/production relay (relay-live env, MCP_ENV=relay-live), this tool requires `confirm: true` to acknowledge that the SDK call may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
2223
2533
  inputSchema: {
2224
2534
  type: "object",
2225
2535
  properties: {
@@ -2273,23 +2583,27 @@ const DEBUG_TOOL_DEFINITIONS = [
2273
2583
  },
2274
2584
  {
2275
2585
  name: "start_debug",
2276
- 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-dev / local-browser-cdplocal Chromium CDP attach (env 1, mock). Both route to the local connection; the names preserve dev-vs-cdp intent.\n relay-dev — real-device dogfood relay (env 3). Side-effect tools run unguarded.\n relay-live — real-device live/production relay (env 4, read-only debugging). Arms the LIVE guard: call_sdk/evaluate then require confirm: true. Entering relay-live ALSO requires confirm: true on this call to acknowledge LIVE intent.\n\nSwitching back to a local mode 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.",
2277
2587
  inputSchema: {
2278
2588
  type: "object",
2279
2589
  properties: {
2280
2590
  mode: {
2281
2591
  type: "string",
2282
2592
  enum: [
2283
- "local-browser-dev",
2284
- "local-browser-cdp",
2285
- "relay-dev",
2593
+ "local-browser",
2594
+ "relay-sandbox",
2595
+ "relay-staging",
2286
2596
  "relay-live"
2287
2597
  ],
2288
- description: "Target environment to switch to. relay-live additionally requires confirm: true."
2598
+ description: "Target environment to switch to. mode=relay-live additionally requires confirm: true (and arms the read-only LIVE guard)."
2289
2599
  },
2290
2600
  confirm: {
2291
2601
  type: "boolean",
2292
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)."
2293
2607
  }
2294
2608
  },
2295
2609
  required: ["mode"]
@@ -2298,7 +2612,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2298
2612
  },
2299
2613
  {
2300
2614
  name: "get_diagnostics",
2301
- description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple 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, 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). Call this first when debugging session state.",
2615
+ description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple 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). Call this first when debugging session state.",
2302
2616
  inputSchema: {
2303
2617
  type: "object",
2304
2618
  properties: { recent_errors_limit: {
@@ -2328,8 +2642,8 @@ function getToolAvailability(name) {
2328
2642
  * Unknown tools return `false` — callers should reject them as unknown rather
2329
2643
  * than as env-mismatched.
2330
2644
  *
2331
- * Relay variants (`relay-dev`, `relay-live`) both satisfy the `'relay'`
2332
- * availability tier — `isRelayEnv()` is used for the check.
2645
+ * Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
2646
+ * `'relay'` availability tier — `isRelayEnv()` is used for the check.
2333
2647
  */
2334
2648
  function isToolAvailableIn(name, env) {
2335
2649
  const availability = getToolAvailability(name);
@@ -2343,7 +2657,8 @@ function isToolAvailableIn(name, env) {
2343
2657
  * matches the given env. Pure — preserves order; both Tier C ("both") and the
2344
2658
  * matching single-env tier pass through.
2345
2659
  *
2346
- * Relay variants (`relay-dev`, `relay-live`) both satisfy the `'relay'` tier.
2660
+ * Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
2661
+ * `'relay'` tier.
2347
2662
  */
2348
2663
  function filterToolsByEnvironment(tools, env) {
2349
2664
  return tools.filter((t) => t.availableIn === "both" || t.availableIn === "relay" && isRelayEnv(env) || t.availableIn === env);
@@ -2962,8 +3277,8 @@ function findRecentException(connection, windowStart, windowEnd) {
2962
3277
  * Calls a dogfood SDK method via `window.__sdkCall` on the attached page.
2963
3278
  * NOT read-only — SDK calls may have side effects.
2964
3279
  *
2965
- * On env 2/3 (real device relay) this hits the real SDK; on env 1 (local
2966
- * mock) it hits the mock SDK.
3280
+ * On env 3/4 (toss WebView relay) this hits the real SDK. On env 1 (local
3281
+ * mock) and env 2 (PWA relay — real WebKit, mock SDK) it hits the mock SDK.
2967
3282
  *
2968
3283
  * 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면
2969
3284
  * `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).
@@ -3117,7 +3432,7 @@ async function readMcpSdkVersion() {
3117
3432
  * some test environments that skip the build step).
3118
3433
  */
3119
3434
  function readDevtoolsVersion() {
3120
- return "0.1.55";
3435
+ return "0.1.57";
3121
3436
  }
3122
3437
  /**
3123
3438
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -3534,9 +3849,14 @@ function extractDeploymentId(schemeUrl) {
3534
3849
  return null;
3535
3850
  }
3536
3851
  }
3537
- /** Returns `true` when the mode routes to a relay connection. */
3852
+ /**
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.
3857
+ */
3538
3858
  function isRelayMode(mode) {
3539
- return mode === "relay-dev" || mode === "relay-live";
3859
+ return mode === "relay-sandbox" || mode === "relay-staging" || mode === "relay-live";
3540
3860
  }
3541
3861
  /**
3542
3862
  * Waits for the first target matching `filterFn` to attach, using the
@@ -3592,14 +3912,15 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
3592
3912
  * naturally via `enableDomains`). The tier only controls visibility.
3593
3913
  */
3594
3914
  function createDebugServer(deps) {
3595
- 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);
3596
3917
  const router = routerDep ?? makeSingleConnectionRouter(connection);
3597
- const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent()));
3918
+ const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin));
3598
3919
  const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},liveIntent=${getLiveIntent()}`);
3599
3920
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
3600
3921
  const server = new Server({
3601
3922
  name: "ait-debug",
3602
- version: "0.1.55"
3923
+ version: "0.1.57"
3603
3924
  }, { capabilities: { tools: { listChanged: true } } });
3604
3925
  server.setRequestHandler(ListToolsRequestSchema, () => {
3605
3926
  const conn = router.active;
@@ -3621,10 +3942,12 @@ function createDebugServer(deps) {
3621
3942
  if (name === "start_debug") {
3622
3943
  const rawMode = request.params.arguments?.mode;
3623
3944
  const mode = normalizeStartDebugMode(rawMode);
3624
- if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser-dev' | 'local-browser-cdp' | 'relay-dev' | 'relay-live' 중 하나를 전달하세요.");
3945
+ if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser' | 'relay-sandbox' | 'relay-staging' | 'relay-live' 중 하나를 전달하세요.");
3625
3946
  const confirm = request.params.arguments?.confirm === true;
3947
+ const rawProjectRoot = request.params.arguments?.projectRoot;
3948
+ const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
3626
3949
  try {
3627
- return jsonResult$1(await router.switchMode(mode, confirm));
3950
+ return jsonResult$1(await router.switchMode(mode, confirm, projectRoot));
3628
3951
  } catch (err) {
3629
3952
  return errorResult(err, name);
3630
3953
  }
@@ -3669,10 +3992,179 @@ function createDebugServer(deps) {
3669
3992
  return errorResult(err, name);
3670
3993
  }
3671
3994
  if (name === "build_attach_url") {
3672
- const schemeUrl = request.params.arguments?.scheme_url;
3673
- if (typeof schemeUrl !== "string" || schemeUrl === "") return mcpError("build_attach_url: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요.");
3674
3995
  const waitForAttach = request.params.arguments?.wait_for_attach === true;
3675
3996
  const openInBrowser = request.params.arguments?.open_in_browser !== false;
3997
+ if (env === "relay-mobile") {
3998
+ const tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
3999
+ if (tunnelHttpUrl === "") return mcpError("build_attach_url(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. unplugin tunnel:{cdp:true} 배너에 출력되는 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 전달하세요.");
4000
+ const tunnelStatus = getTunnelStatus();
4001
+ if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return mcpError("build_attach_url(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.");
4002
+ const secret = getTotpSecret();
4003
+ let totpCode;
4004
+ let totpMeta;
4005
+ if (secret !== void 0 && secret !== "") {
4006
+ const now = Date.now();
4007
+ totpCode = generateTotp(secret, now);
4008
+ const STEP_SECONDS = 30;
4009
+ const currentStep = Math.floor(now / 1e3 / STEP_SECONDS);
4010
+ totpMeta = {
4011
+ enabled: true,
4012
+ ttlSeconds: STEP_SECONDS,
4013
+ expiresAt: (/* @__PURE__ */ new Date((currentStep + 1) * STEP_SECONDS * 1e3)).toISOString()
4014
+ };
4015
+ }
4016
+ const attachUrl = buildLauncherAttachUrl(tunnelHttpUrl, tunnelStatus.wssUrl, totpCode);
4017
+ onAttachUrlBuilt?.(attachUrl);
4018
+ const relayUrl = tunnelStatus.wssUrl;
4019
+ const totp = totpMeta;
4020
+ const isMatchingPage = (pages) => pages.length > 0;
4021
+ const buildTimeoutError = (baseText, timeoutSec, observed) => {
4022
+ const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
4023
+ return `${baseText}\n\nNo page attached within ${timeoutSec}s${observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : ""} — launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.`;
4024
+ };
4025
+ return await (async () => {
4026
+ 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).";
4027
+ const warningPrefix = "";
4028
+ const guiAvailable = canOpenBrowser();
4029
+ if (openInBrowser && !guiAvailable) {
4030
+ const headlessNote = "[open_in_browser] GUI 환경이 감지되지 않았습니다 (headless/remote 환경). open_in_browser=false로 자동 폴백합니다. 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
4031
+ const qrHeadless = await renderQr(attachUrl);
4032
+ const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
4033
+ attachUrl,
4034
+ relayUrl,
4035
+ ...totp ? { totp } : {}
4036
+ }, null, 2)}\n\n${qrHeadless}`;
4037
+ if (!waitForAttach) return { content: [{
4038
+ type: "text",
4039
+ text: headlessText
4040
+ }] };
4041
+ let attachedPagesHl = [];
4042
+ try {
4043
+ attachedPagesHl = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
4044
+ } catch {
4045
+ attachedPagesHl = conn.listTargets();
4046
+ return {
4047
+ content: [{
4048
+ type: "text",
4049
+ text: buildTimeoutError(headlessText, waitForAttachTimeoutMs / 1e3, attachedPagesHl)
4050
+ }],
4051
+ isError: true
4052
+ };
4053
+ }
4054
+ const pagesResultHl = listPages(conn, getTunnelStatus());
4055
+ return { content: [{
4056
+ type: "text",
4057
+ text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
4058
+ }] };
4059
+ }
4060
+ if (openInBrowser && guiAvailable && qrHttpServer) {
4061
+ const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
4062
+ if (browserResult.opened) {
4063
+ const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
4064
+ const openResult = {
4065
+ attempted: true,
4066
+ succeeded: true,
4067
+ ...browserResult.retried ? { retried: true } : {}
4068
+ };
4069
+ const shortText = `${warningPrefix}${header}\n${JSON.stringify({
4070
+ relayUrl,
4071
+ openResult,
4072
+ ...totp ? { totp } : {}
4073
+ }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
4074
+ if (!waitForAttach) return { content: [{
4075
+ type: "text",
4076
+ text: shortText
4077
+ }] };
4078
+ let attachedPages = [];
4079
+ try {
4080
+ attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
4081
+ } catch {
4082
+ attachedPages = conn.listTargets();
4083
+ return {
4084
+ content: [{
4085
+ type: "text",
4086
+ text: buildTimeoutError(shortText, waitForAttachTimeoutMs / 1e3, attachedPages)
4087
+ }],
4088
+ isError: true
4089
+ };
4090
+ }
4091
+ const pagesResult = listPages(conn, getTunnelStatus());
4092
+ return { content: [{
4093
+ type: "text",
4094
+ text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
4095
+ }] };
4096
+ }
4097
+ const openResult = {
4098
+ attempted: true,
4099
+ succeeded: false,
4100
+ failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
4101
+ pngUrl: browserResult.pngUrl,
4102
+ ...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
4103
+ };
4104
+ const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
4105
+ const fallbackNote = `[open_in_browser] 브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:\n${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
4106
+ const qr = await renderQr(attachUrl);
4107
+ const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
4108
+ attachUrl,
4109
+ relayUrl,
4110
+ openResult,
4111
+ ...totp ? { totp } : {}
4112
+ }, null, 2)}\n\n${qr}`;
4113
+ if (!waitForAttach) return { content: [{
4114
+ type: "text",
4115
+ text: baseText
4116
+ }] };
4117
+ let attachedPagesFb = [];
4118
+ try {
4119
+ attachedPagesFb = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
4120
+ } catch {
4121
+ attachedPagesFb = conn.listTargets();
4122
+ return {
4123
+ content: [{
4124
+ type: "text",
4125
+ text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPagesFb)
4126
+ }],
4127
+ isError: true
4128
+ };
4129
+ }
4130
+ const pagesResultFb = listPages(conn, getTunnelStatus());
4131
+ return { content: [{
4132
+ type: "text",
4133
+ text: `${baseText}\n\n${JSON.stringify(pagesResultFb, null, 2)}`
4134
+ }] };
4135
+ }
4136
+ const qr = await renderQr(attachUrl);
4137
+ const baseText = `${warningPrefix}${header}\n${JSON.stringify({
4138
+ attachUrl,
4139
+ relayUrl,
4140
+ ...totp ? { totp } : {}
4141
+ }, null, 2)}\n\n${qr}`;
4142
+ if (!waitForAttach) return { content: [{
4143
+ type: "text",
4144
+ text: baseText
4145
+ }] };
4146
+ let attachedPages = [];
4147
+ try {
4148
+ attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
4149
+ } catch {
4150
+ attachedPages = conn.listTargets();
4151
+ return {
4152
+ content: [{
4153
+ type: "text",
4154
+ text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPages)
4155
+ }],
4156
+ isError: true
4157
+ };
4158
+ }
4159
+ const pagesResult = listPages(conn, getTunnelStatus());
4160
+ return { content: [{
4161
+ type: "text",
4162
+ text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
4163
+ }] };
4164
+ })();
4165
+ }
4166
+ const schemeUrl = request.params.arguments?.scheme_url;
4167
+ if (typeof schemeUrl !== "string" || schemeUrl === "") return mcpError("build_attach_url: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요. 환경 2(mobile)라면 scheme_url 대신 AIT_TUNNEL_BASE_URL을 설정하세요.");
3676
4168
  const deploymentId = extractDeploymentId(schemeUrl);
3677
4169
  if (!deploymentId) logInfo("tool.call", {
3678
4170
  tool: "build_attach_url",
@@ -3691,7 +4183,8 @@ function createDebugServer(deps) {
3691
4183
  return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
3692
4184
  };
3693
4185
  try {
3694
- const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, getTunnelStatus(), totpSecret);
4186
+ const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, getTunnelStatus(), getTotpSecret());
4187
+ onAttachUrlBuilt?.(attachUrl);
3695
4188
  const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
3696
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).";
3697
4190
  const guiAvailable = canOpenBrowser();
@@ -3883,26 +4376,27 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3883
4376
  const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
3884
4377
  if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
3885
4378
  const sdkResult = await callSdk(conn, sdkName, sdkArgs);
3886
- if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
4379
+ if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk", conn.kind === "local");
3887
4380
  return envelopeResult$1(sdkResult, name, env, conn.listTargets().length > 0);
3888
4381
  }
3889
4382
  default: return unknownTool(name);
3890
4383
  }
3891
4384
  } catch (err) {
3892
- return errorResult(err, name);
4385
+ return errorResult(err, name, conn.kind === "local");
3893
4386
  }
3894
4387
  });
3895
4388
  return server;
3896
4389
  }
3897
4390
  /**
3898
4391
  * Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or
3899
- * `null` when the value is not one of the four accepted modes.
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.
3900
4397
  */
3901
4398
  function normalizeStartDebugMode(raw) {
3902
- if (raw === "local-browser-dev") return "local-browser-dev";
3903
- if (raw === "local-browser-cdp") return "local-browser-cdp";
3904
- if (raw === "relay-dev") return "relay-dev";
3905
- if (raw === "relay-live") return "relay-live";
4399
+ if (raw === "local-browser" || raw === "relay-sandbox" || raw === "relay-staging" || raw === "relay-live") return raw;
3906
4400
  return null;
3907
4401
  }
3908
4402
  /**
@@ -3919,7 +4413,9 @@ function makeSingleConnectionRouter(connection) {
3919
4413
  get active() {
3920
4414
  return connection;
3921
4415
  },
3922
- switchMode(mode, confirm) {
4416
+ activeRelayOrigin: void 0,
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 모드로 재시작하세요."));
3923
4419
  if (isRelayMode(mode) !== (connection.kind === "relay")) return Promise.reject(/* @__PURE__ */ new Error(`start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — '${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 원하는 모드로 재시작하세요.`));
3924
4420
  if (mode === "relay-live" && !confirm) return Promise.reject(/* @__PURE__ */ new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요."));
3925
4421
  setLiveIntent(mode === "relay-live");
@@ -3978,8 +4474,8 @@ function classifyEnableDomainError(err, toolName) {
3978
4474
  * CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
3979
4475
  * debug-server 내부 try/catch 블록에서 공통으로 사용한다.
3980
4476
  */
3981
- function errorResult(err, name) {
3982
- return classifyToolError(err, name);
4477
+ function errorResult(err, name, isLocal = false) {
4478
+ return classifyToolError(err, name, isLocal);
3983
4479
  }
3984
4480
  /**
3985
4481
  * Starts a polling watcher that detects the first 0→N target transition on
@@ -4059,34 +4555,12 @@ function startParentWatcher(onOrphaned, opts) {
4059
4555
  } };
4060
4556
  }
4061
4557
  /**
4062
- * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
4063
- * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
4064
- *
4065
- * The predicate checks the `at` query parameter against the current and
4066
- * adjacent TOTP time steps (±1 skew) using `verifyTotp`.
4067
- *
4068
- * Returns `undefined` when the env var is not set — callers treat that as
4069
- * "auth disabled" (no predicate registered on the relay).
4070
- *
4071
- * SECRET-HANDLING: The secret value read from env is captured in a closure and
4072
- * is NEVER written to any log, error message, or process output.
4073
- */
4074
- function buildRelayVerifyAuth() {
4075
- const secret = process.env.AIT_DEBUG_TOTP_SECRET;
4076
- if (!secret) return void 0;
4077
- return (req) => {
4078
- const rawUrl = req.url ?? "";
4079
- const qIndex = rawUrl.indexOf("?");
4080
- const queryStr = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
4081
- return verifyTotp(secret, new URLSearchParams(queryStr).get("at") ?? "");
4082
- };
4083
- }
4084
- /**
4085
4558
  * Factory that constructs a `ChiiCdpConnection` for the given relay base URL.
4086
4559
  *
4087
4560
  * Introduced as a named seam so PR-2 (dual-connection, #348) can defer
4088
- * construction to first-activation time by moving or replacing this call
4089
- * 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.
4090
4564
  *
4091
4565
  * The relay base URL is only available after `startChiiRelay()` resolves, so
4092
4566
  * the factory is called right after that point (same as before this refactor).
@@ -4114,11 +4588,9 @@ var RoutingAitSource = class extends ChiiAitSource {
4114
4588
  * `--remote-debugging-port` and returns a `LocalCdpConnection` attached to it,
4115
4589
  * plus a `stop()` that kills both.
4116
4590
  *
4117
- * Used two ways:
4118
- * - `runDebugServer` (relay-eager): the dual router's lazy callback, booted at
4119
- * most once on the first `start_debug({ mode: 'local-*' })`.
4120
- * - `runLocalDebugServer` (local-eager, #356): the eager family booted at
4121
- * 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).
4122
4594
  */
4123
4595
  async function bootLocalFamily() {
4124
4596
  const chromium = await launchChromium({
@@ -4143,10 +4615,10 @@ async function bootLocalFamily() {
4143
4615
  * `getTunnelStatus()` reflects the live tunnel (it flips up once the background
4144
4616
  * tunnel resolves and follows reissues).
4145
4617
  *
4146
- * Used two ways (symmetry with {@link bootLocalFamily}):
4147
- * - `runDebugServer` (relay-eager): booted at startup.
4148
- * - `runLocalDebugServer` (local-eager, #356): the dual router's lazy
4149
- * 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).
4150
4622
  *
4151
4623
  * The relay base URL is only known after `startChiiRelay()` resolves, so the
4152
4624
  * `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside
@@ -4156,6 +4628,7 @@ async function bootLocalFamily() {
4156
4628
  * (relay host) is never logged here directly.
4157
4629
  */
4158
4630
  async function bootRelayFamily(options = {}) {
4631
+ assertRelayAuthConfigured();
4159
4632
  const relayPort = options.relayPort ?? 0;
4160
4633
  const totpEnabled = options.verifyAuth !== void 0;
4161
4634
  const relay = await startChiiRelay({
@@ -4205,6 +4678,7 @@ async function bootRelayFamily(options = {}) {
4205
4678
  const connection = createRelayConnection(relay.baseUrl);
4206
4679
  return {
4207
4680
  connection,
4681
+ relayOrigin: "intoss-webview",
4208
4682
  getTunnelStatus: () => tunnelStatus,
4209
4683
  stop() {
4210
4684
  tunnelProbe?.stop();
@@ -4215,21 +4689,119 @@ async function bootRelayFamily(options = {}) {
4215
4689
  };
4216
4690
  }
4217
4691
  /**
4218
- * Production `ConnectionRouter` (issues #348, #356 — DUAL-CONNECTION-COEXIST).
4219
- *
4220
- * Holds two families one booted eagerly at startup, the other lazily on the
4221
- * first cross-family switch an `active` pointer, and the single attach watcher
4222
- * armed on the active connection. The router is **direction-neutral** (#356):
4223
- * either family kind can be the eager one, so a `--target=local` session can
4224
- * hot-switch into relay (and vice versa) without restarting the MCP server.
4692
+ * Boots the EXTERNAL relay family for env 2 (real-device PWA, issue #378).
4693
+ *
4694
+ * Unlike {@link bootRelayFamily}, this does NOT start a relay or a tunnel
4695
+ * the unplugin (`tunnel: { cdp: true }`) already brought up a Chii relay for
4696
+ * the env-2 PWA and exposed its public base URL via `AIT_RELAY_BASE_URL`. Here
4697
+ * the MCP only opens a CDP client (`createRelayConnection`) against that
4698
+ * external relay. The relay's lifecycle is owned by the unplugin, so `stop()`
4699
+ * closes ONLY the CDP client — it must never tear down the relay or a tunnel
4700
+ * we did not start.
4701
+ *
4702
+ * `getTunnelStatus()` reports `up: true` with a `wssUrl` derived from
4703
+ * `relayBaseUrl` (http→ws, https→wss) so the `build_attach_url` gate
4704
+ * (`up: true && wssUrl !== null`) is satisfied even though we never opened a
4705
+ * cloudflared tunnel ourselves.
4706
+ *
4707
+ * SECRET-HANDLING: `relayBaseUrl` carries the relay host (same sensitivity as a
4708
+ * wss URL) — it is NEVER logged here. The caller validates presence and passes
4709
+ * the value straight to the CDP client.
4710
+ */
4711
+ async function bootExternalRelayFamily(relayBaseUrl) {
4712
+ assertRelayAuthConfigured();
4713
+ const connection = createRelayConnection(relayBaseUrl);
4714
+ const tunnelStatus = makeTunnelStatus(true, relayBaseUrl.replace(/^http/, "ws"));
4715
+ return {
4716
+ connection,
4717
+ relayOrigin: "external-pwa",
4718
+ getTunnelStatus: () => tunnelStatus,
4719
+ stop() {
4720
+ connection.close();
4721
+ }
4722
+ };
4723
+ }
4724
+ /**
4725
+ * Maps a `StartDebugMode` to the {@link FamilyKey} that serves it (issue #378).
4726
+ * local-browser → 'local-browser'; relay-sandbox → 'relay-sandbox';
4727
+ * relay-staging/relay-live → 'relay-intoss' (the shared physical slot).
4728
+ */
4729
+ function familyKeyForMode(mode) {
4730
+ switch (mode) {
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";
4735
+ }
4736
+ }
4737
+ /** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */
4738
+ const MOBILE_RELAY_BASE_URL_MISSING_MESSAGE = "start_debug(mobile): AIT_RELAY_BASE_URL이 설정되지 않았습니다 — unplugin이 tunnel:{cdp:true}로 띄운 relay base URL을 AIT_RELAY_BASE_URL 환경변수로 전달하세요. 환경 2(실기기 PWA) 진입은 외부 relay base가 필요합니다.";
4739
+ /**
4740
+ * Reads `AIT_RELAY_BASE_URL` from the environment for the env-2 (`mobile`) boot
4741
+ * site (issue #378). Returns the trimmed value, or throws the precise
4742
+ * {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} when unset/empty.
4743
+ *
4744
+ * SECRET-HANDLING: `AIT_RELAY_BASE_URL` carries the relay host (same class as a
4745
+ * wss URL). On the missing path the thrown message describes the env var name
4746
+ * and how to obtain it — it NEVER echoes any partial/garbled URL value. The
4747
+ * present value is returned to the caller (the CDP client) but never logged.
4748
+ */
4749
+ function readMobileRelayBaseUrl(env = process.env) {
4750
+ const raw = env.AIT_RELAY_BASE_URL;
4751
+ const value = typeof raw === "string" ? raw.trim() : "";
4752
+ if (value === "") throw new Error(MOBILE_RELAY_BASE_URL_MISSING_MESSAGE);
4753
+ return value;
4754
+ }
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
+ /**
4773
+ * Production `ConnectionRouter` (issues #348, #356, #378 — DUAL-CONNECTION-COEXIST).
4774
+ *
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
4778
+ * single attach watcher armed on the active connection. The router is
4779
+ * **direction-neutral** (#356): any family can be the first one booted, so a
4780
+ * `--target=local` session can hot-switch into relay (and vice versa) without
4781
+ * restarting the MCP server.
4782
+ *
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.
4225
4797
  *
4226
4798
  * `switchMode`:
4227
- * 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed relay-live;
4228
- * 2. routes by the requested mode's family kind: same kind as `eager` → reuse
4229
- * eager; different kind lazily boot (once) and keep warm;
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)`;
4230
4802
  * 3. flips `active` (the MCP `Server` never re-handshakes — it reads through
4231
4803
  * `active` per request);
4232
- * 4. sets `liveIntent` (true only for relay-live);
4804
+ * 4. sets `liveIntent` (true only for `relay-live`; `relay-sandbox` is dev-intent → false);
4233
4805
  * 5. stops the old attach watcher and re-arms one on the new connection
4234
4806
  * (the watcher self-clears, so re-arm is mandatory);
4235
4807
  * 6. emits `tools/list_changed`.
@@ -4240,30 +4812,37 @@ async function bootRelayFamily(options = {}) {
4240
4812
  */
4241
4813
  var DualConnectionRouter = class {
4242
4814
  deps;
4243
- /** The opposite-kind family, booted lazily on the first cross-family switch. */
4244
- lazyFamily = null;
4245
- activeFamily;
4815
+ /** Families, booted lazily and warm-kept per {@link FamilyKey} (#378, #396). */
4816
+ lazyFamilies = /* @__PURE__ */ new Map();
4817
+ /** `null` until the first `start_debug` boots a family (all-lazy, #396). */
4818
+ activeFamily = null;
4246
4819
  server = null;
4247
4820
  attachWatcher = null;
4248
4821
  swapInFlight = false;
4249
4822
  constructor(deps) {
4250
4823
  this.deps = deps;
4251
- this.activeFamily = deps.eager;
4252
4824
  }
4253
4825
  get active() {
4254
- return this.activeFamily.connection;
4826
+ return this.activeFamily ? this.activeFamily.connection : NULL_CDP_CONNECTION;
4255
4827
  }
4256
- /** Every booted family (for unified shutdown). */
4828
+ /** Relay origin of the currently-active family (issue #378). */
4829
+ get activeRelayOrigin() {
4830
+ return this.activeFamily?.relayOrigin;
4831
+ }
4832
+ /** Every booted family (for unified shutdown). All families are lazy (#396). */
4257
4833
  bootedFamilies() {
4258
- return this.lazyFamily ? [this.deps.eager, this.lazyFamily] : [this.deps.eager];
4834
+ return [...this.lazyFamilies.values()];
4259
4835
  }
4260
4836
  /**
4261
- * Live tunnel status of the relay family, regardless of whether relay is the
4262
- * eager or lazy family (#356). Returns "down" until the relay family has been
4263
- * booted (local-eager sessions before the first relay switch) — which is the
4264
- * correct signal for `build_attach_url` (no tunnel exists yet).
4837
+ * Live tunnel status of the active relay family (issues #356, #378). Reads
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
4840
+ * falls back to the first booted family that has a tunnel. Returns "down"
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).
4265
4843
  */
4266
4844
  relayTunnelStatus() {
4845
+ if (this.activeFamily?.getTunnelStatus) return this.activeFamily.getTunnelStatus();
4267
4846
  for (const family of this.bootedFamilies()) if (family.getTunnelStatus) return family.getTunnelStatus();
4268
4847
  return {
4269
4848
  up: false,
@@ -4271,8 +4850,9 @@ var DualConnectionRouter = class {
4271
4850
  };
4272
4851
  }
4273
4852
  /**
4274
- * Binds the MCP `Server` and arms the initial attach watcher on the active
4275
- * 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`.
4276
4856
  */
4277
4857
  start(server) {
4278
4858
  this.server = server;
@@ -4287,32 +4867,43 @@ var DualConnectionRouter = class {
4287
4867
  armWatcher() {
4288
4868
  const server = this.server;
4289
4869
  if (!server) return;
4290
- 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, () => {
4291
4873
  this.deps.diagnosticsCollector.recordAttach();
4292
- if (this.activeFamily.connection.kind === "relay") this.deps.devtoolsOpener.open(this.relayTunnelStatus().wssUrl, deriveEnvironment(this.activeFamily.connection.kind, getLiveIntent()));
4874
+ this.deps.onPageAttach?.();
4875
+ if (activeFamily.connection.kind === "relay") this.deps.devtoolsOpener.open(this.relayTunnelStatus().wssUrl, deriveEnvironment(activeFamily.connection.kind, getLiveIntent(), activeFamily.relayOrigin));
4293
4876
  });
4294
4877
  }
4295
- async switchMode(mode, confirm) {
4878
+ /**
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.
4883
+ */
4884
+ async familyFor(key) {
4885
+ const warm = this.lazyFamilies.get(key);
4886
+ if (warm) return warm;
4887
+ const booted = await this.deps.bootLazyFor(key);
4888
+ this.lazyFamilies.set(key, booted);
4889
+ return booted;
4890
+ }
4891
+ async switchMode(mode, confirm, projectRoot) {
4296
4892
  if (this.swapInFlight) throw new Error("start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.");
4297
4893
  if (mode === "relay-live" && !confirm) throw new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요.");
4298
4894
  this.swapInFlight = true;
4299
4895
  try {
4300
- const wantRelay = isRelayMode(mode);
4301
- const wantKind = wantRelay ? "relay" : "local";
4302
- let target;
4303
- if (wantKind === this.deps.eager.connection.kind) target = this.deps.eager;
4304
- else {
4305
- if (this.lazyFamily === null) this.lazyFamily = await this.deps.bootLazy();
4306
- target = this.lazyFamily;
4307
- }
4896
+ if (isRelayMode(mode)) await loadRelaySecretReadOnly({ projectRoot });
4897
+ const target = await this.familyFor(familyKeyForMode(mode));
4308
4898
  this.activeFamily = target;
4309
4899
  setLiveIntent(mode === "relay-live");
4310
4900
  this.stopWatcher();
4311
4901
  this.armWatcher();
4312
4902
  this.server?.sendToolListChanged();
4903
+ const wantRelay = isRelayMode(mode);
4313
4904
  return {
4314
4905
  mode,
4315
- environment: deriveEnvironment(target.connection.kind, getLiveIntent()),
4906
+ environment: deriveEnvironment(target.connection.kind, getLiveIntent(), target.relayOrigin),
4316
4907
  kind: target.connection.kind,
4317
4908
  liveGuardActive: target.connection.kind === "relay" && getLiveIntent(),
4318
4909
  nextStep: wantRelay ? "build_attach_url로 attach QR을 생성하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
@@ -4332,26 +4923,39 @@ var DualConnectionRouter = class {
4332
4923
  */
4333
4924
  async function runDebugServer(options = {}) {
4334
4925
  const lockHandle = acquireLock({ force: options.force ?? false });
4335
- const verifyAuth = buildRelayVerifyAuth();
4336
- const relayFamily = await bootRelayFamily({
4337
- relayPort: options.relayPort,
4338
- verifyAuth,
4339
- onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
4340
- });
4341
4926
  const devtoolsOpener = new AutoDevtoolsOpener();
4342
4927
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4343
4928
  const router = new DualConnectionRouter({
4344
- eager: relayFamily,
4345
- bootLazy: 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
+ }),
4346
4937
  diagnosticsCollector,
4347
- devtoolsOpener
4938
+ devtoolsOpener,
4939
+ onPageAttach: () => qrServer?.notifyStateChange()
4348
4940
  });
4349
4941
  const aitSource = new RoutingAitSource(() => {
4350
4942
  return router.active;
4351
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
+ });
4352
4956
  let qrServer;
4353
4957
  try {
4354
- qrServer = await startQrHttpServer();
4958
+ qrServer = await startQrHttpServer(getDashboardState);
4355
4959
  } catch (err) {
4356
4960
  logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
4357
4961
  }
@@ -4364,7 +4968,11 @@ async function runDebugServer(options = {}) {
4364
4968
  return qrServer;
4365
4969
  },
4366
4970
  diagnosticsCollector,
4367
- ...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
+ }
4368
4976
  });
4369
4977
  const transport = new StdioServerTransport();
4370
4978
  let closed = false;
@@ -4425,13 +5033,15 @@ async function runDebugServer(options = {}) {
4425
5033
  }
4426
5034
  }
4427
5035
  /**
4428
- * Boots the local-browser debug stack and serves it over stdio:
4429
- * 1. launch a local Chromium with `--remote-debugging-port=<port>`,
4430
- * 2. attach a `LocalCdpConnection` to the first non-blank page target,
4431
- * 3. expose the debug tools through the SAME direction-neutral
4432
- * `DualConnectionRouter` that `runDebugServer` uses (issue #356) — the
4433
- * local family is eager, the relay family is lazy-booted on the first
4434
- * `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).
4435
5045
  *
4436
5046
  * Symmetry with `runDebugServer` (#356): starting with `--target=local` no
4437
5047
  * longer pins a single-connection router. A `--target=local` session can
@@ -4453,37 +5063,195 @@ async function runDebugServer(options = {}) {
4453
5063
  */
4454
5064
  async function runLocalDebugServer(options = {}) {
4455
5065
  const lockHandle = acquireLock({ force: options.force ?? false });
4456
- const chromium = await launchChromium({
4457
- port: options.cdpPort ?? 0,
4458
- devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
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
+ };
5082
+ };
5083
+ const devtoolsOpener = new AutoDevtoolsOpener();
5084
+ const diagnosticsCollector = new InMemoryDiagnosticsCollector();
5085
+ const router = new DualConnectionRouter({
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
+ }
5092
+ }),
5093
+ diagnosticsCollector,
5094
+ devtoolsOpener,
5095
+ onPageAttach: () => qrServer?.notifyStateChange()
4459
5096
  });
4460
- await new Promise((r) => setTimeout(r, 800));
4461
- const localConnection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
4462
- const localFamily = {
4463
- connection: localConnection,
4464
- stop() {
4465
- localConnection.close();
4466
- chromium.stop();
5097
+ const aitSource = new RoutingAitSource(() => {
5098
+ return router.active;
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
+ });
5112
+ let qrServer;
5113
+ try {
5114
+ qrServer = await startQrHttpServer(getDashboardState);
5115
+ } catch (err) {
5116
+ logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
5117
+ }
5118
+ const server = createDebugServer({
5119
+ connection: router.active,
5120
+ router,
5121
+ aitSource,
5122
+ getTunnelStatus: () => router.relayTunnelStatus(),
5123
+ get qrHttpServer() {
5124
+ return qrServer;
5125
+ },
5126
+ diagnosticsCollector,
5127
+ getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
5128
+ onAttachUrlBuilt: (url) => {
5129
+ lastAttachUrl = url;
5130
+ qrServer?.notifyStateChange();
4467
5131
  }
5132
+ });
5133
+ const transport = new StdioServerTransport();
5134
+ let closed = false;
5135
+ let parentWatcher = null;
5136
+ const shutdown = () => {
5137
+ if (closed) return;
5138
+ closed = true;
5139
+ parentWatcher?.stop();
5140
+ router.stopWatcher();
5141
+ for (const family of router.bootedFamilies()) family.stop();
5142
+ server.close();
5143
+ qrServer?.close();
5144
+ lockHandle.release();
4468
5145
  };
4469
- const verifyAuth = buildRelayVerifyAuth();
5146
+ process.once("SIGINT", shutdown);
5147
+ process.once("SIGTERM", shutdown);
5148
+ process.once("SIGHUP", shutdown);
5149
+ process.on("exit", () => {
5150
+ if (!closed) {
5151
+ closed = true;
5152
+ parentWatcher?.stop();
5153
+ router.stopWatcher();
5154
+ for (const family of router.bootedFamilies()) family.stop();
5155
+ lockHandle.release();
5156
+ }
5157
+ });
5158
+ process.on("uncaughtException", (err) => {
5159
+ logError("tool.error", {
5160
+ msg: `uncaughtException: ${String(err)}`,
5161
+ errorKind: "uncaught",
5162
+ mode: "local-browser"
5163
+ });
5164
+ shutdown();
5165
+ process.exit(1);
5166
+ });
5167
+ process.on("unhandledRejection", (reason) => {
5168
+ logError("tool.error", {
5169
+ msg: `unhandledRejection: ${String(reason)}`,
5170
+ errorKind: "unhandled-rejection",
5171
+ mode: "local-browser"
5172
+ });
5173
+ shutdown();
5174
+ process.exit(1);
5175
+ });
5176
+ await server.connect(transport);
5177
+ router.start(server);
5178
+ if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
5179
+ parentWatcher = startParentWatcher(() => {
5180
+ shutdown();
5181
+ process.exit(0);
5182
+ }, { intervalMs: 5e3 });
5183
+ process.stdin.once("end", () => {
5184
+ shutdown();
5185
+ process.exit(0);
5186
+ });
5187
+ process.stdin.once("close", () => {
5188
+ shutdown();
5189
+ process.exit(0);
5190
+ });
5191
+ }
5192
+ }
5193
+ /**
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' })`.
5198
+ *
5199
+ * Unlike `runDebugServer` (which starts its own relay + cloudflared tunnel),
5200
+ * `runMobileDebugServer` attaches to a relay the unplugin ALREADY brought up
5201
+ * (`tunnel: { cdp: true }`) and exposed via `AIT_RELAY_BASE_URL`. The MCP only
5202
+ * opens a CDP client against that external relay — it never starts or tears down
5203
+ * a relay or a tunnel it did not own (see {@link bootExternalRelayFamily}).
5204
+ *
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).
5212
+ *
5213
+ * SECRET-HANDLING: `AIT_RELAY_BASE_URL` is read once here via
5214
+ * {@link readMobileRelayBaseUrl}; when unset it throws
5215
+ * {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} — a message that names the env
5216
+ * var and how to obtain it, never echoing any URL value. The error propagates to
5217
+ * the bin entry's fatal handler (the missing-URL path prints the guidance, not a
5218
+ * value). The present value is passed straight to the CDP client, never logged.
5219
+ */
5220
+ async function runMobileDebugServer(options = {}) {
5221
+ const relayBaseUrl = readMobileRelayBaseUrl();
5222
+ const lockHandle = acquireLock({ force: options.force ?? false });
4470
5223
  const devtoolsOpener = new AutoDevtoolsOpener();
4471
5224
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4472
5225
  const router = new DualConnectionRouter({
4473
- eager: localFamily,
4474
- bootLazy: () => bootRelayFamily({
4475
- verifyAuth,
4476
- 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
+ }
4477
5232
  }),
4478
5233
  diagnosticsCollector,
4479
- devtoolsOpener
5234
+ devtoolsOpener,
5235
+ onPageAttach: () => qrServer?.notifyStateChange()
4480
5236
  });
4481
5237
  const aitSource = new RoutingAitSource(() => {
4482
5238
  return router.active;
4483
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
+ });
4484
5252
  let qrServer;
4485
5253
  try {
4486
- qrServer = await startQrHttpServer();
5254
+ qrServer = await startQrHttpServer(getDashboardState);
4487
5255
  } catch (err) {
4488
5256
  logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
4489
5257
  }
@@ -4496,7 +5264,11 @@ async function runLocalDebugServer(options = {}) {
4496
5264
  return qrServer;
4497
5265
  },
4498
5266
  diagnosticsCollector,
4499
- ...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
+ }
4500
5272
  });
4501
5273
  const transport = new StdioServerTransport();
4502
5274
  let closed = false;
@@ -4527,7 +5299,7 @@ async function runLocalDebugServer(options = {}) {
4527
5299
  logError("tool.error", {
4528
5300
  msg: `uncaughtException: ${String(err)}`,
4529
5301
  errorKind: "uncaught",
4530
- mode: "local"
5302
+ mode: "relay-sandbox"
4531
5303
  });
4532
5304
  shutdown();
4533
5305
  process.exit(1);
@@ -4536,7 +5308,7 @@ async function runLocalDebugServer(options = {}) {
4536
5308
  logError("tool.error", {
4537
5309
  msg: `unhandledRejection: ${String(reason)}`,
4538
5310
  errorKind: "unhandled-rejection",
4539
- mode: "local"
5311
+ mode: "relay-sandbox"
4540
5312
  });
4541
5313
  shutdown();
4542
5314
  process.exit(1);
@@ -4986,7 +5758,7 @@ function createDevServer(deps = {}) {
4986
5758
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
4987
5759
  const server = new Server({
4988
5760
  name: "ait-devtools",
4989
- version: "0.1.55"
5761
+ version: "0.1.57"
4990
5762
  }, { capabilities: { tools: {} } });
4991
5763
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
4992
5764
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -5061,11 +5833,15 @@ async function runDevServer() {
5061
5833
  *
5062
5834
  * --mode=debug (default)
5063
5835
  * --target=relay (default) — CDP/Chii relay + cloudflared quick tunnel.
5064
- * Attach a running mini-app (real Toss WebView, env 2/3) and read its
5836
+ * Attach a running mini-app (real Toss WebView, env 3/4) and read its
5065
5837
  * console + network over CDP without a human watching a phone.
5066
5838
  * --target=local — CDP direct-attach to a local Chromium launched by the
5067
5839
  * MCP server (env 1). No relay or tunnel; the browser is launched
5068
5840
  * pointing at AIT_DEVTOOLS_URL (default http://localhost:5173).
5841
+ * --target=mobile — CDP attach to an EXTERNAL Chii relay the unplugin
5842
+ * already brought up for the env-2 real-device PWA (`tunnel: { cdp: true }`),
5843
+ * exposed via AIT_RELAY_BASE_URL. The MCP starts no relay or tunnel; it
5844
+ * only opens a CDP client against that external relay (issue #378).
5069
5845
  *
5070
5846
  * --mode=dev — dev mode — reads the live browser mock state from a running
5071
5847
  * Vite dev server (the devtools#130 `devtools_get_mock_state` surface).
@@ -5119,8 +5895,9 @@ function parseMode(argv) {
5119
5895
  * Parses `--target=<value>` / `--target <value>` from argv; default `relay`.
5120
5896
  *
5121
5897
  * Only meaningful when `--mode=debug`:
5122
- * - `relay` — phone/WebView attach over Chii relay + cloudflared tunnel (env 2/3).
5898
+ * - `relay` — phone/WebView attach over Chii relay + cloudflared tunnel (env 3/4).
5123
5899
  * - `local` — local Chromium CDP attach (env 1, no relay needed).
5900
+ * - `mobile` — CDP attach to an EXTERNAL relay (env 2 PWA, AIT_RELAY_BASE_URL).
5124
5901
  */
5125
5902
  function parseTarget(argv) {
5126
5903
  for (let i = 0; i < argv.length; i++) {
@@ -5129,7 +5906,7 @@ function parseTarget(argv) {
5129
5906
  if (arg.startsWith("--target=")) return normalizeTarget(arg.slice(9));
5130
5907
  if (arg === "--target") {
5131
5908
  const next = argv[i + 1];
5132
- if (next === void 0) throw new Error("--target requires a value: 'relay' (default) or 'local'.");
5909
+ if (next === void 0) throw new Error("--target requires a value: 'relay' (default), 'local', or 'mobile'.");
5133
5910
  return normalizeTarget(next);
5134
5911
  }
5135
5912
  }
@@ -5143,7 +5920,8 @@ function normalizeMode(value) {
5143
5920
  function normalizeTarget(value) {
5144
5921
  if (value === "relay") return "relay";
5145
5922
  if (value === "local") return "local";
5146
- throw new Error(`Unknown --target '${value}'. Expected 'relay' (default) or 'local'.`);
5923
+ if (value === "mobile") return "mobile";
5924
+ throw new Error(`Unknown --target '${value}'. Expected 'relay' (default), 'local', or 'mobile'.`);
5147
5925
  }
5148
5926
  async function main() {
5149
5927
  const args = process.argv.slice(2);
@@ -5153,6 +5931,7 @@ async function main() {
5153
5931
  const target = parseTarget(args);
5154
5932
  const force = parseForce(args);
5155
5933
  if (target === "local") await runLocalDebugServer({ force });
5934
+ else if (target === "mobile") await runMobileDebugServer({ force });
5156
5935
  else await runDebugServer({ force });
5157
5936
  }
5158
5937
  }