@ait-co/devtools 0.1.54 → 0.1.56

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 (48) hide show
  1. package/README.en.md +64 -17
  2. package/README.md +63 -16
  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 +782 -226
  14. package/dist/mcp/cli.js.map +1 -1
  15. package/dist/mcp/server.js +18 -13
  16. package/dist/mcp/server.js.map +1 -1
  17. package/dist/mock/index.d.ts.map +1 -1
  18. package/dist/mock/index.js +18 -2
  19. package/dist/mock/index.js.map +1 -1
  20. package/dist/panel/index.js +66 -4
  21. package/dist/panel/index.js.map +1 -1
  22. package/dist/totp-BkKP4m8H.cjs +185 -0
  23. package/dist/totp-BkKP4m8H.cjs.map +1 -0
  24. package/dist/totp-CxHsagqY.js +184 -0
  25. package/dist/totp-CxHsagqY.js.map +1 -0
  26. package/dist/{tunnel-D0_TwDNE.js → tunnel-Cj8g1LIL.js} +12 -4
  27. package/dist/tunnel-Cj8g1LIL.js.map +1 -0
  28. package/dist/{tunnel-BYP0yRBN.cjs → tunnel-p-q6eVWT.cjs} +12 -4
  29. package/dist/tunnel-p-q6eVWT.cjs.map +1 -0
  30. package/dist/unplugin/index.cjs +29 -3
  31. package/dist/unplugin/index.cjs.map +1 -1
  32. package/dist/unplugin/index.d.cts +11 -0
  33. package/dist/unplugin/index.d.cts.map +1 -1
  34. package/dist/unplugin/index.d.ts +12 -1
  35. package/dist/unplugin/index.d.ts.map +1 -1
  36. package/dist/unplugin/index.js +29 -3
  37. package/dist/unplugin/index.js.map +1 -1
  38. package/dist/unplugin/tunnel.cjs +11 -3
  39. package/dist/unplugin/tunnel.cjs.map +1 -1
  40. package/dist/unplugin/tunnel.d.cts +13 -1
  41. package/dist/unplugin/tunnel.d.cts.map +1 -1
  42. package/dist/unplugin/tunnel.d.ts +13 -1
  43. package/dist/unplugin/tunnel.d.ts.map +1 -1
  44. package/dist/unplugin/tunnel.js +11 -3
  45. package/dist/unplugin/tunnel.js.map +1 -1
  46. package/package.json +2 -2
  47. package/dist/tunnel-BYP0yRBN.cjs.map +0 -1
  48. package/dist/tunnel-D0_TwDNE.js.map +0 -1
package/dist/mcp/cli.js CHANGED
@@ -718,6 +718,157 @@ async function startChiiRelay(options = {}) {
718
718
  };
719
719
  }
720
720
  //#endregion
721
+ //#region src/mcp/deeplink.ts
722
+ /**
723
+ * URL of the AITC Sandbox launcher PWA.
724
+ *
725
+ * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the
726
+ * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy
727
+ * for the same reason — keep the two in sync when the URL changes.
728
+ */
729
+ const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
730
+ /**
731
+ * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).
732
+ *
733
+ * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport
734
+ * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the
735
+ * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js
736
+ * is injected. `&at=<totpCode>` is added only when a code is provided (same
737
+ * conditional as {@link buildDeepLinkAttachUrl}).
738
+ *
739
+ * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL
740
+ * via raw string manipulation), this function uses WHATWG `encodeURIComponent`
741
+ * because the target is a standard `https:` URL.
742
+ *
743
+ * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param
744
+ * only — never logged or returned separately. Callers must NOT log the result
745
+ * of this function to stdout/stderr.
746
+ *
747
+ * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL
748
+ * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.
749
+ * @param wssUrl - The `wss://` relay URL the framed page will attach to.
750
+ * @param totpCode - Optional current TOTP code (6 digits). When provided, it
751
+ * is appended as `at=<totpCode>`. Must be computed at call time — it rotates
752
+ * every 30 s. Omit when TOTP is disabled.
753
+ * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>
754
+ * [&at=<code>]` params.
755
+ */
756
+ function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode) {
757
+ let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}&debug=1&relay=${encodeURIComponent(wssUrl)}`;
758
+ if (totpCode !== void 0 && totpCode !== "") url += `&at=${encodeURIComponent(totpCode)}`;
759
+ return url;
760
+ }
761
+ /**
762
+ * Build a self-attaching dogfood deep link.
763
+ *
764
+ * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`
765
+ * URL that opens a dogfood bundle on a phone. The in-app debug gate
766
+ * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries
767
+ * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus
768
+ * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result
769
+ * as a QR code and scanning it with the phone camera opens the mini-app and
770
+ * attaches it to the live Chii relay. QR is the single entry path — it needs
771
+ * no USB cable, platform CLI, or driver, and works the same on iOS/Android.
772
+ *
773
+ * The Toss app propagates extra query params from the entry deep link into the
774
+ * mini-app WebView's `location.search` (confirmed behavior), so the gate reads
775
+ * them at attach time.
776
+ *
777
+ * TOTP `at=` param:
778
+ * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional
779
+ * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.
780
+ * The code must be computed by the caller at call time — do NOT pre-compute
781
+ * and cache it, because the 30-second window expires quickly. The in-app gate
782
+ * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.
783
+ *
784
+ * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.
785
+ * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query
786
+ * decomposition you can rely on across runtimes), so query manipulation via
787
+ * `url.searchParams` is not portable here. We splice the query string directly
788
+ * on the raw string instead, which keeps the scheme, authority, path, and any
789
+ * pre-existing params (notably `_deploymentId`) byte-for-byte intact.
790
+ */
791
+ /**
792
+ * Suspicious/generic authority values that indicate a malformed or placeholder
793
+ * scheme URL. These are host strings that will almost certainly cause the Toss
794
+ * app to fail with "bundle not found" silently.
795
+ *
796
+ * The expected form from `ait deploy --scheme-only` is:
797
+ * intoss-private://<appName>?_deploymentId=<uuid>
798
+ * where `<appName>` is a non-generic string like `aitc-sdk-example`.
799
+ */
800
+ const SUSPICIOUS_AUTHORITIES = new Set([
801
+ "",
802
+ "web",
803
+ "localhost",
804
+ "127.0.0.1",
805
+ "app"
806
+ ]);
807
+ /**
808
+ * Validates the authority (host) portion of a scheme URL.
809
+ *
810
+ * Returns a warning message if the authority is missing or looks like a
811
+ * placeholder, or `null` if the authority looks valid.
812
+ *
813
+ * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`
814
+ * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).
815
+ */
816
+ function validateSchemeAuthority(schemeUrl) {
817
+ const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//, "");
818
+ 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`.";
819
+ const authorityEnd = afterScheme.search(/[/?#]/);
820
+ const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);
821
+ 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.`;
822
+ return null;
823
+ }
824
+ function stripExisting(query, key) {
825
+ if (query === "") return "";
826
+ return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
827
+ }
828
+ /**
829
+ * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a
830
+ * scheme URL's query string, preserving everything else (scheme, authority,
831
+ * path, hash, and the existing `_deploymentId` param). If any of the spliced
832
+ * params is already present it is replaced so the helper is idempotent.
833
+ *
834
+ * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed
835
+ * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B
836
+ * of the gate); this helper does not invent one.
837
+ * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the
838
+ * running debug MCP server's quick tunnel.
839
+ * @param totpCode - Optional current TOTP code (6 digits). When provided, it
840
+ * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates
841
+ * every 30 s. Pass `undefined` or omit when TOTP is disabled.
842
+ * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`
843
+ * appended.
844
+ * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so
845
+ * producing such a link would be a silent dead end).
846
+ */
847
+ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
848
+ let relay;
849
+ try {
850
+ relay = new URL(wssUrl);
851
+ } catch {
852
+ throw new Error(`relay URL is not a valid URL: ${wssUrl}`);
853
+ }
854
+ if (relay.protocol !== "wss:") throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);
855
+ const hashIndex = schemeUrl.indexOf("#");
856
+ const hash = hashIndex === -1 ? "" : schemeUrl.slice(hashIndex);
857
+ const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);
858
+ const queryIndex = beforeHash.indexOf("?");
859
+ const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
860
+ let query = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1);
861
+ const appended = [["debug", "1"], ["relay", wssUrl]];
862
+ if (totpCode !== void 0 && totpCode !== "") appended.push(["at", totpCode]);
863
+ query = stripExisting(query, "at");
864
+ for (const [key] of appended) query = stripExisting(query, key);
865
+ for (const [key, value] of appended) {
866
+ const pair = `${key}=${encodeURIComponent(value)}`;
867
+ query = query === "" ? pair : `${query}&${pair}`;
868
+ }
869
+ return `${base}?${query}${hash}`;
870
+ }
871
+ //#endregion
721
872
  //#region src/mcp/devtools-opener.ts
