@ait-co/devtools 0.1.70 → 0.1.72

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 (65) hide show
  1. package/dist/{chii-relay-D5Hc0G39.cjs → chii-relay-BcnVJBqm.cjs} +76 -3
  2. package/dist/chii-relay-BcnVJBqm.cjs.map +1 -0
  3. package/dist/{chii-relay-BcWDKbQ1.js → chii-relay-DSVG4Ui1.js} +76 -3
  4. package/dist/chii-relay-DSVG4Ui1.js.map +1 -0
  5. package/dist/devtools-opener-BbUXBzgA.js.map +1 -1
  6. package/dist/devtools-opener-Bp671YXu.cjs.map +1 -1
  7. package/dist/devtools-opener-D84kZFtR.js.map +1 -1
  8. package/dist/devtools-opener-h6A-UjzC.cjs.map +1 -1
  9. package/dist/mcp/cli.js +190 -62
  10. package/dist/mcp/cli.js.map +1 -1
  11. package/dist/mcp/server.js +2 -2
  12. package/dist/mcp/server.js.map +1 -1
  13. package/dist/mock/index.d.ts +24 -1
  14. package/dist/mock/index.d.ts.map +1 -1
  15. package/dist/mock/index.js +89 -1
  16. package/dist/mock/index.js.map +1 -1
  17. package/dist/panel/index.js +4 -4
  18. package/dist/panel/index.js.map +1 -1
  19. package/dist/{qr-http-server-JjGU81q7.js → qr-http-server-Bh9qkiRm.js} +5 -5
  20. package/dist/qr-http-server-Bh9qkiRm.js.map +1 -0
  21. package/dist/{qr-http-server-BIIMOcuU.cjs → qr-http-server-CG198dpc.cjs} +5 -5
  22. package/dist/qr-http-server-CG198dpc.cjs.map +1 -0
  23. package/dist/{qr-http-server-CeEzLS3g.js → qr-http-server-CwkTFbMV.js} +5 -5
  24. package/dist/qr-http-server-CwkTFbMV.js.map +1 -0
  25. package/dist/{qr-http-server-ClakYBO9.cjs → qr-http-server-DPOOrh6y.cjs} +5 -5
  26. package/dist/qr-http-server-DPOOrh6y.cjs.map +1 -0
  27. package/dist/{relay-secret-store-CLkF8Pa0.cjs → relay-secret-store-BFOEhsLO.cjs} +2 -2
  28. package/dist/{relay-secret-store-CLkF8Pa0.cjs.map → relay-secret-store-BFOEhsLO.cjs.map} +1 -1
  29. package/dist/{relay-secret-store-DBcKWUl9.js → relay-secret-store-C_LUxvAp.js} +2 -2
  30. package/dist/{relay-secret-store-DBcKWUl9.js.map → relay-secret-store-C_LUxvAp.js.map} +1 -1
  31. package/dist/{relay-secret-store-C4QQN5NA.js → relay-secret-store-D-W-WaSx.js} +2 -2
  32. package/dist/{relay-secret-store-C4QQN5NA.js.map → relay-secret-store-D-W-WaSx.js.map} +1 -1
  33. package/dist/{relay-url-store-COG2dSql.cjs → relay-url-store-2sy_l2bf.cjs} +2 -2
  34. package/dist/{relay-url-store-COG2dSql.cjs.map → relay-url-store-2sy_l2bf.cjs.map} +1 -1
  35. package/dist/{relay-url-store-WKfo0VQV.js → relay-url-store-DAh5KiJi.js} +2 -2
  36. package/dist/{relay-url-store-WKfo0VQV.js.map → relay-url-store-DAh5KiJi.js.map} +1 -1
  37. package/dist/{relay-url-store-Dq3vpd95.js → relay-url-store-DjKJJZ0d.js} +2 -2
  38. package/dist/{relay-url-store-Dq3vpd95.js.map → relay-url-store-DjKJJZ0d.js.map} +1 -1
  39. package/dist/{totp-D0a8VwoR.js → totp-BfVk8gQe.js} +6 -3
  40. package/dist/{totp-D0a8VwoR.js.map → totp-BfVk8gQe.js.map} +1 -1
  41. package/dist/totp-BxtxuEt4.js.map +1 -1
  42. package/dist/{totp-DA8vjAi7.cjs → totp-D4iTMA9U.cjs} +6 -3
  43. package/dist/{totp-DA8vjAi7.cjs.map → totp-D4iTMA9U.cjs.map} +1 -1
  44. package/dist/totp-D8f6qAEu.js +3 -0
  45. package/dist/totp-D9rndqg_.cjs.map +1 -1
  46. package/dist/{totp-BjtKFt88.js → totp-DbEfKQRi.js} +6 -3
  47. package/dist/{totp-BjtKFt88.js.map → totp-DbEfKQRi.js.map} +1 -1
  48. package/dist/{tunnel-DwVrcZ56.cjs → tunnel-BS6Td6f4.cjs} +3 -3
  49. package/dist/{tunnel-DwVrcZ56.cjs.map → tunnel-BS6Td6f4.cjs.map} +1 -1
  50. package/dist/{tunnel-aIy_7nWm.js → tunnel-CQdoKopR.js} +3 -3
  51. package/dist/{tunnel-aIy_7nWm.js.map → tunnel-CQdoKopR.js.map} +1 -1
  52. package/dist/unplugin/index.cjs +6 -6
  53. package/dist/unplugin/index.cjs.map +1 -1
  54. package/dist/unplugin/index.js +6 -6
  55. package/dist/unplugin/index.js.map +1 -1
  56. package/dist/unplugin/tunnel.cjs +1 -1
  57. package/dist/unplugin/tunnel.js +1 -1
  58. package/package.json +1 -1
  59. package/dist/chii-relay-BcWDKbQ1.js.map +0 -1
  60. package/dist/chii-relay-D5Hc0G39.cjs.map +0 -1
  61. package/dist/qr-http-server-BIIMOcuU.cjs.map +0 -1
  62. package/dist/qr-http-server-CeEzLS3g.js.map +0 -1
  63. package/dist/qr-http-server-ClakYBO9.cjs.map +0 -1
  64. package/dist/qr-http-server-JjGU81q7.js.map +0 -1
  65. package/dist/totp-CQFmgOhM.js +0 -3
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../../src/shared/parent-watcher.ts","../../src/unplugin/index.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 * @ait-co/devtools unplugin\n *\n * 모든 주요 번들러를 지원하는 단일 플러그인.\n * @apps-in-toss/web-framework → @ait-co/devtools/mock 으로 alias 설정.\n *\n * Usage:\n * import aitDevtools from '@ait-co/devtools/unplugin';\n *\n * // Vite\n * export default { plugins: [aitDevtools.vite()] };\n *\n * // Webpack / Next.js\n * config.plugins.push(aitDevtools.webpack());\n *\n * // Rspack\n * config.plugins.push(aitDevtools.rspack());\n *\n * // esbuild\n * { plugins: [aitDevtools.esbuild()] }\n *\n * // Rollup\n * { plugins: [aitDevtools.rollup()] }\n */\n\nimport { fileURLToPath } from 'node:url';\nimport { createUnplugin } from 'unplugin';\nimport { startParentWatcher } from '../shared/parent-watcher.js';\n\n/**\n * Resolve `@ait-co/devtools/mock` to its real file path at plugin-load time.\n *\n * Returning the bare specifier from `resolveId` would stop the bundler from\n * walking node_modules for it — Vite 8+ treats such a non-null string as the\n * final resolved id and serves it via the virtual `/@id/` prefix, which 404s\n * because we don't provide a `load` hook. Resolving to an absolute path here\n * lets every supported bundler load the file the normal way.\n */\nconst MOCK_PATH = (() => {\n try {\n return fileURLToPath(import.meta.resolve('@ait-co/devtools/mock'));\n } catch {\n // Fallback for runtimes where `import.meta.resolve` is unavailable.\n return '@ait-co/devtools/mock';\n }\n})();\n\nexport interface AitDevtoolsOptions {\n /**\n * 패널 자동 주입 여부 (default: true)\n * true이면 진입점에 floating panel import를 자동 추가한다.\n */\n panel?: boolean;\n /**\n * production 환경에서도 devtools를 강제로 활성화 (default: false)\n */\n forceEnable?: boolean;\n /**\n * mock alias 활성화 여부. default: true (development), false (production + forceEnable)\n */\n mock?: boolean;\n /**\n * Vite dev server에 MCP state endpoint를 추가할지 여부 (default: false).\n *\n * `true`로 설정하면:\n * - GET /api/ait-devtools/state — 마지막으로 브라우저가 push한 mock state 스냅샷 반환\n * - POST /api/ait-devtools/state — 브라우저 panel이 상태 변경 시 자동 push (panel 내부 처리)\n *\n * 이 endpoint를 `@ait-co/devtools` MCP stdio server가 읽어 AI 에이전트에 mock state를 노출한다.\n * Vite 전용: webpack/rspack/esbuild/rollup 환경에서는 무시된다.\n */\n mcp?: boolean;\n /**\n * Vite dev 서버를 Cloudflare quick tunnel(`*.trycloudflare.com`, 계정 불필요)로\n * 외부 노출해 실제 폰에서 미리보기. **Vite dev 모드 전용** — production은\n * `forceEnable`이어도 터널을 띄우지 않는다 (의도치 않은 노출 방지). 다른 번들러는\n * 무시. `true`면 기본 동작, 객체로 세부 설정 가능.\n */\n tunnel?:\n | boolean\n | {\n /** 노출할 포트 (미지정 시 dev 서버가 실제 listen한 포트 자동 감지). */\n port?: number;\n /** 터미널 ASCII QR 출력 (default: true). */\n qr?: boolean;\n /**\n * 환경 2(실기기 PWA)에 CDP 디버깅 배선 (default: false).\n *\n * `true`면 dev 서버 HTTP 터널과 **별도로** Chii relay를 띄우고 그 relay에\n * 두 번째 quick tunnel을 붙여, launcher QR deep-link에 `&debug=1&relay=<wss>`를\n * 실어 보낸다. 폰의 PWA iframe이 in-app debug gate를 통과해 target.js를 주입받고,\n * AI host MCP가 그 relay에 client로 붙으면 실기기 WebKit 위에서 CDP 디버깅이 열린다.\n * mock SDK는 그대로라 `call_sdk`는 환경 2에서 mock을 친다 (fidelity 사다리의\n * 설계 의도 — SDK fidelity가 필요하면 환경 3로 올라간다).\n */\n cdp?: boolean;\n };\n}\n\nconst FRAMEWORK_ID = '@apps-in-toss/web-framework';\nconst BRIDGE_ID = '@apps-in-toss/web-bridge'; // back-compat (2.x)\nconst ANALYTICS_ID = '@apps-in-toss/web-analytics'; // back-compat (2.x)\nconst WEBVIEW_BRIDGE_ID = '@apps-in-toss/webview-bridge'; // 3.0+\n\n/** MCP state endpoint path — browser panel POSTs here, MCP server GETs here */\nconst MCP_STATE_PATH = '/api/ait-devtools/state';\n\n/**\n * Resolves the effective tunnel option (#425).\n *\n * An explicit `tunnel` value (including `false`) always takes priority over\n * env vars — the `??` operator means `undefined` (= omitted) falls through,\n * but `false` / `true` / an object are preserved as-is (non-breaking).\n *\n * When the option is omitted:\n * - `AIT_TUNNEL=1` enables the base screen-preview tunnel.\n * - `AIT_TUNNEL_CDP=1` (requires `AIT_TUNNEL`) upgrades to the CDP relay.\n * - Neither set → `false` (disabled).\n *\n * Extracted as a pure function so it can be unit-tested without standing up\n * a full Vite dev server.\n *\n * @param explicit - The `tunnel` option as passed by the consumer (or `undefined` when omitted).\n * @param env - The process environment (injectable for testing).\n */\nexport function resolveTunnelOption(\n explicit: AitDevtoolsOptions['tunnel'],\n env: Record<string, string | undefined>,\n): AitDevtoolsOptions['tunnel'] {\n return explicit ?? (env.AIT_TUNNEL ? { cdp: !!env.AIT_TUNNEL_CDP } : false);\n}\n\nconst aitDevtoolsPlugin = createUnplugin((options?: AitDevtoolsOptions) => {\n const isDev = process.env.NODE_ENV !== 'production';\n const shouldEnable = isDev || (options?.forceEnable ?? false);\n const shouldMock = shouldEnable && (options?.mock ?? isDev);\n const shouldPanel = shouldEnable && (options?.panel ?? true);\n const shouldMcp = shouldEnable && (options?.mcp ?? false);\n\n // In-memory store for the last state snapshot pushed by the browser panel.\n // Only allocated when mcp: true to avoid any overhead in the common case.\n let lastState: string | null = null;\n\n // Tunnel is dev-only and Vite-only. Never under production — even with\n // forceEnable — so a production build can't accidentally expose itself.\n //\n // Tunnel toggle resolution (#425): an explicit `tunnel` option always wins;\n // when omitted, fall back to the AIT_TUNNEL / AIT_TUNNEL_CDP env vars so a\n // consumer needs no `tunnel:` line in vite.config to enable env-2 preview.\n // AIT_TUNNEL gates the base (screen preview); AIT_TUNNEL_CDP upgrades to the\n // CDP relay. Production safety is unchanged — the existing\n // `shouldTunnel = isDev && !!tunnelOpt` guard below still blocks prod builds.\n const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);\n const shouldTunnel = isDev && !!tunnelOpt;\n const tunnelConfig = typeof tunnelOpt === 'object' ? tunnelOpt : {};\n\n return {\n name: 'ait-co-devtools',\n enforce: 'pre' as const,\n\n resolveId(id: string) {\n if (!shouldMock) return null;\n // @apps-in-toss/web-framework → @ait-co/devtools/mock (absolute path)\n if (\n id === FRAMEWORK_ID ||\n id === WEBVIEW_BRIDGE_ID ||\n id === BRIDGE_ID ||\n id === ANALYTICS_ID\n ) {\n return MOCK_PATH;\n }\n return null;\n },\n\n transformInclude(id: string) {\n if (!shouldPanel) return false;\n // 진입점 파일에만 패널 import를 주입\n return (\n /\\.(tsx?|jsx?)$/.test(id) &&\n /\\/(main|index|entry|app)\\.[tj]sx?$/i.test(id) &&\n !id.includes('node_modules')\n );\n },\n\n transform(code: string) {\n // transformInclude가 이미 shouldPanel을 확인하지만, 안전망으로 유지\n if (!shouldPanel) return null;\n // 이미 패널이 import 되어있으면 스킵\n if (code.includes('@ait-co/devtools/panel')) return null;\n // transformInclude가 진입점 파일만 통과시키므로 바로 prepend\n return `import '@ait-co/devtools/panel';\\n${code}`;\n },\n\n // Vite-only: register the MCP state HTTP endpoint on the dev server, and\n // optionally start a Cloudflare quick tunnel once the dev server is listening.\n // Non-Vite bundlers do not have a dev server concept so this is silently\n // skipped (unplugin passes `vite` key only when building for Vite).\n vite: {\n config() {\n if (!shouldTunnel) return;\n // Vite blocks requests whose Host header isn't in `server.allowedHosts`\n // (defaults to localhost only). The quick-tunnel hostname is random per\n // run, so allow the whole `.trycloudflare.com` suffix while the tunnel\n // is on. (A leading `.` makes Vite match the domain and its subdomains.)\n return { server: { allowedHosts: ['.trycloudflare.com'] } };\n },\n\n configureServer(server: import('vite').ViteDevServer) {\n // MCP state endpoint: browser panel POSTs state here, MCP stdio server GETs it.\n if (shouldMcp) {\n server.middlewares.use(MCP_STATE_PATH, (req, res) => {\n // Allow Claude Code / AI agents (running locally) to read state\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type');\n\n if (req.method === 'OPTIONS') {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (req.method === 'GET') {\n if (lastState === null) {\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'No state received yet. Open the app in a browser first.',\n }),\n );\n return;\n }\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(lastState);\n return;\n }\n\n if (req.method === 'POST') {\n const chunks: Buffer[] = [];\n req.on('data', (chunk: Buffer) => chunks.push(chunk));\n req.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString('utf-8');\n // Validate it's parseable JSON before caching\n JSON.parse(body);\n lastState = body;\n res.writeHead(204);\n res.end();\n } catch {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Invalid JSON' }));\n }\n });\n return;\n }\n\n res.writeHead(405, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Method not allowed' }));\n });\n }\n\n // Tunnel: start a Cloudflare quick tunnel once the dev server is listening.\n if (shouldTunnel) {\n let tunnel: { stop: () => void } | null = null;\n // env-2 CDP wiring (tunnel.cdp): a second tunnel + Chii relay, torn\n // down alongside the HTTP tunnel. Fire-and-forget close on teardown.\n let relayTunnel: { stop: () => void } | null = null;\n let relay: { close: () => Promise<void> } | null = null;\n // env-2 HTML dashboard (issue #408): local 127.0.0.1 HTTP server that\n // serves the QR + connect-steps + FAQ page (env 3/4 UX parity), opened\n // in the browser when CDP is wired + GUI present. Torn down with the\n // tunnel. Only set when the dashboard actually started.\n let qrDashboard: { close: () => Promise<void> } | null = null;\n // env-2 URL file store (#424): captured after the first writeRelayUrls\n // call so cleanup() can call deleteRelayUrls without a re-import.\n // SECRET-HANDLING: the stored function reference never carries URL values.\n let relayUrlDeleteFn: ((projectRoot: string) => Promise<void>) | null = null;\n // #420: parent-PID watcher — self-terminate when vite's parent dies so\n // cloudflared children don't become zombies holding stale tunnels.\n let parentWatcher: { stop(): void } | null = null;\n const httpServer = server.httpServer;\n\n httpServer?.once('listening', () => {\n const address = httpServer?.address();\n const port =\n tunnelConfig.port ??\n (address && typeof address === 'object' ? address.port : undefined);\n if (!port) {\n console.warn(\n '[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.',\n );\n return;\n }\n // Dynamic import keeps `cloudflared` / `qrcode-terminal` off the\n // module graph unless the tunnel is actually used.\n import('./tunnel.js')\n .then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {\n const t = await startQuickTunnel(port);\n tunnel = t;\n\n // env-2 CDP: boot a Chii relay (OS-assigned local port) and a\n // second quick tunnel to it. The relay's https tunnel URL becomes\n // the `wss://` relay the launcher QR carries (&debug=1&relay=).\n let relayWssUrl: string | undefined;\n // SECRET-HANDLING: relayHttpUrl carries the relay host — never logged.\n let relayHttpUrl: string | undefined;\n if (tunnelConfig.cdp) {\n try {\n // Relay-auth baseline (issue #250): the env-2 CDP relay is\n // reachable over a public `*.trycloudflare.com` tunnel, so a\n // configured TOTP secret is MANDATORY and the relay enforces\n // it on every WS upgrade.\n //\n // First-run auto-mint (issue #394, project-local #396): if\n // AIT_DEBUG_TOTP_SECRET is not yet set, ensureRelaySecret()\n // mints a 256-bit random secret, persists it to the project-\n // local file <project>/.ait_relay (0600, anchored at the\n // nearest package.json directory above server.config.root),\n // and injects it into process.env so the following\n // assertRelayAuthConfigured() call succeeds. On subsequent\n // runs the persisted value is loaded silently — no manual\n // export needed. The MCP daemon reads the SAME file read-only\n // via loadRelaySecretReadOnly() when switching to a relay env.\n // SECRET-HANDLING: neither ensureRelaySecret nor the\n // guard/predicate log the secret value.\n const { ensureRelaySecret } = await import('../mcp/relay-secret-store.js');\n await ensureRelaySecret({ projectRoot: server.config.root });\n const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import(\n '../mcp/totp.js'\n );\n assertRelayAuthConfigured();\n const verifyAuth = buildRelayVerifyAuth();\n const { startChiiRelay } = await import('../mcp/chii-relay.js');\n // Issue #467: this relay lives in the vite process, so the\n // MCP daemon's get_debug_status counter cannot see its 401s.\n // Surface a throttled hint in the vite terminal instead.\n // SECRET-HANDLING: fixed message only — no URL, code, host.\n let lastAuthRejectWarnAt = 0;\n const r = await startChiiRelay({\n port: 0,\n verifyAuth,\n onAuthReject: () => {\n const nowMs = Date.now();\n if (nowMs - lastAuthRejectWarnAt < 10_000) return;\n lastAuthRejectWarnAt = nowMs;\n console.warn(\n '[@ait-co/devtools] tunnel: relay 인증(TOTP) 거부 감지 — 폰에서 QR을 다시 스캔하세요 (코드는 30초 주기로 만료)',\n );\n },\n });\n relay = r;\n const rt = await startQuickTunnel(r.port);\n relayTunnel = rt;\n // SECRET-HANDLING: rt.url is the https relay base — stored in\n // relayHttpUrl for .ait_urls write below; never logged.\n relayHttpUrl = rt.url;\n relayWssUrl = rt.url.replace(/^https:/, 'wss:');\n } catch (err: unknown) {\n console.warn(\n `[@ait-co/devtools] tunnel: CDP relay not started — screen preview works without on-device debugging: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n\n await printTunnelBanner(t.url, { qr: tunnelConfig.qr, relayWssUrl });\n\n // env-2 URL file-based discovery (#424): write .ait_urls so the\n // MCP daemon can discover the relay/tunnel URLs without manual env\n // var copy-paste. SECRET-HANDLING: URL values are never logged.\n // Capture deleteRelayUrls in the outer-scope fn so cleanup() can\n // call it without re-importing (no async in signal handlers).\n const { writeRelayUrls, deleteRelayUrls } = await import(\n '../mcp/relay-url-store.js'\n );\n await writeRelayUrls({\n projectRoot: server.config.root,\n tunnelBaseUrl: t.url,\n ...(relayHttpUrl !== undefined ? { relayBaseUrl: relayHttpUrl } : {}),\n });\n relayUrlDeleteFn = (root: string) => deleteRelayUrls({ projectRoot: root });\n\n // env-2 HTML dashboard (issue #408): when CDP is wired and a GUI\n // is present, serve the same QR+FAQ dashboard env 3/4 uses and\n // open it in the browser. No-op (returns undefined) for the\n // screen-only tunnel, headless, qr:false, or AIT_AUTO_DEVTOOLS=0\n // — the ASCII QR above remains the fallback in those cases.\n if (relayWssUrl) {\n qrDashboard =\n (await startTunnelDashboard({\n tunnelUrl: t.url,\n relayWssUrl,\n qr: tunnelConfig.qr,\n })) ?? null;\n }\n\n // #420: start watching the parent PID now that tunnel resources\n // are allocated. When the parent dies/reparents, clean up\n // synchronously (stops cloudflared children) then exit.\n parentWatcher = startParentWatcher(() => {\n cleanup();\n process.exit(0);\n });\n })\n .catch((err: unknown) => {\n console.warn(\n `[@ait-co/devtools] tunnel failed to start: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n });\n });\n\n const cleanup = () => {\n parentWatcher?.stop();\n tunnel?.stop();\n relayTunnel?.stop();\n void relay?.close();\n void qrDashboard?.close();\n // env-2 URL file cleanup (#424): remove .ait_urls on teardown so a\n // stale file doesn't cause the MCP daemon to attempt a doomed attach.\n // SECRET-HANDLING: relayUrlDeleteFn never logs the path or URL values.\n void relayUrlDeleteFn?.(server.config.root);\n };\n httpServer?.once('close', cleanup);\n process.once('SIGINT', cleanup);\n process.once('SIGTERM', cleanup);\n process.once('SIGHUP', cleanup);\n process.once('exit', cleanup);\n }\n },\n },\n };\n});\n\nexport const vite = aitDevtoolsPlugin.vite;\nexport const webpack = aitDevtoolsPlugin.webpack;\nexport const rollup = aitDevtoolsPlugin.rollup;\nexport const esbuild = aitDevtoolsPlugin.esbuild;\nexport const rspack = aitDevtoolsPlugin.rspack;\n\nexport default aitDevtoolsPlugin;\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../src/shared/parent-watcher.ts","../../src/unplugin/index.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 * @ait-co/devtools unplugin\n *\n * 모든 주요 번들러를 지원하는 단일 플러그인.\n * @apps-in-toss/web-framework → @ait-co/devtools/mock 으로 alias 설정.\n *\n * Usage:\n * import aitDevtools from '@ait-co/devtools/unplugin';\n *\n * // Vite\n * export default { plugins: [aitDevtools.vite()] };\n *\n * // Webpack / Next.js\n * config.plugins.push(aitDevtools.webpack());\n *\n * // Rspack\n * config.plugins.push(aitDevtools.rspack());\n *\n * // esbuild\n * { plugins: [aitDevtools.esbuild()] }\n *\n * // Rollup\n * { plugins: [aitDevtools.rollup()] }\n */\n\nimport { fileURLToPath } from 'node:url';\nimport { createUnplugin } from 'unplugin';\nimport { startParentWatcher } from '../shared/parent-watcher.js';\n\n/**\n * Resolve `@ait-co/devtools/mock` to its real file path at plugin-load time.\n *\n * Returning the bare specifier from `resolveId` would stop the bundler from\n * walking node_modules for it — Vite 8+ treats such a non-null string as the\n * final resolved id and serves it via the virtual `/@id/` prefix, which 404s\n * because we don't provide a `load` hook. Resolving to an absolute path here\n * lets every supported bundler load the file the normal way.\n */\nconst MOCK_PATH = (() => {\n try {\n return fileURLToPath(import.meta.resolve('@ait-co/devtools/mock'));\n } catch {\n // Fallback for runtimes where `import.meta.resolve` is unavailable.\n return '@ait-co/devtools/mock';\n }\n})();\n\nexport interface AitDevtoolsOptions {\n /**\n * 패널 자동 주입 여부 (default: true)\n * true이면 진입점에 floating panel import를 자동 추가한다.\n */\n panel?: boolean;\n /**\n * production 환경에서도 devtools를 강제로 활성화 (default: false)\n */\n forceEnable?: boolean;\n /**\n * mock alias 활성화 여부. default: true (development), false (production + forceEnable)\n */\n mock?: boolean;\n /**\n * Vite dev server에 MCP state endpoint를 추가할지 여부 (default: false).\n *\n * `true`로 설정하면:\n * - GET /api/ait-devtools/state — 마지막으로 브라우저가 push한 mock state 스냅샷 반환\n * - POST /api/ait-devtools/state — 브라우저 panel이 상태 변경 시 자동 push (panel 내부 처리)\n *\n * 이 endpoint를 `@ait-co/devtools` MCP stdio server가 읽어 AI 에이전트에 mock state를 노출한다.\n * Vite 전용: webpack/rspack/esbuild/rollup 환경에서는 무시된다.\n */\n mcp?: boolean;\n /**\n * Vite dev 서버를 Cloudflare quick tunnel(`*.trycloudflare.com`, 계정 불필요)로\n * 외부 노출해 실제 폰에서 미리보기. **Vite dev 모드 전용** — production은\n * `forceEnable`이어도 터널을 띄우지 않는다 (의도치 않은 노출 방지). 다른 번들러는\n * 무시. `true`면 기본 동작, 객체로 세부 설정 가능.\n */\n tunnel?:\n | boolean\n | {\n /** 노출할 포트 (미지정 시 dev 서버가 실제 listen한 포트 자동 감지). */\n port?: number;\n /** 터미널 ASCII QR 출력 (default: true). */\n qr?: boolean;\n /**\n * 환경 2(실기기 PWA)에 CDP 디버깅 배선 (default: false).\n *\n * `true`면 dev 서버 HTTP 터널과 **별도로** Chii relay를 띄우고 그 relay에\n * 두 번째 quick tunnel을 붙여, launcher QR deep-link에 `&debug=1&relay=<wss>`를\n * 실어 보낸다. 폰의 PWA iframe이 in-app debug gate를 통과해 target.js를 주입받고,\n * AI host MCP가 그 relay에 client로 붙으면 실기기 WebKit 위에서 CDP 디버깅이 열린다.\n * mock SDK는 그대로라 `call_sdk`는 환경 2에서 mock을 친다 (fidelity 사다리의\n * 설계 의도 — SDK fidelity가 필요하면 환경 3로 올라간다).\n */\n cdp?: boolean;\n };\n}\n\nconst FRAMEWORK_ID = '@apps-in-toss/web-framework';\nconst BRIDGE_ID = '@apps-in-toss/web-bridge'; // back-compat (2.x)\nconst ANALYTICS_ID = '@apps-in-toss/web-analytics'; // back-compat (2.x)\nconst WEBVIEW_BRIDGE_ID = '@apps-in-toss/webview-bridge'; // 3.0+\n\n/** MCP state endpoint path — browser panel POSTs here, MCP server GETs here */\nconst MCP_STATE_PATH = '/api/ait-devtools/state';\n\n/**\n * Resolves the effective tunnel option (#425).\n *\n * An explicit `tunnel` value (including `false`) always takes priority over\n * env vars — the `??` operator means `undefined` (= omitted) falls through,\n * but `false` / `true` / an object are preserved as-is (non-breaking).\n *\n * When the option is omitted:\n * - `AIT_TUNNEL=1` enables the base screen-preview tunnel.\n * - `AIT_TUNNEL_CDP=1` (requires `AIT_TUNNEL`) upgrades to the CDP relay.\n * - Neither set → `false` (disabled).\n *\n * Extracted as a pure function so it can be unit-tested without standing up\n * a full Vite dev server.\n *\n * @param explicit - The `tunnel` option as passed by the consumer (or `undefined` when omitted).\n * @param env - The process environment (injectable for testing).\n */\nexport function resolveTunnelOption(\n explicit: AitDevtoolsOptions['tunnel'],\n env: Record<string, string | undefined>,\n): AitDevtoolsOptions['tunnel'] {\n return explicit ?? (env.AIT_TUNNEL ? { cdp: !!env.AIT_TUNNEL_CDP } : false);\n}\n\nconst aitDevtoolsPlugin = createUnplugin((options?: AitDevtoolsOptions) => {\n const isDev = process.env.NODE_ENV !== 'production';\n const shouldEnable = isDev || (options?.forceEnable ?? false);\n const shouldMock = shouldEnable && (options?.mock ?? isDev);\n const shouldPanel = shouldEnable && (options?.panel ?? true);\n const shouldMcp = shouldEnable && (options?.mcp ?? false);\n\n // In-memory store for the last state snapshot pushed by the browser panel.\n // Only allocated when mcp: true to avoid any overhead in the common case.\n let lastState: string | null = null;\n\n // Tunnel is dev-only and Vite-only. Never under production — even with\n // forceEnable — so a production build can't accidentally expose itself.\n //\n // Tunnel toggle resolution (#425): an explicit `tunnel` option always wins;\n // when omitted, fall back to the AIT_TUNNEL / AIT_TUNNEL_CDP env vars so a\n // consumer needs no `tunnel:` line in vite.config to enable env-2 preview.\n // AIT_TUNNEL gates the base (screen preview); AIT_TUNNEL_CDP upgrades to the\n // CDP relay. Production safety is unchanged — the existing\n // `shouldTunnel = isDev && !!tunnelOpt` guard below still blocks prod builds.\n const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);\n const shouldTunnel = isDev && !!tunnelOpt;\n const tunnelConfig = typeof tunnelOpt === 'object' ? tunnelOpt : {};\n\n return {\n name: 'ait-co-devtools',\n enforce: 'pre' as const,\n\n resolveId(id: string) {\n if (!shouldMock) return null;\n // @apps-in-toss/web-framework → @ait-co/devtools/mock (absolute path)\n if (\n id === FRAMEWORK_ID ||\n id === WEBVIEW_BRIDGE_ID ||\n id === BRIDGE_ID ||\n id === ANALYTICS_ID\n ) {\n return MOCK_PATH;\n }\n return null;\n },\n\n transformInclude(id: string) {\n if (!shouldPanel) return false;\n // 진입점 파일에만 패널 import를 주입\n return (\n /\\.(tsx?|jsx?)$/.test(id) &&\n /\\/(main|index|entry|app)\\.[tj]sx?$/i.test(id) &&\n !id.includes('node_modules')\n );\n },\n\n transform(code: string) {\n // transformInclude가 이미 shouldPanel을 확인하지만, 안전망으로 유지\n if (!shouldPanel) return null;\n // 이미 패널이 import 되어있으면 스킵\n if (code.includes('@ait-co/devtools/panel')) return null;\n // transformInclude가 진입점 파일만 통과시키므로 바로 prepend\n return `import '@ait-co/devtools/panel';\\n${code}`;\n },\n\n // Vite-only: register the MCP state HTTP endpoint on the dev server, and\n // optionally start a Cloudflare quick tunnel once the dev server is listening.\n // Non-Vite bundlers do not have a dev server concept so this is silently\n // skipped (unplugin passes `vite` key only when building for Vite).\n vite: {\n config() {\n if (!shouldTunnel) return;\n // Vite blocks requests whose Host header isn't in `server.allowedHosts`\n // (defaults to localhost only). The quick-tunnel hostname is random per\n // run, so allow the whole `.trycloudflare.com` suffix while the tunnel\n // is on. (A leading `.` makes Vite match the domain and its subdomains.)\n return { server: { allowedHosts: ['.trycloudflare.com'] } };\n },\n\n configureServer(server: import('vite').ViteDevServer) {\n // MCP state endpoint: browser panel POSTs state here, MCP stdio server GETs it.\n if (shouldMcp) {\n server.middlewares.use(MCP_STATE_PATH, (req, res) => {\n // Allow Claude Code / AI agents (running locally) to read state\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type');\n\n if (req.method === 'OPTIONS') {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (req.method === 'GET') {\n if (lastState === null) {\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'No state received yet. Open the app in a browser first.',\n }),\n );\n return;\n }\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(lastState);\n return;\n }\n\n if (req.method === 'POST') {\n const chunks: Buffer[] = [];\n req.on('data', (chunk: Buffer) => chunks.push(chunk));\n req.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString('utf-8');\n // Validate it's parseable JSON before caching\n JSON.parse(body);\n lastState = body;\n res.writeHead(204);\n res.end();\n } catch {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Invalid JSON' }));\n }\n });\n return;\n }\n\n res.writeHead(405, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Method not allowed' }));\n });\n }\n\n // Tunnel: start a Cloudflare quick tunnel once the dev server is listening.\n if (shouldTunnel) {\n let tunnel: { stop: () => void } | null = null;\n // env-2 CDP wiring (tunnel.cdp): a second tunnel + Chii relay, torn\n // down alongside the HTTP tunnel. Fire-and-forget close on teardown.\n let relayTunnel: { stop: () => void } | null = null;\n let relay: { close: () => Promise<void> } | null = null;\n // env-2 HTML dashboard (issue #408): local 127.0.0.1 HTTP server that\n // serves the QR + connect-steps + FAQ page (env 3/4 UX parity), opened\n // in the browser when CDP is wired + GUI present. Torn down with the\n // tunnel. Only set when the dashboard actually started.\n let qrDashboard: { close: () => Promise<void> } | null = null;\n // env-2 URL file store (#424): captured after the first writeRelayUrls\n // call so cleanup() can call deleteRelayUrls without a re-import.\n // SECRET-HANDLING: the stored function reference never carries URL values.\n let relayUrlDeleteFn: ((projectRoot: string) => Promise<void>) | null = null;\n // #420: parent-PID watcher — self-terminate when vite's parent dies so\n // cloudflared children don't become zombies holding stale tunnels.\n let parentWatcher: { stop(): void } | null = null;\n const httpServer = server.httpServer;\n\n httpServer?.once('listening', () => {\n const address = httpServer?.address();\n const port =\n tunnelConfig.port ??\n (address && typeof address === 'object' ? address.port : undefined);\n if (!port) {\n console.warn(\n '[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.',\n );\n return;\n }\n // Dynamic import keeps `cloudflared` / `qrcode-terminal` off the\n // module graph unless the tunnel is actually used.\n import('./tunnel.js')\n .then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {\n const t = await startQuickTunnel(port);\n tunnel = t;\n\n // env-2 CDP: boot a Chii relay (OS-assigned local port) and a\n // second quick tunnel to it. The relay's https tunnel URL becomes\n // the `wss://` relay the launcher QR carries (&debug=1&relay=).\n let relayWssUrl: string | undefined;\n // SECRET-HANDLING: relayHttpUrl carries the relay host — never logged.\n let relayHttpUrl: string | undefined;\n if (tunnelConfig.cdp) {\n try {\n // Relay-auth baseline (issue #250): the env-2 CDP relay is\n // reachable over a public `*.trycloudflare.com` tunnel, so a\n // configured TOTP secret is MANDATORY and the relay enforces\n // it on every WS upgrade.\n //\n // First-run auto-mint (issue #394, project-local #396): if\n // AIT_DEBUG_TOTP_SECRET is not yet set, ensureRelaySecret()\n // mints a 256-bit random secret, persists it to the project-\n // local file <project>/.ait_relay (0600, anchored at the\n // nearest package.json directory above server.config.root),\n // and injects it into process.env so the following\n // assertRelayAuthConfigured() call succeeds. On subsequent\n // runs the persisted value is loaded silently — no manual\n // export needed. The MCP daemon reads the SAME file read-only\n // via loadRelaySecretReadOnly() when switching to a relay env.\n // SECRET-HANDLING: neither ensureRelaySecret nor the\n // guard/predicate log the secret value.\n const { ensureRelaySecret } = await import('../mcp/relay-secret-store.js');\n await ensureRelaySecret({ projectRoot: server.config.root });\n const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import(\n '../mcp/totp.js'\n );\n assertRelayAuthConfigured();\n const verifyAuth = buildRelayVerifyAuth();\n const { startChiiRelay } = await import('../mcp/chii-relay.js');\n // Issue #467: this relay lives in the vite process, so the\n // MCP daemon's get_debug_status counter cannot see its 401s.\n // Surface a throttled hint in the vite terminal instead.\n // SECRET-HANDLING: fixed message only — no URL, code, host.\n let lastAuthRejectWarnAt = 0;\n const r = await startChiiRelay({\n port: 0,\n verifyAuth,\n onAuthReject: () => {\n const nowMs = Date.now();\n if (nowMs - lastAuthRejectWarnAt < 10_000) return;\n lastAuthRejectWarnAt = nowMs;\n console.warn(\n '[@ait-co/devtools] tunnel: relay 인증(TOTP) 거부 감지 — 폰에서 QR을 다시 스캔하세요 (코드는 ~3분마다 만료)',\n );\n },\n });\n relay = r;\n const rt = await startQuickTunnel(r.port);\n relayTunnel = rt;\n // SECRET-HANDLING: rt.url is the https relay base — stored in\n // relayHttpUrl for .ait_urls write below; never logged.\n relayHttpUrl = rt.url;\n relayWssUrl = rt.url.replace(/^https:/, 'wss:');\n } catch (err: unknown) {\n console.warn(\n `[@ait-co/devtools] tunnel: CDP relay not started — screen preview works without on-device debugging: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n\n await printTunnelBanner(t.url, { qr: tunnelConfig.qr, relayWssUrl });\n\n // env-2 URL file-based discovery (#424): write .ait_urls so the\n // MCP daemon can discover the relay/tunnel URLs without manual env\n // var copy-paste. SECRET-HANDLING: URL values are never logged.\n // Capture deleteRelayUrls in the outer-scope fn so cleanup() can\n // call it without re-importing (no async in signal handlers).\n const { writeRelayUrls, deleteRelayUrls } = await import(\n '../mcp/relay-url-store.js'\n );\n await writeRelayUrls({\n projectRoot: server.config.root,\n tunnelBaseUrl: t.url,\n ...(relayHttpUrl !== undefined ? { relayBaseUrl: relayHttpUrl } : {}),\n });\n relayUrlDeleteFn = (root: string) => deleteRelayUrls({ projectRoot: root });\n\n // env-2 HTML dashboard (issue #408): when CDP is wired and a GUI\n // is present, serve the same QR+FAQ dashboard env 3/4 uses and\n // open it in the browser. No-op (returns undefined) for the\n // screen-only tunnel, headless, qr:false, or AIT_AUTO_DEVTOOLS=0\n // — the ASCII QR above remains the fallback in those cases.\n if (relayWssUrl) {\n qrDashboard =\n (await startTunnelDashboard({\n tunnelUrl: t.url,\n relayWssUrl,\n qr: tunnelConfig.qr,\n })) ?? null;\n }\n\n // #420: start watching the parent PID now that tunnel resources\n // are allocated. When the parent dies/reparents, clean up\n // synchronously (stops cloudflared children) then exit.\n parentWatcher = startParentWatcher(() => {\n cleanup();\n process.exit(0);\n });\n })\n .catch((err: unknown) => {\n console.warn(\n `[@ait-co/devtools] tunnel failed to start: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n });\n });\n\n const cleanup = () => {\n parentWatcher?.stop();\n tunnel?.stop();\n relayTunnel?.stop();\n void relay?.close();\n void qrDashboard?.close();\n // env-2 URL file cleanup (#424): remove .ait_urls on teardown so a\n // stale file doesn't cause the MCP daemon to attempt a doomed attach.\n // SECRET-HANDLING: relayUrlDeleteFn never logs the path or URL values.\n void relayUrlDeleteFn?.(server.config.root);\n };\n httpServer?.once('close', cleanup);\n process.once('SIGINT', cleanup);\n process.once('SIGTERM', cleanup);\n process.once('SIGHUP', cleanup);\n process.once('exit', cleanup);\n }\n },\n },\n };\n});\n\nexport const vite = aitDevtoolsPlugin.vite;\nexport const webpack = aitDevtoolsPlugin.webpack;\nexport const rollup = aitDevtoolsPlugin.rollup;\nexport const esbuild = aitDevtoolsPlugin.esbuild;\nexport const rspack = aitDevtoolsPlugin.rspack;\n\nexport default aitDevtoolsPlugin;\n"],"mappings":"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"}
@@ -220,18 +220,18 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
220
220
  console.warn("[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.");
