@ait-co/devtools 0.1.122 → 0.1.124

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 (78) hide show
  1. package/dist/{attach-orchestrator-EnZEr4Uu.js → attach-orchestrator-CpE0dMew.js} +2 -2
  2. package/dist/{attach-orchestrator-EnZEr4Uu.js.map → attach-orchestrator-CpE0dMew.js.map} +1 -1
  3. package/dist/{cell-3bIG9538.js → cell-Ba9lCI3B.js} +2 -2
  4. package/dist/{cell-3bIG9538.js.map → cell-Ba9lCI3B.js.map} +1 -1
  5. package/dist/{cell-Cad-vi7X.js → cell-CX92lDI0.js} +3 -3
  6. package/dist/{cell-CgKUkDxY.js.map → cell-CX92lDI0.js.map} +1 -1
  7. package/dist/{cell-CgKUkDxY.js → cell-D0dJSGHi.js} +2 -2
  8. package/dist/{cell-Cad-vi7X.js.map → cell-D0dJSGHi.js.map} +1 -1
  9. package/dist/{debug-server-D79E3hTa.js → debug-server-B3W_0Ejf.js} +6 -226
  10. package/dist/debug-server-B3W_0Ejf.js.map +1 -0
  11. package/dist/{debug-server-CF-1s3ks.js → debug-server-BDfeWytB.js} +6 -944
  12. package/dist/debug-server-BDfeWytB.js.map +1 -0
  13. package/dist/{debug-server-D_28CxC1.js → debug-server-BzzNkETg.js} +6 -6
  14. package/dist/{debug-server-D_28CxC1.js.map → debug-server-BzzNkETg.js.map} +1 -1
  15. package/dist/mcp/cli.js +8214 -4
  16. package/dist/mcp/cli.js.map +1 -1
  17. package/dist/mcp/server.js +1 -1
  18. package/dist/panel/index.js +1 -1
  19. package/dist/{qr-http-server-BsWlOA85.js → qr-http-server-BH0UFeUn.js} +11 -1
  20. package/dist/{qr-http-server-gHBd-z7T.js.map → qr-http-server-BH0UFeUn.js.map} +1 -1
  21. package/dist/{qr-http-server-UBV2qUEd.js → qr-http-server-Bo8LMOJQ.js} +11 -1
  22. package/dist/{qr-http-server-BJjw0hDr.js.map → qr-http-server-Bo8LMOJQ.js.map} +1 -1
  23. package/dist/{qr-http-server-CrVKno9V.cjs → qr-http-server-Bt_RAxFn.cjs} +11 -1
  24. package/dist/{qr-http-server-BpTvfeku.cjs.map → qr-http-server-Bt_RAxFn.cjs.map} +1 -1
  25. package/dist/{qr-http-server-CKbtqvKy.js → qr-http-server-CrGohOwC.js} +11 -1
  26. package/dist/{qr-http-server-CKbtqvKy.js.map → qr-http-server-CrGohOwC.js.map} +1 -1
  27. package/dist/{qr-http-server-BpTvfeku.cjs → qr-http-server-DM2R25jP.cjs} +11 -1
  28. package/dist/{qr-http-server-CrVKno9V.cjs.map → qr-http-server-DM2R25jP.cjs.map} +1 -1
  29. package/dist/{qr-http-server-BJjw0hDr.js → qr-http-server-s1iW_5VQ.js} +11 -1
  30. package/dist/{qr-http-server-BsWlOA85.js.map → qr-http-server-s1iW_5VQ.js.map} +1 -1
  31. package/dist/{relay-factory-BBnZM-an.js → relay-factory-CqY264j0.js} +22 -5
  32. package/dist/relay-factory-CqY264j0.js.map +1 -0
  33. package/dist/{relay-secret-store-BJjoxes1.js → relay-secret-store-CFc9n0OA.js} +1 -1
  34. package/dist/{relay-secret-store-BJjoxes1.js.map → relay-secret-store-CFc9n0OA.js.map} +1 -1
  35. package/dist/{relay-secret-store-CtK1eqoV.js → relay-secret-store-DhzAnnj-.js} +3 -3
  36. package/dist/{relay-secret-store-CtK1eqoV.js.map → relay-secret-store-DhzAnnj-.js.map} +1 -1
  37. package/dist/{relay-url-store-D3H067u0.js → relay-url-store-CwKT7i04.js} +2 -2
  38. package/dist/{relay-url-store-D3H067u0.js.map → relay-url-store-CwKT7i04.js.map} +1 -1
  39. package/dist/{relay-url-store-uyVNe-Az.js → relay-url-store-DGQ-HPQC.js} +2 -2
  40. package/dist/{relay-url-store-uyVNe-Az.js.map → relay-url-store-DGQ-HPQC.js.map} +1 -1
  41. package/dist/test-runner/bin.d.ts +2 -0
  42. package/dist/{cli-CaJ-vnBj.js → test-runner/bin.js} +49 -9
  43. package/dist/test-runner/bin.js.map +1 -0
  44. package/dist/test-runner/config.js +1 -1
  45. package/dist/test-runner/relay-factory.js +21 -4
  46. package/dist/test-runner/relay-factory.js.map +1 -1
  47. package/dist/totp-D1pulXLa.js +3 -0
  48. package/dist/{totp-0bvlo9Yb.js → totp-WY6l0ysP.js} +1 -1
  49. package/dist/{totp-0bvlo9Yb.js.map → totp-WY6l0ysP.js.map} +1 -1
  50. package/dist/{tunnel-B7U5k1xa.js → tunnel-BFwOSwT4.js} +2 -2
  51. package/dist/{tunnel-B7U5k1xa.js.map → tunnel-BFwOSwT4.js.map} +1 -1
  52. package/dist/{tunnel-C-cgMnyY.cjs → tunnel-DyEV6WwL.cjs} +2 -2
  53. package/dist/{tunnel-C-cgMnyY.cjs.map → tunnel-DyEV6WwL.cjs.map} +1 -1
  54. package/dist/unplugin/index.cjs +1 -1
  55. package/dist/unplugin/index.js +1 -1
  56. package/dist/unplugin/tunnel.cjs +1 -1
  57. package/dist/unplugin/tunnel.js +1 -1
  58. package/package.json +2 -2
  59. package/dist/attach-orchestrator-BYMCSPjf.js +0 -2797
  60. package/dist/attach-orchestrator-BYMCSPjf.js.map +0 -1
  61. package/dist/attach-orchestrator-LOjvsE9R.js +0 -3
  62. package/dist/cell-Bfbke0cT.js +0 -3
  63. package/dist/cell-SKNUQTr8.js +0 -85
  64. package/dist/cell-SKNUQTr8.js.map +0 -1
  65. package/dist/cli-CaJ-vnBj.js.map +0 -1
  66. package/dist/debug-server-CF-1s3ks.js.map +0 -1
  67. package/dist/debug-server-D79E3hTa.js.map +0 -1
  68. package/dist/debug-server-IVaJ9vHH.js +0 -4278
  69. package/dist/debug-server-IVaJ9vHH.js.map +0 -1
  70. package/dist/qr-http-server-BpcDt-cp.js +0 -3
  71. package/dist/qr-http-server-UBV2qUEd.js.map +0 -1
  72. package/dist/qr-http-server-gHBd-z7T.js +0 -1506
  73. package/dist/relay-factory-BBnZM-an.js.map +0 -1
  74. package/dist/relay-secret-store-BIM_0y18.js +0 -3
  75. package/dist/test-runner/cli.d.ts +0 -547
  76. package/dist/test-runner/cli.d.ts.map +0 -1
  77. package/dist/test-runner/cli.js +0 -3
  78. package/dist/totp-zGIlDsZS.js +0 -3