722
873
  /**
723
874
  * Base URL for the Chrome DevTools inspector hosted on appspot.
@@ -897,15 +1048,25 @@ function wrapEnvelope(data, ctx) {
897
1048
  //#endregion
898
1049
  //#region src/mcp/environment.ts
899
1050
  /**
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.
1051
+ * Returns `true` when the environment is any relay variant (`relay-dev`,
1052
+ * `relay-live`, or `relay-mobile`). Use this instead of `env === 'relay'` for
1053
+ * tier checks — every relay env surfaces the Tier B / relay-only tool set.
1054
+ *
1055
+ * Written as an exhaustive switch so a future `McpEnvironment` member that is
1056
+ * missing an arm is a TS compile error rather than a silent `false`.
902
1057
  */
903
1058
  function isRelayEnv(env) {
904
- return env === "relay-dev" || env === "relay-live";
1059
+ switch (env) {
1060
+ case "relay-dev":
1061
+ case "relay-live":
1062
+ case "relay-mobile": return true;
1063
+ case "mock": return false;
1064
+ }
905
1065
  }
906
1066
  /**
907
1067
  * Returns `true` when the environment is the LIVE relay (`relay-live`).
908
- * This is the guard condition for side-effect tool protection.
1068
+ * This is the guard condition for side-effect tool protection. `relay-mobile`
1069
+ * is a dev-intent env (env 2 PWA) and is NOT live.
909
1070
  */
