@ait-co/devtools 0.1.108 → 0.1.110

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 (79) hide show
  1. package/README.en.md +13 -31
  2. package/README.md +13 -31
  3. package/dist/bundle-KFs4t-wc.d.ts +96 -0
  4. package/dist/bundle-KFs4t-wc.d.ts.map +1 -0
  5. package/dist/in-app/auto.d.ts.map +1 -1
  6. package/dist/in-app/auto.js +40 -3
  7. package/dist/in-app/auto.js.map +1 -1
  8. package/dist/in-app/index.d.ts.map +1 -1
  9. package/dist/in-app/index.js +39 -2
  10. package/dist/in-app/index.js.map +1 -1
  11. package/dist/mcp/cli.d.ts +4 -16
  12. package/dist/mcp/cli.d.ts.map +1 -1
  13. package/dist/mcp/cli.js +803 -712
  14. package/dist/mcp/cli.js.map +1 -1
  15. package/dist/mcp/server.d.ts.map +1 -1
  16. package/dist/mcp/server.js +47 -59
  17. package/dist/mcp/server.js.map +1 -1
  18. package/dist/mock/index.d.ts.map +1 -1
  19. package/dist/mock/index.js +21 -2
  20. package/dist/mock/index.js.map +1 -1
  21. package/dist/panel/index.js +47 -32
  22. package/dist/panel/index.js.map +1 -1
  23. package/dist/{pool-Dkp7I9Bf.d.ts → pool-Bf6rQci4.d.ts} +210 -48
  24. package/dist/pool-Bf6rQci4.d.ts.map +1 -0
  25. package/dist/{qr-http-server-D4EAA7Il.js → qr-http-server-BJJt3ush.js} +8 -17
  26. package/dist/qr-http-server-BJJt3ush.js.map +1 -0
  27. package/dist/{qr-http-server-A9vld8r7.cjs → qr-http-server-BVS-HZjU.cjs} +8 -17
  28. package/dist/qr-http-server-BVS-HZjU.cjs.map +1 -0
  29. package/dist/{qr-http-server-Dj3Z0NHi.cjs → qr-http-server-C1T4RNbq.cjs} +8 -17
  30. package/dist/qr-http-server-C1T4RNbq.cjs.map +1 -0
  31. package/dist/{qr-http-server-HzdCLU8s.js → qr-http-server-Cs93vEPH.js} +8 -17
  32. package/dist/qr-http-server-Cs93vEPH.js.map +1 -0
  33. package/dist/{relay-worker-BzFQ3fv9.d.ts → relay-worker-xxanNQGs.d.ts} +3 -3
  34. package/dist/relay-worker-xxanNQGs.d.ts.map +1 -0
  35. package/dist/{runtime-ORdrpizY.d.ts → runtime-Wi5d6Ywz.d.ts} +3 -3
  36. package/dist/{runtime-ORdrpizY.d.ts.map → runtime-Wi5d6Ywz.d.ts.map} +1 -1
  37. package/dist/test-runner/bundle.d.ts +1 -1
  38. package/dist/test-runner/bundle.js +148 -11
  39. package/dist/test-runner/bundle.js.map +1 -1
  40. package/dist/test-runner/cli.d.ts +59 -14
  41. package/dist/test-runner/cli.d.ts.map +1 -1
  42. package/dist/test-runner/cli.js +171 -32
  43. package/dist/test-runner/cli.js.map +1 -1
  44. package/dist/test-runner/config.d.ts +1 -1
  45. package/dist/test-runner/pool.d.ts +1 -1
  46. package/dist/test-runner/relay-worker.d.ts +1 -1
  47. package/dist/test-runner/relay-worker.js.map +1 -1
  48. package/dist/test-runner/rpc.d.ts +1 -1
  49. package/dist/test-runner/rpc.d.ts.map +1 -1
  50. package/dist/test-runner/rpc.js +1 -1
  51. package/dist/test-runner/rpc.js.map +1 -1
  52. package/dist/test-runner/task-graph.d.ts +1 -1
  53. package/dist/{tunnel-BjJROkcj.js → tunnel-Cpn3mA4u.js} +3 -3
  54. package/dist/tunnel-Cpn3mA4u.js.map +1 -0
  55. package/dist/{tunnel-d_G9AIFn.cjs → tunnel-Dj8Kf2QS.cjs} +3 -3
  56. package/dist/tunnel-Dj8Kf2QS.cjs.map +1 -0
  57. package/dist/unplugin/index.cjs +1 -1
  58. package/dist/unplugin/index.d.cts +196 -34
  59. package/dist/unplugin/index.d.cts.map +1 -1
  60. package/dist/unplugin/index.d.ts +196 -34
  61. package/dist/unplugin/index.d.ts.map +1 -1
  62. package/dist/unplugin/index.js +1 -1
  63. package/dist/unplugin/tunnel.cjs +2 -2
  64. package/dist/unplugin/tunnel.cjs.map +1 -1
  65. package/dist/unplugin/tunnel.d.cts +1 -1
  66. package/dist/unplugin/tunnel.d.ts +1 -1
  67. package/dist/unplugin/tunnel.js +2 -2
  68. package/dist/unplugin/tunnel.js.map +1 -1
  69. package/package.json +14 -14
  70. package/dist/bundle-BJm5jk56.d.ts +0 -49
  71. package/dist/bundle-BJm5jk56.d.ts.map +0 -1
  72. package/dist/pool-Dkp7I9Bf.d.ts.map +0 -1
  73. package/dist/qr-http-server-A9vld8r7.cjs.map +0 -1
  74. package/dist/qr-http-server-D4EAA7Il.js.map +0 -1
  75. package/dist/qr-http-server-Dj3Z0NHi.cjs.map +0 -1
  76. package/dist/qr-http-server-HzdCLU8s.js.map +0 -1
  77. package/dist/relay-worker-BzFQ3fv9.d.ts.map +0 -1
  78. package/dist/tunnel-BjJROkcj.js.map +0 -1
  79. package/dist/tunnel-d_G9AIFn.cjs.map +0 -1
package/dist/mcp/cli.js CHANGED
@@ -2,13 +2,14 @@
2
2
  import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "../totp-WY6l0ysP.js";
3
3
  import { t as loadRelaySecretReadOnly } from "../relay-secret-store-DhzAnnj-.js";
4
4
  import { createRequire } from "node:module";
5
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
+ import { accessSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { argv } from "node:process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
10
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
11
  import { parseArgs } from "node:util";
12
+ import * as fs from "node:fs/promises";
12
13
  import { glob } from "node:fs/promises";
13
14
  import * as path from "node:path";
14
15
  import { isAbsolute, join, resolve } from "node:path";
@@ -23,6 +24,95 @@ import { Tunnel, bin, install } from "cloudflared";
23
24
  //#region \0rolldown/runtime.js
24
25
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
25
26
  //#endregion
27
+ //#region src/in-app/gate.ts
28
+ /**
29
+ * The host suffix the Toss app uses to serve dogfood / private mini-apps.
30
+ *
31
+ * A `intoss-private://` (dogfood) entry maps to a host such as
32
+ * `aitc-sdk-example.private-apps.tossmini.com`. A production `intoss://`
33
+ * entry is served from `*.apps.tossmini.com` — the `.private-apps.` segment
34
+ * is absent. Confirmed live over CDP for mini-app 31146; the exact production
35
+ * host is to be re-confirmed once 31146 passes review (spec open question 2).
36
+ */
37
+ const PRIVATE_APPS_HOST_SUFFIX = ".private-apps.tossmini.com";
38
+ /**
39
+ * The host suffix Cloudflare quick-tunnels serve from — the env 2 (PWA) entry.
40
+ * See {@link isTrycloudflareHost} for why this host kind bypasses Layer B1.
41
+ */
42
+ const TRYCLOUDFLARE_HOST_SUFFIX = ".trycloudflare.com";
43
+ /**
44
+ * Returns whether `hostname` is a `*.private-apps.tossmini.com` subdomain —
45
+ * the host the Toss app reserves for dogfood / private mini-app entries.
46
+ *
47
+ * The match is an exact suffix check, not a substring `.includes()`: a
48
+ * substring test would also accept an attacker-controlled host like
49
+ * `private-apps.tossmini.com.evil.example`, which ends in `.example`, not in
50
+ * `.tossmini.com`. Requiring the string to END with the suffix closes that.
51
+ * The leading `.` in the suffix also forces a real subdomain label, so a
52
+ * bare `private-apps.tossmini.com` (no mini-app subdomain) does not match.
53
+ */
54
+ function isPrivateAppsHost(hostname) {
55
+ return hostname.endsWith(PRIVATE_APPS_HOST_SUFFIX);
56
+ }
57
+ /**
58
+ * The host suffix Cloudflare quick-tunnels use — the env 2 (PWA) entry.
59
+ *
60
+ * Env 2 serves the local Vite dev server through a `*.trycloudflare.com` quick
61
+ * tunnel (`src/unplugin/tunnel.ts`). It has no Toss app, no `intoss-private://`
62
+ * scheme, and — critically — no production runtime: the SDK is the devtools
63
+ * mock, and the page is the developer's own dev build. The Layer B1 safety net
64
+ * (which stops a dogfood build that lands on a Toss *production* host from
65
+ * attaching) has nothing to protect against here, because env 2 has no
66
+ * production host. So a trycloudflare host is allowed past B1 — but ONLY past
67
+ * B1: the remaining layers (C1 opt-in, C2 relay, C3 TOTP) still apply, so a
68
+ * leaked tunnel URL is still blocked by TOTP exactly as on the Toss path.
69
+ *
70
+ * The match is the same exact-suffix `endsWith` check as
71
+ * {@link isPrivateAppsHost} — never a substring `.includes()`, which would
72
+ * accept an attacker-controlled `evil.trycloudflare.com.example.com`. The
73
+ * leading `.` forces a real subdomain label, so a bare `trycloudflare.com`
74
+ * (no tunnel subdomain) does not match.
75
+ */
76
+ function isTrycloudflareHost(hostname) {
77
+ return hostname.endsWith(TRYCLOUDFLARE_HOST_SUFFIX);
78
+ }
79
+ /**
80
+ * Returns true when the hostname is a localhost/loopback address.
81
+ * Allowed: `localhost`, `127.x.x.x` (full RFC 5735 loopback block), `[::1]`,
82
+ * `0.0.0.0`, `*.localhost`.
83
+ *
84
+ * Security note: `hostname.startsWith('127.')` is intentionally NOT used —
85
+ * that pattern would accept `127.evil.com`, which starts with "127." but is an
86
+ * attacker-controlled hostname, not a loopback address. Instead, the 127/8
87
+ * loopback block is matched with a strict numeric-quad regex so only valid
88
+ * dotted-decimal IPv4 in the 127.x.x.x range pass (#665 작업 A fix).
89
+ */
90
+ function isLocalhostHost(hostname) {
91
+ if (hostname === "localhost" || hostname === "0.0.0.0") return true;
92
+ if (hostname === "[::1]") return true;
93
+ if (/^127\.\d+\.\d+\.\d+$/.test(hostname)) return true;
94
+ if (hostname.endsWith(".localhost")) return true;
95
+ return false;
96
+ }
97
+ /**
98
+ * Positive-allowlist kill-switch (#665): returns true when the hostname is a
99
+ * known debug-allowed host. The debug surface is ONLY active on:
100
+ * - localhost / loopback (env 1 desktop dev)
101
+ * - *.trycloudflare.com (env 2 PWA tunnel)
102
+ * - *.private-apps.tossmini.com (env 3 dog-food)
103
+ *
104
+ * Any other host (including apps.tossmini.com — the former env 4 LIVE host)
105
+ * is silently blocked. This is a positive allowlist — unlisted hosts never
106
+ * had debug surface regardless, but this function makes it explicit and
107
+ * auditable in a single place.
108
+ *
109
+ * SECRET-HANDLING: the hostname value MUST NOT be logged or included in any
110
+ * error reason string — only benign labels ('host not in allowlist') are safe.
111
+ */
112
+ function isDebugAllowedHost(hostname) {
113
+ return isLocalhostHost(hostname) || isTrycloudflareHost(hostname) || isPrivateAppsHost(hostname);
114
+ }
115
+ //#endregion
26
116
  //#region src/shared/parent-watcher.ts
27
117
  /**
28
118
  * Shared parent-PID watcher — used by both the MCP debug daemon and the
@@ -158,11 +248,53 @@ async function discoverTestFiles(patterns, cwd) {
158
248
  * esbuild-based bundler for user test files.
159
249
  *
160
250
  * Bundles a single test file into a self-contained IIFE string that can be
161
- * injected into a WebView via `Runtime.evaluate`. The user's SDK imports
162
- * (`@apps-in-toss/web-framework` and sub-paths) are intercepted via an
163
- * esbuild plugin that redirects them to `window.__sdk`, which the in-app
164
- * debug gate (`src/in-app/auto.ts`) installs as a namespace mirror of the
165
- * SDK exports (works for both 2.x and 3.x SDK).
251
+ * injected into a WebView via `Runtime.evaluate`. The bundle includes the
252
+ * test runtime (`runtime.ts`), which provides `describe/it/test/expect` and
253
+ * the `runTestModule(factory)` entry point.
254
+ *
255
+ * ## How the wiring works
256
+ *
257
+ * The bundle exposes two exports on `globalThis.__testBundle`:
258
+ * - `runTestModule` — the runtime's entry function.
259
+ * - `__userFactory` — an async function whose body is the user's top-level
260
+ * test registration code (describe/it/test calls).
261
+ *
262
+ * The Node-side RPC (`rpc.ts`) calls:
263
+ * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
264
+ *
265
+ * `runTestModule` then installs `describe/it/test/expect` as globals, invokes
266
+ * the factory (which registers all tests), runs them, and returns a `RunReport`.
267
+ *
268
+ * ## Why a factory wrapper is needed
269
+ *
270
+ * Naively adding the runtime to `entryPoints` and bundling the user file would
271
+ * fail for two reasons:
272
+ * 1. `describe/it/test/expect` from the runtime are module-local in the IIFE
273
+ * scope. The user's top-level `describe(...)` calls expect them as globals —
274
+ * they are not globals until `runTestModule` installs them.
275
+ * 2. Even with globals pre-installed, the user file runs at IIFE-evaluation
276
+ * time, before the RPC layer calls `runTestModule` to reset state and start
277
+ * the test clock.
278
+ *
279
+ * The factory approach solves both: the user's registration code is deferred
280
+ * into a function that `runTestModule` calls AFTER installing the globals.
281
+ *
282
+ * ## Factory extraction algorithm
283
+ *
284
+ * The `userFactoryPlugin` reads the user file and splits lines into:
285
+ * - **top-level**: `import …` and re-export lines — kept at module scope
286
+ * (the only valid position for static `import` in ESM).
287
+ * - **body**: all other statements — moved into the body of the exported
288
+ * `__userFactory` async function.
289
+ *
290
+ * esbuild processes the re-generated module, following each static import
291
+ * through the normal dependency graph (including the SDK-redirect plugin).
292
+ *
293
+ * ## SDK redirect
294
+ *
295
+ * Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via
296
+ * the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that
297
+ * `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.
166
298
  *
167
299
  * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
168
300
  */
@@ -212,10 +344,90 @@ module.exports = __proxy;
212
344
  };
213
345
  }