@@ -1,4278 +0,0 @@
1
- #!/usr/bin/env node
2
- import { $ as buildDeepLinkAttachUrl, A as isDebugToolName, B as readServerLock, C as getDiagnostics, D as getSdkCallHistory, E as getOperationalEnvironment, F as listPages, G as logWarn, H as isRelayEnv, I as measureSafeArea, J as pageCrashError, K as classifyToolError, L as takeScreenshot, M as listConsoleMessages, N as listExceptions, O as getToolAvailability, P as listNetworkRequests, Q as tierRejectionError, R as takeSnapshot, S as filterToolsByEnvironment, T as getMockState, U as logError, V as deriveEnvironment, W as logInfo, X as relayDisconnectError, Y as pageMissingError, Z as sdkAbsentError, _ as BOOTSTRAP_TOOL_NAMES, b as callSdk, et as buildLauncherAttachUrl, f as generateAttachToken, g as startTunnelHealthProbe, h as startQuickTunnel, j as isToolAvailableIn, k as isAitToolName, l as prepareAttach, m as printAttachBanner, nt as startParentWatcher, p as makeTunnelStatus, q as mcpError, s as isSandboxPageFresh, t as RELAY_SANDBOX_STALE_PAGE_MS, tt as startMaxAgeWatchdog, u as renderAndMaybeWait, v as DEBUG_TOOL_DEFINITIONS, w as getDomDocument, x as evaluate, y as InMemoryDiagnosticsCollector, z as acquireLock } from "./attach-orchestrator-BYMCSPjf.js";
3
- import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "./totp-0bvlo9Yb.js";
4
- import { n as injectGlobals, t as injectDebugIndicator } from "./cell-SKNUQTr8.js";
5
- import { t as startQrHttpServer } from "./qr-http-server-gHBd-z7T.js";
6
- import { n as loadRelaySecretReadOnly } from "./relay-secret-store-CtK1eqoV.js";
7
- import { createRequire } from "node:module";
8
- import { accessSync, existsSync } from "node:fs";
9
- import { fileURLToPath } from "node:url";
10
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
13
- import { platform } from "node:os";
14
- import * as path$1 from "node:path";
15
- import path, { isAbsolute, resolve } from "node:path";
16
- import { parseArgs } from "node:util";
17
- import * as fs from "node:fs/promises";
18
- import { glob, mkdir, writeFile } from "node:fs/promises";
19
- import { EventEmitter } from "node:events";
20
- import { WebSocket, WebSocketServer } from "ws";
21
- import { createServer } from "node:http";
22
- import { spawn } from "node:child_process";
23
- import net from "node:net";
24
- //#region \0rolldown/runtime.js
25
- var __defProp = Object.defineProperty;
26
- var __exportAll = (all, no_symbols) => {
27
- let target = {};
28
- for (var name in all) __defProp(target, name, {
29
- get: all[name],
30
- enumerable: true
31
- });
32
- if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
33
- return target;
34
- };
35
- var __require = /* @__PURE__ */ createRequire(import.meta.url);
36
- //#endregion
37
- //#region src/in-app/gate.ts
38
- /**
39
- * The host suffix the Toss app uses to serve dogfood / private mini-apps.
40
- *
41
- * A `intoss-private://` (dogfood) entry maps to a host such as
42
- * `aitc-sdk-example.private-apps.tossmini.com`. A production `intoss://`
43
- * entry is served from `*.apps.tossmini.com` — the `.private-apps.` segment
44
- * is absent. Confirmed live over CDP for mini-app 31146; the exact production
45
- * host is to be re-confirmed once 31146 passes review (spec open question 2).
46
- */
47
- const PRIVATE_APPS_HOST_SUFFIX = ".private-apps.tossmini.com";
48
- /**
49
- * The host suffix Cloudflare quick-tunnels serve from — the env 2 (PWA) entry.
50
- * See {@link isTrycloudflareHost} for why this host kind bypasses Layer B1.
51
- */
52
- const TRYCLOUDFLARE_HOST_SUFFIX = ".trycloudflare.com";
53
- /**
54
- * Returns whether `hostname` is a `*.private-apps.tossmini.com` subdomain —
55
- * the host the Toss app reserves for dogfood / private mini-app entries.
56
- *
57
- * The match is an exact suffix check, not a substring `.includes()`: a
58
- * substring test would also accept an attacker-controlled host like
59
- * `private-apps.tossmini.com.evil.example`, which ends in `.example`, not in
60
- * `.tossmini.com`. Requiring the string to END with the suffix closes that.
61
- * The leading `.` in the suffix also forces a real subdomain label, so a
62
- * bare `private-apps.tossmini.com` (no mini-app subdomain) does not match.
63
- */
64
- function isPrivateAppsHost(hostname) {
65
- return hostname.endsWith(PRIVATE_APPS_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
- * - *.private-apps.tossmini.com (env 3 dog-food)
113
- *
114
- * Any other host (including apps.tossmini.com — the former env 4 LIVE host)
115
- * is silently blocked. This is a positive allowlist — unlisted hosts never
116
- * had debug surface regardless, but this function makes it explicit and
117
- * auditable in a single place.
118
- *
119
- * SECRET-HANDLING: the hostname value MUST NOT be logged or included in any
120
- * error reason string — only benign labels ('host not in allowlist') are safe.
121
- */
122
- function isDebugAllowedHost(hostname) {
123
- return isLocalhostHost(hostname) || isTrycloudflareHost(hostname) || isPrivateAppsHost(hostname);
124
- }
125
- //#endregion
126
- //#region src/test-runner/discover.ts
127
- /**
128
- * Test-file discovery shared by the `devtools-test` CLI and the `run_tests`
129
- * MCP tool, so both expand glob patterns with identical semantics.
130
- *
131
- * Uses Node's built-in `fs/promises` `glob` (Node 22+) — no extra dependency,
132
- * which keeps the MCP daemon install graph lean (a plain glob lib would land in
133
- * the `npx … devtools-mcp` path for no benefit).
134
- *
135
- * Pure Node IO only (`node:fs/promises` + `node:path`) — react-free, so it is
136
- * safe to import from the MCP daemon graph.
137
- */
138
- /**
139
- * Expands `patterns` (globs or plain paths) into a sorted, de-duplicated list of
140
- * ABSOLUTE test file paths, resolved relative to `cwd`.
141
- *
142
- * A plain (non-glob) path passes through when it matches a real file; a glob
143
- * expands against `cwd`. Absolute matches are kept as-is; relative matches are
144
- * resolved against `cwd`. `bundleTestFile` requires an absolute path, so the
145
- * absolute output feeds it directly.
146
- *
147
- * @param patterns Glob patterns or file paths (e.g. `['src/**\/*.ait.test.ts']`).
148
- * @param cwd Base directory for relative patterns/results.
149
- * @returns Sorted, de-duplicated absolute file paths. Empty when nothing matches.
150
- */
151
- async function discoverTestFiles(patterns, cwd) {
152
- const out = /* @__PURE__ */ new Set();
153
- for await (const match of glob(patterns, { cwd })) out.add(isAbsolute(match) ? match : resolve(cwd, match));
154
- return [...out].sort();
155
- }
156
- //#endregion
157
- //#region src/test-runner/relay-factory.ts
158
- /**
159
- * Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay
160
- * connection.
161
- *
162
- * `open()` performs the full attach lifecycle and BLOCKS for tens of seconds (up
163
- * to `timeoutMs`) while a human scans the rendered QR with their phone — there
164
- * is no way around the manual scan for env3. It resolves with the live
165
- * `CdpConnection` once a matching page attaches; `close()` tears the relay
166
- * family down.
167
- *
168
- * The factory holds the booted relay family in a closure so `close()` can stop
169
- * it. A second `open()` on the same factory boots a fresh family (the previous
170
- * one should have been `close()`d first).
171
- */
172
- function createRelayConnectionFactory(opts) {
173
- const projectRoot = opts.projectRoot ?? process.cwd();
174
- const timeoutMs = opts.timeoutMs ?? 6e5;
175
- const headless = opts.headless === true;
176
- let family;
177
- let qrServer;
178
- return {
179
- async open() {
180
- const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("./attach-orchestrator-LOjvsE9R.js");
181
- const { injectDebugIndicator, injectGlobals } = await import("./cell-Bfbke0cT.js");
182
- const { loadRelaySecretReadOnly } = await import("./relay-secret-store-BIM_0y18.js");
183
- const { bootRelayFamily, buildRelayVerifyAuth } = await Promise.resolve().then(() => debug_server_exports);
184
- await loadRelaySecretReadOnly({ projectRoot });
185
- const booted = await bootRelayFamily({ verifyAuth: buildRelayVerifyAuth() });
186
- family = booted;
187
- let lastAttachParts;
188
- const attachDeps = {
189
- getTunnelStatus: booted.getTunnelStatus ?? (() => ({
190
- up: false,
191
- wssUrl: null
192
- })),
193
- getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
194
- qrHttpServer: void 0,
195
- onAttachUrlBuilt: void 0,
196
- canOpenBrowser: () => !headless
197
- };
198
- try {
199
- const { startQrHttpServer } = await import("./qr-http-server-BpcDt-cp.js");
200
- const getDashboardState = () => ({
201
- tunnel: attachDeps.getTunnelStatus(),
202
- pages: null,
203
- attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,
204
- mode: "relay-dev"
205
- });
206
- qrServer = await startQrHttpServer(getDashboardState);
207
- attachDeps.qrHttpServer = qrServer;
208
- attachDeps.onAttachUrlBuilt = (parts) => {
209
- lastAttachParts = parts;
210
- qrServer?.notifyStateChange();
211
- };
212
- process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\n`);
213
- } catch {}
214
- const prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
215
- if (!prep.ok) {
216
- booted.stop();
217
- family = void 0;
218
- throw new Error("createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up");
219
- }
220
- const waitResult = await renderAndMaybeWait(attachDeps, prep, true, timeoutMs, booted.connection);
221
- if (waitResult.isError) {
222
- booted.stop();
223
- family = void 0;
224
- const timeoutSec = Math.round(timeoutMs / 1e3);
225
- throw new Error(`createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`);
226
- }
227
- const qrChunks = waitResult.content.filter((c) => c.type === "text").map((c) => c.text);
228
- opts.onQrContent(qrChunks);
229
- await injectDebugIndicator(booted.connection);
230
- if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
231
- await booted.connection.enableDomains();
232
- return booted.connection;
233
- },
234
- async close(_connection) {
235
- family?.stop();
236
- family = void 0;
237
- await qrServer?.close();
238
- qrServer = void 0;
239
- }
240
- };
241
- }
242
- //#endregion
243
- //#region src/test-runner/bundle.ts
244
- /**
245
- * esbuild-based bundler for user test files.
246
- *
247
- * Bundles a single test file into a self-contained IIFE string that can be
248
- * injected into a WebView via `Runtime.evaluate`. The bundle includes the
249
- * test runtime (`runtime.ts`), which provides `describe/it/test/expect` and
250
- * the `runTestModule(factory)` entry point.
251
- *
252
- * ## How the wiring works
253
- *
254
- * The bundle exposes two exports on `globalThis.__testBundle`:
255
- * - `runTestModule` — the runtime's entry function.
256
- * - `__userFactory` — an async function whose body is the user's top-level
257
- * test registration code (describe/it/test calls).
258
- *
259
- * The Node-side RPC (`rpc.ts`) calls:
260
- * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
261
- *
262
- * `runTestModule` then installs `describe/it/test/expect` as globals, invokes
263
- * the factory (which registers all tests), runs them, and returns a `RunReport`.
264
- *
265
- * ## Why a factory wrapper is needed
266
- *
267
- * Naively adding the runtime to `entryPoints` and bundling the user file would
268
- * fail for two reasons:
269
- * 1. `describe/it/test/expect` from the runtime are module-local in the IIFE
270
- * scope. The user's top-level `describe(...)` calls expect them as globals —
271
- * they are not globals until `runTestModule` installs them.
272
- * 2. Even with globals pre-installed, the user file runs at IIFE-evaluation
273
- * time, before the RPC layer calls `runTestModule` to reset state and start
274
- * the test clock.
275
- *
276
- * The factory approach solves both: the user's registration code is deferred
277
- * into a function that `runTestModule` calls AFTER installing the globals.
278
- *
279
- * ## Factory extraction algorithm
280
- *
281
- * The `userFactoryPlugin` reads the user file and splits lines into:
282
- * - **top-level**: `import …` and re-export lines — kept at module scope
283
- * (the only valid position for static `import` in ESM).
284
- * - **body**: all other statements — moved into the body of the exported
285
- * `__userFactory` async function.
286
- *
287
- * esbuild processes the re-generated module, following each static import
288
- * through the normal dependency graph (including the SDK-redirect plugin).
289
- *
290
- * ## SDK redirect
291
- *
292
- * Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via
293
- * the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that
294
- * `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.
295
- *
296
- * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
297
- */
298
- /** The SDK package name that mini-app test code imports from. */
299
- const SDK_PACKAGE = "@apps-in-toss/web-framework";
300
- /**
301
- * Names the runtime installs as globals before invoking the user factory.
302
- * The `vitest` virtual module re-exports each as a lazy getter that reads from
303
- * `globalThis` at access time. Keep in sync with the globals installed in
304
- * `runtime.ts#runTestModule`.
305
- */
306
- const VITEST_GLOBAL_NAMES = [
307
- "describe",
308
- "it",
309
- "test",
310
- "expect",
311
- "beforeAll",
312
- "afterAll",
313
- "beforeEach",
314
- "afterEach",
315
- "vi"
316
- ];
317
- /**
318
- * Matches the bare SDK package and any sub-path import
319
- * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
320
- * Built from {@link SDK_PACKAGE} so the package name has a single source.
321
- */
322
- const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
323
- /**
324
- * esbuild plugin that intercepts SDK imports and redirects them to the
325
- * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
326
- *
327
- * Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),
328
- * esbuild resolves it to a virtual module that re-exports all named exports
329
- * via `window.__sdk[name]`. This avoids bundling the real SDK (which may not
330
- * be available in the test environment) while still making named imports work.
331
- *
332
- * If `window.__sdk` is absent (non-dog-food build), every access throws a
333
- * descriptive error rather than returning `undefined` silently.
334
- */
335
- function sdkRedirectPlugin() {
336
- return {
337
- name: "sdk-redirect",
338
- setup(build) {
339
- build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({
340
- path: args.path,
341
- namespace: "sdk-redirect"
342
- }));
343
- build.onLoad({
344
- filter: /.*/,
345
- namespace: "sdk-redirect"
346
- }, () => ({
347
- contents: `
348
- var __proxy = (typeof window !== 'undefined' && window.__sdk)
349
- ? window.__sdk
350
- : new Proxy({}, {
351
- get: function(_t, p) {
352
- throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
353
- }
354
- });
355
- module.exports = __proxy;
356
- `,
357
- loader: "js"
358
- }));
359
- }
360
- };
361
- }
362
- /**
363
- * esbuild plugin that intercepts `import … from 'vitest'` and replaces it with
364
- * a virtual module that delegates every named import to `globalThis` at ACCESS
365
- * time (not at bundle-evaluation time).
366
- *
367
- * The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/
368
- * afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle
369
- * IIFE is evaluated. A value-copy redirect (`export var describe =
370
- * globalThis.describe`) would therefore capture `undefined` at evaluation time
371
- * and the user's `describe(...)` calls would be no-ops — registering zero tests.
372
- *
373
- * The fix defers the lookup to call time using per-name **getter** exports.
374
- * We emit a CommonJS module that:
375
- * 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named
376
- * import directly to a property access on the module (NOT wrapped under a
377
- * `default` shim — which is what happens for a bare Proxy whose own-keys
378
- * are empty, leaving every named import `undefined`);
379
- * 2. defines each global name as a getter that reads `globalThis[name]` on
380
- * every access. So `import { describe } from 'vitest'` compiles to
381
- * `import_vitest.describe`, whose getter returns the real `describe` only
382
- * when the factory calls it — after `runTestModule` installs the globals.
383
- *
384
- * A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes
385
- * the virtual module through `__toESM`, which enumerates own-keys (none on an
386
- * empty Proxy target) and therefore exposes zero named exports. Explicit getter
387
- * properties give `__toESM` real keys to map while keeping access lazy.
388
- */
389
- function vitestRedirectPlugin() {
390
- return {
391
- name: "vitest-redirect",
392
- setup(build) {
393
- build.onResolve({ filter: /^vitest$/ }, () => ({
394
- path: "vitest",
395
- namespace: "vitest-redirect"
396
- }));
397
- build.onLoad({
398
- filter: /^vitest$/,
399
- namespace: "vitest-redirect"
400
- }, () => {
401
- return {
402
- contents: `Object.defineProperty(exports, '__esModule', { value: true });\n${VITEST_GLOBAL_NAMES.map((name) => `Object.defineProperty(exports, ${JSON.stringify(name)}, { enumerable: true, get: function() { return globalThis[${JSON.stringify(name)}]; } });`).join("\n")}\n`,
403
- loader: "js"
404
- };
405
- });
406
- }
407
- };
408
- }
409
- /**
410
- * esbuild plugin that transforms the user test file into a module that exports
411
- * an async `__userFactory` function. The factory defers the user's top-level
412
- * test registration code (describe/it/test calls) so it only runs when
413
- * `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has
414
- * installed describe/it/test/expect as globals.
415
- *
416
- * Algorithm:
417
- * - Import declarations and re-export statements are kept at module top-level
418
- * (the only valid ESM position for static `import`). A statement that spans
419
- * multiple lines — e.g. a named import with one member per line:
420
- * import {
421
- * appLogin,
422
- * getAnonymousKey,
423
- * } from '@apps-in-toss/web-framework';
424
- * is tracked as a single block: every line from the opening `import {` /
425
- * `export {` through the closing `from '…'` (or side-effect `'…'`) line is
426
- * kept together at top-level. This prevents the member lines and the
427
- * closing `} from '…'` line from leaking into the factory body, which would
428
- * leave an unterminated `import {` at module scope (the #678 env3 failure:
429
- * esbuild threw `Expected "as" but found "{"` on multi-line SDK imports).
430
- * - All other lines (describe/it/test calls, local declarations, etc.) are
431
- * moved into the body of the exported async factory function.
432
- *
433
- * This preserves SDK import resolution (the sdk-redirect plugin processes
434
- * top-level imports normally) while deferring test registration to the factory.
435
- */
436
- function userFactoryPlugin(absPath) {
437
- const NAMESPACE = "user-test-factory";
438
- return {
439
- name: "user-test-factory",
440
- setup(build) {
441
- build.onResolve({ filter: /^user-test-factory$/ }, () => ({
442
- path: absPath,
443
- namespace: NAMESPACE
444
- }));
445
- build.onLoad({
446
- filter: /.*/,
447
- namespace: NAMESPACE
448
- }, async (args) => {
449
- const lines = (await fs.readFile(args.path, "utf8")).split("\n");
450
- const topLevelLines = [];
451
- const bodyLines = [];
452
- const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
453
- const isImportStart = (trimmed) => trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"");
454
- const endsStatement = (trimmed) => /['"]\s*;?\s*$/.test(trimmed.replace(/\/\/.*$/, "").trimEnd());
455
- let inImportBlock = false;
456
- for (const line of lines) {
457
- const trimmed = line.trimStart();
458
- const indent = line.slice(0, line.length - trimmed.length);
459
- if (inImportBlock) {
460
- topLevelLines.push(line);
461
- if (endsStatement(trimmed)) inImportBlock = false;
462
- continue;
463
- }
464
- if (isImportStart(trimmed)) {
465
- topLevelLines.push(line);
466
- if (!endsStatement(trimmed)) inImportBlock = true;
467
- } else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
468
- else {
469
- topLevelLines.push(line);
470
- if (/\bfrom\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith("{")) inImportBlock = true;
471
- }
472
- else bodyLines.push(line);
473
- }
474
- return {
475
- contents: [
476
- ...topLevelLines,
477
- "",
478
- "// biome-ignore lint: generated factory wrapper",
479
- "export default async function __userFactory(): Promise<void> {",
480
- ...bodyLines.map((l) => ` ${l}`),
481
- "}"
482
- ].join("\n"),
483
- loader: "ts",
484
- resolveDir: path$1.dirname(absPath)
485
- };
486
- });
487
- }
488
- };
489
- }
490
- /**
491
- * Returns the absolute filesystem path to the test-runner runtime module
492
- * (dist/test-runner/runtime.js — a fully self-contained page-side bundle).
493
- *
494
- * Rolldown code-splitting duplicates this bundling logic into shared chunks
495
- * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
496
- * dist/test-runner/bundle.js (dir = dist/test-runner/), while the `devtools-mcp`
497
- * daemon (dist/mcp/cli.js) pulls it through a ROOT chunk
498
- * (dist/debug-server-<hash>.js, dir = dist/). A fixed `..`-hop candidate list
499
- * is therefore wrong from at least one chunk — the live #697 regression.
500
- *
501
- * This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it
502
- * probes the co-located `runtime.js` and the nested `test-runner/runtime.js`,
503
- * then ascends one directory at a time (bounded) repeating both probes. The
504
- * nested probe catches dist/test-runner/runtime.js from the dist/ root level no
505
- * matter which depth the chunk was hoisted to (root, dist/mcp/, or a future
506
- * relocation). The build always emits dist/test-runner/runtime.js (tsdown entry
507
- * `'test-runner/runtime'`; guarded by scripts/check-test-runner-dist.sh).
508
- *
509
- * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
510
- * read, bypassing Node module resolution entirely, so this works identically in
511
- * the npx-daemon context (its own dist tree) and the consumer-CLI context
512
- * (the mini-app's installed @ait-co/devtools dist) — neither needs the package
513
- * to be node-resolvable from the caller.
514
- */
515
- function getRuntimePath() {
516
- const startDir = path$1.dirname(fileURLToPath(import.meta.url));
517
- const RELATIVE_PROBES = [
518
- ["runtime.js"],
519
- ["test-runner", "runtime.js"],
520
- ["runtime.ts"],
521
- ["test-runner", "runtime.ts"]
522
- ];
523
- let dir = startDir;
524
- for (let i = 0; i < 12; i++) {
525
- for (const segs of RELATIVE_PROBES) {
526
- const candidate = path$1.join(dir, ...segs);
527
- try {
528
- accessSync(candidate);
529
- return candidate;
530
- } catch {}
531
- }
532
- const parent = path$1.dirname(dir);
533
- if (parent === dir) break;
534
- dir = parent;
535
- }
536
- return path$1.join(startDir, "runtime.js");
537
- }
538
- /**
539
- * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
540
- *
541
- * The IIFE installs `window.__testBundle` (or the custom `globalName`) with:
542
- * - `runTestModule` — the runtime entry (from `runtime.ts`).
543
- * - `__userFactory` — an async function wrapping the user's test registration
544
- * code so it runs AFTER `runTestModule` installs the globals.
545
- *
546
- * Callers (rpc.ts) invoke:
547
- * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
548
- *
549
- * @param absPath - Absolute path to the user test file.
550
- * @param opts - Optional bundling overrides.
551
- */
552
- async function bundleTestFile(absPath, opts) {
553
- const globalName = opts?.globalName ?? "__testBundle";
554
- const extraExternals = opts?.extraExternals ?? [];
555
- const esbuild = await import("esbuild");
556
- const runtimePath = getRuntimePath();
557
- const wrapperContent = [
558
- `import { runTestModule } from ${JSON.stringify(runtimePath)};`,
559
- `import __userFactory from "user-test-factory";`,
560
- `export { runTestModule, __userFactory };`
561
- ].join("\n");
562
- const result = await esbuild.build({
563
- stdin: {
564
- contents: wrapperContent,
565
- loader: "ts",
566
- resolveDir: path$1.dirname(absPath)
567
- },
568
- bundle: true,
569
- format: "iife",
570
- globalName,
571
- platform: "browser",
572
- target: "es2022",
573
- write: false,
574
- plugins: [
575
- userFactoryPlugin(absPath),
576
- vitestRedirectPlugin(),
577
- sdkRedirectPlugin()
578
- ],
579
- external: extraExternals,
580
- treeShaking: true,
581
- footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
582
- });
583
- const warnings = result.warnings.map((w) => `${path$1.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
584
- const outputFile = result.outputFiles?.[0];
585
- if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
586
- return {
587
- code: outputFile.text,
588
- warnings
589
- };
590
- }
591
- //#endregion
592
- //#region src/test-runner/capture.ts
593
- /** The exact console-line prefix sdk-example's `flushCapture` emits. */
594
- const CAPTURE_PREFIX = "__AIT_CAPTURE__ ";
595
- /**
596
- * Parses raw console line texts into {@link AitCaptureLine}s.
597
- *
598
- * Filtering rules (each independently drops a line — never throws):
599
- * - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);
600
- * - there must be a non-empty category token (up to the next space);
601
- * - the remaining payload must be valid JSON (`JSON.parse` succeeds).
602
- *
603
- * Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are
604
- * silently discarded — capture harvesting is best-effort and must never fail a
605
- * run or leak a malformed/secret-bearing line.
606
- *
607
- * @param raw - Console line objects (only `.text` is read).
608
- * @returns The captured lines, in input order.
609
- */
610
- function parseCaptureLines(raw) {
611
- const out = [];
612
- for (const { text } of raw) {
613
- if (!text.startsWith(CAPTURE_PREFIX)) continue;
614
- const body = text.slice(16);
615
- const spaceIdx = body.indexOf(" ");
616
- if (spaceIdx === -1) continue;
617
- const category = body.slice(0, spaceIdx);
618
- const json = body.slice(spaceIdx + 1);
619
- if (category === "" || json === "") continue;
620
- try {
621
- JSON.parse(json);
622
- } catch {
623
- continue;
624
- }
625
- out.push({
626
- category,
627
- json
628
- });
629
- }
630
- return out;
631
- }
632
- //#endregion
633
- //#region src/test-runner/rpc.ts
634
- /** Maximum milliseconds to wait for a single evaluate round-trip. */
635
- const DEFAULT_TIMEOUT_MS = 3e4;
636
- /**
637
- * Wraps bundle code in a self-executing IIFE that:
638
- * 1. Evaluates the bundle (registering describe/it/test).
639
- * 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.
640
- * 3. Returns a JSON-serialised `RunReport` string.
641
- *
642
- * The double-serialisation (RunReport → JSON string → returnByValue string)
643
- * is intentional: CDP `returnByValue` reliably transports strings; deeply
644
- * nested objects can lose fidelity across the Chii relay.
645
- *
646
- * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
647
- */
648
- function buildRunTestsExpression(bundleCode) {
649
- return `(async () => { try { ${bundleCode} } catch(e) { return JSON.stringify({ok:false,error:'bundle-eval: ' + String(e && e.message || e)}); } if (typeof globalThis.__testBundle !== 'object' || typeof globalThis.__testBundle.runTestModule !== 'function' || typeof globalThis.__testBundle.__userFactory !== 'function') { return JSON.stringify({ok:false,error:'bundle-missing-export: __testBundle.runTestModule or __userFactory is not a function'}); } try { const report = await globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory); return JSON.stringify({ok:true,value:report}); } catch(e) { return JSON.stringify({ok:false,error:'test-run: ' + String(e && e.message || e)}); }})()`;
650
- }
651
- /**
652
- * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
653
- * evaluate call into a typed `RpcRunResult`.
654
- *
655
- * Throws only on parse failure — an `ok:false` envelope is a normal result.
656
- *
657
- * SECRET-HANDLING: `rawValue` is not included in error messages.
658
- */
659
- function parseRunTestsResult(rawValue) {
660
- if (typeof rawValue !== "string") throw new Error(`rpc.parseRunTestsResult: unexpected return type "${typeof rawValue}" — expected JSON string`);
661
- let parsed;
662
- try {
663
- parsed = JSON.parse(rawValue);
664
- } catch {
665
- throw new Error("rpc.parseRunTestsResult: bridge returned non-JSON string");
666
- }
667
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("rpc.parseRunTestsResult: parsed result is not an object");
668
- const obj = parsed;
669
- if (obj.ok === true) return {
670
- ok: true,
671
- report: obj.value
672
- };
673
- if (obj.ok === false) return {
674
- ok: false,
675
- error: typeof obj.error === "string" ? obj.error : String(obj.error)
676
- };
677
- throw new Error("rpc.parseRunTestsResult: result missing \"ok\" field");
678
- }
679
- /**
680
- * Injects `bundleCode` into the attached page and awaits test execution.
681
- *
682
- * Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the
683
- * async IIFE to settle. The 30-second CDP command timeout covers even
684
- * long-running test suites; split into smaller files if you hit it.
685
- *
686
- * @param connection - Active CDP connection (relay or local).
687
- * @param bundleCode - IIFE bundle string from `bundleTestFile`.
688
- * @param timeoutMs - Override the default 30 s timeout.
689
- *
690
- * SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.
691
- */
692
- async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
693
- const expression = buildRunTestsExpression(bundleCode);
694
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`rpc: evaluate timed out after ${timeoutMs}ms`)), timeoutMs));
695
- const evalPromise = connection.send("Runtime.evaluate", {
696
- expression,
697
- returnByValue: true,
698
- awaitPromise: true
699
- });
700
- const cdpResult = await Promise.race([evalPromise, timeoutPromise]);
701
- if (cdpResult.exceptionDetails) {
702
- const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
703
- throw new Error(`rpc.injectAndRunBundle: ${msg}`);
704
- }
705
- return parseRunTestsResult(cdpResult.result.value);
706
- }
707
- //#endregion
708
- //#region src/test-runner/relay-worker.ts
709
- /**
710
- * Runs all `files` sequentially over the given CDP `connection`.
711
- *
712
- * For each file:
713
- * 1. Bundle with esbuild (includes SDK shim + runtime).
714
- * 2. Inject into the attached page via `Runtime.evaluate`.
715
- * 3. Await the `RunReport` JSON response.
716
- * 4. Accumulate results.
717
- *
718
- * Returns a `RelayRunReport` with per-file results and flattened totals.
719
- *
720
- * This function does NOT open or manage the relay connection — the caller
721
- * is responsible for attaching and closing it.
722
- *
723
- * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
724
- * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
725
- *
726
- * @param connection - Active CDP connection (relay or local kind).
727
- * @param files - Absolute paths to test files, run in order.
728
- * @param opts - Optional per-run overrides.
729
- */
730
- async function runTestFilesOverRelay(connection, files, opts) {
731
- const wallStart = Date.now();
732
- const startedAt = new Date(wallStart).toISOString();
733
- const fileResults = [];
734
- let domainsEnabled = false;
735
- try {
736
- await connection.enableDomains();
737
- domainsEnabled = true;
738
- } catch (e) {
739
- process.stderr.write(`relay-worker: enableDomains() failed before run — console capture may be empty (${e instanceof Error ? e.message : String(e)})\n`);
740
- }
741
- const collectCaptures = opts?.collectCaptures === true;
742
- const liveConsole = [];
743
- let unsubscribeConsole;
744
- if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
745
- liveConsole.push(event);
746
- });
747
- try {
748
- for (const file of files) {
749
- let fileEntry;
750
- try {
751
- const { code } = await bundleTestFile(file, opts?.bundleOptions);
752
- const rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
753
- if (rpcResult.ok) fileEntry = {
754
- file,
755
- result: rpcResult.report
756
- };
757
- else fileEntry = {
758
- file,
759
- result: { error: rpcResult.error }
760
- };
761
- } catch (e) {
762
- fileEntry = {
763
- file,
764
- result: { error: e instanceof Error ? e.message : String(e) }
765
- };
766
- }
767
- fileResults.push(fileEntry);
768
- }
769
- } finally {
770
- unsubscribeConsole?.();
771
- }
772
- const captures = collectCaptures ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) }))) : [];
773
- const totals = fileResults.reduce((acc, { result }) => {
774
- if ("error" in result) {
775
- acc.failed += 1;
776
- acc.total += 1;
777
- } else {
778
- acc.passed += result.passed;
779
- acc.failed += result.failed;
780
- acc.skipped += result.skipped;
781
- acc.total += result.passed + result.failed + result.skipped;
782
- }
783
- return acc;
784
- }, {
785
- passed: 0,
786
- failed: 0,
787
- skipped: 0,
788
- total: 0
789
- });
790
- return {
791
- startedAt,
792
- duration: Date.now() - wallStart,
793
- files: fileResults,
794
- totals,
795
- captures
796
- };
797
- }
798
- /**
799
- * Renders one `Runtime.consoleAPICalled` event to a single line of text, the
800
- * same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-
801
- * joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,
802
- * which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)
803
- * onto the test-runner entry.
804
- *
805
- * SECRET-HANDLING: this only stringifies console args; the caller's
806
- * allowlist-prefix parser then discards everything that is not a genuine
807
- * `__AIT_CAPTURE__` line.
808
- */
809
- function renderConsoleLineText(event) {
810
- return event.args.map((arg) => {
811
- if (arg.value !== void 0) {
812
- if (typeof arg.value === "string") return arg.value;
813
- try {
814
- return JSON.stringify(arg.value);
815
- } catch {
816
- return String(arg.value);
817
- }
818
- }
819
- if (arg.description !== void 0) return arg.description;
820
- if (arg.className !== void 0) return arg.className;
821
- return arg.subtype ?? arg.type;
822
- }).join(" ");
823
- }
824
- //#endregion
825
- //#region src/test-runner/report.ts
826
- /**
827
- * Runner-agnostic report serialisation for env3 test runs (devtools#696).
828
- *
829
- * Both env3 execution paths — the Vitest custom pool (`pool.ts`) and the
830
- * standalone `devtools-test` CLI (`cli.ts`) — call the same core
831
- * `runTestFilesOverRelay` and so produce the same {@link RelayRunReport}. This
832
- * module is the single, runner-neutral place that turns that in-memory report
833
- * into a stable on-disk artifact so a 2.x run and a 3.0 run can be diffed
834
- * cell-by-cell after the fact.
835
- *
836
- * The serialised schema is deliberately MINIMAL and secret-free:
837
- *
838
- * - file paths are stored RELATIVE to `projectRoot` (no absolute `/Users/...`
839
- * leakage — see {@link RunnerAgnosticReport.files});
840
- * - the cell metadata (sdkLine/platform) is baked INTO the body, not only the
841
- * filename, so a moved artifact never loses its provenance;
842
- * - NO relay wss / scheme / TOTP / relayUrl fields exist in the schema at all
843
- * (enforced by the type + this comment) — error strings are the matcher
844
- * message only, inherited from rpc.ts which already strips expression/value.
845
- *
846
- * react-free — depends only on the type-level `RelayRunReport` and `node:fs` /
847
- * `node:path`. Safe to bundle without pulling the chii/cloudflared graph.
848
- */
849
- /**
850
- * Converts an absolute (or already-relative) file path to a projectRoot-relative
851
- * one. `path.relative` returns `''` when the paths are equal — guard that to the
852
- * basename so the field is never empty.
853
- *
854
- * SECRET-HANDLING: this is the single choke point that strips absolute project
855
- * paths from the artifact.
856
- */
857
- function relativise(projectRoot, file) {
858
- const rel = path.relative(projectRoot, file);
859
- if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return path.basename(file);
860
- return rel;
861
- }
862
- /**
863
- * Serialises a {@link RelayRunReport} into the runner-agnostic, secret-free
864
- * on-disk shape. Pure — no IO; testable with a plain report + meta.
865
- *
866
- * @param report - The core relay run report.
867
- * @param meta - Cell axes + projectRoot (projectRoot is consumed, not stored).
868
- */
869
- function serializeRelayReport(report, meta) {
870
- return {
871
- cell: {
872
- sdkLine: meta.sdkLine,
873
- platform: meta.platform
874
- },
875
- startedAt: report.startedAt,
876
- duration: report.duration,
877
- totals: report.totals,
878
- files: report.files.map((f) => {
879
- const file = relativise(meta.projectRoot, f.file);
880
- if ("error" in f.result) return {
881
- file,
882
- error: f.result.error
883
- };
884
- return {
885
- file,
886
- duration: f.result.duration,
887
- passed: f.result.passed,
888
- failed: f.result.failed,
889
- skipped: f.result.skipped,
890
- tests: f.result.tests
891
- };
892
- })
893
- };
894
- }
895
- /**
896
- * Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating
897
- * `dir` if needed. Returns the absolute path written.
898
- *
899
- * The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as
900
- * distinct artifacts in the same directory; the same cell metadata is also baked
901
- * into the body so a renamed/moved file still carries its provenance.
902
- *
903
- * SECRET-HANDLING: the written body contains no relay/secret fields (the schema
904
- * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
905
- *
906
- * @param report - The core relay run report.
907
- * @param dir - Output directory (created recursively if missing).
908
- * @param meta - Cell axes + projectRoot.
909
- * @returns The absolute path of the written file.
910
- */
911
- async function writeReportArtifact(report, dir, meta) {
912
- const serialised = serializeRelayReport(report, meta);
913
- await mkdir(dir, { recursive: true });
914
- const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
915
- await writeFile(outFile, `${JSON.stringify(serialised, null, 2)}\n`, "utf8");
916
- return outFile;
917
- }
918
- /**
919
- * Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,
920
- * named `<category>.<sdkLine>.<platform>.json` — the SAME convention
921
- * sdk-example's env1 `flushCapture` uses on the filesystem, so env1 and env3
922
- * capture artifacts line up for diffing.
923
- *
924
- * Each line's `json` payload is an opaque JSON array of capture records. Lines
925
- * sharing a category are concatenated into one array, in harvest order.
926
- *
927
- * SECRET-HANDLING: only allowlist-prefixed capture lines reach here (the parser
928
- * dropped wss/scheme noise); the `json` payload is written verbatim but is a
929
- * capture record array, not a relay/secret.
930
- *
931
- * @param captures - Parsed capture lines (from `RelayRunReport.captures`).
932
- * @param dir - Output directory (created recursively if missing).
933
- * @param cell - Cell axes for the filename suffix.
934
- * @returns The absolute paths written (one per category), in category order.
935
- */
936
- async function writeCaptureArtifacts(captures, dir, cell) {
937
- if (captures.length === 0) return [];
938
- const byCategory = /* @__PURE__ */ new Map();
939
- for (const { category, json } of captures) {
940
- let merged = byCategory.get(category);
941
- if (!merged) {
942
- merged = [];
943
- byCategory.set(category, merged);
944
- }
945
- const parsed = JSON.parse(json);
946
- if (Array.isArray(parsed)) merged.push(...parsed);
947
- else merged.push(parsed);
948
- }
949
- await mkdir(dir, { recursive: true });
950
- const written = [];
951
- for (const [category, records] of byCategory) {
952
- const outFile = path.join(dir, `${category}.${cell.sdkLine}.${cell.platform}.json`);
953
- await writeFile(outFile, `${JSON.stringify(records, null, 2)}\n`, "utf8");
954
- written.push(outFile);
955
- }
956
- return written;
957
- }
958
- //#endregion
959
- //#region src/test-runner/cli.ts
960
- /**
961
- * `devtools-test` CLI.
962
- *
963
- * Shares test-file discovery with the `run_tests` MCP tool (`discoverTestFiles`)
964
- * and exposes `runWithConnection` — the pure run core that bundles, injects, and
965
- * collects each file over a CDP connection. The CLI's `main()` performs a
966
- * standalone relay attach (boot relay → QR → phone scan → cell inject → run).
967
- *
968
- * NOTE: no shebang in this source file — the tsdown entry's `banner` option
969
- * injects `#!/usr/bin/env node` into the compiled output (same pattern as
970
- * `src/mcp/cli.ts`).
971
- */
972
- const USAGE = `
973
- devtools-test — run mini-app tests on a real device WebView over the CDP relay
974
-
975
- USAGE
976
- devtools-test <glob> [<glob> ...] [options]
977
-
978
- OPTIONS
979
- --scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
980
- (required for standalone relay attach / env3)
981
- --timeout <ms> Per-file evaluate timeout in ms (default: 30000)
982
- --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
983
- --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
984
- (mock|ios|android, default: AIT_CELL_PLATFORM env)
985
- --report-dir <dir> Persist a runner-agnostic report + captures to <dir>
986
- (report: <sdkLine>.<platform>.json; captures:
987
- <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
988
- Omitted = nothing saved. Enables console capture.
989
- --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
990
- non-interactive stdout / CI / AIT_NO_QR_STDOUT)
991
- --headless Disable browser auto-open (text QR only)
992
- --project-root <dir> Project root for .ait_relay secret lookup
993
- (default: current working directory)
994
- --help, -h Show this help message
995
-
996
- DESCRIPTION
997
- Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real
998
- device to scan and attach, injects the cell globals (__AIT_CELL__), bundles
999
- each matched test file with esbuild (SDK imports redirected to window.__sdk),
1000
- injects the bundle into the attached WebView via Runtime.evaluate, and prints
1001
- a summary.
1002
-
1003
- With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
1004
- runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
1005
- compared offline.
1006
-
1007
- The test files run against the live relay connection started by this process;
1008
- no separate MCP daemon is required.
1009
-
1010
- EXAMPLE
1011
- devtools-test 'src/**/*.ait.test.ts' \\
1012
- --scheme-url "intoss-private://..." \\
1013
- --cell-sdk-line 3.x \\
1014
- --cell-platform ios \\
1015
- --report-dir .ait-report \\
1016
- --timeout 60000
1017
-
1018
- `.trimStart();
1019
- /**
1020
- * Runs `files` over `connection` and returns the aggregate report.
1021
- * This pure function is the testable core of the CLI (and is what the
1022
- * `run_tests` MCP tool calls against the daemon's attached connection); it is
1023
- * separate from `main()` so tests can call it without spawning a subprocess.
1024
- */
1025
- async function runWithConnection(connection, files, opts) {
1026
- const report = await runTestFilesOverRelay(connection, files, opts);
1027
- if (opts?.printSummary) {
1028
- const { totals } = report;
1029
- process.stdout.write(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${report.duration}ms)\n`);
1030
- }
1031
- return report;
1032
- }
1033
- /**
1034
- * Decides whether to suppress the QR/attach block on stdout.
1035
- *
1036
- * Suppress when EITHER the user passed `--no-qr-stdout`, OR stdout is not a TTY
1037
- * / `CI` is set / `AIT_NO_QR_STDOUT` is set (non-interactive — a captured stdout
1038
- * must not leak the relay wss + TOTP `at=` code that the QR block encodes). The
1039
- * suppression is whole-chunk: `attachUrl` AND `relayUrl` ride in the same block.
1040
- *
1041
- * Exported for unit testing.
1042
- */
1043
- function shouldSuppressQr(noQrFlag) {
1044
- return noQrFlag || !process.stdout.isTTY || process.env.CI !== void 0 || process.env.AIT_NO_QR_STDOUT !== void 0;
1045
- }
1046
- /**
1047
- * CLI entry point.
1048
- *
1049
- * Performs a standalone relay attach → run lifecycle, sharing the attach
1050
- * assembly with the Vitest pool via `createRelayConnectionFactory` (single
1051
- * source — no drift):
1052
- *
1053
- * 1. Parse args: globs, --timeout, --cell-sdk-line, --cell-platform,
1054
- * --scheme-url (required for env3), --report-dir, --no-qr-stdout,
1055
- * --headless, --project-root.
1056
- * 2. Discover test files; exit 1 if none.
1057
- * 3. factory.open() — boot relay → render QR (suppressed on non-interactive
1058
- * stdout) → wait for phone → inject cell → enableDomains. Returns the conn.
1059
- * 4. runWithConnection(conn, files, { timeoutMs, collectCaptures, printSummary }).
1060
- * 5. With --report-dir: write the runner-agnostic report + capture files.
1061
- * 6. factory.close(); process.exitCode = failed > 0 ? 1 : 0.
1062
- *
1063
- * The CLI is not a daemon — no lock, router, SSE, or tools_list is needed.
1064
- * Attach timeout exits with code 1; test failures exit with code 1.
1065
- *
1066
- * SECRET-HANDLING: scheme_url / relay wssUrl / TOTP codes are never written to
1067
- * stdout/stderr directly. The QR block (which encodes the TOTP `at=` code) is
1068
- * printed only when stdout is interactive AND not suppressed.
1069
- */
1070
- async function main(argv = process.argv.slice(2)) {
1071
- let parsed;
1072
- try {
1073
- parsed = parseArgs({
1074
- args: argv,
1075
- options: {
1076
- help: {
1077
- type: "boolean",
1078
- short: "h"
1079
- },
1080
- timeout: { type: "string" },
1081
- "scheme-url": { type: "string" },
1082
- "cell-sdk-line": { type: "string" },
1083
- "cell-platform": { type: "string" },
1084
- "report-dir": { type: "string" },
1085
- "no-qr-stdout": { type: "boolean" },
1086
- headless: { type: "boolean" },
1087
- "project-root": { type: "string" }
1088
- },
1089
- allowPositionals: true
1090
- });
1091
- } catch (e) {
1092
- process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
1093
- process.exitCode = 1;
1094
- return;
1095
- }
1096
- if (parsed.values.help || argv.length === 0) {
1097
- process.stdout.write(USAGE);
1098
- return;
1099
- }
1100
- const vals = parsed.values;
1101
- const rawTimeout = typeof vals.timeout === "string" ? vals.timeout : void 0;
1102
- const timeoutMs = rawTimeout !== void 0 ? parseInt(rawTimeout, 10) : 3e4;
1103
- if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
1104
- process.stderr.write(`devtools-test: --timeout must be a positive integer\n`);
1105
- process.exitCode = 1;
1106
- return;
1107
- }
1108
- const schemeUrl = typeof vals["scheme-url"] === "string" ? vals["scheme-url"] : "";
1109
- if (schemeUrl === "") {
1110
- process.stderr.write("devtools-test: --scheme-url is required for standalone relay attach.\n Pass the intoss-private:// URL from `ait deploy --scheme-only`.\n");
1111
- process.exitCode = 1;
1112
- return;
1113
- }
1114
- const headless = vals.headless === true;
1115
- const projectRoot = typeof vals["project-root"] === "string" ? vals["project-root"] : process.cwd();
1116
- const reportDir = typeof vals["report-dir"] === "string" ? vals["report-dir"] : void 0;
1117
- const suppressQr = shouldSuppressQr(vals["no-qr-stdout"] === true);
1118
- const cellSdkLine = typeof vals["cell-sdk-line"] === "string" ? vals["cell-sdk-line"] : void 0;
1119
- const cellPlatform = typeof vals["cell-platform"] === "string" ? vals["cell-platform"] : process.env.AIT_CELL_PLATFORM;
1120
- const hasCell = cellSdkLine !== void 0 || cellPlatform !== void 0;
1121
- const cell = {
1122
- sdkLine: cellSdkLine ?? "2.x",
1123
- platform: cellPlatform ?? "mock"
1124
- };
1125
- const globs = parsed.positionals;
1126
- if (globs.length === 0) {
1127
- process.stderr.write(`devtools-test: at least one glob pattern is required\n`);
1128
- process.stdout.write(USAGE);
1129
- process.exitCode = 1;
1130
- return;
1131
- }
1132
- const files = await discoverTestFiles(globs, process.cwd());
1133
- if (files.length === 0) {
1134
- process.stderr.write(`devtools-test: no test files matched ${globs.join(", ")}\n`);
1135
- process.exitCode = 1;
1136
- return;
1137
- }
1138
- process.stderr.write(`devtools-test: found ${files.length} test file(s)\n`);
1139
- if (hasCell) process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\n`);
1140
- const factory = createRelayConnectionFactory({
1141
- schemeUrl,
1142
- projectRoot,
1143
- timeoutMs,
1144
- headless,
1145
- cell: hasCell ? cell : void 0,
1146
- onQrContent: (chunks) => {
1147
- if (suppressQr) {
1148
- process.stdout.write("QR suppressed (non-interactive)\n");
1149
- return;
1150
- }
1151
- for (const chunk of chunks) process.stdout.write(`${chunk}\n`);
1152
- }
1153
- });
1154
- let connection;
1155
- try {
1156
- connection = await factory.open();
1157
- } catch (e) {
1158
- process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
1159
- process.exitCode = 1;
1160
- return;
1161
- }
1162
- let exitCode = 0;
1163
- try {
1164
- const report = await runWithConnection(connection, files, {
1165
- timeoutMs,
1166
- printSummary: true,
1167
- collectCaptures: reportDir !== void 0
1168
- });
1169
- if (reportDir !== void 0) try {
1170
- const reportPath = await writeReportArtifact(report, reportDir, {
1171
- sdkLine: cell.sdkLine,
1172
- platform: cell.platform,
1173
- projectRoot
1174
- });
1175
- process.stderr.write(`devtools-test: wrote report ${reportPath}\n`);
1176
- const capturePaths = await writeCaptureArtifacts(report.captures, `${reportDir}/.ait-capture`, cell);
1177
- if (capturePaths.length > 0) process.stderr.write(`devtools-test: wrote ${capturePaths.length} capture file(s)\n`);
1178
- } catch (e) {
1179
- process.stderr.write(`devtools-test: failed to write report artifacts: ${e instanceof Error ? e.message : String(e)}\n`);
1180
- }
1181
- exitCode = report.totals.failed > 0 ? 1 : 0;
1182
- } finally {
1183
- await factory.close(connection);
1184
- process.exitCode = exitCode;
1185
- }
1186
- }
1187
- if (import.meta.url === new URL(process.argv[1], "file://").href) main().catch((e) => {
1188
- process.stderr.write(`devtools-test: unexpected error: ${e instanceof Error ? e.message : String(e)}\n`);
1189
- process.exitCode = 1;
1190
- });
1191
- //#endregion
1192
- //#region src/mcp/ait-chii-source.ts
1193
- function isObject$2(value) {
1194
- return typeof value === "object" && value !== null;
1195
- }
1196
- /** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
1197
- function asSdkCallHistory(raw) {
1198
- if (isObject$2(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
1199
- return { calls: [] };
1200
- }
1201
- /** Narrows an `AIT.getMockState` response to an opaque record. */
1202
- function asMockState(raw) {
1203
- return isObject$2(raw) ? raw : {};
1204
- }
1205
- /** Narrows an `AIT.getOperationalEnvironment` response. */
1206
- function asOperationalEnvironment(raw) {
1207
- return {
1208
- environment: isObject$2(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
1209
- sdkVersion: isObject$2(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
1210
- };
1211
- }
1212
- var ChiiAitSource = class {
1213
- constructor(sender) {
1214
- this.sender = sender;
1215
- }
1216
- async get(method) {
1217
- const raw = await this.sender.sendCommand(method);
1218
- switch (method) {
1219
- case "AIT.getSdkCallHistory": return asSdkCallHistory(raw);
1220
- case "AIT.getMockState": return asMockState(raw);
1221
- case "AIT.getOperationalEnvironment": return asOperationalEnvironment(raw);
1222
- default: throw new Error(`Unknown AIT method: ${String(method)}`);
1223
- }
1224
- }
1225
- };
1226
- //#endregion
1227
- //#region src/shared/relay-auth-close.ts
1228
- /**
1229
- * Shared constants for the relay's named TOTP-auth rejection (issue #478).
1230
- *
1231
- * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
1232
- * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
1233
- * indistinguishable from a network failure on the browser side — the
1234
- * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
1235
- * could not tell "stale TOTP code" apart from "tunnel down" and stayed
1236
- * silent. The fix is accept-then-close: complete the handshake, then close
1237
- * with an application close code that NAMES the rejection.
1238
- *
1239
- * Three parties share this contract:
1240
- * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
1241
- * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
1242
- * surfaces the code to the launcher shell;
1243
- * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
1244
- * as an auth failure on its own `/client` dial (defensive — #439's fresh
1245
- * code mint means it should not normally hit this).
1246
- *
1247
- * This module is intentionally dependency-free (no Node, no DOM) so it is
1248
- * safe to import from both the browser in-app bundle and the MCP daemon
1249
- * bundle.
1250
- *
1251
- * SECRET-HANDLING: these are fixed enum values. The close reason / error body
1252
- * must never grow to carry a secret, a TOTP code, or a host.
1253
- */
1254
- /**
1255
- * WebSocket close code sent by the relay when TOTP auth is rejected.
1256
- *
1257
- * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
1258
- * HTTP 401 so it reads as "unauthorized" at a glance.
1259
- */
1260
- const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
1261
- /**
1262
- * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
1263
- * the `error` value of the relay's HTTP 401 JSON body. Enum string only —
1264
- * never interpolated with request data.
1265
- */
1266
- const RELAY_AUTH_REJECT_REASON = "totp-rejected";
1267
- //#endregion
1268
- //#region src/mcp/chii-connection.ts
1269
- /**
1270
- * Production `CdpConnection` backed by the local Chii relay.
1271
- *
1272
- * Topology (debug mode):
1273
- * phone target.js --WS--> Chii relay :9100 <--WS-- this connection
1274
- *
1275
- * The phone connects to the relay as a `target`; this module connects as a
1276
- * `client` (the role a CDP frontend would take) so CDP events the page emits
1277
- * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
1278
- * events in ring buffers the tool layer reads via `getBufferedEvents`.
1279
- *
1280
- * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
1281
- *
1282
- * Attach reliability (#281):
1283
- * `refreshTargets()` emits an internal 'target:attached' event whenever a
1284
- * new target is added to the relay. `waitForFirstTarget()` awaits that event
1285
- * (with a polling-interval fallback) so `start_attach`'s attach wait
1286
- * resolves deterministically rather than racing between polling rounds.
1287
- */
1288
- /** Max events retained per domain ring buffer. */
1289
- const DEFAULT_BUFFER_SIZE$1 = 500;
1290
- function isObject$1(value) {
1291
- return typeof value === "object" && value !== null;
1292
- }
1293
- function parseInbound$1(raw) {
1294
- let parsed;
1295
- try {
1296
- parsed = JSON.parse(raw);
1297
- } catch {
1298
- return null;
1299
- }
1300
- if (!isObject$1(parsed)) return null;
1301
- const message = {};
1302
- if (typeof parsed.id === "number") message.id = parsed.id;
1303
- if (typeof parsed.method === "string") message.method = parsed.method;
1304
- if ("params" in parsed) message.params = parsed.params;
1305
- if ("result" in parsed) message.result = parsed.result;
1306
- if (isObject$1(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
1307
- return message;
1308
- }
1309
- const PHASE_1_EVENTS$1 = [
1310
- "Runtime.consoleAPICalled",
1311
- "Network.requestWillBeSent",
1312
- "Network.responseReceived"
1313
- ];
1314
- /**
1315
- * Ring buffer size for `Runtime.exceptionThrown`.
1316
- *
1317
- * Exceptions are rarer than console messages but each is heavier (stack
1318
- * trace). 50 is generous enough to cover a crash scenario while keeping
1319
- * memory bounded.
1320
- *
1321
- * **Lifecycle note**: the exception buffer intentionally survives `replaced` /
1322
- * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
1323
- * transitions. Rationale: an exception fired just before a crash is exactly
1324
- * the signal we want to preserve for root-cause analysis. The buffer
1325
- * represents "exceptions seen in this MCP session", not "exceptions in the
1326
- * current page".
1327
- */
1328
- const EXCEPTION_BUFFER_SIZE = 50;
1329
- /** Default per-command timeout if neither option nor env var is set. */
1330
- const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
1331
- /**
1332
- * Production CDP connection. Polls the relay for the first attached target,
1333
- * opens a client websocket to it, enables Phase 1 domains, and buffers events.
1334
- */
1335
- var ChiiCdpConnection = class {
1336
- /** Authoritative connection kind (issue #348) — relay-backed. */
1337
- kind = "relay";
1338
- relayBaseUrl;
1339
- bufferSize;
1340
- commandTimeoutMs;
1341
- totpSecret;
1342
- emitter = new EventEmitter();
1343
- buffers = /* @__PURE__ */ new Map();
1344
- targets = /* @__PURE__ */ new Map();
1345
- ws = null;
1346
- connectionState = "idle";
1347
- nextCommandId = 1;
1348
- /**
1349
- * The single active target id under the single-attach model.
1350
- * Updated by `refreshTargets()` whenever a non-null target is present.
1351
- * Used to detect a new (different) target attach and evict the previous one.
1352
- */
1353
- activeTargetId = null;
1354
- /** In-flight enableDomains() promise — concurrent callers share it. */
1355
- enablingPromise = null;
1356
- /** Pending request→response commands keyed by CDP message id. */
1357
- pending = /* @__PURE__ */ new Map();
1358
- /**
1359
- * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
1360
- * or `null` if no crash has been detected since the last `enableDomains()`.
1361
- */
1362
- lastCrashDetectedAt = null;
1363
- /**
1364
- * Per-target last-seen timestamp (ms since epoch). Updated on any inbound
1365
- * CDP message carrying data from a target. Keyed by target id.
1366
- */
1367
- targetLastSeenAt = /* @__PURE__ */ new Map();
1368
- /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
1369
- heartbeatHandle = null;
1370
- /** Lifecycle event listeners (crash / destroyed / detached). */
1371
- lifecycleListeners = [];
1372
- constructor(options) {
1373
- this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
1374
- this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE$1;
1375
- this.totpSecret = options.totpSecret;
1376
- const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
1377
- this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
1378
- for (const event of PHASE_1_EVENTS$1) this.buffers.set(event, []);
1379
- this.buffers.set("Runtime.exceptionThrown", []);
1380
- this.emitter.setMaxListeners(0);
1381
- }
1382
- /** Refresh the attached-target list from the relay's `GET /targets`. */
1383
- async refreshTargets() {
1384
- let targetsUrl = `${this.relayBaseUrl}/targets`;
1385
- if (this.totpSecret) {
1386
- const code = generateTotp(this.totpSecret);
1387
- targetsUrl += `?at=${encodeURIComponent(code)}`;
1388
- }
1389
- const res = await fetch(targetsUrl);
1390
- if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
1391
- const body = await res.json();
1392
- const list = isObject$1(body) && Array.isArray(body.targets) ? body.targets : [];
1393
- let newestTargetId = null;
1394
- for (const item of list) {
1395
- if (!isObject$1(item) || typeof item.id !== "string") continue;
1396
- newestTargetId = item.id;
1397
- }
1398
- if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
1399
- const prevId = this.activeTargetId;
1400
- logInfo("page.detached", { prevTargetId: prevId });
1401
- this.evictTarget(prevId);
1402
- }
1403
- this.targets.clear();
1404
- for (const item of list) {
1405
- if (!isObject$1(item) || typeof item.id !== "string") continue;
1406
- if (item.id !== newestTargetId) continue;
1407
- this.targets.set(item.id, {
1408
- id: item.id,
1409
- title: typeof item.title === "string" ? item.title : "",
1410
- url: typeof item.url === "string" ? item.url : ""
1411
- });
1412
- }
1413
- if (newestTargetId !== null) this.activeTargetId = newestTargetId;
1414
- else this.activeTargetId = null;
1415
- const result = [...this.targets.values()];
1416
- if (newestTargetId !== null) this.emitter.emit("target:attached", result);
1417
- return result;
1418
- }
1419
- listTargets() {
1420
- return [...this.targets.values()];
1421
- }
1422
- /**
1423
- * Waits until at least one target matching `filterFn` is attached, then
1424
- * resolves with the full target list at that moment.
1425
- *
1426
- * Resolution happens on whichever comes first:
1427
- * (a) a `'target:attached'` event from `refreshTargets()` (triggered by
1428
- * the /targets poll finding a new target), OR
1429
- * (b) a `'target:attached'` event from `handleMessage()` (triggered by
1430
- * the first inbound CDP message from a target — confirms the relay
1431
- * websocket has data from the phone, not just a target entry in the map).
1432
- *
1433
- * This dual-signal approach eliminates the polling race that previously
1434
- * caused `wait_for_attach` to resolve before the first CDP message arrived.
1435
- *
1436
- * Falls back to checking `listTargets()` every `pollIntervalMs` in case the
1437
- * EventEmitter is missed (defensive belt-and-suspenders).
1438
- *
1439
- * @param filterFn - Predicate that the returned targets must satisfy.
1440
- * @param timeoutMs - Reject after this many ms (default 90 000).
1441
- * @param pollIntervalMs - Fallback poll interval (default 500ms).
1442
- */
1443
- waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
1444
- const current = this.listTargets();
1445
- if (filterFn(current)) return Promise.resolve(current);
1446
- return new Promise((resolve, reject) => {
1447
- let settled = false;
1448
- let pollHandle = null;
1449
- const settle = (targets) => {
1450
- if (settled) return;
1451
- settled = true;
1452
- clearTimeout(timeoutHandle);
1453
- if (pollHandle !== null) {
1454
- clearInterval(pollHandle);
1455
- pollHandle = null;
1456
- }
1457
- this.emitter.off("target:attached", onAttach);
1458
- resolve(targets);
1459
- };
1460
- const onAttach = (targets) => {
1461
- if (filterFn(targets)) settle(targets);
1462
- };
1463
- const timeoutHandle = setTimeout(() => {
1464
- if (settled) return;
1465
- settled = true;
1466
- if (pollHandle !== null) {
1467
- clearInterval(pollHandle);
1468
- pollHandle = null;
1469
- }
1470
- this.emitter.off("target:attached", onAttach);
1471
- reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
1472
- }, timeoutMs);
1473
- this.emitter.on("target:attached", onAttach);
1474
- pollHandle = setInterval(() => {
1475
- this.refreshTargets().then((targets) => {
1476
- if (filterFn(targets)) settle(targets);
1477
- }, () => {});
1478
- }, pollIntervalMs);
1479
- });
1480
- }
1481
- /**
1482
- * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
1483
- * detected since the last `enableDomains()` call, or `null` if none.
1484
- */
1485
- getLastCrashDetectedAt() {
1486
- return this.lastCrashDetectedAt;
1487
- }
1488
- /**
1489
- * Last-seen timestamp (ms since epoch) for a given target id, or `null` if
1490
- * the target is unknown / no message has been received from it yet.
1491
- */
1492
- getTargetLastSeenAt(targetId) {
1493
- return this.targetLastSeenAt.get(targetId) ?? null;
1494
- }
1495
- /** Subscribe to target lifecycle events (crash / destroyed / detached). */
1496
- onLifecycle(listener) {
1497
- this.lifecycleListeners.push(listener);
1498
- return () => {
1499
- const idx = this.lifecycleListeners.indexOf(listener);
1500
- if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
1501
- };
1502
- }
1503
- /**
1504
- * Connect a client websocket to the first attached target and enable Phase 1
1505
- * domains. Resolves once the socket is open and enable commands are sent.
1506
- */
1507
- async enableDomains() {
1508
- if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
1509
- if (this.enablingPromise) return this.enablingPromise;
1510
- this.enablingPromise = this._doEnableDomains().finally(() => {
1511
- this.enablingPromise = null;
1512
- });
1513
- return this.enablingPromise;
1514
- }
1515
- async _doEnableDomains() {
1516
- const target = (await this.refreshTargets())[0];
1517
- if (!target) throw new Error("No mini-app page attached to the Chii relay yet.");
1518
- let clientUrl = `${this.relayBaseUrl.replace(/^http/, "ws")}/client/${`devtools-mcp-${Date.now()}`}?target=${encodeURIComponent(target.id)}`;
1519
- if (this.totpSecret) {
1520
- const code = generateTotp(this.totpSecret);
1521
- clientUrl += `&at=${encodeURIComponent(code)}`;
1522
- }
1523
- const ws = new WebSocket(clientUrl);
1524
- this.ws = ws;
1525
- await new Promise((resolve, reject) => {
1526
- ws.once("open", () => resolve());
1527
- ws.once("error", (err) => reject(err));
1528
- ws.once("close", (code) => {
1529
- if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
1530
- });
1531
- });
1532
- this.lastCrashDetectedAt = null;
1533
- this.targetLastSeenAt.clear();
1534
- this.connectionState = "connected";
1535
- ws.on("message", (data) => this.handleMessage(data.toString()));
1536
- ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
1537
- ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
1538
- this.sendFireAndForget("Runtime.enable");
1539
- this.sendFireAndForget("Network.enable");
1540
- this.sendFireAndForget("DOM.enable");
1541
- this.sendFireAndForget("Page.enable");
1542
- this.sendFireAndForget("Inspector.enable");
1543
- this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
1544
- this.startHeartbeat(target.id);
1545
- }
1546
- /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
1547
- sendFireAndForget(method, params = {}) {
1548
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
1549
- const id = this.nextCommandId++;
1550
- this.ws.send(JSON.stringify({
1551
- id,
1552
- method,
1553
- params
1554
- }));
1555
- }
1556
- /**
1557
- * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
1558
- * error frame or when no websocket is open (no page attached yet).
1559
- */
1560
- send(method, params) {
1561
- return this.sendCommand(method, params ?? {});
1562
- }
1563
- /**
1564
- * Issue an arbitrary request→response command over the relay and resolve with
1565
- * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
1566
- * `AIT.*` methods, forwarded over the same Chii channel) build on this.
1567
- *
1568
- * Rejects immediately if the connection is disconnected (fail-fast — no
1569
- * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
1570
- * reattach.
1571
- *
1572
- * Times out after `commandTimeoutMs` (default 30s, env
1573
- * `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
1574
- * and the promise rejects with a descriptive Korean error.
1575
- */
1576
- sendCommand(method, params = {}) {
1577
- if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
1578
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No mini-app page attached to the Chii relay yet. Call enableDomains() first."));
1579
- const id = this.nextCommandId++;
1580
- const ws = this.ws;
1581
- const timeoutMs = this.commandTimeoutMs;
1582
- return new Promise((resolve, reject) => {
1583
- const handle = setTimeout(() => {
1584
- this.pending.delete(id);
1585
- reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
1586
- }, timeoutMs);
1587
- this.pending.set(id, {
1588
- resolve: (v) => {
1589
- clearTimeout(handle);
1590
- resolve(v);
1591
- },
1592
- reject: (e) => {
1593
- clearTimeout(handle);
1594
- reject(e);
1595
- }
1596
- });
1597
- ws.send(JSON.stringify({
1598
- id,
1599
- method,
1600
- params
1601
- }));
1602
- });
1603
- }
1604
- /**
1605
- * Called on WebSocket `close` or `error` after a successful connection.
1606
- * Rejects all pending commands and marks the connection as disconnected so
1607
- * subsequent `sendCommand` calls fail fast (no auto-reconnect).
1608
- */
1609
- handleDisconnect(reason) {
1610
- if (this.connectionState === "disconnected") return;
1611
- this.connectionState = "disconnected";
1612
- this.ws = null;
1613
- this.stopHeartbeat();
1614
- const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
1615
- for (const waiter of this.pending.values()) waiter.reject(err);
1616
- this.pending.clear();
1617
- }
1618
- /**
1619
- * Evict a previously active target under the single-attach model.
1620
- * Rejects pending commands with a 'replaced-by-new-attach' reason and emits
1621
- * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
1622
- * targetId. The caller is responsible for rebuilding the targets map afterwards.
1623
- *
1624
- * The error message uses 'replaced-by-new-attach' so test assertions can match it.
1625
- */
1626
- evictTarget(targetId) {
1627
- const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
1628
- this.targets.delete(targetId);
1629
- this.targetLastSeenAt.delete(targetId);
1630
- const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
1631
- for (const waiter of this.pending.values()) waiter.reject(err);
1632
- this.pending.clear();
1633
- const event = {
1634
- kind: "replaced",
1635
- targetId,
1636
- detectedAt
1637
- };
1638
- for (const listener of this.lifecycleListeners) try {
1639
- listener(event);
1640
- } catch {}
1641
- }
1642
- /**
1643
- * Handle a page-level crash or target destruction event.
1644
- * Removes the target from the in-memory map, rejects all pending commands,
1645
- * and emits a lifecycle event.
1646
- *
1647
- * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
1648
- * @param targetId - The target ID from the event params (may be null for
1649
- * Inspector.targetCrashed which has no targetId in the params).
1650
- */
1651
- handleTargetGone(kind, targetId) {
1652
- const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
1653
- this.lastCrashDetectedAt = Date.now();
1654
- if (targetId !== null) {
1655
- this.targets.delete(targetId);
1656
- this.targetLastSeenAt.delete(targetId);
1657
- if (this.activeTargetId === targetId) this.activeTargetId = null;
1658
- } else {
1659
- this.targets.clear();
1660
- this.targetLastSeenAt.clear();
1661
- this.activeTargetId = null;
1662
- }
1663
- const err = /* @__PURE__ */ new Error(`[ait-debug] ${kind === "crashed" ? "page crash (Inspector.targetCrashed)" : kind === "destroyed" ? "target 종료 (Target.targetDestroyed)" : "target detach (Target.detachedFromTarget)"} 감지됨 — relay에서 제거됐습니다. 새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).`);
1664
- for (const waiter of this.pending.values()) waiter.reject(err);
1665
- this.pending.clear();
1666
- const event = {
1667
- kind,
1668
- targetId,
1669
- detectedAt
1670
- };
1671
- for (const listener of this.lifecycleListeners) try {
1672
- listener(event);
1673
- } catch {}
1674
- }
1675
- /**
1676
- * Start the optional CDP heartbeat loop.
1677
- *
1678
- * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
1679
- * we send `Runtime.evaluate({expression: '1'})` to each active target. If
1680
- * the command times out (2 s hard deadline) or errors, we treat the target
1681
- * as dead and call `handleTargetGone`.
1682
- *
1683
- * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
1684
- * even when the phone app has crashed, so the ws-level disconnect (#252) won't
1685
- * fire. The heartbeat catches this gap.
1686
- *
1687
- * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
1688
- */
1689
- startHeartbeat(initialTargetId) {
1690
- this.stopHeartbeat();
1691
- const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
1692
- if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
1693
- const PING_TIMEOUT_MS = 2e3;
1694
- this.heartbeatHandle = setInterval(() => {
1695
- const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
1696
- for (const targetId of targetIds) {
1697
- const pingPromise = this.sendCommand("Runtime.evaluate", {
1698
- expression: "1",
1699
- returnByValue: true,
1700
- timeout: PING_TIMEOUT_MS
1701
- });
1702
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
1703
- Promise.race([pingPromise, timeoutPromise]).catch(() => {
1704
- if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
1705
- });
1706
- }
1707
- }, envMs);
1708
- }
1709
- stopHeartbeat() {
1710
- if (this.heartbeatHandle !== null) {
1711
- clearInterval(this.heartbeatHandle);
1712
- this.heartbeatHandle = null;
1713
- }
1714
- }
1715
- handleMessage(raw) {
1716
- const message = parseInbound$1(raw);
1717
- if (!message) return;
1718
- if (typeof message.id === "number" && this.pending.has(message.id)) {
1719
- const waiter = this.pending.get(message.id);
1720
- this.pending.delete(message.id);
1721
- if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
1722
- else waiter.resolve(message.result);
1723
- return;
1724
- }
1725
- const now = Date.now();
1726
- let firstMessageSeen = false;
1727
- for (const targetId of this.targets.keys()) {
1728
- if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
1729
- this.targetLastSeenAt.set(targetId, now);
1730
- }
1731
- if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
1732
- if (typeof message.method !== "string") return;
1733
- if (message.method === "Inspector.targetCrashed") {
1734
- this.handleTargetGone("crashed", null);
1735
- return;
1736
- }
1737
- if (message.method === "Target.targetDestroyed") {
1738
- const targetId = isObject$1(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
1739
- this.handleTargetGone("destroyed", targetId);
1740
- return;
1741
- }
1742
- if (message.method === "Target.detachedFromTarget") {
1743
- const targetId = isObject$1(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
1744
- this.handleTargetGone("detached", targetId);
1745
- return;
1746
- }
1747
- if (!this.buffers.has(message.method)) return;
1748
- const event = message.method;
1749
- const buffer = this.buffers.get(event);
1750
- if (!buffer) return;
1751
- buffer.push(message.params);
1752
- const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
1753
- if (buffer.length > cap) buffer.shift();
1754
- this.emitter.emit(event, message.params);
1755
- }
1756
- getBufferedEvents(event) {
1757
- return this.buffers.get(event) ?? [];
1758
- }
1759
- on(event, listener) {
1760
- this.emitter.on(event, listener);
1761
- return () => this.emitter.off(event, listener);
1762
- }
1763
- /** Close the relay client websocket and reject any in-flight commands. */
1764
- close() {
1765
- const ws = this.ws;
1766
- this.stopHeartbeat();
1767
- this.handleDisconnect("Chii relay connection closed");
1768
- ws?.close();
1769
- }
1770
- };
1771
- //#endregion
1772
- //#region src/mcp/chii-relay.ts
1773
- /**
1774
- * Boots the local Chii relay server.
1775
- *
1776
- * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome
1777
- * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.
1778
- * The relay accepts a `target` websocket from the phone's injected `target.js`
1779
- * and `client` websockets from CDP frontends (our MCP connection).
1780
- *
1781
- * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app
1782
- * entries.
1783
- *
1784
- * TOTP auth (relay-side, authoritative gate):
1785
- * When `verifyAuth` is provided, this module gates both inbound surfaces:
1786
- *
1787
- * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.
1788
- * Node's `http.Server` calls listeners in registration order; the first
1789
- * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny
1790
- * JSON body (`{"error":"totp-rejected"}`) so a cross-origin script
1791
- * `fetch()` probe can READ the status (issue #478). Valid auth → return
1792
- * without side-effect (chii's Koa handler serves it).
1793
- *
1794
- * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade
1795
- * listener, we take over the upgrade chain (remove chii's listeners,
1796
- * re-dispatch manually). Invalid auth → accept-then-close: complete the
1797
- * handshake via a `noServer` WebSocketServer, then immediately close
1798
- * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +
1799
- * `socket.destroy()` only ever surfaced as close code 1006 in the
1800
- * browser — indistinguishable from a tunnel failure, which left the
1801
- * env-2 phone UI silent. The explicit dispatch (not listener ordering)
1802
- * is what keeps chii away from rejected sockets: accept-then-close
1803
- * leaves the socket alive, so an order-based early-return would let
1804
- * chii's later listener complete a SECOND handshake on the same socket
1805
- * — an auth bypass. Valid auth → forward to chii's captured listeners.
1806
- *
1807
- * TOTP code transports (issue #466) — two equivalent ways to carry the code:
1808
- * 1. Query param `at=<code>` — used by the daemon-side `/client` connection
1809
- * (`chii-connection.ts` appends it; it holds the secret).
1810
- * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's
1811
- * stock `target.js` derives its WS endpoint from the script `src`
1812
- * (`scriptEl.src.replace('target.js','')`), so the only way for the
1813
- * phone to carry a code is to embed it in the script URL path. The
1814
- * in-app attach injects `https://<host>/at/<code>/target.js`; both the
1815
- * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS
1816
- * dial then carry the prefix. The listeners below rewrite the prefix
1817
- * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`
1818
- * before chii's own handlers (registered later) parse it — chii only
1819
- * ever sees the stripped URL.
1820
- *
1821
- * Threat model: "URL leak" — someone obtains the tunnel URL (Slack paste, QR
1822
- * screenshot, shoulder-surfing) but does not have the shared TOTP secret.
1823
- * Rotating 6-digit code makes the URL stale after 30 s.
1824
- * A determined attacker who extracts the secret from the dogfood bundle can
1825
- * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).
1826
- *
1827
- * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear
1828
- * in any log, error message, or process output. `verifyAuth` is a black-box
1829
- * predicate from the caller's perspective; this module only forwards pass/fail.
1830
- */
1831
- const require$1 = createRequire(import.meta.url);
1832
- /**
1833
- * WS keepalive ping interval (ms).
1834
- *
1835
- * Cloudflare proxied connections are dropped after ~100 s of no traffic.
1836
- * 45 s comfortably fits inside that window and lets both the phone-target leg
1837
- * and the daemon-client leg survive idle CDP sessions.
1838
- */
1839
- const DEFAULT_KEEPALIVE_INTERVAL_MS = 45e3;
1840
- /**
1841
- * Loads chii's internal WebSocketServer class and returns it together with a
1842
- * flag indicating whether the real class was found.
1843
- *
1844
- * Returns `null` if the internal path is not resolvable (future chii release
1845
- * changes the layout) — callers skip keepalive gracefully.
1846
- */
1847
- function tryLoadChiiWssClass() {
1848
- try {
1849
- const mod = require$1("chii/server/lib/WebSocketServer");
1850
- if (typeof mod === "function") return mod;
1851
- } catch {}
1852
- return null;
1853
- }
1854
- /**
1855
- * Calls `chii.start()` and returns the chii `WebSocketServer` instance that
1856
- * was constructed during the call.
1857
- *
1858
- * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`
1859
- * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`
1860
- * at module load time. The class reference is stable, so we can temporarily
1861
- * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —
1862
- * to record `this` before the original `start` runs.
1863
- *
1864
- * The patch is installed before `chii.start()` and removed (via `finally`)
1865
- * immediately after, so concurrent `startChiiRelay` calls nest correctly: each
1866
- * call's patch overrides the previous in the prototype chain for the duration
1867
- * of its own `chii.start()` call, restoring the prior descriptor on exit.
1868
- *
1869
- * If `ChiiWssClass` is null (internal path changed in a future chii release),
1870
- * `chii.start()` runs unpatched and the function returns null — callers skip
1871
- * keepalive gracefully without affecting relay correctness.
1872
- */
1873
- async function startChiiWithCapture(chii, startOptions, ChiiWssClass) {
1874
- if (ChiiWssClass === null) {
1875
- await chii.start(startOptions);
1876
- return null;
1877
- }
1878
- let captured = null;
1879
- const proto = ChiiWssClass.prototype;
1880
- const originalStart = proto.start;
1881
- proto.start = function(server) {
1882
- captured = this;
1883
- return originalStart.call(this, server);
1884
- };
1885
- try {
1886
- await chii.start(startOptions);
1887
- } finally {
1888
- proto.start = originalStart;
1889
- }
1890
- return captured;
1891
- }
1892
- function loadChiiServer() {
1893
- const mod = require$1("chii");
1894
- if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
1895
- throw new Error("chii server module did not expose start()");
1896
- }
1897
- /**
1898
- * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent
1899
- * query-based form, e.g.:
1900
- *
1901
- * `/at/123456/target.js` → `/target.js?at=123456`
1902
- * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`
1903
- * `/at/123456/` → `/?at=123456`
1904
- *
1905
- * Returns `null` when the URL does not carry the prefix (including an empty
1906
- * code segment) — callers fall back to the unmodified URL and the existing
1907
- * query-based auth path.
1908
- *
1909
- * Pure string surgery — this function knows nothing about secrets or code
1910
- * validity; verification stays inside the caller-provided `verifyAuth`
1911
- * predicate (which parses the query). The raw path segment is appended
1912
- * verbatim to the query: both path segments and query values are
1913
- * percent-decoded exactly once by their consumers, so no re-encoding is
1914
- * needed (TOTP codes are 6 digits and never percent-encoded in practice).
1915
- */
1916
- function rewriteAtPathPrefix(rawUrl) {
1917
- const match = /^\/at\/([^/?]+)(\/[^?]*)?(\?.*)?$/.exec(rawUrl);
1918
- if (match === null) return null;
1919
- const code = match[1];
1920
- const path = match[2] === void 0 || match[2] === "" ? "/" : match[2];
1921
- const query = match[3] ?? "";
1922
- return `${path}${query}${query === "" ? "?" : "&"}at=${code}`;
1923
- }
1924
- /**
1925
- * Starts the Chii relay and resolves once listening.
1926
- *
1927
- * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral
1928
- * port on every start, so a stale cloudflared orphan holding any particular
1929
- * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`
1930
- * always reflect the actual bound port.
1931
- *
1932
- * chii.start() is called with `server` (our pre-created httpServer) BEFORE
1933
- * httpServer.listen(). This is intentional: chii attaches its Koa handler and
1934
- * WS upgrade listener to the server object, but the actual TCP bind is
1935
- * performed by our httpServer.listen() call below. The `port`/`domain` values
1936
- * passed to chii.start() are used for display/banner purposes inside chii and
1937
- * do not affect which port the server binds. The connection path (clients
1938
- * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.
1939
- */
1940
- async function startChiiRelay(options = {}) {
1941
- const requestedPort = options.port ?? 0;
1942
- const host = options.host ?? "127.0.0.1";
1943
- const { verifyAuth, onAuthReject } = options;
1944
- const keepaliveIntervalMs = options.keepaliveIntervalMs !== void 0 ? options.keepaliveIntervalMs : DEFAULT_KEEPALIVE_INTERVAL_MS;
1945
- const httpServer = createServer();
1946
- const notifyAuthReject = (kind) => {
1947
- if (onAuthReject === void 0) return;
1948
- try {
1949
- onAuthReject({ kind });
1950
- } catch {}
1951
- };
1952
- if (verifyAuth) httpServer.on("request", (req, res) => {
1953
- const rewritten = rewriteAtPathPrefix(req.url ?? "");
1954
- if (rewritten !== null) {
1955
- req.url = rewritten;
1956
- if (!verifyAuth(req)) {
1957
- res.statusCode = 401;
1958
- res.setHeader("Access-Control-Allow-Origin", "*");
1959
- res.setHeader("Content-Type", "application/json");
1960
- res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));
1961
- notifyAuthReject("http-request");
1962
- }
1963
- return;
1964
- }
1965
- const pathname = (req.url ?? "").split("?")[0];
1966
- if (pathname === "/targets" || pathname === "/targets/") {
1967
- if (!verifyAuth(req)) {
1968
- res.statusCode = 401;
1969
- res.setHeader("Access-Control-Allow-Origin", "*");
1970
- res.setHeader("Content-Type", "application/json");
1971
- res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));
1972
- notifyAuthReject("http-request");
1973
- return;
1974
- }
1975
- return;
1976
- }
1977
- });
1978
- const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;
1979
- const capturedChiiWss = await startChiiWithCapture(loadChiiServer(), {
1980
- server: httpServer,
1981
- domain: `${host}:${requestedPort}`,
1982
- port: requestedPort
1983
- }, chiiWssClass);
1984
- if (verifyAuth) {
1985
- const chiiUpgradeListeners = httpServer.listeners("upgrade");
1986
- httpServer.removeAllListeners("upgrade");
1987
- const rejectWss = new WebSocketServer({ noServer: true });
1988
- httpServer.on("upgrade", (req, socket, head) => {
1989
- const rewritten = rewriteAtPathPrefix(req.url ?? "");
1990
- if (rewritten !== null) req.url = rewritten;
1991
- if (!verifyAuth(req)) {
1992
- rejectWss.handleUpgrade(req, socket, head, (ws) => {
1993
- ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);
1994
- });
1995
- notifyAuthReject("ws-upgrade");
1996
- return;
1997
- }
1998
- for (const listener of chiiUpgradeListeners) listener(req, socket, head);
1999
- });
2000
- }
2001
- const actualPort = await new Promise((resolve, reject) => {
2002
- httpServer.once("error", reject);
2003
- httpServer.listen(requestedPort, host, () => {
2004
- httpServer.off("error", reject);
2005
- resolve(httpServer.address().port);
2006
- });
2007
- });
2008
- let keepaliveHandle = null;
2009
- if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {
2010
- const chiiWss = capturedChiiWss;
2011
- keepaliveHandle = setInterval(() => {
2012
- for (const client of chiiWss._wss.clients) if (client.readyState === 1) client.ping();
2013
- }, keepaliveIntervalMs);
2014
- }
2015
- return {
2016
- port: actualPort,
2017
- baseUrl: `http://${host}:${actualPort}`,
2018
- close: () => new Promise((resolve) => {
2019
- if (keepaliveHandle !== null) {
2020
- clearInterval(keepaliveHandle);
2021
- keepaliveHandle = null;
2022
- }
2023
- httpServer.close(() => resolve());
2024
- })
2025
- };
2026
- }
2027
- //#endregion
2028
- //#region src/mcp/devtools-opener.ts
2029
- /**
2030
- * Assembles the Chii self-hosted DevTools inspector URL for a given relay
2031
- * and target.
2032
- *
2033
- * Chii serves its own DevTools frontend at
2034
- * `<relayHttpBaseUrl>/front_end/chii_app.html`. The `ws=` (plain HTTP relay)
2035
- * or `wss=` (HTTPS relay) query parameter is a URL-encoded string of the form
2036
- * `<relay-host>/client/<uuid>?target=<id>&at=<totp>` — the same format used
2037
- * by Chii's own target list page (derived from `chii/public/index.js`).
2038
- *
2039
- * The `at=` TOTP code is minted at call time via `mintTotp()`. It is valid
2040
- * for ~3 minutes (relay gate accepts ±RELAY_VERIFY_SKEW_STEPS=6 steps =
2041
- * 180–210 s). The developer must open the returned URL within that window.
2042
- * If the window expires before the browser connects, the relay will reject the
2043
- * WebSocket upgrade with close code 4401.
2044
- *
2045
- * FAIL-CLOSED (issue #509): `mintTotp` is REQUIRED. When omitted (i.e.
2046
- * `undefined`), this function returns `null` — the caller must treat `null` as
2047
- * "inspector not yet available" and show a waiting hint instead of a broken
2048
- * link. Relay sessions gate every WS upgrade with TOTP (#452), so a URL built
2049
- * without `at=` would be rejected with WS 4401 immediately — there is no
2050
- * non-TOTP relay path in production. Returning `null` surfaces this cleanly as
2051
- * a "TOTP not yet configured" state rather than silently producing a URL that
2052
- * will always fail at the WS handshake.
2053
- *
2054
- * SECRET-HANDLING: `mintTotp` returns a code, not a secret. The code is
2055
- * embedded in the `wss=` parameter (inside the `at=` param) of the returned
2056
- * URL. Callers MUST NOT log the returned URL to stdout (stderr is OK — it is
2057
- * the intended fallback surface for the developer to copy the URL).
2058
- *
2059
- * @param relayHttpBaseUrl - Local HTTP base URL of the Chii relay, e.g.
2060
- * `http://127.0.0.1:9100`. No trailing slash.
2061
- * @param targetId - Chii target id (from `GET <relay>/targets`).
2062
- * @param mintTotp - Function that returns a fresh 6-digit TOTP code string.
2063
- * Called at most once. **Required** — when `undefined`, the function returns
2064
- * `null` (fail-closed: no `at=` param means the relay WS gate rejects the
2065
- * handshake, so a null result is safer than a URL that always 404s).
2066
- * @param panel - Initial panel. Defaults to `"console"`.
2067
- *
2068
- * @returns The inspector URL string, or `null` when `mintTotp` is absent.
2069
- *
2070
- * @example
2071
- * buildChiiInspectorUrl(
2072
- * 'http://127.0.0.1:9100',
2073
- * 'abc123',
2074
- * () => generateTotp(secret),
2075
- * )
2076
- * // → 'http://127.0.0.1:9100/front_end/chii_app.html?ws=127.0.0.1%3A9100%2Fclient%2F<uuid>%3Ftarget%3Dabc123%26at%3D<code>'
2077
- */
2078
- function buildChiiInspectorUrl(relayHttpBaseUrl, targetId, mintTotp, panel = "console") {
2079
- if (!mintTotp) return null;
2080
- let relayHost;
2081
- let wsParamName;
2082
- try {
2083
- const parsed = new URL(relayHttpBaseUrl);
2084
- relayHost = parsed.host;
2085
- wsParamName = parsed.protocol === "https:" ? "wss" : "ws";
2086
- } catch {
2087
- relayHost = relayHttpBaseUrl.replace(/^https?:\/\//i, "");
2088
- wsParamName = /^https:/i.test(relayHttpBaseUrl) ? "wss" : "ws";
2089
- }
2090
- const clientId = `devtools-opener-${Date.now().toString(36)}`;
2091
- const code = mintTotp();
2092
- const wsPath = `${relayHost}/client/${clientId}?target=${encodeURIComponent(targetId)}&at=${encodeURIComponent(code)}`;
2093
- const params = new URLSearchParams({
2094
- [wsParamName]: wsPath,
2095
- panel
2096
- });
2097
- return `${relayHttpBaseUrl.replace(/\/$/, "")}/front_end/chii_app.html?${params.toString()}`;
2098
- }
2099
- /**
2100
- * Returns `true` when auto-open is **disabled**.
2101
- *
2102
- * Default (env var absent or any value other than `"1"`) is **disabled** —
2103
- * the developer uses the "디버그 툴 열기" button on the /attach or dashboard
2104
- * page instead. Set `AIT_AUTO_DEVTOOLS=1` to restore the old automatic
2105
- * browser-open behaviour on device attach.
2106
- *
2107
- * `AIT_AUTO_DEVTOOLS=0` retains its explicit opt-out meaning for backward
2108
- * compatibility (same effect as absent).
2109
- */
2110
- function isAutoDevtoolsDisabled() {
2111
- return process.env.AIT_AUTO_DEVTOOLS !== "1";
2112
- }
2113
- /**
2114
- * Opens the given URL in the OS default browser using a platform-appropriate
2115
- * command. Returns `true` on success.
2116
- *
2117
- * Failures are silent from the caller's perspective — the caller should log
2118
- * the URL to stderr as a fallback before calling this function.
2119
- */
2120
- function openUrlInBrowser(url) {
2121
- if (process.env.AIT_AUTO_DEVTOOLS_TEST_SKIP_SPAWN === "1") return false;
2122
- const { spawnSync } = __require("node:child_process");
2123
- const platform = process.platform;
2124
- let candidates;
2125
- if (platform === "darwin") candidates = [{
2126
- cmd: "open",
2127
- args: [url]
2128
- }];
2129
- else if (platform === "win32") candidates = [{
2130
- cmd: "cmd",
2131
- args: [
2132
- "/c",
2133
- "start",
2134
- "",
2135
- url
2136
- ]
2137
- }];
2138
- else candidates = [
2139
- {
2140
- cmd: "xdg-open",
2141
- args: [url]
2142
- },
2143
- {
2144
- cmd: "sensible-browser",
2145
- args: [url]
2146
- },
2147
- {
2148
- cmd: "x-www-browser",
2149
- args: [url]
2150
- }
2151
- ];
2152
- for (const { cmd, args } of candidates) try {
2153
- const result = spawnSync(cmd, args, {
2154
- encoding: "utf8",
2155
- timeout: 5e3
2156
- });
2157
- if (!result.error && result.status === 0) return true;
2158
- } catch {}
2159
- return false;
2160
- }
2161
- /**
2162
- * Manages auto-opening Chrome DevTools on every NEW target attach (issue #530).
2163
- *
2164
- * Create one instance per `runDebugServer` call and pass its `open()` method
2165
- * as the `onAttach` callback to the attach watcher (via `DualConnectionRouter`).
2166
- *
2167
- * The open fires for each NEW `targetId` — subsequent notifications for the
2168
- * same target are de-duplicated. Re-attach with a fresh targetId (e.g. after
2169
- * page reload on the phone) fires a new open. The URL opened is the stable
2170
- * `/inspector` endpoint (issue #530) when `inspectorStableUrl` is provided —
2171
- * it mints a fresh TOTP at click time so there is no expiry race. Falls back to
2172
- * building a direct `front_end/chii_app.html?wss=…` URL when
2173
- * `inspectorStableUrl` is absent.
2174
- *
2175
- * Opt-out and mock-environment guard are checked at call time.
2176
- */
2177
- var AutoDevtoolsOpener = class {
2178
- /** Per-target de-dupe set (issue #530 — target-unit guard replaces once-per-daemon). */
2179
- _openedTargets = /* @__PURE__ */ new Set();
2180
- /**
2181
- * Attempts to auto-open Chii DevTools in the developer's browser.
2182
- *
2183
- * Opens when:
2184
- * - `options.targetId` is a NEW target (not yet in `_openedTargets`).
2185
- *
2186
- * No-op when any of the following conditions hold:
2187
- * 1. `targetId` has already been opened (`_openedTargets` has it).
2188
- * 2. `AIT_AUTO_DEVTOOLS=0` opt-out is set.
2189
- * 3. `options.env` is `mock` (env 1 — F12 is already available).
2190
- * 4. `options.targetId` is null/undefined/empty (no page attached yet).
2191
- * 5. Neither `inspectorStableUrl` nor `relayHttpBaseUrl` is available.
2192
- *
2193
- * When `inspectorStableUrl` is provided (issue #530 stable URL): opens
2194
- * `http://127.0.0.1:<port>/inspector` directly and writes it to stderr.
2195
- * The URL contains no tunnel host or TOTP code — safe to log anywhere.
2196
- *
2197
- * Legacy path (no `inspectorStableUrl`): builds a direct
2198
- * `<relay-base>/front_end/chii_app.html?wss=…` URL from `relayHttpBaseUrl`
2199
- * + `mintTotp`, writes to stderr. TOTP expiry caveat applies (~3 min window).
2200
- *
2201
- * SECRET-HANDLING: direct inspector URL (written to stderr) may contain relay
2202
- * host and TOTP code. Stable URL is secret-free. Neither must go to stdout or
2203
- * persistent logs.
2204
- */
2205
- open(options) {
2206
- if (isAutoDevtoolsDisabled()) return;
2207
- if (options.env === "mock") return;
2208
- if (!options.targetId) return;
2209
- const targetId = options.targetId;
2210
- if (this._openedTargets.has(targetId)) return;
2211
- if (options.inspectorStableUrl) {
2212
- this._openedTargets.add(targetId);
2213
- const stableUrl = options.inspectorStableUrl;
2214
- process.stderr.write(`[ait-debug] 기기가 연결됐습니다.
2215
- [ait-debug] QR 페이지 또는 대시보드(${stableUrl.replace("/inspector", "")})의 "디버그 툴 열기" 버튼을 눌러 DevTools를 여세요.\n[ait-debug] (AIT_AUTO_DEVTOOLS=1 로 설정하면 연결 시 자동으로 열립니다)
2216
- `);
2217
- if (!openUrlInBrowser(stableUrl)) process.stderr.write(`[ait-debug] 브라우저 자동 열기 실패 — ${stableUrl} 을 브라우저에서 직접 여세요.\n`);
2218
- return;
2219
- }
2220
- if (!options.relayHttpBaseUrl) return;
2221
- this._openedTargets.add(targetId);
2222
- const inspectorUrl = buildChiiInspectorUrl(options.relayHttpBaseUrl, targetId, options.mintTotp);
2223
- if (inspectorUrl === null) {
2224
- process.stderr.write("[ait-debug] 기기가 연결됐습니다 — TOTP secret 미설정으로 인스펙터 URL을 생성할 수 없습니다.\n[ait-debug] relay 세션은 AIT_DEBUG_TOTP_SECRET 설정이 필요합니다.\n");
2225
- return;
2226
- }
2227
- process.stderr.write(`[ait-debug] 기기가 연결됐습니다.
2228
- [ait-debug] DevTools URL: ${inspectorUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=1 로 설정하면 연결 시 자동으로 열립니다)
2229
- [ait-debug] 주의: URL의 at= 코드는 ~3분 안에서만 유효합니다.
2230
- `);
2231
- if (!openUrlInBrowser(inspectorUrl)) process.stderr.write("[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\n");
2232
- }
2233
- /**
2234
- * Returns `true` if `open()` has been called for at least one target.
2235
- * (Replaces the old once-per-session `_opened` flag; kept for interface
2236
- * compatibility with tests that read `opener.opened`.)
2237
- */
2238
- get opened() {
2239
- return this._openedTargets.size > 0;
2240
- }
2241
- /** Returns the set of target IDs that have already been auto-opened. */
2242
- get openedTargets() {
2243
- return this._openedTargets;
2244
- }
2245
- };
2246
- //#endregion
2247
- //#region src/mcp/envelope.ts
2248
- /**
2249
- * Returns `true` when `AIT_MCP_COMPAT=chrome-devtools` is set, which bypasses
2250
- * envelope wrapping and returns raw payloads (0.1.x back-compat).
2251
- */
2252
- function isCompatMode() {
2253
- return process.env.AIT_MCP_COMPAT === "chrome-devtools";
2254
- }
2255
- /**
2256
- * Maps `McpEnvironment` to `EnvelopeEnv`. These are now the same 3-value
2257
- * union (`mock | relay-dev | relay-mobile`; `relay-live` removed in #665),
2258
- * so this is identity — kept as a named export for surface stability if
2259
- * envelope env diverges in the future.
2260
- */
2261
- function toEnvelopeEnv(env) {
2262
- return env;
2263
- }
2264
- /**
2265
- * Wraps `data` in a `ToolEnvelope<T>` **unless** compat mode is active, in
2266
- * which case `data` is returned as-is.
2267
- *
2268
- * Use this at every tool call-site in `debug-server.ts` and `server.ts`.
2269
- *
2270
- * @example
2271
- * ```ts
2272
- * return jsonResult(wrapEnvelope(listPages(connection, tunnel), {
2273
- * tool: 'list_pages',
2274
- * env: resolveEnvironment(),
2275
- * attached: connection.listTargets().length > 0,
2276
- * }));
2277
- * ```
2278
- */
2279
- function wrapEnvelope(data, ctx) {
2280
- if (isCompatMode()) return data;
2281
- return {
2282
- ok: true,
2283
- data,
2284
- meta: {
2285
- tool: ctx.tool,
2286
- env: toEnvelopeEnv(ctx.env),
2287
- attached: ctx.attached,
2288
- contentType: ctx.contentType ?? "json"
2289
- }
2290
- };
2291
- }
2292
- //#endregion
2293
- //#region src/mcp/local-connection.ts
2294
- /**
2295
- * Local-browser `CdpConnection` — attaches directly to a Chromium instance
2296
- * started with `--remote-debugging-port=<port>`.
2297
- *
2298
- * Topology (local debug mode, env 1):
2299
- * Chromium --CDP WS--> this connection <--stdio--> MCP host
2300
- *
2301
- * The core insight: local Chromium and the phone's Toss WebView both speak
2302
- * Chrome DevTools Protocol. The only difference is the attach strategy — how
2303
- * you reach the CDP endpoint. Here we hit the Chromium DevTools HTTP endpoint
2304
- * (`GET /json`) to discover per-target websocket URLs, then connect directly.
2305
- * The Chii relay (env 2/3) uses `GET /targets` + `/client/<id>?target=<id>`.
2306
- * Every tool (list_console_messages, get_dom_document, take_screenshot, …)
2307
- * reads only the `CdpConnection` interface and works unchanged on both.
2308
- *
2309
- * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
2310
- */
2311
- /** Max events retained per domain ring buffer. */
2312
- const DEFAULT_BUFFER_SIZE = 500;
2313
- function isObject(value) {
2314
- return typeof value === "object" && value !== null;
2315
- }
2316
- function parseInbound(raw) {
2317
- let parsed;
2318
- try {
2319
- parsed = JSON.parse(raw);
2320
- } catch {
2321
- return null;
2322
- }
2323
- if (!isObject(parsed)) return null;
2324
- const message = {};
2325
- if (typeof parsed.id === "number") message.id = parsed.id;
2326
- if (typeof parsed.method === "string") message.method = parsed.method;
2327
- if ("params" in parsed) message.params = parsed.params;
2328
- if ("result" in parsed) message.result = parsed.result;
2329
- if (isObject(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
2330
- return message;
2331
- }
2332
- const PHASE_1_EVENTS = [
2333
- "Runtime.consoleAPICalled",
2334
- "Network.requestWillBeSent",
2335
- "Network.responseReceived"
2336
- ];
2337
- /**
2338
- * `CdpConnection` that attaches directly to a local Chromium over its built-in
2339
- * CDP websocket. Mirrors `ChiiCdpConnection`'s buffering/command-routing/event
2340
- * logic — same `parseInbound`, ring-buffer, `pending` map patterns — but the
2341
- * attach strategy differs:
2342
- *
2343
- * Chii relay: `GET /targets` → open `/client/<id>?target=<id>` WS
2344
- * Local CDP: `GET /json` → open `webSocketDebuggerUrl` per target directly
2345
- *
2346
- * Target selection: first `type === 'page'` target whose URL is not
2347
- * `about:blank`, `about:newtab`, or a devtools:// URL.
2348
- */
2349
- var LocalCdpConnection = class {
2350
- /** Authoritative connection kind (issue #348) — local Chromium CDP. */
2351
- kind = "local";
2352
- devtoolsHttpUrl;
2353
- bufferSize;
2354
- emitter = new EventEmitter();
2355
- buffers = /* @__PURE__ */ new Map();
2356
- targets = /* @__PURE__ */ new Map();
2357
- ws = null;
2358
- nextCommandId = 1;
2359
- /** In-flight enableDomains() promise — concurrent callers share it. */
2360
- enablingPromise = null;
2361
- /** Pending request→response commands keyed by CDP message id. */
2362
- pending = /* @__PURE__ */ new Map();
2363
- constructor(options) {
2364
- this.devtoolsHttpUrl = options.devtoolsHttpUrl.replace(/\/$/, "");
2365
- this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
2366
- for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);
2367
- this.emitter.setMaxListeners(0);
2368
- }
2369
- /**
2370
- * Fetch the target list from the Chromium DevTools `/json` (or `/json/list`)
2371
- * endpoint and pick the first non-blank page target.
2372
- *
2373
- * Returns the selected target's `webSocketDebuggerUrl` alongside the
2374
- * normalized `CdpTarget` list (all page targets visible to the server).
2375
- */
2376
- async fetchTargets() {
2377
- const res = await fetch(`${this.devtoolsHttpUrl}/json`);
2378
- if (!res.ok) throw new Error(`Chromium DevTools /json returned HTTP ${res.status} ${res.statusText}. Is the browser running with --remote-debugging-port?`);
2379
- const body = await res.json();
2380
- const list = Array.isArray(body) ? body : [];
2381
- this.targets.clear();
2382
- let selected = null;
2383
- for (const item of list) {
2384
- if (!isObject(item) || typeof item.id !== "string") continue;
2385
- const cdpTarget = {
2386
- id: item.id,
2387
- title: typeof item.title === "string" ? item.title : "",
2388
- url: typeof item.url === "string" ? item.url : ""
2389
- };
2390
- this.targets.set(item.id, cdpTarget);
2391
- if (selected === null && item.type === "page" && typeof item.webSocketDebuggerUrl === "string" && !isBlankOrDevtoolsUrl(item.url)) selected = item;
2392
- }
2393
- return {
2394
- selected,
2395
- all: [...this.targets.values()]
2396
- };
2397
- }
2398
- listTargets() {
2399
- return [...this.targets.values()];
2400
- }
2401
- /**
2402
- * Discover the target, open a direct CDP websocket to its
2403
- * `webSocketDebuggerUrl`, and enable Phase 1+2 domains. Resolves once the
2404
- * socket is open and domain-enable commands are sent. Idempotent — concurrent
2405
- * callers share the in-flight promise.
2406
- */
2407
- async enableDomains() {
2408
- if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
2409
- if (this.enablingPromise) return this.enablingPromise;
2410
- this.enablingPromise = this._doEnableDomains().finally(() => {
2411
- this.enablingPromise = null;
2412
- });
2413
- return this.enablingPromise;
2414
- }
2415
- async _doEnableDomains() {
2416
- const { selected } = await this.fetchTargets();
2417
- if (!selected) throw new Error("No suitable page target found in the local Chromium instance. Ensure the browser has a non-blank page open and was started with --remote-debugging-port matching devtoolsHttpUrl.");
2418
- const wsUrl = selected.webSocketDebuggerUrl;
2419
- const ws = new WebSocket(wsUrl);
2420
- this.ws = ws;
2421
- await new Promise((resolve, reject) => {
2422
- ws.once("open", () => resolve());
2423
- ws.once("error", (err) => reject(err));
2424
- });
2425
- ws.on("message", (data) => this.handleMessage(data.toString()));
2426
- this.sendFireAndForget("Runtime.enable");
2427
- this.sendFireAndForget("Network.enable");
2428
- this.sendFireAndForget("DOM.enable");
2429
- this.sendFireAndForget("Page.enable");
2430
- }
2431
- /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
2432
- sendFireAndForget(method, params = {}) {
2433
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
2434
- const id = this.nextCommandId++;
2435
- this.ws.send(JSON.stringify({
2436
- id,
2437
- method,
2438
- params
2439
- }));
2440
- }
2441
- /**
2442
- * Issue a CDP command and resolve with its typed result. Rejects on a CDP
2443
- * error frame or when no websocket is open.
2444
- */
2445
- send(method, params) {
2446
- return this.sendCommand(method, params ?? {});
2447
- }
2448
- /**
2449
- * Issue an arbitrary request→response command and resolve with its raw
2450
- * result. Both the typed CDP `send` and any AIT domain commands build on this.
2451
- */
2452
- sendCommand(method, params = {}) {
2453
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No local Chromium page attached yet. Call enableDomains() first and ensure the browser is running with --remote-debugging-port."));
2454
- const id = this.nextCommandId++;
2455
- const ws = this.ws;
2456
- return new Promise((resolve, reject) => {
2457
- this.pending.set(id, {
2458
- resolve,
2459
- reject
2460
- });
2461
- ws.send(JSON.stringify({
2462
- id,
2463
- method,
2464
- params
2465
- }));
2466
- });
2467
- }
2468
- handleMessage(raw) {
2469
- const message = parseInbound(raw);
2470
- if (!message) return;
2471
- if (typeof message.id === "number" && this.pending.has(message.id)) {
2472
- const waiter = this.pending.get(message.id);
2473
- this.pending.delete(message.id);
2474
- if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
2475
- else waiter.resolve(message.result);
2476
- return;
2477
- }
2478
- if (typeof message.method !== "string") return;
2479
- if (!this.buffers.has(message.method)) return;
2480
- const event = message.method;
2481
- const buffer = this.buffers.get(event);
2482
- if (!buffer) return;
2483
- buffer.push(message.params);
2484
- if (buffer.length > this.bufferSize) buffer.shift();
2485
- this.emitter.emit(event, message.params);
2486
- }
2487
- getBufferedEvents(event) {
2488
- return this.buffers.get(event) ?? [];
2489
- }
2490
- on(event, listener) {
2491
- this.emitter.on(event, listener);
2492
- return () => this.emitter.off(event, listener);
2493
- }
2494
- /** Close the local CDP websocket and reject any in-flight commands. */
2495
- close() {
2496
- this.ws?.close();
2497
- this.ws = null;
2498
- for (const waiter of this.pending.values()) waiter.reject(/* @__PURE__ */ new Error("Local Chromium CDP connection closed."));
2499
- this.pending.clear();
2500
- }
2501
- };
2502
- /** True for URLs that should be skipped when selecting a page target. */
2503
- function isBlankOrDevtoolsUrl(url) {
2504
- return url === "" || url === "about:blank" || url === "about:newtab" || url.startsWith("devtools://") || url.startsWith("chrome://") || url.startsWith("chrome-extension://");
2505
- }
2506
- //#endregion
2507
- //#region src/mcp/local-launcher.ts
2508
- /**
2509
- * Chromium launcher for the local debug mode (env 1).
2510
- *
2511
- * Launch decision rationale:
2512
- * - `chrome-launcher` (npm) is purpose-built and finds installed Chrome, but
2513
- * adds a runtime dependency to the MCP bundle. The repo already has a clear
2514
- * "external dependency minimization" policy; `chrome-launcher` is not worth
2515
- * pulling in for what is essentially `spawn(chromeBin, [...flags])`.
2516
- * - Playwright is a devDependency used for E2E only — pulling `chromium.launch`
2517
- * into the runtime MCP path would add ~100 MB of bundled Chromium to the
2518
- * production install and break the "devDep = e2e only" boundary.
2519
- * - `child_process.spawn` with a platform-aware binary search is the lightest
2520
- * option: zero new dependencies, portable across macOS/Linux/Windows, and
2521
- * trivially testable by injecting a `spawnFn`.
2522
- *
2523
- * The launcher finds an installed Chrome/Chromium using a prioritized list of
2524
- * well-known binary paths per platform, then spawns it with:
2525
- * --remote-debugging-port=<port>
2526
- * --no-first-run
2527
- * --no-default-browser-check
2528
- * <devUrl>
2529
- *
2530
- * `pnpm dev` is started by the user; the MCP only launches the browser pointing
2531
- * at it.
2532
- *
2533
- * Node-only.
2534
- */
2535
- /**
2536
- * Find an ephemeral free TCP port by briefly binding a server on port 0.
2537
- * Resolves with the OS-assigned port number.
2538
- */
2539
- function findFreePort() {
2540
- return new Promise((resolve, reject) => {
2541
- const server = net.createServer();
2542
- server.listen(0, "127.0.0.1", () => {
2543
- const addr = server.address();
2544
- const port = typeof addr === "object" && addr !== null ? addr.port : null;
2545
- server.close(() => {
2546
- if (port === null) reject(/* @__PURE__ */ new Error("Failed to determine free port from net.Server."));
2547
- else resolve(port);
2548
- });
2549
- });
2550
- server.on("error", reject);
2551
- });
2552
- }
2553
- /**
2554
- * Returns an ordered list of Chromium/Chrome binary paths to try for the
2555
- * current platform.
2556
- */
2557
- function candidateChromePaths() {
2558
- const os = platform();
2559
- if (os === "darwin") return [
2560
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
2561
- "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
2562
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
2563
- "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
2564
- ];
2565
- if (os === "linux") return [
2566
- "/usr/bin/google-chrome",
2567
- "/usr/bin/google-chrome-stable",
2568
- "/usr/bin/chromium",
2569
- "/usr/bin/chromium-browser",
2570
- "/usr/local/bin/google-chrome",
2571
- "/usr/local/bin/chromium",
2572
- "/snap/bin/chromium"
2573
- ];
2574
- if (os === "win32") {
2575
- const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files";
2576
- const programFilesX86 = process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
2577
- return [
2578
- `${programFiles}\\Google\\Chrome\\Application\\chrome.exe`,
2579
- `${programFilesX86}\\Google\\Chrome\\Application\\chrome.exe`,
2580
- `${programFiles}\\Chromium\\Application\\chrome.exe`
2581
- ];
2582
- }
2583
- return [];
2584
- }
2585
- /** Find the first Chrome/Chromium binary that exists on this machine. */
2586
- function findChromeBinary() {
2587
- for (const p of candidateChromePaths()) if (existsSync(p)) return p;
2588
- return null;
2589
- }
2590
- /**
2591
- * Launch a local Chromium instance with CDP remote debugging enabled.
2592
- *
2593
- * The caller is responsible for calling `handle.stop()` when done.
2594
- *
2595
- * @throws if no Chrome/Chromium binary is found on the system.
2596
- */
2597
- async function launchChromium(options = {}) {
2598
- const spawnImpl = options.spawnFn ?? spawn;
2599
- const requestedPort = options.port ?? 0;
2600
- const port = requestedPort === 0 ? await findFreePort() : requestedPort;
2601
- const devUrl = options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173";
2602
- const binary = findChromeBinary();
2603
- if (binary === null) throw new Error("No Chrome/Chromium binary found on this system. Install Google Chrome or Chromium and try again. Searched: " + candidateChromePaths().join(", "));
2604
- const child = spawnImpl(binary, [
2605
- `--remote-debugging-port=${port}`,
2606
- "--no-first-run",
2607
- "--no-default-browser-check",
2608
- "--user-data-dir=/tmp/ait-devtools-chromium-profile",
2609
- ...options.extraArgs ?? [],
2610
- devUrl
2611
- ], {
2612
- stdio: "ignore",
2613
- detached: false
2614
- });
2615
- child.unref();
2616
- const devtoolsUrl = `http://127.0.0.1:${port}`;
2617
- process.stderr.write(`[ait-local-debug] Launched Chromium: ${binary}\n[ait-local-debug] CDP endpoint: ${devtoolsUrl}\n[ait-local-debug] Opening: ${devUrl}\n`);
2618
- return {
2619
- port,
2620
- devtoolsUrl,
2621
- stop() {
2622
- try {
2623
- child.kill();
2624
- } catch {}
2625
- }
2626
- };
2627
- }
2628
- //#endregion
2629
- //#region src/mcp/debug-server.ts
2630
- /**
2631
- * @ait-co/devtools debug-mode MCP server (stdio).
2632
- *
2633
- * Lets an AI coding agent attach to a running mini-app (real Toss WebView, or a
2634
- * browser in dev mode) and read its console/network/DOM/screenshot over CDP plus
2635
- * the AIT.* domain, without a human watching a phone. Transport is CDP-via-Chii:
2636
- * a local Chii relay on an OS-assigned port (default 0) exposed through a
2637
- * cloudflared quick tunnel; the phone attaches over the public wss URL.
2638
- *
2639
- * AI host --stdio--> this server --CDP client WS--> Chii relay :<OS-port>
2640
- * ^-- target WS -- phone
2641
- *
2642
- * Port 0 (default): the OS picks a free ephemeral port on every startup.
2643
- * This prevents EADDRINUSE when a stale cloudflared child (orphaned after
2644
- * SIGKILL, PPID 1) still holds a fixed port — which previously caused the MCP
2645
- * handshake to fail with -32000. With port 0 any orphaned cloudflared is
2646
- * harmless; the new relay always gets a fresh port.
2647
- *
2648
- * Best-effort child cleanup: SIGINT/SIGTERM/SIGHUP handlers call shutdown() to
2649
- * stop cloudflared and the relay. uncaughtException/unhandledRejection also
2650
- * call shutdown() before exit. SIGKILL cannot be intercepted by Node, so
2651
- * cloudflared orphans from SIGKILL remain (port 0 makes them harmless). Users
2652
- * can clean up manually: `pkill -f 'cloudflared.*trycloudflare'`.
2653
- *
2654
- * The tool layer reads from an injectable `CdpConnection` (CDP) and `AitSource`
2655
- * (AIT.*), so every tool is unit-testable with a fake (no phone). This module
2656
- * wires the live pieces (relay + tunnel + production connection); the phone
2657
- * roundtrip is fully wired and pending only on-device acceptance.
2658
- *
2659
- * Dynamic tool registration (issue #208):
2660
- * The server advertises `listChanged: true` so MCP clients can subscribe to
2661
- * `notifications/tools/list_changed`. Before any page attaches, only bootstrap
2662
- * tools (`start_attach`, `list_pages`) are listed. Once a target appears,
2663
- * the full attach-dependent tool set is added and a `list_changed` notification
2664
- * is sent — without requiring a session restart. `runDebugServer` and
2665
- * `runLocalDebugServer` start a polling watcher that detects the 0→N target
2666
- * transition and calls `server.sendToolListChanged()`.
2667
- *
2668
- * Note: `src/mcp/server.ts` (dev mode, HTTP mock-state) is NOT subject to this
2669
- * model — it has no attach concept and always exposes the full tool surface.
2670
- *
2671
- * Node-only.
2672
- */
2673
- var debug_server_exports = /* @__PURE__ */ __exportAll({
2674
- DualConnectionRouter: () => DualConnectionRouter,
2675
- MOBILE_RELAY_BASE_URL_MISSING_MESSAGE: () => MOBILE_RELAY_BASE_URL_MISSING_MESSAGE,
2676
- RELAY_SANDBOX_STALE_PAGE_MS: () => RELAY_SANDBOX_STALE_PAGE_MS,
2677
- START_ATTACH_REMINT_THRESHOLD_MS: () => START_ATTACH_REMINT_THRESHOLD_MS,
2678
- START_ATTACH_SEGMENT_MS: () => START_ATTACH_SEGMENT_MS,
2679
- bootExternalRelayFamily: () => bootExternalRelayFamily,
2680
- bootLocalFamily: () => bootLocalFamily,
2681
- bootRelayFamily: () => bootRelayFamily,
2682
- buildRelayVerifyAuth: () => buildRelayVerifyAuth,
2683
- connectionHostsAllowed: () => connectionHostsAllowed,
2684
- createDebugServer: () => createDebugServer,
2685
- envForMode: () => envForMode,
2686
- familyKeyForMode: () => familyKeyForMode,
2687
- isRelayMode: () => isRelayMode,
2688
- makeSingleConnectionRouter: () => makeSingleConnectionRouter,
2689
- normalizeStartDebugMode: () => normalizeStartDebugMode,
2690
- readMobileRelayBaseUrl: () => readMobileRelayBaseUrl,
2691
- readRelayLocalUrl: () => readRelayLocalUrl,
2692
- runDebugServer: () => runDebugServer,
2693
- runLocalDebugServer: () => runLocalDebugServer,
2694
- runMobileDebugServer: () => runMobileDebugServer,
2695
- startAttachWatcher: () => startAttachWatcher
2696
- });
2697
- /**
2698
- * Returns `true` when the mode routes to a relay connection (`relay-sandbox` or
2699
- * `relay-staging`). Both surface the Tier B / relay-only tool set.
2700
- */
2701
- function isRelayMode(mode) {
2702
- return mode === "relay-sandbox" || mode === "relay-staging";
2703
- }
2704
- /**
2705
- * Maps a `StartDebugMode` to the `McpEnvironment` it routes to (issue #626).
2706
- * Used by `start_attach`'s mode prologue to decide whether a `switchMode` is
2707
- * needed: when the active env already equals `envForMode(mode)`, the switch is
2708
- * skipped (no `tools/list_changed` churn).
2709
- *
2710
- * - `local-browser` → `mock`
2711
- * - `relay-sandbox` → `relay-mobile` (env 2 external-PWA relay)
2712
- * - `relay-staging` → `relay-dev` (env 3 intoss-private relay)
2713
- */
2714
- function envForMode(mode) {
2715
- switch (mode) {
2716
- case "local-browser": return "mock";
2717
- case "relay-sandbox": return "relay-mobile";
2718
- case "relay-staging": return "relay-dev";
2719
- }
2720
- }
2721
- /**
2722
- * Single-attach guard for `run_tests` (#646). Two concurrent runs injecting
2723
- * into the same single-attach page would interleave `Runtime.evaluate` and
2724
- * corrupt each other's `globalThis.__testBundle`. The model is "reject the
2725
- * second", not "queue" — a module-level flag is process-wide, which matches the
2726
- * single physical attached page (only one target is live at a time). The
2727
- * entry-time `conn` snapshot ensures a run finishes on the connection it started
2728
- * on even if `router.active` flips mid-run.
2729
- */
2730
- let runTestsInFlight = false;
2731
- /**
2732
- * Builds the debug-mode MCP server around an injected CDP connection + AIT
2733
- * source + tunnel status getter. Pure wiring — does not start a relay or
2734
- * tunnel, which is what makes the tool surface unit-testable.
2735
- *
2736
- * `tools/list` is two-tiered (issue #208):
2737
- * - bootstrap (always): `start_attach`, `list_pages`
2738
- * - attach-dependent (after `connection.listTargets().length > 0`): all others
2739
- *
2740
- * `CallTool` is NOT tiered — hidden tools still execute (attach errors surface
2741
- * naturally via `enableDomains`). The tier only controls visibility.
2742
- */
2743
- function createDebugServer(deps) {
2744
- const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 6e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret, onAttachUrlBuilt, getTunnelChildPid, readLock: readLockDep, stalePageThresholdMs = RELAY_SANDBOX_STALE_PAGE_MS, nowMs = () => Date.now() } = deps;
2745
- const getTotpSecret = deps.getTotpSecret ?? (() => totpSecret);
2746
- const readLockFn = readLockDep ?? readServerLock;
2747
- const router = routerDep ?? makeSingleConnectionRouter(connection);
2748
- const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, router.activeRelayOrigin));
2749
- const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},relayOrigin=${router.activeRelayOrigin ?? "none"}`);
2750
- const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
2751
- const attachDeps = {
2752
- getTunnelStatus,
2753
- getTotpSecret,
2754
- qrHttpServer,
2755
- onAttachUrlBuilt,
2756
- stalePageThresholdMs,
2757
- nowMs
2758
- };
2759
- const server = new Server({
2760
- name: "ait-debug",
2761
- version: "0.1.122"
2762
- }, { capabilities: { tools: { listChanged: true } } });
2763
- server.setRequestHandler(ListToolsRequestSchema, () => {
2764
- const conn = router.active;
2765
- const env = resolveEnvironment();
2766
- const attached = conn.listTargets().length > 0;
2767
- const envFiltered = filterToolsByEnvironment(DEBUG_TOOL_DEFINITIONS, env);
2768
- return { tools: attached ? envFiltered.map((tool) => ({ ...tool })) : envFiltered.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
2769
- });
2770
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
2771
- const name = request.params.name;
2772
- if (!isDebugToolName(name)) return {
2773
- content: [{
2774
- type: "text",
2775
- text: `Unknown tool: ${name}`
2776
- }],
2777
- isError: true
2778
- };
2779
- const conn = router.active;
2780
- if (name === "start_debug") {
2781
- const rawMode = request.params.arguments?.mode;
2782
- const mode = normalizeStartDebugMode(rawMode);
2783
- if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser' | 'relay-sandbox' | 'relay-staging' 중 하나를 전달하세요. (relay-live / env 4는 #665에서 제거됐습니다.)");
2784
- const rawProjectRoot = request.params.arguments?.projectRoot;
2785
- const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
2786
- try {
2787
- return jsonResult(await router.switchMode(mode, projectRoot));
2788
- } catch (err) {
2789
- return errorResult(err, name);
2790
- }
2791
- }
2792
- if (name === "start_attach") {
2793
- const args = request.params.arguments;
2794
- let attachConn = conn;
2795
- const rawMode = args?.mode;
2796
- if (rawMode !== void 0) {
2797
- const mode = normalizeStartDebugMode(rawMode);
2798
- if (mode === null || mode === "local-browser") return mcpError("start_attach: mode가 올바르지 않습니다. 'relay-sandbox' | 'relay-staging' 중 하나를 전달하세요 (local-browser는 QR attach가 없어 start_attach에서 지원하지 않습니다).");
2799
- const targetEnv = envForMode(mode);
2800
- if (resolveEnvironment() !== targetEnv) {
2801
- const rawProjectRoot = args?.projectRoot;
2802
- const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
2803
- try {
2804
- await router.switchMode(mode, projectRoot);
2805
- } catch (err) {
2806
- return errorResult(err, name);
2807
- }
2808
- attachConn = router.active;
2809
- }
2810
- }
2811
- const attachEnv = resolveEnvironment();
2812
- if (!isRelayEnv(attachEnv)) return mcpError("start_attach: relay 전용 tool입니다 (env 2 / relay-sandbox 또는 env 3 / relay-staging). 현재 환경은 'local-browser'(mock)입니다 — mode 인자로 'relay-sandbox' 또는 'relay-staging'을 전달하거나, 먼저 relay 모드로 전환하세요.");
2813
- const waitForAttach = true;
2814
- const rawWaitTimeout = args?.wait_timeout_seconds;
2815
- const callTimeoutMs = (() => {
2816
- if (typeof rawWaitTimeout !== "number" || !Number.isFinite(rawWaitTimeout)) return waitForAttachTimeoutMs;
2817
- if (rawWaitTimeout <= 0) return waitForAttachTimeoutMs;
2818
- return Math.round(Math.max(1, Math.min(600, rawWaitTimeout))) * 1e3;
2819
- })();
2820
- try {
2821
- const prep = await prepareAttach(attachDeps, attachEnv, args, attachConn);
2822
- if (!prep.ok) return prep.error;
2823
- const attachResult = await renderAndMaybeWait(attachDeps, prep, waitForAttach, callTimeoutMs, attachConn);
2824
- if (!attachResult.isError) await injectDebugIndicator(attachConn);
2825
- return attachResult;
2826
- } catch (err) {
2827
- return errorResult(err, name);
2828
- }
2829
- }
2830
- const env = resolveEnvironment();
2831
- const envReason = resolveEnvironmentReason();
2832
- if (!isToolAvailableIn(name, env)) {
2833
- const requiredEnv = getToolAvailability(name) ?? "unknown";
2834
- logWarn("tool.error", {
2835
- tool: name,
2836
- errorKind: "tier-filter",
2837
- requiredEnv,
2838
- currentEnv: env,
2839
- envReason
2840
- });
2841
- return tierRejectionError(name, requiredEnv, env, envReason);
2842
- }
2843
- if (isAitToolName(name)) try {
2844
- await conn.enableDomains();
2845
- switch (name) {
2846
- case "AIT.getSdkCallHistory": return jsonResult(await getSdkCallHistory(aitSource));
2847
- case "AIT.getMockState": return jsonResult(await getMockState(aitSource));
2848
- case "AIT.getOperationalEnvironment": return jsonResult(await getOperationalEnvironment(aitSource));
2849
- default: return unknownTool(name);
2850
- }
2851
- } catch (err) {
2852
- return errorResult(err, name);
2853
- }
2854
- if (name === "get_debug_status") try {
2855
- const rawLimit = request.params.arguments?.recent_errors_limit;
2856
- const recentErrorsLimit = typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 10;
2857
- return envelopeResult(await getDiagnostics({
2858
- tunnel: getTunnelStatus(),
2859
- connection: conn,
2860
- env,
2861
- envReason,
2862
- collector,
2863
- readLock: readLockFn,
2864
- recentErrorsLimit,
2865
- tunnelChildPid: getTunnelChildPid?.() ?? void 0
2866
- }), name, env, conn.listTargets().length > 0);
2867
- } catch (err) {
2868
- return errorResult(err, name);
2869
- }
2870
- try {
2871
- await conn.enableDomains();
2872
- } catch (err) {
2873
- if (name === "list_pages") {
2874
- try {
2875
- await conn.refreshTargets?.();
2876
- } catch {}
2877
- return envelopeResult(listPages(conn, getTunnelStatus()), name, env, conn.listTargets().length > 0);
2878
- }
2879
- return classifyEnableDomainError(err, name);
2880
- }
2881
- try {
2882
- switch (name) {
2883
- case "list_console_messages": return jsonResult(listConsoleMessages(conn));
2884
- case "list_exceptions": {
2885
- const rawLimit = request.params.arguments?.limit;
2886
- return jsonResult({ exceptions: listExceptions(conn, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
2887
- }
2888
- case "list_network_requests": return jsonResult(listNetworkRequests(conn));
2889
- case "list_pages":
2890
- try {
2891
- await conn.refreshTargets?.();
2892
- } catch {}
2893
- return envelopeResult(listPages(conn, getTunnelStatus()), name, env, conn.listTargets().length > 0);
2894
- case "get_dom_document": return jsonResult(await getDomDocument(conn));
2895
- case "take_snapshot": return jsonResult(await takeSnapshot(conn));
2896
- case "take_screenshot": {
2897
- const shot = await takeScreenshot(conn);
2898
- return { content: [{
2899
- type: "image",
2900
- data: shot.data,
2901
- mimeType: shot.mimeType
2902
- }] };
2903
- }
2904
- case "measure_safe_area": return envelopeResult(await measureSafeArea(conn, env), name, env, conn.listTargets().length > 0);
2905
- case "evaluate": {
2906
- const expression = request.params.arguments?.expression;
2907
- if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
2908
- if (!connectionHostsAllowed(conn)) return mcpError("evaluate: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
2909
- return jsonResult(await evaluate(conn, expression));
2910
- }
2911
- case "call_sdk": {
2912
- const sdkName = request.params.arguments?.name;
2913
- if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
2914
- const rawArgs = request.params.arguments?.args;
2915
- const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
2916
- if (!connectionHostsAllowed(conn)) return mcpError("call_sdk: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
2917
- const sdkResult = await callSdk(conn, sdkName, sdkArgs);
2918
- if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk", conn.kind === "local");
2919
- return envelopeResult(sdkResult, name, env, conn.listTargets().length > 0);
2920
- }
2921
- case "run_tests": {
2922
- const rawFiles = request.params.arguments?.files;
2923
- if (!Array.isArray(rawFiles) || rawFiles.length === 0) return mcpError("run_tests: files 인자가 비어 있습니다. 실행할 테스트 파일 glob을 배열로 전달하세요.");
2924
- const patterns = rawFiles.filter((p) => typeof p === "string" && p !== "");
2925
- if (patterns.length === 0) return mcpError("run_tests: files 인자에 유효한 문자열 glob이 없습니다.");
2926
- const rawRoot = request.params.arguments?.projectRoot;
2927
- const projectRoot = typeof rawRoot === "string" ? rawRoot : process.cwd();
2928
- const rawTimeout = request.params.arguments?.timeout_ms;
2929
- const timeoutMs = typeof rawTimeout === "number" && rawTimeout >= 1e3 && rawTimeout <= 6e5 ? rawTimeout : void 0;
2930
- const runTestPages = conn.listTargets();
2931
- const connAsAny = conn;
2932
- const hasLivePage = isSandboxPageFresh(runTestPages, typeof connAsAny.getTargetLastSeenAt === "function" ? (id) => connAsAny.getTargetLastSeenAt(id) : null, nowMs(), stalePageThresholdMs);
2933
- if (!hasLivePage && isRelayEnv(env)) {
2934
- const autoAttachArgs = request.params.arguments;
2935
- const prep = await prepareAttach(attachDeps, env, autoAttachArgs, conn);
2936
- if (!prep.ok) return prep.error;
2937
- const autoAttachResult = await renderAndMaybeWait(attachDeps, prep, true, waitForAttachTimeoutMs, conn);
2938
- if (autoAttachResult.isError) return autoAttachResult;
2939
- const rawCell = autoAttachArgs?.cell;
2940
- if (rawCell !== null && typeof rawCell === "object" && !Array.isArray(rawCell)) await injectGlobals(conn, rawCell);
2941
- if (!connectionHostsAllowed(conn)) return mcpError("run_tests: 연결된 페이지가 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
2942
- if (runTestsInFlight) return mcpError("run_tests: 이미 다른 테스트 실행이 진행 중입니다 (single-attach 모델: 페이지는 한 번에 하나의 실행만 처리). 완료 후 다시 시도하세요.");
2943
- runTestsInFlight = true;
2944
- try {
2945
- const files = await discoverTestFiles(patterns, projectRoot);
2946
- if (files.length === 0) return mcpError(`run_tests: 매칭된 테스트 파일이 없습니다 (patterns: ${patterns.join(", ")}).`);
2947
- if (conn.listTargets().length === 0) return pageMissingError("run_tests");
2948
- logInfo("run_tests.start", {
2949
- fileCount: files.length,
2950
- autoAttach: true
2951
- });
2952
- const report = await runWithConnection(conn, files, {
2953
- timeoutMs,
2954
- collectCaptures: true
2955
- });
2956
- logInfo("run_tests.done", {
2957
- passed: report.totals.passed,
2958
- failed: report.totals.failed,
2959
- skipped: report.totals.skipped
2960
- });
2961
- const runAttached = conn.listTargets().length > 0;
2962
- return envelopeResult(toRunTestsResult(report), name, env, runAttached);
2963
- } finally {
2964
- runTestsInFlight = false;
2965
- }
2966
- }
2967
- if (!hasLivePage) return mcpError("run_tests: 연결된 페이지가 없습니다. mock(로컬) 환경에서는 auto-attach가 지원되지 않습니다. list_pages로 연결 상태를 확인하고 페이지가 붙어 있는지 확인하세요.");
2968
- if (!connectionHostsAllowed(conn)) return mcpError("run_tests: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
2969
- if (runTestsInFlight) return mcpError("run_tests: 이미 다른 테스트 실행이 진행 중입니다 (single-attach 모델: 페이지는 한 번에 하나의 실행만 처리). 완료 후 다시 시도하세요.");
2970
- runTestsInFlight = true;
2971
- try {
2972
- const files = await discoverTestFiles(patterns, projectRoot);
2973
- if (files.length === 0) return mcpError(`run_tests: 매칭된 테스트 파일이 없습니다 (patterns: ${patterns.join(", ")}).`);
2974
- if (conn.listTargets().length === 0) return pageMissingError("run_tests");
2975
- logInfo("run_tests.start", { fileCount: files.length });
2976
- const report = await runWithConnection(conn, files, {
2977
- timeoutMs,
2978
- collectCaptures: true
2979
- });
2980
- logInfo("run_tests.done", {
2981
- passed: report.totals.passed,
2982
- failed: report.totals.failed,
2983
- skipped: report.totals.skipped
2984
- });
2985
- const runAttached = conn.listTargets().length > 0;
2986
- return envelopeResult(toRunTestsResult(report), name, env, runAttached);
2987
- } finally {
2988
- runTestsInFlight = false;
2989
- }
2990
- }
2991
- default: return unknownTool(name);
2992
- }
2993
- } catch (err) {
2994
- return errorResult(err, name, conn.kind === "local");
2995
- }
2996
- });
2997
- return server;
2998
- }
2999
- /**
3000
- * Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or
3001
- * `null` when the value is not one of the three accepted modes:
3002
- * 'local-browser' | 'relay-sandbox' | 'relay-staging'
3003
- *
3004
- * Hard rename (issue #398): the older `local`/`mobile`/`staging`/`live` names
3005
- * and their aliases are no longer accepted — pre-1.0, no back-compat.
3006
- * `relay-live` (env 4) removed in #665.
3007
- */
3008
- function normalizeStartDebugMode(raw) {
3009
- if (raw === "local-browser" || raw === "relay-sandbox" || raw === "relay-staging") return raw;
3010
- return null;
3011
- }
3012
- /**
3013
- * Positive-allowlist kill-switch for side-effect MCP tools (#665).
3014
- *
3015
- * Returns `true` when the connection's attached targets are all on allowed
3016
- * debug hosts (localhost / trycloudflare / private-apps). Returns `false` when
3017
- * any target's page URL is on a non-allowed host (e.g. `apps.tossmini.com`).
3018
- *
3019
- * For local connections this always returns `true` — the local Chromium is
3020
- * always on localhost. For relay connections without any pages it returns
3021
- * `true` (no pages = nothing to block; the caller's page-missing guard fires
3022
- * first).
3023
- *
3024
- * SECRET-HANDLING: hostnames are NEVER logged here — only the boolean result
3025
- * is returned to the caller.
3026
- */
3027
- function connectionHostsAllowed(conn) {
3028
- if (conn.kind === "local") return true;
3029
- const pages = conn.listTargets();
3030
- if (pages.length === 0) return true;
3031
- return pages.every((p) => {
3032
- try {
3033
- return isDebugAllowedHost(new URL(p.url ?? "").hostname);
3034
- } catch {
3035
- return false;
3036
- }
3037
- });
3038
- }
3039
- /**
3040
- * Builds a trivial `ConnectionRouter` pinned to a single connection (issue
3041
- * #348). Used by `createDebugServer` when no real dual router is injected —
3042
- * every existing single-connection test and the `local`-only / `relay`-only
3043
- * boot path. `switchMode` here cannot lazily boot another family, so it only
3044
- * honors a request that matches the connection's own kind; any cross-family
3045
- * request is rejected with a clear "dynamic switch unavailable in this session"
3046
- * error. `confirm` parameter and `relay-live` gate removed (#665).
3047
- */
3048
- function makeSingleConnectionRouter(connection) {
3049
- return {
3050
- get active() {
3051
- return connection;
3052
- },
3053
- activeRelayOrigin: void 0,
3054
- switchMode(mode, _projectRoot) {
3055
- if (mode === "relay-sandbox") return Promise.reject(/* @__PURE__ */ new Error("start_debug: 이 세션은 단일 연결만 보유합니다 — 'relay-sandbox'(환경 2 PWA, 외부 relay)로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 relay-sandbox 모드로 재시작하세요."));
3056
- if (isRelayMode(mode) !== (connection.kind === "relay")) return Promise.reject(/* @__PURE__ */ new Error(`start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — '${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 원하는 모드로 재시작하세요.`));
3057
- const environment = deriveEnvironment(connection.kind);
3058
- return Promise.resolve({
3059
- mode,
3060
- environment,
3061
- kind: connection.kind,
3062
- nextStep: connection.kind === "relay" ? "start_attach로 attach QR 생성 + 폰 attach까지 한 번에 진행하세요." : "list_pages로 로컬 페이지 attach를 확인하세요."
3063
- });
3064
- }
3065
- };
3066
- }
3067
- /**
3068
- * Re-builds an attach URL from stored components with a FRESHLY-minted TOTP code,
3069
- * so the dashboard/`/attach` QR is never an expired bake-in (Defect 1).
3070
- * SECRET-HANDLING: reads AIT_DEBUG_TOTP_SECRET at call time (mirrors tunnel.ts
3071
- * getDashboardState). The minted code rides inside attachUrl's at= param only —
3072
- * never logged. generateTotp() relies on its Date.now() default.
3073
- */
3074
- function rebuildAttachUrl(parts) {
3075
- const secret = process.env.AIT_DEBUG_TOTP_SECRET;
3076
- const code = secret ? generateTotp(secret) : void 0;
3077
- return parts.kind === "launcher" ? buildLauncherAttachUrl(parts.tunnelHttpUrl, parts.wssUrl, code, {
3078
- name: parts.appName,
3079
- ...parts.selfdebug ? { selfdebug: true } : {}
3080
- }) : buildDeepLinkAttachUrl(parts.schemeUrl, parts.wssUrl, code);
3081
- }
3082
- function jsonResult(value) {
3083
- return { content: [{
3084
- type: "text",
3085
- text: JSON.stringify(value, null, 2)
3086
- }] };
3087
- }
3088
- /**
3089
- * Wraps `value` in a `ToolEnvelope` (when compat mode is off) and returns it
3090
- * as a text content block. When `AIT_MCP_COMPAT=chrome-devtools` is set the
3091
- * envelope is skipped and the raw value is returned — identical to `jsonResult`.
3092
- */
3093
- function envelopeResult(value, tool, env, attached) {
3094
- const wrapped = wrapEnvelope(value, {
3095
- tool,
3096
- env,
3097
- attached
3098
- });
3099
- return { content: [{
3100
- type: "text",
3101
- text: JSON.stringify(wrapped, null, 2)
3102
- }] };
3103
- }
3104
- /**
3105
- * Maps a {@link RelayRunReport} to a flat, agent-friendly object for the
3106
- * `run_tests` tool result. SECRET-HANDLING: a RelayRunReport carries only
3107
- * startedAt/duration/totals, per-file `{file, result}`, and capture lines —
3108
- * file paths are surfaced (allowed), relay wss/TOTP URLs never appear in it.
3109
- * No stripping needed; this only reshapes for readability.
3110
- *
3111
- * Captures (#696): the envelope surfaces a COUNT-LEVEL summary only
3112
- * (per-category line counts) — never the line bodies. Capture bodies belong in
3113
- * the on-disk artifact, not the `run_tests` log (keeps the tool result small and
3114
- * avoids dumping large capture arrays into the agent's context).
3115
- */
3116
- function toRunTestsResult(report) {
3117
- const captureCounts = {};
3118
- for (const { category } of report.captures) captureCounts[category] = (captureCounts[category] ?? 0) + 1;
3119
- return {
3120
- startedAt: report.startedAt,
3121
- duration: report.duration,
3122
- totals: report.totals,
3123
- files: report.files.map((f) => "error" in f.result ? {
3124
- file: f.file,
3125
- error: f.result.error
3126
- } : {
3127
- file: f.file,
3128
- duration: f.result.duration,
3129
- passed: f.result.passed,
3130
- failed: f.result.failed,
3131
- skipped: f.result.skipped,
3132
- tests: f.result.tests
3133
- }),
3134
- captures: captureCounts
3135
- };
3136
- }
3137
- function unknownTool(name) {
3138
- return mcpError(`알 수 없는 tool: ${name}`);
3139
- }
3140
- /**
3141
- * enableDomains()가 던진 에러를 4상태로 분류해 적절한 메시지를 반환한다.
3142
- *
3143
- * - "No mini-app page attached" → page 미attach (상태 2)
3144
- * - crash/destroy/replaced 패턴 → page crash (상태 3)
3145
- * - relay disconnect 패턴 → relay 연결 끊김
3146
- * - 그 외 → 원본 메시지 + list_pages 안내
3147
- */
3148
- function classifyEnableDomainError(err, toolName) {
3149
- const message = err instanceof Error ? err.message : String(err);
3150
- if (message.includes("No mini-app page attached") || message.includes("페이지가 attach 안")) return pageMissingError(toolName);
3151
- if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
3152
- if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket") || message.includes("Chii relay connection closed")) return relayDisconnectError(toolName);
3153
- return classifyToolError(err, toolName);
3154
- }
3155
- /**
3156
- * CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
3157
- * debug-server 내부 try/catch 블록에서 공통으로 사용한다.
3158
- */
3159
- function errorResult(err, name, isLocal = false) {
3160
- return classifyToolError(err, name, isLocal);
3161
- }
3162
- /**
3163
- * Starts a polling watcher that detects target-set changes on
3164
- * `connection.listTargets()` and sends a `notifications/tools/list_changed`
3165
- * notification on the given server.
3166
- *
3167
- * The watcher polls every `intervalMs` (default 1 000 ms). On each tick it
3168
- * calls `connection.refreshTargets?.()` first (fix #705-B) so that silent
3169
- * disconnects (no CDP event, phone backgrounded / tunnel quiet) are picked up
3170
- * before the signature is read. If `refreshTargets` throws — e.g. a transient
3171
- * relay error — the tick is skipped entirely to avoid a spurious detach signal.
3172
- *
3173
- * After the refresh, it fires `server.sendToolListChanged()` + `onAttach()`
3174
- * whenever the sorted target-id signature changes AND the new target set is
3175
- * non-empty. This covers:
3176
- * - 0→N first attach
3177
- * - 1→1 target replacement (same count, different id — e.g. rescan)
3178
- * - N→M any change where the result is still non-empty
3179
- *
3180
- * Full detach (→ empty) fires `onDetach()` (fix #705-A) on the exact
3181
- * non-empty→empty edge — i.e. only when the previous signature was non-empty.
3182
- * This lets callers push an immediate "disconnected" SSE update to the
3183
- * dashboard without waiting for the next periodic interval.
3184
- *
3185
- * The interval is **never cleared automatically** — it keeps running until
3186
- * `stop()` is called during shutdown. This ensures that a target replacement
3187
- * after the first attach is always detected.
3188
- *
3189
- * `onAttach` is called on every non-empty signature change (or immediately when
3190
- * already attached). Use this to trigger side-effects such as pushing a fresh
3191
- * SSE state to open dashboard tabs (issue #509). Both callbacks are optional;
3192
- * omitting them preserves the previous behaviour exactly.
3193
- *
3194
- * SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
3195
- * Only an attach-detected stderr line is emitted (no target details).
3196
- *
3197
- * @returns `stop` — call this during shutdown to clear the interval.
3198
- */
3199
- function startAttachWatcher(connection, server, intervalMs = 1e3, onAttach, onDetach) {
3200
- /** Sorted, comma-joined target-id string — '' means no targets attached. */
3201
- function signature() {
3202
- return connection.listTargets().map((t) => t.id).sort().join(",");
3203
- }
3204
- let lastSignature = signature();
3205
- if (lastSignature !== "") {
3206
- server.sendToolListChanged();
3207
- onAttach?.();
3208
- }
3209
- /** Compare current vs last signature and fire the appropriate callback. */
3210
- function tick() {
3211
- const current = signature();
3212
- if (current !== lastSignature) {
3213
- const wasNonEmpty = lastSignature !== "";
3214
- lastSignature = current;
3215
- if (current !== "") {
3216
- server.sendToolListChanged();
3217
- onAttach?.();
3218
- } else if (wasNonEmpty) onDetach?.();
3219
- }
3220
- }
3221
- const handle = setInterval(() => {
3222
- if (connection.refreshTargets) connection.refreshTargets().then(() => {
3223
- tick();
3224
- }, (_err) => {});
3225
- else tick();
3226
- }, intervalMs);
3227
- return { stop() {
3228
- clearInterval(handle);
3229
- } };
3230
- }
3231
- /**
3232
- * Factory that constructs a `ChiiCdpConnection` for the given relay base URL.
3233
- *
3234
- * Introduced as a named seam so PR-2 (dual-connection, #348) can defer
3235
- * construction to first-activation time by moving or replacing this call. Since
3236
- * #396 every family (relay included) is constructed lazily on its first
3237
- * `start_debug`, so this is always called from the lazy boot path.
3238
- *
3239
- * The relay base URL is only available after `startChiiRelay()` resolves, so
3240
- * the factory is called right after that point (same as before this refactor).
3241
- */
3242
- function createRelayConnection(relayBaseUrl) {
3243
- return new ChiiCdpConnection({
3244
- relayBaseUrl,
3245
- totpSecret: process.env.AIT_DEBUG_TOTP_SECRET
3246
- });
3247
- }
3248
- /**
3249
- * AIT source that always forwards over the *currently active* connection
3250
- * (issue #348). The single-connection `ChiiAitSource` binds one sender at
3251
- * construction; in the dual-connection daemon the AIT.* domain must follow the
3252
- * active connection across `start_debug` swaps, so this indirection reads
3253
- * `getActive()` on every call.
3254
- *
3255
- * Both `ChiiCdpConnection` and `LocalCdpConnection` expose `sendCommand`, so
3256
- * the active connection is a valid `AitCommandSender`.
3257
- */
3258
- var RoutingAitSource = class extends ChiiAitSource {
3259
- constructor(getActive) {
3260
- super({ sendCommand: (method, params) => getActive().sendCommand(method, params) });
3261
- }
3262
- };
3263
- /**
3264
- * Boots the local-browser family (issues #348, #356). Launches a Chromium with
3265
- * `--remote-debugging-port` and returns a `LocalCdpConnection` attached to it,
3266
- * plus a `stop()` that kills both.
3267
- *
3268
- * Booted lazily via the dual router's `bootLazyFor('local-browser')` callback,
3269
- * at most once on the first `start_debug({ mode: 'local-browser' })` (all-lazy,
3270
- * #396 — no run function boots a family at startup anymore).
3271
- */
3272
- async function bootLocalFamily() {
3273
- const chromium = await launchChromium({
3274
- port: 0,
3275
- devUrl: process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
3276
- });
3277
- await new Promise((r) => setTimeout(r, 800));
3278
- const connection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
3279
- return {
3280
- connection,
3281
- stop() {
3282
- connection.close();
3283
- chromium.stop();
3284
- }
3285
- };
3286
- }
3287
- /**
3288
- * Boots the relay family (issues #348, #356): starts the Chii relay on an
3289
- * OS-assigned port (with optional TOTP gate), opens a cloudflared quick tunnel
3290
- * to the relay's confirmed port in the background, prints the attach banner,
3291
- * and arms the tunnel health probe. Returns a {@link BootedFamily} whose
3292
- * `getTunnelStatus()` reflects the live tunnel (it flips up once the background
3293
- * tunnel resolves and follows reissues).
3294
- *
3295
- * Booted lazily via the dual router's `bootLazyFor('relay-intoss')` callback
3296
- * (symmetry with {@link bootLocalFamily}), at most once on the first
3297
- * `start_debug({ mode: 'relay-staging' })` (all-lazy, #396 — every relay boot now
3298
- * flows through `switchMode` after the project-local secret load). `relay-live`
3299
- * removed (#665).
3300
- *
3301
- * The relay base URL is only known after `startChiiRelay()` resolves, so the
3302
- * `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside
3303
- * this function, after the relay port is confirmed.
3304
- *
3305
- * SECRET-HANDLING: the TOTP secret rides only inside `verifyAuth`; the wssUrl
3306
- * (relay host) is never logged here directly.
3307
- */
3308
- async function bootRelayFamily(options = {}) {
3309
- assertRelayAuthConfigured();
3310
- const relayPort = options.relayPort ?? 0;
3311
- const totpEnabled = options.verifyAuth !== void 0;
3312
- const relay = await startChiiRelay({
3313
- port: relayPort,
3314
- verifyAuth: options.verifyAuth,
3315
- onAuthReject: options.onAuthReject
3316
- });
3317
- logInfo("server.start", {
3318
- port: relay.port,
3319
- totpEnabled
3320
- });
3321
- let tunnel = null;
3322
- let tunnelStatus = makeTunnelStatus(false, null);
3323
- let tunnelProbe = null;
3324
- generateAttachToken();
3325
- startQuickTunnel(relay.port).then((t) => {
3326
- tunnel = t;
3327
- tunnelStatus = makeTunnelStatus(true, t.wssUrl);
3328
- options.onWssUrl?.(t.wssUrl);
3329
- if (t.childPid !== void 0) options.onTunnelChildPid?.(t.childPid);
3330
- logInfo("tunnel.up", { totpEnabled });
3331
- tunnelProbe = startTunnelHealthProbe(t, relay.port, {
3332
- onReissue: (newTunnel) => {
3333
- tunnel = newTunnel;
3334
- tunnelStatus = makeTunnelStatus(true, newTunnel.wssUrl, null, 0);
3335
- options.onWssUrl?.(newTunnel.wssUrl);
3336
- if (newTunnel.childPid !== void 0) options.onTunnelChildPid?.(newTunnel.childPid);
3337
- printAttachBanner({
3338
- wssUrl: newTunnel.wssUrl,
3339
- totpEnabled
3340
- }).then(() => {
3341
- logInfo("tunnel.up", {
3342
- totpEnabled,
3343
- reissued: true
3344
- });
3345
- });
3346
- },
3347
- onPermanentDrop: (droppedAt) => {
3348
- tunnelStatus = makeTunnelStatus(false, null, droppedAt, 3);
3349
- logError("tunnel.down", { msg: `tunnel permanently dropped (${droppedAt}). Restart: npx @ait-co/devtools devtools-mcp` });
3350
- options.onTunnelDown?.();
3351
- }
3352
- });
3353
- return printAttachBanner({
3354
- wssUrl: t.wssUrl,
3355
- totpEnabled
3356
- });
3357
- }, (err) => {
3358
- logError("tunnel.down", { msg: `Failed to open cloudflared quick tunnel: ${err instanceof Error ? err.message : String(err)}. The relay is up locally; attach over the public URL is unavailable until the tunnel starts.` });
3359
- });
3360
- const connection = createRelayConnection(relay.baseUrl);
3361
- return {
3362
- connection,
3363
- relayOrigin: "intoss-webview",
3364
- relayHttpUrl: relay.baseUrl,
3365
- getTunnelStatus: () => tunnelStatus,
3366
- stop() {
3367
- tunnelProbe?.stop();
3368
- tunnel?.stop();
3369
- connection.close();
3370
- relay.close();
3371
- }
3372
- };
3373
- }
3374
- /**
3375
- * Boots the EXTERNAL relay family for env 2 (real-device PWA, issue #378).
3376
- *
3377
- * Unlike {@link bootRelayFamily}, this does NOT start a relay or a tunnel —
3378
- * the unplugin (`tunnel: { cdp: true }`) already brought up a Chii relay for
3379
- * the env-2 PWA and exposed its public base URL via `AIT_RELAY_BASE_URL`. Here
3380
- * the MCP only opens a CDP client (`createRelayConnection`) against that
3381
- * external relay. The relay's lifecycle is owned by the unplugin, so `stop()`
3382
- * closes ONLY the CDP client — it must never tear down the relay or a tunnel
3383
- * we did not start.
3384
- *
3385
- * `getTunnelStatus()` reports `up: true` with a `wssUrl` derived from
3386
- * `relayBaseUrl` (http→ws, https→wss) so the `start_attach` gate
3387
- * (`up: true && wssUrl !== null`) is satisfied even though we never opened a
3388
- * cloudflared tunnel ourselves.
3389
- *
3390
- * SECRET-HANDLING: `relayBaseUrl` carries the relay host (same sensitivity as a
3391
- * wss URL) — it is NEVER logged here. The caller validates presence and passes
3392
- * the value straight to the CDP client.
3393
- */
3394
- /**
3395
- * Attempts to read the local loopback HTTP base URL of the env-2 Chii relay
3396
- * (issue #530). Resolution order:
3397
- * 1. `AIT_RELAY_LOCAL_URL` env var, if set and non-empty.
3398
- * 2. `relayLocalUrl` from the `.ait_urls` file, if `projectRoot` is given.
3399
- * 3. `undefined` — caller falls back to the tunnel base (existing behavior).
3400
- *
3401
- * This is a best-effort read — never throws. The returned value is a plain
3402
- * `http://127.0.0.1:<port>` loopback URL; no secret exposure.
3403
- */
3404
- async function readRelayLocalUrl(env = process.env, projectRoot) {
3405
- const envValue = (env.AIT_RELAY_LOCAL_URL ?? "").trim();
3406
- if (envValue !== "") return envValue;
3407
- if (projectRoot !== void 0) try {
3408
- const { readRelayUrls } = await import("./relay-url-store-D3H067u0.js");
3409
- const stored = await readRelayUrls({ projectRoot });
3410
- if (stored?.relayLocalUrl) return stored.relayLocalUrl;
3411
- } catch {}
3412
- }
3413
- async function bootExternalRelayFamily(relayBaseUrl, relayLocalUrl) {
3414
- assertRelayAuthConfigured();
3415
- const connection = createRelayConnection(relayBaseUrl);
3416
- const tunnelStatus = makeTunnelStatus(true, relayBaseUrl.replace(/^http/, "ws"));
3417
- return {
3418
- connection,
3419
- relayOrigin: "external-pwa",
3420
- relayHttpUrl: relayBaseUrl,
3421
- relayLocalHttpUrl: relayLocalUrl,
3422
- getTunnelStatus: () => tunnelStatus,
3423
- stop() {
3424
- connection.close();
3425
- }
3426
- };
3427
- }
3428
- /**
3429
- * Maps a `StartDebugMode` to the {@link FamilyKey} that serves it (issue #378).
3430
- * local-browser → 'local-browser'; relay-sandbox → 'relay-sandbox';
3431
- * relay-staging → 'relay-intoss' (the intoss-private relay slot).
3432
- * `relay-live` removed (#665).
3433
- */
3434
- function familyKeyForMode(mode) {
3435
- switch (mode) {
3436
- case "local-browser": return "local-browser";
3437
- case "relay-sandbox": return "relay-sandbox";
3438
- case "relay-staging": return "relay-intoss";
3439
- }
3440
- }
3441
- /** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */
3442
- const MOBILE_RELAY_BASE_URL_MISSING_MESSAGE = "start_debug(mobile): AIT_RELAY_BASE_URL이 설정되지 않았습니다. dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. 자동 발견이 되지 않을 경우 relay base URL을 AIT_RELAY_BASE_URL 환경변수로 직접 전달하세요. 환경 2(실기기 PWA) 진입은 외부 relay base가 필요합니다.";
3443
- /**
3444
- * Reads the env-2 relay base URL for the `mobile` boot site (issue #378, #424).
3445
- *
3446
- * Resolution order (env wins — file is the fallback):
3447
- * 1. `env.AIT_RELAY_BASE_URL` set and non-empty → return it (operator override).
3448
- * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_urls`;
3449
- * if `relayBaseUrl` is present → return it (auto-discovered from dev server).
3450
- * 3. Neither → throw {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE}.
3451
- *
3452
- * SECRET-HANDLING: `AIT_RELAY_BASE_URL` and the file-discovered value carry the
3453
- * relay host. On the missing path the thrown message names the env var and notes
3454
- * that the dev server auto-publishes it — it NEVER echoes any URL value. The
3455
- * present value is returned to the caller (the CDP client) but never logged.
3456
- */
3457
- async function readMobileRelayBaseUrl(env = process.env, projectRoot) {
3458
- const raw = env.AIT_RELAY_BASE_URL;
3459
- const envValue = typeof raw === "string" ? raw.trim() : "";
3460
- if (envValue !== "") return envValue;
3461
- if (projectRoot !== void 0) {
3462
- const { readRelayUrls } = await import("./relay-url-store-D3H067u0.js");
3463
- const stored = await readRelayUrls({ projectRoot });
3464
- if (stored?.relayBaseUrl !== void 0) return stored.relayBaseUrl;
3465
- }
3466
- throw new Error(MOBILE_RELAY_BASE_URL_MISSING_MESSAGE);
3467
- }
3468
- /**
3469
- * Sentinel connection returned by {@link DualConnectionRouter.active} before the
3470
- * first `start_debug` boots a family (all-lazy, issue #396). It satisfies the
3471
- * full {@link CdpConnection} interface but holds nothing: `listTargets()` is
3472
- * empty, every command rejects with a clear "call start_debug first" message,
3473
- * and all event/teardown members are safe no-ops. Callers that read tools before
3474
- * any switchMode therefore get an honest empty/down state instead of an NPE.
3475
- */
3476
- const NULL_CDP_CONNECTION = {
3477
- kind: "local",
3478
- enableDomains: () => Promise.resolve(),
3479
- listTargets: () => [],
3480
- getBufferedEvents: () => [],
3481
- on: () => () => {},
3482
- send: () => Promise.reject(/* @__PURE__ */ new Error("no family booted yet — call start_debug first")),
3483
- close: () => {}
3484
- };
3485
- /**
3486
- * Production `ConnectionRouter` (issues #348, #356, #378 — DUAL-CONNECTION-COEXIST).
3487
- *
3488
- * Holds a keyed set of lazily-booted families ({@link FamilyKey} →
3489
- * `BootedFamily`, issue #378) with NO family active at startup (issue #396); the
3490
- * first `start_debug` boots and activates one. Plus an `active` pointer and the
3491
- * single attach watcher armed on the active connection. The router is
3492
- * **direction-neutral** (#356): any family can be the first one booted, so a
3493
- * `--target=local` session can hot-switch into relay (and vice versa) without
3494
- * restarting the MCP server.
3495
- *
3496
- * Why a KEYED map and not a single lazy slot (#378): `relay-sandbox` (env-2
3497
- * external relay) and `relay-staging` (intoss relay) are BOTH `kind: 'relay'`.
3498
- * A single "opposite-kind" slot could not warm-keep both at once — they would
3499
- * collide. The three `FamilyKey`s (`local-browser` / `relay-intoss` /
3500
- * `relay-sandbox`) give each its own warm slot. `relay-live` (env 4) removed
3501
- * (#665) — `relay-intoss` slot now maps only to `relay-staging`.
3502
- *
3503
- * Why all-lazy (#396): the relay TOTP secret now lives in a project-local
3504
- * `.ait_relay` file loaded read-only by `switchMode` BEFORE a relay family boots.
3505
- * Booting any family eagerly at startup would bypass that load. With NO eager
3506
- * boot every relay boot flows through `switchMode → loadRelaySecretReadOnly`, so
3507
- * the secret is always populated before `assertRelayAuthConfigured()` /
3508
- * `buildRelayVerifyAuth()` run at the boot site.
3509
- *
3510
- * `switchMode`:
3511
- * 1. rejects re-entrant swaps (`swapInFlight`);
3512
- * 2. resolves the requested mode's `FamilyKey`:
3513
- * `lazyFamilies.get(key) ?? (boot via bootLazyFor(key), store)`;
3514
- * 3. flips `active` (the MCP `Server` never re-handshakes — it reads through
3515
- * `active` per request);
3516
- * 4. stops the old attach watcher and re-arms one on the new connection
3517
- * (the watcher self-clears, so re-arm is mandatory);
3518
- * 5. emits `tools/list_changed`.
3519
- *
3520
- * Inactive infra is left WARM — teardown happens only at process exit (the
3521
- * unified shutdown in the run functions), which is what keeps a phone attach
3522
- * alive across a local→relay→local round trip.
3523
- */
3524
- var DualConnectionRouter = class {
3525
- deps;
3526
- /** Families, booted lazily and warm-kept per {@link FamilyKey} (#378, #396). */
3527
- lazyFamilies = /* @__PURE__ */ new Map();
3528
- /** `null` until the first `start_debug` boots a family (all-lazy, #396). */
3529
- activeFamily = null;
3530
- server = null;
3531
- attachWatcher = null;
3532
- swapInFlight = false;
3533
- constructor(deps) {
3534
- this.deps = deps;
3535
- }
3536
- get active() {
3537
- return this.activeFamily ? this.activeFamily.connection : NULL_CDP_CONNECTION;
3538
- }
3539
- /** Relay origin of the currently-active family (issue #378). */
3540
- get activeRelayOrigin() {
3541
- return this.activeFamily?.relayOrigin;
3542
- }
3543
- /**
3544
- * HTTP base URL of the Chii relay to use for inspector URL assembly (#503,
3545
- * #530). Prefers the LOCAL loopback base (`relayLocalHttpUrl`) when available
3546
- * so front_end page load + client WS do not traverse a cloudflare tunnel —
3547
- * falls back to `relayHttpUrl` (the tunnel base for env-2, loopback for env-3/4)
3548
- * when not set. Returns `undefined` when no relay family is active.
3549
- *
3550
- * SECRET-HANDLING: when relayLocalHttpUrl is absent this falls back to
3551
- * relayHttpUrl which may carry the tunnel host — callers must not log it.
3552
- */
3553
- get activeRelayHttpUrl() {
3554
- if (!this.activeFamily) return void 0;
3555
- return this.activeFamily.relayLocalHttpUrl ?? this.activeFamily.relayHttpUrl;
3556
- }
3557
- /** Every booted family (for unified shutdown). All families are lazy (#396). */
3558
- bootedFamilies() {
3559
- return [...this.lazyFamilies.values()];
3560
- }
3561
- /**
3562
- * Live tunnel status of the active relay family (issues #356, #378). Reads
3563
- * the ACTIVE family's tunnel when it has one (so `relay-sandbox` surfaces the
3564
- * external relay wss and `relay-staging` the intoss relay wss); otherwise
3565
- * falls back to the first booted family that has a tunnel. Returns "down"
3566
- * until any relay family is booted (any session before the first relay
3567
- * start_debug) — the correct signal for `start_attach` (no tunnel yet).
3568
- */
3569
- relayTunnelStatus() {
3570
- if (this.activeFamily?.getTunnelStatus) return this.activeFamily.getTunnelStatus();
3571
- for (const family of this.bootedFamilies()) if (family.getTunnelStatus) return family.getTunnelStatus();
3572
- return {
3573
- up: false,
3574
- wssUrl: null
3575
- };
3576
- }
3577
- /**
3578
- * Binds the MCP `Server`; the attach watcher is armed by the first
3579
- * `start_debug` since no family is active at startup (all-lazy, #396). Called
3580
- * once after `createDebugServer` + `connect`.
3581
- */
3582
- start(server) {
3583
- this.server = server;
3584
- this.armWatcher();
3585
- }
3586
- /** Stops the current attach watcher (for shutdown). */
3587
- stopWatcher() {
3588
- this.attachWatcher?.stop();
3589
- this.attachWatcher = null;
3590
- }
3591
- /** Arms a fresh attach watcher on the current active connection. */
3592
- armWatcher() {
3593
- const server = this.server;
3594
- if (!server) return;
3595
- const activeFamily = this.activeFamily;
3596
- if (!activeFamily) return;
3597
- this.attachWatcher = startAttachWatcher(activeFamily.connection, server, this.deps.attachWatcherIntervalMs ?? 1e3, () => {
3598
- this.deps.diagnosticsCollector.recordAttach();
3599
- this.deps.onPageAttach?.();
3600
- if (activeFamily.connection.kind === "relay") {
3601
- const firstTarget = activeFamily.connection.listTargets()[0];
3602
- const env = deriveEnvironment(activeFamily.connection.kind, activeFamily.relayOrigin);
3603
- const inspectorStableUrl = this.deps.getInspectorStableUrl?.() ?? null;
3604
- this.deps.devtoolsOpener.open({
3605
- inspectorStableUrl,
3606
- relayHttpBaseUrl: activeFamily.relayHttpUrl,
3607
- targetId: firstTarget?.id,
3608
- mintTotp: process.env.AIT_DEBUG_TOTP_SECRET ? () => generateTotp(process.env.AIT_DEBUG_TOTP_SECRET) : void 0,
3609
- env
3610
- });
3611
- }
3612
- }, () => {
3613
- this.deps.onPageDetach?.();
3614
- });
3615
- }
3616
- /**
3617
- * Resolves the `BootedFamily` for `key`: the warm family if already booted,
3618
- * otherwise boots it via `bootLazyFor(key, projectRoot)` and stores it (once
3619
- * per key). Since #396 every family is lazy, so this is the single boot path
3620
- * for all three keys.
3621
- *
3622
- * `projectRoot` is forwarded to `bootLazyFor` so `relay-sandbox` boot can
3623
- * fall back to `.ait_urls` file discovery (#424) when `AIT_RELAY_BASE_URL` is
3624
- * not set in the environment.
3625
- *
3626
- * **Relay-sandbox stale-URL rebuild (issue #610):** when the `relay-sandbox`
3627
- * family is already warm, reads the current relay URL via
3628
- * `deps.readSandboxRelayUrl` and compares it against the cached
3629
- * `relayHttpUrl`. If they differ (dev server was restarted → new tunnel),
3630
- * the stale family is torn down, evicted from the map, and a fresh one is
3631
- * booted. If they match, or if the URL cannot be read, the warm family is
3632
- * reused (fail-open — no unnecessary teardown on transient read errors).
3633
- *
3634
- * SECRET-HANDLING: fresh and cached relay URLs carry the tunnel host. The
3635
- * comparison result (same/different) is the only thing surfaced — URLs are
3636
- * never logged.
3637
- */
3638
- async familyFor(key, projectRoot) {
3639
- const warm = this.lazyFamilies.get(key);
3640
- if (warm) {
3641
- if (key === "relay-sandbox" && this.deps.readSandboxRelayUrl !== void 0) {
3642
- let freshUrl = null;
3643
- try {
3644
- freshUrl = await this.deps.readSandboxRelayUrl(projectRoot);
3645
- } catch {
3646
- freshUrl = null;
3647
- }
3648
- if (freshUrl !== null && freshUrl !== warm.relayHttpUrl) {
3649
- warm.stop();
3650
- this.lazyFamilies.delete(key);
3651
- const booted = await this.deps.bootLazyFor(key, projectRoot);
3652
- this.lazyFamilies.set(key, booted);
3653
- return booted;
3654
- }
3655
- }
3656
- return warm;
3657
- }
3658
- const booted = await this.deps.bootLazyFor(key, projectRoot);
3659
- this.lazyFamilies.set(key, booted);
3660
- return booted;
3661
- }
3662
- async switchMode(mode, projectRoot) {
3663
- if (this.swapInFlight) throw new Error("start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.");
3664
- this.swapInFlight = true;
3665
- try {
3666
- if (isRelayMode(mode)) await loadRelaySecretReadOnly({ projectRoot });
3667
- const target = await this.familyFor(familyKeyForMode(mode), projectRoot);
3668
- this.activeFamily = target;
3669
- this.stopWatcher();
3670
- this.armWatcher();
3671
- this.server?.sendToolListChanged();
3672
- const wantRelay = isRelayMode(mode);
3673
- return {
3674
- mode,
3675
- environment: deriveEnvironment(target.connection.kind, target.relayOrigin),
3676
- kind: target.connection.kind,
3677
- nextStep: wantRelay ? "start_attach로 attach QR 생성 + 폰 attach까지 한 번에 진행하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
3678
- };
3679
- } finally {
3680
- this.swapInFlight = false;
3681
- }
3682
- }
3683
- };
3684
- /**
3685
- * Boots the live debug stack and serves it over stdio:
3686
- * 1. start the Chii relay on an OS-assigned port (with TOTP auth if
3687
- * AIT_DEBUG_TOTP_SECRET is set),
3688
- * 2. open a cloudflared quick tunnel to the relay's confirmed port,
3689
- * 3. print relay URL + attach instructions,
3690
- * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
3691
- */
3692
- async function runDebugServer(options = {}) {
3693
- const lockHandle = acquireLock({ force: options.force ?? false });
3694
- const devtoolsOpener = new AutoDevtoolsOpener();
3695
- const diagnosticsCollector = new InMemoryDiagnosticsCollector();
3696
- let activeTunnelChildPid = null;
3697
- const router = new DualConnectionRouter({
3698
- bootLazyFor: async (key, projectRoot) => key === "relay-sandbox" ? bootExternalRelayFamily(await readMobileRelayBaseUrl(process.env, projectRoot), await readRelayLocalUrl(process.env, projectRoot)) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
3699
- relayPort: options.relayPort,
3700
- verifyAuth: buildRelayVerifyAuth(),
3701
- onWssUrl: (wssUrl) => {
3702
- lockHandle.updateWssUrl(wssUrl);
3703
- qrServer?.notifyStateChange();
3704
- },
3705
- onTunnelChildPid: (pid) => {
3706
- activeTunnelChildPid = pid;
3707
- lockHandle.updateTunnelChildPid(pid);
3708
- },
3709
- onAuthReject: () => diagnosticsCollector.recordAuthReject(),
3710
- onTunnelDown: () => qrServer?.notifyStateChange()
3711
- }),
3712
- diagnosticsCollector,
3713
- devtoolsOpener,
3714
- onPageAttach: () => qrServer?.notifyStateChange(),
3715
- onPageDetach: () => qrServer?.notifyStateChange(),
3716
- getInspectorStableUrl: () => qrServer?.inspectorStableUrl ?? null,
3717
- readSandboxRelayUrl: (pr) => readMobileRelayBaseUrl(process.env, pr).catch(() => null)
3718
- });
3719
- const aitSource = new RoutingAitSource(() => {
3720
- return router.active;
3721
- });
3722
- let lastAttachParts = null;
3723
- const getDashboardState = () => {
3724
- const targets = router.active.listTargets();
3725
- const inspectorUrl = qrServer?.inspectorStableUrl ?? null;
3726
- return {
3727
- tunnel: {
3728
- up: router.relayTunnelStatus().up,
3729
- wssUrl: router.relayTunnelStatus().wssUrl
3730
- },
3731
- pages: targets.map((t) => ({
3732
- id: t.id,
3733
- url: t.url
3734
- })),
3735
- attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
3736
- inspectorUrl,
3737
- mode: deriveEnvironment(router.active.kind, router.activeRelayOrigin)
3738
- };
3739
- };
3740
- const getDirectInspectorUrl = () => {
3741
- const relayHttpUrl = router.activeRelayHttpUrl;
3742
- if (!relayHttpUrl) return {
3743
- ok: false,
3744
- reason: "relayDown"
3745
- };
3746
- const targets = router.active.listTargets();
3747
- if (targets.length === 0) return {
3748
- ok: false,
3749
- reason: "noTarget"
3750
- };
3751
- const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
3752
- if (!totpSecret) return {
3753
- ok: false,
3754
- reason: "totpUnavailable"
3755
- };
3756
- const url = buildChiiInspectorUrl(relayHttpUrl, targets[0].id, () => generateTotp(totpSecret, Date.now()));
3757
- if (url === null) return {
3758
- ok: false,
3759
- reason: "totpUnavailable"
3760
- };
3761
- return {
3762
- ok: true,
3763
- url
3764
- };
3765
- };
3766
- let qrServer;
3767
- try {
3768
- qrServer = await startQrHttpServer(getDashboardState, { getDirectInspectorUrl });
3769
- } catch (err) {
3770
- logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
3771
- }
3772
- const TOTP_REFRESH_INTERVAL_MS = 2e4;
3773
- let totpRefreshHandle = null;
3774
- totpRefreshHandle = setInterval(() => {
3775
- if (lastAttachParts !== null) qrServer?.notifyStateChange();
3776
- }, TOTP_REFRESH_INTERVAL_MS);
3777
- totpRefreshHandle.unref();
3778
- const server = createDebugServer({
3779
- connection: router.active,
3780
- router,
3781
- aitSource,
3782
- getTunnelStatus: () => router.relayTunnelStatus(),
3783
- getTunnelChildPid: () => activeTunnelChildPid,
3784
- get qrHttpServer() {
3785
- return qrServer;
3786
- },
3787
- diagnosticsCollector,
3788
- getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
3789
- onAttachUrlBuilt: (parts) => {
3790
- lastAttachParts = parts;
3791
- qrServer?.notifyStateChange();
3792
- }
3793
- });
3794
- const transport = new StdioServerTransport();
3795
- let closed = false;
3796
- let parentWatcher = null;
3797
- let maxAgeWatchdog = null;
3798
- const shutdown = () => {
3799
- if (closed) return;
3800
- closed = true;
3801
- parentWatcher?.stop();
3802
- maxAgeWatchdog?.stop();
3803
- if (totpRefreshHandle) clearInterval(totpRefreshHandle);
3804
- router.stopWatcher();
3805
- for (const family of router.bootedFamilies()) family.stop();
3806
- server.close();
3807
- qrServer?.close();
3808
- lockHandle.release();
3809
- };
3810
- process.once("SIGINT", shutdown);
3811
- process.once("SIGTERM", shutdown);
3812
- process.once("SIGHUP", shutdown);
3813
- process.on("exit", () => {
3814
- if (!closed) {
3815
- closed = true;
3816
- parentWatcher?.stop();
3817
- maxAgeWatchdog?.stop();
3818
- if (totpRefreshHandle) clearInterval(totpRefreshHandle);
3819
- router.stopWatcher();
3820
- for (const family of router.bootedFamilies()) family.stop();
3821
- lockHandle.release();
3822
- }
3823
- });
3824
- process.on("uncaughtException", (err) => {
3825
- logError("tool.error", {
3826
- msg: `uncaughtException: ${String(err)}`,
3827
- errorKind: "uncaught"
3828
- });
3829
- shutdown();
3830
- process.exit(1);
3831
- });
3832
- process.on("unhandledRejection", (reason) => {
3833
- logError("tool.error", {
3834
- msg: `unhandledRejection: ${String(reason)}`,
3835
- errorKind: "unhandled-rejection"
3836
- });
3837
- shutdown();
3838
- process.exit(1);
3839
- });
3840
- await server.connect(transport);
3841
- router.start(server);
3842
- if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
3843
- parentWatcher = startParentWatcher(() => {
3844
- shutdown();
3845
- process.exit(0);
3846
- }, { intervalMs: 5e3 });
3847
- process.stdin.once("end", () => {
3848
- shutdown();
3849
- process.exit(0);
3850
- });
3851
- process.stdin.once("close", () => {
3852
- shutdown();
3853
- process.exit(0);
3854
- });
3855
- }
3856
- if (process.env.AIT_DEBUG_NO_MAX_AGE !== "1") maxAgeWatchdog = startMaxAgeWatchdog(() => {
3857
- process.stderr.write("[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\n");
3858
- shutdown();
3859
- process.exit(0);
3860
- }, { maxAgeMs: process.env.AIT_DEBUG_MAX_AGE_MS ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || void 0 : void 0 });
3861
- }
3862
- /**
3863
- * Serves the debug stack over stdio with the local browser as the default
3864
- * target. Since #396 NOTHING boots at startup — every family (including the
3865
- * local Chromium) is lazy-booted on its first `start_debug`:
3866
- * 1. `start_debug({ mode: 'local-browser' })` launches a local Chromium with
3867
- * `--remote-debugging-port=<port>` and attaches a `LocalCdpConnection`;
3868
- * 2. the intoss/external relay families lazy-boot on the first
3869
- * `start_debug({ mode: 'relay-staging' | 'relay-sandbox' })` (#665: relay-live removed);
3870
- * 3. all of this runs through the SAME direction-neutral
3871
- * `DualConnectionRouter` that `runDebugServer` uses (issue #356).
3872
- *
3873
- * Symmetry with `runDebugServer` (#356): starting with `--target=local` no
3874
- * longer pins a single-connection router. A `--target=local` session can
3875
- * hot-switch into relay (env 1 → env 3) without restarting the MCP server,
3876
- * closing the asymmetry where only the default (relay-target) entry point had
3877
- * bidirectional hot-switch. The intended fidelity-ladder flow — "validate in
3878
- * env 1 (local), then env 3 (intoss-private) in ONE session, no restart" — now
3879
- * works from either entry point.
3880
- *
3881
- * `start_attach` (relay-specific) stays effectively hidden / non-applicable
3882
- * until the relay family is booted: before the first relay switch the env
3883
- * derives to `mock` and `relayTunnelStatus()` reports "down", so the tool fails
3884
- * with a clear "tunnel not up" message. After a relay switch the relay tunnel
3885
- * is live and the tool works.
3886
- *
3887
- * The AIT.* tools (`AIT.getSdkCallHistory`, `AIT.getMockState`,
3888
- * `AIT.getOperationalEnvironment`) ride the *active* connection's CDP channel
3889
- * via `RoutingAitSource`, so they follow `start_debug` swaps.
3890
- */
3891
- async function runLocalDebugServer(options = {}) {
3892
- const lockHandle = acquireLock({ force: options.force ?? false });
3893
- const cdpPort = options.cdpPort ?? 0;
3894
- const devUrl = options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173";
3895
- const bootLocalFamilyForEntry = async () => {
3896
- const chromium = await launchChromium({
3897
- port: cdpPort,
3898
- devUrl
3899
- });
3900
- await new Promise((r) => setTimeout(r, 800));
3901
- const localConnection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
3902
- return {
3903
- connection: localConnection,
3904
- stop() {
3905
- localConnection.close();
3906
- chromium.stop();
3907
- }
3908
- };
3909
- };
3910
- const devtoolsOpener = new AutoDevtoolsOpener();
3911
- const diagnosticsCollector = new InMemoryDiagnosticsCollector();
3912
- let activeTunnelChildPid = null;
3913
- const router = new DualConnectionRouter({
3914
- bootLazyFor: async (key, projectRoot) => key === "relay-sandbox" ? bootExternalRelayFamily(await readMobileRelayBaseUrl(process.env, projectRoot), await readRelayLocalUrl(process.env, projectRoot)) : key === "local-browser" ? bootLocalFamilyForEntry() : bootRelayFamily({
3915
- verifyAuth: buildRelayVerifyAuth(),
3916
- onWssUrl: (wssUrl) => {
3917
- lockHandle.updateWssUrl(wssUrl);
3918
- qrServer?.notifyStateChange();
3919
- },
3920
- onTunnelChildPid: (pid) => {
3921
- activeTunnelChildPid = pid;
3922
- lockHandle.updateTunnelChildPid(pid);
3923
- },
3924
- onAuthReject: () => diagnosticsCollector.recordAuthReject(),
3925
- onTunnelDown: () => qrServer?.notifyStateChange()
3926
- }),
3927
- diagnosticsCollector,
3928
- devtoolsOpener,
3929
- onPageAttach: () => qrServer?.notifyStateChange(),
3930
- onPageDetach: () => qrServer?.notifyStateChange(),
3931
- getInspectorStableUrl: () => qrServer?.inspectorStableUrl ?? null,
3932
- readSandboxRelayUrl: (pr) => readMobileRelayBaseUrl(process.env, pr).catch(() => null)
3933
- });
3934
- const aitSource = new RoutingAitSource(() => {
3935
- return router.active;
3936
- });
3937
- let lastAttachParts = null;
3938
- const getDashboardState = () => {
3939
- const targets = router.active.listTargets();
3940
- const inspectorUrl = qrServer?.inspectorStableUrl ?? null;
3941
- return {
3942
- tunnel: {
3943
- up: router.relayTunnelStatus().up,
3944
- wssUrl: router.relayTunnelStatus().wssUrl
3945
- },
3946
- pages: targets.map((t) => ({
3947
- id: t.id,
3948
- url: t.url
3949
- })),
3950
- attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
3951
- inspectorUrl
3952
- };
3953
- };
3954
- const getDirectInspectorUrl = () => {
3955
- const relayHttpUrl = router.activeRelayHttpUrl;
3956
- if (!relayHttpUrl) return {
3957
- ok: false,
3958
- reason: "relayDown"
3959
- };
3960
- const targets = router.active.listTargets();
3961
- if (targets.length === 0) return {
3962
- ok: false,
3963
- reason: "noTarget"
3964
- };
3965
- const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
3966
- if (!totpSecret) return {
3967
- ok: false,
3968
- reason: "totpUnavailable"
3969
- };
3970
- const url = buildChiiInspectorUrl(relayHttpUrl, targets[0].id, () => generateTotp(totpSecret, Date.now()));
3971
- if (url === null) return {
3972
- ok: false,
3973
- reason: "totpUnavailable"
3974
- };
3975
- return {
3976
- ok: true,
3977
- url
3978
- };
3979
- };
3980
- let qrServer;
3981
- try {
3982
- qrServer = await startQrHttpServer(getDashboardState, { getDirectInspectorUrl });
3983
- } catch (err) {
3984
- logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
3985
- }
3986
- const TOTP_REFRESH_INTERVAL_MS = 2e4;
3987
- let totpRefreshHandle = null;
3988
- totpRefreshHandle = setInterval(() => {
3989
- if (lastAttachParts !== null) qrServer?.notifyStateChange();
3990
- }, TOTP_REFRESH_INTERVAL_MS);
3991
- totpRefreshHandle.unref();
3992
- const server = createDebugServer({
3993
- connection: router.active,
3994
- router,
3995
- aitSource,
3996
- getTunnelStatus: () => router.relayTunnelStatus(),
3997
- getTunnelChildPid: () => activeTunnelChildPid,
3998
- get qrHttpServer() {
3999
- return qrServer;
4000
- },
4001
- diagnosticsCollector,
4002
- getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
4003
- onAttachUrlBuilt: (parts) => {
4004
- lastAttachParts = parts;
4005
- qrServer?.notifyStateChange();
4006
- }
4007
- });
4008
- const transport = new StdioServerTransport();
4009
- let closed = false;
4010
- let parentWatcher = null;
4011
- let maxAgeWatchdog = null;
4012
- const shutdown = () => {
4013
- if (closed) return;
4014
- closed = true;
4015
- parentWatcher?.stop();
4016
- maxAgeWatchdog?.stop();
4017
- if (totpRefreshHandle) clearInterval(totpRefreshHandle);
4018
- router.stopWatcher();
4019
- for (const family of router.bootedFamilies()) family.stop();
4020
- server.close();
4021
- qrServer?.close();
4022
- lockHandle.release();
4023
- };
4024
- process.once("SIGINT", shutdown);
4025
- process.once("SIGTERM", shutdown);
4026
- process.once("SIGHUP", shutdown);
4027
- process.on("exit", () => {
4028
- if (!closed) {
4029
- closed = true;
4030
- parentWatcher?.stop();
4031
- maxAgeWatchdog?.stop();
4032
- if (totpRefreshHandle) clearInterval(totpRefreshHandle);
4033
- router.stopWatcher();
4034
- for (const family of router.bootedFamilies()) family.stop();
4035
- lockHandle.release();
4036
- }
4037
- });
4038
- process.on("uncaughtException", (err) => {
4039
- logError("tool.error", {
4040
- msg: `uncaughtException: ${String(err)}`,
4041
- errorKind: "uncaught",
4042
- mode: "local-browser"
4043
- });
4044
- shutdown();
4045
- process.exit(1);
4046
- });
4047
- process.on("unhandledRejection", (reason) => {
4048
- logError("tool.error", {
4049
- msg: `unhandledRejection: ${String(reason)}`,
4050
- errorKind: "unhandled-rejection",
4051
- mode: "local-browser"
4052
- });
4053
- shutdown();
4054
- process.exit(1);
4055
- });
4056
- await server.connect(transport);
4057
- router.start(server);
4058
- if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
4059
- parentWatcher = startParentWatcher(() => {
4060
- shutdown();
4061
- process.exit(0);
4062
- }, { intervalMs: 5e3 });
4063
- process.stdin.once("end", () => {
4064
- shutdown();
4065
- process.exit(0);
4066
- });
4067
- process.stdin.once("close", () => {
4068
- shutdown();
4069
- process.exit(0);
4070
- });
4071
- }
4072
- if (process.env.AIT_DEBUG_NO_MAX_AGE !== "1") maxAgeWatchdog = startMaxAgeWatchdog(() => {
4073
- process.stderr.write("[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\n");
4074
- shutdown();
4075
- process.exit(0);
4076
- }, { maxAgeMs: process.env.AIT_DEBUG_MAX_AGE_MS ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || void 0 : void 0 });
4077
- }
4078
- /**
4079
- * Serves the env-2 (real-device PWA) debug stack over stdio with the external
4080
- * Chii relay as the default target (issue #378). Since #396 NOTHING boots at
4081
- * startup — the external relay family is lazy-booted on the first
4082
- * `start_debug({ mode: 'relay-sandbox' })`.
4083
- *
4084
- * Unlike `runDebugServer` (which starts its own relay + cloudflared tunnel),
4085
- * `runMobileDebugServer` attaches to a relay the unplugin ALREADY brought up
4086
- * (`tunnel: { cdp: true }`) and exposed via `AIT_RELAY_BASE_URL`. The MCP only
4087
- * opens a CDP client against that external relay — it never starts or tears down
4088
- * a relay or a tunnel it did not own (see {@link bootExternalRelayFamily}).
4089
- *
4090
- * Symmetry with `runDebugServer` / `runLocalDebugServer` (#356, #378, #396): all
4091
- * three families are lazy-booted — the env-2 external relay on the first
4092
- * `start_debug({ mode: 'relay-sandbox' })`, the local family on `local-browser`,
4093
- * the intoss relay on `relay-staging` (#665: relay-live removed) — so a
4094
- * `--target=mobile` session can hot-switch without a restart. The active env
4095
- * derives to `relay-mobile` (external-PWA origin).
4096
- *
4097
- * SECRET-HANDLING: `AIT_RELAY_BASE_URL` is read once here via
4098
- * {@link readMobileRelayBaseUrl}; when unset it throws
4099
- * {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} — a message that names the env
4100
- * var and how to obtain it, never echoing any URL value. The error propagates to
4101
- * the bin entry's fatal handler (the missing-URL path prints the guidance, not a
4102
- * value). The present value is passed straight to the CDP client, never logged.
4103
- */
4104
- async function runMobileDebugServer(options = {}) {
4105
- const relayBaseUrl = await readMobileRelayBaseUrl(process.env, options.projectRoot ?? process.cwd());
4106
- const lockHandle = acquireLock({ force: options.force ?? false });
4107
- const devtoolsOpener = new AutoDevtoolsOpener();
4108
- const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4109
- let activeTunnelChildPid = null;
4110
- const router = new DualConnectionRouter({
4111
- bootLazyFor: async (key) => key === "relay-sandbox" ? bootExternalRelayFamily(relayBaseUrl, await readRelayLocalUrl(process.env, options.projectRoot ?? process.cwd())) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
4112
- verifyAuth: buildRelayVerifyAuth(),
4113
- onWssUrl: (wssUrl) => {
4114
- lockHandle.updateWssUrl(wssUrl);
4115
- qrServer?.notifyStateChange();
4116
- },
4117
- onTunnelChildPid: (pid) => {
4118
- activeTunnelChildPid = pid;
4119
- lockHandle.updateTunnelChildPid(pid);
4120
- },
4121
- onAuthReject: () => diagnosticsCollector.recordAuthReject(),
4122
- onTunnelDown: () => qrServer?.notifyStateChange()
4123
- }),
4124
- diagnosticsCollector,
4125
- devtoolsOpener,
4126
- onPageAttach: () => qrServer?.notifyStateChange(),
4127
- onPageDetach: () => qrServer?.notifyStateChange(),
4128
- getInspectorStableUrl: () => qrServer?.inspectorStableUrl ?? null,
4129
- readSandboxRelayUrl: (pr) => readMobileRelayBaseUrl(process.env, pr ?? options.projectRoot ?? process.cwd()).catch(() => null)
4130
- });
4131
- const aitSource = new RoutingAitSource(() => {
4132
- return router.active;
4133
- });
4134
- let lastAttachParts = null;
4135
- const getDashboardState = () => {
4136
- const targets = router.active.listTargets();
4137
- const inspectorUrl = qrServer?.inspectorStableUrl ?? null;
4138
- return {
4139
- tunnel: {
4140
- up: router.relayTunnelStatus().up,
4141
- wssUrl: router.relayTunnelStatus().wssUrl
4142
- },
4143
- pages: targets.map((t) => ({
4144
- id: t.id,
4145
- url: t.url
4146
- })),
4147
- attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
4148
- inspectorUrl
4149
- };
4150
- };
4151
- const getDirectInspectorUrl = () => {
4152
- const relayHttpUrl = router.activeRelayHttpUrl;
4153
- if (!relayHttpUrl) return {
4154
- ok: false,
4155
- reason: "relayDown"
4156
- };
4157
- const targets = router.active.listTargets();
4158
- if (targets.length === 0) return {
4159
- ok: false,
4160
- reason: "noTarget"
4161
- };
4162
- const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
4163
- if (!totpSecret) return {
4164
- ok: false,
4165
- reason: "totpUnavailable"
4166
- };
4167
- const url = buildChiiInspectorUrl(relayHttpUrl, targets[0].id, () => generateTotp(totpSecret, Date.now()));
4168
- if (url === null) return {
4169
- ok: false,
4170
- reason: "totpUnavailable"
4171
- };
4172
- return {
4173
- ok: true,
4174
- url
4175
- };
4176
- };
4177
- let qrServer;
4178
- try {
4179
- qrServer = await startQrHttpServer(getDashboardState, { getDirectInspectorUrl });
4180
- } catch (err) {
4181
- logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
4182
- }
4183
- const TOTP_REFRESH_INTERVAL_MS = 2e4;
4184
- let totpRefreshHandle = null;
4185
- totpRefreshHandle = setInterval(() => {
4186
- if (lastAttachParts !== null) qrServer?.notifyStateChange();
4187
- }, TOTP_REFRESH_INTERVAL_MS);
4188
- totpRefreshHandle.unref();
4189
- const server = createDebugServer({
4190
- connection: router.active,
4191
- router,
4192
- aitSource,
4193
- getTunnelStatus: () => router.relayTunnelStatus(),
4194
- getTunnelChildPid: () => activeTunnelChildPid,
4195
- get qrHttpServer() {
4196
- return qrServer;
4197
- },
4198
- diagnosticsCollector,
4199
- getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
4200
- onAttachUrlBuilt: (parts) => {
4201
- lastAttachParts = parts;
4202
- qrServer?.notifyStateChange();
4203
- }
4204
- });
4205
- const transport = new StdioServerTransport();
4206
- let closed = false;
4207
- let parentWatcher = null;
4208
- let maxAgeWatchdog = null;
4209
- const shutdown = () => {
4210
- if (closed) return;
4211
- closed = true;
4212
- parentWatcher?.stop();
4213
- maxAgeWatchdog?.stop();
4214
- if (totpRefreshHandle) clearInterval(totpRefreshHandle);
4215
- router.stopWatcher();
4216
- for (const family of router.bootedFamilies()) family.stop();
4217
- server.close();
4218
- qrServer?.close();
4219
- lockHandle.release();
4220
- };
4221
- process.once("SIGINT", shutdown);
4222
- process.once("SIGTERM", shutdown);
4223
- process.once("SIGHUP", shutdown);
4224
- process.on("exit", () => {
4225
- if (!closed) {
4226
- closed = true;
4227
- parentWatcher?.stop();
4228
- maxAgeWatchdog?.stop();
4229
- if (totpRefreshHandle) clearInterval(totpRefreshHandle);
4230
- router.stopWatcher();
4231
- for (const family of router.bootedFamilies()) family.stop();
4232
- lockHandle.release();
4233
- }
4234
- });
4235
- process.on("uncaughtException", (err) => {
4236
- logError("tool.error", {
4237
- msg: `uncaughtException: ${String(err)}`,
4238
- errorKind: "uncaught",
4239
- mode: "relay-sandbox"
4240
- });
4241
- shutdown();
4242
- process.exit(1);
4243
- });
4244
- process.on("unhandledRejection", (reason) => {
4245
- logError("tool.error", {
4246
- msg: `unhandledRejection: ${String(reason)}`,
4247
- errorKind: "unhandled-rejection",
4248
- mode: "relay-sandbox"
4249
- });
4250
- shutdown();
4251
- process.exit(1);
4252
- });
4253
- await server.connect(transport);
4254
- router.start(server);
4255
- if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
4256
- parentWatcher = startParentWatcher(() => {
4257
- shutdown();
4258
- process.exit(0);
4259
- }, { intervalMs: 5e3 });
4260
- process.stdin.once("end", () => {
4261
- shutdown();
4262
- process.exit(0);
4263
- });
4264
- process.stdin.once("close", () => {
4265
- shutdown();
4266
- process.exit(0);
4267
- });
4268
- }
4269
- if (process.env.AIT_DEBUG_NO_MAX_AGE !== "1") maxAgeWatchdog = startMaxAgeWatchdog(() => {
4270
- process.stderr.write("[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\n");
4271
- shutdown();
4272
- process.exit(0);
4273
- }, { maxAgeMs: process.env.AIT_DEBUG_MAX_AGE_MS ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || void 0 : void 0 });
4274
- }
4275
- //#endregion
4276
- export { wrapEnvelope as a, runMobileDebugServer as i, runDebugServer as n, runLocalDebugServer as r, debug_server_exports as t };
4277
-
4278
- //# sourceMappingURL=debug-server-IVaJ9vHH.js.map