@ait-co/devtools 0.1.116 → 0.1.118

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 (102) hide show
  1. package/dist/attach-orchestrator-BEMGBeWm.js +3 -0
  2. package/dist/attach-orchestrator-BZsqoFjB.js +2797 -0
  3. package/dist/attach-orchestrator-BZsqoFjB.js.map +1 -0
  4. package/dist/attach-orchestrator-CFotueL3.js +1749 -0
  5. package/dist/attach-orchestrator-CFotueL3.js.map +1 -0
  6. package/dist/attach-orchestrator-DEJfLm7V.js +1749 -0
  7. package/dist/attach-orchestrator-DEJfLm7V.js.map +1 -0
  8. package/dist/{cli-BLBo0lkP.js → attach-orchestrator-qFEMBUwb.js} +23 -739
  9. package/dist/attach-orchestrator-qFEMBUwb.js.map +1 -0
  10. package/dist/{bundle-KFs4t-wc.d.ts → bundle-CA1TCXED.d.ts} +1 -1
  11. package/dist/{bundle-KFs4t-wc.d.ts.map → bundle-CA1TCXED.d.ts.map} +1 -1
  12. package/dist/capture-BmK_cBqQ.d.ts +58 -0
  13. package/dist/capture-BmK_cBqQ.d.ts.map +1 -0
  14. package/dist/{cdp-connection-C0AP0tH2.d.ts → cdp-connection-DB2zgthr.d.ts} +1 -1
  15. package/dist/{cdp-connection-C0AP0tH2.d.ts.map → cdp-connection-DB2zgthr.d.ts.map} +1 -1
  16. package/dist/cell-BDFMsPXV.js +3 -0
  17. package/dist/cell-BPWZUm_X.js +85 -0
  18. package/dist/cell-BPWZUm_X.js.map +1 -0
  19. package/dist/cell-Cv3e_qwn.js +84 -0
  20. package/dist/cell-Cv3e_qwn.js.map +1 -0
  21. package/dist/cell-Dp_pDs_j.js +85 -0
  22. package/dist/cell-Dp_pDs_j.js.map +1 -0
  23. package/dist/cell-nrWFeqvG.js +84 -0
  24. package/dist/cell-nrWFeqvG.js.map +1 -0
  25. package/dist/cli-BA1ynNNx.js +1039 -0
  26. package/dist/cli-BA1ynNNx.js.map +1 -0
  27. package/dist/debug-server-BhHVy3XT.js +1191 -0
  28. package/dist/debug-server-BhHVy3XT.js.map +1 -0
  29. package/dist/{debug-server-DcAKrbnq.js → debug-server-T8i9_iCc.js} +5 -3
  30. package/dist/debug-server-T8i9_iCc.js.map +1 -0
  31. package/dist/debug-server-krAOy6cl.js +1891 -0
  32. package/dist/debug-server-krAOy6cl.js.map +1 -0
  33. package/dist/{debug-server-DlXJARIC.js → debug-server-wo18jR4T.js} +432 -2884
  34. package/dist/debug-server-wo18jR4T.js.map +1 -0
  35. package/dist/mcp/cli.js +3 -2
  36. package/dist/mcp/cli.js.map +1 -1
  37. package/dist/mcp/server.js +1 -1
  38. package/dist/panel/index.js +1 -1
  39. package/dist/{pool-htlVnEFl.d.ts → pool-U9FrxEuR.d.ts} +5 -5
  40. package/dist/{pool-htlVnEFl.d.ts.map → pool-U9FrxEuR.d.ts.map} +1 -1
  41. package/dist/relay-factory-DVI7TrX4.js +69 -0
  42. package/dist/relay-factory-DVI7TrX4.js.map +1 -0
  43. package/dist/relay-secret-store-BcVrWwTq.js +153 -0
  44. package/dist/relay-secret-store-BcVrWwTq.js.map +1 -0
  45. package/dist/{relay-secret-store-BHcOmaNK.js → relay-secret-store-BlFEhqwb.js} +1 -1
  46. package/dist/{relay-secret-store-CmqchhR5.js → relay-secret-store-CFc9n0OA.js} +1 -1
  47. package/dist/{relay-secret-store-CmqchhR5.js.map → relay-secret-store-CFc9n0OA.js.map} +1 -1
  48. package/dist/{relay-secret-store-CkA7KNUb.js → relay-secret-store-CmDDfZMh.js} +2 -2
  49. package/dist/{relay-secret-store-CkA7KNUb.js.map → relay-secret-store-CmDDfZMh.js.map} +1 -1
  50. package/dist/relay-secret-store-DNPJKHNs.js +153 -0
  51. package/dist/relay-secret-store-DNPJKHNs.js.map +1 -0
  52. package/dist/relay-url-store-CdA58fgw.js +122 -0
  53. package/dist/relay-url-store-CdA58fgw.js.map +1 -0
  54. package/dist/{relay-url-store-C0qukm3R.js → relay-url-store-CnL2zwbH.js} +2 -2
  55. package/dist/{relay-url-store-C0qukm3R.js.map → relay-url-store-CnL2zwbH.js.map} +1 -1
  56. package/dist/{relay-url-store-NDtEcOE-.js → relay-url-store-DGQ-HPQC.js} +2 -2
  57. package/dist/{relay-url-store-NDtEcOE-.js.map → relay-url-store-DGQ-HPQC.js.map} +1 -1
  58. package/dist/relay-url-store-Dv5HSzwb.js +122 -0
  59. package/dist/relay-url-store-Dv5HSzwb.js.map +1 -0
  60. package/dist/{relay-worker-DWW4vZxU.d.ts → relay-worker-D-3aAyDO.d.ts} +28 -4
  61. package/dist/relay-worker-D-3aAyDO.d.ts.map +1 -0
  62. package/dist/{runtime-BU-iMHz6.d.ts → runtime-D57Rtnsd.d.ts} +1 -1
  63. package/dist/{runtime-BU-iMHz6.d.ts.map → runtime-D57Rtnsd.d.ts.map} +1 -1
  64. package/dist/test-runner/bundle.d.ts +1 -1
  65. package/dist/test-runner/bundle.js +9 -9
  66. package/dist/test-runner/bundle.js.map +1 -1
  67. package/dist/test-runner/capture.d.ts +2 -0
  68. package/dist/test-runner/capture.js +44 -0
  69. package/dist/test-runner/capture.js.map +1 -0
  70. package/dist/test-runner/cli.d.ts +85 -12
  71. package/dist/test-runner/cli.d.ts.map +1 -1
  72. package/dist/test-runner/cli.js +2 -2
  73. package/dist/test-runner/config.d.ts +76 -2
  74. package/dist/test-runner/config.d.ts.map +1 -1
  75. package/dist/test-runner/config.js +4 -2
  76. package/dist/test-runner/config.js.map +1 -1
  77. package/dist/test-runner/pool.d.ts +1 -1
  78. package/dist/test-runner/relay-factory.d.ts +11136 -0
  79. package/dist/test-runner/relay-factory.d.ts.map +1 -0
  80. package/dist/test-runner/relay-factory.js +69 -0
  81. package/dist/test-runner/relay-factory.js.map +1 -0
  82. package/dist/test-runner/relay-worker.d.ts +1 -1
  83. package/dist/test-runner/relay-worker.js +66 -20
  84. package/dist/test-runner/relay-worker.js.map +1 -1
  85. package/dist/test-runner/report.d.ts +106 -0
  86. package/dist/test-runner/report.d.ts.map +1 -0
  87. package/dist/test-runner/report.js +139 -0
  88. package/dist/test-runner/report.js.map +1 -0
  89. package/dist/test-runner/rpc.d.ts +2 -2
  90. package/dist/test-runner/runtime.d.ts +1 -1
  91. package/dist/test-runner/task-graph.d.ts +1 -1
  92. package/dist/{totp-D70zD5tJ.js → totp-0bvlo9Yb.js} +1 -1
  93. package/dist/{totp-D70zD5tJ.js.map → totp-0bvlo9Yb.js.map} +1 -1
  94. package/dist/totp-DfekTBk3.js +211 -0
  95. package/dist/totp-DfekTBk3.js.map +1 -0
  96. package/dist/totp-zGIlDsZS.js +3 -0
  97. package/package.json +1 -1
  98. package/dist/cli-BLBo0lkP.js.map +0 -1
  99. package/dist/debug-server-DcAKrbnq.js.map +0 -1
  100. package/dist/debug-server-DlXJARIC.js.map +0 -1
  101. package/dist/relay-worker-DWW4vZxU.d.ts.map +0 -1
  102. package/dist/totp-D104cJQN.js +0 -3