214
346
  /**
347
+ * esbuild plugin that transforms the user test file into a module that exports
348
+ * an async `__userFactory` function. The factory defers the user's top-level
349
+ * test registration code (describe/it/test calls) so it only runs when
350
+ * `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has
351
+ * installed describe/it/test/expect as globals.
352
+ *
353
+ * Algorithm:
354
+ * - Lines matching import declarations or re-export statements are kept at
355
+ * module top-level (the only valid ESM position for static `import`).
356
+ * - All other lines (describe/it/test calls, local declarations, etc.) are
357
+ * moved into the body of the exported async factory function.
358
+ *
359
+ * This preserves SDK import resolution (the sdk-redirect plugin processes
360
+ * top-level imports normally) while deferring test registration to the factory.
361
+ */
362
+ function userFactoryPlugin(absPath) {
363
+ const NAMESPACE = "user-test-factory";
364
+ return {
365
+ name: "user-test-factory",
366
+ setup(build) {
367
+ build.onResolve({ filter: /^user-test-factory$/ }, () => ({
368
+ path: absPath,
369
+ namespace: NAMESPACE
370
+ }));
371
+ build.onLoad({
372
+ filter: /.*/,
373
+ namespace: NAMESPACE
374
+ }, async (args) => {
375
+ const lines = (await fs.readFile(args.path, "utf8")).split("\n");
376
+ const topLevelLines = [];
377
+ const bodyLines = [];
378
+ const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
379
+ for (const line of lines) {
380
+ const trimmed = line.trimStart();
381
+ const indent = line.slice(0, line.length - trimmed.length);
382
+ if (trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"")) topLevelLines.push(line);
383
+ else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
384
+ else topLevelLines.push(line);
385
+ else bodyLines.push(line);
386
+ }
387
+ return {
388
+ contents: [
389
+ ...topLevelLines,
390
+ "",
391
+ "// biome-ignore lint: generated factory wrapper",
392
+ "export default async function __userFactory(): Promise<void> {",
393
+ ...bodyLines.map((l) => ` ${l}`),
394
+ "}"
395
+ ].join("\n"),
396
+ loader: "ts",
397
+ resolveDir: path.dirname(absPath)
398
+ };
399
+ });
400
+ }
401
+ };
402
+ }
403
+ /**
404
+ * Returns the absolute path to the co-located runtime module.
405
+ *
406
+ * In the source tree (running via tsx / ts-node) the file is `runtime.ts`.
407
+ * After `tsdown` compiles to `dist/test-runner/`, it becomes `runtime.js`.
408
+ * We try both extensions to support both environments.
409
+ */
410
+ function getRuntimePath() {
411
+ const dir = path.dirname(fileURLToPath(import.meta.url));
412
+ for (const ext of [".ts", ".js"]) {
413
+ const candidate = path.join(dir, `runtime${ext}`);
414
+ try {
415
+ accessSync(candidate);
416
+ return candidate;
417
+ } catch {}
418
+ }
419
+ return path.join(dir, "runtime.js");
420
+ }
421
+ /**
215
422
  * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
216
423
  *
217
- * The IIFE installs `window.__testBundle` (or the custom `globalName`) with
218
- * `runTestModule` as the callable entry point.
424
+ * The IIFE installs `window.__testBundle` (or the custom `globalName`) with:
425
+ * - `runTestModule` the runtime entry (from `runtime.ts`).
426
+ * - `__userFactory` — an async function wrapping the user's test registration
427
+ * code so it runs AFTER `runTestModule` installs the globals.
428
+ *
429
+ * Callers (rpc.ts) invoke:
430
+ * `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
219
431
  *
220
432
  * @param absPath - Absolute path to the user test file.
221
433
  * @param opts - Optional bundling overrides.
@@ -223,17 +435,29 @@ module.exports = __proxy;
223
435
  async function bundleTestFile(absPath, opts) {
224
436
  const globalName = opts?.globalName ?? "__testBundle";
225
437
  const extraExternals = opts?.extraExternals ?? [];
226
- const result = await (await import("esbuild")).build({
227
- entryPoints: [absPath],
438
+ const esbuild = await import("esbuild");
439
+ const runtimePath = getRuntimePath();
440
+ const wrapperContent = [
441
+ `import { runTestModule } from ${JSON.stringify(runtimePath)};`,
442
+ `import __userFactory from "user-test-factory";`,
443
+ `export { runTestModule, __userFactory };`
444
+ ].join("\n");
445
+ const result = await esbuild.build({
446
+ stdin: {
447
+ contents: wrapperContent,
448
+ loader: "ts",
449
+ resolveDir: path.dirname(absPath)
450
+ },
228
451
  bundle: true,
229
452
  format: "iife",
230
453
  globalName,
231
454
  platform: "browser",
232
455
  target: "es2022",
233
456
  write: false,
234
- plugins: [sdkRedirectPlugin()],
457
+ plugins: [userFactoryPlugin(absPath), sdkRedirectPlugin()],
235
458
  external: extraExternals,
236
- treeShaking: true
459
+ treeShaking: true,
460
+ footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
237
461
  });
238
462
  const warnings = result.warnings.map((w) => `${path.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
239
463
  const outputFile = result.outputFiles?.[0];
@@ -260,7 +484,7 @@ const DEFAULT_TIMEOUT_MS = 3e4;
260
484
  * SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
261
485
  */
262
486
  function buildRunTestsExpression(bundleCode) {
263
- return `(async () => { try { ${bundleCode} } catch(e) { return JSON.stringify({ok:false,error:'bundle-eval: ' + String(e && e.message || e)}); } if (typeof globalThis.__testBundle !== 'object' || typeof globalThis.__testBundle.runTestModule !== 'function') { return JSON.stringify({ok:false,error:'bundle-missing-export: __testBundle.runTestModule is not a function'}); } try { const report = await globalThis.__testBundle.runTestModule(); return JSON.stringify({ok:true,value:report}); } catch(e) { return JSON.stringify({ok:false,error:'test-run: ' + String(e && e.message || e)}); }})()`;
487
+ return `(async () => { try { ${bundleCode} } catch(e) { return JSON.stringify({ok:false,error:'bundle-eval: ' + String(e && e.message || e)}); } if (typeof globalThis.__testBundle !== 'object' || typeof globalThis.__testBundle.runTestModule !== 'function' || typeof globalThis.__testBundle.__userFactory !== 'function') { return JSON.stringify({ok:false,error:'bundle-missing-export: __testBundle.runTestModule or __userFactory is not a function'}); } try { const report = await globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory); return JSON.stringify({ok:true,value:report}); } catch(e) { return JSON.stringify({ok:false,error:'test-run: ' + String(e && e.message || e)}); }})()`;
264
488
  }
265
489
  /**
266
490
  * Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
@@ -393,14 +617,15 @@ async function runTestFilesOverRelay(connection, files, opts) {
393
617
  //#endregion
394
618
  //#region src/test-runner/cli.ts
395
619
  /**
396
- * `devtools-test` CLI — MVP skeleton.
397
- *
398
- * Parses argv, prints usage, and delegates to `runTestFilesOverRelay` when
399
- * a live CDP connection is provided. The relay connection wiring
400
- * (attach → run → detach) is tracked in issue #645 / #646.
620
+ * `devtools-test` CLI.
401
621
  *
402
- * MVP contract: `--help` works, `runWithConnection` is a testable pure
403
- * function, and the binary entry exists in package.json.
622
+ * Shares test-file discovery with the `run_tests` MCP tool (`discoverTestFiles`)
623
+ * and exposes `runWithConnection` — the pure run core that bundles, injects, and
624
+ * collects each file over a CDP connection. Today the run path that has a live
625
+ * connection is the `run_tests` MCP tool (it runs these files against the
626
+ * daemon's attached page); the CLI's own standalone relay attach (resolve CDP
627
+ * URL → attach → run → close) is not wired yet, so `main()` resolves the matched
628
+ * files and points the operator at the MCP tool.
404
629
  *
405
630
  * NOTE: no shebang in this source file — the tsdown entry's `banner` option
406
631
  * injects `#!/usr/bin/env node` into the compiled output (same pattern as
@@ -421,12 +646,10 @@ DESCRIPTION
421
646
  window.__sdk), injects the bundle into the attached WebView via
422
647
  Runtime.evaluate, and returns a RunReport.
423
648
 
424
- A live CDP relay connection must be active before running tests.
425
- Use \`/ait debug\` (devtools-mcp) to attach and then call this CLI from
426
- the same process context.
427
-
428
- Full Vitest pool integration and the \`run_tests\` MCP tool are tracked in
429
- issues #645 and #646 respectively. This MVP provides the transport layer.
649
+ A live CDP relay connection must be active before running tests. Use the
650
+ \`run_tests\` MCP tool (via \`devtools-mcp\` / \`/ait debug\`) to run these files
651
+ against an attached page — the CLI's own standalone relay attach is not wired
652
+ yet (it currently resolves the matched files and defers to that tool).
430
653
 
431
654
  EXAMPLE
432
655
  devtools-test 'src/**/*.phone.test.ts' --timeout 60000
@@ -434,11 +657,12 @@ EXAMPLE
434
657
  `.trimStart();
435
658
  /**
436
659
  * Runs `files` over `connection` and returns the aggregate report.
437
- * This pure function is the testable core of the CLI; it is separate from
438
- * `main()` so tests can call it without spawning a subprocess.
660
+ * This pure function is the testable core of the CLI (and is what the
661
+ * `run_tests` MCP tool calls against the daemon's attached connection); it is
662
+ * separate from `main()` so tests can call it without spawning a subprocess.
439
663
  *
440
- * TODO (#645): add real relay attach/detach lifecycle here (connect via
441
- * Chii relay URL, call enableDomains, run, then close).
664
+ * A standalone CLI relay attach/detach lifecycle (connect via Chii relay URL,
665
+ * `enableDomains`, run, then close) is not wired into `main()` yet.
442
666
  */
443
667
  async function runWithConnection(connection, files, opts) {
444
668
  const report = await runTestFilesOverRelay(connection, files, opts);
@@ -451,8 +675,10 @@ async function runWithConnection(connection, files, opts) {
451
675
  /**
452
676
  * CLI entry point.
453
677
  *
454
- * MVP: prints usage and a "relay attach required" notice. Real relay wiring
455
- * (resolve CDP URL, attach, run, close) is tracked in issues #645 / #646.
678
+ * Resolves the matched test files and prints a "relay attach required" notice:
679
+ * the CLI's own standalone relay attach (resolve CDP URL, attach, run, close) is
680
+ * not wired yet, so today these files run via the `run_tests` MCP tool against
681
+ * the daemon's attached page.
456
682
  */
457
683
  async function main$1(argv = process.argv.slice(2)) {
458
684
  let parsed;
@@ -483,7 +709,7 @@ async function main$1(argv = process.argv.slice(2)) {
483
709
  process.exitCode = 1;
484
710
  return;
485
711
  }
486
- process.stderr.write(`devtools-test: matched ${files.length} test file(s), but direct CLI relay attach is not yet wired.\n Use the devtools-mcp server (\`devtools-mcp\`) to start a debug session,\n then the \`run_tests\` MCP tool to run these files against the attached page.\n Direct CLI relay wiring is tracked in issue #645.\n`);
712
+ process.stderr.write(`devtools-test: matched ${files.length} test file(s), but direct CLI relay attach is not yet wired.\n Use the devtools-mcp server (\`devtools-mcp\`) to start a debug session,\n then the \`run_tests\` MCP tool to run these files against the attached page.\n`);
487
713
  process.exitCode = 1;
488
714
  }
