@ait-co/devtools 0.1.117 → 0.1.119

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 (103) hide show
  1. package/dist/attach-orchestrator-CFotueL3.js +1749 -0
  2. package/dist/attach-orchestrator-CFotueL3.js.map +1 -0
  3. package/dist/attach-orchestrator-CII9Gl5m.js +2797 -0
  4. package/dist/attach-orchestrator-CII9Gl5m.js.map +1 -0
  5. package/dist/attach-orchestrator-C_6xb_L5.js +3 -0
  6. package/dist/attach-orchestrator-DEJfLm7V.js +1749 -0
  7. package/dist/attach-orchestrator-DEJfLm7V.js.map +1 -0
  8. package/dist/{cli-CvUNQKqw.js → attach-orchestrator-qFEMBUwb.js} +3 -805
  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-CA1TCXED.d.ts.map +1 -0
  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-BJiaGlVu.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-bLwQ4gTq.js +85 -0
  22. package/dist/cell-bLwQ4gTq.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-RhzASQef.js +1057 -0
  26. package/dist/cli-RhzASQef.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-jrI9U_f4.js → debug-server-Bi6jLuY6.js} +467 -2989
  30. package/dist/debug-server-Bi6jLuY6.js.map +1 -0
  31. package/dist/debug-server-Buc8yoa7.js +1909 -0
  32. package/dist/debug-server-Buc8yoa7.js.map +1 -0
  33. package/dist/{debug-server-BvrESnaV.js → debug-server-C_5ag9jj.js} +5 -3
  34. package/dist/debug-server-C_5ag9jj.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 +45 -27
  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/bundle-KFs4t-wc.d.ts.map +0 -1
  99. package/dist/cli-CvUNQKqw.js.map +0 -1
  100. package/dist/debug-server-BvrESnaV.js.map +0 -1
  101. package/dist/debug-server-jrI9U_f4.js.map +0 -1
  102. package/dist/relay-worker-DWW4vZxU.d.ts.map +0 -1
  103. package/dist/totp-D104cJQN.js +0 -3
@@ -1,20 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "./totp-D70zD5tJ.js";
3
- import { n as loadRelaySecretReadOnly } from "./relay-secret-store-CkA7KNUb.js";
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-CII9Gl5m.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-bLwQ4gTq.js";
5
+ import { n as loadRelaySecretReadOnly } from "./relay-secret-store-CmDDfZMh.js";
4
6
  import { createRequire } from "node:module";