910
1071
  function isLiveRelayEnv(env) {
911
1072
  return env === "relay-live";
@@ -913,25 +1074,38 @@ function isLiveRelayEnv(env) {
913
1074
  /**
914
1075
  * Maps the `McpEnvironment` union to the legacy two-value union
915
1076
  * (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
1077
+ * Every relay variant (incl. `relay-mobile`) collapses to `'relay'`.
916
1078
  */
917
1079
  function toLegacyEnv(env) {
918
1080
  if (env === "mock") return "mock";
919
1081
  return "relay";
920
1082
  }
921
1083
  /**
922
- * Reconstructs the three-value `McpEnvironment` output string from the two
923
- * orthogonal signals (issue #348):
1084
+ * Reconstructs the four-value `McpEnvironment` output string from the
1085
+ * orthogonal signals (issues #348, #378):
1086
+ *
1087
+ * - `kind === 'local'` → `'mock'`
1088
+ * - `kind === 'relay'` && liveIntent → `'relay-live'`
1089
+ * - `kind === 'relay'` && !liveIntent && origin 'external-pwa' → `'relay-mobile'`
1090
+ * - `kind === 'relay'` && !liveIntent && origin intoss/undefined → `'relay-dev'`
924
1091
  *
925
- * - `kind === 'local'` → `'mock'`
926
- * - `kind === 'relay'` && liveIntent → `'relay-live'`
927
- * - `kind === 'relay'` && !liveIntent → `'relay-dev'`
1092
+ * `relayOrigin` is the booted-family discriminator (NOT sniffed from the URL)
1093
+ * that distinguishes the env-2 external-PWA relay (`relay-mobile`) from the
1094
+ * intoss-private dogfood relay (`relay-dev`); both are `kind: 'relay'`.
928
1095
  *
929
1096
  * Pure — used at every output boundary (envelope `meta.env`, `get_diagnostics`,
930
1097
  * `measure_safe_area` provenance) so the surface never sniffs a URL again.
1098
+ *
1099
+ * Written switch-style so a missing arm is a TS compile error (never falls
1100
+ * through to a default).
931
1101
  */
932
- function deriveEnvironment(kind, liveIntent) {
933
- if (kind === "local") return "mock";
934
- return liveIntent ? "relay-live" : "relay-dev";
1102
+ function deriveEnvironment(kind, liveIntent, relayOrigin) {
1103
+ switch (kind) {
1104
+ case "local": return "mock";
1105
+ case "relay":
1106
+ if (liveIntent) return "relay-live";
1107
+ return relayOrigin === "external-pwa" ? "relay-mobile" : "relay-dev";
1108
+ }
935
1109
  }
936
1110
  /**
937
1111
  * Module-level `relay-dev` vs `relay-live` intent bit (issue #348).
@@ -1010,12 +1184,23 @@ function pageCrashError(toolName) {
1010
1184
  return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 build_attach_url → QR 스캔으로 재attach하세요.`);
1011
1185
  }
1012
1186
  /**
1013
- * 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다 (dogfood 빌드가 아님).
1014
- *
1015
- * call_sdk 호출 시 브리지가 없을 때.
1187
+ * 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다.
1188
+ *
1189
+ * call_sdk 호출 시 브리지가 없을 때. 같은 "브리지 부재"라도 다음 행동은
1190
+ * connection 종류에 따라 정반대다 (issue #360):
1191
+ * - relay(`--target` 없는 intoss / env-2): dogfood 빌드가 아니다 → dogfood
1192
+ * 채널로 재배포 후 QR 재스캔.
1193
+ * - local(`--target=local`, env 1 로컬 브라우저): 재배포가 아니라 dev 서버를
1194
+ * `pnpm dev`로 띄웠는지 + unplugin alias가 `@apps-in-toss/web-framework`를
1195
+ * devtools mock으로 resolve하는지 확인. dev 빌드면 `import.meta.env.DEV`
1196
+ * 경로로 `window.__sdkCall`이 자동 설치된다.
1197
+ *
1198
+ * `isLocal`이 생략되면 relay 안내(이전 동작)를 유지한다.
1016
1199
  */
1017
- function sdkAbsentError(toolName) {
1018
- return mcpError(`${toolName ? `${toolName}: ` : ""}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
1200
+ function sdkAbsentError(toolName, isLocal = false) {
1201
+ const prefix = toolName ? `${toolName}: ` : "";
1202
+ 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\`이 자동 설치됩니다.`);
1203
+ return mcpError(`${prefix}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
1019
1204
  }
1020
1205
  /**
1021
1206
  * relay-live 환경에서 side-effect 도구(`call_sdk`, `evaluate`)를 `confirm: true`
@@ -1048,10 +1233,10 @@ function relayDisconnectError(toolName) {
1048
1233
  * - 연결 끊김 패턴 (`relay에 연결되어 있지 않습니다`, `relay WebSocket`) → relayDisconnectError
1049
1234
  * - 그 외 (일반 에러) → 원본 메시지를 포함한 mcpError
1050
1235
  */
1051
- function classifyToolError(err, toolName) {
1236
+ function classifyToolError(err, toolName, isLocal = false) {
1052
1237
  const message = err instanceof Error ? err.message : String(err);
1053
1238
  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);
1239
+ 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
1240
  if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
1056
1241
  if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket")) return relayDisconnectError(toolName);
1057
1242
  return mcpError(`${toolName} 실패: ${message}\nlist_pages로 미니앱이 relay에 attach됐는지 확인하세요.`);
@@ -1744,118 +1929,6 @@ function acquireLock(options = {}) {
1744
1929
  };
1745
1930
  }
1746
1931
  //#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}`;
1857
- }
1858
- //#endregion
1859
1932
  //#region src/mcp/sdk-signatures.ts
1860
1933
  function isObject$1(v) {
1861
1934
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -2088,6 +2161,109 @@ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
2088
2161
  }
2089
2162
  return false;
2090
2163
  }
2164
+ /**
2165
+ * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.
2166
+ *
2167
+ * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,
2168
+ * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key
2169
+ * we are willing to gate a public relay behind. `generateAttachToken()` emits
2170
+ * 64 hex chars (32 bytes), comfortably above this bar.
2171
+ */
2172
+ const MIN_SECRET_HEX_CHARS = 32;
2173
+ /** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */
2174
+ const HEX_RE = /^[0-9a-fA-F]+$/;
2175
+ /**
2176
+ * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.
2177
+ *
2178
+ * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and
2179
+ * how to mint one. It NEVER echoes the configured value, its length, or any
2180
+ * fragment derived from it — see {@link assertRelayAuthConfigured}.
2181
+ *
2182
+ * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`
2183
+ * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be
2184
+ * silently mis-decoded and every TOTP code would fail to match, so the minting
2185
+ * command emits hex.
2186
+ */
2187
+ const RELAY_AUTH_SECRET_MISSING_MESSAGE = [
2188
+ "[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.",
2189
+ "발급: openssl rand -hex 32",
2190
+ "자세히: https://docs.aitc.dev/guides/relay-auth-totp"
2191
+ ].join("\n");
2192
+ /**
2193
+ * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at
2194
+ * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd
2195
+ * length would have its trailing nibble silently dropped by `Buffer.from(...,
2196
+ * 'hex')`, weakening the key without warning).
2197
+ *
2198
+ * Pure predicate so callers can test the validation independently of the
2199
+ * fail-fast side effect in {@link assertRelayAuthConfigured}.
2200
+ *
2201
+ * SECRET-HANDLING: returns only a boolean — the input value is never returned,
2202
+ * logged, or echoed.
2203
+ */
2204
+ function isValidRelayAuthSecret(secret) {
2205
+ if (secret === void 0 || secret === "") return false;
2206
+ if (secret.length < MIN_SECRET_HEX_CHARS) return false;
2207
+ if (secret.length % 2 !== 0) return false;
2208
+ return HEX_RE.test(secret);
2209
+ }
2210
+ /**
2211
+ * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before
2212
+ * a public-internet-exposed relay is booted (issue #250).
2213
+ *
2214
+ * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes
2215
+ * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third
2216
+ * party attach a debugger to a dogfood/live mini-app. Without a secret the relay
2217
+ * comes up unauthenticated, so this guard is called at every relay-boot site —
2218
+ * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),
2219
+ * both eager and lazy. Local-only sessions never boot a relay and so never reach
2220
+ * this guard, matching the issue's exemption for non-relay debugging.
2221
+ *
2222
+ * Throws when the secret is unset, empty, too short, or not a valid hex string.
2223
+ * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)
2224
+ * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.
2225
+ *
2226
+ * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean
2227
+ * predicate, and never logged. The thrown message names the requirement, never
2228
+ * the value, its length, or any derived fragment.
2229
+ *
2230
+ * @param env - Environment to read from. Defaults to `process.env`; injectable
2231
+ * for tests so they never mutate the real process environment.
2232
+ */
2233
+ function assertRelayAuthConfigured(env = process.env) {
2234
+ if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);
2235
+ }
2236
+ /**
2237
+ * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
2238
+ * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
2239
+ *
2240
+ * The predicate checks the `at` query parameter against the current and
2241
+ * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.
2242
+ *
2243
+ * Returns `undefined` when the env var is not set — callers treat that as
2244
+ * "auth disabled" (no predicate registered on the relay). Note that since
2245
+ * issue #250 the secret is MANDATORY at every relay-boot site (enforced by
2246
+ * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production
2247
+ * this never returns `undefined` for a relay that actually boots; the
2248
+ * `undefined` branch only matters for the no-relay local path and tests.
2249
+ *
2250
+ * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the
2251
+ * same gate without importing the heavy MCP server module graph. Re-exported
2252
+ * from `debug-server.ts` for back-compat.
2253
+ *
2254
+ * SECRET-HANDLING: The secret value read from env is captured in a closure and
2255
+ * is NEVER written to any log, error message, or process output.
2256
+ */
2257
+ function buildRelayVerifyAuth(env = process.env) {
2258
+ const secret = env.AIT_DEBUG_TOTP_SECRET;
2259
+ if (!secret) return void 0;
2260
+ return (req) => {
2261
+ const rawUrl = req.url ?? "";
2262
+ const qIndex = rawUrl.indexOf("?");
2263
+ const queryStr = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
2264
+ return verifyTotp(secret, new URLSearchParams(queryStr).get("at") ?? "");
2265
+ };
2266
+ }
2091
2267
  //#endregion
2092
2268
  //#region src/mcp/tools.ts
2093
2269
  /** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
@@ -2124,13 +2300,13 @@ const DEBUG_TOOL_DEFINITIONS = [
2124
2300
  },
2125
2301
  {
2126
2302
  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.",
2303
+ description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Builds a self-attaching deep link for the active relay environment and returns a QR code. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Call list_pages first to confirm the relay/tunnel is up. If the tunnel is not up, restart: `npx @ait-co/devtools devtools-mcp`.\n\nEnvironment-specific behaviour:\n • env 3 / staging (start_debug mode=\"staging\"): requires scheme_url — the intoss-private://…?_deploymentId=<uuid> URL from `ait deploy --scheme-only`. Splices debug=1 + relay URL into the scheme URL to produce a self-attach deep link.\n • env 2 / mobile (start_debug mode=\"mobile\"): scheme_url is NOT used. Instead, reads AIT_TUNNEL_BASE_URL (the https://*.trycloudflare.com app tunnel from `tunnel:{cdp:true}`) and builds a launcher PWA deep-link (https://devtools.aitc.dev/launcher/?url=…&debug=1&relay=…). Scan the QR with the phone to open the launcher, which frames the tunnel URL and attaches CDP.\n\nSet wait_for_attach=true to block until a page attaches (polls up to 30 s). On timeout, call build_attach_url again to resume polling. When open_in_browser=true (default), saves the QR as a PNG and opens it in the OS default browser — only works when the MCP server runs on a local GUI machine (not headless/remote containers). \n\nTOTP auth: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the returned attachUrl automatically includes the current one-time code (at=<code>) — the URL is single-use for that 30-second step. The response includes a `totp` field with `expiresAt` (ISO timestamp). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh one-time URL. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.",
2128
2304
  inputSchema: {
2129
2305
  type: "object",
2130
2306
  properties: {
2131
2307
  scheme_url: {
2132
2308
  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."
2309
+ description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). Required for env 3/staging mode. Not used in env 2/mobile mode (use AIT_TUNNEL_BASE_URL instead). The authority (host) must be the app name (e.g. intoss-private://aitc-sdk-example?_deploymentId=…). Generic values like \"web\" or an empty host indicate a malformed URL."
2134
2310
  },
2135
2311
  wait_for_attach: {
2136
2312
  type: "boolean",
@@ -2141,7 +2317,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2141
2317
  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
2318
  }
2143
2319
  },
2144
- required: ["scheme_url"]
2320
+ required: []
2145
2321
  },
2146
2322
  availableIn: "relay"
2147
2323
  },
@@ -2219,7 +2395,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2219
2395
  },
2220
2396
  {
2221
2397
  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\", [])",
2398
+ 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
2399
  inputSchema: {
2224
2400
  type: "object",
2225
2401
  properties: {
@@ -2273,23 +2449,23 @@ const DEBUG_TOOL_DEFINITIONS = [
2273
2449
  },
2274
2450
  {
2275
2451
  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.",
2452
+ description: "Switches the active debug environment in-place (issue #348) — no Claude Code restart and no MCP re-handshake. One daemon holds both a local (env 1, mock SDK in a Chromium) and a relay (env 3/4, real-device Toss WebView over the Chii relay + cloudflared tunnel) connection at once; this tool flips which one every other tool reads from, lazily booting the requested family's infra on first use and keeping the inactive one warm so an existing attach survives the switch. After switching it emits notifications/tools/list_changed — call tools/list again to see the updated tool surface for the new environment.\n\nmodes:\n local — env 1: desktop Chromium with the MOCK SDK and a local CDP attach. Side-effect tools (call_sdk/evaluate) run unguarded against the mock; nothing touches a real device or real users. No prerequisites — the default, always-available environment for state/contract and visual-layout work.\n mobile env 2: a real-device PWA (real WebKit engine, MOCK SDK) over an external Chii relay that the unplugin already started with tunnel:{cdp:true}. CDP covers real-device WebKit DOM, console, exceptions, and safe-area observation; call_sdk still hits the mock (SDK fidelity needs staging). liveIntent off — dev-intent, LIVE guard inactive, side-effect tools run unguarded against the mock. Prerequisites: AIT_RELAY_BASE_URL env var set + unplugin running with tunnel:{cdp:true} so the relay tunnel is already up.\n staging env 3: a real-device Toss WebView dogfood build with the REAL SDK over the intoss-private relay. The first environment where call_sdk exercises the genuine native bridge. Side-effect tools run unguarded (dogfood, not released to real users). Prerequisite: a deployed dogfood candidate bundle + the device cold-loaded via the intoss-private deep-link/QR relay injection.\n live — env 4: the REVIEW-PASSED, released production runtime with the REAL SDK over the intoss relay real end users are on the other side. Read-only debugging is the intent: the LIVE guard is armed, so call_sdk/evaluate require confirm:true per call, and ENTERING live ALSO requires confirm:true on this call. Use it only to observe a shipped regression; verify fixes in staging first.\n\nSwitching back to local automatically disarms the LIVE guard.",
2277
2453
  inputSchema: {
2278
2454
  type: "object",
2279
2455
  properties: {
2280
2456
  mode: {
2281
2457
  type: "string",
2282
2458
  enum: [
2283
- "local-browser-dev",
2284
- "local-browser-cdp",
2285
- "relay-dev",
2286
- "relay-live"
2459
+ "local",
2460
+ "mobile",
2461
+ "staging",
2462
+ "live"
2287
2463
  ],
2288
- description: "Target environment to switch to. relay-live additionally requires confirm: true."
2464
+ description: "Target environment to switch to. mode=live additionally requires confirm: true (and arms the read-only LIVE guard)."
2289
2465
  },
2290
2466
  confirm: {
2291
2467
  type: "boolean",
2292
- 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."
2468
+ description: "Required when mode=live — set true to acknowledge entering LIVE (env 4) debugging that can affect real users. Ignored for the other modes."
2293
2469
  }
2294
2470
  },
2295
2471
  required: ["mode"]
@@ -2298,7 +2474,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2298
2474
  },
2299
2475
  {
2300
2476
  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.",
2477
+ 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
2478
  inputSchema: {
2303
2479
  type: "object",
2304
2480
  properties: { recent_errors_limit: {
@@ -2328,8 +2504,8 @@ function getToolAvailability(name) {
2328
2504
  * Unknown tools return `false` — callers should reject them as unknown rather
2329
2505
  * than as env-mismatched.
2330
2506
  *
2331
- * Relay variants (`relay-dev`, `relay-live`) both satisfy the `'relay'`
2332
- * availability tier — `isRelayEnv()` is used for the check.
2507
+ * Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
2508
+ * `'relay'` availability tier — `isRelayEnv()` is used for the check.
2333
2509
  */
2334
2510
  function isToolAvailableIn(name, env) {
2335
2511
  const availability = getToolAvailability(name);
@@ -2343,7 +2519,8 @@ function isToolAvailableIn(name, env) {
2343
2519
  * matches the given env. Pure — preserves order; both Tier C ("both") and the
2344
2520
  * matching single-env tier pass through.
2345
2521
  *
2346
- * Relay variants (`relay-dev`, `relay-live`) both satisfy the `'relay'` tier.
2522
+ * Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
2523
+ * `'relay'` tier.
2347
2524
  */
2348
2525
  function filterToolsByEnvironment(tools, env) {
2349
2526
  return tools.filter((t) => t.availableIn === "both" || t.availableIn === "relay" && isRelayEnv(env) || t.availableIn === env);
@@ -2962,8 +3139,8 @@ function findRecentException(connection, windowStart, windowEnd) {
2962
3139
  * Calls a dogfood SDK method via `window.__sdkCall` on the attached page.
2963
3140
  * NOT read-only — SDK calls may have side effects.
2964
3141
  *
2965
- * On env 2/3 (real device relay) this hits the real SDK; on env 1 (local
2966
- * mock) it hits the mock SDK.
3142
+ * On env 3/4 (toss WebView relay) this hits the real SDK. On env 1 (local
3143
+ * mock) and env 2 (PWA relay — real WebKit, mock SDK) it hits the mock SDK.
2967
3144
  *
2968
3145
  * 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면
2969
3146
  * `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).
@@ -3117,7 +3294,7 @@ async function readMcpSdkVersion() {
3117
3294
  * some test environments that skip the build step).
3118
3295
  */
3119
3296
  function readDevtoolsVersion() {
3120
- return "0.1.54";
3297
+ return "0.1.56";
3121
3298
  }
3122
3299
  /**
3123
3300
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -3534,9 +3711,14 @@ function extractDeploymentId(schemeUrl) {
3534
3711
  return null;
3535
3712
  }
3536
3713
  }
3537
- /** Returns `true` when the mode routes to a relay connection. */
3714
+ /**
3715
+ * Returns `true` when the mode routes to a relay connection (`mobile`,
3716
+ * `staging`, or `live`). `mobile` is an external-PWA relay; `staging`/`live`
3717
+ * are intoss-private relays — but all three surface the Tier B / relay-only
3718
+ * tool set.
3719
+ */
3538
3720
  function isRelayMode(mode) {
3539
- return mode === "relay-dev" || mode === "relay-live";
3721
+ return mode === "mobile" || mode === "staging" || mode === "live";
3540
3722
  }
3541
3723
  /**
3542
3724
  * Waits for the first target matching `filterFn` to attach, using the
@@ -3594,12 +3776,12 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
3594
3776
  function createDebugServer(deps) {
3595
3777
  const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret } = deps;
3596
3778
  const router = routerDep ?? makeSingleConnectionRouter(connection);
3597
- const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent()));
3779
+ const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin));
3598
3780
  const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},liveIntent=${getLiveIntent()}`);
3599
3781
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
3600
3782
  const server = new Server({
3601
3783
  name: "ait-debug",
3602
- version: "0.1.54"
3784
+ version: "0.1.56"
3603
3785
  }, { capabilities: { tools: { listChanged: true } } });
3604
3786
  server.setRequestHandler(ListToolsRequestSchema, () => {
3605
3787
  const conn = router.active;
@@ -3621,7 +3803,7 @@ function createDebugServer(deps) {
3621
3803
  if (name === "start_debug") {
3622
3804
  const rawMode = request.params.arguments?.mode;
3623
3805
  const mode = normalizeStartDebugMode(rawMode);
3624
- if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser-dev' | 'local-browser-cdp' | 'relay-dev' | 'relay-live' 중 하나를 전달하세요.");
3806
+ if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local' | 'mobile' | 'staging' | 'live' 중 하나를 전달하세요 (deprecated 별칭 'local-browser-dev'/'local-browser-cdp'/'relay-mobile'/'relay-dev'/'relay-live' 수용).");
3625
3807
  const confirm = request.params.arguments?.confirm === true;
3626
3808
  try {
3627
3809
  return jsonResult$1(await router.switchMode(mode, confirm));
@@ -3669,10 +3851,177 @@ function createDebugServer(deps) {
3669
3851
  return errorResult(err, name);
3670
3852
  }
3671
3853
  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
3854
  const waitForAttach = request.params.arguments?.wait_for_attach === true;
3675
3855
  const openInBrowser = request.params.arguments?.open_in_browser !== false;
3856
+ if (env === "relay-mobile") {
3857
+ const tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
3858
+ if (tunnelHttpUrl === "") return mcpError("build_attach_url(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. unplugin tunnel:{cdp:true} 배너에 출력되는 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 전달하세요.");
3859
+ const tunnelStatus = getTunnelStatus();
3860
+ if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return mcpError("build_attach_url(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.");
3861
+ let totpCode;
3862
+ let totpMeta;
3863
+ if (totpSecret !== void 0 && totpSecret !== "") {
3864
+ const now = Date.now();
3865
+ totpCode = generateTotp(totpSecret, now);
3866
+ const STEP_SECONDS = 30;
3867
+ const currentStep = Math.floor(now / 1e3 / STEP_SECONDS);
3868
+ totpMeta = {
3869
+ enabled: true,
3870
+ ttlSeconds: STEP_SECONDS,
3871
+ expiresAt: (/* @__PURE__ */ new Date((currentStep + 1) * STEP_SECONDS * 1e3)).toISOString()
3872
+ };
3873
+ }
3874
+ const attachUrl = buildLauncherAttachUrl(tunnelHttpUrl, tunnelStatus.wssUrl, totpCode);
3875
+ const relayUrl = tunnelStatus.wssUrl;
3876
+ const totp = totpMeta;
3877
+ const isMatchingPage = (pages) => pages.length > 0;
3878
+ const buildTimeoutError = (baseText, timeoutSec, observed) => {
3879
+ const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
3880
+ return `${baseText}\n\nNo page attached within ${timeoutSec}s${observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : ""} — launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.`;
3881
+ };
3882
+ return await (async () => {
3883
+ 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).";
3884
+ const warningPrefix = "";
3885
+ const guiAvailable = canOpenBrowser();
3886
+ if (openInBrowser && !guiAvailable) {
3887
+ const headlessNote = "[open_in_browser] GUI 환경이 감지되지 않았습니다 (headless/remote 환경). open_in_browser=false로 자동 폴백합니다. 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
3888
+ const qrHeadless = await renderQr(attachUrl);
3889
+ const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
3890
+ attachUrl,
3891
+ relayUrl,
3892
+ ...totp ? { totp } : {}
3893
+ }, null, 2)}\n\n${qrHeadless}`;
3894
+ if (!waitForAttach) return { content: [{
3895
+ type: "text",
3896
+ text: headlessText
3897
+ }] };
3898
+ let attachedPagesHl = [];
3899
+ try {
3900
+ attachedPagesHl = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
3901
+ } catch {
3902
+ attachedPagesHl = conn.listTargets();
3903
+ return {
3904
+ content: [{
3905
+ type: "text",
3906
+ text: buildTimeoutError(headlessText, waitForAttachTimeoutMs / 1e3, attachedPagesHl)
3907
+ }],
3908
+ isError: true
3909
+ };
3910
+ }
3911
+ const pagesResultHl = listPages(conn, getTunnelStatus());
3912
+ return { content: [{
3913
+ type: "text",
3914
+ text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
3915
+ }] };
3916
+ }
3917
+ if (openInBrowser && guiAvailable && qrHttpServer) {
3918
+ const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
3919
+ if (browserResult.opened) {
3920
+ const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
3921
+ const openResult = {
3922
+ attempted: true,
3923
+ succeeded: true,
3924
+ ...browserResult.retried ? { retried: true } : {}
3925
+ };
3926
+ const shortText = `${warningPrefix}${header}\n${JSON.stringify({
3927
+ relayUrl,
3928
+ openResult,
3929
+ ...totp ? { totp } : {}
3930
+ }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
3931
+ if (!waitForAttach) return { content: [{
3932
+ type: "text",
3933
+ text: shortText
3934
+ }] };
3935
+ let attachedPages = [];
3936
+ try {
3937
+ attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
3938
+ } catch {
3939
+ attachedPages = conn.listTargets();
3940
+ return {
3941
+ content: [{
3942
+ type: "text",
3943
+ text: buildTimeoutError(shortText, waitForAttachTimeoutMs / 1e3, attachedPages)
3944
+ }],
3945
+ isError: true
3946
+ };
3947
+ }
3948
+ const pagesResult = listPages(conn, getTunnelStatus());
3949
+ return { content: [{
3950
+ type: "text",
3951
+ text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
3952
+ }] };
3953
+ }
3954
+ const openResult = {
3955
+ attempted: true,
3956
+ succeeded: false,
3957
+ failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
3958
+ pngUrl: browserResult.pngUrl,
3959
+ ...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
3960
+ };
3961
+ const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
3962
+ const fallbackNote = `[open_in_browser] 브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:\n${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
3963
+ const qr = await renderQr(attachUrl);
3964
+ const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
3965
+ attachUrl,
3966
+ relayUrl,
3967
+ openResult,
3968
+ ...totp ? { totp } : {}
3969
+ }, null, 2)}\n\n${qr}`;
3970
+ if (!waitForAttach) return { content: [{
3971
+ type: "text",
3972
+ text: baseText
3973
+ }] };
3974
+ let attachedPagesFb = [];
3975
+ try {
3976
+ attachedPagesFb = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
3977
+ } catch {
3978
+ attachedPagesFb = conn.listTargets();
3979
+ return {
3980
+ content: [{
3981
+ type: "text",
3982
+ text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPagesFb)
3983
+ }],
3984
+ isError: true
3985
+ };
3986
+ }
3987
+ const pagesResultFb = listPages(conn, getTunnelStatus());
3988
+ return { content: [{
3989
+ type: "text",
3990
+ text: `${baseText}\n\n${JSON.stringify(pagesResultFb, null, 2)}`
3991
+ }] };
3992
+ }
3993
+ const qr = await renderQr(attachUrl);
3994
+ const baseText = `${warningPrefix}${header}\n${JSON.stringify({
3995
+ attachUrl,
3996
+ relayUrl,
3997
+ ...totp ? { totp } : {}
3998
+ }, null, 2)}\n\n${qr}`;
3999
+ if (!waitForAttach) return { content: [{
4000
+ type: "text",
4001
+ text: baseText
4002
+ }] };
4003
+ let attachedPages = [];
4004
+ try {
4005
+ attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
4006
+ } catch {
4007
+ attachedPages = conn.listTargets();
4008
+ return {
4009
+ content: [{
4010
+ type: "text",
4011
+ text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPages)
4012
+ }],
4013
+ isError: true
4014
+ };
4015
+ }
4016
+ const pagesResult = listPages(conn, getTunnelStatus());
4017
+ return { content: [{
4018
+ type: "text",
4019
+ text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
4020
+ }] };
4021
+ })();
4022
+ }
4023
+ const schemeUrl = request.params.arguments?.scheme_url;
4024
+ 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
4025
  const deploymentId = extractDeploymentId(schemeUrl);
3677
4026
  if (!deploymentId) logInfo("tool.call", {
3678
4027
  tool: "build_attach_url",
@@ -3883,26 +4232,39 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3883
4232
  const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
3884
4233
  if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
3885
4234
  const sdkResult = await callSdk(conn, sdkName, sdkArgs);
3886
- if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
4235
+ if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk", conn.kind === "local");
3887
4236
  return envelopeResult$1(sdkResult, name, env, conn.listTargets().length > 0);
3888
4237
  }
3889
4238
  default: return unknownTool(name);
3890
4239
  }
3891
4240
  } catch (err) {
3892
- return errorResult(err, name);
4241
+ return errorResult(err, name, conn.kind === "local");
3893
4242
  }
3894
4243
  });
3895
4244
  return server;
3896
4245
  }
3897
4246
  /**
3898
4247
  * Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or
3899
- * `null` when the value is not one of the four accepted modes.
4248
+ * `null` when the value is not one of the accepted modes.
4249
+ *
4250
+ * Accepts the 4 canonical modes + 5 deprecated aliases (back-compat for
4251
+ * pinned .mcp.json / docs / QA runbooks that still emit old strings):
4252
+ * 'local' → 'local' (canonical)
4253
+ * 'mobile' → 'mobile' (canonical)
4254
+ * 'staging' → 'staging' (canonical)
4255
+ * 'live' → 'live' (canonical)
4256
+ * 'local-browser-dev' → 'local' (deprecated alias)
4257
+ * 'local-browser-cdp' → 'local' (deprecated alias)
4258
+ * 'relay-mobile' → 'mobile' (deprecated alias)
4259
+ * 'relay-dev' → 'staging' (deprecated alias)
4260
+ * 'relay-live' → 'live' (deprecated alias)
3900
4261
  */
3901
4262
  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";
4263
+ if (raw === "local" || raw === "mobile" || raw === "staging" || raw === "live") return raw;
4264
+ if (raw === "local-browser-dev" || raw === "local-browser-cdp") return "local";
4265
+ if (raw === "relay-mobile") return "mobile";
4266
+ if (raw === "relay-dev") return "staging";
4267
+ if (raw === "relay-live") return "live";
3906
4268
  return null;
3907
4269
  }
