@ait-co/devtools 0.1.134 → 0.1.135
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.
- package/dist/{debug-server-BcSBrajf.js → debug-server-B2aCwwtV.js} +7 -2
- package/dist/debug-server-B2aCwwtV.js.map +1 -0
- package/dist/{debug-server-DnNCoNiR.js → debug-server-CBqMfcUi.js} +2 -2
- package/dist/{debug-server-DnNCoNiR.js.map → debug-server-CBqMfcUi.js.map} +1 -1
- package/dist/{debug-server-D9SmprUI.js → debug-server-DR84Z243.js} +8 -3
- package/dist/debug-server-DR84Z243.js.map +1 -0
- package/dist/mcp/cli.js +67 -9
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.js +1 -1
- package/dist/{qr-http-server-iH_Hh_si.cjs → qr-http-server-B1en4tTL.cjs} +60 -7
- package/dist/{qr-http-server-iH_Hh_si.cjs.map → qr-http-server-B1en4tTL.cjs.map} +1 -1
- package/dist/{qr-http-server-C_DTp9WU.js → qr-http-server-CLt72Dwe.js} +60 -8
- package/dist/{qr-http-server-V0xuU5TJ.js.map → qr-http-server-CLt72Dwe.js.map} +1 -1
- package/dist/{qr-http-server-FVn61xAq.js → qr-http-server-DlNWJEaH.js} +60 -7
- package/dist/{qr-http-server-C_DTp9WU.js.map → qr-http-server-DlNWJEaH.js.map} +1 -1
- package/dist/{qr-http-server-DX_9UA-p.cjs → qr-http-server-hnuUw9TK.cjs} +60 -7
- package/dist/{qr-http-server-DX_9UA-p.cjs.map → qr-http-server-hnuUw9TK.cjs.map} +1 -1
- package/dist/{qr-http-server-V0xuU5TJ.js → qr-http-server-iBqsAKor.js} +60 -7
- package/dist/{qr-http-server-FVn61xAq.js.map → qr-http-server-iBqsAKor.js.map} +1 -1
- package/dist/{qr-http-server-DeRrKp_R.js → qr-http-server-yQ1EWTwf.js} +61 -7
- package/dist/{qr-http-server-DeRrKp_R.js.map → qr-http-server-yQ1EWTwf.js.map} +1 -1
- package/dist/{relay-factory-CsVO9o25.js → relay-factory-CjqAJ3Y8.js} +4 -4
- package/dist/relay-factory-CjqAJ3Y8.js.map +1 -0
- package/dist/test-runner/bin.js +36 -4
- package/dist/test-runner/bin.js.map +1 -1
- package/dist/test-runner/config.d.ts +8 -0
- package/dist/test-runner/config.d.ts.map +1 -1
- package/dist/test-runner/config.js +1 -1
- package/dist/test-runner/relay-factory.d.ts +8 -0
- package/dist/test-runner/relay-factory.d.ts.map +1 -1
- package/dist/test-runner/relay-factory.js +3 -3
- package/dist/test-runner/relay-factory.js.map +1 -1
- package/dist/{tunnel-DP453L4e.cjs → tunnel-Cr_oTkaj.cjs} +2 -2
- package/dist/{tunnel-DP453L4e.cjs.map → tunnel-Cr_oTkaj.cjs.map} +1 -1
- package/dist/{tunnel-Djf-zmjJ.js → tunnel-DUCogcQi.js} +2 -2
- package/dist/{tunnel-Djf-zmjJ.js.map → tunnel-DUCogcQi.js.map} +1 -1
- package/dist/unplugin/index.cjs +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/unplugin/tunnel.cjs +1 -1
- package/dist/unplugin/tunnel.js +1 -1
- package/package.json +1 -1
- package/dist/debug-server-BcSBrajf.js.map +0 -1
- package/dist/debug-server-D9SmprUI.js.map +0 -1
- package/dist/relay-factory-CsVO9o25.js.map +0 -1
|
@@ -2,8 +2,8 @@ import { a as ChiiCdpConnection, o as RELAY_AUTH_REJECT_CLOSE_CODE, s as RELAY_A
|
|
|
2
2
|
import { c as generateAttachToken, d as startQuickTunnel, f as startTunnelHealthProbe, l as makeTunnelStatus, m as logInfo, p as logError, u as printAttachBanner } from "./attach-orchestrator-CMoDuG2A.js";
|
|
3
3
|
import { n as buildRelayVerifyAuth, t as assertRelayAuthConfigured } from "./totp-DAxys-r0.js";
|
|
4
4
|
import "./cell-DHA578lX.js";
|
|
5
|
-
import "./relay-factory-
|
|
6
|
-
import "./qr-http-server-
|
|
5
|
+
import "./relay-factory-CjqAJ3Y8.js";
|
|
6
|
+
import "./qr-http-server-DlNWJEaH.js";
|
|
7
7
|
import "./relay-secret-store-DGduVJhs.js";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import "node:events";
|
|
@@ -37,6 +37,11 @@ OPTIONS
|
|
|
37
37
|
(report: <sdkLine>.<platform>.json; captures:
|
|
38
38
|
<dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
|
|
39
39
|
Omitted = nothing saved. Enables console capture.
|
|
40
|
+
--dashboard-port <port> Base port for the QR dashboard HTTP server. On
|
|
41
|
+
EADDRINUSE it increments (+1, up to 20 tries) before
|
|
42
|
+
falling back to an ephemeral port. Omit to use
|
|
43
|
+
AIT_DEBUG_HTTP_PORT env or the built-in default
|
|
44
|
+
(8317) — pass 0 to force a random ephemeral port.
|
|
40
45
|
--no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
|
|
41
46
|
non-interactive stdout / CI / AIT_NO_QR_STDOUT)
|
|
42
47
|
--headless Disable browser auto-open (text QR only)
|
|
@@ -450,4 +455,4 @@ async function bootRelayFamily(options = {}) {
|
|
|
450
455
|
//#endregion
|
|
451
456
|
export { bootRelayFamily, buildRelayVerifyAuth };
|
|
452
457
|
|
|
453
|
-
//# sourceMappingURL=debug-server-
|
|
458
|
+
//# sourceMappingURL=debug-server-DR84Z243.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"debug-server-DR84Z243.js","names":[],"sources":["../src/test-runner/cli.ts","../src/mcp/chii-relay.ts","../src/mcp/debug-server.ts"],"sourcesContent":["/**\n * `devtools-test` CLI.\n *\n * Shares test-file discovery with the `run_tests` MCP tool (`discoverTestFiles`)\n * and exposes `runWithConnection` — the pure run core that bundles, injects, and\n * collects each file over a CDP connection. The CLI's `main()` performs a\n * standalone relay attach (boot relay → QR → phone scan → cell inject → run).\n *\n * NOTE: no shebang in this source file — the tsdown entry's `banner` option\n * injects `#!/usr/bin/env node` into the compiled output (same pattern as\n * `src/mcp/cli.ts`).\n */\n\nimport { basename } from 'node:path';\nimport { parseArgs } from 'node:util';\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport { discoverTestFiles, MANUAL_TEST_SUFFIX, partitionManualTests } from './discover.js';\nimport { createRelayConnectionFactory } from './relay-factory.js';\nimport type { RelayRunOptions, RelayRunReport } from './relay-worker.js';\nimport { runTestFilesOverRelay } from './relay-worker.js';\nimport { writeCaptureArtifacts, writeReportArtifact } from './report.js';\n\n/* -------------------------------------------------------------------------- */\n/* CLI help */\n/* -------------------------------------------------------------------------- */\n\nconst USAGE = `\ndevtools-test — run mini-app tests on a real device WebView over the CDP relay\n\nUSAGE\n devtools-test <glob> [<glob> ...] [options]\n\nOPTIONS\n --scheme-url <url> intoss-private:// URL from \\`ait deploy --scheme-only\\`\n (required for standalone relay attach / env3)\n --timeout <ms> Per-file evaluate timeout in ms (default: 60000).\n Controls how long a single test file is allowed to run\n before it is considered hung. Does NOT affect how long\n the CLI waits for a human to scan the QR code — use\n --attach-timeout for that.\n --attach-timeout <ms> How long to wait for a human to scan the QR code with\n their phone. Omit (default) to wait indefinitely — the\n runner stays up until you stop it (Ctrl-C/SIGTERM).\n Pass a value to bound the wait for CI/headless runs.\n --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)\n --cell-platform <plat> Platform to inject as __AIT_CELL__.platform\n (mock|ios|android, default: AIT_CELL_PLATFORM env)\n --report-dir <dir> Persist a runner-agnostic report + captures to <dir>\n (report: <sdkLine>.<platform>.json; captures:\n <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).\n Omitted = nothing saved. Enables console capture.\n --dashboard-port <port> Base port for the QR dashboard HTTP server. On\n EADDRINUSE it increments (+1, up to 20 tries) before\n falling back to an ephemeral port. Omit to use\n AIT_DEBUG_HTTP_PORT env or the built-in default\n (8317) — pass 0 to force a random ephemeral port.\n --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for\n non-interactive stdout / CI / AIT_NO_QR_STDOUT)\n --headless Disable browser auto-open (text QR only)\n --project-root <dir> Project root for .ait_relay secret lookup\n (default: current working directory)\n --manual-blocking Run manual-tagged test files (*.manual.ait.test.ts)\n LAST, after all regular files, with a human present.\n Before each manual file, the QR dashboard is pushed\n a step-by-step Korean prompt naming the file + its\n progress (k/n), and the same line is printed to\n stdout. Manual files get a 5-minute per-file evaluate\n timeout (vs. --timeout for everything else) since a\n human is expected to tap through a native sheet\n (photo picker, permission dialog, fullscreen ad).\n Without this flag (default off), *.manual.ait.test.ts\n files are EXCLUDED from the glob expansion entirely —\n existing unattended runs are byte-for-byte unaffected.\n With --report-dir, a run that included manual files\n ALSO writes <sdkLine>.<platform>.manual.json\n alongside (never replacing) the standard report, and\n each manual file's report entry is stamped\n mode: 'manual' — never diff a manual run against an\n unattended baseline as if they were equivalent.\n --help, -h Show this help message\n\nDESCRIPTION\n Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real\n device to scan and attach, injects the cell globals (__AIT_CELL__), bundles\n each matched test file with esbuild (SDK imports redirected to window.__sdk),\n injects the bundle into the attached WebView via Runtime.evaluate, and prints\n a summary.\n\n With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a\n runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be\n compared offline.\n\n The test files run against the live relay connection started by this process;\n no separate MCP daemon is required.\n\nEXAMPLE\n devtools-test 'src/**/*.ait.test.ts' \\\\\n --scheme-url \"intoss-private://...\" \\\\\n --cell-sdk-line 3.x \\\\\n --cell-platform ios \\\\\n --report-dir .ait-report \\\\\n --timeout 60000\n\n`.trimStart();\n\n/* -------------------------------------------------------------------------- */\n/* Timeout resolution (exported for unit tests) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Resolved timeout values derived from raw CLI flag strings.\n *\n * Exported so unit tests can assert the two clocks in isolation without\n * spawning a subprocess.\n */\nexport interface ResolvedTimeouts {\n /** Per-file evaluate timeout (ms). From --timeout; default 60 000. */\n evaluateTimeoutMs: number;\n /**\n * QR-scan wait timeout (ms), or `undefined` when --attach-timeout was not\n * supplied. `undefined` signals \"let relay-factory.ts's default govern\" —\n * which is an UNBOUNDED wait (devtools#735): the runner stays up until the\n * user stops it (Ctrl-C/SIGTERM). We intentionally do not inline that\n * default here so the factory remains the single source of truth for it.\n */\n attachTimeoutMs: number | undefined;\n}\n\n/**\n * Parses --timeout and --attach-timeout raw string values into the two\n * distinct clocks.\n *\n * Returns an error string on invalid input, or the resolved timeouts on\n * success. The caller (main) writes the error to stderr and exits 1.\n *\n * Exported for unit testing — main() is the only other caller.\n */\nexport function resolveTimeouts(\n rawTimeout: string | undefined,\n rawAttachTimeout: string | undefined,\n): ResolvedTimeouts | string {\n // Default must match rpc.ts DEFAULT_TIMEOUT_MS (60_000) — the CLI's own\n // fallback used to be 30_000, which silently overrode rpc.ts's 60s bump\n // (#726) on every CLI invocation that omitted --timeout (#731).\n const evaluateTimeoutMs = rawTimeout !== undefined ? parseInt(rawTimeout, 10) : 60_000;\n if (Number.isNaN(evaluateTimeoutMs) || evaluateTimeoutMs <= 0) {\n return '--timeout must be a positive integer';\n }\n\n const attachTimeoutMs =\n rawAttachTimeout !== undefined ? parseInt(rawAttachTimeout, 10) : undefined;\n if (attachTimeoutMs !== undefined && (Number.isNaN(attachTimeoutMs) || attachTimeoutMs <= 0)) {\n return '--attach-timeout must be a positive integer';\n }\n\n return { evaluateTimeoutMs, attachTimeoutMs };\n}\n\n/**\n * Parses the `--dashboard-port` raw string value into a validated port\n * number, or `undefined` when the flag was omitted (letting relay-factory /\n * qr-http-server resolve their own default — env then the built-in fixed\n * default, devtools#752).\n *\n * `0` is a valid, meaningful value (explicit opt-out to pure ephemeral) and\n * is passed through as-is — it must NOT be confused with \"omitted\".\n *\n * Returns an error string on invalid input (non-integer, negative, or\n * >65535), or the resolved port on success. Exported for unit testing.\n */\nexport function resolveDashboardPort(raw: string | undefined): number | undefined | string {\n if (raw === undefined) return undefined;\n const port = parseInt(raw, 10);\n if (Number.isNaN(port) || port < 0 || port > 65535) {\n return '--dashboard-port must be an integer between 0 and 65535';\n }\n return port;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Per-file summary rendering (exported for unit tests) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Renders per-file result lines and the aggregate totals line to a string.\n *\n * Each file gets one line:\n * - Error/timeout: `FAIL <basename>: <error-class>`\n * - Pass (0 tests): `OK <basename>: 0 passed (empty file)`\n * - Pass: `OK <basename>: N passed[, M failed][, K skipped]`\n *\n * The aggregate totals line always follows.\n *\n * SECRET-HANDLING: only `basename(file)` is used — no absolute paths, relay\n * URLs, wss URLs, scheme URLs, or TOTP codes appear in the output. The error\n * string comes from `result.error` which is already secret-free (relay-worker\n * produces only error-class messages like \"rpc: evaluate timed out after\n * 30000ms\").\n *\n * Exported so unit tests can assert the per-file lines without spawning a\n * subprocess or going through the full relay attach flow.\n */\nexport function renderSummary(report: RelayRunReport): string {\n const lines: string[] = [];\n\n for (const { file, result } of report.files) {\n const name = basename(file);\n if ('error' in result) {\n // Timed-out or errored file — the error string is already secret-free\n // (relay-worker only surfaces error-class text, never URLs or codes).\n lines.push(`FAIL ${name}: ${result.error}`);\n } else {\n const parts: string[] = [`${result.passed} passed`];\n if (result.failed > 0) parts.push(`${result.failed} failed`);\n if (result.skipped > 0) parts.push(`${result.skipped} skipped`);\n const suffix = result.passed + result.failed + result.skipped === 0 ? ' (empty file)' : '';\n lines.push(`OK ${name}: ${parts.join(', ')}${suffix}`);\n }\n }\n\n const { totals, duration } = report;\n lines.push(\n `\\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${duration}ms)`,\n );\n\n return lines.join('\\n');\n}\n\n/* -------------------------------------------------------------------------- */\n/* Pure run function (testable without a real relay) */\n/* -------------------------------------------------------------------------- */\n\n/** Options for `runWithConnection`. */\nexport interface RunWithConnectionOptions extends RelayRunOptions {\n /** If true, print a summary to stdout. Defaults to false in tests. */\n printSummary?: boolean;\n}\n\n/**\n * Runs `files` over `connection` and returns the aggregate report.\n * This pure function is the testable core of the CLI (and is what the\n * `run_tests` MCP tool calls against the daemon's attached connection); it is\n * separate from `main()` so tests can call it without spawning a subprocess.\n */\nexport async function runWithConnection(\n connection: CdpConnection,\n files: string[],\n opts?: RunWithConnectionOptions,\n): Promise<RelayRunReport> {\n const report = await runTestFilesOverRelay(connection, files, opts);\n\n if (opts?.printSummary) {\n process.stdout.write(`\\n${renderSummary(report)}\\n`);\n }\n\n return report;\n}\n\n/* -------------------------------------------------------------------------- */\n/* main() — CLI entry point */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Decides whether to suppress the QR/attach block on stdout.\n *\n * Suppress when EITHER the user passed `--no-qr-stdout`, OR stdout is not a TTY\n * / `CI` is set / `AIT_NO_QR_STDOUT` is set (non-interactive — a captured stdout\n * must not leak the relay wss + TOTP `at=` code that the QR block encodes). The\n * suppression is whole-chunk: `attachUrl` AND `relayUrl` ride in the same block.\n *\n * Exported for unit testing.\n */\nexport function shouldSuppressQr(noQrFlag: boolean): boolean {\n return (\n noQrFlag ||\n !process.stdout.isTTY ||\n process.env.CI !== undefined ||\n process.env.AIT_NO_QR_STDOUT !== undefined\n );\n}\n\n/**\n * CLI entry point.\n *\n * Performs a standalone relay attach → run lifecycle, sharing the attach\n * assembly with the Vitest pool via `createRelayConnectionFactory` (single\n * source — no drift):\n *\n * 1. Parse args: globs, --timeout (per-file evaluate), --attach-timeout (QR\n * scan wait), --cell-sdk-line, --cell-platform, --scheme-url (required for\n * env3), --report-dir, --dashboard-port, --no-qr-stdout, --headless,\n * --project-root.\n * 2. Discover test files; exit 1 if none.\n * 3. factory.open() — boot relay → render QR (suppressed on non-interactive\n * stdout) → wait for phone (up to attachTimeoutMs) → inject cell →\n * enableDomains. Returns the conn.\n * 4. runWithConnection(conn, files, { evaluateTimeoutMs, collectCaptures,\n * printSummary }).\n * 5. With --report-dir: write the runner-agnostic report + capture files.\n * 6. factory.close(); process.exitCode = failed > 0 ? 1 : 0.\n *\n * The CLI is not a daemon — no lock, router, SSE, or tools_list is needed.\n * Attach timeout exits with code 1; test failures exit with code 1.\n *\n * SECRET-HANDLING: scheme_url / relay wssUrl / TOTP codes are never written to\n * stdout/stderr directly. The QR block (which encodes the TOTP `at=` code) is\n * printed only when stdout is interactive AND not suppressed.\n */\nexport async function main(argv: string[] = process.argv.slice(2)): Promise<void> {\n // ── Step 1: parse arguments ───────────────────────────────────────────────\n let parsed: ReturnType<typeof parseArgs>;\n try {\n parsed = parseArgs({\n args: argv,\n options: {\n help: { type: 'boolean', short: 'h' },\n timeout: { type: 'string' },\n 'attach-timeout': { type: 'string' },\n 'scheme-url': { type: 'string' },\n 'cell-sdk-line': { type: 'string' },\n 'cell-platform': { type: 'string' },\n 'report-dir': { type: 'string' },\n 'dashboard-port': { type: 'string' },\n 'no-qr-stdout': { type: 'boolean' },\n headless: { type: 'boolean' },\n 'project-root': { type: 'string' },\n 'manual-blocking': { type: 'boolean' },\n } as const,\n allowPositionals: true,\n });\n } catch (e) {\n process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\\n`);\n process.exitCode = 1;\n return;\n }\n\n if (parsed.values.help || argv.length === 0) {\n process.stdout.write(USAGE);\n return;\n }\n\n // Extract string-typed flags explicitly — parseArgs returns `string | boolean | (string | boolean)[]`\n // for the union `values` type when options have mixed `type` fields, so we\n // narrow each flag here.\n const vals = parsed.values;\n\n // Resolve the two timeout clocks via the exported pure helper (unit-tested).\n const timeouts = resolveTimeouts(\n typeof vals.timeout === 'string' ? vals.timeout : undefined,\n typeof vals['attach-timeout'] === 'string' ? vals['attach-timeout'] : undefined,\n );\n if (typeof timeouts === 'string') {\n process.stderr.write(`devtools-test: ${timeouts}\\n`);\n process.exitCode = 1;\n return;\n }\n const { evaluateTimeoutMs, attachTimeoutMs } = timeouts;\n\n const schemeUrl = typeof vals['scheme-url'] === 'string' ? vals['scheme-url'] : '';\n if (schemeUrl === '') {\n process.stderr.write(\n `devtools-test: --scheme-url is required for standalone relay attach.\\n` +\n ` Pass the intoss-private:// URL from \\`ait deploy --scheme-only\\`.\\n`,\n );\n process.exitCode = 1;\n return;\n }\n\n const headless = vals.headless === true;\n const projectRoot =\n typeof vals['project-root'] === 'string' ? vals['project-root'] : process.cwd();\n const reportDir = typeof vals['report-dir'] === 'string' ? vals['report-dir'] : undefined;\n const dashboardPort = resolveDashboardPort(\n typeof vals['dashboard-port'] === 'string' ? vals['dashboard-port'] : undefined,\n );\n if (typeof dashboardPort === 'string') {\n process.stderr.write(`devtools-test: ${dashboardPort}\\n`);\n process.exitCode = 1;\n return;\n }\n const suppressQr = shouldSuppressQr(vals['no-qr-stdout'] === true);\n const manualBlocking = vals['manual-blocking'] === true;\n\n // Cell: --cell-sdk-line and --cell-platform (fall back to AIT_CELL_PLATFORM env).\n const cellSdkLine = typeof vals['cell-sdk-line'] === 'string' ? vals['cell-sdk-line'] : undefined;\n const cellPlatform =\n typeof vals['cell-platform'] === 'string'\n ? vals['cell-platform']\n : process.env.AIT_CELL_PLATFORM;\n const hasCell = cellSdkLine !== undefined || cellPlatform !== undefined;\n // The cell injected onto the page and the report/capture filename suffix.\n // sdk-example's own fallbacks are '2.x'/'mock'; mirror them when a flag is\n // absent so artifacts still get a stable, meaningful cell suffix.\n const cell = { sdkLine: cellSdkLine ?? '2.x', platform: cellPlatform ?? 'mock' };\n\n // ── Step 2: discover test files ───────────────────────────────────────────\n const globs = parsed.positionals;\n if (globs.length === 0) {\n process.stderr.write(`devtools-test: at least one glob pattern is required\\n`);\n process.stdout.write(USAGE);\n process.exitCode = 1;\n return;\n }\n\n // Tagging contract (devtools#741): *.manual.ait.test.ts files are excluded\n // from discovery unless --manual-blocking is passed, in which case they are\n // included and then scheduled strictly AFTER every regular file below —\n // this is the entire \"manual-variant\" ordering guarantee.\n const discovered = await discoverTestFiles(globs, process.cwd(), {\n includeManual: manualBlocking,\n });\n if (discovered.length === 0) {\n process.stderr.write(`devtools-test: no test files matched ${globs.join(', ')}\\n`);\n process.exitCode = 1;\n return;\n }\n const { regular, manual } = partitionManualTests(discovered);\n // Manual files always run LAST, regardless of the discovery/glob order —\n // `files` below is what's actually injected, in run order.\n const files = manualBlocking ? [...regular, ...manual] : regular;\n const manualFileSet = new Set(manual);\n process.stderr.write(\n manualBlocking && manual.length > 0\n ? `devtools-test: found ${regular.length} regular + ${manual.length} manual (${MANUAL_TEST_SUFFIX}) test file(s)\\n`\n : `devtools-test: found ${files.length} test file(s)\\n`,\n );\n\n // ── Step 3: open the relay connection via the shared factory ──────────────\n // The factory boots the relay, renders the QR, waits for the phone, injects\n // the cell, and enables CDP domains — the same assembly the Vitest pool uses.\n // We pass `onQrContent` so the CLI owns the stdout decision: suppress the whole\n // QR block on non-interactive stdout (it encodes the relay wss + TOTP code).\n // SECRET-HANDLING: scheme_url / wss / TOTP are never logged by the factory.\n if (hasCell) {\n process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\\n`);\n }\n const factory = createRelayConnectionFactory({\n schemeUrl,\n projectRoot,\n // attachTimeoutMs is only forwarded when the user explicitly passed\n // --attach-timeout; otherwise we omit it so relay-factory.ts's built-in\n // UNBOUNDED default governs (devtools#735) — single source of truth.\n ...(attachTimeoutMs !== undefined ? { timeoutMs: attachTimeoutMs } : {}),\n // dashboardPort is only forwarded when the user explicitly passed\n // --dashboard-port; otherwise omit so relay-factory/qr-http-server's own\n // default resolution governs (env → fixed default, devtools#752).\n ...(dashboardPort !== undefined ? { dashboardPort } : {}),\n headless,\n cell: hasCell ? cell : undefined,\n onQrContent: (chunks) => {\n if (suppressQr) {\n process.stdout.write('QR suppressed (non-interactive)\\n');\n return;\n }\n for (const chunk of chunks) process.stdout.write(`${chunk}\\n`);\n },\n });\n\n let connection: CdpConnection;\n try {\n connection = await factory.open();\n } catch (e) {\n process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\\n`);\n process.exitCode = 1;\n return;\n }\n\n // Ensure cleanup on early exit.\n let exitCode = 0;\n try {\n // ── Step 4: run test files ──────────────────────────────────────────────\n // #730: mark the dashboard as 'running' before the run starts so the QR\n // page reflects an in-progress session rather than staying 'active' the\n // whole time. collectCaptures is enabled only when a report dir is given\n // (the only sink for captures) — keeps the no-report path free of\n // listener overhead.\n factory.onSessionPhase?.('running');\n const report = await runWithConnection(connection, files, {\n timeoutMs: evaluateTimeoutMs,\n printSummary: true,\n collectCaptures: reportDir !== undefined,\n manualFiles: manualBlocking && manualFileSet.size > 0 ? manualFileSet : undefined,\n // #741: before each manual file, push the dashboard prompt AND print\n // the same Korean instruction line to stdout (human may be watching\n // either surface). Clearing the prompt (dashboard only) happens once\n // the whole run ends, in the finally block below — a manual file is\n // always the tail of `files`, so there is no \"next regular file\" to\n // clear it for mid-run.\n onManualFile: (file, index, total) => {\n const name = basename(file);\n process.stdout.write(\n `수동 단계: ${name} — 폰에서 네이티브 시트가 뜨면 안내에 따라 조작하세요 (${index}/${total})\\n`,\n );\n factory.onManualPrompt?.({ file: name, index, total });\n },\n });\n\n // Clear the dashboard's manual prompt now that the run (including any\n // manual tail) has finished — leaves no stale \"수동 단계\" banner up once\n // the human is done.\n if (manualBlocking && manualFileSet.size > 0) {\n factory.onManualPrompt?.(null);\n }\n\n // ── Step 5: persist artifacts (only with --report-dir) ──────────────────\n if (reportDir !== undefined) {\n try {\n const reportPaths = await writeReportArtifact(report, reportDir, {\n sdkLine: cell.sdkLine,\n platform: cell.platform,\n projectRoot,\n });\n for (const reportPath of reportPaths) {\n process.stderr.write(`devtools-test: wrote report ${reportPath}\\n`);\n }\n const capturePaths = await writeCaptureArtifacts(\n report.captures,\n `${reportDir}/.ait-capture`,\n cell,\n );\n if (capturePaths.length > 0) {\n process.stderr.write(`devtools-test: wrote ${capturePaths.length} capture file(s)\\n`);\n }\n } catch (e) {\n // Artifact write failure must not mask the test result.\n process.stderr.write(\n `devtools-test: failed to write report artifacts: ${e instanceof Error ? e.message : String(e)}\\n`,\n );\n }\n }\n\n exitCode = report.totals.failed > 0 ? 1 : 0;\n } finally {\n // ── Step 6: teardown ────────────────────────────────────────────────────\n // #730: mark the dashboard 'complete' BEFORE close() so the terminal SSE\n // frame reaches any open dashboard tab before the HTTP server is closed.\n // Redundant-but-safe with close()'s own internal push (belt-and-suspenders\n // so the frame flushes even if the two ever run in a different order).\n factory.onSessionPhase?.('complete');\n // factory.close() stops the relay family (closes the CDP connection +\n // shuts down the relay + cloudflared child).\n await factory.close(connection);\n process.exitCode = exitCode;\n }\n}\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * WS keepalive ping interval (ms).\n *\n * Cloudflare proxied connections are dropped after ~100 s of no traffic.\n * 45 s comfortably fits inside that window and lets both the phone-target leg\n * and the daemon-client leg survive idle CDP sessions.\n */\nconst DEFAULT_KEEPALIVE_INTERVAL_MS = 45_000;\n\n/**\n * Minimal shape of chii's internal WebSocketServer instance.\n *\n * `chii/server/lib/WebSocketServer` holds the real `ws.Server` in `_wss`.\n * `_wss.clients` is the standard `Set<WebSocket>` tracking all live sockets.\n * We access this to ping every connected socket — no chii internals beyond\n * this single field are touched.\n */\ninterface ChiiInternalWss {\n _wss: { clients: Set<WebSocket> };\n start(server: import('node:http').Server): void;\n}\n\n/**\n * Loads chii's internal WebSocketServer class and returns it together with a\n * flag indicating whether the real class was found.\n *\n * Returns `null` if the internal path is not resolvable (future chii release\n * changes the layout) — callers skip keepalive gracefully.\n */\nfunction tryLoadChiiWssClass(): (new () => ChiiInternalWss) | null {\n try {\n const mod: unknown = require('chii/server/lib/WebSocketServer');\n if (typeof mod === 'function') {\n return mod as new () => ChiiInternalWss;\n }\n } catch {\n // Module not found or shape changed — keepalive will be skipped.\n }\n return null;\n}\n\n/**\n * Calls `chii.start()` and returns the chii `WebSocketServer` instance that\n * was constructed during the call.\n *\n * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`\n * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`\n * at module load time. The class reference is stable, so we can temporarily\n * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —\n * to record `this` before the original `start` runs.\n *\n * The patch is installed before `chii.start()` and removed (via `finally`)\n * immediately after, so concurrent `startChiiRelay` calls nest correctly: each\n * call's patch overrides the previous in the prototype chain for the duration\n * of its own `chii.start()` call, restoring the prior descriptor on exit.\n *\n * If `ChiiWssClass` is null (internal path changed in a future chii release),\n * `chii.start()` runs unpatched and the function returns null — callers skip\n * keepalive gracefully without affecting relay correctness.\n */\nasync function startChiiWithCapture(\n chii: ChiiServerModule,\n startOptions: Parameters<ChiiServerModule['start']>[0],\n ChiiWssClass: (new () => ChiiInternalWss) | null,\n): Promise<ChiiInternalWss | null> {\n if (ChiiWssClass === null) {\n await chii.start(startOptions);\n return null;\n }\n\n let captured: ChiiInternalWss | null = null;\n const proto = ChiiWssClass.prototype as ChiiInternalWss;\n const originalStart = proto.start;\n\n proto.start = function (this: ChiiInternalWss, server) {\n captured = this;\n return originalStart.call(this, server);\n };\n\n try {\n await chii.start(startOptions);\n } finally {\n // Always restore — even if chii.start() throws.\n proto.start = originalStart;\n }\n\n return captured;\n}\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n /**\n * WS protocol ping interval in milliseconds (issue #483).\n *\n * The relay sends a ping frame to every connected WebSocket at this interval\n * so that Cloudflare's proxied-connection idle timer (~100 s) is reset for\n * both the phone-target leg and the daemon-client leg. The peer responds with\n * a pong automatically (browser / ws library behaviour) — no application\n * code change is needed on either end.\n *\n * Default: 45 000 ms (45 s). Set to 0 to disable keepalive entirely.\n *\n * Pass a small value in tests to avoid real-time waits — pair with fake\n * timers (`vi.useFakeTimers()`) or a short sleep.\n */\n keepaliveIntervalMs?: number;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n const keepaliveIntervalMs =\n options.keepaliveIntervalMs !== undefined\n ? options.keepaliveIntervalMs\n : DEFAULT_KEEPALIVE_INTERVAL_MS;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: two cases are gated, everything else passes through.\n //\n // Case 1 — path-prefixed form (`/at/<code>/…`): the phone fetches\n // `target.js` via `https://<host>/at/<code>/target.js` (issue #466).\n // The prefix is rewritten to the query form and then verified; chii's Koa\n // static handler sees the stripped URL.\n //\n // Case 2 — `/targets` read route (issue #474): without gating this route a\n // URL-leaker (threat model: someone who obtained the tunnel URL but not\n // the secret) can read session metadata (id/url/title, including any query\n // params in the page URL) without a code. Debugger attach stays blocked by\n // the WS gate, but /targets is an HTTP read that was previously ungated.\n // The `at` code may arrive as a query param (`/targets?at=<code>`) — which\n // buildRelayVerifyAuth already handles — or via the `/at/<code>/` path\n // prefix (rewriteAtPathPrefix normalises that to the query form first).\n //\n // Static assets (target.js, chii front-end HTML/JS/CSS) and any other\n // non-prefixed, non-/targets request keep today's ungated pass-through — the\n // phone fetches some via the legacy no-prefix path and gating them would\n // break env-2/3/4.\n //\n // SECRET-HANDLING: We do NOT log req.url or any auth value in this listener.\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n // Path-prefix form: normalise to query form, then verify.\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed (or was rejected above): return so the path-prefix branch\n // never falls through to the /targets check below.\n return;\n }\n\n // Non-prefixed request: check if this is the /targets read route (issue\n // #474). Extract the pathname robustly — req.url is a raw path+query\n // string like `/targets?at=123456` so we split on `?`.\n const pathname = (req.url ?? '').split('?')[0];\n if (pathname === '/targets' || pathname === '/targets/') {\n // The `at` code must be present as a query param — verifyAuth reads it\n // from req.url via URLSearchParams, which already handles `?at=<code>`\n // without any URL rewrite needed.\n if (!verifyAuth(req)) {\n // Same 401 shape as the path-prefix branch (issue #478 contract).\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n // res.end() wins — chii's Koa handler will not write.\n return;\n }\n // Auth passed: return without ending the response. Node invokes every\n // 'request' listener in registration order, so chii's Koa listener\n // (registered later by chii.start) still runs and serves the /targets\n // JSON — this return only means \"this gate listener is done\", not \"end\n // the response\".\n return;\n }\n\n // Any other non-prefixed request (static assets, chii front-end, etc.):\n // ungated pass-through to chii. Auth passed: no-op — chii's Koa\n // 'request' listener (registered below by chii.start) serves the URL.\n // (Koa skips writing when an earlier listener already ended the response,\n // so the 401 paths above are safe even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n","/**\n * @ait-co/devtools debug-mode MCP server (stdio).\n *\n * Lets an AI coding agent attach to a running mini-app (real Toss WebView, or a\n * browser in dev mode) and read its console/network/DOM/screenshot over CDP plus\n * the AIT.* domain, without a human watching a phone. Transport is CDP-via-Chii:\n * a local Chii relay on an OS-assigned port (default 0) exposed through a\n * cloudflared quick tunnel; the phone attaches over the public wss URL.\n *\n * AI host --stdio--> this server --CDP client WS--> Chii relay :<OS-port>\n * ^-- target WS -- phone\n *\n * Port 0 (default): the OS picks a free ephemeral port on every startup.\n * This prevents EADDRINUSE when a stale cloudflared child (orphaned after\n * SIGKILL, PPID 1) still holds a fixed port — which previously caused the MCP\n * handshake to fail with -32000. With port 0 any orphaned cloudflared is\n * harmless; the new relay always gets a fresh port.\n *\n * Best-effort child cleanup: SIGINT/SIGTERM/SIGHUP handlers call shutdown() to\n * stop cloudflared and the relay. uncaughtException/unhandledRejection also\n * call shutdown() before exit. SIGKILL cannot be intercepted by Node, so\n * cloudflared orphans from SIGKILL remain (port 0 makes them harmless). Users\n * can clean up manually: `pkill -f 'cloudflared.*trycloudflare'`.\n *\n * The tool layer reads from an injectable `CdpConnection` (CDP) and `AitSource`\n * (AIT.*), so every tool is unit-testable with a fake (no phone). This module\n * wires the live pieces (relay + tunnel + production connection); the phone\n * roundtrip is fully wired and pending only on-device acceptance.\n *\n * Dynamic tool registration (issue #208):\n * The server advertises `listChanged: true` so MCP clients can subscribe to\n * `notifications/tools/list_changed`. Before any page attaches, only bootstrap\n * tools (`start_attach`, `list_pages`) are listed. Once a target appears,\n * the full attach-dependent tool set is added and a `list_changed` notification\n * is sent — without requiring a session restart. `runDebugServer` and\n * `runLocalDebugServer` start a polling watcher that detects the 0→N target\n * transition and calls `server.sendToolListChanged()`.\n *\n * Note: `src/mcp/server.ts` (dev mode, HTTP mock-state) is NOT subject to this\n * model — it has no attach concept and always exposes the full tool surface.\n *\n * Node-only.\n */\n\nimport { Server } from '@modelcontextprotocol/sdk/server/index.js';\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';\nimport { isDebugAllowedHost } from '../in-app/gate.js';\nimport { startMaxAgeWatchdog, startParentWatcher } from '../shared/parent-watcher.js';\n// Test-runner core (#646): run_tests reuses the same orchestration the\n// `devtools-test` CLI uses. These imports are react-free (node:* + esbuild),\n// so they do not break the MCP-daemon react-free invariant.\nimport { injectDebugIndicator, injectGlobals } from '../test-runner/cell.js';\nimport { runWithConnection } from '../test-runner/cli.js';\nimport { discoverTestFiles } from '../test-runner/discover.js';\nimport type { RelayRunReport } from '../test-runner/relay-worker.js';\nimport { ChiiAitSource } from './ait-chii-source.js';\nimport type { AitSource } from './ait-source.js';\n// Attach orchestrator (issue #684 §2) — the relay-attach orchestration extracted\n// to module level. `createDebugServer` assembles `attachDeps` from its closure\n// variables and calls these. Pure extraction; behavior unchanged (#684 PR1).\nimport {\n type AttachDeps,\n type AttachUrlParts,\n isSandboxPageFresh,\n prepareAttach as prepareAttachCore,\n RELAY_SANDBOX_STALE_PAGE_MS,\n renderAndMaybeWait as renderAndMaybeWaitCore,\n} from './attach-orchestrator.js';\n\n// Back-compat re-exports (issue #684 PR1): these symbols moved to\n// `attach-orchestrator.ts` but were previously exported from here. Re-export so\n// existing importers (tests) keep resolving them from `./debug-server.js`\n// unchanged — a pure refactor must not move a public symbol's import path.\nexport {\n type AttachUrlParts,\n extractDeploymentId,\n isSandboxPageFresh,\n RELAY_SANDBOX_STALE_PAGE_MS,\n START_ATTACH_REMINT_THRESHOLD_MS,\n START_ATTACH_SEGMENT_MS,\n} from './attach-orchestrator.js';\n\nimport type { CdpConnection } from './cdp-connection.js';\nimport { ChiiCdpConnection } from './chii-connection.js';\nimport { startChiiRelay } from './chii-relay.js';\nimport { buildDeepLinkAttachUrl, buildLauncherAttachUrl } from './deeplink.js';\nimport { AutoDevtoolsOpener, buildChiiInspectorUrl } from './devtools-opener.js';\nimport { wrapEnvelope } from './envelope.js';\nimport {\n deriveEnvironment,\n isRelayEnv,\n type McpEnvironment,\n type RelayOrigin,\n} from './environment.js';\nimport {\n classifyToolError,\n mcpError,\n pageCrashError,\n pageMissingError,\n relayDisconnectError,\n sdkAbsentError,\n tierRejectionError,\n} from './errors.js';\nimport { LocalCdpConnection } from './local-connection.js';\nimport { launchChromium } from './local-launcher.js';\nimport { logError, logInfo, logWarn } from './log.js';\nimport {\n type DashboardState,\n type QrHttpServer,\n type QrHttpServerOptions,\n startQrHttpServer,\n} from './qr-http-server.js';\nimport { loadRelaySecretReadOnly } from './relay-secret-store.js';\nimport { acquireLock, readServerLock } from './server-lock.js';\nimport {\n BOOTSTRAP_TOOL_NAMES,\n callSdk,\n DEBUG_TOOL_DEFINITIONS,\n type DiagnosticsCollector,\n evaluate,\n filterToolsByEnvironment,\n getDiagnostics,\n getDomDocument,\n getMockState,\n getOperationalEnvironment,\n getSdkCallHistory,\n getToolAvailability,\n InMemoryDiagnosticsCollector,\n isAitToolName,\n isDebugToolName,\n isToolAvailableIn,\n listConsoleMessages,\n listExceptions,\n listNetworkRequests,\n listPages,\n measureSafeArea,\n type TunnelStatus,\n takeScreenshot,\n takeSnapshot,\n} from './tools.js';\nimport { assertRelayAuthConfigured, buildRelayVerifyAuth, generateTotp } from './totp.js';\n\nexport { startMaxAgeWatchdog, startParentWatcher } from '../shared/parent-watcher.js';\n\nimport {\n generateAttachToken,\n makeTunnelStatus,\n printAttachBanner,\n type QuickTunnel,\n startQuickTunnel,\n startTunnelHealthProbe,\n} from './tunnel.js';\n\n// RELAY_SANDBOX_STALE_PAGE_MS / START_ATTACH_SEGMENT_MS /\n// START_ATTACH_REMINT_THRESHOLD_MS / isSandboxPageFresh / extractDeploymentId\n// moved to `attach-orchestrator.ts` (issue #684 PR1) and are re-exported above\n// for back-compat.\n\n/**\n * The result of a `start_debug` mode switch (issue #348). Reported back to the\n * agent so it knows the active mode, whether the LIVE guard is armed, and the\n * suggested next step — all without a Claude Code restart or MCP re-handshake.\n */\nexport interface ModeSwitchReport {\n /** The mode now active after the switch. */\n mode: StartDebugMode;\n /** Derived `McpEnvironment` for the now-active connection. */\n environment: McpEnvironment;\n /** Kind of the now-active connection. */\n kind: 'relay' | 'local';\n /** Human-readable next-step hint for the agent. */\n nextStep: string;\n}\n\n/**\n * The three canonical `start_debug` modes (issues #382, #378, #398, #665 — each\n * names the environment fidelity ladder rung it attaches to):\n *\n * - `local-browser` → env 1: desktop Chromium with the MOCK SDK + local CDP\n * attach. Side-effect tools (call_sdk/evaluate) run unguarded\n * against the mock; nothing touches a real device or real users.\n * No prerequisites — the default, always-available environment.\n *\n * - `relay-sandbox` → env 2: real-device PWA (real WebKit engine + mock SDK)\n * over an EXTERNAL CDP relay that the unplugin (`tunnel: { cdp:\n * true }`) already brought up. Output env `relay-mobile`.\n * Prerequisite: `AIT_RELAY_BASE_URL` set to the unplugin's relay\n * base URL. The MCP only attaches a CDP client; it does NOT start\n * (or stop) that relay.\n *\n * - `relay-staging` → env 3: real-device Toss WebView dog-food build with the\n * REAL SDK over the intoss-private relay.\n * Prerequisite: deployed dog-food bundle + device cold-loaded via\n * intoss-private deep-link/QR relay injection.\n *\n * `relay-live` (env 4) has been removed (#665) — the debug surface is now gated\n * by a positive allowlist (localhost/trycloudflare/private-apps). Hosts on\n * `apps.tossmini.com` are blocked at the in-app entry and MCP layer.\n *\n * Normalization is handled by `normalizeStartDebugMode`.\n */\nexport type StartDebugMode = 'local-browser' | 'relay-sandbox' | 'relay-staging';\n\n/**\n * Returns `true` when the mode routes to a relay connection (`relay-sandbox` or\n * `relay-staging`). Both surface the Tier B / relay-only tool set.\n */\nexport function isRelayMode(mode: StartDebugMode): boolean {\n return mode === 'relay-sandbox' || mode === 'relay-staging';\n}\n\n/**\n * Maps a `StartDebugMode` to the `McpEnvironment` it routes to (issue #626).\n * Used by `start_attach`'s mode prologue to decide whether a `switchMode` is\n * needed: when the active env already equals `envForMode(mode)`, the switch is\n * skipped (no `tools/list_changed` churn).\n *\n * - `local-browser` → `mock`\n * - `relay-sandbox` → `relay-mobile` (env 2 external-PWA relay)\n * - `relay-staging` → `relay-dev` (env 3 intoss-private relay)\n */\nexport function envForMode(mode: StartDebugMode): McpEnvironment {\n switch (mode) {\n case 'local-browser':\n return 'mock';\n case 'relay-sandbox':\n return 'relay-mobile';\n case 'relay-staging':\n return 'relay-dev';\n }\n}\n\n// AttachUrlParts / AttachTotpMeta / PrepareAttachResult / McpResult moved to\n// `attach-orchestrator.ts` (issue #684 PR1). AttachUrlParts / PrepareAttachResult\n// / McpResult are imported above; AttachUrlParts is also re-exported for\n// back-compat.\n\n/**\n * Owns the two coexisting CDP connections (local + relay) and the `active`\n * pointer that `start_debug` flips (issue #348 — DUAL-CONNECTION-COEXIST).\n *\n * The MCP `Server` + transport are created once; the request handlers read the\n * connection through `active`, so swapping the pointer underneath is invisible\n * to the MCP host (no re-handshake, no restart). Inactive infra is left warm —\n * teardown happens only at process exit (see the unified shutdown in the run\n * functions), which is what preserves a warm attach across mode switches.\n */\nexport interface ConnectionRouter {\n /** The connection the request handlers must read this instant. */\n readonly active: CdpConnection;\n /**\n * Relay origin of the currently-active family (issue #378) — the\n * discriminator that distinguishes the env-2 external-PWA relay\n * (`'external-pwa'` → `relay-mobile`) from the intoss-private relay\n * (`'intoss-webview'` → `relay-dev`). `undefined` for a local (mock) active\n * connection, or for a single-connection router that has no family concept.\n * Threaded into `deriveEnvironment` so the output env can tell the two\n * `kind: 'relay'` families apart.\n */\n readonly activeRelayOrigin?: RelayOrigin;\n /**\n * Switches the active connection to the family for `mode`, lazily booting\n * that family's infra if needed, re-arming the attach watcher, and emitting\n * `tools/list_changed`.\n *\n * `projectRoot` (issue #396) is the per-debug-session mini-app project root\n * supplied by `start_debug`. When switching into a relay family the router\n * loads the relay TOTP secret read-only from `<projectRoot>/.ait_relay` into\n * `process.env` (via `loadRelaySecretReadOnly`) BEFORE the relay boots, so the\n * `assertRelayAuthConfigured()` / `buildRelayVerifyAuth()` at the boot site see\n * it. The daemon never mints — it only reads. Ignored for the local family.\n *\n * Rejects (without swapping) when a swap is already in flight.\n * `relay-live` (env 4) is removed — `confirm` parameter is gone (#665).\n */\n switchMode(mode: StartDebugMode, projectRoot?: string): Promise<ModeSwitchReport>;\n}\n\n/** Live infra the connection reads tunnel status from. */\nexport interface DebugServerDeps {\n connection: CdpConnection;\n /**\n * Dual-connection router (issue #348). When provided, the request handlers\n * read the live connection through `router.active` and `start_debug` calls\n * `router.switchMode()`. When omitted (the dominant test path), a trivial\n * router pinned to `deps.connection` is synthesized and `start_debug` reports\n * that dynamic switching is unavailable — back-compat with every existing\n * single-connection test.\n */\n router?: ConnectionRouter;\n /** AIT.* domain source — forwarded over the same Chii channel in production. */\n aitSource: AitSource;\n /** Returns current tunnel status (URL changes per spawn). */\n getTunnelStatus(): TunnelStatus;\n /**\n * Maximum time in ms to wait for a page to attach when `wait_for_attach=true`.\n * Default 60 000 ms. Exposed for testing so tests can use a small value without\n * fake timers (which conflict with MCP SDK's own timeouts).\n */\n waitForAttachTimeoutMs?: number;\n /**\n * 로컬 QR HTTP 서버 — `start_attach` tool이 브라우저로 열 HTTP URL을 제공.\n * 없으면 text QR fallback으로만 동작 (GUI 없는 환경 호환).\n */\n qrHttpServer?: QrHttpServer;\n /**\n * Resolves the current MCP environment (`mock` | `relay-dev` | `relay-mobile`).\n * Used by `tools/list` to filter Tier A/B tools and by Tier C tools (e.g.\n * `measure_safe_area`) to label the `source` provenance field.\n *\n * Optional — defaults (issue #348, #665) to deriving the env from the *active*\n * connection's `kind` + `relayOrigin`\n * (`deriveEnvironment(router.active.kind, router.activeRelayOrigin)`). No URL\n * sniffing or precedence chain. `liveIntent` removed (#665). Tests inject a\n * fake to pin a precise env.\n */\n getEnvironment?: () => McpEnvironment;\n /** Resolves the reason for the current env decision (for logs). */\n getEnvironmentReason?: () => string;\n /**\n * Diagnostics collector — records server-side errors, attach/detach events,\n * and surfaces them via `get_debug_status`. When omitted a no-op collector is\n * used (backwards-compatible with existing tests that don't inject one).\n */\n diagnosticsCollector?: DiagnosticsCollector;\n /**\n * Hex-encoded TOTP secret for `start_attach` auto-splice.\n *\n * When set, `start_attach` generates a fresh TOTP code on every call and\n * splices it as `at=<code>` into the returned `attachUrl`. The response also\n * includes a `totp` field with `ttlSeconds` and `expiresAt` so callers know\n * when to re-invoke.\n *\n * SECRET-HANDLING: this value is captured in a closure and MUST NOT be logged\n * or included in any output other than the `at=` param inside `attachUrl`.\n *\n * Tests inject a dummy hex string or omit it. Production uses the late-bound\n * {@link getTotpSecret} variant instead (read at call time) — see below.\n */\n totpSecret?: string;\n /**\n * Late-bound variant of {@link totpSecret}: read AT `start_attach` CALL\n * TIME rather than captured once at server construction (issue #396).\n *\n * Why late-bound: since #396 the relay TOTP secret lives in a project-local\n * `.ait_relay` file loaded read-only into `process.env.AIT_DEBUG_TOTP_SECRET`\n * by `switchMode` BEFORE a relay family boots — which is AFTER the daemon\n * (and thus `createDebugServer`) already started. Capturing the secret at\n * construction would read an empty value on the all-lazy daemon, so\n * `start_attach` would emit a QR with no `at=` code and every attach would\n * be rejected by the relay gate. Reading it at call time makes the loaded\n * secret visible.\n *\n * When omitted, `createDebugServer` falls back to the captured {@link totpSecret}\n * (preserving all existing test behavior).\n *\n * SECRET-HANDLING: same as {@link totpSecret} — the returned value MUST NOT be\n * logged or included in any output other than the `at=` param inside `attachUrl`.\n *\n * Production: passed as `() => process.env.AIT_DEBUG_TOTP_SECRET` by the three\n * run functions.\n */\n getTotpSecret?: () => string | undefined;\n /**\n * `start_attach` 핸들러가 attach URL 컴포넌트를 확정한 직후 호출되는 콜백.\n * run 함수에서 `lastAttachParts` 갱신 + `qrHttpServer.notifyStateChange()` 트리거에 사용.\n * 테스트에서는 주입하지 않아도 되고, 미주입 시 no-op.\n *\n * 완성된 URL 문자열이 아니라 컴포넌트를 전달하는 이유: `getDashboardState`가\n * 호출될 때마다 최신 TOTP 코드를 freshly mint해 QR을 갱신하기 위함이다.\n * 정적 URL에 구워진 코드는 ~3분 후 만료(RELAY_VERIFY_SKEW_STEPS=6 기준) → relay 401 reason:'auth' (Defect 1).\n * rebuildAttachUrl()이 매 호출 시 generateTotp(secret)를 새로 계산한다.\n *\n * SECRET-HANDLING: 컴포넌트 안의 tunnel/scheme host와 wssUrl은 NEVER 로그 출력.\n * TOTP 코드는 rebuildAttachUrl() 내부에서만 mint되며 attachUrl의 at= param 안에만 노출.\n */\n onAttachUrlBuilt?: (parts: AttachUrlParts) => void;\n /**\n * Returns the cloudflared child PID of the currently active tunnel.\n * When provided, `get_debug_status` passes it to `getDiagnostics` as the\n * live in-memory source for FIX 2 (issue #571) — the PID is also picked up\n * from the lock file as a fallback, but the in-memory value is preferred as\n * it stays current across reissues.\n *\n * Production: injected by the run functions via a captured `activeTunnelChildPid`\n * variable that is updated whenever `onTunnelChildPid` fires (including reissues).\n * Tests inject a controlled value. Omitting it (old tests) falls back to the\n * lock-file path in `getDiagnostics`.\n */\n getTunnelChildPid?: () => number | null | undefined;\n /**\n * Lock-file reader — injected here so tests can control the lock data without\n * touching the filesystem. Defaults to `readServerLock` (the real file).\n *\n * This also enables handler-level tests for FIX 2 (issue #572 review) that\n * need to simulate a stale lock with a dead tunnelChildPid.\n */\n readLock?: () => import('./server-lock.js').LockData | null;\n /**\n * Maximum age (ms) of a page's `lastSeenAt` before it is treated as a\n * ghost and excluded from `wait_for_attach` short-circuit logic (issue #610).\n *\n * Default: {@link RELAY_SANDBOX_STALE_PAGE_MS} (5 minutes).\n * Injectable for tests so they can use a small value without fake timers.\n */\n stalePageThresholdMs?: number;\n /**\n * Monotonic clock for stale-page checks (issue #610). Defaults to\n * `Date.now`. Injectable for tests so they can freeze time without fake\n * timers (which conflict with MCP SDK's own timeouts).\n */\n nowMs?: () => number;\n}\n\n/**\n * Single-attach guard for `run_tests` (#646). Two concurrent runs injecting\n * into the same single-attach page would interleave `Runtime.evaluate` and\n * corrupt each other's `globalThis.__testBundle`. The model is \"reject the\n * second\", not \"queue\" — a module-level flag is process-wide, which matches the\n * single physical attached page (only one target is live at a time). The\n * entry-time `conn` snapshot ensures a run finishes on the connection it started\n * on even if `router.active` flips mid-run.\n */\nlet runTestsInFlight = false;\n\n// waitForAttachWithEvents moved to `attach-orchestrator.ts` (issue #684 PR1) —\n// it is the orchestrator's wait primitive (used by renderAndMaybeWait's\n// segmented wait). The `start_attach` handler no longer references it directly.\n\n/**\n * Builds the debug-mode MCP server around an injected CDP connection + AIT\n * source + tunnel status getter. Pure wiring — does not start a relay or\n * tunnel, which is what makes the tool surface unit-testable.\n *\n * `tools/list` is two-tiered (issue #208):\n * - bootstrap (always): `start_attach`, `list_pages`\n * - attach-dependent (after `connection.listTargets().length > 0`): all others\n *\n * `CallTool` is NOT tiered — hidden tools still execute (attach errors surface\n * naturally via `enableDomains`). The tier only controls visibility.\n */\nexport function createDebugServer(deps: DebugServerDeps): Server {\n const {\n connection,\n router: routerDep,\n aitSource,\n getTunnelStatus,\n waitForAttachTimeoutMs = 60_000,\n qrHttpServer,\n getEnvironment: getEnvDep,\n getEnvironmentReason: getEnvReasonDep,\n diagnosticsCollector: collectorDep,\n totpSecret,\n onAttachUrlBuilt,\n getTunnelChildPid,\n readLock: readLockDep,\n stalePageThresholdMs = RELAY_SANDBOX_STALE_PAGE_MS,\n nowMs = () => Date.now(),\n } = deps;\n\n // Late-bound TOTP secret accessor (issue #396): production injects\n // `getTotpSecret` so the secret is read from env at `start_attach` call\n // time (after switchMode's project-local .ait_relay load). When absent we fall\n // back to the captured `totpSecret` — preserving existing test behavior.\n // SECRET-HANDLING: the returned value is used only for the at= code, never logged.\n const getTotpSecret = deps.getTotpSecret ?? (() => totpSecret);\n\n // Lock-file reader — defaults to the real file reader; injected by tests to\n // control lock data without touching the filesystem. Also used by the\n // get_debug_status handler to forward lock data into getDiagnostics for the\n // FIX 2 lock-file fallback (issue #572 review).\n const readLockFn = readLockDep ?? readServerLock;\n\n // Dual-connection router (issue #348). Production passes a real router that\n // holds both the local + relay connections and flips `active` on\n // `start_debug`. Tests (and any single-connection caller) omit it — we\n // synthesize a trivial router pinned to `deps.connection` whose `switchMode`\n // reports that dynamic switching is unavailable. Either way the handlers read\n // the live connection through `router.active`, so per-call snapshots are\n // uniform.\n const router: ConnectionRouter = routerDep ?? makeSingleConnectionRouter(connection);\n\n // Env SSoT (issue #348, #665) — derived, not detected: `mock` vs `relay-*` is\n // free from the ACTIVE connection's `kind`; `relay-dev` vs `relay-mobile` is\n // `relayOrigin`. No URL sniffing, no precedence chain. `liveIntent` removed\n // (#665). Tests inject `getEnvironment`/`getEnvironmentReason` to pin a precise env.\n const resolveEnvironment: () => McpEnvironment =\n getEnvDep ?? (() => deriveEnvironment(router.active.kind, router.activeRelayOrigin));\n const resolveEnvironmentReason: () => string =\n getEnvReasonDep ??\n (() => `derived:kind=${router.active.kind},relayOrigin=${router.activeRelayOrigin ?? 'none'}`);\n\n // Diagnostics collector — production uses an `InMemoryDiagnosticsCollector`;\n // tests may inject a no-op or fake. A no-op is created lazily when none\n // is supplied so existing tests that don't inject one continue to work.\n const collector: DiagnosticsCollector = collectorDep ?? new InMemoryDiagnosticsCollector();\n\n // ──────────────────────────────────────────────────────────────────────────\n // start_attach orchestration (issue #626 → extracted #684 PR1).\n //\n // The attach orchestration (mint URL / validate env / render QR / open browser\n // / segmented wait with in-call TOTP re-mint) moved to `attach-orchestrator.ts`\n // at module level. Here we assemble `attachDeps` from this server's closure\n // variables — the six dependencies the extracted functions used to read off\n // this closure — and the `start_attach` handler calls `prepareAttachCore` /\n // `renderAndMaybeWaitCore` with it. Behavior is identical (pure extraction).\n //\n // SECRET-HANDLING: `getTotpSecret` is late-bound (read at call time, #396); its\n // value rides inside the attach URL's `at=` param only — never logged.\n // ──────────────────────────────────────────────────────────────────────────\n const attachDeps: AttachDeps = {\n getTunnelStatus,\n getTotpSecret,\n qrHttpServer,\n onAttachUrlBuilt,\n stalePageThresholdMs,\n nowMs,\n };\n\n const server = new Server(\n { name: 'ait-debug', version: __VERSION__ },\n // listChanged: true — the server emits notifications/tools/list_changed when\n // a page attaches (0→N target transition), promoted attach-dependent tools.\n { capabilities: { tools: { listChanged: true } } },\n );\n\n server.setRequestHandler(ListToolsRequestSchema, () => {\n // Per-request snapshot of the active connection (issue #348). `kind` is\n // authoritative even before any target attaches, so bootstrap visibility\n // (e.g. Tier B `start_attach`) is correct from the first `tools/list`.\n const conn = router.active;\n const env = resolveEnvironment();\n const attached = conn.listTargets().length > 0;\n // Tier A/B filter first (env), then bootstrap tier (attach state).\n const envFiltered = filterToolsByEnvironment(DEBUG_TOOL_DEFINITIONS, env);\n const tools = attached\n ? envFiltered.map((tool) => ({ ...tool }))\n : envFiltered\n .filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name))\n .map((tool) => ({ ...tool }));\n return { tools };\n });\n\n server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const name = request.params.name;\n if (!isDebugToolName(name)) {\n return {\n content: [{ type: 'text', text: `Unknown tool: ${name}` }],\n isError: true,\n };\n }\n\n // PER-CALL SNAPSHOT (issue #348). Capture the active connection exactly\n // once at handler entry and use ONLY `conn` for the rest of this call.\n // `start_debug` may flip `router.active` mid-flight (and other concurrent\n // requests too); re-reading `router.active` after an `await` would race the\n // swap. This is the hard-constraint that keeps a switch from corrupting an\n // in-flight tool call.\n const conn = router.active;\n\n // start_debug — single entry to switch families (local ↔ relay) without a\n // Claude Code restart or MCP re-handshake. Always callable (Tier C /\n // bootstrap), so it is handled before the env-mismatch guard below.\n if (name === 'start_debug') {\n const rawMode = request.params.arguments?.mode;\n const mode = normalizeStartDebugMode(rawMode);\n if (mode === null) {\n return mcpError(\n 'start_debug: mode가 올바르지 않습니다. ' +\n \"'local-browser' | 'relay-sandbox' | 'relay-staging' 중 하나를 전달하세요. \" +\n '(relay-live / env 4는 #665에서 제거됐습니다.)',\n );\n }\n // Per-session project root (issue #396): the daemon reads the relay TOTP\n // secret read-only from <projectRoot>/.ait_relay when switching to a relay\n // family. Optional — omitted for local, or when the operator exported the\n // secret. SECRET-HANDLING: projectRoot is a path, never the secret value.\n const rawProjectRoot = request.params.arguments?.projectRoot;\n const projectRoot = typeof rawProjectRoot === 'string' ? rawProjectRoot : undefined;\n try {\n const report = await router.switchMode(mode, projectRoot);\n return jsonResult(report);\n } catch (err) {\n return errorResult(err, name);\n }\n }\n\n // start_attach — single entry to attach a real device (issue #626). Folds\n // the old attach-URL + start_debug two-step into one call: optional\n // mode switch → QR synthesis → attach wait with in-call TOTP re-mint. Handled\n // before the env-mismatch guard (like start_debug) because its `mode` arg can\n // switch FROM mock INTO a relay family.\n if (name === 'start_attach') {\n const args = request.params.arguments;\n // Mode prologue (optional). When `mode` is given and differs from the\n // active env, switch first. local-browser is rejected below (relay-only).\n let attachConn = conn;\n const rawMode = args?.mode;\n if (rawMode !== undefined) {\n const mode = normalizeStartDebugMode(rawMode);\n // Reject an invalid OR local-browser mode BEFORE any switch — local has\n // no QR attach, so switching into it then failing would needlessly churn\n // the active connection + emit a spurious tools/list_changed.\n if (mode === null || mode === 'local-browser') {\n return mcpError(\n 'start_attach: mode가 올바르지 않습니다. ' +\n \"'relay-sandbox' | 'relay-staging' 중 하나를 전달하세요 \" +\n '(local-browser는 QR attach가 없어 start_attach에서 지원하지 않습니다).',\n );\n }\n const targetEnv = envForMode(mode);\n // Skip the switch when already in the target env (no tools/list churn).\n if (resolveEnvironment() !== targetEnv) {\n const rawProjectRoot = args?.projectRoot;\n const projectRoot = typeof rawProjectRoot === 'string' ? rawProjectRoot : undefined;\n try {\n await router.switchMode(mode, projectRoot);\n } catch (err) {\n return errorResult(err, name);\n }\n // PER-CALL SNAPSHOT re-capture (issue #348 — CRITICAL). switchMode\n // flipped router.active; re-read the connection now so the rest of\n // this call uses the post-switch family, not the stale pre-switch one.\n attachConn = router.active;\n }\n }\n\n // Resolve env AFTER the (possible) switch.\n const attachEnv = resolveEnvironment();\n if (!isRelayEnv(attachEnv)) {\n return mcpError(\n 'start_attach: relay 전용 tool입니다 (env 2 / relay-sandbox 또는 env 3 / relay-staging). ' +\n \"현재 환경은 'local-browser'(mock)입니다 — mode 인자로 'relay-sandbox' 또는 'relay-staging'을 \" +\n '전달하거나, 먼저 relay 모드로 전환하세요.',\n );\n }\n\n // wait defaults to true (#626 — behavior change from the old attach tool's\n // opt-in wait_for_attach). callTimeoutMs clamps wait_timeout_seconds to\n // 1–600 s; invalid values fall back to the default.\n const waitForAttach = true;\n const rawWaitTimeout = args?.wait_timeout_seconds;\n const callTimeoutMs = (() => {\n if (typeof rawWaitTimeout !== 'number' || !Number.isFinite(rawWaitTimeout)) {\n return waitForAttachTimeoutMs;\n }\n if (rawWaitTimeout <= 0) return waitForAttachTimeoutMs;\n const clamped = Math.max(1, Math.min(600, rawWaitTimeout));\n return Math.round(clamped) * 1000;\n })();\n\n try {\n const prep = await prepareAttachCore(attachDeps, attachEnv, args, attachConn);\n if (!prep.ok) return prep.error;\n const attachResult = await renderAndMaybeWaitCore(\n attachDeps,\n prep,\n waitForAttach,\n callTimeoutMs,\n attachConn,\n );\n if (!attachResult.isError) {\n // Debugger attached — show the on-phone \"Debugger Connected\" indicator.\n await injectDebugIndicator(attachConn);\n }\n return attachResult;\n } catch (err) {\n return errorResult(err, name);\n }\n }\n\n // PER-CALL SNAPSHOT of the derived environment (issue #348 / #354 regression\n // fix). Capture `env` + `envReason` exactly once, right after the start_debug\n // branch (so this call sees the post-switch env when it *is* a switch) and\n // before the first `await`. Every site below reuses these locals instead of\n // re-calling `resolveEnvironment()`/`resolveEnvironmentReason()` — those\n // closures re-read `router.active.kind` + `relayOrigin` live, so a\n // concurrent `start_debug` swap mid-await would otherwise corrupt the env\n // stamped into this call's envelope / provenance label.\n const env = resolveEnvironment();\n const envReason = resolveEnvironmentReason();\n // Tier A/B env-mismatch guard (RFC #277). Tier C tools pass through.\n // We return a tool-result error (not an MCP protocol error) so the client\n // sees a structured isError + reason text rather than a thrown exception —\n // the MCP SDK still surfaces this as an error to the agent, but with the\n // explanatory `data.reason` payload preserved as text.\n if (!isToolAvailableIn(name, env)) {\n const requiredEnv = getToolAvailability(name) ?? 'unknown';\n // Log structured (no secrets — only stable env strings + tool name).\n logWarn('tool.error', {\n tool: name,\n errorKind: 'tier-filter',\n requiredEnv,\n currentEnv: env,\n envReason,\n });\n return tierRejectionError(name, requiredEnv, env, envReason);\n }\n\n // AIT.* tools are served by the AIT source. In production it rides the same\n // Chii websocket as CDP, so the connection must be attached first; the AIT\n // source's sendCommand rejects with a clear message if no page is attached.\n if (isAitToolName(name)) {\n try {\n await conn.enableDomains();\n switch (name) {\n case 'AIT.getSdkCallHistory':\n return jsonResult(await getSdkCallHistory(aitSource));\n case 'AIT.getMockState':\n return jsonResult(await getMockState(aitSource));\n case 'AIT.getOperationalEnvironment':\n return jsonResult(await getOperationalEnvironment(aitSource));\n default:\n return unknownTool(name);\n }\n } catch (err) {\n return errorResult(err, name);\n }\n }\n\n // get_debug_status is a bootstrap tool — it works before any page attaches\n // and must not require enableDomains. It aggregates all server state into a\n // single response so the agent can diagnose session problems in one call.\n if (name === 'get_debug_status') {\n try {\n const rawLimit = request.params.arguments?.recent_errors_limit;\n const recentErrorsLimit = typeof rawLimit === 'number' && rawLimit > 0 ? rawLimit : 10;\n const result = await getDiagnostics({\n tunnel: getTunnelStatus(),\n connection: conn,\n env,\n envReason,\n collector,\n readLock: readLockFn,\n recentErrorsLimit,\n tunnelChildPid: getTunnelChildPid?.() ?? undefined,\n });\n const attached = conn.listTargets().length > 0;\n return envelopeResult(result, name, env, attached);\n } catch (err) {\n return errorResult(err, name);\n }\n }\n\n try {\n // Ensure CDP domains are enabled before reading. No-op once attached;\n // throws a clear message while no page is attached yet.\n await conn.enableDomains();\n } catch (err) {\n if (name === 'list_pages') {\n // list_pages is still useful pre-attach: report tunnel + empty pages.\n // Refresh from relay first so evicted-then-reattached targets are not\n // served as stale empty (#281 — stale cache diagnosis).\n try {\n await conn.refreshTargets?.();\n } catch {\n // Ignore refresh errors — still return cached state.\n }\n const pagesData = listPages(conn, getTunnelStatus());\n const attached = conn.listTargets().length > 0;\n return envelopeResult(pagesData, name, env, attached);\n }\n // 4상태 분류: page 미attach vs crash vs relay disconnect\n return classifyEnableDomainError(err, name);\n }\n\n try {\n switch (name) {\n case 'list_console_messages':\n return jsonResult(listConsoleMessages(conn));\n case 'list_exceptions': {\n const rawLimit = request.params.arguments?.limit;\n const limit = typeof rawLimit === 'number' && rawLimit > 0 ? rawLimit : 50;\n return jsonResult({ exceptions: listExceptions(conn, limit) });\n }\n case 'list_network_requests':\n return jsonResult(listNetworkRequests(conn));\n case 'list_pages': {\n // Refresh from relay so evict→reattach transitions are not served stale.\n try {\n await conn.refreshTargets?.();\n } catch {\n // Ignore refresh errors — still return cached state.\n }\n const listPagesData = listPages(conn, getTunnelStatus());\n const listPagesAttached = conn.listTargets().length > 0;\n return envelopeResult(listPagesData, name, env, listPagesAttached);\n }\n case 'get_dom_document':\n return jsonResult(await getDomDocument(conn));\n case 'take_snapshot':\n return jsonResult(await takeSnapshot(conn));\n case 'take_screenshot': {\n const shot = await takeScreenshot(conn);\n return {\n content: [{ type: 'image' as const, data: shot.data, mimeType: shot.mimeType }],\n };\n }\n case 'measure_safe_area': {\n // Pass the SNAPSHOT env to attach `source: 'mock' | 'relay'` to the\n // result (Tier C parity per RFC #277 — the same Runtime.evaluate probe\n // runs in both envs; only the provenance label differs). The label must\n // match the `conn` the probe actually ran on, so it reads the snapshot\n // `env` (entry-time, same as `conn`) — not a freshly re-derived env that\n // a concurrent swap could have moved.\n const safeAreaData = await measureSafeArea(conn, env);\n const safeAreaAttached = conn.listTargets().length > 0;\n return envelopeResult(safeAreaData, name, env, safeAreaAttached);\n }\n case 'evaluate': {\n const expression = request.params.arguments?.expression;\n if (typeof expression !== 'string' || expression === '') {\n return mcpError(\n 'evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.',\n );\n }\n // Host allowlist kill-switch (#665). Replaces the old LIVE guard.\n // connectionHostsAllowed() checks each attached page's URL hostname\n // against the positive allowlist (localhost/trycloudflare/private-apps).\n // SECRET-HANDLING: hostname never logged — only the boolean.\n if (!connectionHostsAllowed(conn)) {\n return mcpError(\n 'evaluate: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). ' +\n '허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.',\n );\n }\n // SECRET-HANDLING: do not log expression or result value.\n return jsonResult(await evaluate(conn, expression));\n }\n case 'call_sdk': {\n const sdkName = request.params.arguments?.name;\n if (typeof sdkName !== 'string' || sdkName === '') {\n return mcpError(\n 'call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.',\n );\n }\n const rawArgs = request.params.arguments?.args;\n const sdkArgs: unknown[] = Array.isArray(rawArgs) ? rawArgs : [];\n // Host allowlist kill-switch (#665). Replaces the old LIVE guard.\n // SECRET-HANDLING: hostname never logged — only the boolean.\n if (!connectionHostsAllowed(conn)) {\n return mcpError(\n 'call_sdk: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). ' +\n '허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.',\n );\n }\n // SECRET-HANDLING: do not log name, args, or result value.\n const sdkResult = await callSdk(conn, sdkName, sdkArgs);\n // 상태 4: SDK 부재 — ok:false + 'sdk-absent:' 패턴은 isError로 승격\n if (\n !sdkResult.ok &&\n typeof sdkResult.error === 'string' &&\n sdkResult.error.startsWith('sdk-absent:')\n ) {\n // issue #360: local(`--target=local`) 세션은 dog-food 재배포가 아니라\n // dev 서버/unplugin alias 확인이 맞는 안내다 — connection.kind로 분기.\n return sdkAbsentError('call_sdk', conn.kind === 'local');\n }\n const callSdkAttached = conn.listTargets().length > 0;\n return envelopeResult(sdkResult, name, env, callSdkAttached);\n }\n case 'run_tests': {\n const rawFiles = request.params.arguments?.files;\n if (!Array.isArray(rawFiles) || rawFiles.length === 0) {\n return mcpError(\n 'run_tests: files 인자가 비어 있습니다. 실행할 테스트 파일 glob을 배열로 전달하세요.',\n );\n }\n const patterns = rawFiles.filter((p): p is string => typeof p === 'string' && p !== '');\n if (patterns.length === 0) {\n return mcpError('run_tests: files 인자에 유효한 문자열 glob이 없습니다.');\n }\n const rawRoot = request.params.arguments?.projectRoot;\n const projectRoot = typeof rawRoot === 'string' ? rawRoot : process.cwd();\n const rawTimeout = request.params.arguments?.timeout_ms;\n const timeoutMs =\n typeof rawTimeout === 'number' && rawTimeout >= 1000 && rawTimeout <= 600_000\n ? rawTimeout\n : undefined; // undefined → relay-worker default (30 000)\n\n // ── page-0 판정 with freshness guard (설계 §3.1 + §6 위험1) ───────\n // Simple `conn.listTargets().length > 0` is NOT sufficient: relay-sandbox\n // may have a ghost page whose `lastSeenAt` froze when the old relay died\n // (issue #610). Use `isSandboxPageFresh` — the same guard `prepareAttach`\n // uses — so the auto-attach branch fires for ghost pages too.\n const runTestPages = conn.listTargets();\n const connAsAny = conn as unknown as {\n getTargetLastSeenAt?: (id: string) => number | null;\n };\n const runTestGetLastSeenAt =\n typeof connAsAny.getTargetLastSeenAt === 'function'\n ? (id: string) => (connAsAny.getTargetLastSeenAt as (id: string) => number | null)(id)\n : null;\n const runTestNow = nowMs();\n const hasLivePage = isSandboxPageFresh(\n runTestPages,\n runTestGetLastSeenAt,\n runTestNow,\n stalePageThresholdMs,\n );\n\n // ── auto-attach分岐: no live page + relay env (설계 §3.1 4b) ────────\n // When there is no live attached page AND we are in a relay environment,\n // run_tests triggers QR attach on behalf of the caller (QR dashboard +\n // phone wait), then optionally injects a cell, then runs.\n // This path is ONLY taken when hasLivePage is false AND env is relay —\n // meaning the existing attached-page flow (4a) is completely unchanged.\n if (!hasLivePage && isRelayEnv(env)) {\n const autoAttachArgs = request.params.arguments as Record<string, unknown> | undefined;\n const prep = await prepareAttachCore(attachDeps, env, autoAttachArgs, conn);\n if (!prep.ok) return prep.error;\n\n // Wait for the phone to attach (wait=true, use the server's default\n // attach timeout — same as start_attach uses).\n const autoAttachResult = await renderAndMaybeWaitCore(\n attachDeps,\n prep,\n true,\n waitForAttachTimeoutMs,\n conn,\n );\n // If attach timed out or failed, surface the error — no tests to run.\n if (autoAttachResult.isError) return autoAttachResult;\n\n // ── cell injection (설계 §4.2 — attach 직후, 첫 bundle inject 전) ─\n // The caller may pass a `cell` argument — an arbitrary object to merge\n // into globalThis BEFORE the first test bundle runs.\n // devtools does NOT know the sdk-example shape of `__AIT_CELL__` —\n // the caller wraps it: { \"__AIT_CELL__\": { sdkLine, platform } }.\n // SECRET-HANDLING: cell values are informational (axes, not secrets);\n // we log key names only.\n const rawCell = autoAttachArgs?.cell;\n if (rawCell !== null && typeof rawCell === 'object' && !Array.isArray(rawCell)) {\n await injectGlobals(conn, rawCell as Record<string, unknown>);\n }\n\n // ── Host allowlist check (run only on allowed hosts) ────────────────\n if (!connectionHostsAllowed(conn)) {\n return mcpError(\n 'run_tests: 연결된 페이지가 debug 허용 호스트가 아닙니다 (#665). ' +\n '허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.',\n );\n }\n\n // ── single-attach guard ─────────────────────────────────────────────\n if (runTestsInFlight) {\n return mcpError(\n 'run_tests: 이미 다른 테스트 실행이 진행 중입니다 ' +\n '(single-attach 모델: 페이지는 한 번에 하나의 실행만 처리). 완료 후 다시 시도하세요.',\n );\n }\n runTestsInFlight = true;\n try {\n const files = await discoverTestFiles(patterns, projectRoot);\n if (files.length === 0) {\n return mcpError(\n `run_tests: 매칭된 테스트 파일이 없습니다 (patterns: ${patterns.join(', ')}).`,\n );\n }\n // Verify the page is still alive after the auto-attach wait.\n if (conn.listTargets().length === 0) {\n return pageMissingError('run_tests');\n }\n logInfo('run_tests.start', { fileCount: files.length, autoAttach: true });\n const report = await runWithConnection(conn, files, {\n timeoutMs,\n // #696: harvest __AIT_CAPTURE__ lines on the MCP path. The\n // envelope surfaces only a per-category count (toRunTestsResult);\n // line bodies stay off the run_tests result.\n collectCaptures: true,\n });\n logInfo('run_tests.done', {\n passed: report.totals.passed,\n failed: report.totals.failed,\n skipped: report.totals.skipped,\n });\n const runAttached = conn.listTargets().length > 0;\n return envelopeResult(toRunTestsResult(report), name, env, runAttached);\n } finally {\n runTestsInFlight = false;\n }\n }\n\n // ── 4c: no live page + mock/local env → original guidance error ──────\n // (mock has no relay; auto-attach is not applicable)\n if (!hasLivePage) {\n return mcpError(\n 'run_tests: 연결된 페이지가 없습니다. mock(로컬) 환경에서는 auto-attach가 지원되지 않습니다. ' +\n 'list_pages로 연결 상태를 확인하고 페이지가 붙어 있는지 확인하세요.',\n );\n }\n\n // ── 4a: already attached → EXISTING PATH, behavior unchanged ─────────\n // Host allowlist kill-switch (#665). Replaces the old LIVE guard.\n // Test injection runs arbitrary code via Runtime.evaluate — must be on\n // an allowed debug host. SECRET-HANDLING: hostname never logged.\n if (!connectionHostsAllowed(conn)) {\n return mcpError(\n 'run_tests: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). ' +\n '허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.',\n );\n }\n\n // Single-attach guard — reject a concurrent run (no queue). The flag\n // MUST be set SYNCHRONOUSLY (no await between the check and the set),\n // or two concurrent calls both read `false` before either suspends and\n // both proceed — a TOCTOU race in JS's cooperative async model. So we\n // claim the lock here and do discovery/fail-fast inside the try, with\n // `finally` always releasing it (covers the no-match/page-missing\n // early returns too).\n if (runTestsInFlight) {\n return mcpError(\n 'run_tests: 이미 다른 테스트 실행이 진행 중입니다 ' +\n '(single-attach 모델: 페이지는 한 번에 하나의 실행만 처리). 완료 후 다시 시도하세요.',\n );\n }\n runTestsInFlight = true;\n try {\n const files = await discoverTestFiles(patterns, projectRoot);\n if (files.length === 0) {\n return mcpError(\n `run_tests: 매칭된 테스트 파일이 없습니다 (patterns: ${patterns.join(', ')}).`,\n );\n }\n\n // Fail-fast: if the page was evicted between the enableDomains gate\n // and here, surface the re-attach hint instead of bundling N files.\n if (conn.listTargets().length === 0) {\n return pageMissingError('run_tests');\n }\n\n // Progress is the per-file results array (MCP is request/response —\n // no mid-call streaming). Log only counts, never file content/paths\n // as secrets / relay URLs. SECRET-HANDLING: do not log bundle code,\n // expression, or result values.\n logInfo('run_tests.start', { fileCount: files.length });\n const report = await runWithConnection(conn, files, {\n timeoutMs,\n // #696: symmetric with the auto-attach path above — run_tests must\n // harvest __AIT_CAPTURE__ lines regardless of how the page was\n // attached. toRunTestsResult exposes per-category counts only.\n collectCaptures: true,\n });\n logInfo('run_tests.done', {\n passed: report.totals.passed,\n failed: report.totals.failed,\n skipped: report.totals.skipped,\n });\n const runAttached = conn.listTargets().length > 0;\n return envelopeResult(toRunTestsResult(report), name, env, runAttached);\n } finally {\n runTestsInFlight = false;\n }\n }\n default:\n return unknownTool(name);\n }\n } catch (err) {\n // issue #360: sdk-absent 분류가 local 세션이면 dev-bridge 안내로 분기하도록\n // connection 종류를 넘긴다. 다른 에러 분류에는 영향 없음(isLocal 미사용).\n return errorResult(err, name, conn.kind === 'local');\n }\n });\n\n return server;\n}\n\n/**\n * Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or\n * `null` when the value is not one of the three accepted modes:\n * 'local-browser' | 'relay-sandbox' | 'relay-staging'\n *\n * Hard rename (issue #398): the older `local`/`mobile`/`staging`/`live` names\n * and their aliases are no longer accepted — pre-1.0, no back-compat.\n * `relay-live` (env 4) removed in #665.\n */\nexport function normalizeStartDebugMode(raw: unknown): StartDebugMode | null {\n if (raw === 'local-browser' || raw === 'relay-sandbox' || raw === 'relay-staging') {\n return raw;\n }\n return null;\n}\n\n/**\n * Positive-allowlist kill-switch for side-effect MCP tools (#665).\n *\n * Returns `true` when the connection's attached targets are all on allowed\n * debug hosts (localhost / trycloudflare / private-apps). Returns `false` when\n * any target's page URL is on a non-allowed host (e.g. `apps.tossmini.com`).\n *\n * For local connections this always returns `true` — the local Chromium is\n * always on localhost. For relay connections without any pages it returns\n * `true` (no pages = nothing to block; the caller's page-missing guard fires\n * first).\n *\n * SECRET-HANDLING: hostnames are NEVER logged here — only the boolean result\n * is returned to the caller.\n */\nexport function connectionHostsAllowed(conn: CdpConnection): boolean {\n if (conn.kind === 'local') return true;\n const pages = conn.listTargets();\n if (pages.length === 0) return true;\n return pages.every((p) => {\n try {\n const url = new URL(p.url ?? '');\n return isDebugAllowedHost(url.hostname);\n } catch {\n // Unparseable URL — fail-closed (#665 positive-allowlist).\n // A relay target with an unparseable URL cannot have a known-good host;\n // blocking it preserves the positive-allowlist invariant.\n return false;\n }\n });\n}\n\n/**\n * Builds a trivial `ConnectionRouter` pinned to a single connection (issue\n * #348). Used by `createDebugServer` when no real dual router is injected —\n * every existing single-connection test and the `local`-only / `relay`-only\n * boot path. `switchMode` here cannot lazily boot another family, so it only\n * honors a request that matches the connection's own kind; any cross-family\n * request is rejected with a clear \"dynamic switch unavailable in this session\"\n * error. `confirm` parameter and `relay-live` gate removed (#665).\n */\nexport function makeSingleConnectionRouter(connection: CdpConnection): ConnectionRouter {\n return {\n get active() {\n return connection;\n },\n // A single-connection router has no family concept, so it carries no relay\n // origin discriminator (issue #378). Env derives as `relay-dev` for a relay\n // connection here — `relay-sandbox` (external-PWA origin) is rejected below\n // since this router cannot boot the external relay family.\n activeRelayOrigin: undefined,\n // `_projectRoot` (issue #396) is accepted for interface conformance but\n // unused here: this router never lazily boots a relay family — its single\n // connection (and thus any relay verifyAuth) was already built at startup,\n // so a per-session project-local secret cannot retroactively rewire it. The\n // dual router below performs the read-only load before a lazy relay boot.\n switchMode(mode: StartDebugMode, _projectRoot?: string): Promise<ModeSwitchReport> {\n // `relay-sandbox` (env 2) needs a distinct external-PWA relay family this\n // single-connection router cannot synthesize. Reject the same way a\n // cross-family switch is rejected (issue #378).\n if (mode === 'relay-sandbox') {\n return Promise.reject(\n new Error(\n 'start_debug: 이 세션은 단일 연결만 보유합니다 — ' +\n \"'relay-sandbox'(환경 2 PWA, 외부 relay)로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). \" +\n 'MCP 서버를 relay-sandbox 모드로 재시작하세요.',\n ),\n );\n }\n const wantRelay = isRelayMode(mode);\n const haveRelay = connection.kind === 'relay';\n if (wantRelay !== haveRelay) {\n return Promise.reject(\n new Error(\n `start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — ` +\n `'${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). ` +\n 'MCP 서버를 원하는 모드로 재시작하세요.',\n ),\n );\n }\n const environment = deriveEnvironment(connection.kind);\n return Promise.resolve({\n mode,\n environment,\n kind: connection.kind,\n nextStep:\n connection.kind === 'relay'\n ? 'start_attach로 attach QR 생성 + 폰 attach까지 한 번에 진행하세요.'\n : 'list_pages로 로컬 페이지 attach를 확인하세요.',\n });\n },\n };\n}\n\n/**\n * Re-builds an attach URL from stored components with a FRESHLY-minted TOTP code,\n * so the dashboard/`/attach` QR is never an expired bake-in (Defect 1).\n * SECRET-HANDLING: reads AIT_DEBUG_TOTP_SECRET at call time (mirrors tunnel.ts\n * getDashboardState). The minted code rides inside attachUrl's at= param only —\n * never logged. generateTotp() relies on its Date.now() default.\n */\nfunction rebuildAttachUrl(parts: AttachUrlParts): string {\n const secret = process.env.AIT_DEBUG_TOTP_SECRET;\n const code = secret ? generateTotp(secret) : undefined;\n return parts.kind === 'launcher'\n ? buildLauncherAttachUrl(parts.tunnelHttpUrl, parts.wssUrl, code, {\n name: parts.appName,\n ...(parts.selfdebug ? { selfdebug: true } : {}),\n })\n : buildDeepLinkAttachUrl(parts.schemeUrl, parts.wssUrl, code);\n}\n\nfunction jsonResult(value: unknown) {\n return { content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }] };\n}\n\n/**\n * Wraps `value` in a `ToolEnvelope` (when compat mode is off) and returns it\n * as a text content block. When `AIT_MCP_COMPAT=chrome-devtools` is set the\n * envelope is skipped and the raw value is returned — identical to `jsonResult`.\n */\nfunction envelopeResult(value: unknown, tool: string, env: McpEnvironment, attached: boolean) {\n const wrapped = wrapEnvelope(value, { tool, env, attached });\n return { content: [{ type: 'text' as const, text: JSON.stringify(wrapped, null, 2) }] };\n}\n\n/**\n * Maps a {@link RelayRunReport} to a flat, agent-friendly object for the\n * `run_tests` tool result. SECRET-HANDLING: a RelayRunReport carries only\n * startedAt/duration/totals, per-file `{file, result}`, and capture lines —\n * file paths are surfaced (allowed), relay wss/TOTP URLs never appear in it.\n * No stripping needed; this only reshapes for readability.\n *\n * Captures (#696): the envelope surfaces a COUNT-LEVEL summary only\n * (per-category line counts) — never the line bodies. Capture bodies belong in\n * the on-disk artifact, not the `run_tests` log (keeps the tool result small and\n * avoids dumping large capture arrays into the agent's context).\n */\nfunction toRunTestsResult(report: RelayRunReport) {\n // Per-category capture counts — { clipboard: 3, storage: 1, ... }. Bodies are\n // deliberately omitted (artifact-only policy).\n const captureCounts: Record<string, number> = {};\n for (const { category } of report.captures) {\n captureCounts[category] = (captureCounts[category] ?? 0) + 1;\n }\n return {\n startedAt: report.startedAt,\n duration: report.duration,\n totals: report.totals,\n files: report.files.map((f) =>\n 'error' in f.result\n ? { file: f.file, error: f.result.error }\n : {\n file: f.file,\n // Per-file in-page run time (from the runtime's RunReport) helps\n // an agent triage which file is slow — the top-level `duration` is\n // the whole-run wall-clock (bundling + sequential injection), not\n // this per-file figure.\n duration: f.result.duration,\n passed: f.result.passed,\n failed: f.result.failed,\n skipped: f.result.skipped,\n tests: f.result.tests,\n },\n ),\n // Count-level capture summary only (per category). Empty object when no\n // capture lines were harvested.\n captures: captureCounts,\n };\n}\n\nfunction unknownTool(name: string) {\n return mcpError(`알 수 없는 tool: ${name}`);\n}\n\n/**\n * enableDomains()가 던진 에러를 4상태로 분류해 적절한 메시지를 반환한다.\n *\n * - \"No mini-app page attached\" → page 미attach (상태 2)\n * - crash/destroy/replaced 패턴 → page crash (상태 3)\n * - relay disconnect 패턴 → relay 연결 끊김\n * - 그 외 → 원본 메시지 + list_pages 안내\n */\nfunction classifyEnableDomainError(err: unknown, toolName: string) {\n const message = err instanceof Error ? err.message : String(err);\n\n // 상태 2: page 미attach\n if (message.includes('No mini-app page attached') || message.includes('페이지가 attach 안')) {\n return pageMissingError(toolName);\n }\n\n // 상태 3: page crash / target destroyed / replaced\n if (\n message.includes('replaced-by-new-attach') ||\n message.includes('targetCrashed') ||\n message.includes('targetDestroyed') ||\n message.includes('detachedFromTarget')\n ) {\n return pageCrashError(toolName);\n }\n\n // relay 연결 끊김\n if (\n message.includes('relay에 연결되어 있지 않습니다') ||\n message.includes('relay WebSocket') ||\n message.includes('Chii relay connection closed')\n ) {\n return relayDisconnectError(toolName);\n }\n\n // 그 외\n return classifyToolError(err, toolName);\n}\n\n/**\n * CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.\n * debug-server 내부 try/catch 블록에서 공통으로 사용한다.\n */\nfunction errorResult(err: unknown, name: string, isLocal = false) {\n return classifyToolError(err, name, isLocal);\n}\n\n/**\n * Starts a polling watcher that detects target-set changes on\n * `connection.listTargets()` and sends a `notifications/tools/list_changed`\n * notification on the given server.\n *\n * The watcher polls every `intervalMs` (default 1 000 ms). On each tick it\n * calls `connection.refreshTargets?.()` first (fix #705-B) so that silent\n * disconnects (no CDP event, phone backgrounded / tunnel quiet) are picked up\n * before the signature is read. If `refreshTargets` throws — e.g. a transient\n * relay error — the tick is skipped entirely to avoid a spurious detach signal.\n *\n * After the refresh, it fires `server.sendToolListChanged()` + `onAttach()`\n * whenever the sorted target-id signature changes AND the new target set is\n * non-empty. This covers:\n * - 0→N first attach\n * - 1→1 target replacement (same count, different id — e.g. rescan)\n * - N→M any change where the result is still non-empty\n *\n * Full detach (→ empty) fires `onDetach()` (fix #705-A) on the exact\n * non-empty→empty edge — i.e. only when the previous signature was non-empty.\n * This lets callers push an immediate \"disconnected\" SSE update to the\n * dashboard without waiting for the next periodic interval.\n *\n * The interval is **never cleared automatically** — it keeps running until\n * `stop()` is called during shutdown. This ensures that a target replacement\n * after the first attach is always detected.\n *\n * `onAttach` is called on every non-empty signature change (or immediately when\n * already attached). Use this to trigger side-effects such as pushing a fresh\n * SSE state to open dashboard tabs (issue #509). Both callbacks are optional;\n * omitting them preserves the previous behaviour exactly.\n *\n * SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.\n * Only an attach-detected stderr line is emitted (no target details).\n *\n * @returns `stop` — call this during shutdown to clear the interval.\n */\nexport function startAttachWatcher(\n connection: CdpConnection,\n server: Server,\n intervalMs = 1_000,\n onAttach?: () => void,\n onDetach?: () => void,\n): { stop(): void } {\n /** Sorted, comma-joined target-id string — '' means no targets attached. */\n function signature(): string {\n return connection\n .listTargets()\n .map((t) => t.id)\n .sort()\n .join(',');\n }\n\n let lastSignature = signature();\n // If already attached when the watcher starts, send once immediately.\n if (lastSignature !== '') {\n void server.sendToolListChanged();\n onAttach?.();\n }\n\n /** Compare current vs last signature and fire the appropriate callback. */\n function tick(): void {\n const current = signature();\n if (current !== lastSignature) {\n const wasNonEmpty = lastSignature !== '';\n lastSignature = current;\n if (current !== '') {\n // Non-empty signature change — new or replaced target(s).\n void server.sendToolListChanged();\n onAttach?.();\n } else if (wasNonEmpty) {\n // Fix #705-A: genuine non-empty→empty edge — fire detach callback so\n // the dashboard gets an immediate SSE push (\"disconnected\").\n onDetach?.();\n }\n // empty→empty at startup: neither callback fires.\n }\n }\n\n const handle = setInterval(() => {\n if (connection.refreshTargets) {\n // Fix #705-B: refresh the in-memory target cache from the relay before\n // reading the signature, so silent disconnects are detected even without\n // a CDP event. A transient relay error causes the tick to be skipped\n // entirely — we never treat a fetch failure as a detach.\n connection.refreshTargets().then(\n () => {\n tick();\n },\n (_err: unknown) => {\n // Relay unreachable this tick — skip; do not update lastSignature.\n },\n );\n } else {\n // No refreshTargets on this connection (local/test) — tick synchronously.\n tick();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n\nexport interface RunDebugServerOptions {\n /**\n * Local Chii relay port. Default 0 (OS-assigned ephemeral port).\n *\n * Passing 0 lets the OS choose a free port on each startup — this prevents\n * EADDRINUSE when a stale cloudflared orphan still holds a fixed port (the\n * root cause of -32000 MCP handshake failures). Pass an explicit port number\n * only when a fixed port is specifically required (backwards-compatible).\n */\n relayPort?: number;\n /**\n * When `true`, terminates the process holding the existing server lock and\n * takes over the session. Corresponds to `--force` / `--takeover` CLI flags.\n *\n * Default `false`.\n */\n force?: boolean;\n}\n\n// `buildRelayVerifyAuth` now lives in `./totp.js` (lightweight, node:crypto\n// only) so the unplugin's env-2 relay can wire the same TOTP upgrade gate\n// without pulling the heavy MCP server module graph. Re-exported here so\n// existing importers (and tests) keep resolving it from `debug-server.js`.\nexport { buildRelayVerifyAuth };\n\n/**\n * Factory that constructs a `ChiiCdpConnection` for the given relay base URL.\n *\n * Introduced as a named seam so PR-2 (dual-connection, #348) can defer\n * construction to first-activation time by moving or replacing this call. Since\n * #396 every family (relay included) is constructed lazily on its first\n * `start_debug`, so this is always called from the lazy boot path.\n *\n * The relay base URL is only available after `startChiiRelay()` resolves, so\n * the factory is called right after that point (same as before this refactor).\n */\nfunction createRelayConnection(relayBaseUrl: string): ChiiCdpConnection {\n // Pass the SECRET (not a code) so the connection mints a fresh TOTP per\n // (re)connect. Read from env directly: both callers run\n // assertRelayAuthConfigured() first, so when a TOTP-gated relay is up this is\n // a valid hex secret; when TOTP is disabled it is undefined and no `at=` is\n // appended (backward compatible). SECRET-HANDLING: forwarded, never logged.\n return new ChiiCdpConnection({\n relayBaseUrl,\n totpSecret: process.env.AIT_DEBUG_TOTP_SECRET,\n });\n}\n\n/**\n * AIT source that always forwards over the *currently active* connection\n * (issue #348). The single-connection `ChiiAitSource` binds one sender at\n * construction; in the dual-connection daemon the AIT.* domain must follow the\n * active connection across `start_debug` swaps, so this indirection reads\n * `getActive()` on every call.\n *\n * Both `ChiiCdpConnection` and `LocalCdpConnection` expose `sendCommand`, so\n * the active connection is a valid `AitCommandSender`.\n */\nclass RoutingAitSource extends ChiiAitSource {\n constructor(\n getActive: () => {\n sendCommand(method: string, params?: Record<string, unknown>): Promise<unknown>;\n },\n ) {\n super({\n sendCommand: (method, params) => getActive().sendCommand(method, params),\n });\n }\n}\n\n/**\n * A booted infra family the dual router can tear down at process exit.\n *\n * Direction-neutral (issue #356): any of the three families can be the first one\n * booted. Since #396 every family is lazy-booted on its first `start_debug`. The\n * relay family additionally exposes its live tunnel status; the local family\n * leaves it `undefined` (a local browser has no relay tunnel), so the\n * router/handlers read the relay tunnel status from whichever family is the\n * relay one.\n */\nexport interface BootedFamily {\n connection: CdpConnection;\n /** Synchronous best-effort teardown (closes the connection + any infra). */\n stop(): void;\n /**\n * Live tunnel status — only the relay family provides it (the URL changes per\n * tunnel reissue). `undefined` on the local family.\n */\n getTunnelStatus?: () => TunnelStatus;\n /**\n * Relay origin discriminator (issue #378) — set by the boot fn, NOT sniffed\n * from the URL. `'intoss-webview'` for the intoss-private relay\n * (`bootRelayFamily`), `'external-pwa'` for the env-2 external relay\n * (`bootExternalRelayFamily`). `undefined` for the local family (kind is\n * `'local'`, so the origin is irrelevant). Threaded into `deriveEnvironment`\n * so `relay-mobile` can be told apart from `relay-dev`.\n */\n relayOrigin?: RelayOrigin;\n /**\n * Local HTTP base URL of the Chii relay (e.g. `http://127.0.0.1:9100` for\n * the intoss relay, or the external cloudflare URL for env-2). Used by\n * {@link AutoDevtoolsOpener} to build the Chii self-hosted inspector URL\n * (`<relayHttpUrl>/front_end/chii_app.html`). `undefined` for the local-\n * browser family (no relay, F12 is available directly).\n *\n * SECRET-HANDLING: this value contains the relay host. MUST NOT be logged.\n */\n relayHttpUrl?: string;\n /**\n * LOCAL loopback HTTP base URL of the Chii relay for env-2\n * (`http://127.0.0.1:<relay-port>`). When set, the MCP uses this instead of\n * `relayHttpUrl` (the cloudflare tunnel base) to build inspector URLs — so\n * front_end page load and the client WS leg stay on the loopback and do not\n * traverse the tunnel (issue #530).\n *\n * Only relevant for `bootExternalRelayFamily` (env-2): the intoss relay\n * (`bootRelayFamily`) already uses a loopback `relay.baseUrl`.\n *\n * Safe to log/surface: loopback address contains no tunnel host.\n */\n relayLocalHttpUrl?: string;\n}\n\n/**\n * Boots the local-browser family (issues #348, #356). Launches a Chromium with\n * `--remote-debugging-port` and returns a `LocalCdpConnection` attached to it,\n * plus a `stop()` that kills both.\n *\n * Booted lazily via the dual router's `bootLazyFor('local-browser')` callback,\n * at most once on the first `start_debug({ mode: 'local-browser' })` (all-lazy,\n * #396 — no run function boots a family at startup anymore).\n */\nexport async function bootLocalFamily(): Promise<BootedFamily> {\n const cdpPort = 0; // OS-assigned ephemeral port.\n const devUrl = process.env.AIT_DEVTOOLS_URL ?? 'http://localhost:5173';\n const chromium = await launchChromium({ port: cdpPort, devUrl });\n // Give Chromium a moment to open its CDP endpoint before first attach.\n await new Promise<void>((r) => setTimeout(r, 800));\n const connection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });\n return {\n connection,\n stop() {\n connection.close();\n chromium.stop();\n },\n };\n}\n\n/** Options for {@link bootRelayFamily}. */\nexport interface BootRelayFamilyOptions {\n /** Relay local port. Default 0 (OS-assigned ephemeral). */\n relayPort?: number;\n /**\n * TOTP `verifyAuth` predicate for the relay WS upgrade gate. Built from\n * `AIT_DEBUG_TOTP_SECRET` at the call site via {@link buildRelayVerifyAuth}.\n * `undefined` disables the gate.\n */\n verifyAuth?: (req: import('node:http').IncomingMessage) => boolean;\n /**\n * Called whenever the public tunnel URL is (re)assigned, so the caller can\n * mirror it into the server lock file (`lockHandle.updateWssUrl`). The wssUrl\n * carries the relay host — callers MUST NOT log it directly.\n */\n onWssUrl?: (wssUrl: string) => void;\n /**\n * Secret-free observability callback for relay auth rejections (issue #467) —\n * forwarded to {@link startChiiRelay}'s `onAuthReject`. Receives only the\n * rejection kind; never the URL, query, code, or secret. Boot sites wire it\n * to `DiagnosticsCollector.recordAuthReject()` so `get_debug_status` can\n * surface silent 401s.\n */\n onAuthReject?: (event: import('./chii-relay.js').RelayAuthRejectEvent) => void;\n /**\n * Called with the cloudflared child PID once the tunnel is up.\n *\n * FIX 3 (issue #571): callers wire this to\n * `lockHandle.updateTunnelChildPid(pid)` so the lock file records the child\n * PID and a subsequent `acquireLock` can detect a zombie daemon (Node\n * process alive, tunnel child dead) without requiring `--force`.\n */\n onTunnelChildPid?: (pid: number) => void;\n /**\n * Called when the tunnel goes permanently down (3 reissue attempts failed),\n * so the caller can immediately push the new state to dashboard SSE clients\n * via `qrServer?.notifyStateChange()`. Without this, the dashboard keeps a\n * scannable-but-dead QR on screen until the next periodic TOTP refresh\n * happens to push (issue #631) — the render gate only flips once `tunnel.up`\n * reaches the client. Carries no arguments (the droppedAt timestamp rides\n * inside `tunnelStatus`, surfaced via `getTunnelStatus()`).\n */\n onTunnelDown?: () => void;\n}\n\n/**\n * Boots the relay family (issues #348, #356): starts the Chii relay on an\n * OS-assigned port (with optional TOTP gate), opens a cloudflared quick tunnel\n * to the relay's confirmed port in the background, prints the attach banner,\n * and arms the tunnel health probe. Returns a {@link BootedFamily} whose\n * `getTunnelStatus()` reflects the live tunnel (it flips up once the background\n * tunnel resolves and follows reissues).\n *\n * Booted lazily via the dual router's `bootLazyFor('relay-intoss')` callback\n * (symmetry with {@link bootLocalFamily}), at most once on the first\n * `start_debug({ mode: 'relay-staging' })` (all-lazy, #396 — every relay boot now\n * flows through `switchMode` after the project-local secret load). `relay-live`\n * removed (#665).\n *\n * The relay base URL is only known after `startChiiRelay()` resolves, so the\n * `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside\n * this function, after the relay port is confirmed.\n *\n * SECRET-HANDLING: the TOTP secret rides only inside `verifyAuth`; the wssUrl\n * (relay host) is never logged here directly.\n */\nexport async function bootRelayFamily(options: BootRelayFamilyOptions = {}): Promise<BootedFamily> {\n // Relay-auth baseline (issue #250): this boots a public-internet-exposed relay\n // (cloudflared quick tunnel), so a configured TOTP secret is MANDATORY — Layer\n // C is the only fail-fast layer that stops a leaked tunnel URL from attaching.\n // Fail fast before opening the relay/tunnel. Local-only sessions never call\n // this fn and so stay exempt. SECRET-HANDLING: the guard never logs the value.\n assertRelayAuthConfigured();\n\n // Default 0: OS picks a free port. Prevents EADDRINUSE from stale cloudflared\n // orphans (SIGKILL survivors) that would otherwise block a fixed port and\n // cause -32000 MCP handshake failures on reconnect.\n const relayPort = options.relayPort ?? 0;\n const totpEnabled = options.verifyAuth !== undefined;\n\n const relay = await startChiiRelay({\n port: relayPort,\n verifyAuth: options.verifyAuth,\n onAuthReject: options.onAuthReject,\n });\n // relay.port is the actual OS-assigned port (may differ from relayPort when 0).\n logInfo('server.start', { port: relay.port, totpEnabled });\n\n let tunnel: QuickTunnel | null = null;\n let tunnelStatus: TunnelStatus = makeTunnelStatus(false, null);\n let tunnelProbe: { stop(): void } | null = null;\n // generateAttachToken is kept for legacy/non-TOTP token use, but we no longer\n // print it in the banner to avoid accidental secret exposure.\n const _token = generateAttachToken();\n\n // Bring the cloudflared tunnel up in the background so the MCP stdio transport\n // can answer `initialize` immediately. cloudflared has to lazy-download a\n // ~38 MB binary on first run; awaiting it here pushes the initialize response\n // past Claude Code's MCP connection timeout. Tools that need the tunnel\n // (`start_attach`) already gate on `getTunnelStatus()` and return a clear\n // \"tunnel not up\" message when it isn't ready yet, so dropping the await is\n // safe — the agent retries once the banner prints.\n const tunnelReady = startQuickTunnel(relay.port).then(\n (t) => {\n tunnel = t;\n tunnelStatus = makeTunnelStatus(true, t.wssUrl);\n options.onWssUrl?.(t.wssUrl);\n // FIX 3 (issue #571): notify caller of the cloudflared child PID so it\n // can be persisted in the server lock file for zombie detection.\n // childPid is a plain integer — not a secret.\n if (t.childPid !== undefined) {\n options.onTunnelChildPid?.(t.childPid);\n }\n // SECRET-HANDLING: wssUrl contains the relay host — do not log it directly.\n logInfo('tunnel.up', { totpEnabled });\n\n // Start the health probe now that the tunnel URL is known.\n // The probe runs every 60 s and attempts up to 3 reissues on drop.\n tunnelProbe = startTunnelHealthProbe(t, relay.port, {\n onReissue: (newTunnel) => {\n tunnel = newTunnel;\n tunnelStatus = makeTunnelStatus(true, newTunnel.wssUrl, null, 0);\n options.onWssUrl?.(newTunnel.wssUrl);\n // FIX (issue #572 review): update the lock's tunnelChildPid so a later\n // acquireLock sees the reissued tunnel's child — not the original dead one.\n // childPid is a plain integer — not a secret.\n if (newTunnel.childPid !== undefined) {\n options.onTunnelChildPid?.(newTunnel.childPid);\n }\n // Reprint the banner so the user (and agent) see the new URL + QR.\n void printAttachBanner({ wssUrl: newTunnel.wssUrl, totpEnabled }).then(() => {\n logInfo('tunnel.up', { totpEnabled, reissued: true });\n });\n },\n onPermanentDrop: (droppedAt) => {\n tunnelStatus = makeTunnelStatus(false, null, droppedAt, 3);\n logError('tunnel.down', {\n msg: `tunnel permanently dropped (${droppedAt}). Restart: npx @ait-co/devtools devtools-mcp`,\n });\n // Wake open dashboard SSE clients immediately so the render gate\n // swaps the now-dead QR for the tunnel-down error state (issue #631).\n // Mirrors the onWssUrl path — without it the page shows a scannable\n // dead QR until the next periodic refresh push (up to 20s later).\n options.onTunnelDown?.();\n },\n });\n\n return printAttachBanner({ wssUrl: t.wssUrl, totpEnabled });\n },\n (err) => {\n const message = err instanceof Error ? err.message : String(err);\n logError('tunnel.down', {\n msg: `Failed to open cloudflared quick tunnel: ${message}. The relay is up locally; attach over the public URL is unavailable until the tunnel starts.`,\n });\n },\n );\n // Reference the promise to placate the linter — actual completion is observed\n // via the side-effects on `tunnelStatus` from inside `.then`.\n void tunnelReady;\n\n const connection = createRelayConnection(relay.baseUrl);\n\n return {\n connection,\n // Intoss-private dog-food relay (env 3) → relay-dev. env 4 removed (#665).\n relayOrigin: 'intoss-webview',\n // Local HTTP base of the Chii relay — used by AutoDevtoolsOpener to build\n // the self-hosted inspector URL. SECRET-HANDLING: not logged.\n relayHttpUrl: relay.baseUrl,\n getTunnelStatus: () => tunnelStatus,\n stop() {\n tunnelProbe?.stop();\n // tunnel.stop() is synchronous (child process kill) — safe from exit handler.\n tunnel?.stop();\n connection.close();\n // relay.close() is async — fine for signal/exit handlers.\n void relay.close();\n },\n };\n}\n\n/**\n * Boots the EXTERNAL relay family for env 2 (real-device PWA, issue #378).\n *\n * Unlike {@link bootRelayFamily}, this does NOT start a relay or a tunnel —\n * the unplugin (`tunnel: { cdp: true }`) already brought up a Chii relay for\n * the env-2 PWA and exposed its public base URL via `AIT_RELAY_BASE_URL`. Here\n * the MCP only opens a CDP client (`createRelayConnection`) against that\n * external relay. The relay's lifecycle is owned by the unplugin, so `stop()`\n * closes ONLY the CDP client — it must never tear down the relay or a tunnel\n * we did not start.\n *\n * `getTunnelStatus()` reports `up: true` with a `wssUrl` derived from\n * `relayBaseUrl` (http→ws, https→wss) so the `start_attach` gate\n * (`up: true && wssUrl !== null`) is satisfied even though we never opened a\n * cloudflared tunnel ourselves.\n *\n * SECRET-HANDLING: `relayBaseUrl` carries the relay host (same sensitivity as a\n * wss URL) — it is NEVER logged here. The caller validates presence and passes\n * the value straight to the CDP client.\n */\n/**\n * Attempts to read the local loopback HTTP base URL of the env-2 Chii relay\n * (issue #530). Resolution order:\n * 1. `AIT_RELAY_LOCAL_URL` env var, if set and non-empty.\n * 2. `relayLocalUrl` from the `.ait_urls` file, if `projectRoot` is given.\n * 3. `undefined` — caller falls back to the tunnel base (existing behavior).\n *\n * This is a best-effort read — never throws. The returned value is a plain\n * `http://127.0.0.1:<port>` loopback URL; no secret exposure.\n */\nexport async function readRelayLocalUrl(\n env: NodeJS.ProcessEnv = process.env,\n projectRoot?: string,\n): Promise<string | undefined> {\n const envValue = (env.AIT_RELAY_LOCAL_URL ?? '').trim();\n if (envValue !== '') return envValue;\n\n if (projectRoot !== undefined) {\n try {\n const { readRelayUrls } = await import('./relay-url-store.js');\n const stored = await readRelayUrls({ projectRoot });\n if (stored?.relayLocalUrl) return stored.relayLocalUrl;\n } catch {\n // Silent best-effort.\n }\n }\n return undefined;\n}\n\nexport async function bootExternalRelayFamily(\n relayBaseUrl: string,\n relayLocalUrl?: string,\n): Promise<BootedFamily> {\n // Relay-auth baseline (issue #250): the env-2 PWA relay is reachable over a\n // public `*.trycloudflare.com` tunnel (started by the unplugin). The Layer C\n // TOTP gate is what blocks a leaked tunnel URL, so a configured secret is\n // MANDATORY here too. The unplugin's relay reads the SAME `AIT_DEBUG_TOTP_SECRET`,\n // so this also fails fast when the operator forgot to set it. Fail before\n // opening the CDP client. SECRET-HANDLING: the guard never logs the value.\n assertRelayAuthConfigured();\n\n const connection = createRelayConnection(relayBaseUrl);\n // Derive the public wss URL from the relay base so start_attach's\n // `up && wssUrl !== null` gate passes. SECRET-HANDLING: not logged.\n const externalWss = relayBaseUrl.replace(/^http/, 'ws');\n const tunnelStatus = makeTunnelStatus(true, externalWss);\n return {\n connection,\n // External env-2 PWA relay → relay-mobile (distinct from relay-dev).\n relayOrigin: 'external-pwa',\n // HTTP base of the external relay — used as fallback for inspector URL.\n // For env-2 this is the cloudflare tunnel URL (https://<host>.trycloudflare.com).\n // SECRET-HANDLING: not logged.\n relayHttpUrl: relayBaseUrl,\n // LOCAL loopback base for inspector URL assembly (issue #530) — preferred\n // over relayHttpUrl when available so front_end + client WS stay local.\n // Safe to log: loopback URL contains no tunnel host.\n relayLocalHttpUrl: relayLocalUrl,\n getTunnelStatus: () => tunnelStatus,\n stop() {\n // The unplugin owns the relay + its tunnel — close ONLY our CDP client.\n connection.close();\n },\n };\n}\n\n/**\n * Identifies a booted family slot in the dual router (issue #378).\n *\n * Before #378 the router warm-kept a single \"opposite-kind\" lazy family, which\n * could not hold both an intoss relay (`relay-staging`) AND an external relay\n * (`relay-sandbox`) at once — they are both `kind: 'relay'` and would collide\n * in the single slot. The three keys separate the three distinct families (3\n * exposed modes → 3 physical slots, see {@link familyKeyForMode}).\n * `relay-live` removed (#665):\n *\n * - `'local-browser'` — local Chromium + mock SDK (env 1).\n * - `'relay-intoss'` — intoss-private relay (env 3/4, `bootRelayFamily`).\n * - `'relay-sandbox'` — env-2 external PWA relay (`bootExternalRelayFamily`).\n */\nexport type FamilyKey = 'local-browser' | 'relay-intoss' | 'relay-sandbox';\n\n/**\n * Maps a `StartDebugMode` to the {@link FamilyKey} that serves it (issue #378).\n * local-browser → 'local-browser'; relay-sandbox → 'relay-sandbox';\n * relay-staging → 'relay-intoss' (the intoss-private relay slot).\n * `relay-live` removed (#665).\n */\nexport function familyKeyForMode(mode: StartDebugMode): FamilyKey {\n switch (mode) {\n case 'local-browser':\n return 'local-browser';\n case 'relay-sandbox':\n return 'relay-sandbox';\n case 'relay-staging':\n return 'relay-intoss';\n }\n}\n\n/** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */\nexport const MOBILE_RELAY_BASE_URL_MISSING_MESSAGE =\n 'start_debug(mobile): AIT_RELAY_BASE_URL이 설정되지 않았습니다. ' +\n 'dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. ' +\n '자동 발견이 되지 않을 경우 relay base URL을 AIT_RELAY_BASE_URL 환경변수로 직접 전달하세요. ' +\n '환경 2(실기기 PWA) 진입은 외부 relay base가 필요합니다.';\n\n/**\n * Reads the env-2 relay base URL for the `mobile` boot site (issue #378, #424).\n *\n * Resolution order (env wins — file is the fallback):\n * 1. `env.AIT_RELAY_BASE_URL` set and non-empty → return it (operator override).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_urls`;\n * if `relayBaseUrl` is present → return it (auto-discovered from dev server).\n * 3. Neither → throw {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE}.\n *\n * SECRET-HANDLING: `AIT_RELAY_BASE_URL` and the file-discovered value carry the\n * relay host. On the missing path the thrown message names the env var and notes\n * that the dev server auto-publishes it — it NEVER echoes any URL value. The\n * present value is returned to the caller (the CDP client) but never logged.\n */\nexport async function readMobileRelayBaseUrl(\n env: NodeJS.ProcessEnv = process.env,\n projectRoot?: string,\n): Promise<string> {\n // 1. Env wins — operator override.\n const raw = env.AIT_RELAY_BASE_URL;\n const envValue = typeof raw === 'string' ? raw.trim() : '';\n if (envValue !== '') {\n return envValue;\n }\n\n // 2. File fallback — auto-discovered from dev server (#424).\n if (projectRoot !== undefined) {\n const { readRelayUrls } = await import('./relay-url-store.js');\n const stored = await readRelayUrls({ projectRoot });\n if (stored?.relayBaseUrl !== undefined) {\n return stored.relayBaseUrl;\n }\n }\n\n // 3. Neither source — throw the precise guidance message.\n throw new Error(MOBILE_RELAY_BASE_URL_MISSING_MESSAGE);\n}\n\n/**\n * Options the dual router needs to re-arm the attach watcher and auto-open\n * DevTools after a swap (issues #348, #356, #378, #396).\n *\n * All-lazy (#396): NO family is booted at startup — every family boots lazily on\n * its first `start_debug` via `bootLazyFor(key)`. This routes EVERY relay boot\n * through `switchMode` (which runs `loadRelaySecretReadOnly` first), closing the\n * gap where an eager startup boot bypassed the project-local secret load. The\n * router is direction-neutral (#356): any of the three families can be the first\n * one booted, so a session can hot-switch in any direction without a restart.\n */\nexport interface DualRouterDeps {\n /**\n * Lazy boot for the family identified by `key` — called at most once per key,\n * on the first `start_debug` whose family key has not yet been booted (issue\n * #378 — keyed so an intoss relay and an external relay can be warm-kept\n * simultaneously). Since #396 NO family is booted eagerly, so this boots the\n * family for ANY of the three FamilyKey values on first use.\n *\n * `projectRoot` is threaded from the per-session `start_debug` call (#424) so\n * `relay-sandbox` boot can fall back to the `.ait_urls` file discovery when\n * `AIT_RELAY_BASE_URL` is not set.\n */\n bootLazyFor: (key: FamilyKey, projectRoot?: string) => Promise<BootedFamily>;\n /**\n * Reads the current relay base URL for the `relay-sandbox` family (issue #610).\n *\n * Called on every `relay-sandbox` re-entry when a warm family is already\n * cached — the result is compared against the cached family's `relayHttpUrl`.\n * When they differ the stale family is torn down and a fresh one is booted.\n * When they match the warm family is reused (no unnecessary teardown).\n *\n * Returns `null` on any failure (missing file, missing env var) — the caller\n * keeps the warm family on null (fail-open: better a stale connection than a\n * surprise disconnect).\n *\n * SECRET-HANDLING: the returned URL carries the relay host. Callers MUST NOT\n * log it. Only boolean same/different is safe to surface.\n *\n * Production: injected by the run functions as\n * `(pr) => readMobileRelayBaseUrl(process.env, pr).catch(() => null)`.\n * Tests inject a controlled function.\n */\n readSandboxRelayUrl?: (projectRoot?: string) => Promise<string | null>;\n /** Diagnostics collector (re-armed watcher records attach there). */\n diagnosticsCollector: DiagnosticsCollector;\n /** Auto-opens Chrome DevTools on the first relay attach (env 3/4 only). */\n devtoolsOpener: AutoDevtoolsOpener;\n /** Attach-watcher poll interval (ms). Default 1 000. */\n attachWatcherIntervalMs?: number;\n /**\n * Called on every non-empty target-signature change (first attach, target\n * replacement, or re-attach after detach). Used by run functions to push a\n * dashboard SSE notification so open browser tabs receive fresh target id\n * and TOTP links (issue #509).\n */\n onPageAttach?: () => void;\n /**\n * Called on a genuine non-empty→empty target-signature transition (silent\n * disconnect — phone backgrounded, tunnel quiet, TOTP re-attach rejected).\n * Used by run functions to push an immediate \"disconnected\" SSE update to\n * the dashboard so it stops showing a stale \"connected\" page (fix #705-A).\n */\n onPageDetach?: () => void;\n /**\n * Returns the stable `/inspector` URL from the QR HTTP server (issue #530).\n * Called by `armWatcher` to pass to `AutoDevtoolsOpener.open()` so it can\n * open the secret-free stable URL instead of building a direct TOTP URL.\n * Returns null if the QR server is not yet started.\n */\n getInspectorStableUrl?: () => string | null;\n}\n\n/**\n * Sentinel connection returned by {@link DualConnectionRouter.active} before the\n * first `start_debug` boots a family (all-lazy, issue #396). It satisfies the\n * full {@link CdpConnection} interface but holds nothing: `listTargets()` is\n * empty, every command rejects with a clear \"call start_debug first\" message,\n * and all event/teardown members are safe no-ops. Callers that read tools before\n * any switchMode therefore get an honest empty/down state instead of an NPE.\n */\nconst NULL_CDP_CONNECTION: CdpConnection = {\n kind: 'local',\n enableDomains: () => Promise.resolve(),\n listTargets: () => [],\n getBufferedEvents: () => [],\n on: () => () => {},\n send: () => Promise.reject(new Error('no family booted yet — call start_debug first')),\n close: () => {},\n};\n\n/**\n * Production `ConnectionRouter` (issues #348, #356, #378 — DUAL-CONNECTION-COEXIST).\n *\n * Holds a keyed set of lazily-booted families ({@link FamilyKey} →\n * `BootedFamily`, issue #378) with NO family active at startup (issue #396); the\n * first `start_debug` boots and activates one. Plus an `active` pointer and the\n * single attach watcher armed on the active connection. The router is\n * **direction-neutral** (#356): any family can be the first one booted, so a\n * `--target=local` session can hot-switch into relay (and vice versa) without\n * restarting the MCP server.\n *\n * Why a KEYED map and not a single lazy slot (#378): `relay-sandbox` (env-2\n * external relay) and `relay-staging` (intoss relay) are BOTH `kind: 'relay'`.\n * A single \"opposite-kind\" slot could not warm-keep both at once — they would\n * collide. The three `FamilyKey`s (`local-browser` / `relay-intoss` /\n * `relay-sandbox`) give each its own warm slot. `relay-live` (env 4) removed\n * (#665) — `relay-intoss` slot now maps only to `relay-staging`.\n *\n * Why all-lazy (#396): the relay TOTP secret now lives in a project-local\n * `.ait_relay` file loaded read-only by `switchMode` BEFORE a relay family boots.\n * Booting any family eagerly at startup would bypass that load. With NO eager\n * boot every relay boot flows through `switchMode → loadRelaySecretReadOnly`, so\n * the secret is always populated before `assertRelayAuthConfigured()` /\n * `buildRelayVerifyAuth()` run at the boot site.\n *\n * `switchMode`:\n * 1. rejects re-entrant swaps (`swapInFlight`);\n * 2. resolves the requested mode's `FamilyKey`:\n * `lazyFamilies.get(key) ?? (boot via bootLazyFor(key), store)`;\n * 3. flips `active` (the MCP `Server` never re-handshakes — it reads through\n * `active` per request);\n * 4. stops the old attach watcher and re-arms one on the new connection\n * (the watcher self-clears, so re-arm is mandatory);\n * 5. emits `tools/list_changed`.\n *\n * Inactive infra is left WARM — teardown happens only at process exit (the\n * unified shutdown in the run functions), which is what keeps a phone attach\n * alive across a local→relay→local round trip.\n */\nexport class DualConnectionRouter implements ConnectionRouter {\n private readonly deps: DualRouterDeps;\n /** Families, booted lazily and warm-kept per {@link FamilyKey} (#378, #396). */\n private readonly lazyFamilies = new Map<FamilyKey, BootedFamily>();\n /** `null` until the first `start_debug` boots a family (all-lazy, #396). */\n private activeFamily: BootedFamily | null = null;\n private server: Server | null = null;\n private attachWatcher: { stop(): void } | null = null;\n private swapInFlight = false;\n\n constructor(deps: DualRouterDeps) {\n this.deps = deps;\n }\n\n get active(): CdpConnection {\n return this.activeFamily ? this.activeFamily.connection : NULL_CDP_CONNECTION;\n }\n\n /** Relay origin of the currently-active family (issue #378). */\n get activeRelayOrigin(): RelayOrigin | undefined {\n return this.activeFamily?.relayOrigin;\n }\n\n /**\n * HTTP base URL of the Chii relay to use for inspector URL assembly (#503,\n * #530). Prefers the LOCAL loopback base (`relayLocalHttpUrl`) when available\n * so front_end page load + client WS do not traverse a cloudflare tunnel —\n * falls back to `relayHttpUrl` (the tunnel base for env-2, loopback for env-3/4)\n * when not set. Returns `undefined` when no relay family is active.\n *\n * SECRET-HANDLING: when relayLocalHttpUrl is absent this falls back to\n * relayHttpUrl which may carry the tunnel host — callers must not log it.\n */\n get activeRelayHttpUrl(): string | undefined {\n if (!this.activeFamily) return undefined;\n return this.activeFamily.relayLocalHttpUrl ?? this.activeFamily.relayHttpUrl;\n }\n\n /** Every booted family (for unified shutdown). All families are lazy (#396). */\n bootedFamilies(): BootedFamily[] {\n return [...this.lazyFamilies.values()];\n }\n\n /**\n * Live tunnel status of the active relay family (issues #356, #378). Reads\n * the ACTIVE family's tunnel when it has one (so `relay-sandbox` surfaces the\n * external relay wss and `relay-staging` the intoss relay wss); otherwise\n * falls back to the first booted family that has a tunnel. Returns \"down\"\n * until any relay family is booted (any session before the first relay\n * start_debug) — the correct signal for `start_attach` (no tunnel yet).\n */\n relayTunnelStatus(): TunnelStatus {\n if (this.activeFamily?.getTunnelStatus) return this.activeFamily.getTunnelStatus();\n for (const family of this.bootedFamilies()) {\n if (family.getTunnelStatus) return family.getTunnelStatus();\n }\n return { up: false, wssUrl: null };\n }\n\n /**\n * Binds the MCP `Server`; the attach watcher is armed by the first\n * `start_debug` since no family is active at startup (all-lazy, #396). Called\n * once after `createDebugServer` + `connect`.\n */\n start(server: Server): void {\n this.server = server;\n this.armWatcher();\n }\n\n /** Stops the current attach watcher (for shutdown). */\n stopWatcher(): void {\n this.attachWatcher?.stop();\n this.attachWatcher = null;\n }\n\n /** Arms a fresh attach watcher on the current active connection. */\n private armWatcher(): void {\n const server = this.server;\n if (!server) return;\n // No family active yet (all-lazy, #396) — nothing to watch until the first\n // `start_debug` boots one and re-arms the watcher.\n const activeFamily = this.activeFamily;\n if (!activeFamily) return;\n this.attachWatcher = startAttachWatcher(\n activeFamily.connection,\n server,\n this.deps.attachWatcherIntervalMs ?? 1_000,\n () => {\n this.deps.diagnosticsCollector.recordAttach();\n // Notify dashboard of page attach — SSE push so the browser tab updates.\n this.deps.onPageAttach?.();\n // Auto-open Chii DevTools only for a relay attach (env 2/3/4). The\n // opener no-ops for a local (mock) connection — guard on the active\n // kind so a local session never tries to open a relay devtools.\n // AutoDevtoolsOpener._opened is a once-per-session guard, so repeat\n // fires (target replacement) do not open an extra browser window.\n if (activeFamily.connection.kind === 'relay') {\n // Take the first attached target's id — we are in the onAttach\n // callback, so listTargets() is guaranteed to be non-empty.\n const firstTarget = activeFamily.connection.listTargets()[0];\n const env = deriveEnvironment(activeFamily.connection.kind, activeFamily.relayOrigin);\n // Prefer the stable /inspector URL (issue #530): secret-free, no\n // expiry race. Falls back to the direct URL path when qrServer is\n // not yet available (should not happen in practice).\n const inspectorStableUrl = this.deps.getInspectorStableUrl?.() ?? null;\n this.deps.devtoolsOpener.open({\n inspectorStableUrl,\n relayHttpBaseUrl: activeFamily.relayHttpUrl,\n targetId: firstTarget?.id,\n // Mint a fresh TOTP code from the daemon's secret at open time.\n // The relay gate accepts ±RELAY_VERIFY_SKEW_STEPS=6 steps (~3 min).\n // SECRET-HANDLING: the closure captures only the getter, never logs.\n // Only used when inspectorStableUrl is absent (legacy path).\n mintTotp: process.env.AIT_DEBUG_TOTP_SECRET\n ? () => generateTotp(process.env.AIT_DEBUG_TOTP_SECRET as string)\n : undefined,\n env,\n });\n }\n },\n // Fix #705-A: notify dashboard on silent detach (non-empty→empty) so\n // open browser tabs immediately see the \"disconnected\" state.\n () => {\n this.deps.onPageDetach?.();\n },\n );\n }\n\n /**\n * Resolves the `BootedFamily` for `key`: the warm family if already booted,\n * otherwise boots it via `bootLazyFor(key, projectRoot)` and stores it (once\n * per key). Since #396 every family is lazy, so this is the single boot path\n * for all three keys.\n *\n * `projectRoot` is forwarded to `bootLazyFor` so `relay-sandbox` boot can\n * fall back to `.ait_urls` file discovery (#424) when `AIT_RELAY_BASE_URL` is\n * not set in the environment.\n *\n * **Relay-sandbox stale-URL rebuild (issue #610):** when the `relay-sandbox`\n * family is already warm, reads the current relay URL via\n * `deps.readSandboxRelayUrl` and compares it against the cached\n * `relayHttpUrl`. If they differ (dev server was restarted → new tunnel),\n * the stale family is torn down, evicted from the map, and a fresh one is\n * booted. If they match, or if the URL cannot be read, the warm family is\n * reused (fail-open — no unnecessary teardown on transient read errors).\n *\n * SECRET-HANDLING: fresh and cached relay URLs carry the tunnel host. The\n * comparison result (same/different) is the only thing surfaced — URLs are\n * never logged.\n */\n private async familyFor(key: FamilyKey, projectRoot?: string): Promise<BootedFamily> {\n const warm = this.lazyFamilies.get(key);\n if (warm) {\n // (#610) relay-sandbox re-entry: check whether the relay host has rotated.\n // env-2 relay is owned by the dev server (unplugin), so every `dev:phone:cdp`\n // restart produces a new quick-tunnel URL. If the cached family still points\n // at the old tunnel, teardown and rebuild with the fresh URL.\n if (key === 'relay-sandbox' && this.deps.readSandboxRelayUrl !== undefined) {\n let freshUrl: string | null = null;\n try {\n freshUrl = await this.deps.readSandboxRelayUrl(projectRoot);\n } catch {\n // Treat any read error as \"URL unchanged\" — fail-open to avoid\n // dropping a working connection on a transient FS error.\n freshUrl = null;\n }\n // SECRET-HANDLING: only compare; never log the URL values.\n const changed = freshUrl !== null && freshUrl !== warm.relayHttpUrl;\n if (changed) {\n // Stale relay: close only the CDP client (the unplugin owns the relay\n // + tunnel — exactly what bootExternalRelayFamily's stop() does).\n warm.stop();\n this.lazyFamilies.delete(key);\n const booted = await this.deps.bootLazyFor(key, projectRoot);\n this.lazyFamilies.set(key, booted);\n return booted;\n }\n }\n return warm;\n }\n const booted = await this.deps.bootLazyFor(key, projectRoot);\n this.lazyFamilies.set(key, booted);\n return booted;\n }\n\n async switchMode(mode: StartDebugMode, projectRoot?: string): Promise<ModeSwitchReport> {\n if (this.swapInFlight) {\n throw new Error('start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.');\n }\n // relay-live (env 4) removed (#665) — confirm parameter and gate gone.\n\n this.swapInFlight = true;\n try {\n // (1) Project-local relay secret load (issue #396). When entering a relay\n // family, read the relay TOTP secret read-only from\n // <projectRoot>/.ait_relay into process.env BEFORE the relay boots, so the\n // lazy boot's assertRelayAuthConfigured() + buildRelayVerifyAuth() (both\n // read env at the boot site) see it. The daemon NEVER mints — a missing or\n // invalid file leaves env untouched and the boot-site assert remains the\n // single #250 fail-fast. Local switches need no secret, so skip the load.\n // SECRET-HANDLING: loadRelaySecretReadOnly never logs the value or path.\n if (isRelayMode(mode)) {\n await loadRelaySecretReadOnly({ projectRoot });\n }\n\n // (2) Resolve the family by key (#378). `bootLazyFor` may throw (e.g.\n // mobile without AIT_RELAY_BASE_URL / .ait_urls) — let it propagate\n // WITHOUT flipping active, so a failed entry leaves state untouched.\n // Pass projectRoot so relay-sandbox boot can discover the relay URL from\n // .ait_urls (#424).\n const target = await this.familyFor(familyKeyForMode(mode), projectRoot);\n\n // (3) Flip the active pointer. The MCP Server reads through `active` per\n // request, so no re-handshake / restart is needed.\n this.activeFamily = target;\n\n // (4) Re-arm the attach watcher on the new connection (self-clearing).\n this.stopWatcher();\n this.armWatcher();\n\n // (5) Tell the MCP host the tool surface may have changed (env flip).\n void this.server?.sendToolListChanged();\n\n const wantRelay = isRelayMode(mode);\n const environment = deriveEnvironment(target.connection.kind, target.relayOrigin);\n return {\n mode,\n environment,\n kind: target.connection.kind,\n nextStep: wantRelay\n ? 'start_attach로 attach QR 생성 + 폰 attach까지 한 번에 진행하세요 (relay 세션).'\n : 'list_pages로 로컬 Chromium 페이지 attach를 확인하세요.',\n };\n } finally {\n this.swapInFlight = false;\n }\n }\n}\n\n/**\n * Boots the live debug stack and serves it over stdio:\n * 1. start the Chii relay on an OS-assigned port (with TOTP auth if\n * AIT_DEBUG_TOTP_SECRET is set),\n * 2. open a cloudflared quick tunnel to the relay's confirmed port,\n * 3. print relay URL + attach instructions,\n * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.\n */\nexport async function runDebugServer(options: RunDebugServerOptions = {}): Promise<void> {\n // Enforce a single debug session per machine. If another server is alive,\n // ServerLockConflictError is thrown — the MCP host surfaces the message to\n // the agent without a relay or cloudflared ever starting.\n // `force: true` kills the existing process and takes over the lock.\n const lockHandle = acquireLock({ force: options.force ?? false });\n\n // Dual-connection router (issues #348, #356, #378, #396): ALL families are\n // lazy-booted on the first matching `start_debug`. Nothing boots at startup —\n // every relay boot flows through `switchMode → loadRelaySecretReadOnly` first,\n // so the project-local `.ait_relay` secret is always loaded before the relay\n // boot's assertRelayAuthConfigured() / buildRelayVerifyAuth() read the env.\n const devtoolsOpener = new AutoDevtoolsOpener();\n // Diagnostics collector — records server-side errors and attach/detach events\n // so `get_debug_status` can surface them in a single call.\n const diagnosticsCollector = new InMemoryDiagnosticsCollector();\n\n // FIX (issue #572 review): track the live cloudflared child PID in memory so\n // get_debug_status can pass it to getDiagnostics as source (a). Updated by\n // onTunnelChildPid on initial boot and on every reissue.\n let activeTunnelChildPid: number | null = null;\n\n const router = new DualConnectionRouter({\n // Lazy resolver for all three family slots (#378, #396, #424).\n // SECRET-HANDLING: readMobileRelayBaseUrl reads AIT_RELAY_BASE_URL (or .ait_urls\n // fallback) only here, at the mobile boot site, and never logs its value.\n // verifyAuth is built INSIDE the lambda (lazily, at the relay boot site) so it\n // reads the env AFTER switchMode's project-local secret load (#396) has\n // populated AIT_DEBUG_TOTP_SECRET — never captured at server startup.\n bootLazyFor: async (key, projectRoot) =>\n key === 'relay-sandbox'\n ? bootExternalRelayFamily(\n await readMobileRelayBaseUrl(process.env, projectRoot),\n await readRelayLocalUrl(process.env, projectRoot),\n )\n : key === 'local-browser'\n ? bootLocalFamily()\n : bootRelayFamily({\n relayPort: options.relayPort,\n verifyAuth: buildRelayVerifyAuth(),\n // Mirror the assigned tunnel URL into the lock file so a second\n // caller sees the correct wssUrl in the conflict error message, and\n // notify the dashboard SSE clients of the tunnel URL change.\n onWssUrl: (wssUrl) => {\n lockHandle.updateWssUrl(wssUrl);\n qrServer?.notifyStateChange();\n },\n // FIX 3 (issue #571): persist the cloudflared child PID in the\n // lock file so a subsequent acquireLock can detect zombie daemons.\n // Also update the in-memory tracker (source a for FIX 2).\n onTunnelChildPid: (pid) => {\n activeTunnelChildPid = pid;\n lockHandle.updateTunnelChildPid(pid);\n },\n // Issue #467: count relay TOTP 401s (secret-free) so\n // get_debug_status can distinguish \"phone never arrived\" from\n // \"phone arrived but was rejected\".\n onAuthReject: () => diagnosticsCollector.recordAuthReject(),\n // Issue #631: on permanent tunnel drop, immediately push the\n // new state so the dashboard swaps the dead QR for the error\n // state (mirror of onWssUrl's notifyStateChange).\n onTunnelDown: () => qrServer?.notifyStateChange(),\n }),\n diagnosticsCollector,\n devtoolsOpener,\n onPageAttach: () => qrServer?.notifyStateChange(),\n // Fix #705-A: push an immediate SSE update when the phone silently disconnects.\n onPageDetach: () => qrServer?.notifyStateChange(),\n // Stable /inspector URL for auto-open (issue #530). qrServer is set after\n // the router is created but before armWatcher fires, so the closure safely\n // captures it by reference.\n getInspectorStableUrl: () => qrServer?.inspectorStableUrl ?? null,\n // (#610) Stale relay-sandbox rebuild: re-read the relay URL on every\n // relay-sandbox re-entry so the router can detect when the dev server was\n // restarted (new quick-tunnel) and rebuild the CDP client accordingly.\n // SECRET-HANDLING: readMobileRelayBaseUrl never logs the URL value.\n readSandboxRelayUrl: (pr) => readMobileRelayBaseUrl(process.env, pr).catch(() => null),\n });\n\n // AIT.* methods ride the *active* connection's command channel (relay Chii or\n // local CDP), so the AIT source follows `start_debug` swaps.\n const aitSource = new RoutingAitSource(() => {\n const active = router.active as CdpConnection & {\n sendCommand(method: string, params?: Record<string, unknown>): Promise<unknown>;\n };\n return active;\n });\n\n // dashboard용 lastAttachParts 상태 — start_attach 호출마다 갱신.\n // 완성 URL 대신 컴포넌트를 저장해 getDashboardState 호출마다 fresh TOTP를 mint (Defect 1).\n // SECRET-HANDLING: 컴포넌트에는 tunnel/scheme host가 있으므로 로그 출력 금지.\n let lastAttachParts: AttachUrlParts | null = null;\n\n // 세션 생애주기 phase (#730) — daemon은 'shutdown' 전환 시에만 갱신한다.\n // 'running'/'complete'는 CLI 전용(devtools-test)이라 daemon 경로는 사용하지 않는다.\n let currentPhase: DashboardState['phase'] = 'active';\n\n // getDashboardState 클로저 — qr-http-server dashboard에 현재 상태 전달.\n // rebuildAttachUrl()로 매 호출마다 최신 TOTP 코드를 mint한 URL을 생성한다 (Defect 1).\n // inspectorUrl은 안정 /inspector URL(issue #530) — 시크릿 없으므로 출력 가능.\n const getDashboardState = (): DashboardState => {\n const targets = router.active.listTargets();\n // inspectorUrl — /inspector 안정 진입점 (issue #530).\n // qrServer가 아직 없으면 null(초기화 직후 race). qrServer가 생기면 항상 안정 URL.\n // 클릭 시점에 TOTP를 mint하고 302 redirect하므로 stale 문제가 없다.\n // SECRET-HANDLING: /inspector URL 자체에 시크릿 없음 — 출력 가능.\n const inspectorUrl = qrServer?.inspectorStableUrl ?? null;\n return {\n tunnel: { up: router.relayTunnelStatus().up, wssUrl: router.relayTunnelStatus().wssUrl },\n pages: targets.map((t) => ({ id: t.id, url: t.url })),\n attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,\n inspectorUrl,\n // 현재 active connection에서 매 호출마다 파생한 env — /attach 카피·환경 라벨\n // 분기(#468). start_debug family swap을 따라가도록 저장하지 않고 파생한다.\n mode: deriveEnvironment(router.active.kind, router.activeRelayOrigin),\n phase: currentPhase, // #730\n };\n };\n\n // getDirectInspectorUrl — /inspector 라우트에서 직접 chii front_end URL을 조립.\n // getDashboardState().inspectorUrl(= /inspector 자기 자신)을 쓰면 무한 루프가 발생하므로\n // 별도 getter로 분리한다. 매 요청마다 호출되어 TOTP를 요청 시점에 mint한다.\n // SECRET-HANDLING: ok:true url에 relay host + at= 코드가 담긴다 — 로그/stdout 출력 금지.\n const getDirectInspectorUrl = (): ReturnType<\n NonNullable<QrHttpServerOptions['getDirectInspectorUrl']>\n > => {\n const relayHttpUrl = router.activeRelayHttpUrl;\n if (!relayHttpUrl) {\n return { ok: false, reason: 'relayDown' };\n }\n const targets = router.active.listTargets();\n if (targets.length === 0) {\n return { ok: false, reason: 'noTarget' };\n }\n const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;\n if (!totpSecret) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n const url = buildChiiInspectorUrl(relayHttpUrl, targets[0].id, () =>\n generateTotp(totpSecret, Date.now()),\n );\n if (url === null) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n return { ok: true, url };\n };\n\n // 로컬 QR HTTP 서버를 await로 시작 — start_attach 첫 호출이 qrHttpServer 확인 전에\n // 도달하는 race를 없애기 위해 cloudflared(fire-and-forget)와 달리 동기 await 사용.\n // GUI 없는 환경에서는 startQrHttpServer가 실패해도 text QR fallback으로 동작한다.\n let qrServer: QrHttpServer | undefined;\n try {\n qrServer = await startQrHttpServer(getDashboardState, { getDirectInspectorUrl });\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n logWarn('server.start', { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}` });\n }\n\n // TOTP 주기 갱신 타이머 — 이벤트 없이 페이지가 방치될 때 at= 코드가 stale되는 갭 수정 (#445).\n // TOTP step은 30초이므로 20초 주기로 push해 step 경계를 놓치지 않는다.\n // SECRET-HANDLING: 콜백은 단순 trigger만 — TOTP 값·at= 코드는 절대 로그/stdout에 출력 금지.\n const TOTP_REFRESH_INTERVAL_MS = 20_000;\n let totpRefreshHandle: ReturnType<typeof setInterval> | null = null;\n totpRefreshHandle = setInterval(() => {\n if (lastAttachParts !== null) {\n qrServer?.notifyStateChange();\n }\n }, TOTP_REFRESH_INTERVAL_MS);\n totpRefreshHandle.unref();\n\n const server = createDebugServer({\n // `connection` is still required by the deps shape; the router overrides\n // which connection the handlers actually read (NULL until the first switch).\n connection: router.active,\n router,\n aitSource,\n // Tunnel status follows the active relay family once one is lazy-booted (#356).\n getTunnelStatus: () => router.relayTunnelStatus(),\n // FIX (issue #572 review): expose the live cloudflared child PID (source a)\n // so get_debug_status can feed it into getDiagnostics for the FIX 2 probe.\n getTunnelChildPid: () => activeTunnelChildPid,\n get qrHttpServer() {\n return qrServer;\n },\n diagnosticsCollector,\n // SECRET-HANDLING: the TOTP secret is read from env AT CALL TIME (inside\n // start_attach) so the project-local .ait_relay secret loaded by\n // switchMode (#396) is visible. It is used only to generate the at= code and\n // is never logged or surfaced in any output.\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n // dashboard 갱신 콜백 — URL 컴포넌트 저장 후 SSE push.\n // 컴포넌트를 저장해 getDashboardState가 fresh TOTP로 URL을 재빌드 (Defect 1).\n onAttachUrlBuilt: (parts) => {\n lastAttachParts = parts;\n qrServer?.notifyStateChange();\n },\n });\n\n const transport = new StdioServerTransport();\n\n // ---------------------------------------------------------------------------\n // Unified dual-family shutdown (issues #348, #356, #396): tears down every\n // family ever booted at process exit (all are lazy now — relay + tunnel +\n // health probe + every booted connection, plus a lazily-booted local\n // Chromium). Each family's `stop()` owns its own infra teardown — the relay\n // family stops its tunnel + probe, the local family kills its Chromium.\n // Inactive infra is left warm during the session and only collected here —\n // that is what preserves a warm attach across `start_debug` swaps.\n //\n // SIGKILL cannot be intercepted — cloudflared may remain orphaned (PPID 1).\n // Port 0 makes such orphans harmless: the next startup gets a fresh port.\n // Manual cleanup if needed: `pkill -f 'cloudflared.*trycloudflare'`\n // ---------------------------------------------------------------------------\n\n let closed = false;\n let parentWatcher: { stop(): void } | null = null;\n let maxAgeWatchdog: { stop(): void } | null = null;\n\n const shutdown = () => {\n // Idempotent: multiple simultaneous signals/exit/uncaught calls run only once.\n if (closed) return;\n closed = true;\n\n // #730: push the terminal SSE frame FIRST — before any teardown — so the\n // dashboard renders \"서버 종료\" instead of a bare connection-refused. The\n // HTTP server (qrServer) is still alive for the rest of this synchronous\n // function body, so notifyStateChange's res.write reaches already-open\n // SSE sockets before qrServer.close() below severs them.\n currentPhase = 'shutdown';\n qrServer?.notifyStateChange();\n\n parentWatcher?.stop();\n maxAgeWatchdog?.stop();\n if (totpRefreshHandle) clearInterval(totpRefreshHandle);\n router.stopWatcher();\n // Tear down every booted family (all lazy, #396 — only those ever started).\n // family.stop() is synchronous for the infra (tunnel/Chromium kill) — safe\n // from exit handlers; the relay's relay.close() inside is async fire-and-forget.\n for (const family of router.bootedFamilies()) family.stop();\n // server.close(), qrServer.close() are async — fine for signal handlers.\n void server.close();\n void qrServer?.close();\n // Remove the lock file so the next startup can proceed immediately.\n lockHandle.release();\n };\n\n // Graceful termination signals.\n process.once('SIGINT', shutdown);\n process.once('SIGTERM', shutdown);\n // SIGHUP: terminal hangup / parent process exit.\n process.once('SIGHUP', shutdown);\n\n // Synchronous-only cleanup on process.exit (async calls are silently ignored\n // by Node at this stage — only family.stop() infra kills which are sync).\n process.on('exit', () => {\n if (!closed) {\n closed = true;\n parentWatcher?.stop();\n maxAgeWatchdog?.stop();\n if (totpRefreshHandle) clearInterval(totpRefreshHandle);\n router.stopWatcher();\n for (const family of router.bootedFamilies()) family.stop();\n // Synchronous lock release — rmSync is safe from exit handlers.\n lockHandle.release();\n }\n });\n\n // Crash safety: shutdown before exiting so cloudflared is killed even on\n // unhandled errors. Covers cases where no signal is delivered (e.g. thrown\n // exception in async code that wasn't caught).\n process.on('uncaughtException', (err) => {\n logError('tool.error', { msg: `uncaughtException: ${String(err)}`, errorKind: 'uncaught' });\n shutdown();\n process.exit(1);\n });\n\n process.on('unhandledRejection', (reason) => {\n logError('tool.error', {\n msg: `unhandledRejection: ${String(reason)}`,\n errorKind: 'unhandled-rejection',\n });\n shutdown();\n process.exit(1);\n });\n\n await server.connect(transport);\n\n // Bind the server to the router. No family is active yet (all-lazy, #396) —\n // the attach watcher is armed by the first `start_debug` and re-armed on every\n // swap.\n router.start(server);\n\n // Self-terminate when the parent process (Claude Code or another AI host) has\n // died without sending SIGTERM/SIGHUP. Without this watcher the daemon runs\n // as a zombie, holding a stale cloudflared tunnel that silently blocks new\n // attach attempts.\n //\n // AIT_DEBUG_NO_PARENT_WATCH=1 disables the watcher — useful for:\n // - shells / process managers that legitimately re-parent the daemon\n // - manual standalone invocations where ppid churn is expected\n if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== '1') {\n parentWatcher = startParentWatcher(\n () => {\n shutdown();\n process.exit(0);\n },\n { intervalMs: 5_000 },\n );\n // Also exit when stdin closes — the MCP host closed the pipe.\n process.stdin.once('end', () => {\n shutdown();\n process.exit(0);\n });\n process.stdin.once('close', () => {\n shutdown();\n process.exit(0);\n });\n }\n\n // FIX 4 (issue #571): max-age watchdog — self-terminate after a configured\n // maximum lifetime. cloudflared quick-tunnel lifetimes are finite; a daemon\n // that outlives its tunnel will silently fail. Default 6 hours.\n //\n // AIT_DEBUG_NO_MAX_AGE=1 disables the watchdog — useful for long-running\n // manual debug sessions or process-manager environments.\n // AIT_DEBUG_MAX_AGE_MS=<ms> overrides the default 6-hour cap.\n if (process.env.AIT_DEBUG_NO_MAX_AGE !== '1') {\n const maxAgeMs = process.env.AIT_DEBUG_MAX_AGE_MS\n ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || undefined\n : undefined;\n maxAgeWatchdog = startMaxAgeWatchdog(\n () => {\n process.stderr.write(\n '[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\\n',\n );\n shutdown();\n process.exit(0);\n },\n { maxAgeMs },\n );\n }\n}\n\nexport interface RunLocalDebugServerOptions {\n /**\n * CDP remote debugging port for the local Chromium. Default 0 (OS-assigned).\n * Uses an ephemeral free port when 0, avoiding EADDRINUSE on reconnect.\n */\n cdpPort?: number;\n /**\n * URL to open in the launched browser. Defaults to `AIT_DEVTOOLS_URL` env var\n * or `http://localhost:5173`.\n */\n devUrl?: string;\n /**\n * When `true`, terminates the process holding the existing server lock and\n * takes over the session. Corresponds to `--force` / `--takeover` CLI flags.\n *\n * Default `false`.\n */\n force?: boolean;\n}\n\n/**\n * Serves the debug stack over stdio with the local browser as the default\n * target. Since #396 NOTHING boots at startup — every family (including the\n * local Chromium) is lazy-booted on its first `start_debug`:\n * 1. `start_debug({ mode: 'local-browser' })` launches a local Chromium with\n * `--remote-debugging-port=<port>` and attaches a `LocalCdpConnection`;\n * 2. the intoss/external relay families lazy-boot on the first\n * `start_debug({ mode: 'relay-staging' | 'relay-sandbox' })` (#665: relay-live removed);\n * 3. all of this runs through the SAME direction-neutral\n * `DualConnectionRouter` that `runDebugServer` uses (issue #356).\n *\n * Symmetry with `runDebugServer` (#356): starting with `--target=local` no\n * longer pins a single-connection router. A `--target=local` session can\n * hot-switch into relay (env 1 → env 3) without restarting the MCP server,\n * closing the asymmetry where only the default (relay-target) entry point had\n * bidirectional hot-switch. The intended fidelity-ladder flow — \"validate in\n * env 1 (local), then env 3 (intoss-private) in ONE session, no restart\" — now\n * works from either entry point.\n *\n * `start_attach` (relay-specific) stays effectively hidden / non-applicable\n * until the relay family is booted: before the first relay switch the env\n * derives to `mock` and `relayTunnelStatus()` reports \"down\", so the tool fails\n * with a clear \"tunnel not up\" message. After a relay switch the relay tunnel\n * is live and the tool works.\n *\n * The AIT.* tools (`AIT.getSdkCallHistory`, `AIT.getMockState`,\n * `AIT.getOperationalEnvironment`) ride the *active* connection's CDP channel\n * via `RoutingAitSource`, so they follow `start_debug` swaps.\n */\nexport async function runLocalDebugServer(options: RunLocalDebugServerOptions = {}): Promise<void> {\n // Enforce a single debug session per machine (same lock as relay mode).\n // `force: true` kills the existing process and takes over the lock.\n const lockHandle = acquireLock({ force: options.force ?? false });\n\n const cdpPort = options.cdpPort ?? 0;\n const devUrl = options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? 'http://localhost:5173';\n\n // Local family boot, deferred into the lazy resolver (all-lazy, #396). Launches\n // the Chromium + attaches a LocalCdpConnection only when `start_debug({ mode:\n // 'local-browser' })` first fires — so a session that goes straight to relay never\n // spawns a Chromium it would have to clean up. Honors this entry's\n // cdpPort/devUrl options (vs the env-only `bootLocalFamily`).\n const bootLocalFamilyForEntry = async (): Promise<BootedFamily> => {\n const chromium = await launchChromium({ port: cdpPort, devUrl });\n // Give Chromium a moment to start the CDP endpoint before we connect.\n // 800 ms is enough on most machines; the connection retries if it fails.\n await new Promise<void>((r) => setTimeout(r, 800));\n const localConnection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });\n return {\n connection: localConnection,\n stop() {\n localConnection.close();\n chromium.stop();\n },\n };\n };\n\n // Dual-connection router (issues #348, #356, #378, #396): ALL families are\n // lazy-booted — the local family on the first `start_debug({ mode: 'local-browser' })`,\n // the intoss relay on `relay-staging`, the env-2 external relay on `relay-sandbox`.\n // `relay-live` removed (#665).\n const devtoolsOpener = new AutoDevtoolsOpener();\n const diagnosticsCollector = new InMemoryDiagnosticsCollector();\n\n // FIX (issue #572 review): track the live cloudflared child PID in memory so\n // get_debug_status can pass it to getDiagnostics as source (a). Updated by\n // onTunnelChildPid on initial boot and on every reissue.\n let activeTunnelChildPid: number | null = null;\n\n const router = new DualConnectionRouter({\n // Lazy resolver for all three family slots (#378, #396, #424).\n // SECRET-HANDLING: readMobileRelayBaseUrl reads AIT_RELAY_BASE_URL (or .ait_urls\n // fallback) only here, at the mobile boot site, and never logs its value.\n // verifyAuth is built INSIDE the lambda (lazily, at the relay boot site) so it\n // reads the env AFTER switchMode's project-local secret load (#396) has\n // populated AIT_DEBUG_TOTP_SECRET — never captured at server startup.\n bootLazyFor: async (key, projectRoot) =>\n key === 'relay-sandbox'\n ? bootExternalRelayFamily(\n await readMobileRelayBaseUrl(process.env, projectRoot),\n await readRelayLocalUrl(process.env, projectRoot),\n )\n : key === 'local-browser'\n ? bootLocalFamilyForEntry()\n : bootRelayFamily({\n verifyAuth: buildRelayVerifyAuth(),\n onWssUrl: (wssUrl) => {\n lockHandle.updateWssUrl(wssUrl);\n qrServer?.notifyStateChange();\n },\n // FIX 3 (issue #571): persist cloudflared child PID for zombie detection.\n // Also update the in-memory tracker (source a for FIX 2).\n onTunnelChildPid: (pid) => {\n activeTunnelChildPid = pid;\n lockHandle.updateTunnelChildPid(pid);\n },\n // Issue #467: secret-free relay TOTP 401 counter for get_debug_status.\n onAuthReject: () => diagnosticsCollector.recordAuthReject(),\n // Issue #631: on permanent tunnel drop, immediately push the\n // new state so the dashboard swaps the dead QR for the error\n // state (mirror of onWssUrl's notifyStateChange).\n onTunnelDown: () => qrServer?.notifyStateChange(),\n }),\n diagnosticsCollector,\n devtoolsOpener,\n onPageAttach: () => qrServer?.notifyStateChange(),\n // Fix #705-A: push an immediate SSE update when the phone silently disconnects.\n onPageDetach: () => qrServer?.notifyStateChange(),\n // Stable /inspector URL for auto-open (issue #530).\n getInspectorStableUrl: () => qrServer?.inspectorStableUrl ?? null,\n // (#610) Stale relay-sandbox rebuild: re-read the relay URL on every\n // relay-sandbox re-entry so the router can detect when the dev server was\n // restarted (new quick-tunnel) and rebuild the CDP client accordingly.\n // SECRET-HANDLING: readMobileRelayBaseUrl never logs the URL value.\n readSandboxRelayUrl: (pr) => readMobileRelayBaseUrl(process.env, pr).catch(() => null),\n });\n\n // AIT.* methods ride the *active* connection's command channel (local CDP or,\n // after a relay switch, relay Chii), so the AIT source follows swaps.\n const aitSource = new RoutingAitSource(() => {\n const active = router.active as CdpConnection & {\n sendCommand(method: string, params?: Record<string, unknown>): Promise<unknown>;\n };\n return active;\n });\n\n // dashboard용 lastAttachParts 상태 — start_attach 호출마다 갱신.\n // 완성 URL 대신 컴포넌트를 저장해 getDashboardState 호출마다 fresh TOTP를 mint (Defect 1).\n // SECRET-HANDLING: 컴포넌트에는 tunnel/scheme host가 있으므로 로그 출력 금지.\n let lastAttachParts: AttachUrlParts | null = null;\n\n // 세션 생애주기 phase (#730) — daemon은 'shutdown' 전환 시에만 갱신한다.\n let currentPhase: DashboardState['phase'] = 'active';\n\n const getDashboardState = (): DashboardState => {\n const targets = router.active.listTargets();\n // inspectorUrl — /inspector 안정 진입점 (issue #530).\n // SECRET-HANDLING: /inspector URL 자체에 시크릿 없음 — 출력 가능.\n const inspectorUrl = qrServer?.inspectorStableUrl ?? null;\n return {\n tunnel: { up: router.relayTunnelStatus().up, wssUrl: router.relayTunnelStatus().wssUrl },\n pages: targets.map((t) => ({ id: t.id, url: t.url })),\n attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,\n inspectorUrl,\n phase: currentPhase, // #730\n };\n };\n\n // getDirectInspectorUrl — /inspector 라우트에서 직접 chii front_end URL을 조립.\n // getDashboardState().inspectorUrl(= /inspector 자기 자신)을 쓰면 무한 루프가 발생하므로\n // 별도 getter로 분리한다. 매 요청마다 호출되어 TOTP를 요청 시점에 mint한다.\n // SECRET-HANDLING: ok:true url에 relay host + at= 코드가 담긴다 — 로그/stdout 출력 금지.\n const getDirectInspectorUrl = (): ReturnType<\n NonNullable<QrHttpServerOptions['getDirectInspectorUrl']>\n > => {\n const relayHttpUrl = router.activeRelayHttpUrl;\n if (!relayHttpUrl) {\n return { ok: false, reason: 'relayDown' };\n }\n const targets = router.active.listTargets();\n if (targets.length === 0) {\n return { ok: false, reason: 'noTarget' };\n }\n const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;\n if (!totpSecret) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n const url = buildChiiInspectorUrl(relayHttpUrl, targets[0].id, () =>\n generateTotp(totpSecret, Date.now()),\n );\n if (url === null) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n return { ok: true, url };\n };\n\n // Local QR HTTP server — awaited so the first start_attach call (after a\n // relay switch) doesn't race its startup. Failure falls back to text QR.\n let qrServer: QrHttpServer | undefined;\n try {\n qrServer = await startQrHttpServer(getDashboardState, { getDirectInspectorUrl });\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n logWarn('server.start', { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}` });\n }\n\n // TOTP 주기 갱신 타이머 — 이벤트 없이 페이지가 방치될 때 at= 코드가 stale되는 갭 수정 (#448).\n // TOTP step은 30초이므로 20초 주기로 push해 step 경계를 놓치지 않는다.\n // local-only 동안엔 lastAttachParts가 null이라 no-op — relay로 전환된 뒤 첫 start_attach\n // 호출 시 lastAttachParts가 세팅되면 갱신이 시작된다.\n // SECRET-HANDLING: 콜백은 단순 trigger만 — TOTP 값·at= 코드는 절대 로그/stdout 출력 금지.\n const TOTP_REFRESH_INTERVAL_MS = 20_000;\n let totpRefreshHandle: ReturnType<typeof setInterval> | null = null;\n totpRefreshHandle = setInterval(() => {\n if (lastAttachParts !== null) {\n qrServer?.notifyStateChange();\n }\n }, TOTP_REFRESH_INTERVAL_MS);\n totpRefreshHandle.unref();\n\n const server = createDebugServer({\n connection: router.active,\n router,\n aitSource,\n // Tunnel status follows the relay family once it is lazy-booted (#356);\n // until then it reports \"down\" (no relay tunnel exists), which keeps\n // start_attach correctly gated.\n getTunnelStatus: () => router.relayTunnelStatus(),\n // FIX (issue #572 review): expose the live cloudflared child PID (source a)\n // so get_debug_status can feed it into getDiagnostics for the FIX 2 probe.\n getTunnelChildPid: () => activeTunnelChildPid,\n get qrHttpServer() {\n return qrServer;\n },\n diagnosticsCollector,\n // SECRET-HANDLING: the TOTP secret is read from env AT CALL TIME (inside\n // start_attach) so the project-local .ait_relay secret loaded by\n // switchMode (#396) is visible. It is used only to generate the at= code and\n // is never logged or surfaced in any output.\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n // dashboard 갱신 콜백 — URL 컴포넌트 저장 후 SSE push (Defect 1 fix).\n onAttachUrlBuilt: (parts) => {\n lastAttachParts = parts;\n qrServer?.notifyStateChange();\n },\n });\n\n const transport = new StdioServerTransport();\n\n // ---------------------------------------------------------------------------\n // Unified dual-family shutdown (issues #356, #396, mirrors runDebugServer):\n // tears down every family ever booted at process exit (all lazy now). Each\n // family's stop() owns its infra — the local family kills its Chromium, a\n // lazily-booted relay family stops its tunnel + probe + relay. Inactive infra\n // is left warm during the session.\n // ---------------------------------------------------------------------------\n\n let closed = false;\n let parentWatcher: { stop(): void } | null = null;\n let maxAgeWatchdog: { stop(): void } | null = null;\n\n const shutdown = () => {\n if (closed) return;\n closed = true;\n\n // #730: push the terminal SSE frame FIRST — see runDebugServer's shutdown\n // for the full ordering rationale (mirrors that daemon variant).\n currentPhase = 'shutdown';\n qrServer?.notifyStateChange();\n\n parentWatcher?.stop();\n maxAgeWatchdog?.stop();\n if (totpRefreshHandle) clearInterval(totpRefreshHandle);\n router.stopWatcher();\n // Tear down every booted family (all lazy, #396 — only those ever started).\n for (const family of router.bootedFamilies()) family.stop();\n void server.close();\n void qrServer?.close();\n // Remove the lock file so the next startup can proceed immediately.\n lockHandle.release();\n };\n\n process.once('SIGINT', shutdown);\n process.once('SIGTERM', shutdown);\n process.once('SIGHUP', shutdown);\n\n process.on('exit', () => {\n if (!closed) {\n closed = true;\n parentWatcher?.stop();\n maxAgeWatchdog?.stop();\n if (totpRefreshHandle) clearInterval(totpRefreshHandle);\n router.stopWatcher();\n for (const family of router.bootedFamilies()) family.stop();\n lockHandle.release();\n }\n });\n\n process.on('uncaughtException', (err) => {\n logError('tool.error', {\n msg: `uncaughtException: ${String(err)}`,\n errorKind: 'uncaught',\n mode: 'local-browser',\n });\n shutdown();\n process.exit(1);\n });\n\n process.on('unhandledRejection', (reason) => {\n logError('tool.error', {\n msg: `unhandledRejection: ${String(reason)}`,\n errorKind: 'unhandled-rejection',\n mode: 'local-browser',\n });\n shutdown();\n process.exit(1);\n });\n\n await server.connect(transport);\n\n // Bind the server to the router. No family is active yet (all-lazy, #396) —\n // the attach watcher is armed by the first `start_debug` and re-armed on every\n // swap.\n router.start(server);\n\n // Self-terminate when the parent process has died without sending SIGTERM/SIGHUP.\n if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== '1') {\n parentWatcher = startParentWatcher(\n () => {\n shutdown();\n process.exit(0);\n },\n { intervalMs: 5_000 },\n );\n process.stdin.once('end', () => {\n shutdown();\n process.exit(0);\n });\n process.stdin.once('close', () => {\n shutdown();\n process.exit(0);\n });\n }\n\n // FIX 4 (issue #571): max-age watchdog.\n if (process.env.AIT_DEBUG_NO_MAX_AGE !== '1') {\n const maxAgeMs = process.env.AIT_DEBUG_MAX_AGE_MS\n ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || undefined\n : undefined;\n maxAgeWatchdog = startMaxAgeWatchdog(\n () => {\n process.stderr.write(\n '[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\\n',\n );\n shutdown();\n process.exit(0);\n },\n { maxAgeMs },\n );\n }\n}\n\nexport interface RunMobileDebugServerOptions {\n /**\n * When `true`, terminates the process holding the existing server lock and\n * takes over the session. Corresponds to `--force` / `--takeover` CLI flags.\n *\n * Default `false`.\n */\n force?: boolean;\n /**\n * Project root for `.ait_urls` file-based URL discovery (#424). When supplied,\n * `readMobileRelayBaseUrl` falls back to the `.ait_urls` file written by the\n * unplugin if `AIT_RELAY_BASE_URL` is not set. Defaults to `process.cwd()`.\n */\n projectRoot?: string;\n}\n\n/**\n * Serves the env-2 (real-device PWA) debug stack over stdio with the external\n * Chii relay as the default target (issue #378). Since #396 NOTHING boots at\n * startup — the external relay family is lazy-booted on the first\n * `start_debug({ mode: 'relay-sandbox' })`.\n *\n * Unlike `runDebugServer` (which starts its own relay + cloudflared tunnel),\n * `runMobileDebugServer` attaches to a relay the unplugin ALREADY brought up\n * (`tunnel: { cdp: true }`) and exposed via `AIT_RELAY_BASE_URL`. The MCP only\n * opens a CDP client against that external relay — it never starts or tears down\n * a relay or a tunnel it did not own (see {@link bootExternalRelayFamily}).\n *\n * Symmetry with `runDebugServer` / `runLocalDebugServer` (#356, #378, #396): all\n * three families are lazy-booted — the env-2 external relay on the first\n * `start_debug({ mode: 'relay-sandbox' })`, the local family on `local-browser`,\n * the intoss relay on `relay-staging` (#665: relay-live removed) — so a\n * `--target=mobile` session can hot-switch without a restart. The active env\n * derives to `relay-mobile` (external-PWA origin).\n *\n * SECRET-HANDLING: `AIT_RELAY_BASE_URL` is read once here via\n * {@link readMobileRelayBaseUrl}; when unset it throws\n * {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} — a message that names the env\n * var and how to obtain it, never echoing any URL value. The error propagates to\n * the bin entry's fatal handler (the missing-URL path prints the guidance, not a\n * value). The present value is passed straight to the CDP client, never logged.\n */\nexport async function runMobileDebugServer(\n options: RunMobileDebugServerOptions = {},\n): Promise<void> {\n // Read the external relay base BEFORE acquiring the lock so a missing-URL\n // invocation fails fast (fatal stderr via the bin entry) without taking the\n // single-session lock or opening any connection. Kept pre-flight (NOT moved\n // into the lazy lambda) so the fail-fast still precedes the lock.\n // (#424) Falls back to .ait_urls if AIT_RELAY_BASE_URL is unset.\n // SECRET-HANDLING: relayBaseUrl is passed to the CDP client only, never logged.\n const relayBaseUrl = await readMobileRelayBaseUrl(\n process.env,\n options.projectRoot ?? process.cwd(),\n );\n\n // Enforce a single debug session per machine (same lock as the other modes).\n // `force: true` kills the existing process and takes over the lock.\n const lockHandle = acquireLock({ force: options.force ?? false });\n\n // Dual-connection router (issues #348, #356, #378, #396): ALL families are\n // lazy-booted — the env-2 external relay on the first `start_debug({ mode:\n // 'relay-sandbox' })`, the local family on `local-browser`, the intoss relay on\n // `relay-staging`. `relay-live` removed (#665).\n const devtoolsOpener = new AutoDevtoolsOpener();\n const diagnosticsCollector = new InMemoryDiagnosticsCollector();\n\n // FIX (issue #572 review): track the live cloudflared child PID in memory so\n // get_debug_status can pass it to getDiagnostics as source (a). Updated by\n // onTunnelChildPid on initial boot and on every reissue.\n let activeTunnelChildPid: number | null = null;\n\n const router = new DualConnectionRouter({\n // Lazy resolver for all three family slots (#378, #396, #424). The external\n // relay boot captures the pre-flight `relayBaseUrl`. Its stop() closes ONLY\n // the CDP client — the unplugin owns the relay + its tunnel.\n // verifyAuth is built INSIDE the lambda (lazily, at the relay boot site) so it\n // reads the env AFTER switchMode's project-local secret load (#396) has\n // populated AIT_DEBUG_TOTP_SECRET — never captured at server startup.\n bootLazyFor: async (key) =>\n key === 'relay-sandbox'\n ? bootExternalRelayFamily(\n relayBaseUrl,\n await readRelayLocalUrl(process.env, options.projectRoot ?? process.cwd()),\n )\n : key === 'local-browser'\n ? bootLocalFamily()\n : bootRelayFamily({\n verifyAuth: buildRelayVerifyAuth(),\n onWssUrl: (wssUrl) => {\n lockHandle.updateWssUrl(wssUrl);\n qrServer?.notifyStateChange();\n },\n // FIX 3 (issue #571): persist cloudflared child PID for zombie detection.\n // Also update the in-memory tracker (source a for FIX 2).\n onTunnelChildPid: (pid) => {\n activeTunnelChildPid = pid;\n lockHandle.updateTunnelChildPid(pid);\n },\n // Issue #467: secret-free relay TOTP 401 counter for get_debug_status.\n onAuthReject: () => diagnosticsCollector.recordAuthReject(),\n // Issue #631: on permanent tunnel drop, immediately push the\n // new state so the dashboard swaps the dead QR for the error\n // state (mirror of onWssUrl's notifyStateChange).\n onTunnelDown: () => qrServer?.notifyStateChange(),\n }),\n diagnosticsCollector,\n devtoolsOpener,\n onPageAttach: () => qrServer?.notifyStateChange(),\n // Fix #705-A: push an immediate SSE update when the phone silently disconnects.\n onPageDetach: () => qrServer?.notifyStateChange(),\n // Stable /inspector URL for auto-open (issue #530).\n getInspectorStableUrl: () => qrServer?.inspectorStableUrl ?? null,\n // (#610) Stale relay-sandbox rebuild: re-read the relay URL on every\n // relay-sandbox re-entry so the router can detect when the dev server was\n // restarted (new quick-tunnel) and rebuild the CDP client accordingly.\n // SECRET-HANDLING: readMobileRelayBaseUrl never logs the URL value.\n readSandboxRelayUrl: (pr) =>\n readMobileRelayBaseUrl(process.env, pr ?? options.projectRoot ?? process.cwd()).catch(\n () => null,\n ),\n });\n\n // AIT.* methods ride the *active* connection's command channel (external relay\n // Chii, or local CDP / intoss Chii after a switch), so the AIT source follows\n // `start_debug` swaps.\n const aitSource = new RoutingAitSource(() => {\n const active = router.active as CdpConnection & {\n sendCommand(method: string, params?: Record<string, unknown>): Promise<unknown>;\n };\n return active;\n });\n\n // dashboard용 lastAttachParts 상태 — start_attach 호출마다 갱신.\n // 완성 URL 대신 컴포넌트를 저장해 getDashboardState 호출마다 fresh TOTP를 mint (Defect 1).\n // SECRET-HANDLING: 컴포넌트에는 tunnel/scheme host가 있으므로 로그 출력 금지.\n let lastAttachParts: AttachUrlParts | null = null;\n\n // 세션 생애주기 phase (#730) — daemon은 'shutdown' 전환 시에만 갱신한다.\n let currentPhase: DashboardState['phase'] = 'active';\n\n const getDashboardState = (): DashboardState => {\n const targets = router.active.listTargets();\n // inspectorUrl — /inspector 안정 진입점 (issue #530).\n // SECRET-HANDLING: /inspector URL 자체에 시크릿 없음 — 출력 가능.\n const inspectorUrl = qrServer?.inspectorStableUrl ?? null;\n return {\n tunnel: { up: router.relayTunnelStatus().up, wssUrl: router.relayTunnelStatus().wssUrl },\n pages: targets.map((t) => ({ id: t.id, url: t.url })),\n attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,\n inspectorUrl,\n phase: currentPhase, // #730\n };\n };\n\n // getDirectInspectorUrl — /inspector 라우트에서 직접 chii front_end URL을 조립.\n // getDashboardState().inspectorUrl(= /inspector 자기 자신)을 쓰면 무한 루프가 발생하므로\n // 별도 getter로 분리한다. 매 요청마다 호출되어 TOTP를 요청 시점에 mint한다.\n // SECRET-HANDLING: ok:true url에 relay host + at= 코드가 담긴다 — 로그/stdout 출력 금지.\n const getDirectInspectorUrl = (): ReturnType<\n NonNullable<QrHttpServerOptions['getDirectInspectorUrl']>\n > => {\n const relayHttpUrl = router.activeRelayHttpUrl;\n if (!relayHttpUrl) {\n return { ok: false, reason: 'relayDown' };\n }\n const targets = router.active.listTargets();\n if (targets.length === 0) {\n return { ok: false, reason: 'noTarget' };\n }\n const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;\n if (!totpSecret) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n const url = buildChiiInspectorUrl(relayHttpUrl, targets[0].id, () =>\n generateTotp(totpSecret, Date.now()),\n );\n if (url === null) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n return { ok: true, url };\n };\n\n // Local QR HTTP server — awaited so the first start_attach call doesn't\n // race its startup. Failure falls back to text QR.\n let qrServer: QrHttpServer | undefined;\n try {\n qrServer = await startQrHttpServer(getDashboardState, { getDirectInspectorUrl });\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n logWarn('server.start', { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}` });\n }\n\n // TOTP 주기 갱신 타이머 — 이벤트 없이 페이지가 방치될 때 at= 코드가 stale되는 갭 수정 (#448).\n // TOTP step은 30초이므로 20초 주기로 push해 step 경계를 놓치지 않는다.\n // SECRET-HANDLING: 콜백은 단순 trigger만 — TOTP 값·at= 코드는 절대 로그/stdout 출력 금지.\n const TOTP_REFRESH_INTERVAL_MS = 20_000;\n let totpRefreshHandle: ReturnType<typeof setInterval> | null = null;\n totpRefreshHandle = setInterval(() => {\n if (lastAttachParts !== null) {\n qrServer?.notifyStateChange();\n }\n }, TOTP_REFRESH_INTERVAL_MS);\n totpRefreshHandle.unref();\n\n const server = createDebugServer({\n connection: router.active,\n router,\n aitSource,\n // Tunnel status follows the active relay family — once the env-2 external\n // relay is lazy-booted it reports up with its wss URL, so start_attach is\n // satisfied without us opening a cloudflared tunnel.\n getTunnelStatus: () => router.relayTunnelStatus(),\n // FIX (issue #572 review): expose the live cloudflared child PID (source a)\n // so get_debug_status can feed it into getDiagnostics for the FIX 2 probe.\n getTunnelChildPid: () => activeTunnelChildPid,\n get qrHttpServer() {\n return qrServer;\n },\n diagnosticsCollector,\n // SECRET-HANDLING: the TOTP secret is read from env AT CALL TIME (inside\n // start_attach) so the project-local .ait_relay secret loaded by\n // switchMode (#396) is visible. It is used only to generate the at= code and\n // is never logged or surfaced in any output.\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n // dashboard 갱신 콜백 — URL 컴포넌트 저장 후 SSE push (Defect 1 fix).\n onAttachUrlBuilt: (parts) => {\n lastAttachParts = parts;\n qrServer?.notifyStateChange();\n },\n });\n\n const transport = new StdioServerTransport();\n\n // ---------------------------------------------------------------------------\n // Unified dual-family shutdown (issues #356, #378, #396, mirrors the other run\n // functions): tears down every family ever booted at process exit (all lazy\n // now). The external relay family's stop() closes ONLY our CDP client (the\n // unplugin owns the relay + tunnel); a lazily-booted intoss relay family stops\n // its own tunnel + probe + relay; a lazily-booted local family kills its\n // Chromium.\n // ---------------------------------------------------------------------------\n\n let closed = false;\n let parentWatcher: { stop(): void } | null = null;\n let maxAgeWatchdog: { stop(): void } | null = null;\n\n const shutdown = () => {\n if (closed) return;\n closed = true;\n\n // #730: push the terminal SSE frame FIRST — see runDebugServer's shutdown\n // for the full ordering rationale (mirrors that daemon variant).\n currentPhase = 'shutdown';\n qrServer?.notifyStateChange();\n\n parentWatcher?.stop();\n maxAgeWatchdog?.stop();\n if (totpRefreshHandle) clearInterval(totpRefreshHandle);\n router.stopWatcher();\n for (const family of router.bootedFamilies()) family.stop();\n void server.close();\n void qrServer?.close();\n lockHandle.release();\n };\n\n process.once('SIGINT', shutdown);\n process.once('SIGTERM', shutdown);\n process.once('SIGHUP', shutdown);\n\n process.on('exit', () => {\n if (!closed) {\n closed = true;\n parentWatcher?.stop();\n maxAgeWatchdog?.stop();\n if (totpRefreshHandle) clearInterval(totpRefreshHandle);\n router.stopWatcher();\n for (const family of router.bootedFamilies()) family.stop();\n lockHandle.release();\n }\n });\n\n process.on('uncaughtException', (err) => {\n logError('tool.error', {\n msg: `uncaughtException: ${String(err)}`,\n errorKind: 'uncaught',\n mode: 'relay-sandbox',\n });\n shutdown();\n process.exit(1);\n });\n\n process.on('unhandledRejection', (reason) => {\n logError('tool.error', {\n msg: `unhandledRejection: ${String(reason)}`,\n errorKind: 'unhandled-rejection',\n mode: 'relay-sandbox',\n });\n shutdown();\n process.exit(1);\n });\n\n await server.connect(transport);\n\n // Bind the server to the router. No family is active yet (all-lazy, #396) —\n // the attach watcher is armed by the first `start_debug` and re-armed on every\n // swap.\n router.start(server);\n\n // Self-terminate when the parent process has died without sending SIGTERM/SIGHUP.\n if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== '1') {\n parentWatcher = startParentWatcher(\n () => {\n shutdown();\n process.exit(0);\n },\n { intervalMs: 5_000 },\n );\n process.stdin.once('end', () => {\n shutdown();\n process.exit(0);\n });\n process.stdin.once('close', () => {\n shutdown();\n process.exit(0);\n });\n }\n\n // FIX 4 (issue #571): max-age watchdog.\n if (process.env.AIT_DEBUG_NO_MAX_AGE !== '1') {\n const maxAgeMs = process.env.AIT_DEBUG_MAX_AGE_MS\n ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || undefined\n : undefined;\n maxAgeWatchdog = startMaxAgeWatchdog(\n () => {\n process.stderr.write(\n '[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\\n',\n );\n shutdown();\n process.exit(0);\n },\n { maxAgeMs },\n );\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AA0Bc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA6EZ,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC9Bb,MAAM,UAAU,cAAc,OAAO,KAAK,IAAI;;;;;;;;AAS9C,MAAM,gCAAgC;;;;;;;;AAsBtC,SAAS,sBAA0D;AACjE,KAAI;EACF,MAAM,MAAe,QAAQ,kCAAkC;AAC/D,MAAI,OAAO,QAAQ,WACjB,QAAO;SAEH;AAGR,QAAO;;;;;;;;;;;;;;;;;;;;;AAsBT,eAAe,qBACb,MACA,cACA,cACiC;AACjC,KAAI,iBAAiB,MAAM;AACzB,QAAM,KAAK,MAAM,aAAa;AAC9B,SAAO;;CAGT,IAAI,WAAmC;CACvC,MAAM,QAAQ,aAAa;CAC3B,MAAM,gBAAgB,MAAM;AAE5B,OAAM,QAAQ,SAAiC,QAAQ;AACrD,aAAW;AACX,SAAO,cAAc,KAAK,MAAM,OAAO;;AAGzC,KAAI;AACF,QAAM,KAAK,MAAM,aAAa;WACtB;AAER,QAAM,QAAQ;;AAGhB,QAAO;;AAcT,SAAS,iBAAmC;CAE1C,MAAM,MAAe,QAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;;;;AAyC9D,SAAgB,oBAAoB,QAA+B;CACjE,MAAM,QAAQ,oCAAoC,KAAK,OAAO;AAC9D,KAAI,UAAU,KAAM,QAAO;CAC3B,MAAM,OAAO,MAAM;CACnB,MAAM,OAAO,MAAM,OAAO,KAAA,KAAa,MAAM,OAAO,KAAK,MAAM,MAAM;CACrE,MAAM,QAAQ,MAAM,MAAM;AAE1B,QAAO,GAAG,OAAO,QADC,UAAU,KAAK,MAAM,IACJ,KAAK;;;;;;;;;;;;;;;;;;AA+E1C,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,YAAY,iBAAiB;CACrC,MAAM,sBACJ,QAAQ,wBAAwB,KAAA,IAC5B,QAAQ,sBACR;CAEN,MAAM,aAAa,cAAc;CAIjC,MAAM,oBAAoB,SAA6C;AACrE,MAAI,iBAAiB,KAAA,EAAW;AAChC,MAAI;AACF,gBAAa,EAAE,MAAM,CAAC;UAChB;;AAiBV,KAAI,WAuBF,YAAW,GAAG,YAAY,KAAK,QAAQ;EACrC,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,MAAI,cAAc,MAAM;AAEtB,OAAI,MAAM;AACV,OAAI,CAAC,WAAW,IAAI,EAAE;AAMpB,QAAI,aAAa;AACjB,QAAI,UAAU,+BAA+B,IAAI;AACjD,QAAI,UAAU,gBAAgB,mBAAmB;AACjD,QAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,qBAAiB,eAAe;;AAIlC;;EAMF,MAAM,YAAY,IAAI,OAAO,IAAI,MAAM,IAAI,CAAC;AAC5C,MAAI,aAAa,cAAc,aAAa,aAAa;AAIvD,OAAI,CAAC,WAAW,IAAI,EAAE;AAEpB,QAAI,aAAa;AACjB,QAAI,UAAU,+BAA+B,IAAI;AACjD,QAAI,UAAU,gBAAgB,mBAAmB;AACjD,QAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,qBAAiB,eAAe;AAEhC;;AAOF;;GAQF;CAgBJ,MAAM,eAAe,sBAAsB,IAAI,qBAAqB,GAAG;CACvE,MAAM,kBAAkB,MAAM,qBAC5B,gBAAgB,EAChB;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,EAC/E,aACD;AAUD,KAAI,YAAY;EACd,MAAM,uBAAuB,WAAW,UAAU,UAAU;AAG5D,aAAW,mBAAmB,UAAU;EAGxC,MAAM,YAAY,IAAI,gBAAgB,EAAE,UAAU,MAAM,CAAC;AACzD,aAAW,GAAG,YAAY,KAAsB,QAAgB,SAAiB;GAK/E,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,OAAI,cAAc,KAChB,KAAI,MAAM;AAEZ,OAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,cAAU,cAAc,KAAK,QAAQ,OAAO,OAAO;AACjD,QAAG,MAAM,8BAA8B,yBAAyB;MAChE;AACF,qBAAiB,aAAa;AAE9B;;AAIF,QAAK,MAAM,YAAY,qBACrB,UAAS,KAAK,QAAQ,KAAK;IAE7B;;CAGJ,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;CAYF,IAAI,kBAAyD;AAC7D,KAAI,sBAAsB,KAAK,oBAAoB,MAAM;EACvD,MAAM,UAAU;AAChB,oBAAkB,kBAAkB;AAClC,QAAK,MAAM,UAAU,QAAQ,KAAK,QAEhC,KAAI,OAAO,eAAe,EACxB,QAAO,MAAM;KAGhB,oBAAoB;;AAGzB,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,OAAI,oBAAoB,MAAM;AAC5B,kBAAc,gBAAgB;AAC9B,sBAAkB;;AAEpB,cAAW,YAAY,SAAS,CAAC;IACjC;EACL;;;;;;;;;;;;;;;AC+4BH,SAAS,sBAAsB,cAAyC;AAMtE,QAAO,IAAI,kBAAkB;EAC3B;EACA,YAAY,QAAQ,IAAI;EACzB,CAAC;;;;;;;;;;;;;;;;;;;;;;;AAyKJ,eAAsB,gBAAgB,UAAkC,EAAE,EAAyB;AAMjG,4BAA2B;CAK3B,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,cAAc,QAAQ,eAAe,KAAA;CAE3C,MAAM,QAAQ,MAAM,eAAe;EACjC,MAAM;EACN,YAAY,QAAQ;EACpB,cAAc,QAAQ;EACvB,CAAC;AAEF,SAAQ,gBAAgB;EAAE,MAAM,MAAM;EAAM;EAAa,CAAC;CAE1D,IAAI,SAA6B;CACjC,IAAI,eAA6B,iBAAiB,OAAO,KAAK;CAC9D,IAAI,cAAuC;AAG5B,sBAAqB;AAShB,kBAAiB,MAAM,KAAK,CAAC,MAC9C,MAAM;AACL,WAAS;AACT,iBAAe,iBAAiB,MAAM,EAAE,OAAO;AAC/C,UAAQ,WAAW,EAAE,OAAO;AAI5B,MAAI,EAAE,aAAa,KAAA,EACjB,SAAQ,mBAAmB,EAAE,SAAS;AAGxC,UAAQ,aAAa,EAAE,aAAa,CAAC;AAIrC,gBAAc,uBAAuB,GAAG,MAAM,MAAM;GAClD,YAAY,cAAc;AACxB,aAAS;AACT,mBAAe,iBAAiB,MAAM,UAAU,QAAQ,MAAM,EAAE;AAChE,YAAQ,WAAW,UAAU,OAAO;AAIpC,QAAI,UAAU,aAAa,KAAA,EACzB,SAAQ,mBAAmB,UAAU,SAAS;AAG3C,sBAAkB;KAAE,QAAQ,UAAU;KAAQ;KAAa,CAAC,CAAC,WAAW;AAC3E,aAAQ,aAAa;MAAE;MAAa,UAAU;MAAM,CAAC;MACrD;;GAEJ,kBAAkB,cAAc;AAC9B,mBAAe,iBAAiB,OAAO,MAAM,WAAW,EAAE;AAC1D,aAAS,eAAe,EACtB,KAAK,+BAA+B,UAAU,gDAC/C,CAAC;AAKF,YAAQ,gBAAgB;;GAE3B,CAAC;AAEF,SAAO,kBAAkB;GAAE,QAAQ,EAAE;GAAQ;GAAa,CAAC;KAE5D,QAAQ;AAEP,WAAS,eAAe,EACtB,KAAK,4CAFS,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI,CAEL,gGAC1D,CAAC;GAEL;CAKD,MAAM,aAAa,sBAAsB,MAAM,QAAQ;AAEvD,QAAO;EACL;EAEA,aAAa;EAGb,cAAc,MAAM;EACpB,uBAAuB;EACvB,OAAO;AACL,gBAAa,MAAM;AAEnB,WAAQ,MAAM;AACd,cAAW,OAAO;AAEb,SAAM,OAAO;;EAErB"}
|