@@ -0,0 +1,1891 @@
1
+ import { n as buildRelayVerifyAuth, r as generateTotp, t as assertRelayAuthConfigured } from "./totp-DfekTBk3.js";
2
+ import { createRelayConnectionFactory } from "./test-runner/relay-factory.js";
3
+ import { a as startTunnelHealthProbe, i as startQuickTunnel, n as makeTunnelStatus, o as logError, r as printAttachBanner, s as logInfo, t as generateAttachToken } from "./attach-orchestrator-DEJfLm7V.js";
4
+ import "./cell-Cv3e_qwn.js";
5
+ import "./relay-secret-store-DNPJKHNs.js";
6
+ import { createRequire } from "node:module";
7
+ import { accessSync } from "node:fs";
8
+ import * as path$1 from "node:path";
9
+ import path, { isAbsolute, resolve } from "node:path";
10
+ import "@modelcontextprotocol/sdk/server/index.js";
11
+ import "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import "@modelcontextprotocol/sdk/types.js";
13
+ import { parseArgs } from "node:util";
14
+ import * as fs from "node:fs/promises";
15
+ import { glob, mkdir, writeFile } from "node:fs/promises";
16
+ import { fileURLToPath } from "node:url";
17
+ import { EventEmitter } from "node:events";
18
+ import { WebSocket, WebSocketServer } from "ws";
19
+ import { createServer } from "node:http";
20
+ //#region src/test-runner/discover.ts
21
+ /**
22
+ * Test-file discovery shared by the `devtools-test` CLI and the `run_tests`
23
+ * MCP tool, so both expand glob patterns with identical semantics.
24
+ *
25
+ * Uses Node's built-in `fs/promises` `glob` (Node 22+) — no extra dependency,
26
+ * which keeps the MCP daemon install graph lean (a plain glob lib would land in
27
+ * the `npx … devtools-mcp` path for no benefit).
28
+ *
29
+ * Pure Node IO only (`node:fs/promises` + `node:path`) — react-free, so it is
30
+ * safe to import from the MCP daemon graph.
31
+ */
32
+ /**
33
+ * Expands `patterns` (globs or plain paths) into a sorted, de-duplicated list of
34
+ * ABSOLUTE test file paths, resolved relative to `cwd`.
35
+ *
36
+ * A plain (non-glob) path passes through when it matches a real file; a glob
37
+ * expands against `cwd`. Absolute matches are kept as-is; relative matches are
38
+ * resolved against `cwd`. `bundleTestFile` requires an absolute path, so the
39
+ * absolute output feeds it directly.
40
+ *
41
+ * @param patterns Glob patterns or file paths (e.g. `['src/**\/*.ait.test.ts']`).
42
+ * @param cwd Base directory for relative patterns/results.
43
+ * @returns Sorted, de-duplicated absolute file paths. Empty when nothing matches.
44
+ */
45
+ async function discoverTestFiles(patterns, cwd) {
46
+ const out = /* @__PURE__ */ new Set();
47
+ for await (const match of glob(patterns, { cwd })) out.add(isAbsolute(match) ? match : resolve(cwd, match));
48
+ return [...out].sort();
49
+ }
50
+ //#endregion
51
+ //#region src/test-runner/bundle.ts
52
+ /**
53
+ * esbuild-based bundler for user test files.
54
+ *
55
+ * Bundles a single test file into a self-contained IIFE string that can be
56
+ * injected into a WebView via `Runtime.evaluate`. The bundle includes the
57
+ * test runtime (`runtime.ts`), which provides `describe/it/test/expect` and
58
+ * the `runTestModule(factory)` entry point.
59
+ *
60
+ * ## How the wiring works
61
+ *
62
+ * The bundle exposes two exports on `globalThis.__testBundle`:
63
+ * - `runTestModule` — the runtime's entry function.
64
+ * - `__userFactory` — an async function whose body is the user's top-level
65
+ * test registration code (describe/it/test calls).
66
+ *
67
+ * The Node-side RPC (`rpc.ts`) calls:
68
+ * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
69
+ *
70
+ * `runTestModule` then installs `describe/it/test/expect` as globals, invokes
71
+ * the factory (which registers all tests), runs them, and returns a `RunReport`.
72
+ *
73
+ * ## Why a factory wrapper is needed
74
+ *
75
+ * Naively adding the runtime to `entryPoints` and bundling the user file would
76
+ * fail for two reasons:
77
+ * 1. `describe/it/test/expect` from the runtime are module-local in the IIFE
78
+ * scope. The user's top-level `describe(...)` calls expect them as globals —
79
+ * they are not globals until `runTestModule` installs them.
80
+ * 2. Even with globals pre-installed, the user file runs at IIFE-evaluation
81
+ * time, before the RPC layer calls `runTestModule` to reset state and start
82
+ * the test clock.
83
+ *
84
+ * The factory approach solves both: the user's registration code is deferred
85
+ * into a function that `runTestModule` calls AFTER installing the globals.
86
+ *
87
+ * ## Factory extraction algorithm
88
+ *
89
+ * The `userFactoryPlugin` reads the user file and splits lines into:
90
+ * - **top-level**: `import …` and re-export lines — kept at module scope
91
+ * (the only valid position for static `import` in ESM).
92
+ * - **body**: all other statements — moved into the body of the exported
93
+ * `__userFactory` async function.
94
+ *
95
+ * esbuild processes the re-generated module, following each static import
96
+ * through the normal dependency graph (including the SDK-redirect plugin).
97
+ *
98
+ * ## SDK redirect
99
+ *
100
+ * Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via
101
+ * the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that
102
+ * `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.
103
+ *
104
+ * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
105
+ */
106
+ /** The SDK package name that mini-app test code imports from. */
107
+ const SDK_PACKAGE = "@apps-in-toss/web-framework";
108
+ /**
109
+ * Names the runtime installs as globals before invoking the user factory.
110
+ * The `vitest` virtual module re-exports each as a lazy getter that reads from
111
+ * `globalThis` at access time. Keep in sync with the globals installed in
112
+ * `runtime.ts#runTestModule`.
113
+ */
114
+ const VITEST_GLOBAL_NAMES = [
115
+ "describe",
116
+ "it",
117
+ "test",
118
+ "expect",
119
+ "beforeAll",
120
+ "afterAll",
121
+ "beforeEach",
122
+ "afterEach",
123
+ "vi"
124
+ ];
125
+ /**
126
+ * Matches the bare SDK package and any sub-path import
127
+ * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
128
+ * Built from {@link SDK_PACKAGE} so the package name has a single source.
129
+ */
130
+ const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
131
+ /**
132
+ * esbuild plugin that intercepts SDK imports and redirects them to the
133
+ * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
134
+ *
135
+ * Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),
136
+ * esbuild resolves it to a virtual module that re-exports all named exports
137
+ * via `window.__sdk[name]`. This avoids bundling the real SDK (which may not
138
+ * be available in the test environment) while still making named imports work.
139
+ *
140
+ * If `window.__sdk` is absent (non-dog-food build), every access throws a
141
+ * descriptive error rather than returning `undefined` silently.
142
+ */
143
+ function sdkRedirectPlugin() {
144
+ return {
145
+ name: "sdk-redirect",
146
+ setup(build) {
147
+ build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({
148
+ path: args.path,
149
+ namespace: "sdk-redirect"
150
+ }));
151
+ build.onLoad({
152
+ filter: /.*/,
153
+ namespace: "sdk-redirect"
154
+ }, () => ({
155
+ contents: `
156
+ var __proxy = (typeof window !== 'undefined' && window.__sdk)
157
+ ? window.__sdk
158
+ : new Proxy({}, {
159
+ get: function(_t, p) {
160
+ throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
161
+ }
162
+ });
163
+ module.exports = __proxy;
164
+ `,
165
+ loader: "js"
166
+ }));
167
+ }
168
+ };
169
+ }
170
+ /**
171
+ * esbuild plugin that intercepts `import … from 'vitest'` and replaces it with
172
+ * a virtual module that delegates every named import to `globalThis` at ACCESS
173
+ * time (not at bundle-evaluation time).
174
+ *
175
+ * The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/
176
+ * afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle
177
+ * IIFE is evaluated. A value-copy redirect (`export var describe =
178
+ * globalThis.describe`) would therefore capture `undefined` at evaluation time
179
+ * and the user's `describe(...)` calls would be no-ops — registering zero tests.
180
+ *
181
+ * The fix defers the lookup to call time using per-name **getter** exports.
182
+ * We emit a CommonJS module that:
183
+ * 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named
184
+ * import directly to a property access on the module (NOT wrapped under a
185
+ * `default` shim — which is what happens for a bare Proxy whose own-keys
186
+ * are empty, leaving every named import `undefined`);
187
+ * 2. defines each global name as a getter that reads `globalThis[name]` on
188
+ * every access. So `import { describe } from 'vitest'` compiles to
189
+ * `import_vitest.describe`, whose getter returns the real `describe` only
190
+ * when the factory calls it — after `runTestModule` installs the globals.
191
+ *
192
+ * A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes
193
+ * the virtual module through `__toESM`, which enumerates own-keys (none on an
194
+ * empty Proxy target) and therefore exposes zero named exports. Explicit getter
195
+ * properties give `__toESM` real keys to map while keeping access lazy.
196
+ */
197
+ function vitestRedirectPlugin() {
198
+ return {
199
+ name: "vitest-redirect",
200
+ setup(build) {
201
+ build.onResolve({ filter: /^vitest$/ }, () => ({
202
+ path: "vitest",
203
+ namespace: "vitest-redirect"
204
+ }));
205
+ build.onLoad({
206
+ filter: /^vitest$/,
207
+ namespace: "vitest-redirect"
208
+ }, () => {
209
+ return {
210
+ 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`,
211
+ loader: "js"
212
+ };
213
+ });
214
+ }
215
+ };
216
+ }
217
+ /**
218
+ * esbuild plugin that transforms the user test file into a module that exports
219
+ * an async `__userFactory` function. The factory defers the user's top-level
220
+ * test registration code (describe/it/test calls) so it only runs when
221
+ * `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has
222
+ * installed describe/it/test/expect as globals.
223
+ *
224
+ * Algorithm:
225
+ * - Import declarations and re-export statements are kept at module top-level
226
+ * (the only valid ESM position for static `import`). A statement that spans
227
+ * multiple lines — e.g. a named import with one member per line:
228
+ * import {
229
+ * appLogin,
230
+ * getAnonymousKey,
231
+ * } from '@apps-in-toss/web-framework';
232
+ * is tracked as a single block: every line from the opening `import {` /
233
+ * `export {` through the closing `from '…'` (or side-effect `'…'`) line is
234
+ * kept together at top-level. This prevents the member lines and the
235
+ * closing `} from '…'` line from leaking into the factory body, which would
236
+ * leave an unterminated `import {` at module scope (the #678 env3 failure:
237
+ * esbuild threw `Expected "as" but found "{"` on multi-line SDK imports).
238
+ * - All other lines (describe/it/test calls, local declarations, etc.) are
239
+ * moved into the body of the exported async factory function.
240
+ *
241
+ * This preserves SDK import resolution (the sdk-redirect plugin processes
242
+ * top-level imports normally) while deferring test registration to the factory.
243
+ */
244
+ function userFactoryPlugin(absPath) {
245
+ const NAMESPACE = "user-test-factory";
246
+ return {
247
+ name: "user-test-factory",
248
+ setup(build) {
249
+ build.onResolve({ filter: /^user-test-factory$/ }, () => ({
250
+ path: absPath,
251
+ namespace: NAMESPACE
252
+ }));
253
+ build.onLoad({
254
+ filter: /.*/,
255
+ namespace: NAMESPACE
256
+ }, async (args) => {
257
+ const lines = (await fs.readFile(args.path, "utf8")).split("\n");
258
+ const topLevelLines = [];
259
+ const bodyLines = [];
260
+ const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
261
+ const isImportStart = (trimmed) => trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"");
262
+ const endsStatement = (trimmed) => /['"]\s*;?\s*$/.test(trimmed.replace(/\/\/.*$/, "").trimEnd());
263
+ let inImportBlock = false;
264
+ for (const line of lines) {
265
+ const trimmed = line.trimStart();
266
+ const indent = line.slice(0, line.length - trimmed.length);
267
+ if (inImportBlock) {
268
+ topLevelLines.push(line);
269
+ if (endsStatement(trimmed)) inImportBlock = false;
270
+ continue;
271
+ }
272
+ if (isImportStart(trimmed)) {
273
+ topLevelLines.push(line);
274
+ if (!endsStatement(trimmed)) inImportBlock = true;
275
+ } else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
276
+ else {
277
+ topLevelLines.push(line);
278
+ if (/\bfrom\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith("{")) inImportBlock = true;
279
+ }
280
+ else bodyLines.push(line);
281
+ }
282
+ return {
283
+ contents: [
284
+ ...topLevelLines,
285
+ "",
286
+ "// biome-ignore lint: generated factory wrapper",
287
+ "export default async function __userFactory(): Promise<void> {",
288
+ ...bodyLines.map((l) => ` ${l}`),
289
+ "}"
290
+ ].join("\n"),
291
+ loader: "ts",
292
+ resolveDir: path$1.dirname(absPath)
293
+ };
294
+ });
295
+ }
296
+ };
297
+ }
298
+ /**
299
+ * Returns the absolute path to the test-runner runtime module.
300
+ *
301
+ * Searches candidates in priority order:
302
+ * 1. Co-located `runtime.ts` / `runtime.js` — covers the source tree
303
+ * (tsx / ts-node) and the `dist/test-runner/` entry.
304
+ * 2. `../test-runner/runtime.js` — covers the `dist/mcp/cli.js` entry,
305
+ * where `import.meta.url` resolves to `dist/mcp/` (a sibling directory
306
+ * of `dist/test-runner/`). Without this second candidate the MCP entry
307
+ * point would look for `dist/mcp/runtime.js`, which does not exist, and
308
+ * every `run_tests` call would fail with an esbuild "Could not resolve"
309
+ * error (#678).
310
+ *
311
+ * Returns the first candidate that exists on disk. Falls back to the
312
+ * co-located `runtime.js` path so esbuild produces a clear "file not found"
313
+ * error rather than a cryptic failure.
314
+ */
315
+ function getRuntimePath() {
316
+ const dir = path$1.dirname(fileURLToPath(import.meta.url));
317
+ const candidates = [
318
+ path$1.join(dir, "runtime.ts"),
319
+ path$1.join(dir, "runtime.js"),
320
+ path$1.join(dir, "..", "test-runner", "runtime.js")
321
+ ];
322
+ for (const candidate of candidates) try {
323
+ accessSync(candidate);
324
+ return candidate;
325
+ } catch {}
326
+ return path$1.join(dir, "runtime.js");
327
+ }
328
+ /**
329
+ * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
330
+ *
331
+ * The IIFE installs `window.__testBundle` (or the custom `globalName`) with:
332
+ * - `runTestModule` — the runtime entry (from `runtime.ts`).
333
+ * - `__userFactory` — an async function wrapping the user's test registration
334
+ * code so it runs AFTER `runTestModule` installs the globals.
335
+ *
336
+ * Callers (rpc.ts) invoke:
337
+ * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
338
+ *
339
+ * @param absPath - Absolute path to the user test file.
340
+ * @param opts - Optional bundling overrides.
341
+ */
342
+ async function bundleTestFile(absPath, opts) {
343
+ const globalName = opts?.globalName ?? "__testBundle";
344
+ const extraExternals = opts?.extraExternals ?? [];
345
+ const esbuild = await import("esbuild");
346
+ const runtimePath = getRuntimePath();
347
+ const wrapperContent = [
348
+ `import { runTestModule } from ${JSON.stringify(runtimePath)};`,
349
+ `import __userFactory from "user-test-factory";`,
350
+ `export { runTestModule, __userFactory };`
351
+ ].join("\n");
352
+ const result = await esbuild.build({
353
+ stdin: {
354
+ contents: wrapperContent,
355
+ loader: "ts",
356
+ resolveDir: path$1.dirname(absPath)
357
+ },
358
+ bundle: true,
359
+ format: "iife",
360
+ globalName,
361
+ platform: "browser",
362
+ target: "es2022",
363
+ write: false,
364
+ plugins: [
365
+ userFactoryPlugin(absPath),
366
+ vitestRedirectPlugin(),
367
+ sdkRedirectPlugin()
368
+ ],
369
+ external: extraExternals,
370
+ treeShaking: true,
371
+ footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
372
+ });
373
+ const warnings = result.warnings.map((w) => `${path$1.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
374
+ const outputFile = result.outputFiles?.[0];
375
+ if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
376
+ return {
377
+ code: outputFile.text,
378
+ warnings
379
+ };
380
+ }
381
+ //#endregion
382
+ //#region src/test-runner/capture.ts
383
+ /** The exact console-line prefix sdk-example's `flushCapture` emits. */
384
+ const CAPTURE_PREFIX = "__AIT_CAPTURE__ ";
385
+ /**
386
+ * Parses raw console line texts into {@link AitCaptureLine}s.
387
+ *
388
+ * Filtering rules (each independently drops a line — never throws):
389
+ * - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);
390
+ * - there must be a non-empty category token (up to the next space);
391
+ * - the remaining payload must be valid JSON (`JSON.parse` succeeds).
392
+ *
393
+ * Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are
394
+ * silently discarded — capture harvesting is best-effort and must never fail a
395
+ * run or leak a malformed/secret-bearing line.
396
+ *
397
+ * @param raw - Console line objects (only `.text` is read).
398
+ * @returns The captured lines, in input order.
399
+ */
400
+ function parseCaptureLines(raw) {
401
+ const out = [];
402
+ for (const { text } of raw) {
403
+ if (!text.startsWith(CAPTURE_PREFIX)) continue;
404
+ const body = text.slice(16);
405
+ const spaceIdx = body.indexOf(" ");
406
+ if (spaceIdx === -1) continue;
407
+ const category = body.slice(0, spaceIdx);
408
+ const json = body.slice(spaceIdx + 1);
409
+ if (category === "" || json === "") continue;
410
+ try {
411
+ JSON.parse(json);
412
+ } catch {
413
+ continue;
414
+ }
415
+ out.push({
416
+ category,
417
+ json
418
+ });
419
+ }
420
+ return out;
421
+ }
422
+ //#endregion
423
+ //#region src/test-runner/rpc.ts
424
+ /** Maximum milliseconds to wait for a single evaluate round-trip. */
425
+ const DEFAULT_TIMEOUT_MS = 3e4;
426
+ /**
427
+ * Wraps bundle code in a self-executing IIFE that:
428
+ * 1. Evaluates the bundle (registering describe/it/test).
429
+ * 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.
430
+ * 3. Returns a JSON-serialised `RunReport` string.
431
+ *
432
+ * The double-serialisation (RunReport → JSON string → returnByValue string)
433
+ * is intentional: CDP `returnByValue` reliably transports strings; deeply
434
+ * nested objects can lose fidelity across the Chii relay.
435
+ *
436
+ * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
437
+ */
438
+ function buildRunTestsExpression(bundleCode) {
439
+ 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)}); }})()`;
440
+ }
441
+ /**
442
+ * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
443
+ * evaluate call into a typed `RpcRunResult`.
444
+ *
445
+ * Throws only on parse failure — an `ok:false` envelope is a normal result.
446
+ *
447
+ * SECRET-HANDLING: `rawValue` is not included in error messages.
448
+ */
449
+ function parseRunTestsResult(rawValue) {
450
+ if (typeof rawValue !== "string") throw new Error(`rpc.parseRunTestsResult: unexpected return type "${typeof rawValue}" — expected JSON string`);
451
+ let parsed;
452
+ try {
453
+ parsed = JSON.parse(rawValue);
454
+ } catch {
455
+ throw new Error("rpc.parseRunTestsResult: bridge returned non-JSON string");
456
+ }
457
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("rpc.parseRunTestsResult: parsed result is not an object");
458
+ const obj = parsed;
459
+ if (obj.ok === true) return {
460
+ ok: true,
461
+ report: obj.value
462
+ };
463
+ if (obj.ok === false) return {
464
+ ok: false,
465
+ error: typeof obj.error === "string" ? obj.error : String(obj.error)
466
+ };
467
+ throw new Error("rpc.parseRunTestsResult: result missing \"ok\" field");
468
+ }
469
+ /**
470
+ * Injects `bundleCode` into the attached page and awaits test execution.
471
+ *
472
+ * Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the
473
+ * async IIFE to settle. The 30-second CDP command timeout covers even
474
+ * long-running test suites; split into smaller files if you hit it.
475
+ *
476
+ * @param connection - Active CDP connection (relay or local).
477
+ * @param bundleCode - IIFE bundle string from `bundleTestFile`.
478
+ * @param timeoutMs - Override the default 30 s timeout.
479
+ *
480
+ * SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.
481
+ */
482
+ async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
483
+ const expression = buildRunTestsExpression(bundleCode);
484
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`rpc: evaluate timed out after ${timeoutMs}ms`)), timeoutMs));
485
+ const evalPromise = connection.send("Runtime.evaluate", {
486
+ expression,
487
+ returnByValue: true,
488
+ awaitPromise: true
489
+ });
490
+ const cdpResult = await Promise.race([evalPromise, timeoutPromise]);
491
+ if (cdpResult.exceptionDetails) {
492
+ const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
493
+ throw new Error(`rpc.injectAndRunBundle: ${msg}`);
494
+ }
495
+ return parseRunTestsResult(cdpResult.result.value);
496
+ }
497
+ //#endregion
498
+ //#region src/test-runner/relay-worker.ts
499
+ /**
500
+ * Runs all `files` sequentially over the given CDP `connection`.
501
+ *
502
+ * For each file:
503
+ * 1. Bundle with esbuild (includes SDK shim + runtime).
504
+ * 2. Inject into the attached page via `Runtime.evaluate`.
505
+ * 3. Await the `RunReport` JSON response.
506
+ * 4. Accumulate results.
507
+ *
508
+ * Returns a `RelayRunReport` with per-file results and flattened totals.
509
+ *
510
+ * This function does NOT open or manage the relay connection — the caller
511
+ * is responsible for attaching and closing it.
512
+ *
513
+ * TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
514
+ * so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
515
+ *
516
+ * @param connection - Active CDP connection (relay or local kind).
517
+ * @param files - Absolute paths to test files, run in order.
518
+ * @param opts - Optional per-run overrides.
519
+ */
520
+ async function runTestFilesOverRelay(connection, files, opts) {
521
+ const wallStart = Date.now();
522
+ const startedAt = new Date(wallStart).toISOString();
523
+ const fileResults = [];
524
+ let domainsEnabled = false;
525
+ try {
526
+ await connection.enableDomains();
527
+ domainsEnabled = true;
528
+ } catch (e) {
529
+ process.stderr.write(`relay-worker: enableDomains() failed before run — console capture may be empty (${e instanceof Error ? e.message : String(e)})\n`);
530
+ }
531
+ const collectCaptures = opts?.collectCaptures === true;
532
+ const liveConsole = [];
533
+ let unsubscribeConsole;
534
+ if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
535
+ liveConsole.push(event);
536
+ });
537
+ try {
538
+ for (const file of files) {
539
+ let fileEntry;
540
+ try {
541
+ const { code } = await bundleTestFile(file, opts?.bundleOptions);
542
+ const rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
543
+ if (rpcResult.ok) fileEntry = {
544
+ file,
545
+ result: rpcResult.report
546
+ };
547
+ else fileEntry = {
548
+ file,
549
+ result: { error: rpcResult.error }
550
+ };
551
+ } catch (e) {
552
+ fileEntry = {
553
+ file,
554
+ result: { error: e instanceof Error ? e.message : String(e) }
555
+ };
556
+ }
557
+ fileResults.push(fileEntry);
558
+ }
559
+ } finally {
560
+ unsubscribeConsole?.();
561
+ }
562
+ const captures = collectCaptures ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) }))) : [];
563
+ const totals = fileResults.reduce((acc, { result }) => {
564
+ if ("error" in result) {
565
+ acc.failed += 1;
566
+ acc.total += 1;
567
+ } else {
568
+ acc.passed += result.passed;
569
+ acc.failed += result.failed;
570
+ acc.skipped += result.skipped;
571
+ acc.total += result.passed + result.failed + result.skipped;
572
+ }
573
+ return acc;
574
+ }, {
575
+ passed: 0,
576
+ failed: 0,
577
+ skipped: 0,
578
+ total: 0
579
+ });
580
+ return {
581
+ startedAt,
582
+ duration: Date.now() - wallStart,
583
+ files: fileResults,
584
+ totals,
585
+ captures
586
+ };
587
+ }
588
+ /**
589
+ * Renders one `Runtime.consoleAPICalled` event to a single line of text, the
590
+ * same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-
591
+ * joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,
592
+ * which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)
593
+ * onto the test-runner entry.
594
+ *
595
+ * SECRET-HANDLING: this only stringifies console args; the caller's
596
+ * allowlist-prefix parser then discards everything that is not a genuine
597
+ * `__AIT_CAPTURE__` line.
598
+ */
599
+ function renderConsoleLineText(event) {
600
+ return event.args.map((arg) => {
601
+ if (arg.value !== void 0) {
602
+ if (typeof arg.value === "string") return arg.value;
603
+ try {
604
+ return JSON.stringify(arg.value);
605
+ } catch {
606
+ return String(arg.value);
607
+ }
608
+ }
609
+ if (arg.description !== void 0) return arg.description;
610
+ if (arg.className !== void 0) return arg.className;
611
+ return arg.subtype ?? arg.type;
612
+ }).join(" ");
613
+ }
614
+ //#endregion
615
+ //#region src/test-runner/report.ts
616
+ /**
617
+ * Runner-agnostic report serialisation for env3 test runs (devtools#696).
618
+ *
619
+ * Both env3 execution paths — the Vitest custom pool (`pool.ts`) and the
620
+ * standalone `devtools-test` CLI (`cli.ts`) — call the same core
621
+ * `runTestFilesOverRelay` and so produce the same {@link RelayRunReport}. This
622
+ * module is the single, runner-neutral place that turns that in-memory report
623
+ * into a stable on-disk artifact so a 2.x run and a 3.0 run can be diffed
624
+ * cell-by-cell after the fact.
625
+ *
626
+ * The serialised schema is deliberately MINIMAL and secret-free:
627
+ *
628
+ * - file paths are stored RELATIVE to `projectRoot` (no absolute `/Users/...`
629
+ * leakage — see {@link RunnerAgnosticReport.files});
630
+ * - the cell metadata (sdkLine/platform) is baked INTO the body, not only the
631
+ * filename, so a moved artifact never loses its provenance;
632
+ * - NO relay wss / scheme / TOTP / relayUrl fields exist in the schema at all
633
+ * (enforced by the type + this comment) — error strings are the matcher
634
+ * message only, inherited from rpc.ts which already strips expression/value.
635
+ *
636
+ * react-free — depends only on the type-level `RelayRunReport` and `node:fs` /
637
+ * `node:path`. Safe to bundle without pulling the chii/cloudflared graph.
638
+ */
639
+ /**
640
+ * Converts an absolute (or already-relative) file path to a projectRoot-relative
641
+ * one. `path.relative` returns `''` when the paths are equal — guard that to the
642
+ * basename so the field is never empty.
643
+ *
644
+ * SECRET-HANDLING: this is the single choke point that strips absolute project
645
+ * paths from the artifact.
646
+ */
647
+ function relativise(projectRoot, file) {
648
+ const rel = path.relative(projectRoot, file);
649
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return path.basename(file);
650
+ return rel;
651
+ }
652
+ /**
653
+ * Serialises a {@link RelayRunReport} into the runner-agnostic, secret-free
654
+ * on-disk shape. Pure — no IO; testable with a plain report + meta.
655
+ *
656
+ * @param report - The core relay run report.
657
+ * @param meta - Cell axes + projectRoot (projectRoot is consumed, not stored).
658
+ */
659
+ function serializeRelayReport(report, meta) {
660
+ return {
661
+ cell: {
662
+ sdkLine: meta.sdkLine,
663
+ platform: meta.platform
664
+ },
665
+ startedAt: report.startedAt,
666
+ duration: report.duration,
667
+ totals: report.totals,
668
+ files: report.files.map((f) => {
669
+ const file = relativise(meta.projectRoot, f.file);
670
+ if ("error" in f.result) return {
671
+ file,
672
+ error: f.result.error
673
+ };
674
+ return {
675
+ file,
676
+ duration: f.result.duration,
677
+ passed: f.result.passed,
678
+ failed: f.result.failed,
679
+ skipped: f.result.skipped,
680
+ tests: f.result.tests
681
+ };
682
+ })
683
+ };
684
+ }
685
+ /**
686
+ * Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating
687
+ * `dir` if needed. Returns the absolute path written.
688
+ *
689
+ * The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as
690
+ * distinct artifacts in the same directory; the same cell metadata is also baked
691
+ * into the body so a renamed/moved file still carries its provenance.
692
+ *
693
+ * SECRET-HANDLING: the written body contains no relay/secret fields (the schema
694
+ * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
695
+ *
696
+ * @param report - The core relay run report.
697
+ * @param dir - Output directory (created recursively if missing).
698
+ * @param meta - Cell axes + projectRoot.
699
+ * @returns The absolute path of the written file.
700
+ */
701
+ async function writeReportArtifact(report, dir, meta) {
702
+ const serialised = serializeRelayReport(report, meta);
703
+ await mkdir(dir, { recursive: true });
704
+ const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
705
+ await writeFile(outFile, `${JSON.stringify(serialised, null, 2)}\n`, "utf8");
706
+ return outFile;
707
+ }
708
+ /**
709
+ * Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,
710
+ * named `<category>.<sdkLine>.<platform>.json` — the SAME convention
711
+ * sdk-example's env1 `flushCapture` uses on the filesystem, so env1 and env3
712
+ * capture artifacts line up for diffing.
713
+ *
714
+ * Each line's `json` payload is an opaque JSON array of capture records. Lines
715
+ * sharing a category are concatenated into one array, in harvest order.
716
+ *
717
+ * SECRET-HANDLING: only allowlist-prefixed capture lines reach here (the parser
718
+ * dropped wss/scheme noise); the `json` payload is written verbatim but is a
719
+ * capture record array, not a relay/secret.
720
+ *
721
+ * @param captures - Parsed capture lines (from `RelayRunReport.captures`).
722
+ * @param dir - Output directory (created recursively if missing).
723
+ * @param cell - Cell axes for the filename suffix.
724
+ * @returns The absolute paths written (one per category), in category order.
725
+ */
726
+ async function writeCaptureArtifacts(captures, dir, cell) {
727
+ if (captures.length === 0) return [];
728
+ const byCategory = /* @__PURE__ */ new Map();
729
+ for (const { category, json } of captures) {
730
+ let merged = byCategory.get(category);
731
+ if (!merged) {
732
+ merged = [];
733
+ byCategory.set(category, merged);
734
+ }
735
+ const parsed = JSON.parse(json);
736
+ if (Array.isArray(parsed)) merged.push(...parsed);
737
+ else merged.push(parsed);
738
+ }
739
+ await mkdir(dir, { recursive: true });
740
+ const written = [];
741
+ for (const [category, records] of byCategory) {
742
+ const outFile = path.join(dir, `${category}.${cell.sdkLine}.${cell.platform}.json`);
743
+ await writeFile(outFile, `${JSON.stringify(records, null, 2)}\n`, "utf8");
744
+ written.push(outFile);
745
+ }
746
+ return written;
747
+ }
748
+ //#endregion
749
+ //#region src/test-runner/cli.ts
750
+ /**
751
+ * `devtools-test` CLI.
752
+ *
753
+ * Shares test-file discovery with the `run_tests` MCP tool (`discoverTestFiles`)
754
+ * and exposes `runWithConnection` — the pure run core that bundles, injects, and
755
+ * collects each file over a CDP connection. The CLI's `main()` performs a
756
+ * standalone relay attach (boot relay → QR → phone scan → cell inject → run).
757
+ *
758
+ * NOTE: no shebang in this source file — the tsdown entry's `banner` option
759
+ * injects `#!/usr/bin/env node` into the compiled output (same pattern as
760
+ * `src/mcp/cli.ts`).
761
+ */
762
+ const USAGE = `
763
+ devtools-test — run mini-app tests on a real device WebView over the CDP relay
764
+
765
+ USAGE
766
+ devtools-test <glob> [<glob> ...] [options]
767
+
768
+ OPTIONS
769
+ --scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
770
+ (required for standalone relay attach / env3)
771
+ --timeout <ms> Per-file evaluate timeout in ms (default: 30000)
772
+ --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
773
+ --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
774
+ (mock|ios|android, default: AIT_CELL_PLATFORM env)
775
+ --report-dir <dir> Persist a runner-agnostic report + captures to <dir>
776
+ (report: <sdkLine>.<platform>.json; captures:
777
+ <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
778
+ Omitted = nothing saved. Enables console capture.
779
+ --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
780
+ non-interactive stdout / CI / AIT_NO_QR_STDOUT)
781
+ --headless Disable browser auto-open (text QR only)
782
+ --project-root <dir> Project root for .ait_relay secret lookup
783
+ (default: current working directory)
784
+ --help, -h Show this help message
785
+
786
+ DESCRIPTION
787
+ Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real
788
+ device to scan and attach, injects the cell globals (__AIT_CELL__), bundles
789
+ each matched test file with esbuild (SDK imports redirected to window.__sdk),
790
+ injects the bundle into the attached WebView via Runtime.evaluate, and prints
791
+ a summary.
792
+
793
+ With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
794
+ runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
795
+ compared offline.
796
+
797
+ The test files run against the live relay connection started by this process;
798
+ no separate MCP daemon is required.
799
+
800
+ EXAMPLE
801
+ devtools-test 'src/**/*.ait.test.ts' \\
802
+ --scheme-url "intoss-private://..." \\
803
+ --cell-sdk-line 3.x \\
804
+ --cell-platform ios \\
805
+ --report-dir .ait-report \\
806
+ --timeout 60000
807
+
808
+ `.trimStart();
809
+ /**
810
+ * Runs `files` over `connection` and returns the aggregate report.
811
+ * This pure function is the testable core of the CLI (and is what the
812
+ * `run_tests` MCP tool calls against the daemon's attached connection); it is
813
+ * separate from `main()` so tests can call it without spawning a subprocess.
814
+ */
815
+ async function runWithConnection(connection, files, opts) {
816
+ const report = await runTestFilesOverRelay(connection, files, opts);
817
+ if (opts?.printSummary) {
818
+ const { totals } = report;
819
+ process.stdout.write(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${report.duration}ms)\n`);
820
+ }
821
+ return report;
822
+ }
823
+ /**
824
+ * Decides whether to suppress the QR/attach block on stdout.
825
+ *
826
+ * Suppress when EITHER the user passed `--no-qr-stdout`, OR stdout is not a TTY
827
+ * / `CI` is set / `AIT_NO_QR_STDOUT` is set (non-interactive — a captured stdout
828
+ * must not leak the relay wss + TOTP `at=` code that the QR block encodes). The
829
+ * suppression is whole-chunk: `attachUrl` AND `relayUrl` ride in the same block.
830
+ *
831
+ * Exported for unit testing.
832
+ */
833
+ function shouldSuppressQr(noQrFlag) {
834
+ return noQrFlag || !process.stdout.isTTY || process.env.CI !== void 0 || process.env.AIT_NO_QR_STDOUT !== void 0;
835
+ }
836
+ /**
837
+ * CLI entry point.
838
+ *
839
+ * Performs a standalone relay attach → run lifecycle, sharing the attach
840
+ * assembly with the Vitest pool via `createRelayConnectionFactory` (single
841
+ * source — no drift):
842
+ *
843
+ * 1. Parse args: globs, --timeout, --cell-sdk-line, --cell-platform,
844
+ * --scheme-url (required for env3), --report-dir, --no-qr-stdout,
845
+ * --headless, --project-root.
846
+ * 2. Discover test files; exit 1 if none.
847
+ * 3. factory.open() — boot relay → render QR (suppressed on non-interactive
848
+ * stdout) → wait for phone → inject cell → enableDomains. Returns the conn.
849
+ * 4. runWithConnection(conn, files, { timeoutMs, collectCaptures, printSummary }).
850
+ * 5. With --report-dir: write the runner-agnostic report + capture files.
851
+ * 6. factory.close(); process.exitCode = failed > 0 ? 1 : 0.
852
+ *
853
+ * The CLI is not a daemon — no lock, router, SSE, or tools_list is needed.
854
+ * Attach timeout exits with code 1; test failures exit with code 1.
855
+ *
856
+ * SECRET-HANDLING: scheme_url / relay wssUrl / TOTP codes are never written to
857
+ * stdout/stderr directly. The QR block (which encodes the TOTP `at=` code) is
858
+ * printed only when stdout is interactive AND not suppressed.
859
+ */
860
+ async function main(argv = process.argv.slice(2)) {
861
+ let parsed;
862
+ try {
863
+ parsed = parseArgs({
864
+ args: argv,
865
+ options: {
866
+ help: {
867
+ type: "boolean",
868
+ short: "h"
869
+ },
870
+ timeout: { type: "string" },
871
+ "scheme-url": { type: "string" },
872
+ "cell-sdk-line": { type: "string" },
873
+ "cell-platform": { type: "string" },
874
+ "report-dir": { type: "string" },
875
+ "no-qr-stdout": { type: "boolean" },
876
+ headless: { type: "boolean" },
877
+ "project-root": { type: "string" }
878
+ },
879
+ allowPositionals: true
880
+ });
881
+ } catch (e) {
882
+ process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
883
+ process.exitCode = 1;
884
+ return;
885
+ }
886
+ if (parsed.values.help || argv.length === 0) {
887
+ process.stdout.write(USAGE);
888
+ return;
889
+ }
890
+ const vals = parsed.values;
891
+ const rawTimeout = typeof vals.timeout === "string" ? vals.timeout : void 0;
892
+ const timeoutMs = rawTimeout !== void 0 ? parseInt(rawTimeout, 10) : 3e4;
893
+ if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
894
+ process.stderr.write(`devtools-test: --timeout must be a positive integer\n`);
895
+ process.exitCode = 1;
896
+ return;
897
+ }
898
+ const schemeUrl = typeof vals["scheme-url"] === "string" ? vals["scheme-url"] : "";
899
+ if (schemeUrl === "") {
900
+ 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");
901
+ process.exitCode = 1;
902
+ return;
903
+ }
904
+ const headless = vals.headless === true;
905
+ const projectRoot = typeof vals["project-root"] === "string" ? vals["project-root"] : process.cwd();
906
+ const reportDir = typeof vals["report-dir"] === "string" ? vals["report-dir"] : void 0;
907
+ const suppressQr = shouldSuppressQr(vals["no-qr-stdout"] === true);
908
+ const cellSdkLine = typeof vals["cell-sdk-line"] === "string" ? vals["cell-sdk-line"] : void 0;
909
+ const cellPlatform = typeof vals["cell-platform"] === "string" ? vals["cell-platform"] : process.env.AIT_CELL_PLATFORM;
910
+ const hasCell = cellSdkLine !== void 0 || cellPlatform !== void 0;
911
+ const cell = {
912
+ sdkLine: cellSdkLine ?? "2.x",
913
+ platform: cellPlatform ?? "mock"
914
+ };
915
+ const globs = parsed.positionals;
916
+ if (globs.length === 0) {
917
+ process.stderr.write(`devtools-test: at least one glob pattern is required\n`);
918
+ process.stdout.write(USAGE);
919
+ process.exitCode = 1;
920
+ return;
921
+ }
922
+ const files = await discoverTestFiles(globs, process.cwd());
923
+ if (files.length === 0) {
924
+ process.stderr.write(`devtools-test: no test files matched ${globs.join(", ")}\n`);
925
+ process.exitCode = 1;
926
+ return;
927
+ }
928
+ process.stderr.write(`devtools-test: found ${files.length} test file(s)\n`);
929
+ if (hasCell) process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\n`);
930
+ const factory = createRelayConnectionFactory({
931
+ schemeUrl,
932
+ projectRoot,
933
+ timeoutMs,
934
+ headless,
935
+ cell: hasCell ? cell : void 0,
936
+ onQrContent: (chunks) => {
937
+ if (suppressQr) {
938
+ process.stdout.write("QR suppressed (non-interactive)\n");
939
+ return;
940
+ }
941
+ for (const chunk of chunks) process.stdout.write(`${chunk}\n`);
942
+ }
943
+ });
944
+ let connection;
945
+ try {
946
+ connection = await factory.open();
947
+ } catch (e) {
948
+ process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
949
+ process.exitCode = 1;
950
+ return;
951
+ }
952
+ let exitCode = 0;
953
+ try {
954
+ const report = await runWithConnection(connection, files, {
955
+ timeoutMs,
956
+ printSummary: true,
957
+ collectCaptures: reportDir !== void 0
958
+ });
959
+ if (reportDir !== void 0) try {
960
+ const reportPath = await writeReportArtifact(report, reportDir, {
961
+ sdkLine: cell.sdkLine,
962
+ platform: cell.platform,
963
+ projectRoot
964
+ });
965
+ process.stderr.write(`devtools-test: wrote report ${reportPath}\n`);
966
+ const capturePaths = await writeCaptureArtifacts(report.captures, `${reportDir}/.ait-capture`, cell);
967
+ if (capturePaths.length > 0) process.stderr.write(`devtools-test: wrote ${capturePaths.length} capture file(s)\n`);
968
+ } catch (e) {
969
+ process.stderr.write(`devtools-test: failed to write report artifacts: ${e instanceof Error ? e.message : String(e)}\n`);
970
+ }
971
+ exitCode = report.totals.failed > 0 ? 1 : 0;
972
+ } finally {
973
+ await factory.close(connection);
974
+ process.exitCode = exitCode;
975
+ }
976
+ }
977
+ if (import.meta.url === new URL(process.argv[1], "file://").href) main().catch((e) => {
978
+ process.stderr.write(`devtools-test: unexpected error: ${e instanceof Error ? e.message : String(e)}\n`);
979
+ process.exitCode = 1;
980
+ });
981
+ //#endregion
982
+ //#region src/shared/relay-auth-close.ts
983
+ /**
984
+ * Shared constants for the relay's named TOTP-auth rejection (issue #478).
985
+ *
986
+ * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
987
+ * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
988
+ * indistinguishable from a network failure on the browser side — the
989
+ * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
990
+ * could not tell "stale TOTP code" apart from "tunnel down" and stayed
991
+ * silent. The fix is accept-then-close: complete the handshake, then close
992
+ * with an application close code that NAMES the rejection.
993
+ *
994
+ * Three parties share this contract:
995
+ * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
996
+ * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
997
+ * surfaces the code to the launcher shell;
998
+ * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
999
+ * as an auth failure on its own `/client` dial (defensive — #439's fresh
1000
+ * code mint means it should not normally hit this).
1001
+ *
1002
+ * This module is intentionally dependency-free (no Node, no DOM) so it is
1003
+ * safe to import from both the browser in-app bundle and the MCP daemon
1004
+ * bundle.
1005
+ *
1006
+ * SECRET-HANDLING: these are fixed enum values. The close reason / error body
1007
+ * must never grow to carry a secret, a TOTP code, or a host.
1008
+ */
1009
+ /**
1010
+ * WebSocket close code sent by the relay when TOTP auth is rejected.
1011
+ *
1012
+ * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
1013
+ * HTTP 401 so it reads as "unauthorized" at a glance.
1014
+ */
1015
+ const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
1016
+ /**
1017
+ * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
1018
+ * the `error` value of the relay's HTTP 401 JSON body. Enum string only —
1019
+ * never interpolated with request data.
1020
+ */
1021
+ const RELAY_AUTH_REJECT_REASON = "totp-rejected";
1022
+ //#endregion
1023
+ //#region src/mcp/chii-connection.ts
1024
+ /**
1025
+ * Production `CdpConnection` backed by the local Chii relay.
1026
+ *
1027
+ * Topology (debug mode):
1028
+ * phone target.js --WS--> Chii relay :9100 <--WS-- this connection
1029
+ *
1030
+ * The phone connects to the relay as a `target`; this module connects as a
1031
+ * `client` (the role a CDP frontend would take) so CDP events the page emits
1032
+ * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
1033
+ * events in ring buffers the tool layer reads via `getBufferedEvents`.
1034
+ *
1035
+ * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
1036
+ *
1037
+ * Attach reliability (#281):
1038
+ * `refreshTargets()` emits an internal 'target:attached' event whenever a
1039
+ * new target is added to the relay. `waitForFirstTarget()` awaits that event
1040
+ * (with a polling-interval fallback) so `start_attach`'s attach wait
1041
+ * resolves deterministically rather than racing between polling rounds.
1042
+ */
1043
+ /** Max events retained per domain ring buffer. */
1044
+ const DEFAULT_BUFFER_SIZE = 500;
1045
+ function isObject(value) {
1046
+ return typeof value === "object" && value !== null;
1047
+ }
1048
+ function parseInbound(raw) {
1049
+ let parsed;
1050
+ try {
1051
+ parsed = JSON.parse(raw);
1052
+ } catch {
1053
+ return null;
1054
+ }
1055
+ if (!isObject(parsed)) return null;
1056
+ const message = {};
1057
+ if (typeof parsed.id === "number") message.id = parsed.id;
1058
+ if (typeof parsed.method === "string") message.method = parsed.method;
1059
+ if ("params" in parsed) message.params = parsed.params;
1060
+ if ("result" in parsed) message.result = parsed.result;
1061
+ if (isObject(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
1062
+ return message;
1063
+ }
1064
+ const PHASE_1_EVENTS = [
1065
+ "Runtime.consoleAPICalled",
1066
+ "Network.requestWillBeSent",
1067
+ "Network.responseReceived"
1068
+ ];
1069
+ /**
1070
+ * Ring buffer size for `Runtime.exceptionThrown`.
1071
+ *
1072
+ * Exceptions are rarer than console messages but each is heavier (stack
1073
+ * trace). 50 is generous enough to cover a crash scenario while keeping
1074
+ * memory bounded.
1075
+ *
1076
+ * **Lifecycle note**: the exception buffer intentionally survives `replaced` /
1077
+ * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
1078
+ * transitions. Rationale: an exception fired just before a crash is exactly
1079
+ * the signal we want to preserve for root-cause analysis. The buffer
1080
+ * represents "exceptions seen in this MCP session", not "exceptions in the
1081
+ * current page".
1082
+ */
1083
+ const EXCEPTION_BUFFER_SIZE = 50;
1084
+ /** Default per-command timeout if neither option nor env var is set. */
1085
+ const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
1086
+ /**
1087
+ * Production CDP connection. Polls the relay for the first attached target,
1088
+ * opens a client websocket to it, enables Phase 1 domains, and buffers events.
1089
+ */
1090
+ var ChiiCdpConnection = class {
1091
+ /** Authoritative connection kind (issue #348) — relay-backed. */
1092
+ kind = "relay";
1093
+ relayBaseUrl;
1094
+ bufferSize;
1095
+ commandTimeoutMs;
1096
+ totpSecret;
1097
+ emitter = new EventEmitter();
1098
+ buffers = /* @__PURE__ */ new Map();
1099
+ targets = /* @__PURE__ */ new Map();
1100
+ ws = null;
1101
+ connectionState = "idle";
1102
+ nextCommandId = 1;
1103
+ /**
1104
+ * The single active target id under the single-attach model.
1105
+ * Updated by `refreshTargets()` whenever a non-null target is present.
1106
+ * Used to detect a new (different) target attach and evict the previous one.
1107
+ */
1108
+ activeTargetId = null;
1109
+ /** In-flight enableDomains() promise — concurrent callers share it. */
1110
+ enablingPromise = null;
1111
+ /** Pending request→response commands keyed by CDP message id. */
1112
+ pending = /* @__PURE__ */ new Map();
1113
+ /**
1114
+ * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
1115
+ * or `null` if no crash has been detected since the last `enableDomains()`.
1116
+ */
1117
+ lastCrashDetectedAt = null;
1118
+ /**
1119
+ * Per-target last-seen timestamp (ms since epoch). Updated on any inbound
1120
+ * CDP message carrying data from a target. Keyed by target id.
1121
+ */
1122
+ targetLastSeenAt = /* @__PURE__ */ new Map();
1123
+ /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
1124
+ heartbeatHandle = null;
1125
+ /** Lifecycle event listeners (crash / destroyed / detached). */
1126
+ lifecycleListeners = [];
1127
+ constructor(options) {
1128
+ this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
1129
+ this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
1130
+ this.totpSecret = options.totpSecret;
1131
+ const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
1132
+ this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
1133
+ for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);
1134
+ this.buffers.set("Runtime.exceptionThrown", []);
1135
+ this.emitter.setMaxListeners(0);
1136
+ }
1137
+ /** Refresh the attached-target list from the relay's `GET /targets`. */
1138
+ async refreshTargets() {
1139
+ let targetsUrl = `${this.relayBaseUrl}/targets`;
1140
+ if (this.totpSecret) {
1141
+ const code = generateTotp(this.totpSecret);
1142
+ targetsUrl += `?at=${encodeURIComponent(code)}`;
1143
+ }
1144
+ const res = await fetch(targetsUrl);
1145
+ if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
1146
+ const body = await res.json();
1147
+ const list = isObject(body) && Array.isArray(body.targets) ? body.targets : [];
1148
+ let newestTargetId = null;
1149
+ for (const item of list) {
1150
+ if (!isObject(item) || typeof item.id !== "string") continue;
1151
+ newestTargetId = item.id;
1152
+ }
1153
+ if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
1154
+ const prevId = this.activeTargetId;
1155
+ logInfo("page.detached", { prevTargetId: prevId });
1156
+ this.evictTarget(prevId);
1157
+ }
1158
+ this.targets.clear();
1159
+ for (const item of list) {
1160
+ if (!isObject(item) || typeof item.id !== "string") continue;
1161
+ if (item.id !== newestTargetId) continue;
1162
+ this.targets.set(item.id, {
1163
+ id: item.id,
1164
+ title: typeof item.title === "string" ? item.title : "",
1165
+ url: typeof item.url === "string" ? item.url : ""
1166
+ });
1167
+ }
1168
+ if (newestTargetId !== null) this.activeTargetId = newestTargetId;
1169
+ else this.activeTargetId = null;
1170
+ const result = [...this.targets.values()];
1171
+ if (newestTargetId !== null) this.emitter.emit("target:attached", result);
1172
+ return result;
1173
+ }
1174
+ listTargets() {
1175
+ return [...this.targets.values()];
1176
+ }
1177
+ /**
1178
+ * Waits until at least one target matching `filterFn` is attached, then
1179
+ * resolves with the full target list at that moment.
1180
+ *
1181
+ * Resolution happens on whichever comes first:
1182
+ * (a) a `'target:attached'` event from `refreshTargets()` (triggered by
1183
+ * the /targets poll finding a new target), OR
1184
+ * (b) a `'target:attached'` event from `handleMessage()` (triggered by
1185
+ * the first inbound CDP message from a target — confirms the relay
1186
+ * websocket has data from the phone, not just a target entry in the map).
1187
+ *
1188
+ * This dual-signal approach eliminates the polling race that previously
1189
+ * caused `wait_for_attach` to resolve before the first CDP message arrived.
1190
+ *
1191
+ * Falls back to checking `listTargets()` every `pollIntervalMs` in case the
1192
+ * EventEmitter is missed (defensive belt-and-suspenders).
1193
+ *
1194
+ * @param filterFn - Predicate that the returned targets must satisfy.
1195
+ * @param timeoutMs - Reject after this many ms (default 90 000).
1196
+ * @param pollIntervalMs - Fallback poll interval (default 500ms).
1197
+ */
1198
+ waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
1199
+ const current = this.listTargets();
1200
+ if (filterFn(current)) return Promise.resolve(current);
1201
+ return new Promise((resolve, reject) => {
1202
+ let settled = false;
1203
+ let pollHandle = null;
1204
+ const settle = (targets) => {
1205
+ if (settled) return;
1206
+ settled = true;
1207
+ clearTimeout(timeoutHandle);
1208
+ if (pollHandle !== null) {
1209
+ clearInterval(pollHandle);
1210
+ pollHandle = null;
1211
+ }
1212
+ this.emitter.off("target:attached", onAttach);
1213
+ resolve(targets);
1214
+ };
1215
+ const onAttach = (targets) => {
1216
+ if (filterFn(targets)) settle(targets);
1217
+ };
1218
+ const timeoutHandle = setTimeout(() => {
1219
+ if (settled) return;
1220
+ settled = true;
1221
+ if (pollHandle !== null) {
1222
+ clearInterval(pollHandle);
1223
+ pollHandle = null;
1224
+ }
1225
+ this.emitter.off("target:attached", onAttach);
1226
+ reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
1227
+ }, timeoutMs);
1228
+ this.emitter.on("target:attached", onAttach);
1229
+ pollHandle = setInterval(() => {
1230
+ this.refreshTargets().then((targets) => {
1231
+ if (filterFn(targets)) settle(targets);
1232
+ }, () => {});
1233
+ }, pollIntervalMs);
1234
+ });
1235
+ }
1236
+ /**
1237
+ * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
1238
+ * detected since the last `enableDomains()` call, or `null` if none.
1239
+ */
1240
+ getLastCrashDetectedAt() {
1241
+ return this.lastCrashDetectedAt;
1242
+ }
1243
+ /**
1244
+ * Last-seen timestamp (ms since epoch) for a given target id, or `null` if
1245
+ * the target is unknown / no message has been received from it yet.
1246
+ */
1247
+ getTargetLastSeenAt(targetId) {
1248
+ return this.targetLastSeenAt.get(targetId) ?? null;
1249
+ }
1250
+ /** Subscribe to target lifecycle events (crash / destroyed / detached). */
1251
+ onLifecycle(listener) {
1252
+ this.lifecycleListeners.push(listener);
1253
+ return () => {
1254
+ const idx = this.lifecycleListeners.indexOf(listener);
1255
+ if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
1256
+ };
1257
+ }
1258
+ /**
1259
+ * Connect a client websocket to the first attached target and enable Phase 1
1260
+ * domains. Resolves once the socket is open and enable commands are sent.
1261
+ */
1262
+ async enableDomains() {
1263
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
1264
+ if (this.enablingPromise) return this.enablingPromise;
1265
+ this.enablingPromise = this._doEnableDomains().finally(() => {
1266
+ this.enablingPromise = null;
1267
+ });
1268
+ return this.enablingPromise;
1269
+ }
1270
+ async _doEnableDomains() {
1271
+ const target = (await this.refreshTargets())[0];
1272
+ if (!target) throw new Error("No mini-app page attached to the Chii relay yet.");
1273
+ let clientUrl = `${this.relayBaseUrl.replace(/^http/, "ws")}/client/${`devtools-mcp-${Date.now()}`}?target=${encodeURIComponent(target.id)}`;
1274
+ if (this.totpSecret) {
1275
+ const code = generateTotp(this.totpSecret);
1276
+ clientUrl += `&at=${encodeURIComponent(code)}`;
1277
+ }
1278
+ const ws = new WebSocket(clientUrl);
1279
+ this.ws = ws;
1280
+ await new Promise((resolve, reject) => {
1281
+ ws.once("open", () => resolve());
1282
+ ws.once("error", (err) => reject(err));
1283
+ ws.once("close", (code) => {
1284
+ if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
1285
+ });
1286
+ });
1287
+ this.lastCrashDetectedAt = null;
1288
+ this.targetLastSeenAt.clear();
1289
+ this.connectionState = "connected";
1290
+ ws.on("message", (data) => this.handleMessage(data.toString()));
1291
+ ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
1292
+ ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
1293
+ this.sendFireAndForget("Runtime.enable");
1294
+ this.sendFireAndForget("Network.enable");
1295
+ this.sendFireAndForget("DOM.enable");
1296
+ this.sendFireAndForget("Page.enable");
1297
+ this.sendFireAndForget("Inspector.enable");
1298
+ this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
1299
+ this.startHeartbeat(target.id);
1300
+ }
1301
+ /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
1302
+ sendFireAndForget(method, params = {}) {
1303
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
1304
+ const id = this.nextCommandId++;
1305
+ this.ws.send(JSON.stringify({
1306
+ id,
1307
+ method,
1308
+ params
1309
+ }));
1310
+ }
1311
+ /**
1312
+ * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
1313
+ * error frame or when no websocket is open (no page attached yet).
1314
+ */
1315
+ send(method, params) {
1316
+ return this.sendCommand(method, params ?? {});
1317
+ }
1318
+ /**
1319
+ * Issue an arbitrary request→response command over the relay and resolve with
1320
+ * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
1321
+ * `AIT.*` methods, forwarded over the same Chii channel) build on this.
1322
+ *
1323
+ * Rejects immediately if the connection is disconnected (fail-fast — no
1324
+ * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
1325
+ * reattach.
1326
+ *
1327
+ * Times out after `commandTimeoutMs` (default 30s, env
1328
+ * `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
1329
+ * and the promise rejects with a descriptive Korean error.
1330
+ */
1331
+ sendCommand(method, params = {}) {
1332
+ if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
1333
+ 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."));
1334
+ const id = this.nextCommandId++;
1335
+ const ws = this.ws;
1336
+ const timeoutMs = this.commandTimeoutMs;
1337
+ return new Promise((resolve, reject) => {
1338
+ const handle = setTimeout(() => {
1339
+ this.pending.delete(id);
1340
+ reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
1341
+ }, timeoutMs);
1342
+ this.pending.set(id, {
1343
+ resolve: (v) => {
1344
+ clearTimeout(handle);
1345
+ resolve(v);
1346
+ },
1347
+ reject: (e) => {
1348
+ clearTimeout(handle);
1349
+ reject(e);
1350
+ }
1351
+ });
1352
+ ws.send(JSON.stringify({
1353
+ id,
1354
+ method,
1355
+ params
1356
+ }));
1357
+ });
1358
+ }
1359
+ /**
1360
+ * Called on WebSocket `close` or `error` after a successful connection.
1361
+ * Rejects all pending commands and marks the connection as disconnected so
1362
+ * subsequent `sendCommand` calls fail fast (no auto-reconnect).
1363
+ */
1364
+ handleDisconnect(reason) {
1365
+ if (this.connectionState === "disconnected") return;
1366
+ this.connectionState = "disconnected";
1367
+ this.ws = null;
1368
+ this.stopHeartbeat();
1369
+ const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
1370
+ for (const waiter of this.pending.values()) waiter.reject(err);
1371
+ this.pending.clear();
1372
+ }
1373
+ /**
1374
+ * Evict a previously active target under the single-attach model.
1375
+ * Rejects pending commands with a 'replaced-by-new-attach' reason and emits
1376
+ * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
1377
+ * targetId. The caller is responsible for rebuilding the targets map afterwards.
1378
+ *
1379
+ * The error message uses 'replaced-by-new-attach' so test assertions can match it.
1380
+ */
1381
+ evictTarget(targetId) {
1382
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
1383
+ this.targets.delete(targetId);
1384
+ this.targetLastSeenAt.delete(targetId);
1385
+ const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
1386
+ for (const waiter of this.pending.values()) waiter.reject(err);
1387
+ this.pending.clear();
1388
+ const event = {
1389
+ kind: "replaced",
1390
+ targetId,
1391
+ detectedAt
1392
+ };
1393
+ for (const listener of this.lifecycleListeners) try {
1394
+ listener(event);
1395
+ } catch {}
1396
+ }
1397
+ /**
1398
+ * Handle a page-level crash or target destruction event.
1399
+ * Removes the target from the in-memory map, rejects all pending commands,
1400
+ * and emits a lifecycle event.
1401
+ *
1402
+ * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
1403
+ * @param targetId - The target ID from the event params (may be null for
1404
+ * Inspector.targetCrashed which has no targetId in the params).
1405
+ */
1406
+ handleTargetGone(kind, targetId) {
1407
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
1408
+ this.lastCrashDetectedAt = Date.now();
1409
+ if (targetId !== null) {
1410
+ this.targets.delete(targetId);
1411
+ this.targetLastSeenAt.delete(targetId);
1412
+ if (this.activeTargetId === targetId) this.activeTargetId = null;
1413
+ } else {
1414
+ this.targets.clear();
1415
+ this.targetLastSeenAt.clear();
1416
+ this.activeTargetId = null;
1417
+ }
1418
+ 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()로 재연결).`);
1419
+ for (const waiter of this.pending.values()) waiter.reject(err);
1420
+ this.pending.clear();
1421
+ const event = {
1422
+ kind,
1423
+ targetId,
1424
+ detectedAt
1425
+ };
1426
+ for (const listener of this.lifecycleListeners) try {
1427
+ listener(event);
1428
+ } catch {}
1429
+ }
1430
+ /**
1431
+ * Start the optional CDP heartbeat loop.
1432
+ *
1433
+ * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
1434
+ * we send `Runtime.evaluate({expression: '1'})` to each active target. If
1435
+ * the command times out (2 s hard deadline) or errors, we treat the target
1436
+ * as dead and call `handleTargetGone`.
1437
+ *
1438
+ * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
1439
+ * even when the phone app has crashed, so the ws-level disconnect (#252) won't
1440
+ * fire. The heartbeat catches this gap.
1441
+ *
1442
+ * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
1443
+ */
1444
+ startHeartbeat(initialTargetId) {
1445
+ this.stopHeartbeat();
1446
+ const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
1447
+ if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
1448
+ const PING_TIMEOUT_MS = 2e3;
1449
+ this.heartbeatHandle = setInterval(() => {
1450
+ const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
1451
+ for (const targetId of targetIds) {
1452
+ const pingPromise = this.sendCommand("Runtime.evaluate", {
1453
+ expression: "1",
1454
+ returnByValue: true,
1455
+ timeout: PING_TIMEOUT_MS
1456
+ });
1457
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
1458
+ Promise.race([pingPromise, timeoutPromise]).catch(() => {
1459
+ if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
1460
+ });
1461
+ }
1462
+ }, envMs);
1463
+ }
1464
+ stopHeartbeat() {
1465
+ if (this.heartbeatHandle !== null) {
1466
+ clearInterval(this.heartbeatHandle);
1467
+ this.heartbeatHandle = null;
1468
+ }
1469
+ }
1470
+ handleMessage(raw) {
1471
+ const message = parseInbound(raw);
1472
+ if (!message) return;
1473
+ if (typeof message.id === "number" && this.pending.has(message.id)) {
1474
+ const waiter = this.pending.get(message.id);
1475
+ this.pending.delete(message.id);
1476
+ if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
1477
+ else waiter.resolve(message.result);
1478
+ return;
1479
+ }
1480
+ const now = Date.now();
1481
+ let firstMessageSeen = false;
1482
+ for (const targetId of this.targets.keys()) {
1483
+ if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
1484
+ this.targetLastSeenAt.set(targetId, now);
1485
+ }
1486
+ if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
1487
+ if (typeof message.method !== "string") return;
1488
+ if (message.method === "Inspector.targetCrashed") {
1489
+ this.handleTargetGone("crashed", null);
1490
+ return;
1491
+ }
1492
+ if (message.method === "Target.targetDestroyed") {
1493
+ const targetId = isObject(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
1494
+ this.handleTargetGone("destroyed", targetId);
1495
+ return;
1496
+ }
1497
+ if (message.method === "Target.detachedFromTarget") {
1498
+ const targetId = isObject(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
1499
+ this.handleTargetGone("detached", targetId);
1500
+ return;
1501
+ }
1502
+ if (!this.buffers.has(message.method)) return;
1503
+ const event = message.method;
1504
+ const buffer = this.buffers.get(event);
1505
+ if (!buffer) return;
1506
+ buffer.push(message.params);
1507
+ const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
1508
+ if (buffer.length > cap) buffer.shift();
1509
+ this.emitter.emit(event, message.params);
1510
+ }
1511
+ getBufferedEvents(event) {
1512
+ return this.buffers.get(event) ?? [];
1513
+ }
1514
+ on(event, listener) {
1515
+ this.emitter.on(event, listener);
1516
+ return () => this.emitter.off(event, listener);
1517
+ }
1518
+ /** Close the relay client websocket and reject any in-flight commands. */
1519
+ close() {
1520
+ const ws = this.ws;
1521
+ this.stopHeartbeat();
1522
+ this.handleDisconnect("Chii relay connection closed");
1523
+ ws?.close();
1524
+ }
1525
+ };
1526
+ //#endregion
1527
+ //#region src/mcp/chii-relay.ts
1528
+ /**
1529
+ * Boots the local Chii relay server.
1530
+ *
1531
+ * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome
1532
+ * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.
1533
+ * The relay accepts a `target` websocket from the phone's injected `target.js`
1534
+ * and `client` websockets from CDP frontends (our MCP connection).
1535
+ *
1536
+ * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app
1537
+ * entries.
1538
+ *
1539
+ * TOTP auth (relay-side, authoritative gate):
1540
+ * When `verifyAuth` is provided, this module gates both inbound surfaces:
1541
+ *
1542
+ * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.
1543
+ * Node's `http.Server` calls listeners in registration order; the first
1544
+ * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny
1545
+ * JSON body (`{"error":"totp-rejected"}`) so a cross-origin script
1546
+ * `fetch()` probe can READ the status (issue #478). Valid auth → return
1547
+ * without side-effect (chii's Koa handler serves it).
1548
+ *
1549
+ * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade
1550
+ * listener, we take over the upgrade chain (remove chii's listeners,
1551
+ * re-dispatch manually). Invalid auth → accept-then-close: complete the
1552
+ * handshake via a `noServer` WebSocketServer, then immediately close
1553
+ * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +
1554
+ * `socket.destroy()` only ever surfaced as close code 1006 in the
1555
+ * browser — indistinguishable from a tunnel failure, which left the
1556
+ * env-2 phone UI silent. The explicit dispatch (not listener ordering)
1557
+ * is what keeps chii away from rejected sockets: accept-then-close
1558
+ * leaves the socket alive, so an order-based early-return would let
1559
+ * chii's later listener complete a SECOND handshake on the same socket
1560
+ * — an auth bypass. Valid auth → forward to chii's captured listeners.
1561
+ *
1562
+ * TOTP code transports (issue #466) — two equivalent ways to carry the code:
1563
+ * 1. Query param `at=<code>` — used by the daemon-side `/client` connection
1564
+ * (`chii-connection.ts` appends it; it holds the secret).
1565
+ * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's
1566
+ * stock `target.js` derives its WS endpoint from the script `src`
1567
+ * (`scriptEl.src.replace('target.js','')`), so the only way for the
1568
+ * phone to carry a code is to embed it in the script URL path. The
1569
+ * in-app attach injects `https://<host>/at/<code>/target.js`; both the
1570
+ * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS
1571
+ * dial then carry the prefix. The listeners below rewrite the prefix
1572
+ * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`
1573
+ * before chii's own handlers (registered later) parse it — chii only
1574
+ * ever sees the stripped URL.
1575
+ *
1576
+ * Threat model: "URL leak" — someone obtains the tunnel URL (Slack paste, QR
1577
+ * screenshot, shoulder-surfing) but does not have the shared TOTP secret.
1578
+ * Rotating 6-digit code makes the URL stale after 30 s.
1579
+ * A determined attacker who extracts the secret from the dogfood bundle can
1580
+ * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).
1581
+ *
1582
+ * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear
1583
+ * in any log, error message, or process output. `verifyAuth` is a black-box
1584
+ * predicate from the caller's perspective; this module only forwards pass/fail.
1585
+ */
1586
+ const require = createRequire(import.meta.url);
1587
+ /**
1588
+ * WS keepalive ping interval (ms).
1589
+ *
1590
+ * Cloudflare proxied connections are dropped after ~100 s of no traffic.
1591
+ * 45 s comfortably fits inside that window and lets both the phone-target leg
1592
+ * and the daemon-client leg survive idle CDP sessions.
1593
+ */
1594
+ const DEFAULT_KEEPALIVE_INTERVAL_MS = 45e3;
1595
+ /**
1596
+ * Loads chii's internal WebSocketServer class and returns it together with a
1597
+ * flag indicating whether the real class was found.
1598
+ *
1599
+ * Returns `null` if the internal path is not resolvable (future chii release
1600
+ * changes the layout) — callers skip keepalive gracefully.
1601
+ */
1602
+ function tryLoadChiiWssClass() {
1603
+ try {
1604
+ const mod = require("chii/server/lib/WebSocketServer");
1605
+ if (typeof mod === "function") return mod;
1606
+ } catch {}
1607
+ return null;
1608
+ }
1609
+ /**
1610
+ * Calls `chii.start()` and returns the chii `WebSocketServer` instance that
1611
+ * was constructed during the call.
1612
+ *
1613
+ * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`
1614
+ * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`
1615
+ * at module load time. The class reference is stable, so we can temporarily
1616
+ * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —
1617
+ * to record `this` before the original `start` runs.
1618
+ *
1619
+ * The patch is installed before `chii.start()` and removed (via `finally`)
1620
+ * immediately after, so concurrent `startChiiRelay` calls nest correctly: each
1621
+ * call's patch overrides the previous in the prototype chain for the duration
1622
+ * of its own `chii.start()` call, restoring the prior descriptor on exit.
1623
+ *
1624
+ * If `ChiiWssClass` is null (internal path changed in a future chii release),
1625
+ * `chii.start()` runs unpatched and the function returns null — callers skip
1626
+ * keepalive gracefully without affecting relay correctness.
1627
+ */
1628
+ async function startChiiWithCapture(chii, startOptions, ChiiWssClass) {
1629
+ if (ChiiWssClass === null) {
1630
+ await chii.start(startOptions);
1631
+ return null;
1632
+ }
1633
+ let captured = null;
1634
+ const proto = ChiiWssClass.prototype;
1635
+ const originalStart = proto.start;
1636
+ proto.start = function(server) {
1637
+ captured = this;
1638
+ return originalStart.call(this, server);
1639
+ };
1640
+ try {
1641
+ await chii.start(startOptions);
1642
+ } finally {
1643
+ proto.start = originalStart;
1644
+ }
1645
+ return captured;
1646
+ }
1647
+ function loadChiiServer() {
1648
+ const mod = require("chii");
1649
+ if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
1650
+ throw new Error("chii server module did not expose start()");
1651
+ }
1652
+ /**
1653
+ * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent
1654
+ * query-based form, e.g.:
1655
+ *
1656
+ * `/at/123456/target.js` → `/target.js?at=123456`
1657
+ * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`
1658
+ * `/at/123456/` → `/?at=123456`
1659
+ *
1660
+ * Returns `null` when the URL does not carry the prefix (including an empty
1661
+ * code segment) — callers fall back to the unmodified URL and the existing
1662
+ * query-based auth path.
1663
+ *
1664
+ * Pure string surgery — this function knows nothing about secrets or code
1665
+ * validity; verification stays inside the caller-provided `verifyAuth`
1666
+ * predicate (which parses the query). The raw path segment is appended
1667
+ * verbatim to the query: both path segments and query values are
1668
+ * percent-decoded exactly once by their consumers, so no re-encoding is
1669
+ * needed (TOTP codes are 6 digits and never percent-encoded in practice).
1670
+ */
1671
+ function rewriteAtPathPrefix(rawUrl) {
1672
+ const match = /^\/at\/([^/?]+)(\/[^?]*)?(\?.*)?$/.exec(rawUrl);
1673
+ if (match === null) return null;
1674
+ const code = match[1];
1675
+ const path = match[2] === void 0 || match[2] === "" ? "/" : match[2];
1676
+ const query = match[3] ?? "";
1677
+ return `${path}${query}${query === "" ? "?" : "&"}at=${code}`;
1678
+ }
1679
+ /**
1680
+ * Starts the Chii relay and resolves once listening.
1681
+ *
1682
+ * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral
1683
+ * port on every start, so a stale cloudflared orphan holding any particular
1684
+ * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`
1685
+ * always reflect the actual bound port.
1686
+ *
1687
+ * chii.start() is called with `server` (our pre-created httpServer) BEFORE
1688
+ * httpServer.listen(). This is intentional: chii attaches its Koa handler and
1689
+ * WS upgrade listener to the server object, but the actual TCP bind is
1690
+ * performed by our httpServer.listen() call below. The `port`/`domain` values
1691
+ * passed to chii.start() are used for display/banner purposes inside chii and
1692
+ * do not affect which port the server binds. The connection path (clients
1693
+ * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.
1694
+ */
1695
+ async function startChiiRelay(options = {}) {
1696
+ const requestedPort = options.port ?? 0;
1697
+ const host = options.host ?? "127.0.0.1";
1698
+ const { verifyAuth, onAuthReject } = options;
1699
+ const keepaliveIntervalMs = options.keepaliveIntervalMs !== void 0 ? options.keepaliveIntervalMs : DEFAULT_KEEPALIVE_INTERVAL_MS;
1700
+ const httpServer = createServer();
1701
+ const notifyAuthReject = (kind) => {
1702
+ if (onAuthReject === void 0) return;
1703
+ try {
1704
+ onAuthReject({ kind });
1705
+ } catch {}
1706
+ };
1707
+ if (verifyAuth) httpServer.on("request", (req, res) => {
1708
+ const rewritten = rewriteAtPathPrefix(req.url ?? "");
1709
+ if (rewritten !== null) {
1710
+ req.url = rewritten;
1711
+ if (!verifyAuth(req)) {
1712
+ res.statusCode = 401;
1713
+ res.setHeader("Access-Control-Allow-Origin", "*");
1714
+ res.setHeader("Content-Type", "application/json");
1715
+ res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));
1716
+ notifyAuthReject("http-request");
1717
+ }
1718
+ return;
1719
+ }
1720
+ const pathname = (req.url ?? "").split("?")[0];
1721
+ if (pathname === "/targets" || pathname === "/targets/") {
1722
+ if (!verifyAuth(req)) {
1723
+ res.statusCode = 401;
1724
+ res.setHeader("Access-Control-Allow-Origin", "*");
1725
+ res.setHeader("Content-Type", "application/json");
1726
+ res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));
1727
+ notifyAuthReject("http-request");
1728
+ return;
1729
+ }
1730
+ return;
1731
+ }
1732
+ });
1733
+ const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;
1734
+ const capturedChiiWss = await startChiiWithCapture(loadChiiServer(), {
1735
+ server: httpServer,
1736
+ domain: `${host}:${requestedPort}`,
1737
+ port: requestedPort
1738
+ }, chiiWssClass);
1739
+ if (verifyAuth) {
1740
+ const chiiUpgradeListeners = httpServer.listeners("upgrade");
1741
+ httpServer.removeAllListeners("upgrade");
1742
+ const rejectWss = new WebSocketServer({ noServer: true });
1743
+ httpServer.on("upgrade", (req, socket, head) => {
1744
+ const rewritten = rewriteAtPathPrefix(req.url ?? "");
1745
+ if (rewritten !== null) req.url = rewritten;
1746
+ if (!verifyAuth(req)) {
1747
+ rejectWss.handleUpgrade(req, socket, head, (ws) => {
1748
+ ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);
1749
+ });
1750
+ notifyAuthReject("ws-upgrade");
1751
+ return;
1752
+ }
1753
+ for (const listener of chiiUpgradeListeners) listener(req, socket, head);
1754
+ });
1755
+ }
1756
+ const actualPort = await new Promise((resolve, reject) => {
1757
+ httpServer.once("error", reject);
1758
+ httpServer.listen(requestedPort, host, () => {
1759
+ httpServer.off("error", reject);
1760
+ resolve(httpServer.address().port);
1761
+ });
1762
+ });
1763
+ let keepaliveHandle = null;
1764
+ if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {
1765
+ const chiiWss = capturedChiiWss;
1766
+ keepaliveHandle = setInterval(() => {
1767
+ for (const client of chiiWss._wss.clients) if (client.readyState === 1) client.ping();
1768
+ }, keepaliveIntervalMs);
1769
+ }
1770
+ return {
1771
+ port: actualPort,
1772
+ baseUrl: `http://${host}:${actualPort}`,
1773
+ close: () => new Promise((resolve) => {
1774
+ if (keepaliveHandle !== null) {
1775
+ clearInterval(keepaliveHandle);
1776
+ keepaliveHandle = null;
1777
+ }
1778
+ httpServer.close(() => resolve());
1779
+ })
1780
+ };
1781
+ }
1782
+ //#endregion
1783
+ //#region src/mcp/debug-server.ts
1784
+ /**
1785
+ * Factory that constructs a `ChiiCdpConnection` for the given relay base URL.
1786
+ *
1787
+ * Introduced as a named seam so PR-2 (dual-connection, #348) can defer
1788
+ * construction to first-activation time by moving or replacing this call. Since
1789
+ * #396 every family (relay included) is constructed lazily on its first
1790
+ * `start_debug`, so this is always called from the lazy boot path.
1791
+ *
1792
+ * The relay base URL is only available after `startChiiRelay()` resolves, so
1793
+ * the factory is called right after that point (same as before this refactor).
1794
+ */
1795
+ function createRelayConnection(relayBaseUrl) {
1796
+ return new ChiiCdpConnection({
1797
+ relayBaseUrl,
1798
+ totpSecret: process.env.AIT_DEBUG_TOTP_SECRET
1799
+ });
1800
+ }
1801
+ /**
1802
+ * Boots the relay family (issues #348, #356): starts the Chii relay on an
1803
+ * OS-assigned port (with optional TOTP gate), opens a cloudflared quick tunnel
1804
+ * to the relay's confirmed port in the background, prints the attach banner,
1805
+ * and arms the tunnel health probe. Returns a {@link BootedFamily} whose
1806
+ * `getTunnelStatus()` reflects the live tunnel (it flips up once the background
1807
+ * tunnel resolves and follows reissues).
1808
+ *
1809
+ * Booted lazily via the dual router's `bootLazyFor('relay-intoss')` callback
1810
+ * (symmetry with {@link bootLocalFamily}), at most once on the first
1811
+ * `start_debug({ mode: 'relay-staging' })` (all-lazy, #396 — every relay boot now
1812
+ * flows through `switchMode` after the project-local secret load). `relay-live`
1813
+ * removed (#665).
1814
+ *
1815
+ * The relay base URL is only known after `startChiiRelay()` resolves, so the
1816
+ * `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside
1817
+ * this function, after the relay port is confirmed.
1818
+ *
1819
+ * SECRET-HANDLING: the TOTP secret rides only inside `verifyAuth`; the wssUrl
1820
+ * (relay host) is never logged here directly.
1821
+ */
1822
+ async function bootRelayFamily(options = {}) {
1823
+ assertRelayAuthConfigured();
1824
+ const relayPort = options.relayPort ?? 0;
1825
+ const totpEnabled = options.verifyAuth !== void 0;
1826
+ const relay = await startChiiRelay({
1827
+ port: relayPort,
1828
+ verifyAuth: options.verifyAuth,
1829
+ onAuthReject: options.onAuthReject
1830
+ });
1831
+ logInfo("server.start", {
1832
+ port: relay.port,
1833
+ totpEnabled
1834
+ });
1835
+ let tunnel = null;
1836
+ let tunnelStatus = makeTunnelStatus(false, null);
1837
+ let tunnelProbe = null;
1838
+ generateAttachToken();
1839
+ startQuickTunnel(relay.port).then((t) => {
1840
+ tunnel = t;
1841
+ tunnelStatus = makeTunnelStatus(true, t.wssUrl);
1842
+ options.onWssUrl?.(t.wssUrl);
1843
+ if (t.childPid !== void 0) options.onTunnelChildPid?.(t.childPid);
1844
+ logInfo("tunnel.up", { totpEnabled });
1845
+ tunnelProbe = startTunnelHealthProbe(t, relay.port, {
1846
+ onReissue: (newTunnel) => {
1847
+ tunnel = newTunnel;
1848
+ tunnelStatus = makeTunnelStatus(true, newTunnel.wssUrl, null, 0);
1849
+ options.onWssUrl?.(newTunnel.wssUrl);
1850
+ if (newTunnel.childPid !== void 0) options.onTunnelChildPid?.(newTunnel.childPid);
1851
+ printAttachBanner({
1852
+ wssUrl: newTunnel.wssUrl,
1853
+ totpEnabled
1854
+ }).then(() => {
1855
+ logInfo("tunnel.up", {
1856
+ totpEnabled,
1857
+ reissued: true
1858
+ });
1859
+ });
1860
+ },
1861
+ onPermanentDrop: (droppedAt) => {
1862
+ tunnelStatus = makeTunnelStatus(false, null, droppedAt, 3);
1863
+ logError("tunnel.down", { msg: `tunnel permanently dropped (${droppedAt}). Restart: npx @ait-co/devtools devtools-mcp` });
1864
+ options.onTunnelDown?.();
1865
+ }
1866
+ });
1867
+ return printAttachBanner({
1868
+ wssUrl: t.wssUrl,
1869
+ totpEnabled
1870
+ });
1871
+ }, (err) => {
1872
+ 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.` });
1873
+ });
1874
+ const connection = createRelayConnection(relay.baseUrl);
1875
+ return {
1876
+ connection,
1877
+ relayOrigin: "intoss-webview",
1878
+ relayHttpUrl: relay.baseUrl,
1879
+ getTunnelStatus: () => tunnelStatus,
1880
+ stop() {
1881
+ tunnelProbe?.stop();
1882
+ tunnel?.stop();
1883
+ connection.close();
1884
+ relay.close();
1885
+ }
1886
+ };
1887
+ }
1888
+ //#endregion
1889
+ export { bootRelayFamily, buildRelayVerifyAuth };
1890
+
1891
+ //# sourceMappingURL=debug-server-krAOy6cl.js.map