3908
4270
  /**
@@ -3919,10 +4281,12 @@ function makeSingleConnectionRouter(connection) {
3919
4281
  get active() {
3920
4282
  return connection;
3921
4283
  },
4284
+ activeRelayOrigin: void 0,
3922
4285
  switchMode(mode, confirm) {
4286
+ if (mode === "mobile") return Promise.reject(/* @__PURE__ */ new Error("start_debug: 이 세션은 단일 연결만 보유합니다 — 'mobile'(환경 2 PWA, 외부 relay)로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 mobile 모드로 재시작하세요."));
3923
4287
  if (isRelayMode(mode) !== (connection.kind === "relay")) return Promise.reject(/* @__PURE__ */ new Error(`start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — '${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 원하는 모드로 재시작하세요.`));
3924
- if (mode === "relay-live" && !confirm) return Promise.reject(/* @__PURE__ */ new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요."));
3925
- setLiveIntent(mode === "relay-live");
4288
+ if (mode === "live" && !confirm) return Promise.reject(/* @__PURE__ */ new Error("start_debug: live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요."));
4289
+ setLiveIntent(mode === "live");
3926
4290
  const environment = deriveEnvironment(connection.kind, getLiveIntent());
3927
4291
  return Promise.resolve({
3928
4292
  mode,
@@ -3978,8 +4342,8 @@ function classifyEnableDomainError(err, toolName) {
3978
4342
  * CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
3979
4343
  * debug-server 내부 try/catch 블록에서 공통으로 사용한다.
3980
4344
  */
3981
- function errorResult(err, name) {
3982
- return classifyToolError(err, name);
4345
+ function errorResult(err, name, isLocal = false) {
4346
+ return classifyToolError(err, name, isLocal);
3983
4347
  }
3984
4348
  /**
3985
4349
  * Starts a polling watcher that detects the first 0→N target transition on
@@ -4059,29 +4423,6 @@ function startParentWatcher(onOrphaned, opts) {
4059
4423
  } };
4060
4424
  }
4061
4425
  /**
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
4426
  * Factory that constructs a `ChiiCdpConnection` for the given relay base URL.
4086
4427
  *
4087
4428
  * Introduced as a named seam so PR-2 (dual-connection, #348) can defer
@@ -4156,6 +4497,7 @@ async function bootLocalFamily() {
4156
4497
  * (relay host) is never logged here directly.
4157
4498
  */
4158
4499
  async function bootRelayFamily(options = {}) {
4500
+ assertRelayAuthConfigured();
4159
4501
  const relayPort = options.relayPort ?? 0;
4160
4502
  const totpEnabled = options.verifyAuth !== void 0;
4161
4503
  const relay = await startChiiRelay({
@@ -4205,6 +4547,7 @@ async function bootRelayFamily(options = {}) {
4205
4547
  const connection = createRelayConnection(relay.baseUrl);
4206
4548
  return {
4207
4549
  connection,
4550
+ relayOrigin: "intoss-webview",
4208
4551
  getTunnelStatus: () => tunnelStatus,
4209
4552
  stop() {
4210
4553
  tunnelProbe?.stop();
@@ -4215,21 +4558,91 @@ async function bootRelayFamily(options = {}) {
4215
4558
  };
4216
4559
  }
4217
4560
  /**
4218
- * Production `ConnectionRouter` (issues #348, #356 — DUAL-CONNECTION-COEXIST).
4561
+ * Boots the EXTERNAL relay family for env 2 (real-device PWA, issue #378).
4562
+ *
4563
+ * Unlike {@link bootRelayFamily}, this does NOT start a relay or a tunnel —
4564
+ * the unplugin (`tunnel: { cdp: true }`) already brought up a Chii relay for
4565
+ * the env-2 PWA and exposed its public base URL via `AIT_RELAY_BASE_URL`. Here
4566
+ * the MCP only opens a CDP client (`createRelayConnection`) against that
4567
+ * external relay. The relay's lifecycle is owned by the unplugin, so `stop()`
4568
+ * closes ONLY the CDP client — it must never tear down the relay or a tunnel
4569
+ * we did not start.
4570
+ *
4571
+ * `getTunnelStatus()` reports `up: true` with a `wssUrl` derived from
4572
+ * `relayBaseUrl` (http→ws, https→wss) so the `build_attach_url` gate
4573
+ * (`up: true && wssUrl !== null`) is satisfied even though we never opened a
4574
+ * cloudflared tunnel ourselves.
4575
+ *
4576
+ * SECRET-HANDLING: `relayBaseUrl` carries the relay host (same sensitivity as a
4577
+ * wss URL) — it is NEVER logged here. The caller validates presence and passes
4578
+ * the value straight to the CDP client.
4579
+ */
4580
+ async function bootExternalRelayFamily(relayBaseUrl) {
4581
+ assertRelayAuthConfigured();
4582
+ const connection = createRelayConnection(relayBaseUrl);
4583
+ const tunnelStatus = makeTunnelStatus(true, relayBaseUrl.replace(/^http/, "ws"));
4584
+ return {
4585
+ connection,
4586
+ relayOrigin: "external-pwa",
4587
+ getTunnelStatus: () => tunnelStatus,
4588
+ stop() {
4589
+ connection.close();
4590
+ }
4591
+ };
4592
+ }
4593
+ /**
4594
+ * Maps a `StartDebugMode` to the {@link FamilyKey} that serves it (issue #378).
4595
+ * local → 'local'; mobile → 'relay-external'; staging/live → 'relay-intoss'.
4596
+ */
4597
+ function familyKeyForMode(mode) {
4598
+ switch (mode) {
4599
+ case "local": return "local";
4600
+ case "mobile": return "relay-external";
4601
+ case "staging":
4602
+ case "live": return "relay-intoss";
4603
+ }
4604
+ }
4605
+ /** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */
4606
+ 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가 필요합니다.";
4607
+ /**
4608
+ * Reads `AIT_RELAY_BASE_URL` from the environment for the env-2 (`mobile`) boot
4609
+ * site (issue #378). Returns the trimmed value, or throws the precise
4610
+ * {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} when unset/empty.
4611
+ *
4612
+ * SECRET-HANDLING: `AIT_RELAY_BASE_URL` carries the relay host (same class as a
4613
+ * wss URL). On the missing path the thrown message describes the env var name
4614
+ * and how to obtain it — it NEVER echoes any partial/garbled URL value. The
4615
+ * present value is returned to the caller (the CDP client) but never logged.
4616
+ */
4617
+ function readMobileRelayBaseUrl(env = process.env) {
4618
+ const raw = env.AIT_RELAY_BASE_URL;
4619
+ const value = typeof raw === "string" ? raw.trim() : "";
4620
+ if (value === "") throw new Error(MOBILE_RELAY_BASE_URL_MISSING_MESSAGE);
4621
+ return value;
4622
+ }
4623
+ /**
4624
+ * Production `ConnectionRouter` (issues #348, #356, #378 — DUAL-CONNECTION-COEXIST).
4625
+ *
4626
+ * Holds one eagerly-booted family plus a keyed set of lazily-booted families
4627
+ * ({@link FamilyKey} → `BootedFamily`, issue #378), an `active` pointer, and the
4628
+ * single attach watcher armed on the active connection. The router is
4629
+ * **direction-neutral** (#356): any family can be the eager one, so a
4630
+ * `--target=local` session can hot-switch into relay (and vice versa) without
4631
+ * restarting the MCP server.
4219
4632
  *
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.
4633
+ * Why a KEYED map and not a single lazy slot (#378): `mobile` (env-2 external
4634
+ * relay) and `staging`/`live` (intoss relay) are BOTH `kind: 'relay'`. A single
4635
+ * "opposite-kind" slot could not warm-keep both at once they would collide.
4636
+ * The three `FamilyKey`s (`local` / `relay-intoss` / `relay-external`) give each
4637
+ * its own warm slot.
4225
4638
  *
4226
4639
  * `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;
4640
+ * 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed `live`;
4641
+ * 2. resolves the requested mode's `FamilyKey`: equals `eagerKey` → reuse
4642
+ * eager; else `lazyFamilies.get(key) ?? (boot via bootLazyFor(key), store)`;
4230
4643
  * 3. flips `active` (the MCP `Server` never re-handshakes — it reads through
4231
4644
  * `active` per request);
4232
- * 4. sets `liveIntent` (true only for relay-live);
4645
+ * 4. sets `liveIntent` (true only for `live`; `mobile` is dev-intent → false);
4233
4646
  * 5. stops the old attach watcher and re-arms one on the new connection
4234
4647
  * (the watcher self-clears, so re-arm is mandatory);
4235
4648
  * 6. emits `tools/list_changed`.
@@ -4240,8 +4653,8 @@ async function bootRelayFamily(options = {}) {
4240
4653
  */
4241
4654
  var DualConnectionRouter = class {
4242
4655
  deps;
4243
- /** The opposite-kind family, booted lazily on the first cross-family switch. */
4244
- lazyFamily = null;
4656
+ /** Non-eager families, booted lazily and warm-kept per {@link FamilyKey} (#378). */
4657
+ lazyFamilies = /* @__PURE__ */ new Map();
4245
4658
  activeFamily;
4246
4659
  server = null;
4247
4660
  attachWatcher = null;
@@ -4253,17 +4666,24 @@ var DualConnectionRouter = class {
4253
4666
  get active() {
4254
4667
  return this.activeFamily.connection;
4255
4668
  }
4669
+ /** Relay origin of the currently-active family (issue #378). */
4670
+ get activeRelayOrigin() {
4671
+ return this.activeFamily.relayOrigin;
4672
+ }
4256
4673
  /** Every booted family (for unified shutdown). */
4257
4674
  bootedFamilies() {
4258
- return this.lazyFamily ? [this.deps.eager, this.lazyFamily] : [this.deps.eager];
4675
+ return [this.deps.eager, ...this.lazyFamilies.values()];
4259
4676
  }
4260
4677
  /**
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).
4678
+ * Live tunnel status of the active relay family (issues #356, #378). Reads
4679
+ * the ACTIVE family's tunnel when it has one (so `mobile` surfaces the
4680
+ * external relay wss and `staging`/`live` the intoss relay wss); otherwise
4681
+ * falls back to the first booted family that has a tunnel. Returns "down"
4682
+ * until any relay family is booted (local-eager sessions before the first
4683
+ * relay switch) — the correct signal for `build_attach_url` (no tunnel yet).
4265
4684
  */
4266
4685
  relayTunnelStatus() {
4686
+ if (this.activeFamily.getTunnelStatus) return this.activeFamily.getTunnelStatus();
4267
4687
  for (const family of this.bootedFamilies()) if (family.getTunnelStatus) return family.getTunnelStatus();
4268
4688
  return {
4269
4689
  up: false,
@@ -4289,30 +4709,37 @@ var DualConnectionRouter = class {
4289
4709
  if (!server) return;
4290
4710
  this.attachWatcher = startAttachWatcher(this.activeFamily.connection, server, this.deps.attachWatcherIntervalMs ?? 1e3, () => {
4291
4711
  this.deps.diagnosticsCollector.recordAttach();
4292
- if (this.activeFamily.connection.kind === "relay") this.deps.devtoolsOpener.open(this.relayTunnelStatus().wssUrl, deriveEnvironment(this.activeFamily.connection.kind, getLiveIntent()));
4712
+ if (this.activeFamily.connection.kind === "relay") this.deps.devtoolsOpener.open(this.relayTunnelStatus().wssUrl, deriveEnvironment(this.activeFamily.connection.kind, getLiveIntent(), this.activeFamily.relayOrigin));
4293
4713
  });
4294
4714
  }
4715
+ /**
4716
+ * Resolves the `BootedFamily` for `key`: the eager family when `key` matches
4717
+ * `eagerKey`, otherwise the warm lazy family (booting + storing it once on
4718
+ * first use). Only ever asks `bootLazyFor` for non-eager keys.
4719
+ */
4720
+ async familyFor(key) {
4721
+ if (key === this.deps.eagerKey) return this.deps.eager;
4722
+ const warm = this.lazyFamilies.get(key);
4723
+ if (warm) return warm;
4724
+ const booted = await this.deps.bootLazyFor(key);
4725
+ this.lazyFamilies.set(key, booted);
4726
+ return booted;
4727
+ }
4295
4728
  async switchMode(mode, confirm) {
4296
4729
  if (this.swapInFlight) throw new Error("start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.");
4297
- if (mode === "relay-live" && !confirm) throw new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요.");
4730
+ if (mode === "live" && !confirm) throw new Error("start_debug: live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요.");
4298
4731
  this.swapInFlight = true;
4299
4732
  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
- }
4733
+ const target = await this.familyFor(familyKeyForMode(mode));
4308
4734
  this.activeFamily = target;
4309
- setLiveIntent(mode === "relay-live");
4735
+ setLiveIntent(mode === "live");
4310
4736
  this.stopWatcher();
4311
4737
  this.armWatcher();
4312
4738
  this.server?.sendToolListChanged();
4739
+ const wantRelay = isRelayMode(mode);
4313
4740
  return {
4314
4741
  mode,
4315
- environment: deriveEnvironment(target.connection.kind, getLiveIntent()),
4742
+ environment: deriveEnvironment(target.connection.kind, getLiveIntent(), target.relayOrigin),
4316
4743
  kind: target.connection.kind,
4317
4744
  liveGuardActive: target.connection.kind === "relay" && getLiveIntent(),
4318
4745
  nextStep: wantRelay ? "build_attach_url로 attach QR을 생성하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
@@ -4342,7 +4769,8 @@ async function runDebugServer(options = {}) {
4342
4769
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4343
4770
  const router = new DualConnectionRouter({
4344
4771
  eager: relayFamily,
4345
- bootLazy: bootLocalFamily,
4772
+ eagerKey: "relay-intoss",
4773
+ bootLazyFor: (key) => key === "relay-external" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : bootLocalFamily(),
4346
4774
  diagnosticsCollector,
4347
4775
  devtoolsOpener
4348
4776
  });
@@ -4471,7 +4899,8 @@ async function runLocalDebugServer(options = {}) {
4471
4899
  const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4472
4900
  const router = new DualConnectionRouter({
4473
4901
  eager: localFamily,
4474
- bootLazy: () => bootRelayFamily({
4902
+ eagerKey: "local",
4903
+ bootLazyFor: (key) => key === "relay-external" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : bootRelayFamily({
4475
4904
  verifyAuth,
4476
4905
  onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
4477
4906
  }),
@@ -4558,6 +4987,126 @@ async function runLocalDebugServer(options = {}) {
4558
4987
  });
4559
4988
  }
4560
4989
  }
4990
+ /**
4991
+ * Boots the env-2 (real-device PWA) debug stack and serves it over stdio
4992
+ * (issue #378). The external Chii relay is the EAGER family here.
4993
+ *
4994
+ * Unlike `runDebugServer` (which starts its own relay + cloudflared tunnel),
4995
+ * `runMobileDebugServer` attaches to a relay the unplugin ALREADY brought up
4996
+ * (`tunnel: { cdp: true }`) and exposed via `AIT_RELAY_BASE_URL`. The MCP only
4997
+ * opens a CDP client against that external relay — it never starts or tears down
4998
+ * a relay or a tunnel it did not own (see {@link bootExternalRelayFamily}).
4999
+ *
5000
+ * Symmetry with `runDebugServer` / `runLocalDebugServer` (#356, #378): the env-2
5001
+ * external relay is eager; the local family and the intoss relay family are
5002
+ * lazy-booted on the first `start_debug({ mode: 'local' | 'staging' | 'live' })`,
5003
+ * so a `--target=mobile` session can hot-switch without a restart. The active
5004
+ * env derives to `relay-mobile` (external-PWA origin, liveIntent off).
5005
+ *
5006
+ * SECRET-HANDLING: `AIT_RELAY_BASE_URL` is read once here via
5007
+ * {@link readMobileRelayBaseUrl}; when unset it throws
5008
+ * {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} — a message that names the env
5009
+ * var and how to obtain it, never echoing any URL value. The error propagates to
5010
+ * the bin entry's fatal handler (the missing-URL path prints the guidance, not a
5011
+ * value). The present value is passed straight to the CDP client, never logged.
5012
+ */
5013
+ async function runMobileDebugServer(options = {}) {
5014
+ const relayBaseUrl = readMobileRelayBaseUrl();
5015
+ const lockHandle = acquireLock({ force: options.force ?? false });
5016
+ const externalRelayFamily = await bootExternalRelayFamily(relayBaseUrl);
5017
+ const verifyAuth = buildRelayVerifyAuth();
5018
+ const devtoolsOpener = new AutoDevtoolsOpener();
5019
+ const diagnosticsCollector = new InMemoryDiagnosticsCollector();
5020
+ const router = new DualConnectionRouter({
5021
+ eager: externalRelayFamily,
5022
+ eagerKey: "relay-external",
5023
+ bootLazyFor: (key) => key === "local" ? bootLocalFamily() : bootRelayFamily({
5024
+ verifyAuth,
5025
+ onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
5026
+ }),
5027
+ diagnosticsCollector,
5028
+ devtoolsOpener
5029
+ });
5030
+ const aitSource = new RoutingAitSource(() => {
5031
+ return router.active;
5032
+ });
5033
+ let qrServer;
5034
+ try {
5035
+ qrServer = await startQrHttpServer();
5036
+ } catch (err) {
5037
+ logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
5038
+ }
5039
+ const server = createDebugServer({
5040
+ connection: router.active,
5041
+ router,
5042
+ aitSource,
5043
+ getTunnelStatus: () => router.relayTunnelStatus(),
5044
+ get qrHttpServer() {
5045
+ return qrServer;
5046
+ },
5047
+ diagnosticsCollector,
5048
+ ...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
5049
+ });
5050
+ const transport = new StdioServerTransport();
5051
+ let closed = false;
5052
+ let parentWatcher = null;
5053
+ const shutdown = () => {
5054
+ if (closed) return;
5055
+ closed = true;
5056
+ parentWatcher?.stop();
5057
+ router.stopWatcher();
5058
+ for (const family of router.bootedFamilies()) family.stop();
5059
+ server.close();
5060
+ qrServer?.close();
5061
+ lockHandle.release();
5062
+ };
5063
+ process.once("SIGINT", shutdown);
5064
+ process.once("SIGTERM", shutdown);
5065
+ process.once("SIGHUP", shutdown);
5066
+ process.on("exit", () => {
5067
+ if (!closed) {
5068
+ closed = true;
5069
+ parentWatcher?.stop();
5070
+ router.stopWatcher();
5071
+ for (const family of router.bootedFamilies()) family.stop();
5072
+ lockHandle.release();
5073
+ }
5074
+ });
5075
+ process.on("uncaughtException", (err) => {
5076
+ logError("tool.error", {
5077
+ msg: `uncaughtException: ${String(err)}`,
5078
+ errorKind: "uncaught",
5079
+ mode: "mobile"
5080
+ });
5081
+ shutdown();
5082
+ process.exit(1);
5083
+ });
5084
+ process.on("unhandledRejection", (reason) => {
5085
+ logError("tool.error", {
5086
+ msg: `unhandledRejection: ${String(reason)}`,
5087
+ errorKind: "unhandled-rejection",
5088
+ mode: "mobile"
5089
+ });
5090
+ shutdown();
5091
+ process.exit(1);
5092
+ });
5093
+ await server.connect(transport);
5094
+ router.start(server);
5095
+ if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
5096
+ parentWatcher = startParentWatcher(() => {
5097
+ shutdown();
5098
+ process.exit(0);
5099
+ }, { intervalMs: 5e3 });
5100
+ process.stdin.once("end", () => {
5101
+ shutdown();
5102
+ process.exit(0);
5103
+ });
5104
+ process.stdin.once("close", () => {
5105
+ shutdown();
5106
+ process.exit(0);
5107
+ });
5108
+ }
5109
+ }
4561
5110
  //#endregion
4562
5111
  //#region src/mcp/ait-http-source.ts
4563
5112
  function isObject(value) {
@@ -4986,7 +5535,7 @@ function createDevServer(deps = {}) {
4986
5535
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
4987
5536
  const server = new Server({
4988
5537
  name: "ait-devtools",
4989
- version: "0.1.54"
5538
+ version: "0.1.56"
4990
5539
  }, { capabilities: { tools: {} } });
4991
5540
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
4992
5541
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -5061,11 +5610,15 @@ async function runDevServer() {
5061
5610
  *
5062
5611
  * --mode=debug (default)
5063
5612
  * --target=relay (default) — CDP/Chii relay + cloudflared quick tunnel.
5064
- * Attach a running mini-app (real Toss WebView, env 2/3) and read its
5613
+ * Attach a running mini-app (real Toss WebView, env 3/4) and read its
5065
5614
  * console + network over CDP without a human watching a phone.
5066
5615
  * --target=local — CDP direct-attach to a local Chromium launched by the
5067
5616
  * MCP server (env 1). No relay or tunnel; the browser is launched
5068
5617
  * pointing at AIT_DEVTOOLS_URL (default http://localhost:5173).
5618
+ * --target=mobile — CDP attach to an EXTERNAL Chii relay the unplugin
5619
+ * already brought up for the env-2 real-device PWA (`tunnel: { cdp: true }`),
5620
+ * exposed via AIT_RELAY_BASE_URL. The MCP starts no relay or tunnel; it
5621
+ * only opens a CDP client against that external relay (issue #378).
5069
5622
  *
5070
5623
  * --mode=dev — dev mode — reads the live browser mock state from a running
5071
5624
  * Vite dev server (the devtools#130 `devtools_get_mock_state` surface).
@@ -5119,8 +5672,9 @@ function parseMode(argv) {
5119
5672
  * Parses `--target=<value>` / `--target <value>` from argv; default `relay`.
5120
5673
  *
5121
5674
  * Only meaningful when `--mode=debug`:
5122
- * - `relay` — phone/WebView attach over Chii relay + cloudflared tunnel (env 2/3).
5675
+ * - `relay` — phone/WebView attach over Chii relay + cloudflared tunnel (env 3/4).
5123
5676
  * - `local` — local Chromium CDP attach (env 1, no relay needed).
5677
+ * - `mobile` — CDP attach to an EXTERNAL relay (env 2 PWA, AIT_RELAY_BASE_URL).
5124
5678
  */
5125
5679
  function parseTarget(argv) {
5126
5680
  for (let i = 0; i < argv.length; i++) {
@@ -5129,7 +5683,7 @@ function parseTarget(argv) {
5129
5683
  if (arg.startsWith("--target=")) return normalizeTarget(arg.slice(9));
5130
5684
  if (arg === "--target") {
5131
5685
  const next = argv[i + 1];
5132
- if (next === void 0) throw new Error("--target requires a value: 'relay' (default) or 'local'.");
5686
+ if (next === void 0) throw new Error("--target requires a value: 'relay' (default), 'local', or 'mobile'.");
5133
5687
  return normalizeTarget(next);
5134
5688
  }
5135
5689
  }
@@ -5143,7 +5697,8 @@ function normalizeMode(value) {
5143
5697
  function normalizeTarget(value) {
5144
5698
  if (value === "relay") return "relay";
5145
5699
  if (value === "local") return "local";
5146
- throw new Error(`Unknown --target '${value}'. Expected 'relay' (default) or 'local'.`);
5700
+ if (value === "mobile") return "mobile";
5701
+ throw new Error(`Unknown --target '${value}'. Expected 'relay' (default), 'local', or 'mobile'.`);
5147
5702
  }
5148
5703
  async function main() {
5149
5704
  const args = process.argv.slice(2);
@@ -5153,6 +5708,7 @@ async function main() {
5153
5708
  const target = parseTarget(args);
5154
5709
  const force = parseForce(args);
5155
5710
  if (target === "local") await runLocalDebugServer({ force });
5711
+ else if (target === "mobile") await runMobileDebugServer({ force });
5156
5712
  else await runDebugServer({ force });
5157
5713
  }
5158
5714
  }