@ait-co/devtools 0.1.118 → 0.1.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/{attach-orchestrator-BZsqoFjB.js → attach-orchestrator-BJr41GN8.js} +2 -2
  2. package/dist/{attach-orchestrator-BZsqoFjB.js.map → attach-orchestrator-BJr41GN8.js.map} +1 -1
  3. package/dist/{attach-orchestrator-BEMGBeWm.js → attach-orchestrator-BbCfcfT0.js} +1 -1
  4. package/dist/bundle-CA1TCXED.d.ts.map +1 -1
  5. package/dist/{cell-Dp_pDs_j.js → cell-BCNQJ_cS.js} +2 -2
  6. package/dist/{cell-Dp_pDs_j.js.map → cell-BCNQJ_cS.js.map} +1 -1
  7. package/dist/{cell-BDFMsPXV.js → cell-CJcDstzr.js} +1 -1
  8. package/dist/{cli-BA1ynNNx.js → cli-RhzASQef.js} +45 -27
  9. package/dist/cli-RhzASQef.js.map +1 -0
  10. package/dist/{debug-server-krAOy6cl.js → debug-server-Buc8yoa7.js} +44 -26
  11. package/dist/debug-server-Buc8yoa7.js.map +1 -0
  12. package/dist/{debug-server-T8i9_iCc.js → debug-server-C_5ag9jj.js} +2 -2
  13. package/dist/{debug-server-T8i9_iCc.js.map → debug-server-C_5ag9jj.js.map} +1 -1
  14. package/dist/{debug-server-wo18jR4T.js → debug-server-DNqP-T9V.js} +49 -31
  15. package/dist/debug-server-DNqP-T9V.js.map +1 -0
  16. package/dist/mcp/cli.js +3 -3
  17. package/dist/mcp/server.js +1 -1
  18. package/dist/panel/index.js +1 -1
  19. package/dist/{pool-U9FrxEuR.d.ts → pool-D5QH6Ydv.d.ts} +2 -2
  20. package/dist/{pool-U9FrxEuR.d.ts.map → pool-D5QH6Ydv.d.ts.map} +1 -1
  21. package/dist/{relay-worker-D-3aAyDO.d.ts → relay-worker-BBu1oEqC.d.ts} +2 -2
  22. package/dist/{relay-worker-D-3aAyDO.d.ts.map → relay-worker-BBu1oEqC.d.ts.map} +1 -1
  23. package/dist/{runtime-D57Rtnsd.d.ts → runtime-Bm_sT1C9.d.ts} +2 -2
  24. package/dist/{runtime-D57Rtnsd.d.ts.map → runtime-Bm_sT1C9.d.ts.map} +1 -1
  25. package/dist/test-runner/bundle.js +41 -23
  26. package/dist/test-runner/bundle.js.map +1 -1
  27. package/dist/test-runner/cli.js +1 -1
  28. package/dist/test-runner/config.d.ts +1 -1
  29. package/dist/test-runner/pool.d.ts +1 -1
  30. package/dist/test-runner/relay-factory.js +1 -1
  31. package/dist/test-runner/relay-worker.d.ts +1 -1
  32. package/dist/test-runner/report.d.ts +2 -2
  33. package/dist/test-runner/rpc.d.ts +1 -1
  34. package/dist/test-runner/runtime.d.ts +1 -1
  35. package/dist/test-runner/runtime.js +5 -1
  36. package/dist/test-runner/runtime.js.map +1 -1
  37. package/dist/test-runner/task-graph.d.ts +1 -1
  38. package/package.json +1 -1
  39. package/dist/cli-BA1ynNNx.js.map +0 -1
  40. package/dist/debug-server-krAOy6cl.js.map +0 -1
  41. package/dist/debug-server-wo18jR4T.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"attach-orchestrator-BZsqoFjB.js","names":["isPidAlive","_isPidAlive","defaultCanOpenBrowser"],"sources":["../src/shared/parent-watcher.ts","../src/mcp/deeplink.ts","../src/mcp/errors.ts","../src/mcp/log.ts","../src/mcp/environment.ts","../src/mcp/sdk-signatures.ts","../src/mcp/server-lock.ts","../src/mcp/tools.ts","../src/mcp/tunnel.ts","../src/mcp/attach-orchestrator.ts"],"sourcesContent":["/**\n * Shared parent-PID watcher — used by both the MCP debug daemon and the\n * unplugin tunnel path to self-terminate when the parent process (e.g. Claude\n * Code, vite) has died or been reparented without sending SIGTERM/SIGHUP.\n *\n * Intentionally react-free and Node-stdlib-only so this module is safe to\n * import from the MCP daemon bundle (`dist/mcp/cli.js`) without violating the\n * install-graph invariant.\n */\n\n// ---------------------------------------------------------------------------\n// isPidAlive — extracted from src/mcp/server-lock.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when the given PID refers to a running process.\n *\n * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process\n * exists and we have permission to signal it; throws ESRCH when it doesn't exist.\n */\nexport function isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (err: unknown) {\n // ESRCH = no such process → stale lock.\n // EPERM = process exists but we can't signal it (still alive).\n if ((err as NodeJS.ErrnoException).code === 'EPERM') return true;\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// startParentWatcher — extracted from src/mcp/debug-server.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Starts a periodic watcher that detects when the parent process (e.g. Claude\n * Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the\n * daemon can self-terminate rather than running as a zombie.\n *\n * Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns\n * `{ stop(): void }`, injectable deps for testability.\n *\n * @param onOrphaned - Called once when the parent is gone.\n * @param opts.intervalMs - Poll interval in milliseconds (default 5 000).\n * @param opts.initialPpid - Parent PID to watch (default `process.ppid`).\n * @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).\n * @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).\n * Detects ppid changes as well as death.\n * @param opts.log - Logger (default `process.stderr.write`).\n *\n * @returns `stop` — call during shutdown to clear the interval.\n */\nexport function startParentWatcher(\n onOrphaned: () => void,\n opts?: {\n intervalMs?: number;\n initialPpid?: number;\n isAlive?: (pid: number) => boolean;\n getPpid?: () => number;\n log?: (msg: string) => void;\n },\n): { stop(): void } {\n const {\n intervalMs = 5_000,\n initialPpid = process.ppid,\n isAlive = isPidAlive,\n getPpid = () => process.ppid,\n log = (msg: string) => process.stderr.write(msg),\n } = opts ?? {};\n\n // PID 1 is init/launchd — running under a process manager or as a detached\n // daemon. There is no meaningful parent to watch; skip the watcher entirely.\n if (initialPpid <= 1) {\n log('[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\\n');\n return { stop() {} };\n }\n\n let fired = false;\n\n const handle = setInterval(() => {\n if (fired) return;\n\n const currentPpid = getPpid();\n const orphaned = currentPpid !== initialPpid || !isAlive(initialPpid);\n\n if (orphaned) {\n fired = true;\n clearInterval(handle);\n log(\n `[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\\n`,\n );\n onOrphaned();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// startMaxAgeWatchdog — FIX 4: daemon lifetime cap\n// ---------------------------------------------------------------------------\n\n/**\n * Starts a periodic watchdog that calls `onExpired` once after `maxAgeMs`\n * milliseconds have elapsed since the watchdog was created.\n *\n * Motivation (issue #571): cloudflared quick-tunnel lifetimes are finite (a\n * few hours). A daemon that has been running for days will have outlived its\n * tunnel regardless of whether the tunnel process exited cleanly. This watchdog\n * caps the daemon's maximum age and forces a fresh start so the tunnel is\n * replaced before it silently expires.\n *\n * @param onExpired - Called once when the maximum age is reached. The caller\n * should call `shutdown()` then `process.exit(0)`.\n * @param opts.maxAgeMs - Maximum daemon lifetime in ms. Default 6 h.\n * @param opts.intervalMs - Check interval in ms. Default 60 000 (1 min).\n * @param opts.now - Time source (injectable for tests). Default `Date.now`.\n *\n * @returns `stop` — call during shutdown to clear the interval.\n */\nexport function startMaxAgeWatchdog(\n onExpired: () => void,\n opts: {\n maxAgeMs?: number;\n intervalMs?: number;\n now?: () => number;\n } = {},\n): { stop(): void } {\n const {\n maxAgeMs = 6 * 60 * 60 * 1_000, // 6 hours\n intervalMs = 60_000,\n now = () => Date.now(),\n } = opts;\n\n const startedAt = now();\n let fired = false;\n\n const handle = setInterval(() => {\n if (fired) return;\n if (now() - startedAt >= maxAgeMs) {\n fired = true;\n clearInterval(handle);\n onExpired();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n","/**\n * URL of the AITC Sandbox launcher PWA.\n *\n * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the\n * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy\n * for the same reason — keep the two in sync when the URL changes.\n */\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Optional metadata that enriches the launcher deep-link (#498).\n *\n * These fields are added as query params so the launcher PWA can display\n * a recognizable identity (name, icon) without the user having to configure\n * anything extra.\n */\nexport interface LauncherAttachUrlOpts {\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * Absolute `https://` icon URL for the partner nav bar icon slot (`icon=`\n * param). Non-https or falsy values are not added.\n */\n icon?: string;\n /**\n * When `true`, adds `selfdebug=1` to the launcher URL so the launcher PWA\n * registers its own document as a CDP target (issue #531/#543).\n *\n * **Single-attach model**: attaching the launcher self-target causes any\n * currently-attached mini-app target to be evicted. This is intentional —\n * `selfdebug` is a \"launcher diagnostics mode\" for inspecting the launcher's\n * own DOM/console/safe-area, not simultaneous dual-attach.\n *\n * When `false` or omitted (default), the param is not added and the output\n * is byte-identical to the previous behaviour.\n */\n selfdebug?: boolean;\n}\n\n/**\n * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).\n *\n * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport\n * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the\n * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js\n * is injected. `&at=<totpCode>` is added only when a code is provided (same\n * conditional as {@link buildDeepLinkAttachUrl}).\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the\n * launcher partner bar shows the app name instead of the generic default (#498).\n * When `opts.icon` is an absolute https:// URL, it is added as `&icon=` so the\n * launcher can render an icon next to the title (#498).\n *\n * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL\n * via raw string manipulation), this function uses WHATWG `encodeURIComponent`\n * because the target is a standard `https:` URL.\n *\n * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param\n * only — never logged or returned separately. Callers must NOT log the result\n * of this function to stdout/stderr.\n *\n * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL\n * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.\n * @param wssUrl - The `wss://` relay URL the framed page will attach to.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is appended as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Omit when TOTP is disabled.\n * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`\n * (#498, #543).\n * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>\n * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.\n */\nexport function buildLauncherAttachUrl(\n tunnelUrl: string,\n wssUrl: string,\n totpCode?: string,\n opts?: LauncherAttachUrlOpts,\n): string {\n let url =\n `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}` +\n `&debug=1&relay=${encodeURIComponent(wssUrl)}`;\n if (totpCode !== undefined && totpCode !== '') {\n url += `&at=${encodeURIComponent(totpCode)}`;\n }\n // App identity hints (#498): add non-blank name and valid https icon.\n if (opts?.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts?.icon !== undefined) {\n let iconParsed: URL;\n try {\n iconParsed = new URL(opts.icon);\n } catch {\n iconParsed = null as unknown as URL;\n }\n if (iconParsed?.protocol === 'https:') {\n url += `&icon=${encodeURIComponent(opts.icon)}`;\n }\n }\n // Self-debug opt-in (#543): add selfdebug=1 only when explicitly requested.\n // Without this flag the output is byte-identical to the previous behaviour.\n if (opts?.selfdebug === true) {\n url += '&selfdebug=1';\n }\n return url;\n}\n\n/**\n * Build a self-attaching dog-food deep-link.\n *\n * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`\n * URL that opens a dog-food bundle on a phone. The in-app debug gate\n * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries\n * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus\n * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result\n * as a QR code and scanning it with the phone camera opens the mini-app and\n * attaches it to the live Chii relay. QR is the single entry path — it needs\n * no USB cable, platform CLI, or driver, and works the same on iOS/Android.\n *\n * The Toss app propagates extra query params from the entry deep link into the\n * mini-app WebView's `location.search` (confirmed behavior), so the gate reads\n * them at attach time.\n *\n * TOTP `at=` param:\n * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional\n * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.\n * The code must be computed by the caller at call time — do NOT pre-compute\n * and cache it, because the 30-second window expires quickly. The in-app gate\n * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.\n *\n * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.\n * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query\n * decomposition you can rely on across runtimes), so query manipulation via\n * `url.searchParams` is not portable here. We splice the query string directly\n * on the raw string instead, which keeps the scheme, authority, path, and any\n * pre-existing params (notably `_deploymentId`) byte-for-byte intact.\n */\n\n/**\n * Suspicious/generic authority values that indicate a malformed or placeholder\n * scheme URL. These are host strings that will almost certainly cause the Toss\n * app to fail with \"bundle not found\" silently.\n *\n * The expected form from `ait deploy --scheme-only` is:\n * intoss-private://<appName>?_deploymentId=<uuid>\n * where `<appName>` is a non-generic string like `aitc-sdk-example`.\n */\nconst SUSPICIOUS_AUTHORITIES = new Set<string>(['', 'web', 'localhost', '127.0.0.1', 'app']);\n\n/**\n * Validates the authority (host) portion of a scheme URL.\n *\n * Returns a warning message if the authority is missing or looks like a\n * placeholder, or `null` if the authority looks valid.\n *\n * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`\n * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).\n */\nexport function validateSchemeAuthority(schemeUrl: string): string | null {\n // Extract authority from `scheme://authority[/path][?query][#hash]`.\n // We cannot use the WHATWG URL parser for non-special schemes reliably\n // (see the deeplink.ts module comment), so we parse the raw string.\n const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\\-.]*:\\/\\//, '');\n if (afterScheme === schemeUrl) {\n // No `://` found — not a scheme URL at all.\n return (\n 'scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). ' +\n 'Use the URL printed by `ait deploy --scheme-only`.'\n );\n }\n\n // authority ends at the first `/`, `?`, `#`, or end of string.\n const authorityEnd = afterScheme.search(/[/?#]/);\n const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);\n\n if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) {\n const displayAuthority = authority === '' ? '(empty)' : `\"${authority}\"`;\n return (\n `scheme_url authority ${displayAuthority} looks like a placeholder. ` +\n 'Expected an app name like `intoss-private://aitc-sdk-example?_deploymentId=<uuid>`. ' +\n 'Use the URL printed by `ait deploy --scheme-only` — it includes the correct app name as the host.'\n );\n }\n\n return null;\n}\n\n/** A param the helper appends. Existing occurrences are replaced, not duplicated. */\ntype AppendParam = readonly [key: string, value: string];\n\nfunction stripExisting(query: string, key: string): string {\n if (query === '') return '';\n return query\n .split('&')\n .filter((pair) => pair !== '' && pair.split('=')[0] !== key)\n .join('&');\n}\n\n/**\n * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a\n * scheme URL's query string, preserving everything else (scheme, authority,\n * path, hash, and the existing `_deploymentId` param). If any of the spliced\n * params is already present it is replaced so the helper is idempotent.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed\n * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B\n * of the gate); this helper does not invent one.\n * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the\n * running debug MCP server's quick tunnel.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Pass `undefined` or omit when TOTP is disabled.\n * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`\n * appended.\n * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so\n * producing such a link would be a silent dead end).\n */\nexport function buildDeepLinkAttachUrl(\n schemeUrl: string,\n wssUrl: string,\n totpCode?: string,\n): string {\n let relay: URL;\n try {\n relay = new URL(wssUrl);\n } catch {\n throw new Error(`relay URL is not a valid URL: ${wssUrl}`);\n }\n if (relay.protocol !== 'wss:') {\n throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);\n }\n\n const hashIndex = schemeUrl.indexOf('#');\n const hash = hashIndex === -1 ? '' : schemeUrl.slice(hashIndex);\n const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);\n\n const queryIndex = beforeHash.indexOf('?');\n const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);\n let query = queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1);\n\n const appended: AppendParam[] = [\n ['debug', '1'],\n ['relay', wssUrl],\n ];\n // Only splice `at=` when a code is provided (TOTP enabled). Omitting it when\n // TOTP is disabled preserves backward compatibility with gate deployments\n // that do not yet evaluate the `at` param.\n if (totpCode !== undefined && totpCode !== '') {\n appended.push(['at', totpCode]);\n }\n\n // Always strip the `at` key from the existing query so a stale code from a\n // previous run is removed even when the caller does not provide a fresh code.\n query = stripExisting(query, 'at');\n\n for (const [key] of appended) {\n query = stripExisting(query, key);\n }\n for (const [key, value] of appended) {\n const pair = `${key}=${encodeURIComponent(value)}`;\n query = query === '' ? pair : `${query}&${pair}`;\n }\n\n return `${base}?${query}${hash}`;\n}\n","/**\n * MCP tool 거부/에러 응답 메시지 헬퍼 — 4상태 차별화 + Tier 거부 통일.\n *\n * 모든 tool 거부/에러 응답을 \"원인 + 다음 행동\" 한국어 한 줄 포맷으로 일원화한다.\n * debug-server.ts · tools.ts의 거부 응답 호출부가 이 헬퍼를 통해 생성된다.\n *\n * 4가지 상태 (진단 메시지 차별화):\n * - tunnel-down : cloudflared 터널 미가동 — 서버 재시작 필요\n * - page-missing : 페이지가 attach 안 됨 — start_attach → QR 스캔\n * - page-crash : 페이지 crash 감지 — 앱 재실행 후 재attach\n * - sdk-absent : window.__sdkCall 미주입 — dog-food 채널로 재배포\n *\n * `liveGuardError` (relay-live env 4 guard)는 #665에서 제거됨.\n * relay-live / LIVE guard 전체가 positive-allowlist kill-switch로 대체됐다.\n */\n\n/** MCP tool-result 에러 응답 형식. */\nexport interface McpErrorResult {\n content: Array<{ type: 'text'; text: string }>;\n isError: true;\n}\n\n/**\n * 한국어 한 줄 \"원인 + 다음 행동\" 포맷으로 에러 결과를 빌드한다.\n *\n * @param message - 사용자에게 보여줄 에러 본문 (원인 + 다음 행동 포함).\n */\nexport function mcpError(message: string): McpErrorResult {\n return {\n content: [{ type: 'text', text: message }],\n isError: true,\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Tier 거부 메시지 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Tier A/B 환경 불일치 거부 메시지.\n *\n * @param toolName - 거부된 tool 이름.\n * @param requiredEnv - 해당 tool이 요구하는 환경 ('mock' | 'relay').\n * @param currentEnv - 현재 세션 환경.\n * @param reason - 환경이 결정된 근거를 나타내는 파생 문자열\n * (예: `derived:kind=relay,liveIntent=true`).\n */\nexport function tierRejectionError(\n toolName: string,\n requiredEnv: string,\n currentEnv: string,\n reason: string,\n): McpErrorResult {\n const envLabel = requiredEnv === 'relay' ? 'relay (실기기 연결)' : 'mock (로컬 브라우저)';\n const currentLabel = currentEnv === 'relay' ? 'relay' : 'mock';\n const hint =\n requiredEnv === 'relay'\n ? 'relay로 전환하려면 MCP_ENV=relay 설정 후 서버를 재시작하고 start_attach → QR 스캔으로 실기기를 attach하세요.'\n : 'mock으로 전환하려면 MCP_ENV=mock 설정 후 서버를 재시작하세요.';\n const text =\n `${toolName}은 ${envLabel} 환경에서만 사용할 수 있습니다. ` +\n `현재 환경: ${currentLabel} (${reason}). ${hint}`;\n // 하위 호환 — 기존 테스트가 기대하는 영문 패턴도 유지\n const compat = `tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`;\n return mcpError(`${text}\\n\\n${compat}`);\n}\n\n/* -------------------------------------------------------------------------- */\n/* 4상태 차별화 메시지 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * 상태 1: tunnel 미가동 — cloudflared 터널이 아직 뜨지 않았다.\n *\n * `start_attach` 호출 시 tunnel.up === false 인 경우.\n */\nexport function tunnelDownError(): McpErrorResult {\n return mcpError(\n 'cloudflared 터널이 안 떠 있습니다. ' +\n 'MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.',\n );\n}\n\n/**\n * 상태 2: page 미attach — 터널은 살아 있으나 아직 페이지가 연결되지 않았다.\n *\n * enableDomains()가 \"No mini-app page attached\" 에러를 던질 때.\n */\nexport function pageMissingError(toolName?: string): McpErrorResult {\n const prefix = toolName ? `${toolName}: ` : '';\n return mcpError(\n `${prefix}페이지가 attach 안 됨. ` +\n 'dog-food 번들 배포 후 start_attach를 호출해 QR 생성 + attach까지 진행하세요: ' +\n '`ait deploy --scheme-only` → `start_attach(scheme_url)` → QR 스캔.',\n );\n}\n\n/**\n * 상태 3: page crash — 연결됐던 페이지가 crash/destroy됐다.\n *\n * chii-connection 이 'replaced-by-new-attach' / 'targetCrashed' / 'targetDestroyed' 를\n * 던질 때 이 메시지를 사용한다.\n */\nexport function pageCrashError(toolName?: string): McpErrorResult {\n const prefix = toolName ? `${toolName}: ` : '';\n return mcpError(\n `${prefix}페이지가 crash됐습니다. ` +\n '토스 앱을 재실행한 뒤 start_attach → QR 스캔으로 재attach하세요.',\n );\n}\n\n/**\n * 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다.\n *\n * call_sdk 호출 시 브리지가 없을 때. 같은 \"브리지 부재\"라도 다음 행동은\n * connection 종류에 따라 정반대다 (issue #360):\n * - relay(`--target` 없는 intoss / env-2): dog-food 빌드가 아니다 → dog-food\n * 채널로 재배포 후 QR 재스캔.\n * - local(`--target=local`, env 1 로컬 브라우저): 재배포가 아니라 dev 서버를\n * `pnpm dev`로 띄웠는지 + unplugin alias가 `@apps-in-toss/web-framework`를\n * devtools mock으로 resolve하는지 확인. dev 빌드면 `import.meta.env.DEV`\n * 경로로 `window.__sdkCall`이 자동 설치된다.\n *\n * `isLocal`이 생략되면 relay 안내(이전 동작)를 유지한다.\n */\nexport function sdkAbsentError(toolName?: string, isLocal = false): McpErrorResult {\n const prefix = toolName ? `${toolName}: ` : '';\n if (isLocal) {\n return mcpError(\n `${prefix}window.__sdkCall이 주입되지 않았습니다 (로컬 dev 브리지 부재). ` +\n 'sdk-example을 `pnpm dev`로 띄웠는지, 그리고 unplugin alias가 ' +\n '`@apps-in-toss/web-framework`를 devtools mock으로 resolve하는지 확인하세요. ' +\n 'dev 빌드(`import.meta.env.DEV`)면 `window.__sdkCall`이 자동 설치됩니다.',\n );\n }\n return mcpError(\n `${prefix}window.__sdkCall이 주입되지 않았습니다 (dog-food 빌드가 아닙니다). ` +\n 'dog-food 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: ' +\n '`ait build && aitcc app deploy`.',\n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* relay 연결 끊김 메시지 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * relay WebSocket 연결이 끊겼을 때 — 크래시가 아닌 네트워크/프로세스 종료.\n */\nexport function relayDisconnectError(toolName?: string): McpErrorResult {\n const prefix = toolName ? `${toolName}: ` : '';\n return mcpError(\n `${prefix}relay 연결이 끊겼습니다. ` +\n 'list_pages로 상태를 확인하고, 필요하면 앱을 재실행 후 재attach하세요.',\n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* 일반 tool 에러 메시지 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * CDP/AIT 명령 중 발생한 예외를 4상태로 분류해 적절한 에러 결과를 반환한다.\n *\n * - SDK 부재 패턴 (`window.__sdkCall is not available`) → sdkAbsentError\n * - crash 패턴 (`replaced-by-new-attach`, `targetCrashed`, `targetDestroyed`) → pageCrashError\n * - 연결 끊김 패턴 (`relay에 연결되어 있지 않습니다`, `relay WebSocket`) → relayDisconnectError\n * - 그 외 (일반 에러) → 원본 메시지를 포함한 mcpError\n */\nexport function classifyToolError(err: unknown, toolName: string, isLocal = false): McpErrorResult {\n const message = err instanceof Error ? err.message : String(err);\n\n // 상태 1: tunnel 미가동 (buildAttachUrl이 던지는 패턴)\n if (message.startsWith('tunnel-down:') || message.includes('터널이 안 떠 있습니다')) {\n return tunnelDownError();\n }\n\n // 상태 4: SDK 부재. page-side probe가 던지는 메시지는 relay 가정으로 쓰여\n // 있으나, 안내는 connection 종류로 재구성한다 (issue #360) — local 세션이면\n // dog-food 재배포가 아니라 dev 서버/unplugin alias 확인이 맞다.\n if (\n message.startsWith('sdk-absent:') ||\n message.includes('__sdkCall이 주입되지 않았습니다') ||\n message.includes('window.__sdkCall is not available') ||\n (message.includes('__sdkCall') && message.includes('not available'))\n ) {\n return sdkAbsentError(toolName, isLocal);\n }\n\n // 상태 3: page crash / target destroyed / replaced-by-new-attach\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 연결 끊김 (단순 disconnect — crash 아님)\n if (message.includes('relay에 연결되어 있지 않습니다') || message.includes('relay WebSocket')) {\n return relayDisconnectError(toolName);\n }\n\n // 그 외: 원본 메시지를 포함하되 list_pages 다음 행동 안내 추가\n return mcpError(\n `${toolName} 실패: ${message}\\nlist_pages로 미니앱이 relay에 attach됐는지 확인하세요.`,\n );\n}\n","/**\n * Structured JSON-line server logger + allowlist-based secret redact.\n *\n * Every log line emitted by the debug-mode MCP server is a single JSON object:\n * { \"ts\": \"<ISO-8601>\", \"level\": \"info\"|\"warn\"|\"error\", \"event\": \"<category>\", ...fields }\n *\n * Allowlist approach — only the keys in ALLOWED_KEYS pass through to the output\n * object unchanged. Any value that matches a known-secret pattern is replaced\n * with \"***\" regardless of key name. This provides two complementary layers:\n * 1. Key allowlist — unknown keys (e.g. a future field accidentally containing\n * a credential) are dropped entirely.\n * 2. Value redact — pattern matching catches secrets that slip through under\n * an allowed key name (e.g. a message string that includes a TOTP code).\n *\n * SECRET-HANDLING (MUST NOT appear in stdout/stderr/logs):\n * - TOTP 6-digit codes (pattern: standalone 6-digit run)\n * - AITCC_API_KEY values (pattern: \"aitcc_\" or \"AITCC_\" prefix — Deploy Key format)\n * - cookie header values (pattern: \"cookie:\" header content)\n * - relay WSS URLs (contain the relay host which is semi-sensitive)\n * - \"at=<TOTP>\" query params\n *\n * Canonical event categories:\n * server.start — MCP server started (relay port, TOTP enabled, etc.)\n * tunnel.up — cloudflared tunnel assigned a public URL\n * tunnel.down — tunnel error / shutdown\n * page.attached — first CDP target appeared (deploymentId, env)\n * page.detached — target evicted / session replaced\n * page.crashed — target crash detected\n * tool.call — MCP tool invocation (tool name only — no args/results)\n * tool.error — MCP tool error (tool name + safe error category)\n */\n\n/** Structured log levels. */\nexport type LogLevel = 'info' | 'warn' | 'error';\n\n/** Every valid event category. */\nexport type LogEvent =\n | 'server.start'\n | 'tunnel.up'\n | 'tunnel.down'\n | 'page.attached'\n | 'page.detached'\n | 'page.crashed'\n | 'tool.call'\n | 'tool.error'\n // run_tests progress (#646) — operator-visible in the daemon log, never in\n // the agent response. Carries only counts (secret-free, redact-safe numbers).\n | 'run_tests.start'\n | 'run_tests.done';\n\n/**\n * Allowed field keys that may pass through to a log line.\n * Unknown keys are dropped. Values are still redact-scanned.\n */\nconst ALLOWED_KEYS = new Set([\n 'ts',\n 'level',\n 'event',\n 'msg',\n 'port',\n 'totpEnabled',\n 'env',\n 'tool',\n 'deploymentId',\n 'errorKind',\n 'reason',\n 'prevTargetId',\n 'mode',\n // run_tests progress counts (#646) — numbers, redact-safe.\n 'fileCount',\n 'passed',\n 'failed',\n 'skipped',\n]);\n\n/**\n * Patterns that match secret values.\n * Match order matters — more-specific patterns first.\n *\n * #268 redact script covers: relay=wss://…, at=<TOTP>, _deploymentId=<uuid>.\n * Here we extend to in-process value-level patterns used in server logs.\n */\nconst SECRET_PATTERNS: RegExp[] = [\n // TOTP 6-digit code as a standalone value (whole string is exactly 6 digits).\n /^\\d{6}$/,\n // Deploy Key — AITCC_API_KEY value prefix formats.\n /^(aitcc_|AITCC_)/i,\n // Cookie header value (whole string starts with a cookie-like name=value pair).\n /^[A-Za-z0-9_-]+=.{4,}/,\n // WSS relay URL value.\n /^wss:\\/\\//,\n // TOTP \"at=\" query param embedded in a string.\n /(?:^|[?&])at=[A-Z0-9]{6}/i,\n];\n\n/**\n * Returns `true` when the string value matches any known-secret pattern.\n * Only string values are tested — numbers/booleans are always safe.\n */\nfunction isSecretValue(value: string): boolean {\n return SECRET_PATTERNS.some((re) => re.test(value));\n}\n\n/**\n * Redacts a single scalar value.\n * - strings: return \"***\" if the value matches a secret pattern.\n * - other: return as-is.\n */\nfunction redactValue(value: unknown): unknown {\n if (typeof value === 'string' && isSecretValue(value)) {\n return '***';\n }\n return value;\n}\n\n/**\n * Builds a safe log payload from raw fields.\n *\n * - Only keys in `ALLOWED_KEYS` are included.\n * - String values are scanned for secret patterns and replaced with \"***\".\n * - `ts` and `level` and `event` are always included (they are injected by the\n * logger functions below, not by callers).\n */\nfunction buildPayload(\n level: LogLevel,\n event: LogEvent,\n fields: Record<string, unknown>,\n): Record<string, unknown> {\n const out: Record<string, unknown> = {\n ts: new Date().toISOString(),\n level,\n event,\n };\n\n for (const [key, value] of Object.entries(fields)) {\n if (!ALLOWED_KEYS.has(key)) continue;\n // ts/level/event are controlled above.\n if (key === 'ts' || key === 'level' || key === 'event') continue;\n out[key] = redactValue(value);\n }\n\n return out;\n}\n\n/**\n * Writes a single JSON log line to stderr.\n * MCP stdio transport uses stdout; all diagnostics go to stderr.\n */\nfunction writeLog(level: LogLevel, event: LogEvent, fields: Record<string, unknown> = {}): void {\n const payload = buildPayload(level, event, fields);\n process.stderr.write(`${JSON.stringify(payload)}\\n`);\n}\n\n// ---------------------------------------------------------------------------\n// Public logger functions — one per level.\n// ---------------------------------------------------------------------------\n\n/** Log an informational structured event. */\nexport function logInfo(event: LogEvent, fields: Record<string, unknown> = {}): void {\n writeLog('info', event, fields);\n}\n\n/** Log a warning structured event. */\nexport function logWarn(event: LogEvent, fields: Record<string, unknown> = {}): void {\n writeLog('warn', event, fields);\n}\n\n/** Log an error structured event. */\nexport function logError(event: LogEvent, fields: Record<string, unknown> = {}): void {\n writeLog('error', event, fields);\n}\n\n// ---------------------------------------------------------------------------\n// Exported redact helper for use in tests and callers that need to sanitise\n// before passing to the logger (e.g. error message strings).\n// ---------------------------------------------------------------------------\n\n/**\n * Returns a redacted copy of `value`:\n * - string: \"***\" if it matches a secret pattern, otherwise the original.\n * - other types: returned as-is.\n *\n * Exposed for unit tests and for callers that build dynamic `msg` strings.\n */\nexport function redact(value: unknown): unknown {\n return redactValue(value);\n}\n","/**\n * MCP environment — derived from two orthogonal axes (issue #348).\n *\n * Before #348 the environment was a single sticky decision made once per\n * process by `getEnvironment()` via a 5-step precedence chain (env var → URL\n * pattern sniffing → caller-stated default → baked-in default). That model\n * could not express a daemon that holds two live connections at once and swaps\n * the active one without a restart — the dual-connection design (#348).\n *\n * The 3-value `McpEnvironment` is now *derived* from cheap signals rather\n * than detected (env 4 / relay-live removed in #665):\n *\n * 1. `mock` vs `relay-*` — free from `connection.kind` (`'local'` | `'relay'`,\n * see `cdp-connection.ts`). Authoritative, known before any target\n * attaches, and swappable at runtime by pointing at a different connection.\n *\n * 2. `relay-dev` vs `relay-mobile` — both are `kind: 'relay'` relays,\n * distinguished by the booted family's `relayOrigin` discriminator\n * (`'intoss-webview'` → relay-dev, `'external-pwa'` → relay-mobile,\n * issue #378). NOT sniffed from the relay URL.\n *\n * `McpEnvironment` survives as an OUTPUT-BOUNDARY type — `get_debug_status` and\n * the envelope `meta.env` field still surface the precise three-value string —\n * but it is reconstructed from `(connection.kind, relayOrigin)` via\n * {@link deriveEnvironment}, never sniffed.\n *\n * Positive-allowlist kill-switch (#665): `relay-live` (env 4) is removed.\n * The debug surface is now only active on localhost/trycloudflare/private-apps\n * hosts. `relay-live`/`liveIntent`/LIVE guard are fully removed.\n *\n * SECRET-HANDLING: this module never reads the TOTP secret, deploy key, or any\n * URL. It deals only in the connection kind and optional relay origin.\n */\n\n/**\n * The three environments the MCP server can surface in its output (issues #307,\n * #378, #665).\n *\n * - `mock` — local Chromium + mock SDK (env 1) — active connection is local.\n * - `relay-dev` — real-device dog-food relay (env 3) — relay connection,\n * intoss-private WebView (the relay devtools started).\n * - `relay-mobile` — real-device PWA over an EXTERNAL relay (env 2, issue #378) —\n * relay connection, an external-PWA relay\n * (the unplugin started it; the MCP only attaches a CDP client).\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) at the in-app\n * entry and the MCP server no longer tracks a LIVE intent bit.\n *\n * This is a derived OUTPUT string (see module docstring) — not a detected,\n * sticky decision.\n */\nexport type McpEnvironment = 'mock' | 'relay-dev' | 'relay-mobile';\n\n/** Connection kind — the authoritative `mock` vs `relay` signal (issue #348). */\nexport type ConnectionKind = 'relay' | 'local';\n\n/**\n * Origin of a relay connection — the discriminator that distinguishes two relay\n * families that are otherwise both `kind: 'relay'` (issue #378):\n *\n * - `'intoss-webview'` — the intoss-private dog-food / live relay (env 3/4),\n * booted BY the MCP server (`bootRelayFamily`). Maps to `relay-dev` /\n * `relay-live` depending on `liveIntent`.\n * - `'external-pwa'` — an external CDP relay the unplugin already brought up\n * for the env-2 PWA (`bootExternalRelayFamily`). Maps to `relay-mobile`.\n *\n * Carried on the booted family (NOT sniffed from the relay URL), so the output\n * layer can tell `relay-mobile` apart from `relay-dev`.\n */\nexport type RelayOrigin = 'intoss-webview' | 'external-pwa';\n\n/**\n * Returns `true` when the environment is any relay variant (`relay-dev` or\n * `relay-mobile`). Use this instead of `env === 'relay'` for tier checks —\n * every relay env surfaces the Tier B / relay-only tool set.\n *\n * Written as an exhaustive switch so a future `McpEnvironment` member that is\n * missing an arm is a TS compile error rather than a silent `false`.\n */\nexport function isRelayEnv(env: McpEnvironment): boolean {\n switch (env) {\n case 'relay-dev':\n case 'relay-mobile':\n return true;\n case 'mock':\n return false;\n }\n}\n\n/**\n * Maps the `McpEnvironment` union to the legacy two-value union\n * (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.\n * Every relay variant (`relay-dev`, `relay-mobile`) collapses to `'relay'`.\n * Written as an exhaustive switch so a missing arm is a TS compile error.\n */\nexport function toLegacyEnv(env: McpEnvironment): 'mock' | 'relay' {\n switch (env) {\n case 'mock':\n return 'mock';\n case 'relay-dev':\n case 'relay-mobile':\n return 'relay';\n }\n}\n\n/**\n * Reconstructs the three-value `McpEnvironment` output string from the\n * orthogonal signals (issues #348, #378, #665):\n *\n * - `kind === 'local'` → `'mock'`\n * - `kind === 'relay'` && origin 'external-pwa' → `'relay-mobile'`\n * - `kind === 'relay'` && origin intoss/undefined → `'relay-dev'`\n *\n * `relayOrigin` is the booted-family discriminator (NOT sniffed from the URL)\n * that distinguishes the env-2 external-PWA relay (`relay-mobile`) from the\n * intoss-private dog-food relay (`relay-dev`); both are `kind: 'relay'`.\n *\n * `relay-live` (env 4) has been removed (#665). `liveIntent` parameter is gone.\n *\n * Pure — used at every output boundary (envelope `meta.env`, `get_debug_status`,\n * `measure_safe_area` provenance) so the surface never sniffs a URL again.\n *\n * Written switch-style so a missing arm is a TS compile error (never falls\n * through to a default).\n */\nexport function deriveEnvironment(kind: ConnectionKind, relayOrigin?: RelayOrigin): McpEnvironment {\n switch (kind) {\n case 'local':\n return 'mock';\n case 'relay':\n return relayOrigin === 'external-pwa' ? 'relay-mobile' : 'relay-dev';\n }\n}\n\n/* -------------------------------------------------------------------------- */\n/* Test override hook (narrow) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Test/override hook — when non-null, callers that consult\n * {@link getEnvironmentOverride} return this value regardless of the live\n * connection kind. Production code never sets it; it exists so a unit test can\n * pin a precise `McpEnvironment` without constructing a real connection.\n *\n * This is intentionally NARROW: it no longer drives a precedence chain. The\n * authoritative production signal is `connection.kind` + `relayOrigin`; this\n * override is a pure test affordance.\n */\nlet envOverride: McpEnvironment | null = null;\n\n/** Sets a sticky environment override. Intended for tests only. */\nexport function setEnvironmentOverride(env: McpEnvironment | null): void {\n envOverride = env;\n}\n\n/** Reads the current override (test inspection). */\nexport function getEnvironmentOverride(): McpEnvironment | null {\n return envOverride;\n}\n","/**\n * call_sdk 인자 시그니처 레지스트리\n *\n * 잘 알려진 SDK 메서드의 인자 schema를 수동으로 등록한다.\n * 목적: 잘못된 인자가 native bridge에 도달하기 전에 MCP 레이어에서 reject하여\n * 토스 앱 crash(Swift/Kotlin 측에서 `.type` 등을 undefined로 읽는 경우)를 예방.\n *\n * 등록되지 않은 메서드는 passthrough — 알 수 없는 메서드에 대해 stderr 경고 1회.\n *\n * 시그니처 출처:\n * - `src/__typecheck.ts` — Original SDK 타입 호환성 검증\n * - `src/mock/navigation/index.ts` — mock 구현의 함수 시그니처\n * - `src/mock/device/` — device mock 시그니처\n *\n * 새 메서드 추가 방법:\n * 1. `src/__typecheck.ts` 또는 mock 구현에서 시그니처 확인\n * 2. 아래 SIGNATURES 배열에 `SdkSignature` 항목 추가\n * 3. `src/__tests__/call-sdk-validation.test.ts`에 ok + bad 케이스 추가\n */\n\n/** 단일 메서드에 대한 인자 검증 결과 */\nexport type ValidationResult = { ok: true } | { ok: false; expected: string; received: string };\n\n/** 등록된 SDK 메서드 시그니처 */\nexport interface SdkSignature {\n /** SDK 메서드 이름 (예: \"setDeviceOrientation\") */\n name: string;\n /**\n * 인자 배열을 검증하는 함수.\n * `args[0]` 등 필요한 인자를 `unknown` 타입으로 받아 type guard로 검증.\n */\n validateArgs(args: unknown[]): ValidationResult;\n /**\n * 에러 메시지에 포함할 올바른 호출 예시.\n * 예: `call_sdk('setDeviceOrientation', [{ type: 'landscape' }])`\n */\n example: string;\n}\n\n/* -------------------------------------------------------------------------- */\n/* 헬퍼 — 공통 type guard */\n/* -------------------------------------------------------------------------- */\n\nfunction isObject(v: unknown): v is Record<string, unknown> {\n return typeof v === 'object' && v !== null && !Array.isArray(v);\n}\n\nfunction describeArgs(args: unknown[]): string {\n try {\n return JSON.stringify(args);\n } catch {\n return String(args);\n }\n}\n\n/* -------------------------------------------------------------------------- */\n/* 시그니처 레지스트리 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * 등록된 메서드 목록.\n *\n * 시그니처 출처 확인:\n * - 함수가 인자를 받지 않으면 args[0] 없음 → `args.length === 0`을 체크하지 않고\n * 그냥 통과시킨다(args 무시하는 stub가 많아서 noArgs 체크가 noise).\n * - 실 SDK 시그니처는 `src/__typecheck.ts`의 `Assert<Mock, Original>` 줄로 보장.\n */\nconst SIGNATURES: SdkSignature[] = [\n // --- setDeviceOrientation ---\n // 실 시그니처: setDeviceOrientation(options: { type: 'portrait' | 'landscape' }): Promise<void>\n // 출처: src/mock/navigation/index.ts:40 / src/__typecheck.ts:55\n {\n name: 'setDeviceOrientation',\n validateArgs(args) {\n const arg = args[0];\n if (!isObject(arg)) {\n return {\n ok: false,\n expected: \"{ type: 'portrait' | 'landscape' }\",\n received: describeArgs(args),\n };\n }\n const type = arg.type;\n if (type !== 'portrait' && type !== 'landscape') {\n return {\n ok: false,\n expected: \"{ type: 'portrait' | 'landscape' }\",\n received: describeArgs(args),\n };\n }\n return { ok: true };\n },\n example: \"call_sdk('setDeviceOrientation', [{ type: 'landscape' }])\",\n },\n\n // --- setIosSwipeGestureEnabled ---\n // 실 시그니처: setIosSwipeGestureEnabled(options: { isEnabled: boolean }): Promise<void>\n // 출처: src/mock/navigation/index.ts:32 / src/__typecheck.ts:51\n {\n name: 'setIosSwipeGestureEnabled',\n validateArgs(args) {\n const arg = args[0];\n if (!isObject(arg) || typeof arg.isEnabled !== 'boolean') {\n return {\n ok: false,\n expected: '{ isEnabled: boolean }',\n received: describeArgs(args),\n };\n }\n return { ok: true };\n },\n example: \"call_sdk('setIosSwipeGestureEnabled', [{ isEnabled: false }])\",\n },\n\n // --- setSecureScreen ---\n // 실 시그니처: setSecureScreen(options: { enabled: boolean }): Promise<{ enabled: boolean }>\n // 출처: src/mock/navigation/index.ts:66 / src/__typecheck.ts:46\n {\n name: 'setSecureScreen',\n validateArgs(args) {\n const arg = args[0];\n if (!isObject(arg) || typeof arg.enabled !== 'boolean') {\n return {\n ok: false,\n expected: '{ enabled: boolean }',\n received: describeArgs(args),\n };\n }\n return { ok: true };\n },\n example: \"call_sdk('setSecureScreen', [{ enabled: true }])\",\n },\n\n // --- setScreenAwakeMode ---\n // 실 시그니처: setScreenAwakeMode(options: { enabled: boolean }): Promise<{ enabled: boolean }>\n // 출처: src/mock/navigation/index.ts:57 / src/__typecheck.ts:47\n {\n name: 'setScreenAwakeMode',\n validateArgs(args) {\n const arg = args[0];\n if (!isObject(arg) || typeof arg.enabled !== 'boolean') {\n return {\n ok: false,\n expected: '{ enabled: boolean }',\n received: describeArgs(args),\n };\n }\n return { ok: true };\n },\n example: \"call_sdk('setScreenAwakeMode', [{ enabled: true }])\",\n },\n\n // --- getOperationalEnvironment ---\n // 실 시그니처: getOperationalEnvironment(): 'toss' | 'sandbox'\n // 인자 없음 — args는 무시 (SDK 자체가 인자를 무시함)\n // 출처: src/mock/navigation/index.ts:88 / src/__typecheck.ts:62\n {\n name: 'getOperationalEnvironment',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getOperationalEnvironment', [])\",\n },\n\n // --- getPlatformOS ---\n // 실 시그니처: getPlatformOS(): 'ios' | 'android'\n // 출처: src/mock/navigation/index.ts:84 / src/__typecheck.ts:61\n {\n name: 'getPlatformOS',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getPlatformOS', [])\",\n },\n\n // --- getDeviceId ---\n // 실 시그니처: getDeviceId(): string\n // 출처: src/mock/navigation/index.ts:119 / src/__typecheck.ts:74\n {\n name: 'getDeviceId',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getDeviceId', [])\",\n },\n\n // --- getLocale ---\n // 실 시그니처: getLocale(): string\n // 출처: src/mock/navigation/index.ts:115 / src/__typecheck.ts:72\n {\n name: 'getLocale',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getLocale', [])\",\n },\n\n // --- getNetworkStatus ---\n // 실 시그니처: getNetworkStatus(): Promise<NetworkStatus>\n // 출처: src/mock/navigation/index.ts:127 / src/__typecheck.ts:73\n {\n name: 'getNetworkStatus',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getNetworkStatus', [])\",\n },\n\n // --- getSchemeUri ---\n // 실 시그니처: getSchemeUri(): string\n // 출처: src/mock/navigation/index.ts:111 / src/__typecheck.ts:71\n {\n name: 'getSchemeUri',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getSchemeUri', [])\",\n },\n\n // --- requestReview ---\n // 실 시그니처: requestReview(): Promise<void>\n // 출처: src/mock/navigation/index.ts:75 / src/__typecheck.ts:76\n {\n name: 'requestReview',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('requestReview', [])\",\n },\n\n // --- closeView ---\n // 실 시그니처: closeView(): Promise<void>\n // 출처: src/mock/navigation/index.ts:10 / src/__typecheck.ts:42\n {\n name: 'closeView',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('closeView', [])\",\n },\n];\n\n/* -------------------------------------------------------------------------- */\n/* 레지스트리 공개 API */\n/* -------------------------------------------------------------------------- */\n\nconst SIGNATURE_MAP = new Map<string, SdkSignature>(SIGNATURES.map((s) => [s.name, s]));\n\n/** 세션 내 passthrough 경고를 한 번만 emit하기 위한 Set */\nconst _warnedPassthrough = new Set<string>();\n\n/**\n * 메서드 이름으로 시그니처를 조회한다.\n * 등록된 메서드이면 `SdkSignature`를 반환하고, 미등록이면 `undefined`.\n */\nexport function lookupSignature(name: string): SdkSignature | undefined {\n return SIGNATURE_MAP.get(name);\n}\n\n/**\n * 미등록 메서드에 대해 stderr에 passthrough 경고를 1회 출력한다.\n * 세션 내 동일 메서드 이름은 최초 1회만 출력.\n */\nexport function warnPassthrough(name: string): void {\n if (_warnedPassthrough.has(name)) return;\n _warnedPassthrough.add(name);\n process.stderr.write(`[ait-debug] call_sdk: \"${name}\" 시그니처가 등록되지 않음 — passthrough\\n`);\n}\n\n/**\n * 테스트에서 passthrough 경고 Set을 초기화하기 위한 헬퍼.\n * 프로덕션 코드에서는 호출하지 않는다.\n */\nexport function _resetWarnedPassthroughForTest(): void {\n _warnedPassthrough.clear();\n}\n\n/**\n * 등록된 메서드 이름 목록 — tool description 생성 등에서 사용.\n */\nexport const REGISTERED_METHOD_NAMES: ReadonlyArray<string> = SIGNATURES.map((s) => s.name);\n","/**\n * Single debug session lock for the `devtools-mcp` debug server.\n *\n * At most one debug server process should run on a given machine at a time —\n * multiple concurrent instances create duplicate cloudflared tunnels, waste\n * resources, and confuse the user about which wssUrl to use.\n *\n * ## Lock file\n *\n * Location: `~/.ait-devtools/server.lock`\n *\n * Schema (JSON):\n * ```json\n * { \"pid\": 12345, \"wssUrl\": \"wss://xxx.trycloudflare.com\", \"startedAt\": \"2026-01-01T00:00:00.000Z\" }\n * ```\n *\n * ## Behaviour\n *\n * - **Acquire**: write PID + wssUrl + startedAt. Returns a `release()` handle.\n * - **Stale lock recovery**: if the stored PID is no longer alive\n * (`process.kill(pid, 0)` throws ESRCH), the lock is silently replaced.\n * - **Live conflict (option B)**: if the stored PID is alive, `acquireLock`\n * throws `ServerLockConflictError` with the existing PID and wssUrl so the\n * caller can surface a clear message to the agent.\n * - **Release**: remove the lock file. Called on graceful shutdown (SIGINT /\n * SIGTERM / SIGHUP). SIGKILL survivors leave a stale file — the next startup\n * recovers it automatically via the alive check.\n *\n * ## wssUrl update\n *\n * The lock is written before cloudflared starts, so `wssUrl` begins as `null`\n * and is updated in place once the tunnel URL is known via `updateWssUrl`.\n *\n * Node-only.\n */\n\nimport { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\nimport { isPidAlive as _isPidAlive } from '../shared/parent-watcher.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface LockData {\n pid: number;\n /** `null` until the cloudflared tunnel URL is assigned. */\n wssUrl: string | null;\n startedAt: string;\n /**\n * PID of the cloudflared child process. Written once the tunnel is up via\n * `LockHandle.updateTunnelChildPid`. Absent in lock files written by older\n * versions — those fall back to PID-only stale detection.\n *\n * FIX 3 (issue #571): `acquireLock` treats a live holder whose tunnel child\n * is known-dead as a stale lock and reclaims it.\n */\n tunnelChildPid?: number | null;\n}\n\nexport interface LockHandle {\n /** Updates the wssUrl field in the lock file once the tunnel URL is known. */\n updateWssUrl(wssUrl: string): void;\n /**\n * Updates the cloudflared child PID in the lock file once the tunnel is up.\n *\n * FIX 3 (issue #571): a second `acquireLock` caller will see this PID and\n * can detect that the holder's tunnel child is dead even though the Node\n * process itself is still alive, allowing lock reclamation.\n */\n updateTunnelChildPid(pid: number): void;\n /** Removes the lock file. Idempotent — safe to call multiple times. */\n release(): void;\n}\n\n/** Thrown when a live server process already holds the lock. */\nexport class ServerLockConflictError extends Error {\n /** PID of the existing server process. */\n readonly existingPid: number;\n /** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */\n readonly existingWssUrl: string | null;\n /** ISO timestamp from the existing lock — when that session started. */\n readonly existingStartedAt: string;\n\n constructor(existingPid: number, existingWssUrl: string | null, existingStartedAt: string) {\n const urlNote =\n existingWssUrl != null\n ? ` relay URL: ${existingWssUrl}\\n`\n : ' relay URL: (tunnel still starting — retry in a moment)\\n';\n\n super(\n `A debug server is already running (PID ${existingPid}).\\n` +\n urlNote +\n 'Stop the existing session before starting a new one.\\n' +\n 'If it is already stopped but this error persists, remove the lock file:\\n' +\n ` rm \"${lockFilePath()}\"`,\n );\n this.name = 'ServerLockConflictError';\n this.existingPid = existingPid;\n this.existingWssUrl = existingWssUrl;\n this.existingStartedAt = existingStartedAt;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Paths\n// ---------------------------------------------------------------------------\n\n/** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */\nexport function lockFilePath(): string {\n const dir = process.env.AIT_DEVTOOLS_LOCK_DIR ?? join(homedir(), '.ait-devtools');\n return join(dir, 'server.lock');\n}\n\nfunction ensureLockDir(lockPath: string): void {\n const dir = join(lockPath, '..');\n mkdirSync(dir, { recursive: true });\n}\n\n// ---------------------------------------------------------------------------\n// PID alive check\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when the given PID refers to a running process.\n *\n * Re-exported from `../shared/parent-watcher` so external callers that\n * import from `./server-lock` keep working without an import-path change.\n */\nexport const isPidAlive: (pid: number) => boolean = _isPidAlive;\n\n// ---------------------------------------------------------------------------\n// Read / write helpers\n// ---------------------------------------------------------------------------\n\nfunction readLock(lockPath: string): LockData | null {\n if (!existsSync(lockPath)) return null;\n try {\n const raw = readFileSync(lockPath, 'utf8');\n const parsed: unknown = JSON.parse(raw);\n if (\n typeof parsed === 'object' &&\n parsed !== null &&\n 'pid' in parsed &&\n typeof (parsed as Record<string, unknown>).pid === 'number' &&\n 'startedAt' in parsed &&\n typeof (parsed as Record<string, unknown>).startedAt === 'string'\n ) {\n const p = parsed as Record<string, unknown>;\n // FIX 3: read optional tunnelChildPid — absent in lock files from older\n // versions; those fall back to PID-only stale detection.\n const tunnelChildPid = typeof p.tunnelChildPid === 'number' ? p.tunnelChildPid : null;\n return {\n pid: p.pid as number,\n wssUrl: typeof p.wssUrl === 'string' ? p.wssUrl : null,\n startedAt: p.startedAt as string,\n tunnelChildPid,\n };\n }\n // Unrecognised schema — treat as stale.\n return null;\n } catch {\n // Corrupt / unreadable — treat as stale.\n return null;\n }\n}\n\nfunction writeLock(lockPath: string, data: LockData): void {\n ensureLockDir(lockPath);\n writeFileSync(lockPath, JSON.stringify(data, null, 2), { encoding: 'utf8' });\n}\n\nfunction removeLock(lockPath: string): void {\n try {\n rmSync(lockPath);\n } catch {\n // Already removed — fine.\n }\n}\n\n// ---------------------------------------------------------------------------\n// Force-takeover helper\n// ---------------------------------------------------------------------------\n\n/**\n * Sends SIGTERM to `pid` and waits up to `graceMs` (default 2 000 ms) for it\n * to exit; then falls back to SIGKILL. Synchronous — uses a busy-wait loop so\n * it is usable in the top-level startup path without async plumbing.\n *\n * Ignores errors from `process.kill` so that a race where the target exits\n * between the alive check and the kill call does not crash the caller.\n */\nfunction killAndWait(pid: number, graceMs = 2_000): void {\n try {\n process.kill(pid, 'SIGTERM');\n } catch {\n // Already gone — nothing to do.\n return;\n }\n\n const deadline = Date.now() + graceMs;\n // Poll every 100 ms until the process is gone or the grace period expires.\n while (isPidAlive(pid) && Date.now() < deadline) {\n // Busy-wait: this is a very short window (≤2 s) at startup.\n const end = Date.now() + 100;\n while (Date.now() < end) {\n // spin\n }\n }\n\n if (isPidAlive(pid)) {\n try {\n process.kill(pid, 'SIGKILL');\n } catch {\n // Already gone.\n }\n }\n}\n\n/**\n * Reaps an orphaned cloudflared tunnel child left behind by a previous session\n * (issue #628). The normal shutdown path TERM-cascades to the child, but a\n * SIGKILL'd or crashed Node process can't run cleanup — its cloudflared child\n * keeps the (now stale) quick tunnel alive. When `acquireLock` reclaims such a\n * lock, this kills the still-alive `tunnelChildPid` so the new session starts\n * from a clean slate (companion to the #347/#571 zombie-daemon defenses).\n *\n * No-op when the lock carries no `tunnelChildPid` (older lock files) or the\n * child is already gone. SECRET-HANDLING: logs the PID only — never the tunnel\n * host/wss (those never enter the lock file or this path).\n */\nfunction reapOrphanTunnelChild(existing: LockData): void {\n const childPid = existing.tunnelChildPid;\n if (typeof childPid !== 'number' || !isPidAlive(childPid)) return;\n process.stderr.write(\n `[ait-debug] reaping orphaned tunnel child PID=${childPid} from previous session.\\n`,\n );\n killAndWait(childPid);\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Reads the current lock file without acquiring it. Returns the parsed\n * `LockData` when the file exists and is valid, otherwise `null`. Used by\n * `get_debug_status` to surface the `serverLockHolder` field without\n * interfering with the running lock owner.\n */\nexport function readServerLock(): LockData | null {\n return readLock(lockFilePath());\n}\n\n/** Options for `acquireLock`. */\nexport interface AcquireLockOptions {\n /**\n * When `true`, terminates the process holding the existing lock (SIGTERM →\n * wait up to 2 s → SIGKILL) and takes over the lock.\n *\n * Corresponds to the `--force` / `--takeover` CLI flag.\n */\n force?: boolean;\n}\n\n/**\n * Attempts to acquire the server lock.\n *\n * - If no lock exists (or the lock is stale): writes a new lock and returns a\n * `LockHandle` with `updateWssUrl` + `release`.\n * - If a live process holds the lock and `force` is `false` (default): writes\n * a clear recovery message to stderr and throws `ServerLockConflictError`.\n * - If a live process holds the lock and `force` is `true`: sends SIGTERM to\n * that process (waiting up to 2 s then SIGKILL) and takes over the lock.\n *\n * The initial `wssUrl` in the lock file is `null` — call\n * `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.\n */\nexport function acquireLock(options: AcquireLockOptions = {}): LockHandle {\n const { force = false } = options;\n const lockPath = lockFilePath();\n const existing = readLock(lockPath);\n\n if (existing !== null) {\n if (isPidAlive(existing.pid)) {\n // FIX 3 (issue #571): even if the Node process is alive, check whether\n // its cloudflared child has died. A zombie daemon whose tunnel is dead\n // is effectively stale — reclaim the lock without waiting for the user\n // to manually kill the process.\n const tunnelChildPid = existing.tunnelChildPid;\n const tunnelChildDead = typeof tunnelChildPid === 'number' && !isPidAlive(tunnelChildPid);\n\n if (tunnelChildDead) {\n process.stderr.write(\n `[ait-debug] stale lock: holder PID=${existing.pid} alive but tunnel child PID=${tunnelChildPid} is dead — reclaiming lock.\\n`,\n );\n // Fall through to write a fresh lock.\n } else if (force) {\n // Force takeover: SIGTERM → 2 s grace → SIGKILL.\n process.stderr.write(\n `[ait-debug] --force: terminating existing session PID=${existing.pid} …\\n`,\n );\n killAndWait(existing.pid);\n // Issue #628: killing the Node holder doesn't reliably cascade to a\n // detached cloudflared child — reap it explicitly so --force leaves no\n // orphaned tunnel.\n reapOrphanTunnelChild(existing);\n process.stderr.write(`[ait-debug] --force: PID=${existing.pid} stopped, taking over.\\n`);\n } else {\n // Emit a user-actionable message before throwing so the MCP host can\n // surface it — the thrown message is included in the \"process exited\"\n // log, but the stderr line is more prominent and machine-parseable.\n const urlPart =\n existing.wssUrl != null ? `wssUrl=${existing.wssUrl}` : 'wssUrl=(tunnel starting)';\n process.stderr.write(\n `[ait-debug] 기존 debug-mode 세션이 이미 실행 중 — PID=${existing.pid}, started ${existing.startedAt}, ${urlPart}\\n` +\n `[ait-debug] 회복: \\`kill ${existing.pid}\\` 또는 \\`npx @ait-co/devtools devtools-mcp --force\\`\\n`,\n );\n throw new ServerLockConflictError(existing.pid, existing.wssUrl, existing.startedAt);\n }\n } else {\n // Stale lock — previous process died without cleanup.\n process.stderr.write(\n `[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\\n`,\n );\n // Issue #628: a SIGKILL'd/crashed Node couldn't TERM-cascade to its\n // cloudflared child, so that child may still be holding a stale tunnel.\n // Reap it before the new session boots its own tunnel.\n reapOrphanTunnelChild(existing);\n }\n }\n\n const data: LockData = {\n pid: process.pid,\n wssUrl: null,\n startedAt: new Date().toISOString(),\n };\n writeLock(lockPath, data);\n\n let released = false;\n\n return {\n updateWssUrl(wssUrl: string): void {\n if (released) return;\n data.wssUrl = wssUrl;\n writeLock(lockPath, data);\n },\n updateTunnelChildPid(pid: number): void {\n if (released) return;\n data.tunnelChildPid = pid;\n writeLock(lockPath, data);\n },\n release(): void {\n if (released) return;\n released = true;\n removeLock(lockPath);\n },\n };\n}\n","/**\n * Debug-mode MCP tools (Phase 1–3 + safe-area probe).\n *\n * Read-only tools that normalize CDP / AIT data into `chrome-devtools-mcp`-\n * compatible shapes. The tools never touch a websocket or HTTP endpoint\n * directly — they read from an injected `CdpConnection` (CDP events/commands)\n * or `AitSource` (AIT.* domain), which is what makes them unit-testable with a\n * fake. No phone and no running dev server are needed in tests.\n *\n * Phase 1 (CDP events):\n * - `list_console_messages` ← Runtime.consoleAPICalled\n * - `list_network_requests` ← Network.requestWillBeSent + responseReceived\n * - `list_pages` ← Chii relay target list + tunnel status\n * Phase 2 (CDP commands):\n * - `get_dom_document` ← DOM.getDocument\n * - `take_snapshot` ← DOMSnapshot.captureSnapshot\n * - `take_screenshot` ← Page.captureScreenshot\n * - `measure_safe_area` ← Runtime.evaluate (safe-area probe)\n * Phase 3 (AIT.* domain — CDP can't cover these):\n * - `AIT.getSdkCallHistory`\n * - `AIT.getMockState`\n * - `AIT.getOperationalEnvironment`\n */\n\nimport type {\n AitMockState,\n AitOperationalEnvironment,\n AitSdkCallHistory,\n AitSource,\n} from './ait-source.js';\nimport type {\n CdpCallFrame,\n CdpConnection,\n CdpRemoteObject,\n ConsoleApiCalledEvent,\n DomGetDocumentResult,\n DomSnapshotResult,\n NetworkRequestWillBeSentEvent,\n NetworkResponseReceivedEvent,\n RuntimeExceptionThrownEvent,\n} from './cdp-connection.js';\nimport { buildDeepLinkAttachUrl, validateSchemeAuthority } from './deeplink.js';\nimport type { McpEnvironment } from './environment.js';\nimport { isRelayEnv, toLegacyEnv } from './environment.js';\nimport { lookupSignature, warnPassthrough } from './sdk-signatures.js';\nimport { isPidAlive } from './server-lock.js';\nimport { generateTotp, RELAY_VERIFY_SKEW_STEPS } from './totp.js';\n\n/** Tunnel state surfaced by `list_pages`. */\nexport interface TunnelStatus {\n /** Whether the cloudflared quick tunnel is up. */\n up: boolean;\n /** Public `wss://*.trycloudflare.com` relay URL the phone attaches to. */\n wssUrl: string | null;\n /**\n * ISO timestamp when a tunnel drop was first detected by the health probe.\n * `null` means the tunnel has not dropped (or has recovered since the last\n * drop). When non-null and `up` is false, the tunnel is down and the probe\n * has exhausted all reissue attempts — the server must be restarted.\n */\n droppedAt?: string | null;\n /**\n * Number of automatic reissue attempts made after a drop was detected.\n * Resets to 0 after a successful reissue. Reaches `MAX_REISSUE_ATTEMPTS`\n * (3) before the probe gives up and enters the permanent-error state.\n */\n reissueAttempts?: number;\n}\n\n/**\n * Tier classification per RFC #277 (\"MCP tool surface fidelity\"):\n *\n * - **Tier A** (`mock` only) — mock-internal state dials with no real-device\n * equivalent. Hidden when env is `relay`.\n * - **Tier B** (`relay` only) — relay infrastructure tools that have no mock\n * equivalent (e.g. `start_attach` needs a cloudflared tunnel URL). Hidden\n * when env is `mock`.\n * - **Tier C** (`both`) — fidelity-parallel tools that produce semantically\n * equivalent results across mock and relay. The agent sees the same tool with\n * the same shape; only the `source` provenance field (where applicable)\n * differs.\n */\nexport type ToolAvailability = 'mock' | 'relay' | 'both';\n\n/** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */\nexport const DEBUG_TOOL_DEFINITIONS = [\n {\n name: 'list_console_messages',\n description:\n 'Lists recent console messages (console.log/warn/error/info) captured from the attached ' +\n 'mini-app page over CDP (Runtime.consoleAPICalled). Read-only. Returns level, text, ' +\n 'timestamp, and stringified args, oldest-first.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'list_network_requests',\n description:\n 'Lists recent network requests (XHR/fetch) captured from the attached mini-app page over ' +\n 'CDP (Network.requestWillBeSent + Network.responseReceived). Read-only. Returns url, ' +\n 'method, status, and timing, oldest-first.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'list_pages',\n description:\n 'Returns the single active page (at most one) the relay sees attached. ' +\n 'When a second page attaches, the previous one is evicted (last-attach wins — ' +\n 'single-attach model). The result includes `singleAttachModel: true` so the agent ' +\n 'knows the array is always 0 or 1 entries. ' +\n 'Also returns whether the cloudflared tunnel is up and the public wss relay URL. ' +\n 'The `tunnel` field includes `droppedAt` (ISO timestamp or null/undefined): when non-null ' +\n 'the tunnel has permanently dropped after 3 failed reissue attempts — restart the debug ' +\n 'server with `npx @ait-co/devtools devtools-mcp`. ' +\n 'Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from ' +\n 'that target — useful to detect stale entries when the phone app backgrounded). ' +\n 'The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, ' +\n 'a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since ' +\n 'the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint ' +\n 'to re-attach. ' +\n 'Call this first to confirm a page is attached before reading console/network. ' +\n 'When a page attaches or detaches the server emits notifications/tools/list_changed — ' +\n 'call tools/list again to get the full updated tool surface.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'start_attach',\n description:\n \"The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. \" +\n 'Single entry point to attach a real device: switches the debug mode (if `mode` is given), ' +\n 'builds the self-attaching deep-link QR for the active relay environment, and waits for the ' +\n 'phone to attach — all in one call (replaces the old attach-URL + start_debug two-step). ' +\n 'Scan the QR with the phone camera to open the mini-app and attach it to this debug session ' +\n '(QR is the single entry path — no USB cable or platform CLI needed).\\n\\n' +\n 'mode (optional): pass \"relay-sandbox\" (env 2) or \"relay-staging\" (env 3) to switch the active ' +\n 'environment first. When omitted, the current relay environment is used as-is (no switch). ' +\n 'Passing \"local-browser\" returns an error — start_attach is relay-only (env 2/3). ' +\n 'When the session is already in the requested mode, the switch is skipped.\\n\\n' +\n 'Environment-specific behaviour:\\n' +\n ' • env 3 / relay-staging: requires scheme_url — the intoss-private://…?_deploymentId=<uuid> ' +\n 'URL from `ait deploy --scheme-only`. Splices debug=1 + relay URL into the scheme URL.\\n' +\n ' • env 2 / relay-sandbox: scheme_url is NOT used. Instead, reads AIT_TUNNEL_BASE_URL ' +\n '(the https://*.trycloudflare.com app tunnel from `tunnel:{cdp:true}`) and builds a launcher PWA ' +\n 'deep-link (https://devtools.aitc.dev/launcher/?url=…&debug=1&relay=…). When projectRoot is given, ' +\n 'the app name from <projectRoot>/package.json is added as name= so the launcher partner bar shows it.\\n\\n' +\n 'Waits for a page to attach by default (up to wait_timeout_seconds, default 60 s). ' +\n 'The server automatically opens the QR dashboard in the OS default browser when running on a ' +\n 'local GUI machine — headless/remote environments fall back to the text QR in the tool output.' +\n '\\n\\nTOTP auto re-mint: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the attachUrl carries ' +\n 'a one-time code (at=<code>) valid for ~3 minutes (the relay gate accepts ±6 TOTP steps). ' +\n 'While waiting, start_attach AUTOMATICALLY re-mints a fresh code before the current one expires ' +\n 'and refreshes the dashboard QR in place (no browser re-open). You do NOT need to re-call ' +\n 'start_attach every time the code would expire — a single call covers the whole wait window. ' +\n 'The response includes a `totp` field with `expiresAt` and a `reminted` count of how many fresh ' +\n 'codes were issued during the wait. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.\\n\\n' +\n 'selfdebug (env 2 / relay-sandbox only): pass selfdebug=true to add &selfdebug=1 to the ' +\n 'launcher deep-link. The launcher PWA then registers its own document as the CDP target ' +\n 'instead of the framed mini-app. SINGLE-ATTACH MODEL: attaching the launcher self-target ' +\n 'evicts any currently-attached mini-app target — use this mode exclusively for diagnosing ' +\n 'the launcher document itself (DOM, safe-area, console). Not applicable in env 3 ' +\n '(relay-staging) — passing selfdebug=true there returns an error.',\n inputSchema: {\n type: 'object',\n properties: {\n mode: {\n type: 'string',\n enum: ['local-browser', 'relay-sandbox', 'relay-staging'],\n description:\n 'Optional debug mode to switch into before attaching. \"relay-sandbox\" = env 2 (launcher PWA), ' +\n '\"relay-staging\" = env 3 (intoss-private dog-food). \"local-browser\" returns an error ' +\n '(start_attach is relay-only). Omit to keep the current relay environment.',\n },\n scheme_url: {\n type: 'string',\n description:\n 'The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). ' +\n 'Required for env 3/relay-staging mode. Not used in env 2/relay-sandbox mode (use AIT_TUNNEL_BASE_URL instead). ' +\n 'The authority (host) must be the app name (e.g. intoss-private://aitc-sdk-example?_deploymentId=…). ' +\n 'Generic values like \"web\" or an empty host indicate a malformed URL.',\n },\n wait_timeout_seconds: {\n type: 'number',\n description:\n 'Maximum seconds to wait for a page to attach (default 60, range 1–600). ' +\n 'Values outside the range or invalid inputs (0, negative, NaN) fall back to the default silently. ' +\n 'During the wait the TOTP code is auto re-minted as needed, so a single call covers the whole window.',\n },\n projectRoot: {\n type: 'string',\n description:\n 'Absolute path to the mini-app project root (the directory containing its package.json and .ait_urls). ' +\n 'When AIT_TUNNEL_BASE_URL is unset (env 2 / relay-mobile only), the daemon reads the app tunnel URL ' +\n 'from <projectRoot>/.ait_urls written by the dev server (tunnel:{cdp:true}). ' +\n \"Pass this because the daemon's own cwd is fixed at launch. Omit when AIT_TUNNEL_BASE_URL is set explicitly.\",\n },\n selfdebug: {\n type: 'boolean',\n description:\n 'Env 2 / relay-sandbox only. When true, adds &selfdebug=1 to the launcher deep-link ' +\n 'so the launcher PWA registers its own document as the CDP target (launcher diagnostics mode). ' +\n 'SINGLE-ATTACH MODEL: self-target attach evicts any currently-attached mini-app target. ' +\n 'Use only when you need to inspect the launcher itself (DOM, safe-area, console). ' +\n 'Passing selfdebug=true in env 3 (relay-staging) returns an error. ' +\n 'Default: false (omitted).',\n },\n },\n // No required fields — mode/scheme_url requirements are enforced at runtime\n // based on the active (or switched-into) relay environment.\n required: [],\n },\n // Tier B per RFC #277 — the URL synthesis requires a live cloudflared\n // tunnel + relay, which only exists in the `relay` environment.\n availableIn: 'relay' as ToolAvailability,\n },\n {\n name: 'get_dom_document',\n description:\n 'Returns the DOM tree of the attached mini-app page over CDP (DOM.getDocument). Read-only. ' +\n 'Use for structural/layout regression diagnosis (e.g. confirming an element exists, ' +\n 'inspecting attributes). Returns the document root node with children.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'take_snapshot',\n description:\n 'Captures a serialized snapshot of the attached page over CDP (DOMSnapshot.captureSnapshot). ' +\n 'Read-only. Returns the documents + interned strings table for visual-regression diagnosis ' +\n '(e.g. checking computed CSS custom properties like --sat against the live layout).',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'take_screenshot',\n description:\n 'Captures a PNG screenshot of the attached mini-app page over CDP (Page.captureScreenshot) ' +\n 'so the agent can see the phone screen directly. Read-only. ' +\n 'Returns an image content block — this is the only debug tool that returns an image; ' +\n 'all other debug tools return text (JSON).',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'measure_safe_area',\n description:\n 'Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns ' +\n 'normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. ' +\n 'Read-only — does not modify page state. ' +\n 'Tier C per RFC #277: the same Runtime.evaluate probe runs in both `mock` (devtools panel ' +\n 'page with window.__ait state) and `relay` (real-device WebView with window.__sdk). ' +\n 'The result includes a `source: \"mock\" | \"relay-dev\" | \"relay-mobile\"` field so consumers can identify ' +\n 'provenance without inspecting payload values. ' +\n '(`relay-mobile` = env 2 real-device PWA over an external relay; ' +\n '`relay-dev` = env 3 dog-food WebView; relay-live/env 4 removed #665.) ' +\n 'Use in a relay session (phone attached) to get ground-truth values for upgrading a ' +\n 'viewport preset from extrapolated/placeholder to measured. ' +\n 'Requires a page to be attached — call list_pages first.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'evaluate',\n description:\n 'Evaluates an arbitrary JavaScript expression on the attached mini-app page via ' +\n 'CDP Runtime.evaluate (returnByValue: true) and returns the result. ' +\n 'NOT read-only — the expression can have side effects (DOM mutations, SDK calls, ' +\n 'state changes). Requires the relay to be attached — call list_pages first. ' +\n 'Throws if the evaluation throws an exception on the page.\\n\\n' +\n 'SECURITY: expression and result are not redacted — never include secrets or auth ' +\n 'tokens in the expression.\\n\\n' +\n 'Positive-allowlist kill-switch (#665): this tool is blocked when the attached ' +\n 'page is on a non-debug host (apps.tossmini.com / env 4). Only localhost, ' +\n '*.trycloudflare.com, and *.private-apps.tossmini.com are allowed. ' +\n 'relay-live (env 4) and the LIVE confirm guard are removed.',\n inputSchema: {\n type: 'object',\n properties: {\n expression: {\n type: 'string',\n description: 'JavaScript expression to evaluate in the page context.',\n },\n },\n required: ['expression'],\n },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'list_exceptions',\n description:\n 'Lists JS-level exceptions captured via `Runtime.exceptionThrown` from the relay attached ' +\n 'page. Includes timestamp, exception text, source URL/line, and stack trace. ' +\n 'Use to root-cause SDK throws that may precede a Toss app crash (#265 / #267). ' +\n 'The buffer holds up to 50 most recent exceptions and survives target ' +\n 'replaced/crashed/destroyed events so an exception just before a crash is preserved. ' +\n 'Returns up to 50 most recent by default.',\n inputSchema: {\n type: 'object',\n properties: {\n limit: {\n type: 'number',\n description: 'Maximum number of exceptions to return (default 50, max 50).',\n },\n },\n required: [],\n },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'call_sdk',\n description:\n 'Calls a dog-food SDK method via the window.__sdkCall bridge ' +\n '(exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). ' +\n 'NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). ' +\n 'On env 3/4 (real device relay) this hits the real SDK; on env 1 (local mock) and ' +\n 'env 2 (PWA relay — real WebKit, mock SDK) it hits the mock SDK. ' +\n 'Requires the relay to be attached — call list_pages first. ' +\n 'Returns {ok: true, value} on success or {ok: false, error} on failure. ' +\n 'If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], ' +\n 'the result also includes `recentException` for crash triage. ' +\n 'Returns a clear error if window.__sdkCall is not available — on relay (env 3/4) ' +\n 'that means a non-dog-food bundle (redeploy via `ait build && aitcc app deploy`); ' +\n 'on local (--target=local, env 1) it means the dev bridge is not installed ' +\n '(start the dev server with `pnpm dev`).\\n\\n' +\n 'SECURITY: method name, args, and result value are not redacted — never include secrets.\\n\\n' +\n 'Positive-allowlist kill-switch (#665): blocked when the attached page is on ' +\n 'a non-debug host (apps.tossmini.com / env 4). relay-live and the LIVE guard removed.\\n\\n' +\n 'IMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\\n' +\n ' setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\\n' +\n ' setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\\n' +\n ' setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\\n' +\n ' setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\\n' +\n ' getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\\n' +\n ' getPlatformOS: call_sdk(\"getPlatformOS\", [])\\n' +\n ' getDeviceId: call_sdk(\"getDeviceId\", [])\\n' +\n ' getLocale: call_sdk(\"getLocale\", [])\\n' +\n ' getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\\n' +\n ' getSchemeUri: call_sdk(\"getSchemeUri\", [])\\n' +\n ' requestReview: call_sdk(\"requestReview\", [])\\n' +\n ' closeView: call_sdk(\"closeView\", [])',\n inputSchema: {\n type: 'object',\n properties: {\n name: {\n type: 'string',\n description: 'SDK method name to call (e.g. \"getOperationalEnvironment\").',\n },\n args: {\n type: 'array',\n description: 'Arguments to pass to the SDK method (optional, default []).',\n items: {},\n },\n },\n required: ['name'],\n },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'AIT.getSdkCallHistory',\n description:\n 'Returns the recent Apps in Toss SDK call trace (method, args, result/error, timestamp) that ' +\n 'raw CDP cannot observe. Read-only. Use to confirm an SDK call fired and how it resolved ' +\n '(e.g. a saveBase64Data permission regression).',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'AIT.getMockState',\n description:\n 'Returns the devtools mock state snapshot (window.__ait) — environment, permissions, location, ' +\n 'auth, network, IAP, and more. Read-only. In dev mode this is the live browser mock state; in ' +\n 'debug mode the in-app side reports it over the AIT domain.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'AIT.getOperationalEnvironment',\n description:\n 'Returns getOperationalEnvironment() plus the resolved SDK version — metadata raw CDP cannot ' +\n 'observe. Read-only.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'start_debug',\n description:\n 'Switches the active debug environment in-place (issue #348) — no Claude Code restart and ' +\n 'no MCP re-handshake. One daemon holds both a local (env 1, mock SDK in a Chromium) and a ' +\n 'relay (env 2/3, real-device over the Chii relay + cloudflared tunnel) ' +\n 'connection at once; this tool flips which one every other tool reads from, lazily booting ' +\n \"the requested family's infra on first use and keeping the inactive one warm so an existing \" +\n 'attach survives the switch. After switching it emits notifications/tools/list_changed — ' +\n 'call tools/list again to see the updated tool surface for the new environment.\\n\\n' +\n 'Positive-allowlist kill-switch (#665): relay sessions on apps.tossmini.com (env 4, released ' +\n 'production) are silently blocked at both the in-app gate and this MCP layer — ' +\n 'relay-live and the LIVE guard have been removed. Only localhost/loopback (env 1), ' +\n '*.trycloudflare.com (env 2), and *.private-apps.tossmini.com (env 3) are allowed.\\n\\n' +\n 'modes:\\n' +\n ' local-browser — env 1: desktop Chromium with the mock SDK and a local CDP attach. ' +\n 'Side-effect tools (call_sdk/evaluate) run unguarded against the mock; nothing touches a ' +\n 'real device or real users. No prerequisites — the default, always-available environment ' +\n 'for state/contract and visual-layout work.\\n' +\n ' relay-sandbox — env 2: a real-device PWA (real WebKit engine, mock SDK) over an external ' +\n 'Chii relay. CDP covers real-device WebKit DOM, console, exceptions, and safe-area ' +\n 'observation; call_sdk still hits the mock (SDK fidelity needs relay-staging). ' +\n 'Side-effect tools run unguarded against the mock. ' +\n 'Only the dual-connection daemon can enter relay-sandbox in-place; a single-connection ' +\n 'session rejects it with \"동적 전환할 수 없습니다 … relay-sandbox 모드로 재시작하세요\" — ' +\n 'follow that hint and restart the MCP server in relay-sandbox mode rather than retrying. ' +\n 'Prerequisites: both AIT_RELAY_BASE_URL (the relay base the unplugin emits when started ' +\n 'with tunnel:{cdp:true}, used for the CDP attach) and AIT_TUNNEL_BASE_URL (the dev-server ' +\n 'tunnel host, required by start_attach to render the launcher QR) must be set before ' +\n 'the MCP server starts — the unplugin does not auto-forward either; set them explicitly. ' +\n 'Both carry relay/tunnel hosts (secret-class) — keep them out of logs.\\n' +\n ' relay-staging — env 3: a real-device Toss WebView dog-food build with the REAL SDK over the ' +\n 'intoss-private relay. The first environment where call_sdk exercises the genuine native ' +\n 'bridge. Side-effect tools run unguarded (dog-food, not released to real users). ' +\n 'Prerequisite: a dog-food candidate bundle built with `RELEASE_CHANNEL=dogfood ait build`, ' +\n 'then uploaded with `ait deploy` (add `--scheme-only` to print the resulting ' +\n 'intoss-private://…?_deploymentId=… deep-link); open that deep-link/QR on the device to ' +\n 'cold-load the bundle with the relay injected. Unlike env 2, env 3 is NOT a dev-server ' +\n 'tunnel — it is a deployed bundle reached via the intoss-private scheme, so `pnpm dev` ' +\n 'plays no part here.\\n\\n' +\n 'For a relay mode (relay-sandbox/relay-staging), also pass projectRoot — the ' +\n 'absolute mini-app project root — so the daemon can read the relay auth secret from ' +\n '<projectRoot>/.ait_relay (read-only; the daemon never mints it). Omit it for local-browser.',\n inputSchema: {\n type: 'object',\n properties: {\n mode: {\n type: 'string',\n enum: ['local-browser', 'relay-sandbox', 'relay-staging'],\n description:\n 'Target environment to switch to. relay-live (env 4) has been removed (#665) — use relay-staging (env 3) for dog-food debugging.',\n },\n projectRoot: {\n type: 'string',\n description:\n 'Absolute path to the mini-app project root (the directory containing its package.json and .ait_relay). ' +\n 'The daemon reads the relay auth secret from <projectRoot>/.ait_relay (read-only) when switching to a relay ' +\n \"environment (relay-staging/relay-sandbox). Pass this because the daemon's own cwd is fixed at launch and may not be \" +\n 'the project being debugged. Omit for mode=local-browser (no secret needed).',\n },\n },\n required: ['mode'],\n },\n // Tier C — always callable so the agent can enter any environment from any\n // starting environment (including a fresh, unattached session).\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'get_debug_status',\n description:\n 'Reports the current debug session state — which environment/mode is active, whether a page ' +\n 'is attached, and a full diagnostic snapshot — in one call. Use this any time to answer ' +\n '\"what mode am I in right now?\" or \"why is this not working?\" without chaining tools. ' +\n 'Fields: mcpVersion (MCP SDK version), ' +\n 'devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), ' +\n 'pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, ' +\n 'recentErrors (last N server-side errors, PII/secret redacted), ' +\n 'authRejects ({count, lastAt} — relay TOTP 401 rejections, secret-free; count > 0 with empty pages ' +\n 'means the phone reached the relay but its code was rejected), ' +\n 'environment (kind: mock|relay-dev|relay-mobile, env: mock|relay backward-compat, reason, ' +\n 'liveGuardActive: always false — relay-live and LIVE guard removed (#665); ' +\n 'start_debug mode→kind mapping: relay-sandbox→relay-mobile, relay-staging→relay-dev, ' +\n 'local-browser→mock), ' +\n 'serverLockHolder (pid + startedAt from the lock file, or null), ' +\n 'nextRecommendedAction ({tool, reason} or null — the single next tool to call; ' +\n 'in local-target mode tunnel.up=false is normal so \"restart\" is never recommended). ' +\n 'All fields are nullable — missing data is null, not an error. ' +\n 'debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. ' +\n 'Tier C (both mock and relay).',\n inputSchema: {\n type: 'object',\n properties: {\n recent_errors_limit: {\n type: 'number',\n description:\n 'Maximum number of recent server-side errors to include (default 10, max 50).',\n },\n },\n required: [],\n },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'run_tests',\n description:\n 'Runs mini-app test files on the attached page over CDP (Runtime.evaluate). ' +\n 'Each matched file is bundled with esbuild (SDK imports redirected to the live mock/SDK), ' +\n 'injected into the attached WebView, and executed; returns per-file results plus flattened ' +\n 'totals (passed/failed/skipped/total). ' +\n 'When there is no attached page and the current environment is relay (env 3), this tool ' +\n 'automatically shows the QR dashboard and waits for a phone to connect before running ' +\n '(scheme_url required for env 3 relay-dev). ' +\n 'Files run SEQUENTIALLY (single-attach model: the relay/local target ' +\n 'serves one page), and one run_tests call runs at a time (a concurrent call is rejected). ' +\n 'Test verification (assert/snapshot) is delegated to the in-page Vitest runtime; this tool is ' +\n 'the transport + report. The per-file results array is the progress record — on partial failure ' +\n 'you see exactly which files passed/failed/timed-out. ' +\n 'Positive-allowlist kill-switch (#665): blocked when the attached page is on a non-debug host. ' +\n 'debug-mode only — dev-mode (--mode=dev) has no CDP. ' +\n 'Tier C (both mock/local and relay).',\n inputSchema: {\n type: 'object',\n properties: {\n files: {\n type: 'array',\n items: { type: 'string' },\n description:\n 'Glob patterns or file paths to run (e.g. [\"src/**/*.ait.test.ts\"]). ' +\n 'Resolved relative to projectRoot when given, else the daemon cwd. Required, non-empty.',\n },\n projectRoot: {\n type: 'string',\n description:\n 'Absolute path to the mini-app project root used as the glob base. ' +\n \"Pass this because the daemon's cwd is fixed at launch. Optional.\",\n },\n timeout_ms: {\n type: 'number',\n description:\n 'Per-file evaluate timeout in ms (default 30000, range 1000–600000). ' +\n 'Out-of-range/invalid values fall back to the default.',\n },\n scheme_url: {\n type: 'string',\n description:\n 'intoss-private:// deep-link URL from `ait deploy --scheme-only` (env 3 relay-dev only). ' +\n 'Required when there is no attached page and the environment is relay-dev — ' +\n 'the tool uses it to build the QR attach URL and wait for the phone to connect. ' +\n 'Ignored when a page is already attached.',\n },\n cell: {\n type: 'object',\n description:\n 'Optional cell object to inject into globalThis BEFORE running tests ' +\n '(e.g. { \"__AIT_CELL__\": { \"sdkLine\": \"2.x\", \"platform\": \"ios\" } }). ' +\n 'Applied only on the auto-attach path (no-page → attach → inject → run). ' +\n 'When a page is already attached the caller is responsible for any prior injection. ' +\n 'Ignored when empty or absent. Values must be JSON-serialisable.',\n additionalProperties: true,\n },\n },\n required: ['files'],\n },\n availableIn: 'both' as ToolAvailability,\n },\n] as const;\n\nexport type DebugToolName = (typeof DEBUG_TOOL_DEFINITIONS)[number]['name'];\n\nconst DEBUG_TOOL_NAMES = new Set<string>(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));\n\nexport function isDebugToolName(name: string): name is DebugToolName {\n return DEBUG_TOOL_NAMES.has(name);\n}\n\n/**\n * Returns the `ToolAvailability` declared on a registered debug tool, or\n * `undefined` when the name is not a known debug tool. Used by the tool\n * registry to filter `tools/list` by current env and by the call handler to\n * reject env-mismatch invocations.\n */\nexport function getToolAvailability(name: string): ToolAvailability | undefined {\n for (const t of DEBUG_TOOL_DEFINITIONS) {\n if (t.name === name) return t.availableIn;\n }\n return undefined;\n}\n\n/**\n * Returns true when the named tool is available in the given environment.\n * Unknown tools return `false` — callers should reject them as unknown rather\n * than as env-mismatched.\n *\n * Relay variants (`relay-dev`, `relay-mobile`) all satisfy the\n * `'relay'` availability tier — `isRelayEnv()` is used for the check.\n * (`relay-live` removed #665.)\n */\nexport function isToolAvailableIn(name: string, env: McpEnvironment): boolean {\n const availability = getToolAvailability(name);\n if (availability === undefined) return false;\n if (availability === 'both') return true;\n if (availability === 'relay') return isRelayEnv(env);\n return availability === env;\n}\n\n/**\n * Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`\n * matches the given env. Pure — preserves order; both Tier C (\"both\") and the\n * matching single-env tier pass through.\n *\n * Relay variants (`relay-dev`, `relay-mobile`) all satisfy the\n * `'relay'` tier. (`relay-live` removed #665.)\n */\nexport function filterToolsByEnvironment<T extends { name: string; availableIn: ToolAvailability }>(\n tools: ReadonlyArray<T>,\n env: McpEnvironment,\n): T[] {\n return tools.filter(\n (t) =>\n t.availableIn === 'both' ||\n (t.availableIn === 'relay' && isRelayEnv(env)) ||\n t.availableIn === env,\n );\n}\n\n/**\n * Tool names that are available before any page attaches (bootstrap tier).\n *\n * `start_attach` — mode switch + QR synthesis + attach wait, no prior attach needed.\n * `list_pages` — reports tunnel status + empty pages even pre-attach.\n *\n * All other tools require an attached page (`enableDomains` must succeed) and\n * are only advertised in `tools/list` once a target appears.\n */\nexport const BOOTSTRAP_TOOL_NAMES: ReadonlySet<string> = new Set<string>([\n 'start_attach',\n 'get_debug_status',\n 'list_pages',\n // start_debug must be visible from the very first tools/list (before any\n // attach) so the agent can switch environments to bootstrap an attach.\n 'start_debug',\n]);\n\n/** Normalized console message returned by `list_console_messages`. */\nexport interface ConsoleMessage {\n level: string;\n text: string;\n timestamp: number;\n args: string[];\n}\n\n/** Normalized network request returned by `list_network_requests`. */\nexport interface NetworkRequest {\n requestId: string;\n url: string;\n method: string;\n /** HTTP status once a response was seen, else null (still in-flight). */\n status: number | null;\n statusText: string | null;\n /** Request start (CDP timestamp). */\n startTime: number;\n /** Response received (CDP timestamp), else null. */\n endTime: number | null;\n}\n\n/** Renders a CDP `RemoteObject` console arg to a stable display string. */\nfunction renderRemoteObject(arg: CdpRemoteObject): string {\n if (arg.value !== undefined) {\n if (typeof arg.value === 'string') return arg.value;\n try {\n return JSON.stringify(arg.value);\n } catch {\n return String(arg.value);\n }\n }\n if (arg.description !== undefined) return arg.description;\n if (arg.className !== undefined) return arg.className;\n return arg.subtype ?? arg.type;\n}\n\nexport function normalizeConsoleMessage(event: ConsoleApiCalledEvent): ConsoleMessage {\n const args = event.args.map(renderRemoteObject);\n return {\n level: event.type,\n text: args.join(' '),\n timestamp: event.timestamp,\n args,\n };\n}\n\nexport function listConsoleMessages(connection: CdpConnection): ConsoleMessage[] {\n return connection\n .getBufferedEvents('Runtime.consoleAPICalled')\n .map((event) => normalizeConsoleMessage(event));\n}\n\nexport function listNetworkRequests(connection: CdpConnection): NetworkRequest[] {\n const requests = connection.getBufferedEvents('Network.requestWillBeSent');\n const responses = connection.getBufferedEvents('Network.responseReceived');\n\n const responseByRequestId = new Map<string, NetworkResponseReceivedEvent>();\n for (const response of responses) {\n responseByRequestId.set(response.requestId, response);\n }\n\n return requests.map((request: NetworkRequestWillBeSentEvent) => {\n const response = responseByRequestId.get(request.requestId);\n return {\n requestId: request.requestId,\n url: request.request.url,\n method: request.request.method,\n status: response ? response.response.status : null,\n statusText: response ? response.response.statusText : null,\n startTime: request.timestamp,\n endTime: response ? response.timestamp : null,\n };\n });\n}\n\n/* -------------------------------------------------------------------------- */\n/* list_exceptions — Runtime.exceptionThrown ring buffer */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Normalized exception returned by `list_exceptions`.\n *\n * Flattens the CDP `Runtime.ExceptionDetails` shape into the most useful\n * fields. The `raw` field carries the original event for callers that need\n * the full payload.\n */\nexport interface BufferedException {\n /** Wall-clock ms since epoch (CDP `Runtime.Timestamp`). */\n timestamp: number;\n /** Short summary text from `exceptionDetails.text`. */\n text: string;\n /** Source URL where the exception was thrown, if known. */\n url?: string;\n /** 0-based line number in the source file, if known. */\n lineNumber?: number;\n /** 0-based column number in the source file, if known. */\n columnNumber?: number;\n /** `description` of the thrown `RemoteObject` (e.g. \"TypeError: …\"). */\n exceptionText?: string;\n /**\n * Formatted stack trace: `at fn (url:line:col)` lines joined by `\\n`.\n * Omitted when no `stackTrace.callFrames` are available.\n */\n stack?: string;\n /** Full original `Runtime.exceptionThrown` event payload. */\n raw: RuntimeExceptionThrownEvent;\n}\n\n/** Formats a single CDP call frame into `at fn (url:line:col)`. */\nfunction formatCallFrame(frame: CdpCallFrame): string {\n const fn = frame.functionName || '(anonymous)';\n return `at ${fn} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`;\n}\n\n/** Normalizes a raw `Runtime.exceptionThrown` event into a `BufferedException`. */\nexport function normalizeException(event: RuntimeExceptionThrownEvent): BufferedException {\n const { timestamp, exceptionDetails } = event;\n const frames = exceptionDetails.stackTrace?.callFrames;\n const stack = frames && frames.length > 0 ? frames.map(formatCallFrame).join('\\n') : undefined;\n const exceptionText = exceptionDetails.exception?.description ?? undefined;\n\n const result: BufferedException = {\n timestamp,\n text: exceptionDetails.text,\n raw: event,\n };\n if (exceptionDetails.url !== undefined) result.url = exceptionDetails.url;\n if (exceptionDetails.lineNumber !== undefined) result.lineNumber = exceptionDetails.lineNumber;\n if (exceptionDetails.columnNumber !== undefined)\n result.columnNumber = exceptionDetails.columnNumber;\n if (exceptionText !== undefined) result.exceptionText = exceptionText;\n if (stack !== undefined) result.stack = stack;\n return result;\n}\n\n/**\n * Returns the most recent buffered `Runtime.exceptionThrown` events, normalized.\n * Oldest-first; limited to `limit` entries (default 50, max 50).\n */\nexport function listExceptions(connection: CdpConnection, limit = 50): BufferedException[] {\n const cap = Math.min(Math.max(1, limit), 50);\n const events = connection.getBufferedEvents('Runtime.exceptionThrown');\n // Slice from the tail to respect the cap while preserving oldest-first order.\n const sliced = events.length > cap ? events.slice(events.length - cap) : events;\n return sliced.map((e) => normalizeException(e));\n}\n\n/** A page entry in the `list_pages` result, extended with freshness info. */\nexport interface ListPagesEntry {\n id: string;\n title: string;\n url: string;\n /** ISO timestamp of the last inbound CDP message from this target, or null. */\n lastSeenAt: string | null;\n}\n\n/** Result of `list_pages`: attach status + tunnel state + crash info. */\nexport interface ListPagesResult {\n /**\n * The single active page, or an empty array when nothing is attached.\n * Under the single-attach model this is always 0 or 1 entries.\n */\n pages: ListPagesEntry[];\n tunnel: TunnelStatus;\n /**\n * ISO timestamp of the most recent crash / targetDestroyed / detachedFromTarget\n * event detected since the last `enableDomains()`, or `null` if none.\n * When non-null, all attached pages have been removed from the relay map and\n * a new `enableDomains()` call is required to resume debugging.\n */\n crashDetectedAt: string | null;\n /** Korean warning line shown in tool output when a crash was detected. */\n crashWarning: string | null;\n /**\n * Always `true` — signals to the agent that at most one page is ever present.\n * When a second page attaches, the previous one is evicted (last-attach wins).\n */\n singleAttachModel: true;\n}\n\n/**\n * Duck-type interface for the crash-detection extras exposed by `ChiiCdpConnection`.\n * The base `CdpConnection` interface is kept minimal (fake-friendly); the extras\n * are opt-in so tests without them continue to compile.\n */\ninterface CrashAwareCdpConnection extends CdpConnection {\n getLastCrashDetectedAt(): number | null;\n getTargetLastSeenAt(targetId: string): number | null;\n}\n\nfunction isCrashAware(conn: CdpConnection): conn is CrashAwareCdpConnection {\n return (\n typeof (conn as CrashAwareCdpConnection).getLastCrashDetectedAt === 'function' &&\n typeof (conn as CrashAwareCdpConnection).getTargetLastSeenAt === 'function'\n );\n}\n\nexport function listPages(connection: CdpConnection, tunnel: TunnelStatus): ListPagesResult {\n const rawTargets = connection.listTargets();\n const pages: ListPagesEntry[] = rawTargets.map((t) => {\n const lastSeenMs = isCrashAware(connection) ? connection.getTargetLastSeenAt(t.id) : null;\n return {\n id: t.id,\n title: t.title,\n // SECRET-HANDLING: a relay-attach page url can carry the one-time TOTP\n // `at=<code>` (#real-phone repro, #668). Redact it for display only —\n // attach drives off targetId, never this url. relay/_deploymentId/debug kept.\n url: redactAtParam(t.url),\n lastSeenAt: lastSeenMs !== null ? new Date(lastSeenMs).toISOString() : null,\n };\n });\n\n const crashMs = isCrashAware(connection) ? connection.getLastCrashDetectedAt() : null;\n const crashDetectedAt = crashMs !== null ? new Date(crashMs).toISOString() : null;\n const crashWarning = crashDetectedAt\n ? `[ait-debug] page crash 감지됨 — 새 attach 필요 (관측 시각: ${crashDetectedAt})`\n : null;\n\n return { pages, tunnel, crashDetectedAt, crashWarning, singleAttachModel: true };\n}\n\n/** A `buildAttachUrl()` result: the spliced deep-link the phone should open. */\nexport interface BuildAttachUrlResult {\n /** The scheme URL with `debug=1&relay=<wss>[&at=<totp-code>]` spliced in. */\n attachUrl: string;\n /** The relay URL that was spliced in (this session's quick tunnel). */\n relayUrl: string;\n /**\n * Non-fatal warning about the scheme URL's authority being missing or\n * suspicious (e.g. \"web\", \"localhost\"). Callers should surface this to\n * help the user catch a malformed URL early.\n */\n authorityWarning?: string;\n /**\n * TOTP metadata — present when `AIT_DEBUG_TOTP_SECRET` is set.\n *\n * SECRET-HANDLING: the `at=` code value is spliced into `attachUrl` only.\n * It is never surfaced separately here to avoid inadvertent logging of the\n * one-time code outside of the URL.\n */\n totp?: {\n /** `true` when a TOTP code was spliced into `attachUrl`. */\n enabled: true;\n /** RFC 6238 step duration in seconds. */\n ttlSeconds: number;\n /** ISO timestamp when the current step expires. start_attach auto re-mints before this elapses. */\n expiresAt: string;\n };\n}\n\n/**\n * Builds a self-attaching dog-food deep-link from an `ait deploy --scheme-only`\n * URL plus this session's live relay. Throws if the tunnel is not up yet (no\n * relay URL to splice in) — the caller surfaces that as a tool error.\n *\n * When `AIT_DEBUG_TOTP_SECRET` is set, generates the current TOTP code and\n * splices it as `at=<code>` into the attach URL. The code is valid for ~3\n * minutes (the relay gate uses {@link RELAY_VERIFY_SKEW_STEPS}=6, accepting\n * past 6 steps = 180–210 s backwards from issuance). The start_attach handler\n * auto re-mints a fresh code before `totp.expiresAt` elapses during its wait (#490).\n *\n * Also validates the scheme URL's authority. A suspicious authority (empty,\n * \"web\", \"localhost\", etc.) is surfaced as a non-fatal `authorityWarning` on\n * the result so the caller can show a helpful hint without blocking the link\n * generation (the warning is consistent with how other validation in\n * `buildDeepLinkAttachUrl` works — hard errors for relay, soft warning for\n * the scheme authority which is in the caller's input, not ours to own).\n *\n * SECRET-HANDLING: `totpSecret` (if provided) is used only to compute a code\n * and must never appear in any log, error message, or output outside of the\n * spliced `at=` param in `attachUrl`.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL.\n * @param tunnel - Current tunnel status from the running debug server.\n * @param totpSecret - Optional hex-encoded TOTP secret (from\n * `AIT_DEBUG_TOTP_SECRET`). When provided, the current code is spliced into\n * the attach URL as `at=<code>`.\n */\nexport function buildAttachUrl(\n schemeUrl: string,\n tunnel: TunnelStatus,\n totpSecret?: string,\n): BuildAttachUrlResult {\n if (!tunnel.up || tunnel.wssUrl === null) {\n throw new Error(\n 'tunnel-down: cloudflared 터널이 안 떠 있습니다. ' +\n 'MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.',\n );\n }\n const authorityWarning = validateSchemeAuthority(schemeUrl) ?? undefined;\n\n // Generate a live TOTP code when a secret is provided.\n // SECRET-HANDLING: the code value is placed into attachUrl only — not logged.\n let totpCode: string | undefined;\n let totpMeta: BuildAttachUrlResult['totp'];\n if (totpSecret !== undefined && totpSecret !== '') {\n const now = Date.now();\n totpCode = generateTotp(totpSecret, now);\n const STEP_SECONDS = 30;\n // expiresAt reflects the relay gate's actual acceptance window (#490):\n // the gate uses RELAY_VERIFY_SKEW_STEPS=6, so past 6 steps (180 s) are\n // accepted. The code issued NOW is valid until step (currentStep + 7)\n // starts — i.e. the earliest time it can be rejected is 180 s after\n // the NEXT step boundary, which is (currentStep+1)*30 + 6*30 = now-aligned\n // ~180–210 s from issuance. We report issuanceTime + 180 s as a conservative\n // lower bound so callers know the code is safe for at least ~3 minutes.\n const expiresAtMs = now + RELAY_VERIFY_SKEW_STEPS * STEP_SECONDS * 1000;\n totpMeta = {\n enabled: true,\n ttlSeconds: RELAY_VERIFY_SKEW_STEPS * STEP_SECONDS,\n expiresAt: new Date(expiresAtMs).toISOString(),\n };\n }\n\n return {\n attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl, totpCode),\n relayUrl: tunnel.wssUrl,\n ...(authorityWarning !== undefined ? { authorityWarning } : {}),\n ...(totpMeta !== undefined ? { totp: totpMeta } : {}),\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* QR PNG rendering + browser open */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Heuristic: can this process open a GUI browser?\n *\n * Returns `true` when we think a GUI is available:\n * - On macOS (`darwin`) we assume yes (MCP normally runs on the user's Mac).\n * - On Linux we check for `DISPLAY` or `WAYLAND_DISPLAY`.\n * - On Windows we assume yes.\n * - In a CI environment (`CI=true`) we assume no.\n */\nexport function canOpenBrowser(): boolean {\n if (process.env.CI === 'true' || process.env.CI === '1') return false;\n const platform = process.platform;\n if (platform === 'darwin' || platform === 'win32') return true;\n if (platform === 'linux') {\n return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);\n }\n return false;\n}\n\n/**\n * Result of `openQrInBrowser`.\n *\n * HTTP URL 기반으로 재구현 — tmp 파일 없음. `httpUrl`이 브라우저에 전달되는 URL이다.\n * SECRET-HANDLING: `httpUrl`은 127.0.0.1 로컬 전용이며 tunnel host·relay wss·TOTP at= 코드를\n * 담지 않는다 (#595). attachUrl은 server-state(getDashboardState)로만 보유된다.\n */\nexport interface OpenQrInBrowserResult {\n /** `true` if the browser was successfully opened. */\n opened: boolean;\n /** `http://127.0.0.1:<port>/` — 브라우저에 전달되는 루트 URL (#595). */\n httpUrl: string;\n /** `http://127.0.0.1:<port>/qr.png?u=...` — PNG fallback URL. */\n pngUrl: string;\n /** Error message if `opened` is false (browser spawn failed). */\n error?: string;\n /** Captured stderr from failed spawn attempts (at= 값은 redact됨). */\n stderrSummary?: string;\n /**\n * `true` when the first attempt failed but a retry succeeded.\n * Helps distinguish \"worked on first try\" from \"needed retry\" in diagnostics.\n */\n retried?: boolean;\n}\n\n/** platform별 browser open 명령 후보 목록 — 앞에서부터 순차 시도. */\nfunction getBrowserCandidates(httpUrl: string): Array<{ cmd: string; args: string[] }> {\n const platform = process.platform;\n if (platform === 'darwin') {\n return [\n { cmd: 'open', args: [httpUrl] },\n { cmd: 'open', args: ['-a', 'Safari', httpUrl] },\n { cmd: 'open', args: ['-a', 'Google Chrome', httpUrl] },\n { cmd: 'open', args: ['-a', 'Firefox', httpUrl] },\n ];\n }\n if (platform === 'win32') {\n return [\n { cmd: 'cmd', args: ['/c', 'start', '', httpUrl] },\n { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', httpUrl] },\n ];\n }\n // linux + fallback\n return [\n { cmd: 'xdg-open', args: [httpUrl] },\n { cmd: 'sensible-browser', args: [httpUrl] },\n { cmd: 'x-www-browser', args: [httpUrl] },\n { cmd: 'firefox', args: [httpUrl] },\n { cmd: 'google-chrome', args: [httpUrl] },\n { cmd: 'chromium', args: [httpUrl] },\n ];\n}\n\n/**\n * Redacts ONLY the `at=<value>` (TOTP) query param to `at=<redacted>`, leaving\n * every other query param (_deploymentId, debug, relay) intact. SECRET-HANDLING:\n * the TOTP code is the single short-lived secret carried in a CDP page url.\n */\nexport function redactAtParam(text: string): string {\n return text.replace(/\\bat=([^&\\s\"']+)/g, 'at=<redacted>');\n}\n\n/** stderr에서 at= TOTP 코드 값을 redact한다. */\nfunction redactSecrets(text: string): string {\n return redactAtParam(text);\n}\n\n/** spawnSync exit 0이어도 stderr에 launch 실패 시그널이 있으면 실패로 판단한다. */\nconst LAUNCH_FAILURE_PATTERNS = [\n /LSOpenURLsWithRole\\(\\) failed/,\n /kLSApplicationNotFoundErr/,\n /No application/,\n /Unable to find application/,\n /xdg-open: not found/,\n /command not found/,\n];\n\nfunction isLaunchFailureStderr(stderr: string): boolean {\n return LAUNCH_FAILURE_PATTERNS.some((p) => p.test(stderr));\n}\n\n/**\n * 로컬 HTTP 서버 루트 URL(`http://127.0.0.1:<port>/`)을 OS 기본 브라우저로 연다 (#595).\n *\n * platform별 fallback chain으로 시도하며, 모두 실패하면 1회 retry를 수행한다\n * (ephemeral process launch 타이밍 문제 대응). retry까지 실패해도 `opened: false` +\n * `httpUrl`을 반환해 사용자가 직접 브라우저에 붙여넣을 수 있게 한다.\n *\n * SECRET-HANDLING:\n * - tmp 파일을 만들지 않는다 (HTML/PNG는 HTTP 서버가 메모리에서 응답).\n * - httpUrl은 `http://127.0.0.1:<port>/`(루트, 시크릿 없음). pngUrl은 127.0.0.1 로컬 전용.\n * - stderr 캡처 결과에서 at= 코드 값을 redact한 후 stderrSummary에 포함.\n * - attachUrl, deploymentId, TOTP 코드를 stdout/stderr/로그에 직접 출력 금지.\n *\n * @param httpUrl - `http://127.0.0.1:<port>/` 루트 URL (시크릿 없음, #595).\n * @param pngUrl - `http://127.0.0.1:<port>/qr.png?u=<encoded>` PNG fallback URL.\n */\nexport async function openQrInBrowser(\n httpUrl: string,\n pngUrl: string,\n): Promise<OpenQrInBrowserResult> {\n const { spawnSync } = await import('node:child_process');\n\n /**\n * 한 번의 fallback chain 시도. 성공하면 열린 후보 cmd를 반환, 실패하면 null.\n * stderrLines에 각 후보의 stderr를 누적한다.\n */\n function tryOnce(stderrLines: string[]): boolean {\n const candidates = getBrowserCandidates(httpUrl);\n for (const { cmd, args } of candidates) {\n const result = spawnSync(cmd, args, { encoding: 'utf8', timeout: 5000 });\n\n if (result.error) {\n stderrLines.push(`${cmd}: ${result.error.message}`);\n continue;\n }\n\n const stderr = typeof result.stderr === 'string' ? result.stderr : '';\n if (stderr) {\n stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);\n }\n\n if (result.status === 0 && !isLaunchFailureStderr(stderr)) {\n return true;\n }\n }\n return false;\n }\n\n const stderrLines: string[] = [];\n\n // 1차 시도\n if (tryOnce(stderrLines)) {\n return { opened: true, httpUrl, pngUrl };\n }\n\n // 1회 retry (ephemeral process launch 타이밍 문제 대응)\n if (tryOnce(stderrLines)) {\n return { opened: true, httpUrl, pngUrl, retried: true };\n }\n\n const stderrSummary = stderrLines.length > 0 ? stderrLines.join('\\n') : undefined;\n return {\n opened: false,\n httpUrl,\n pngUrl,\n error: '모든 브라우저 실행 후보가 실패했습니다.',\n stderrSummary,\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Phase 2 — DOM / snapshot / screenshot (CDP commands) */\n/* -------------------------------------------------------------------------- */\n\n/** Returns the DOM tree of the attached page (`DOM.getDocument`). */\nexport function getDomDocument(connection: CdpConnection): Promise<DomGetDocumentResult> {\n // `pierce: true` flattens shadow roots; depth -1 returns the whole subtree so\n // a single call yields the full tree for structural diagnosis.\n return connection.send('DOM.getDocument', { depth: -1, pierce: true });\n}\n\n/** Returns a serialized page snapshot (`DOMSnapshot.captureSnapshot`). */\nexport function takeSnapshot(connection: CdpConnection): Promise<DomSnapshotResult> {\n return connection.send('DOMSnapshot.captureSnapshot', {});\n}\n\n/** A `take_screenshot` result: the raw base64 PNG plus a ready-to-use data URI. */\nexport interface ScreenshotResult {\n /** Base64-encoded PNG bytes (no data-URI prefix). */\n data: string;\n /** `data:image/png;base64,…` form for clients that render a URI. */\n dataUri: string;\n mimeType: 'image/png';\n}\n\n/** Captures a PNG screenshot of the attached page (`Page.captureScreenshot`). */\nexport async function takeScreenshot(connection: CdpConnection): Promise<ScreenshotResult> {\n const { data } = await connection.send('Page.captureScreenshot', { format: 'png' });\n return { data, dataUri: `data:image/png;base64,${data}`, mimeType: 'image/png' };\n}\n\n/* -------------------------------------------------------------------------- */\n/* measure_safe_area — Runtime.evaluate probe */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The JS probe injected via `Runtime.evaluate`. It reads:\n * 1. `env(safe-area-inset-*)` via a temporary element with padding set to\n * those CSS env vars, then `getComputedStyle`.\n * 2. SDK insets via a priority chain so the SAME probe works on both relay\n * (real device) and mock (devtools panel page):\n * a. `window.__sdk.SafeAreaInsets.get()` — dog-food bundle on real device.\n * b. `window.__sdk.getSafeAreaInsets()` — dog-food bundle (deprecated).\n * c. `window.__ait.state.safeAreaInsets` — devtools mock state (mock env).\n * The probe records `sdkInsetsSource` = `'window.__sdk'` | `'window.__ait'`\n * | `null`. If all paths fail the result carries `sdkInsetsError`.\n * 3. nav bar geometry: the SDK does not expose navBar height as a standalone\n * API — `.ait-navbar` DOM height is read as a cross-check, and\n * `navBarHeightSource` records where it came from.\n * 4. `innerWidth`, `innerHeight`, `devicePixelRatio`, `navigator.userAgent`.\n *\n * Returns a plain JSON-serialisable object so `returnByValue: true` works.\n *\n * NOTE: This expression is evaluated in the page context — on the real device\n * (relay) or on the mock panel page. It does not mutate any page state — the\n * temporary element is removed after reading. No secret or auth token is read\n * or returned.\n *\n * RFC #277 Tier C parity: the SAME probe string runs in both envs. Mock fidelity\n * comes from the panel's `applyViewport` / `computeSafeAreaInsets` correctly\n * setting `window.__ait.state.safeAreaInsets` (#275). When that is correct,\n * the cssEnv + sdkInsets pair returned here matches the relay's shape.\n */\nexport const SAFE_AREA_PROBE_EXPRESSION = `\n(function() {\n var el = document.createElement('div');\n el.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;visibility:hidden;' +\n 'padding-top:env(safe-area-inset-top,0px);' +\n 'padding-right:env(safe-area-inset-right,0px);' +\n 'padding-bottom:env(safe-area-inset-bottom,0px);' +\n 'padding-left:env(safe-area-inset-left,0px)';\n document.documentElement.appendChild(el);\n var cs = window.getComputedStyle(el);\n var cssEnv = {\n top: parseFloat(cs.paddingTop) || 0,\n right: parseFloat(cs.paddingRight) || 0,\n bottom: parseFloat(cs.paddingBottom) || 0,\n left: parseFloat(cs.paddingLeft) || 0\n };\n document.documentElement.removeChild(el);\n var sdkInsets = null;\n var sdkInsetsSource = null;\n var sdkInsetsError = undefined;\n try {\n var sdk = window.__sdk;\n var ait = window.__ait;\n if (sdk && sdk.SafeAreaInsets && typeof sdk.SafeAreaInsets.get === 'function') {\n sdkInsets = sdk.SafeAreaInsets.get();\n sdkInsetsSource = 'window.__sdk';\n } else if (sdk && typeof sdk.getSafeAreaInsets === 'function') {\n sdkInsets = sdk.getSafeAreaInsets();\n sdkInsetsSource = 'window.__sdk';\n } else if (ait && ait.state && ait.state.safeAreaInsets &&\n typeof ait.state.safeAreaInsets.top === 'number') {\n var s = ait.state.safeAreaInsets;\n sdkInsets = { top: s.top, bottom: s.bottom, left: s.left, right: s.right };\n sdkInsetsSource = 'window.__ait';\n } else if (!sdk && !ait) {\n sdkInsetsError = 'neither window.__sdk (relay) nor window.__ait (mock) available';\n } else if (sdk) {\n sdkInsetsError = 'neither SafeAreaInsets.get nor getSafeAreaInsets found on window.__sdk';\n } else {\n sdkInsetsError = 'window.__ait.state.safeAreaInsets is missing or malformed';\n }\n } catch(e) {\n sdkInsetsError = String(e && e.message || e);\n }\n var navBarHeight = null;\n var navBarHeightSource = 'not-exposed-by-sdk';\n try {\n var nb = document.querySelector('.ait-navbar');\n if (nb) {\n navBarHeight = nb.getBoundingClientRect().height;\n navBarHeightSource = 'dom-.ait-navbar';\n }\n } catch(_) {}\n var result = {\n cssEnv: cssEnv,\n sdkInsets: sdkInsets,\n sdkInsetsSource: sdkInsetsSource,\n navBarHeight: navBarHeight,\n navBarHeightSource: navBarHeightSource,\n innerWidth: window.innerWidth,\n innerHeight: window.innerHeight,\n devicePixelRatio: window.devicePixelRatio,\n userAgent: navigator.userAgent\n };\n if (sdkInsetsError !== undefined) result.sdkInsetsError = sdkInsetsError;\n return JSON.stringify(result);\n})()\n`.trim();\n\n/**\n * Where the SDK insets came from. `null` when the lookup failed (in which case\n * `sdkInsetsError` is populated).\n *\n * - `'window.__sdk'` — real-device dog-food bundle (relay env).\n * - `'window.__ait'` — devtools mock state (mock env).\n * - `null` — both paths absent or threw.\n */\nexport type SdkInsetsSource = 'window.__sdk' | 'window.__ait' | null;\n\n/**\n * Normalized result returned by `measure_safe_area`.\n *\n * All inset values are in CSS pixels as reported by the page context.\n * `userAgent` is included for device identification; it never contains\n * authentication secrets or session tokens.\n */\nexport interface SafeAreaMeasurement {\n /**\n * MCP environment this measurement was taken in:\n * - `'mock'` — dev browser panel\n * - `'relay-dev'` — real-device WebView, dog-food build\n * - `'relay-mobile'` — real-device PWA (env 2) over an external relay\n * (`relay-live` / env 4 removed #665.)\n *\n * Set by the caller (`measureSafeArea`) from the env detection SSoT\n * (`getEnvironment`).\n */\n source: McpEnvironment;\n /**\n * `env(safe-area-inset-*)` values read via `getComputedStyle` on the page.\n * On iOS inside the Toss host WebView this is typically all-zero because the\n * WebView viewport is placed below the physical notch by the host app.\n */\n cssEnv: { top: number; right: number; bottom: number; left: number };\n /**\n * SDK insets from one of three paths (in priority order):\n * - `window.__sdk.SafeAreaInsets.get()` (relay, dog-food bundle)\n * - `window.__sdk.getSafeAreaInsets()` (relay, deprecated)\n * - `window.__ait.state.safeAreaInsets` (mock, devtools panel state)\n *\n * `null` when all paths fail — see `sdkInsetsError` for the reason.\n * In the Toss host WebView `top` is the nav bar height and `bottom` is the\n * home-indicator height.\n */\n sdkInsets: { top: number; right: number; bottom: number; left: number } | null;\n /**\n * Which path resolved `sdkInsets` — useful for diagnosis of fidelity gaps\n * between mock and relay. `null` when `sdkInsets` is `null`.\n */\n sdkInsetsSource: SdkInsetsSource;\n /**\n * Populated when the SDK inset lookup failed (all paths absent or threw).\n * `undefined` when `sdkInsets` is non-null (i.e. the lookup succeeded).\n *\n * Example values:\n * - `\"neither window.__sdk (relay) nor window.__ait (mock) available\"`\n * - `\"neither SafeAreaInsets.get nor getSafeAreaInsets found on window.__sdk\"`\n * - `\"window.__ait.state.safeAreaInsets is missing or malformed\"`\n * - `\"TypeError: ...\"`\n */\n sdkInsetsError?: string;\n /**\n * Height of the `.ait-navbar` element (px) if present, else `null`.\n * The SDK does not expose navBar height as a standalone API; this DOM\n * measurement is used to cross-validate `sdkInsets.top`.\n */\n navBarHeight: number | null;\n /**\n * Describes where `navBarHeight` came from:\n * - `\"dom-.ait-navbar\"` — read from the `.ait-navbar` element's bounding rect.\n * - `\"not-exposed-by-sdk\"` — the SDK has no standalone navBar height API and\n * no `.ait-navbar` element was found in the DOM.\n */\n navBarHeightSource: string;\n /** CSS viewport width (`window.innerWidth`). */\n innerWidth: number;\n /** CSS viewport height (`window.innerHeight`). */\n innerHeight: number;\n /**\n * Device pixel ratio (`window.devicePixelRatio`).\n * Note: `window.devicePixelRatio` is read-only in the browser, so devtools\n * cannot emulate DPR locally — this is the ground-truth value from the device.\n */\n devicePixelRatio: number;\n /**\n * `navigator.userAgent` string for device identification.\n * Does not contain authentication secrets.\n */\n userAgent: string;\n}\n\n/**\n * Parses a raw `Runtime.evaluate` result value into a `SafeAreaMeasurement`.\n * The probe returns a JSON string (because `returnByValue:true` with a plain\n * object works unreliably across Chii relay versions — stringifying is safer).\n *\n * `source` is supplied by the caller (`measureSafeArea`) from the env SSoT.\n *\n * Throws if the result is missing, contains an exception, or cannot be parsed.\n */\nexport function normalizeSafeAreaResult(\n rawValue: unknown,\n source: McpEnvironment,\n): SafeAreaMeasurement {\n if (typeof rawValue !== 'string') {\n throw new Error(\n `measure_safe_area: probe returned unexpected type \"${typeof rawValue}\" — expected JSON string`,\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(rawValue);\n } catch {\n throw new Error(`measure_safe_area: probe returned non-JSON string: ${rawValue}`);\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw new Error('measure_safe_area: parsed result is not an object');\n }\n const obj = parsed as Record<string, unknown>;\n\n function requireInsets(\n key: string,\n ): { top: number; right: number; bottom: number; left: number } | null {\n const v = obj[key];\n if (v === null || v === undefined) return null;\n if (typeof v !== 'object') return null;\n const r = v as Record<string, unknown>;\n return {\n top: typeof r.top === 'number' ? r.top : 0,\n right: typeof r.right === 'number' ? r.right : 0,\n bottom: typeof r.bottom === 'number' ? r.bottom : 0,\n left: typeof r.left === 'number' ? r.left : 0,\n };\n }\n\n const cssEnv = requireInsets('cssEnv') ?? { top: 0, right: 0, bottom: 0, left: 0 };\n const sdkInsets = requireInsets('sdkInsets');\n const sdkInsetsSource: SdkInsetsSource =\n obj.sdkInsetsSource === 'window.__sdk' || obj.sdkInsetsSource === 'window.__ait'\n ? obj.sdkInsetsSource\n : null;\n const sdkInsetsError = typeof obj.sdkInsetsError === 'string' ? obj.sdkInsetsError : undefined;\n const navBarHeight = typeof obj.navBarHeight === 'number' ? obj.navBarHeight : null;\n const navBarHeightSource =\n typeof obj.navBarHeightSource === 'string' ? obj.navBarHeightSource : 'not-exposed-by-sdk';\n const innerWidth = typeof obj.innerWidth === 'number' ? obj.innerWidth : 0;\n const innerHeight = typeof obj.innerHeight === 'number' ? obj.innerHeight : 0;\n const devicePixelRatio = typeof obj.devicePixelRatio === 'number' ? obj.devicePixelRatio : 1;\n const userAgent = typeof obj.userAgent === 'string' ? obj.userAgent : '';\n\n return {\n source,\n cssEnv,\n sdkInsets,\n sdkInsetsSource,\n ...(sdkInsetsError !== undefined ? { sdkInsetsError } : {}),\n navBarHeight,\n navBarHeightSource,\n innerWidth,\n innerHeight,\n devicePixelRatio,\n userAgent,\n };\n}\n\n/**\n * Runs the safe-area probe on the attached page and returns a normalized\n * `SafeAreaMeasurement`. Read-only — does not mutate page state.\n *\n * `source` is supplied by the caller from the env detection SSoT (see\n * `src/mcp/environment.ts`). The same `Runtime.evaluate` call runs in both\n * envs — the probe expression tries `window.__sdk` first (relay) then\n * `window.__ait` (mock), so mock fidelity is enforced by the panel's\n * `applyViewport`/`computeSafeAreaInsets` keeping `__ait.state.safeAreaInsets`\n * correct (RFC #277 Tier C parity, #275 model).\n *\n * Throws on CDP error, probe exception, or result parse failure.\n */\nexport async function measureSafeArea(\n connection: CdpConnection,\n source: McpEnvironment,\n): Promise<SafeAreaMeasurement> {\n const result = await connection.send('Runtime.evaluate', {\n expression: SAFE_AREA_PROBE_EXPRESSION,\n returnByValue: true,\n awaitPromise: false,\n });\n if (result.exceptionDetails) {\n const msg =\n result.exceptionDetails.exception?.description ??\n result.exceptionDetails.text ??\n 'Runtime.evaluate threw an exception';\n throw new Error(`measure_safe_area: probe threw — ${msg}`);\n }\n return normalizeSafeAreaResult(result.result.value, source);\n}\n\n/* -------------------------------------------------------------------------- */\n/* evaluate — arbitrary JS via Runtime.evaluate */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Result returned by the `evaluate` tool.\n *\n * `value` holds the `returnByValue` result from CDP — it may be any\n * JSON-serialisable type. Treat it as opaque for logging purposes (it could\n * carry sensitive data from the page context).\n *\n * SECRET-HANDLING: do NOT write `value` to any log or stderr — return it to\n * the agent via the tool result only.\n */\nexport interface EvaluateResult {\n /** The evaluated result value (`returnByValue: true`). */\n value: unknown;\n /** CDP type string of the result (e.g. \"string\", \"number\", \"object\"). */\n type: string;\n}\n\n/**\n * Evaluates an arbitrary JS expression on the attached page via\n * `Runtime.evaluate`. NOT read-only — the expression may have side effects.\n *\n * Throws if the evaluation produced a CDP exception.\n *\n * SECRET-HANDLING: expression and result value are NOT written to any log.\n */\nexport async function evaluate(\n connection: CdpConnection,\n expression: string,\n): Promise<EvaluateResult> {\n const result = await connection.send('Runtime.evaluate', {\n expression,\n returnByValue: true,\n awaitPromise: false,\n });\n if (result.exceptionDetails) {\n // Surface only the engine error string — never the expression or result value.\n const msg =\n result.exceptionDetails.exception?.description ??\n result.exceptionDetails.text ??\n 'Runtime.evaluate threw an exception';\n throw new Error(`evaluate failed: ${msg}`);\n }\n return { value: result.result.value, type: result.result.type };\n}\n\n/* -------------------------------------------------------------------------- */\n/* call_sdk — window.__sdkCall bridge via Runtime.evaluate */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Result returned by the `call_sdk` tool.\n * The bridge call wraps success/failure in a JSON envelope so cross-Chii\n * stringification is reliable (same approach as `measure_safe_area`).\n *\n * `recentException` is populated when a `Runtime.exceptionThrown` event was\n * observed within the heuristic triage window [callStart-50ms, callEnd+200ms].\n * This helps correlate an SDK throw with the bridge result, especially when\n * the SDK throws synchronously before the promise resolves.\n */\nexport type CallSdkResult =\n | { ok: true; value: unknown; recentException?: BufferedException }\n | { ok: false; error: string; recentException?: BufferedException };\n\n/**\n * Builds the Runtime.evaluate expression that calls `window.__sdkCall` with\n * the given method name and args, awaits the promise, and returns a JSON\n * envelope `{ok, value/error}` as a string.\n *\n * Name and args are embedded via `JSON.stringify` so they are safely escaped.\n * The expression checks for `window.__sdkCall` and returns a clear error if\n * it is absent (non-dog-food bundle).\n *\n * SECRET-HANDLING: the expression is built here and MUST NOT be written to\n * any log or stderr by the caller.\n */\nexport function buildCallSdkExpression(name: string, args: unknown[]): string {\n const safeName = JSON.stringify(name);\n const safeArgs = JSON.stringify(args);\n return (\n `(async () => {` +\n ` if (typeof window.__sdkCall !== 'function') {` +\n ` return JSON.stringify({ok:false,error:'sdk-absent: window.__sdkCall이 주입되지 않았습니다 (dog-food 빌드가 아닙니다). dog-food 채널로 재배포하세요.'});` +\n ` }` +\n ` try {` +\n ` const r = await window.__sdkCall(${safeName}, ...${safeArgs});` +\n ` return JSON.stringify({ok:true,value:r});` +\n ` } catch(e) {` +\n ` return JSON.stringify({ok:false,error:String(e && e.message || e)});` +\n ` }` +\n `})()`\n );\n}\n\n/**\n * Parses the JSON envelope string returned by the `call_sdk` expression.\n * Returns a typed `CallSdkResult`.\n *\n * Throws only on parse failure (not on ok:false — that is a normal result).\n */\nexport function normalizeCallSdkResult(rawValue: unknown): CallSdkResult {\n if (typeof rawValue !== 'string') {\n throw new Error(\n `call_sdk: bridge returned unexpected type \"${typeof rawValue}\" — expected JSON string`,\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(rawValue);\n } catch {\n // Do NOT include rawValue in the error message — it could contain secrets.\n throw new Error('call_sdk: bridge returned non-JSON string');\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw new Error('call_sdk: parsed result is not an object');\n }\n const obj = parsed as Record<string, unknown>;\n if (obj.ok === true) {\n return { ok: true, value: obj.value };\n }\n if (obj.ok === false) {\n return { ok: false, error: typeof obj.error === 'string' ? obj.error : String(obj.error) };\n }\n throw new Error('call_sdk: bridge result missing \"ok\" field');\n}\n\n/**\n * Looks up the most recent exception from the buffer that falls within the\n * triage window [windowStart, windowEnd]. Returns `undefined` if none found.\n *\n * The heuristic window is:\n * - windowStart = callStart - 50ms (catch sync throws before bridge fires)\n * - windowEnd = callEnd + 200ms (catch async throws resolved soon after)\n *\n * Only the most recent exception within the window is returned (the one most\n * likely to be causally related to the SDK call).\n */\nfunction findRecentException(\n connection: CdpConnection,\n windowStart: number,\n windowEnd: number,\n): BufferedException | undefined {\n const events = connection.getBufferedEvents('Runtime.exceptionThrown');\n // Scan from the tail (most recent) to find the closest-in-time exception.\n for (let i = events.length - 1; i >= 0; i--) {\n const e = events[i];\n if (e.timestamp >= windowStart && e.timestamp <= windowEnd) {\n return normalizeException(e);\n }\n }\n return undefined;\n}\n\n/**\n * Calls a dog-food SDK method via `window.__sdkCall` on the attached page.\n * NOT read-only — SDK calls may have side effects.\n *\n * On env 3/4 (toss WebView relay) this hits the real SDK. On env 1 (local\n * mock) and env 2 (PWA relay — real WebKit, mock SDK) it hits the mock SDK.\n *\n * 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면\n * `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).\n * 미등록 메서드는 passthrough + stderr 경고 1회.\n *\n * Throws on CDP error or result parse failure. Returns `{ok:false, error}`\n * for bridge-level errors (method not found, SDK threw, bridge absent) or\n * argument schema violations.\n *\n * If a `Runtime.exceptionThrown` event was observed within the triage window\n * [callStart-50ms, callEnd+200ms], the result includes `recentException` for\n * crash triage. This window is a heuristic — it catches the common case of an\n * SDK throw immediately before/after the bridge resolves.\n *\n * SECRET-HANDLING: name, args, and the result value are NOT written to any log.\n */\nexport async function callSdk(\n connection: CdpConnection,\n name: string,\n args: unknown[],\n): Promise<CallSdkResult> {\n // 인자 시그니처 검증 — bridge 호출 전에 reject하여 native crash를 예방한다.\n const signature = lookupSignature(name);\n if (signature !== undefined) {\n const validation = signature.validateArgs(args);\n if (!validation.ok) {\n // isError: true 형태로 반환 — bridge에 도달하지 않음.\n const errorText =\n `call_sdk(\"${name}\") 인자 시그니처 오류.\\n` +\n `받음: ${validation.received}\\n` +\n `기대: ${validation.expected}\\n` +\n `올바른 예시: ${signature.example}`;\n return { ok: false, error: errorText };\n }\n } else {\n // 미등록 메서드 — passthrough하지만 stderr에 경고 1회.\n warnPassthrough(name);\n }\n\n const callStart = Date.now();\n const expression = buildCallSdkExpression(name, args);\n const result = await connection.send('Runtime.evaluate', {\n expression,\n returnByValue: true,\n awaitPromise: true,\n });\n const callEnd = Date.now();\n\n if (result.exceptionDetails) {\n // Surface only the engine error string — never name, args, or result value.\n const msg =\n result.exceptionDetails.exception?.description ??\n result.exceptionDetails.text ??\n 'Runtime.evaluate threw an exception';\n throw new Error(`call_sdk threw: ${msg}`);\n }\n\n const sdkResult = normalizeCallSdkResult(result.result.value);\n\n // Triage window: [callStart - 50ms, callEnd + 200ms].\n // -50ms: catches sync throws that fire just before the bridge call is sent.\n // +200ms: catches async throws resolved shortly after the bridge returns.\n const recentException = findRecentException(connection, callStart - 50, callEnd + 200);\n\n if (recentException !== undefined) {\n return { ...sdkResult, recentException };\n }\n return sdkResult;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Phase 3 — AIT.* domain (CDP can't cover these) */\n/* -------------------------------------------------------------------------- */\n\n/** Set of tool names served by the AIT source rather than the CDP connection. */\nconst AIT_TOOL_NAMES = new Set<string>([\n 'AIT.getSdkCallHistory',\n 'AIT.getMockState',\n 'AIT.getOperationalEnvironment',\n]);\n\n/** True for the Phase 3 AIT.* tools (served by an `AitSource`, not CDP). */\nexport function isAitToolName(name: string): boolean {\n return AIT_TOOL_NAMES.has(name);\n}\n\n/** Returns the recent SDK call trace (`AIT.getSdkCallHistory`). */\nexport function getSdkCallHistory(source: AitSource): Promise<AitSdkCallHistory> {\n return source.get('AIT.getSdkCallHistory');\n}\n\n/** Returns the devtools mock-state snapshot (`AIT.getMockState`). */\nexport function getMockState(source: AitSource): Promise<AitMockState> {\n return source.get('AIT.getMockState');\n}\n\n/** Returns the operational environment + SDK version (`AIT.getOperationalEnvironment`). */\nexport function getOperationalEnvironment(source: AitSource): Promise<AitOperationalEnvironment> {\n return source.get('AIT.getOperationalEnvironment');\n}\n\n/* -------------------------------------------------------------------------- */\n/* get_debug_status — single-call server status snapshot (#286) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Represents a single redacted server-side error entry in the diagnostics\n * snapshot. PII / secrets are scrubbed before this is returned.\n */\nexport interface DiagnosticsError {\n /** ISO timestamp when the error was recorded. */\n timestamp: string;\n /** Error message with PII/secrets redacted (e.g. `at=<redacted>`). */\n message: string;\n /** Optional error category for quick triage. */\n category?: string;\n}\n\n/**\n * Tunnel state in the diagnostics snapshot. Same shape as `TunnelStatus` but\n * extended with the lock-file data (pid, startedAt) when available.\n */\nexport interface DiagnosticsTunnelInfo {\n /** Whether the cloudflared quick tunnel is currently up. */\n up: boolean;\n /** Public `wss://*.trycloudflare.com` relay URL, or `null`. */\n wssUrl: string | null;\n /**\n * PID of the MCP server process that owns the tunnel (from the lock file),\n * or `null` when no lock is present.\n */\n pid: number | null;\n /**\n * ISO timestamp when the owning server process started (from the lock file),\n * or `null`.\n */\n startedAt: string | null;\n /**\n * ISO timestamp when the tunnel permanently dropped (health probe exhausted\n * all reissue attempts). `null` when the tunnel has not permanently dropped.\n * When non-null, the MCP server must be restarted to recover.\n */\n droppedAt: string | null;\n /**\n * Number of automatic reissue attempts made before the permanent drop.\n * 0 when no drop has occurred.\n */\n reissueAttempts: number;\n}\n\n/**\n * Server-lock holder info from `~/.ait-devtools/server.lock`. `null` when\n * no lock file exists (server was cleanly shut down or never started).\n */\nexport interface DiagnosticsLockHolder {\n pid: number;\n startedAt: string;\n /** wssUrl recorded in the lock file — may be `null` when tunnel is still starting. */\n wssUrl: string | null;\n}\n\n/**\n * Secret-free snapshot of relay auth rejections (issue #467).\n *\n * Counts WS-upgrade / HTTP 401 rejections from the relay's TOTP gate so an\n * empty `pages` list can be distinguished from \"the phone never reached the\n * relay\". SECRET-HANDLING: count + timestamp ONLY — never the URL, query,\n * code, or secret.\n */\nexport interface AuthRejectsSnapshot {\n /** Total auth-rejected relay requests since server start. */\n count: number;\n /** ISO timestamp of the most recent rejection, or `null` when none. */\n lastAt: string | null;\n}\n\n/**\n * The next recommended tool for the agent to call, based on the current server\n * state snapshot. `null` means the session looks healthy — no specific action needed.\n */\nexport interface NextRecommendedAction {\n /** MCP tool name to call next (e.g. `'start_attach'`, `'restart'`). */\n tool: string;\n /** Human-readable reason explaining why this action is recommended. */\n reason: string;\n}\n\n/**\n * Full server status snapshot returned by `get_debug_status`.\n *\n * All fields are nullable — a missing value means \"not yet known\" (e.g. tunnel\n * not up yet) rather than an error. The schema is intentionally stable across\n * versions: new optional fields may be added but existing fields are not\n * removed or renamed.\n *\n * SECRET-HANDLING: No TOTP secret, cookie, deploy key, or `at=` code value\n * appears in this snapshot. `recentErrors` entries are redacted before inclusion.\n */\nexport interface DiagnosticsResult {\n /** `@modelcontextprotocol/sdk` package version string. */\n mcpVersion: string | null;\n /** `@ait-co/devtools` package version string. */\n devtoolsVersion: string | null;\n /** Tunnel state including lock-file pid/startedAt. */\n tunnel: DiagnosticsTunnelInfo;\n /** Current list_pages result (pages + crash info + singleAttachModel). */\n pages: ListPagesResult | null;\n /** ISO timestamp of the most recent page attach, or `null`. */\n lastAttachAt: string | null;\n /** ISO timestamp of the most recent page detach, or `null`. */\n lastDetachAt: string | null;\n /**\n * Recent server-side errors (up to `recent_errors_limit`, default 10).\n * Redacted: `at=<redacted>`, cookie headers stripped, AITCC_API_KEY masked.\n * When auth rejections occurred, ONE synthetic summary entry\n * (`category: 'auth'`) is appended so an empty array can be read as\n * \"no attach attempts\" without missing silent 401s (issue #467).\n */\n recentErrors: DiagnosticsError[];\n /**\n * Relay TOTP auth-reject counter (issue #467). `count` is 0 when no\n * rejection occurred. Secret-free: count + last timestamp only.\n */\n authRejects: AuthRejectsSnapshot;\n /**\n * Resolved environment and the reason string.\n *\n * `kind` — the precise three-value environment (`mock` | `relay-dev` |\n * `relay-mobile`). Use this for new code. (`relay-live` removed #665.)\n * `env` — backward-compat two-value alias (`mock` | `relay`). Kept so\n * existing callers that only distinguish mock vs relay continue to work.\n */\n environment: {\n kind: McpEnvironment;\n /** @deprecated Use `kind` instead. Kept for backward compatibility. */\n env: 'mock' | 'relay';\n reason: string;\n /**\n * @deprecated Always `false` — relay-live and the LIVE guard removed (#665).\n * Kept for backward compatibility with consumers that read this field.\n */\n liveGuardActive: false;\n };\n /**\n * Contents of `~/.ait-devtools/server.lock`, or `null` when absent.\n * Useful for diagnosing stale-lock conflicts without running the full server.\n */\n serverLockHolder: DiagnosticsLockHolder | null;\n /**\n * Basic process identity for the running MCP server daemon.\n * Useful for diagnosing orphaned daemons and stale parent associations.\n */\n process: {\n /** PID of this MCP server process. */\n pid: number;\n /** Parent PID at the time `get_debug_status` was called. */\n ppid: number;\n /** Whether the parent process is still alive at snapshot time. */\n parentAlive: boolean;\n };\n /**\n * Single next recommended action for the agent, or `null` when the session\n * looks healthy. Derived deterministically from the other snapshot fields —\n * the agent should call this tool next rather than inferring from raw fields.\n *\n * Branch rules (evaluated in priority order):\n * 0. tunnel.droppedAt non-null → restart (permanent tunnel drop)\n * 1. tunnel.up === false AND relay env → restart\n * 1b. tunnel.up === false AND mock env, no pages → wait_for_page (local target is tunnel-less)\n * 2a. authRejects.count > 0 AND pages empty → start_attach (TOTP 거부 — QR 재스캔 안내)\n * 2. tunnel.up, pages empty, env === relay → start_attach\n * 3. pages[0] exists + crashDetectedAt non-null → start_attach (re-attach)\n * 4. otherwise → null\n */\n nextRecommendedAction: NextRecommendedAction | null;\n}\n\n/**\n * Registry of server-side errors collected by `DiagnosticsCollector`.\n * Injected into `createDebugServer` so it is testable without a real process.\n */\nexport interface DiagnosticsCollector {\n /** Records a server-side error for later surfacing in `get_debug_status`. */\n recordError(message: string, category?: string): void;\n /** Returns the most recent `limit` errors, oldest-first. */\n getRecentErrors(limit: number): DiagnosticsError[];\n /** Records an attach event (ISO timestamp stored). */\n recordAttach(): void;\n /** Records a detach event (ISO timestamp stored). */\n recordDetach(): void;\n /** Returns the ISO timestamp of the last attach, or `null`. */\n getLastAttachAt(): string | null;\n /** Returns the ISO timestamp of the last detach, or `null`. */\n getLastDetachAt(): string | null;\n /**\n * Records one relay auth rejection (issue #467). Secret-free by contract —\n * implementations store only a counter + timestamp, never request data.\n */\n recordAuthReject(): void;\n /** Returns the auth-reject counter snapshot ({count: 0, lastAt: null} when none). */\n getAuthRejects(): AuthRejectsSnapshot;\n}\n\n/** Secret-redaction patterns applied before error messages enter the buffer. */\nconst SECRET_REDACT_PATTERNS: ReadonlyArray<[RegExp, string]> = [\n // TOTP at= code value.\n [/\\bat=([^&\\s\"']+)/g, 'at=<redacted>'],\n // Cookie / Set-Cookie header values — replace everything after the colon.\n [/((?:set-)?cookie)\\s*:\\s*.+/gi, '$1: <redacted>'],\n // AITCC_API_KEY env-var-style references.\n [/AITCC_API_KEY\\s*=\\s*\\S+/gi, 'AITCC_API_KEY=<redacted>'],\n // Authorization header (covers \"Authorization: Bearer …\" and bare \"Bearer <token>\").\n [/Authorization\\s*:\\s*.+/gi, 'Authorization: <redacted>'],\n [/\\bBearer\\s+\\S+/g, 'Bearer <redacted>'],\n];\n\n/**\n * Applies all secret-redaction patterns to an error message string.\n * Used before storing errors in the `DiagnosticsCollector` ring buffer.\n *\n * SECRET-HANDLING: this is the single bottleneck for redaction — all error\n * strings must pass through here before reaching the buffer.\n */\nexport function redactErrorMessage(message: string): string {\n let result = message;\n for (const [pattern, replacement] of SECRET_REDACT_PATTERNS) {\n result = result.replace(pattern, replacement);\n }\n return result;\n}\n\n/** Default max buffer size for the error ring buffer. */\nconst DEFAULT_ERROR_BUFFER_SIZE = 50;\n\n/**\n * In-memory implementation of `DiagnosticsCollector`. Thread-safe in the\n * single-threaded Node.js sense (synchronous mutations only).\n */\nexport class InMemoryDiagnosticsCollector implements DiagnosticsCollector {\n private readonly buffer: DiagnosticsError[] = [];\n private readonly maxSize: number;\n private lastAttachAt: string | null = null;\n private lastDetachAt: string | null = null;\n private authRejectCount = 0;\n private lastAuthRejectAt: string | null = null;\n\n constructor(maxSize = DEFAULT_ERROR_BUFFER_SIZE) {\n this.maxSize = maxSize;\n }\n\n recordError(message: string, category?: string): void {\n const entry: DiagnosticsError = {\n timestamp: new Date().toISOString(),\n message: redactErrorMessage(message),\n ...(category !== undefined ? { category } : {}),\n };\n this.buffer.push(entry);\n // Keep only the most recent `maxSize` entries.\n if (this.buffer.length > this.maxSize) {\n this.buffer.shift();\n }\n }\n\n getRecentErrors(limit: number): DiagnosticsError[] {\n const cap = Math.min(Math.max(1, limit), DEFAULT_ERROR_BUFFER_SIZE);\n const sliced =\n this.buffer.length > cap ? this.buffer.slice(this.buffer.length - cap) : [...this.buffer];\n return sliced;\n }\n\n recordAttach(): void {\n this.lastAttachAt = new Date().toISOString();\n }\n\n recordDetach(): void {\n this.lastDetachAt = new Date().toISOString();\n }\n\n getLastAttachAt(): string | null {\n return this.lastAttachAt;\n }\n\n getLastDetachAt(): string | null {\n return this.lastDetachAt;\n }\n\n recordAuthReject(): void {\n this.authRejectCount += 1;\n this.lastAuthRejectAt = new Date().toISOString();\n }\n\n getAuthRejects(): AuthRejectsSnapshot {\n return { count: this.authRejectCount, lastAt: this.lastAuthRejectAt };\n }\n}\n\n/**\n * Returns the `@modelcontextprotocol/sdk` version baked in at build time via\n * the `__MCP_SDK_VERSION__` define (see `tsdown.config.ts`). Returns `null`\n * when the define is absent (unbundled test runs) and the runtime fallback\n * below also fails — diagnostics must never throw.\n *\n * Earlier attempts resolved `@modelcontextprotocol/sdk/package.json` (not in\n * the SDK `exports` map → `ERR_PACKAGE_PATH_NOT_EXPORTED`) or the bare\n * `@modelcontextprotocol/sdk` main entry (also absent → `MODULE_NOT_FOUND`),\n * so both this fallback AND the build-time define silently produced `null` —\n * leaving `mcpVersion: null` in a real bundle (issue #361, observed live). The\n * fix resolves a subpath that IS exported (`./server/mcp.js`) and walks back to\n * the package root, in BOTH the build define and this fallback.\n *\n * Kept `async` for call-site compatibility (`Promise.all` at the caller); the\n * body is synchronous apart from the best-effort fallback.\n */\nexport async function readMcpSdkVersion(): Promise<string | null> {\n // Primary: build-time define (bare identifier, substituted by tsdown).\n if (typeof __MCP_SDK_VERSION__ === 'string' && __MCP_SDK_VERSION__.length > 0) {\n return __MCP_SDK_VERSION__;\n }\n // Fallback for unbundled runs (the define never ran): resolve an EXPORTED\n // subpath (`./server/mcp.js`) and read the sibling package.json by path —\n // bypassing the `exports` gate that blocks both the `/package.json` subpath\n // and the bare main entry.\n try {\n const { createRequire } = await import('node:module');\n const req = createRequire(import.meta.url);\n const entry = req.resolve('@modelcontextprotocol/sdk/server/mcp.js');\n const marker = '@modelcontextprotocol/sdk';\n const root = entry.slice(0, entry.indexOf(marker) + marker.length);\n const { readFileSync } = await import('node:fs');\n const raw = readFileSync(`${root}/package.json`, 'utf8');\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n return typeof parsed.version === 'string' ? parsed.version : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Returns the `@ait-co/devtools` package version injected at build time via\n * the `__VERSION__` define. Returns `null` when the global is absent (e.g. in\n * some test environments that skip the build step).\n */\nexport function readDevtoolsVersion(): string | null {\n // `__VERSION__` is a bare identifier replaced at build time by the tsdown\n // `define` (see `tsdown.config.ts`) — the SAME mechanism `debug-server.ts`\n // and `server.ts` use for the MCP server `version`. It must be referenced as\n // a bare identifier, not `globalThis.__VERSION__`: `define` only substitutes\n // the bare token, so the property access always read `undefined` and this\n // function always returned `null` in a real bundle (issue #361). The\n // `typeof` guard keeps it null-safe in unbundled test runs where the define\n // never ran.\n return typeof __VERSION__ === 'string' && __VERSION__.length > 0 ? __VERSION__ : null;\n}\n\n/**\n * Derives the next recommended action from a completed diagnostics snapshot.\n *\n * Branch rules (evaluated in priority order):\n * 0. tunnel.droppedAt non-null → restart (permanent tunnel drop — highest priority)\n * 1. tunnel.up === false AND env is relay → restart (relay needs a live tunnel)\n * 1b. tunnel.up === false AND env is mock → wait_for_page (local target: tunnel-less is normal)\n * 2a. authRejects.count > 0 AND pages empty → start_attach (relay TOTP 거부 관측 — QR 재스캔\n * 또는 target-side `at` 전달 확인. 일반 rule 2보다 구체적이므로 먼저 평가 — issue #467)\n * 2. tunnel.up, pages empty, env === relay → start_attach (start attach)\n * 3. pages has entry + crashDetectedAt non-null → start_attach (re-attach after crash)\n * 4. otherwise → null (session looks healthy)\n *\n * Pure — does not throw; receives the final assembled snapshot fields.\n *\n * SECRET-HANDLING: the auth-reject reason string carries only the count and\n * timestamp from {@link AuthRejectsSnapshot} — never a URL, code, or secret.\n */\nexport function computeNextRecommendedAction(\n tunnel: DiagnosticsTunnelInfo,\n pages: ListPagesResult | null,\n env: McpEnvironment,\n authRejects: AuthRejectsSnapshot | null = null,\n): NextRecommendedAction | null {\n // Rule 0: permanent tunnel drop — highest priority, beats crash / empty-pages rules.\n // droppedAt is set by the health probe after exhausting all reissue attempts.\n if (tunnel.droppedAt != null) {\n return {\n tool: 'restart',\n reason:\n `tunnel permanently dropped at ${tunnel.droppedAt} after ${tunnel.reissueAttempts} reissue attempt(s) — ` +\n 'restart the MCP server (npx @ait-co/devtools devtools-mcp)',\n };\n }\n\n // Rule 1: tunnel is down.\n if (!tunnel.up) {\n // Rule 1b: local-target (mock env) runs without a relay tunnel by design —\n // tunnel.up === false is the expected steady state. Instead of recommending\n // a server restart, guide the agent to wait for the page to load.\n if (!isRelayEnv(env)) {\n // Only surface wait_for_page when no page is attached yet; once a page\n // attaches the session is healthy and null is the correct return value.\n if (pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) {\n return {\n tool: 'wait_for_page',\n reason:\n 'local Chromium spawn 직후 — 페이지 로드를 기다리거나 list_pages를 재호출하세요 ' +\n '(local 모드는 tunnel이 없는 게 정상입니다)',\n };\n }\n // Page already attached or crash detected — fall through to other rules.\n } else {\n // Rule 1 (relay env): tunnel must be up for relay to work — restart.\n return {\n tool: 'restart',\n reason: 'tunnel not up — run `npx @ait-co/devtools devtools-mcp` to restart',\n };\n }\n }\n\n // Rule 2a (issue #467): auth rejections observed while no page is attached —\n // the phone DID reach the relay but its TOTP verification failed. Without\n // this rule the generic rule 2 (\"call start_attach\") hides the rejection\n // and the diagnosis runs the wrong way (\"the phone never arrived\").\n if (authRejects !== null && authRejects.count > 0 && pages !== null && pages.pages.length === 0) {\n return {\n tool: 'start_attach',\n reason:\n `relay 인증(TOTP) 거부 ${authRejects.count}건 발생 (last ${authRejects.lastAt ?? 'unknown'}) — ` +\n 'QR을 다시 스캔해 새 코드로 attach하세요(코드는 ~3분마다 만료). 반복되면 폰 페이지 URL에 ' +\n 'at 파라미터가 전달되는지(target-side TOTP 전달 경로)를 확인하세요',\n };\n }\n\n // Rule 2: tunnel up but no pages attached in relay env → start attach.\n if (isRelayEnv(env) && pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) {\n return {\n tool: 'start_attach',\n reason: 'tunnel ready, no pages attached — call start_attach to generate the attach QR',\n };\n }\n\n // Rule 3: crash detected — need to re-attach.\n if (pages !== null && pages.crashDetectedAt !== null) {\n return {\n tool: 'start_attach',\n reason: `page crashed at ${pages.crashDetectedAt} — call start_attach to re-attach`,\n };\n }\n\n // Rule 4: session looks healthy.\n return null;\n}\n\n/** Input for `getDiagnostics`. */\nexport interface GetDiagnosticsInput {\n /** Current tunnel status (from the server's live `getTunnelStatus()`). */\n tunnel: TunnelStatus;\n /**\n * CDP connection used to call `list_pages` — may be absent in edge cases\n * (e.g. called from the dev-mode server which has no CDP connection).\n */\n connection?: CdpConnection;\n /**\n * Resolved MCP environment (`mock` | `relay-dev` | `relay-mobile`).\n * Caller obtains via `resolveEnvironment()`. (`relay-live` removed #665.)\n */\n env: McpEnvironment;\n /** Human-readable reason for the env decision. */\n envReason: string;\n /** Diagnostics collector for errors / attach events. */\n collector: DiagnosticsCollector;\n /** Lock-file reader — injected so tests can override without touching the FS. */\n readLock: () => import('./server-lock.js').LockData | null;\n /** Maximum number of recent errors to include (default 10). */\n recentErrorsLimit?: number;\n /** Optional async resolver for the MCP SDK version. */\n getMcpVersion?: () => Promise<string | null>;\n /**\n * Injectable parent-alive check for testability.\n * Defaults to `() => isPidAlive(process.ppid)` in production.\n */\n checkParentAlive?: () => boolean;\n /**\n * PID of the cloudflared child process — obtained from `QuickTunnel.childPid`\n * and written to the lock file via `LockHandle.updateTunnelChildPid`.\n *\n * FIX 2 (issue #571): when this PID is known, `getDiagnostics` performs a\n * live `isPidAlive(tunnelChildPid)` check and overrides `tunnel.up = false`\n * if the child is dead, preventing the cached `up: true` from being reported\n * as truth when the cloudflared process has already exited.\n */\n tunnelChildPid?: number | null;\n}\n\n/**\n * Builds the `get_debug_status` response. Pure — does not throw; missing data\n * fields are `null`. Async because `readMcpSdkVersion` needs `import()`.\n *\n * SECRET-HANDLING:\n * - `recentErrors` messages are already redacted by `recordError` (via\n * `redactErrorMessage`). No additional redaction needed here.\n * - `tunnel.wssUrl` is a public cloudflared hostname — not a secret.\n * - Lock file data contains only pid + startedAt + wssUrl — no secrets.\n */\nexport async function getDiagnostics(input: GetDiagnosticsInput): Promise<DiagnosticsResult> {\n const {\n tunnel,\n connection,\n env,\n envReason,\n collector,\n readLock: readLockFn,\n recentErrorsLimit = 10,\n getMcpVersion = readMcpSdkVersion,\n checkParentAlive = () => isPidAlive(process.ppid),\n tunnelChildPid,\n } = input;\n\n const [mcpVersion, devtoolsVersion] = await Promise.all([\n getMcpVersion(),\n Promise.resolve(readDevtoolsVersion()),\n ]);\n\n // Read lock file for serverLockHolder + tunnel pid/startedAt.\n const lockData = readLockFn();\n const serverLockHolder: DiagnosticsLockHolder | null = lockData\n ? { pid: lockData.pid, startedAt: lockData.startedAt, wssUrl: lockData.wssUrl }\n : null;\n\n // FIX 2 (issue #571): if the cloudflared child PID is known, perform a live\n // probe to detect child death even when the cached `tunnel.up` is still true.\n // This prevents the 2d17h zombie scenario where the process died but the cache\n // was never invalidated.\n //\n // Source priority: explicit `tunnelChildPid` arg (in-memory, always current) →\n // lock file's `tunnelChildPid` (populated by FIX 3 via onTunnelChildPid) →\n // null (no probe). The lock-file fallback ensures the check fires even when the\n // handler didn't pass the in-memory PID explicitly (issue #572 review).\n const effectiveTunnelChildPid = tunnelChildPid ?? lockData?.tunnelChildPid ?? null;\n let effectiveUp = tunnel.up;\n if (\n tunnel.up &&\n typeof effectiveTunnelChildPid === 'number' &&\n effectiveTunnelChildPid !== null &&\n !isPidAlive(effectiveTunnelChildPid)\n ) {\n effectiveUp = false;\n }\n\n const tunnelInfo: DiagnosticsTunnelInfo = {\n up: effectiveUp,\n wssUrl: tunnel.wssUrl,\n pid: lockData?.pid ?? null,\n startedAt: lockData?.startedAt ?? null,\n droppedAt: tunnel.droppedAt ?? null,\n reissueAttempts: tunnel.reissueAttempts ?? 0,\n };\n\n // list_pages — non-fatal; null on any error.\n // Refresh from relay first (#551 — stale cache causes pages:0 / wrong\n // nextRecommendedAction even when a target is attached). Same best-effort\n // pattern as the list_pages handler: errors are silently ignored so the\n // caller always gets *something* back, even when the relay is unreachable.\n let pages: ListPagesResult | null = null;\n if (connection !== undefined) {\n try {\n await connection.refreshTargets?.();\n } catch {\n // Ignore refresh errors — continue with cached state.\n }\n try {\n pages = listPages(connection, tunnel);\n } catch {\n // Ignore — pages stays null.\n }\n }\n\n const limit = Math.min(Math.max(1, recentErrorsLimit), 50);\n const recentErrors = collector.getRecentErrors(limit);\n\n // Issue #467: surface relay auth rejections. One synthetic summary entry\n // (not N entries — rejections can be frequent) so \"recentErrors: []\" can be\n // read as \"no attach attempts\" without hiding silent 401s.\n // SECRET-HANDLING: message carries only count + timestamp.\n const authRejects = collector.getAuthRejects();\n if (authRejects.count > 0) {\n recentErrors.push({\n timestamp: authRejects.lastAt ?? new Date().toISOString(),\n message: `WS upgrade auth-rejected (${authRejects.count} times, last ${authRejects.lastAt ?? 'unknown'})`,\n category: 'auth',\n });\n }\n\n const nextRecommendedAction = computeNextRecommendedAction(tunnelInfo, pages, env, authRejects);\n\n return {\n mcpVersion,\n devtoolsVersion,\n tunnel: tunnelInfo,\n pages,\n lastAttachAt: collector.getLastAttachAt(),\n lastDetachAt: collector.getLastDetachAt(),\n recentErrors,\n authRejects,\n environment: {\n kind: env,\n env: toLegacyEnv(env),\n reason: envReason,\n liveGuardActive: false, // relay-live and LIVE guard removed (#665)\n },\n serverLockHolder,\n process: {\n pid: process.pid,\n ppid: process.ppid,\n parentAlive: checkParentAlive(),\n },\n nextRecommendedAction,\n };\n}\n","/**\n * cloudflared quick tunnel + attach banner for the debug-mode MCP server.\n *\n * On spawn, the debug server opens an accountless `*.trycloudflare.com` quick\n * tunnel to the local Chii relay so the phone can attach over a public wss URL,\n * then prints a unicode half-block QR + attach instructions. When TOTP auth is\n * enabled (`AIT_DEBUG_TOTP_SECRET` is set), the QR encodes only the base relay\n * URL — the TOTP code (`at=`) is NOT included because it rotates every 30 s\n * and would be stale by the time a human scans. The in-app deep-link builder\n * splices the live code at attach time.\n *\n * Tunnel health probe (`TunnelHealthProbe`):\n * After the tunnel is up, a periodic HTTP HEAD probe hits the tunnel's\n * `https://` URL every `probeIntervalMs` (default 60 s). Two consecutive\n * failures trigger a reissue attempt (spawn a new cloudflared quick tunnel\n * and redirect traffic). After `MAX_REISSUE_ATTEMPTS` (3) consecutive\n * reissue failures, the probe gives up and marks the tunnel permanently\n * dropped — `tunnelStatus.up` becomes false with `droppedAt` set. The caller\n * should surface this to the agent so the user knows to restart the server.\n *\n * SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear\n * in any output from this module.\n *\n * Node-only: spawns the cloudflared binary and writes to stdout/stderr.\n */\n\nimport { randomBytes } from 'node:crypto';\nimport { bin, install, Tunnel } from 'cloudflared';\nimport type { TunnelStatus } from './tools.js';\n\n/** Generates a 32-byte hex attach token shown as a pairing hint (relay-side validation is a later phase). */\nexport function generateAttachToken(): string {\n return randomBytes(32).toString('hex');\n}\n\nexport interface QuickTunnel {\n /** Public `https://*.trycloudflare.com` URL the tunnel exposes. */\n url: string;\n /** Same host as `wss://` — the relay endpoint the phone attaches to. */\n wssUrl: string;\n /**\n * PID of the cloudflared child process. Present once the tunnel is up.\n * Safe to surface in diagnostics (plain integer — not a secret).\n */\n childPid?: number;\n /**\n * Register a callback to be invoked when the cloudflared child exits\n * unexpectedly (i.e. NOT due to our own `stop()` call). The caller\n * (`startTunnelHealthProbe`) uses this to immediately trigger reissue\n * without waiting for the next probe interval.\n *\n * Only one callback can be registered at a time; calling this again\n * replaces the previous one.\n */\n onUnexpectedExit(cb: (code: number | null) => void): void;\n stop(): void;\n}\n\n/** Ensures the cloudflared binary is installed (downloads + caches on first run). */\nasync function ensureCloudflaredBin(): Promise<void> {\n const { existsSync } = await import('node:fs');\n if (!existsSync(bin)) {\n await install(bin);\n }\n}\n\n/**\n * Opens a cloudflared quick tunnel to the local relay port and resolves once\n * the public URL is assigned.\n *\n * FIX 1 (issue #571): after URL resolution the returned `QuickTunnel` object\n * watches the cloudflared child process for unexpected exits and calls any\n * registered `onUnexpectedExit` callback so the health probe can immediately\n * trigger reissue instead of waiting for the next poll interval.\n */\nexport async function startQuickTunnel(localPort: number): Promise<QuickTunnel> {\n await ensureCloudflaredBin();\n\n const tunnel = Tunnel.quick(`http://127.0.0.1:${localPort}`);\n\n const url = await new Promise<string>((resolve, reject) => {\n const onUrl = (assigned: string) => {\n cleanup();\n resolve(assigned);\n };\n const onError = (err: Error) => {\n cleanup();\n reject(err);\n };\n const onExit = (code: number | null) => {\n cleanup();\n reject(new Error(`cloudflared exited before assigning a URL (code ${code})`));\n };\n const cleanup = () => {\n tunnel.off('url', onUrl);\n tunnel.off('error', onError);\n tunnel.off('exit', onExit);\n };\n tunnel.once('url', onUrl);\n tunnel.once('error', onError);\n tunnel.once('exit', onExit);\n });\n\n // FIX 1: watch for unexpected child death AFTER URL is resolved.\n // `intentionalStop` guards against triggering a reissue when we called stop() ourselves\n // (cloudflared exits on SIGINT from tunnel.stop(), which would otherwise look like a crash).\n let intentionalStop = false;\n let unexpectedExitCb: ((code: number | null) => void) | null = null;\n\n tunnel.once('exit', (code: number | null) => {\n if (!intentionalStop && unexpectedExitCb !== null) {\n unexpectedExitCb(code);\n }\n });\n\n return {\n url,\n wssUrl: url.replace(/^https/, 'wss'),\n childPid: (tunnel.process as { pid?: number } | null)?.pid,\n onUnexpectedExit(cb: (code: number | null) => void): void {\n unexpectedExitCb = cb;\n },\n stop(): void {\n intentionalStop = true;\n tunnel.stop();\n },\n };\n}\n\nexport interface AttachBannerInput {\n wssUrl: string;\n /**\n * Whether TOTP auth is enabled on the relay (`AIT_DEBUG_TOTP_SECRET` is set).\n *\n * When `true`, the banner notes that a rotating code (`at=`) will be\n * appended to attach URLs at call time — the code is NOT printed here\n * because it rotates every 30 s and would be stale in seconds.\n */\n totpEnabled: boolean;\n}\n\n/**\n * Renders a pure unicode half-block QR string for the given text.\n *\n * Uses `qrcode` (Node full lib) to get the raw bit matrix, then encodes every\n * two vertical modules into a single half-block character:\n * - both dark → `█`\n * - top only → `▀`\n * - bottom only → `▄`\n * - both light → ` ` (space)\n *\n * The output contains **zero ANSI escape codes**, so it renders correctly in\n * every surface (terminal, VS Code, JetBrains, web) and can be scanned by a\n * phone camera when shown verbatim in an agent response.\n *\n * Shared by `renderAttachBanner` (relay wssUrl QR) and the `start_attach`\n * MCP tool response (attach deep-link QR).\n */\nexport async function renderQr(text: string): Promise<string> {\n // Dynamic import mirrors the cloudflared/qrcode-terminal precedent: keeps the\n // dependency out of the module graph when the function is not called.\n const { default: QRCode } = await import('qrcode');\n const qr = QRCode.create(text, { errorCorrectionLevel: 'M' });\n const size: number = qr.modules.size;\n const data: Uint8Array = qr.modules.data as Uint8Array;\n\n const isDark = (x: number, y: number): boolean => {\n if (x < 0 || y < 0 || x >= size || y >= size) return false;\n return data[y * size + x] === 1;\n };\n\n const QUIET = 1;\n const lines: string[] = [];\n for (let y = -QUIET; y < size + QUIET; y += 2) {\n let line = '';\n for (let x = -QUIET; x < size + QUIET; x++) {\n const top = isDark(x, y);\n const bot = isDark(x, y + 1);\n line += top && bot ? '█' : top ? '▀' : bot ? '▄' : ' ';\n }\n lines.push(line);\n }\n return `${lines.join('\\n')}\\n`;\n}\n\n/**\n * Renders the attach banner (relay URL + unicode half-block QR) as a string.\n *\n * The QR is produced by `renderQr` (a half-block matrix, not the\n * `qrcode-terminal` ASCII art used by the unplugin banner) and encodes the\n * base `wssUrl` only. When `totpEnabled` is true, a note\n * is added that attach URLs generated by `start_attach` will include a\n * live TOTP code (`at=`) appended at call time.\n *\n * SECRET-HANDLING: no secret value, TOTP code, or intermediate value is\n * included in this output.\n */\nexport async function renderAttachBanner(input: AttachBannerInput): Promise<string> {\n // The QR encodes only the relay wssUrl — no token or code. This is safe\n // because the relay gate enforces the code at WS upgrade time anyway; the\n // QR is just for locating the relay, not for bypassing auth.\n const qr = await renderQr(input.wssUrl);\n\n const authNote = input.totpEnabled\n ? ' auth: TOTP enabled — attach URLs include a rotating code (at=).'\n : ' auth: none (set AIT_DEBUG_TOTP_SECRET to enable TOTP).';\n\n return [\n '',\n 'AIT debug — attach a mini-app to this session',\n '',\n ` relay (wss): ${input.wssUrl}`,\n authNote,\n '',\n ' Use start_attach to generate a deep link with the current TOTP code.',\n ' Scan the QR to locate the relay (open the dog-food URL separately with',\n ' ?debug=1&relay=<wss>&at=<code> or use the start_attach tool):',\n '',\n qr,\n ].join('\\n');\n}\n\n/** Prints the attach banner to stderr (stdout is the MCP stdio channel). */\nexport async function printAttachBanner(input: AttachBannerInput): Promise<void> {\n const banner = await renderAttachBanner(input);\n process.stderr.write(`${banner}\\n`);\n}\n\n/* -------------------------------------------------------------------------- */\n/* TunnelHealthProbe — periodic health check + auto-reissue */\n/* -------------------------------------------------------------------------- */\n\n/** Maximum consecutive reissue attempts before the probe gives up. */\nexport const MAX_REISSUE_ATTEMPTS = 3;\n\n/**\n * Probes `https://` URL with an HTTP HEAD request.\n * Returns `true` when the server responds (any HTTP status), `false` on\n * network error or timeout.\n *\n * We treat any HTTP response (including 4xx/5xx) as \"tunnel alive\" because\n * cloudflared itself responds to the HEAD — if the tunnel process died, the\n * request fails at the network level rather than returning a status code.\n *\n * @param httpsUrl - The `https://` tunnel URL to probe.\n * @param timeoutMs - Abort timeout in ms. Default 10 000.\n */\nexport async function probeTunnel(httpsUrl: string, timeoutMs = 10_000): Promise<boolean> {\n const { default: https } = await import('node:https');\n return new Promise<boolean>((resolve) => {\n const url = new URL(httpsUrl);\n const timer = setTimeout(() => {\n req.destroy();\n resolve(false);\n }, timeoutMs);\n\n const req = https.request(\n { hostname: url.hostname, port: 443, path: url.pathname || '/', method: 'HEAD' },\n (_res) => {\n clearTimeout(timer);\n _res.resume(); // drain response body to free socket\n resolve(true);\n },\n );\n req.on('error', () => {\n clearTimeout(timer);\n resolve(false);\n });\n req.end();\n });\n}\n\nexport interface TunnelHealthProbeOptions {\n /**\n * Interval in ms between health probes. Default 60 000 (60 s).\n * Use a smaller value in tests.\n */\n probeIntervalMs?: number;\n /**\n * How many consecutive probe failures to tolerate before triggering a\n * reissue. Default 2 (so one transient network hiccup is forgiven).\n */\n failuresBeforeReissue?: number;\n /**\n * Callback invoked after a successful reissue. The caller (debug-server)\n * uses this to update `tunnelStatus` and reprint the attach banner with the\n * new `wssUrl`.\n */\n onReissue: (newTunnel: QuickTunnel) => void;\n /**\n * Callback invoked when the probe permanently gives up (all reissue attempts\n * exhausted). The caller should mark `tunnelStatus.up = false` and surface\n * the error to the agent / user.\n */\n onPermanentDrop: (droppedAt: string) => void;\n /**\n * Optional stderr-compatible logger. Default `process.stderr.write`.\n * Injected in tests to avoid real I/O.\n */\n log?: (msg: string) => void;\n /**\n * Optional probe function override (for tests — avoids real HTTP requests).\n */\n probe?: (httpsUrl: string) => Promise<boolean>;\n /**\n * Optional tunnel spawner override (for tests — avoids real cloudflared).\n */\n spawnTunnel?: (localPort: number) => Promise<QuickTunnel>;\n}\n\n/**\n * Starts a periodic health probe for a cloudflared quick tunnel.\n *\n * Every `probeIntervalMs` the probe sends an HTTP HEAD request to the tunnel's\n * `https://` URL. When `failuresBeforeReissue` consecutive failures are\n * detected, it attempts to spawn a new tunnel (up to `MAX_REISSUE_ATTEMPTS`\n * times). On success the caller is notified via `onReissue`; on permanent\n * failure via `onPermanentDrop`.\n *\n * FIX 1 (issue #571): the probe also subscribes to each tunnel's\n * `onUnexpectedExit` callback to detect child death *immediately* instead of\n * waiting for the next probe interval (which could be 60 s away).\n *\n * @returns `stop` — call during server shutdown to clear the probe interval.\n */\nexport function startTunnelHealthProbe(\n initialTunnel: QuickTunnel,\n localPort: number,\n options: TunnelHealthProbeOptions,\n): { stop(): void } {\n const {\n probeIntervalMs = 60_000,\n failuresBeforeReissue = 2,\n onReissue,\n onPermanentDrop,\n log = (msg: string) => process.stderr.write(msg),\n probe = probeTunnel,\n spawnTunnel = startQuickTunnel,\n } = options;\n\n let currentTunnel = initialTunnel;\n let consecutiveFailures = 0;\n let reissueAttempts = 0;\n let stopped = false;\n\n // FIX 1: shared reissue-or-drop logic — called both from the periodic\n // interval (after failuresBeforeReissue consecutive probe misses) and from\n // the child-exit handler (immediately on unexpected process death).\n const doReissueOrDrop = async (): Promise<void> => {\n if (stopped) return;\n\n reissueAttempts += 1;\n if (reissueAttempts > MAX_REISSUE_ATTEMPTS) {\n // Already exhausted — do not log again.\n return;\n }\n\n log(\n `[ait-debug] tunnel drop detected — reissuing (attempt ${reissueAttempts}/${MAX_REISSUE_ATTEMPTS})\\n`,\n );\n\n try {\n const newTunnel = await spawnTunnel(localPort);\n // Stop the old tunnel process to free system resources.\n try {\n currentTunnel.stop();\n } catch {\n // Ignore stop errors — the process may already be dead.\n }\n currentTunnel = newTunnel;\n consecutiveFailures = 0;\n // FIX 1: arm child-exit watcher on the newly spawned tunnel too.\n armChildExitWatch(newTunnel);\n log(`[ait-debug] tunnel reissued — new relay: ${newTunnel.wssUrl}\\n`);\n onReissue(newTunnel);\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n log(`[ait-debug] tunnel reissue attempt ${reissueAttempts} failed: ${message}\\n`);\n\n if (reissueAttempts >= MAX_REISSUE_ATTEMPTS) {\n clearInterval(handle);\n stopped = true;\n const droppedAt = new Date().toISOString();\n log(\n `[ait-debug] tunnel permanently dropped after ${MAX_REISSUE_ATTEMPTS} reissue attempts — ` +\n 'restart the debug server to continue (npx @ait-co/devtools devtools-mcp).\\n',\n );\n onPermanentDrop(droppedAt);\n }\n }\n };\n\n // FIX 1: register exit watcher on a QuickTunnel so unexpected child death\n // immediately kicks off reissue without waiting for the probe interval.\n const armChildExitWatch = (t: QuickTunnel): void => {\n t.onUnexpectedExit((code) => {\n if (stopped) return;\n log(\n `[ait-debug] cloudflared child exited unexpectedly (code=${code}) — triggering immediate reissue\\n`,\n );\n // Set failures to threshold so the next interval probe also sees a clean\n // state; the actual reissue happens immediately below.\n consecutiveFailures = failuresBeforeReissue;\n void doReissueOrDrop();\n });\n };\n\n // Arm the watcher on the initial tunnel.\n armChildExitWatch(initialTunnel);\n\n const handle = setInterval(() => {\n void (async () => {\n if (stopped) return;\n\n const httpsUrl = currentTunnel.url;\n const alive = await probe(httpsUrl);\n\n if (alive) {\n // Tunnel responded — reset failure counter.\n if (consecutiveFailures > 0) {\n log('[ait-debug] tunnel health probe: tunnel recovered\\n');\n }\n consecutiveFailures = 0;\n reissueAttempts = 0;\n return;\n }\n\n consecutiveFailures += 1;\n log(\n `[ait-debug] tunnel health probe: failure ${consecutiveFailures}/${failuresBeforeReissue} (url=${httpsUrl})\\n`,\n );\n\n if (consecutiveFailures < failuresBeforeReissue) {\n // Tolerate transient failures — wait for the next interval.\n return;\n }\n\n // Threshold reached — attempt reissue.\n await doReissueOrDrop();\n })();\n }, probeIntervalMs);\n\n return {\n stop() {\n stopped = true;\n clearInterval(handle);\n },\n };\n}\n\n/**\n * Builds a `TunnelStatus` snapshot that includes drop state.\n *\n * Convenience helper for callers (debug-server) that maintain a mutable\n * `tunnelStatus` object — keeps the shape construction in one place.\n */\nexport function makeTunnelStatus(\n up: boolean,\n wssUrl: string | null,\n droppedAt: string | null = null,\n reissueAttempts = 0,\n): TunnelStatus {\n return { up, wssUrl, droppedAt, reissueAttempts };\n}\n","/**\n * @ait-co/devtools attach orchestrator (issue #684 §2).\n *\n * The relay-attach orchestration — minting a fresh-TOTP attach URL, validating\n * an env's attach preconditions, rendering the QR (dashboard or text), opening\n * the browser, and waiting (segmented, with in-call TOTP re-mint) for a real\n * device to attach — used to live INLINE in `createDebugServer`'s closure\n * (`src/mcp/debug-server.ts`). It read six closure variables, so it could not be\n * reused outside the MCP `start_attach` handler.\n *\n * This module lifts that orchestration to MODULE LEVEL and promotes those six\n * closure variables to an explicit {@link AttachDeps} object. The behavior is\n * IDENTICAL — this is a pure extraction (issue #684 PR1). Two adapters consume\n * it:\n *\n * - the MCP `start_attach` CallTool handler (`debug-server.ts`) — assembles\n * `attachDeps` from its own closure variables and calls these functions;\n * - (forthcoming, PR3) the `devtools-test` CLI — boots a single relay family\n * and assembles `attachDeps` without a dashboard/SSE callback.\n *\n * The orchestrator imports NEITHER adapter (zero reverse dependency). It pulls\n * only modules already in the MCP-daemon graph (react-free) — see the\n * install-graph invariant in devtools `CLAUDE.md`.\n *\n * SECRET-HANDLING: the TOTP code is minted fresh at call time and rides inside\n * the assembled URL's `at=` param only — never logged or returned separately.\n * Tunnel/scheme hosts + relay wss URLs are NEVER logged. The browser is opened\n * on a `http://127.0.0.1:<port>` URL only.\n *\n * Node-only.\n */\n\nimport type { CdpConnection } from './cdp-connection.js';\nimport {\n buildDeepLinkAttachUrl,\n buildLauncherAttachUrl,\n validateSchemeAuthority,\n} from './deeplink.js';\nimport type { McpEnvironment } from './environment.js';\nimport { classifyToolError, mcpError } from './errors.js';\nimport { logInfo } from './log.js';\nimport type { QrHttpServer } from './qr-http-server.js';\nimport {\n canOpenBrowser as defaultCanOpenBrowser,\n listPages,\n openQrInBrowser,\n type TunnelStatus,\n} from './tools.js';\nimport { generateTotp, RELAY_VERIFY_SKEW_STEPS } from './totp.js';\nimport { renderQr } from './tunnel.js';\n\n/**\n * Maximum age (ms) of a page's `lastSeenAt` before it is treated as a ghost\n * and excluded from the `wait_for_attach` short-circuit in `start_attach`\n * (issue #610).\n *\n * Rationale: the env-2 relay is owned by the dev server (unplugin), so every\n * `dev:phone:cdp` restart produces a new quick-tunnel. The old relay goes\n * offline immediately, but the daemon's warm `ChiiCdpConnection` still lists\n * the last-seen target — its `lastSeenAt` freezes at the moment the old relay\n * died. A 5-minute threshold is large enough to be invisible in normal usage\n * (active CDP sessions see a message every few seconds) while being small\n * enough to catch a relay that went down before the daemon was re-entered.\n *\n * Injectable for tests via {@link AttachDeps.stalePageThresholdMs}.\n */\nexport const RELAY_SANDBOX_STALE_PAGE_MS = 5 * 60 * 1_000; // 5 minutes\n\n/**\n * Segment length (ms) of the `start_attach` wait loop (issue #626 — TOTP in-call\n * re-mint). The single-shot `wait_for_attach` of the old attach tool could\n * not re-mint a TOTP code mid-wait; `start_attach` decomposes the wait into\n * SEGMENT_MS slices so it can detect an aging code between slices and re-mint a\n * fresh one without the agent re-calling the tool. 30 s = one TOTP step.\n */\nexport const START_ATTACH_SEGMENT_MS = 30_000;\n\n/**\n * Elapsed-since-mint threshold (ms) at which `start_attach` re-mints a fresh\n * TOTP code during its wait loop (issue #626). The relay gate accepts a code for\n * `RELAY_VERIFY_SKEW_STEPS` (6) × 30 s = 180 s backwards from issuance; we re-mint\n * at 150 s to leave a 30 s margin so a phone scan never lands on an expired code.\n */\nexport const START_ATTACH_REMINT_THRESHOLD_MS = 150_000;\n\n/**\n * Predicate used by `start_attach`'s `wait_for_attach` loop to decide\n * whether the relay-sandbox connection has a genuinely fresh page attached.\n *\n * Stale-ghost gating (issue #610): when the dev server restarts with a new\n * quick-tunnel, the warm `ChiiCdpConnection` still lists the last-seen target\n * but its `lastSeenAt` is frozen. A page whose `lastSeenAt` exceeds\n * `stalePageThresholdMs` is a ghost from the dead relay — it must NOT\n * short-circuit `wait_for_attach`.\n *\n * Rules:\n * - `pages.length === 0` → false (nothing attached).\n * - Connection has no `getLastSeenAt` (test fakes, local-browser) → falls back\n * to `pages.length > 0` (regression-safe).\n * - `seenMs === null` → treat as fresh (no CDP message received yet, first\n * message pending — the connection is alive).\n * - Otherwise: at least one page must satisfy `nowMs - seenMs <=\n * stalePageThresholdMs`.\n *\n * Exported for unit testing.\n */\nexport function isSandboxPageFresh(\n pages: ReadonlyArray<{ id: string }>,\n getLastSeenAt: ((id: string) => number | null) | null,\n nowMs: number,\n stalePageThresholdMs: number,\n): boolean {\n if (pages.length === 0) return false;\n if (getLastSeenAt === null) return true;\n return pages.some((p) => {\n const seenMs = getLastSeenAt(p.id);\n // null = no CDP message yet (fresh attach, first message pending) → fresh.\n if (seenMs === null) return true;\n return nowMs - seenMs <= stalePageThresholdMs;\n });\n}\n\n/**\n * Parses `_deploymentId` from the query string of a scheme URL.\n *\n * Returns `null` when the param is absent or empty — callers treat that as\n * \"no deploymentId filter; match on presence only\" and fall back to the\n * original `attachedPages.length > 0` condition.\n *\n * SECRET-HANDLING: deploymentId is a public identifier and may appear in\n * debug output. Never confuse it with TOTP secrets or relay tunnel URLs.\n */\nexport function extractDeploymentId(schemeUrl: string): string | null {\n try {\n // scheme URLs like `intoss-private://host?_deploymentId=xxx` are not\n // parseable by `new URL()` in all environments, so we extract the query\n // string manually.\n const qIndex = schemeUrl.indexOf('?');\n if (qIndex === -1) return null;\n const params = new URLSearchParams(schemeUrl.slice(qIndex + 1));\n const id = params.get('_deploymentId');\n return id && id.length > 0 ? id : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Attach URL components — stored in the run functions instead of a finished\n * URL string so that `getDashboardState` can RE-MINT a fresh TOTP code on\n * every call (Defect 1: baked codes expire → relay 401 reason:'auth').\n *\n * `kind: 'launcher'` = env 2 (launcher PWA QR, `buildLauncherAttachUrl`).\n * `kind: 'scheme'` = env 3/4 (intoss-private deep-link, `buildDeepLinkAttachUrl`).\n *\n * SECRET-HANDLING: these components contain tunnel/scheme hosts. They are\n * NEVER logged. The TOTP code is minted fresh at call time via `mintAttachUrl`\n * and rides inside the assembled URL's `at=` param only.\n */\nexport type AttachUrlParts =\n | {\n kind: 'launcher';\n tunnelHttpUrl: string;\n wssUrl: string;\n appName?: string;\n selfdebug?: boolean;\n }\n | { kind: 'scheme'; schemeUrl: string; wssUrl: string };\n\n/** TOTP metadata surfaced in an attach tool result (code value never included). */\nexport interface AttachTotpMeta {\n enabled: true;\n ttlSeconds: number;\n expiresAt: string;\n}\n\n/** The tool-result shape returned by every CallTool handler branch. */\nexport type McpResult = {\n content: Array<\n { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }\n >;\n isError?: boolean;\n};\n\n/**\n * Output of the {@link prepareAttach} helper (issue #626) — the shared validation +\n * component bundle that the env-2 (relay-mobile) and env-3 (relay-dev) attach\n * paths both produce. On any validation failure the helper returns\n * `{ ok: false, error }` with a ready-to-return `McpResult`.\n */\nexport type PrepareAttachResult =\n | {\n ok: true;\n parts: AttachUrlParts;\n isMatchingPage: (pages: ReturnType<CdpConnection['listTargets']>) => boolean;\n buildTimeoutError: (\n baseText: string,\n timeoutSec: number,\n observed: ReturnType<CdpConnection['listTargets']>,\n ) => string;\n authorityWarning: string | undefined;\n totpMeta: AttachTotpMeta | undefined;\n }\n | { ok: false; error: McpResult };\n\n/**\n * Explicit dependencies for the attach orchestrator (issue #684 §2.2) — the six\n * closure variables `prepareAttach`/`renderAndMaybeWait` used to read off\n * `createDebugServer`, promoted to a passable object so the orchestration is\n * reusable outside the MCP handler.\n *\n * Production (`createDebugServer`) assembles this from its own closure\n * variables; the CLI (PR3) assembles it from a booted relay family.\n */\nexport interface AttachDeps {\n /** relay 터널 up/wssUrl. CLI는 bootRelayFamily().getTunnelStatus를 그대로 넘긴다. */\n getTunnelStatus(): TunnelStatus;\n /**\n * Late-bound TOTP secret accessor (issue #396) — read from env AT CALL TIME so\n * the project-local `.ait_relay` secret loaded by `switchMode` is visible.\n * SECRET-HANDLING: the returned value is used only for the `at=` code, never logged.\n */\n getTotpSecret(): string | undefined;\n /**\n * 로컬 127.0.0.1 QR 대시보드. 없으면 text QR fallback (headless/CLI-no-GUI).\n */\n qrHttpServer?: QrHttpServer;\n /**\n * attach URL 컴포넌트 확정 직후 콜백 (대시보드 SSE push). CLI는 미주입(no-op).\n * 완성된 URL이 아니라 컴포넌트를 전달하는 이유: `getDashboardState`가 매 호출 시\n * 최신 TOTP 코드를 freshly mint해 QR을 갱신하기 위함이다.\n */\n onAttachUrlBuilt?: (parts: AttachUrlParts) => void;\n /** ghost page 가드 임계 (#610). 기본 {@link RELAY_SANDBOX_STALE_PAGE_MS}. */\n stalePageThresholdMs?: number;\n /** 테스트 시계 주입. 기본 () => Date.now(). */\n nowMs?: () => number;\n /**\n * GUI 감지 override (테스트/headless/CLI `--headless`). 기본 `canOpenBrowser`.\n * `renderAndMaybeWait`이 브라우저 자동 열기 가능 여부 판정에 사용한다.\n */\n canOpenBrowser?: () => boolean;\n}\n\n/** Resolves `deps.stalePageThresholdMs` to its default. */\nfunction resolveStalePageThresholdMs(deps: AttachDeps): number {\n return deps.stalePageThresholdMs ?? RELAY_SANDBOX_STALE_PAGE_MS;\n}\n\n/** Resolves `deps.nowMs` to its default (`Date.now`). */\nfunction resolveNowMs(deps: AttachDeps): () => number {\n return deps.nowMs ?? (() => Date.now());\n}\n\n/** Resolves `deps.canOpenBrowser` to the module default. */\nfunction resolveCanOpenBrowser(deps: AttachDeps): () => boolean {\n return deps.canOpenBrowser ?? defaultCanOpenBrowser;\n}\n\n/**\n * Waits for the first target matching `filterFn` to attach, using the\n * event-driven `waitForFirstTarget()` when the connection supports it\n * (interface-optional member, present on `ChiiCdpConnection`), or falling\n * back to a polling loop for connections that don't implement it (test fakes,\n * `LocalCdpConnection`).\n *\n * This eliminates the polling-only race that previously caused `wait_for_attach`\n * to resolve before the relay had observed the first inbound CDP message from\n * the phone.\n *\n * Timeout note: callers (e.g. the `start_attach` path) always pass an\n * explicit `timeoutMs`, sourced from the factory's `waitForAttachTimeoutMs`\n * (default 60 000). That value is forwarded to `waitForFirstTarget`, so it\n * overrides that method's own 90 000 signature default — the effective\n * wait on the tool path is 60 s, not 90 s.\n *\n * @param connection - The CDP connection (production or fake).\n * @param filterFn - Resolves when this predicate is satisfied.\n * @param timeoutMs - Maximum wait time in ms.\n * @param pollIntervalMs - Fallback poll interval for connections without waitForFirstTarget.\n */\nexport function waitForAttachWithEvents(\n connection: CdpConnection,\n filterFn: (targets: ReturnType<CdpConnection['listTargets']>) => boolean,\n timeoutMs: number,\n pollIntervalMs = 1_000,\n): Promise<ReturnType<CdpConnection['listTargets']>> {\n // Use event-driven path when available (CdpConnection.waitForFirstTarget is\n // optional; ChiiCdpConnection implements it, LocalCdpConnection and test fakes do not).\n if (connection.waitForFirstTarget) {\n return connection.waitForFirstTarget(filterFn, timeoutMs, pollIntervalMs);\n }\n // Generic fallback for connections without waitForFirstTarget\n // (test fakes, LocalCdpConnection — they don't emit 'target:attached').\n return new Promise<ReturnType<CdpConnection['listTargets']>>((resolve, reject) => {\n const deadline = Date.now() + timeoutMs;\n let settled = false;\n const poll = setInterval(() => {\n const targets = connection.listTargets();\n if (filterFn(targets)) {\n settled = true;\n clearInterval(poll);\n resolve(targets);\n } else if (Date.now() >= deadline) {\n settled = true;\n clearInterval(poll);\n reject(new Error(`waitForAttachWithEvents: 타임아웃 (${timeoutMs}ms)`));\n }\n }, pollIntervalMs);\n // Also check immediately.\n const targets = connection.listTargets();\n if (!settled && filterFn(targets)) {\n settled = true;\n clearInterval(poll);\n resolve(targets);\n }\n });\n}\n\n/**\n * Synthesizes an attach URL from stored components with a FRESHLY-minted TOTP\n * code (issue #626 §3/§4 — the single mint point). Reads the late-bound secret\n * via `deps.getTotpSecret()` so the project-local `.ait_relay` secret loaded by\n * `switchMode` is visible. SECRET-HANDLING: the minted code rides inside the\n * URL's `at=` param only — never logged or returned separately.\n */\nexport function mintAttachUrl(deps: AttachDeps, parts: AttachUrlParts): string {\n const secret = deps.getTotpSecret();\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\n/** Builds the fresh TOTP metadata (expiresAt window) for a tool result. */\nexport function buildTotpMeta(deps: AttachDeps): AttachTotpMeta | undefined {\n const secret = deps.getTotpSecret();\n if (secret === undefined || secret === '') return undefined;\n const STEP_SECONDS = 30;\n const expiresAtMs = resolveNowMs(deps)() + RELAY_VERIFY_SKEW_STEPS * STEP_SECONDS * 1000;\n return {\n enabled: true,\n ttlSeconds: RELAY_VERIFY_SKEW_STEPS * STEP_SECONDS,\n expiresAt: new Date(expiresAtMs).toISOString(),\n };\n}\n\n/**\n * Env-specific validation + component bundle for `start_attach` (issue #626).\n * Branches on `env`: `relay-mobile` reads AIT_TUNNEL_BASE_URL + builds launcher\n * parts; `relay-dev` requires scheme_url + builds scheme parts. Returns\n * `{ ok: false, error }` with a ready McpResult on any failure.\n */\nexport async function prepareAttach(\n deps: AttachDeps,\n env: McpEnvironment,\n args: Record<string, unknown> | undefined,\n conn: CdpConnection,\n): Promise<PrepareAttachResult> {\n const { getTunnelStatus, getTotpSecret } = deps;\n const stalePageThresholdMs = resolveStalePageThresholdMs(deps);\n const nowMs = resolveNowMs(deps);\n const selfdebug = args?.selfdebug === true;\n\n // Guard: selfdebug is a launcher-only feature — reject early for env 3\n // so the caller gets a clear diagnostic instead of silently ignoring it.\n if (selfdebug && env !== 'relay-mobile') {\n return {\n ok: false,\n error: mcpError(\n 'start_attach: selfdebug=true는 env 2 / relay-sandbox 전용 기능입니다. ' +\n '현재 환경(env 3)에서는 launcher가 없어 self-target 모드를 지원하지 않습니다. ' +\n 'launcher self-target이 필요하다면 relay-sandbox 모드로 전환하세요.',\n ),\n };\n }\n\n // ── relay-mobile branch (env 2 — launcher PWA QR) ──────────────────────\n if (env === 'relay-mobile') {\n // SECRET-HANDLING: AIT_TUNNEL_BASE_URL carries the app tunnel host —\n // NEVER echo it in error messages or logs. (#424) env wins; .ait_urls\n // is the fallback when env is unset.\n const rawProjectRoot = args?.projectRoot;\n const buildProjectRoot = typeof rawProjectRoot === 'string' ? rawProjectRoot : undefined;\n const envTunnelUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? '';\n let tunnelHttpUrl = envTunnelUrl;\n if (tunnelHttpUrl === '' && buildProjectRoot !== undefined) {\n const { readRelayUrls } = await import('./relay-url-store.js');\n const stored = await readRelayUrls({ projectRoot: buildProjectRoot });\n tunnelHttpUrl = stored?.tunnelBaseUrl ?? '';\n }\n if (tunnelHttpUrl === '') {\n return {\n ok: false,\n error: mcpError(\n 'start_attach(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. ' +\n 'dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. ' +\n '자동 발견이 되지 않을 경우 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 직접 전달하세요.',\n ),\n };\n }\n const tunnelStatus = getTunnelStatus();\n if (!tunnelStatus.up || tunnelStatus.wssUrl === null) {\n return {\n ok: false,\n error: mcpError(\n 'start_attach(mobile): relay wssUrl이 아직 설정되지 않았습니다. ' +\n 'unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.',\n ),\n };\n }\n\n // Defense-in-depth (#452): relay mode requires TOTP auth — fail-closed if\n // the secret is missing rather than issuing an unauthenticated attach URL.\n // SECRET-HANDLING: error message names the requirement only.\n const secret = getTotpSecret();\n if (secret === undefined || secret === '') {\n return {\n ok: false,\n error: mcpError(\n 'start_attach(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. ' +\n 'relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.',\n ),\n };\n }\n\n // Read the app name from projectRoot/package.json for the launcher\n // partner bar (#498). Failure to read is silently ignored (fail-open).\n let launcherAppName: string | undefined;\n if (buildProjectRoot !== undefined) {\n try {\n const { readFileSync } = await import('node:fs');\n const pkgRaw = readFileSync(`${buildProjectRoot}/package.json`, 'utf8');\n const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;\n const rawName = typeof pkg.name === 'string' ? pkg.name : '';\n const stripped = rawName.includes('/') ? rawName.slice(rawName.indexOf('/') + 1) : rawName;\n launcherAppName = stripped.trim() || undefined;\n } catch {\n // Silently ignore — fail-open.\n }\n }\n\n const parts: AttachUrlParts = {\n kind: 'launcher',\n tunnelHttpUrl,\n wssUrl: tunnelStatus.wssUrl,\n appName: launcherAppName,\n ...(selfdebug ? { selfdebug: true } : {}),\n };\n\n // In mobile mode, deploymentId filtering is not applicable — match on\n // presence only, but with a stale-ghost guard (issue #610). The env-2\n // relay is owned by the dev server; a restart leaves a frozen lastSeenAt.\n const connAsAny = conn as unknown as {\n getTargetLastSeenAt?: (id: string) => number | null;\n };\n const getLastSeenAt =\n typeof connAsAny.getTargetLastSeenAt === 'function'\n ? (id: string) => (connAsAny.getTargetLastSeenAt as (id: string) => number | null)(id)\n : null;\n const callNow = nowMs();\n const isMatchingPage = (pages: ReturnType<CdpConnection['listTargets']>): boolean =>\n isSandboxPageFresh(pages, getLastSeenAt, callNow, stalePageThresholdMs);\n const buildTimeoutError = (\n baseText: string,\n timeoutSec: number,\n observed: ReturnType<CdpConnection['listTargets']>,\n ): string => {\n const observedUrls = observed\n .slice(0, 3)\n .map((p) => p.url.slice(0, 80))\n .join(', ');\n const observedNote =\n observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : '';\n return (\n `${baseText}\\n\\nNo page attached within ${timeoutSec}s${observedNote} — ` +\n 'launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.'\n );\n };\n\n return {\n ok: true,\n parts,\n isMatchingPage,\n buildTimeoutError,\n authorityWarning: undefined, // no scheme authority for launcher\n totpMeta: buildTotpMeta(deps),\n };\n }\n // ── end relay-mobile branch ────────────────────────────────────────────\n\n // ── relay-dev branch (env 3 — intoss-private QR) ───────────────────────\n const schemeUrl = args?.scheme_url;\n if (typeof schemeUrl !== 'string' || schemeUrl === '') {\n return {\n ok: false,\n error: mcpError(\n 'start_attach: scheme_url이 비어 있습니다. ' +\n '`ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요. ' +\n '환경 2(mobile)라면 scheme_url 대신 AIT_TUNNEL_BASE_URL을 설정하세요.',\n ),\n };\n }\n\n // Defense-in-depth (#452): relay-dev mode requires TOTP auth.\n // SECRET-HANDLING: error message names the requirement only.\n {\n const relaySecret = getTotpSecret();\n if (relaySecret === undefined || relaySecret === '') {\n return {\n ok: false,\n error: mcpError(\n 'start_attach(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. ' +\n 'relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.',\n ),\n };\n }\n }\n\n // Tunnel-down check (the old buildAttachUrl threw here; we fail-fast with a\n // structured error to keep prepareAttach side-effect-free).\n const tunnelForBuild = getTunnelStatus();\n if (!tunnelForBuild.up || tunnelForBuild.wssUrl === null) {\n return { ok: false, error: classifyToolError(new Error('tunnel-down:'), 'start_attach') };\n }\n const authorityWarning = validateSchemeAuthority(schemeUrl) ?? undefined;\n\n const parts: AttachUrlParts = {\n kind: 'scheme',\n schemeUrl,\n wssUrl: tunnelForBuild.wssUrl,\n };\n\n // Parse _deploymentId to filter stale attached pages (null → presence-only).\n const deploymentId = extractDeploymentId(schemeUrl);\n if (!deploymentId) {\n logInfo('tool.call', {\n tool: 'start_attach',\n msg: 'no _deploymentId in scheme_url; matching on presence only',\n });\n }\n const isMatchingPage = (pages: ReturnType<CdpConnection['listTargets']>): boolean => {\n if (pages.length === 0) return false;\n if (deploymentId === null) return true;\n return pages.some((p) => p.url.includes(deploymentId));\n };\n const buildTimeoutError = (\n baseText: string,\n timeoutSec: number,\n observed: ReturnType<CdpConnection['listTargets']>,\n ): string => {\n const observedUrls = observed\n .slice(0, 3)\n .map((p) => p.url.slice(0, 80))\n .join(', ');\n const observedNote =\n observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : '';\n const deploymentNote = deploymentId ? ` matching deploymentId=${deploymentId}` : '';\n return (\n `${baseText}\\n\\nNo page${deploymentNote} attached within ${timeoutSec}s${observedNote} — ` +\n 'call list_pages to retry.'\n );\n };\n\n return {\n ok: true,\n parts,\n isMatchingPage,\n buildTimeoutError,\n authorityWarning,\n totpMeta: buildTotpMeta(deps),\n };\n}\n\n/**\n * QR render + browser open + segmented attach wait with in-call TOTP re-mint\n * (issue #626 §3). Shared by env-2 and env-3 (4 render paths:\n * headless / browser-opened / browser-open-failed / no-http-server).\n *\n * The wait is decomposed into `START_ATTACH_SEGMENT_MS` slices. Between slices,\n * if the current TOTP code has aged past `START_ATTACH_REMINT_THRESHOLD_MS`,\n * a fresh URL is minted via `mintAttachUrl` and pushed to the dashboard via\n * `onAttachUrlBuilt` (SSE refresh — NO browser re-open). The `reminted` count\n * rides in the success/timeout result.\n *\n * SECRET-HANDLING: attachUrl encodes tunnel/scheme host + the TOTP `at=` code\n * in the QR payload only. The browser is opened on a 127.0.0.1 URL only. The\n * tool result carries `totp.expiresAt` + `reminted` count — never the code.\n */\nexport async function renderAndMaybeWait(\n deps: AttachDeps,\n prep: Extract<PrepareAttachResult, { ok: true }>,\n waitForAttach: boolean,\n callTimeoutMs: number,\n conn: CdpConnection,\n): Promise<McpResult> {\n const { getTunnelStatus, qrHttpServer, onAttachUrlBuilt } = deps;\n const nowMs = resolveNowMs(deps);\n const canOpenBrowserFn = resolveCanOpenBrowser(deps);\n const { parts, isMatchingPage, buildTimeoutError, authorityWarning, totpMeta } = prep;\n\n // Initial mint + dashboard notify (components, not a finished URL, so\n // getDashboardState re-mints on every SSE push — Defect 1).\n let attachUrl = mintAttachUrl(deps, parts);\n onAttachUrlBuilt?.(parts);\n let totpIssuedAt = nowMs();\n let reminted = 0;\n const relayUrl = parts.wssUrl;\n\n const header =\n 'This tool result is shown to the user directly — do NOT re-print the QR below in your reply (it wastes output tokens). Just tell the user to scan the QR in this output (Ctrl+O to expand if collapsed).';\n const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\\n\\n` : '';\n const guiAvailable = canOpenBrowserFn();\n\n /** Builds the totp object surfaced in results (fresh expiresAt + reminted). */\n const totpResult = (): Record<string, unknown> | undefined => {\n if (!totpMeta) return undefined;\n const STEP_SECONDS = 30;\n const expiresAtMs = totpIssuedAt + RELAY_VERIFY_SKEW_STEPS * STEP_SECONDS * 1000;\n return {\n enabled: true,\n ttlSeconds: totpMeta.ttlSeconds,\n expiresAt: new Date(expiresAtMs).toISOString(),\n ...(reminted > 0 ? { reminted } : {}),\n };\n };\n\n /**\n * Segmented wait with TOTP re-mint (issue #626 §3). Resolves with the\n * attached page list, or rejects on timeout. Between SEGMENT_MS slices it\n * re-mints when the code has aged past the threshold (max ~4 re-mints over\n * 600 s). Returns immediately once a matching page attaches (no re-mint).\n */\n async function waitWithRemint(): Promise<ReturnType<CdpConnection['listTargets']>> {\n const deadline = nowMs() + callTimeoutMs;\n // Immediate check — already attached resolves without any wait/re-mint.\n if (isMatchingPage(conn.listTargets())) return conn.listTargets();\n for (;;) {\n const remaining = deadline - nowMs();\n if (remaining <= 0) {\n throw new Error(`start_attach: 타임아웃 (${callTimeoutMs}ms)`);\n }\n const segmentMs = Math.min(START_ATTACH_SEGMENT_MS, remaining);\n try {\n return await waitForAttachWithEvents(conn, isMatchingPage, segmentMs);\n } catch {\n // Segment elapsed without attach — re-mint if the code is aging, then\n // loop into the next segment. SECRET-HANDLING: code never logged.\n if (totpMeta && nowMs() - totpIssuedAt >= START_ATTACH_REMINT_THRESHOLD_MS) {\n attachUrl = mintAttachUrl(deps, parts);\n onAttachUrlBuilt?.(parts);\n totpIssuedAt = nowMs();\n reminted += 1;\n }\n }\n }\n }\n\n /**\n * Assembles the success result after a page attaches. `baseText` carries the\n * QR + pre-wait JSON block (the QR the user already scanned). The attach\n * itself ends the wait, so the QR is moot — what matters now is the final\n * TOTP state. If the segmented wait re-minted (issue #626 §3), surface the\n * post-wait `totp` block (fresh `expiresAt` + `reminted` count) so the result\n * reflects how many times the code rotated during the wait. SECRET-HANDLING:\n * the totp block carries expiresAt + reminted only — never the code value.\n */\n const successResult = (baseText: string): McpResult => {\n const pagesResult = listPages(conn, getTunnelStatus());\n const finalTotp = totpResult();\n const remintNote =\n finalTotp && reminted > 0 ? `\\n\\n${JSON.stringify({ totp: finalTotp }, null, 2)}` : '';\n return {\n content: [\n {\n type: 'text',\n text: `${baseText}\\n\\n${JSON.stringify(pagesResult, null, 2)}${remintNote}`,\n },\n ],\n };\n };\n\n /** Runs the wait (when requested) and returns success/timeout result. */\n const runWait = async (baseText: string): Promise<McpResult> => {\n if (!waitForAttach) {\n return { content: [{ type: 'text', text: baseText }] };\n }\n try {\n await waitWithRemint();\n } catch {\n const observed = conn.listTargets();\n return {\n content: [\n { type: 'text', text: buildTimeoutError(baseText, callTimeoutMs / 1000, observed) },\n ],\n isError: true,\n };\n }\n return successResult(baseText);\n };\n\n // Path 1: headless — no GUI, text QR only.\n if (!guiAvailable) {\n const headlessNote =\n 'GUI 환경이 감지되지 않았습니다 (headless/remote 환경). ' +\n '텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\\n\\n';\n const qr = await renderQr(attachUrl);\n const baseText = `${warningPrefix}${headlessNote}${header}\\n${JSON.stringify({ attachUrl, relayUrl, ...(totpResult() ? { totp: totpResult() } : {}) }, null, 2)}\\n\\n${qr}`;\n return runWait(baseText);\n }\n\n // Path 2 / 3: GUI + HTTP server — open the dashboard in the browser.\n if (guiAvailable && qrHttpServer) {\n const httpUrl = qrHttpServer.buildAttachPageUrl(attachUrl);\n const pngUrl = `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`;\n const browserResult = await openQrInBrowser(httpUrl, pngUrl);\n\n if (browserResult.opened) {\n const retriedNote = browserResult.retried ? ' (1회 retry 후 성공)' : '';\n const openResult = {\n attempted: true,\n succeeded: true,\n ...(browserResult.retried ? { retried: true } : {}),\n };\n const shortText =\n `${warningPrefix}${header}\\n` +\n `${JSON.stringify({ relayUrl, openResult, ...(totpResult() ? { totp: totpResult() } : {}) }, null, 2)}\\n\\n` +\n `브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\\n` +\n `URL: ${browserResult.httpUrl}`;\n return runWait(shortText);\n }\n\n // Browser open failed — structured error + URL hint + text QR fallback.\n const openResult = {\n attempted: true,\n succeeded: false,\n failureReason: browserResult.error ?? '브라우저 실행 후보 모두 실패',\n pngUrl: browserResult.pngUrl,\n ...(browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}),\n };\n const stderrNote = browserResult.stderrSummary\n ? `\\nstderr: ${browserResult.stderrSummary}`\n : '';\n const fallbackNote =\n `브라우저 자동 열기에 실패했습니다. ` +\n `다음 URL을 직접 브라우저에서 여세요:\\n${browserResult.httpUrl}\\n` +\n `또는 PNG로 받기: ${browserResult.pngUrl}` +\n stderrNote +\n '\\n\\n';\n const qr = await renderQr(attachUrl);\n const baseText = `${warningPrefix}${fallbackNote}${header}\\n${JSON.stringify({ attachUrl, relayUrl, openResult, ...(totpResult() ? { totp: totpResult() } : {}) }, null, 2)}\\n\\n${qr}`;\n return runWait(baseText);\n }\n\n // Path 4: GUI but no HTTP server — text QR fallback.\n const qr = await renderQr(attachUrl);\n const baseText = `${warningPrefix}${header}\\n${JSON.stringify({ attachUrl, relayUrl, ...(totpResult() ? { totp: totpResult() } : {}) }, null, 2)}\\n\\n${qr}`;\n return runWait(baseText);\n}\n\n/**\n * Builds a self-contained IIFE DOM expression that renders a dismissible\n * \"Debugger Connected\" badge on the bottom-left of the phone screen.\n *\n * **Pure function** — returns a JS expression string; does NOT inject it.\n * Injection is performed by {@link injectDebugIndicator} in `cell.ts`.\n *\n * The expression, when evaluated on the page, does the following:\n * 1. Early-returns if `#__ait_debug_indicator` already exists (idempotent —\n * prevents duplicate badges on re-injection after page reload).\n * 2. Appends a fixed-position `<div id=\"__ait_debug_indicator\">` at the\n * bottom-left, accounting for safe-area insets.\n * 3. Attaches a `{ once: true }` `pointerdown` listener that removes the\n * badge on first touch — one-tap dismiss.\n *\n * The expression intentionally contains NO relay URLs, wss addresses, TOTP\n * codes, or any other secrets. It is pure DOM UI text only.\n *\n * SECRET-HANDLING: this expression contains no secrets, relay URLs, wss\n * addresses, or TOTP codes whatsoever — DOM label text only.\n *\n * @param opts.label - Badge text (default: `'Debugger Connected'`).\n * @returns A JS expression string suitable for `Runtime.evaluate`.\n */\nexport function buildIndicatorExpression(opts?: { label?: string }): string {\n const label = opts?.label ?? 'Debugger Connected';\n // JSON.stringify ensures the label is safely embedded even if it contains\n // quotes or backslashes.\n const safeLabel = JSON.stringify(label);\n return (\n `(() => {` +\n // Idempotent guard — prevents duplicates on re-injection.\n `if (document.getElementById('__ait_debug_indicator')) return;` +\n `const el = document.createElement('div');` +\n `el.id = '__ait_debug_indicator';` +\n // Position: fixed, bottom-left with safe-area inset support.\n `el.style.cssText = [` +\n `'position:fixed',` +\n `'left:max(12px,calc(env(safe-area-inset-left,0px) + 8px))',` +\n `'bottom:max(12px,calc(env(safe-area-inset-bottom,0px) + 8px))',` +\n `'z-index:2147483647',` +\n `'background:#e5484d',` +\n `'color:#fff',` +\n `'font:bold 11px/1 system-ui,sans-serif',` +\n `'padding:5px 9px',` +\n `'border-radius:6px',` +\n `'pointer-events:auto',` +\n `'user-select:none',` +\n `].join(';');` +\n `el.textContent = ${safeLabel};` +\n // One-tap dismiss — removes itself on the first touch/click.\n `el.addEventListener('pointerdown', () => el.remove(), { once: true });` +\n `document.body.appendChild(el);` +\n `})()`\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"attach-orchestrator-BJr41GN8.js","names":["isPidAlive","_isPidAlive","defaultCanOpenBrowser"],"sources":["../src/shared/parent-watcher.ts","../src/mcp/deeplink.ts","../src/mcp/errors.ts","../src/mcp/log.ts","../src/mcp/environment.ts","../src/mcp/sdk-signatures.ts","../src/mcp/server-lock.ts","../src/mcp/tools.ts","../src/mcp/tunnel.ts","../src/mcp/attach-orchestrator.ts"],"sourcesContent":["/**\n * Shared parent-PID watcher — used by both the MCP debug daemon and the\n * unplugin tunnel path to self-terminate when the parent process (e.g. Claude\n * Code, vite) has died or been reparented without sending SIGTERM/SIGHUP.\n *\n * Intentionally react-free and Node-stdlib-only so this module is safe to\n * import from the MCP daemon bundle (`dist/mcp/cli.js`) without violating the\n * install-graph invariant.\n */\n\n// ---------------------------------------------------------------------------\n// isPidAlive — extracted from src/mcp/server-lock.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when the given PID refers to a running process.\n *\n * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process\n * exists and we have permission to signal it; throws ESRCH when it doesn't exist.\n */\nexport function isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (err: unknown) {\n // ESRCH = no such process → stale lock.\n // EPERM = process exists but we can't signal it (still alive).\n if ((err as NodeJS.ErrnoException).code === 'EPERM') return true;\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// startParentWatcher — extracted from src/mcp/debug-server.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Starts a periodic watcher that detects when the parent process (e.g. Claude\n * Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the\n * daemon can self-terminate rather than running as a zombie.\n *\n * Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns\n * `{ stop(): void }`, injectable deps for testability.\n *\n * @param onOrphaned - Called once when the parent is gone.\n * @param opts.intervalMs - Poll interval in milliseconds (default 5 000).\n * @param opts.initialPpid - Parent PID to watch (default `process.ppid`).\n * @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).\n * @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).\n * Detects ppid changes as well as death.\n * @param opts.log - Logger (default `process.stderr.write`).\n *\n * @returns `stop` — call during shutdown to clear the interval.\n */\nexport function startParentWatcher(\n onOrphaned: () => void,\n opts?: {\n intervalMs?: number;\n initialPpid?: number;\n isAlive?: (pid: number) => boolean;\n getPpid?: () => number;\n log?: (msg: string) => void;\n },\n): { stop(): void } {\n const {\n intervalMs = 5_000,\n initialPpid = process.ppid,\n isAlive = isPidAlive,\n getPpid = () => process.ppid,\n log = (msg: string) => process.stderr.write(msg),\n } = opts ?? {};\n\n // PID 1 is init/launchd — running under a process manager or as a detached\n // daemon. There is no meaningful parent to watch; skip the watcher entirely.\n if (initialPpid <= 1) {\n log('[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\\n');\n return { stop() {} };\n }\n\n let fired = false;\n\n const handle = setInterval(() => {\n if (fired) return;\n\n const currentPpid = getPpid();\n const orphaned = currentPpid !== initialPpid || !isAlive(initialPpid);\n\n if (orphaned) {\n fired = true;\n clearInterval(handle);\n log(\n `[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\\n`,\n );\n onOrphaned();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// startMaxAgeWatchdog — FIX 4: daemon lifetime cap\n// ---------------------------------------------------------------------------\n\n/**\n * Starts a periodic watchdog that calls `onExpired` once after `maxAgeMs`\n * milliseconds have elapsed since the watchdog was created.\n *\n * Motivation (issue #571): cloudflared quick-tunnel lifetimes are finite (a\n * few hours). A daemon that has been running for days will have outlived its\n * tunnel regardless of whether the tunnel process exited cleanly. This watchdog\n * caps the daemon's maximum age and forces a fresh start so the tunnel is\n * replaced before it silently expires.\n *\n * @param onExpired - Called once when the maximum age is reached. The caller\n * should call `shutdown()` then `process.exit(0)`.\n * @param opts.maxAgeMs - Maximum daemon lifetime in ms. Default 6 h.\n * @param opts.intervalMs - Check interval in ms. Default 60 000 (1 min).\n * @param opts.now - Time source (injectable for tests). Default `Date.now`.\n *\n * @returns `stop` — call during shutdown to clear the interval.\n */\nexport function startMaxAgeWatchdog(\n onExpired: () => void,\n opts: {\n maxAgeMs?: number;\n intervalMs?: number;\n now?: () => number;\n } = {},\n): { stop(): void } {\n const {\n maxAgeMs = 6 * 60 * 60 * 1_000, // 6 hours\n intervalMs = 60_000,\n now = () => Date.now(),\n } = opts;\n\n const startedAt = now();\n let fired = false;\n\n const handle = setInterval(() => {\n if (fired) return;\n if (now() - startedAt >= maxAgeMs) {\n fired = true;\n clearInterval(handle);\n onExpired();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n","/**\n * URL of the AITC Sandbox launcher PWA.\n *\n * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the\n * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy\n * for the same reason — keep the two in sync when the URL changes.\n */\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Optional metadata that enriches the launcher deep-link (#498).\n *\n * These fields are added as query params so the launcher PWA can display\n * a recognizable identity (name, icon) without the user having to configure\n * anything extra.\n */\nexport interface LauncherAttachUrlOpts {\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * Absolute `https://` icon URL for the partner nav bar icon slot (`icon=`\n * param). Non-https or falsy values are not added.\n */\n icon?: string;\n /**\n * When `true`, adds `selfdebug=1` to the launcher URL so the launcher PWA\n * registers its own document as a CDP target (issue #531/#543).\n *\n * **Single-attach model**: attaching the launcher self-target causes any\n * currently-attached mini-app target to be evicted. This is intentional —\n * `selfdebug` is a \"launcher diagnostics mode\" for inspecting the launcher's\n * own DOM/console/safe-area, not simultaneous dual-attach.\n *\n * When `false` or omitted (default), the param is not added and the output\n * is byte-identical to the previous behaviour.\n */\n selfdebug?: boolean;\n}\n\n/**\n * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).\n *\n * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport\n * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the\n * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js\n * is injected. `&at=<totpCode>` is added only when a code is provided (same\n * conditional as {@link buildDeepLinkAttachUrl}).\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the\n * launcher partner bar shows the app name instead of the generic default (#498).\n * When `opts.icon` is an absolute https:// URL, it is added as `&icon=` so the\n * launcher can render an icon next to the title (#498).\n *\n * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL\n * via raw string manipulation), this function uses WHATWG `encodeURIComponent`\n * because the target is a standard `https:` URL.\n *\n * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param\n * only — never logged or returned separately. Callers must NOT log the result\n * of this function to stdout/stderr.\n *\n * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL\n * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.\n * @param wssUrl - The `wss://` relay URL the framed page will attach to.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is appended as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Omit when TOTP is disabled.\n * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`\n * (#498, #543).\n * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>\n * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.\n */\nexport function buildLauncherAttachUrl(\n tunnelUrl: string,\n wssUrl: string,\n totpCode?: string,\n opts?: LauncherAttachUrlOpts,\n): string {\n let url =\n `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}` +\n `&debug=1&relay=${encodeURIComponent(wssUrl)}`;\n if (totpCode !== undefined && totpCode !== '') {\n url += `&at=${encodeURIComponent(totpCode)}`;\n }\n // App identity hints (#498): add non-blank name and valid https icon.\n if (opts?.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts?.icon !== undefined) {\n let iconParsed: URL;\n try {\n iconParsed = new URL(opts.icon);\n } catch {\n iconParsed = null as unknown as URL;\n }\n if (iconParsed?.protocol === 'https:') {\n url += `&icon=${encodeURIComponent(opts.icon)}`;\n }\n }\n // Self-debug opt-in (#543): add selfdebug=1 only when explicitly requested.\n // Without this flag the output is byte-identical to the previous behaviour.\n if (opts?.selfdebug === true) {\n url += '&selfdebug=1';\n }\n return url;\n}\n\n/**\n * Build a self-attaching dog-food deep-link.\n *\n * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`\n * URL that opens a dog-food bundle on a phone. The in-app debug gate\n * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries\n * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus\n * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result\n * as a QR code and scanning it with the phone camera opens the mini-app and\n * attaches it to the live Chii relay. QR is the single entry path — it needs\n * no USB cable, platform CLI, or driver, and works the same on iOS/Android.\n *\n * The Toss app propagates extra query params from the entry deep link into the\n * mini-app WebView's `location.search` (confirmed behavior), so the gate reads\n * them at attach time.\n *\n * TOTP `at=` param:\n * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional\n * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.\n * The code must be computed by the caller at call time — do NOT pre-compute\n * and cache it, because the 30-second window expires quickly. The in-app gate\n * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.\n *\n * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.\n * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query\n * decomposition you can rely on across runtimes), so query manipulation via\n * `url.searchParams` is not portable here. We splice the query string directly\n * on the raw string instead, which keeps the scheme, authority, path, and any\n * pre-existing params (notably `_deploymentId`) byte-for-byte intact.\n */\n\n/**\n * Suspicious/generic authority values that indicate a malformed or placeholder\n * scheme URL. These are host strings that will almost certainly cause the Toss\n * app to fail with \"bundle not found\" silently.\n *\n * The expected form from `ait deploy --scheme-only` is:\n * intoss-private://<appName>?_deploymentId=<uuid>\n * where `<appName>` is a non-generic string like `aitc-sdk-example`.\n */\nconst SUSPICIOUS_AUTHORITIES = new Set<string>(['', 'web', 'localhost', '127.0.0.1', 'app']);\n\n/**\n * Validates the authority (host) portion of a scheme URL.\n *\n * Returns a warning message if the authority is missing or looks like a\n * placeholder, or `null` if the authority looks valid.\n *\n * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`\n * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).\n */\nexport function validateSchemeAuthority(schemeUrl: string): string | null {\n // Extract authority from `scheme://authority[/path][?query][#hash]`.\n // We cannot use the WHATWG URL parser for non-special schemes reliably\n // (see the deeplink.ts module comment), so we parse the raw string.\n const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\\-.]*:\\/\\//, '');\n if (afterScheme === schemeUrl) {\n // No `://` found — not a scheme URL at all.\n return (\n 'scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). ' +\n 'Use the URL printed by `ait deploy --scheme-only`.'\n );\n }\n\n // authority ends at the first `/`, `?`, `#`, or end of string.\n const authorityEnd = afterScheme.search(/[/?#]/);\n const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);\n\n if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) {\n const displayAuthority = authority === '' ? '(empty)' : `\"${authority}\"`;\n return (\n `scheme_url authority ${displayAuthority} looks like a placeholder. ` +\n 'Expected an app name like `intoss-private://aitc-sdk-example?_deploymentId=<uuid>`. ' +\n 'Use the URL printed by `ait deploy --scheme-only` — it includes the correct app name as the host.'\n );\n }\n\n return null;\n}\n\n/** A param the helper appends. Existing occurrences are replaced, not duplicated. */\ntype AppendParam = readonly [key: string, value: string];\n\nfunction stripExisting(query: string, key: string): string {\n if (query === '') return '';\n return query\n .split('&')\n .filter((pair) => pair !== '' && pair.split('=')[0] !== key)\n .join('&');\n}\n\n/**\n * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a\n * scheme URL's query string, preserving everything else (scheme, authority,\n * path, hash, and the existing `_deploymentId` param). If any of the spliced\n * params is already present it is replaced so the helper is idempotent.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed\n * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B\n * of the gate); this helper does not invent one.\n * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the\n * running debug MCP server's quick tunnel.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Pass `undefined` or omit when TOTP is disabled.\n * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`\n * appended.\n * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so\n * producing such a link would be a silent dead end).\n */\nexport function buildDeepLinkAttachUrl(\n schemeUrl: string,\n wssUrl: string,\n totpCode?: string,\n): string {\n let relay: URL;\n try {\n relay = new URL(wssUrl);\n } catch {\n throw new Error(`relay URL is not a valid URL: ${wssUrl}`);\n }\n if (relay.protocol !== 'wss:') {\n throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);\n }\n\n const hashIndex = schemeUrl.indexOf('#');\n const hash = hashIndex === -1 ? '' : schemeUrl.slice(hashIndex);\n const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);\n\n const queryIndex = beforeHash.indexOf('?');\n const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);\n let query = queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1);\n\n const appended: AppendParam[] = [\n ['debug', '1'],\n ['relay', wssUrl],\n ];\n // Only splice `at=` when a code is provided (TOTP enabled). Omitting it when\n // TOTP is disabled preserves backward compatibility with gate deployments\n // that do not yet evaluate the `at` param.\n if (totpCode !== undefined && totpCode !== '') {\n appended.push(['at', totpCode]);\n }\n\n // Always strip the `at` key from the existing query so a stale code from a\n // previous run is removed even when the caller does not provide a fresh code.\n query = stripExisting(query, 'at');\n\n for (const [key] of appended) {\n query = stripExisting(query, key);\n }\n for (const [key, value] of appended) {\n const pair = `${key}=${encodeURIComponent(value)}`;\n query = query === '' ? pair : `${query}&${pair}`;\n }\n\n return `${base}?${query}${hash}`;\n}\n","/**\n * MCP tool 거부/에러 응답 메시지 헬퍼 — 4상태 차별화 + Tier 거부 통일.\n *\n * 모든 tool 거부/에러 응답을 \"원인 + 다음 행동\" 한국어 한 줄 포맷으로 일원화한다.\n * debug-server.ts · tools.ts의 거부 응답 호출부가 이 헬퍼를 통해 생성된다.\n *\n * 4가지 상태 (진단 메시지 차별화):\n * - tunnel-down : cloudflared 터널 미가동 — 서버 재시작 필요\n * - page-missing : 페이지가 attach 안 됨 — start_attach → QR 스캔\n * - page-crash : 페이지 crash 감지 — 앱 재실행 후 재attach\n * - sdk-absent : window.__sdkCall 미주입 — dog-food 채널로 재배포\n *\n * `liveGuardError` (relay-live env 4 guard)는 #665에서 제거됨.\n * relay-live / LIVE guard 전체가 positive-allowlist kill-switch로 대체됐다.\n */\n\n/** MCP tool-result 에러 응답 형식. */\nexport interface McpErrorResult {\n content: Array<{ type: 'text'; text: string }>;\n isError: true;\n}\n\n/**\n * 한국어 한 줄 \"원인 + 다음 행동\" 포맷으로 에러 결과를 빌드한다.\n *\n * @param message - 사용자에게 보여줄 에러 본문 (원인 + 다음 행동 포함).\n */\nexport function mcpError(message: string): McpErrorResult {\n return {\n content: [{ type: 'text', text: message }],\n isError: true,\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Tier 거부 메시지 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Tier A/B 환경 불일치 거부 메시지.\n *\n * @param toolName - 거부된 tool 이름.\n * @param requiredEnv - 해당 tool이 요구하는 환경 ('mock' | 'relay').\n * @param currentEnv - 현재 세션 환경.\n * @param reason - 환경이 결정된 근거를 나타내는 파생 문자열\n * (예: `derived:kind=relay,liveIntent=true`).\n */\nexport function tierRejectionError(\n toolName: string,\n requiredEnv: string,\n currentEnv: string,\n reason: string,\n): McpErrorResult {\n const envLabel = requiredEnv === 'relay' ? 'relay (실기기 연결)' : 'mock (로컬 브라우저)';\n const currentLabel = currentEnv === 'relay' ? 'relay' : 'mock';\n const hint =\n requiredEnv === 'relay'\n ? 'relay로 전환하려면 MCP_ENV=relay 설정 후 서버를 재시작하고 start_attach → QR 스캔으로 실기기를 attach하세요.'\n : 'mock으로 전환하려면 MCP_ENV=mock 설정 후 서버를 재시작하세요.';\n const text =\n `${toolName}은 ${envLabel} 환경에서만 사용할 수 있습니다. ` +\n `현재 환경: ${currentLabel} (${reason}). ${hint}`;\n // 하위 호환 — 기존 테스트가 기대하는 영문 패턴도 유지\n const compat = `tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`;\n return mcpError(`${text}\\n\\n${compat}`);\n}\n\n/* -------------------------------------------------------------------------- */\n/* 4상태 차별화 메시지 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * 상태 1: tunnel 미가동 — cloudflared 터널이 아직 뜨지 않았다.\n *\n * `start_attach` 호출 시 tunnel.up === false 인 경우.\n */\nexport function tunnelDownError(): McpErrorResult {\n return mcpError(\n 'cloudflared 터널이 안 떠 있습니다. ' +\n 'MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.',\n );\n}\n\n/**\n * 상태 2: page 미attach — 터널은 살아 있으나 아직 페이지가 연결되지 않았다.\n *\n * enableDomains()가 \"No mini-app page attached\" 에러를 던질 때.\n */\nexport function pageMissingError(toolName?: string): McpErrorResult {\n const prefix = toolName ? `${toolName}: ` : '';\n return mcpError(\n `${prefix}페이지가 attach 안 됨. ` +\n 'dog-food 번들 배포 후 start_attach를 호출해 QR 생성 + attach까지 진행하세요: ' +\n '`ait deploy --scheme-only` → `start_attach(scheme_url)` → QR 스캔.',\n );\n}\n\n/**\n * 상태 3: page crash — 연결됐던 페이지가 crash/destroy됐다.\n *\n * chii-connection 이 'replaced-by-new-attach' / 'targetCrashed' / 'targetDestroyed' 를\n * 던질 때 이 메시지를 사용한다.\n */\nexport function pageCrashError(toolName?: string): McpErrorResult {\n const prefix = toolName ? `${toolName}: ` : '';\n return mcpError(\n `${prefix}페이지가 crash됐습니다. ` +\n '토스 앱을 재실행한 뒤 start_attach → QR 스캔으로 재attach하세요.',\n );\n}\n\n/**\n * 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다.\n *\n * call_sdk 호출 시 브리지가 없을 때. 같은 \"브리지 부재\"라도 다음 행동은\n * connection 종류에 따라 정반대다 (issue #360):\n * - relay(`--target` 없는 intoss / env-2): dog-food 빌드가 아니다 → dog-food\n * 채널로 재배포 후 QR 재스캔.\n * - local(`--target=local`, env 1 로컬 브라우저): 재배포가 아니라 dev 서버를\n * `pnpm dev`로 띄웠는지 + unplugin alias가 `@apps-in-toss/web-framework`를\n * devtools mock으로 resolve하는지 확인. dev 빌드면 `import.meta.env.DEV`\n * 경로로 `window.__sdkCall`이 자동 설치된다.\n *\n * `isLocal`이 생략되면 relay 안내(이전 동작)를 유지한다.\n */\nexport function sdkAbsentError(toolName?: string, isLocal = false): McpErrorResult {\n const prefix = toolName ? `${toolName}: ` : '';\n if (isLocal) {\n return mcpError(\n `${prefix}window.__sdkCall이 주입되지 않았습니다 (로컬 dev 브리지 부재). ` +\n 'sdk-example을 `pnpm dev`로 띄웠는지, 그리고 unplugin alias가 ' +\n '`@apps-in-toss/web-framework`를 devtools mock으로 resolve하는지 확인하세요. ' +\n 'dev 빌드(`import.meta.env.DEV`)면 `window.__sdkCall`이 자동 설치됩니다.',\n );\n }\n return mcpError(\n `${prefix}window.__sdkCall이 주입되지 않았습니다 (dog-food 빌드가 아닙니다). ` +\n 'dog-food 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: ' +\n '`ait build && aitcc app deploy`.',\n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* relay 연결 끊김 메시지 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * relay WebSocket 연결이 끊겼을 때 — 크래시가 아닌 네트워크/프로세스 종료.\n */\nexport function relayDisconnectError(toolName?: string): McpErrorResult {\n const prefix = toolName ? `${toolName}: ` : '';\n return mcpError(\n `${prefix}relay 연결이 끊겼습니다. ` +\n 'list_pages로 상태를 확인하고, 필요하면 앱을 재실행 후 재attach하세요.',\n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* 일반 tool 에러 메시지 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * CDP/AIT 명령 중 발생한 예외를 4상태로 분류해 적절한 에러 결과를 반환한다.\n *\n * - SDK 부재 패턴 (`window.__sdkCall is not available`) → sdkAbsentError\n * - crash 패턴 (`replaced-by-new-attach`, `targetCrashed`, `targetDestroyed`) → pageCrashError\n * - 연결 끊김 패턴 (`relay에 연결되어 있지 않습니다`, `relay WebSocket`) → relayDisconnectError\n * - 그 외 (일반 에러) → 원본 메시지를 포함한 mcpError\n */\nexport function classifyToolError(err: unknown, toolName: string, isLocal = false): McpErrorResult {\n const message = err instanceof Error ? err.message : String(err);\n\n // 상태 1: tunnel 미가동 (buildAttachUrl이 던지는 패턴)\n if (message.startsWith('tunnel-down:') || message.includes('터널이 안 떠 있습니다')) {\n return tunnelDownError();\n }\n\n // 상태 4: SDK 부재. page-side probe가 던지는 메시지는 relay 가정으로 쓰여\n // 있으나, 안내는 connection 종류로 재구성한다 (issue #360) — local 세션이면\n // dog-food 재배포가 아니라 dev 서버/unplugin alias 확인이 맞다.\n if (\n message.startsWith('sdk-absent:') ||\n message.includes('__sdkCall이 주입되지 않았습니다') ||\n message.includes('window.__sdkCall is not available') ||\n (message.includes('__sdkCall') && message.includes('not available'))\n ) {\n return sdkAbsentError(toolName, isLocal);\n }\n\n // 상태 3: page crash / target destroyed / replaced-by-new-attach\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 연결 끊김 (단순 disconnect — crash 아님)\n if (message.includes('relay에 연결되어 있지 않습니다') || message.includes('relay WebSocket')) {\n return relayDisconnectError(toolName);\n }\n\n // 그 외: 원본 메시지를 포함하되 list_pages 다음 행동 안내 추가\n return mcpError(\n `${toolName} 실패: ${message}\\nlist_pages로 미니앱이 relay에 attach됐는지 확인하세요.`,\n );\n}\n","/**\n * Structured JSON-line server logger + allowlist-based secret redact.\n *\n * Every log line emitted by the debug-mode MCP server is a single JSON object:\n * { \"ts\": \"<ISO-8601>\", \"level\": \"info\"|\"warn\"|\"error\", \"event\": \"<category>\", ...fields }\n *\n * Allowlist approach — only the keys in ALLOWED_KEYS pass through to the output\n * object unchanged. Any value that matches a known-secret pattern is replaced\n * with \"***\" regardless of key name. This provides two complementary layers:\n * 1. Key allowlist — unknown keys (e.g. a future field accidentally containing\n * a credential) are dropped entirely.\n * 2. Value redact — pattern matching catches secrets that slip through under\n * an allowed key name (e.g. a message string that includes a TOTP code).\n *\n * SECRET-HANDLING (MUST NOT appear in stdout/stderr/logs):\n * - TOTP 6-digit codes (pattern: standalone 6-digit run)\n * - AITCC_API_KEY values (pattern: \"aitcc_\" or \"AITCC_\" prefix — Deploy Key format)\n * - cookie header values (pattern: \"cookie:\" header content)\n * - relay WSS URLs (contain the relay host which is semi-sensitive)\n * - \"at=<TOTP>\" query params\n *\n * Canonical event categories:\n * server.start — MCP server started (relay port, TOTP enabled, etc.)\n * tunnel.up — cloudflared tunnel assigned a public URL\n * tunnel.down — tunnel error / shutdown\n * page.attached — first CDP target appeared (deploymentId, env)\n * page.detached — target evicted / session replaced\n * page.crashed — target crash detected\n * tool.call — MCP tool invocation (tool name only — no args/results)\n * tool.error — MCP tool error (tool name + safe error category)\n */\n\n/** Structured log levels. */\nexport type LogLevel = 'info' | 'warn' | 'error';\n\n/** Every valid event category. */\nexport type LogEvent =\n | 'server.start'\n | 'tunnel.up'\n | 'tunnel.down'\n | 'page.attached'\n | 'page.detached'\n | 'page.crashed'\n | 'tool.call'\n | 'tool.error'\n // run_tests progress (#646) — operator-visible in the daemon log, never in\n // the agent response. Carries only counts (secret-free, redact-safe numbers).\n | 'run_tests.start'\n | 'run_tests.done';\n\n/**\n * Allowed field keys that may pass through to a log line.\n * Unknown keys are dropped. Values are still redact-scanned.\n */\nconst ALLOWED_KEYS = new Set([\n 'ts',\n 'level',\n 'event',\n 'msg',\n 'port',\n 'totpEnabled',\n 'env',\n 'tool',\n 'deploymentId',\n 'errorKind',\n 'reason',\n 'prevTargetId',\n 'mode',\n // run_tests progress counts (#646) — numbers, redact-safe.\n 'fileCount',\n 'passed',\n 'failed',\n 'skipped',\n]);\n\n/**\n * Patterns that match secret values.\n * Match order matters — more-specific patterns first.\n *\n * #268 redact script covers: relay=wss://…, at=<TOTP>, _deploymentId=<uuid>.\n * Here we extend to in-process value-level patterns used in server logs.\n */\nconst SECRET_PATTERNS: RegExp[] = [\n // TOTP 6-digit code as a standalone value (whole string is exactly 6 digits).\n /^\\d{6}$/,\n // Deploy Key — AITCC_API_KEY value prefix formats.\n /^(aitcc_|AITCC_)/i,\n // Cookie header value (whole string starts with a cookie-like name=value pair).\n /^[A-Za-z0-9_-]+=.{4,}/,\n // WSS relay URL value.\n /^wss:\\/\\//,\n // TOTP \"at=\" query param embedded in a string.\n /(?:^|[?&])at=[A-Z0-9]{6}/i,\n];\n\n/**\n * Returns `true` when the string value matches any known-secret pattern.\n * Only string values are tested — numbers/booleans are always safe.\n */\nfunction isSecretValue(value: string): boolean {\n return SECRET_PATTERNS.some((re) => re.test(value));\n}\n\n/**\n * Redacts a single scalar value.\n * - strings: return \"***\" if the value matches a secret pattern.\n * - other: return as-is.\n */\nfunction redactValue(value: unknown): unknown {\n if (typeof value === 'string' && isSecretValue(value)) {\n return '***';\n }\n return value;\n}\n\n/**\n * Builds a safe log payload from raw fields.\n *\n * - Only keys in `ALLOWED_KEYS` are included.\n * - String values are scanned for secret patterns and replaced with \"***\".\n * - `ts` and `level` and `event` are always included (they are injected by the\n * logger functions below, not by callers).\n */\nfunction buildPayload(\n level: LogLevel,\n event: LogEvent,\n fields: Record<string, unknown>,\n): Record<string, unknown> {\n const out: Record<string, unknown> = {\n ts: new Date().toISOString(),\n level,\n event,\n };\n\n for (const [key, value] of Object.entries(fields)) {\n if (!ALLOWED_KEYS.has(key)) continue;\n // ts/level/event are controlled above.\n if (key === 'ts' || key === 'level' || key === 'event') continue;\n out[key] = redactValue(value);\n }\n\n return out;\n}\n\n/**\n * Writes a single JSON log line to stderr.\n * MCP stdio transport uses stdout; all diagnostics go to stderr.\n */\nfunction writeLog(level: LogLevel, event: LogEvent, fields: Record<string, unknown> = {}): void {\n const payload = buildPayload(level, event, fields);\n process.stderr.write(`${JSON.stringify(payload)}\\n`);\n}\n\n// ---------------------------------------------------------------------------\n// Public logger functions — one per level.\n// ---------------------------------------------------------------------------\n\n/** Log an informational structured event. */\nexport function logInfo(event: LogEvent, fields: Record<string, unknown> = {}): void {\n writeLog('info', event, fields);\n}\n\n/** Log a warning structured event. */\nexport function logWarn(event: LogEvent, fields: Record<string, unknown> = {}): void {\n writeLog('warn', event, fields);\n}\n\n/** Log an error structured event. */\nexport function logError(event: LogEvent, fields: Record<string, unknown> = {}): void {\n writeLog('error', event, fields);\n}\n\n// ---------------------------------------------------------------------------\n// Exported redact helper for use in tests and callers that need to sanitise\n// before passing to the logger (e.g. error message strings).\n// ---------------------------------------------------------------------------\n\n/**\n * Returns a redacted copy of `value`:\n * - string: \"***\" if it matches a secret pattern, otherwise the original.\n * - other types: returned as-is.\n *\n * Exposed for unit tests and for callers that build dynamic `msg` strings.\n */\nexport function redact(value: unknown): unknown {\n return redactValue(value);\n}\n","/**\n * MCP environment — derived from two orthogonal axes (issue #348).\n *\n * Before #348 the environment was a single sticky decision made once per\n * process by `getEnvironment()` via a 5-step precedence chain (env var → URL\n * pattern sniffing → caller-stated default → baked-in default). That model\n * could not express a daemon that holds two live connections at once and swaps\n * the active one without a restart — the dual-connection design (#348).\n *\n * The 3-value `McpEnvironment` is now *derived* from cheap signals rather\n * than detected (env 4 / relay-live removed in #665):\n *\n * 1. `mock` vs `relay-*` — free from `connection.kind` (`'local'` | `'relay'`,\n * see `cdp-connection.ts`). Authoritative, known before any target\n * attaches, and swappable at runtime by pointing at a different connection.\n *\n * 2. `relay-dev` vs `relay-mobile` — both are `kind: 'relay'` relays,\n * distinguished by the booted family's `relayOrigin` discriminator\n * (`'intoss-webview'` → relay-dev, `'external-pwa'` → relay-mobile,\n * issue #378). NOT sniffed from the relay URL.\n *\n * `McpEnvironment` survives as an OUTPUT-BOUNDARY type — `get_debug_status` and\n * the envelope `meta.env` field still surface the precise three-value string —\n * but it is reconstructed from `(connection.kind, relayOrigin)` via\n * {@link deriveEnvironment}, never sniffed.\n *\n * Positive-allowlist kill-switch (#665): `relay-live` (env 4) is removed.\n * The debug surface is now only active on localhost/trycloudflare/private-apps\n * hosts. `relay-live`/`liveIntent`/LIVE guard are fully removed.\n *\n * SECRET-HANDLING: this module never reads the TOTP secret, deploy key, or any\n * URL. It deals only in the connection kind and optional relay origin.\n */\n\n/**\n * The three environments the MCP server can surface in its output (issues #307,\n * #378, #665).\n *\n * - `mock` — local Chromium + mock SDK (env 1) — active connection is local.\n * - `relay-dev` — real-device dog-food relay (env 3) — relay connection,\n * intoss-private WebView (the relay devtools started).\n * - `relay-mobile` — real-device PWA over an EXTERNAL relay (env 2, issue #378) —\n * relay connection, an external-PWA relay\n * (the unplugin started it; the MCP only attaches a CDP client).\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) at the in-app\n * entry and the MCP server no longer tracks a LIVE intent bit.\n *\n * This is a derived OUTPUT string (see module docstring) — not a detected,\n * sticky decision.\n */\nexport type McpEnvironment = 'mock' | 'relay-dev' | 'relay-mobile';\n\n/** Connection kind — the authoritative `mock` vs `relay` signal (issue #348). */\nexport type ConnectionKind = 'relay' | 'local';\n\n/**\n * Origin of a relay connection — the discriminator that distinguishes two relay\n * families that are otherwise both `kind: 'relay'` (issue #378):\n *\n * - `'intoss-webview'` — the intoss-private dog-food / live relay (env 3/4),\n * booted BY the MCP server (`bootRelayFamily`). Maps to `relay-dev` /\n * `relay-live` depending on `liveIntent`.\n * - `'external-pwa'` — an external CDP relay the unplugin already brought up\n * for the env-2 PWA (`bootExternalRelayFamily`). Maps to `relay-mobile`.\n *\n * Carried on the booted family (NOT sniffed from the relay URL), so the output\n * layer can tell `relay-mobile` apart from `relay-dev`.\n */\nexport type RelayOrigin = 'intoss-webview' | 'external-pwa';\n\n/**\n * Returns `true` when the environment is any relay variant (`relay-dev` or\n * `relay-mobile`). Use this instead of `env === 'relay'` for tier checks —\n * every relay env surfaces the Tier B / relay-only tool set.\n *\n * Written as an exhaustive switch so a future `McpEnvironment` member that is\n * missing an arm is a TS compile error rather than a silent `false`.\n */\nexport function isRelayEnv(env: McpEnvironment): boolean {\n switch (env) {\n case 'relay-dev':\n case 'relay-mobile':\n return true;\n case 'mock':\n return false;\n }\n}\n\n/**\n * Maps the `McpEnvironment` union to the legacy two-value union\n * (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.\n * Every relay variant (`relay-dev`, `relay-mobile`) collapses to `'relay'`.\n * Written as an exhaustive switch so a missing arm is a TS compile error.\n */\nexport function toLegacyEnv(env: McpEnvironment): 'mock' | 'relay' {\n switch (env) {\n case 'mock':\n return 'mock';\n case 'relay-dev':\n case 'relay-mobile':\n return 'relay';\n }\n}\n\n/**\n * Reconstructs the three-value `McpEnvironment` output string from the\n * orthogonal signals (issues #348, #378, #665):\n *\n * - `kind === 'local'` → `'mock'`\n * - `kind === 'relay'` && origin 'external-pwa' → `'relay-mobile'`\n * - `kind === 'relay'` && origin intoss/undefined → `'relay-dev'`\n *\n * `relayOrigin` is the booted-family discriminator (NOT sniffed from the URL)\n * that distinguishes the env-2 external-PWA relay (`relay-mobile`) from the\n * intoss-private dog-food relay (`relay-dev`); both are `kind: 'relay'`.\n *\n * `relay-live` (env 4) has been removed (#665). `liveIntent` parameter is gone.\n *\n * Pure — used at every output boundary (envelope `meta.env`, `get_debug_status`,\n * `measure_safe_area` provenance) so the surface never sniffs a URL again.\n *\n * Written switch-style so a missing arm is a TS compile error (never falls\n * through to a default).\n */\nexport function deriveEnvironment(kind: ConnectionKind, relayOrigin?: RelayOrigin): McpEnvironment {\n switch (kind) {\n case 'local':\n return 'mock';\n case 'relay':\n return relayOrigin === 'external-pwa' ? 'relay-mobile' : 'relay-dev';\n }\n}\n\n/* -------------------------------------------------------------------------- */\n/* Test override hook (narrow) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Test/override hook — when non-null, callers that consult\n * {@link getEnvironmentOverride} return this value regardless of the live\n * connection kind. Production code never sets it; it exists so a unit test can\n * pin a precise `McpEnvironment` without constructing a real connection.\n *\n * This is intentionally NARROW: it no longer drives a precedence chain. The\n * authoritative production signal is `connection.kind` + `relayOrigin`; this\n * override is a pure test affordance.\n */\nlet envOverride: McpEnvironment | null = null;\n\n/** Sets a sticky environment override. Intended for tests only. */\nexport function setEnvironmentOverride(env: McpEnvironment | null): void {\n envOverride = env;\n}\n\n/** Reads the current override (test inspection). */\nexport function getEnvironmentOverride(): McpEnvironment | null {\n return envOverride;\n}\n","/**\n * call_sdk 인자 시그니처 레지스트리\n *\n * 잘 알려진 SDK 메서드의 인자 schema를 수동으로 등록한다.\n * 목적: 잘못된 인자가 native bridge에 도달하기 전에 MCP 레이어에서 reject하여\n * 토스 앱 crash(Swift/Kotlin 측에서 `.type` 등을 undefined로 읽는 경우)를 예방.\n *\n * 등록되지 않은 메서드는 passthrough — 알 수 없는 메서드에 대해 stderr 경고 1회.\n *\n * 시그니처 출처:\n * - `src/__typecheck.ts` — Original SDK 타입 호환성 검증\n * - `src/mock/navigation/index.ts` — mock 구현의 함수 시그니처\n * - `src/mock/device/` — device mock 시그니처\n *\n * 새 메서드 추가 방법:\n * 1. `src/__typecheck.ts` 또는 mock 구현에서 시그니처 확인\n * 2. 아래 SIGNATURES 배열에 `SdkSignature` 항목 추가\n * 3. `src/__tests__/call-sdk-validation.test.ts`에 ok + bad 케이스 추가\n */\n\n/** 단일 메서드에 대한 인자 검증 결과 */\nexport type ValidationResult = { ok: true } | { ok: false; expected: string; received: string };\n\n/** 등록된 SDK 메서드 시그니처 */\nexport interface SdkSignature {\n /** SDK 메서드 이름 (예: \"setDeviceOrientation\") */\n name: string;\n /**\n * 인자 배열을 검증하는 함수.\n * `args[0]` 등 필요한 인자를 `unknown` 타입으로 받아 type guard로 검증.\n */\n validateArgs(args: unknown[]): ValidationResult;\n /**\n * 에러 메시지에 포함할 올바른 호출 예시.\n * 예: `call_sdk('setDeviceOrientation', [{ type: 'landscape' }])`\n */\n example: string;\n}\n\n/* -------------------------------------------------------------------------- */\n/* 헬퍼 — 공통 type guard */\n/* -------------------------------------------------------------------------- */\n\nfunction isObject(v: unknown): v is Record<string, unknown> {\n return typeof v === 'object' && v !== null && !Array.isArray(v);\n}\n\nfunction describeArgs(args: unknown[]): string {\n try {\n return JSON.stringify(args);\n } catch {\n return String(args);\n }\n}\n\n/* -------------------------------------------------------------------------- */\n/* 시그니처 레지스트리 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * 등록된 메서드 목록.\n *\n * 시그니처 출처 확인:\n * - 함수가 인자를 받지 않으면 args[0] 없음 → `args.length === 0`을 체크하지 않고\n * 그냥 통과시킨다(args 무시하는 stub가 많아서 noArgs 체크가 noise).\n * - 실 SDK 시그니처는 `src/__typecheck.ts`의 `Assert<Mock, Original>` 줄로 보장.\n */\nconst SIGNATURES: SdkSignature[] = [\n // --- setDeviceOrientation ---\n // 실 시그니처: setDeviceOrientation(options: { type: 'portrait' | 'landscape' }): Promise<void>\n // 출처: src/mock/navigation/index.ts:40 / src/__typecheck.ts:55\n {\n name: 'setDeviceOrientation',\n validateArgs(args) {\n const arg = args[0];\n if (!isObject(arg)) {\n return {\n ok: false,\n expected: \"{ type: 'portrait' | 'landscape' }\",\n received: describeArgs(args),\n };\n }\n const type = arg.type;\n if (type !== 'portrait' && type !== 'landscape') {\n return {\n ok: false,\n expected: \"{ type: 'portrait' | 'landscape' }\",\n received: describeArgs(args),\n };\n }\n return { ok: true };\n },\n example: \"call_sdk('setDeviceOrientation', [{ type: 'landscape' }])\",\n },\n\n // --- setIosSwipeGestureEnabled ---\n // 실 시그니처: setIosSwipeGestureEnabled(options: { isEnabled: boolean }): Promise<void>\n // 출처: src/mock/navigation/index.ts:32 / src/__typecheck.ts:51\n {\n name: 'setIosSwipeGestureEnabled',\n validateArgs(args) {\n const arg = args[0];\n if (!isObject(arg) || typeof arg.isEnabled !== 'boolean') {\n return {\n ok: false,\n expected: '{ isEnabled: boolean }',\n received: describeArgs(args),\n };\n }\n return { ok: true };\n },\n example: \"call_sdk('setIosSwipeGestureEnabled', [{ isEnabled: false }])\",\n },\n\n // --- setSecureScreen ---\n // 실 시그니처: setSecureScreen(options: { enabled: boolean }): Promise<{ enabled: boolean }>\n // 출처: src/mock/navigation/index.ts:66 / src/__typecheck.ts:46\n {\n name: 'setSecureScreen',\n validateArgs(args) {\n const arg = args[0];\n if (!isObject(arg) || typeof arg.enabled !== 'boolean') {\n return {\n ok: false,\n expected: '{ enabled: boolean }',\n received: describeArgs(args),\n };\n }\n return { ok: true };\n },\n example: \"call_sdk('setSecureScreen', [{ enabled: true }])\",\n },\n\n // --- setScreenAwakeMode ---\n // 실 시그니처: setScreenAwakeMode(options: { enabled: boolean }): Promise<{ enabled: boolean }>\n // 출처: src/mock/navigation/index.ts:57 / src/__typecheck.ts:47\n {\n name: 'setScreenAwakeMode',\n validateArgs(args) {\n const arg = args[0];\n if (!isObject(arg) || typeof arg.enabled !== 'boolean') {\n return {\n ok: false,\n expected: '{ enabled: boolean }',\n received: describeArgs(args),\n };\n }\n return { ok: true };\n },\n example: \"call_sdk('setScreenAwakeMode', [{ enabled: true }])\",\n },\n\n // --- getOperationalEnvironment ---\n // 실 시그니처: getOperationalEnvironment(): 'toss' | 'sandbox'\n // 인자 없음 — args는 무시 (SDK 자체가 인자를 무시함)\n // 출처: src/mock/navigation/index.ts:88 / src/__typecheck.ts:62\n {\n name: 'getOperationalEnvironment',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getOperationalEnvironment', [])\",\n },\n\n // --- getPlatformOS ---\n // 실 시그니처: getPlatformOS(): 'ios' | 'android'\n // 출처: src/mock/navigation/index.ts:84 / src/__typecheck.ts:61\n {\n name: 'getPlatformOS',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getPlatformOS', [])\",\n },\n\n // --- getDeviceId ---\n // 실 시그니처: getDeviceId(): string\n // 출처: src/mock/navigation/index.ts:119 / src/__typecheck.ts:74\n {\n name: 'getDeviceId',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getDeviceId', [])\",\n },\n\n // --- getLocale ---\n // 실 시그니처: getLocale(): string\n // 출처: src/mock/navigation/index.ts:115 / src/__typecheck.ts:72\n {\n name: 'getLocale',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getLocale', [])\",\n },\n\n // --- getNetworkStatus ---\n // 실 시그니처: getNetworkStatus(): Promise<NetworkStatus>\n // 출처: src/mock/navigation/index.ts:127 / src/__typecheck.ts:73\n {\n name: 'getNetworkStatus',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getNetworkStatus', [])\",\n },\n\n // --- getSchemeUri ---\n // 실 시그니처: getSchemeUri(): string\n // 출처: src/mock/navigation/index.ts:111 / src/__typecheck.ts:71\n {\n name: 'getSchemeUri',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('getSchemeUri', [])\",\n },\n\n // --- requestReview ---\n // 실 시그니처: requestReview(): Promise<void>\n // 출처: src/mock/navigation/index.ts:75 / src/__typecheck.ts:76\n {\n name: 'requestReview',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('requestReview', [])\",\n },\n\n // --- closeView ---\n // 실 시그니처: closeView(): Promise<void>\n // 출처: src/mock/navigation/index.ts:10 / src/__typecheck.ts:42\n {\n name: 'closeView',\n validateArgs(_args) {\n return { ok: true };\n },\n example: \"call_sdk('closeView', [])\",\n },\n];\n\n/* -------------------------------------------------------------------------- */\n/* 레지스트리 공개 API */\n/* -------------------------------------------------------------------------- */\n\nconst SIGNATURE_MAP = new Map<string, SdkSignature>(SIGNATURES.map((s) => [s.name, s]));\n\n/** 세션 내 passthrough 경고를 한 번만 emit하기 위한 Set */\nconst _warnedPassthrough = new Set<string>();\n\n/**\n * 메서드 이름으로 시그니처를 조회한다.\n * 등록된 메서드이면 `SdkSignature`를 반환하고, 미등록이면 `undefined`.\n */\nexport function lookupSignature(name: string): SdkSignature | undefined {\n return SIGNATURE_MAP.get(name);\n}\n\n/**\n * 미등록 메서드에 대해 stderr에 passthrough 경고를 1회 출력한다.\n * 세션 내 동일 메서드 이름은 최초 1회만 출력.\n */\nexport function warnPassthrough(name: string): void {\n if (_warnedPassthrough.has(name)) return;\n _warnedPassthrough.add(name);\n process.stderr.write(`[ait-debug] call_sdk: \"${name}\" 시그니처가 등록되지 않음 — passthrough\\n`);\n}\n\n/**\n * 테스트에서 passthrough 경고 Set을 초기화하기 위한 헬퍼.\n * 프로덕션 코드에서는 호출하지 않는다.\n */\nexport function _resetWarnedPassthroughForTest(): void {\n _warnedPassthrough.clear();\n}\n\n/**\n * 등록된 메서드 이름 목록 — tool description 생성 등에서 사용.\n */\nexport const REGISTERED_METHOD_NAMES: ReadonlyArray<string> = SIGNATURES.map((s) => s.name);\n","/**\n * Single debug session lock for the `devtools-mcp` debug server.\n *\n * At most one debug server process should run on a given machine at a time —\n * multiple concurrent instances create duplicate cloudflared tunnels, waste\n * resources, and confuse the user about which wssUrl to use.\n *\n * ## Lock file\n *\n * Location: `~/.ait-devtools/server.lock`\n *\n * Schema (JSON):\n * ```json\n * { \"pid\": 12345, \"wssUrl\": \"wss://xxx.trycloudflare.com\", \"startedAt\": \"2026-01-01T00:00:00.000Z\" }\n * ```\n *\n * ## Behaviour\n *\n * - **Acquire**: write PID + wssUrl + startedAt. Returns a `release()` handle.\n * - **Stale lock recovery**: if the stored PID is no longer alive\n * (`process.kill(pid, 0)` throws ESRCH), the lock is silently replaced.\n * - **Live conflict (option B)**: if the stored PID is alive, `acquireLock`\n * throws `ServerLockConflictError` with the existing PID and wssUrl so the\n * caller can surface a clear message to the agent.\n * - **Release**: remove the lock file. Called on graceful shutdown (SIGINT /\n * SIGTERM / SIGHUP). SIGKILL survivors leave a stale file — the next startup\n * recovers it automatically via the alive check.\n *\n * ## wssUrl update\n *\n * The lock is written before cloudflared starts, so `wssUrl` begins as `null`\n * and is updated in place once the tunnel URL is known via `updateWssUrl`.\n *\n * Node-only.\n */\n\nimport { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\nimport { isPidAlive as _isPidAlive } from '../shared/parent-watcher.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface LockData {\n pid: number;\n /** `null` until the cloudflared tunnel URL is assigned. */\n wssUrl: string | null;\n startedAt: string;\n /**\n * PID of the cloudflared child process. Written once the tunnel is up via\n * `LockHandle.updateTunnelChildPid`. Absent in lock files written by older\n * versions — those fall back to PID-only stale detection.\n *\n * FIX 3 (issue #571): `acquireLock` treats a live holder whose tunnel child\n * is known-dead as a stale lock and reclaims it.\n */\n tunnelChildPid?: number | null;\n}\n\nexport interface LockHandle {\n /** Updates the wssUrl field in the lock file once the tunnel URL is known. */\n updateWssUrl(wssUrl: string): void;\n /**\n * Updates the cloudflared child PID in the lock file once the tunnel is up.\n *\n * FIX 3 (issue #571): a second `acquireLock` caller will see this PID and\n * can detect that the holder's tunnel child is dead even though the Node\n * process itself is still alive, allowing lock reclamation.\n */\n updateTunnelChildPid(pid: number): void;\n /** Removes the lock file. Idempotent — safe to call multiple times. */\n release(): void;\n}\n\n/** Thrown when a live server process already holds the lock. */\nexport class ServerLockConflictError extends Error {\n /** PID of the existing server process. */\n readonly existingPid: number;\n /** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */\n readonly existingWssUrl: string | null;\n /** ISO timestamp from the existing lock — when that session started. */\n readonly existingStartedAt: string;\n\n constructor(existingPid: number, existingWssUrl: string | null, existingStartedAt: string) {\n const urlNote =\n existingWssUrl != null\n ? ` relay URL: ${existingWssUrl}\\n`\n : ' relay URL: (tunnel still starting — retry in a moment)\\n';\n\n super(\n `A debug server is already running (PID ${existingPid}).\\n` +\n urlNote +\n 'Stop the existing session before starting a new one.\\n' +\n 'If it is already stopped but this error persists, remove the lock file:\\n' +\n ` rm \"${lockFilePath()}\"`,\n );\n this.name = 'ServerLockConflictError';\n this.existingPid = existingPid;\n this.existingWssUrl = existingWssUrl;\n this.existingStartedAt = existingStartedAt;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Paths\n// ---------------------------------------------------------------------------\n\n/** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */\nexport function lockFilePath(): string {\n const dir = process.env.AIT_DEVTOOLS_LOCK_DIR ?? join(homedir(), '.ait-devtools');\n return join(dir, 'server.lock');\n}\n\nfunction ensureLockDir(lockPath: string): void {\n const dir = join(lockPath, '..');\n mkdirSync(dir, { recursive: true });\n}\n\n// ---------------------------------------------------------------------------\n// PID alive check\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when the given PID refers to a running process.\n *\n * Re-exported from `../shared/parent-watcher` so external callers that\n * import from `./server-lock` keep working without an import-path change.\n */\nexport const isPidAlive: (pid: number) => boolean = _isPidAlive;\n\n// ---------------------------------------------------------------------------\n// Read / write helpers\n// ---------------------------------------------------------------------------\n\nfunction readLock(lockPath: string): LockData | null {\n if (!existsSync(lockPath)) return null;\n try {\n const raw = readFileSync(lockPath, 'utf8');\n const parsed: unknown = JSON.parse(raw);\n if (\n typeof parsed === 'object' &&\n parsed !== null &&\n 'pid' in parsed &&\n typeof (parsed as Record<string, unknown>).pid === 'number' &&\n 'startedAt' in parsed &&\n typeof (parsed as Record<string, unknown>).startedAt === 'string'\n ) {\n const p = parsed as Record<string, unknown>;\n // FIX 3: read optional tunnelChildPid — absent in lock files from older\n // versions; those fall back to PID-only stale detection.\n const tunnelChildPid = typeof p.tunnelChildPid === 'number' ? p.tunnelChildPid : null;\n return {\n pid: p.pid as number,\n wssUrl: typeof p.wssUrl === 'string' ? p.wssUrl : null,\n startedAt: p.startedAt as string,\n tunnelChildPid,\n };\n }\n // Unrecognised schema — treat as stale.\n return null;\n } catch {\n // Corrupt / unreadable — treat as stale.\n return null;\n }\n}\n\nfunction writeLock(lockPath: string, data: LockData): void {\n ensureLockDir(lockPath);\n writeFileSync(lockPath, JSON.stringify(data, null, 2), { encoding: 'utf8' });\n}\n\nfunction removeLock(lockPath: string): void {\n try {\n rmSync(lockPath);\n } catch {\n // Already removed — fine.\n }\n}\n\n// ---------------------------------------------------------------------------\n// Force-takeover helper\n// ---------------------------------------------------------------------------\n\n/**\n * Sends SIGTERM to `pid` and waits up to `graceMs` (default 2 000 ms) for it\n * to exit; then falls back to SIGKILL. Synchronous — uses a busy-wait loop so\n * it is usable in the top-level startup path without async plumbing.\n *\n * Ignores errors from `process.kill` so that a race where the target exits\n * between the alive check and the kill call does not crash the caller.\n */\nfunction killAndWait(pid: number, graceMs = 2_000): void {\n try {\n process.kill(pid, 'SIGTERM');\n } catch {\n // Already gone — nothing to do.\n return;\n }\n\n const deadline = Date.now() + graceMs;\n // Poll every 100 ms until the process is gone or the grace period expires.\n while (isPidAlive(pid) && Date.now() < deadline) {\n // Busy-wait: this is a very short window (≤2 s) at startup.\n const end = Date.now() + 100;\n while (Date.now() < end) {\n // spin\n }\n }\n\n if (isPidAlive(pid)) {\n try {\n process.kill(pid, 'SIGKILL');\n } catch {\n // Already gone.\n }\n }\n}\n\n/**\n * Reaps an orphaned cloudflared tunnel child left behind by a previous session\n * (issue #628). The normal shutdown path TERM-cascades to the child, but a\n * SIGKILL'd or crashed Node process can't run cleanup — its cloudflared child\n * keeps the (now stale) quick tunnel alive. When `acquireLock` reclaims such a\n * lock, this kills the still-alive `tunnelChildPid` so the new session starts\n * from a clean slate (companion to the #347/#571 zombie-daemon defenses).\n *\n * No-op when the lock carries no `tunnelChildPid` (older lock files) or the\n * child is already gone. SECRET-HANDLING: logs the PID only — never the tunnel\n * host/wss (those never enter the lock file or this path).\n */\nfunction reapOrphanTunnelChild(existing: LockData): void {\n const childPid = existing.tunnelChildPid;\n if (typeof childPid !== 'number' || !isPidAlive(childPid)) return;\n process.stderr.write(\n `[ait-debug] reaping orphaned tunnel child PID=${childPid} from previous session.\\n`,\n );\n killAndWait(childPid);\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Reads the current lock file without acquiring it. Returns the parsed\n * `LockData` when the file exists and is valid, otherwise `null`. Used by\n * `get_debug_status` to surface the `serverLockHolder` field without\n * interfering with the running lock owner.\n */\nexport function readServerLock(): LockData | null {\n return readLock(lockFilePath());\n}\n\n/** Options for `acquireLock`. */\nexport interface AcquireLockOptions {\n /**\n * When `true`, terminates the process holding the existing lock (SIGTERM →\n * wait up to 2 s → SIGKILL) and takes over the lock.\n *\n * Corresponds to the `--force` / `--takeover` CLI flag.\n */\n force?: boolean;\n}\n\n/**\n * Attempts to acquire the server lock.\n *\n * - If no lock exists (or the lock is stale): writes a new lock and returns a\n * `LockHandle` with `updateWssUrl` + `release`.\n * - If a live process holds the lock and `force` is `false` (default): writes\n * a clear recovery message to stderr and throws `ServerLockConflictError`.\n * - If a live process holds the lock and `force` is `true`: sends SIGTERM to\n * that process (waiting up to 2 s then SIGKILL) and takes over the lock.\n *\n * The initial `wssUrl` in the lock file is `null` — call\n * `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.\n */\nexport function acquireLock(options: AcquireLockOptions = {}): LockHandle {\n const { force = false } = options;\n const lockPath = lockFilePath();\n const existing = readLock(lockPath);\n\n if (existing !== null) {\n if (isPidAlive(existing.pid)) {\n // FIX 3 (issue #571): even if the Node process is alive, check whether\n // its cloudflared child has died. A zombie daemon whose tunnel is dead\n // is effectively stale — reclaim the lock without waiting for the user\n // to manually kill the process.\n const tunnelChildPid = existing.tunnelChildPid;\n const tunnelChildDead = typeof tunnelChildPid === 'number' && !isPidAlive(tunnelChildPid);\n\n if (tunnelChildDead) {\n process.stderr.write(\n `[ait-debug] stale lock: holder PID=${existing.pid} alive but tunnel child PID=${tunnelChildPid} is dead — reclaiming lock.\\n`,\n );\n // Fall through to write a fresh lock.\n } else if (force) {\n // Force takeover: SIGTERM → 2 s grace → SIGKILL.\n process.stderr.write(\n `[ait-debug] --force: terminating existing session PID=${existing.pid} …\\n`,\n );\n killAndWait(existing.pid);\n // Issue #628: killing the Node holder doesn't reliably cascade to a\n // detached cloudflared child — reap it explicitly so --force leaves no\n // orphaned tunnel.\n reapOrphanTunnelChild(existing);\n process.stderr.write(`[ait-debug] --force: PID=${existing.pid} stopped, taking over.\\n`);\n } else {\n // Emit a user-actionable message before throwing so the MCP host can\n // surface it — the thrown message is included in the \"process exited\"\n // log, but the stderr line is more prominent and machine-parseable.\n const urlPart =\n existing.wssUrl != null ? `wssUrl=${existing.wssUrl}` : 'wssUrl=(tunnel starting)';\n process.stderr.write(\n `[ait-debug] 기존 debug-mode 세션이 이미 실행 중 — PID=${existing.pid}, started ${existing.startedAt}, ${urlPart}\\n` +\n `[ait-debug] 회복: \\`kill ${existing.pid}\\` 또는 \\`npx @ait-co/devtools devtools-mcp --force\\`\\n`,\n );\n throw new ServerLockConflictError(existing.pid, existing.wssUrl, existing.startedAt);\n }\n } else {\n // Stale lock — previous process died without cleanup.\n process.stderr.write(\n `[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\\n`,\n );\n // Issue #628: a SIGKILL'd/crashed Node couldn't TERM-cascade to its\n // cloudflared child, so that child may still be holding a stale tunnel.\n // Reap it before the new session boots its own tunnel.\n reapOrphanTunnelChild(existing);\n }\n }\n\n const data: LockData = {\n pid: process.pid,\n wssUrl: null,\n startedAt: new Date().toISOString(),\n };\n writeLock(lockPath, data);\n\n let released = false;\n\n return {\n updateWssUrl(wssUrl: string): void {\n if (released) return;\n data.wssUrl = wssUrl;\n writeLock(lockPath, data);\n },\n updateTunnelChildPid(pid: number): void {\n if (released) return;\n data.tunnelChildPid = pid;\n writeLock(lockPath, data);\n },\n release(): void {\n if (released) return;\n released = true;\n removeLock(lockPath);\n },\n };\n}\n","/**\n * Debug-mode MCP tools (Phase 1–3 + safe-area probe).\n *\n * Read-only tools that normalize CDP / AIT data into `chrome-devtools-mcp`-\n * compatible shapes. The tools never touch a websocket or HTTP endpoint\n * directly — they read from an injected `CdpConnection` (CDP events/commands)\n * or `AitSource` (AIT.* domain), which is what makes them unit-testable with a\n * fake. No phone and no running dev server are needed in tests.\n *\n * Phase 1 (CDP events):\n * - `list_console_messages` ← Runtime.consoleAPICalled\n * - `list_network_requests` ← Network.requestWillBeSent + responseReceived\n * - `list_pages` ← Chii relay target list + tunnel status\n * Phase 2 (CDP commands):\n * - `get_dom_document` ← DOM.getDocument\n * - `take_snapshot` ← DOMSnapshot.captureSnapshot\n * - `take_screenshot` ← Page.captureScreenshot\n * - `measure_safe_area` ← Runtime.evaluate (safe-area probe)\n * Phase 3 (AIT.* domain — CDP can't cover these):\n * - `AIT.getSdkCallHistory`\n * - `AIT.getMockState`\n * - `AIT.getOperationalEnvironment`\n */\n\nimport type {\n AitMockState,\n AitOperationalEnvironment,\n AitSdkCallHistory,\n AitSource,\n} from './ait-source.js';\nimport type {\n CdpCallFrame,\n CdpConnection,\n CdpRemoteObject,\n ConsoleApiCalledEvent,\n DomGetDocumentResult,\n DomSnapshotResult,\n NetworkRequestWillBeSentEvent,\n NetworkResponseReceivedEvent,\n RuntimeExceptionThrownEvent,\n} from './cdp-connection.js';\nimport { buildDeepLinkAttachUrl, validateSchemeAuthority } from './deeplink.js';\nimport type { McpEnvironment } from './environment.js';\nimport { isRelayEnv, toLegacyEnv } from './environment.js';\nimport { lookupSignature, warnPassthrough } from './sdk-signatures.js';\nimport { isPidAlive } from './server-lock.js';\nimport { generateTotp, RELAY_VERIFY_SKEW_STEPS } from './totp.js';\n\n/** Tunnel state surfaced by `list_pages`. */\nexport interface TunnelStatus {\n /** Whether the cloudflared quick tunnel is up. */\n up: boolean;\n /** Public `wss://*.trycloudflare.com` relay URL the phone attaches to. */\n wssUrl: string | null;\n /**\n * ISO timestamp when a tunnel drop was first detected by the health probe.\n * `null` means the tunnel has not dropped (or has recovered since the last\n * drop). When non-null and `up` is false, the tunnel is down and the probe\n * has exhausted all reissue attempts — the server must be restarted.\n */\n droppedAt?: string | null;\n /**\n * Number of automatic reissue attempts made after a drop was detected.\n * Resets to 0 after a successful reissue. Reaches `MAX_REISSUE_ATTEMPTS`\n * (3) before the probe gives up and enters the permanent-error state.\n */\n reissueAttempts?: number;\n}\n\n/**\n * Tier classification per RFC #277 (\"MCP tool surface fidelity\"):\n *\n * - **Tier A** (`mock` only) — mock-internal state dials with no real-device\n * equivalent. Hidden when env is `relay`.\n * - **Tier B** (`relay` only) — relay infrastructure tools that have no mock\n * equivalent (e.g. `start_attach` needs a cloudflared tunnel URL). Hidden\n * when env is `mock`.\n * - **Tier C** (`both`) — fidelity-parallel tools that produce semantically\n * equivalent results across mock and relay. The agent sees the same tool with\n * the same shape; only the `source` provenance field (where applicable)\n * differs.\n */\nexport type ToolAvailability = 'mock' | 'relay' | 'both';\n\n/** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */\nexport const DEBUG_TOOL_DEFINITIONS = [\n {\n name: 'list_console_messages',\n description:\n 'Lists recent console messages (console.log/warn/error/info) captured from the attached ' +\n 'mini-app page over CDP (Runtime.consoleAPICalled). Read-only. Returns level, text, ' +\n 'timestamp, and stringified args, oldest-first.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'list_network_requests',\n description:\n 'Lists recent network requests (XHR/fetch) captured from the attached mini-app page over ' +\n 'CDP (Network.requestWillBeSent + Network.responseReceived). Read-only. Returns url, ' +\n 'method, status, and timing, oldest-first.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'list_pages',\n description:\n 'Returns the single active page (at most one) the relay sees attached. ' +\n 'When a second page attaches, the previous one is evicted (last-attach wins — ' +\n 'single-attach model). The result includes `singleAttachModel: true` so the agent ' +\n 'knows the array is always 0 or 1 entries. ' +\n 'Also returns whether the cloudflared tunnel is up and the public wss relay URL. ' +\n 'The `tunnel` field includes `droppedAt` (ISO timestamp or null/undefined): when non-null ' +\n 'the tunnel has permanently dropped after 3 failed reissue attempts — restart the debug ' +\n 'server with `npx @ait-co/devtools devtools-mcp`. ' +\n 'Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from ' +\n 'that target — useful to detect stale entries when the phone app backgrounded). ' +\n 'The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, ' +\n 'a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since ' +\n 'the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint ' +\n 'to re-attach. ' +\n 'Call this first to confirm a page is attached before reading console/network. ' +\n 'When a page attaches or detaches the server emits notifications/tools/list_changed — ' +\n 'call tools/list again to get the full updated tool surface.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'start_attach',\n description:\n \"The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. \" +\n 'Single entry point to attach a real device: switches the debug mode (if `mode` is given), ' +\n 'builds the self-attaching deep-link QR for the active relay environment, and waits for the ' +\n 'phone to attach — all in one call (replaces the old attach-URL + start_debug two-step). ' +\n 'Scan the QR with the phone camera to open the mini-app and attach it to this debug session ' +\n '(QR is the single entry path — no USB cable or platform CLI needed).\\n\\n' +\n 'mode (optional): pass \"relay-sandbox\" (env 2) or \"relay-staging\" (env 3) to switch the active ' +\n 'environment first. When omitted, the current relay environment is used as-is (no switch). ' +\n 'Passing \"local-browser\" returns an error — start_attach is relay-only (env 2/3). ' +\n 'When the session is already in the requested mode, the switch is skipped.\\n\\n' +\n 'Environment-specific behaviour:\\n' +\n ' • env 3 / relay-staging: requires scheme_url — the intoss-private://…?_deploymentId=<uuid> ' +\n 'URL from `ait deploy --scheme-only`. Splices debug=1 + relay URL into the scheme URL.\\n' +\n ' • env 2 / relay-sandbox: scheme_url is NOT used. Instead, reads AIT_TUNNEL_BASE_URL ' +\n '(the https://*.trycloudflare.com app tunnel from `tunnel:{cdp:true}`) and builds a launcher PWA ' +\n 'deep-link (https://devtools.aitc.dev/launcher/?url=…&debug=1&relay=…). When projectRoot is given, ' +\n 'the app name from <projectRoot>/package.json is added as name= so the launcher partner bar shows it.\\n\\n' +\n 'Waits for a page to attach by default (up to wait_timeout_seconds, default 60 s). ' +\n 'The server automatically opens the QR dashboard in the OS default browser when running on a ' +\n 'local GUI machine — headless/remote environments fall back to the text QR in the tool output.' +\n '\\n\\nTOTP auto re-mint: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the attachUrl carries ' +\n 'a one-time code (at=<code>) valid for ~3 minutes (the relay gate accepts ±6 TOTP steps). ' +\n 'While waiting, start_attach AUTOMATICALLY re-mints a fresh code before the current one expires ' +\n 'and refreshes the dashboard QR in place (no browser re-open). You do NOT need to re-call ' +\n 'start_attach every time the code would expire — a single call covers the whole wait window. ' +\n 'The response includes a `totp` field with `expiresAt` and a `reminted` count of how many fresh ' +\n 'codes were issued during the wait. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.\\n\\n' +\n 'selfdebug (env 2 / relay-sandbox only): pass selfdebug=true to add &selfdebug=1 to the ' +\n 'launcher deep-link. The launcher PWA then registers its own document as the CDP target ' +\n 'instead of the framed mini-app. SINGLE-ATTACH MODEL: attaching the launcher self-target ' +\n 'evicts any currently-attached mini-app target — use this mode exclusively for diagnosing ' +\n 'the launcher document itself (DOM, safe-area, console). Not applicable in env 3 ' +\n '(relay-staging) — passing selfdebug=true there returns an error.',\n inputSchema: {\n type: 'object',\n properties: {\n mode: {\n type: 'string',\n enum: ['local-browser', 'relay-sandbox', 'relay-staging'],\n description:\n 'Optional debug mode to switch into before attaching. \"relay-sandbox\" = env 2 (launcher PWA), ' +\n '\"relay-staging\" = env 3 (intoss-private dog-food). \"local-browser\" returns an error ' +\n '(start_attach is relay-only). Omit to keep the current relay environment.',\n },\n scheme_url: {\n type: 'string',\n description:\n 'The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). ' +\n 'Required for env 3/relay-staging mode. Not used in env 2/relay-sandbox mode (use AIT_TUNNEL_BASE_URL instead). ' +\n 'The authority (host) must be the app name (e.g. intoss-private://aitc-sdk-example?_deploymentId=…). ' +\n 'Generic values like \"web\" or an empty host indicate a malformed URL.',\n },\n wait_timeout_seconds: {\n type: 'number',\n description:\n 'Maximum seconds to wait for a page to attach (default 60, range 1–600). ' +\n 'Values outside the range or invalid inputs (0, negative, NaN) fall back to the default silently. ' +\n 'During the wait the TOTP code is auto re-minted as needed, so a single call covers the whole window.',\n },\n projectRoot: {\n type: 'string',\n description:\n 'Absolute path to the mini-app project root (the directory containing its package.json and .ait_urls). ' +\n 'When AIT_TUNNEL_BASE_URL is unset (env 2 / relay-mobile only), the daemon reads the app tunnel URL ' +\n 'from <projectRoot>/.ait_urls written by the dev server (tunnel:{cdp:true}). ' +\n \"Pass this because the daemon's own cwd is fixed at launch. Omit when AIT_TUNNEL_BASE_URL is set explicitly.\",\n },\n selfdebug: {\n type: 'boolean',\n description:\n 'Env 2 / relay-sandbox only. When true, adds &selfdebug=1 to the launcher deep-link ' +\n 'so the launcher PWA registers its own document as the CDP target (launcher diagnostics mode). ' +\n 'SINGLE-ATTACH MODEL: self-target attach evicts any currently-attached mini-app target. ' +\n 'Use only when you need to inspect the launcher itself (DOM, safe-area, console). ' +\n 'Passing selfdebug=true in env 3 (relay-staging) returns an error. ' +\n 'Default: false (omitted).',\n },\n },\n // No required fields — mode/scheme_url requirements are enforced at runtime\n // based on the active (or switched-into) relay environment.\n required: [],\n },\n // Tier B per RFC #277 — the URL synthesis requires a live cloudflared\n // tunnel + relay, which only exists in the `relay` environment.\n availableIn: 'relay' as ToolAvailability,\n },\n {\n name: 'get_dom_document',\n description:\n 'Returns the DOM tree of the attached mini-app page over CDP (DOM.getDocument). Read-only. ' +\n 'Use for structural/layout regression diagnosis (e.g. confirming an element exists, ' +\n 'inspecting attributes). Returns the document root node with children.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'take_snapshot',\n description:\n 'Captures a serialized snapshot of the attached page over CDP (DOMSnapshot.captureSnapshot). ' +\n 'Read-only. Returns the documents + interned strings table for visual-regression diagnosis ' +\n '(e.g. checking computed CSS custom properties like --sat against the live layout).',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'take_screenshot',\n description:\n 'Captures a PNG screenshot of the attached mini-app page over CDP (Page.captureScreenshot) ' +\n 'so the agent can see the phone screen directly. Read-only. ' +\n 'Returns an image content block — this is the only debug tool that returns an image; ' +\n 'all other debug tools return text (JSON).',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'measure_safe_area',\n description:\n 'Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns ' +\n 'normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. ' +\n 'Read-only — does not modify page state. ' +\n 'Tier C per RFC #277: the same Runtime.evaluate probe runs in both `mock` (devtools panel ' +\n 'page with window.__ait state) and `relay` (real-device WebView with window.__sdk). ' +\n 'The result includes a `source: \"mock\" | \"relay-dev\" | \"relay-mobile\"` field so consumers can identify ' +\n 'provenance without inspecting payload values. ' +\n '(`relay-mobile` = env 2 real-device PWA over an external relay; ' +\n '`relay-dev` = env 3 dog-food WebView; relay-live/env 4 removed #665.) ' +\n 'Use in a relay session (phone attached) to get ground-truth values for upgrading a ' +\n 'viewport preset from extrapolated/placeholder to measured. ' +\n 'Requires a page to be attached — call list_pages first.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'evaluate',\n description:\n 'Evaluates an arbitrary JavaScript expression on the attached mini-app page via ' +\n 'CDP Runtime.evaluate (returnByValue: true) and returns the result. ' +\n 'NOT read-only — the expression can have side effects (DOM mutations, SDK calls, ' +\n 'state changes). Requires the relay to be attached — call list_pages first. ' +\n 'Throws if the evaluation throws an exception on the page.\\n\\n' +\n 'SECURITY: expression and result are not redacted — never include secrets or auth ' +\n 'tokens in the expression.\\n\\n' +\n 'Positive-allowlist kill-switch (#665): this tool is blocked when the attached ' +\n 'page is on a non-debug host (apps.tossmini.com / env 4). Only localhost, ' +\n '*.trycloudflare.com, and *.private-apps.tossmini.com are allowed. ' +\n 'relay-live (env 4) and the LIVE confirm guard are removed.',\n inputSchema: {\n type: 'object',\n properties: {\n expression: {\n type: 'string',\n description: 'JavaScript expression to evaluate in the page context.',\n },\n },\n required: ['expression'],\n },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'list_exceptions',\n description:\n 'Lists JS-level exceptions captured via `Runtime.exceptionThrown` from the relay attached ' +\n 'page. Includes timestamp, exception text, source URL/line, and stack trace. ' +\n 'Use to root-cause SDK throws that may precede a Toss app crash (#265 / #267). ' +\n 'The buffer holds up to 50 most recent exceptions and survives target ' +\n 'replaced/crashed/destroyed events so an exception just before a crash is preserved. ' +\n 'Returns up to 50 most recent by default.',\n inputSchema: {\n type: 'object',\n properties: {\n limit: {\n type: 'number',\n description: 'Maximum number of exceptions to return (default 50, max 50).',\n },\n },\n required: [],\n },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'call_sdk',\n description:\n 'Calls a dog-food SDK method via the window.__sdkCall bridge ' +\n '(exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). ' +\n 'NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). ' +\n 'On env 3/4 (real device relay) this hits the real SDK; on env 1 (local mock) and ' +\n 'env 2 (PWA relay — real WebKit, mock SDK) it hits the mock SDK. ' +\n 'Requires the relay to be attached — call list_pages first. ' +\n 'Returns {ok: true, value} on success or {ok: false, error} on failure. ' +\n 'If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], ' +\n 'the result also includes `recentException` for crash triage. ' +\n 'Returns a clear error if window.__sdkCall is not available — on relay (env 3/4) ' +\n 'that means a non-dog-food bundle (redeploy via `ait build && aitcc app deploy`); ' +\n 'on local (--target=local, env 1) it means the dev bridge is not installed ' +\n '(start the dev server with `pnpm dev`).\\n\\n' +\n 'SECURITY: method name, args, and result value are not redacted — never include secrets.\\n\\n' +\n 'Positive-allowlist kill-switch (#665): blocked when the attached page is on ' +\n 'a non-debug host (apps.tossmini.com / env 4). relay-live and the LIVE guard removed.\\n\\n' +\n 'IMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\\n' +\n ' setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\\n' +\n ' setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\\n' +\n ' setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\\n' +\n ' setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\\n' +\n ' getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\\n' +\n ' getPlatformOS: call_sdk(\"getPlatformOS\", [])\\n' +\n ' getDeviceId: call_sdk(\"getDeviceId\", [])\\n' +\n ' getLocale: call_sdk(\"getLocale\", [])\\n' +\n ' getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\\n' +\n ' getSchemeUri: call_sdk(\"getSchemeUri\", [])\\n' +\n ' requestReview: call_sdk(\"requestReview\", [])\\n' +\n ' closeView: call_sdk(\"closeView\", [])',\n inputSchema: {\n type: 'object',\n properties: {\n name: {\n type: 'string',\n description: 'SDK method name to call (e.g. \"getOperationalEnvironment\").',\n },\n args: {\n type: 'array',\n description: 'Arguments to pass to the SDK method (optional, default []).',\n items: {},\n },\n },\n required: ['name'],\n },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'AIT.getSdkCallHistory',\n description:\n 'Returns the recent Apps in Toss SDK call trace (method, args, result/error, timestamp) that ' +\n 'raw CDP cannot observe. Read-only. Use to confirm an SDK call fired and how it resolved ' +\n '(e.g. a saveBase64Data permission regression).',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'AIT.getMockState',\n description:\n 'Returns the devtools mock state snapshot (window.__ait) — environment, permissions, location, ' +\n 'auth, network, IAP, and more. Read-only. In dev mode this is the live browser mock state; in ' +\n 'debug mode the in-app side reports it over the AIT domain.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'AIT.getOperationalEnvironment',\n description:\n 'Returns getOperationalEnvironment() plus the resolved SDK version — metadata raw CDP cannot ' +\n 'observe. Read-only.',\n inputSchema: { type: 'object', properties: {}, required: [] },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'start_debug',\n description:\n 'Switches the active debug environment in-place (issue #348) — no Claude Code restart and ' +\n 'no MCP re-handshake. One daemon holds both a local (env 1, mock SDK in a Chromium) and a ' +\n 'relay (env 2/3, real-device over the Chii relay + cloudflared tunnel) ' +\n 'connection at once; this tool flips which one every other tool reads from, lazily booting ' +\n \"the requested family's infra on first use and keeping the inactive one warm so an existing \" +\n 'attach survives the switch. After switching it emits notifications/tools/list_changed — ' +\n 'call tools/list again to see the updated tool surface for the new environment.\\n\\n' +\n 'Positive-allowlist kill-switch (#665): relay sessions on apps.tossmini.com (env 4, released ' +\n 'production) are silently blocked at both the in-app gate and this MCP layer — ' +\n 'relay-live and the LIVE guard have been removed. Only localhost/loopback (env 1), ' +\n '*.trycloudflare.com (env 2), and *.private-apps.tossmini.com (env 3) are allowed.\\n\\n' +\n 'modes:\\n' +\n ' local-browser — env 1: desktop Chromium with the mock SDK and a local CDP attach. ' +\n 'Side-effect tools (call_sdk/evaluate) run unguarded against the mock; nothing touches a ' +\n 'real device or real users. No prerequisites — the default, always-available environment ' +\n 'for state/contract and visual-layout work.\\n' +\n ' relay-sandbox — env 2: a real-device PWA (real WebKit engine, mock SDK) over an external ' +\n 'Chii relay. CDP covers real-device WebKit DOM, console, exceptions, and safe-area ' +\n 'observation; call_sdk still hits the mock (SDK fidelity needs relay-staging). ' +\n 'Side-effect tools run unguarded against the mock. ' +\n 'Only the dual-connection daemon can enter relay-sandbox in-place; a single-connection ' +\n 'session rejects it with \"동적 전환할 수 없습니다 … relay-sandbox 모드로 재시작하세요\" — ' +\n 'follow that hint and restart the MCP server in relay-sandbox mode rather than retrying. ' +\n 'Prerequisites: both AIT_RELAY_BASE_URL (the relay base the unplugin emits when started ' +\n 'with tunnel:{cdp:true}, used for the CDP attach) and AIT_TUNNEL_BASE_URL (the dev-server ' +\n 'tunnel host, required by start_attach to render the launcher QR) must be set before ' +\n 'the MCP server starts — the unplugin does not auto-forward either; set them explicitly. ' +\n 'Both carry relay/tunnel hosts (secret-class) — keep them out of logs.\\n' +\n ' relay-staging — env 3: a real-device Toss WebView dog-food build with the REAL SDK over the ' +\n 'intoss-private relay. The first environment where call_sdk exercises the genuine native ' +\n 'bridge. Side-effect tools run unguarded (dog-food, not released to real users). ' +\n 'Prerequisite: a dog-food candidate bundle built with `RELEASE_CHANNEL=dogfood ait build`, ' +\n 'then uploaded with `ait deploy` (add `--scheme-only` to print the resulting ' +\n 'intoss-private://…?_deploymentId=… deep-link); open that deep-link/QR on the device to ' +\n 'cold-load the bundle with the relay injected. Unlike env 2, env 3 is NOT a dev-server ' +\n 'tunnel — it is a deployed bundle reached via the intoss-private scheme, so `pnpm dev` ' +\n 'plays no part here.\\n\\n' +\n 'For a relay mode (relay-sandbox/relay-staging), also pass projectRoot — the ' +\n 'absolute mini-app project root — so the daemon can read the relay auth secret from ' +\n '<projectRoot>/.ait_relay (read-only; the daemon never mints it). Omit it for local-browser.',\n inputSchema: {\n type: 'object',\n properties: {\n mode: {\n type: 'string',\n enum: ['local-browser', 'relay-sandbox', 'relay-staging'],\n description:\n 'Target environment to switch to. relay-live (env 4) has been removed (#665) — use relay-staging (env 3) for dog-food debugging.',\n },\n projectRoot: {\n type: 'string',\n description:\n 'Absolute path to the mini-app project root (the directory containing its package.json and .ait_relay). ' +\n 'The daemon reads the relay auth secret from <projectRoot>/.ait_relay (read-only) when switching to a relay ' +\n \"environment (relay-staging/relay-sandbox). Pass this because the daemon's own cwd is fixed at launch and may not be \" +\n 'the project being debugged. Omit for mode=local-browser (no secret needed).',\n },\n },\n required: ['mode'],\n },\n // Tier C — always callable so the agent can enter any environment from any\n // starting environment (including a fresh, unattached session).\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'get_debug_status',\n description:\n 'Reports the current debug session state — which environment/mode is active, whether a page ' +\n 'is attached, and a full diagnostic snapshot — in one call. Use this any time to answer ' +\n '\"what mode am I in right now?\" or \"why is this not working?\" without chaining tools. ' +\n 'Fields: mcpVersion (MCP SDK version), ' +\n 'devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), ' +\n 'pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, ' +\n 'recentErrors (last N server-side errors, PII/secret redacted), ' +\n 'authRejects ({count, lastAt} — relay TOTP 401 rejections, secret-free; count > 0 with empty pages ' +\n 'means the phone reached the relay but its code was rejected), ' +\n 'environment (kind: mock|relay-dev|relay-mobile, env: mock|relay backward-compat, reason, ' +\n 'liveGuardActive: always false — relay-live and LIVE guard removed (#665); ' +\n 'start_debug mode→kind mapping: relay-sandbox→relay-mobile, relay-staging→relay-dev, ' +\n 'local-browser→mock), ' +\n 'serverLockHolder (pid + startedAt from the lock file, or null), ' +\n 'nextRecommendedAction ({tool, reason} or null — the single next tool to call; ' +\n 'in local-target mode tunnel.up=false is normal so \"restart\" is never recommended). ' +\n 'All fields are nullable — missing data is null, not an error. ' +\n 'debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. ' +\n 'Tier C (both mock and relay).',\n inputSchema: {\n type: 'object',\n properties: {\n recent_errors_limit: {\n type: 'number',\n description:\n 'Maximum number of recent server-side errors to include (default 10, max 50).',\n },\n },\n required: [],\n },\n availableIn: 'both' as ToolAvailability,\n },\n {\n name: 'run_tests',\n description:\n 'Runs mini-app test files on the attached page over CDP (Runtime.evaluate). ' +\n 'Each matched file is bundled with esbuild (SDK imports redirected to the live mock/SDK), ' +\n 'injected into the attached WebView, and executed; returns per-file results plus flattened ' +\n 'totals (passed/failed/skipped/total). ' +\n 'When there is no attached page and the current environment is relay (env 3), this tool ' +\n 'automatically shows the QR dashboard and waits for a phone to connect before running ' +\n '(scheme_url required for env 3 relay-dev). ' +\n 'Files run SEQUENTIALLY (single-attach model: the relay/local target ' +\n 'serves one page), and one run_tests call runs at a time (a concurrent call is rejected). ' +\n 'Test verification (assert/snapshot) is delegated to the in-page Vitest runtime; this tool is ' +\n 'the transport + report. The per-file results array is the progress record — on partial failure ' +\n 'you see exactly which files passed/failed/timed-out. ' +\n 'Positive-allowlist kill-switch (#665): blocked when the attached page is on a non-debug host. ' +\n 'debug-mode only — dev-mode (--mode=dev) has no CDP. ' +\n 'Tier C (both mock/local and relay).',\n inputSchema: {\n type: 'object',\n properties: {\n files: {\n type: 'array',\n items: { type: 'string' },\n description:\n 'Glob patterns or file paths to run (e.g. [\"src/**/*.ait.test.ts\"]). ' +\n 'Resolved relative to projectRoot when given, else the daemon cwd. Required, non-empty.',\n },\n projectRoot: {\n type: 'string',\n description:\n 'Absolute path to the mini-app project root used as the glob base. ' +\n \"Pass this because the daemon's cwd is fixed at launch. Optional.\",\n },\n timeout_ms: {\n type: 'number',\n description:\n 'Per-file evaluate timeout in ms (default 30000, range 1000–600000). ' +\n 'Out-of-range/invalid values fall back to the default.',\n },\n scheme_url: {\n type: 'string',\n description:\n 'intoss-private:// deep-link URL from `ait deploy --scheme-only` (env 3 relay-dev only). ' +\n 'Required when there is no attached page and the environment is relay-dev — ' +\n 'the tool uses it to build the QR attach URL and wait for the phone to connect. ' +\n 'Ignored when a page is already attached.',\n },\n cell: {\n type: 'object',\n description:\n 'Optional cell object to inject into globalThis BEFORE running tests ' +\n '(e.g. { \"__AIT_CELL__\": { \"sdkLine\": \"2.x\", \"platform\": \"ios\" } }). ' +\n 'Applied only on the auto-attach path (no-page → attach → inject → run). ' +\n 'When a page is already attached the caller is responsible for any prior injection. ' +\n 'Ignored when empty or absent. Values must be JSON-serialisable.',\n additionalProperties: true,\n },\n },\n required: ['files'],\n },\n availableIn: 'both' as ToolAvailability,\n },\n] as const;\n\nexport type DebugToolName = (typeof DEBUG_TOOL_DEFINITIONS)[number]['name'];\n\nconst DEBUG_TOOL_NAMES = new Set<string>(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));\n\nexport function isDebugToolName(name: string): name is DebugToolName {\n return DEBUG_TOOL_NAMES.has(name);\n}\n\n/**\n * Returns the `ToolAvailability` declared on a registered debug tool, or\n * `undefined` when the name is not a known debug tool. Used by the tool\n * registry to filter `tools/list` by current env and by the call handler to\n * reject env-mismatch invocations.\n */\nexport function getToolAvailability(name: string): ToolAvailability | undefined {\n for (const t of DEBUG_TOOL_DEFINITIONS) {\n if (t.name === name) return t.availableIn;\n }\n return undefined;\n}\n\n/**\n * Returns true when the named tool is available in the given environment.\n * Unknown tools return `false` — callers should reject them as unknown rather\n * than as env-mismatched.\n *\n * Relay variants (`relay-dev`, `relay-mobile`) all satisfy the\n * `'relay'` availability tier — `isRelayEnv()` is used for the check.\n * (`relay-live` removed #665.)\n */\nexport function isToolAvailableIn(name: string, env: McpEnvironment): boolean {\n const availability = getToolAvailability(name);\n if (availability === undefined) return false;\n if (availability === 'both') return true;\n if (availability === 'relay') return isRelayEnv(env);\n return availability === env;\n}\n\n/**\n * Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`\n * matches the given env. Pure — preserves order; both Tier C (\"both\") and the\n * matching single-env tier pass through.\n *\n * Relay variants (`relay-dev`, `relay-mobile`) all satisfy the\n * `'relay'` tier. (`relay-live` removed #665.)\n */\nexport function filterToolsByEnvironment<T extends { name: string; availableIn: ToolAvailability }>(\n tools: ReadonlyArray<T>,\n env: McpEnvironment,\n): T[] {\n return tools.filter(\n (t) =>\n t.availableIn === 'both' ||\n (t.availableIn === 'relay' && isRelayEnv(env)) ||\n t.availableIn === env,\n );\n}\n\n/**\n * Tool names that are available before any page attaches (bootstrap tier).\n *\n * `start_attach` — mode switch + QR synthesis + attach wait, no prior attach needed.\n * `list_pages` — reports tunnel status + empty pages even pre-attach.\n *\n * All other tools require an attached page (`enableDomains` must succeed) and\n * are only advertised in `tools/list` once a target appears.\n */\nexport const BOOTSTRAP_TOOL_NAMES: ReadonlySet<string> = new Set<string>([\n 'start_attach',\n 'get_debug_status',\n 'list_pages',\n // start_debug must be visible from the very first tools/list (before any\n // attach) so the agent can switch environments to bootstrap an attach.\n 'start_debug',\n]);\n\n/** Normalized console message returned by `list_console_messages`. */\nexport interface ConsoleMessage {\n level: string;\n text: string;\n timestamp: number;\n args: string[];\n}\n\n/** Normalized network request returned by `list_network_requests`. */\nexport interface NetworkRequest {\n requestId: string;\n url: string;\n method: string;\n /** HTTP status once a response was seen, else null (still in-flight). */\n status: number | null;\n statusText: string | null;\n /** Request start (CDP timestamp). */\n startTime: number;\n /** Response received (CDP timestamp), else null. */\n endTime: number | null;\n}\n\n/** Renders a CDP `RemoteObject` console arg to a stable display string. */\nfunction renderRemoteObject(arg: CdpRemoteObject): string {\n if (arg.value !== undefined) {\n if (typeof arg.value === 'string') return arg.value;\n try {\n return JSON.stringify(arg.value);\n } catch {\n return String(arg.value);\n }\n }\n if (arg.description !== undefined) return arg.description;\n if (arg.className !== undefined) return arg.className;\n return arg.subtype ?? arg.type;\n}\n\nexport function normalizeConsoleMessage(event: ConsoleApiCalledEvent): ConsoleMessage {\n const args = event.args.map(renderRemoteObject);\n return {\n level: event.type,\n text: args.join(' '),\n timestamp: event.timestamp,\n args,\n };\n}\n\nexport function listConsoleMessages(connection: CdpConnection): ConsoleMessage[] {\n return connection\n .getBufferedEvents('Runtime.consoleAPICalled')\n .map((event) => normalizeConsoleMessage(event));\n}\n\nexport function listNetworkRequests(connection: CdpConnection): NetworkRequest[] {\n const requests = connection.getBufferedEvents('Network.requestWillBeSent');\n const responses = connection.getBufferedEvents('Network.responseReceived');\n\n const responseByRequestId = new Map<string, NetworkResponseReceivedEvent>();\n for (const response of responses) {\n responseByRequestId.set(response.requestId, response);\n }\n\n return requests.map((request: NetworkRequestWillBeSentEvent) => {\n const response = responseByRequestId.get(request.requestId);\n return {\n requestId: request.requestId,\n url: request.request.url,\n method: request.request.method,\n status: response ? response.response.status : null,\n statusText: response ? response.response.statusText : null,\n startTime: request.timestamp,\n endTime: response ? response.timestamp : null,\n };\n });\n}\n\n/* -------------------------------------------------------------------------- */\n/* list_exceptions — Runtime.exceptionThrown ring buffer */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Normalized exception returned by `list_exceptions`.\n *\n * Flattens the CDP `Runtime.ExceptionDetails` shape into the most useful\n * fields. The `raw` field carries the original event for callers that need\n * the full payload.\n */\nexport interface BufferedException {\n /** Wall-clock ms since epoch (CDP `Runtime.Timestamp`). */\n timestamp: number;\n /** Short summary text from `exceptionDetails.text`. */\n text: string;\n /** Source URL where the exception was thrown, if known. */\n url?: string;\n /** 0-based line number in the source file, if known. */\n lineNumber?: number;\n /** 0-based column number in the source file, if known. */\n columnNumber?: number;\n /** `description` of the thrown `RemoteObject` (e.g. \"TypeError: …\"). */\n exceptionText?: string;\n /**\n * Formatted stack trace: `at fn (url:line:col)` lines joined by `\\n`.\n * Omitted when no `stackTrace.callFrames` are available.\n */\n stack?: string;\n /** Full original `Runtime.exceptionThrown` event payload. */\n raw: RuntimeExceptionThrownEvent;\n}\n\n/** Formats a single CDP call frame into `at fn (url:line:col)`. */\nfunction formatCallFrame(frame: CdpCallFrame): string {\n const fn = frame.functionName || '(anonymous)';\n return `at ${fn} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`;\n}\n\n/** Normalizes a raw `Runtime.exceptionThrown` event into a `BufferedException`. */\nexport function normalizeException(event: RuntimeExceptionThrownEvent): BufferedException {\n const { timestamp, exceptionDetails } = event;\n const frames = exceptionDetails.stackTrace?.callFrames;\n const stack = frames && frames.length > 0 ? frames.map(formatCallFrame).join('\\n') : undefined;\n const exceptionText = exceptionDetails.exception?.description ?? undefined;\n\n const result: BufferedException = {\n timestamp,\n text: exceptionDetails.text,\n raw: event,\n };\n if (exceptionDetails.url !== undefined) result.url = exceptionDetails.url;\n if (exceptionDetails.lineNumber !== undefined) result.lineNumber = exceptionDetails.lineNumber;\n if (exceptionDetails.columnNumber !== undefined)\n result.columnNumber = exceptionDetails.columnNumber;\n if (exceptionText !== undefined) result.exceptionText = exceptionText;\n if (stack !== undefined) result.stack = stack;\n return result;\n}\n\n/**\n * Returns the most recent buffered `Runtime.exceptionThrown` events, normalized.\n * Oldest-first; limited to `limit` entries (default 50, max 50).\n */\nexport function listExceptions(connection: CdpConnection, limit = 50): BufferedException[] {\n const cap = Math.min(Math.max(1, limit), 50);\n const events = connection.getBufferedEvents('Runtime.exceptionThrown');\n // Slice from the tail to respect the cap while preserving oldest-first order.\n const sliced = events.length > cap ? events.slice(events.length - cap) : events;\n return sliced.map((e) => normalizeException(e));\n}\n\n/** A page entry in the `list_pages` result, extended with freshness info. */\nexport interface ListPagesEntry {\n id: string;\n title: string;\n url: string;\n /** ISO timestamp of the last inbound CDP message from this target, or null. */\n lastSeenAt: string | null;\n}\n\n/** Result of `list_pages`: attach status + tunnel state + crash info. */\nexport interface ListPagesResult {\n /**\n * The single active page, or an empty array when nothing is attached.\n * Under the single-attach model this is always 0 or 1 entries.\n */\n pages: ListPagesEntry[];\n tunnel: TunnelStatus;\n /**\n * ISO timestamp of the most recent crash / targetDestroyed / detachedFromTarget\n * event detected since the last `enableDomains()`, or `null` if none.\n * When non-null, all attached pages have been removed from the relay map and\n * a new `enableDomains()` call is required to resume debugging.\n */\n crashDetectedAt: string | null;\n /** Korean warning line shown in tool output when a crash was detected. */\n crashWarning: string | null;\n /**\n * Always `true` — signals to the agent that at most one page is ever present.\n * When a second page attaches, the previous one is evicted (last-attach wins).\n */\n singleAttachModel: true;\n}\n\n/**\n * Duck-type interface for the crash-detection extras exposed by `ChiiCdpConnection`.\n * The base `CdpConnection` interface is kept minimal (fake-friendly); the extras\n * are opt-in so tests without them continue to compile.\n */\ninterface CrashAwareCdpConnection extends CdpConnection {\n getLastCrashDetectedAt(): number | null;\n getTargetLastSeenAt(targetId: string): number | null;\n}\n\nfunction isCrashAware(conn: CdpConnection): conn is CrashAwareCdpConnection {\n return (\n typeof (conn as CrashAwareCdpConnection).getLastCrashDetectedAt === 'function' &&\n typeof (conn as CrashAwareCdpConnection).getTargetLastSeenAt === 'function'\n );\n}\n\nexport function listPages(connection: CdpConnection, tunnel: TunnelStatus): ListPagesResult {\n const rawTargets = connection.listTargets();\n const pages: ListPagesEntry[] = rawTargets.map((t) => {\n const lastSeenMs = isCrashAware(connection) ? connection.getTargetLastSeenAt(t.id) : null;\n return {\n id: t.id,\n title: t.title,\n // SECRET-HANDLING: a relay-attach page url can carry the one-time TOTP\n // `at=<code>` (#real-phone repro, #668). Redact it for display only —\n // attach drives off targetId, never this url. relay/_deploymentId/debug kept.\n url: redactAtParam(t.url),\n lastSeenAt: lastSeenMs !== null ? new Date(lastSeenMs).toISOString() : null,\n };\n });\n\n const crashMs = isCrashAware(connection) ? connection.getLastCrashDetectedAt() : null;\n const crashDetectedAt = crashMs !== null ? new Date(crashMs).toISOString() : null;\n const crashWarning = crashDetectedAt\n ? `[ait-debug] page crash 감지됨 — 새 attach 필요 (관측 시각: ${crashDetectedAt})`\n : null;\n\n return { pages, tunnel, crashDetectedAt, crashWarning, singleAttachModel: true };\n}\n\n/** A `buildAttachUrl()` result: the spliced deep-link the phone should open. */\nexport interface BuildAttachUrlResult {\n /** The scheme URL with `debug=1&relay=<wss>[&at=<totp-code>]` spliced in. */\n attachUrl: string;\n /** The relay URL that was spliced in (this session's quick tunnel). */\n relayUrl: string;\n /**\n * Non-fatal warning about the scheme URL's authority being missing or\n * suspicious (e.g. \"web\", \"localhost\"). Callers should surface this to\n * help the user catch a malformed URL early.\n */\n authorityWarning?: string;\n /**\n * TOTP metadata — present when `AIT_DEBUG_TOTP_SECRET` is set.\n *\n * SECRET-HANDLING: the `at=` code value is spliced into `attachUrl` only.\n * It is never surfaced separately here to avoid inadvertent logging of the\n * one-time code outside of the URL.\n */\n totp?: {\n /** `true` when a TOTP code was spliced into `attachUrl`. */\n enabled: true;\n /** RFC 6238 step duration in seconds. */\n ttlSeconds: number;\n /** ISO timestamp when the current step expires. start_attach auto re-mints before this elapses. */\n expiresAt: string;\n };\n}\n\n/**\n * Builds a self-attaching dog-food deep-link from an `ait deploy --scheme-only`\n * URL plus this session's live relay. Throws if the tunnel is not up yet (no\n * relay URL to splice in) — the caller surfaces that as a tool error.\n *\n * When `AIT_DEBUG_TOTP_SECRET` is set, generates the current TOTP code and\n * splices it as `at=<code>` into the attach URL. The code is valid for ~3\n * minutes (the relay gate uses {@link RELAY_VERIFY_SKEW_STEPS}=6, accepting\n * past 6 steps = 180–210 s backwards from issuance). The start_attach handler\n * auto re-mints a fresh code before `totp.expiresAt` elapses during its wait (#490).\n *\n * Also validates the scheme URL's authority. A suspicious authority (empty,\n * \"web\", \"localhost\", etc.) is surfaced as a non-fatal `authorityWarning` on\n * the result so the caller can show a helpful hint without blocking the link\n * generation (the warning is consistent with how other validation in\n * `buildDeepLinkAttachUrl` works — hard errors for relay, soft warning for\n * the scheme authority which is in the caller's input, not ours to own).\n *\n * SECRET-HANDLING: `totpSecret` (if provided) is used only to compute a code\n * and must never appear in any log, error message, or output outside of the\n * spliced `at=` param in `attachUrl`.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL.\n * @param tunnel - Current tunnel status from the running debug server.\n * @param totpSecret - Optional hex-encoded TOTP secret (from\n * `AIT_DEBUG_TOTP_SECRET`). When provided, the current code is spliced into\n * the attach URL as `at=<code>`.\n */\nexport function buildAttachUrl(\n schemeUrl: string,\n tunnel: TunnelStatus,\n totpSecret?: string,\n): BuildAttachUrlResult {\n if (!tunnel.up || tunnel.wssUrl === null) {\n throw new Error(\n 'tunnel-down: cloudflared 터널이 안 떠 있습니다. ' +\n 'MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.',\n );\n }\n const authorityWarning = validateSchemeAuthority(schemeUrl) ?? undefined;\n\n // Generate a live TOTP code when a secret is provided.\n // SECRET-HANDLING: the code value is placed into attachUrl only — not logged.\n let totpCode: string | undefined;\n let totpMeta: BuildAttachUrlResult['totp'];\n if (totpSecret !== undefined && totpSecret !== '') {\n const now = Date.now();\n totpCode = generateTotp(totpSecret, now);\n const STEP_SECONDS = 30;\n // expiresAt reflects the relay gate's actual acceptance window (#490):\n // the gate uses RELAY_VERIFY_SKEW_STEPS=6, so past 6 steps (180 s) are\n // accepted. The code issued NOW is valid until step (currentStep + 7)\n // starts — i.e. the earliest time it can be rejected is 180 s after\n // the NEXT step boundary, which is (currentStep+1)*30 + 6*30 = now-aligned\n // ~180–210 s from issuance. We report issuanceTime + 180 s as a conservative\n // lower bound so callers know the code is safe for at least ~3 minutes.\n const expiresAtMs = now + RELAY_VERIFY_SKEW_STEPS * STEP_SECONDS * 1000;\n totpMeta = {\n enabled: true,\n ttlSeconds: RELAY_VERIFY_SKEW_STEPS * STEP_SECONDS,\n expiresAt: new Date(expiresAtMs).toISOString(),\n };\n }\n\n return {\n attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl, totpCode),\n relayUrl: tunnel.wssUrl,\n ...(authorityWarning !== undefined ? { authorityWarning } : {}),\n ...(totpMeta !== undefined ? { totp: totpMeta } : {}),\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* QR PNG rendering + browser open */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Heuristic: can this process open a GUI browser?\n *\n * Returns `true` when we think a GUI is available:\n * - On macOS (`darwin`) we assume yes (MCP normally runs on the user's Mac).\n * - On Linux we check for `DISPLAY` or `WAYLAND_DISPLAY`.\n * - On Windows we assume yes.\n * - In a CI environment (`CI=true`) we assume no.\n */\nexport function canOpenBrowser(): boolean {\n if (process.env.CI === 'true' || process.env.CI === '1') return false;\n const platform = process.platform;\n if (platform === 'darwin' || platform === 'win32') return true;\n if (platform === 'linux') {\n return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);\n }\n return false;\n}\n\n/**\n * Result of `openQrInBrowser`.\n *\n * HTTP URL 기반으로 재구현 — tmp 파일 없음. `httpUrl`이 브라우저에 전달되는 URL이다.\n * SECRET-HANDLING: `httpUrl`은 127.0.0.1 로컬 전용이며 tunnel host·relay wss·TOTP at= 코드를\n * 담지 않는다 (#595). attachUrl은 server-state(getDashboardState)로만 보유된다.\n */\nexport interface OpenQrInBrowserResult {\n /** `true` if the browser was successfully opened. */\n opened: boolean;\n /** `http://127.0.0.1:<port>/` — 브라우저에 전달되는 루트 URL (#595). */\n httpUrl: string;\n /** `http://127.0.0.1:<port>/qr.png?u=...` — PNG fallback URL. */\n pngUrl: string;\n /** Error message if `opened` is false (browser spawn failed). */\n error?: string;\n /** Captured stderr from failed spawn attempts (at= 값은 redact됨). */\n stderrSummary?: string;\n /**\n * `true` when the first attempt failed but a retry succeeded.\n * Helps distinguish \"worked on first try\" from \"needed retry\" in diagnostics.\n */\n retried?: boolean;\n}\n\n/** platform별 browser open 명령 후보 목록 — 앞에서부터 순차 시도. */\nfunction getBrowserCandidates(httpUrl: string): Array<{ cmd: string; args: string[] }> {\n const platform = process.platform;\n if (platform === 'darwin') {\n return [\n { cmd: 'open', args: [httpUrl] },\n { cmd: 'open', args: ['-a', 'Safari', httpUrl] },\n { cmd: 'open', args: ['-a', 'Google Chrome', httpUrl] },\n { cmd: 'open', args: ['-a', 'Firefox', httpUrl] },\n ];\n }\n if (platform === 'win32') {\n return [\n { cmd: 'cmd', args: ['/c', 'start', '', httpUrl] },\n { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', httpUrl] },\n ];\n }\n // linux + fallback\n return [\n { cmd: 'xdg-open', args: [httpUrl] },\n { cmd: 'sensible-browser', args: [httpUrl] },\n { cmd: 'x-www-browser', args: [httpUrl] },\n { cmd: 'firefox', args: [httpUrl] },\n { cmd: 'google-chrome', args: [httpUrl] },\n { cmd: 'chromium', args: [httpUrl] },\n ];\n}\n\n/**\n * Redacts ONLY the `at=<value>` (TOTP) query param to `at=<redacted>`, leaving\n * every other query param (_deploymentId, debug, relay) intact. SECRET-HANDLING:\n * the TOTP code is the single short-lived secret carried in a CDP page url.\n */\nexport function redactAtParam(text: string): string {\n return text.replace(/\\bat=([^&\\s\"']+)/g, 'at=<redacted>');\n}\n\n/** stderr에서 at= TOTP 코드 값을 redact한다. */\nfunction redactSecrets(text: string): string {\n return redactAtParam(text);\n}\n\n/** spawnSync exit 0이어도 stderr에 launch 실패 시그널이 있으면 실패로 판단한다. */\nconst LAUNCH_FAILURE_PATTERNS = [\n /LSOpenURLsWithRole\\(\\) failed/,\n /kLSApplicationNotFoundErr/,\n /No application/,\n /Unable to find application/,\n /xdg-open: not found/,\n /command not found/,\n];\n\nfunction isLaunchFailureStderr(stderr: string): boolean {\n return LAUNCH_FAILURE_PATTERNS.some((p) => p.test(stderr));\n}\n\n/**\n * 로컬 HTTP 서버 루트 URL(`http://127.0.0.1:<port>/`)을 OS 기본 브라우저로 연다 (#595).\n *\n * platform별 fallback chain으로 시도하며, 모두 실패하면 1회 retry를 수행한다\n * (ephemeral process launch 타이밍 문제 대응). retry까지 실패해도 `opened: false` +\n * `httpUrl`을 반환해 사용자가 직접 브라우저에 붙여넣을 수 있게 한다.\n *\n * SECRET-HANDLING:\n * - tmp 파일을 만들지 않는다 (HTML/PNG는 HTTP 서버가 메모리에서 응답).\n * - httpUrl은 `http://127.0.0.1:<port>/`(루트, 시크릿 없음). pngUrl은 127.0.0.1 로컬 전용.\n * - stderr 캡처 결과에서 at= 코드 값을 redact한 후 stderrSummary에 포함.\n * - attachUrl, deploymentId, TOTP 코드를 stdout/stderr/로그에 직접 출력 금지.\n *\n * @param httpUrl - `http://127.0.0.1:<port>/` 루트 URL (시크릿 없음, #595).\n * @param pngUrl - `http://127.0.0.1:<port>/qr.png?u=<encoded>` PNG fallback URL.\n */\nexport async function openQrInBrowser(\n httpUrl: string,\n pngUrl: string,\n): Promise<OpenQrInBrowserResult> {\n const { spawnSync } = await import('node:child_process');\n\n /**\n * 한 번의 fallback chain 시도. 성공하면 열린 후보 cmd를 반환, 실패하면 null.\n * stderrLines에 각 후보의 stderr를 누적한다.\n */\n function tryOnce(stderrLines: string[]): boolean {\n const candidates = getBrowserCandidates(httpUrl);\n for (const { cmd, args } of candidates) {\n const result = spawnSync(cmd, args, { encoding: 'utf8', timeout: 5000 });\n\n if (result.error) {\n stderrLines.push(`${cmd}: ${result.error.message}`);\n continue;\n }\n\n const stderr = typeof result.stderr === 'string' ? result.stderr : '';\n if (stderr) {\n stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);\n }\n\n if (result.status === 0 && !isLaunchFailureStderr(stderr)) {\n return true;\n }\n }\n return false;\n }\n\n const stderrLines: string[] = [];\n\n // 1차 시도\n if (tryOnce(stderrLines)) {\n return { opened: true, httpUrl, pngUrl };\n }\n\n // 1회 retry (ephemeral process launch 타이밍 문제 대응)\n if (tryOnce(stderrLines)) {\n return { opened: true, httpUrl, pngUrl, retried: true };\n }\n\n const stderrSummary = stderrLines.length > 0 ? stderrLines.join('\\n') : undefined;\n return {\n opened: false,\n httpUrl,\n pngUrl,\n error: '모든 브라우저 실행 후보가 실패했습니다.',\n stderrSummary,\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Phase 2 — DOM / snapshot / screenshot (CDP commands) */\n/* -------------------------------------------------------------------------- */\n\n/** Returns the DOM tree of the attached page (`DOM.getDocument`). */\nexport function getDomDocument(connection: CdpConnection): Promise<DomGetDocumentResult> {\n // `pierce: true` flattens shadow roots; depth -1 returns the whole subtree so\n // a single call yields the full tree for structural diagnosis.\n return connection.send('DOM.getDocument', { depth: -1, pierce: true });\n}\n\n/** Returns a serialized page snapshot (`DOMSnapshot.captureSnapshot`). */\nexport function takeSnapshot(connection: CdpConnection): Promise<DomSnapshotResult> {\n return connection.send('DOMSnapshot.captureSnapshot', {});\n}\n\n/** A `take_screenshot` result: the raw base64 PNG plus a ready-to-use data URI. */\nexport interface ScreenshotResult {\n /** Base64-encoded PNG bytes (no data-URI prefix). */\n data: string;\n /** `data:image/png;base64,…` form for clients that render a URI. */\n dataUri: string;\n mimeType: 'image/png';\n}\n\n/** Captures a PNG screenshot of the attached page (`Page.captureScreenshot`). */\nexport async function takeScreenshot(connection: CdpConnection): Promise<ScreenshotResult> {\n const { data } = await connection.send('Page.captureScreenshot', { format: 'png' });\n return { data, dataUri: `data:image/png;base64,${data}`, mimeType: 'image/png' };\n}\n\n/* -------------------------------------------------------------------------- */\n/* measure_safe_area — Runtime.evaluate probe */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The JS probe injected via `Runtime.evaluate`. It reads:\n * 1. `env(safe-area-inset-*)` via a temporary element with padding set to\n * those CSS env vars, then `getComputedStyle`.\n * 2. SDK insets via a priority chain so the SAME probe works on both relay\n * (real device) and mock (devtools panel page):\n * a. `window.__sdk.SafeAreaInsets.get()` — dog-food bundle on real device.\n * b. `window.__sdk.getSafeAreaInsets()` — dog-food bundle (deprecated).\n * c. `window.__ait.state.safeAreaInsets` — devtools mock state (mock env).\n * The probe records `sdkInsetsSource` = `'window.__sdk'` | `'window.__ait'`\n * | `null`. If all paths fail the result carries `sdkInsetsError`.\n * 3. nav bar geometry: the SDK does not expose navBar height as a standalone\n * API — `.ait-navbar` DOM height is read as a cross-check, and\n * `navBarHeightSource` records where it came from.\n * 4. `innerWidth`, `innerHeight`, `devicePixelRatio`, `navigator.userAgent`.\n *\n * Returns a plain JSON-serialisable object so `returnByValue: true` works.\n *\n * NOTE: This expression is evaluated in the page context — on the real device\n * (relay) or on the mock panel page. It does not mutate any page state — the\n * temporary element is removed after reading. No secret or auth token is read\n * or returned.\n *\n * RFC #277 Tier C parity: the SAME probe string runs in both envs. Mock fidelity\n * comes from the panel's `applyViewport` / `computeSafeAreaInsets` correctly\n * setting `window.__ait.state.safeAreaInsets` (#275). When that is correct,\n * the cssEnv + sdkInsets pair returned here matches the relay's shape.\n */\nexport const SAFE_AREA_PROBE_EXPRESSION = `\n(function() {\n var el = document.createElement('div');\n el.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;visibility:hidden;' +\n 'padding-top:env(safe-area-inset-top,0px);' +\n 'padding-right:env(safe-area-inset-right,0px);' +\n 'padding-bottom:env(safe-area-inset-bottom,0px);' +\n 'padding-left:env(safe-area-inset-left,0px)';\n document.documentElement.appendChild(el);\n var cs = window.getComputedStyle(el);\n var cssEnv = {\n top: parseFloat(cs.paddingTop) || 0,\n right: parseFloat(cs.paddingRight) || 0,\n bottom: parseFloat(cs.paddingBottom) || 0,\n left: parseFloat(cs.paddingLeft) || 0\n };\n document.documentElement.removeChild(el);\n var sdkInsets = null;\n var sdkInsetsSource = null;\n var sdkInsetsError = undefined;\n try {\n var sdk = window.__sdk;\n var ait = window.__ait;\n if (sdk && sdk.SafeAreaInsets && typeof sdk.SafeAreaInsets.get === 'function') {\n sdkInsets = sdk.SafeAreaInsets.get();\n sdkInsetsSource = 'window.__sdk';\n } else if (sdk && typeof sdk.getSafeAreaInsets === 'function') {\n sdkInsets = sdk.getSafeAreaInsets();\n sdkInsetsSource = 'window.__sdk';\n } else if (ait && ait.state && ait.state.safeAreaInsets &&\n typeof ait.state.safeAreaInsets.top === 'number') {\n var s = ait.state.safeAreaInsets;\n sdkInsets = { top: s.top, bottom: s.bottom, left: s.left, right: s.right };\n sdkInsetsSource = 'window.__ait';\n } else if (!sdk && !ait) {\n sdkInsetsError = 'neither window.__sdk (relay) nor window.__ait (mock) available';\n } else if (sdk) {\n sdkInsetsError = 'neither SafeAreaInsets.get nor getSafeAreaInsets found on window.__sdk';\n } else {\n sdkInsetsError = 'window.__ait.state.safeAreaInsets is missing or malformed';\n }\n } catch(e) {\n sdkInsetsError = String(e && e.message || e);\n }\n var navBarHeight = null;\n var navBarHeightSource = 'not-exposed-by-sdk';\n try {\n var nb = document.querySelector('.ait-navbar');\n if (nb) {\n navBarHeight = nb.getBoundingClientRect().height;\n navBarHeightSource = 'dom-.ait-navbar';\n }\n } catch(_) {}\n var result = {\n cssEnv: cssEnv,\n sdkInsets: sdkInsets,\n sdkInsetsSource: sdkInsetsSource,\n navBarHeight: navBarHeight,\n navBarHeightSource: navBarHeightSource,\n innerWidth: window.innerWidth,\n innerHeight: window.innerHeight,\n devicePixelRatio: window.devicePixelRatio,\n userAgent: navigator.userAgent\n };\n if (sdkInsetsError !== undefined) result.sdkInsetsError = sdkInsetsError;\n return JSON.stringify(result);\n})()\n`.trim();\n\n/**\n * Where the SDK insets came from. `null` when the lookup failed (in which case\n * `sdkInsetsError` is populated).\n *\n * - `'window.__sdk'` — real-device dog-food bundle (relay env).\n * - `'window.__ait'` — devtools mock state (mock env).\n * - `null` — both paths absent or threw.\n */\nexport type SdkInsetsSource = 'window.__sdk' | 'window.__ait' | null;\n\n/**\n * Normalized result returned by `measure_safe_area`.\n *\n * All inset values are in CSS pixels as reported by the page context.\n * `userAgent` is included for device identification; it never contains\n * authentication secrets or session tokens.\n */\nexport interface SafeAreaMeasurement {\n /**\n * MCP environment this measurement was taken in:\n * - `'mock'` — dev browser panel\n * - `'relay-dev'` — real-device WebView, dog-food build\n * - `'relay-mobile'` — real-device PWA (env 2) over an external relay\n * (`relay-live` / env 4 removed #665.)\n *\n * Set by the caller (`measureSafeArea`) from the env detection SSoT\n * (`getEnvironment`).\n */\n source: McpEnvironment;\n /**\n * `env(safe-area-inset-*)` values read via `getComputedStyle` on the page.\n * On iOS inside the Toss host WebView this is typically all-zero because the\n * WebView viewport is placed below the physical notch by the host app.\n */\n cssEnv: { top: number; right: number; bottom: number; left: number };\n /**\n * SDK insets from one of three paths (in priority order):\n * - `window.__sdk.SafeAreaInsets.get()` (relay, dog-food bundle)\n * - `window.__sdk.getSafeAreaInsets()` (relay, deprecated)\n * - `window.__ait.state.safeAreaInsets` (mock, devtools panel state)\n *\n * `null` when all paths fail — see `sdkInsetsError` for the reason.\n * In the Toss host WebView `top` is the nav bar height and `bottom` is the\n * home-indicator height.\n */\n sdkInsets: { top: number; right: number; bottom: number; left: number } | null;\n /**\n * Which path resolved `sdkInsets` — useful for diagnosis of fidelity gaps\n * between mock and relay. `null` when `sdkInsets` is `null`.\n */\n sdkInsetsSource: SdkInsetsSource;\n /**\n * Populated when the SDK inset lookup failed (all paths absent or threw).\n * `undefined` when `sdkInsets` is non-null (i.e. the lookup succeeded).\n *\n * Example values:\n * - `\"neither window.__sdk (relay) nor window.__ait (mock) available\"`\n * - `\"neither SafeAreaInsets.get nor getSafeAreaInsets found on window.__sdk\"`\n * - `\"window.__ait.state.safeAreaInsets is missing or malformed\"`\n * - `\"TypeError: ...\"`\n */\n sdkInsetsError?: string;\n /**\n * Height of the `.ait-navbar` element (px) if present, else `null`.\n * The SDK does not expose navBar height as a standalone API; this DOM\n * measurement is used to cross-validate `sdkInsets.top`.\n */\n navBarHeight: number | null;\n /**\n * Describes where `navBarHeight` came from:\n * - `\"dom-.ait-navbar\"` — read from the `.ait-navbar` element's bounding rect.\n * - `\"not-exposed-by-sdk\"` — the SDK has no standalone navBar height API and\n * no `.ait-navbar` element was found in the DOM.\n */\n navBarHeightSource: string;\n /** CSS viewport width (`window.innerWidth`). */\n innerWidth: number;\n /** CSS viewport height (`window.innerHeight`). */\n innerHeight: number;\n /**\n * Device pixel ratio (`window.devicePixelRatio`).\n * Note: `window.devicePixelRatio` is read-only in the browser, so devtools\n * cannot emulate DPR locally — this is the ground-truth value from the device.\n */\n devicePixelRatio: number;\n /**\n * `navigator.userAgent` string for device identification.\n * Does not contain authentication secrets.\n */\n userAgent: string;\n}\n\n/**\n * Parses a raw `Runtime.evaluate` result value into a `SafeAreaMeasurement`.\n * The probe returns a JSON string (because `returnByValue:true` with a plain\n * object works unreliably across Chii relay versions — stringifying is safer).\n *\n * `source` is supplied by the caller (`measureSafeArea`) from the env SSoT.\n *\n * Throws if the result is missing, contains an exception, or cannot be parsed.\n */\nexport function normalizeSafeAreaResult(\n rawValue: unknown,\n source: McpEnvironment,\n): SafeAreaMeasurement {\n if (typeof rawValue !== 'string') {\n throw new Error(\n `measure_safe_area: probe returned unexpected type \"${typeof rawValue}\" — expected JSON string`,\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(rawValue);\n } catch {\n throw new Error(`measure_safe_area: probe returned non-JSON string: ${rawValue}`);\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw new Error('measure_safe_area: parsed result is not an object');\n }\n const obj = parsed as Record<string, unknown>;\n\n function requireInsets(\n key: string,\n ): { top: number; right: number; bottom: number; left: number } | null {\n const v = obj[key];\n if (v === null || v === undefined) return null;\n if (typeof v !== 'object') return null;\n const r = v as Record<string, unknown>;\n return {\n top: typeof r.top === 'number' ? r.top : 0,\n right: typeof r.right === 'number' ? r.right : 0,\n bottom: typeof r.bottom === 'number' ? r.bottom : 0,\n left: typeof r.left === 'number' ? r.left : 0,\n };\n }\n\n const cssEnv = requireInsets('cssEnv') ?? { top: 0, right: 0, bottom: 0, left: 0 };\n const sdkInsets = requireInsets('sdkInsets');\n const sdkInsetsSource: SdkInsetsSource =\n obj.sdkInsetsSource === 'window.__sdk' || obj.sdkInsetsSource === 'window.__ait'\n ? obj.sdkInsetsSource\n : null;\n const sdkInsetsError = typeof obj.sdkInsetsError === 'string' ? obj.sdkInsetsError : undefined;\n const navBarHeight = typeof obj.navBarHeight === 'number' ? obj.navBarHeight : null;\n const navBarHeightSource =\n typeof obj.navBarHeightSource === 'string' ? obj.navBarHeightSource : 'not-exposed-by-sdk';\n const innerWidth = typeof obj.innerWidth === 'number' ? obj.innerWidth : 0;\n const innerHeight = typeof obj.innerHeight === 'number' ? obj.innerHeight : 0;\n const devicePixelRatio = typeof obj.devicePixelRatio === 'number' ? obj.devicePixelRatio : 1;\n const userAgent = typeof obj.userAgent === 'string' ? obj.userAgent : '';\n\n return {\n source,\n cssEnv,\n sdkInsets,\n sdkInsetsSource,\n ...(sdkInsetsError !== undefined ? { sdkInsetsError } : {}),\n navBarHeight,\n navBarHeightSource,\n innerWidth,\n innerHeight,\n devicePixelRatio,\n userAgent,\n };\n}\n\n/**\n * Runs the safe-area probe on the attached page and returns a normalized\n * `SafeAreaMeasurement`. Read-only — does not mutate page state.\n *\n * `source` is supplied by the caller from the env detection SSoT (see\n * `src/mcp/environment.ts`). The same `Runtime.evaluate` call runs in both\n * envs — the probe expression tries `window.__sdk` first (relay) then\n * `window.__ait` (mock), so mock fidelity is enforced by the panel's\n * `applyViewport`/`computeSafeAreaInsets` keeping `__ait.state.safeAreaInsets`\n * correct (RFC #277 Tier C parity, #275 model).\n *\n * Throws on CDP error, probe exception, or result parse failure.\n */\nexport async function measureSafeArea(\n connection: CdpConnection,\n source: McpEnvironment,\n): Promise<SafeAreaMeasurement> {\n const result = await connection.send('Runtime.evaluate', {\n expression: SAFE_AREA_PROBE_EXPRESSION,\n returnByValue: true,\n awaitPromise: false,\n });\n if (result.exceptionDetails) {\n const msg =\n result.exceptionDetails.exception?.description ??\n result.exceptionDetails.text ??\n 'Runtime.evaluate threw an exception';\n throw new Error(`measure_safe_area: probe threw — ${msg}`);\n }\n return normalizeSafeAreaResult(result.result.value, source);\n}\n\n/* -------------------------------------------------------------------------- */\n/* evaluate — arbitrary JS via Runtime.evaluate */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Result returned by the `evaluate` tool.\n *\n * `value` holds the `returnByValue` result from CDP — it may be any\n * JSON-serialisable type. Treat it as opaque for logging purposes (it could\n * carry sensitive data from the page context).\n *\n * SECRET-HANDLING: do NOT write `value` to any log or stderr — return it to\n * the agent via the tool result only.\n */\nexport interface EvaluateResult {\n /** The evaluated result value (`returnByValue: true`). */\n value: unknown;\n /** CDP type string of the result (e.g. \"string\", \"number\", \"object\"). */\n type: string;\n}\n\n/**\n * Evaluates an arbitrary JS expression on the attached page via\n * `Runtime.evaluate`. NOT read-only — the expression may have side effects.\n *\n * Throws if the evaluation produced a CDP exception.\n *\n * SECRET-HANDLING: expression and result value are NOT written to any log.\n */\nexport async function evaluate(\n connection: CdpConnection,\n expression: string,\n): Promise<EvaluateResult> {\n const result = await connection.send('Runtime.evaluate', {\n expression,\n returnByValue: true,\n awaitPromise: false,\n });\n if (result.exceptionDetails) {\n // Surface only the engine error string — never the expression or result value.\n const msg =\n result.exceptionDetails.exception?.description ??\n result.exceptionDetails.text ??\n 'Runtime.evaluate threw an exception';\n throw new Error(`evaluate failed: ${msg}`);\n }\n return { value: result.result.value, type: result.result.type };\n}\n\n/* -------------------------------------------------------------------------- */\n/* call_sdk — window.__sdkCall bridge via Runtime.evaluate */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Result returned by the `call_sdk` tool.\n * The bridge call wraps success/failure in a JSON envelope so cross-Chii\n * stringification is reliable (same approach as `measure_safe_area`).\n *\n * `recentException` is populated when a `Runtime.exceptionThrown` event was\n * observed within the heuristic triage window [callStart-50ms, callEnd+200ms].\n * This helps correlate an SDK throw with the bridge result, especially when\n * the SDK throws synchronously before the promise resolves.\n */\nexport type CallSdkResult =\n | { ok: true; value: unknown; recentException?: BufferedException }\n | { ok: false; error: string; recentException?: BufferedException };\n\n/**\n * Builds the Runtime.evaluate expression that calls `window.__sdkCall` with\n * the given method name and args, awaits the promise, and returns a JSON\n * envelope `{ok, value/error}` as a string.\n *\n * Name and args are embedded via `JSON.stringify` so they are safely escaped.\n * The expression checks for `window.__sdkCall` and returns a clear error if\n * it is absent (non-dog-food bundle).\n *\n * SECRET-HANDLING: the expression is built here and MUST NOT be written to\n * any log or stderr by the caller.\n */\nexport function buildCallSdkExpression(name: string, args: unknown[]): string {\n const safeName = JSON.stringify(name);\n const safeArgs = JSON.stringify(args);\n return (\n `(async () => {` +\n ` if (typeof window.__sdkCall !== 'function') {` +\n ` return JSON.stringify({ok:false,error:'sdk-absent: window.__sdkCall이 주입되지 않았습니다 (dog-food 빌드가 아닙니다). dog-food 채널로 재배포하세요.'});` +\n ` }` +\n ` try {` +\n ` const r = await window.__sdkCall(${safeName}, ...${safeArgs});` +\n ` return JSON.stringify({ok:true,value:r});` +\n ` } catch(e) {` +\n ` return JSON.stringify({ok:false,error:String(e && e.message || e)});` +\n ` }` +\n `})()`\n );\n}\n\n/**\n * Parses the JSON envelope string returned by the `call_sdk` expression.\n * Returns a typed `CallSdkResult`.\n *\n * Throws only on parse failure (not on ok:false — that is a normal result).\n */\nexport function normalizeCallSdkResult(rawValue: unknown): CallSdkResult {\n if (typeof rawValue !== 'string') {\n throw new Error(\n `call_sdk: bridge returned unexpected type \"${typeof rawValue}\" — expected JSON string`,\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(rawValue);\n } catch {\n // Do NOT include rawValue in the error message — it could contain secrets.\n throw new Error('call_sdk: bridge returned non-JSON string');\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw new Error('call_sdk: parsed result is not an object');\n }\n const obj = parsed as Record<string, unknown>;\n if (obj.ok === true) {\n return { ok: true, value: obj.value };\n }\n if (obj.ok === false) {\n return { ok: false, error: typeof obj.error === 'string' ? obj.error : String(obj.error) };\n }\n throw new Error('call_sdk: bridge result missing \"ok\" field');\n}\n\n/**\n * Looks up the most recent exception from the buffer that falls within the\n * triage window [windowStart, windowEnd]. Returns `undefined` if none found.\n *\n * The heuristic window is:\n * - windowStart = callStart - 50ms (catch sync throws before bridge fires)\n * - windowEnd = callEnd + 200ms (catch async throws resolved soon after)\n *\n * Only the most recent exception within the window is returned (the one most\n * likely to be causally related to the SDK call).\n */\nfunction findRecentException(\n connection: CdpConnection,\n windowStart: number,\n windowEnd: number,\n): BufferedException | undefined {\n const events = connection.getBufferedEvents('Runtime.exceptionThrown');\n // Scan from the tail (most recent) to find the closest-in-time exception.\n for (let i = events.length - 1; i >= 0; i--) {\n const e = events[i];\n if (e.timestamp >= windowStart && e.timestamp <= windowEnd) {\n return normalizeException(e);\n }\n }\n return undefined;\n}\n\n/**\n * Calls a dog-food SDK method via `window.__sdkCall` on the attached page.\n * NOT read-only — SDK calls may have side effects.\n *\n * On env 3/4 (toss WebView relay) this hits the real SDK. On env 1 (local\n * mock) and env 2 (PWA relay — real WebKit, mock SDK) it hits the mock SDK.\n *\n * 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면\n * `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).\n * 미등록 메서드는 passthrough + stderr 경고 1회.\n *\n * Throws on CDP error or result parse failure. Returns `{ok:false, error}`\n * for bridge-level errors (method not found, SDK threw, bridge absent) or\n * argument schema violations.\n *\n * If a `Runtime.exceptionThrown` event was observed within the triage window\n * [callStart-50ms, callEnd+200ms], the result includes `recentException` for\n * crash triage. This window is a heuristic — it catches the common case of an\n * SDK throw immediately before/after the bridge resolves.\n *\n * SECRET-HANDLING: name, args, and the result value are NOT written to any log.\n */\nexport async function callSdk(\n connection: CdpConnection,\n name: string,\n args: unknown[],\n): Promise<CallSdkResult> {\n // 인자 시그니처 검증 — bridge 호출 전에 reject하여 native crash를 예방한다.\n const signature = lookupSignature(name);\n if (signature !== undefined) {\n const validation = signature.validateArgs(args);\n if (!validation.ok) {\n // isError: true 형태로 반환 — bridge에 도달하지 않음.\n const errorText =\n `call_sdk(\"${name}\") 인자 시그니처 오류.\\n` +\n `받음: ${validation.received}\\n` +\n `기대: ${validation.expected}\\n` +\n `올바른 예시: ${signature.example}`;\n return { ok: false, error: errorText };\n }\n } else {\n // 미등록 메서드 — passthrough하지만 stderr에 경고 1회.\n warnPassthrough(name);\n }\n\n const callStart = Date.now();\n const expression = buildCallSdkExpression(name, args);\n const result = await connection.send('Runtime.evaluate', {\n expression,\n returnByValue: true,\n awaitPromise: true,\n });\n const callEnd = Date.now();\n\n if (result.exceptionDetails) {\n // Surface only the engine error string — never name, args, or result value.\n const msg =\n result.exceptionDetails.exception?.description ??\n result.exceptionDetails.text ??\n 'Runtime.evaluate threw an exception';\n throw new Error(`call_sdk threw: ${msg}`);\n }\n\n const sdkResult = normalizeCallSdkResult(result.result.value);\n\n // Triage window: [callStart - 50ms, callEnd + 200ms].\n // -50ms: catches sync throws that fire just before the bridge call is sent.\n // +200ms: catches async throws resolved shortly after the bridge returns.\n const recentException = findRecentException(connection, callStart - 50, callEnd + 200);\n\n if (recentException !== undefined) {\n return { ...sdkResult, recentException };\n }\n return sdkResult;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Phase 3 — AIT.* domain (CDP can't cover these) */\n/* -------------------------------------------------------------------------- */\n\n/** Set of tool names served by the AIT source rather than the CDP connection. */\nconst AIT_TOOL_NAMES = new Set<string>([\n 'AIT.getSdkCallHistory',\n 'AIT.getMockState',\n 'AIT.getOperationalEnvironment',\n]);\n\n/** True for the Phase 3 AIT.* tools (served by an `AitSource`, not CDP). */\nexport function isAitToolName(name: string): boolean {\n return AIT_TOOL_NAMES.has(name);\n}\n\n/** Returns the recent SDK call trace (`AIT.getSdkCallHistory`). */\nexport function getSdkCallHistory(source: AitSource): Promise<AitSdkCallHistory> {\n return source.get('AIT.getSdkCallHistory');\n}\n\n/** Returns the devtools mock-state snapshot (`AIT.getMockState`). */\nexport function getMockState(source: AitSource): Promise<AitMockState> {\n return source.get('AIT.getMockState');\n}\n\n/** Returns the operational environment + SDK version (`AIT.getOperationalEnvironment`). */\nexport function getOperationalEnvironment(source: AitSource): Promise<AitOperationalEnvironment> {\n return source.get('AIT.getOperationalEnvironment');\n}\n\n/* -------------------------------------------------------------------------- */\n/* get_debug_status — single-call server status snapshot (#286) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Represents a single redacted server-side error entry in the diagnostics\n * snapshot. PII / secrets are scrubbed before this is returned.\n */\nexport interface DiagnosticsError {\n /** ISO timestamp when the error was recorded. */\n timestamp: string;\n /** Error message with PII/secrets redacted (e.g. `at=<redacted>`). */\n message: string;\n /** Optional error category for quick triage. */\n category?: string;\n}\n\n/**\n * Tunnel state in the diagnostics snapshot. Same shape as `TunnelStatus` but\n * extended with the lock-file data (pid, startedAt) when available.\n */\nexport interface DiagnosticsTunnelInfo {\n /** Whether the cloudflared quick tunnel is currently up. */\n up: boolean;\n /** Public `wss://*.trycloudflare.com` relay URL, or `null`. */\n wssUrl: string | null;\n /**\n * PID of the MCP server process that owns the tunnel (from the lock file),\n * or `null` when no lock is present.\n */\n pid: number | null;\n /**\n * ISO timestamp when the owning server process started (from the lock file),\n * or `null`.\n */\n startedAt: string | null;\n /**\n * ISO timestamp when the tunnel permanently dropped (health probe exhausted\n * all reissue attempts). `null` when the tunnel has not permanently dropped.\n * When non-null, the MCP server must be restarted to recover.\n */\n droppedAt: string | null;\n /**\n * Number of automatic reissue attempts made before the permanent drop.\n * 0 when no drop has occurred.\n */\n reissueAttempts: number;\n}\n\n/**\n * Server-lock holder info from `~/.ait-devtools/server.lock`. `null` when\n * no lock file exists (server was cleanly shut down or never started).\n */\nexport interface DiagnosticsLockHolder {\n pid: number;\n startedAt: string;\n /** wssUrl recorded in the lock file — may be `null` when tunnel is still starting. */\n wssUrl: string | null;\n}\n\n/**\n * Secret-free snapshot of relay auth rejections (issue #467).\n *\n * Counts WS-upgrade / HTTP 401 rejections from the relay's TOTP gate so an\n * empty `pages` list can be distinguished from \"the phone never reached the\n * relay\". SECRET-HANDLING: count + timestamp ONLY — never the URL, query,\n * code, or secret.\n */\nexport interface AuthRejectsSnapshot {\n /** Total auth-rejected relay requests since server start. */\n count: number;\n /** ISO timestamp of the most recent rejection, or `null` when none. */\n lastAt: string | null;\n}\n\n/**\n * The next recommended tool for the agent to call, based on the current server\n * state snapshot. `null` means the session looks healthy — no specific action needed.\n */\nexport interface NextRecommendedAction {\n /** MCP tool name to call next (e.g. `'start_attach'`, `'restart'`). */\n tool: string;\n /** Human-readable reason explaining why this action is recommended. */\n reason: string;\n}\n\n/**\n * Full server status snapshot returned by `get_debug_status`.\n *\n * All fields are nullable — a missing value means \"not yet known\" (e.g. tunnel\n * not up yet) rather than an error. The schema is intentionally stable across\n * versions: new optional fields may be added but existing fields are not\n * removed or renamed.\n *\n * SECRET-HANDLING: No TOTP secret, cookie, deploy key, or `at=` code value\n * appears in this snapshot. `recentErrors` entries are redacted before inclusion.\n */\nexport interface DiagnosticsResult {\n /** `@modelcontextprotocol/sdk` package version string. */\n mcpVersion: string | null;\n /** `@ait-co/devtools` package version string. */\n devtoolsVersion: string | null;\n /** Tunnel state including lock-file pid/startedAt. */\n tunnel: DiagnosticsTunnelInfo;\n /** Current list_pages result (pages + crash info + singleAttachModel). */\n pages: ListPagesResult | null;\n /** ISO timestamp of the most recent page attach, or `null`. */\n lastAttachAt: string | null;\n /** ISO timestamp of the most recent page detach, or `null`. */\n lastDetachAt: string | null;\n /**\n * Recent server-side errors (up to `recent_errors_limit`, default 10).\n * Redacted: `at=<redacted>`, cookie headers stripped, AITCC_API_KEY masked.\n * When auth rejections occurred, ONE synthetic summary entry\n * (`category: 'auth'`) is appended so an empty array can be read as\n * \"no attach attempts\" without missing silent 401s (issue #467).\n */\n recentErrors: DiagnosticsError[];\n /**\n * Relay TOTP auth-reject counter (issue #467). `count` is 0 when no\n * rejection occurred. Secret-free: count + last timestamp only.\n */\n authRejects: AuthRejectsSnapshot;\n /**\n * Resolved environment and the reason string.\n *\n * `kind` — the precise three-value environment (`mock` | `relay-dev` |\n * `relay-mobile`). Use this for new code. (`relay-live` removed #665.)\n * `env` — backward-compat two-value alias (`mock` | `relay`). Kept so\n * existing callers that only distinguish mock vs relay continue to work.\n */\n environment: {\n kind: McpEnvironment;\n /** @deprecated Use `kind` instead. Kept for backward compatibility. */\n env: 'mock' | 'relay';\n reason: string;\n /**\n * @deprecated Always `false` — relay-live and the LIVE guard removed (#665).\n * Kept for backward compatibility with consumers that read this field.\n */\n liveGuardActive: false;\n };\n /**\n * Contents of `~/.ait-devtools/server.lock`, or `null` when absent.\n * Useful for diagnosing stale-lock conflicts without running the full server.\n */\n serverLockHolder: DiagnosticsLockHolder | null;\n /**\n * Basic process identity for the running MCP server daemon.\n * Useful for diagnosing orphaned daemons and stale parent associations.\n */\n process: {\n /** PID of this MCP server process. */\n pid: number;\n /** Parent PID at the time `get_debug_status` was called. */\n ppid: number;\n /** Whether the parent process is still alive at snapshot time. */\n parentAlive: boolean;\n };\n /**\n * Single next recommended action for the agent, or `null` when the session\n * looks healthy. Derived deterministically from the other snapshot fields —\n * the agent should call this tool next rather than inferring from raw fields.\n *\n * Branch rules (evaluated in priority order):\n * 0. tunnel.droppedAt non-null → restart (permanent tunnel drop)\n * 1. tunnel.up === false AND relay env → restart\n * 1b. tunnel.up === false AND mock env, no pages → wait_for_page (local target is tunnel-less)\n * 2a. authRejects.count > 0 AND pages empty → start_attach (TOTP 거부 — QR 재스캔 안내)\n * 2. tunnel.up, pages empty, env === relay → start_attach\n * 3. pages[0] exists + crashDetectedAt non-null → start_attach (re-attach)\n * 4. otherwise → null\n */\n nextRecommendedAction: NextRecommendedAction | null;\n}\n\n/**\n * Registry of server-side errors collected by `DiagnosticsCollector`.\n * Injected into `createDebugServer` so it is testable without a real process.\n */\nexport interface DiagnosticsCollector {\n /** Records a server-side error for later surfacing in `get_debug_status`. */\n recordError(message: string, category?: string): void;\n /** Returns the most recent `limit` errors, oldest-first. */\n getRecentErrors(limit: number): DiagnosticsError[];\n /** Records an attach event (ISO timestamp stored). */\n recordAttach(): void;\n /** Records a detach event (ISO timestamp stored). */\n recordDetach(): void;\n /** Returns the ISO timestamp of the last attach, or `null`. */\n getLastAttachAt(): string | null;\n /** Returns the ISO timestamp of the last detach, or `null`. */\n getLastDetachAt(): string | null;\n /**\n * Records one relay auth rejection (issue #467). Secret-free by contract —\n * implementations store only a counter + timestamp, never request data.\n */\n recordAuthReject(): void;\n /** Returns the auth-reject counter snapshot ({count: 0, lastAt: null} when none). */\n getAuthRejects(): AuthRejectsSnapshot;\n}\n\n/** Secret-redaction patterns applied before error messages enter the buffer. */\nconst SECRET_REDACT_PATTERNS: ReadonlyArray<[RegExp, string]> = [\n // TOTP at= code value.\n [/\\bat=([^&\\s\"']+)/g, 'at=<redacted>'],\n // Cookie / Set-Cookie header values — replace everything after the colon.\n [/((?:set-)?cookie)\\s*:\\s*.+/gi, '$1: <redacted>'],\n // AITCC_API_KEY env-var-style references.\n [/AITCC_API_KEY\\s*=\\s*\\S+/gi, 'AITCC_API_KEY=<redacted>'],\n // Authorization header (covers \"Authorization: Bearer …\" and bare \"Bearer <token>\").\n [/Authorization\\s*:\\s*.+/gi, 'Authorization: <redacted>'],\n [/\\bBearer\\s+\\S+/g, 'Bearer <redacted>'],\n];\n\n/**\n * Applies all secret-redaction patterns to an error message string.\n * Used before storing errors in the `DiagnosticsCollector` ring buffer.\n *\n * SECRET-HANDLING: this is the single bottleneck for redaction — all error\n * strings must pass through here before reaching the buffer.\n */\nexport function redactErrorMessage(message: string): string {\n let result = message;\n for (const [pattern, replacement] of SECRET_REDACT_PATTERNS) {\n result = result.replace(pattern, replacement);\n }\n return result;\n}\n\n/** Default max buffer size for the error ring buffer. */\nconst DEFAULT_ERROR_BUFFER_SIZE = 50;\n\n/**\n * In-memory implementation of `DiagnosticsCollector`. Thread-safe in the\n * single-threaded Node.js sense (synchronous mutations only).\n */\nexport class InMemoryDiagnosticsCollector implements DiagnosticsCollector {\n private readonly buffer: DiagnosticsError[] = [];\n private readonly maxSize: number;\n private lastAttachAt: string | null = null;\n private lastDetachAt: string | null = null;\n private authRejectCount = 0;\n private lastAuthRejectAt: string | null = null;\n\n constructor(maxSize = DEFAULT_ERROR_BUFFER_SIZE) {\n this.maxSize = maxSize;\n }\n\n recordError(message: string, category?: string): void {\n const entry: DiagnosticsError = {\n timestamp: new Date().toISOString(),\n message: redactErrorMessage(message),\n ...(category !== undefined ? { category } : {}),\n };\n this.buffer.push(entry);\n // Keep only the most recent `maxSize` entries.\n if (this.buffer.length > this.maxSize) {\n this.buffer.shift();\n }\n }\n\n getRecentErrors(limit: number): DiagnosticsError[] {\n const cap = Math.min(Math.max(1, limit), DEFAULT_ERROR_BUFFER_SIZE);\n const sliced =\n this.buffer.length > cap ? this.buffer.slice(this.buffer.length - cap) : [...this.buffer];\n return sliced;\n }\n\n recordAttach(): void {\n this.lastAttachAt = new Date().toISOString();\n }\n\n recordDetach(): void {\n this.lastDetachAt = new Date().toISOString();\n }\n\n getLastAttachAt(): string | null {\n return this.lastAttachAt;\n }\n\n getLastDetachAt(): string | null {\n return this.lastDetachAt;\n }\n\n recordAuthReject(): void {\n this.authRejectCount += 1;\n this.lastAuthRejectAt = new Date().toISOString();\n }\n\n getAuthRejects(): AuthRejectsSnapshot {\n return { count: this.authRejectCount, lastAt: this.lastAuthRejectAt };\n }\n}\n\n/**\n * Returns the `@modelcontextprotocol/sdk` version baked in at build time via\n * the `__MCP_SDK_VERSION__` define (see `tsdown.config.ts`). Returns `null`\n * when the define is absent (unbundled test runs) and the runtime fallback\n * below also fails — diagnostics must never throw.\n *\n * Earlier attempts resolved `@modelcontextprotocol/sdk/package.json` (not in\n * the SDK `exports` map → `ERR_PACKAGE_PATH_NOT_EXPORTED`) or the bare\n * `@modelcontextprotocol/sdk` main entry (also absent → `MODULE_NOT_FOUND`),\n * so both this fallback AND the build-time define silently produced `null` —\n * leaving `mcpVersion: null` in a real bundle (issue #361, observed live). The\n * fix resolves a subpath that IS exported (`./server/mcp.js`) and walks back to\n * the package root, in BOTH the build define and this fallback.\n *\n * Kept `async` for call-site compatibility (`Promise.all` at the caller); the\n * body is synchronous apart from the best-effort fallback.\n */\nexport async function readMcpSdkVersion(): Promise<string | null> {\n // Primary: build-time define (bare identifier, substituted by tsdown).\n if (typeof __MCP_SDK_VERSION__ === 'string' && __MCP_SDK_VERSION__.length > 0) {\n return __MCP_SDK_VERSION__;\n }\n // Fallback for unbundled runs (the define never ran): resolve an EXPORTED\n // subpath (`./server/mcp.js`) and read the sibling package.json by path —\n // bypassing the `exports` gate that blocks both the `/package.json` subpath\n // and the bare main entry.\n try {\n const { createRequire } = await import('node:module');\n const req = createRequire(import.meta.url);\n const entry = req.resolve('@modelcontextprotocol/sdk/server/mcp.js');\n const marker = '@modelcontextprotocol/sdk';\n const root = entry.slice(0, entry.indexOf(marker) + marker.length);\n const { readFileSync } = await import('node:fs');\n const raw = readFileSync(`${root}/package.json`, 'utf8');\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n return typeof parsed.version === 'string' ? parsed.version : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Returns the `@ait-co/devtools` package version injected at build time via\n * the `__VERSION__` define. Returns `null` when the global is absent (e.g. in\n * some test environments that skip the build step).\n */\nexport function readDevtoolsVersion(): string | null {\n // `__VERSION__` is a bare identifier replaced at build time by the tsdown\n // `define` (see `tsdown.config.ts`) — the SAME mechanism `debug-server.ts`\n // and `server.ts` use for the MCP server `version`. It must be referenced as\n // a bare identifier, not `globalThis.__VERSION__`: `define` only substitutes\n // the bare token, so the property access always read `undefined` and this\n // function always returned `null` in a real bundle (issue #361). The\n // `typeof` guard keeps it null-safe in unbundled test runs where the define\n // never ran.\n return typeof __VERSION__ === 'string' && __VERSION__.length > 0 ? __VERSION__ : null;\n}\n\n/**\n * Derives the next recommended action from a completed diagnostics snapshot.\n *\n * Branch rules (evaluated in priority order):\n * 0. tunnel.droppedAt non-null → restart (permanent tunnel drop — highest priority)\n * 1. tunnel.up === false AND env is relay → restart (relay needs a live tunnel)\n * 1b. tunnel.up === false AND env is mock → wait_for_page (local target: tunnel-less is normal)\n * 2a. authRejects.count > 0 AND pages empty → start_attach (relay TOTP 거부 관측 — QR 재스캔\n * 또는 target-side `at` 전달 확인. 일반 rule 2보다 구체적이므로 먼저 평가 — issue #467)\n * 2. tunnel.up, pages empty, env === relay → start_attach (start attach)\n * 3. pages has entry + crashDetectedAt non-null → start_attach (re-attach after crash)\n * 4. otherwise → null (session looks healthy)\n *\n * Pure — does not throw; receives the final assembled snapshot fields.\n *\n * SECRET-HANDLING: the auth-reject reason string carries only the count and\n * timestamp from {@link AuthRejectsSnapshot} — never a URL, code, or secret.\n */\nexport function computeNextRecommendedAction(\n tunnel: DiagnosticsTunnelInfo,\n pages: ListPagesResult | null,\n env: McpEnvironment,\n authRejects: AuthRejectsSnapshot | null = null,\n): NextRecommendedAction | null {\n // Rule 0: permanent tunnel drop — highest priority, beats crash / empty-pages rules.\n // droppedAt is set by the health probe after exhausting all reissue attempts.\n if (tunnel.droppedAt != null) {\n return {\n tool: 'restart',\n reason:\n `tunnel permanently dropped at ${tunnel.droppedAt} after ${tunnel.reissueAttempts} reissue attempt(s) — ` +\n 'restart the MCP server (npx @ait-co/devtools devtools-mcp)',\n };\n }\n\n // Rule 1: tunnel is down.\n if (!tunnel.up) {\n // Rule 1b: local-target (mock env) runs without a relay tunnel by design —\n // tunnel.up === false is the expected steady state. Instead of recommending\n // a server restart, guide the agent to wait for the page to load.\n if (!isRelayEnv(env)) {\n // Only surface wait_for_page when no page is attached yet; once a page\n // attaches the session is healthy and null is the correct return value.\n if (pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) {\n return {\n tool: 'wait_for_page',\n reason:\n 'local Chromium spawn 직후 — 페이지 로드를 기다리거나 list_pages를 재호출하세요 ' +\n '(local 모드는 tunnel이 없는 게 정상입니다)',\n };\n }\n // Page already attached or crash detected — fall through to other rules.\n } else {\n // Rule 1 (relay env): tunnel must be up for relay to work — restart.\n return {\n tool: 'restart',\n reason: 'tunnel not up — run `npx @ait-co/devtools devtools-mcp` to restart',\n };\n }\n }\n\n // Rule 2a (issue #467): auth rejections observed while no page is attached —\n // the phone DID reach the relay but its TOTP verification failed. Without\n // this rule the generic rule 2 (\"call start_attach\") hides the rejection\n // and the diagnosis runs the wrong way (\"the phone never arrived\").\n if (authRejects !== null && authRejects.count > 0 && pages !== null && pages.pages.length === 0) {\n return {\n tool: 'start_attach',\n reason:\n `relay 인증(TOTP) 거부 ${authRejects.count}건 발생 (last ${authRejects.lastAt ?? 'unknown'}) — ` +\n 'QR을 다시 스캔해 새 코드로 attach하세요(코드는 ~3분마다 만료). 반복되면 폰 페이지 URL에 ' +\n 'at 파라미터가 전달되는지(target-side TOTP 전달 경로)를 확인하세요',\n };\n }\n\n // Rule 2: tunnel up but no pages attached in relay env → start attach.\n if (isRelayEnv(env) && pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) {\n return {\n tool: 'start_attach',\n reason: 'tunnel ready, no pages attached — call start_attach to generate the attach QR',\n };\n }\n\n // Rule 3: crash detected — need to re-attach.\n if (pages !== null && pages.crashDetectedAt !== null) {\n return {\n tool: 'start_attach',\n reason: `page crashed at ${pages.crashDetectedAt} — call start_attach to re-attach`,\n };\n }\n\n // Rule 4: session looks healthy.\n return null;\n}\n\n/** Input for `getDiagnostics`. */\nexport interface GetDiagnosticsInput {\n /** Current tunnel status (from the server's live `getTunnelStatus()`). */\n tunnel: TunnelStatus;\n /**\n * CDP connection used to call `list_pages` — may be absent in edge cases\n * (e.g. called from the dev-mode server which has no CDP connection).\n */\n connection?: CdpConnection;\n /**\n * Resolved MCP environment (`mock` | `relay-dev` | `relay-mobile`).\n * Caller obtains via `resolveEnvironment()`. (`relay-live` removed #665.)\n */\n env: McpEnvironment;\n /** Human-readable reason for the env decision. */\n envReason: string;\n /** Diagnostics collector for errors / attach events. */\n collector: DiagnosticsCollector;\n /** Lock-file reader — injected so tests can override without touching the FS. */\n readLock: () => import('./server-lock.js').LockData | null;\n /** Maximum number of recent errors to include (default 10). */\n recentErrorsLimit?: number;\n /** Optional async resolver for the MCP SDK version. */\n getMcpVersion?: () => Promise<string | null>;\n /**\n * Injectable parent-alive check for testability.\n * Defaults to `() => isPidAlive(process.ppid)` in production.\n */\n checkParentAlive?: () => boolean;\n /**\n * PID of the cloudflared child process — obtained from `QuickTunnel.childPid`\n * and written to the lock file via `LockHandle.updateTunnelChildPid`.\n *\n * FIX 2 (issue #571): when this PID is known, `getDiagnostics` performs a\n * live `isPidAlive(tunnelChildPid)` check and overrides `tunnel.up = false`\n * if the child is dead, preventing the cached `up: true` from being reported\n * as truth when the cloudflared process has already exited.\n */\n tunnelChildPid?: number | null;\n}\n\n/**\n * Builds the `get_debug_status` response. Pure — does not throw; missing data\n * fields are `null`. Async because `readMcpSdkVersion` needs `import()`.\n *\n * SECRET-HANDLING:\n * - `recentErrors` messages are already redacted by `recordError` (via\n * `redactErrorMessage`). No additional redaction needed here.\n * - `tunnel.wssUrl` is a public cloudflared hostname — not a secret.\n * - Lock file data contains only pid + startedAt + wssUrl — no secrets.\n */\nexport async function getDiagnostics(input: GetDiagnosticsInput): Promise<DiagnosticsResult> {\n const {\n tunnel,\n connection,\n env,\n envReason,\n collector,\n readLock: readLockFn,\n recentErrorsLimit = 10,\n getMcpVersion = readMcpSdkVersion,\n checkParentAlive = () => isPidAlive(process.ppid),\n tunnelChildPid,\n } = input;\n\n const [mcpVersion, devtoolsVersion] = await Promise.all([\n getMcpVersion(),\n Promise.resolve(readDevtoolsVersion()),\n ]);\n\n // Read lock file for serverLockHolder + tunnel pid/startedAt.\n const lockData = readLockFn();\n const serverLockHolder: DiagnosticsLockHolder | null = lockData\n ? { pid: lockData.pid, startedAt: lockData.startedAt, wssUrl: lockData.wssUrl }\n : null;\n\n // FIX 2 (issue #571): if the cloudflared child PID is known, perform a live\n // probe to detect child death even when the cached `tunnel.up` is still true.\n // This prevents the 2d17h zombie scenario where the process died but the cache\n // was never invalidated.\n //\n // Source priority: explicit `tunnelChildPid` arg (in-memory, always current) →\n // lock file's `tunnelChildPid` (populated by FIX 3 via onTunnelChildPid) →\n // null (no probe). The lock-file fallback ensures the check fires even when the\n // handler didn't pass the in-memory PID explicitly (issue #572 review).\n const effectiveTunnelChildPid = tunnelChildPid ?? lockData?.tunnelChildPid ?? null;\n let effectiveUp = tunnel.up;\n if (\n tunnel.up &&\n typeof effectiveTunnelChildPid === 'number' &&\n effectiveTunnelChildPid !== null &&\n !isPidAlive(effectiveTunnelChildPid)\n ) {\n effectiveUp = false;\n }\n\n const tunnelInfo: DiagnosticsTunnelInfo = {\n up: effectiveUp,\n wssUrl: tunnel.wssUrl,\n pid: lockData?.pid ?? null,\n startedAt: lockData?.startedAt ?? null,\n droppedAt: tunnel.droppedAt ?? null,\n reissueAttempts: tunnel.reissueAttempts ?? 0,\n };\n\n // list_pages — non-fatal; null on any error.\n // Refresh from relay first (#551 — stale cache causes pages:0 / wrong\n // nextRecommendedAction even when a target is attached). Same best-effort\n // pattern as the list_pages handler: errors are silently ignored so the\n // caller always gets *something* back, even when the relay is unreachable.\n let pages: ListPagesResult | null = null;\n if (connection !== undefined) {\n try {\n await connection.refreshTargets?.();\n } catch {\n // Ignore refresh errors — continue with cached state.\n }\n try {\n pages = listPages(connection, tunnel);\n } catch {\n // Ignore — pages stays null.\n }\n }\n\n const limit = Math.min(Math.max(1, recentErrorsLimit), 50);\n const recentErrors = collector.getRecentErrors(limit);\n\n // Issue #467: surface relay auth rejections. One synthetic summary entry\n // (not N entries — rejections can be frequent) so \"recentErrors: []\" can be\n // read as \"no attach attempts\" without hiding silent 401s.\n // SECRET-HANDLING: message carries only count + timestamp.\n const authRejects = collector.getAuthRejects();\n if (authRejects.count > 0) {\n recentErrors.push({\n timestamp: authRejects.lastAt ?? new Date().toISOString(),\n message: `WS upgrade auth-rejected (${authRejects.count} times, last ${authRejects.lastAt ?? 'unknown'})`,\n category: 'auth',\n });\n }\n\n const nextRecommendedAction = computeNextRecommendedAction(tunnelInfo, pages, env, authRejects);\n\n return {\n mcpVersion,\n devtoolsVersion,\n tunnel: tunnelInfo,\n pages,\n lastAttachAt: collector.getLastAttachAt(),\n lastDetachAt: collector.getLastDetachAt(),\n recentErrors,\n authRejects,\n environment: {\n kind: env,\n env: toLegacyEnv(env),\n reason: envReason,\n liveGuardActive: false, // relay-live and LIVE guard removed (#665)\n },\n serverLockHolder,\n process: {\n pid: process.pid,\n ppid: process.ppid,\n parentAlive: checkParentAlive(),\n },\n nextRecommendedAction,\n };\n}\n","/**\n * cloudflared quick tunnel + attach banner for the debug-mode MCP server.\n *\n * On spawn, the debug server opens an accountless `*.trycloudflare.com` quick\n * tunnel to the local Chii relay so the phone can attach over a public wss URL,\n * then prints a unicode half-block QR + attach instructions. When TOTP auth is\n * enabled (`AIT_DEBUG_TOTP_SECRET` is set), the QR encodes only the base relay\n * URL — the TOTP code (`at=`) is NOT included because it rotates every 30 s\n * and would be stale by the time a human scans. The in-app deep-link builder\n * splices the live code at attach time.\n *\n * Tunnel health probe (`TunnelHealthProbe`):\n * After the tunnel is up, a periodic HTTP HEAD probe hits the tunnel's\n * `https://` URL every `probeIntervalMs` (default 60 s). Two consecutive\n * failures trigger a reissue attempt (spawn a new cloudflared quick tunnel\n * and redirect traffic). After `MAX_REISSUE_ATTEMPTS` (3) consecutive\n * reissue failures, the probe gives up and marks the tunnel permanently\n * dropped — `tunnelStatus.up` becomes false with `droppedAt` set. The caller\n * should surface this to the agent so the user knows to restart the server.\n *\n * SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear\n * in any output from this module.\n *\n * Node-only: spawns the cloudflared binary and writes to stdout/stderr.\n */\n\nimport { randomBytes } from 'node:crypto';\nimport { bin, install, Tunnel } from 'cloudflared';\nimport type { TunnelStatus } from './tools.js';\n\n/** Generates a 32-byte hex attach token shown as a pairing hint (relay-side validation is a later phase). */\nexport function generateAttachToken(): string {\n return randomBytes(32).toString('hex');\n}\n\nexport interface QuickTunnel {\n /** Public `https://*.trycloudflare.com` URL the tunnel exposes. */\n url: string;\n /** Same host as `wss://` — the relay endpoint the phone attaches to. */\n wssUrl: string;\n /**\n * PID of the cloudflared child process. Present once the tunnel is up.\n * Safe to surface in diagnostics (plain integer — not a secret).\n */\n childPid?: number;\n /**\n * Register a callback to be invoked when the cloudflared child exits\n * unexpectedly (i.e. NOT due to our own `stop()` call). The caller\n * (`startTunnelHealthProbe`) uses this to immediately trigger reissue\n * without waiting for the next probe interval.\n *\n * Only one callback can be registered at a time; calling this again\n * replaces the previous one.\n */\n onUnexpectedExit(cb: (code: number | null) => void): void;\n stop(): void;\n}\n\n/** Ensures the cloudflared binary is installed (downloads + caches on first run). */\nasync function ensureCloudflaredBin(): Promise<void> {\n const { existsSync } = await import('node:fs');\n if (!existsSync(bin)) {\n await install(bin);\n }\n}\n\n/**\n * Opens a cloudflared quick tunnel to the local relay port and resolves once\n * the public URL is assigned.\n *\n * FIX 1 (issue #571): after URL resolution the returned `QuickTunnel` object\n * watches the cloudflared child process for unexpected exits and calls any\n * registered `onUnexpectedExit` callback so the health probe can immediately\n * trigger reissue instead of waiting for the next poll interval.\n */\nexport async function startQuickTunnel(localPort: number): Promise<QuickTunnel> {\n await ensureCloudflaredBin();\n\n const tunnel = Tunnel.quick(`http://127.0.0.1:${localPort}`);\n\n const url = await new Promise<string>((resolve, reject) => {\n const onUrl = (assigned: string) => {\n cleanup();\n resolve(assigned);\n };\n const onError = (err: Error) => {\n cleanup();\n reject(err);\n };\n const onExit = (code: number | null) => {\n cleanup();\n reject(new Error(`cloudflared exited before assigning a URL (code ${code})`));\n };\n const cleanup = () => {\n tunnel.off('url', onUrl);\n tunnel.off('error', onError);\n tunnel.off('exit', onExit);\n };\n tunnel.once('url', onUrl);\n tunnel.once('error', onError);\n tunnel.once('exit', onExit);\n });\n\n // FIX 1: watch for unexpected child death AFTER URL is resolved.\n // `intentionalStop` guards against triggering a reissue when we called stop() ourselves\n // (cloudflared exits on SIGINT from tunnel.stop(), which would otherwise look like a crash).\n let intentionalStop = false;\n let unexpectedExitCb: ((code: number | null) => void) | null = null;\n\n tunnel.once('exit', (code: number | null) => {\n if (!intentionalStop && unexpectedExitCb !== null) {\n unexpectedExitCb(code);\n }\n });\n\n return {\n url,\n wssUrl: url.replace(/^https/, 'wss'),\n childPid: (tunnel.process as { pid?: number } | null)?.pid,\n onUnexpectedExit(cb: (code: number | null) => void): void {\n unexpectedExitCb = cb;\n },\n stop(): void {\n intentionalStop = true;\n tunnel.stop();\n },\n };\n}\n\nexport interface AttachBannerInput {\n wssUrl: string;\n /**\n * Whether TOTP auth is enabled on the relay (`AIT_DEBUG_TOTP_SECRET` is set).\n *\n * When `true`, the banner notes that a rotating code (`at=`) will be\n * appended to attach URLs at call time — the code is NOT printed here\n * because it rotates every 30 s and would be stale in seconds.\n */\n totpEnabled: boolean;\n}\n\n/**\n * Renders a pure unicode half-block QR string for the given text.\n *\n * Uses `qrcode` (Node full lib) to get the raw bit matrix, then encodes every\n * two vertical modules into a single half-block character:\n * - both dark → `█`\n * - top only → `▀`\n * - bottom only → `▄`\n * - both light → ` ` (space)\n *\n * The output contains **zero ANSI escape codes**, so it renders correctly in\n * every surface (terminal, VS Code, JetBrains, web) and can be scanned by a\n * phone camera when shown verbatim in an agent response.\n *\n * Shared by `renderAttachBanner` (relay wssUrl QR) and the `start_attach`\n * MCP tool response (attach deep-link QR).\n */\nexport async function renderQr(text: string): Promise<string> {\n // Dynamic import mirrors the cloudflared/qrcode-terminal precedent: keeps the\n // dependency out of the module graph when the function is not called.\n const { default: QRCode } = await import('qrcode');\n const qr = QRCode.create(text, { errorCorrectionLevel: 'M' });\n const size: number = qr.modules.size;\n const data: Uint8Array = qr.modules.data as Uint8Array;\n\n const isDark = (x: number, y: number): boolean => {\n if (x < 0 || y < 0 || x >= size || y >= size) return false;\n return data[y * size + x] === 1;\n };\n\n const QUIET = 1;\n const lines: string[] = [];\n for (let y = -QUIET; y < size + QUIET; y += 2) {\n let line = '';\n for (let x = -QUIET; x < size + QUIET; x++) {\n const top = isDark(x, y);\n const bot = isDark(x, y + 1);\n line += top && bot ? '█' : top ? '▀' : bot ? '▄' : ' ';\n }\n lines.push(line);\n }\n return `${lines.join('\\n')}\\n`;\n}\n\n/**\n * Renders the attach banner (relay URL + unicode half-block QR) as a string.\n *\n * The QR is produced by `renderQr` (a half-block matrix, not the\n * `qrcode-terminal` ASCII art used by the unplugin banner) and encodes the\n * base `wssUrl` only. When `totpEnabled` is true, a note\n * is added that attach URLs generated by `start_attach` will include a\n * live TOTP code (`at=`) appended at call time.\n *\n * SECRET-HANDLING: no secret value, TOTP code, or intermediate value is\n * included in this output.\n */\nexport async function renderAttachBanner(input: AttachBannerInput): Promise<string> {\n // The QR encodes only the relay wssUrl — no token or code. This is safe\n // because the relay gate enforces the code at WS upgrade time anyway; the\n // QR is just for locating the relay, not for bypassing auth.\n const qr = await renderQr(input.wssUrl);\n\n const authNote = input.totpEnabled\n ? ' auth: TOTP enabled — attach URLs include a rotating code (at=).'\n : ' auth: none (set AIT_DEBUG_TOTP_SECRET to enable TOTP).';\n\n return [\n '',\n 'AIT debug — attach a mini-app to this session',\n '',\n ` relay (wss): ${input.wssUrl}`,\n authNote,\n '',\n ' Use start_attach to generate a deep link with the current TOTP code.',\n ' Scan the QR to locate the relay (open the dog-food URL separately with',\n ' ?debug=1&relay=<wss>&at=<code> or use the start_attach tool):',\n '',\n qr,\n ].join('\\n');\n}\n\n/** Prints the attach banner to stderr (stdout is the MCP stdio channel). */\nexport async function printAttachBanner(input: AttachBannerInput): Promise<void> {\n const banner = await renderAttachBanner(input);\n process.stderr.write(`${banner}\\n`);\n}\n\n/* -------------------------------------------------------------------------- */\n/* TunnelHealthProbe — periodic health check + auto-reissue */\n/* -------------------------------------------------------------------------- */\n\n/** Maximum consecutive reissue attempts before the probe gives up. */\nexport const MAX_REISSUE_ATTEMPTS = 3;\n\n/**\n * Probes `https://` URL with an HTTP HEAD request.\n * Returns `true` when the server responds (any HTTP status), `false` on\n * network error or timeout.\n *\n * We treat any HTTP response (including 4xx/5xx) as \"tunnel alive\" because\n * cloudflared itself responds to the HEAD — if the tunnel process died, the\n * request fails at the network level rather than returning a status code.\n *\n * @param httpsUrl - The `https://` tunnel URL to probe.\n * @param timeoutMs - Abort timeout in ms. Default 10 000.\n */\nexport async function probeTunnel(httpsUrl: string, timeoutMs = 10_000): Promise<boolean> {\n const { default: https } = await import('node:https');\n return new Promise<boolean>((resolve) => {\n const url = new URL(httpsUrl);\n const timer = setTimeout(() => {\n req.destroy();\n resolve(false);\n }, timeoutMs);\n\n const req = https.request(\n { hostname: url.hostname, port: 443, path: url.pathname || '/', method: 'HEAD' },\n (_res) => {\n clearTimeout(timer);\n _res.resume(); // drain response body to free socket\n resolve(true);\n },\n );\n req.on('error', () => {\n clearTimeout(timer);\n resolve(false);\n });\n req.end();\n });\n}\n\nexport interface TunnelHealthProbeOptions {\n /**\n * Interval in ms between health probes. Default 60 000 (60 s).\n * Use a smaller value in tests.\n */\n probeIntervalMs?: number;\n /**\n * How many consecutive probe failures to tolerate before triggering a\n * reissue. Default 2 (so one transient network hiccup is forgiven).\n */\n failuresBeforeReissue?: number;\n /**\n * Callback invoked after a successful reissue. The caller (debug-server)\n * uses this to update `tunnelStatus` and reprint the attach banner with the\n * new `wssUrl`.\n */\n onReissue: (newTunnel: QuickTunnel) => void;\n /**\n * Callback invoked when the probe permanently gives up (all reissue attempts\n * exhausted). The caller should mark `tunnelStatus.up = false` and surface\n * the error to the agent / user.\n */\n onPermanentDrop: (droppedAt: string) => void;\n /**\n * Optional stderr-compatible logger. Default `process.stderr.write`.\n * Injected in tests to avoid real I/O.\n */\n log?: (msg: string) => void;\n /**\n * Optional probe function override (for tests — avoids real HTTP requests).\n */\n probe?: (httpsUrl: string) => Promise<boolean>;\n /**\n * Optional tunnel spawner override (for tests — avoids real cloudflared).\n */\n spawnTunnel?: (localPort: number) => Promise<QuickTunnel>;\n}\n\n/**\n * Starts a periodic health probe for a cloudflared quick tunnel.\n *\n * Every `probeIntervalMs` the probe sends an HTTP HEAD request to the tunnel's\n * `https://` URL. When `failuresBeforeReissue` consecutive failures are\n * detected, it attempts to spawn a new tunnel (up to `MAX_REISSUE_ATTEMPTS`\n * times). On success the caller is notified via `onReissue`; on permanent\n * failure via `onPermanentDrop`.\n *\n * FIX 1 (issue #571): the probe also subscribes to each tunnel's\n * `onUnexpectedExit` callback to detect child death *immediately* instead of\n * waiting for the next probe interval (which could be 60 s away).\n *\n * @returns `stop` — call during server shutdown to clear the probe interval.\n */\nexport function startTunnelHealthProbe(\n initialTunnel: QuickTunnel,\n localPort: number,\n options: TunnelHealthProbeOptions,\n): { stop(): void } {\n const {\n probeIntervalMs = 60_000,\n failuresBeforeReissue = 2,\n onReissue,\n onPermanentDrop,\n log = (msg: string) => process.stderr.write(msg),\n probe = probeTunnel,\n spawnTunnel = startQuickTunnel,\n } = options;\n\n let currentTunnel = initialTunnel;\n let consecutiveFailures = 0;\n let reissueAttempts = 0;\n let stopped = false;\n\n // FIX 1: shared reissue-or-drop logic — called both from the periodic\n // interval (after failuresBeforeReissue consecutive probe misses) and from\n // the child-exit handler (immediately on unexpected process death).\n const doReissueOrDrop = async (): Promise<void> => {\n if (stopped) return;\n\n reissueAttempts += 1;\n if (reissueAttempts > MAX_REISSUE_ATTEMPTS) {\n // Already exhausted — do not log again.\n return;\n }\n\n log(\n `[ait-debug] tunnel drop detected — reissuing (attempt ${reissueAttempts}/${MAX_REISSUE_ATTEMPTS})\\n`,\n );\n\n try {\n const newTunnel = await spawnTunnel(localPort);\n // Stop the old tunnel process to free system resources.\n try {\n currentTunnel.stop();\n } catch {\n // Ignore stop errors — the process may already be dead.\n }\n currentTunnel = newTunnel;\n consecutiveFailures = 0;\n // FIX 1: arm child-exit watcher on the newly spawned tunnel too.\n armChildExitWatch(newTunnel);\n log(`[ait-debug] tunnel reissued — new relay: ${newTunnel.wssUrl}\\n`);\n onReissue(newTunnel);\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n log(`[ait-debug] tunnel reissue attempt ${reissueAttempts} failed: ${message}\\n`);\n\n if (reissueAttempts >= MAX_REISSUE_ATTEMPTS) {\n clearInterval(handle);\n stopped = true;\n const droppedAt = new Date().toISOString();\n log(\n `[ait-debug] tunnel permanently dropped after ${MAX_REISSUE_ATTEMPTS} reissue attempts — ` +\n 'restart the debug server to continue (npx @ait-co/devtools devtools-mcp).\\n',\n );\n onPermanentDrop(droppedAt);\n }\n }\n };\n\n // FIX 1: register exit watcher on a QuickTunnel so unexpected child death\n // immediately kicks off reissue without waiting for the probe interval.\n const armChildExitWatch = (t: QuickTunnel): void => {\n t.onUnexpectedExit((code) => {\n if (stopped) return;\n log(\n `[ait-debug] cloudflared child exited unexpectedly (code=${code}) — triggering immediate reissue\\n`,\n );\n // Set failures to threshold so the next interval probe also sees a clean\n // state; the actual reissue happens immediately below.\n consecutiveFailures = failuresBeforeReissue;\n void doReissueOrDrop();\n });\n };\n\n // Arm the watcher on the initial tunnel.\n armChildExitWatch(initialTunnel);\n\n const handle = setInterval(() => {\n void (async () => {\n if (stopped) return;\n\n const httpsUrl = currentTunnel.url;\n const alive = await probe(httpsUrl);\n\n if (alive) {\n // Tunnel responded — reset failure counter.\n if (consecutiveFailures > 0) {\n log('[ait-debug] tunnel health probe: tunnel recovered\\n');\n }\n consecutiveFailures = 0;\n reissueAttempts = 0;\n return;\n }\n\n consecutiveFailures += 1;\n log(\n `[ait-debug] tunnel health probe: failure ${consecutiveFailures}/${failuresBeforeReissue} (url=${httpsUrl})\\n`,\n );\n\n if (consecutiveFailures < failuresBeforeReissue) {\n // Tolerate transient failures — wait for the next interval.\n return;\n }\n\n // Threshold reached — attempt reissue.\n await doReissueOrDrop();\n })();\n }, probeIntervalMs);\n\n return {\n stop() {\n stopped = true;\n clearInterval(handle);\n },\n };\n}\n\n/**\n * Builds a `TunnelStatus` snapshot that includes drop state.\n *\n * Convenience helper for callers (debug-server) that maintain a mutable\n * `tunnelStatus` object — keeps the shape construction in one place.\n */\nexport function makeTunnelStatus(\n up: boolean,\n wssUrl: string | null,\n droppedAt: string | null = null,\n reissueAttempts = 0,\n): TunnelStatus {\n return { up, wssUrl, droppedAt, reissueAttempts };\n}\n","/**\n * @ait-co/devtools attach orchestrator (issue #684 §2).\n *\n * The relay-attach orchestration — minting a fresh-TOTP attach URL, validating\n * an env's attach preconditions, rendering the QR (dashboard or text), opening\n * the browser, and waiting (segmented, with in-call TOTP re-mint) for a real\n * device to attach — used to live INLINE in `createDebugServer`'s closure\n * (`src/mcp/debug-server.ts`). It read six closure variables, so it could not be\n * reused outside the MCP `start_attach` handler.\n *\n * This module lifts that orchestration to MODULE LEVEL and promotes those six\n * closure variables to an explicit {@link AttachDeps} object. The behavior is\n * IDENTICAL — this is a pure extraction (issue #684 PR1). Two adapters consume\n * it:\n *\n * - the MCP `start_attach` CallTool handler (`debug-server.ts`) — assembles\n * `attachDeps` from its own closure variables and calls these functions;\n * - (forthcoming, PR3) the `devtools-test` CLI — boots a single relay family\n * and assembles `attachDeps` without a dashboard/SSE callback.\n *\n * The orchestrator imports NEITHER adapter (zero reverse dependency). It pulls\n * only modules already in the MCP-daemon graph (react-free) — see the\n * install-graph invariant in devtools `CLAUDE.md`.\n *\n * SECRET-HANDLING: the TOTP code is minted fresh at call time and rides inside\n * the assembled URL's `at=` param only — never logged or returned separately.\n * Tunnel/scheme hosts + relay wss URLs are NEVER logged. The browser is opened\n * on a `http://127.0.0.1:<port>` URL only.\n *\n * Node-only.\n */\n\nimport type { CdpConnection } from './cdp-connection.js';\nimport {\n buildDeepLinkAttachUrl,\n buildLauncherAttachUrl,\n validateSchemeAuthority,\n} from './deeplink.js';\nimport type { McpEnvironment } from './environment.js';\nimport { classifyToolError, mcpError } from './errors.js';\nimport { logInfo } from './log.js';\nimport type { QrHttpServer } from './qr-http-server.js';\nimport {\n canOpenBrowser as defaultCanOpenBrowser,\n listPages,\n openQrInBrowser,\n type TunnelStatus,\n} from './tools.js';\nimport { generateTotp, RELAY_VERIFY_SKEW_STEPS } from './totp.js';\nimport { renderQr } from './tunnel.js';\n\n/**\n * Maximum age (ms) of a page's `lastSeenAt` before it is treated as a ghost\n * and excluded from the `wait_for_attach` short-circuit in `start_attach`\n * (issue #610).\n *\n * Rationale: the env-2 relay is owned by the dev server (unplugin), so every\n * `dev:phone:cdp` restart produces a new quick-tunnel. The old relay goes\n * offline immediately, but the daemon's warm `ChiiCdpConnection` still lists\n * the last-seen target — its `lastSeenAt` freezes at the moment the old relay\n * died. A 5-minute threshold is large enough to be invisible in normal usage\n * (active CDP sessions see a message every few seconds) while being small\n * enough to catch a relay that went down before the daemon was re-entered.\n *\n * Injectable for tests via {@link AttachDeps.stalePageThresholdMs}.\n */\nexport const RELAY_SANDBOX_STALE_PAGE_MS = 5 * 60 * 1_000; // 5 minutes\n\n/**\n * Segment length (ms) of the `start_attach` wait loop (issue #626 — TOTP in-call\n * re-mint). The single-shot `wait_for_attach` of the old attach tool could\n * not re-mint a TOTP code mid-wait; `start_attach` decomposes the wait into\n * SEGMENT_MS slices so it can detect an aging code between slices and re-mint a\n * fresh one without the agent re-calling the tool. 30 s = one TOTP step.\n */\nexport const START_ATTACH_SEGMENT_MS = 30_000;\n\n/**\n * Elapsed-since-mint threshold (ms) at which `start_attach` re-mints a fresh\n * TOTP code during its wait loop (issue #626). The relay gate accepts a code for\n * `RELAY_VERIFY_SKEW_STEPS` (6) × 30 s = 180 s backwards from issuance; we re-mint\n * at 150 s to leave a 30 s margin so a phone scan never lands on an expired code.\n */\nexport const START_ATTACH_REMINT_THRESHOLD_MS = 150_000;\n\n/**\n * Predicate used by `start_attach`'s `wait_for_attach` loop to decide\n * whether the relay-sandbox connection has a genuinely fresh page attached.\n *\n * Stale-ghost gating (issue #610): when the dev server restarts with a new\n * quick-tunnel, the warm `ChiiCdpConnection` still lists the last-seen target\n * but its `lastSeenAt` is frozen. A page whose `lastSeenAt` exceeds\n * `stalePageThresholdMs` is a ghost from the dead relay — it must NOT\n * short-circuit `wait_for_attach`.\n *\n * Rules:\n * - `pages.length === 0` → false (nothing attached).\n * - Connection has no `getLastSeenAt` (test fakes, local-browser) → falls back\n * to `pages.length > 0` (regression-safe).\n * - `seenMs === null` → treat as fresh (no CDP message received yet, first\n * message pending — the connection is alive).\n * - Otherwise: at least one page must satisfy `nowMs - seenMs <=\n * stalePageThresholdMs`.\n *\n * Exported for unit testing.\n */\nexport function isSandboxPageFresh(\n pages: ReadonlyArray<{ id: string }>,\n getLastSeenAt: ((id: string) => number | null) | null,\n nowMs: number,\n stalePageThresholdMs: number,\n): boolean {\n if (pages.length === 0) return false;\n if (getLastSeenAt === null) return true;\n return pages.some((p) => {\n const seenMs = getLastSeenAt(p.id);\n // null = no CDP message yet (fresh attach, first message pending) → fresh.\n if (seenMs === null) return true;\n return nowMs - seenMs <= stalePageThresholdMs;\n });\n}\n\n/**\n * Parses `_deploymentId` from the query string of a scheme URL.\n *\n * Returns `null` when the param is absent or empty — callers treat that as\n * \"no deploymentId filter; match on presence only\" and fall back to the\n * original `attachedPages.length > 0` condition.\n *\n * SECRET-HANDLING: deploymentId is a public identifier and may appear in\n * debug output. Never confuse it with TOTP secrets or relay tunnel URLs.\n */\nexport function extractDeploymentId(schemeUrl: string): string | null {\n try {\n // scheme URLs like `intoss-private://host?_deploymentId=xxx` are not\n // parseable by `new URL()` in all environments, so we extract the query\n // string manually.\n const qIndex = schemeUrl.indexOf('?');\n if (qIndex === -1) return null;\n const params = new URLSearchParams(schemeUrl.slice(qIndex + 1));\n const id = params.get('_deploymentId');\n return id && id.length > 0 ? id : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Attach URL components — stored in the run functions instead of a finished\n * URL string so that `getDashboardState` can RE-MINT a fresh TOTP code on\n * every call (Defect 1: baked codes expire → relay 401 reason:'auth').\n *\n * `kind: 'launcher'` = env 2 (launcher PWA QR, `buildLauncherAttachUrl`).\n * `kind: 'scheme'` = env 3/4 (intoss-private deep-link, `buildDeepLinkAttachUrl`).\n *\n * SECRET-HANDLING: these components contain tunnel/scheme hosts. They are\n * NEVER logged. The TOTP code is minted fresh at call time via `mintAttachUrl`\n * and rides inside the assembled URL's `at=` param only.\n */\nexport type AttachUrlParts =\n | {\n kind: 'launcher';\n tunnelHttpUrl: string;\n wssUrl: string;\n appName?: string;\n selfdebug?: boolean;\n }\n | { kind: 'scheme'; schemeUrl: string; wssUrl: string };\n\n/** TOTP metadata surfaced in an attach tool result (code value never included). */\nexport interface AttachTotpMeta {\n enabled: true;\n ttlSeconds: number;\n expiresAt: string;\n}\n\n/** The tool-result shape returned by every CallTool handler branch. */\nexport type McpResult = {\n content: Array<\n { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }\n >;\n isError?: boolean;\n};\n\n/**\n * Output of the {@link prepareAttach} helper (issue #626) — the shared validation +\n * component bundle that the env-2 (relay-mobile) and env-3 (relay-dev) attach\n * paths both produce. On any validation failure the helper returns\n * `{ ok: false, error }` with a ready-to-return `McpResult`.\n */\nexport type PrepareAttachResult =\n | {\n ok: true;\n parts: AttachUrlParts;\n isMatchingPage: (pages: ReturnType<CdpConnection['listTargets']>) => boolean;\n buildTimeoutError: (\n baseText: string,\n timeoutSec: number,\n observed: ReturnType<CdpConnection['listTargets']>,\n ) => string;\n authorityWarning: string | undefined;\n totpMeta: AttachTotpMeta | undefined;\n }\n | { ok: false; error: McpResult };\n\n/**\n * Explicit dependencies for the attach orchestrator (issue #684 §2.2) — the six\n * closure variables `prepareAttach`/`renderAndMaybeWait` used to read off\n * `createDebugServer`, promoted to a passable object so the orchestration is\n * reusable outside the MCP handler.\n *\n * Production (`createDebugServer`) assembles this from its own closure\n * variables; the CLI (PR3) assembles it from a booted relay family.\n */\nexport interface AttachDeps {\n /** relay 터널 up/wssUrl. CLI는 bootRelayFamily().getTunnelStatus를 그대로 넘긴다. */\n getTunnelStatus(): TunnelStatus;\n /**\n * Late-bound TOTP secret accessor (issue #396) — read from env AT CALL TIME so\n * the project-local `.ait_relay` secret loaded by `switchMode` is visible.\n * SECRET-HANDLING: the returned value is used only for the `at=` code, never logged.\n */\n getTotpSecret(): string | undefined;\n /**\n * 로컬 127.0.0.1 QR 대시보드. 없으면 text QR fallback (headless/CLI-no-GUI).\n */\n qrHttpServer?: QrHttpServer;\n /**\n * attach URL 컴포넌트 확정 직후 콜백 (대시보드 SSE push). CLI는 미주입(no-op).\n * 완성된 URL이 아니라 컴포넌트를 전달하는 이유: `getDashboardState`가 매 호출 시\n * 최신 TOTP 코드를 freshly mint해 QR을 갱신하기 위함이다.\n */\n onAttachUrlBuilt?: (parts: AttachUrlParts) => void;\n /** ghost page 가드 임계 (#610). 기본 {@link RELAY_SANDBOX_STALE_PAGE_MS}. */\n stalePageThresholdMs?: number;\n /** 테스트 시계 주입. 기본 () => Date.now(). */\n nowMs?: () => number;\n /**\n * GUI 감지 override (테스트/headless/CLI `--headless`). 기본 `canOpenBrowser`.\n * `renderAndMaybeWait`이 브라우저 자동 열기 가능 여부 판정에 사용한다.\n */\n canOpenBrowser?: () => boolean;\n}\n\n/** Resolves `deps.stalePageThresholdMs` to its default. */\nfunction resolveStalePageThresholdMs(deps: AttachDeps): number {\n return deps.stalePageThresholdMs ?? RELAY_SANDBOX_STALE_PAGE_MS;\n}\n\n/** Resolves `deps.nowMs` to its default (`Date.now`). */\nfunction resolveNowMs(deps: AttachDeps): () => number {\n return deps.nowMs ?? (() => Date.now());\n}\n\n/** Resolves `deps.canOpenBrowser` to the module default. */\nfunction resolveCanOpenBrowser(deps: AttachDeps): () => boolean {\n return deps.canOpenBrowser ?? defaultCanOpenBrowser;\n}\n\n/**\n * Waits for the first target matching `filterFn` to attach, using the\n * event-driven `waitForFirstTarget()` when the connection supports it\n * (interface-optional member, present on `ChiiCdpConnection`), or falling\n * back to a polling loop for connections that don't implement it (test fakes,\n * `LocalCdpConnection`).\n *\n * This eliminates the polling-only race that previously caused `wait_for_attach`\n * to resolve before the relay had observed the first inbound CDP message from\n * the phone.\n *\n * Timeout note: callers (e.g. the `start_attach` path) always pass an\n * explicit `timeoutMs`, sourced from the factory's `waitForAttachTimeoutMs`\n * (default 60 000). That value is forwarded to `waitForFirstTarget`, so it\n * overrides that method's own 90 000 signature default — the effective\n * wait on the tool path is 60 s, not 90 s.\n *\n * @param connection - The CDP connection (production or fake).\n * @param filterFn - Resolves when this predicate is satisfied.\n * @param timeoutMs - Maximum wait time in ms.\n * @param pollIntervalMs - Fallback poll interval for connections without waitForFirstTarget.\n */\nexport function waitForAttachWithEvents(\n connection: CdpConnection,\n filterFn: (targets: ReturnType<CdpConnection['listTargets']>) => boolean,\n timeoutMs: number,\n pollIntervalMs = 1_000,\n): Promise<ReturnType<CdpConnection['listTargets']>> {\n // Use event-driven path when available (CdpConnection.waitForFirstTarget is\n // optional; ChiiCdpConnection implements it, LocalCdpConnection and test fakes do not).\n if (connection.waitForFirstTarget) {\n return connection.waitForFirstTarget(filterFn, timeoutMs, pollIntervalMs);\n }\n // Generic fallback for connections without waitForFirstTarget\n // (test fakes, LocalCdpConnection — they don't emit 'target:attached').\n return new Promise<ReturnType<CdpConnection['listTargets']>>((resolve, reject) => {\n const deadline = Date.now() + timeoutMs;\n let settled = false;\n const poll = setInterval(() => {\n const targets = connection.listTargets();\n if (filterFn(targets)) {\n settled = true;\n clearInterval(poll);\n resolve(targets);\n } else if (Date.now() >= deadline) {\n settled = true;\n clearInterval(poll);\n reject(new Error(`waitForAttachWithEvents: 타임아웃 (${timeoutMs}ms)`));\n }\n }, pollIntervalMs);\n // Also check immediately.\n const targets = connection.listTargets();\n if (!settled && filterFn(targets)) {\n settled = true;\n clearInterval(poll);\n resolve(targets);\n }\n });\n}\n\n/**\n * Synthesizes an attach URL from stored components with a FRESHLY-minted TOTP\n * code (issue #626 §3/§4 — the single mint point). Reads the late-bound secret\n * via `deps.getTotpSecret()` so the project-local `.ait_relay` secret loaded by\n * `switchMode` is visible. SECRET-HANDLING: the minted code rides inside the\n * URL's `at=` param only — never logged or returned separately.\n */\nexport function mintAttachUrl(deps: AttachDeps, parts: AttachUrlParts): string {\n const secret = deps.getTotpSecret();\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\n/** Builds the fresh TOTP metadata (expiresAt window) for a tool result. */\nexport function buildTotpMeta(deps: AttachDeps): AttachTotpMeta | undefined {\n const secret = deps.getTotpSecret();\n if (secret === undefined || secret === '') return undefined;\n const STEP_SECONDS = 30;\n const expiresAtMs = resolveNowMs(deps)() + RELAY_VERIFY_SKEW_STEPS * STEP_SECONDS * 1000;\n return {\n enabled: true,\n ttlSeconds: RELAY_VERIFY_SKEW_STEPS * STEP_SECONDS,\n expiresAt: new Date(expiresAtMs).toISOString(),\n };\n}\n\n/**\n * Env-specific validation + component bundle for `start_attach` (issue #626).\n * Branches on `env`: `relay-mobile` reads AIT_TUNNEL_BASE_URL + builds launcher\n * parts; `relay-dev` requires scheme_url + builds scheme parts. Returns\n * `{ ok: false, error }` with a ready McpResult on any failure.\n */\nexport async function prepareAttach(\n deps: AttachDeps,\n env: McpEnvironment,\n args: Record<string, unknown> | undefined,\n conn: CdpConnection,\n): Promise<PrepareAttachResult> {\n const { getTunnelStatus, getTotpSecret } = deps;\n const stalePageThresholdMs = resolveStalePageThresholdMs(deps);\n const nowMs = resolveNowMs(deps);\n const selfdebug = args?.selfdebug === true;\n\n // Guard: selfdebug is a launcher-only feature — reject early for env 3\n // so the caller gets a clear diagnostic instead of silently ignoring it.\n if (selfdebug && env !== 'relay-mobile') {\n return {\n ok: false,\n error: mcpError(\n 'start_attach: selfdebug=true는 env 2 / relay-sandbox 전용 기능입니다. ' +\n '현재 환경(env 3)에서는 launcher가 없어 self-target 모드를 지원하지 않습니다. ' +\n 'launcher self-target이 필요하다면 relay-sandbox 모드로 전환하세요.',\n ),\n };\n }\n\n // ── relay-mobile branch (env 2 — launcher PWA QR) ──────────────────────\n if (env === 'relay-mobile') {\n // SECRET-HANDLING: AIT_TUNNEL_BASE_URL carries the app tunnel host —\n // NEVER echo it in error messages or logs. (#424) env wins; .ait_urls\n // is the fallback when env is unset.\n const rawProjectRoot = args?.projectRoot;\n const buildProjectRoot = typeof rawProjectRoot === 'string' ? rawProjectRoot : undefined;\n const envTunnelUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? '';\n let tunnelHttpUrl = envTunnelUrl;\n if (tunnelHttpUrl === '' && buildProjectRoot !== undefined) {\n const { readRelayUrls } = await import('./relay-url-store.js');\n const stored = await readRelayUrls({ projectRoot: buildProjectRoot });\n tunnelHttpUrl = stored?.tunnelBaseUrl ?? '';\n }\n if (tunnelHttpUrl === '') {\n return {\n ok: false,\n error: mcpError(\n 'start_attach(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. ' +\n 'dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. ' +\n '자동 발견이 되지 않을 경우 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 직접 전달하세요.',\n ),\n };\n }\n const tunnelStatus = getTunnelStatus();\n if (!tunnelStatus.up || tunnelStatus.wssUrl === null) {\n return {\n ok: false,\n error: mcpError(\n 'start_attach(mobile): relay wssUrl이 아직 설정되지 않았습니다. ' +\n 'unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.',\n ),\n };\n }\n\n // Defense-in-depth (#452): relay mode requires TOTP auth — fail-closed if\n // the secret is missing rather than issuing an unauthenticated attach URL.\n // SECRET-HANDLING: error message names the requirement only.\n const secret = getTotpSecret();\n if (secret === undefined || secret === '') {\n return {\n ok: false,\n error: mcpError(\n 'start_attach(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. ' +\n 'relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.',\n ),\n };\n }\n\n // Read the app name from projectRoot/package.json for the launcher\n // partner bar (#498). Failure to read is silently ignored (fail-open).\n let launcherAppName: string | undefined;\n if (buildProjectRoot !== undefined) {\n try {\n const { readFileSync } = await import('node:fs');\n const pkgRaw = readFileSync(`${buildProjectRoot}/package.json`, 'utf8');\n const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;\n const rawName = typeof pkg.name === 'string' ? pkg.name : '';\n const stripped = rawName.includes('/') ? rawName.slice(rawName.indexOf('/') + 1) : rawName;\n launcherAppName = stripped.trim() || undefined;\n } catch {\n // Silently ignore — fail-open.\n }\n }\n\n const parts: AttachUrlParts = {\n kind: 'launcher',\n tunnelHttpUrl,\n wssUrl: tunnelStatus.wssUrl,\n appName: launcherAppName,\n ...(selfdebug ? { selfdebug: true } : {}),\n };\n\n // In mobile mode, deploymentId filtering is not applicable — match on\n // presence only, but with a stale-ghost guard (issue #610). The env-2\n // relay is owned by the dev server; a restart leaves a frozen lastSeenAt.\n const connAsAny = conn as unknown as {\n getTargetLastSeenAt?: (id: string) => number | null;\n };\n const getLastSeenAt =\n typeof connAsAny.getTargetLastSeenAt === 'function'\n ? (id: string) => (connAsAny.getTargetLastSeenAt as (id: string) => number | null)(id)\n : null;\n const callNow = nowMs();\n const isMatchingPage = (pages: ReturnType<CdpConnection['listTargets']>): boolean =>\n isSandboxPageFresh(pages, getLastSeenAt, callNow, stalePageThresholdMs);\n const buildTimeoutError = (\n baseText: string,\n timeoutSec: number,\n observed: ReturnType<CdpConnection['listTargets']>,\n ): string => {\n const observedUrls = observed\n .slice(0, 3)\n .map((p) => p.url.slice(0, 80))\n .join(', ');\n const observedNote =\n observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : '';\n return (\n `${baseText}\\n\\nNo page attached within ${timeoutSec}s${observedNote} — ` +\n 'launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.'\n );\n };\n\n return {\n ok: true,\n parts,\n isMatchingPage,\n buildTimeoutError,\n authorityWarning: undefined, // no scheme authority for launcher\n totpMeta: buildTotpMeta(deps),\n };\n }\n // ── end relay-mobile branch ────────────────────────────────────────────\n\n // ── relay-dev branch (env 3 — intoss-private QR) ───────────────────────\n const schemeUrl = args?.scheme_url;\n if (typeof schemeUrl !== 'string' || schemeUrl === '') {\n return {\n ok: false,\n error: mcpError(\n 'start_attach: scheme_url이 비어 있습니다. ' +\n '`ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요. ' +\n '환경 2(mobile)라면 scheme_url 대신 AIT_TUNNEL_BASE_URL을 설정하세요.',\n ),\n };\n }\n\n // Defense-in-depth (#452): relay-dev mode requires TOTP auth.\n // SECRET-HANDLING: error message names the requirement only.\n {\n const relaySecret = getTotpSecret();\n if (relaySecret === undefined || relaySecret === '') {\n return {\n ok: false,\n error: mcpError(\n 'start_attach(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. ' +\n 'relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.',\n ),\n };\n }\n }\n\n // Tunnel-down check (the old buildAttachUrl threw here; we fail-fast with a\n // structured error to keep prepareAttach side-effect-free).\n const tunnelForBuild = getTunnelStatus();\n if (!tunnelForBuild.up || tunnelForBuild.wssUrl === null) {\n return { ok: false, error: classifyToolError(new Error('tunnel-down:'), 'start_attach') };\n }\n const authorityWarning = validateSchemeAuthority(schemeUrl) ?? undefined;\n\n const parts: AttachUrlParts = {\n kind: 'scheme',\n schemeUrl,\n wssUrl: tunnelForBuild.wssUrl,\n };\n\n // Parse _deploymentId to filter stale attached pages (null → presence-only).\n const deploymentId = extractDeploymentId(schemeUrl);\n if (!deploymentId) {\n logInfo('tool.call', {\n tool: 'start_attach',\n msg: 'no _deploymentId in scheme_url; matching on presence only',\n });\n }\n const isMatchingPage = (pages: ReturnType<CdpConnection['listTargets']>): boolean => {\n if (pages.length === 0) return false;\n if (deploymentId === null) return true;\n return pages.some((p) => p.url.includes(deploymentId));\n };\n const buildTimeoutError = (\n baseText: string,\n timeoutSec: number,\n observed: ReturnType<CdpConnection['listTargets']>,\n ): string => {\n const observedUrls = observed\n .slice(0, 3)\n .map((p) => p.url.slice(0, 80))\n .join(', ');\n const observedNote =\n observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : '';\n const deploymentNote = deploymentId ? ` matching deploymentId=${deploymentId}` : '';\n return (\n `${baseText}\\n\\nNo page${deploymentNote} attached within ${timeoutSec}s${observedNote} — ` +\n 'call list_pages to retry.'\n );\n };\n\n return {\n ok: true,\n parts,\n isMatchingPage,\n buildTimeoutError,\n authorityWarning,\n totpMeta: buildTotpMeta(deps),\n };\n}\n\n/**\n * QR render + browser open + segmented attach wait with in-call TOTP re-mint\n * (issue #626 §3). Shared by env-2 and env-3 (4 render paths:\n * headless / browser-opened / browser-open-failed / no-http-server).\n *\n * The wait is decomposed into `START_ATTACH_SEGMENT_MS` slices. Between slices,\n * if the current TOTP code has aged past `START_ATTACH_REMINT_THRESHOLD_MS`,\n * a fresh URL is minted via `mintAttachUrl` and pushed to the dashboard via\n * `onAttachUrlBuilt` (SSE refresh — NO browser re-open). The `reminted` count\n * rides in the success/timeout result.\n *\n * SECRET-HANDLING: attachUrl encodes tunnel/scheme host + the TOTP `at=` code\n * in the QR payload only. The browser is opened on a 127.0.0.1 URL only. The\n * tool result carries `totp.expiresAt` + `reminted` count — never the code.\n */\nexport async function renderAndMaybeWait(\n deps: AttachDeps,\n prep: Extract<PrepareAttachResult, { ok: true }>,\n waitForAttach: boolean,\n callTimeoutMs: number,\n conn: CdpConnection,\n): Promise<McpResult> {\n const { getTunnelStatus, qrHttpServer, onAttachUrlBuilt } = deps;\n const nowMs = resolveNowMs(deps);\n const canOpenBrowserFn = resolveCanOpenBrowser(deps);\n const { parts, isMatchingPage, buildTimeoutError, authorityWarning, totpMeta } = prep;\n\n // Initial mint + dashboard notify (components, not a finished URL, so\n // getDashboardState re-mints on every SSE push — Defect 1).\n let attachUrl = mintAttachUrl(deps, parts);\n onAttachUrlBuilt?.(parts);\n let totpIssuedAt = nowMs();\n let reminted = 0;\n const relayUrl = parts.wssUrl;\n\n const header =\n 'This tool result is shown to the user directly — do NOT re-print the QR below in your reply (it wastes output tokens). Just tell the user to scan the QR in this output (Ctrl+O to expand if collapsed).';\n const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\\n\\n` : '';\n const guiAvailable = canOpenBrowserFn();\n\n /** Builds the totp object surfaced in results (fresh expiresAt + reminted). */\n const totpResult = (): Record<string, unknown> | undefined => {\n if (!totpMeta) return undefined;\n const STEP_SECONDS = 30;\n const expiresAtMs = totpIssuedAt + RELAY_VERIFY_SKEW_STEPS * STEP_SECONDS * 1000;\n return {\n enabled: true,\n ttlSeconds: totpMeta.ttlSeconds,\n expiresAt: new Date(expiresAtMs).toISOString(),\n ...(reminted > 0 ? { reminted } : {}),\n };\n };\n\n /**\n * Segmented wait with TOTP re-mint (issue #626 §3). Resolves with the\n * attached page list, or rejects on timeout. Between SEGMENT_MS slices it\n * re-mints when the code has aged past the threshold (max ~4 re-mints over\n * 600 s). Returns immediately once a matching page attaches (no re-mint).\n */\n async function waitWithRemint(): Promise<ReturnType<CdpConnection['listTargets']>> {\n const deadline = nowMs() + callTimeoutMs;\n // Immediate check — already attached resolves without any wait/re-mint.\n if (isMatchingPage(conn.listTargets())) return conn.listTargets();\n for (;;) {\n const remaining = deadline - nowMs();\n if (remaining <= 0) {\n throw new Error(`start_attach: 타임아웃 (${callTimeoutMs}ms)`);\n }\n const segmentMs = Math.min(START_ATTACH_SEGMENT_MS, remaining);\n try {\n return await waitForAttachWithEvents(conn, isMatchingPage, segmentMs);\n } catch {\n // Segment elapsed without attach — re-mint if the code is aging, then\n // loop into the next segment. SECRET-HANDLING: code never logged.\n if (totpMeta && nowMs() - totpIssuedAt >= START_ATTACH_REMINT_THRESHOLD_MS) {\n attachUrl = mintAttachUrl(deps, parts);\n onAttachUrlBuilt?.(parts);\n totpIssuedAt = nowMs();\n reminted += 1;\n }\n }\n }\n }\n\n /**\n * Assembles the success result after a page attaches. `baseText` carries the\n * QR + pre-wait JSON block (the QR the user already scanned). The attach\n * itself ends the wait, so the QR is moot — what matters now is the final\n * TOTP state. If the segmented wait re-minted (issue #626 §3), surface the\n * post-wait `totp` block (fresh `expiresAt` + `reminted` count) so the result\n * reflects how many times the code rotated during the wait. SECRET-HANDLING:\n * the totp block carries expiresAt + reminted only — never the code value.\n */\n const successResult = (baseText: string): McpResult => {\n const pagesResult = listPages(conn, getTunnelStatus());\n const finalTotp = totpResult();\n const remintNote =\n finalTotp && reminted > 0 ? `\\n\\n${JSON.stringify({ totp: finalTotp }, null, 2)}` : '';\n return {\n content: [\n {\n type: 'text',\n text: `${baseText}\\n\\n${JSON.stringify(pagesResult, null, 2)}${remintNote}`,\n },\n ],\n };\n };\n\n /** Runs the wait (when requested) and returns success/timeout result. */\n const runWait = async (baseText: string): Promise<McpResult> => {\n if (!waitForAttach) {\n return { content: [{ type: 'text', text: baseText }] };\n }\n try {\n await waitWithRemint();\n } catch {\n const observed = conn.listTargets();\n return {\n content: [\n { type: 'text', text: buildTimeoutError(baseText, callTimeoutMs / 1000, observed) },\n ],\n isError: true,\n };\n }\n return successResult(baseText);\n };\n\n // Path 1: headless — no GUI, text QR only.\n if (!guiAvailable) {\n const headlessNote =\n 'GUI 환경이 감지되지 않았습니다 (headless/remote 환경). ' +\n '텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\\n\\n';\n const qr = await renderQr(attachUrl);\n const baseText = `${warningPrefix}${headlessNote}${header}\\n${JSON.stringify({ attachUrl, relayUrl, ...(totpResult() ? { totp: totpResult() } : {}) }, null, 2)}\\n\\n${qr}`;\n return runWait(baseText);\n }\n\n // Path 2 / 3: GUI + HTTP server — open the dashboard in the browser.\n if (guiAvailable && qrHttpServer) {\n const httpUrl = qrHttpServer.buildAttachPageUrl(attachUrl);\n const pngUrl = `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`;\n const browserResult = await openQrInBrowser(httpUrl, pngUrl);\n\n if (browserResult.opened) {\n const retriedNote = browserResult.retried ? ' (1회 retry 후 성공)' : '';\n const openResult = {\n attempted: true,\n succeeded: true,\n ...(browserResult.retried ? { retried: true } : {}),\n };\n const shortText =\n `${warningPrefix}${header}\\n` +\n `${JSON.stringify({ relayUrl, openResult, ...(totpResult() ? { totp: totpResult() } : {}) }, null, 2)}\\n\\n` +\n `브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\\n` +\n `URL: ${browserResult.httpUrl}`;\n return runWait(shortText);\n }\n\n // Browser open failed — structured error + URL hint + text QR fallback.\n const openResult = {\n attempted: true,\n succeeded: false,\n failureReason: browserResult.error ?? '브라우저 실행 후보 모두 실패',\n pngUrl: browserResult.pngUrl,\n ...(browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}),\n };\n const stderrNote = browserResult.stderrSummary\n ? `\\nstderr: ${browserResult.stderrSummary}`\n : '';\n const fallbackNote =\n `브라우저 자동 열기에 실패했습니다. ` +\n `다음 URL을 직접 브라우저에서 여세요:\\n${browserResult.httpUrl}\\n` +\n `또는 PNG로 받기: ${browserResult.pngUrl}` +\n stderrNote +\n '\\n\\n';\n const qr = await renderQr(attachUrl);\n const baseText = `${warningPrefix}${fallbackNote}${header}\\n${JSON.stringify({ attachUrl, relayUrl, openResult, ...(totpResult() ? { totp: totpResult() } : {}) }, null, 2)}\\n\\n${qr}`;\n return runWait(baseText);\n }\n\n // Path 4: GUI but no HTTP server — text QR fallback.\n const qr = await renderQr(attachUrl);\n const baseText = `${warningPrefix}${header}\\n${JSON.stringify({ attachUrl, relayUrl, ...(totpResult() ? { totp: totpResult() } : {}) }, null, 2)}\\n\\n${qr}`;\n return runWait(baseText);\n}\n\n/**\n * Builds a self-contained IIFE DOM expression that renders a dismissible\n * \"Debugger Connected\" badge on the bottom-left of the phone screen.\n *\n * **Pure function** — returns a JS expression string; does NOT inject it.\n * Injection is performed by {@link injectDebugIndicator} in `cell.ts`.\n *\n * The expression, when evaluated on the page, does the following:\n * 1. Early-returns if `#__ait_debug_indicator` already exists (idempotent —\n * prevents duplicate badges on re-injection after page reload).\n * 2. Appends a fixed-position `<div id=\"__ait_debug_indicator\">` at the\n * bottom-left, accounting for safe-area insets.\n * 3. Attaches a `{ once: true }` `pointerdown` listener that removes the\n * badge on first touch — one-tap dismiss.\n *\n * The expression intentionally contains NO relay URLs, wss addresses, TOTP\n * codes, or any other secrets. It is pure DOM UI text only.\n *\n * SECRET-HANDLING: this expression contains no secrets, relay URLs, wss\n * addresses, or TOTP codes whatsoever — DOM label text only.\n *\n * @param opts.label - Badge text (default: `'Debugger Connected'`).\n * @returns A JS expression string suitable for `Runtime.evaluate`.\n */\nexport function buildIndicatorExpression(opts?: { label?: string }): string {\n const label = opts?.label ?? 'Debugger Connected';\n // JSON.stringify ensures the label is safely embedded even if it contains\n // quotes or backslashes.\n const safeLabel = JSON.stringify(label);\n return (\n `(() => {` +\n // Idempotent guard — prevents duplicates on re-injection.\n `if (document.getElementById('__ait_debug_indicator')) return;` +\n `const el = document.createElement('div');` +\n `el.id = '__ait_debug_indicator';` +\n // Position: fixed, bottom-left with safe-area inset support.\n `el.style.cssText = [` +\n `'position:fixed',` +\n `'left:max(12px,calc(env(safe-area-inset-left,0px) + 8px))',` +\n `'bottom:max(12px,calc(env(safe-area-inset-bottom,0px) + 8px))',` +\n `'z-index:2147483647',` +\n `'background:#e5484d',` +\n `'color:#fff',` +\n `'font:bold 11px/1 system-ui,sans-serif',` +\n `'padding:5px 9px',` +\n `'border-radius:6px',` +\n `'pointer-events:auto',` +\n `'user-select:none',` +\n `].join(';');` +\n `el.textContent = ${safeLabel};` +\n // One-tap dismiss — removes itself on the first touch/click.\n `el.addEventListener('pointerdown', () => el.remove(), { once: true });` +\n `document.body.appendChild(el);` +\n `})()`\n );\n}\n"],"mappings":"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"}