@ait-co/devtools 0.1.144 → 0.2.1

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 (182) hide show
  1. package/README.en.md +47 -218
  2. package/README.md +36 -246
  3. package/dist/in-app/auto.d.ts +1 -138
  4. package/dist/in-app/auto.js +28 -1102
  5. package/dist/in-app/auto.js.map +1 -1
  6. package/dist/in-app/index.d.ts +38 -547
  7. package/dist/in-app/index.d.ts.map +1 -1
  8. package/dist/in-app/index.js +62 -939
  9. package/dist/in-app/index.js.map +1 -1
  10. package/dist/mcp/cli.d.ts +1 -54
  11. package/dist/mcp/cli.js +33 -9720
  12. package/dist/mcp/cli.js.map +1 -1
  13. package/dist/mcp/server.d.ts +1 -88
  14. package/dist/mcp/server.js +36 -1076
  15. package/dist/mcp/server.js.map +1 -1
  16. package/dist/mock/index.d.ts +35 -21
  17. package/dist/mock/index.d.ts.map +1 -1
  18. package/dist/mock/index.js +81 -2
  19. package/dist/mock/index.js.map +1 -1
  20. package/dist/panel/index.js +80 -104
  21. package/dist/panel/index.js.map +1 -1
  22. package/dist/relay-url-store-CkVSQZMq.cjs +110 -0
  23. package/dist/relay-url-store-CkVSQZMq.cjs.map +1 -0
  24. package/dist/relay-url-store-dkII-DHD.js +109 -0
  25. package/dist/relay-url-store-dkII-DHD.js.map +1 -0
  26. package/dist/stubs/bin-devtools-mcp.js +58 -0
  27. package/dist/stubs/bin-devtools-mcp.js.map +1 -0
  28. package/dist/stubs/bin-devtools-test.d.ts +2 -0
  29. package/dist/stubs/bin-devtools-test.js +55 -0
  30. package/dist/stubs/bin-devtools-test.js.map +1 -0
  31. package/dist/test-runner/config.d.ts +1 -231
  32. package/dist/test-runner/config.js +41 -45
  33. package/dist/test-runner/config.js.map +1 -1
  34. package/dist/{tunnel-BGT9Curk.cjs → tunnel-BKZkOyQp.cjs} +1 -1
  35. package/dist/{tunnel-BGT9Curk.cjs.map → tunnel-BKZkOyQp.cjs.map} +1 -1
  36. package/dist/{tunnel-BOKmLzBO.js → tunnel-CqSCIrdU.js} +1 -1
  37. package/dist/{tunnel-BOKmLzBO.js.map → tunnel-CqSCIrdU.js.map} +1 -1
  38. package/dist/unplugin/index.cjs +9 -18
  39. package/dist/unplugin/index.cjs.map +1 -1
  40. package/dist/unplugin/index.d.cts +26 -5
  41. package/dist/unplugin/index.d.cts.map +1 -1
  42. package/dist/unplugin/index.d.ts +27 -6
  43. package/dist/unplugin/index.d.ts.map +1 -1
  44. package/dist/unplugin/index.js +10 -19
  45. package/dist/unplugin/index.js.map +1 -1
  46. package/package.json +10 -25
  47. package/dist/attach-orchestrator-0F0m_UqQ.js +0 -1845
  48. package/dist/attach-orchestrator-0F0m_UqQ.js.map +0 -1
  49. package/dist/attach-orchestrator-D65KxFy_.js +0 -1831
  50. package/dist/attach-orchestrator-D65KxFy_.js.map +0 -1
  51. package/dist/attach-orchestrator-DL3NQ9ca.js +0 -1846
  52. package/dist/attach-orchestrator-DL3NQ9ca.js.map +0 -1
  53. package/dist/bundle-C796JIwG.d.ts +0 -159
  54. package/dist/bundle-C796JIwG.d.ts.map +0 -1
  55. package/dist/capture-DsP525OZ.d.ts +0 -58
  56. package/dist/capture-DsP525OZ.d.ts.map +0 -1
  57. package/dist/cdp-connection-rP1WdnH5.d.ts +0 -287
  58. package/dist/cdp-connection-rP1WdnH5.d.ts.map +0 -1
  59. package/dist/cell-BaLvusOl.js +0 -68
  60. package/dist/cell-BaLvusOl.js.map +0 -1
  61. package/dist/cell-CBUS3-nT.js +0 -274
  62. package/dist/cell-CBUS3-nT.js.map +0 -1
  63. package/dist/cell-EBKKpAAT.js +0 -307
  64. package/dist/cell-EBKKpAAT.js.map +0 -1
  65. package/dist/chii-relay-B3ZhjGMi.js +0 -304
  66. package/dist/chii-relay-B3ZhjGMi.js.map +0 -1
  67. package/dist/chii-relay-CGMlePMd.cjs +0 -304
  68. package/dist/chii-relay-CGMlePMd.cjs.map +0 -1
  69. package/dist/debug-server-B3ABDrRI.js +0 -456
  70. package/dist/debug-server-B3ABDrRI.js.map +0 -1
  71. package/dist/debug-server-BWhwrVXa.js +0 -1158
  72. package/dist/debug-server-BWhwrVXa.js.map +0 -1
  73. package/dist/debug-server-CfQNxxGW.js +0 -600
  74. package/dist/debug-server-CfQNxxGW.js.map +0 -1
  75. package/dist/devtools-opener-3Drge_RJ.js +0 -75
  76. package/dist/devtools-opener-3Drge_RJ.js.map +0 -1
  77. package/dist/devtools-opener-CJpEsXXQ.js +0 -76
  78. package/dist/devtools-opener-CJpEsXXQ.js.map +0 -1
  79. package/dist/devtools-opener-CxtryS8c.js +0 -75
  80. package/dist/devtools-opener-CxtryS8c.js.map +0 -1
  81. package/dist/in-app/auto.d.ts.map +0 -1
  82. package/dist/mcp/cli.d.ts.map +0 -1
  83. package/dist/mcp/server.d.ts.map +0 -1
  84. package/dist/pool-DcaaOwUq.d.ts +0 -14761
  85. package/dist/pool-DcaaOwUq.d.ts.map +0 -1
  86. package/dist/qr-http-server-C_lqOrgc.js +0 -1644
  87. package/dist/qr-http-server-C_lqOrgc.js.map +0 -1
  88. package/dist/qr-http-server-CopuMbub.js +0 -1644
  89. package/dist/qr-http-server-CopuMbub.js.map +0 -1
  90. package/dist/qr-http-server-DrbIVDjO.js +0 -1645
  91. package/dist/qr-http-server-DrbIVDjO.js.map +0 -1
  92. package/dist/relay-factory-N9QobQxG.js +0 -206
  93. package/dist/relay-factory-N9QobQxG.js.map +0 -1
  94. package/dist/relay-secret-store-BR0YIkNv.cjs +0 -241
  95. package/dist/relay-secret-store-BR0YIkNv.cjs.map +0 -1
  96. package/dist/relay-secret-store-Bmyleu0A.js +0 -154
  97. package/dist/relay-secret-store-Bmyleu0A.js.map +0 -1
  98. package/dist/relay-secret-store-CQenfcSL.js +0 -154
  99. package/dist/relay-secret-store-CQenfcSL.js.map +0 -1
  100. package/dist/relay-secret-store-CYM8CBIF.js +0 -240
  101. package/dist/relay-secret-store-CYM8CBIF.js.map +0 -1
  102. package/dist/relay-secret-store-DKxs7zwq.js +0 -153
  103. package/dist/relay-secret-store-DKxs7zwq.js.map +0 -1
  104. package/dist/relay-secret-store-WJ8EGkIl.js +0 -153
  105. package/dist/relay-secret-store-WJ8EGkIl.js.map +0 -1
  106. package/dist/relay-url-store-BR2XodiO.js +0 -123
  107. package/dist/relay-url-store-BR2XodiO.js.map +0 -1
  108. package/dist/relay-url-store-C1as_m5G.cjs +0 -115
  109. package/dist/relay-url-store-C1as_m5G.cjs.map +0 -1
  110. package/dist/relay-url-store-CH63fVCm.js +0 -122
  111. package/dist/relay-url-store-CH63fVCm.js.map +0 -1
  112. package/dist/relay-url-store-CzFo_84F.js +0 -114
  113. package/dist/relay-url-store-CzFo_84F.js.map +0 -1
  114. package/dist/relay-url-store-DaY1QPes.js +0 -123
  115. package/dist/relay-url-store-DaY1QPes.js.map +0 -1
  116. package/dist/relay-url-store-xmUuTjXA.js +0 -122
  117. package/dist/relay-url-store-xmUuTjXA.js.map +0 -1
  118. package/dist/relay-worker-B5HKkGUY.js +0 -832
  119. package/dist/relay-worker-B5HKkGUY.js.map +0 -1
  120. package/dist/relay-worker-YdlpZQl9.d.ts +0 -214
  121. package/dist/relay-worker-YdlpZQl9.d.ts.map +0 -1
  122. package/dist/rolldown-runtime-DGkTqVfb.js +0 -15
  123. package/dist/rolldown-runtime-DUslC3ob.js +0 -14
  124. package/dist/runtime-kn9DxOeg.d.ts +0 -249
  125. package/dist/runtime-kn9DxOeg.d.ts.map +0 -1
  126. package/dist/test-runner/bin.js +0 -2584
  127. package/dist/test-runner/bin.js.map +0 -1
  128. package/dist/test-runner/bridge-stub.d.ts +0 -125
  129. package/dist/test-runner/bridge-stub.d.ts.map +0 -1
  130. package/dist/test-runner/bridge-stub.js +0 -92
  131. package/dist/test-runner/bridge-stub.js.map +0 -1
  132. package/dist/test-runner/bundle.d.ts +0 -2
  133. package/dist/test-runner/bundle.js +0 -439
  134. package/dist/test-runner/bundle.js.map +0 -1
  135. package/dist/test-runner/capture.d.ts +0 -2
  136. package/dist/test-runner/capture.js +0 -44
  137. package/dist/test-runner/capture.js.map +0 -1
  138. package/dist/test-runner/config.d.ts.map +0 -1
  139. package/dist/test-runner/method-pace.d.ts +0 -82
  140. package/dist/test-runner/method-pace.d.ts.map +0 -1
  141. package/dist/test-runner/method-pace.js +0 -120
  142. package/dist/test-runner/method-pace.js.map +0 -1
  143. package/dist/test-runner/pool.d.ts +0 -2
  144. package/dist/test-runner/pool.js +0 -136
  145. package/dist/test-runner/pool.js.map +0 -1
  146. package/dist/test-runner/relay-factory.d.ts +0 -11245
  147. package/dist/test-runner/relay-factory.d.ts.map +0 -1
  148. package/dist/test-runner/relay-factory.js +0 -206
  149. package/dist/test-runner/relay-factory.js.map +0 -1
  150. package/dist/test-runner/relay-worker.d.ts +0 -2
  151. package/dist/test-runner/relay-worker.js +0 -2
  152. package/dist/test-runner/report.d.ts +0 -163
  153. package/dist/test-runner/report.d.ts.map +0 -1
  154. package/dist/test-runner/report.js +0 -198
  155. package/dist/test-runner/report.js.map +0 -1
  156. package/dist/test-runner/rpc.d.ts +0 -56
  157. package/dist/test-runner/rpc.d.ts.map +0 -1
  158. package/dist/test-runner/rpc.js +0 -98
  159. package/dist/test-runner/rpc.js.map +0 -1
  160. package/dist/test-runner/runtime.d.ts +0 -2
  161. package/dist/test-runner/runtime.js +0 -659
  162. package/dist/test-runner/runtime.js.map +0 -1
  163. package/dist/test-runner/task-graph.d.ts +0 -38
  164. package/dist/test-runner/task-graph.d.ts.map +0 -1
  165. package/dist/test-runner/task-graph.js +0 -182
  166. package/dist/test-runner/task-graph.js.map +0 -1
  167. package/dist/throttle-DKKzX1qC.js +0 -59
  168. package/dist/throttle-DKKzX1qC.js.map +0 -1
  169. package/dist/totp-BqmCLSNA.js +0 -189
  170. package/dist/totp-BqmCLSNA.js.map +0 -1
  171. package/dist/totp-CMHR5lsW.cjs +0 -191
  172. package/dist/totp-CMHR5lsW.cjs.map +0 -1
  173. package/dist/totp-CZLLKfOC.js +0 -200
  174. package/dist/totp-CZLLKfOC.js.map +0 -1
  175. package/dist/totp-DAxys-r0.js +0 -199
  176. package/dist/totp-DAxys-r0.js.map +0 -1
  177. package/dist/totp-DfekTBk3.js +0 -211
  178. package/dist/totp-DfekTBk3.js.map +0 -1
  179. package/dist/totp-Dwft0Kz7.js +0 -3
  180. package/dist/totp-WY6l0ysP.js +0 -190
  181. package/dist/totp-WY6l0ysP.js.map +0 -1
  182. /package/dist/{test-runner/bin.d.ts → stubs/bin-devtools-mcp.d.ts} +0 -0