5
- import { accessSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { accessSync, existsSync } from "node:fs";
6
8
  import { fileURLToPath } from "node:url";
7
9
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
11
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
10
- import { homedir, platform } from "node:os";
11
- import * as path from "node:path";
12
- import { isAbsolute, join, resolve } from "node:path";
13
- import { randomBytes } from "node:crypto";
14
- import { Tunnel, bin, install } from "cloudflared";
12
+ import { platform } from "node:os";
13
+ import * as path$1 from "node:path";
14
+ import path, { isAbsolute, resolve } from "node:path";
15
15
  import { parseArgs } from "node:util";
16
16
  import * as fs from "node:fs/promises";
17
- import { glob } from "node:fs/promises";
17
+ import { glob, mkdir, writeFile } from "node:fs/promises";
18
18
  import { EventEmitter } from "node:events";
19
19
  import { WebSocket, WebSocketServer } from "ws";
20
20
  import { createServer } from "node:http";
@@ -122,2873 +122,6 @@ function isDebugAllowedHost(hostname) {
122
122
  return isLocalhostHost(hostname) || isTrycloudflareHost(hostname) || isPrivateAppsHost(hostname);
123
123
  }
124
124
  //#endregion
125
- //#region src/shared/parent-watcher.ts
126
- /**
127
- * Shared parent-PID watcher — used by both the MCP debug daemon and the
128
- * unplugin tunnel path to self-terminate when the parent process (e.g. Claude
129
- * Code, vite) has died or been reparented without sending SIGTERM/SIGHUP.
130
- *
131
- * Intentionally react-free and Node-stdlib-only so this module is safe to
132
- * import from the MCP daemon bundle (`dist/mcp/cli.js`) without violating the
133
- * install-graph invariant.
134
- */
135
- /**
136
- * Returns `true` when the given PID refers to a running process.
137
- *
138
- * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process
139
- * exists and we have permission to signal it; throws ESRCH when it doesn't exist.
140
- */
141
- function isPidAlive$1(pid) {
142
- try {
143
- process.kill(pid, 0);
144
- return true;
145
- } catch (err) {
146
- if (err.code === "EPERM") return true;
147
- return false;
148
- }
149
- }
150
- /**
151
- * Starts a periodic watcher that detects when the parent process (e.g. Claude
152
- * Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the
153
- * daemon can self-terminate rather than running as a zombie.
154
- *
155
- * Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns
156
- * `{ stop(): void }`, injectable deps for testability.
157
- *
158
- * @param onOrphaned - Called once when the parent is gone.
159
- * @param opts.intervalMs - Poll interval in milliseconds (default 5 000).
160
- * @param opts.initialPpid - Parent PID to watch (default `process.ppid`).
161
- * @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).
162
- * @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).
163
- * Detects ppid changes as well as death.
164
- * @param opts.log - Logger (default `process.stderr.write`).
165
- *
166
- * @returns `stop` — call during shutdown to clear the interval.
167
- */
168
- function startParentWatcher(onOrphaned, opts) {
169
- const { intervalMs = 5e3, initialPpid = process.ppid, isAlive = isPidAlive$1, getPpid = () => process.ppid, log = (msg) => process.stderr.write(msg) } = opts ?? {};
170
- if (initialPpid <= 1) {
171
- log("[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\n");
172
- return { stop() {} };
173
- }
174
- let fired = false;
175
- const handle = setInterval(() => {
176
- if (fired) return;
177
- const currentPpid = getPpid();
178
- if (currentPpid !== initialPpid || !isAlive(initialPpid)) {
179
- fired = true;
180
- clearInterval(handle);
181
- log(`[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\n`);
182
- onOrphaned();
183
- }
184
- }, intervalMs);
185
- return { stop() {
186
- clearInterval(handle);
187
- } };
188
- }
189
- /**
190
- * Starts a periodic watchdog that calls `onExpired` once after `maxAgeMs`
191
- * milliseconds have elapsed since the watchdog was created.
192
- *
193
- * Motivation (issue #571): cloudflared quick-tunnel lifetimes are finite (a
194
- * few hours). A daemon that has been running for days will have outlived its
195
- * tunnel regardless of whether the tunnel process exited cleanly. This watchdog
196
- * caps the daemon's maximum age and forces a fresh start so the tunnel is
197
- * replaced before it silently expires.
198
- *
199
- * @param onExpired - Called once when the maximum age is reached. The caller
200
- * should call `shutdown()` then `process.exit(0)`.
201
- * @param opts.maxAgeMs - Maximum daemon lifetime in ms. Default 6 h.
202
- * @param opts.intervalMs - Check interval in ms. Default 60 000 (1 min).
203
- * @param opts.now - Time source (injectable for tests). Default `Date.now`.
204
- *
205
- * @returns `stop` — call during shutdown to clear the interval.
206
- */
207
- function startMaxAgeWatchdog(onExpired, opts = {}) {
208
- const { maxAgeMs = 360 * 60 * 1e3, intervalMs = 6e4, now = () => Date.now() } = opts;
209
- const startedAt = now();
210
- let fired = false;
211
- const handle = setInterval(() => {
212
- if (fired) return;
213
- if (now() - startedAt >= maxAgeMs) {
214
- fired = true;
215
- clearInterval(handle);
216
- onExpired();
217
- }
218
- }, intervalMs);
219
- return { stop() {
220
- clearInterval(handle);
221
- } };
222
- }
223
- //#endregion
224
- //#region src/mcp/deeplink.ts
225
- /**
226
- * URL of the AITC Sandbox launcher PWA.
227
- *
228
- * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the
229
- * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy
230
- * for the same reason — keep the two in sync when the URL changes.
231
- */
232
- const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
233
- /**
234
- * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).
235
- *
236
- * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport
237
- * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the
238
- * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js
239
- * is injected. `&at=<totpCode>` is added only when a code is provided (same
240
- * conditional as {@link buildDeepLinkAttachUrl}).
241
- *
242
- * When `opts.name` is given (non-blank), it is added as `&name=` so the
243
- * launcher partner bar shows the app name instead of the generic default (#498).
244
- * When `opts.icon` is an absolute https:// URL, it is added as `&icon=` so the
245
- * launcher can render an icon next to the title (#498).
246
- *
247
- * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL
248
- * via raw string manipulation), this function uses WHATWG `encodeURIComponent`
249
- * because the target is a standard `https:` URL.
250
- *
251
- * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param
252
- * only — never logged or returned separately. Callers must NOT log the result
253
- * of this function to stdout/stderr.
254
- *
255
- * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL
256
- * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.
257
- * @param wssUrl - The `wss://` relay URL the framed page will attach to.
258
- * @param totpCode - Optional current TOTP code (6 digits). When provided, it
259
- * is appended as `at=<totpCode>`. Must be computed at call time — it rotates
260
- * every 30 s. Omit when TOTP is disabled.
261
- * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`
262
- * (#498, #543).
263
- * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>
264
- * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.
265
- */
266
- function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
267
- let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}&debug=1&relay=${encodeURIComponent(wssUrl)}`;
268
- if (totpCode !== void 0 && totpCode !== "") url += `&at=${encodeURIComponent(totpCode)}`;
269
- if (opts?.name !== void 0 && opts.name.trim() !== "") url += `&name=${encodeURIComponent(opts.name.trim())}`;
270
- if (opts?.icon !== void 0) {
271
- let iconParsed;
272
- try {
273
- iconParsed = new URL(opts.icon);
274
- } catch {
275
- iconParsed = null;
276
- }
277
- if (iconParsed?.protocol === "https:") url += `&icon=${encodeURIComponent(opts.icon)}`;
278
- }
279
- if (opts?.selfdebug === true) url += "&selfdebug=1";
280
- return url;
281
- }
282
- /**
283
- * Build a self-attaching dog-food deep-link.
284
- *
285
- * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`
286
- * URL that opens a dog-food bundle on a phone. The in-app debug gate
287
- * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries
288
- * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus
289
- * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result
290
- * as a QR code and scanning it with the phone camera opens the mini-app and
291
- * attaches it to the live Chii relay. QR is the single entry path — it needs
292
- * no USB cable, platform CLI, or driver, and works the same on iOS/Android.
293
- *
294
- * The Toss app propagates extra query params from the entry deep link into the
295
- * mini-app WebView's `location.search` (confirmed behavior), so the gate reads
296
- * them at attach time.
297
- *
298
- * TOTP `at=` param:
299
- * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional
300
- * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.
301
- * The code must be computed by the caller at call time — do NOT pre-compute
302
- * and cache it, because the 30-second window expires quickly. The in-app gate
303
- * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.
304
- *
305
- * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.
306
- * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query
307
- * decomposition you can rely on across runtimes), so query manipulation via
308
- * `url.searchParams` is not portable here. We splice the query string directly
309
- * on the raw string instead, which keeps the scheme, authority, path, and any
310
- * pre-existing params (notably `_deploymentId`) byte-for-byte intact.
311
- */
312
- /**
313
- * Suspicious/generic authority values that indicate a malformed or placeholder
314
- * scheme URL. These are host strings that will almost certainly cause the Toss
315
- * app to fail with "bundle not found" silently.
316
- *
317
- * The expected form from `ait deploy --scheme-only` is:
318
- * intoss-private://<appName>?_deploymentId=<uuid>
319
- * where `<appName>` is a non-generic string like `aitc-sdk-example`.
320
- */
321
- const SUSPICIOUS_AUTHORITIES = new Set([
322
- "",
323
- "web",
324
- "localhost",
325
- "127.0.0.1",
326
- "app"
327
- ]);
328
- /**
329
- * Validates the authority (host) portion of a scheme URL.
330
- *
331
- * Returns a warning message if the authority is missing or looks like a
332
- * placeholder, or `null` if the authority looks valid.
333
- *
334
- * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`
335
- * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).
336
- */
337
- function validateSchemeAuthority(schemeUrl) {
338
- const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//, "");
339
- if (afterScheme === schemeUrl) return "scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). Use the URL printed by `ait deploy --scheme-only`.";
340
- const authorityEnd = afterScheme.search(/[/?#]/);
341
- const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);
342
- if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) return `scheme_url authority ${authority === "" ? "(empty)" : `"${authority}"`} looks like a placeholder. Expected an app name like \`intoss-private://aitc-sdk-example?_deploymentId=<uuid>\`. Use the URL printed by \`ait deploy --scheme-only\` — it includes the correct app name as the host.`;
343
- return null;
344
- }
345
- function stripExisting(query, key) {
346
- if (query === "") return "";
347
- return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
348
- }
349
- /**
350
- * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a
351
- * scheme URL's query string, preserving everything else (scheme, authority,
352
- * path, hash, and the existing `_deploymentId` param). If any of the spliced
353
- * params is already present it is replaced so the helper is idempotent.
354
- *
355
- * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed
356
- * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B
357
- * of the gate); this helper does not invent one.
358
- * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the
359
- * running debug MCP server's quick tunnel.
360
- * @param totpCode - Optional current TOTP code (6 digits). When provided, it
361
- * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates
362
- * every 30 s. Pass `undefined` or omit when TOTP is disabled.
363
- * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`
364
- * appended.
365
- * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so
366
- * producing such a link would be a silent dead end).
367
- */
368
- function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
369
- let relay;
370
- try {
371
- relay = new URL(wssUrl);
372
- } catch {
373
- throw new Error(`relay URL is not a valid URL: ${wssUrl}`);
374
- }
375
- if (relay.protocol !== "wss:") throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);
376
- const hashIndex = schemeUrl.indexOf("#");
377
- const hash = hashIndex === -1 ? "" : schemeUrl.slice(hashIndex);
378
- const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);
379
- const queryIndex = beforeHash.indexOf("?");
380
- const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
381
- let query = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1);
382
- const appended = [["debug", "1"], ["relay", wssUrl]];
383
- if (totpCode !== void 0 && totpCode !== "") appended.push(["at", totpCode]);
384
- query = stripExisting(query, "at");
385
- for (const [key] of appended) query = stripExisting(query, key);
386
- for (const [key, value] of appended) {
387
- const pair = `${key}=${encodeURIComponent(value)}`;
388
- query = query === "" ? pair : `${query}&${pair}`;
389
- }
390
- return `${base}?${query}${hash}`;
391
- }
392
- //#endregion
393
- //#region src/mcp/errors.ts
394
- /**
395
- * 한국어 한 줄 "원인 + 다음 행동" 포맷으로 에러 결과를 빌드한다.
396
- *
397
- * @param message - 사용자에게 보여줄 에러 본문 (원인 + 다음 행동 포함).
398
- */
399
- function mcpError(message) {
400
- return {
401
- content: [{
402
- type: "text",
403
- text: message
404
- }],
405
- isError: true
406
- };
407
- }
408
- /**
409
- * Tier A/B 환경 불일치 거부 메시지.
410
- *
411
- * @param toolName - 거부된 tool 이름.
412
- * @param requiredEnv - 해당 tool이 요구하는 환경 ('mock' | 'relay').
413
- * @param currentEnv - 현재 세션 환경.
414
- * @param reason - 환경이 결정된 근거를 나타내는 파생 문자열
415
- * (예: `derived:kind=relay,liveIntent=true`).
416
- */
417
- function tierRejectionError(toolName, requiredEnv, currentEnv, reason) {
418
- return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "relay로 전환하려면 MCP_ENV=relay 설정 후 서버를 재시작하고 start_attach → QR 스캔으로 실기기를 attach하세요." : "mock으로 전환하려면 MCP_ENV=mock 설정 후 서버를 재시작하세요."}`}\n\n${`tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`}`);
419
- }
420
- /**
421
- * 상태 1: tunnel 미가동 — cloudflared 터널이 아직 뜨지 않았다.
422
- *
423
- * `start_attach` 호출 시 tunnel.up === false 인 경우.
424
- */
425
- function tunnelDownError() {
426
- return mcpError("cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
427
- }
428
- /**
429
- * 상태 2: page 미attach — 터널은 살아 있으나 아직 페이지가 연결되지 않았다.
430
- *
431
- * enableDomains()가 "No mini-app page attached" 에러를 던질 때.
432
- */
433
- function pageMissingError(toolName) {
434
- return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. dog-food 번들 배포 후 start_attach를 호출해 QR 생성 + attach까지 진행하세요: \`ait deploy --scheme-only\` → \`start_attach(scheme_url)\` → QR 스캔.`);
435
- }
436
- /**
437
- * 상태 3: page crash — 연결됐던 페이지가 crash/destroy됐다.
438
- *
439
- * chii-connection 이 'replaced-by-new-attach' / 'targetCrashed' / 'targetDestroyed' 를
440
- * 던질 때 이 메시지를 사용한다.
441
- */
442
- function pageCrashError(toolName) {
443
- return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 start_attach → QR 스캔으로 재attach하세요.`);
444
- }
445
- /**
446
- * 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다.
447
- *
448
- * call_sdk 호출 시 브리지가 없을 때. 같은 "브리지 부재"라도 다음 행동은
449
- * connection 종류에 따라 정반대다 (issue #360):
450
- * - relay(`--target` 없는 intoss / env-2): dog-food 빌드가 아니다 → dog-food
451
- * 채널로 재배포 후 QR 재스캔.
452
- * - local(`--target=local`, env 1 로컬 브라우저): 재배포가 아니라 dev 서버를
453
- * `pnpm dev`로 띄웠는지 + unplugin alias가 `@apps-in-toss/web-framework`를
454
- * devtools mock으로 resolve하는지 확인. dev 빌드면 `import.meta.env.DEV`
455
- * 경로로 `window.__sdkCall`이 자동 설치된다.
456
- *
457
- * `isLocal`이 생략되면 relay 안내(이전 동작)를 유지한다.
458
- */
459
- function sdkAbsentError(toolName, isLocal = false) {
460
- const prefix = toolName ? `${toolName}: ` : "";
461
- if (isLocal) return mcpError(`${prefix}window.__sdkCall이 주입되지 않았습니다 (로컬 dev 브리지 부재). sdk-example을 \`pnpm dev\`로 띄웠는지, 그리고 unplugin alias가 \`@apps-in-toss/web-framework\`를 devtools mock으로 resolve하는지 확인하세요. dev 빌드(\`import.meta.env.DEV\`)면 \`window.__sdkCall\`이 자동 설치됩니다.`);
462
- return mcpError(`${prefix}window.__sdkCall이 주입되지 않았습니다 (dog-food 빌드가 아닙니다). dog-food 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
463
- }
464
- /**
465
- * relay WebSocket 연결이 끊겼을 때 — 크래시가 아닌 네트워크/프로세스 종료.
466
- */
467
- function relayDisconnectError(toolName) {
468
- return mcpError(`${toolName ? `${toolName}: ` : ""}relay 연결이 끊겼습니다. list_pages로 상태를 확인하고, 필요하면 앱을 재실행 후 재attach하세요.`);
469
- }
470
- /**
471
- * CDP/AIT 명령 중 발생한 예외를 4상태로 분류해 적절한 에러 결과를 반환한다.
472
- *
473
- * - SDK 부재 패턴 (`window.__sdkCall is not available`) → sdkAbsentError
474
- * - crash 패턴 (`replaced-by-new-attach`, `targetCrashed`, `targetDestroyed`) → pageCrashError
475
- * - 연결 끊김 패턴 (`relay에 연결되어 있지 않습니다`, `relay WebSocket`) → relayDisconnectError
476
- * - 그 외 (일반 에러) → 원본 메시지를 포함한 mcpError
477
- */
478
- function classifyToolError(err, toolName, isLocal = false) {
479
- const message = err instanceof Error ? err.message : String(err);
480
- if (message.startsWith("tunnel-down:") || message.includes("터널이 안 떠 있습니다")) return tunnelDownError();
481
- if (message.startsWith("sdk-absent:") || message.includes("__sdkCall이 주입되지 않았습니다") || message.includes("window.__sdkCall is not available") || message.includes("__sdkCall") && message.includes("not available")) return sdkAbsentError(toolName, isLocal);
482
- if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
483
- if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket")) return relayDisconnectError(toolName);
484
- return mcpError(`${toolName} 실패: ${message}\nlist_pages로 미니앱이 relay에 attach됐는지 확인하세요.`);
485
- }
486
- //#endregion
487
- //#region src/mcp/log.ts
488
- /**
489
- * Allowed field keys that may pass through to a log line.
490
- * Unknown keys are dropped. Values are still redact-scanned.
491
- */
492
- const ALLOWED_KEYS = new Set([
493
- "ts",
494
- "level",
495
- "event",
496
- "msg",
497
- "port",
498
- "totpEnabled",
499
- "env",
500
- "tool",
501
- "deploymentId",
502
- "errorKind",
503
- "reason",
504
- "prevTargetId",
505
- "mode",
506
- "fileCount",
507
- "passed",
508
- "failed",
509
- "skipped"
510
- ]);
511
- /**
512
- * Patterns that match secret values.
513
- * Match order matters — more-specific patterns first.
514
- *
515
- * #268 redact script covers: relay=wss://…, at=<TOTP>, _deploymentId=<uuid>.
516
- * Here we extend to in-process value-level patterns used in server logs.
517
- */
518
- const SECRET_PATTERNS = [
519
- /^\d{6}$/,
520
- /^(aitcc_|AITCC_)/i,
521
- /^[A-Za-z0-9_-]+=.{4,}/,
522
- /^wss:\/\//,
523
- /(?:^|[?&])at=[A-Z0-9]{6}/i
524
- ];
525
- /**
526
- * Returns `true` when the string value matches any known-secret pattern.
527
- * Only string values are tested — numbers/booleans are always safe.
528
- */
529
- function isSecretValue(value) {
530
- return SECRET_PATTERNS.some((re) => re.test(value));
531
- }
532
- /**
533
- * Redacts a single scalar value.
534
- * - strings: return "***" if the value matches a secret pattern.
535
- * - other: return as-is.
536
- */
537
- function redactValue(value) {
538
- if (typeof value === "string" && isSecretValue(value)) return "***";
539
- return value;
540
- }
541
- /**
542
- * Builds a safe log payload from raw fields.
543
- *
544
- * - Only keys in `ALLOWED_KEYS` are included.
545
- * - String values are scanned for secret patterns and replaced with "***".
546
- * - `ts` and `level` and `event` are always included (they are injected by the
547
- * logger functions below, not by callers).
548
- */
549
- function buildPayload(level, event, fields) {
550
- const out = {
551
- ts: (/* @__PURE__ */ new Date()).toISOString(),
552
- level,
553
- event
554
- };
555
- for (const [key, value] of Object.entries(fields)) {
556
- if (!ALLOWED_KEYS.has(key)) continue;
557
- if (key === "ts" || key === "level" || key === "event") continue;
558
- out[key] = redactValue(value);
559
- }
560
- return out;
561
- }
562
- /**
563
- * Writes a single JSON log line to stderr.
564
- * MCP stdio transport uses stdout; all diagnostics go to stderr.
565
- */
566
- function writeLog(level, event, fields = {}) {
567
- const payload = buildPayload(level, event, fields);
568
- process.stderr.write(`${JSON.stringify(payload)}\n`);
569
- }
570
- /** Log an informational structured event. */
571
- function logInfo(event, fields = {}) {
572
- writeLog("info", event, fields);
573
- }
574
- /** Log a warning structured event. */
575
- function logWarn(event, fields = {}) {
576
- writeLog("warn", event, fields);
577
- }
578
- /** Log an error structured event. */
579
- function logError(event, fields = {}) {
580
- writeLog("error", event, fields);
581
- }
582
- //#endregion
583
- //#region src/mcp/environment.ts
584
- /**
585
- * Returns `true` when the environment is any relay variant (`relay-dev` or
586
- * `relay-mobile`). Use this instead of `env === 'relay'` for tier checks —
587
- * every relay env surfaces the Tier B / relay-only tool set.
588
- *
589
- * Written as an exhaustive switch so a future `McpEnvironment` member that is
590
- * missing an arm is a TS compile error rather than a silent `false`.
591
- */
592
- function isRelayEnv(env) {
593
- switch (env) {
594
- case "relay-dev":
595
- case "relay-mobile": return true;
596
- case "mock": return false;
597
- }
598
- }
599
- /**
600
- * Maps the `McpEnvironment` union to the legacy two-value union
601
- * (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
602
- * Every relay variant (`relay-dev`, `relay-mobile`) collapses to `'relay'`.
603
- * Written as an exhaustive switch so a missing arm is a TS compile error.
604
- */
605
- function toLegacyEnv(env) {
606
- switch (env) {
607
- case "mock": return "mock";
608
- case "relay-dev":
609
- case "relay-mobile": return "relay";
610
- }
611
- }
612
- /**
613
- * Reconstructs the three-value `McpEnvironment` output string from the
614
- * orthogonal signals (issues #348, #378, #665):
615
- *
616
- * - `kind === 'local'` → `'mock'`
617
- * - `kind === 'relay'` && origin 'external-pwa' → `'relay-mobile'`
618
- * - `kind === 'relay'` && origin intoss/undefined → `'relay-dev'`
619
- *
620
- * `relayOrigin` is the booted-family discriminator (NOT sniffed from the URL)
621
- * that distinguishes the env-2 external-PWA relay (`relay-mobile`) from the
622
- * intoss-private dog-food relay (`relay-dev`); both are `kind: 'relay'`.
623
- *
624
- * `relay-live` (env 4) has been removed (#665). `liveIntent` parameter is gone.
625
- *
626
- * Pure — used at every output boundary (envelope `meta.env`, `get_debug_status`,
627
- * `measure_safe_area` provenance) so the surface never sniffs a URL again.
628
- *
629
- * Written switch-style so a missing arm is a TS compile error (never falls
630
- * through to a default).
631
- */
632
- function deriveEnvironment(kind, relayOrigin) {
633
- switch (kind) {
634
- case "local": return "mock";
635
- case "relay": return relayOrigin === "external-pwa" ? "relay-mobile" : "relay-dev";
636
- }
637
- }
638
- //#endregion
639
- //#region src/mcp/sdk-signatures.ts
640
- function isObject$3(v) {
641
- return typeof v === "object" && v !== null && !Array.isArray(v);
642
- }
643
- function describeArgs(args) {
644
- try {
645
- return JSON.stringify(args);
646
- } catch {
647
- return String(args);
648
- }
649
- }
650
- /**
651
- * 등록된 메서드 목록.
652
- *
653
- * 시그니처 출처 확인:
654
- * - 함수가 인자를 받지 않으면 args[0] 없음 → `args.length === 0`을 체크하지 않고
655
- * 그냥 통과시킨다(args 무시하는 stub가 많아서 noArgs 체크가 noise).
656
- * - 실 SDK 시그니처는 `src/__typecheck.ts`의 `Assert<Mock, Original>` 줄로 보장.
657
- */
658
- const SIGNATURES = [
659
- {
660
- name: "setDeviceOrientation",
661
- validateArgs(args) {
662
- const arg = args[0];
663
- if (!isObject$3(arg)) return {
664
- ok: false,
665
- expected: "{ type: 'portrait' | 'landscape' }",
666
- received: describeArgs(args)
667
- };
668
- const type = arg.type;
669
- if (type !== "portrait" && type !== "landscape") return {
670
- ok: false,
671
- expected: "{ type: 'portrait' | 'landscape' }",
672
- received: describeArgs(args)
673
- };
674
- return { ok: true };
675
- },
676
- example: "call_sdk('setDeviceOrientation', [{ type: 'landscape' }])"
677
- },
678
- {
679
- name: "setIosSwipeGestureEnabled",
680
- validateArgs(args) {
681
- const arg = args[0];
682
- if (!isObject$3(arg) || typeof arg.isEnabled !== "boolean") return {
683
- ok: false,
684
- expected: "{ isEnabled: boolean }",
685
- received: describeArgs(args)
686
- };
687
- return { ok: true };
688
- },
689
- example: "call_sdk('setIosSwipeGestureEnabled', [{ isEnabled: false }])"
690
- },
691
- {
692
- name: "setSecureScreen",
693
- validateArgs(args) {
694
- const arg = args[0];
695
- if (!isObject$3(arg) || typeof arg.enabled !== "boolean") return {
696
- ok: false,
697
- expected: "{ enabled: boolean }",
698
- received: describeArgs(args)
699
- };
700
- return { ok: true };
701
- },
702
- example: "call_sdk('setSecureScreen', [{ enabled: true }])"
703
- },
704
- {
705
- name: "setScreenAwakeMode",
706
- validateArgs(args) {
707
- const arg = args[0];
708
- if (!isObject$3(arg) || typeof arg.enabled !== "boolean") return {
709
- ok: false,
710
- expected: "{ enabled: boolean }",
711
- received: describeArgs(args)
712
- };
713
- return { ok: true };
714
- },
715
- example: "call_sdk('setScreenAwakeMode', [{ enabled: true }])"
716
- },
717
- {
718
- name: "getOperationalEnvironment",
719
- validateArgs(_args) {
720
- return { ok: true };
721
- },
722
- example: "call_sdk('getOperationalEnvironment', [])"
723
- },
724
- {
725
- name: "getPlatformOS",
726
- validateArgs(_args) {
727
- return { ok: true };
728
- },
729
- example: "call_sdk('getPlatformOS', [])"
730
- },
731
- {
732
- name: "getDeviceId",
733
- validateArgs(_args) {
734
- return { ok: true };
735
- },
736
- example: "call_sdk('getDeviceId', [])"
737
- },
738
- {
739
- name: "getLocale",
740
- validateArgs(_args) {
741
- return { ok: true };
742
- },
743
- example: "call_sdk('getLocale', [])"
744
- },
745
- {
746
- name: "getNetworkStatus",
747
- validateArgs(_args) {
748
- return { ok: true };
749
- },
750
- example: "call_sdk('getNetworkStatus', [])"
751
- },
752
- {
753
- name: "getSchemeUri",
754
- validateArgs(_args) {
755
- return { ok: true };
756
- },
757
- example: "call_sdk('getSchemeUri', [])"
758
- },
759
- {
760
- name: "requestReview",
761
- validateArgs(_args) {
762
- return { ok: true };
763
- },
764
- example: "call_sdk('requestReview', [])"
765
- },
766
- {
767
- name: "closeView",
768
- validateArgs(_args) {
769
- return { ok: true };
770
- },
771
- example: "call_sdk('closeView', [])"
772
- }
773
- ];
774
- const SIGNATURE_MAP = new Map(SIGNATURES.map((s) => [s.name, s]));
775
- /** 세션 내 passthrough 경고를 한 번만 emit하기 위한 Set */
776
- const _warnedPassthrough = /* @__PURE__ */ new Set();
777
- /**
778
- * 메서드 이름으로 시그니처를 조회한다.
779
- * 등록된 메서드이면 `SdkSignature`를 반환하고, 미등록이면 `undefined`.
780
- */
781
- function lookupSignature(name) {
782
- return SIGNATURE_MAP.get(name);
783
- }
784
- /**
785
- * 미등록 메서드에 대해 stderr에 passthrough 경고를 1회 출력한다.
786
- * 세션 내 동일 메서드 이름은 최초 1회만 출력.
787
- */
788
- function warnPassthrough(name) {
789
- if (_warnedPassthrough.has(name)) return;
790
- _warnedPassthrough.add(name);
791
- process.stderr.write(`[ait-debug] call_sdk: "${name}" 시그니처가 등록되지 않음 — passthrough\n`);
792
- }
793
- SIGNATURES.map((s) => s.name);
794
- //#endregion
795
- //#region src/mcp/server-lock.ts
796
- /**
797
- * Single debug session lock for the `devtools-mcp` debug server.
798
- *
799
- * At most one debug server process should run on a given machine at a time —
800
- * multiple concurrent instances create duplicate cloudflared tunnels, waste
801
- * resources, and confuse the user about which wssUrl to use.
802
- *
803
- * ## Lock file
804
- *
805
- * Location: `~/.ait-devtools/server.lock`
806
- *
807
- * Schema (JSON):
808
- * ```json
809
- * { "pid": 12345, "wssUrl": "wss://xxx.trycloudflare.com", "startedAt": "2026-01-01T00:00:00.000Z" }
810
- * ```
811
- *
812
- * ## Behaviour
813
- *
814
- * - **Acquire**: write PID + wssUrl + startedAt. Returns a `release()` handle.
815
- * - **Stale lock recovery**: if the stored PID is no longer alive
816
- * (`process.kill(pid, 0)` throws ESRCH), the lock is silently replaced.
817
- * - **Live conflict (option B)**: if the stored PID is alive, `acquireLock`
818
- * throws `ServerLockConflictError` with the existing PID and wssUrl so the
819
- * caller can surface a clear message to the agent.
820
- * - **Release**: remove the lock file. Called on graceful shutdown (SIGINT /
821
- * SIGTERM / SIGHUP). SIGKILL survivors leave a stale file — the next startup
822
- * recovers it automatically via the alive check.
823
- *
824
- * ## wssUrl update
825
- *
826
- * The lock is written before cloudflared starts, so `wssUrl` begins as `null`
827
- * and is updated in place once the tunnel URL is known via `updateWssUrl`.
828
- *
829
- * Node-only.
830
- */
831
- /** Thrown when a live server process already holds the lock. */
832
- var ServerLockConflictError = class extends Error {
833
- /** PID of the existing server process. */
834
- existingPid;
835
- /** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */
836
- existingWssUrl;
837
- /** ISO timestamp from the existing lock — when that session started. */
838
- existingStartedAt;
839
- constructor(existingPid, existingWssUrl, existingStartedAt) {
840
- const urlNote = existingWssUrl != null ? ` relay URL: ${existingWssUrl}\n` : " relay URL: (tunnel still starting — retry in a moment)\n";
841
- super(`A debug server is already running (PID ${existingPid}).\n` + urlNote + `Stop the existing session before starting a new one.
842
- If it is already stopped but this error persists, remove the lock file:
843
- rm "${lockFilePath()}"`);
844
- this.name = "ServerLockConflictError";
845
- this.existingPid = existingPid;
846
- this.existingWssUrl = existingWssUrl;
847
- this.existingStartedAt = existingStartedAt;
848
- }
849
- };
850
- /** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */
851
- function lockFilePath() {
852
- return join(process.env.AIT_DEVTOOLS_LOCK_DIR ?? join(homedir(), ".ait-devtools"), "server.lock");
853
- }
854
- function ensureLockDir(lockPath) {
855
- mkdirSync(join(lockPath, ".."), { recursive: true });
856
- }
857
- /**
858
- * Returns `true` when the given PID refers to a running process.
859
- *
860
- * Re-exported from `../shared/parent-watcher` so external callers that
861
- * import from `./server-lock` keep working without an import-path change.
862
- */
863
- const isPidAlive = isPidAlive$1;
864
- function readLock(lockPath) {
865
- if (!existsSync(lockPath)) return null;
866
- try {
867
- const raw = readFileSync(lockPath, "utf8");
868
- const parsed = JSON.parse(raw);
869
- if (typeof parsed === "object" && parsed !== null && "pid" in parsed && typeof parsed.pid === "number" && "startedAt" in parsed && typeof parsed.startedAt === "string") {
870
- const p = parsed;
871
- const tunnelChildPid = typeof p.tunnelChildPid === "number" ? p.tunnelChildPid : null;
872
- return {
873
- pid: p.pid,
874
- wssUrl: typeof p.wssUrl === "string" ? p.wssUrl : null,
875
- startedAt: p.startedAt,
876
- tunnelChildPid
877
- };
878
- }
879
- return null;
880
- } catch {
881
- return null;
882
- }
883
- }
884
- function writeLock(lockPath, data) {
885
- ensureLockDir(lockPath);
886
- writeFileSync(lockPath, JSON.stringify(data, null, 2), { encoding: "utf8" });
887
- }
888
- function removeLock(lockPath) {
889
- try {
890
- rmSync(lockPath);
891
- } catch {}
892
- }
893
- /**
894
- * Sends SIGTERM to `pid` and waits up to `graceMs` (default 2 000 ms) for it
895
- * to exit; then falls back to SIGKILL. Synchronous — uses a busy-wait loop so
896
- * it is usable in the top-level startup path without async plumbing.
897
- *
898
- * Ignores errors from `process.kill` so that a race where the target exits
899
- * between the alive check and the kill call does not crash the caller.
900
- */
901
- function killAndWait(pid, graceMs = 2e3) {
902
- try {
903
- process.kill(pid, "SIGTERM");
904
- } catch {
905
- return;
906
- }
907
- const deadline = Date.now() + graceMs;
908
- while (isPidAlive(pid) && Date.now() < deadline) {
909
- const end = Date.now() + 100;
910
- while (Date.now() < end);
911
- }
912
- if (isPidAlive(pid)) try {
913
- process.kill(pid, "SIGKILL");
914
- } catch {}
915
- }
916
- /**
917
- * Reaps an orphaned cloudflared tunnel child left behind by a previous session
918
- * (issue #628). The normal shutdown path TERM-cascades to the child, but a
919
- * SIGKILL'd or crashed Node process can't run cleanup — its cloudflared child
920
- * keeps the (now stale) quick tunnel alive. When `acquireLock` reclaims such a
921
- * lock, this kills the still-alive `tunnelChildPid` so the new session starts
922
- * from a clean slate (companion to the #347/#571 zombie-daemon defenses).
923
- *
924
- * No-op when the lock carries no `tunnelChildPid` (older lock files) or the
925
- * child is already gone. SECRET-HANDLING: logs the PID only — never the tunnel
926
- * host/wss (those never enter the lock file or this path).
927
- */
928
- function reapOrphanTunnelChild(existing) {
929
- const childPid = existing.tunnelChildPid;
930
- if (typeof childPid !== "number" || !isPidAlive(childPid)) return;
931
- process.stderr.write(`[ait-debug] reaping orphaned tunnel child PID=${childPid} from previous session.\n`);
932
- killAndWait(childPid);
933
- }
934
- /**
935
- * Reads the current lock file without acquiring it. Returns the parsed
936
- * `LockData` when the file exists and is valid, otherwise `null`. Used by
937
- * `get_debug_status` to surface the `serverLockHolder` field without
938
- * interfering with the running lock owner.
939
- */
940
- function readServerLock() {
941
- return readLock(lockFilePath());
942
- }
943
- /**
944
- * Attempts to acquire the server lock.
945
- *
946
- * - If no lock exists (or the lock is stale): writes a new lock and returns a
947
- * `LockHandle` with `updateWssUrl` + `release`.
948
- * - If a live process holds the lock and `force` is `false` (default): writes
949
- * a clear recovery message to stderr and throws `ServerLockConflictError`.
950
- * - If a live process holds the lock and `force` is `true`: sends SIGTERM to
951
- * that process (waiting up to 2 s then SIGKILL) and takes over the lock.
952
- *
953
- * The initial `wssUrl` in the lock file is `null` — call
954
- * `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.
955
- */
956
- function acquireLock(options = {}) {
957
- const { force = false } = options;
958
- const lockPath = lockFilePath();
959
- const existing = readLock(lockPath);
960
- if (existing !== null) if (isPidAlive(existing.pid)) {
961
- const tunnelChildPid = existing.tunnelChildPid;
962
- if (typeof tunnelChildPid === "number" && !isPidAlive(tunnelChildPid)) process.stderr.write(`[ait-debug] stale lock: holder PID=${existing.pid} alive but tunnel child PID=${tunnelChildPid} is dead — reclaiming lock.\n`);
963
- else if (force) {
964
- process.stderr.write(`[ait-debug] --force: terminating existing session PID=${existing.pid} …\n`);
965
- killAndWait(existing.pid);
966
- reapOrphanTunnelChild(existing);
967
- process.stderr.write(`[ait-debug] --force: PID=${existing.pid} stopped, taking over.\n`);
968
- } else {
969
- const urlPart = existing.wssUrl != null ? `wssUrl=${existing.wssUrl}` : "wssUrl=(tunnel starting)";
970
- process.stderr.write(`[ait-debug] 기존 debug-mode 세션이 이미 실행 중 — PID=${existing.pid}, started ${existing.startedAt}, ${urlPart}\n[ait-debug] 회복: \`kill ${existing.pid}\` 또는 \`npx @ait-co/devtools devtools-mcp --force\`\n`);
971
- throw new ServerLockConflictError(existing.pid, existing.wssUrl, existing.startedAt);
972
- }
973
- } else {
974
- process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
975
- reapOrphanTunnelChild(existing);
976
- }
977
- const data = {
978
- pid: process.pid,
979
- wssUrl: null,
980
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
981
- };
982
- writeLock(lockPath, data);
983
- let released = false;
984
- return {
985
- updateWssUrl(wssUrl) {
986
- if (released) return;
987
- data.wssUrl = wssUrl;
988
- writeLock(lockPath, data);
989
- },
990
- updateTunnelChildPid(pid) {
991
- if (released) return;
992
- data.tunnelChildPid = pid;
993
- writeLock(lockPath, data);
994
- },
995
- release() {
996
- if (released) return;
997
- released = true;
998
- removeLock(lockPath);
999
- }
1000
- };
1001
- }
1002
- //#endregion
1003
- //#region src/mcp/tools.ts
1004
- /** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
1005
- const DEBUG_TOOL_DEFINITIONS = [
1006
- {
1007
- name: "list_console_messages",
1008
- description: "Lists recent console messages (console.log/warn/error/info) captured from the attached mini-app page over CDP (Runtime.consoleAPICalled). Read-only. Returns level, text, timestamp, and stringified args, oldest-first.",
1009
- inputSchema: {
1010
- type: "object",
1011
- properties: {},
1012
- required: []
1013
- },
1014
- availableIn: "both"
1015
- },
1016
- {
1017
- name: "list_network_requests",
1018
- description: "Lists recent network requests (XHR/fetch) captured from the attached mini-app page over CDP (Network.requestWillBeSent + Network.responseReceived). Read-only. Returns url, method, status, and timing, oldest-first.",
1019
- inputSchema: {
1020
- type: "object",
1021
- properties: {},
1022
- required: []
1023
- },
1024
- availableIn: "both"
1025
- },
1026
- {
1027
- name: "list_pages",
1028
- description: "Returns the single active page (at most one) the relay sees attached. When a second page attaches, the previous one is evicted (last-attach wins — single-attach model). The result includes `singleAttachModel: true` so the agent knows the array is always 0 or 1 entries. Also returns whether the cloudflared tunnel is up and the public wss relay URL. The `tunnel` field includes `droppedAt` (ISO timestamp or null/undefined): when non-null the tunnel has permanently dropped after 3 failed reissue attempts — restart the debug server with `npx @ait-co/devtools devtools-mcp`. Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from that target — useful to detect stale entries when the phone app backgrounded). The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint to re-attach. Call this first to confirm a page is attached before reading console/network. When a page attaches or detaches the server emits notifications/tools/list_changed — call tools/list again to get the full updated tool surface.",
1029
- inputSchema: {
1030
- type: "object",
1031
- properties: {},
1032
- required: []
1033
- },
1034
- availableIn: "both"
1035
- },
1036
- {
1037
- name: "start_attach",
1038
- description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Single entry point to attach a real device: switches the debug mode (if `mode` is given), builds the self-attaching deep-link QR for the active relay environment, and waits for the phone to attach — all in one call (replaces the old attach-URL + start_debug two-step). Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed).\n\nmode (optional): pass \"relay-sandbox\" (env 2) or \"relay-staging\" (env 3) to switch the active environment first. When omitted, the current relay environment is used as-is (no switch). Passing \"local-browser\" returns an error — start_attach is relay-only (env 2/3). When the session is already in the requested mode, the switch is skipped.\n\nEnvironment-specific behaviour:\n • env 3 / relay-staging: requires scheme_url — the intoss-private://…?_deploymentId=<uuid> URL from `ait deploy --scheme-only`. Splices debug=1 + relay URL into the scheme URL.\n • env 2 / relay-sandbox: scheme_url is NOT used. Instead, reads AIT_TUNNEL_BASE_URL (the https://*.trycloudflare.com app tunnel from `tunnel:{cdp:true}`) and builds a launcher PWA deep-link (https://devtools.aitc.dev/launcher/?url=…&debug=1&relay=…). When projectRoot is given, the app name from <projectRoot>/package.json is added as name= so the launcher partner bar shows it.\n\nWaits for a page to attach by default (up to wait_timeout_seconds, default 60 s). The server automatically opens the QR dashboard in the OS default browser when running on a local GUI machine — headless/remote environments fall back to the text QR in the tool output.\n\nTOTP auto re-mint: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the attachUrl carries a one-time code (at=<code>) valid for ~3 minutes (the relay gate accepts ±6 TOTP steps). While waiting, start_attach AUTOMATICALLY re-mints a fresh code before the current one expires and refreshes the dashboard QR in place (no browser re-open). You do NOT need to re-call start_attach every time the code would expire — a single call covers the whole wait window. The response includes a `totp` field with `expiresAt` and a `reminted` count of how many fresh codes were issued during the wait. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.\n\nselfdebug (env 2 / relay-sandbox only): pass selfdebug=true to add &selfdebug=1 to the launcher deep-link. The launcher PWA then registers its own document as the CDP target instead of the framed mini-app. SINGLE-ATTACH MODEL: attaching the launcher self-target evicts any currently-attached mini-app target — use this mode exclusively for diagnosing the launcher document itself (DOM, safe-area, console). Not applicable in env 3 (relay-staging) — passing selfdebug=true there returns an error.",
1039
- inputSchema: {
1040
- type: "object",
1041
- properties: {
1042
- mode: {
1043
- type: "string",
1044
- enum: [
1045
- "local-browser",
1046
- "relay-sandbox",
1047
- "relay-staging"
1048
- ],
1049
- description: "Optional debug mode to switch into before attaching. \"relay-sandbox\" = env 2 (launcher PWA), \"relay-staging\" = env 3 (intoss-private dog-food). \"local-browser\" returns an error (start_attach is relay-only). Omit to keep the current relay environment."
1050
- },
1051
- scheme_url: {
1052
- type: "string",
1053
- description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). Required for env 3/relay-staging mode. Not used in env 2/relay-sandbox mode (use AIT_TUNNEL_BASE_URL instead). The authority (host) must be the app name (e.g. intoss-private://aitc-sdk-example?_deploymentId=…). Generic values like \"web\" or an empty host indicate a malformed URL."
1054
- },
1055
- wait_timeout_seconds: {
1056
- type: "number",
1057
- description: "Maximum seconds to wait for a page to attach (default 60, range 1–600). Values outside the range or invalid inputs (0, negative, NaN) fall back to the default silently. During the wait the TOTP code is auto re-minted as needed, so a single call covers the whole window."
1058
- },
1059
- projectRoot: {
1060
- type: "string",
1061
- description: "Absolute path to the mini-app project root (the directory containing its package.json and .ait_urls). When AIT_TUNNEL_BASE_URL is unset (env 2 / relay-mobile only), the daemon reads the app tunnel URL from <projectRoot>/.ait_urls written by the dev server (tunnel:{cdp:true}). Pass this because the daemon's own cwd is fixed at launch. Omit when AIT_TUNNEL_BASE_URL is set explicitly."
1062
- },
1063
- selfdebug: {
1064
- type: "boolean",
1065
- description: "Env 2 / relay-sandbox only. When true, adds &selfdebug=1 to the launcher deep-link so the launcher PWA registers its own document as the CDP target (launcher diagnostics mode). SINGLE-ATTACH MODEL: self-target attach evicts any currently-attached mini-app target. Use only when you need to inspect the launcher itself (DOM, safe-area, console). Passing selfdebug=true in env 3 (relay-staging) returns an error. Default: false (omitted)."
1066
- }
1067
- },
1068
- required: []
1069
- },
1070
- availableIn: "relay"
1071
- },
1072
- {
1073
- name: "get_dom_document",
1074
- description: "Returns the DOM tree of the attached mini-app page over CDP (DOM.getDocument). Read-only. Use for structural/layout regression diagnosis (e.g. confirming an element exists, inspecting attributes). Returns the document root node with children.",
1075
- inputSchema: {
1076
- type: "object",
1077
- properties: {},
1078
- required: []
1079
- },
1080
- availableIn: "both"
1081
- },
1082
- {
1083
- name: "take_snapshot",
1084
- description: "Captures a serialized snapshot of the attached page over CDP (DOMSnapshot.captureSnapshot). Read-only. Returns the documents + interned strings table for visual-regression diagnosis (e.g. checking computed CSS custom properties like --sat against the live layout).",
1085
- inputSchema: {
1086
- type: "object",
1087
- properties: {},
1088
- required: []
1089
- },
1090
- availableIn: "both"
1091
- },
1092
- {
1093
- name: "take_screenshot",
1094
- description: "Captures a PNG screenshot of the attached mini-app page over CDP (Page.captureScreenshot) so the agent can see the phone screen directly. Read-only. Returns an image content block — this is the only debug tool that returns an image; all other debug tools return text (JSON).",
1095
- inputSchema: {
1096
- type: "object",
1097
- properties: {},
1098
- required: []
1099
- },
1100
- availableIn: "both"
1101
- },
1102
- {
1103
- name: "measure_safe_area",
1104
- description: "Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. Read-only — does not modify page state. Tier C per RFC #277: the same Runtime.evaluate probe runs in both `mock` (devtools panel page with window.__ait state) and `relay` (real-device WebView with window.__sdk). The result includes a `source: \"mock\" | \"relay-dev\" | \"relay-mobile\"` field so consumers can identify provenance without inspecting payload values. (`relay-mobile` = env 2 real-device PWA over an external relay; `relay-dev` = env 3 dog-food WebView; relay-live/env 4 removed #665.) Use in a relay session (phone attached) to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires a page to be attached — call list_pages first.",
1105
- inputSchema: {
1106
- type: "object",
1107
- properties: {},
1108
- required: []
1109
- },
1110
- availableIn: "both"
1111
- },
1112
- {
1113
- name: "evaluate",
1114
- description: "Evaluates an arbitrary JavaScript expression on the attached mini-app page via CDP Runtime.evaluate (returnByValue: true) and returns the result. NOT read-only — the expression can have side effects (DOM mutations, SDK calls, state changes). Requires the relay to be attached — call list_pages first. Throws if the evaluation throws an exception on the page.\n\nSECURITY: expression and result are not redacted — never include secrets or auth tokens in the expression.\n\nPositive-allowlist kill-switch (#665): this tool is blocked when the attached page is on a non-debug host (apps.tossmini.com / env 4). Only localhost, *.trycloudflare.com, and *.private-apps.tossmini.com are allowed. relay-live (env 4) and the LIVE confirm guard are removed.",
1115
- inputSchema: {
1116
- type: "object",
1117
- properties: { expression: {
1118
- type: "string",
1119
- description: "JavaScript expression to evaluate in the page context."
1120
- } },
1121
- required: ["expression"]
1122
- },
1123
- availableIn: "both"
1124
- },
1125
- {
1126
- name: "list_exceptions",
1127
- description: "Lists JS-level exceptions captured via `Runtime.exceptionThrown` from the relay attached page. Includes timestamp, exception text, source URL/line, and stack trace. Use to root-cause SDK throws that may precede a Toss app crash (#265 / #267). The buffer holds up to 50 most recent exceptions and survives target replaced/crashed/destroyed events so an exception just before a crash is preserved. Returns up to 50 most recent by default.",
1128
- inputSchema: {
1129
- type: "object",
1130
- properties: { limit: {
1131
- type: "number",
1132
- description: "Maximum number of exceptions to return (default 50, max 50)."
1133
- } },
1134
- required: []
1135
- },
1136
- availableIn: "both"
1137
- },
1138
- {
1139
- name: "call_sdk",
1140
- description: "Calls a dog-food SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 3/4 (real device relay) this hits the real SDK; on env 1 (local mock) and env 2 (PWA relay — real WebKit, mock SDK) it hits the mock SDK. Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available — on relay (env 3/4) that means a non-dog-food bundle (redeploy via `ait build && aitcc app deploy`); on local (--target=local, env 1) it means the dev bridge is not installed (start the dev server with `pnpm dev`).\n\nSECURITY: method name, args, and result value are not redacted — never include secrets.\n\nPositive-allowlist kill-switch (#665): blocked when the attached page is on a non-debug host (apps.tossmini.com / env 4). relay-live and the LIVE guard removed.\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
1141
- inputSchema: {
1142
- type: "object",
1143
- properties: {
1144
- name: {
1145
- type: "string",
1146
- description: "SDK method name to call (e.g. \"getOperationalEnvironment\")."
1147
- },
1148
- args: {
1149
- type: "array",
1150
- description: "Arguments to pass to the SDK method (optional, default []).",
1151
- items: {}
1152
- }
1153
- },
1154
- required: ["name"]
1155
- },
1156
- availableIn: "both"
1157
- },
1158
- {
1159
- name: "AIT.getSdkCallHistory",
1160
- description: "Returns the recent Apps in Toss SDK call trace (method, args, result/error, timestamp) that raw CDP cannot observe. Read-only. Use to confirm an SDK call fired and how it resolved (e.g. a saveBase64Data permission regression).",
1161
- inputSchema: {
1162
- type: "object",
1163
- properties: {},
1164
- required: []
1165
- },
1166
- availableIn: "both"
1167
- },
1168
- {
1169
- name: "AIT.getMockState",
1170
- description: "Returns the devtools mock state snapshot (window.__ait) — environment, permissions, location, auth, network, IAP, and more. Read-only. In dev mode this is the live browser mock state; in debug mode the in-app side reports it over the AIT domain.",
1171
- inputSchema: {
1172
- type: "object",
1173
- properties: {},
1174
- required: []
1175
- },
1176
- availableIn: "both"
1177
- },
1178
- {
1179
- name: "AIT.getOperationalEnvironment",
1180
- description: "Returns getOperationalEnvironment() plus the resolved SDK version — metadata raw CDP cannot observe. Read-only.",
1181
- inputSchema: {
1182
- type: "object",
1183
- properties: {},
1184
- required: []
1185
- },
1186
- availableIn: "both"
1187
- },
1188
- {
1189
- name: "start_debug",
1190
- description: "Switches the active debug environment in-place (issue #348) — no Claude Code restart and no MCP re-handshake. One daemon holds both a local (env 1, mock SDK in a Chromium) and a relay (env 2/3, real-device over the Chii relay + cloudflared tunnel) connection at once; this tool flips which one every other tool reads from, lazily booting the requested family's infra on first use and keeping the inactive one warm so an existing attach survives the switch. After switching it emits notifications/tools/list_changed — call tools/list again to see the updated tool surface for the new environment.\n\nPositive-allowlist kill-switch (#665): relay sessions on apps.tossmini.com (env 4, released production) are silently blocked at both the in-app gate and this MCP layer — relay-live and the LIVE guard have been removed. Only localhost/loopback (env 1), *.trycloudflare.com (env 2), and *.private-apps.tossmini.com (env 3) are allowed.\n\nmodes:\n local-browser — env 1: desktop Chromium with the mock SDK and a local CDP attach. Side-effect tools (call_sdk/evaluate) run unguarded against the mock; nothing touches a real device or real users. No prerequisites — the default, always-available environment for state/contract and visual-layout work.\n relay-sandbox — env 2: a real-device PWA (real WebKit engine, mock SDK) over an external Chii relay. CDP covers real-device WebKit DOM, console, exceptions, and safe-area observation; call_sdk still hits the mock (SDK fidelity needs relay-staging). Side-effect tools run unguarded against the mock. Only the dual-connection daemon can enter relay-sandbox in-place; a single-connection session rejects it with \"동적 전환할 수 없습니다 … relay-sandbox 모드로 재시작하세요\" — follow that hint and restart the MCP server in relay-sandbox mode rather than retrying. Prerequisites: both AIT_RELAY_BASE_URL (the relay base the unplugin emits when started with tunnel:{cdp:true}, used for the CDP attach) and AIT_TUNNEL_BASE_URL (the dev-server tunnel host, required by start_attach to render the launcher QR) must be set before the MCP server starts — the unplugin does not auto-forward either; set them explicitly. Both carry relay/tunnel hosts (secret-class) — keep them out of logs.\n relay-staging — env 3: a real-device Toss WebView dog-food build with the REAL SDK over the intoss-private relay. The first environment where call_sdk exercises the genuine native bridge. Side-effect tools run unguarded (dog-food, not released to real users). Prerequisite: a dog-food candidate bundle built with `RELEASE_CHANNEL=dogfood ait build`, then uploaded with `ait deploy` (add `--scheme-only` to print the resulting intoss-private://…?_deploymentId=… deep-link); open that deep-link/QR on the device to cold-load the bundle with the relay injected. Unlike env 2, env 3 is NOT a dev-server tunnel — it is a deployed bundle reached via the intoss-private scheme, so `pnpm dev` plays no part here.\n\nFor a relay mode (relay-sandbox/relay-staging), also pass projectRoot — the absolute mini-app project root — so the daemon can read the relay auth secret from <projectRoot>/.ait_relay (read-only; the daemon never mints it). Omit it for local-browser.",
1191
- inputSchema: {
1192
- type: "object",
1193
- properties: {
1194
- mode: {
1195
- type: "string",
1196
- enum: [
1197
- "local-browser",
1198
- "relay-sandbox",
1199
- "relay-staging"
1200
- ],
1201
- description: "Target environment to switch to. relay-live (env 4) has been removed (#665) — use relay-staging (env 3) for dog-food debugging."
1202
- },
1203
- projectRoot: {
1204
- type: "string",
1205
- description: "Absolute path to the mini-app project root (the directory containing its package.json and .ait_relay). The daemon reads the relay auth secret from <projectRoot>/.ait_relay (read-only) when switching to a relay environment (relay-staging/relay-sandbox). Pass this because the daemon's own cwd is fixed at launch and may not be the project being debugged. Omit for mode=local-browser (no secret needed)."
1206
- }
1207
- },
1208
- required: ["mode"]
1209
- },
1210
- availableIn: "both"
1211
- },
1212
- {
1213
- name: "get_debug_status",
1214
- description: "Reports the current debug session state — which environment/mode is active, whether a page is attached, and a full diagnostic snapshot — in one call. Use this any time to answer \"what mode am I in right now?\" or \"why is this not working?\" without chaining tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), authRejects ({count, lastAt} — relay TOTP 401 rejections, secret-free; count > 0 with empty pages means the phone reached the relay but its code was rejected), environment (kind: mock|relay-dev|relay-mobile, env: mock|relay backward-compat, reason, liveGuardActive: always false — relay-live and LIVE guard removed (#665); start_debug mode→kind mapping: relay-sandbox→relay-mobile, relay-staging→relay-dev, local-browser→mock), serverLockHolder (pid + startedAt from the lock file, or null), nextRecommendedAction ({tool, reason} or null — the single next tool to call; in local-target mode tunnel.up=false is normal so \"restart\" is never recommended). All fields are nullable — missing data is null, not an error. debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. Tier C (both mock and relay).",
1215
- inputSchema: {
1216
- type: "object",
1217
- properties: { recent_errors_limit: {
1218
- type: "number",
1219
- description: "Maximum number of recent server-side errors to include (default 10, max 50)."
1220
- } },
1221
- required: []
1222
- },
1223
- availableIn: "both"
1224
- },
1225
- {
1226
- name: "run_tests",
1227
- description: "Runs mini-app test files on the attached page over CDP (Runtime.evaluate). Each matched file is bundled with esbuild (SDK imports redirected to the live mock/SDK), injected into the attached WebView, and executed; returns per-file results plus flattened totals (passed/failed/skipped/total). When there is no attached page and the current environment is relay (env 3), this tool automatically shows the QR dashboard and waits for a phone to connect before running (scheme_url required for env 3 relay-dev). Files run SEQUENTIALLY (single-attach model: the relay/local target serves one page), and one run_tests call runs at a time (a concurrent call is rejected). Test verification (assert/snapshot) is delegated to the in-page Vitest runtime; this tool is the transport + report. The per-file results array is the progress record — on partial failure you see exactly which files passed/failed/timed-out. Positive-allowlist kill-switch (#665): blocked when the attached page is on a non-debug host. debug-mode only — dev-mode (--mode=dev) has no CDP. Tier C (both mock/local and relay).",
1228
- inputSchema: {
1229
- type: "object",
1230
- properties: {
1231
- files: {
1232
- type: "array",
1233
- items: { type: "string" },
1234
- description: "Glob patterns or file paths to run (e.g. [\"src/**/*.ait.test.ts\"]). Resolved relative to projectRoot when given, else the daemon cwd. Required, non-empty."
1235
- },
1236
- projectRoot: {
1237
- type: "string",
1238
- description: "Absolute path to the mini-app project root used as the glob base. Pass this because the daemon's cwd is fixed at launch. Optional."
1239
- },
1240
- timeout_ms: {
1241
- type: "number",
1242
- description: "Per-file evaluate timeout in ms (default 30000, range 1000–600000). Out-of-range/invalid values fall back to the default."
1243
- },
1244
- scheme_url: {
1245
- type: "string",
1246
- description: "intoss-private:// deep-link URL from `ait deploy --scheme-only` (env 3 relay-dev only). Required when there is no attached page and the environment is relay-dev — the tool uses it to build the QR attach URL and wait for the phone to connect. Ignored when a page is already attached."
1247
- },
1248
- cell: {
1249
- type: "object",
1250
- description: "Optional cell object to inject into globalThis BEFORE running tests (e.g. { \"__AIT_CELL__\": { \"sdkLine\": \"2.x\", \"platform\": \"ios\" } }). Applied only on the auto-attach path (no-page → attach → inject → run). When a page is already attached the caller is responsible for any prior injection. Ignored when empty or absent. Values must be JSON-serialisable.",
1251
- additionalProperties: true
1252
- }
1253
- },
1254
- required: ["files"]
1255
- },
1256
- availableIn: "both"
1257
- }
1258
- ];
1259
- const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
1260
- function isDebugToolName(name) {
1261
- return DEBUG_TOOL_NAMES.has(name);
1262
- }
1263
- /**
1264
- * Returns the `ToolAvailability` declared on a registered debug tool, or
1265
- * `undefined` when the name is not a known debug tool. Used by the tool
1266
- * registry to filter `tools/list` by current env and by the call handler to
1267
- * reject env-mismatch invocations.
1268
- */
1269
- function getToolAvailability(name) {
1270
- for (const t of DEBUG_TOOL_DEFINITIONS) if (t.name === name) return t.availableIn;
1271
- }
1272
- /**
1273
- * Returns true when the named tool is available in the given environment.
1274
- * Unknown tools return `false` — callers should reject them as unknown rather
1275
- * than as env-mismatched.
1276
- *
1277
- * Relay variants (`relay-dev`, `relay-mobile`) all satisfy the
1278
- * `'relay'` availability tier — `isRelayEnv()` is used for the check.
1279
- * (`relay-live` removed #665.)
1280
- */
1281
- function isToolAvailableIn(name, env) {
1282
- const availability = getToolAvailability(name);
1283
- if (availability === void 0) return false;
1284
- if (availability === "both") return true;
1285
- if (availability === "relay") return isRelayEnv(env);
1286
- return availability === env;
1287
- }
1288
- /**
1289
- * Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`
1290
- * matches the given env. Pure — preserves order; both Tier C ("both") and the
1291
- * matching single-env tier pass through.
1292
- *
1293
- * Relay variants (`relay-dev`, `relay-mobile`) all satisfy the
1294
- * `'relay'` tier. (`relay-live` removed #665.)
1295
- */
1296
- function filterToolsByEnvironment(tools, env) {
1297
- return tools.filter((t) => t.availableIn === "both" || t.availableIn === "relay" && isRelayEnv(env) || t.availableIn === env);
1298
- }
1299
- /**
1300
- * Tool names that are available before any page attaches (bootstrap tier).
1301
- *
1302
- * `start_attach` — mode switch + QR synthesis + attach wait, no prior attach needed.
1303
- * `list_pages` — reports tunnel status + empty pages even pre-attach.
1304
- *
1305
- * All other tools require an attached page (`enableDomains` must succeed) and
1306
- * are only advertised in `tools/list` once a target appears.
1307
- */
1308
- const BOOTSTRAP_TOOL_NAMES = new Set([
1309
- "start_attach",
1310
- "get_debug_status",
1311
- "list_pages",
1312
- "start_debug"
1313
- ]);
1314
- /** Renders a CDP `RemoteObject` console arg to a stable display string. */
1315
- function renderRemoteObject(arg) {
1316
- if (arg.value !== void 0) {
1317
- if (typeof arg.value === "string") return arg.value;
1318
- try {
1319
- return JSON.stringify(arg.value);
1320
- } catch {
1321
- return String(arg.value);
1322
- }
1323
- }
1324
- if (arg.description !== void 0) return arg.description;
1325
- if (arg.className !== void 0) return arg.className;
1326
- return arg.subtype ?? arg.type;
1327
- }
1328
- function normalizeConsoleMessage(event) {
1329
- const args = event.args.map(renderRemoteObject);
1330
- return {
1331
- level: event.type,
1332
- text: args.join(" "),
1333
- timestamp: event.timestamp,
1334
- args
1335
- };
1336
- }
1337
- function listConsoleMessages(connection) {
1338
- return connection.getBufferedEvents("Runtime.consoleAPICalled").map((event) => normalizeConsoleMessage(event));
1339
- }
1340
- function listNetworkRequests(connection) {
1341
- const requests = connection.getBufferedEvents("Network.requestWillBeSent");
1342
- const responses = connection.getBufferedEvents("Network.responseReceived");
1343
- const responseByRequestId = /* @__PURE__ */ new Map();
1344
- for (const response of responses) responseByRequestId.set(response.requestId, response);
1345
- return requests.map((request) => {
1346
- const response = responseByRequestId.get(request.requestId);
1347
- return {
1348
- requestId: request.requestId,
1349
- url: request.request.url,
1350
- method: request.request.method,
1351
- status: response ? response.response.status : null,
1352
- statusText: response ? response.response.statusText : null,
1353
- startTime: request.timestamp,
1354
- endTime: response ? response.timestamp : null
1355
- };
1356
- });
1357
- }
1358
- /** Formats a single CDP call frame into `at fn (url:line:col)`. */
1359
- function formatCallFrame(frame) {
1360
- return `at ${frame.functionName || "(anonymous)"} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`;
1361
- }
1362
- /** Normalizes a raw `Runtime.exceptionThrown` event into a `BufferedException`. */
1363
- function normalizeException(event) {
1364
- const { timestamp, exceptionDetails } = event;
1365
- const frames = exceptionDetails.stackTrace?.callFrames;
1366
- const stack = frames && frames.length > 0 ? frames.map(formatCallFrame).join("\n") : void 0;
1367
- const exceptionText = exceptionDetails.exception?.description ?? void 0;
1368
- const result = {
1369
- timestamp,
1370
- text: exceptionDetails.text,
1371
- raw: event
1372
- };
1373
- if (exceptionDetails.url !== void 0) result.url = exceptionDetails.url;
1374
- if (exceptionDetails.lineNumber !== void 0) result.lineNumber = exceptionDetails.lineNumber;
1375
- if (exceptionDetails.columnNumber !== void 0) result.columnNumber = exceptionDetails.columnNumber;
1376
- if (exceptionText !== void 0) result.exceptionText = exceptionText;
1377
- if (stack !== void 0) result.stack = stack;
1378
- return result;
1379
- }
1380
- /**
1381
- * Returns the most recent buffered `Runtime.exceptionThrown` events, normalized.
1382
- * Oldest-first; limited to `limit` entries (default 50, max 50).
1383
- */
1384
- function listExceptions(connection, limit = 50) {
1385
- const cap = Math.min(Math.max(1, limit), 50);
1386
- const events = connection.getBufferedEvents("Runtime.exceptionThrown");
1387
- return (events.length > cap ? events.slice(events.length - cap) : events).map((e) => normalizeException(e));
1388
- }
1389
- function isCrashAware(conn) {
1390
- return typeof conn.getLastCrashDetectedAt === "function" && typeof conn.getTargetLastSeenAt === "function";
1391
- }
1392
- function listPages(connection, tunnel) {
1393
- const pages = connection.listTargets().map((t) => {
1394
- const lastSeenMs = isCrashAware(connection) ? connection.getTargetLastSeenAt(t.id) : null;
1395
- return {
1396
- id: t.id,
1397
- title: t.title,
1398
- url: redactAtParam(t.url),
1399
- lastSeenAt: lastSeenMs !== null ? new Date(lastSeenMs).toISOString() : null
1400
- };
1401
- });
1402
- const crashMs = isCrashAware(connection) ? connection.getLastCrashDetectedAt() : null;
1403
- const crashDetectedAt = crashMs !== null ? new Date(crashMs).toISOString() : null;
1404
- return {
1405
- pages,
1406
- tunnel,
1407
- crashDetectedAt,
1408
- crashWarning: crashDetectedAt ? `[ait-debug] page crash 감지됨 — 새 attach 필요 (관측 시각: ${crashDetectedAt})` : null,
1409
- singleAttachModel: true
1410
- };
1411
- }
1412
- /**
1413
- * Heuristic: can this process open a GUI browser?
1414
- *
1415
- * Returns `true` when we think a GUI is available:
1416
- * - On macOS (`darwin`) we assume yes (MCP normally runs on the user's Mac).
1417
- * - On Linux we check for `DISPLAY` or `WAYLAND_DISPLAY`.
1418
- * - On Windows we assume yes.
1419
- * - In a CI environment (`CI=true`) we assume no.
1420
- */
1421
- function canOpenBrowser() {
1422
- if (process.env.CI === "true" || process.env.CI === "1") return false;
1423
- const platform = process.platform;
1424
- if (platform === "darwin" || platform === "win32") return true;
1425
- if (platform === "linux") return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);
1426
- return false;
1427
- }
1428
- /** platform별 browser open 명령 후보 목록 — 앞에서부터 순차 시도. */
1429
- function getBrowserCandidates(httpUrl) {
1430
- const platform = process.platform;
1431
- if (platform === "darwin") return [
1432
- {
1433
- cmd: "open",
1434
- args: [httpUrl]
1435
- },
1436
- {
1437
- cmd: "open",
1438
- args: [
1439
- "-a",
1440
- "Safari",
1441
- httpUrl
1442
- ]
1443
- },
1444
- {
1445
- cmd: "open",
1446
- args: [
1447
- "-a",
1448
- "Google Chrome",
1449
- httpUrl
1450
- ]
1451
- },
1452
- {
1453
- cmd: "open",
1454
- args: [
1455
- "-a",
1456
- "Firefox",
1457
- httpUrl
1458
- ]
1459
- }
1460
- ];
1461
- if (platform === "win32") return [{
1462
- cmd: "cmd",
1463
- args: [
1464
- "/c",
1465
- "start",
1466
- "",
1467
- httpUrl
1468
- ]
1469
- }, {
1470
- cmd: "rundll32",
1471
- args: ["url.dll,FileProtocolHandler", httpUrl]
1472
- }];
1473
- return [
1474
- {
1475
- cmd: "xdg-open",
1476
- args: [httpUrl]
1477
- },
1478
- {
1479
- cmd: "sensible-browser",
1480
- args: [httpUrl]
1481
- },
1482
- {
1483
- cmd: "x-www-browser",
1484
- args: [httpUrl]
1485
- },
1486
- {
1487
- cmd: "firefox",
1488
- args: [httpUrl]
1489
- },
1490
- {
1491
- cmd: "google-chrome",
1492
- args: [httpUrl]
1493
- },
1494
- {
1495
- cmd: "chromium",
1496
- args: [httpUrl]
1497
- }
1498
- ];
1499
- }
1500
- /**
1501
- * Redacts ONLY the `at=<value>` (TOTP) query param to `at=<redacted>`, leaving
1502
- * every other query param (_deploymentId, debug, relay) intact. SECRET-HANDLING:
1503
- * the TOTP code is the single short-lived secret carried in a CDP page url.
1504
- */
1505
- function redactAtParam(text) {
1506
- return text.replace(/\bat=([^&\s"']+)/g, "at=<redacted>");
1507
- }
1508
- /** stderr에서 at= TOTP 코드 값을 redact한다. */
1509
- function redactSecrets(text) {
1510
- return redactAtParam(text);
1511
- }
1512
- /** spawnSync exit 0이어도 stderr에 launch 실패 시그널이 있으면 실패로 판단한다. */
1513
- const LAUNCH_FAILURE_PATTERNS = [
1514
- /LSOpenURLsWithRole\(\) failed/,
1515
- /kLSApplicationNotFoundErr/,
1516
- /No application/,
1517
- /Unable to find application/,
1518
- /xdg-open: not found/,
1519
- /command not found/
1520
- ];
1521
- function isLaunchFailureStderr(stderr) {
1522
- return LAUNCH_FAILURE_PATTERNS.some((p) => p.test(stderr));
1523
- }
1524
- /**
1525
- * 로컬 HTTP 서버 루트 URL(`http://127.0.0.1:<port>/`)을 OS 기본 브라우저로 연다 (#595).
1526
- *
1527
- * platform별 fallback chain으로 시도하며, 모두 실패하면 1회 retry를 수행한다
1528
- * (ephemeral process launch 타이밍 문제 대응). retry까지 실패해도 `opened: false` +
1529
- * `httpUrl`을 반환해 사용자가 직접 브라우저에 붙여넣을 수 있게 한다.
1530
- *
1531
- * SECRET-HANDLING:
1532
- * - tmp 파일을 만들지 않는다 (HTML/PNG는 HTTP 서버가 메모리에서 응답).
1533
- * - httpUrl은 `http://127.0.0.1:<port>/`(루트, 시크릿 없음). pngUrl은 127.0.0.1 로컬 전용.
1534
- * - stderr 캡처 결과에서 at= 코드 값을 redact한 후 stderrSummary에 포함.
1535
- * - attachUrl, deploymentId, TOTP 코드를 stdout/stderr/로그에 직접 출력 금지.
1536
- *
1537
- * @param httpUrl - `http://127.0.0.1:<port>/` 루트 URL (시크릿 없음, #595).
1538
- * @param pngUrl - `http://127.0.0.1:<port>/qr.png?u=<encoded>` PNG fallback URL.
1539
- */
1540
- async function openQrInBrowser(httpUrl, pngUrl) {
1541
- const { spawnSync } = await import("node:child_process");
1542
- /**
1543
- * 한 번의 fallback chain 시도. 성공하면 열린 후보 cmd를 반환, 실패하면 null.
1544
- * stderrLines에 각 후보의 stderr를 누적한다.
1545
- */
1546
- function tryOnce(stderrLines) {
1547
- const candidates = getBrowserCandidates(httpUrl);
1548
- for (const { cmd, args } of candidates) {
1549
- const result = spawnSync(cmd, args, {
1550
- encoding: "utf8",
1551
- timeout: 5e3
1552
- });
1553
- if (result.error) {
1554
- stderrLines.push(`${cmd}: ${result.error.message}`);
1555
- continue;
1556
- }
1557
- const stderr = typeof result.stderr === "string" ? result.stderr : "";
1558
- if (stderr) stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);
1559
- if (result.status === 0 && !isLaunchFailureStderr(stderr)) return true;
1560
- }
1561
- return false;
1562
- }
1563
- const stderrLines = [];
1564
- if (tryOnce(stderrLines)) return {
1565
- opened: true,
1566
- httpUrl,
1567
- pngUrl
1568
- };
1569
- if (tryOnce(stderrLines)) return {
1570
- opened: true,
1571
- httpUrl,
1572
- pngUrl,
1573
- retried: true
1574
- };
1575
- return {
1576
- opened: false,
1577
- httpUrl,
1578
- pngUrl,
1579
- error: "모든 브라우저 실행 후보가 실패했습니다.",
1580
- stderrSummary: stderrLines.length > 0 ? stderrLines.join("\n") : void 0
1581
- };
1582
- }
1583
- /** Returns the DOM tree of the attached page (`DOM.getDocument`). */
1584
- function getDomDocument(connection) {
1585
- return connection.send("DOM.getDocument", {
1586
- depth: -1,
1587
- pierce: true
1588
- });
1589
- }
1590
- /** Returns a serialized page snapshot (`DOMSnapshot.captureSnapshot`). */
1591
- function takeSnapshot(connection) {
1592
- return connection.send("DOMSnapshot.captureSnapshot", {});
1593
- }
1594
- /** Captures a PNG screenshot of the attached page (`Page.captureScreenshot`). */
1595
- async function takeScreenshot(connection) {
1596
- const { data } = await connection.send("Page.captureScreenshot", { format: "png" });
1597
- return {
1598
- data,
1599
- dataUri: `data:image/png;base64,${data}`,
1600
- mimeType: "image/png"
1601
- };
1602
- }
1603
- /**
1604
- * The JS probe injected via `Runtime.evaluate`. It reads:
1605
- * 1. `env(safe-area-inset-*)` via a temporary element with padding set to
1606
- * those CSS env vars, then `getComputedStyle`.
1607
- * 2. SDK insets via a priority chain so the SAME probe works on both relay
1608
- * (real device) and mock (devtools panel page):
1609
- * a. `window.__sdk.SafeAreaInsets.get()` — dog-food bundle on real device.
1610
- * b. `window.__sdk.getSafeAreaInsets()` — dog-food bundle (deprecated).
1611
- * c. `window.__ait.state.safeAreaInsets` — devtools mock state (mock env).
1612
- * The probe records `sdkInsetsSource` = `'window.__sdk'` | `'window.__ait'`
1613
- * | `null`. If all paths fail the result carries `sdkInsetsError`.
1614
- * 3. nav bar geometry: the SDK does not expose navBar height as a standalone
1615
- * API — `.ait-navbar` DOM height is read as a cross-check, and
1616
- * `navBarHeightSource` records where it came from.
1617
- * 4. `innerWidth`, `innerHeight`, `devicePixelRatio`, `navigator.userAgent`.
1618
- *
1619
- * Returns a plain JSON-serialisable object so `returnByValue: true` works.
1620
- *
1621
- * NOTE: This expression is evaluated in the page context — on the real device
1622
- * (relay) or on the mock panel page. It does not mutate any page state — the
1623
- * temporary element is removed after reading. No secret or auth token is read
1624
- * or returned.
1625
- *
1626
- * RFC #277 Tier C parity: the SAME probe string runs in both envs. Mock fidelity
1627
- * comes from the panel's `applyViewport` / `computeSafeAreaInsets` correctly
1628
- * setting `window.__ait.state.safeAreaInsets` (#275). When that is correct,
1629
- * the cssEnv + sdkInsets pair returned here matches the relay's shape.
1630
- */
1631
- const SAFE_AREA_PROBE_EXPRESSION = `
1632
- (function() {
1633
- var el = document.createElement('div');
1634
- el.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;visibility:hidden;' +
1635
- 'padding-top:env(safe-area-inset-top,0px);' +
1636
- 'padding-right:env(safe-area-inset-right,0px);' +
1637
- 'padding-bottom:env(safe-area-inset-bottom,0px);' +
1638
- 'padding-left:env(safe-area-inset-left,0px)';
1639
- document.documentElement.appendChild(el);
1640
- var cs = window.getComputedStyle(el);
1641
- var cssEnv = {
1642
- top: parseFloat(cs.paddingTop) || 0,
1643
- right: parseFloat(cs.paddingRight) || 0,
1644
- bottom: parseFloat(cs.paddingBottom) || 0,
1645
- left: parseFloat(cs.paddingLeft) || 0
1646
- };
1647
- document.documentElement.removeChild(el);
1648
- var sdkInsets = null;
1649
- var sdkInsetsSource = null;
1650
- var sdkInsetsError = undefined;
1651
- try {
1652
- var sdk = window.__sdk;
1653
- var ait = window.__ait;
1654
- if (sdk && sdk.SafeAreaInsets && typeof sdk.SafeAreaInsets.get === 'function') {
1655
- sdkInsets = sdk.SafeAreaInsets.get();
1656
- sdkInsetsSource = 'window.__sdk';
1657
- } else if (sdk && typeof sdk.getSafeAreaInsets === 'function') {
1658
- sdkInsets = sdk.getSafeAreaInsets();
1659
- sdkInsetsSource = 'window.__sdk';
1660
- } else if (ait && ait.state && ait.state.safeAreaInsets &&
1661
- typeof ait.state.safeAreaInsets.top === 'number') {
1662
- var s = ait.state.safeAreaInsets;
1663
- sdkInsets = { top: s.top, bottom: s.bottom, left: s.left, right: s.right };
1664
- sdkInsetsSource = 'window.__ait';
1665
- } else if (!sdk && !ait) {
1666
- sdkInsetsError = 'neither window.__sdk (relay) nor window.__ait (mock) available';
1667
- } else if (sdk) {
1668
- sdkInsetsError = 'neither SafeAreaInsets.get nor getSafeAreaInsets found on window.__sdk';
1669
- } else {
1670
- sdkInsetsError = 'window.__ait.state.safeAreaInsets is missing or malformed';
1671
- }
1672
- } catch(e) {
1673
- sdkInsetsError = String(e && e.message || e);
1674
- }
1675
- var navBarHeight = null;
1676
- var navBarHeightSource = 'not-exposed-by-sdk';
1677
- try {
1678
- var nb = document.querySelector('.ait-navbar');
1679
- if (nb) {
1680
- navBarHeight = nb.getBoundingClientRect().height;
1681
- navBarHeightSource = 'dom-.ait-navbar';
1682
- }
1683
- } catch(_) {}
1684
- var result = {
1685
- cssEnv: cssEnv,
1686
- sdkInsets: sdkInsets,
1687
- sdkInsetsSource: sdkInsetsSource,
1688
- navBarHeight: navBarHeight,
1689
- navBarHeightSource: navBarHeightSource,
1690
- innerWidth: window.innerWidth,
1691
- innerHeight: window.innerHeight,
1692
- devicePixelRatio: window.devicePixelRatio,
1693
- userAgent: navigator.userAgent
1694
- };
1695
- if (sdkInsetsError !== undefined) result.sdkInsetsError = sdkInsetsError;
1696
- return JSON.stringify(result);
1697
- })()
1698
- `.trim();
1699
- /**
1700
- * Parses a raw `Runtime.evaluate` result value into a `SafeAreaMeasurement`.
1701
- * The probe returns a JSON string (because `returnByValue:true` with a plain
1702
- * object works unreliably across Chii relay versions — stringifying is safer).
1703
- *
1704
- * `source` is supplied by the caller (`measureSafeArea`) from the env SSoT.
1705
- *
1706
- * Throws if the result is missing, contains an exception, or cannot be parsed.
1707
- */
1708
- function normalizeSafeAreaResult(rawValue, source) {
1709
- if (typeof rawValue !== "string") throw new Error(`measure_safe_area: probe returned unexpected type "${typeof rawValue}" — expected JSON string`);
1710
- let parsed;
1711
- try {
1712
- parsed = JSON.parse(rawValue);
1713
- } catch {
1714
- throw new Error(`measure_safe_area: probe returned non-JSON string: ${rawValue}`);
1715
- }
1716
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("measure_safe_area: parsed result is not an object");
1717
- const obj = parsed;
1718
- function requireInsets(key) {
1719
- const v = obj[key];
1720
- if (v === null || v === void 0) return null;
1721
- if (typeof v !== "object") return null;
1722
- const r = v;
1723
- return {
1724
- top: typeof r.top === "number" ? r.top : 0,
1725
- right: typeof r.right === "number" ? r.right : 0,
1726
- bottom: typeof r.bottom === "number" ? r.bottom : 0,
1727
- left: typeof r.left === "number" ? r.left : 0
1728
- };
1729
- }
1730
- const cssEnv = requireInsets("cssEnv") ?? {
1731
- top: 0,
1732
- right: 0,
1733
- bottom: 0,
1734
- left: 0
1735
- };
1736
- const sdkInsets = requireInsets("sdkInsets");
1737
- const sdkInsetsSource = obj.sdkInsetsSource === "window.__sdk" || obj.sdkInsetsSource === "window.__ait" ? obj.sdkInsetsSource : null;
1738
- const sdkInsetsError = typeof obj.sdkInsetsError === "string" ? obj.sdkInsetsError : void 0;
1739
- const navBarHeight = typeof obj.navBarHeight === "number" ? obj.navBarHeight : null;
1740
- const navBarHeightSource = typeof obj.navBarHeightSource === "string" ? obj.navBarHeightSource : "not-exposed-by-sdk";
1741
- const innerWidth = typeof obj.innerWidth === "number" ? obj.innerWidth : 0;
1742
- const innerHeight = typeof obj.innerHeight === "number" ? obj.innerHeight : 0;
1743
- const devicePixelRatio = typeof obj.devicePixelRatio === "number" ? obj.devicePixelRatio : 1;
1744
- const userAgent = typeof obj.userAgent === "string" ? obj.userAgent : "";
1745
- return {
1746
- source,
1747
- cssEnv,
1748
- sdkInsets,
1749
- sdkInsetsSource,
1750
- ...sdkInsetsError !== void 0 ? { sdkInsetsError } : {},
1751
- navBarHeight,
1752
- navBarHeightSource,
1753
- innerWidth,
1754
- innerHeight,
1755
- devicePixelRatio,
1756
- userAgent
1757
- };
1758
- }
1759
- /**
1760
- * Runs the safe-area probe on the attached page and returns a normalized
1761
- * `SafeAreaMeasurement`. Read-only — does not mutate page state.
1762
- *
1763
- * `source` is supplied by the caller from the env detection SSoT (see
1764
- * `src/mcp/environment.ts`). The same `Runtime.evaluate` call runs in both
1765
- * envs — the probe expression tries `window.__sdk` first (relay) then
1766
- * `window.__ait` (mock), so mock fidelity is enforced by the panel's
1767
- * `applyViewport`/`computeSafeAreaInsets` keeping `__ait.state.safeAreaInsets`
1768
- * correct (RFC #277 Tier C parity, #275 model).
1769
- *
1770
- * Throws on CDP error, probe exception, or result parse failure.
1771
- */
1772
- async function measureSafeArea(connection, source) {
1773
- const result = await connection.send("Runtime.evaluate", {
1774
- expression: SAFE_AREA_PROBE_EXPRESSION,
1775
- returnByValue: true,
1776
- awaitPromise: false
1777
- });
1778
- if (result.exceptionDetails) {
1779
- const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
1780
- throw new Error(`measure_safe_area: probe threw — ${msg}`);
1781
- }
1782
- return normalizeSafeAreaResult(result.result.value, source);
1783
- }
1784
- /**
1785
- * Evaluates an arbitrary JS expression on the attached page via
1786
- * `Runtime.evaluate`. NOT read-only — the expression may have side effects.
1787
- *
1788
- * Throws if the evaluation produced a CDP exception.
1789
- *
1790
- * SECRET-HANDLING: expression and result value are NOT written to any log.
1791
- */
1792
- async function evaluate(connection, expression) {
1793
- const result = await connection.send("Runtime.evaluate", {
1794
- expression,
1795
- returnByValue: true,
1796
- awaitPromise: false
1797
- });
1798
- if (result.exceptionDetails) {
1799
- const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
1800
- throw new Error(`evaluate failed: ${msg}`);
1801
- }
1802
- return {
1803
- value: result.result.value,
1804
- type: result.result.type
1805
- };
1806
- }
1807
- /**
1808
- * Builds the Runtime.evaluate expression that calls `window.__sdkCall` with
1809
- * the given method name and args, awaits the promise, and returns a JSON
1810
- * envelope `{ok, value/error}` as a string.
1811
- *
1812
- * Name and args are embedded via `JSON.stringify` so they are safely escaped.
1813
- * The expression checks for `window.__sdkCall` and returns a clear error if
1814
- * it is absent (non-dog-food bundle).
1815
- *
1816
- * SECRET-HANDLING: the expression is built here and MUST NOT be written to
1817
- * any log or stderr by the caller.
1818
- */
1819
- function buildCallSdkExpression(name, args) {
1820
- return `(async () => { if (typeof window.__sdkCall !== 'function') { return JSON.stringify({ok:false,error:'sdk-absent: window.__sdkCall이 주입되지 않았습니다 (dog-food 빌드가 아닙니다). dog-food 채널로 재배포하세요.'}); } try { const r = await window.__sdkCall(${JSON.stringify(name)}, ...${JSON.stringify(args)}); return JSON.stringify({ok:true,value:r}); } catch(e) { return JSON.stringify({ok:false,error:String(e && e.message || e)}); }})()`;
1821
- }
1822
- /**
1823
- * Parses the JSON envelope string returned by the `call_sdk` expression.
1824
- * Returns a typed `CallSdkResult`.
1825
- *
1826
- * Throws only on parse failure (not on ok:false — that is a normal result).
1827
- */
1828
- function normalizeCallSdkResult(rawValue) {
1829
- if (typeof rawValue !== "string") throw new Error(`call_sdk: bridge returned unexpected type "${typeof rawValue}" — expected JSON string`);
1830
- let parsed;
1831
- try {
1832
- parsed = JSON.parse(rawValue);
1833
- } catch {
1834
- throw new Error("call_sdk: bridge returned non-JSON string");
1835
- }
1836
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("call_sdk: parsed result is not an object");
1837
- const obj = parsed;
1838
- if (obj.ok === true) return {
1839
- ok: true,
1840
- value: obj.value
1841
- };
1842
- if (obj.ok === false) return {
1843
- ok: false,
1844
- error: typeof obj.error === "string" ? obj.error : String(obj.error)
1845
- };
1846
- throw new Error("call_sdk: bridge result missing \"ok\" field");
1847
- }
1848
- /**
1849
- * Looks up the most recent exception from the buffer that falls within the
1850
- * triage window [windowStart, windowEnd]. Returns `undefined` if none found.
1851
- *
1852
- * The heuristic window is:
1853
- * - windowStart = callStart - 50ms (catch sync throws before bridge fires)
1854
- * - windowEnd = callEnd + 200ms (catch async throws resolved soon after)
1855
- *
1856
- * Only the most recent exception within the window is returned (the one most
1857
- * likely to be causally related to the SDK call).
1858
- */
1859
- function findRecentException(connection, windowStart, windowEnd) {
1860
- const events = connection.getBufferedEvents("Runtime.exceptionThrown");
1861
- for (let i = events.length - 1; i >= 0; i--) {
1862
- const e = events[i];
1863
- if (e.timestamp >= windowStart && e.timestamp <= windowEnd) return normalizeException(e);
1864
- }
1865
- }
1866
- /**
1867
- * Calls a dog-food SDK method via `window.__sdkCall` on the attached page.
1868
- * NOT read-only — SDK calls may have side effects.
1869
- *
1870
- * On env 3/4 (toss WebView relay) this hits the real SDK. On env 1 (local
1871
- * mock) and env 2 (PWA relay — real WebKit, mock SDK) it hits the mock SDK.
1872
- *
1873
- * 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면
1874
- * `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).
1875
- * 미등록 메서드는 passthrough + stderr 경고 1회.
1876
- *
1877
- * Throws on CDP error or result parse failure. Returns `{ok:false, error}`
1878
- * for bridge-level errors (method not found, SDK threw, bridge absent) or
1879
- * argument schema violations.
1880
- *
1881
- * If a `Runtime.exceptionThrown` event was observed within the triage window
1882
- * [callStart-50ms, callEnd+200ms], the result includes `recentException` for
1883
- * crash triage. This window is a heuristic — it catches the common case of an
1884
- * SDK throw immediately before/after the bridge resolves.
1885
- *
1886
- * SECRET-HANDLING: name, args, and the result value are NOT written to any log.
1887
- */
1888
- async function callSdk(connection, name, args) {
1889
- const signature = lookupSignature(name);
1890
- if (signature !== void 0) {
1891
- const validation = signature.validateArgs(args);
1892
- if (!validation.ok) return {
1893
- ok: false,
1894
- error: `call_sdk("${name}") 인자 시그니처 오류.\n받음: ${validation.received}\n기대: ${validation.expected}\n올바른 예시: ${signature.example}`
1895
- };
1896
- } else warnPassthrough(name);
1897
- const callStart = Date.now();
1898
- const expression = buildCallSdkExpression(name, args);
1899
- const result = await connection.send("Runtime.evaluate", {
1900
- expression,
1901
- returnByValue: true,
1902
- awaitPromise: true
1903
- });
1904
- const callEnd = Date.now();
1905
- if (result.exceptionDetails) {
1906
- const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
1907
- throw new Error(`call_sdk threw: ${msg}`);
1908
- }
1909
- const sdkResult = normalizeCallSdkResult(result.result.value);
1910
- const recentException = findRecentException(connection, callStart - 50, callEnd + 200);
1911
- if (recentException !== void 0) return {
1912
- ...sdkResult,
1913
- recentException
1914
- };
1915
- return sdkResult;
1916
- }
1917
- /** Set of tool names served by the AIT source rather than the CDP connection. */
1918
- const AIT_TOOL_NAMES = new Set([
1919
- "AIT.getSdkCallHistory",
1920
- "AIT.getMockState",
1921
- "AIT.getOperationalEnvironment"
1922
- ]);
1923
- /** True for the Phase 3 AIT.* tools (served by an `AitSource`, not CDP). */
1924
- function isAitToolName(name) {
1925
- return AIT_TOOL_NAMES.has(name);
1926
- }
1927
- /** Returns the recent SDK call trace (`AIT.getSdkCallHistory`). */
1928
- function getSdkCallHistory(source) {
1929
- return source.get("AIT.getSdkCallHistory");
1930
- }
1931
- /** Returns the devtools mock-state snapshot (`AIT.getMockState`). */
1932
- function getMockState(source) {
1933
- return source.get("AIT.getMockState");
1934
- }
1935
- /** Returns the operational environment + SDK version (`AIT.getOperationalEnvironment`). */
1936
- function getOperationalEnvironment(source) {
1937
- return source.get("AIT.getOperationalEnvironment");
1938
- }
1939
- /** Secret-redaction patterns applied before error messages enter the buffer. */
1940
- const SECRET_REDACT_PATTERNS = [
1941
- [/\bat=([^&\s"']+)/g, "at=<redacted>"],
1942
- [/((?:set-)?cookie)\s*:\s*.+/gi, "$1: <redacted>"],
1943
- [/AITCC_API_KEY\s*=\s*\S+/gi, "AITCC_API_KEY=<redacted>"],
1944
- [/Authorization\s*:\s*.+/gi, "Authorization: <redacted>"],
1945
- [/\bBearer\s+\S+/g, "Bearer <redacted>"]
1946
- ];
1947
- /**
1948
- * Applies all secret-redaction patterns to an error message string.
1949
- * Used before storing errors in the `DiagnosticsCollector` ring buffer.
1950
- *
1951
- * SECRET-HANDLING: this is the single bottleneck for redaction — all error
1952
- * strings must pass through here before reaching the buffer.
1953
- */
1954
- function redactErrorMessage(message) {
1955
- let result = message;
1956
- for (const [pattern, replacement] of SECRET_REDACT_PATTERNS) result = result.replace(pattern, replacement);
1957
- return result;
1958
- }
1959
- /** Default max buffer size for the error ring buffer. */
1960
- const DEFAULT_ERROR_BUFFER_SIZE = 50;
1961
- /**
1962
- * In-memory implementation of `DiagnosticsCollector`. Thread-safe in the
1963
- * single-threaded Node.js sense (synchronous mutations only).
1964
- */
1965
- var InMemoryDiagnosticsCollector = class {
1966
- buffer = [];
1967
- maxSize;
1968
- lastAttachAt = null;
1969
- lastDetachAt = null;
1970
- authRejectCount = 0;
1971
- lastAuthRejectAt = null;
1972
- constructor(maxSize = DEFAULT_ERROR_BUFFER_SIZE) {
1973
- this.maxSize = maxSize;
1974
- }
1975
- recordError(message, category) {
1976
- const entry = {
1977
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1978
- message: redactErrorMessage(message),
1979
- ...category !== void 0 ? { category } : {}
1980
- };
1981
- this.buffer.push(entry);
1982
- if (this.buffer.length > this.maxSize) this.buffer.shift();
1983
- }
1984
- getRecentErrors(limit) {
1985
- const cap = Math.min(Math.max(1, limit), DEFAULT_ERROR_BUFFER_SIZE);
1986
- return this.buffer.length > cap ? this.buffer.slice(this.buffer.length - cap) : [...this.buffer];
1987
- }
1988
- recordAttach() {
1989
- this.lastAttachAt = (/* @__PURE__ */ new Date()).toISOString();
1990
- }
1991
- recordDetach() {
1992
- this.lastDetachAt = (/* @__PURE__ */ new Date()).toISOString();
1993
- }
1994
- getLastAttachAt() {
1995
- return this.lastAttachAt;
1996
- }
1997
- getLastDetachAt() {
1998
- return this.lastDetachAt;
1999
- }
2000
- recordAuthReject() {
2001
- this.authRejectCount += 1;
2002
- this.lastAuthRejectAt = (/* @__PURE__ */ new Date()).toISOString();
2003
- }
2004
- getAuthRejects() {
2005
- return {
2006
- count: this.authRejectCount,
2007
- lastAt: this.lastAuthRejectAt
2008
- };
2009
- }
2010
- };
2011
- /**
2012
- * Returns the `@modelcontextprotocol/sdk` version baked in at build time via
2013
- * the `__MCP_SDK_VERSION__` define (see `tsdown.config.ts`). Returns `null`
2014
- * when the define is absent (unbundled test runs) and the runtime fallback
2015
- * below also fails — diagnostics must never throw.
2016
- *
2017
- * Earlier attempts resolved `@modelcontextprotocol/sdk/package.json` (not in
2018
- * the SDK `exports` map → `ERR_PACKAGE_PATH_NOT_EXPORTED`) or the bare
2019
- * `@modelcontextprotocol/sdk` main entry (also absent → `MODULE_NOT_FOUND`),
2020
- * so both this fallback AND the build-time define silently produced `null` —
2021
- * leaving `mcpVersion: null` in a real bundle (issue #361, observed live). The
2022
- * fix resolves a subpath that IS exported (`./server/mcp.js`) and walks back to
2023
- * the package root, in BOTH the build define and this fallback.
2024
- *
2025
- * Kept `async` for call-site compatibility (`Promise.all` at the caller); the
2026
- * body is synchronous apart from the best-effort fallback.
2027
- */
2028
- async function readMcpSdkVersion() {
2029
- return "1.29.0";
2030
- }
2031
- /**
2032
- * Returns the `@ait-co/devtools` package version injected at build time via
2033
- * the `__VERSION__` define. Returns `null` when the global is absent (e.g. in
2034
- * some test environments that skip the build step).
2035
- */
2036
- function readDevtoolsVersion() {
2037
- return "0.1.117";
2038
- }
2039
- /**
2040
- * Derives the next recommended action from a completed diagnostics snapshot.
2041
- *
2042
- * Branch rules (evaluated in priority order):
2043
- * 0. tunnel.droppedAt non-null → restart (permanent tunnel drop — highest priority)
2044
- * 1. tunnel.up === false AND env is relay → restart (relay needs a live tunnel)
2045
- * 1b. tunnel.up === false AND env is mock → wait_for_page (local target: tunnel-less is normal)
2046
- * 2a. authRejects.count > 0 AND pages empty → start_attach (relay TOTP 거부 관측 — QR 재스캔
2047
- * 또는 target-side `at` 전달 확인. 일반 rule 2보다 구체적이므로 먼저 평가 — issue #467)
2048
- * 2. tunnel.up, pages empty, env === relay → start_attach (start attach)
2049
- * 3. pages has entry + crashDetectedAt non-null → start_attach (re-attach after crash)
2050
- * 4. otherwise → null (session looks healthy)
2051
- *
2052
- * Pure — does not throw; receives the final assembled snapshot fields.
2053
- *
2054
- * SECRET-HANDLING: the auth-reject reason string carries only the count and
2055
- * timestamp from {@link AuthRejectsSnapshot} — never a URL, code, or secret.
2056
- */
2057
- function computeNextRecommendedAction(tunnel, pages, env, authRejects = null) {
2058
- if (tunnel.droppedAt != null) return {
2059
- tool: "restart",
2060
- reason: `tunnel permanently dropped at ${tunnel.droppedAt} after ${tunnel.reissueAttempts} reissue attempt(s) — restart the MCP server (npx @ait-co/devtools devtools-mcp)`
2061
- };
2062
- if (!tunnel.up) if (!isRelayEnv(env)) {
2063
- if (pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) return {
2064
- tool: "wait_for_page",
2065
- reason: "local Chromium spawn 직후 — 페이지 로드를 기다리거나 list_pages를 재호출하세요 (local 모드는 tunnel이 없는 게 정상입니다)"
2066
- };
2067
- } else return {
2068
- tool: "restart",
2069
- reason: "tunnel not up — run `npx @ait-co/devtools devtools-mcp` to restart"
2070
- };
2071
- if (authRejects !== null && authRejects.count > 0 && pages !== null && pages.pages.length === 0) return {
2072
- tool: "start_attach",
2073
- reason: `relay 인증(TOTP) 거부 ${authRejects.count}건 발생 (last ${authRejects.lastAt ?? "unknown"}) — QR을 다시 스캔해 새 코드로 attach하세요(코드는 ~3분마다 만료). 반복되면 폰 페이지 URL에 at 파라미터가 전달되는지(target-side TOTP 전달 경로)를 확인하세요`
2074
- };
2075
- if (isRelayEnv(env) && pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) return {
2076
- tool: "start_attach",
2077
- reason: "tunnel ready, no pages attached — call start_attach to generate the attach QR"
2078
- };
2079
- if (pages !== null && pages.crashDetectedAt !== null) return {
2080
- tool: "start_attach",
2081
- reason: `page crashed at ${pages.crashDetectedAt} — call start_attach to re-attach`
2082
- };
2083
- return null;
2084
- }
2085
- /**
2086
- * Builds the `get_debug_status` response. Pure — does not throw; missing data
2087
- * fields are `null`. Async because `readMcpSdkVersion` needs `import()`.
2088
- *
2089
- * SECRET-HANDLING:
2090
- * - `recentErrors` messages are already redacted by `recordError` (via
2091
- * `redactErrorMessage`). No additional redaction needed here.
2092
- * - `tunnel.wssUrl` is a public cloudflared hostname — not a secret.
2093
- * - Lock file data contains only pid + startedAt + wssUrl — no secrets.
2094
- */
2095
- async function getDiagnostics(input) {
2096
- const { tunnel, connection, env, envReason, collector, readLock: readLockFn, recentErrorsLimit = 10, getMcpVersion = readMcpSdkVersion, checkParentAlive = () => isPidAlive(process.ppid), tunnelChildPid } = input;
2097
- const [mcpVersion, devtoolsVersion] = await Promise.all([getMcpVersion(), Promise.resolve(readDevtoolsVersion())]);
2098
- const lockData = readLockFn();
2099
- const serverLockHolder = lockData ? {
2100
- pid: lockData.pid,
2101
- startedAt: lockData.startedAt,
2102
- wssUrl: lockData.wssUrl
2103
- } : null;
2104
- const effectiveTunnelChildPid = tunnelChildPid ?? lockData?.tunnelChildPid ?? null;
2105
- let effectiveUp = tunnel.up;
2106
- if (tunnel.up && typeof effectiveTunnelChildPid === "number" && effectiveTunnelChildPid !== null && !isPidAlive(effectiveTunnelChildPid)) effectiveUp = false;
2107
- const tunnelInfo = {
2108
- up: effectiveUp,
2109
- wssUrl: tunnel.wssUrl,
2110
- pid: lockData?.pid ?? null,
2111
- startedAt: lockData?.startedAt ?? null,
2112
- droppedAt: tunnel.droppedAt ?? null,
2113
- reissueAttempts: tunnel.reissueAttempts ?? 0
2114
- };
2115
- let pages = null;
2116
- if (connection !== void 0) {
2117
- try {
2118
- await connection.refreshTargets?.();
2119
- } catch {}
2120
- try {
2121
- pages = listPages(connection, tunnel);
2122
- } catch {}
2123
- }
2124
- const limit = Math.min(Math.max(1, recentErrorsLimit), 50);
2125
- const recentErrors = collector.getRecentErrors(limit);
2126
- const authRejects = collector.getAuthRejects();
2127
- if (authRejects.count > 0) recentErrors.push({
2128
- timestamp: authRejects.lastAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2129
- message: `WS upgrade auth-rejected (${authRejects.count} times, last ${authRejects.lastAt ?? "unknown"})`,
2130
- category: "auth"
2131
- });
2132
- const nextRecommendedAction = computeNextRecommendedAction(tunnelInfo, pages, env, authRejects);
2133
- return {
2134
- mcpVersion,
2135
- devtoolsVersion,
2136
- tunnel: tunnelInfo,
2137
- pages,
2138
- lastAttachAt: collector.getLastAttachAt(),
2139
- lastDetachAt: collector.getLastDetachAt(),
2140
- recentErrors,
2141
- authRejects,
2142
- environment: {
2143
- kind: env,
2144
- env: toLegacyEnv(env),
2145
- reason: envReason,
2146
- liveGuardActive: false
2147
- },
2148
- serverLockHolder,
2149
- process: {
2150
- pid: process.pid,
2151
- ppid: process.ppid,
2152
- parentAlive: checkParentAlive()
2153
- },
2154
- nextRecommendedAction
2155
- };
2156
- }
2157
- //#endregion
2158
- //#region src/mcp/tunnel.ts
2159
- /**
2160
- * cloudflared quick tunnel + attach banner for the debug-mode MCP server.
2161
- *
2162
- * On spawn, the debug server opens an accountless `*.trycloudflare.com` quick
2163
- * tunnel to the local Chii relay so the phone can attach over a public wss URL,
2164
- * then prints a unicode half-block QR + attach instructions. When TOTP auth is
2165
- * enabled (`AIT_DEBUG_TOTP_SECRET` is set), the QR encodes only the base relay
2166
- * URL — the TOTP code (`at=`) is NOT included because it rotates every 30 s
2167
- * and would be stale by the time a human scans. The in-app deep-link builder
2168
- * splices the live code at attach time.
2169
- *
2170
- * Tunnel health probe (`TunnelHealthProbe`):
2171
- * After the tunnel is up, a periodic HTTP HEAD probe hits the tunnel's
2172
- * `https://` URL every `probeIntervalMs` (default 60 s). Two consecutive
2173
- * failures trigger a reissue attempt (spawn a new cloudflared quick tunnel
2174
- * and redirect traffic). After `MAX_REISSUE_ATTEMPTS` (3) consecutive
2175
- * reissue failures, the probe gives up and marks the tunnel permanently
2176
- * dropped — `tunnelStatus.up` becomes false with `droppedAt` set. The caller
2177
- * should surface this to the agent so the user knows to restart the server.
2178
- *
2179
- * SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear
2180
- * in any output from this module.
2181
- *
2182
- * Node-only: spawns the cloudflared binary and writes to stdout/stderr.
2183
- */
2184
- /** Generates a 32-byte hex attach token shown as a pairing hint (relay-side validation is a later phase). */
2185
- function generateAttachToken() {
2186
- return randomBytes(32).toString("hex");
2187
- }
2188
- /** Ensures the cloudflared binary is installed (downloads + caches on first run). */
2189
- async function ensureCloudflaredBin() {
2190
- const { existsSync } = await import("node:fs");
2191
- if (!existsSync(bin)) await install(bin);
2192
- }
2193
- /**
2194
- * Opens a cloudflared quick tunnel to the local relay port and resolves once
2195
- * the public URL is assigned.
2196
- *
2197
- * FIX 1 (issue #571): after URL resolution the returned `QuickTunnel` object
2198
- * watches the cloudflared child process for unexpected exits and calls any
2199
- * registered `onUnexpectedExit` callback so the health probe can immediately
2200
- * trigger reissue instead of waiting for the next poll interval.
2201
- */
2202
- async function startQuickTunnel(localPort) {
2203
- await ensureCloudflaredBin();
2204
- const tunnel = Tunnel.quick(`http://127.0.0.1:${localPort}`);
2205
- const url = await new Promise((resolve, reject) => {
2206
- const onUrl = (assigned) => {
2207
- cleanup();
2208
- resolve(assigned);
2209
- };
2210
- const onError = (err) => {
2211
- cleanup();
2212
- reject(err);
2213
- };
2214
- const onExit = (code) => {
2215
- cleanup();
2216
- reject(/* @__PURE__ */ new Error(`cloudflared exited before assigning a URL (code ${code})`));
2217
- };
2218
- const cleanup = () => {
2219
- tunnel.off("url", onUrl);
2220
- tunnel.off("error", onError);
2221
- tunnel.off("exit", onExit);
2222
- };
2223
- tunnel.once("url", onUrl);
2224
- tunnel.once("error", onError);
2225
- tunnel.once("exit", onExit);
2226
- });
2227
- let intentionalStop = false;
2228
- let unexpectedExitCb = null;
2229
- tunnel.once("exit", (code) => {
2230
- if (!intentionalStop && unexpectedExitCb !== null) unexpectedExitCb(code);
2231
- });
2232
- return {
2233
- url,
2234
- wssUrl: url.replace(/^https/, "wss"),
2235
- childPid: tunnel.process?.pid,
2236
- onUnexpectedExit(cb) {
2237
- unexpectedExitCb = cb;
2238
- },
2239
- stop() {
2240
- intentionalStop = true;
2241
- tunnel.stop();
2242
- }
2243
- };
2244
- }
2245
- /**
2246
- * Renders a pure unicode half-block QR string for the given text.
2247
- *
2248
- * Uses `qrcode` (Node full lib) to get the raw bit matrix, then encodes every
2249
- * two vertical modules into a single half-block character:
2250
- * - both dark → `█`
2251
- * - top only → `▀`
2252
- * - bottom only → `▄`
2253
- * - both light → ` ` (space)
2254
- *
2255
- * The output contains **zero ANSI escape codes**, so it renders correctly in
2256
- * every surface (terminal, VS Code, JetBrains, web) and can be scanned by a
2257
- * phone camera when shown verbatim in an agent response.
2258
- *
2259
- * Shared by `renderAttachBanner` (relay wssUrl QR) and the `start_attach`
2260
- * MCP tool response (attach deep-link QR).
2261
- */
2262
- async function renderQr(text) {
2263
- const { default: QRCode } = await import("qrcode");
2264
- const qr = QRCode.create(text, { errorCorrectionLevel: "M" });
2265
- const size = qr.modules.size;
2266
- const data = qr.modules.data;
2267
- const isDark = (x, y) => {
2268
- if (x < 0 || y < 0 || x >= size || y >= size) return false;
2269
- return data[y * size + x] === 1;
2270
- };
2271
- const QUIET = 1;
2272
- const lines = [];
2273
- for (let y = -QUIET; y < size + QUIET; y += 2) {
2274
- let line = "";
2275
- for (let x = -QUIET; x < size + QUIET; x++) {
2276
- const top = isDark(x, y);
2277
- const bot = isDark(x, y + 1);
2278
- line += top && bot ? "█" : top ? "▀" : bot ? "▄" : " ";
2279
- }
2280
- lines.push(line);
2281
- }
2282
- return `${lines.join("\n")}\n`;
2283
- }
2284
- /**
2285
- * Renders the attach banner (relay URL + unicode half-block QR) as a string.
2286
- *
2287
- * The QR is produced by `renderQr` (a half-block matrix, not the
2288
- * `qrcode-terminal` ASCII art used by the unplugin banner) and encodes the
2289
- * base `wssUrl` only. When `totpEnabled` is true, a note
2290
- * is added that attach URLs generated by `start_attach` will include a
2291
- * live TOTP code (`at=`) appended at call time.
2292
- *
2293
- * SECRET-HANDLING: no secret value, TOTP code, or intermediate value is
2294
- * included in this output.
2295
- */
2296
- async function renderAttachBanner(input) {
2297
- const qr = await renderQr(input.wssUrl);
2298
- const authNote = input.totpEnabled ? " auth: TOTP enabled — attach URLs include a rotating code (at=)." : " auth: none (set AIT_DEBUG_TOTP_SECRET to enable TOTP).";
2299
- return [
2300
- "",
2301
- "AIT debug — attach a mini-app to this session",
2302
- "",
2303
- ` relay (wss): ${input.wssUrl}`,
2304
- authNote,
2305
- "",
2306
- " Use start_attach to generate a deep link with the current TOTP code.",
2307
- " Scan the QR to locate the relay (open the dog-food URL separately with",
2308
- " ?debug=1&relay=<wss>&at=<code> or use the start_attach tool):",
2309
- "",
2310
- qr
2311
- ].join("\n");
2312
- }
2313
- /** Prints the attach banner to stderr (stdout is the MCP stdio channel). */
2314
- async function printAttachBanner(input) {
2315
- const banner = await renderAttachBanner(input);
2316
- process.stderr.write(`${banner}\n`);
2317
- }
2318
- /**
2319
- * Probes `https://` URL with an HTTP HEAD request.
2320
- * Returns `true` when the server responds (any HTTP status), `false` on
2321
- * network error or timeout.
2322
- *
2323
- * We treat any HTTP response (including 4xx/5xx) as "tunnel alive" because
2324
- * cloudflared itself responds to the HEAD — if the tunnel process died, the
2325
- * request fails at the network level rather than returning a status code.
2326
- *
2327
- * @param httpsUrl - The `https://` tunnel URL to probe.
2328
- * @param timeoutMs - Abort timeout in ms. Default 10 000.
2329
- */
2330
- async function probeTunnel(httpsUrl, timeoutMs = 1e4) {
2331
- const { default: https } = await import("node:https");
2332
- return new Promise((resolve) => {
2333
- const url = new URL(httpsUrl);
2334
- const timer = setTimeout(() => {
2335
- req.destroy();
2336
- resolve(false);
2337
- }, timeoutMs);
2338
- const req = https.request({
2339
- hostname: url.hostname,
2340
- port: 443,
2341
- path: url.pathname || "/",
2342
- method: "HEAD"
2343
- }, (_res) => {
2344
- clearTimeout(timer);
2345
- _res.resume();
2346
- resolve(true);
2347
- });
2348
- req.on("error", () => {
2349
- clearTimeout(timer);
2350
- resolve(false);
2351
- });
2352
- req.end();
2353
- });
2354
- }
2355
- /**
2356
- * Starts a periodic health probe for a cloudflared quick tunnel.
2357
- *
2358
- * Every `probeIntervalMs` the probe sends an HTTP HEAD request to the tunnel's
2359
- * `https://` URL. When `failuresBeforeReissue` consecutive failures are
2360
- * detected, it attempts to spawn a new tunnel (up to `MAX_REISSUE_ATTEMPTS`
2361
- * times). On success the caller is notified via `onReissue`; on permanent
2362
- * failure via `onPermanentDrop`.
2363
- *
2364
- * FIX 1 (issue #571): the probe also subscribes to each tunnel's
2365
- * `onUnexpectedExit` callback to detect child death *immediately* instead of
2366
- * waiting for the next probe interval (which could be 60 s away).
2367
- *
2368
- * @returns `stop` — call during server shutdown to clear the probe interval.
2369
- */
2370
- function startTunnelHealthProbe(initialTunnel, localPort, options) {
2371
- const { probeIntervalMs = 6e4, failuresBeforeReissue = 2, onReissue, onPermanentDrop, log = (msg) => process.stderr.write(msg), probe = probeTunnel, spawnTunnel = startQuickTunnel } = options;
2372
- let currentTunnel = initialTunnel;
2373
- let consecutiveFailures = 0;
2374
- let reissueAttempts = 0;
2375
- let stopped = false;
2376
- const doReissueOrDrop = async () => {
2377
- if (stopped) return;
2378
- reissueAttempts += 1;
2379
- if (reissueAttempts > 3) return;
2380
- log(`[ait-debug] tunnel drop detected — reissuing (attempt ${reissueAttempts}/3)\n`);
2381
- try {
2382
- const newTunnel = await spawnTunnel(localPort);
2383
- try {
2384
- currentTunnel.stop();
2385
- } catch {}
2386
- currentTunnel = newTunnel;
2387
- consecutiveFailures = 0;
2388
- armChildExitWatch(newTunnel);
2389
- log(`[ait-debug] tunnel reissued — new relay: ${newTunnel.wssUrl}\n`);
2390
- onReissue(newTunnel);
2391
- } catch (err) {
2392
- const message = err instanceof Error ? err.message : String(err);
2393
- log(`[ait-debug] tunnel reissue attempt ${reissueAttempts} failed: ${message}\n`);
2394
- if (reissueAttempts >= 3) {
2395
- clearInterval(handle);
2396
- stopped = true;
2397
- const droppedAt = (/* @__PURE__ */ new Date()).toISOString();
2398
- log(`[ait-debug] tunnel permanently dropped after 3 reissue attempts — restart the debug server to continue (npx @ait-co/devtools devtools-mcp).
2399
- `);
2400
- onPermanentDrop(droppedAt);
2401
- }
2402
- }
2403
- };
2404
- const armChildExitWatch = (t) => {
2405
- t.onUnexpectedExit((code) => {
2406
- if (stopped) return;
2407
- log(`[ait-debug] cloudflared child exited unexpectedly (code=${code}) — triggering immediate reissue\n`);
2408
- consecutiveFailures = failuresBeforeReissue;
2409
- doReissueOrDrop();
2410
- });
2411
- };
2412
- armChildExitWatch(initialTunnel);
2413
- const handle = setInterval(() => {
2414
- (async () => {
2415
- if (stopped) return;
2416
- const httpsUrl = currentTunnel.url;
2417
- if (await probe(httpsUrl)) {
2418
- if (consecutiveFailures > 0) log("[ait-debug] tunnel health probe: tunnel recovered\n");
2419
- consecutiveFailures = 0;
2420
- reissueAttempts = 0;
2421
- return;
2422
- }
2423
- consecutiveFailures += 1;
2424
- log(`[ait-debug] tunnel health probe: failure ${consecutiveFailures}/${failuresBeforeReissue} (url=${httpsUrl})\n`);
2425
- if (consecutiveFailures < failuresBeforeReissue) return;
2426
- await doReissueOrDrop();
2427
- })();
2428
- }, probeIntervalMs);
2429
- return { stop() {
2430
- stopped = true;
2431
- clearInterval(handle);
2432
- } };
2433
- }
2434
- /**
2435
- * Builds a `TunnelStatus` snapshot that includes drop state.
2436
- *
2437
- * Convenience helper for callers (debug-server) that maintain a mutable
2438
- * `tunnelStatus` object — keeps the shape construction in one place.
2439
- */
2440
- function makeTunnelStatus(up, wssUrl, droppedAt = null, reissueAttempts = 0) {
2441
- return {
2442
- up,
2443
- wssUrl,
2444
- droppedAt,
2445
- reissueAttempts
2446
- };
2447
- }
2448
- //#endregion
2449
- //#region src/mcp/attach-orchestrator.ts
2450
- /**
2451
- * Maximum age (ms) of a page's `lastSeenAt` before it is treated as a ghost
2452
- * and excluded from the `wait_for_attach` short-circuit in `start_attach`
2453
- * (issue #610).
2454
- *
2455
- * Rationale: the env-2 relay is owned by the dev server (unplugin), so every
2456
- * `dev:phone:cdp` restart produces a new quick-tunnel. The old relay goes
2457
- * offline immediately, but the daemon's warm `ChiiCdpConnection` still lists
2458
- * the last-seen target — its `lastSeenAt` freezes at the moment the old relay
2459
- * died. A 5-minute threshold is large enough to be invisible in normal usage
2460
- * (active CDP sessions see a message every few seconds) while being small
2461
- * enough to catch a relay that went down before the daemon was re-entered.
2462
- *
2463
- * Injectable for tests via {@link AttachDeps.stalePageThresholdMs}.
2464
- */
2465
- const RELAY_SANDBOX_STALE_PAGE_MS = 300 * 1e3;
2466
- /**
2467
- * Segment length (ms) of the `start_attach` wait loop (issue #626 — TOTP in-call
2468
- * re-mint). The single-shot `wait_for_attach` of the old attach tool could
2469
- * not re-mint a TOTP code mid-wait; `start_attach` decomposes the wait into
2470
- * SEGMENT_MS slices so it can detect an aging code between slices and re-mint a
2471
- * fresh one without the agent re-calling the tool. 30 s = one TOTP step.
2472
- */
2473
- const START_ATTACH_SEGMENT_MS = 3e4;
2474
- /**
2475
- * Elapsed-since-mint threshold (ms) at which `start_attach` re-mints a fresh
2476
- * TOTP code during its wait loop (issue #626). The relay gate accepts a code for
2477
- * `RELAY_VERIFY_SKEW_STEPS` (6) × 30 s = 180 s backwards from issuance; we re-mint
2478
- * at 150 s to leave a 30 s margin so a phone scan never lands on an expired code.
2479
- */
2480
- const START_ATTACH_REMINT_THRESHOLD_MS = 15e4;
2481
- /**
2482
- * Predicate used by `start_attach`'s `wait_for_attach` loop to decide
2483
- * whether the relay-sandbox connection has a genuinely fresh page attached.
2484
- *
2485
- * Stale-ghost gating (issue #610): when the dev server restarts with a new
2486
- * quick-tunnel, the warm `ChiiCdpConnection` still lists the last-seen target
2487
- * but its `lastSeenAt` is frozen. A page whose `lastSeenAt` exceeds
2488
- * `stalePageThresholdMs` is a ghost from the dead relay — it must NOT
2489
- * short-circuit `wait_for_attach`.
2490
- *
2491
- * Rules:
2492
- * - `pages.length === 0` → false (nothing attached).
2493
- * - Connection has no `getLastSeenAt` (test fakes, local-browser) → falls back
2494
- * to `pages.length > 0` (regression-safe).
2495
- * - `seenMs === null` → treat as fresh (no CDP message received yet, first
2496
- * message pending — the connection is alive).
2497
- * - Otherwise: at least one page must satisfy `nowMs - seenMs <=
2498
- * stalePageThresholdMs`.
2499
- *
2500
- * Exported for unit testing.
2501
- */
2502
- function isSandboxPageFresh(pages, getLastSeenAt, nowMs, stalePageThresholdMs) {
2503
- if (pages.length === 0) return false;
2504
- if (getLastSeenAt === null) return true;
2505
- return pages.some((p) => {
2506
- const seenMs = getLastSeenAt(p.id);
2507
- if (seenMs === null) return true;
2508
- return nowMs - seenMs <= stalePageThresholdMs;
2509
- });
2510
- }
2511
- /**
2512
- * Parses `_deploymentId` from the query string of a scheme URL.
2513
- *
2514
- * Returns `null` when the param is absent or empty — callers treat that as
2515
- * "no deploymentId filter; match on presence only" and fall back to the
2516
- * original `attachedPages.length > 0` condition.
2517
- *
2518
- * SECRET-HANDLING: deploymentId is a public identifier and may appear in
2519
- * debug output. Never confuse it with TOTP secrets or relay tunnel URLs.
2520
- */
2521
- function extractDeploymentId(schemeUrl) {
2522
- try {
2523
- const qIndex = schemeUrl.indexOf("?");
2524
- if (qIndex === -1) return null;
2525
- const id = new URLSearchParams(schemeUrl.slice(qIndex + 1)).get("_deploymentId");
2526
- return id && id.length > 0 ? id : null;
2527
- } catch {
2528
- return null;
2529
- }
2530
- }
2531
- /** Resolves `deps.stalePageThresholdMs` to its default. */
2532
- function resolveStalePageThresholdMs(deps) {
2533
- return deps.stalePageThresholdMs ?? 3e5;
2534
- }
2535
- /** Resolves `deps.nowMs` to its default (`Date.now`). */
2536
- function resolveNowMs(deps) {
2537
- return deps.nowMs ?? (() => Date.now());
2538
- }
2539
- /** Resolves `deps.canOpenBrowser` to the module default. */
2540
- function resolveCanOpenBrowser(deps) {
2541
- return deps.canOpenBrowser ?? canOpenBrowser;
2542
- }
2543
- /**
2544
- * Waits for the first target matching `filterFn` to attach, using the
2545
- * event-driven `waitForFirstTarget()` when the connection supports it
2546
- * (interface-optional member, present on `ChiiCdpConnection`), or falling
2547
- * back to a polling loop for connections that don't implement it (test fakes,
2548
- * `LocalCdpConnection`).
2549
- *
2550
- * This eliminates the polling-only race that previously caused `wait_for_attach`
2551
- * to resolve before the relay had observed the first inbound CDP message from
2552
- * the phone.
2553
- *
2554
- * Timeout note: callers (e.g. the `start_attach` path) always pass an
2555
- * explicit `timeoutMs`, sourced from the factory's `waitForAttachTimeoutMs`
2556
- * (default 60 000). That value is forwarded to `waitForFirstTarget`, so it
2557
- * overrides that method's own 90 000 signature default — the effective
2558
- * wait on the tool path is 60 s, not 90 s.
2559
- *
2560
- * @param connection - The CDP connection (production or fake).
2561
- * @param filterFn - Resolves when this predicate is satisfied.
2562
- * @param timeoutMs - Maximum wait time in ms.
2563
- * @param pollIntervalMs - Fallback poll interval for connections without waitForFirstTarget.
2564
- */
2565
- function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs = 1e3) {
2566
- if (connection.waitForFirstTarget) return connection.waitForFirstTarget(filterFn, timeoutMs, pollIntervalMs);
2567
- return new Promise((resolve, reject) => {
2568
- const deadline = Date.now() + timeoutMs;
2569
- let settled = false;
2570
- const poll = setInterval(() => {
2571
- const targets = connection.listTargets();
2572
- if (filterFn(targets)) {
2573
- settled = true;
2574
- clearInterval(poll);
2575
- resolve(targets);
2576
- } else if (Date.now() >= deadline) {
2577
- settled = true;
2578
- clearInterval(poll);
2579
- reject(/* @__PURE__ */ new Error(`waitForAttachWithEvents: 타임아웃 (${timeoutMs}ms)`));
2580
- }
2581
- }, pollIntervalMs);
2582
- const targets = connection.listTargets();
2583
- if (!settled && filterFn(targets)) {
2584
- settled = true;
2585
- clearInterval(poll);
2586
- resolve(targets);
2587
- }
2588
- });
2589
- }
2590
- /**
2591
- * Synthesizes an attach URL from stored components with a FRESHLY-minted TOTP
2592
- * code (issue #626 §3/§4 — the single mint point). Reads the late-bound secret
2593
- * via `deps.getTotpSecret()` so the project-local `.ait_relay` secret loaded by
2594
- * `switchMode` is visible. SECRET-HANDLING: the minted code rides inside the
2595
- * URL's `at=` param only — never logged or returned separately.
2596
- */
2597
- function mintAttachUrl(deps, parts) {
2598
- const secret = deps.getTotpSecret();
2599
- const code = secret ? generateTotp(secret) : void 0;
2600
- return parts.kind === "launcher" ? buildLauncherAttachUrl(parts.tunnelHttpUrl, parts.wssUrl, code, {
2601
- name: parts.appName,
2602
- ...parts.selfdebug ? { selfdebug: true } : {}
2603
- }) : buildDeepLinkAttachUrl(parts.schemeUrl, parts.wssUrl, code);
2604
- }
2605
- /** Builds the fresh TOTP metadata (expiresAt window) for a tool result. */
2606
- function buildTotpMeta(deps) {
2607
- const secret = deps.getTotpSecret();
2608
- if (secret === void 0 || secret === "") return void 0;
2609
- const STEP_SECONDS = 30;
2610
- const expiresAtMs = resolveNowMs(deps)() + 6 * STEP_SECONDS * 1e3;
2611
- return {
2612
- enabled: true,
2613
- ttlSeconds: 6 * STEP_SECONDS,
2614
- expiresAt: new Date(expiresAtMs).toISOString()
2615
- };
2616
- }
2617
- /**
2618
- * Env-specific validation + component bundle for `start_attach` (issue #626).
2619
- * Branches on `env`: `relay-mobile` reads AIT_TUNNEL_BASE_URL + builds launcher
2620
- * parts; `relay-dev` requires scheme_url + builds scheme parts. Returns
2621
- * `{ ok: false, error }` with a ready McpResult on any failure.
2622
- */
2623
- async function prepareAttach(deps, env, args, conn) {
2624
- const { getTunnelStatus, getTotpSecret } = deps;
2625
- const stalePageThresholdMs = resolveStalePageThresholdMs(deps);
2626
- const nowMs = resolveNowMs(deps);
2627
- const selfdebug = args?.selfdebug === true;
2628
- if (selfdebug && env !== "relay-mobile") return {
2629
- ok: false,
2630
- error: mcpError("start_attach: selfdebug=true는 env 2 / relay-sandbox 전용 기능입니다. 현재 환경(env 3)에서는 launcher가 없어 self-target 모드를 지원하지 않습니다. launcher self-target이 필요하다면 relay-sandbox 모드로 전환하세요.")
2631
- };
2632
- if (env === "relay-mobile") {
2633
- const rawProjectRoot = args?.projectRoot;
2634
- const buildProjectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
2635
- let tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
2636
- if (tunnelHttpUrl === "" && buildProjectRoot !== void 0) {
2637
- const { readRelayUrls } = await import("./relay-url-store-C0qukm3R.js");
2638
- tunnelHttpUrl = (await readRelayUrls({ projectRoot: buildProjectRoot }))?.tunnelBaseUrl ?? "";
2639
- }
2640
- if (tunnelHttpUrl === "") return {
2641
- ok: false,
2642
- error: mcpError("start_attach(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. 자동 발견이 되지 않을 경우 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 직접 전달하세요.")
2643
- };
2644
- const tunnelStatus = getTunnelStatus();
2645
- if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return {
2646
- ok: false,
2647
- error: mcpError("start_attach(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.")
2648
- };
2649
- const secret = getTotpSecret();
2650
- if (secret === void 0 || secret === "") return {
2651
- ok: false,
2652
- error: mcpError("start_attach(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.")
2653
- };
2654
- let launcherAppName;
2655
- if (buildProjectRoot !== void 0) try {
2656
- const { readFileSync } = await import("node:fs");
2657
- const pkgRaw = readFileSync(`${buildProjectRoot}/package.json`, "utf8");
2658
- const pkg = JSON.parse(pkgRaw);
2659
- const rawName = typeof pkg.name === "string" ? pkg.name : "";
2660
- launcherAppName = (rawName.includes("/") ? rawName.slice(rawName.indexOf("/") + 1) : rawName).trim() || void 0;
2661
- } catch {}
2662
- const parts = {
2663
- kind: "launcher",
2664
- tunnelHttpUrl,
2665
- wssUrl: tunnelStatus.wssUrl,
2666
- appName: launcherAppName,
2667
- ...selfdebug ? { selfdebug: true } : {}
2668
- };
2669
- const connAsAny = conn;
2670
- const getLastSeenAt = typeof connAsAny.getTargetLastSeenAt === "function" ? (id) => connAsAny.getTargetLastSeenAt(id) : null;
2671
- const callNow = nowMs();
2672
- const isMatchingPage = (pages) => isSandboxPageFresh(pages, getLastSeenAt, callNow, stalePageThresholdMs);
2673
- const buildTimeoutError = (baseText, timeoutSec, observed) => {
2674
- const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
2675
- return `${baseText}\n\nNo page attached within ${timeoutSec}s${observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : ""} — launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.`;
2676
- };
2677
- return {
2678
- ok: true,
2679
- parts,
2680
- isMatchingPage,
2681
- buildTimeoutError,
2682
- authorityWarning: void 0,
2683
- totpMeta: buildTotpMeta(deps)
2684
- };
2685
- }
2686
- const schemeUrl = args?.scheme_url;
2687
- if (typeof schemeUrl !== "string" || schemeUrl === "") return {
2688
- ok: false,
2689
- error: mcpError("start_attach: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요. 환경 2(mobile)라면 scheme_url 대신 AIT_TUNNEL_BASE_URL을 설정하세요.")
2690
- };
2691
- {
2692
- const relaySecret = getTotpSecret();
2693
- if (relaySecret === void 0 || relaySecret === "") return {
2694
- ok: false,
2695
- error: mcpError("start_attach(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.")
2696
- };
2697
- }
2698
- const tunnelForBuild = getTunnelStatus();
2699
- if (!tunnelForBuild.up || tunnelForBuild.wssUrl === null) return {
2700
- ok: false,
2701
- error: classifyToolError(/* @__PURE__ */ new Error("tunnel-down:"), "start_attach")
2702
- };
2703
- const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
2704
- const parts = {
2705
- kind: "scheme",
2706
- schemeUrl,
2707
- wssUrl: tunnelForBuild.wssUrl
2708
- };
2709
- const deploymentId = extractDeploymentId(schemeUrl);
2710
- if (!deploymentId) logInfo("tool.call", {
2711
- tool: "start_attach",
2712
- msg: "no _deploymentId in scheme_url; matching on presence only"
2713
- });
2714
- const isMatchingPage = (pages) => {
2715
- if (pages.length === 0) return false;
2716
- if (deploymentId === null) return true;
2717
- return pages.some((p) => p.url.includes(deploymentId));
2718
- };
2719
- const buildTimeoutError = (baseText, timeoutSec, observed) => {
2720
- const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
2721
- const observedNote = observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : "";
2722
- return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
2723
- };
2724
- return {
2725
- ok: true,
2726
- parts,
2727
- isMatchingPage,
2728
- buildTimeoutError,
2729
- authorityWarning,
2730
- totpMeta: buildTotpMeta(deps)
2731
- };
2732
- }
2733
- /**
2734
- * QR render + browser open + segmented attach wait with in-call TOTP re-mint
2735
- * (issue #626 §3). Shared by env-2 and env-3 (4 render paths:
2736
- * headless / browser-opened / browser-open-failed / no-http-server).
2737
- *
2738
- * The wait is decomposed into `START_ATTACH_SEGMENT_MS` slices. Between slices,
2739
- * if the current TOTP code has aged past `START_ATTACH_REMINT_THRESHOLD_MS`,
2740
- * a fresh URL is minted via `mintAttachUrl` and pushed to the dashboard via
2741
- * `onAttachUrlBuilt` (SSE refresh — NO browser re-open). The `reminted` count
2742
- * rides in the success/timeout result.
2743
- *
2744
- * SECRET-HANDLING: attachUrl encodes tunnel/scheme host + the TOTP `at=` code
2745
- * in the QR payload only. The browser is opened on a 127.0.0.1 URL only. The
2746
- * tool result carries `totp.expiresAt` + `reminted` count — never the code.
2747
- */
2748
- async function renderAndMaybeWait(deps, prep, waitForAttach, callTimeoutMs, conn) {
2749
- const { getTunnelStatus, qrHttpServer, onAttachUrlBuilt } = deps;
2750
- const nowMs = resolveNowMs(deps);
2751
- const canOpenBrowserFn = resolveCanOpenBrowser(deps);
2752
- const { parts, isMatchingPage, buildTimeoutError, authorityWarning, totpMeta } = prep;
2753
- let attachUrl = mintAttachUrl(deps, parts);
2754
- onAttachUrlBuilt?.(parts);
2755
- let totpIssuedAt = nowMs();
2756
- let reminted = 0;
2757
- const relayUrl = parts.wssUrl;
2758
- const header = "This tool result is shown to the user directly — do NOT re-print the QR below in your reply (it wastes output tokens). Just tell the user to scan the QR in this output (Ctrl+O to expand if collapsed).";
2759
- const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
2760
- const guiAvailable = canOpenBrowserFn();
2761
- /** Builds the totp object surfaced in results (fresh expiresAt + reminted). */
2762
- const totpResult = () => {
2763
- if (!totpMeta) return void 0;
2764
- const expiresAtMs = totpIssuedAt + 180 * 1e3;
2765
- return {
2766
- enabled: true,
2767
- ttlSeconds: totpMeta.ttlSeconds,
2768
- expiresAt: new Date(expiresAtMs).toISOString(),
2769
- ...reminted > 0 ? { reminted } : {}
2770
- };
2771
- };
2772
- /**
2773
- * Segmented wait with TOTP re-mint (issue #626 §3). Resolves with the
2774
- * attached page list, or rejects on timeout. Between SEGMENT_MS slices it
2775
- * re-mints when the code has aged past the threshold (max ~4 re-mints over
2776
- * 600 s). Returns immediately once a matching page attaches (no re-mint).
2777
- */
2778
- async function waitWithRemint() {
2779
- const deadline = nowMs() + callTimeoutMs;
2780
- if (isMatchingPage(conn.listTargets())) return conn.listTargets();
2781
- for (;;) {
2782
- const remaining = deadline - nowMs();
2783
- if (remaining <= 0) throw new Error(`start_attach: 타임아웃 (${callTimeoutMs}ms)`);
2784
- const segmentMs = Math.min(START_ATTACH_SEGMENT_MS, remaining);
2785
- try {
2786
- return await waitForAttachWithEvents(conn, isMatchingPage, segmentMs);
2787
- } catch {
2788
- if (totpMeta && nowMs() - totpIssuedAt >= 15e4) {
2789
- attachUrl = mintAttachUrl(deps, parts);
2790
- onAttachUrlBuilt?.(parts);
2791
- totpIssuedAt = nowMs();
2792
- reminted += 1;
2793
- }
2794
- }
2795
- }
2796
- }
2797
- /**
2798
- * Assembles the success result after a page attaches. `baseText` carries the
2799
- * QR + pre-wait JSON block (the QR the user already scanned). The attach
2800
- * itself ends the wait, so the QR is moot — what matters now is the final
2801
- * TOTP state. If the segmented wait re-minted (issue #626 §3), surface the
2802
- * post-wait `totp` block (fresh `expiresAt` + `reminted` count) so the result
2803
- * reflects how many times the code rotated during the wait. SECRET-HANDLING:
2804
- * the totp block carries expiresAt + reminted only — never the code value.
2805
- */
2806
- const successResult = (baseText) => {
2807
- const pagesResult = listPages(conn, getTunnelStatus());
2808
- const finalTotp = totpResult();
2809
- const remintNote = finalTotp && reminted > 0 ? `\n\n${JSON.stringify({ totp: finalTotp }, null, 2)}` : "";
2810
- return { content: [{
2811
- type: "text",
2812
- text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}${remintNote}`
2813
- }] };
2814
- };
2815
- /** Runs the wait (when requested) and returns success/timeout result. */
2816
- const runWait = async (baseText) => {
2817
- if (!waitForAttach) return { content: [{
2818
- type: "text",
2819
- text: baseText
2820
- }] };
2821
- try {
2822
- await waitWithRemint();
2823
- } catch {
2824
- const observed = conn.listTargets();
2825
- return {
2826
- content: [{
2827
- type: "text",
2828
- text: buildTimeoutError(baseText, callTimeoutMs / 1e3, observed)
2829
- }],
2830
- isError: true
2831
- };
2832
- }
2833
- return successResult(baseText);
2834
- };
2835
- if (!guiAvailable) {
2836
- const headlessNote = "GUI 환경이 감지되지 않았습니다 (headless/remote 환경). 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
2837
- const qr = await renderQr(attachUrl);
2838
- return runWait(`${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
2839
- attachUrl,
2840
- relayUrl,
2841
- ...totpResult() ? { totp: totpResult() } : {}
2842
- }, null, 2)}\n\n${qr}`);
2843
- }
2844
- if (guiAvailable && qrHttpServer) {
2845
- const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
2846
- if (browserResult.opened) {
2847
- const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
2848
- const openResult = {
2849
- attempted: true,
2850
- succeeded: true,
2851
- ...browserResult.retried ? { retried: true } : {}
2852
- };
2853
- return runWait(`${warningPrefix}${header}\n${JSON.stringify({
2854
- relayUrl,
2855
- openResult,
2856
- ...totpResult() ? { totp: totpResult() } : {}
2857
- }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`);
2858
- }
2859
- const openResult = {
2860
- attempted: true,
2861
- succeeded: false,
2862
- failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
2863
- pngUrl: browserResult.pngUrl,
2864
- ...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
2865
- };
2866
- const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
2867
- const fallbackNote = `브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:\n${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
2868
- const qr = await renderQr(attachUrl);
2869
- return runWait(`${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
2870
- attachUrl,
2871
- relayUrl,
2872
- openResult,
2873
- ...totpResult() ? { totp: totpResult() } : {}
2874
- }, null, 2)}\n\n${qr}`);
2875
- }
2876
- const qr = await renderQr(attachUrl);
2877
- return runWait(`${warningPrefix}${header}\n${JSON.stringify({
2878
- attachUrl,
2879
- relayUrl,
2880
- ...totpResult() ? { totp: totpResult() } : {}
2881
- }, null, 2)}\n\n${qr}`);
2882
- }
2883
- /**
2884
- * Builds a self-contained IIFE DOM expression that renders a dismissible
2885
- * "Debugger Connected" badge on the bottom-left of the phone screen.
2886
- *
2887
- * **Pure function** — returns a JS expression string; does NOT inject it.
2888
- * Injection is performed by {@link injectDebugIndicator} in `cell.ts`.
2889
- *
2890
- * The expression, when evaluated on the page, does the following:
2891
- * 1. Early-returns if `#__ait_debug_indicator` already exists (idempotent —
2892
- * prevents duplicate badges on re-injection after page reload).
2893
- * 2. Appends a fixed-position `<div id="__ait_debug_indicator">` at the
2894
- * bottom-left, accounting for safe-area insets.
2895
- * 3. Attaches a `{ once: true }` `pointerdown` listener that removes the
2896
- * badge on first touch — one-tap dismiss.
2897
- *
2898
- * The expression intentionally contains NO relay URLs, wss addresses, TOTP
2899
- * codes, or any other secrets. It is pure DOM UI text only.
2900
- *
2901
- * SECRET-HANDLING: this expression contains no secrets, relay URLs, wss
2902
- * addresses, or TOTP codes whatsoever — DOM label text only.
2903
- *
2904
- * @param opts.label - Badge text (default: `'Debugger Connected'`).
2905
- * @returns A JS expression string suitable for `Runtime.evaluate`.
2906
- */
2907
- function buildIndicatorExpression(opts) {
2908
- const label = opts?.label ?? "Debugger Connected";
2909
- return `(() => {if (document.getElementById('__ait_debug_indicator')) return;const el = document.createElement('div');el.id = '__ait_debug_indicator';el.style.cssText = ['position:fixed','left:max(12px,calc(env(safe-area-inset-left,0px) + 8px))','bottom:max(12px,calc(env(safe-area-inset-bottom,0px) + 8px))','z-index:2147483647','background:#e5484d','color:#fff','font:bold 11px/1 system-ui,sans-serif','padding:5px 9px','border-radius:6px','pointer-events:auto','user-select:none',].join(';');el.textContent = ${JSON.stringify(label)};el.addEventListener('pointerdown', () => el.remove(), { once: true });document.body.appendChild(el);})()`;
2910
- }
2911
- //#endregion
2912
- //#region src/test-runner/cell.ts
2913
- /**
2914
- * Cell injection utility for the `devtools-test` CLI (issue #684 §4.1).
2915
- *
2916
- * Injects arbitrary globals into the page via `Runtime.evaluate` BEFORE the
2917
- * first test bundle is injected. The injected values are session-global — one
2918
- * call covers all files in the run.
2919
- *
2920
- * The primary use-case is injecting `__AIT_CELL__` (sdkLine/platform) so
2921
- * sdk-example's `aitCapture.ts` picks up the correct test-axis values instead
2922
- * of falling back to `'2.x'`/`'mock'`.
2923
- *
2924
- * devtools does NOT know the shape of `__AIT_CELL__` — it only provides the
2925
- * general injection mechanism. The caller (CLI or MCP auto-attach path) is
2926
- * responsible for constructing the cell object.
2927
- *
2928
- * SECRET-HANDLING: cell values (sdkLine/platform) are not secrets and may be
2929
- * logged at the caller's discretion. This module does NOT log them itself.
2930
- *
2931
- * Node-only. react-free. CdpConnection only.
2932
- */
2933
- /**
2934
- * Injects each key of `globals` into `globalThis` in the page via a single
2935
- * `Runtime.evaluate` call. Must be called BEFORE the first `bundleTestFile`
2936
- * inject — the cell is session-global and applies to all subsequent files.
2937
- *
2938
- * Throws if the CDP evaluate returns an exception.
2939
- *
2940
- * @param conn - The live CDP connection (relay-attached page).
2941
- * @param globals - Plain-JSON-serialisable key→value map to assign onto `globalThis`.
2942
- */
2943
- async function injectGlobals(conn, globals) {
2944
- const expr = `(() => { Object.assign(globalThis, ${JSON.stringify(globals)}); return true; })()`;
2945
- const result = await conn.send("Runtime.evaluate", {
2946
- expression: expr,
2947
- returnByValue: true
2948
- });
2949
- if (result.exceptionDetails) {
2950
- const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "unknown error";
2951
- throw new Error(`injectGlobals: Runtime.evaluate threw: ${msg}`);
2952
- }
2953
- }
2954
- /**
2955
- * Injects the "Debugger Connected" on-phone indicator via `Runtime.evaluate`.
2956
- *
2957
- * Uses the same CDP mechanism as {@link injectGlobals} — a single
2958
- * `Runtime.evaluate` round-trip with the expression built by
2959
- * {@link buildIndicatorExpression}. The indicator is a dismissible red badge
2960
- * rendered at the bottom-left of the page (position:fixed, safe-area-aware).
2961
- *
2962
- * **Isolation**: unlike {@link injectGlobals}, this function NEVER throws to
2963
- * its caller. Injection failure (e.g. page detached during inject, CSS not
2964
- * supported) is swallowed and logged as `console.debug` — the badge is
2965
- * informational UI and must never block attach success or test execution.
2966
- * This is the same fire-and-forget spirit as the eruda mount in
2967
- * `src/in-app/attach.ts`.
2968
- *
2969
- * Call this ONLY on the manual debug paths (start_attach MCP, devtools-test
2970
- * CLI). Do NOT call on the `run_tests` auto-attach path — the red badge
2971
- * would contaminate screenshots, measure_safe_area probes, and DOM snapshots
2972
- * taken during automated measurement runs.
2973
- *
2974
- * SECRET-HANDLING: the injected expression contains no secrets, relay URLs,
2975
- * wss addresses, or TOTP codes — it is pure DOM UI text built by
2976
- * {@link buildIndicatorExpression}.
2977
- *
2978
- * @param conn - The live CDP connection (relay-attached page).
2979
- * @param opts - Optional overrides forwarded to {@link buildIndicatorExpression}.
2980
- */
2981
- async function injectDebugIndicator(conn, opts) {
2982
- try {
2983
- await conn.send("Runtime.evaluate", {
2984
- expression: buildIndicatorExpression(opts),
2985
- returnByValue: true
2986
- });
2987
- } catch (err) {
2988
- console.debug("[@ait-co/devtools] debug indicator inject skipped:", err);
2989
- }
2990
- }
2991
- //#endregion
2992
125
  //#region src/test-runner/discover.ts
2993
126
  /**
2994
127
  * Test-file discovery shared by the `devtools-test` CLI and the `run_tests`
@@ -3020,6 +153,72 @@ async function discoverTestFiles(patterns, cwd) {
3020
153
  return [...out].sort();
3021
154
  }
3022
155
  //#endregion
156
+ //#region src/test-runner/relay-factory.ts
157
+ /**
158
+ * Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay
159
+ * connection.
160
+ *
161
+ * `open()` performs the full attach lifecycle and BLOCKS for tens of seconds (up
162
+ * to `timeoutMs`) while a human scans the rendered QR with their phone — there
163
+ * is no way around the manual scan for env3. It resolves with the live
164
+ * `CdpConnection` once a matching page attaches; `close()` tears the relay
165
+ * family down.
166
+ *
167
+ * The factory holds the booted relay family in a closure so `close()` can stop
168
+ * it. A second `open()` on the same factory boots a fresh family (the previous
169
+ * one should have been `close()`d first).
170
+ */
171
+ function createRelayConnectionFactory(opts) {
172
+ const projectRoot = opts.projectRoot ?? process.cwd();
173
+ const timeoutMs = opts.timeoutMs ?? 6e5;
174
+ const headless = opts.headless === true;
175
+ let family;
176
+ return {
177
+ async open() {
178
+ const { prepareAttach, renderAndMaybeWait } = await import("./attach-orchestrator-C_6xb_L5.js");
179
+ const { injectDebugIndicator, injectGlobals } = await import("./cell-BJiaGlVu.js");
180
+ const { loadRelaySecretReadOnly } = await import("./relay-secret-store-BlFEhqwb.js");
181
+ const { bootRelayFamily, buildRelayVerifyAuth } = await Promise.resolve().then(() => debug_server_exports);
182
+ await loadRelaySecretReadOnly({ projectRoot });
183
+ const booted = await bootRelayFamily({ verifyAuth: buildRelayVerifyAuth() });
184
+ family = booted;
185
+ const attachDeps = {
186
+ getTunnelStatus: booted.getTunnelStatus ?? (() => ({
187
+ up: false,
188
+ wssUrl: null
189
+ })),
190
+ getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
191
+ qrHttpServer: void 0,
192
+ onAttachUrlBuilt: void 0,
193
+ canOpenBrowser: () => !headless
194
+ };
195
+ const prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
196
+ if (!prep.ok) {
197
+ booted.stop();
198
+ family = void 0;
199
+ throw new Error("createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up");
200
+ }
201
+ const waitResult = await renderAndMaybeWait(attachDeps, prep, true, timeoutMs, booted.connection);
202
+ if (waitResult.isError) {
203
+ booted.stop();
204
+ family = void 0;
205
+ const timeoutSec = Math.round(timeoutMs / 1e3);
206
+ throw new Error(`createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`);
207
+ }
208
+ const qrChunks = waitResult.content.filter((c) => c.type === "text").map((c) => c.text);
209
+ opts.onQrContent(qrChunks);
210
+ await injectDebugIndicator(booted.connection);
211
+ if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
212
+ await booted.connection.enableDomains();
213
+ return booted.connection;
214
+ },
215
+ async close(_connection) {
216
+ family?.stop();
217
+ family = void 0;
218
+ }
219
+ };
220
+ }
221
+ //#endregion
3023
222
  //#region src/test-runner/bundle.ts
3024
223
  /**
3025
224
  * esbuild-based bundler for user test files.
@@ -3261,41 +460,59 @@ function userFactoryPlugin(absPath) {
3261
460
  "}"
3262
461
  ].join("\n"),
3263
462
  loader: "ts",
3264
- resolveDir: path.dirname(absPath)
463
+ resolveDir: path$1.dirname(absPath)
3265
464
  };
3266
465
  });
3267
466
  }
3268
467
  };
3269
468
  }
3270
469
  /**
3271
- * Returns the absolute path to the test-runner runtime module.
3272
- *
3273
- * Searches candidates in priority order:
3274
- * 1. Co-located `runtime.ts` / `runtime.js` covers the source tree
3275
- * (tsx / ts-node) and the `dist/test-runner/` entry.
3276
- * 2. `../test-runner/runtime.js` covers the `dist/mcp/cli.js` entry,
3277
- * where `import.meta.url` resolves to `dist/mcp/` (a sibling directory
3278
- * of `dist/test-runner/`). Without this second candidate the MCP entry
3279
- * point would look for `dist/mcp/runtime.js`, which does not exist, and
3280
- * every `run_tests` call would fail with an esbuild "Could not resolve"
3281
- * error (#678).
3282
- *
3283
- * Returns the first candidate that exists on disk. Falls back to the
3284
- * co-located `runtime.js` path so esbuild produces a clear "file not found"
3285
- * error rather than a cryptic failure.
470
+ * Returns the absolute filesystem path to the test-runner runtime module
471
+ * (dist/test-runner/runtime.js — a fully self-contained page-side bundle).
472
+ *
473
+ * Rolldown code-splitting duplicates this bundling logic into shared chunks
474
+ * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
475
+ * dist/test-runner/bundle.js (dir = dist/test-runner/), while the `devtools-mcp`
476
+ * daemon (dist/mcp/cli.js) pulls it through a ROOT chunk
477
+ * (dist/debug-server-<hash>.js, dir = dist/). A fixed `..`-hop candidate list
478
+ * is therefore wrong from at least one chunk — the live #697 regression.
479
+ *
480
+ * This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it
481
+ * probes the co-located `runtime.js` and the nested `test-runner/runtime.js`,
482
+ * then ascends one directory at a time (bounded) repeating both probes. The
483
+ * nested probe catches dist/test-runner/runtime.js from the dist/ root level no
484
+ * matter which depth the chunk was hoisted to (root, dist/mcp/, or a future
485
+ * relocation). The build always emits dist/test-runner/runtime.js (tsdown entry
486
+ * `'test-runner/runtime'`; guarded by scripts/check-test-runner-dist.sh).
487
+ *
488
+ * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
489
+ * read, bypassing Node module resolution entirely, so this works identically in
490
+ * the npx-daemon context (its own dist tree) and the consumer-CLI context
491
+ * (the mini-app's installed @ait-co/devtools dist) — neither needs the package
492
+ * to be node-resolvable from the caller.
3286
493
  */
3287
494
  function getRuntimePath() {
3288
- const dir = path.dirname(fileURLToPath(import.meta.url));
3289
- const candidates = [
3290
- path.join(dir, "runtime.ts"),
3291
- path.join(dir, "runtime.js"),
3292
- path.join(dir, "..", "test-runner", "runtime.js")
495
+ const startDir = path$1.dirname(fileURLToPath(import.meta.url));
496
+ const RELATIVE_PROBES = [
497
+ ["runtime.js"],
498
+ ["test-runner", "runtime.js"],
499
+ ["runtime.ts"],
500
+ ["test-runner", "runtime.ts"]
3293
501
  ];
3294
- for (const candidate of candidates) try {
3295
- accessSync(candidate);
3296
- return candidate;
3297
- } catch {}
3298
- return path.join(dir, "runtime.js");
502
+ let dir = startDir;
503
+ for (let i = 0; i < 12; i++) {
504
+ for (const segs of RELATIVE_PROBES) {
505
+ const candidate = path$1.join(dir, ...segs);
506
+ try {
507
+ accessSync(candidate);
508
+ return candidate;
509
+ } catch {}
510
+ }
511
+ const parent = path$1.dirname(dir);
512
+ if (parent === dir) break;
513
+ dir = parent;
514
+ }
515
+ return path$1.join(startDir, "runtime.js");
3299
516
  }
3300
517
  /**
3301
518
  * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
@@ -3325,7 +542,7 @@ async function bundleTestFile(absPath, opts) {
3325
542
  stdin: {
3326
543
  contents: wrapperContent,
3327
544
  loader: "ts",
3328
- resolveDir: path.dirname(absPath)
545
+ resolveDir: path$1.dirname(absPath)
3329
546
  },
3330
547
  bundle: true,
3331
548
  format: "iife",
@@ -3342,7 +559,7 @@ async function bundleTestFile(absPath, opts) {
3342
559
  treeShaking: true,
3343
560
  footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
3344
561
  });
3345
- const warnings = result.warnings.map((w) => `${path.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
562
+ const warnings = result.warnings.map((w) => `${path$1.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
3346
563
  const outputFile = result.outputFiles?.[0];
3347
564
  if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
3348
565
  return {
@@ -3351,6 +568,47 @@ async function bundleTestFile(absPath, opts) {
3351
568
  };
3352
569
  }
3353
570
  //#endregion
571
+ //#region src/test-runner/capture.ts
572
+ /** The exact console-line prefix sdk-example's `flushCapture` emits. */
573
+ const CAPTURE_PREFIX = "__AIT_CAPTURE__ ";
574
+ /**
575
+ * Parses raw console line texts into {@link AitCaptureLine}s.
576
+ *
577
+ * Filtering rules (each independently drops a line — never throws):
578
+ * - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);
579
+ * - there must be a non-empty category token (up to the next space);
580
+ * - the remaining payload must be valid JSON (`JSON.parse` succeeds).
581
+ *
582
+ * Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are
583
+ * silently discarded — capture harvesting is best-effort and must never fail a
584
+ * run or leak a malformed/secret-bearing line.
585
+ *
586
+ * @param raw - Console line objects (only `.text` is read).
587
+ * @returns The captured lines, in input order.
588
+ */
589
+ function parseCaptureLines(raw) {
590
+ const out = [];
591
+ for (const { text } of raw) {
592
+ if (!text.startsWith(CAPTURE_PREFIX)) continue;
593
+ const body = text.slice(16);
594
+ const spaceIdx = body.indexOf(" ");
595
+ if (spaceIdx === -1) continue;
596
+ const category = body.slice(0, spaceIdx);
597
+ const json = body.slice(spaceIdx + 1);
598
+ if (category === "" || json === "") continue;
599
+ try {
600
+ JSON.parse(json);
601
+ } catch {
602
+ continue;
603
+ }
604
+ out.push({
605
+ category,
606
+ json
607
+ });
608
+ }
609
+ return out;
610
+ }
611
+ //#endregion
3354
612
  //#region src/test-runner/rpc.ts
3355
613
  /** Maximum milliseconds to wait for a single evaluate round-trip. */
3356
614
  const DEFAULT_TIMEOUT_MS = 3e4;
@@ -3452,27 +710,45 @@ async function runTestFilesOverRelay(connection, files, opts) {
3452
710
  const wallStart = Date.now();
3453
711
  const startedAt = new Date(wallStart).toISOString();
3454
712
  const fileResults = [];
3455
- for (const file of files) {
3456
- let fileEntry;
3457
- try {
3458
- const { code } = await bundleTestFile(file, opts?.bundleOptions);
3459
- const rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
3460
- if (rpcResult.ok) fileEntry = {
3461
- file,
3462
- result: rpcResult.report
3463
- };
3464
- else fileEntry = {
3465
- file,
3466
- result: { error: rpcResult.error }
3467
- };
3468
- } catch (e) {
3469
- fileEntry = {
3470
- file,
3471
- result: { error: e instanceof Error ? e.message : String(e) }
3472
- };
713
+ let domainsEnabled = false;
714
+ try {
715
+ await connection.enableDomains();
716
+ domainsEnabled = true;
717
+ } catch (e) {
718
+ process.stderr.write(`relay-worker: enableDomains() failed before run — console capture may be empty (${e instanceof Error ? e.message : String(e)})\n`);
719
+ }
720
+ const collectCaptures = opts?.collectCaptures === true;
721
+ const liveConsole = [];
722
+ let unsubscribeConsole;
723
+ if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
724
+ liveConsole.push(event);
725
+ });
726
+ try {
727
+ for (const file of files) {
728
+ let fileEntry;
729
+ try {
730
+ const { code } = await bundleTestFile(file, opts?.bundleOptions);
731
+ const rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
732
+ if (rpcResult.ok) fileEntry = {
733
+ file,
734
+ result: rpcResult.report
735
+ };
736
+ else fileEntry = {
737
+ file,
738
+ result: { error: rpcResult.error }
739
+ };
740
+ } catch (e) {
741
+ fileEntry = {
742
+ file,
743
+ result: { error: e instanceof Error ? e.message : String(e) }
744
+ };
745
+ }
746
+ fileResults.push(fileEntry);
3473
747
  }
3474
- fileResults.push(fileEntry);
748
+ } finally {
749
+ unsubscribeConsole?.();
3475
750
  }
751
+ const captures = collectCaptures ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) }))) : [];
3476
752
  const totals = fileResults.reduce((acc, { result }) => {
3477
753
  if ("error" in result) {
3478
754
  acc.failed += 1;
@@ -3494,9 +770,170 @@ async function runTestFilesOverRelay(connection, files, opts) {
3494
770
  startedAt,
3495
771
  duration: Date.now() - wallStart,
3496
772
  files: fileResults,
3497
- totals
773
+ totals,
774
+ captures
775
+ };
776
+ }
777
+ /**
778
+ * Renders one `Runtime.consoleAPICalled` event to a single line of text, the
779
+ * same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-
780
+ * joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,
781
+ * which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)
782
+ * onto the test-runner entry.
783
+ *
784
+ * SECRET-HANDLING: this only stringifies console args; the caller's
785
+ * allowlist-prefix parser then discards everything that is not a genuine
786
+ * `__AIT_CAPTURE__` line.
787
+ */
788
+ function renderConsoleLineText(event) {
789
+ return event.args.map((arg) => {
790
+ if (arg.value !== void 0) {
791
+ if (typeof arg.value === "string") return arg.value;
792
+ try {
793
+ return JSON.stringify(arg.value);
794
+ } catch {
795
+ return String(arg.value);
796
+ }
797
+ }
798
+ if (arg.description !== void 0) return arg.description;
799
+ if (arg.className !== void 0) return arg.className;
800
+ return arg.subtype ?? arg.type;
801
+ }).join(" ");
802
+ }
803
+ //#endregion
804
+ //#region src/test-runner/report.ts
805
+ /**
806
+ * Runner-agnostic report serialisation for env3 test runs (devtools#696).
807
+ *
808
+ * Both env3 execution paths — the Vitest custom pool (`pool.ts`) and the
809
+ * standalone `devtools-test` CLI (`cli.ts`) — call the same core
810
+ * `runTestFilesOverRelay` and so produce the same {@link RelayRunReport}. This
811
+ * module is the single, runner-neutral place that turns that in-memory report
812
+ * into a stable on-disk artifact so a 2.x run and a 3.0 run can be diffed
813
+ * cell-by-cell after the fact.
814
+ *
815
+ * The serialised schema is deliberately MINIMAL and secret-free:
816
+ *
817
+ * - file paths are stored RELATIVE to `projectRoot` (no absolute `/Users/...`
818
+ * leakage — see {@link RunnerAgnosticReport.files});
819
+ * - the cell metadata (sdkLine/platform) is baked INTO the body, not only the
820
+ * filename, so a moved artifact never loses its provenance;
821
+ * - NO relay wss / scheme / TOTP / relayUrl fields exist in the schema at all
822
+ * (enforced by the type + this comment) — error strings are the matcher
823
+ * message only, inherited from rpc.ts which already strips expression/value.
824
+ *
825
+ * react-free — depends only on the type-level `RelayRunReport` and `node:fs` /
826
+ * `node:path`. Safe to bundle without pulling the chii/cloudflared graph.
827
+ */
828
+ /**
829
+ * Converts an absolute (or already-relative) file path to a projectRoot-relative
830
+ * one. `path.relative` returns `''` when the paths are equal — guard that to the
831
+ * basename so the field is never empty.
832
+ *
833
+ * SECRET-HANDLING: this is the single choke point that strips absolute project
834
+ * paths from the artifact.
835
+ */
836
+ function relativise(projectRoot, file) {
837
+ const rel = path.relative(projectRoot, file);
838
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return path.basename(file);
839
+ return rel;
840
+ }
841
+ /**
842
+ * Serialises a {@link RelayRunReport} into the runner-agnostic, secret-free
843
+ * on-disk shape. Pure — no IO; testable with a plain report + meta.
844
+ *
845
+ * @param report - The core relay run report.
846
+ * @param meta - Cell axes + projectRoot (projectRoot is consumed, not stored).
847
+ */
848
+ function serializeRelayReport(report, meta) {
849
+ return {
850
+ cell: {
851
+ sdkLine: meta.sdkLine,
852
+ platform: meta.platform
853
+ },
854
+ startedAt: report.startedAt,
855
+ duration: report.duration,
856
+ totals: report.totals,
857
+ files: report.files.map((f) => {
858
+ const file = relativise(meta.projectRoot, f.file);
859
+ if ("error" in f.result) return {
860
+ file,
861
+ error: f.result.error
862
+ };
863
+ return {
864
+ file,
865
+ duration: f.result.duration,
866
+ passed: f.result.passed,
867
+ failed: f.result.failed,
868
+ skipped: f.result.skipped,
869
+ tests: f.result.tests
870
+ };
871
+ })
3498
872
  };
3499
873
  }
874
+ /**
875
+ * Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating
876
+ * `dir` if needed. Returns the absolute path written.
877
+ *
878
+ * The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as
879
+ * distinct artifacts in the same directory; the same cell metadata is also baked
880
+ * into the body so a renamed/moved file still carries its provenance.
881
+ *
882
+ * SECRET-HANDLING: the written body contains no relay/secret fields (the schema
883
+ * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
884
+ *
885
+ * @param report - The core relay run report.
886
+ * @param dir - Output directory (created recursively if missing).
887
+ * @param meta - Cell axes + projectRoot.
888
+ * @returns The absolute path of the written file.
889
+ */
890
+ async function writeReportArtifact(report, dir, meta) {
891
+ const serialised = serializeRelayReport(report, meta);
892
+ await mkdir(dir, { recursive: true });
893
+ const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
894
+ await writeFile(outFile, `${JSON.stringify(serialised, null, 2)}\n`, "utf8");
895
+ return outFile;
896
+ }
897
+ /**
898
+ * Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,
899
+ * named `<category>.<sdkLine>.<platform>.json` — the SAME convention
900
+ * sdk-example's env1 `flushCapture` uses on the filesystem, so env1 and env3
901
+ * capture artifacts line up for diffing.
902
+ *
903
+ * Each line's `json` payload is an opaque JSON array of capture records. Lines
904
+ * sharing a category are concatenated into one array, in harvest order.
905
+ *
906
+ * SECRET-HANDLING: only allowlist-prefixed capture lines reach here (the parser
907
+ * dropped wss/scheme noise); the `json` payload is written verbatim but is a
908
+ * capture record array, not a relay/secret.
909
+ *
910
+ * @param captures - Parsed capture lines (from `RelayRunReport.captures`).
911
+ * @param dir - Output directory (created recursively if missing).
912
+ * @param cell - Cell axes for the filename suffix.
913
+ * @returns The absolute paths written (one per category), in category order.
914
+ */
915
+ async function writeCaptureArtifacts(captures, dir, cell) {
916
+ if (captures.length === 0) return [];
917
+ const byCategory = /* @__PURE__ */ new Map();
918
+ for (const { category, json } of captures) {
919
+ let merged = byCategory.get(category);
920
+ if (!merged) {
921
+ merged = [];
922
+ byCategory.set(category, merged);
923
+ }
924
+ const parsed = JSON.parse(json);
925
+ if (Array.isArray(parsed)) merged.push(...parsed);
926
+ else merged.push(parsed);
927
+ }
928
+ await mkdir(dir, { recursive: true });
929
+ const written = [];
930
+ for (const [category, records] of byCategory) {
931
+ const outFile = path.join(dir, `${category}.${cell.sdkLine}.${cell.platform}.json`);
932
+ await writeFile(outFile, `${JSON.stringify(records, null, 2)}\n`, "utf8");
933
+ written.push(outFile);
934
+ }
935
+ return written;
936
+ }
3500
937
  //#endregion
3501
938
  //#region src/test-runner/cli.ts
3502
939
  /**
@@ -3524,6 +961,12 @@ OPTIONS
3524
961
  --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
3525
962
  --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
3526
963
  (mock|ios|android, default: AIT_CELL_PLATFORM env)
964
+ --report-dir <dir> Persist a runner-agnostic report + captures to <dir>
965
+ (report: <sdkLine>.<platform>.json; captures:
966
+ <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
967
+ Omitted = nothing saved. Enables console capture.
968
+ --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
969
+ non-interactive stdout / CI / AIT_NO_QR_STDOUT)
3527
970
  --headless Disable browser auto-open (text QR only)
3528
971
  --project-root <dir> Project root for .ait_relay secret lookup
3529
972
  (default: current working directory)
@@ -3536,13 +979,19 @@ DESCRIPTION
3536
979
  injects the bundle into the attached WebView via Runtime.evaluate, and prints
3537
980
  a summary.
3538
981
 
982
+ With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
983
+ runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
984
+ compared offline.
985
+
3539
986
  The test files run against the live relay connection started by this process;
3540
987
  no separate MCP daemon is required.
3541
988
 
3542
989
  EXAMPLE
3543
990
  devtools-test 'src/**/*.ait.test.ts' \\
3544
991
  --scheme-url "intoss-private://..." \\
992
+ --cell-sdk-line 3.x \\
3545
993
  --cell-platform ios \\
994
+ --report-dir .ait-report \\
3546
995
  --timeout 60000
3547
996
 
3548
997
  `.trimStart();
@@ -3561,27 +1010,41 @@ async function runWithConnection(connection, files, opts) {
3561
1010
  return report;
3562
1011
  }
3563
1012
  /**
1013
+ * Decides whether to suppress the QR/attach block on stdout.
1014
+ *
1015
+ * Suppress when EITHER the user passed `--no-qr-stdout`, OR stdout is not a TTY
1016
+ * / `CI` is set / `AIT_NO_QR_STDOUT` is set (non-interactive — a captured stdout
1017
+ * must not leak the relay wss + TOTP `at=` code that the QR block encodes). The
1018
+ * suppression is whole-chunk: `attachUrl` AND `relayUrl` ride in the same block.
1019
+ *
1020
+ * Exported for unit testing.
1021
+ */
1022
+ function shouldSuppressQr(noQrFlag) {
1023
+ return noQrFlag || !process.stdout.isTTY || process.env.CI !== void 0 || process.env.AIT_NO_QR_STDOUT !== void 0;
1024
+ }
1025
+ /**
3564
1026
  * CLI entry point.
3565
1027
  *
3566
- * Performs a standalone relay attach → run lifecycle:
1028
+ * Performs a standalone relay attach → run lifecycle, sharing the attach
1029
+ * assembly with the Vitest pool via `createRelayConnectionFactory` (single
1030
+ * source — no drift):
3567
1031
  *
3568
1032
  * 1. Parse args: globs, --timeout, --cell-sdk-line, --cell-platform,
3569
- * --scheme-url (required for env3), --headless, --project-root.
1033
+ * --scheme-url (required for env3), --report-dir, --no-qr-stdout,
1034
+ * --headless, --project-root.
3570
1035
  * 2. Discover test files; exit 1 if none.
3571
- * 3. Load .ait_relay secret into AIT_DEBUG_TOTP_SECRET, then boot relay family.
3572
- * 4. Assemble AttachDeps (no qrHttpServertext QR).
3573
- * 5. prepareAttach(deps, 'relay-dev', { scheme_url }, conn).
3574
- * 6. renderAndMaybeWait(deps, prep, true, timeoutMs, conn) text QR + wait.
3575
- * 7. If cell flags present, injectGlobals({ __AIT_CELL__: cell }) before run.
3576
- * 8. runWithConnection(conn, files, { timeoutMs, printSummary: true }).
3577
- * 9. family.stop(); process.exitCode = failed > 0 ? 1 : 0.
1036
+ * 3. factory.open() boot relay render QR (suppressed on non-interactive
1037
+ * stdout) wait for phone inject cell enableDomains. Returns the conn.
1038
+ * 4. runWithConnection(conn, files, { timeoutMs, collectCaptures, printSummary }).
1039
+ * 5. With --report-dir: write the runner-agnostic report + capture files.
1040
+ * 6. factory.close(); process.exitCode = failed > 0 ? 1 : 0.
3578
1041
  *
3579
1042
  * The CLI is not a daemon — no lock, router, SSE, or tools_list is needed.
3580
1043
  * Attach timeout exits with code 1; test failures exit with code 1.
3581
1044
  *
3582
1045
  * SECRET-HANDLING: scheme_url / relay wssUrl / TOTP codes are never written to
3583
- * stdout/stderr directly. text QR renders via renderAndMaybeWait which encodes
3584
- * the TOTP `at=` code inside the QR payload (not in plain log lines).
1046
+ * stdout/stderr directly. The QR block (which encodes the TOTP `at=` code) is
1047
+ * printed only when stdout is interactive AND not suppressed.
3585
1048
  */
3586
1049
  async function main(argv = process.argv.slice(2)) {
3587
1050
  let parsed;
@@ -3597,6 +1060,8 @@ async function main(argv = process.argv.slice(2)) {
3597
1060
  "scheme-url": { type: "string" },
3598
1061
  "cell-sdk-line": { type: "string" },
3599
1062
  "cell-platform": { type: "string" },
1063
+ "report-dir": { type: "string" },
1064
+ "no-qr-stdout": { type: "boolean" },
3600
1065
  headless: { type: "boolean" },
3601
1066
  "project-root": { type: "string" }
3602
1067
  },
@@ -3627,9 +1092,15 @@ async function main(argv = process.argv.slice(2)) {
3627
1092
  }
3628
1093
  const headless = vals.headless === true;
3629
1094
  const projectRoot = typeof vals["project-root"] === "string" ? vals["project-root"] : process.cwd();
1095
+ const reportDir = typeof vals["report-dir"] === "string" ? vals["report-dir"] : void 0;
1096
+ const suppressQr = shouldSuppressQr(vals["no-qr-stdout"] === true);
3630
1097
  const cellSdkLine = typeof vals["cell-sdk-line"] === "string" ? vals["cell-sdk-line"] : void 0;
3631
1098
  const cellPlatform = typeof vals["cell-platform"] === "string" ? vals["cell-platform"] : process.env.AIT_CELL_PLATFORM;
3632
1099
  const hasCell = cellSdkLine !== void 0 || cellPlatform !== void 0;
1100
+ const cell = {
1101
+ sdkLine: cellSdkLine ?? "2.x",
1102
+ platform: cellPlatform ?? "mock"
1103
+ };
3633
1104
  const globs = parsed.positionals;
3634
1105
  if (globs.length === 0) {
3635
1106
  process.stderr.write(`devtools-test: at least one glob pattern is required\n`);
@@ -3644,58 +1115,51 @@ async function main(argv = process.argv.slice(2)) {
3644
1115
  return;
3645
1116
  }
3646
1117
  process.stderr.write(`devtools-test: found ${files.length} test file(s)\n`);
3647
- const { loadRelaySecretReadOnly } = await import("./relay-secret-store-BHcOmaNK.js");
3648
- await loadRelaySecretReadOnly({ projectRoot });
3649
- const { bootRelayFamily, buildRelayVerifyAuth } = await Promise.resolve().then(() => debug_server_exports);
3650
- let family;
1118
+ if (hasCell) process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\n`);
1119
+ const factory = createRelayConnectionFactory({
1120
+ schemeUrl,
1121
+ projectRoot,
1122
+ timeoutMs,
1123
+ headless,
1124
+ cell: hasCell ? cell : void 0,
1125
+ onQrContent: (chunks) => {
1126
+ if (suppressQr) {
1127
+ process.stdout.write("QR suppressed (non-interactive)\n");
1128
+ return;
1129
+ }
1130
+ for (const chunk of chunks) process.stdout.write(`${chunk}\n`);
1131
+ }
1132
+ });
1133
+ let connection;
3651
1134
  try {
3652
- family = await bootRelayFamily({ verifyAuth: buildRelayVerifyAuth() });
1135
+ connection = await factory.open();
3653
1136
  } catch (e) {
3654
- process.stderr.write(`devtools-test: failed to boot relay: ${e instanceof Error ? e.message : String(e)}\n`);
1137
+ process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
3655
1138
  process.exitCode = 1;
3656
1139
  return;
3657
1140
  }
3658
1141
  let exitCode = 0;
3659
1142
  try {
3660
- const attachDeps = {
3661
- getTunnelStatus: family.getTunnelStatus ?? (() => ({
3662
- up: false,
3663
- wssUrl: null
3664
- })),
3665
- getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
3666
- qrHttpServer: void 0,
3667
- onAttachUrlBuilt: void 0,
3668
- canOpenBrowser: () => !headless
3669
- };
3670
- const prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: schemeUrl }, family.connection);
3671
- if (!prep.ok) {
3672
- const errText = prep.error.content.filter((c) => c.type === "text").map((c) => c.text).join("\n");
3673
- process.stderr.write(`devtools-test: attach preparation failed:\n${errText}\n`);
3674
- exitCode = 1;
3675
- return;
3676
- }
3677
- const waitResult = await renderAndMaybeWait(attachDeps, prep, true, timeoutMs, family.connection);
3678
- if (waitResult.isError) {
3679
- const errText = waitResult.content.filter((c) => c.type === "text").map((c) => c.text).join("\n");
3680
- process.stderr.write(`devtools-test: attach timed out or failed:\n${errText}\n`);
3681
- exitCode = 1;
3682
- return;
3683
- }
3684
- for (const chunk of waitResult.content) if (chunk.type === "text") process.stdout.write(`${chunk.text}\n`);
3685
- await injectDebugIndicator(family.connection);
3686
- if (hasCell) {
3687
- const cell = {};
3688
- if (cellSdkLine !== void 0) cell.sdkLine = cellSdkLine;
3689
- if (cellPlatform !== void 0) cell.platform = cellPlatform;
3690
- process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\n`);
3691
- await injectGlobals(family.connection, { __AIT_CELL__: cell });
3692
- }
3693
- exitCode = (await runWithConnection(family.connection, files, {
1143
+ const report = await runWithConnection(connection, files, {
3694
1144
  timeoutMs,
3695
- printSummary: true
3696
- })).totals.failed > 0 ? 1 : 0;
1145
+ printSummary: true,
1146
+ collectCaptures: reportDir !== void 0
1147
+ });
1148
+ if (reportDir !== void 0) try {
1149
+ const reportPath = await writeReportArtifact(report, reportDir, {
1150
+ sdkLine: cell.sdkLine,
1151
+ platform: cell.platform,
1152
+ projectRoot
1153
+ });
1154
+ process.stderr.write(`devtools-test: wrote report ${reportPath}\n`);
1155
+ const capturePaths = await writeCaptureArtifacts(report.captures, `${reportDir}/.ait-capture`, cell);
1156
+ if (capturePaths.length > 0) process.stderr.write(`devtools-test: wrote ${capturePaths.length} capture file(s)\n`);
1157
+ } catch (e) {
1158
+ process.stderr.write(`devtools-test: failed to write report artifacts: ${e instanceof Error ? e.message : String(e)}\n`);
1159
+ }
1160
+ exitCode = report.totals.failed > 0 ? 1 : 0;
3697
1161
  } finally {
3698
- family.stop();
1162
+ await factory.close(connection);
3699
1163
  process.exitCode = exitCode;
3700
1164
  }
3701
1165
  }
@@ -6774,7 +4238,7 @@ function createDebugServer(deps) {
6774
4238
  };
6775
4239
  const server = new Server({
6776
4240
  name: "ait-debug",
6777
- version: "0.1.117"
4241
+ version: "0.1.119"
6778
4242
  }, { capabilities: { tools: { listChanged: true } } });
6779
4243
  server.setRequestHandler(ListToolsRequestSchema, () => {
6780
4244
  const conn = router.active;
@@ -6965,7 +4429,10 @@ function createDebugServer(deps) {
6965
4429
  fileCount: files.length,
6966
4430
  autoAttach: true
6967
4431
  });
6968
- const report = await runWithConnection(conn, files, { timeoutMs });
4432
+ const report = await runWithConnection(conn, files, {
4433
+ timeoutMs,
4434
+ collectCaptures: true
4435
+ });
6969
4436
  logInfo("run_tests.done", {
6970
4437
  passed: report.totals.passed,
6971
4438
  failed: report.totals.failed,
@@ -6986,7 +4453,10 @@ function createDebugServer(deps) {
6986
4453
  if (files.length === 0) return mcpError(`run_tests: 매칭된 테스트 파일이 없습니다 (patterns: ${patterns.join(", ")}).`);
6987
4454
  if (conn.listTargets().length === 0) return pageMissingError("run_tests");
6988
4455
  logInfo("run_tests.start", { fileCount: files.length });
6989
- const report = await runWithConnection(conn, files, { timeoutMs });
4456
+ const report = await runWithConnection(conn, files, {
4457
+ timeoutMs,
4458
+ collectCaptures: true
4459
+ });
6990
4460
  logInfo("run_tests.done", {
6991
4461
  passed: report.totals.passed,
6992
4462
  failed: report.totals.failed,
@@ -7114,11 +4584,18 @@ function envelopeResult(value, tool, env, attached) {
7114
4584
  /**
7115
4585
  * Maps a {@link RelayRunReport} to a flat, agent-friendly object for the
7116
4586
  * `run_tests` tool result. SECRET-HANDLING: a RelayRunReport carries only
7117
- * startedAt/duration/totals and per-file `{file, result}` file paths are
7118
- * surfaced (allowed), relay wss/TOTP URLs never appear in it. No stripping
7119
- * needed; this only reshapes for readability.
4587
+ * startedAt/duration/totals, per-file `{file, result}`, and capture lines
4588
+ * file paths are surfaced (allowed), relay wss/TOTP URLs never appear in it.
4589
+ * No stripping needed; this only reshapes for readability.
4590
+ *
4591
+ * Captures (#696): the envelope surfaces a COUNT-LEVEL summary only
4592
+ * (per-category line counts) — never the line bodies. Capture bodies belong in
4593
+ * the on-disk artifact, not the `run_tests` log (keeps the tool result small and
4594
+ * avoids dumping large capture arrays into the agent's context).
7120
4595
  */
7121
4596
  function toRunTestsResult(report) {
4597
+ const captureCounts = {};
4598
+ for (const { category } of report.captures) captureCounts[category] = (captureCounts[category] ?? 0) + 1;
7122
4599
  return {
7123
4600
  startedAt: report.startedAt,
7124
4601
  duration: report.duration,
@@ -7133,7 +4610,8 @@ function toRunTestsResult(report) {
7133
4610
  failed: f.result.failed,
7134
4611
  skipped: f.result.skipped,
7135
4612
  tests: f.result.tests
7136
- })
4613
+ }),
4614
+ captures: captureCounts
7137
4615
  };
7138
4616
  }
7139
4617
  function unknownTool(name) {
@@ -7391,7 +4869,7 @@ async function readRelayLocalUrl(env = process.env, projectRoot) {
7391
4869
  const envValue = (env.AIT_RELAY_LOCAL_URL ?? "").trim();
7392
4870
  if (envValue !== "") return envValue;
7393
4871
  if (projectRoot !== void 0) try {
7394
- const { readRelayUrls } = await import("./relay-url-store-C0qukm3R.js");
4872
+ const { readRelayUrls } = await import("./relay-url-store-CnL2zwbH.js");
7395
4873
  const stored = await readRelayUrls({ projectRoot });
7396
4874
  if (stored?.relayLocalUrl) return stored.relayLocalUrl;
7397
4875
  } catch {}
@@ -7445,7 +4923,7 @@ async function readMobileRelayBaseUrl(env = process.env, projectRoot) {
7445
4923
  const envValue = typeof raw === "string" ? raw.trim() : "";
7446
4924
  if (envValue !== "") return envValue;
7447
4925
  if (projectRoot !== void 0) {
7448
- const { readRelayUrls } = await import("./relay-url-store-C0qukm3R.js");
4926
+ const { readRelayUrls } = await import("./relay-url-store-CnL2zwbH.js");
7449
4927
  const stored = await readRelayUrls({ projectRoot });
7450
4928
  if (stored?.relayBaseUrl !== void 0) return stored.relayBaseUrl;
7451
4929
  }
@@ -8254,6 +5732,6 @@ async function runMobileDebugServer(options = {}) {
8254
5732
  }, { maxAgeMs: process.env.AIT_DEBUG_MAX_AGE_MS ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || void 0 : void 0 });
8255
5733
  }
8256
5734
  //#endregion
8257
- export { wrapEnvelope as a, getSdkCallHistory as c, tierRejectionError as d, runMobileDebugServer as i, isAitToolName as l, runDebugServer as n, getMockState as o, runLocalDebugServer as r, getOperationalEnvironment as s, debug_server_exports as t, mcpError as u };
5735
+ export { wrapEnvelope as a, runMobileDebugServer as i, runDebugServer as n, runLocalDebugServer as r, debug_server_exports as t };
8258
5736
 
8259
- //# sourceMappingURL=debug-server-jrI9U_f4.js.map
5737
+ //# sourceMappingURL=debug-server-Bi6jLuY6.js.map