489
715
  if (import.meta.url === new URL(process.argv[1], "file://").href) main$1().catch((e) => {
@@ -680,7 +906,7 @@ function logError(event, fields = {}) {
680
906
  * Attach reliability (#281):
681
907
  * `refreshTargets()` emits an internal 'target:attached' event whenever a
682
908
  * new target is added to the relay. `waitForFirstTarget()` awaits that event
683
- * (with a polling-interval fallback) so `build_attach_url wait_for_attach`
909
+ * (with a polling-interval fallback) so `start_attach`'s attach wait
684
910
  * resolves deterministically rather than racing between polling rounds.
685
911
  */
686
912
  /** Max events retained per domain ring buffer. */
@@ -1820,9 +2046,10 @@ function isCompatMode() {
1820
2046
  return process.env.AIT_MCP_COMPAT === "chrome-devtools";
1821
2047
  }
1822
2048
  /**
1823
- * Maps `McpEnvironment` to `EnvelopeEnv`. After #307 these are the same
1824
- * union (`mock | relay-dev | relay-live`), so this is identity — kept as a
1825
- * named export for surface stability if envelope env diverges in the future.
2049
+ * Maps `McpEnvironment` to `EnvelopeEnv`. These are now the same 3-value
2050
+ * union (`mock | relay-dev | relay-mobile`; `relay-live` removed in #665),
2051
+ * so this is identity — kept as a named export for surface stability if
2052
+ * envelope env diverges in the future.
1826
2053
  */
1827
2054
  function toEnvelopeEnv(env) {
1828
2055
  return env;
@@ -1858,9 +2085,9 @@ function wrapEnvelope(data, ctx) {
1858
2085
  //#endregion
1859
2086
  //#region src/mcp/environment.ts
1860
2087
  /**
1861
- * Returns `true` when the environment is any relay variant (`relay-dev`,
1862
- * `relay-live`, or `relay-mobile`). Use this instead of `env === 'relay'` for
1863
- * tier checks — every relay env surfaces the Tier B / relay-only tool set.
2088
+ * Returns `true` when the environment is any relay variant (`relay-dev` or
2089
+ * `relay-mobile`). Use this instead of `env === 'relay'` for tier checks —
2090
+ * every relay env surfaces the Tier B / relay-only tool set.
1864
2091
  *
1865
2092
  * Written as an exhaustive switch so a future `McpEnvironment` member that is
1866
2093
  * missing an arm is a TS compile error rather than a silent `false`.
@@ -1868,78 +2095,49 @@ function wrapEnvelope(data, ctx) {
1868
2095
  function isRelayEnv(env) {
1869
2096
  switch (env) {
1870
2097
  case "relay-dev":
1871
- case "relay-live":
1872
2098
  case "relay-mobile": return true;
1873
2099
  case "mock": return false;
1874
2100
  }
1875
2101
  }
1876
2102
  /**
1877
- * Returns `true` when the environment is the LIVE relay (`relay-live`).
1878
- * This is the guard condition for side-effect tool protection. `relay-mobile`
1879
- * is a dev-intent env (env 2 PWA) and is NOT live.
1880
- */
1881
- function isLiveRelayEnv(env) {
1882
- return env === "relay-live";
1883
- }
1884
- /**
1885
2103
  * Maps the `McpEnvironment` union to the legacy two-value union
1886
2104
  * (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
1887
- * Every relay variant (incl. `relay-mobile`) collapses to `'relay'`.
2105
+ * Every relay variant (`relay-dev`, `relay-mobile`) collapses to `'relay'`.
2106
+ * Written as an exhaustive switch so a missing arm is a TS compile error.
1888
2107
  */
1889
2108
  function toLegacyEnv(env) {
1890
- if (env === "mock") return "mock";
1891
- return "relay";
2109
+ switch (env) {
2110
+ case "mock": return "mock";
2111
+ case "relay-dev":
2112
+ case "relay-mobile": return "relay";
2113
+ }
1892
2114
  }
1893
2115
  /**
1894
- * Reconstructs the four-value `McpEnvironment` output string from the
1895
- * orthogonal signals (issues #348, #378):
2116
+ * Reconstructs the three-value `McpEnvironment` output string from the
2117
+ * orthogonal signals (issues #348, #378, #665):
1896
2118
  *
1897
- * - `kind === 'local'` → `'mock'`
1898
- * - `kind === 'relay'` && liveIntent → `'relay-live'`
1899
- * - `kind === 'relay'` && !liveIntent && origin 'external-pwa' → `'relay-mobile'`
1900
- * - `kind === 'relay'` && !liveIntent && origin intoss/undefined → `'relay-dev'`
2119
+ * - `kind === 'local'` → `'mock'`
2120
+ * - `kind === 'relay'` && origin 'external-pwa' → `'relay-mobile'`
2121
+ * - `kind === 'relay'` && origin intoss/undefined → `'relay-dev'`
1901
2122
  *
1902
2123
  * `relayOrigin` is the booted-family discriminator (NOT sniffed from the URL)
1903
2124
  * that distinguishes the env-2 external-PWA relay (`relay-mobile`) from the
1904
2125
  * intoss-private dog-food relay (`relay-dev`); both are `kind: 'relay'`.
1905
2126
  *
2127
+ * `relay-live` (env 4) has been removed (#665). `liveIntent` parameter is gone.
2128
+ *
1906
2129
  * Pure — used at every output boundary (envelope `meta.env`, `get_debug_status`,
1907
2130
  * `measure_safe_area` provenance) so the surface never sniffs a URL again.
1908
2131
  *
1909
2132
  * Written switch-style so a missing arm is a TS compile error (never falls
1910
2133
  * through to a default).
1911
2134
  */
1912
- function deriveEnvironment(kind, liveIntent, relayOrigin) {
2135
+ function deriveEnvironment(kind, relayOrigin) {
1913
2136
  switch (kind) {
1914
2137
  case "local": return "mock";
1915
- case "relay":
1916
- if (liveIntent) return "relay-live";
1917
- return relayOrigin === "external-pwa" ? "relay-mobile" : "relay-dev";
2138
+ case "relay": return relayOrigin === "external-pwa" ? "relay-mobile" : "relay-dev";
1918
2139
  }
1919
2140
  }
1920
- /**
1921
- * Module-level `relay-dev` vs `relay-live` intent bit (issue #348).
1922
- *
1923
- * Armed by `start_debug({ mode: 'relay-live' })` (and seeded at boot by the
1924
- * deprecated `MCP_ENV=relay-live` alias). Disarming is implicit: when the
1925
- * active connection becomes local, the LIVE guard reads
1926
- * `connection.kind === 'relay' && liveIntent`, so a stale `true` bit is inert.
1927
- *
1928
- * SECRET-HANDLING: this is a boolean — never a secret. Safe to read in logs.
1929
- */
1930
- let liveIntent = false;
1931
- /** Returns the current `liveIntent` bit. */
1932
- function getLiveIntent() {
1933
- return liveIntent;
1934
- }
1935
- /**
1936
- * Sets the `liveIntent` bit. Called by `start_debug` (true for `relay-live`,
1937
- * false for every other mode) and once at boot by the `MCP_ENV=relay-live`
1938
- * deprecated alias.
1939
- */
1940
- function setLiveIntent(value) {
1941
- liveIntent = value;
1942
- }
1943
2141
  //#endregion
1944
2142
  //#region src/mcp/errors.ts
1945
2143
  /**
@@ -1966,12 +2164,12 @@ function mcpError(message) {
1966
2164
  * (예: `derived:kind=relay,liveIntent=true`).
1967
2165
  */
1968
2166
  function tierRejectionError(toolName, requiredEnv, currentEnv, reason) {
1969
- return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "relay로 전환하려면 MCP_ENV=relay 설정 후 서버를 재시작하고 build_attach_url → QR 스캔으로 실기기를 attach하세요." : "mock으로 전환하려면 MCP_ENV=mock 설정 후 서버를 재시작하세요."}`}\n\n${`tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`}`);
2167
+ 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}).`}`);
1970
2168
  }
1971
2169
  /**
1972
2170
  * 상태 1: tunnel 미가동 — cloudflared 터널이 아직 뜨지 않았다.
1973
2171
  *
1974
- * `build_attach_url` 호출 시 tunnel.up === false 인 경우.
2172
+ * `start_attach` 호출 시 tunnel.up === false 인 경우.
1975
2173
  */
1976
2174
  function tunnelDownError() {
1977
2175
  return mcpError("cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
@@ -1982,7 +2180,7 @@ function tunnelDownError() {
1982
2180
  * enableDomains()가 "No mini-app page attached" 에러를 던질 때.
1983
2181
  */
1984
2182
  function pageMissingError(toolName) {
1985
- return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. dog-food 번들 배포 후 build_attach_url을 호출해 QR 생성하세요: \`ait deploy --scheme-only\` → \`build_attach_url(scheme_url)\` → QR 스캔.`);
2183
+ return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. dog-food 번들 배포 후 start_attach를 호출해 QR 생성 + attach까지 진행하세요: \`ait deploy --scheme-only\` → \`start_attach(scheme_url)\` → QR 스캔.`);
1986
2184
  }
1987
2185
  /**
1988
2186
  * 상태 3: page crash — 연결됐던 페이지가 crash/destroy됐다.
@@ -1991,7 +2189,7 @@ function pageMissingError(toolName) {
1991
2189
  * 던질 때 이 메시지를 사용한다.
1992
2190
  */
1993
2191
  function pageCrashError(toolName) {
1994
- return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 build_attach_url → QR 스캔으로 재attach하세요.`);
2192
+ return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 start_attach → QR 스캔으로 재attach하세요.`);
1995
2193
  }
1996
2194
  /**
1997
2195
  * 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다.
@@ -2013,23 +2211,6 @@ function sdkAbsentError(toolName, isLocal = false) {
2013
2211
  return mcpError(`${prefix}window.__sdkCall이 주입되지 않았습니다 (dog-food 빌드가 아닙니다). dog-food 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
2014
2212
  }
2015
2213
  /**
2016
- * relay-live 환경에서 side-effect 도구(`call_sdk`, `evaluate`)를 `confirm: true`
2017
- * 없이 호출했을 때 반환하는 거부 메시지.
2018
- *
2019
- * 다음 행동을 두 가지로 제시한다:
2020
- * 1. 같은 호출에 `confirm: true` 인자를 추가해 재시도.
2021
- * 2. 읽기 전용 환경(relay-dev, mock)으로 전환.
2022
- */
2023
- function liveGuardError(toolName) {
2024
- return mcpError(`[LIVE relay guard] ${toolName}은 현재 relay-live(실 출시 런타임) 세션에서 side-effect 호출입니다. 실유저에게 영향을 줄 수 있어 명시적 동의가 필요합니다.
2025
-
2026
- 다음 중 하나를 선택하세요:
2027
- 1. \`confirm: true\` 인자를 추가해 재호출: ${toolName}(…, confirm: true)\n 2. 읽기 전용 도구(list_pages, list_console_messages, take_screenshot 등)를 사용하세요.
2028
- 3. dog-food 빌드(relay-dev 환경)에서 먼저 검증 후 live에 적용하세요.
2029
-
2030
- live-guard: MCP_ENV=relay-live + confirm: true missing`);
2031
- }
2032
- /**
2033
2214
  * relay WebSocket 연결이 끊겼을 때 — 크래시가 아닌 네트워크/프로세스 종료.
2034
2215
  */
2035
2216
  function relayDisconnectError(toolName) {
@@ -2539,7 +2720,7 @@ const en = {
2539
2720
  "dashboard.tunnel.up": "Connected",
2540
2721
  "dashboard.tunnel.down": "Disconnected",
2541
2722
  "dashboard.attach.section": "Attach QR",
2542
- "dashboard.attach.hint": "Call the build_attach_url MCP tool to show the QR here.",
2723
+ "dashboard.attach.hint": "Call the start_attach MCP tool to show the QR here.",
2543
2724
  "dashboard.attach.tunnelDown": "Relay disconnected — this QR is no longer valid. Restart the relay, then regenerate the QR.",
2544
2725
  "dashboard.pages.section": "Connected Pages",
2545
2726
  "dashboard.pages.empty": "No attached pages",
@@ -2557,7 +2738,6 @@ const en = {
2557
2738
  "attach.url.section": "URL (fallback)",
2558
2739
  "attach.mode.sandbox": "env 2 — AITC Sandbox App (PWA)",
2559
2740
  "attach.mode.intossDev": "env 3 — intoss-private relay dev",
2560
- "attach.mode.intossLive": "env 4 — intoss live relay debug",
2561
2741
  "attach.sandbox.step1": "Launch the launcher PWA icon on your home screen (if the Safari address bar is visible, it is not standalone).",
2562
2742
  "attach.sandbox.step2": "Scan this QR code with <strong>\"Scan QR with camera\"</strong> inside the launcher.",
2563
2743
  "attach.sandbox.step3": "The mini-app opens fullscreen and the debug session attaches automatically.",
@@ -2573,7 +2753,6 @@ const en = {
2573
2753
  "attach.intoss.faq.prepare": "<strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter",
2574
2754
  "attach.intoss.faq.chii": "<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import",
2575
2755
  "attach.intoss.faq.totp": "<strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server",
2576
- "attach.intoss.faq.liveReadOnly": "<strong>LIVE session is read-only</strong> — <code>call_sdk</code>/<code>evaluate</code> require an explicit <code>confirm</code>",
2577
2756
  "launcher.title": "AITC DevTools Launcher",
2578
2757
  "launcher.description": "Scan the terminal QR code or paste the tunnel URL.",
2579
2758
  "launcher.installCta": "Install launcher to your phone",
@@ -2776,7 +2955,7 @@ const tables = {
2776
2955
  "dashboard.tunnel.up": "연결됨",
2777
2956
  "dashboard.tunnel.down": "끊어짐",
2778
2957
  "dashboard.attach.section": "Attach QR",
2779
- "dashboard.attach.hint": "build_attach_url MCP tool을 호출하면 QR이 여기에 표시됩니다.",
2958
+ "dashboard.attach.hint": "start_attach MCP tool을 호출하면 QR이 여기에 표시됩니다.",
2780
2959
  "dashboard.attach.tunnelDown": "relay 연결이 끊겼습니다 — 이 QR은 더 이상 유효하지 않습니다. relay를 재시작한 뒤 QR을 다시 생성하세요.",
2781
2960
  "dashboard.pages.section": "연결된 Pages",
2782
2961
  "dashboard.pages.empty": "attach된 페이지 없음",
@@ -2794,7 +2973,6 @@ const tables = {
2794
2973
  "attach.url.section": "URL (fallback)",
2795
2974
  "attach.mode.sandbox": "환경 2 — AITC Sandbox App (PWA)",
2796
2975
  "attach.mode.intossDev": "환경 3 — intoss-private relay dev",
2797
- "attach.mode.intossLive": "환경 4 — intoss live relay debug",
2798
2976
  "attach.sandbox.step1": "홈 화면의 launcher PWA 아이콘으로 실행하세요 (Safari 주소창이 보이면 standalone이 아닙니다).",
2799
2977
  "attach.sandbox.step2": "launcher 안의 <strong>\"QR 카메라로 스캔\"</strong>으로 이 QR 코드를 스캔하세요.",
2800
2978
  "attach.sandbox.step3": "미니앱이 풀스크린으로 열리고 디버그 세션이 자동으로 attach됩니다.",
@@ -2810,7 +2988,6 @@ const tables = {
2810
2988
  "attach.intoss.faq.prepare": "<strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인",
2811
2989
  "attach.intoss.faq.chii": "<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인",
2812
2990
  "attach.intoss.faq.totp": "<strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인",
2813
- "attach.intoss.faq.liveReadOnly": "<strong>LIVE 세션은 read-only입니다</strong> — <code>call_sdk</code>/<code>evaluate</code> 실행에는 명시적 <code>confirm</code>이 필요합니다",
2814
2991
  "launcher.title": "AITC DevTools Launcher",
2815
2992
  "launcher.description": "터미널 QR을 스캔하거나 URL을 입력하세요.",
2816
2993
  "launcher.installCta": "폰에 런처 설치하기",
@@ -3069,7 +3246,7 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0;
3069
3246
  }
3070
3247
  .inspector-link:hover { background: #388bfd; }
3071
3248
  .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
3072
- </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li>__LIVE_FAQ__</ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section><hr/><section id="inspector-section"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section></body></html>`;
3249
+ </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section><hr/><section id="inspector-section"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section></body></html>`;
3073
3250
  const dashboardChromeHtmlEn = `<!DOCTYPE html>
3074
3251
  <html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>AIT Debug Dashboard</title><style>
3075
3252
  *, *::before, *::after { box-sizing: border-box; }
@@ -3250,7 +3427,7 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0;
3250
3427
  }
3251
3428
  .inspector-link:hover { background: #388bfd; }
3252
3429
  .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
3253
- </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>"Open in Toss"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li>__LIVE_FAQ__</ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section><hr/><section id="inspector-section"><h2>Inspector</h2>__INSPECTOR_SECTION__</section></body></html>`;
3430
+ </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>"Open in Toss"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section><hr/><section id="inspector-section"><h2>Inspector</h2>__INSPECTOR_SECTION__</section></body></html>`;
3254
3431
  /** Map from Locale to the precompiled dashboard chrome string. */
3255
3432
  const dashboardChromeByLocale = {
3256
3433
  ko: dashboardChromeHtmlKo,
@@ -3287,9 +3464,6 @@ function buildModeLabel(mode, s) {
3287
3464
  case "relay-dev":
3288
3465
  label = s("attach.mode.intossDev");
3289
3466
  break;
3290
- case "relay-live":
3291
- label = s("attach.mode.intossLive");
3292
- break;
3293
3467
  case "mock":
3294
3468
  case void 0: return "";
3295
3469
  }
@@ -3563,12 +3737,11 @@ function buildSseScript(strings) {
3563
3737
  * - __SAFE_LABEL__ : HTML-escaped deploymentId label (intoss family에만 존재)
3564
3738
  * - __SAFE_ATTACH_URL__ : HTML-escaped attach URL (TOTP at= 코드 포함 — 의도된 전달)
3565
3739
  * - __MODE_LABEL__ : 환경 배지 (`<p class="mode-label">…</p>` 또는 빈 문자열, #468)
3566
- * - __LIVE_FAQ__ : 환경 4 LIVE read-only `<li>` 또는 빈 문자열 (intoss family에만 존재)
3567
3740
  * - __INSPECTOR_SECTION__ : "디버그 툴 열기" 버튼 또는 대기 힌트 (#544)
3568
3741
  *
3569
3742
  * mode-aware 분기 (#468): mode가 `relay-mobile`이면 sandbox family chrome(launcher
3570
- * PWA 절차), 그 외는 intoss family chrome(토스 앱 절차)을 선택한다. `relay-live`는
3571
- * intoss chrome에 LIVE read-only 라인을 추가한다.
3743
+ * PWA 절차), 그 외는 intoss family chrome(토스 앱 절차)을 선택한다.
3744
+ * relay-live (env 4) 제거 (#665) — positive-allowlist kill-switch.
3572
3745
  *
3573
3746
  * SSE 스크립트도 주입 — `#attach-section` hook이 있으면 `/events` push 때 QR이
3574
3747
  * `/qr.png?u=<fresh attachUrl>`로 자동 갱신된다. `#inspector-link`도 SSE push로
@@ -3581,11 +3754,10 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/a
3581
3754
  const s = resolveLocaleStrings(locale);
3582
3755
  const langSwitcher = buildLangSwitcher(path, params, locale, s);
3583
3756
  const family = attachFamilyForMode(mode);
3584
- const liveFaq = mode === "relay-live" ? `<li>${s("attach.intoss.faq.liveReadOnly")}</li>` : "";
3585
3757
  let inspectorSection;
3586
3758
  if (pagesAttached && inspectorStableUrl) inspectorSection = `<a class="inspector-link" id="inspector-link" href="${escapeHtml(inspectorStableUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(s("dashboard.inspector.open"))}</a>`;
3587
3759
  else inspectorSection = `<span class="inspector-hint" id="inspector-link">${escapeHtml(s("dashboard.inspector.waiting"))}</span>`;
3588
- const filled = attachChromeByLocale[locale][family].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__MODE_LABEL__", buildModeLabel(mode, s)).replaceAll("__LIVE_FAQ__", liveFaq).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl).replaceAll("__INSPECTOR_SECTION__", inspectorSection);
3760
+ const filled = attachChromeByLocale[locale][family].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__MODE_LABEL__", buildModeLabel(mode, s)).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl).replaceAll("__INSPECTOR_SECTION__", inspectorSection);
3589
3761
  const sseScript = buildSseScript({
3590
3762
  tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
3591
3763
  tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
@@ -4212,22 +4384,27 @@ const DEBUG_TOOL_DEFINITIONS = [
4212
4384
  availableIn: "both"
4213
4385
  },
4214
4386
  {
4215
- name: "build_attach_url",
4216
- description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Builds a self-attaching deep-link for the active relay environment and returns a QR code. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Call list_pages first to confirm the relay/tunnel is up. If the tunnel is not up, restart: `npx @ait-co/devtools devtools-mcp`.\n\nEnvironment-specific behaviour:\n • env 3 / relay-staging (start_debug mode=\"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 to produce a self-attach deep-link.\n • env 2 / relay-sandbox (start_debug mode=\"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 automatically added as name= so the launcher partner bar shows it. Scan the QR with the phone to open the launcher, which frames the tunnel URL and attaches CDP.\n\nSet wait_for_attach=true to block until a page attaches (default 60 s, adjustable via wait_timeout_seconds). On timeout, call build_attach_url again to resume polling. 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 auth: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the returned attachUrl automatically includes the current one-time code (at=<code>). The code is valid for ~3 minutes (the relay gate accepts ±6 TOTP steps = 180–210 s of backwards acceptance). The response includes a `totp` field with `expiresAt` (ISO timestamp, ~3 min from issuance). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh URL. 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/4 (relay-staging/relay-live) — passing selfdebug=true there returns an error.",
4387
+ name: "start_attach",
4388
+ 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.",
4217
4389
  inputSchema: {
4218
4390
  type: "object",
4219
4391
  properties: {
4392
+ mode: {
4393
+ type: "string",
4394
+ enum: [
4395
+ "local-browser",
4396
+ "relay-sandbox",
4397
+ "relay-staging"
4398
+ ],
4399
+ 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."
4400
+ },
4220
4401
  scheme_url: {
4221
4402
  type: "string",
4222
4403
  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."
4223
4404
  },
4224
- wait_for_attach: {
4225
- type: "boolean",
4226
- description: "If true, block after returning the QR until a page attaches to the relay (polls listTargets ~1 s interval, default 60 s). On attach, the response includes the attached page list. On timeout, call build_attach_url again to resume polling."
4227
- },
4228
4405
  wait_timeout_seconds: {
4229
4406
  type: "number",
4230
- description: "Maximum seconds to wait when wait_for_attach=true (default 60, range 1–600). Values outside the range or invalid inputs (0, negative, NaN) fall back to the default silently. Only meaningful when wait_for_attach=true."
4407
+ 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."
4231
4408
  },
4232
4409
  projectRoot: {
4233
4410
  type: "string",
@@ -4235,7 +4412,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4235
4412
  },
4236
4413
  selfdebug: {
4237
4414
  type: "boolean",
4238
- 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/4 (relay-staging/relay-live) returns an error. Default: false (omitted — output is byte-identical to previous behaviour)."
4415
+ 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)."
4239
4416
  }
4240
4417
  },
4241
4418
  required: []
@@ -4274,7 +4451,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4274
4451
  },
4275
4452
  {
4276
4453
  name: "measure_safe_area",
4277
- 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-live\" | \"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 production WebView.) 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.",
4454
+ 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.",
4278
4455
  inputSchema: {
4279
4456
  type: "object",
4280
4457
  properties: {},
@@ -4284,19 +4461,13 @@ const DEBUG_TOOL_DEFINITIONS = [
4284
4461
  },
4285
4462
  {
4286
4463
  name: "evaluate",
4287
- 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\nLIVE guard: when running against a live/production relay (relay-live env, MCP_ENV=relay-live), this tool requires `confirm: true` to acknowledge that the expression may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.",
4464
+ 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.",
4288
4465
  inputSchema: {
4289
4466
  type: "object",
4290
- properties: {
4291
- expression: {
4292
- type: "string",
4293
- description: "JavaScript expression to evaluate in the page context."
4294
- },
4295
- confirm: {
4296
- type: "boolean",
4297
- description: "Required when MCP_ENV=relay-live. Set to `true` to explicitly acknowledge that this expression may have side effects on real/live users. Omitting this in a relay-live session results in a structured rejection error. Has no effect in mock or relay-dev sessions."
4298
- }
4299
- },
4467
+ properties: { expression: {
4468
+ type: "string",
4469
+ description: "JavaScript expression to evaluate in the page context."
4470
+ } },
4300
4471
  required: ["expression"]
4301
4472
  },
4302
4473
  availableIn: "both"
@@ -4316,7 +4487,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4316
4487
  },
4317
4488
  {
4318
4489
  name: "call_sdk",
4319
- 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\nLIVE guard: when running against a live/production relay (relay-live env, MCP_ENV=relay-live), this tool requires `confirm: true` to acknowledge that the SDK call may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
4490
+ 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\", [])",
4320
4491
  inputSchema: {
4321
4492
  type: "object",
4322
4493
  properties: {
@@ -4328,10 +4499,6 @@ const DEBUG_TOOL_DEFINITIONS = [
4328
4499
  type: "array",
4329
4500
  description: "Arguments to pass to the SDK method (optional, default []).",
4330
4501
  items: {}
4331
- },
4332
- confirm: {
4333
- type: "boolean",
4334
- description: "Required when MCP_ENV=relay-live. Set to `true` to explicitly acknowledge that this SDK call may have side effects on real/live users. Omitting this in a relay-live session results in a structured rejection error. Has no effect in mock or relay-dev sessions."
4335
4502
  }
4336
4503
  },
4337
4504
  required: ["name"]
@@ -4370,7 +4537,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4370
4537
  },
4371
4538
  {
4372
4539
  name: "start_debug",
4373
- description: "Switches the active debug environment in-place (issue #348) — no Claude Code restart and no MCP re-handshake. One daemon holds both a local (env 1, mock SDK in a Chromium) and a relay (env 3/4, real-device Toss WebView over the Chii relay + cloudflared tunnel) connection at once; this tool flips which one every other tool reads from, lazily booting the requested family's infra on first use and keeping the inactive one warm so an existing attach survives the switch. After switching it emits notifications/tools/list_changed — call tools/list again to see the updated tool surface for the new environment.\n\nmodes:\n local-browser — 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). liveIntent off — dev-intent, LIVE guard inactive, 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 build_attach_url 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 relay-live — env 4: the REVIEW-PASSED, released production runtime with the REAL SDK over the intoss relay — real end users are on the other side. Read-only debugging is the intent: the LIVE guard is armed, so call_sdk/evaluate require confirm:true per call, and ENTERING relay-live ALSO requires confirm:true on this call. Use it only to observe a shipped regression; verify fixes in relay-staging first.\n\nSwitching back to local-browser automatically disarms the LIVE guard.\n\nFor a relay mode (relay-sandbox/relay-staging/relay-live), 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.",
4540
+ 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.",
4374
4541
  inputSchema: {
4375
4542
  type: "object",
4376
4543
  properties: {
@@ -4379,18 +4546,13 @@ const DEBUG_TOOL_DEFINITIONS = [
4379
4546
  enum: [
4380
4547
  "local-browser",
4381
4548
  "relay-sandbox",
4382
- "relay-staging",
4383
- "relay-live"
4549
+ "relay-staging"
4384
4550
  ],
4385
- description: "Target environment to switch to. mode=relay-live additionally requires confirm: true (and arms the read-only LIVE guard)."
4386
- },
4387
- confirm: {
4388
- type: "boolean",
4389
- description: "Required when mode=relay-live — set true to acknowledge entering LIVE (env 4) debugging that can affect real users. Ignored for the other modes."
4551
+ description: "Target environment to switch to. relay-live (env 4) has been removed (#665) use relay-staging (env 3) for dog-food debugging."
4390
4552
  },
4391
4553
  projectRoot: {
4392
4554
  type: "string",
4393
- 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-live/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)."
4555
+ 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)."
4394
4556
  }
4395
4557
  },
4396
4558
  required: ["mode"]
@@ -4399,7 +4561,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4399
4561
  },
4400
4562
  {
4401
4563
  name: "get_debug_status",
4402
- 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-live|relay-mobile, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active; start_debug mode→kind mapping: relay-sandbox→relay-mobile, relay-staging→relay-dev, relay-live→relay-live, 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).",
4564
+ 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).",
4403
4565
  inputSchema: {
4404
4566
  type: "object",
4405
4567
  properties: { recent_errors_limit: {
@@ -4412,7 +4574,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4412
4574
  },
4413
4575
  {
4414
4576
  name: "run_tests",
4415
- 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). Requires an attached page — call list_pages first to confirm one is attached. 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. In a relay-live session this is a state-mutating injection and is blocked unless confirm=true (ignored in mock/local/relay-dev/relay-mobile). debug-mode only — dev-mode (--mode=dev) has no CDP. Tier C (both mock/local and relay). Use the test-runner CLI (devtools-test) for the same run outside MCP.",
4577
+ 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). Requires an attached page — call list_pages first to confirm one is attached. 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). The devtools-test CLI shares this run core and file discovery, but its standalone relay attach is not wired yet — run via this tool for now.",
4416
4578
  inputSchema: {
4417
4579
  type: "object",
4418
4580
  properties: {
@@ -4428,10 +4590,6 @@ const DEBUG_TOOL_DEFINITIONS = [
4428
4590
  timeout_ms: {
4429
4591
  type: "number",
4430
4592
  description: "Per-file evaluate timeout in ms (default 30000, range 1000–600000). Out-of-range/invalid values fall back to the default."
4431
- },
4432
- confirm: {
4433
- type: "boolean",
4434
- description: "Required (true) to run in a relay-live session — test injection mutates page state. Ignored in mock/local/relay-dev/relay-mobile sessions."
4435
4593
  }
4436
4594
  },
4437
4595
  required: ["files"]
@@ -4457,8 +4615,9 @@ function getToolAvailability(name) {
4457
4615
  * Unknown tools return `false` — callers should reject them as unknown rather
4458
4616
  * than as env-mismatched.
4459
4617
  *
4460
- * Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
4618
+ * Relay variants (`relay-dev`, `relay-mobile`) all satisfy the
4461
4619
  * `'relay'` availability tier — `isRelayEnv()` is used for the check.
4620
+ * (`relay-live` removed #665.)
4462
4621
  */
4463
4622
  function isToolAvailableIn(name, env) {
4464
4623
  const availability = getToolAvailability(name);
@@ -4472,8 +4631,8 @@ function isToolAvailableIn(name, env) {
4472
4631
  * matches the given env. Pure — preserves order; both Tier C ("both") and the
4473
4632
  * matching single-env tier pass through.
4474
4633
  *
4475
- * Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
4476
- * `'relay'` tier.
4634
+ * Relay variants (`relay-dev`, `relay-mobile`) all satisfy the
4635
+ * `'relay'` tier. (`relay-live` removed #665.)
4477
4636
  */
4478
4637
  function filterToolsByEnvironment(tools, env) {
4479
4638
  return tools.filter((t) => t.availableIn === "both" || t.availableIn === "relay" && isRelayEnv(env) || t.availableIn === env);
@@ -4481,14 +4640,14 @@ function filterToolsByEnvironment(tools, env) {
4481
4640
  /**
4482
4641
  * Tool names that are available before any page attaches (bootstrap tier).
4483
4642
  *
4484
- * `build_attach_url` — pure URL synthesis, no attach needed.
4485
- * `list_pages` — reports tunnel status + empty pages even pre-attach.
4643
+ * `start_attach` — mode switch + QR synthesis + attach wait, no prior attach needed.
4644
+ * `list_pages` — reports tunnel status + empty pages even pre-attach.
4486
4645
  *
4487
4646
  * All other tools require an attached page (`enableDomains` must succeed) and
4488
4647
  * are only advertised in `tools/list` once a target appears.
4489
4648
  */
4490
4649
  const BOOTSTRAP_TOOL_NAMES = new Set([
4491
- "build_attach_url",
4650
+ "start_attach",
4492
4651
  "get_debug_status",
4493
4652
  "list_pages",
4494
4653
  "start_debug"
@@ -4592,57 +4751,6 @@ function listPages(connection, tunnel) {
4592
4751
  };
4593
4752
  }
4594
4753
  /**
4595
- * Builds a self-attaching dog-food deep-link from an `ait deploy --scheme-only`
4596
- * URL plus this session's live relay. Throws if the tunnel is not up yet (no
4597
- * relay URL to splice in) — the caller surfaces that as a tool error.
4598
- *
4599
- * When `AIT_DEBUG_TOTP_SECRET` is set, generates the current TOTP code and
4600
- * splices it as `at=<code>` into the attach URL. The code is valid for ~3
4601
- * minutes (the relay gate uses {@link RELAY_VERIFY_SKEW_STEPS}=6, accepting
4602
- * past 6 steps = 180–210 s backwards from issuance). If the scan happens after
4603
- * `totp.expiresAt`, call `build_attach_url` again to get a fresh code (#490).
4604
- *
4605
- * Also validates the scheme URL's authority. A suspicious authority (empty,
4606
- * "web", "localhost", etc.) is surfaced as a non-fatal `authorityWarning` on
4607
- * the result so the caller can show a helpful hint without blocking the link
4608
- * generation (the warning is consistent with how other validation in
4609
- * `buildDeepLinkAttachUrl` works — hard errors for relay, soft warning for
4610
- * the scheme authority which is in the caller's input, not ours to own).
4611
- *
4612
- * SECRET-HANDLING: `totpSecret` (if provided) is used only to compute a code
4613
- * and must never appear in any log, error message, or output outside of the
4614
- * spliced `at=` param in `attachUrl`.
4615
- *
4616
- * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL.
4617
- * @param tunnel - Current tunnel status from the running debug server.
4618
- * @param totpSecret - Optional hex-encoded TOTP secret (from
4619
- * `AIT_DEBUG_TOTP_SECRET`). When provided, the current code is spliced into
4620
- * the attach URL as `at=<code>`.
4621
- */
4622
- function buildAttachUrl(schemeUrl, tunnel, totpSecret) {
4623
- if (!tunnel.up || tunnel.wssUrl === null) throw new Error("tunnel-down: cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
4624
- const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
4625
- let totpCode;
4626
- let totpMeta;
4627
- if (totpSecret !== void 0 && totpSecret !== "") {
4628
- const now = Date.now();
4629
- totpCode = generateTotp(totpSecret, now);
4630
- const STEP_SECONDS = 30;
4631
- const expiresAtMs = now + 6 * STEP_SECONDS * 1e3;
4632
- totpMeta = {
4633
- enabled: true,
4634
- ttlSeconds: 6 * STEP_SECONDS,
4635
- expiresAt: new Date(expiresAtMs).toISOString()
4636
- };
4637
- }
4638
- return {
4639
- attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl, totpCode),
4640
- relayUrl: tunnel.wssUrl,
4641
- ...authorityWarning !== void 0 ? { authorityWarning } : {},
4642
- ...totpMeta !== void 0 ? { totp: totpMeta } : {}
4643
- };
4644
- }
4645
- /**
4646
4754
  * Heuristic: can this process open a GUI browser?
4647
4755
  *
4648
4756
  * Returns `true` when we think a GUI is available:
@@ -5259,7 +5367,7 @@ async function readMcpSdkVersion() {
5259
5367
  * some test environments that skip the build step).
5260
5368
  */
5261
5369
  function readDevtoolsVersion() {
5262
- return "0.1.108";
5370
+ return "0.1.110";
5263
5371
  }
5264
5372
  /**
5265
5373
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -5268,10 +5376,10 @@ function readDevtoolsVersion() {
5268
5376
  * 0. tunnel.droppedAt non-null → restart (permanent tunnel drop — highest priority)
5269
5377
  * 1. tunnel.up === false AND env is relay → restart (relay needs a live tunnel)
5270
5378
  * 1b. tunnel.up === false AND env is mock → wait_for_page (local target: tunnel-less is normal)
5271
- * 2a. authRejects.count > 0 AND pages empty → build_attach_url (relay TOTP 거부 관측 — QR 재스캔
5379
+ * 2a. authRejects.count > 0 AND pages empty → start_attach (relay TOTP 거부 관측 — QR 재스캔
5272
5380
  * 또는 target-side `at` 전달 확인. 일반 rule 2보다 구체적이므로 먼저 평가 — issue #467)
5273
- * 2. tunnel.up, pages empty, env === relay → build_attach_url (start attach)
5274
- * 3. pages has entry + crashDetectedAt non-null → build_attach_url (re-attach after crash)
5381
+ * 2. tunnel.up, pages empty, env === relay → start_attach (start attach)
5382
+ * 3. pages has entry + crashDetectedAt non-null → start_attach (re-attach after crash)
5275
5383
  * 4. otherwise → null (session looks healthy)
5276
5384
  *
5277
5385
  * Pure — does not throw; receives the final assembled snapshot fields.
@@ -5294,16 +5402,16 @@ function computeNextRecommendedAction(tunnel, pages, env, authRejects = null) {
5294
5402
  reason: "tunnel not up — run `npx @ait-co/devtools devtools-mcp` to restart"
5295
5403
  };
5296
5404
  if (authRejects !== null && authRejects.count > 0 && pages !== null && pages.pages.length === 0) return {
5297
- tool: "build_attach_url",
5405
+ tool: "start_attach",
5298
5406
  reason: `relay 인증(TOTP) 거부 ${authRejects.count}건 발생 (last ${authRejects.lastAt ?? "unknown"}) — QR을 다시 스캔해 새 코드로 attach하세요(코드는 ~3분마다 만료). 반복되면 폰 페이지 URL에 at 파라미터가 전달되는지(target-side TOTP 전달 경로)를 확인하세요`
5299
5407
  };
5300
5408
  if (isRelayEnv(env) && pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) return {
5301
- tool: "build_attach_url",
5302
- reason: "tunnel ready, no pages attached — call build_attach_url to generate the attach QR"
5409
+ tool: "start_attach",
5410
+ reason: "tunnel ready, no pages attached — call start_attach to generate the attach QR"
5303
5411
  };
5304
5412
  if (pages !== null && pages.crashDetectedAt !== null) return {
5305
- tool: "build_attach_url",
5306
- reason: `page crashed at ${pages.crashDetectedAt} — call build_attach_url to re-attach`
5413
+ tool: "start_attach",
5414
+ reason: `page crashed at ${pages.crashDetectedAt} — call start_attach to re-attach`
5307
5415
  };
5308
5416
  return null;
5309
5417
  }
@@ -5368,7 +5476,7 @@ async function getDiagnostics(input) {
5368
5476
  kind: env,
5369
5477
  env: toLegacyEnv(env),
5370
5478
  reason: envReason,
5371
- liveGuardActive: isLiveRelayEnv(env)
5479
+ liveGuardActive: false
5372
5480
  },
5373
5481
  serverLockHolder,
5374
5482
  process: {
@@ -5481,7 +5589,7 @@ async function startQuickTunnel(localPort) {
5481
5589
  * every surface (terminal, VS Code, JetBrains, web) and can be scanned by a
5482
5590
  * phone camera when shown verbatim in an agent response.
5483
5591
  *
5484
- * Shared by `renderAttachBanner` (relay wssUrl QR) and the `build_attach_url`
5592
+ * Shared by `renderAttachBanner` (relay wssUrl QR) and the `start_attach`
5485
5593
  * MCP tool response (attach deep-link QR).
5486
5594
  */
5487
5595
  async function renderQr(text) {
@@ -5507,10 +5615,12 @@ async function renderQr(text) {
5507
5615
  return `${lines.join("\n")}\n`;
5508
5616
  }
5509
5617
  /**
5510
- * Renders the attach banner (relay URL + ASCII QR) as a string.
5618
+ * Renders the attach banner (relay URL + unicode half-block QR) as a string.
5511
5619
  *
5512
- * The QR encodes the base `wssUrl` only. When `totpEnabled` is true, a note
5513
- * is added that attach URLs generated by `build_attach_url` will include a
5620
+ * The QR is produced by `renderQr` (a half-block matrix, not the
5621
+ * `qrcode-terminal` ASCII art used by the unplugin banner) and encodes the
5622
+ * base `wssUrl` only. When `totpEnabled` is true, a note
5623
+ * is added that attach URLs generated by `start_attach` will include a
5514
5624
  * live TOTP code (`at=`) appended at call time.
5515
5625
  *
5516
5626
  * SECRET-HANDLING: no secret value, TOTP code, or intermediate value is
@@ -5526,9 +5636,9 @@ async function renderAttachBanner(input) {
5526
5636
  ` relay (wss): ${input.wssUrl}`,
5527
5637
  authNote,
5528
5638
  "",
5529
- " Use build_attach_url to generate a deep link with the current TOTP code.",
5639
+ " Use start_attach to generate a deep link with the current TOTP code.",
5530
5640
  " Scan the QR to locate the relay (open the dog-food URL separately with",
5531
- " ?debug=1&relay=<wss>&at=<code> or use the build_attach_url tool):",
5641
+ " ?debug=1&relay=<wss>&at=<code> or use the start_attach tool):",
5532
5642
  "",
5533
5643
  qr
5534
5644
  ].join("\n");
@@ -5702,7 +5812,7 @@ function makeTunnelStatus(up, wssUrl, droppedAt = null, reissueAttempts = 0) {
5702
5812
  * Dynamic tool registration (issue #208):
5703
5813
  * The server advertises `listChanged: true` so MCP clients can subscribe to
5704
5814
  * `notifications/tools/list_changed`. Before any page attaches, only bootstrap
5705
- * tools (`build_attach_url`, `list_pages`) are listed. Once a target appears,
5815
+ * tools (`start_attach`, `list_pages`) are listed. Once a target appears,
5706
5816
  * the full attach-dependent tool set is added and a `list_changed` notification
5707
5817
  * is sent — without requiring a session restart. `runDebugServer` and
5708
5818
  * `runLocalDebugServer` start a polling watcher that detects the 0→N target
@@ -5715,7 +5825,7 @@ function makeTunnelStatus(up, wssUrl, droppedAt = null, reissueAttempts = 0) {
5715
5825
  */
5716
5826
  /**
5717
5827
  * Maximum age (ms) of a page's `lastSeenAt` before it is treated as a ghost
5718
- * and excluded from the `wait_for_attach` short-circuit in `build_attach_url`
5828
+ * and excluded from the `wait_for_attach` short-circuit in `start_attach`
5719
5829
  * (issue #610).
5720
5830
  *
5721
5831
  * Rationale: the env-2 relay is owned by the dev server (unplugin), so every
@@ -5730,7 +5840,15 @@ function makeTunnelStatus(up, wssUrl, droppedAt = null, reissueAttempts = 0) {
5730
5840
  */
5731
5841
  const RELAY_SANDBOX_STALE_PAGE_MS = 300 * 1e3;
5732
5842
  /**
5733
- * Predicate used by `build_attach_url`'s `wait_for_attach` loop to decide
5843
+ * Segment length (ms) of the `start_attach` wait loop (issue #626 — TOTP in-call
5844
+ * re-mint). The single-shot `wait_for_attach` of the old attach tool could
5845
+ * not re-mint a TOTP code mid-wait; `start_attach` decomposes the wait into
5846
+ * SEGMENT_MS slices so it can detect an aging code between slices and re-mint a
5847
+ * fresh one without the agent re-calling the tool. 30 s = one TOTP step.
5848
+ */
5849
+ const START_ATTACH_SEGMENT_MS = 3e4;
5850
+ /**
5851
+ * Predicate used by `start_attach`'s `wait_for_attach` loop to decide
5734
5852
  * whether the relay-sandbox connection has a genuinely fresh page attached.
5735
5853
  *
5736
5854
  * Stale-ghost gating (issue #610): when the dev server restarts with a new
@@ -5780,13 +5898,28 @@ function extractDeploymentId(schemeUrl) {
5780
5898
  }
5781
5899
  }
5782
5900
  /**
5783
- * Returns `true` when the mode routes to a relay connection (`relay-sandbox`,
5784
- * `relay-staging`, or `relay-live`). `relay-sandbox` is an external-PWA relay;
5785
- * `relay-staging`/`relay-live` are intoss-private relays — but all three surface
5786
- * the Tier B / relay-only tool set.
5901
+ * Returns `true` when the mode routes to a relay connection (`relay-sandbox` or
5902
+ * `relay-staging`). Both surface the Tier B / relay-only tool set.
5787
5903
  */
5788
5904
  function isRelayMode(mode) {
5789
- return mode === "relay-sandbox" || mode === "relay-staging" || mode === "relay-live";
5905
+ return mode === "relay-sandbox" || mode === "relay-staging";
5906
+ }
5907
+ /**
5908
+ * Maps a `StartDebugMode` to the `McpEnvironment` it routes to (issue #626).
5909
+ * Used by `start_attach`'s mode prologue to decide whether a `switchMode` is
5910
+ * needed: when the active env already equals `envForMode(mode)`, the switch is
5911
+ * skipped (no `tools/list_changed` churn).
5912
+ *
5913
+ * - `local-browser` → `mock`
5914
+ * - `relay-sandbox` → `relay-mobile` (env 2 external-PWA relay)
5915
+ * - `relay-staging` → `relay-dev` (env 3 intoss-private relay)
5916
+ */
5917
+ function envForMode(mode) {
5918
+ switch (mode) {
5919
+ case "local-browser": return "mock";
5920
+ case "relay-sandbox": return "relay-mobile";
5921
+ case "relay-staging": return "relay-dev";
5922
+ }
5790
5923
  }
5791
5924
  /**
5792
5925
  * Single-attach guard for `run_tests` (#646). Two concurrent runs injecting
@@ -5809,6 +5942,12 @@ let runTestsInFlight = false;
5809
5942
  * to resolve before the relay had observed the first inbound CDP message from
5810
5943
  * the phone.
5811
5944
  *
5945
+ * Timeout note: callers (e.g. the `start_attach` path) always pass an
5946
+ * explicit `timeoutMs`, sourced from the factory's `waitForAttachTimeoutMs`
5947
+ * (default 60 000). That value is forwarded to `waitForFirstTarget`, so it
5948
+ * overrides that method's own 90 000 signature default — the effective
5949
+ * wait on the tool path is 60 s, not 90 s.
5950
+ *
5812
5951
  * @param connection - The CDP connection (production or fake).
5813
5952
  * @param filterFn - Resolves when this predicate is satisfied.
5814
5953
  * @param timeoutMs - Maximum wait time in ms.
@@ -5845,7 +5984,7 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
5845
5984
  * tunnel, which is what makes the tool surface unit-testable.
5846
5985
  *
5847
5986
  * `tools/list` is two-tiered (issue #208):
5848
- * - bootstrap (always): `build_attach_url`, `list_pages`
5987
+ * - bootstrap (always): `start_attach`, `list_pages`
5849
5988
  * - attach-dependent (after `connection.listTargets().length > 0`): all others
5850
5989
  *
5851
5990
  * `CallTool` is NOT tiered — hidden tools still execute (attach errors surface
@@ -5856,12 +5995,299 @@ function createDebugServer(deps) {
5856
5995
  const getTotpSecret = deps.getTotpSecret ?? (() => totpSecret);
5857
5996
  const readLockFn = readLockDep ?? readServerLock;
5858
5997
  const router = routerDep ?? makeSingleConnectionRouter(connection);
5859
- const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin));
5860
- const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},liveIntent=${getLiveIntent()}`);
5998
+ const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, router.activeRelayOrigin));
5999
+ const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},relayOrigin=${router.activeRelayOrigin ?? "none"}`);
5861
6000
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
6001
+ /**
6002
+ * Synthesizes an attach URL from stored components with a FRESHLY-minted TOTP
6003
+ * code (issue #626 §3/§4 — the single mint point). Reads the late-bound secret
6004
+ * via `getTotpSecret()` so the project-local `.ait_relay` secret loaded by
6005
+ * `switchMode` is visible. SECRET-HANDLING: the minted code rides inside the
6006
+ * URL's `at=` param only — never logged or returned separately.
6007
+ */
6008
+ function mintAttachUrl(parts) {
6009
+ const secret = getTotpSecret();
6010
+ const code = secret ? generateTotp(secret) : void 0;
6011
+ return parts.kind === "launcher" ? buildLauncherAttachUrl(parts.tunnelHttpUrl, parts.wssUrl, code, {
6012
+ name: parts.appName,
6013
+ ...parts.selfdebug ? { selfdebug: true } : {}
6014
+ }) : buildDeepLinkAttachUrl(parts.schemeUrl, parts.wssUrl, code);
6015
+ }
6016
+ /** Builds the fresh TOTP metadata (expiresAt window) for a tool result. */
6017
+ function buildTotpMeta() {
6018
+ const secret = getTotpSecret();
6019
+ if (secret === void 0 || secret === "") return void 0;
6020
+ const STEP_SECONDS = 30;
6021
+ const expiresAtMs = nowMs() + 6 * STEP_SECONDS * 1e3;
6022
+ return {
6023
+ enabled: true,
6024
+ ttlSeconds: 6 * STEP_SECONDS,
6025
+ expiresAt: new Date(expiresAtMs).toISOString()
6026
+ };
6027
+ }
6028
+ /**
6029
+ * Env-specific validation + component bundle for `start_attach` (issue #626).
6030
+ * Branches on `env`: `relay-mobile` reads AIT_TUNNEL_BASE_URL + builds launcher
6031
+ * parts; `relay-dev` requires scheme_url + builds scheme parts. Returns
6032
+ * `{ ok: false, error }` with a ready McpResult on any failure.
6033
+ */
6034
+ async function prepareAttach(env, args, conn) {
6035
+ const selfdebug = args?.selfdebug === true;
6036
+ if (selfdebug && env !== "relay-mobile") return {
6037
+ ok: false,
6038
+ error: mcpError("start_attach: selfdebug=true는 env 2 / relay-sandbox 전용 기능입니다. 현재 환경(env 3)에서는 launcher가 없어 self-target 모드를 지원하지 않습니다. launcher self-target이 필요하다면 relay-sandbox 모드로 전환하세요.")
6039
+ };
6040
+ if (env === "relay-mobile") {
6041
+ const rawProjectRoot = args?.projectRoot;
6042
+ const buildProjectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
6043
+ let tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
6044
+ if (tunnelHttpUrl === "" && buildProjectRoot !== void 0) {
6045
+ const { readRelayUrls } = await import("../relay-url-store-CwKT7i04.js");
6046
+ tunnelHttpUrl = (await readRelayUrls({ projectRoot: buildProjectRoot }))?.tunnelBaseUrl ?? "";
6047
+ }
6048
+ if (tunnelHttpUrl === "") return {
6049
+ ok: false,
6050
+ error: mcpError("start_attach(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. 자동 발견이 되지 않을 경우 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 직접 전달하세요.")
6051
+ };
6052
+ const tunnelStatus = getTunnelStatus();
6053
+ if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return {
6054
+ ok: false,
6055
+ error: mcpError("start_attach(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.")
6056
+ };
6057
+ const secret = getTotpSecret();
6058
+ if (secret === void 0 || secret === "") return {
6059
+ ok: false,
6060
+ error: mcpError("start_attach(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.")
6061
+ };
6062
+ let launcherAppName;
6063
+ if (buildProjectRoot !== void 0) try {
6064
+ const { readFileSync } = await import("node:fs");
6065
+ const pkgRaw = readFileSync(`${buildProjectRoot}/package.json`, "utf8");
6066
+ const pkg = JSON.parse(pkgRaw);
6067
+ const rawName = typeof pkg.name === "string" ? pkg.name : "";
6068
+ launcherAppName = (rawName.includes("/") ? rawName.slice(rawName.indexOf("/") + 1) : rawName).trim() || void 0;
6069
+ } catch {}
6070
+ const parts = {
6071
+ kind: "launcher",
6072
+ tunnelHttpUrl,
6073
+ wssUrl: tunnelStatus.wssUrl,
6074
+ appName: launcherAppName,
6075
+ ...selfdebug ? { selfdebug: true } : {}
6076
+ };
6077
+ const connAsAny = conn;
6078
+ const getLastSeenAt = typeof connAsAny.getTargetLastSeenAt === "function" ? (id) => connAsAny.getTargetLastSeenAt(id) : null;
6079
+ const callNow = nowMs();
6080
+ const isMatchingPage = (pages) => isSandboxPageFresh(pages, getLastSeenAt, callNow, stalePageThresholdMs);
6081
+ const buildTimeoutError = (baseText, timeoutSec, observed) => {
6082
+ const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
6083
+ return `${baseText}\n\nNo page attached within ${timeoutSec}s${observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : ""} — launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.`;
6084
+ };
6085
+ return {
6086
+ ok: true,
6087
+ parts,
6088
+ isMatchingPage,
6089
+ buildTimeoutError,
6090
+ authorityWarning: void 0,
6091
+ totpMeta: buildTotpMeta()
6092
+ };
6093
+ }
6094
+ const schemeUrl = args?.scheme_url;
6095
+ if (typeof schemeUrl !== "string" || schemeUrl === "") return {
6096
+ ok: false,
6097
+ error: mcpError("start_attach: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요. 환경 2(mobile)라면 scheme_url 대신 AIT_TUNNEL_BASE_URL을 설정하세요.")
6098
+ };
6099
+ {
6100
+ const relaySecret = getTotpSecret();
6101
+ if (relaySecret === void 0 || relaySecret === "") return {
6102
+ ok: false,
6103
+ error: mcpError("start_attach(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.")
6104
+ };
6105
+ }
6106
+ const tunnelForBuild = getTunnelStatus();
6107
+ if (!tunnelForBuild.up || tunnelForBuild.wssUrl === null) return {
6108
+ ok: false,
6109
+ error: classifyToolError(/* @__PURE__ */ new Error("tunnel-down:"), "start_attach")
6110
+ };
6111
+ const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
6112
+ const parts = {
6113
+ kind: "scheme",
6114
+ schemeUrl,
6115
+ wssUrl: tunnelForBuild.wssUrl
6116
+ };
6117
+ const deploymentId = extractDeploymentId(schemeUrl);
6118
+ if (!deploymentId) logInfo("tool.call", {
6119
+ tool: "start_attach",
6120
+ msg: "no _deploymentId in scheme_url; matching on presence only"
6121
+ });
6122
+ const isMatchingPage = (pages) => {
6123
+ if (pages.length === 0) return false;
6124
+ if (deploymentId === null) return true;
6125
+ return pages.some((p) => p.url.includes(deploymentId));
6126
+ };
6127
+ const buildTimeoutError = (baseText, timeoutSec, observed) => {
6128
+ const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
6129
+ const observedNote = observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : "";
6130
+ return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
6131
+ };
6132
+ return {
6133
+ ok: true,
6134
+ parts,
6135
+ isMatchingPage,
6136
+ buildTimeoutError,
6137
+ authorityWarning,
6138
+ totpMeta: buildTotpMeta()
6139
+ };
6140
+ }
6141
+ /**
6142
+ * QR render + browser open + segmented attach wait with in-call TOTP re-mint
6143
+ * (issue #626 §3). Shared by env-2 and env-3 (4 render paths:
6144
+ * headless / browser-opened / browser-open-failed / no-http-server).
6145
+ *
6146
+ * The wait is decomposed into `START_ATTACH_SEGMENT_MS` slices. Between slices,
6147
+ * if the current TOTP code has aged past `START_ATTACH_REMINT_THRESHOLD_MS`,
6148
+ * a fresh URL is minted via `mintAttachUrl` and pushed to the dashboard via
6149
+ * `onAttachUrlBuilt` (SSE refresh — NO browser re-open). The `reminted` count
6150
+ * rides in the success/timeout result.
6151
+ *
6152
+ * SECRET-HANDLING: attachUrl encodes tunnel/scheme host + the TOTP `at=` code
6153
+ * in the QR payload only. The browser is opened on a 127.0.0.1 URL only. The
6154
+ * tool result carries `totp.expiresAt` + `reminted` count — never the code.
6155
+ */
6156
+ async function renderAndMaybeWait(prep, waitForAttach, callTimeoutMs, conn) {
6157
+ const { parts, isMatchingPage, buildTimeoutError, authorityWarning, totpMeta } = prep;
6158
+ let attachUrl = mintAttachUrl(parts);
6159
+ onAttachUrlBuilt?.(parts);
6160
+ let totpIssuedAt = nowMs();
6161
+ let reminted = 0;
6162
+ const relayUrl = parts.wssUrl;
6163
+ 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).";
6164
+ const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
6165
+ const guiAvailable = canOpenBrowser();
6166
+ /** Builds the totp object surfaced in results (fresh expiresAt + reminted). */
6167
+ const totpResult = () => {
6168
+ if (!totpMeta) return void 0;
6169
+ const expiresAtMs = totpIssuedAt + 180 * 1e3;
6170
+ return {
6171
+ enabled: true,
6172
+ ttlSeconds: totpMeta.ttlSeconds,
6173
+ expiresAt: new Date(expiresAtMs).toISOString(),
6174
+ ...reminted > 0 ? { reminted } : {}
6175
+ };
6176
+ };
6177
+ /**
6178
+ * Segmented wait with TOTP re-mint (issue #626 §3). Resolves with the
6179
+ * attached page list, or rejects on timeout. Between SEGMENT_MS slices it
6180
+ * re-mints when the code has aged past the threshold (max ~4 re-mints over
6181
+ * 600 s). Returns immediately once a matching page attaches (no re-mint).
6182
+ */
6183
+ async function waitWithRemint() {
6184
+ const deadline = nowMs() + callTimeoutMs;
6185
+ if (isMatchingPage(conn.listTargets())) return conn.listTargets();
6186
+ for (;;) {
6187
+ const remaining = deadline - nowMs();
6188
+ if (remaining <= 0) throw new Error(`start_attach: 타임아웃 (${callTimeoutMs}ms)`);
6189
+ const segmentMs = Math.min(START_ATTACH_SEGMENT_MS, remaining);
6190
+ try {
6191
+ return await waitForAttachWithEvents(conn, isMatchingPage, segmentMs);
6192
+ } catch {
6193
+ if (totpMeta && nowMs() - totpIssuedAt >= 15e4) {
6194
+ attachUrl = mintAttachUrl(parts);
6195
+ onAttachUrlBuilt?.(parts);
6196
+ totpIssuedAt = nowMs();
6197
+ reminted += 1;
6198
+ }
6199
+ }
6200
+ }
6201
+ }
6202
+ /**
6203
+ * Assembles the success result after a page attaches. `baseText` carries the
6204
+ * QR + pre-wait JSON block (the QR the user already scanned). The attach
6205
+ * itself ends the wait, so the QR is moot — what matters now is the final
6206
+ * TOTP state. If the segmented wait re-minted (issue #626 §3), surface the
6207
+ * post-wait `totp` block (fresh `expiresAt` + `reminted` count) so the result
6208
+ * reflects how many times the code rotated during the wait. SECRET-HANDLING:
6209
+ * the totp block carries expiresAt + reminted only — never the code value.
6210
+ */
6211
+ const successResult = (baseText) => {
6212
+ const pagesResult = listPages(conn, getTunnelStatus());
6213
+ const finalTotp = totpResult();
6214
+ const remintNote = finalTotp && reminted > 0 ? `\n\n${JSON.stringify({ totp: finalTotp }, null, 2)}` : "";
6215
+ return { content: [{
6216
+ type: "text",
6217
+ text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}${remintNote}`
6218
+ }] };
6219
+ };
6220
+ /** Runs the wait (when requested) and returns success/timeout result. */
6221
+ const runWait = async (baseText) => {
6222
+ if (!waitForAttach) return { content: [{
6223
+ type: "text",
6224
+ text: baseText
6225
+ }] };
6226
+ try {
6227
+ await waitWithRemint();
6228
+ } catch {
6229
+ const observed = conn.listTargets();
6230
+ return {
6231
+ content: [{
6232
+ type: "text",
6233
+ text: buildTimeoutError(baseText, callTimeoutMs / 1e3, observed)
6234
+ }],
6235
+ isError: true
6236
+ };
6237
+ }
6238
+ return successResult(baseText);
6239
+ };
6240
+ if (!guiAvailable) {
6241
+ const headlessNote = "GUI 환경이 감지되지 않았습니다 (headless/remote 환경). 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
6242
+ const qr = await renderQr(attachUrl);
6243
+ return runWait(`${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
6244
+ attachUrl,
6245
+ relayUrl,
6246
+ ...totpResult() ? { totp: totpResult() } : {}
6247
+ }, null, 2)}\n\n${qr}`);
6248
+ }
6249
+ if (guiAvailable && qrHttpServer) {
6250
+ const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
6251
+ if (browserResult.opened) {
6252
+ const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
6253
+ const openResult = {
6254
+ attempted: true,
6255
+ succeeded: true,
6256
+ ...browserResult.retried ? { retried: true } : {}
6257
+ };
6258
+ return runWait(`${warningPrefix}${header}\n${JSON.stringify({
6259
+ relayUrl,
6260
+ openResult,
6261
+ ...totpResult() ? { totp: totpResult() } : {}
6262
+ }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`);
6263
+ }
6264
+ const openResult = {
6265
+ attempted: true,
6266
+ succeeded: false,
6267
+ failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
6268
+ pngUrl: browserResult.pngUrl,
6269
+ ...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
6270
+ };
6271
+ const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
6272
+ const fallbackNote = `브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:\n${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
6273
+ const qr = await renderQr(attachUrl);
6274
+ return runWait(`${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
6275
+ attachUrl,
6276
+ relayUrl,
6277
+ openResult,
6278
+ ...totpResult() ? { totp: totpResult() } : {}
6279
+ }, null, 2)}\n\n${qr}`);
6280
+ }
6281
+ const qr = await renderQr(attachUrl);
6282
+ return runWait(`${warningPrefix}${header}\n${JSON.stringify({
6283
+ attachUrl,
6284
+ relayUrl,
6285
+ ...totpResult() ? { totp: totpResult() } : {}
6286
+ }, null, 2)}\n\n${qr}`);
6287
+ }
5862
6288
  const server = new Server({
5863
6289
  name: "ait-debug",
5864
- version: "0.1.108"
6290
+ version: "0.1.110"
5865
6291
  }, { capabilities: { tools: { listChanged: true } } });
5866
6292
  server.setRequestHandler(ListToolsRequestSchema, () => {
5867
6293
  const conn = router.active;
@@ -5883,12 +6309,47 @@ function createDebugServer(deps) {
5883
6309
  if (name === "start_debug") {
5884
6310
  const rawMode = request.params.arguments?.mode;
5885
6311
  const mode = normalizeStartDebugMode(rawMode);
5886
- if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser' | 'relay-sandbox' | 'relay-staging' | 'relay-live' 하나를 전달하세요.");
5887
- const confirm = request.params.arguments?.confirm === true;
6312
+ if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser' | 'relay-sandbox' | 'relay-staging' 하나를 전달하세요. (relay-live / env 4는 #665에서 제거됐습니다.)");
5888
6313
  const rawProjectRoot = request.params.arguments?.projectRoot;
5889
6314
  const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
5890
6315
  try {
5891
- return jsonResult$1(await router.switchMode(mode, confirm, projectRoot));
6316
+ return jsonResult$1(await router.switchMode(mode, projectRoot));
6317
+ } catch (err) {
6318
+ return errorResult(err, name);
6319
+ }
6320
+ }
6321
+ if (name === "start_attach") {
6322
+ const args = request.params.arguments;
6323
+ let attachConn = conn;
6324
+ const rawMode = args?.mode;
6325
+ if (rawMode !== void 0) {
6326
+ const mode = normalizeStartDebugMode(rawMode);
6327
+ if (mode === null || mode === "local-browser") return mcpError("start_attach: mode가 올바르지 않습니다. 'relay-sandbox' | 'relay-staging' 중 하나를 전달하세요 (local-browser는 QR attach가 없어 start_attach에서 지원하지 않습니다).");
6328
+ const targetEnv = envForMode(mode);
6329
+ if (resolveEnvironment() !== targetEnv) {
6330
+ const rawProjectRoot = args?.projectRoot;
6331
+ const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
6332
+ try {
6333
+ await router.switchMode(mode, projectRoot);
6334
+ } catch (err) {
6335
+ return errorResult(err, name);
6336
+ }
6337
+ attachConn = router.active;
6338
+ }
6339
+ }
6340
+ const attachEnv = resolveEnvironment();
6341
+ if (!isRelayEnv(attachEnv)) return mcpError("start_attach: relay 전용 tool입니다 (env 2 / relay-sandbox 또는 env 3 / relay-staging). 현재 환경은 'local-browser'(mock)입니다 — mode 인자로 'relay-sandbox' 또는 'relay-staging'을 전달하거나, 먼저 relay 모드로 전환하세요.");
6342
+ const waitForAttach = true;
6343
+ const rawWaitTimeout = args?.wait_timeout_seconds;
6344
+ const callTimeoutMs = (() => {
6345
+ if (typeof rawWaitTimeout !== "number" || !Number.isFinite(rawWaitTimeout)) return waitForAttachTimeoutMs;
6346
+ if (rawWaitTimeout <= 0) return waitForAttachTimeoutMs;
6347
+ return Math.round(Math.max(1, Math.min(600, rawWaitTimeout))) * 1e3;
6348
+ })();
6349
+ try {
6350
+ const prep = await prepareAttach(attachEnv, args, attachConn);
6351
+ if (!prep.ok) return prep.error;
6352
+ return await renderAndMaybeWait(prep, waitForAttach, callTimeoutMs, attachConn);
5892
6353
  } catch (err) {
5893
6354
  return errorResult(err, name);
5894
6355
  }
@@ -5933,386 +6394,6 @@ function createDebugServer(deps) {
5933
6394
  } catch (err) {
5934
6395
  return errorResult(err, name);
5935
6396
  }
5936
- if (name === "build_attach_url") {
5937
- const waitForAttach = request.params.arguments?.wait_for_attach === true;
5938
- const selfdebug = request.params.arguments?.selfdebug === true;
5939
- const rawWaitTimeout = request.params.arguments?.wait_timeout_seconds;
5940
- const callTimeoutMs = (() => {
5941
- if (typeof rawWaitTimeout !== "number" || !Number.isFinite(rawWaitTimeout)) return waitForAttachTimeoutMs;
5942
- const clamped = Math.max(1, Math.min(600, rawWaitTimeout));
5943
- if (rawWaitTimeout <= 0) return waitForAttachTimeoutMs;
5944
- return Math.round(clamped) * 1e3;
5945
- })();
5946
- if (selfdebug && env !== "relay-mobile") return mcpError("build_attach_url: selfdebug=true는 env 2 / relay-sandbox 전용 기능입니다. 현재 환경(env 3/4)에서는 launcher가 없어 self-target 모드를 지원하지 않습니다. launcher self-target이 필요하다면 relay-sandbox 모드로 재시작하세요.");
5947
- if (env === "relay-mobile") {
5948
- const rawBuildProjectRoot = request.params.arguments?.projectRoot;
5949
- const buildProjectRoot = typeof rawBuildProjectRoot === "string" ? rawBuildProjectRoot : void 0;
5950
- let tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
5951
- if (tunnelHttpUrl === "" && buildProjectRoot !== void 0) {
5952
- const { readRelayUrls } = await import("../relay-url-store-CwKT7i04.js");
5953
- tunnelHttpUrl = (await readRelayUrls({ projectRoot: buildProjectRoot }))?.tunnelBaseUrl ?? "";
5954
- }
5955
- if (tunnelHttpUrl === "") return mcpError("build_attach_url(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. 자동 발견이 되지 않을 경우 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 직접 전달하세요.");
5956
- const tunnelStatus = getTunnelStatus();
5957
- if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return mcpError("build_attach_url(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.");
5958
- const secret = getTotpSecret();
5959
- if (secret === void 0 || secret === "") return mcpError("build_attach_url(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.");
5960
- let totpCode;
5961
- let totpMeta;
5962
- {
5963
- const now = Date.now();
5964
- totpCode = generateTotp(secret, now);
5965
- const STEP_SECONDS = 30;
5966
- const expiresAtMs = now + 6 * STEP_SECONDS * 1e3;
5967
- totpMeta = {
5968
- enabled: true,
5969
- ttlSeconds: 6 * STEP_SECONDS,
5970
- expiresAt: new Date(expiresAtMs).toISOString()
5971
- };
5972
- }
5973
- let launcherAppName;
5974
- if (buildProjectRoot !== void 0) try {
5975
- const { readFileSync } = await import("node:fs");
5976
- const pkgRaw = readFileSync(`${buildProjectRoot}/package.json`, "utf8");
5977
- const pkg = JSON.parse(pkgRaw);
5978
- const rawName = typeof pkg.name === "string" ? pkg.name : "";
5979
- launcherAppName = (rawName.includes("/") ? rawName.slice(rawName.indexOf("/") + 1) : rawName).trim() || void 0;
5980
- } catch {}
5981
- const attachUrl = buildLauncherAttachUrl(tunnelHttpUrl, tunnelStatus.wssUrl, totpCode, {
5982
- name: launcherAppName,
5983
- ...selfdebug ? { selfdebug: true } : {}
5984
- });
5985
- onAttachUrlBuilt?.({
5986
- kind: "launcher",
5987
- tunnelHttpUrl,
5988
- wssUrl: tunnelStatus.wssUrl,
5989
- appName: launcherAppName
5990
- });
5991
- const relayUrl = tunnelStatus.wssUrl;
5992
- const totp = totpMeta;
5993
- const connAsAny = conn;
5994
- const getLastSeenAt = typeof connAsAny.getTargetLastSeenAt === "function" ? (id) => connAsAny.getTargetLastSeenAt(id) : null;
5995
- const callNow = nowMs();
5996
- const isMatchingPage = (pages) => isSandboxPageFresh(pages, getLastSeenAt, callNow, stalePageThresholdMs);
5997
- const buildTimeoutError = (baseText, timeoutSec, observed) => {
5998
- const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
5999
- return `${baseText}\n\nNo page attached within ${timeoutSec}s${observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : ""} — launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.`;
6000
- };
6001
- return await (async () => {
6002
- 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).";
6003
- const warningPrefix = "";
6004
- const guiAvailable = canOpenBrowser();
6005
- if (!guiAvailable) {
6006
- const headlessNote = "GUI 환경이 감지되지 않았습니다 (headless/remote 환경). 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
6007
- const qrHeadless = await renderQr(attachUrl);
6008
- const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
6009
- attachUrl,
6010
- relayUrl,
6011
- ...totp ? { totp } : {}
6012
- }, null, 2)}\n\n${qrHeadless}`;
6013
- if (!waitForAttach) return { content: [{
6014
- type: "text",
6015
- text: headlessText
6016
- }] };
6017
- let attachedPagesHl = [];
6018
- try {
6019
- attachedPagesHl = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6020
- } catch {
6021
- attachedPagesHl = conn.listTargets();
6022
- return {
6023
- content: [{
6024
- type: "text",
6025
- text: buildTimeoutError(headlessText, callTimeoutMs / 1e3, attachedPagesHl)
6026
- }],
6027
- isError: true
6028
- };
6029
- }
6030
- const pagesResultHl = listPages(conn, getTunnelStatus());
6031
- return { content: [{
6032
- type: "text",
6033
- text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
6034
- }] };
6035
- }
6036
- if (guiAvailable && qrHttpServer) {
6037
- const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
6038
- if (browserResult.opened) {
6039
- const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
6040
- const openResult = {
6041
- attempted: true,
6042
- succeeded: true,
6043
- ...browserResult.retried ? { retried: true } : {}
6044
- };
6045
- const shortText = `${warningPrefix}${header}\n${JSON.stringify({
6046
- relayUrl,
6047
- openResult,
6048
- ...totp ? { totp } : {}
6049
- }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
6050
- if (!waitForAttach) return { content: [{
6051
- type: "text",
6052
- text: shortText
6053
- }] };
6054
- let attachedPages = [];
6055
- try {
6056
- attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6057
- } catch {
6058
- attachedPages = conn.listTargets();
6059
- return {
6060
- content: [{
6061
- type: "text",
6062
- text: buildTimeoutError(shortText, callTimeoutMs / 1e3, attachedPages)
6063
- }],
6064
- isError: true
6065
- };
6066
- }
6067
- const pagesResult = listPages(conn, getTunnelStatus());
6068
- return { content: [{
6069
- type: "text",
6070
- text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
6071
- }] };
6072
- }
6073
- const openResult = {
6074
- attempted: true,
6075
- succeeded: false,
6076
- failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
6077
- pngUrl: browserResult.pngUrl,
6078
- ...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
6079
- };
6080
- const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
6081
- const fallbackNote = `브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:\n${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
6082
- const qr = await renderQr(attachUrl);
6083
- const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
6084
- attachUrl,
6085
- relayUrl,
6086
- openResult,
6087
- ...totp ? { totp } : {}
6088
- }, null, 2)}\n\n${qr}`;
6089
- if (!waitForAttach) return { content: [{
6090
- type: "text",
6091
- text: baseText
6092
- }] };
6093
- let attachedPagesFb = [];
6094
- try {
6095
- attachedPagesFb = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6096
- } catch {
6097
- attachedPagesFb = conn.listTargets();
6098
- return {
6099
- content: [{
6100
- type: "text",
6101
- text: buildTimeoutError(baseText, callTimeoutMs / 1e3, attachedPagesFb)
6102
- }],
6103
- isError: true
6104
- };
6105
- }
6106
- const pagesResultFb = listPages(conn, getTunnelStatus());
6107
- return { content: [{
6108
- type: "text",
6109
- text: `${baseText}\n\n${JSON.stringify(pagesResultFb, null, 2)}`
6110
- }] };
6111
- }
6112
- const qr = await renderQr(attachUrl);
6113
- const baseText = `${warningPrefix}${header}\n${JSON.stringify({
6114
- attachUrl,
6115
- relayUrl,
6116
- ...totp ? { totp } : {}
6117
- }, null, 2)}\n\n${qr}`;
6118
- if (!waitForAttach) return { content: [{
6119
- type: "text",
6120
- text: baseText
6121
- }] };
6122
- let attachedPages = [];
6123
- try {
6124
- attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6125
- } catch {
6126
- attachedPages = conn.listTargets();
6127
- return {
6128
- content: [{
6129
- type: "text",
6130
- text: buildTimeoutError(baseText, callTimeoutMs / 1e3, attachedPages)
6131
- }],
6132
- isError: true
6133
- };
6134
- }
6135
- const pagesResult = listPages(conn, getTunnelStatus());
6136
- return { content: [{
6137
- type: "text",
6138
- text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
6139
- }] };
6140
- })();
6141
- }
6142
- const schemeUrl = request.params.arguments?.scheme_url;
6143
- if (typeof schemeUrl !== "string" || schemeUrl === "") return mcpError("build_attach_url: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요. 환경 2(mobile)라면 scheme_url 대신 AIT_TUNNEL_BASE_URL을 설정하세요.");
6144
- const deploymentId = extractDeploymentId(schemeUrl);
6145
- if (!deploymentId) logInfo("tool.call", {
6146
- tool: "build_attach_url",
6147
- msg: "no _deploymentId in scheme_url; matching on presence only"
6148
- });
6149
- /** Returns true when the page list satisfies the attach condition. */
6150
- const isMatchingPage = (pages) => {
6151
- if (pages.length === 0) return false;
6152
- if (deploymentId === null) return true;
6153
- return pages.some((p) => p.url.includes(deploymentId));
6154
- };
6155
- /** Builds a timeout error message with diagnostic context. */
6156
- const buildTimeoutError = (baseText, timeoutSec, observed) => {
6157
- const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
6158
- const observedNote = observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : "";
6159
- return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
6160
- };
6161
- {
6162
- const relaySecret = getTotpSecret();
6163
- if (relaySecret === void 0 || relaySecret === "") return mcpError("build_attach_url(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.");
6164
- }
6165
- try {
6166
- const tunnelForBuild = getTunnelStatus();
6167
- const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, tunnelForBuild, getTotpSecret());
6168
- if (tunnelForBuild.wssUrl !== null) onAttachUrlBuilt?.({
6169
- kind: "scheme",
6170
- schemeUrl,
6171
- wssUrl: tunnelForBuild.wssUrl
6172
- });
6173
- const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
6174
- 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).";
6175
- const guiAvailable = canOpenBrowser();
6176
- if (!guiAvailable) {
6177
- const headlessNote = "GUI 환경이 감지되지 않았습니다 (headless/remote 환경). 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
6178
- const qrHeadless = await renderQr(attachUrl);
6179
- const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
6180
- attachUrl,
6181
- relayUrl,
6182
- ...totp ? { totp } : {}
6183
- }, null, 2)}\n\n${qrHeadless}`;
6184
- if (!waitForAttach) return { content: [{
6185
- type: "text",
6186
- text: headlessText
6187
- }] };
6188
- let attachedPagesHl = [];
6189
- try {
6190
- attachedPagesHl = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6191
- } catch {
6192
- attachedPagesHl = conn.listTargets();
6193
- return {
6194
- content: [{
6195
- type: "text",
6196
- text: buildTimeoutError(headlessText, callTimeoutMs / 1e3, attachedPagesHl)
6197
- }],
6198
- isError: true
6199
- };
6200
- }
6201
- const pagesResultHl = listPages(conn, getTunnelStatus());
6202
- return { content: [{
6203
- type: "text",
6204
- text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
6205
- }] };
6206
- }
6207
- if (guiAvailable && qrHttpServer) {
6208
- const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
6209
- if (browserResult.opened) {
6210
- const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
6211
- const openResult = {
6212
- attempted: true,
6213
- succeeded: true,
6214
- ...browserResult.retried ? { retried: true } : {}
6215
- };
6216
- const shortText = `${warningPrefix}${header}\n${JSON.stringify({
6217
- relayUrl,
6218
- openResult,
6219
- ...totp ? { totp } : {}
6220
- }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
6221
- if (!waitForAttach) return { content: [{
6222
- type: "text",
6223
- text: shortText
6224
- }] };
6225
- let attachedPages = [];
6226
- try {
6227
- attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6228
- } catch {
6229
- attachedPages = conn.listTargets();
6230
- return {
6231
- content: [{
6232
- type: "text",
6233
- text: buildTimeoutError(shortText, callTimeoutMs / 1e3, attachedPages)
6234
- }],
6235
- isError: true
6236
- };
6237
- }
6238
- const pagesResult = listPages(conn, getTunnelStatus());
6239
- return { content: [{
6240
- type: "text",
6241
- text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
6242
- }] };
6243
- }
6244
- const openResult = {
6245
- attempted: true,
6246
- succeeded: false,
6247
- failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
6248
- pngUrl: browserResult.pngUrl,
6249
- ...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
6250
- };
6251
- const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
6252
- const fallbackNote = `브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:
6253
- ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
6254
- const qr = await renderQr(attachUrl);
6255
- const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
6256
- attachUrl,
6257
- relayUrl,
6258
- openResult,
6259
- ...totp ? { totp } : {}
6260
- }, null, 2)}\n\n${qr}`;
6261
- if (!waitForAttach) return { content: [{
6262
- type: "text",
6263
- text: baseText
6264
- }] };
6265
- let attachedPagesFb = [];
6266
- try {
6267
- attachedPagesFb = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6268
- } catch {
6269
- attachedPagesFb = conn.listTargets();
6270
- return {
6271
- content: [{
6272
- type: "text",
6273
- text: buildTimeoutError(baseText, callTimeoutMs / 1e3, attachedPagesFb)
6274
- }],
6275
- isError: true
6276
- };
6277
- }
6278
- const pagesResultFb = listPages(conn, getTunnelStatus());
6279
- return { content: [{
6280
- type: "text",
6281
- text: `${baseText}\n\n${JSON.stringify(pagesResultFb, null, 2)}`
6282
- }] };
6283
- }
6284
- const qr = await renderQr(attachUrl);
6285
- const baseText = `${warningPrefix}${header}\n${JSON.stringify({
6286
- attachUrl,
6287
- relayUrl,
6288
- ...totp ? { totp } : {}
6289
- }, null, 2)}\n\n${qr}`;
6290
- if (!waitForAttach) return { content: [{
6291
- type: "text",
6292
- text: baseText
6293
- }] };
6294
- let attachedPages = [];
6295
- try {
6296
- attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6297
- } catch {
6298
- attachedPages = conn.listTargets();
6299
- return {
6300
- content: [{
6301
- type: "text",
6302
- text: buildTimeoutError(baseText, callTimeoutMs / 1e3, attachedPages)
6303
- }],
6304
- isError: true
6305
- };
6306
- }
6307
- const pagesResult = listPages(conn, getTunnelStatus());
6308
- return { content: [{
6309
- type: "text",
6310
- text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
6311
- }] };
6312
- } catch (err) {
6313
- return errorResult(err, name);
6314
- }
6315
- }
6316
6397
  try {
6317
6398
  await conn.enableDomains();
6318
6399
  } catch (err) {
@@ -6351,7 +6432,7 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
6351
6432
  case "evaluate": {
6352
6433
  const expression = request.params.arguments?.expression;
6353
6434
  if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
6354
- if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("evaluate");
6435
+ if (!connectionHostsAllowed(conn)) return mcpError("evaluate: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
6355
6436
  return jsonResult$1(await evaluate(conn, expression));
6356
6437
  }
6357
6438
  case "call_sdk": {
@@ -6359,7 +6440,7 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
6359
6440
  if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
6360
6441
  const rawArgs = request.params.arguments?.args;
6361
6442
  const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
6362
- if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
6443
+ if (!connectionHostsAllowed(conn)) return mcpError("call_sdk: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
6363
6444
  const sdkResult = await callSdk(conn, sdkName, sdkArgs);
6364
6445
  if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk", conn.kind === "local");
6365
6446
  return envelopeResult$1(sdkResult, name, env, conn.listTargets().length > 0);
@@ -6373,7 +6454,7 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
6373
6454
  const projectRoot = typeof rawRoot === "string" ? rawRoot : process.cwd();
6374
6455
  const rawTimeout = request.params.arguments?.timeout_ms;
6375
6456
  const timeoutMs = typeof rawTimeout === "number" && rawTimeout >= 1e3 && rawTimeout <= 6e5 ? rawTimeout : void 0;
6376
- if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("run_tests");
6457
+ if (!connectionHostsAllowed(conn)) return mcpError("run_tests: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
6377
6458
  if (runTestsInFlight) return mcpError("run_tests: 이미 다른 테스트 실행이 진행 중입니다 (single-attach 모델: 페이지는 한 번에 하나의 실행만 처리). 완료 후 다시 시도하세요.");
6378
6459
  runTestsInFlight = true;
6379
6460
  try {
@@ -6403,24 +6484,52 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
6403
6484
  }
6404
6485
  /**
6405
6486
  * Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or
6406
- * `null` when the value is not one of the four accepted modes:
6407
- * 'local-browser' | 'relay-sandbox' | 'relay-staging' | 'relay-live'
6487
+ * `null` when the value is not one of the three accepted modes:
6488
+ * 'local-browser' | 'relay-sandbox' | 'relay-staging'
6408
6489
  *
6409
6490
  * Hard rename (issue #398): the older `local`/`mobile`/`staging`/`live` names
6410
6491
  * and their aliases are no longer accepted — pre-1.0, no back-compat.
6492
+ * `relay-live` (env 4) removed in #665.
6411
6493
  */
6412
6494
  function normalizeStartDebugMode(raw) {
6413
- if (raw === "local-browser" || raw === "relay-sandbox" || raw === "relay-staging" || raw === "relay-live") return raw;
6495
+ if (raw === "local-browser" || raw === "relay-sandbox" || raw === "relay-staging") return raw;
6414
6496
  return null;
6415
6497
  }
6416
6498
  /**
6499
+ * Positive-allowlist kill-switch for side-effect MCP tools (#665).
6500
+ *
6501
+ * Returns `true` when the connection's attached targets are all on allowed
6502
+ * debug hosts (localhost / trycloudflare / private-apps). Returns `false` when
6503
+ * any target's page URL is on a non-allowed host (e.g. `apps.tossmini.com`).
6504
+ *
6505
+ * For local connections this always returns `true` — the local Chromium is
6506
+ * always on localhost. For relay connections without any pages it returns
6507
+ * `true` (no pages = nothing to block; the caller's page-missing guard fires
6508
+ * first).
6509
+ *
6510
+ * SECRET-HANDLING: hostnames are NEVER logged here — only the boolean result
6511
+ * is returned to the caller.
6512
+ */
6513
+ function connectionHostsAllowed(conn) {
6514
+ if (conn.kind === "local") return true;
6515
+ const pages = conn.listTargets();
6516
+ if (pages.length === 0) return true;
6517
+ return pages.every((p) => {
6518
+ try {
6519
+ return isDebugAllowedHost(new URL(p.url ?? "").hostname);
6520
+ } catch {
6521
+ return false;
6522
+ }
6523
+ });
6524
+ }
6525
+ /**
6417
6526
  * Builds a trivial `ConnectionRouter` pinned to a single connection (issue
6418
6527
  * #348). Used by `createDebugServer` when no real dual router is injected —
6419
6528
  * every existing single-connection test and the `local`-only / `relay`-only
6420
6529
  * boot path. `switchMode` here cannot lazily boot another family, so it only
6421
- * honors a request that matches the connection's own kind (and arms/disarms
6422
- * `liveIntent` accordingly for relay-live); any cross-family request is
6423
- * rejected with a clear "dynamic switch unavailable in this session" error.
6530
+ * honors a request that matches the connection's own kind; any cross-family
6531
+ * request is rejected with a clear "dynamic switch unavailable in this session"
6532
+ * error. `confirm` parameter and `relay-live` gate removed (#665).
6424
6533
  */
6425
6534
  function makeSingleConnectionRouter(connection) {
6426
6535
  return {
@@ -6428,18 +6537,15 @@ function makeSingleConnectionRouter(connection) {
6428
6537
  return connection;
6429
6538
  },
6430
6539
  activeRelayOrigin: void 0,
6431
- switchMode(mode, confirm, _projectRoot) {
6540
+ switchMode(mode, _projectRoot) {
6432
6541
  if (mode === "relay-sandbox") return Promise.reject(/* @__PURE__ */ new Error("start_debug: 이 세션은 단일 연결만 보유합니다 — 'relay-sandbox'(환경 2 PWA, 외부 relay)로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 relay-sandbox 모드로 재시작하세요."));
6433
6542
  if (isRelayMode(mode) !== (connection.kind === "relay")) return Promise.reject(/* @__PURE__ */ new Error(`start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — '${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 원하는 모드로 재시작하세요.`));
6434
- if (mode === "relay-live" && !confirm) return Promise.reject(/* @__PURE__ */ new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요."));
6435
- setLiveIntent(mode === "relay-live");
6436
- const environment = deriveEnvironment(connection.kind, getLiveIntent());
6543
+ const environment = deriveEnvironment(connection.kind);
6437
6544
  return Promise.resolve({
6438
6545
  mode,
6439
6546
  environment,
6440
6547
  kind: connection.kind,
6441
- liveGuardActive: connection.kind === "relay" && getLiveIntent(),
6442
- nextStep: connection.kind === "relay" ? "build_attach_url로 attach QR을 생성하세요." : "list_pages로 로컬 페이지 attach를 확인하세요."
6548
+ nextStep: connection.kind === "relay" ? "start_attach로 attach QR 생성 + 폰 attach까지 한 번에 진행하세요." : "list_pages로 로컬 페이지 attach를 확인하세요."
6443
6549
  });
6444
6550
  }
6445
6551
  };
@@ -6454,7 +6560,10 @@ function makeSingleConnectionRouter(connection) {
6454
6560
  function rebuildAttachUrl(parts) {
6455
6561
  const secret = process.env.AIT_DEBUG_TOTP_SECRET;
6456
6562
  const code = secret ? generateTotp(secret) : void 0;
6457
- return parts.kind === "launcher" ? buildLauncherAttachUrl(parts.tunnelHttpUrl, parts.wssUrl, code, { name: parts.appName }) : buildDeepLinkAttachUrl(parts.schemeUrl, parts.wssUrl, code);
6563
+ return parts.kind === "launcher" ? buildLauncherAttachUrl(parts.tunnelHttpUrl, parts.wssUrl, code, {
6564
+ name: parts.appName,
6565
+ ...parts.selfdebug ? { selfdebug: true } : {}
6566
+ }) : buildDeepLinkAttachUrl(parts.schemeUrl, parts.wssUrl, code);
6458
6567
  }
6459
6568
  function jsonResult$1(value) {
6460
6569
  return { content: [{
@@ -6495,6 +6604,7 @@ function toRunTestsResult(report) {
6495
6604
  error: f.result.error
6496
6605
  } : {
6497
6606
  file: f.file,
6607
+ duration: f.result.duration,
6498
6608
  passed: f.result.passed,
6499
6609
  failed: f.result.failed,
6500
6610
  skipped: f.result.skipped,
@@ -6646,8 +6756,9 @@ async function bootLocalFamily() {
6646
6756
  *
6647
6757
  * Booted lazily via the dual router's `bootLazyFor('relay-intoss')` callback
6648
6758
  * (symmetry with {@link bootLocalFamily}), at most once on the first
6649
- * `start_debug({ mode: 'relay-staging' | 'relay-live' })` (all-lazy, #396 — every
6650
- * relay boot now flows through `switchMode` after the project-local secret load).
6759
+ * `start_debug({ mode: 'relay-staging' })` (all-lazy, #396 — every relay boot now
6760
+ * flows through `switchMode` after the project-local secret load). `relay-live`
6761
+ * removed (#665).
6651
6762
  *
6652
6763
  * The relay base URL is only known after `startChiiRelay()` resolves, so the
6653
6764
  * `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside
@@ -6734,7 +6845,7 @@ async function bootRelayFamily(options = {}) {
6734
6845
  * we did not start.
6735
6846
  *
6736
6847
  * `getTunnelStatus()` reports `up: true` with a `wssUrl` derived from
6737
- * `relayBaseUrl` (http→ws, https→wss) so the `build_attach_url` gate
6848
+ * `relayBaseUrl` (http→ws, https→wss) so the `start_attach` gate
6738
6849
  * (`up: true && wssUrl !== null`) is satisfied even though we never opened a
6739
6850
  * cloudflared tunnel ourselves.
6740
6851
  *
@@ -6779,14 +6890,14 @@ async function bootExternalRelayFamily(relayBaseUrl, relayLocalUrl) {
6779
6890
  /**
6780
6891
  * Maps a `StartDebugMode` to the {@link FamilyKey} that serves it (issue #378).
6781
6892
  * local-browser → 'local-browser'; relay-sandbox → 'relay-sandbox';
6782
- * relay-staging/relay-live → 'relay-intoss' (the shared physical slot).
6893
+ * relay-staging → 'relay-intoss' (the intoss-private relay slot).
6894
+ * `relay-live` removed (#665).
6783
6895
  */
6784
6896
  function familyKeyForMode(mode) {
6785
6897
  switch (mode) {
6786
6898
  case "local-browser": return "local-browser";
6787
6899
  case "relay-sandbox": return "relay-sandbox";
6788
- case "relay-staging":
6789
- case "relay-live": return "relay-intoss";
6900
+ case "relay-staging": return "relay-intoss";
6790
6901
  }
6791
6902
  }
6792
6903
  /** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */
@@ -6845,12 +6956,11 @@ const NULL_CDP_CONNECTION = {
6845
6956
  * restarting the MCP server.
6846
6957
  *
6847
6958
  * Why a KEYED map and not a single lazy slot (#378): `relay-sandbox` (env-2
6848
- * external relay) and `relay-staging`/`relay-live` (intoss relay) are BOTH
6849
- * `kind: 'relay'`. A single "opposite-kind" slot could not warm-keep both at
6850
- * once — they would collide. The three `FamilyKey`s
6851
- * (`local-browser` / `relay-intoss` / `relay-sandbox`) give each its own warm
6852
- * slot — `relay-staging` and `relay-live` deliberately share the one
6853
- * `relay-intoss` slot (wire-identical, distinguished only by `liveIntent`).
6959
+ * external relay) and `relay-staging` (intoss relay) are BOTH `kind: 'relay'`.
6960
+ * A single "opposite-kind" slot could not warm-keep both at once — they would
6961
+ * collide. The three `FamilyKey`s (`local-browser` / `relay-intoss` /
6962
+ * `relay-sandbox`) give each its own warm slot. `relay-live` (env 4) removed
6963
+ * (#665) — `relay-intoss` slot now maps only to `relay-staging`.
6854
6964
  *
6855
6965
  * Why all-lazy (#396): the relay TOTP secret now lives in a project-local
6856
6966
  * `.ait_relay` file loaded read-only by `switchMode` BEFORE a relay family boots.
@@ -6860,15 +6970,14 @@ const NULL_CDP_CONNECTION = {
6860
6970
  * `buildRelayVerifyAuth()` run at the boot site.
6861
6971
  *
6862
6972
  * `switchMode`:
6863
- * 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed `relay-live`;
6973
+ * 1. rejects re-entrant swaps (`swapInFlight`);
6864
6974
  * 2. resolves the requested mode's `FamilyKey`:
6865
6975
  * `lazyFamilies.get(key) ?? (boot via bootLazyFor(key), store)`;
6866
6976
  * 3. flips `active` (the MCP `Server` never re-handshakes — it reads through
6867
6977
  * `active` per request);
6868
- * 4. sets `liveIntent` (true only for `relay-live`; `relay-sandbox` is dev-intent false);
6869
- * 5. stops the old attach watcher and re-arms one on the new connection
6978
+ * 4. stops the old attach watcher and re-arms one on the new connection
6870
6979
  * (the watcher self-clears, so re-arm is mandatory);
6871
- * 6. emits `tools/list_changed`.
6980
+ * 5. emits `tools/list_changed`.
6872
6981
  *
6873
6982
  * Inactive infra is left WARM — teardown happens only at process exit (the
6874
6983
  * unified shutdown in the run functions), which is what keeps a phone attach
@@ -6914,10 +7023,10 @@ var DualConnectionRouter = class {
6914
7023
  /**
6915
7024
  * Live tunnel status of the active relay family (issues #356, #378). Reads
6916
7025
  * the ACTIVE family's tunnel when it has one (so `relay-sandbox` surfaces the
6917
- * external relay wss and `relay-staging`/`relay-live` the intoss relay wss); otherwise
7026
+ * external relay wss and `relay-staging` the intoss relay wss); otherwise
6918
7027
  * falls back to the first booted family that has a tunnel. Returns "down"
6919
7028
  * until any relay family is booted (any session before the first relay
6920
- * start_debug) — the correct signal for `build_attach_url` (no tunnel yet).
7029
+ * start_debug) — the correct signal for `start_attach` (no tunnel yet).
6921
7030
  */
6922
7031
  relayTunnelStatus() {
6923
7032
  if (this.activeFamily?.getTunnelStatus) return this.activeFamily.getTunnelStatus();
@@ -6952,7 +7061,7 @@ var DualConnectionRouter = class {
6952
7061
  this.deps.onPageAttach?.();
6953
7062
  if (activeFamily.connection.kind === "relay") {
6954
7063
  const firstTarget = activeFamily.connection.listTargets()[0];
6955
- const env = deriveEnvironment(activeFamily.connection.kind, getLiveIntent(), activeFamily.relayOrigin);
7064
+ const env = deriveEnvironment(activeFamily.connection.kind, activeFamily.relayOrigin);
6956
7065
  const inspectorStableUrl = this.deps.getInspectorStableUrl?.() ?? null;
6957
7066
  this.deps.devtoolsOpener.open({
6958
7067
  inspectorStableUrl,
@@ -7010,25 +7119,22 @@ var DualConnectionRouter = class {
7010
7119
  this.lazyFamilies.set(key, booted);
7011
7120
  return booted;
7012
7121
  }
7013
- async switchMode(mode, confirm, projectRoot) {
7122
+ async switchMode(mode, projectRoot) {
7014
7123
  if (this.swapInFlight) throw new Error("start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.");
7015
- if (mode === "relay-live" && !confirm) throw new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요.");
7016
7124
  this.swapInFlight = true;
7017
7125
  try {
7018
7126
  if (isRelayMode(mode)) await loadRelaySecretReadOnly({ projectRoot });
7019
7127
  const target = await this.familyFor(familyKeyForMode(mode), projectRoot);
7020
7128
  this.activeFamily = target;
7021
- setLiveIntent(mode === "relay-live");
7022
7129
  this.stopWatcher();
7023
7130
  this.armWatcher();
7024
7131
  this.server?.sendToolListChanged();
7025
7132
  const wantRelay = isRelayMode(mode);
7026
7133
  return {
7027
7134
  mode,
7028
- environment: deriveEnvironment(target.connection.kind, getLiveIntent(), target.relayOrigin),
7135
+ environment: deriveEnvironment(target.connection.kind, target.relayOrigin),
7029
7136
  kind: target.connection.kind,
7030
- liveGuardActive: target.connection.kind === "relay" && getLiveIntent(),
7031
- nextStep: wantRelay ? "build_attach_url로 attach QR을 생성하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
7137
+ nextStep: wantRelay ? "start_attach로 attach QR 생성 + 폰 attach까지 한 번에 진행하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
7032
7138
  };
7033
7139
  } finally {
7034
7140
  this.swapInFlight = false;
@@ -7087,7 +7193,7 @@ async function runDebugServer(options = {}) {
7087
7193
  })),
7088
7194
  attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
7089
7195
  inspectorUrl,
7090
- mode: deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin)
7196
+ mode: deriveEnvironment(router.active.kind, router.activeRelayOrigin)
7091
7197
  };
7092
7198
  };
7093
7199
  const getDirectInspectorUrl = () => {
@@ -7219,7 +7325,7 @@ async function runDebugServer(options = {}) {
7219
7325
  * 1. `start_debug({ mode: 'local-browser' })` launches a local Chromium with
7220
7326
  * `--remote-debugging-port=<port>` and attaches a `LocalCdpConnection`;
7221
7327
  * 2. the intoss/external relay families lazy-boot on the first
7222
- * `start_debug({ mode: 'relay-staging' | 'relay-live' | 'relay-sandbox' })`;
7328
+ * `start_debug({ mode: 'relay-staging' | 'relay-sandbox' })` (#665: relay-live removed);
7223
7329
  * 3. all of this runs through the SAME direction-neutral
7224
7330
  * `DualConnectionRouter` that `runDebugServer` uses (issue #356).
7225
7331
  *
@@ -7231,7 +7337,7 @@ async function runDebugServer(options = {}) {
7231
7337
  * env 1 (local), then env 3 (intoss-private) in ONE session, no restart" — now
7232
7338
  * works from either entry point.
7233
7339
  *
7234
- * `build_attach_url` (relay-specific) stays effectively hidden / non-applicable
7340
+ * `start_attach` (relay-specific) stays effectively hidden / non-applicable
7235
7341
  * until the relay family is booted: before the first relay switch the env
7236
7342
  * derives to `mock` and `relayTunnelStatus()` reports "down", so the tool fails
7237
7343
  * with a clear "tunnel not up" message. After a relay switch the relay tunnel
@@ -7442,10 +7548,9 @@ async function runLocalDebugServer(options = {}) {
7442
7548
  * Symmetry with `runDebugServer` / `runLocalDebugServer` (#356, #378, #396): all
7443
7549
  * three families are lazy-booted — the env-2 external relay on the first
7444
7550
  * `start_debug({ mode: 'relay-sandbox' })`, the local family on `local-browser`,
7445
- * the intoss relay on `relay-staging`/`relay-live` — so a `--target=mobile`
7446
- * session can hot-switch
7447
- * without a restart. The active env derives to `relay-mobile` (external-PWA
7448
- * origin, liveIntent off).
7551
+ * the intoss relay on `relay-staging` (#665: relay-live removed) — so a
7552
+ * `--target=mobile` session can hot-switch without a restart. The active env
7553
+ * derives to `relay-mobile` (external-PWA origin).
7449
7554
  *
7450
7555
  * SECRET-HANDLING: `AIT_RELAY_BASE_URL` is read once here via
7451
7556
  * {@link readMobileRelayBaseUrl}; when unset it throws
@@ -7826,25 +7931,30 @@ const DEV_TOOL_DEFINITIONS = [
7826
7931
  availableIn: "both"
7827
7932
  },
7828
7933
  {
7829
- name: "build_attach_url",
7830
- description: "Turns an `ait deploy --scheme-only` URL into a self-attaching deep link for a real device. NOT available in dev-mode — requires a live cloudflared relay (Tier B, relay-only). To use this tool: restart the MCP server with `--mode=debug` (or omit --mode) and set MCP_ENV=relay, then call build_attach_url to generate the QR for phone scanning. See: https://docs.aitc.dev/guides/debug-relay",
7934
+ name: "start_attach",
7935
+ description: "Switches into a relay mode (if given), builds a self-attaching deep-link QR for a real device, and waits for the phone to attach — all in one call. NOT available in dev-mode — requires a live cloudflared relay (Tier B, relay-only). To use this tool: restart the MCP server with `--mode=debug` (or omit --mode) and set MCP_ENV=relay, then call start_attach to generate the QR for phone scanning. See: https://docs.aitc.dev/guides/debug-relay",
7831
7936
  inputSchema: {
7832
7937
  type: "object",
7833
7938
  properties: {
7834
- scheme_url: {
7939
+ mode: {
7835
7940
  type: "string",
7836
- description: "The intoss-private:// URL from `ait deploy --scheme-only`."
7941
+ enum: [
7942
+ "local-browser",
7943
+ "relay-sandbox",
7944
+ "relay-staging"
7945
+ ],
7946
+ description: "Optional relay mode to switch into before attaching."
7837
7947
  },
7838
- wait_for_attach: {
7839
- type: "boolean",
7840
- description: "If true, block until a page attaches (default 60 s)."
7948
+ scheme_url: {
7949
+ type: "string",
7950
+ description: "The intoss-private:// URL from `ait deploy --scheme-only` (env 3/relay-staging)."
7841
7951
  },
7842
7952
  wait_timeout_seconds: {
7843
7953
  type: "number",
7844
- description: "Maximum seconds to wait when wait_for_attach=true (default 60, range 1–600). Invalid inputs fall back to default."
7954
+ description: "Maximum seconds to wait for a page to attach (default 60, range 1–600). Invalid inputs fall back to default."
7845
7955
  }
7846
7956
  },
7847
- required: ["scheme_url"]
7957
+ required: []
7848
7958
  },
7849
7959
  availableIn: "relay"
7850
7960
  },
@@ -7942,10 +8052,6 @@ const DEV_TOOL_DEFINITIONS = [
7942
8052
  timeout_ms: {
7943
8053
  type: "number",
7944
8054
  description: "Per-file evaluate timeout in ms."
7945
- },
7946
- confirm: {
7947
- type: "boolean",
7948
- description: "Required in relay-live sessions."
7949
8055
  }
7950
8056
  },
7951
8057
  required: ["files"]
@@ -7971,7 +8077,7 @@ const CDP_ONLY_TOOL_NAMES = new Set([
7971
8077
  * Listed in dev-mode tool surface (issue #323) so agents get a hand-off hint
7972
8078
  * toward `--mode=debug` instead of "Unknown tool".
7973
8079
  */
7974
- const TIER_B_TOOL_NAMES = new Set(["build_attach_url"]);
8080
+ const TIER_B_TOOL_NAMES = new Set(["start_attach"]);
7975
8081
  /**
7976
8082
  * Builds the `list_pages` dev-mode shim response.
7977
8083
  * Returns the Vite dev URL as a single-entry page list with `devMode: true`.
@@ -8081,7 +8187,7 @@ function createDevServer(deps = {}) {
8081
8187
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
8082
8188
  const server = new Server({
8083
8189
  name: "ait-devtools",
8084
- version: "0.1.108"
8190
+ version: "0.1.110"
8085
8191
  }, { capabilities: { tools: {} } });
8086
8192
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
8087
8193
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -8156,7 +8262,7 @@ async function runDevServer() {
8156
8262
  *
8157
8263
  * --mode=debug (default)
8158
8264
  * --target=relay (default) — CDP/Chii relay + cloudflared quick tunnel.
8159
- * Attach a running mini-app (real Toss WebView, env 3/4) and read its
8265
+ * Attach a running mini-app (real Toss WebView, env 3) and read its
8160
8266
  * console + network over CDP without a human watching a phone.
8161
8267
  * --target=local — CDP direct-attach to a local Chromium launched by the
8162
8268
  * MCP server (env 1). No relay or tunnel; the browser is launched
@@ -8172,26 +8278,12 @@ async function runDevServer() {
8172
8278
  * Back-compat (issue #348): the legacy `--mode`/`--target` flags and `MCP_ENV`
8173
8279
  * still work. `--target=relay`/`local` select the initial active connection;
8174
8280
  * the in-session `start_debug(mode)` MCP tool can then flip between them with no
8175
- * restart. `MCP_ENV=relay-live` seeds LIVE intent at boot (deprecated alias);
8176
- * `MCP_ENV=mock|relay|relay-dev` are accepted and ignored for env derivation
8177
- * (the active connection's `kind` is authoritative).
8281
+ * restart. `MCP_ENV` values are accepted and ignored (the active connection's
8282
+ * `kind` is authoritative; `relay-live` and `liveIntent` are removed, #665).
8178
8283
  *
8179
8284
  * Node-only stdio process.
8180
8285
  */
8181
8286
  /**
8182
- * Seeds the module-level `liveIntent` bit from the deprecated `MCP_ENV` alias
8183
- * (issue #348). `MCP_ENV=relay-live` is the only value that matters now — it
8184
- * arms LIVE intent at boot so a session launched straight into env 4 has the
8185
- * guard active without a `start_debug({ mode: 'relay-live' })` round-trip. All
8186
- * other `MCP_ENV` values (`mock`, `relay`, `relay-dev`) are accepted-and-ignored
8187
- * for env derivation — the active connection's `kind` is authoritative.
8188
- *
8189
- * SECRET-HANDLING: reads only the env-var string; never logs a secret.
8190
- */
8191
- function seedLiveIntentFromEnv(env = process.env) {
8192
- if (env.MCP_ENV === "relay-live") setLiveIntent(true);
8193
- }
8194
- /**
8195
8287
  * Returns `true` when `--force` or `--takeover` is present in argv.
8196
8288
  *
8197
8289
  * Both flags are accepted as aliases — `--force` is the short form listed in
@@ -8248,7 +8340,6 @@ function normalizeTarget(value) {
8248
8340
  }
8249
8341
  async function main() {
8250
8342
  const args = process.argv.slice(2);
8251
- seedLiveIntentFromEnv();
8252
8343
  if (parseMode(args) === "dev") await runDevServer();
8253
8344
  else {
8254
8345
  const target = parseTarget(args);
@@ -8283,6 +8374,6 @@ if (isEntrypoint()) main().catch((err) => {
8283
8374
  process.exitCode = 1;
8284
8375
  });
8285
8376
  //#endregion
8286
- export { parseForce, parseMode, parseTarget, seedLiveIntentFromEnv };
8377
+ export { parseForce, parseMode, parseTarget };
8287
8378
 
8288
8379
  //# sourceMappingURL=cli.js.map