@@ -1,968 +1,91 @@
1
- import { setScreenAwakeMode } from "@apps-in-toss/web-framework";
2
- //#region src/in-app/gate.ts
1
+ //#region src/stubs/moved.ts
2
+ /** The package that now owns the on-device attach + eruda console. */
3
+ const DEBUG_CONSOLE_PACKAGE = "@ait-co/debug-console";
3
4
  /**
4
- * The host suffix the Toss app uses to serve dogfood / private mini-apps.
5
+ * Builds the migration sentence for a moved subpath.
5
6
  *
6
- * A `intoss-private://` (dogfood) entry maps to a host such as
7
- * `aitc-sdk-example.private-apps.tossmini.com`. A production `intoss://`
8
- * entry is served from `*.apps.tossmini.com` — the `.private-apps.` segment
9
- * is absent. Confirmed live over CDP for mini-app 31146; the exact production
10
- * host is to be re-confirmed once 31146 passes review (spec open question 2).
7
+ * SECRET-HANDLING: fixed text plus the two specifiers only — no paths, hosts,
8
+ * URLs, or environment values.
11
9
  */
12
- const PRIVATE_APPS_HOST_SUFFIX = ".private-apps.tossmini.com";
13
- /**
14
- * The host suffix Cloudflare quick-tunnels serve from — the env 2 (PWA) entry.
15
- * See {@link isTrycloudflareHost} for why this host kind bypasses Layer B1.
16
- */
17
- const TRYCLOUDFLARE_HOST_SUFFIX = ".trycloudflare.com";
18
- /**
19
- * Returns whether `hostname` is a `*.private-apps.tossmini.com` subdomain —
20
- * the host the Toss app reserves for dogfood / private mini-app entries.
21
- *
22
- * The match is an exact suffix check, not a substring `.includes()`: a
23
- * substring test would also accept an attacker-controlled host like
24
- * `private-apps.tossmini.com.evil.example`, which ends in `.example`, not in
25
- * `.tossmini.com`. Requiring the string to END with the suffix closes that.
26
- * The leading `.` in the suffix also forces a real subdomain label, so a
27
- * bare `private-apps.tossmini.com` (no mini-app subdomain) does not match.
28
- */
29
- function isPrivateAppsHost(hostname) {
30
- return hostname.endsWith(PRIVATE_APPS_HOST_SUFFIX);
31
- }
32
- /**
33
- * The parent host suffix for the whole Toss mini-app serving family.
34
- *
35
- * The 3.0 runtime loader serves mini-app pages from tossmini.com hosts that
36
- * are NOT `*.private-apps.tossmini.com` (observed live 2026-07-08 on mini-app
37
- * 31146 with a 3.0-beta bundle: a 4-label host ending in `.tossmini.com`
38
- * whose middle label is not `private-apps`, with `_deploymentId` consumed by
39
- * the native loader and not propagated to the page URL — devtools#760).
40
- *
41
- * Under 3.0 the hostname therefore no longer distinguishes a dogfood
42
- * candidate from a production entry, so for these hosts Layer B is demoted
43
- * from a stage discriminator (#665) to a "Toss-owned host family" filter,
44
- * and the effective boundary moves to Layer C: explicit `debug=1`, a valid
45
- * `wss:` relay, and a MANDATORY `at=` TOTP code (see Layer C3 in
46
- * {@link evaluateDebugGate}). A production user's entry URL carries none of
47
- * those params, so an accidentally-shipped debug build stays dormant exactly
48
- * as #665 intended; what changes is that a deliberate operator holding the
49
- * TOTP secret can now attach on a 3.0-family host.
50
- *
51
- * The match is the same exact-suffix `endsWith` check as
52
- * {@link isPrivateAppsHost} — never a substring `.includes()`, which would
53
- * accept an attacker-controlled `x.tossmini.com.evil.example`. The leading
54
- * `.` forces at least one subdomain label, so a bare `tossmini.com` does not
55
- * match.
56
- */
57
- const TOSSMINI_HOST_SUFFIX = ".tossmini.com";
58
- /**
59
- * Returns whether `hostname` is any `*.tossmini.com` subdomain — the host
60
- * family the Toss app serves mini-app pages from. Includes the 2.x
61
- * `*.private-apps.tossmini.com` dogfood hosts and the 3.0 unified serving
62
- * hosts (devtools#760).
63
- */
64
- function isTossminiHost(hostname) {
65
- return hostname.endsWith(TOSSMINI_HOST_SUFFIX);
66
- }
67
- /**
68
- * The host suffix Cloudflare quick-tunnels use — the env 2 (PWA) entry.
69
- *
70
- * Env 2 serves the local Vite dev server through a `*.trycloudflare.com` quick
71
- * tunnel (`src/unplugin/tunnel.ts`). It has no Toss app, no `intoss-private://`
72
- * scheme, and — critically — no production runtime: the SDK is the devtools
73
- * mock, and the page is the developer's own dev build. The Layer B1 safety net
74
- * (which stops a dogfood build that lands on a Toss *production* host from
75
- * attaching) has nothing to protect against here, because env 2 has no
76
- * production host. So a trycloudflare host is allowed past B1 — but ONLY past
77
- * B1: the remaining layers (C1 opt-in, C2 relay, C3 TOTP) still apply, so a
78
- * leaked tunnel URL is still blocked by TOTP exactly as on the Toss path.
79
- *
80
- * The match is the same exact-suffix `endsWith` check as
81
- * {@link isPrivateAppsHost} — never a substring `.includes()`, which would
82
- * accept an attacker-controlled `evil.trycloudflare.com.example.com`. The
83
- * leading `.` forces a real subdomain label, so a bare `trycloudflare.com`
84
- * (no tunnel subdomain) does not match.
85
- */
86
- function isTrycloudflareHost(hostname) {
87
- return hostname.endsWith(TRYCLOUDFLARE_HOST_SUFFIX);
88
- }
89
- /**
90
- * Returns true when the hostname is a localhost/loopback address.
91
- * Allowed: `localhost`, `127.x.x.x` (full RFC 5735 loopback block), `[::1]`,
92
- * `0.0.0.0`, `*.localhost`.
93
- *
94
- * Security note: `hostname.startsWith('127.')` is intentionally NOT used —
95
- * that pattern would accept `127.evil.com`, which starts with "127." but is an
96
- * attacker-controlled hostname, not a loopback address. Instead, the 127/8
97
- * loopback block is matched with a strict numeric-quad regex so only valid
98
- * dotted-decimal IPv4 in the 127.x.x.x range pass (#665 작업 A fix).
99
- */
100
- function isLocalhostHost(hostname) {
101
- if (hostname === "localhost" || hostname === "0.0.0.0") return true;
102
- if (hostname === "[::1]") return true;
103
- if (/^127\.\d+\.\d+\.\d+$/.test(hostname)) return true;
104
- if (hostname.endsWith(".localhost")) return true;
105
- return false;
106
- }
107
- /**
108
- * Positive-allowlist kill-switch (#665): returns true when the hostname is a
109
- * known debug-allowed host. The debug surface is ONLY active on:
110
- * - localhost / loopback (env 1 desktop dev)
111
- * - *.trycloudflare.com (env 2 PWA tunnel)
112
- * - *.tossmini.com (env 3 dog-food — 2.x private-apps hosts AND the 3.0
113
- * unified serving family, devtools#760)
114
- *
115
- * Any other host is silently blocked. This is a positive allowlist —
116
- * unlisted hosts never had debug surface regardless, but this function makes
117
- * it explicit and auditable in a single place.
118
- *
119
- * #760 note on the #665 boundary: the former env 4 LIVE host family
120
- * (`*.apps.tossmini.com`) now passes this coarse filter because the 3.0
121
- * loader serves dogfood candidates and production entries from the same
122
- * host family — the hostname alone can no longer separate them. The #665
123
- * invariant ("no naked attach on a production-family host") is preserved
124
- * one layer down: on tossmini hosts that are not `*.private-apps.*`, Layer
125
- * C3 makes the TOTP `at=` code MANDATORY, and production entry URLs carry
126
- * no debug/relay/at params at all.
127
- *
128
- * SECRET-HANDLING: the hostname value MUST NOT be logged or included in any
129
- * error reason string — only benign labels ('host not in allowlist') are safe.
130
- */
131
- function isDebugAllowedHost(hostname) {
132
- return isLocalhostHost(hostname) || isTrycloudflareHost(hostname) || isTossminiHost(hostname);
133
- }
134
- /**
135
- * Pure function that evaluates the runtime debug activation layers (B and C).
136
- *
137
- * Has no side effects. The input is explicit. Returns a discriminated union
138
- * so callers can pattern-match on `result.attach`.
139
- *
140
- * Layer A (build-time) is intentionally not evaluated here — see the file-level
141
- * comment. By the time this function runs, the consumer's `if (__DEBUG_BUILD__)`
142
- * guard has already passed; this function only decides B and C.
143
- *
144
- * @example
145
- * ```ts
146
- * const result = evaluateDebugGate({
147
- * hostname: window.location.hostname,
148
- * searchParams: new URLSearchParams(window.location.search),
149
- * });
150
- * if (result.attach) {
151
- * // Proceed to load Chii client
152
- * }
153
- * ```
154
- */
155
- function evaluateDebugGate(input) {
156
- const isTunnel = isTrycloudflareHost(input.hostname);
157
- const isLocal = isLocalhostHost(input.hostname);
158
- if (!isDebugAllowedHost(input.hostname)) return {
159
- attach: false,
160
- reason: "host"
161
- };
162
- let deploymentId = "";
163
- if (isPrivateAppsHost(input.hostname)) {
164
- deploymentId = input.searchParams.get("_deploymentId") ?? "";
165
- if (deploymentId === "") return {
166
- attach: false,
167
- reason: "entry"
168
- };
169
- } else if (!isTunnel && !isLocal) deploymentId = input.searchParams.get("_deploymentId") ?? "";
170
- if (input.searchParams.get("debug") !== "1") return {
171
- attach: false,
172
- reason: "opt-in"
173
- };
174
- const relayRaw = input.searchParams.get("relay") ?? "";
175
- if (relayRaw === "") return {
176
- attach: false,
177
- reason: "invalid-relay"
178
- };
179
- let relayUrl;
180
- try {
181
- relayUrl = new URL(relayRaw);
182
- } catch {
183
- return {
184
- attach: false,
185
- reason: "invalid-relay"
186
- };
187
- }
188
- if (relayUrl.protocol !== "wss:") return {
189
- attach: false,
190
- reason: "invalid-relay"
191
- };
192
- const atCode = input.searchParams.get("at") ?? "";
193
- if (input.verifyTotpCode !== void 0) {
194
- if (!input.verifyTotpCode(atCode)) return {
195
- attach: false,
196
- reason: "auth"
197
- };
198
- } else if (isTossminiHost(input.hostname) && !isPrivateAppsHost(input.hostname) && atCode === "") return {
199
- attach: false,
200
- reason: "auth"
201
- };
202
- return {
203
- attach: true,
204
- relayUrl: relayUrl.href,
205
- deploymentId
206
- };
10
+ function movedMessage(oldSubpath, newPackage, install) {
11
+ return [
12
+ `[@ait-co/devtools] '${oldSubpath}' 0.2.0에서 제거되었습니다.`,
13
+ `이 기능은 '${newPackage}' 이동했습니다.`,
14
+ `설치: ${install}`
15
+ ].join(" ");
207
16
  }
208
17
  //#endregion
209
- //#region src/shared/relay-auth-close.ts
18
+ //#region src/stubs/in-app.ts
210
19
  /**
211
- * Shared constants for the relay's named TOTP-auth rejection (issue #478).
20
+ * TRANSITION STUB REMOVE IN 1.0.0.
212
21
  *
213
- * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
214
- * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
215
- * indistinguishable from a network failure on the browser side — the
216
- * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
217
- * could not tell "stale TOTP code" apart from "tunnel down" and stayed
218
- * silent. The fix is accept-then-close: complete the handshake, then close
219
- * with an application close code that NAMES the rejection.
22
+ * `@ait-co/devtools/in-app` moved to `@ait-co/debug-console` (#818).
220
23
  *
221
- * Three parties share this contract:
222
- * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
223
- * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
224
- * surfaces the code to the launcher shell;
225
- * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
226
- * as an auth failure on its own `/client` dial (defensive #439's fresh
227
- * code mint means it should not normally hit this).
24
+ * **This stub must never throw.** Unlike the `/mcp/*` and `/test-runner`
25
+ * subpaths, the in-app attach surface is the one piece of the old debug
26
+ * surface that legitimately ends up inside a shipped mini-app bundle. A
27
+ * consumer who built and deployed against `@ait-co/devtools@0.1.x` has this
28
+ * import (or an unplugin-injected equivalent) sitting in production code; if
29
+ * upgrading the package turned that import into a throw, their app would die
30
+ * on a real user's phone over a developer-tools concern. So every export here
31
+ * is an inert no-op that says its piece through `console.error` once and
32
+ * returns the shape the old API returned.
228
33
  *
229
- * This module is intentionally dependency-free (no Node, no DOM) so it is
230
- * safe to import from both the browser in-app bundle and the MCP daemon
231
- * bundle.
232
- *
233
- * SECRET-HANDLING: these are fixed enum values. The close reason / error body
234
- * must never grow to carry a secret, a TOTP code, or a host.
34
+ * The surface below mirrors the pre-split `src/in-app/index.ts` exports so a
35
+ * stale call site still type-checks and still runs; it simply does nothing.
235
36
  */
37
+ const MESSAGE = movedMessage("@ait-co/devtools/in-app", DEBUG_CONSOLE_PACKAGE, `pnpm add ${DEBUG_CONSOLE_PACKAGE}`);
236
38
  /**
237
- * WebSocket close code sent by the relay when TOTP auth is rejected.
39
+ * Emits the migration notice at most once per module instance.
238
40
  *
239
- * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
240
- * HTTP 401 so it reads as "unauthorized" at a glance.
241
- */
242
- const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
243
- /**
244
- * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
245
- * the `error` value of the relay's HTTP 401 JSON body. Enum string only —
246
- * never interpolated with request data.
247
- */
248
- const RELAY_AUTH_REJECT_REASON = "totp-rejected";
249
- //#endregion
250
- //#region src/in-app/bridge-observer.ts
251
- /**
252
- * CustomEvent fired (no detail) on every start/settle so the indicator badge
253
- * re-renders promptly. SECRET-HANDLING: carries no detail payload at all — the
254
- * badge reads the enum-only `window.__ait_bridge` snapshot on receipt.
255
- */
256
- const BRIDGE_CALL_EVENT = "ait:bridge-call";
257
- /**
258
- * Pending entries older than this are pruned on the next start — a safety net
259
- * for the fallback path where a settle signal might be missed, so the pending
260
- * list can never grow unbounded or show a forever-stuck row. Generous enough
261
- * that a genuinely slow native call (the exact signal we want to surface) still
262
- * shows while it is plausibly in flight.
263
- */
264
- const MAX_PENDING_AGE_MS = 12e4;
265
- /** Guard so the observer wraps the bridge at most once per page lifecycle. */
266
- let bridgeObserverInstalled = false;
267
- /** Monotonic id source for the primary (3.0) path, where native gives us none. */
268
- let callIdCounter = 0;
269
- /** Undo hooks that {@link uninstallBridgeObserver} runs to restore originals. */
270
- let restoreHooks = [];
271
- /** Drops pending entries older than {@link MAX_PENDING_AGE_MS}. */
272
- function pruneStale(state, now) {
273
- for (const id of Object.keys(state.pending)) {
274
- const entry = state.pending[id];
275
- if (entry !== void 0 && now - entry.startedAt > MAX_PENDING_AGE_MS) delete state.pending[id];
276
- }
277
- }
278
- /** Fires the payload-less notify event so the badge re-renders. */
279
- function broadcast() {
280
- if (typeof window === "undefined") return;
281
- window.dispatchEvent(new CustomEvent(BRIDGE_CALL_EVENT));
282
- }
283
- /** Records a call start: add to pending, set last=pending, notify. */
284
- function startCall(state, id, method, now) {
285
- pruneStale(state, now);
286
- state.pending[id] = {
287
- method,
288
- startedAt: now
289
- };
290
- state.last = {
291
- method,
292
- at: now,
293
- status: "pending"
294
- };
295
- broadcast();
296
- }
297
- /** Records a call settle: remove from pending, stamp last, notify. */
298
- function settleCall(state, id, status, now) {
299
- const entry = state.pending[id];
300
- delete state.pending[id];
301
- state.last = {
302
- method: entry?.method ?? state.last?.method ?? "unknown",
303
- at: now,
304
- status
305
- };
306
- broadcast();
307
- }
308
- /**
309
- * Parses an outbound `ReactNativeWebView.postMessage` JSON envelope and records
310
- * a START for async request/response calls only. Event subscriptions
311
- * (`addEventListener`/`removeEventListener`/`callEventMethod`), cleanup, and
312
- * constants are ignored — they are not the "spinner is a pending call" signal.
41
+ * Once, not per call: `maybeAttach()` can sit on a hot path, and a stub whose
42
+ * only job is to be harmless must not turn into a console flood.
313
43
  *
314
- * SECRET-HANDLING: reads `type`/`name`/`functionName`/`callbackId`/`eventId`
315
- * ONLY — never `params`/`args`.
44
+ * SECRET-HANDLING: fixed text only — no URL, host, secret, or TOTP code.
316
45
  */
317
- function observeOutbound(state, message) {
318
- if (typeof message !== "string") return;
319
- let parsed;
320
- try {
321
- parsed = JSON.parse(message);
322
- } catch {
323
- return;
324
- }
325
- const now = Date.now();
326
- if (parsed.type === "callAsyncMethod" && typeof parsed.name === "string" && typeof parsed.callbackId === "string") {
327
- startCall(state, parsed.callbackId, parsed.name, now);
328
- return;
329
- }
330
- if (parsed.type === "method" && typeof parsed.functionName === "string" && typeof parsed.eventId === "string") startCall(state, parsed.eventId, parsed.functionName, now);
46
+ let warned = false;
47
+ function warnOnce() {
48
+ if (warned) return;
49
+ warned = true;
50
+ console.error(MESSAGE);
331
51
  }
332
- /**
333
- * Parses a `__GRANITE_NATIVE_EMITTER.emit` event name and records a SETTLE for
334
- * 2.x async resolves/rejects (`<method>/resolve/<eventId>` |
335
- * `<method>/reject/<eventId>`). Event-bridge emits (`.../onEvent/...`) are
336
- * ignored. SECRET-HANDLING: reads the event NAME only — never the emitted args.
337
- */
338
- function observeSettle(state, event) {
339
- if (typeof event !== "string") return;
340
- const match = /\/(resolve|reject)\/([^/]+)$/.exec(event);
341
- if (match === null) return;
342
- settleCall(state, match[2], match[1] === "resolve" ? "resolved" : "rejected", Date.now());
52
+ /** No-op. The real attach lives in `@ait-co/debug-console`. */
53
+ function maybeAttach() {
54
+ warnOnce();
343
55
  }
344
- /**
345
- * Installs the native-bridge call observer (#749). Idempotent per page
346
- * lifecycle. Called by {@link maybeAttach} after the gate passes (debug builds
347
- * only). Prefers the 3.0 single-dispatcher wrap; falls back to the universal
348
- * 2.x postMessage-start + emitter-settle pair. A context with no observable
349
- * bridge (env 2 mock) leaves `window.__ait_bridge` as an empty snapshot — the
350
- * badge then shows the heartbeat only, which is correct.
351
- *
352
- * Never throws into the host app — a wrapped hook that somehow fails is caught
353
- * and the original behavior is always preserved.
354
- */
355
- function installBridgeObserver() {
356
- if (bridgeObserverInstalled) return;
357
- if (typeof window === "undefined") return;
358
- bridgeObserverInstalled = true;
359
- const state = {
360
- pending: Object.create(null),
361
- last: null
362
- };
363
- window.__ait_bridge = state;
364
- const nativeBridge = window.__appsInTossNativeBridge;
365
- if (nativeBridge !== void 0 && typeof nativeBridge.callAsyncMethod === "function") {
366
- const original = nativeBridge.callAsyncMethod;
367
- const wrapped = function(name, params) {
368
- const id = `c${++callIdCounter}`;
369
- startCall(state, id, String(name), Date.now());
370
- let result;
371
- try {
372
- result = original.call(this, name, params);
373
- } catch (err) {
374
- settleCall(state, id, "rejected", Date.now());
375
- throw err;
376
- }
377
- if (result !== null && typeof result.then === "function") result.then(() => settleCall(state, id, "resolved", Date.now()), () => settleCall(state, id, "rejected", Date.now()));
378
- else settleCall(state, id, "resolved", Date.now());
379
- return result;
380
- };
381
- nativeBridge.callAsyncMethod = wrapped;
382
- restoreHooks.push(() => {
383
- nativeBridge.callAsyncMethod = original;
384
- });
385
- return;
386
- }
387
- const webView = window.ReactNativeWebView;
388
- if (webView !== void 0 && typeof webView.postMessage === "function") {
389
- const originalPost = webView.postMessage;
390
- webView.postMessage = function(message) {
391
- try {
392
- observeOutbound(state, message);
393
- } catch {}
394
- originalPost.call(this, message);
395
- };
396
- restoreHooks.push(() => {
397
- webView.postMessage = originalPost;
398
- });
399
- }
400
- const emitter = window.__GRANITE_NATIVE_EMITTER;
401
- if (emitter !== void 0 && typeof emitter.emit === "function") {
402
- const originalEmit = emitter.emit;
403
- emitter.emit = function(event, args) {
404
- try {
405
- observeSettle(state, event);
406
- } catch {}
407
- originalEmit.call(this, event, args);
408
- };
409
- restoreHooks.push(() => {
410
- emitter.emit = originalEmit;
411
- });
412
- }
56
+ /** No-op — there is nothing attached to detach. */
57
+ function detachDebugSurface() {
58
+ warnOnce();
413
59
  }
414
- /**
415
- * Restores every wrapped bridge hook and removes `window.__ait_bridge` (#749).
416
- * Idempotent; safe to call when nothing was installed. Wired into
417
- * {@link detachDebugSurface} (#748) so no bridge wrap survives a run's end.
418
- */
419
- function uninstallBridgeObserver() {
420
- if (!bridgeObserverInstalled) return;
421
- bridgeObserverInstalled = false;
422
- const hooks = restoreHooks;
423
- restoreHooks = [];
424
- for (const undo of hooks) try {
425
- undo();
426
- } catch {}
427
- if (typeof window !== "undefined") window.__ait_bridge = void 0;
60
+ /** No-op — the WebView type self-report moved with the attach surface. */
61
+ function reportWebViewType() {
62
+ warnOnce();
428
63
  }
429
- //#endregion
430
- //#region src/in-app/eruda-overlay.ts
431
- /**
432
- * In-app eruda console overlay for the debug attach flow.
433
- *
434
- * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md
435
- *
436
- * This module mounts the eruda in-page console (https://github.com/liriliri/eruda)
437
- * on the phone screen when a debug session attaches. It is the mobile-only
438
- * counterpart to the Chii `target.js` injection in {@link attach.ts}: Chii is a
439
- * REMOTE CDP transport (phone → relay → PC DevTools frontend), whereas eruda is
440
- * a LOCAL in-page view — a floating button + console/network/DOM/storage panels
441
- * rendered directly on the phone, with no relay or second device. The two are
442
- * orthogonal and coexist (eruda opens no WebSocket, mounts into its own
443
- * `#eruda` shadow host — it cannot collide with the relay WS or the Chii DOM).
444
- *
445
- * Build-time absence (the security contract): this module lives in the
446
- * `@ait-co/devtools/in-app` graph. A consumer wraps its
447
- * `import('@ait-co/devtools/in-app')` call site in `if (__DEBUG_BUILD__) { … }`;
448
- * a release build folds that constant to `false` and dead-code-eliminates the
449
- * whole module — so eruda (and its dynamic `import('eruda')` chunk) is simply
450
- * absent from release bundles, exactly like the Chii target.js injection. The
451
- * `import('eruda')` here is a dynamic import precisely so the bundler emits it
452
- * as a separate chunk that the dead branch never pulls in.
453
- *
454
- * Runtime gate: `mountEruda()` is called only from `maybeAttach()` AFTER the
455
- * full Layer B/C gate has passed (`gateResult.attach === true`) — host
456
- * allowlist, `debug=1`, relay URL, and TOTP. So eruda inherits the same
457
- * four-layer defence as the Chii injection, byte-for-byte, with no eruda-
458
- * specific gate of its own.
459
- *
460
- * SECRET-HANDLING: this module reads no secret, TOTP code, relay URL, or host
461
- * value, and logs none. eruda observes only the page it is mounted on.
462
- */
463
- /** Module-level guard against double mount across repeated `maybeAttach` calls. */
464
- let erudaMounted = false;
465
- /**
466
- * The loaded eruda module, captured on a successful {@link mountEruda} so
467
- * {@link unmountEruda} can call `.destroy()` on the same instance during
468
- * graceful detach (#748). `null` when eruda was never mounted.
469
- */
470
- let erudaModule = null;
471
- /**
472
- * Mounts the eruda in-page console once.
473
- *
474
- * Idempotent: repeated calls after a successful mount are no-ops, mirroring the
475
- * `attached` guard in {@link attach.ts}. Fail-silent: if the dynamic import or
476
- * `eruda.init()` throws (eruda absent, or a runtime that rejects it), the Chii
477
- * debug session is unaffected — eruda is an additive convenience, not a
478
- * dependency of the relay path.
479
- *
480
- * `eruda.init()` mounts eruda's own floating entry button on the phone screen;
481
- * tapping it opens the console. We do not add a separate button.
482
- */
64
+ /** No-op — the eruda overlay moved with the attach surface. */
483
65
  async function mountEruda() {
484
- if (erudaMounted || typeof document === "undefined") return;
485
- erudaMounted = true;
486
- try {
487
- const eruda = (await import("eruda")).default;
488
- eruda.init();
489
- erudaModule = eruda;
490
- } catch (err) {
491
- erudaMounted = false;
492
- console.debug("[@ait-co/devtools] eruda console mount skipped:", err);
493
- }
66
+ warnOnce();
494
67
  }
495
- /**
496
- * Unmounts the eruda in-page console (#748 graceful detach).
497
- *
498
- * Calls `eruda.destroy()`, which removes eruda's floating entry button, any
499
- * open panel, and its `#eruda` shadow host — returning the phone screen to a
500
- * clean, non-debug state when a debug session ends. After a successful unmount
501
- * the guard is reset so a later {@link mountEruda} (a fresh attach) can
502
- * re-mount.
503
- *
504
- * Idempotent: a call when eruda was never mounted (or already unmounted) is a
505
- * no-op. Fail-silent: a `destroy()` throw is swallowed — teardown must never
506
- * throw into the host app.
507
- */
68
+ /** No-op counterpart to {@link mountEruda}. */
508
69
  function unmountEruda() {
509
- if (!erudaMounted || erudaModule === null) return;
510
- try {
511
- erudaModule.destroy();
512
- } catch (err) {
513
- console.debug("[@ait-co/devtools] eruda console unmount skipped:", err);
514
- } finally {
515
- erudaMounted = false;
516
- erudaModule = null;
517
- }
518
- }
519
- //#endregion
520
- //#region src/in-app/attach.ts
521
- /**
522
- * In-app Chii target injection for the debug attach flow.
523
- *
524
- * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md
525
- * "MCP attach" topology section — Phase 1 browser-side implementation.
526
- *
527
- * This module bridges the 3-layer gate result to a Chii `target.js` script
528
- * injection. The Chii npm package is the relay SERVER — the in-app side is
529
- * a plain `<script src="…/target.js">` pointing at the relay host. No chii
530
- * npm dependency is needed here.
531
- */
532
- /**
533
- * Converts a validated `wss:` relay URL into the Chii `target.js` script URL.
534
- *
535
- * Scheme is mapped `wss:` → `https:`. Host and port are preserved.
536
- * Pathname is set to `/target.js` (or `/at/<code>/target.js` when a TOTP code
537
- * is given) regardless of the relay path. Query params and hash from the
538
- * relay URL are dropped — the target script URL is a static asset path on the
539
- * same host.
540
- *
541
- * TOTP path-prefix transport (issue #466): chii's stock `target.js` derives
542
- * its WS endpoint from the script `src` (`scriptEl.src.replace('target.js',
543
- * '')`), so embedding the current TOTP code in the script URL *path* is the
544
- * only way the phone-side WS upgrade can carry it — both the script fetch and
545
- * the derived `wss://<host>/at/<code>/target/<id>` dial inherit the prefix,
546
- * and the relay verifies + strips it before chii parses the URL. The
547
- * `window.ChiiServerUrl` + query alternative does NOT work: chii appends
548
- * `target/<id>` to the serverUrl string, which would land after a `?`.
549
- *
550
- * SECRET-HANDLING: `atCode` rides only inside the returned URL (the intended
551
- * transport — same exposure grade as the daemon client's `at=` query). It is
552
- * never logged here.
553
- *
554
- * @example
555
- * deriveTargetScriptUrl('wss://abc.trycloudflare.com/relay')
556
- * // → 'https://abc.trycloudflare.com/target.js'
557
- *
558
- * deriveTargetScriptUrl('wss://h.example.com:9100/', '123456')
559
- * // → 'https://h.example.com:9100/at/123456/target.js'
560
- *
561
- * @param relayUrl - Validated `wss:` relay URL from the gate result.
562
- * @param atCode - Current TOTP code from the page URL's `at` query param, or
563
- * `null`/`undefined`/`''` to keep the legacy un-prefixed URL.
564
- */
565
- function deriveTargetScriptUrl(relayUrl, atCode) {
566
- const u = new URL(relayUrl);
567
- u.protocol = "https:";
568
- u.pathname = atCode !== void 0 && atCode !== null && atCode !== "" ? `/at/${encodeURIComponent(atCode)}/target.js` : "/target.js";
569
- u.search = "";
570
- u.hash = "";
571
- return u.toString();
70
+ warnOnce();
572
71
  }
573
- /** Module-level guard against double-injection within a page lifecycle. */
574
- let attached = false;
575
- /** One-shot guard for the parent notification (both observer + onerror probe). */
576
- let authExpiredNotified = false;
577
- /** Set once a relay-bound socket closed with 4401 — flips dials to fail-fast. */
578
- let relayAuthExpired = false;
579
- /** Guard against stacking multiple observer wrappers on window.WebSocket. */
580
- let wsObserverInstalled = false;
581
- /**
582
- * Broadcasts relay-socket lifecycle to any in-page listener (#730) — the
583
- * on-phone debug indicator subscribes to this instead of wrapping
584
- * `window.WebSocket` a second time.
585
- *
586
- * SECRET-HANDLING: the CustomEvent `detail` carries ONLY the enum
587
- * `'open' | 'close'` — never a close code, host, relay URL, or TOTP value.
588
- */
589
- function broadcastRelayWsState(state) {
590
- if (typeof window === "undefined") return;
591
- window.dispatchEvent(new CustomEvent("ait:relay-ws-state", { detail: { state } }));
592
- }
593
- /**
594
- * Posts the `auth-expired` block signal to the parent launcher shell, once.
595
- *
596
- * Mirrors the existing `reason: 'auth'` postMessage in {@link maybeAttach}.
597
- * SECRET-HANDLING: the payload carries ONLY the reason enum — never the code,
598
- * secret, host, or relay URL.
599
- */
600
- function notifyAuthExpired() {
601
- if (authExpiredNotified) return;
602
- if (typeof window === "undefined" || window.parent === window) return;
603
- authExpiredNotified = true;
604
- window.parent.postMessage({
605
- type: "ait:debug-attach-blocked",
606
- reason: "auth-expired"
607
- }, "*");
608
- }
609
- /**
610
- * Normalises a URL into a comparable origin key, mapping the HTTP scheme pair
611
- * onto the WS pair (`https:`→`wss:`, `http:`→`ws:`) so the `wss:` relay URL
612
- * from the gate result matches the dials target.js derives from its
613
- * `https://…/target.js` script src. Returns `null` for unparsable URLs.
614
- */
615
- function wsOriginKey(rawUrl) {
616
- let parsed;
617
- try {
618
- parsed = new URL(rawUrl);
619
- } catch {
620
- return null;
621
- }
622
- return `${parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol}//${parsed.host}`;
623
- }
624
- /**
625
- * Builds a dummy WebSocket that never connects and closes immediately
626
- * (asynchronously, with the 4401 code) — returned for relay-bound dials after
627
- * auth expiry so chii's internal reconnect loop stops producing real network
628
- * traffic. We cannot stop the loop itself (it lives inside stock target.js);
629
- * we can only make each iteration free.
630
- *
631
- * Both `onclose`-style property handlers and `addEventListener` listeners are
632
- * fired — stock target.js uses property handlers, but we cannot know every
633
- * consumer. (A consumer wiring BOTH would see a double callback; acceptable
634
- * for a retry scheduler and irrelevant for chii.)
635
- */
636
- function createFailFastSocket(url) {
637
- const eventTarget = new EventTarget();
638
- const sock = {
639
- url,
640
- readyState: 3,
641
- bufferedAmount: 0,
642
- extensions: "",
643
- protocol: "",
644
- binaryType: "blob",
645
- onopen: null,
646
- onmessage: null,
647
- onerror: null,
648
- onclose: null,
649
- close() {},
650
- send() {},
651
- addEventListener: eventTarget.addEventListener.bind(eventTarget),
652
- removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
653
- dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
654
- CONNECTING: 0,
655
- OPEN: 1,
656
- CLOSING: 2,
657
- CLOSED: 3
658
- };
659
- setTimeout(() => {
660
- const errorEvent = new Event("error");
661
- sock.onerror?.(errorEvent);
662
- eventTarget.dispatchEvent(errorEvent);
663
- let closeEvent;
664
- try {
665
- closeEvent = new CloseEvent("close", {
666
- code: RELAY_AUTH_REJECT_CLOSE_CODE,
667
- reason: RELAY_AUTH_REJECT_REASON,
668
- wasClean: false
669
- });
670
- } catch {
671
- closeEvent = Object.assign(new Event("close"), {
672
- code: RELAY_AUTH_REJECT_CLOSE_CODE,
673
- reason: RELAY_AUTH_REJECT_REASON,
674
- wasClean: false
675
- });
676
- }
677
- sock.onclose?.(closeEvent);
678
- eventTarget.dispatchEvent(closeEvent);
679
- }, 0);
680
- return sock;
681
- }
682
- /** Grace window before a non-terminal relay close tears the surface down. */
683
- const RECONNECT_GRACE_MS = 5e3;
684
- /** One-shot guard so the teardown runs at most once per page lifecycle. */
685
- let debugSurfaceDetached = false;
686
- /** Pending grace-window timer for a non-terminal close, or `null`. */
687
- let pendingDetachTimer = null;
688
- /** Cancels a scheduled teardown — called when a relay socket re-opens. */
689
- function cancelScheduledDetach() {
690
- if (pendingDetachTimer !== null) {
691
- clearTimeout(pendingDetachTimer);
692
- pendingDetachTimer = null;
693
- }
694
- }
695
- /**
696
- * Schedules {@link detachDebugSurface} after {@link RECONNECT_GRACE_MS} unless
697
- * a reconnect cancels it first. No-op if teardown already ran or is already
698
- * scheduled. Defensive: if `setTimeout` is somehow unavailable, tears down
699
- * immediately rather than never.
700
- */
701
- function scheduleDetach() {
702
- if (debugSurfaceDetached || pendingDetachTimer !== null) return;
703
- if (typeof setTimeout === "undefined") {
704
- detachDebugSurface();
705
- return;
706
- }
707
- pendingDetachTimer = setTimeout(() => {
708
- pendingDetachTimer = null;
709
- detachDebugSurface();
710
- }, RECONNECT_GRACE_MS);
711
- }
712
- /**
713
- * Idempotent, non-throwing teardown of the in-app debug surface (issue #748).
714
- *
715
- * Removes every debug-surface element WE injected and restores the one side
716
- * effect WE applied:
717
- * 1. The CDP-injected `#__ait_debug_indicator` badge (the persistent
718
- * "Debugger Disconnected" element). Its live heartbeat/pending-call timer
719
- * (#749) is stopped first via the badge controller's `stop()` so no 1 Hz
720
- * interval leaks past detach; `buildIndicatorExpression` also
721
- * self-dismisses the node — this is the in-app hard guarantee, idempotent,
722
- * a no-op if it is already gone.
723
- * 2. The eruda in-page console (floating button + any open panel).
724
- * 3. The native-bridge call observer (#749) — its `callAsyncMethod` /
725
- * `postMessage` / emitter wraps are restored and `window.__ait_bridge` is
726
- * removed, so nothing we wrapped survives the run's end.
727
- * 4. keepAwake — forced on at attach; restored here so a run that ends
728
- * WITHOUT a page unload does not leave the screen pinned awake (the
729
- * existing `beforeunload` restore only covers the unload path).
730
- *
731
- * Deliberately NOT touched: the `window.WebSocket` observer proxy (kept so the
732
- * #478 post-4401 fail-fast survives; it is non-blocking and never absorbs
733
- * input), and any NATIVE overlay (out of our layer — see the block comment
734
- * above and issue #748 hypothesis (a)).
735
- *
736
- * Never throws into the host app — every step is individually guarded.
737
- * Exported for unit tests and for a consumer that wants to force a clean detach.
738
- */
739
- function detachDebugSurface() {
740
- if (debugSurfaceDetached) return;
741
- debugSurfaceDetached = true;
742
- cancelScheduledDetach();
743
- try {
744
- if (typeof window !== "undefined") window.__ait_indicator?.stop?.();
745
- if (typeof document !== "undefined") document.getElementById("__ait_debug_indicator")?.remove();
746
- } catch {}
747
- try {
748
- unmountEruda();
749
- } catch {}
750
- try {
751
- uninstallBridgeObserver();
752
- } catch {}
753
- try {
754
- setScreenAwakeMode({ enabled: false }).catch(() => {});
755
- } catch {}
756
- }
757
- /**
758
- * Wraps `window.WebSocket` with a relay-origin-scoped observer (issue #478).
759
- *
760
- * - Connections whose URL origin does NOT match the relay origin pass through
761
- * to the native constructor untouched — app traffic is never observed.
762
- * - Relay-origin connections get a `close` listener: code 4401 (the relay's
763
- * named TOTP rejection) flips the module into the expired state and posts
764
- * `reason: 'auth-expired'` to the parent launcher shell (once).
765
- * - After 4401, further relay-origin dials return a fail-fast dummy socket so
766
- * target.js's autonomous reconnect loop stops hitting the network.
767
- *
768
- * Installed by {@link maybeAttach} BEFORE target.js is injected so the very
769
- * first dial is already observed. Idempotent per page lifecycle. Exported for
770
- * unit tests.
771
- */
772
- function installRelayWsObserver(relayUrl) {
773
- if (wsObserverInstalled) return;
774
- if (typeof window === "undefined" || typeof window.WebSocket !== "function") return;
775
- const relayKey = wsOriginKey(relayUrl);
776
- if (relayKey === null) return;
777
- wsObserverInstalled = true;
778
- window.__ait_relay_ws_observed = true;
779
- window.addEventListener("pagehide", () => detachDebugSurface(), { once: true });
780
- const NativeWebSocket = window.WebSocket;
781
- const observed = new Proxy(NativeWebSocket, { construct(target, args) {
782
- const url = String(args[0]);
783
- if (wsOriginKey(url) !== relayKey) return Reflect.construct(target, args);
784
- if (relayAuthExpired) return createFailFastSocket(url);
785
- const ws = Reflect.construct(target, args);
786
- ws.addEventListener("open", () => {
787
- broadcastRelayWsState("open");
788
- cancelScheduledDetach();
789
- });
790
- ws.addEventListener("close", (event) => {
791
- broadcastRelayWsState("close");
792
- if (event.code === 4401) {
793
- relayAuthExpired = true;
794
- notifyAuthExpired();
795
- detachDebugSurface();
796
- } else scheduleDetach();
797
- });
798
- ws.addEventListener("error", () => {
799
- scheduleDetach();
800
- });
801
- return ws;
802
- } });
803
- window.WebSocket = observed;
804
- }
805
- /**
806
- * The webViewType self-report postMessage type (#580).
807
- *
808
- * Canonical definition + the receive-side parser live in
809
- * `src/mock/safe-area-bridge.ts` (`WEB_VIEW_TYPE_MESSAGE_TYPE`,
810
- * `parseWebViewTypeMessage`). It is re-declared here as a local literal so the
811
- * in-app entry does NOT import the mock barrel (which would drag mock internals
812
- * — navigation/state — into the dogfood in-app graph). The two literals are
813
- * kept in sync by value; if one changes, change both. Same decoupling pattern
814
- * the launcher fixture uses for its message-type constants.
815
- */
816
- const WEB_VIEW_TYPE_MESSAGE_TYPE = "ait:web-view-type";
817
- /** Guard so the webViewType self-report is posted at most once per page. */
818
- let webViewTypeReported = false;
819
- /**
820
- * Self-report the mini-app's webViewType to the parent launcher shell, ONCE
821
- * (#580).
822
- *
823
- * The mini-app's type is the build constant `__WEB_VIEW_TYPE__`, injected by
824
- * the devtools unplugin from `granite.config.ts`'s `webViewProps.type`. The
825
- * launcher (env-2 PWA) is cross-origin and cannot read it directly, so the
826
- * framed page posts it to `window.parent`; the launcher switches to game mode
827
- * automatically (no manual `?navBarType=game` URL edit).
828
- *
829
- * Defensive by construction — must NEVER break attach:
830
- * - `__WEB_VIEW_TYPE__` is a CONSUMER-build define; it does not exist in
831
- * devtools' own build or where the unplugin did not inject it. The `typeof`
832
- * guard avoids a ReferenceError; an absent constant is a silent no-op.
833
- * - Only posts when inside an iframe (`window.parent !== window`) — a
834
- * top-level load has no launcher shell to receive the message.
835
- * - The SDK's deprecated `'external'` alias of `partner` (web-framework 2.6.1)
836
- * is mapped to `'partner'`; the launcher only emulates `partner` | `game`.
837
- * - Wrapped in try/catch so any postMessage/iframe edge case is swallowed.
838
- *
839
- * SECRET-HANDLING: the payload carries ONLY the webViewType enum — no host,
840
- * relay URL, code, or secret.
841
- */
842
- function reportWebViewType() {
843
- if (webViewTypeReported) return;
844
- try {
845
- if (typeof window === "undefined" || window.parent === window) return;
846
- const raw = typeof __WEB_VIEW_TYPE__ !== "undefined" ? __WEB_VIEW_TYPE__ : void 0;
847
- if (raw === void 0) return;
848
- const value = raw === "game" ? "game" : raw === "partner" || raw === "external" ? "partner" : null;
849
- if (value === null) return;
850
- webViewTypeReported = true;
851
- window.parent.postMessage({
852
- type: WEB_VIEW_TYPE_MESSAGE_TYPE,
853
- value
854
- }, "*");
855
- } catch {}
72
+ /** No-op the bridge observer moved with the attach surface. */
73
+ function installBridgeObserver() {
74
+ warnOnce();
856
75
  }
857
- /**
858
- * Evaluates the 3-layer debug gate and, if the gate passes, injects the Chii
859
- * `target.js` script into `document.head`.
860
- *
861
- * Idempotent — calling more than once is safe. The second call is a no-op if
862
- * a script with the same `src` is already present in the document, and the
863
- * module-level `attached` flag prevents redundant DOM queries after the first
864
- * successful injection.
865
- *
866
- * Safe to call even if `document` is somehow unavailable (defensive boundary
867
- * guard — in practice this always runs in a real WebView).
868
- *
869
- * **keepAwake side effect**: on a successful attach, `setScreenAwakeMode({
870
- * enabled: true })` is called so the phone screen stays awake during the debug
871
- * session. A `beforeunload` handler restores normal sleep on page unload.
872
- * Opt out by adding `noKeepAwake=1` to the page URL query string — the check
873
- * reads `window.location.search` directly, consistent with other guards in
874
- * this file.
875
- *
876
- * @param gateResult - Optional pre-evaluated gate result for testability.
877
- * Defaults to `checkDebugGate()` which reads the current page URL. Passing a
878
- * custom value avoids the need to manipulate `window.location` in tests.
879
- */
880
- function maybeAttach(gateResult = checkDebugGate()) {
881
- reportWebViewType();
882
- if (!gateResult.attach) {
883
- console.debug(`[@ait-co/devtools] debug attach skipped — gate blocked (reason: ${gateResult.reason})`);
884
- if (gateResult.reason === "auth" && typeof window !== "undefined" && window.parent !== window) window.parent.postMessage({
885
- type: "ait:debug-attach-blocked",
886
- reason: "auth"
887
- }, "*");
888
- return;
889
- }
890
- if (attached) return;
891
- if (typeof document === "undefined") return;
892
- const atCode = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("at") : null;
893
- const src = deriveTargetScriptUrl(gateResult.relayUrl, atCode);
894
- installRelayWsObserver(gateResult.relayUrl);
895
- installBridgeObserver();
896
- if (document.querySelector(`script[src="${src}"]`) !== null) {
897
- attached = true;
898
- return;
899
- }
900
- const script = document.createElement("script");
901
- script.src = src;
902
- script.async = true;
903
- script.onerror = () => {
904
- fetch(src).then((res) => {
905
- if (res.status === 401) notifyAuthExpired();
906
- }).catch(() => {});
907
- };
908
- (document.head ?? document.documentElement).appendChild(script);
909
- attached = true;
910
- mountEruda();
911
- if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("noKeepAwake") === "1") return;
912
- setScreenAwakeMode({ enabled: true }).then(() => {
913
- window.addEventListener("beforeunload", () => {
914
- setScreenAwakeMode({ enabled: false }).catch(() => {});
915
- }, { once: true });
916
- }).catch((err) => {
917
- console.debug("[@ait-co/devtools] setScreenAwakeMode failed:", err);
918
- });
76
+ /** No-op counterpart to {@link installBridgeObserver}. */
77
+ function uninstallBridgeObserver() {
78
+ warnOnce();
919
79
  }
920
- //#endregion
921
- //#region src/in-app/index.ts
922
- /**
923
- * @ait-co/devtools/in-app entry point.
924
- *
925
- * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md
926
- *
927
- * Phase 1 — gate + browser-side Chii target injection.
928
- * WebSocket relay, QR/paste UI, and AI-host MCP bin are later phases that
929
- * require real-device validation and are not included here.
930
- *
931
- * This thin entry reads `window.location` and calls the pure
932
- * {@link evaluateDebugGate} function. All testable logic lives in `./gate.ts`
933
- * and `./attach.ts`, not here.
934
- *
935
- * Layer A of the activation gate (build-time) is NOT enforced in this module.
936
- * It is the consumer's responsibility: the consumer wraps its
937
- * `import('@ait-co/devtools/in-app')` call site in `if (__DEBUG_BUILD__) { … }`
938
- * (see sdk-example `src/main.tsx`), where `__DEBUG_BUILD__` is a
939
- * consumer-build-time constant. A release consumer build folds that constant
940
- * to `false` and dead-code-eliminates this whole module. This package is
941
- * pre-built and ships with `__DEBUG_BUILD__` already resolved at devtools'
942
- * publish time, so it could never re-evaluate the consumer's build channel —
943
- * which is exactly why Layer A lives at the consumer guard, not here.
944
- */
945
- /**
946
- * Evaluates the runtime debug activation layers (B and C) against the current
947
- * page URL.
948
- *
949
- * Returns the gate result. Callers can check `result.attach` to decide whether
950
- * to proceed with debug surface attachment.
951
- *
952
- * This function reads `window.location` only — both the hostname (Layer B1
953
- * host allowlist) and the search params (Layers B2 and C). Layer A
954
- * (build-time) is enforced by the consumer's `if (__DEBUG_BUILD__)` guard
955
- * around the import site, not here — see the file-level comment. Consumers
956
- * call this with no arguments, so the Layer B1 host check is picked up with
957
- * no change at the call site.
958
- */
80
+ /** Always reports a closed gate — nothing can attach from this package. */
959
81
  function checkDebugGate() {
960
- return evaluateDebugGate({
961
- hostname: window.location.hostname,
962
- searchParams: new URLSearchParams(window.location.search)
963
- });
82
+ warnOnce();
83
+ return {
84
+ attach: false,
85
+ reason: "moved-to-debug-console"
86
+ };
964
87
  }
965
88
  //#endregion
966
- export { BRIDGE_CALL_EVENT, checkDebugGate, deriveTargetScriptUrl, detachDebugSurface, evaluateDebugGate, installBridgeObserver, isPrivateAppsHost, isTossminiHost, isTrycloudflareHost, maybeAttach, mountEruda, reportWebViewType, uninstallBridgeObserver, unmountEruda };
89
+ export { checkDebugGate, detachDebugSurface, installBridgeObserver, maybeAttach, mountEruda, reportWebViewType, uninstallBridgeObserver, unmountEruda };
967
90
 
968
91
  //# sourceMappingURL=index.js.map