221
221
  return;
222
222
  }
223
- import("../tunnel-aIy_7nWm.js").then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {
223
+ import("../tunnel-CQdoKopR.js").then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {
224
224
  const t = await startQuickTunnel(port);
225
225
  tunnel = t;
226
226
  let relayWssUrl;
227
227
  let relayHttpUrl;
228
228
  if (tunnelConfig.cdp) try {
229
- const { ensureRelaySecret } = await import("../relay-secret-store-C4QQN5NA.js");
229
+ const { ensureRelaySecret } = await import("../relay-secret-store-D-W-WaSx.js");
230
230
  await ensureRelaySecret({ projectRoot: server.config.root });
231
- const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import("../totp-BjtKFt88.js");
231
+ const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import("../totp-DbEfKQRi.js");
232
232
  assertRelayAuthConfigured();
233
233
  const verifyAuth = buildRelayVerifyAuth();
234
- const { startChiiRelay } = await import("../chii-relay-BcWDKbQ1.js");
234
+ const { startChiiRelay } = await import("../chii-relay-DSVG4Ui1.js");
235
235
  let lastAuthRejectWarnAt = 0;
236
236
  const r = await startChiiRelay({
237
237
  port: 0,
@@ -240,7 +240,7 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
240
240
  const nowMs = Date.now();
241
241
  if (nowMs - lastAuthRejectWarnAt < 1e4) return;
242
242
  lastAuthRejectWarnAt = nowMs;
243
- console.warn("[@ait-co/devtools] tunnel: relay 인증(TOTP) 거부 감지 — 폰에서 QR을 다시 스캔하세요 (코드는 30초 주기로 만료)");
243
+ console.warn("[@ait-co/devtools] tunnel: relay 인증(TOTP) 거부 감지 — 폰에서 QR을 다시 스캔하세요 (코드는 ~3분마다 만료)");
244
244
  }
245
245
  });
246
246
  relay = r;
@@ -255,7 +255,7 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
255
255
  qr: tunnelConfig.qr,
256
256
  relayWssUrl
257
257
  });
258
- const { writeRelayUrls, deleteRelayUrls } = await import("../relay-url-store-WKfo0VQV.js");
258
+ const { writeRelayUrls, deleteRelayUrls } = await import("../relay-url-store-DAh5KiJi.js");
259
259
  await writeRelayUrls({
260
260
  projectRoot: server.config.root,
261
261
  tunnelBaseUrl: t.url,
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/shared/parent-watcher.ts","../../src/unplugin/index.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 * @ait-co/devtools unplugin\n *\n * 모든 주요 번들러를 지원하는 단일 플러그인.\n * @apps-in-toss/web-framework → @ait-co/devtools/mock 으로 alias 설정.\n *\n * Usage:\n * import aitDevtools from '@ait-co/devtools/unplugin';\n *\n * // Vite\n * export default { plugins: [aitDevtools.vite()] };\n *\n * // Webpack / Next.js\n * config.plugins.push(aitDevtools.webpack());\n *\n * // Rspack\n * config.plugins.push(aitDevtools.rspack());\n *\n * // esbuild\n * { plugins: [aitDevtools.esbuild()] }\n *\n * // Rollup\n * { plugins: [aitDevtools.rollup()] }\n */\n\nimport { fileURLToPath } from 'node:url';\nimport { createUnplugin } from 'unplugin';\nimport { startParentWatcher } from '../shared/parent-watcher.js';\n\n/**\n * Resolve `@ait-co/devtools/mock` to its real file path at plugin-load time.\n *\n * Returning the bare specifier from `resolveId` would stop the bundler from\n * walking node_modules for it — Vite 8+ treats such a non-null string as the\n * final resolved id and serves it via the virtual `/@id/` prefix, which 404s\n * because we don't provide a `load` hook. Resolving to an absolute path here\n * lets every supported bundler load the file the normal way.\n */\nconst MOCK_PATH = (() => {\n try {\n return fileURLToPath(import.meta.resolve('@ait-co/devtools/mock'));\n } catch {\n // Fallback for runtimes where `import.meta.resolve` is unavailable.\n return '@ait-co/devtools/mock';\n }\n})();\n\nexport interface AitDevtoolsOptions {\n /**\n * 패널 자동 주입 여부 (default: true)\n * true이면 진입점에 floating panel import를 자동 추가한다.\n */\n panel?: boolean;\n /**\n * production 환경에서도 devtools를 강제로 활성화 (default: false)\n */\n forceEnable?: boolean;\n /**\n * mock alias 활성화 여부. default: true (development), false (production + forceEnable)\n */\n mock?: boolean;\n /**\n * Vite dev server에 MCP state endpoint를 추가할지 여부 (default: false).\n *\n * `true`로 설정하면:\n * - GET /api/ait-devtools/state — 마지막으로 브라우저가 push한 mock state 스냅샷 반환\n * - POST /api/ait-devtools/state — 브라우저 panel이 상태 변경 시 자동 push (panel 내부 처리)\n *\n * 이 endpoint를 `@ait-co/devtools` MCP stdio server가 읽어 AI 에이전트에 mock state를 노출한다.\n * Vite 전용: webpack/rspack/esbuild/rollup 환경에서는 무시된다.\n */\n mcp?: boolean;\n /**\n * Vite dev 서버를 Cloudflare quick tunnel(`*.trycloudflare.com`, 계정 불필요)로\n * 외부 노출해 실제 폰에서 미리보기. **Vite dev 모드 전용** — production은\n * `forceEnable`이어도 터널을 띄우지 않는다 (의도치 않은 노출 방지). 다른 번들러는\n * 무시. `true`면 기본 동작, 객체로 세부 설정 가능.\n */\n tunnel?:\n | boolean\n | {\n /** 노출할 포트 (미지정 시 dev 서버가 실제 listen한 포트 자동 감지). */\n port?: number;\n /** 터미널 ASCII QR 출력 (default: true). */\n qr?: boolean;\n /**\n * 환경 2(실기기 PWA)에 CDP 디버깅 배선 (default: false).\n *\n * `true`면 dev 서버 HTTP 터널과 **별도로** Chii relay를 띄우고 그 relay에\n * 두 번째 quick tunnel을 붙여, launcher QR deep-link에 `&debug=1&relay=<wss>`를\n * 실어 보낸다. 폰의 PWA iframe이 in-app debug gate를 통과해 target.js를 주입받고,\n * AI host MCP가 그 relay에 client로 붙으면 실기기 WebKit 위에서 CDP 디버깅이 열린다.\n * mock SDK는 그대로라 `call_sdk`는 환경 2에서 mock을 친다 (fidelity 사다리의\n * 설계 의도 — SDK fidelity가 필요하면 환경 3로 올라간다).\n */\n cdp?: boolean;\n };\n}\n\nconst FRAMEWORK_ID = '@apps-in-toss/web-framework';\nconst BRIDGE_ID = '@apps-in-toss/web-bridge'; // back-compat (2.x)\nconst ANALYTICS_ID = '@apps-in-toss/web-analytics'; // back-compat (2.x)\nconst WEBVIEW_BRIDGE_ID = '@apps-in-toss/webview-bridge'; // 3.0+\n\n/** MCP state endpoint path — browser panel POSTs here, MCP server GETs here */\nconst MCP_STATE_PATH = '/api/ait-devtools/state';\n\n/**\n * Resolves the effective tunnel option (#425).\n *\n * An explicit `tunnel` value (including `false`) always takes priority over\n * env vars — the `??` operator means `undefined` (= omitted) falls through,\n * but `false` / `true` / an object are preserved as-is (non-breaking).\n *\n * When the option is omitted:\n * - `AIT_TUNNEL=1` enables the base screen-preview tunnel.\n * - `AIT_TUNNEL_CDP=1` (requires `AIT_TUNNEL`) upgrades to the CDP relay.\n * - Neither set → `false` (disabled).\n *\n * Extracted as a pure function so it can be unit-tested without standing up\n * a full Vite dev server.\n *\n * @param explicit - The `tunnel` option as passed by the consumer (or `undefined` when omitted).\n * @param env - The process environment (injectable for testing).\n */\nexport function resolveTunnelOption(\n explicit: AitDevtoolsOptions['tunnel'],\n env: Record<string, string | undefined>,\n): AitDevtoolsOptions['tunnel'] {\n return explicit ?? (env.AIT_TUNNEL ? { cdp: !!env.AIT_TUNNEL_CDP } : false);\n}\n\nconst aitDevtoolsPlugin = createUnplugin((options?: AitDevtoolsOptions) => {\n const isDev = process.env.NODE_ENV !== 'production';\n const shouldEnable = isDev || (options?.forceEnable ?? false);\n const shouldMock = shouldEnable && (options?.mock ?? isDev);\n const shouldPanel = shouldEnable && (options?.panel ?? true);\n const shouldMcp = shouldEnable && (options?.mcp ?? false);\n\n // In-memory store for the last state snapshot pushed by the browser panel.\n // Only allocated when mcp: true to avoid any overhead in the common case.\n let lastState: string | null = null;\n\n // Tunnel is dev-only and Vite-only. Never under production — even with\n // forceEnable — so a production build can't accidentally expose itself.\n //\n // Tunnel toggle resolution (#425): an explicit `tunnel` option always wins;\n // when omitted, fall back to the AIT_TUNNEL / AIT_TUNNEL_CDP env vars so a\n // consumer needs no `tunnel:` line in vite.config to enable env-2 preview.\n // AIT_TUNNEL gates the base (screen preview); AIT_TUNNEL_CDP upgrades to the\n // CDP relay. Production safety is unchanged — the existing\n // `shouldTunnel = isDev && !!tunnelOpt` guard below still blocks prod builds.\n const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);\n const shouldTunnel = isDev && !!tunnelOpt;\n const tunnelConfig = typeof tunnelOpt === 'object' ? tunnelOpt : {};\n\n return {\n name: 'ait-co-devtools',\n enforce: 'pre' as const,\n\n resolveId(id: string) {\n if (!shouldMock) return null;\n // @apps-in-toss/web-framework → @ait-co/devtools/mock (absolute path)\n if (\n id === FRAMEWORK_ID ||\n id === WEBVIEW_BRIDGE_ID ||\n id === BRIDGE_ID ||\n id === ANALYTICS_ID\n ) {\n return MOCK_PATH;\n }\n return null;\n },\n\n transformInclude(id: string) {\n if (!shouldPanel) return false;\n // 진입점 파일에만 패널 import를 주입\n return (\n /\\.(tsx?|jsx?)$/.test(id) &&\n /\\/(main|index|entry|app)\\.[tj]sx?$/i.test(id) &&\n !id.includes('node_modules')\n );\n },\n\n transform(code: string) {\n // transformInclude가 이미 shouldPanel을 확인하지만, 안전망으로 유지\n if (!shouldPanel) return null;\n // 이미 패널이 import 되어있으면 스킵\n if (code.includes('@ait-co/devtools/panel')) return null;\n // transformInclude가 진입점 파일만 통과시키므로 바로 prepend\n return `import '@ait-co/devtools/panel';\\n${code}`;\n },\n\n // Vite-only: register the MCP state HTTP endpoint on the dev server, and\n // optionally start a Cloudflare quick tunnel once the dev server is listening.\n // Non-Vite bundlers do not have a dev server concept so this is silently\n // skipped (unplugin passes `vite` key only when building for Vite).\n vite: {\n config() {\n if (!shouldTunnel) return;\n // Vite blocks requests whose Host header isn't in `server.allowedHosts`\n // (defaults to localhost only). The quick-tunnel hostname is random per\n // run, so allow the whole `.trycloudflare.com` suffix while the tunnel\n // is on. (A leading `.` makes Vite match the domain and its subdomains.)\n return { server: { allowedHosts: ['.trycloudflare.com'] } };\n },\n\n configureServer(server: import('vite').ViteDevServer) {\n // MCP state endpoint: browser panel POSTs state here, MCP stdio server GETs it.\n if (shouldMcp) {\n server.middlewares.use(MCP_STATE_PATH, (req, res) => {\n // Allow Claude Code / AI agents (running locally) to read state\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type');\n\n if (req.method === 'OPTIONS') {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (req.method === 'GET') {\n if (lastState === null) {\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'No state received yet. Open the app in a browser first.',\n }),\n );\n return;\n }\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(lastState);\n return;\n }\n\n if (req.method === 'POST') {\n const chunks: Buffer[] = [];\n req.on('data', (chunk: Buffer) => chunks.push(chunk));\n req.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString('utf-8');\n // Validate it's parseable JSON before caching\n JSON.parse(body);\n lastState = body;\n res.writeHead(204);\n res.end();\n } catch {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Invalid JSON' }));\n }\n });\n return;\n }\n\n res.writeHead(405, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Method not allowed' }));\n });\n }\n\n // Tunnel: start a Cloudflare quick tunnel once the dev server is listening.\n if (shouldTunnel) {\n let tunnel: { stop: () => void } | null = null;\n // env-2 CDP wiring (tunnel.cdp): a second tunnel + Chii relay, torn\n // down alongside the HTTP tunnel. Fire-and-forget close on teardown.\n let relayTunnel: { stop: () => void } | null = null;\n let relay: { close: () => Promise<void> } | null = null;\n // env-2 HTML dashboard (issue #408): local 127.0.0.1 HTTP server that\n // serves the QR + connect-steps + FAQ page (env 3/4 UX parity), opened\n // in the browser when CDP is wired + GUI present. Torn down with the\n // tunnel. Only set when the dashboard actually started.\n let qrDashboard: { close: () => Promise<void> } | null = null;\n // env-2 URL file store (#424): captured after the first writeRelayUrls\n // call so cleanup() can call deleteRelayUrls without a re-import.\n // SECRET-HANDLING: the stored function reference never carries URL values.\n let relayUrlDeleteFn: ((projectRoot: string) => Promise<void>) | null = null;\n // #420: parent-PID watcher — self-terminate when vite's parent dies so\n // cloudflared children don't become zombies holding stale tunnels.\n let parentWatcher: { stop(): void } | null = null;\n const httpServer = server.httpServer;\n\n httpServer?.once('listening', () => {\n const address = httpServer?.address();\n const port =\n tunnelConfig.port ??\n (address && typeof address === 'object' ? address.port : undefined);\n if (!port) {\n console.warn(\n '[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.',\n );\n return;\n }\n // Dynamic import keeps `cloudflared` / `qrcode-terminal` off the\n // module graph unless the tunnel is actually used.\n import('./tunnel.js')\n .then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {\n const t = await startQuickTunnel(port);\n tunnel = t;\n\n // env-2 CDP: boot a Chii relay (OS-assigned local port) and a\n // second quick tunnel to it. The relay's https tunnel URL becomes\n // the `wss://` relay the launcher QR carries (&debug=1&relay=).\n let relayWssUrl: string | undefined;\n // SECRET-HANDLING: relayHttpUrl carries the relay host — never logged.\n let relayHttpUrl: string | undefined;\n if (tunnelConfig.cdp) {\n try {\n // Relay-auth baseline (issue #250): the env-2 CDP relay is\n // reachable over a public `*.trycloudflare.com` tunnel, so a\n // configured TOTP secret is MANDATORY and the relay enforces\n // it on every WS upgrade.\n //\n // First-run auto-mint (issue #394, project-local #396): if\n // AIT_DEBUG_TOTP_SECRET is not yet set, ensureRelaySecret()\n // mints a 256-bit random secret, persists it to the project-\n // local file <project>/.ait_relay (0600, anchored at the\n // nearest package.json directory above server.config.root),\n // and injects it into process.env so the following\n // assertRelayAuthConfigured() call succeeds. On subsequent\n // runs the persisted value is loaded silently — no manual\n // export needed. The MCP daemon reads the SAME file read-only\n // via loadRelaySecretReadOnly() when switching to a relay env.\n // SECRET-HANDLING: neither ensureRelaySecret nor the\n // guard/predicate log the secret value.\n const { ensureRelaySecret } = await import('../mcp/relay-secret-store.js');\n await ensureRelaySecret({ projectRoot: server.config.root });\n const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import(\n '../mcp/totp.js'\n );\n assertRelayAuthConfigured();\n const verifyAuth = buildRelayVerifyAuth();\n const { startChiiRelay } = await import('../mcp/chii-relay.js');\n // Issue #467: this relay lives in the vite process, so the\n // MCP daemon's get_debug_status counter cannot see its 401s.\n // Surface a throttled hint in the vite terminal instead.\n // SECRET-HANDLING: fixed message only — no URL, code, host.\n let lastAuthRejectWarnAt = 0;\n const r = await startChiiRelay({\n port: 0,\n verifyAuth,\n onAuthReject: () => {\n const nowMs = Date.now();\n if (nowMs - lastAuthRejectWarnAt < 10_000) return;\n lastAuthRejectWarnAt = nowMs;\n console.warn(\n '[@ait-co/devtools] tunnel: relay 인증(TOTP) 거부 감지 — 폰에서 QR을 다시 스캔하세요 (코드는 30초 주기로 만료)',\n );\n },\n });\n relay = r;\n const rt = await startQuickTunnel(r.port);\n relayTunnel = rt;\n // SECRET-HANDLING: rt.url is the https relay base — stored in\n // relayHttpUrl for .ait_urls write below; never logged.\n relayHttpUrl = rt.url;\n relayWssUrl = rt.url.replace(/^https:/, 'wss:');\n } catch (err: unknown) {\n console.warn(\n `[@ait-co/devtools] tunnel: CDP relay not started — screen preview works without on-device debugging: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n\n await printTunnelBanner(t.url, { qr: tunnelConfig.qr, relayWssUrl });\n\n // env-2 URL file-based discovery (#424): write .ait_urls so the\n // MCP daemon can discover the relay/tunnel URLs without manual env\n // var copy-paste. SECRET-HANDLING: URL values are never logged.\n // Capture deleteRelayUrls in the outer-scope fn so cleanup() can\n // call it without re-importing (no async in signal handlers).\n const { writeRelayUrls, deleteRelayUrls } = await import(\n '../mcp/relay-url-store.js'\n );\n await writeRelayUrls({\n projectRoot: server.config.root,\n tunnelBaseUrl: t.url,\n ...(relayHttpUrl !== undefined ? { relayBaseUrl: relayHttpUrl } : {}),\n });\n relayUrlDeleteFn = (root: string) => deleteRelayUrls({ projectRoot: root });\n\n // env-2 HTML dashboard (issue #408): when CDP is wired and a GUI\n // is present, serve the same QR+FAQ dashboard env 3/4 uses and\n // open it in the browser. No-op (returns undefined) for the\n // screen-only tunnel, headless, qr:false, or AIT_AUTO_DEVTOOLS=0\n // — the ASCII QR above remains the fallback in those cases.\n if (relayWssUrl) {\n qrDashboard =\n (await startTunnelDashboard({\n tunnelUrl: t.url,\n relayWssUrl,\n qr: tunnelConfig.qr,\n })) ?? null;\n }\n\n // #420: start watching the parent PID now that tunnel resources\n // are allocated. When the parent dies/reparents, clean up\n // synchronously (stops cloudflared children) then exit.\n parentWatcher = startParentWatcher(() => {\n cleanup();\n process.exit(0);\n });\n })\n .catch((err: unknown) => {\n console.warn(\n `[@ait-co/devtools] tunnel failed to start: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n });\n });\n\n const cleanup = () => {\n parentWatcher?.stop();\n tunnel?.stop();\n relayTunnel?.stop();\n void relay?.close();\n void qrDashboard?.close();\n // env-2 URL file cleanup (#424): remove .ait_urls on teardown so a\n // stale file doesn't cause the MCP daemon to attempt a doomed attach.\n // SECRET-HANDLING: relayUrlDeleteFn never logs the path or URL values.\n void relayUrlDeleteFn?.(server.config.root);\n };\n httpServer?.once('close', cleanup);\n process.once('SIGINT', cleanup);\n process.once('SIGTERM', cleanup);\n process.once('SIGHUP', cleanup);\n process.once('exit', cleanup);\n }\n },\n },\n };\n});\n\nexport const vite = aitDevtoolsPlugin.vite;\nexport const webpack = aitDevtoolsPlugin.webpack;\nexport const rollup = aitDevtoolsPlugin.rollup;\nexport const esbuild = aitDevtoolsPlugin.esbuild;\nexport const rspack = aitDevtoolsPlugin.rspack;\n\nexport default aitDevtoolsPlugin;\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/shared/parent-watcher.ts","../../src/unplugin/index.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 * @ait-co/devtools unplugin\n *\n * 모든 주요 번들러를 지원하는 단일 플러그인.\n * @apps-in-toss/web-framework → @ait-co/devtools/mock 으로 alias 설정.\n *\n * Usage:\n * import aitDevtools from '@ait-co/devtools/unplugin';\n *\n * // Vite\n * export default { plugins: [aitDevtools.vite()] };\n *\n * // Webpack / Next.js\n * config.plugins.push(aitDevtools.webpack());\n *\n * // Rspack\n * config.plugins.push(aitDevtools.rspack());\n *\n * // esbuild\n * { plugins: [aitDevtools.esbuild()] }\n *\n * // Rollup\n * { plugins: [aitDevtools.rollup()] }\n */\n\nimport { fileURLToPath } from 'node:url';\nimport { createUnplugin } from 'unplugin';\nimport { startParentWatcher } from '../shared/parent-watcher.js';\n\n/**\n * Resolve `@ait-co/devtools/mock` to its real file path at plugin-load time.\n *\n * Returning the bare specifier from `resolveId` would stop the bundler from\n * walking node_modules for it — Vite 8+ treats such a non-null string as the\n * final resolved id and serves it via the virtual `/@id/` prefix, which 404s\n * because we don't provide a `load` hook. Resolving to an absolute path here\n * lets every supported bundler load the file the normal way.\n */\nconst MOCK_PATH = (() => {\n try {\n return fileURLToPath(import.meta.resolve('@ait-co/devtools/mock'));\n } catch {\n // Fallback for runtimes where `import.meta.resolve` is unavailable.\n return '@ait-co/devtools/mock';\n }\n})();\n\nexport interface AitDevtoolsOptions {\n /**\n * 패널 자동 주입 여부 (default: true)\n * true이면 진입점에 floating panel import를 자동 추가한다.\n */\n panel?: boolean;\n /**\n * production 환경에서도 devtools를 강제로 활성화 (default: false)\n */\n forceEnable?: boolean;\n /**\n * mock alias 활성화 여부. default: true (development), false (production + forceEnable)\n */\n mock?: boolean;\n /**\n * Vite dev server에 MCP state endpoint를 추가할지 여부 (default: false).\n *\n * `true`로 설정하면:\n * - GET /api/ait-devtools/state — 마지막으로 브라우저가 push한 mock state 스냅샷 반환\n * - POST /api/ait-devtools/state — 브라우저 panel이 상태 변경 시 자동 push (panel 내부 처리)\n *\n * 이 endpoint를 `@ait-co/devtools` MCP stdio server가 읽어 AI 에이전트에 mock state를 노출한다.\n * Vite 전용: webpack/rspack/esbuild/rollup 환경에서는 무시된다.\n */\n mcp?: boolean;\n /**\n * Vite dev 서버를 Cloudflare quick tunnel(`*.trycloudflare.com`, 계정 불필요)로\n * 외부 노출해 실제 폰에서 미리보기. **Vite dev 모드 전용** — production은\n * `forceEnable`이어도 터널을 띄우지 않는다 (의도치 않은 노출 방지). 다른 번들러는\n * 무시. `true`면 기본 동작, 객체로 세부 설정 가능.\n */\n tunnel?:\n | boolean\n | {\n /** 노출할 포트 (미지정 시 dev 서버가 실제 listen한 포트 자동 감지). */\n port?: number;\n /** 터미널 ASCII QR 출력 (default: true). */\n qr?: boolean;\n /**\n * 환경 2(실기기 PWA)에 CDP 디버깅 배선 (default: false).\n *\n * `true`면 dev 서버 HTTP 터널과 **별도로** Chii relay를 띄우고 그 relay에\n * 두 번째 quick tunnel을 붙여, launcher QR deep-link에 `&debug=1&relay=<wss>`를\n * 실어 보낸다. 폰의 PWA iframe이 in-app debug gate를 통과해 target.js를 주입받고,\n * AI host MCP가 그 relay에 client로 붙으면 실기기 WebKit 위에서 CDP 디버깅이 열린다.\n * mock SDK는 그대로라 `call_sdk`는 환경 2에서 mock을 친다 (fidelity 사다리의\n * 설계 의도 — SDK fidelity가 필요하면 환경 3로 올라간다).\n */\n cdp?: boolean;\n };\n}\n\nconst FRAMEWORK_ID = '@apps-in-toss/web-framework';\nconst BRIDGE_ID = '@apps-in-toss/web-bridge'; // back-compat (2.x)\nconst ANALYTICS_ID = '@apps-in-toss/web-analytics'; // back-compat (2.x)\nconst WEBVIEW_BRIDGE_ID = '@apps-in-toss/webview-bridge'; // 3.0+\n\n/** MCP state endpoint path — browser panel POSTs here, MCP server GETs here */\nconst MCP_STATE_PATH = '/api/ait-devtools/state';\n\n/**\n * Resolves the effective tunnel option (#425).\n *\n * An explicit `tunnel` value (including `false`) always takes priority over\n * env vars — the `??` operator means `undefined` (= omitted) falls through,\n * but `false` / `true` / an object are preserved as-is (non-breaking).\n *\n * When the option is omitted:\n * - `AIT_TUNNEL=1` enables the base screen-preview tunnel.\n * - `AIT_TUNNEL_CDP=1` (requires `AIT_TUNNEL`) upgrades to the CDP relay.\n * - Neither set → `false` (disabled).\n *\n * Extracted as a pure function so it can be unit-tested without standing up\n * a full Vite dev server.\n *\n * @param explicit - The `tunnel` option as passed by the consumer (or `undefined` when omitted).\n * @param env - The process environment (injectable for testing).\n */\nexport function resolveTunnelOption(\n explicit: AitDevtoolsOptions['tunnel'],\n env: Record<string, string | undefined>,\n): AitDevtoolsOptions['tunnel'] {\n return explicit ?? (env.AIT_TUNNEL ? { cdp: !!env.AIT_TUNNEL_CDP } : false);\n}\n\nconst aitDevtoolsPlugin = createUnplugin((options?: AitDevtoolsOptions) => {\n const isDev = process.env.NODE_ENV !== 'production';\n const shouldEnable = isDev || (options?.forceEnable ?? false);\n const shouldMock = shouldEnable && (options?.mock ?? isDev);\n const shouldPanel = shouldEnable && (options?.panel ?? true);\n const shouldMcp = shouldEnable && (options?.mcp ?? false);\n\n // In-memory store for the last state snapshot pushed by the browser panel.\n // Only allocated when mcp: true to avoid any overhead in the common case.\n let lastState: string | null = null;\n\n // Tunnel is dev-only and Vite-only. Never under production — even with\n // forceEnable — so a production build can't accidentally expose itself.\n //\n // Tunnel toggle resolution (#425): an explicit `tunnel` option always wins;\n // when omitted, fall back to the AIT_TUNNEL / AIT_TUNNEL_CDP env vars so a\n // consumer needs no `tunnel:` line in vite.config to enable env-2 preview.\n // AIT_TUNNEL gates the base (screen preview); AIT_TUNNEL_CDP upgrades to the\n // CDP relay. Production safety is unchanged — the existing\n // `shouldTunnel = isDev && !!tunnelOpt` guard below still blocks prod builds.\n const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);\n const shouldTunnel = isDev && !!tunnelOpt;\n const tunnelConfig = typeof tunnelOpt === 'object' ? tunnelOpt : {};\n\n return {\n name: 'ait-co-devtools',\n enforce: 'pre' as const,\n\n resolveId(id: string) {\n if (!shouldMock) return null;\n // @apps-in-toss/web-framework → @ait-co/devtools/mock (absolute path)\n if (\n id === FRAMEWORK_ID ||\n id === WEBVIEW_BRIDGE_ID ||\n id === BRIDGE_ID ||\n id === ANALYTICS_ID\n ) {\n return MOCK_PATH;\n }\n return null;\n },\n\n transformInclude(id: string) {\n if (!shouldPanel) return false;\n // 진입점 파일에만 패널 import를 주입\n return (\n /\\.(tsx?|jsx?)$/.test(id) &&\n /\\/(main|index|entry|app)\\.[tj]sx?$/i.test(id) &&\n !id.includes('node_modules')\n );\n },\n\n transform(code: string) {\n // transformInclude가 이미 shouldPanel을 확인하지만, 안전망으로 유지\n if (!shouldPanel) return null;\n // 이미 패널이 import 되어있으면 스킵\n if (code.includes('@ait-co/devtools/panel')) return null;\n // transformInclude가 진입점 파일만 통과시키므로 바로 prepend\n return `import '@ait-co/devtools/panel';\\n${code}`;\n },\n\n // Vite-only: register the MCP state HTTP endpoint on the dev server, and\n // optionally start a Cloudflare quick tunnel once the dev server is listening.\n // Non-Vite bundlers do not have a dev server concept so this is silently\n // skipped (unplugin passes `vite` key only when building for Vite).\n vite: {\n config() {\n if (!shouldTunnel) return;\n // Vite blocks requests whose Host header isn't in `server.allowedHosts`\n // (defaults to localhost only). The quick-tunnel hostname is random per\n // run, so allow the whole `.trycloudflare.com` suffix while the tunnel\n // is on. (A leading `.` makes Vite match the domain and its subdomains.)\n return { server: { allowedHosts: ['.trycloudflare.com'] } };\n },\n\n configureServer(server: import('vite').ViteDevServer) {\n // MCP state endpoint: browser panel POSTs state here, MCP stdio server GETs it.\n if (shouldMcp) {\n server.middlewares.use(MCP_STATE_PATH, (req, res) => {\n // Allow Claude Code / AI agents (running locally) to read state\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type');\n\n if (req.method === 'OPTIONS') {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (req.method === 'GET') {\n if (lastState === null) {\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'No state received yet. Open the app in a browser first.',\n }),\n );\n return;\n }\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(lastState);\n return;\n }\n\n if (req.method === 'POST') {\n const chunks: Buffer[] = [];\n req.on('data', (chunk: Buffer) => chunks.push(chunk));\n req.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString('utf-8');\n // Validate it's parseable JSON before caching\n JSON.parse(body);\n lastState = body;\n res.writeHead(204);\n res.end();\n } catch {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Invalid JSON' }));\n }\n });\n return;\n }\n\n res.writeHead(405, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Method not allowed' }));\n });\n }\n\n // Tunnel: start a Cloudflare quick tunnel once the dev server is listening.\n if (shouldTunnel) {\n let tunnel: { stop: () => void } | null = null;\n // env-2 CDP wiring (tunnel.cdp): a second tunnel + Chii relay, torn\n // down alongside the HTTP tunnel. Fire-and-forget close on teardown.\n let relayTunnel: { stop: () => void } | null = null;\n let relay: { close: () => Promise<void> } | null = null;\n // env-2 HTML dashboard (issue #408): local 127.0.0.1 HTTP server that\n // serves the QR + connect-steps + FAQ page (env 3/4 UX parity), opened\n // in the browser when CDP is wired + GUI present. Torn down with the\n // tunnel. Only set when the dashboard actually started.\n let qrDashboard: { close: () => Promise<void> } | null = null;\n // env-2 URL file store (#424): captured after the first writeRelayUrls\n // call so cleanup() can call deleteRelayUrls without a re-import.\n // SECRET-HANDLING: the stored function reference never carries URL values.\n let relayUrlDeleteFn: ((projectRoot: string) => Promise<void>) | null = null;\n // #420: parent-PID watcher — self-terminate when vite's parent dies so\n // cloudflared children don't become zombies holding stale tunnels.\n let parentWatcher: { stop(): void } | null = null;\n const httpServer = server.httpServer;\n\n httpServer?.once('listening', () => {\n const address = httpServer?.address();\n const port =\n tunnelConfig.port ??\n (address && typeof address === 'object' ? address.port : undefined);\n if (!port) {\n console.warn(\n '[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.',\n );\n return;\n }\n // Dynamic import keeps `cloudflared` / `qrcode-terminal` off the\n // module graph unless the tunnel is actually used.\n import('./tunnel.js')\n .then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {\n const t = await startQuickTunnel(port);\n tunnel = t;\n\n // env-2 CDP: boot a Chii relay (OS-assigned local port) and a\n // second quick tunnel to it. The relay's https tunnel URL becomes\n // the `wss://` relay the launcher QR carries (&debug=1&relay=).\n let relayWssUrl: string | undefined;\n // SECRET-HANDLING: relayHttpUrl carries the relay host — never logged.\n let relayHttpUrl: string | undefined;\n if (tunnelConfig.cdp) {\n try {\n // Relay-auth baseline (issue #250): the env-2 CDP relay is\n // reachable over a public `*.trycloudflare.com` tunnel, so a\n // configured TOTP secret is MANDATORY and the relay enforces\n // it on every WS upgrade.\n //\n // First-run auto-mint (issue #394, project-local #396): if\n // AIT_DEBUG_TOTP_SECRET is not yet set, ensureRelaySecret()\n // mints a 256-bit random secret, persists it to the project-\n // local file <project>/.ait_relay (0600, anchored at the\n // nearest package.json directory above server.config.root),\n // and injects it into process.env so the following\n // assertRelayAuthConfigured() call succeeds. On subsequent\n // runs the persisted value is loaded silently — no manual\n // export needed. The MCP daemon reads the SAME file read-only\n // via loadRelaySecretReadOnly() when switching to a relay env.\n // SECRET-HANDLING: neither ensureRelaySecret nor the\n // guard/predicate log the secret value.\n const { ensureRelaySecret } = await import('../mcp/relay-secret-store.js');\n await ensureRelaySecret({ projectRoot: server.config.root });\n const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import(\n '../mcp/totp.js'\n );\n assertRelayAuthConfigured();\n const verifyAuth = buildRelayVerifyAuth();\n const { startChiiRelay } = await import('../mcp/chii-relay.js');\n // Issue #467: this relay lives in the vite process, so the\n // MCP daemon's get_debug_status counter cannot see its 401s.\n // Surface a throttled hint in the vite terminal instead.\n // SECRET-HANDLING: fixed message only — no URL, code, host.\n let lastAuthRejectWarnAt = 0;\n const r = await startChiiRelay({\n port: 0,\n verifyAuth,\n onAuthReject: () => {\n const nowMs = Date.now();\n if (nowMs - lastAuthRejectWarnAt < 10_000) return;\n lastAuthRejectWarnAt = nowMs;\n console.warn(\n '[@ait-co/devtools] tunnel: relay 인증(TOTP) 거부 감지 — 폰에서 QR을 다시 스캔하세요 (코드는 ~3분마다 만료)',\n );\n },\n });\n relay = r;\n const rt = await startQuickTunnel(r.port);\n relayTunnel = rt;\n // SECRET-HANDLING: rt.url is the https relay base — stored in\n // relayHttpUrl for .ait_urls write below; never logged.\n relayHttpUrl = rt.url;\n relayWssUrl = rt.url.replace(/^https:/, 'wss:');\n } catch (err: unknown) {\n console.warn(\n `[@ait-co/devtools] tunnel: CDP relay not started — screen preview works without on-device debugging: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n\n await printTunnelBanner(t.url, { qr: tunnelConfig.qr, relayWssUrl });\n\n // env-2 URL file-based discovery (#424): write .ait_urls so the\n // MCP daemon can discover the relay/tunnel URLs without manual env\n // var copy-paste. SECRET-HANDLING: URL values are never logged.\n // Capture deleteRelayUrls in the outer-scope fn so cleanup() can\n // call it without re-importing (no async in signal handlers).\n const { writeRelayUrls, deleteRelayUrls } = await import(\n '../mcp/relay-url-store.js'\n );\n await writeRelayUrls({\n projectRoot: server.config.root,\n tunnelBaseUrl: t.url,\n ...(relayHttpUrl !== undefined ? { relayBaseUrl: relayHttpUrl } : {}),\n });\n relayUrlDeleteFn = (root: string) => deleteRelayUrls({ projectRoot: root });\n\n // env-2 HTML dashboard (issue #408): when CDP is wired and a GUI\n // is present, serve the same QR+FAQ dashboard env 3/4 uses and\n // open it in the browser. No-op (returns undefined) for the\n // screen-only tunnel, headless, qr:false, or AIT_AUTO_DEVTOOLS=0\n // — the ASCII QR above remains the fallback in those cases.\n if (relayWssUrl) {\n qrDashboard =\n (await startTunnelDashboard({\n tunnelUrl: t.url,\n relayWssUrl,\n qr: tunnelConfig.qr,\n })) ?? null;\n }\n\n // #420: start watching the parent PID now that tunnel resources\n // are allocated. When the parent dies/reparents, clean up\n // synchronously (stops cloudflared children) then exit.\n parentWatcher = startParentWatcher(() => {\n cleanup();\n process.exit(0);\n });\n })\n .catch((err: unknown) => {\n console.warn(\n `[@ait-co/devtools] tunnel failed to start: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n });\n });\n\n const cleanup = () => {\n parentWatcher?.stop();\n tunnel?.stop();\n relayTunnel?.stop();\n void relay?.close();\n void qrDashboard?.close();\n // env-2 URL file cleanup (#424): remove .ait_urls on teardown so a\n // stale file doesn't cause the MCP daemon to attempt a doomed attach.\n // SECRET-HANDLING: relayUrlDeleteFn never logs the path or URL values.\n void relayUrlDeleteFn?.(server.config.root);\n };\n httpServer?.once('close', cleanup);\n process.once('SIGINT', cleanup);\n process.once('SIGTERM', cleanup);\n process.once('SIGHUP', cleanup);\n process.once('exit', cleanup);\n }\n },\n },\n };\n});\n\nexport const vite = aitDevtoolsPlugin.vite;\nexport const webpack = aitDevtoolsPlugin.webpack;\nexport const rollup = aitDevtoolsPlugin.rollup;\nexport const esbuild = aitDevtoolsPlugin.esbuild;\nexport const rspack = aitDevtoolsPlugin.rspack;\n\nexport default aitDevtoolsPlugin;\n"],"mappings":";;;;;;;;;;;;;;;;;;AAoBA,SAAgB,WAAW,KAAsB;AAC/C,KAAI;AACF,UAAQ,KAAK,KAAK,EAAE;AACpB,SAAO;UACA,KAAc;AAGrB,MAAK,IAA8B,SAAS,QAAS,QAAO;AAC5D,SAAO;;;;;;;;;;;;;;;;;;;;;AA0BX,SAAgB,mBACd,YACA,MAOkB;CAClB,MAAM,EACJ,aAAa,KACb,cAAc,QAAQ,MACtB,UAAU,YACV,gBAAgB,QAAQ,MACxB,OAAO,QAAgB,QAAQ,OAAO,MAAM,IAAI,KAC9C,QAAQ,EAAE;AAId,KAAI,eAAe,GAAG;AACpB,MAAI,2EAA2E;AAC/E,SAAO,EAAE,OAAO,IAAI;;CAGtB,IAAI,QAAQ;CAEZ,MAAM,SAAS,kBAAkB;AAC/B,MAAI,MAAO;EAEX,MAAM,cAAc,SAAS;AAG7B,MAFiB,gBAAgB,eAAe,CAAC,QAAQ,YAAY,EAEvD;AACZ,WAAQ;AACR,iBAAc,OAAO;AACrB,OACE,8CAA8C,YAAY,wBAAwB,YAAY,qBAC/F;AACD,eAAY;;IAEb,WAAW;AAEd,QAAO,EACL,OAAO;AACL,gBAAc,OAAO;IAExB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC/DH,MAAM,mBAAmB;AACvB,KAAI;AACF,SAAO,cAAc,OAAO,KAAK,QAAQ,wBAAwB,CAAC;SAC5D;AAEN,SAAO;;IAEP;AAsDJ,MAAM,eAAe;AACrB,MAAM,YAAY;AAClB,MAAM,eAAe;AACrB,MAAM,oBAAoB;;AAG1B,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;AAoBvB,SAAgB,oBACd,UACA,KAC8B;AAC9B,QAAO,aAAa,IAAI,aAAa,EAAE,KAAK,CAAC,CAAC,IAAI,gBAAgB,GAAG;;AAGvE,MAAM,oBAAoB,gBAAgB,YAAiC;CACzE,MAAM,QAAQ,QAAQ,IAAI,aAAa;CACvC,MAAM,eAAe,UAAU,SAAS,eAAe;CACvD,MAAM,aAAa,iBAAiB,SAAS,QAAQ;CACrD,MAAM,cAAc,iBAAiB,SAAS,SAAS;CACvD,MAAM,YAAY,iBAAiB,SAAS,OAAO;CAInD,IAAI,YAA2B;CAW/B,MAAM,YAAY,oBAAoB,SAAS,QAAQ,QAAQ,IAAI;CACnE,MAAM,eAAe,SAAS,CAAC,CAAC;CAChC,MAAM,eAAe,OAAO,cAAc,WAAW,YAAY,EAAE;AAEnE,QAAO;EACL,MAAM;EACN,SAAS;EAET,UAAU,IAAY;AACpB,OAAI,CAAC,WAAY,QAAO;AAExB,OACE,OAAO,gBACP,OAAO,qBACP,OAAO,aACP,OAAO,aAEP,QAAO;AAET,UAAO;;EAGT,iBAAiB,IAAY;AAC3B,OAAI,CAAC,YAAa,QAAO;AAEzB,UACE,iBAAiB,KAAK,GAAG,IACzB,sCAAsC,KAAK,GAAG,IAC9C,CAAC,GAAG,SAAS,eAAe;;EAIhC,UAAU,MAAc;AAEtB,OAAI,CAAC,YAAa,QAAO;AAEzB,OAAI,KAAK,SAAS,yBAAyB,CAAE,QAAO;AAEpD,UAAO,qCAAqC;;EAO9C,MAAM;GACJ,SAAS;AACP,QAAI,CAAC,aAAc;AAKnB,WAAO,EAAE,QAAQ,EAAE,cAAc,CAAC,qBAAqB,EAAE,EAAE;;GAG7D,gBAAgB,QAAsC;AAEpD,QAAI,UACF,QAAO,YAAY,IAAI,iBAAiB,KAAK,QAAQ;AAEnD,SAAI,UAAU,+BAA+B,IAAI;AACjD,SAAI,UAAU,gCAAgC,qBAAqB;AACnE,SAAI,UAAU,gCAAgC,eAAe;AAE7D,SAAI,IAAI,WAAW,WAAW;AAC5B,UAAI,UAAU,IAAI;AAClB,UAAI,KAAK;AACT;;AAGF,SAAI,IAAI,WAAW,OAAO;AACxB,UAAI,cAAc,MAAM;AACtB,WAAI,UAAU,KAAK,EAAE,gBAAgB,oBAAoB,CAAC;AAC1D,WAAI,IACF,KAAK,UAAU,EACb,OAAO,2DACR,CAAC,CACH;AACD;;AAEF,UAAI,UAAU,KAAK,EAAE,gBAAgB,oBAAoB,CAAC;AAC1D,UAAI,IAAI,UAAU;AAClB;;AAGF,SAAI,IAAI,WAAW,QAAQ;MACzB,MAAM,SAAmB,EAAE;AAC3B,UAAI,GAAG,SAAS,UAAkB,OAAO,KAAK,MAAM,CAAC;AACrD,UAAI,GAAG,aAAa;AAClB,WAAI;QACF,MAAM,OAAO,OAAO,OAAO,OAAO,CAAC,SAAS,QAAQ;AAEpD,aAAK,MAAM,KAAK;AAChB,oBAAY;AACZ,YAAI,UAAU,IAAI;AAClB,YAAI,KAAK;eACH;AACN,YAAI,UAAU,KAAK,EAAE,gBAAgB,oBAAoB,CAAC;AAC1D,YAAI,IAAI,KAAK,UAAU,EAAE,OAAO,gBAAgB,CAAC,CAAC;;QAEpD;AACF;;AAGF,SAAI,UAAU,KAAK,EAAE,gBAAgB,oBAAoB,CAAC;AAC1D,SAAI,IAAI,KAAK,UAAU,EAAE,OAAO,sBAAsB,CAAC,CAAC;MACxD;AAIJ,QAAI,cAAc;KAChB,IAAI,SAAsC;KAG1C,IAAI,cAA2C;KAC/C,IAAI,QAA+C;KAKnD,IAAI,cAAqD;KAIzD,IAAI,mBAAoE;KAGxE,IAAI,gBAAyC;KAC7C,MAAM,aAAa,OAAO;AAE1B,iBAAY,KAAK,mBAAmB;MAClC,MAAM,UAAU,YAAY,SAAS;MACrC,MAAM,OACJ,aAAa,SACZ,WAAW,OAAO,YAAY,WAAW,QAAQ,OAAO,KAAA;AAC3D,UAAI,CAAC,MAAM;AACT,eAAQ,KACN,gFACD;AACD;;AAIF,aAAO,yBACJ,KAAK,OAAO,EAAE,kBAAkB,mBAAmB,2BAA2B;OAC7E,MAAM,IAAI,MAAM,iBAAiB,KAAK;AACtC,gBAAS;OAKT,IAAI;OAEJ,IAAI;AACJ,WAAI,aAAa,IACf,KAAI;QAkBF,MAAM,EAAE,sBAAsB,MAAM,OAAO;AAC3C,cAAM,kBAAkB,EAAE,aAAa,OAAO,OAAO,MAAM,CAAC;QAC5D,MAAM,EAAE,2BAA2B,yBAAyB,MAAM,OAChE;AAEF,mCAA2B;QAC3B,MAAM,aAAa,sBAAsB;QACzC,MAAM,EAAE,mBAAmB,MAAM,OAAO;QAKxC,IAAI,uBAAuB;QAC3B,MAAM,IAAI,MAAM,eAAe;SAC7B,MAAM;SACN;SACA,oBAAoB;UAClB,MAAM,QAAQ,KAAK,KAAK;AACxB,cAAI,QAAQ,uBAAuB,IAAQ;AAC3C,iCAAuB;AACvB,kBAAQ,KACN,oFACD;;SAEJ,CAAC;AACF,gBAAQ;QACR,MAAM,KAAK,MAAM,iBAAiB,EAAE,KAAK;AACzC,sBAAc;AAGd,uBAAe,GAAG;AAClB,sBAAc,GAAG,IAAI,QAAQ,WAAW,OAAO;gBACxC,KAAc;AACrB,gBAAQ,KACN,wGACE,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI,GAEnD;;AAIL,aAAM,kBAAkB,EAAE,KAAK;QAAE,IAAI,aAAa;QAAI;QAAa,CAAC;OAOpE,MAAM,EAAE,gBAAgB,oBAAoB,MAAM,OAChD;AAEF,aAAM,eAAe;QACnB,aAAa,OAAO,OAAO;QAC3B,eAAe,EAAE;QACjB,GAAI,iBAAiB,KAAA,IAAY,EAAE,cAAc,cAAc,GAAG,EAAE;QACrE,CAAC;AACF,2BAAoB,SAAiB,gBAAgB,EAAE,aAAa,MAAM,CAAC;AAO3E,WAAI,YACF,eACG,MAAM,qBAAqB;QAC1B,WAAW,EAAE;QACb;QACA,IAAI,aAAa;QAClB,CAAC,IAAK;AAMX,uBAAgB,yBAAyB;AACvC,iBAAS;AACT,gBAAQ,KAAK,EAAE;SACf;QACF,CACD,OAAO,QAAiB;AACvB,eAAQ,KACN,8CACE,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI,GAEnD;QACD;OACJ;KAEF,MAAM,gBAAgB;AACpB,qBAAe,MAAM;AACrB,cAAQ,MAAM;AACd,mBAAa,MAAM;AACd,aAAO,OAAO;AACd,mBAAa,OAAO;AAIpB,yBAAmB,OAAO,OAAO,KAAK;;AAE7C,iBAAY,KAAK,SAAS,QAAQ;AAClC,aAAQ,KAAK,UAAU,QAAQ;AAC/B,aAAQ,KAAK,WAAW,QAAQ;AAChC,aAAQ,KAAK,UAAU,QAAQ;AAC/B,aAAQ,KAAK,QAAQ,QAAQ;;;GAGlC;EACF;EACD;AAEF,MAAa,OAAO,kBAAkB;AACtC,MAAa,UAAU,kBAAkB;AACzC,MAAa,SAAS,kBAAkB;AACxC,MAAa,UAAU,kBAAkB;AACzC,MAAa,SAAS,kBAAkB"}
@@ -127,7 +127,7 @@ async function startTunnelDashboard(opts) {
127
127
  if (opts.qr === false) return void 0;
128
128
  const { isAutoDevtoolsDisabled } = await Promise.resolve().then(() => require("../devtools-opener-h6A-UjzC.cjs"));
129
129
  if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
130
- const { startQrHttpServer } = await Promise.resolve().then(() => require("../qr-http-server-ClakYBO9.cjs"));
130
+ const { startQrHttpServer } = await Promise.resolve().then(() => require("../qr-http-server-DPOOrh6y.cjs"));
131
131
  const { buildLauncherAttachUrl } = await Promise.resolve().then(() => require("../deeplink-CCGiyoHq.cjs"));
132
132
  const { generateTotp } = await Promise.resolve().then(() => require("../totp-D9rndqg_.cjs"));
133
133
  const getDashboardState = () => {
@@ -126,7 +126,7 @@ async function startTunnelDashboard(opts) {
126
126
  if (opts.qr === false) return void 0;
127
127
  const { isAutoDevtoolsDisabled } = await import("../devtools-opener-D84kZFtR.js");
128
128
  if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
129
- const { startQrHttpServer } = await import("../qr-http-server-CeEzLS3g.js");
129
+ const { startQrHttpServer } = await import("../qr-http-server-Bh9qkiRm.js");
130
130
  const { buildLauncherAttachUrl } = await import("../deeplink-Cqli4qzm.js");
131
131
  const { generateTotp } = await import("../totp-BxtxuEt4.js");
132
132
  const getDashboardState = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ait-co/devtools",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "description": "Development tools for Apps in Toss mini-apps — mock SDK, floating devtools panel, and universal bundler plugin",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1 +0,0 @@
1
- {"version":3,"file":"chii-relay-BcWDKbQ1.js","names":[],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: only the path-prefixed form is ours — the phone\n // fetches `target.js` via `https://<host>/at/<code>/target.js` (issue\n // #466), which must be verified + stripped so chii's Koa static handler\n // serves `/target.js`. Non-prefixed requests keep today's behaviour\n // (ungated pass-through to chii).\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten === null) return;\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // We do NOT log req.url or any auth param here to avoid leaking codes.\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed: no-op — chii's Koa 'request' listener (registered below\n // by chii.start) serves the rewritten URL. (Koa skips writing when an\n // earlier listener already ended the response, so the 401 path is safe\n // even though Koa still runs.)\n });\n }\n\n const chii = loadChiiServer();\n // Passing an existing `server` makes chii attach its Koa handler + WS upgrade\n // to our HTTP server rather than creating its own listener.\n // Note: port/domain here are display-only inside chii — the TCP bind is ours.\n await chii.start({ server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort });\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,+BAA+B;;;;;;AAO5C,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACiCxC,MAAM,UAAU,cAAc,OAAO,KAAK,IAAI;AAa9C,SAAS,iBAAmC;CAE1C,MAAM,MAAe,QAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;;;;AAyC9D,SAAgB,oBAAoB,QAA+B;CACjE,MAAM,QAAQ,oCAAoC,KAAK,OAAO;AAC9D,KAAI,UAAU,KAAM,QAAO;CAC3B,MAAM,OAAO,MAAM;CACnB,MAAM,OAAO,MAAM,OAAO,KAAA,KAAa,MAAM,OAAO,KAAK,MAAM,MAAM;CACrE,MAAM,QAAQ,MAAM,MAAM;AAE1B,QAAO,GAAG,OAAO,QADC,UAAU,KAAK,MAAM,IACJ,KAAK;;;;;;;;;;;;;;;;;;AAgE1C,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,YAAY,iBAAiB;CAErC,MAAM,aAAa,cAAc;CAIjC,MAAM,oBAAoB,SAA6C;AACrE,MAAI,iBAAiB,KAAA,EAAW;AAChC,MAAI;AACF,gBAAa,EAAE,MAAM,CAAC;UAChB;;AAiBV,KAAI,WAMF,YAAW,GAAG,YAAY,KAAK,QAAQ;EACrC,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,MAAI,cAAc,KAAM;AACxB,MAAI,MAAM;AACV,MAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,OAAI,aAAa;AACjB,OAAI,UAAU,+BAA+B,IAAI;AACjD,OAAI,UAAU,gBAAgB,mBAAmB;AACjD,OAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,oBAAiB,eAAe;;GAMlC;AAOJ,OAJa,gBAAgB,CAIlB,MAAM;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,CAAC;AAUjG,KAAI,YAAY;EACd,MAAM,uBAAuB,WAAW,UAAU,UAAU;AAG5D,aAAW,mBAAmB,UAAU;EAGxC,MAAM,YAAY,IAAI,gBAAgB,EAAE,UAAU,MAAM,CAAC;AACzD,aAAW,GAAG,YAAY,KAAsB,QAAgB,SAAiB;GAK/E,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,OAAI,cAAc,KAChB,KAAI,MAAM;AAEZ,OAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,cAAU,cAAc,KAAK,QAAQ,OAAO,OAAO;AACjD,QAAG,MAAM,8BAA8B,yBAAyB;MAChE;AACF,qBAAiB,aAAa;AAE9B;;AAIF,QAAK,MAAM,YAAY,qBACrB,UAAS,KAAK,QAAQ,KAAK;IAE7B;;CAGJ,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;AAEF,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,cAAW,YAAY,SAAS,CAAC;IACjC;EACL"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"chii-relay-D5Hc0G39.cjs","names":["require","WebSocketServer"],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: only the path-prefixed form is ours — the phone\n // fetches `target.js` via `https://<host>/at/<code>/target.js` (issue\n // #466), which must be verified + stripped so chii's Koa static handler\n // serves `/target.js`. Non-prefixed requests keep today's behaviour\n // (ungated pass-through to chii).\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten === null) return;\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // We do NOT log req.url or any auth param here to avoid leaking codes.\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed: no-op — chii's Koa 'request' listener (registered below\n // by chii.start) serves the rewritten URL. (Koa skips writing when an\n // earlier listener already ended the response, so the 401 path is safe\n // even though Koa still runs.)\n });\n }\n\n const chii = loadChiiServer();\n // Passing an existing `server` makes chii attach its Koa handler + WS upgrade\n // to our HTTP server rather than creating its own listener.\n // Note: port/domain here are display-only inside chii — the TCP bind is ours.\n await chii.start({ server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort });\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,+BAA+B;;;;;;AAO5C,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACiCxC,MAAMA,aAAAA,GAAAA,YAAAA,eAAAA,QAAAA,MAAAA,CAAAA,cAAAA,WAAAA,CAAAA,KAAwC;AAa9C,SAAS,iBAAmC;CAE1C,MAAM,MAAeA,UAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;;;;AAyC9D,SAAgB,oBAAoB,QAA+B;CACjE,MAAM,QAAQ,oCAAoC,KAAK,OAAO;AAC9D,KAAI,UAAU,KAAM,QAAO;CAC3B,MAAM,OAAO,MAAM;CACnB,MAAM,OAAO,MAAM,OAAO,KAAA,KAAa,MAAM,OAAO,KAAK,MAAM,MAAM;CACrE,MAAM,QAAQ,MAAM,MAAM;AAE1B,QAAO,GAAG,OAAO,QADC,UAAU,KAAK,MAAM,IACJ,KAAK;;;;;;;;;;;;;;;;;;AAgE1C,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,YAAY,iBAAiB;CAErC,MAAM,cAAA,GAAA,UAAA,eAA2B;CAIjC,MAAM,oBAAoB,SAA6C;AACrE,MAAI,iBAAiB,KAAA,EAAW;AAChC,MAAI;AACF,gBAAa,EAAE,MAAM,CAAC;UAChB;;AAiBV,KAAI,WAMF,YAAW,GAAG,YAAY,KAAK,QAAQ;EACrC,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,MAAI,cAAc,KAAM;AACxB,MAAI,MAAM;AACV,MAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,OAAI,aAAa;AACjB,OAAI,UAAU,+BAA+B,IAAI;AACjD,OAAI,UAAU,gBAAgB,mBAAmB;AACjD,OAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,oBAAiB,eAAe;;GAMlC;AAOJ,OAJa,gBAAgB,CAIlB,MAAM;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,CAAC;AAUjG,KAAI,YAAY;EACd,MAAM,uBAAuB,WAAW,UAAU,UAAU;AAG5D,aAAW,mBAAmB,UAAU;EAGxC,MAAM,YAAY,IAAIC,GAAAA,gBAAgB,EAAE,UAAU,MAAM,CAAC;AACzD,aAAW,GAAG,YAAY,KAAsB,QAAgB,SAAiB;GAK/E,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,OAAI,cAAc,KAChB,KAAI,MAAM;AAEZ,OAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,cAAU,cAAc,KAAK,QAAQ,OAAO,SAAO;AACjD,UAAG,MAAM,8BAA8B,yBAAyB;MAChE;AACF,qBAAiB,aAAa;AAE9B;;AAIF,QAAK,MAAM,YAAY,qBACrB,UAAS,KAAK,QAAQ,KAAK;IAE7B;;CAGJ,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;AAEF,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,cAAW,YAAY,SAAS,CAAC;IACjC;EACL"}