@ait-co/devtools 0.1.101 → 0.1.102

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 (36) hide show
  1. package/dist/mcp/cli.js +20 -13
  2. package/dist/mcp/cli.js.map +1 -1
  3. package/dist/mcp/server.js +1 -1
  4. package/dist/panel/index.js +2 -2
  5. package/dist/{qr-http-server-Clvk1weS.cjs → qr-http-server-B7DsRdN1.cjs} +13 -6
  6. package/dist/{qr-http-server-Clvk1weS.cjs.map → qr-http-server-B7DsRdN1.cjs.map} +1 -1
  7. package/dist/{qr-http-server-B1fmICC4.js → qr-http-server-CK-ZT_pC.js} +13 -6
  8. package/dist/{qr-http-server-B1fmICC4.js.map → qr-http-server-CK-ZT_pC.js.map} +1 -1
  9. package/dist/{qr-http-server-ofopTUL-.js → qr-http-server-DI3A6f5L.js} +13 -6
  10. package/dist/{qr-http-server-ofopTUL-.js.map → qr-http-server-DI3A6f5L.js.map} +1 -1
  11. package/dist/{qr-http-server-C9NUBysQ.cjs → qr-http-server-Dqb3GQju.cjs} +13 -6
  12. package/dist/{qr-http-server-C9NUBysQ.cjs.map → qr-http-server-Dqb3GQju.cjs.map} +1 -1
  13. package/dist/{relay-secret-store-J0SUUXjH.js → relay-secret-store-B0DH-8Qb.js} +46 -3
  14. package/dist/relay-secret-store-B0DH-8Qb.js.map +1 -0
  15. package/dist/{relay-secret-store-B5WAozDv.cjs → relay-secret-store-CqDaaFW1.cjs} +43 -2
  16. package/dist/relay-secret-store-CqDaaFW1.cjs.map +1 -0
  17. package/dist/{relay-secret-store-BvNWdSjV.js → relay-secret-store-DKuoAJmA.js} +43 -2
  18. package/dist/relay-secret-store-DKuoAJmA.js.map +1 -0
  19. package/dist/{relay-url-store-RKcao_yG.js → relay-url-store-BPeUZsiY.js} +2 -2
  20. package/dist/{relay-url-store-RKcao_yG.js.map → relay-url-store-BPeUZsiY.js.map} +1 -1
  21. package/dist/{relay-url-store-D2lX9POP.cjs → relay-url-store-CIZlFBkR.cjs} +2 -2
  22. package/dist/{relay-url-store-D2lX9POP.cjs.map → relay-url-store-CIZlFBkR.cjs.map} +1 -1
  23. package/dist/{relay-url-store-1CXVqNDL.js → relay-url-store-DASEZiT9.js} +2 -2
  24. package/dist/{relay-url-store-1CXVqNDL.js.map → relay-url-store-DASEZiT9.js.map} +1 -1
  25. package/dist/{tunnel-C_qpse3-.js → tunnel-CepDBgEc.js} +2 -2
  26. package/dist/{tunnel-C_qpse3-.js.map → tunnel-CepDBgEc.js.map} +1 -1
  27. package/dist/{tunnel-BmDfjkQI.cjs → tunnel-D0QnxKsF.cjs} +2 -2
  28. package/dist/{tunnel-BmDfjkQI.cjs.map → tunnel-D0QnxKsF.cjs.map} +1 -1
  29. package/dist/unplugin/index.cjs +3 -3
  30. package/dist/unplugin/index.js +3 -3
  31. package/dist/unplugin/tunnel.cjs +1 -1
  32. package/dist/unplugin/tunnel.js +1 -1
  33. package/package.json +1 -1
  34. package/dist/relay-secret-store-B5WAozDv.cjs.map +0 -1
  35. package/dist/relay-secret-store-BvNWdSjV.js.map +0 -1
  36. package/dist/relay-secret-store-J0SUUXjH.js.map +0 -1
@@ -154,7 +154,7 @@ async function startTunnelDashboard(opts) {
154
154
  if (opts.qr === false) return void 0;
155
155
  const { isAutoDevtoolsDisabled } = await import("./devtools-opener-XpwL3fZ9.js");
156
156
  if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
157
- const { startQrHttpServer } = await import("./qr-http-server-ofopTUL-.js");
157
+ const { startQrHttpServer } = await import("./qr-http-server-CK-ZT_pC.js");
158
158
  const { buildLauncherAttachUrl } = await import("./deeplink-D1HXJ2YG.js");
159
159
  const { generateTotp } = await import("./totp-CauHjkdE.js");
160
160
  const getDashboardState = () => {
@@ -288,4 +288,4 @@ async function startQuickTunnel(port) {
288
288
  //#endregion
289
289
  export { printTunnelBanner, startQuickTunnel, startTunnelDashboard };
290
290
 
291
- //# sourceMappingURL=tunnel-C_qpse3-.js.map
291
+ //# sourceMappingURL=tunnel-CepDBgEc.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel-C_qpse3-.js","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, the deep-link carries `&navBarType=game`\n * so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the default) is the launcher's implicit default — not added to\n * keep the URL clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, the deep-link carries `&navBarTransparent=1` so the launcher\n * partner bar renders with a transparent background (content shows through).\n * `false` / omitted → not added (URL clean, back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (`granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, the deep-link carries\n * `&navBarTheme=<v>` so the launcher partner bar uses the matching foreground\n * colour. Omitted / other values → not added (URL clean, back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Options for {@link buildLauncherDeepLink}.\n */\nexport interface BuildLauncherDeepLinkOptions {\n /**\n * `wss://` relay URL for env-2 CDP wiring. When present the deep-link carries\n * `&debug=1&relay=<wss>`.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param, #498).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, adds `&navBarType=game` to the\n * deep-link so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the launcher's implicit default) is not added to keep the URL\n * clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, adds `&navBarTransparent=1` to the deep-link so the launcher\n * partner bar renders with a transparent background. Omitted when `false` /\n * undefined to keep the URL clean (back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, adds `&navBarTheme=<v>` to\n * the deep-link so the launcher partner bar uses the matching foreground colour.\n * Omitted when undefined / other values to keep the URL clean (back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `opts.relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the launcher\n * partner bar shows the app name instead of the generic default (#498).\n *\n * When `opts.webViewType` is `'game'`, `&navBarType=game` is appended so the\n * launcher enters game nav chrome (floating capsule, no full bar) automatically\n * on scan. `'partner'` is the launcher's implicit default and is not added to\n * keep the URL clean (#584).\n *\n * When `opts.navBarTransparent` is `true`, `&navBarTransparent=1` is appended\n * so the launcher partner bar renders with a transparent background (#587).\n *\n * When `opts.navBarTheme` is `'light'` or `'dark'`, `&navBarTheme=<v>` is\n * appended so the launcher partner bar uses the matching foreground colour (#587).\n *\n * Back-compat: the second argument may also be a plain string (`relayWssUrl`)\n * for callers that haven't migrated to the options object yet.\n */\nexport function buildLauncherDeepLink(\n tunnelUrl: string,\n optsOrRelay?: string | BuildLauncherDeepLinkOptions,\n): string {\n // Normalise the overloaded second argument.\n const opts: BuildLauncherDeepLinkOptions =\n typeof optsOrRelay === 'string' ? { relayWssUrl: optsOrRelay } : (optsOrRelay ?? {});\n\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n let url = base;\n if (opts.relayWssUrl) {\n url += `&debug=1&relay=${encodeURIComponent(opts.relayWssUrl)}`;\n }\n if (opts.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts.webViewType === 'game') {\n url += '&navBarType=game';\n }\n if (opts.navBarTransparent === true) {\n url += '&navBarTransparent=1';\n }\n if (opts.navBarTheme === 'light' || opts.navBarTheme === 'dark') {\n url += `&navBarTheme=${opts.navBarTheme}`;\n }\n return url;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, {\n relayWssUrl: opts.relayWssUrl,\n name: opts.name,\n webViewType: opts.webViewType,\n navBarTransparent: opts.navBarTransparent,\n navBarTheme: opts.navBarTheme,\n });\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\n/**\n * Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in\n * `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path\n * does not statically pull the heavy MCP `tools.ts` module graph into the lazy\n * `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.\n *\n * - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).\n * - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.\n * - CI (`CI=true`/`CI=1`) → no.\n */\nfunction canOpenBrowser(): boolean {\n if (process.env.CI === 'true' || process.env.CI === '1') return false;\n const platform = process.platform;\n if (platform === 'darwin' || platform === 'win32') return true;\n if (platform === 'linux') {\n return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);\n }\n return false;\n}\n\n/** Handle returned by {@link startTunnelDashboard}. */\nexport interface TunnelDashboard {\n /** `http://127.0.0.1:<port>` — the local dashboard URL opened in the browser. */\n url: string;\n /** Tear down the local HTTP server. Idempotent via the underlying server. */\n close: () => Promise<void>;\n}\n\nexport interface StartTunnelDashboardOptions {\n /** The public `https://*.trycloudflare.com` app tunnel URL the launcher frames. */\n tunnelUrl: string;\n /** The `wss://` relay URL of the env-2 CDP tunnel. REQUIRED — the dashboard is a CDP-only UX. */\n relayWssUrl: string;\n /** Mirror of `tunnel.qr` — when `false` the dashboard is skipped (no browser open). */\n qr?: boolean;\n /**\n * Override the GUI/opt-out gate (testing only). When omitted the real\n * `canOpenBrowser()` + `AIT_AUTO_DEVTOOLS` checks decide.\n */\n shouldOpen?: () => boolean;\n /** Sink for the one-line \"opened in browser\" note (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n}\n\n/**\n * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is\n * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps\n * + FAQ) that the MCP `build_attach_url` path serves, and auto-open it in the\n * browser. headless / opt-out falls back to the terminal ASCII QR (printed\n * separately by {@link printTunnelBanner}).\n *\n * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP\n * server, the opener) is reached only through dynamic `import()` here, inside\n * the already-lazy `tunnel.js` chunk — nothing is added to the common build\n * graph or the MCP-only install graph.\n *\n * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH\n * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds\n * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on\n * each SSE push / page reload from this closure, the code a phone scans is always\n * within its 30 s window — no stale code is baked into static HTML.\n *\n * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`\n * value/path are NEVER written to stdout/stderr/logs here. They live only inside\n * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).\n * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).\n *\n * @returns the dashboard handle when it started (caller wires `close()` into the\n * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,\n * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.\n */\nexport async function startTunnelDashboard(\n opts: StartTunnelDashboardOptions,\n): Promise<TunnelDashboard | undefined> {\n const log = opts.log ?? ((m: string) => console.log(m));\n\n // Gate: dashboard is a CDP-only UX (needs a relay to attach to).\n if (!opts.relayWssUrl) return undefined;\n // Opt-out via `tunnel.qr:false` (same toggle that suppresses the ASCII QR).\n if (opts.qr === false) return undefined;\n\n // GUI + AIT_AUTO_DEVTOOLS gate. Reuse the MCP opener's opt-out predicate so\n // the env-2 path honours the same `AIT_AUTO_DEVTOOLS=0` switch as env 3/4.\n const { isAutoDevtoolsDisabled } = await import('../mcp/devtools-opener.js');\n const gateOpen = opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser());\n if (!gateOpen()) return undefined;\n\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n const { buildLauncherAttachUrl } = await import('../mcp/deeplink.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // getDashboardState — mints a fresh TOTP + attach URL on every call so the QR\n // the dashboard renders (on load and on each SSE push) is never expired.\n // SECRET-HANDLING: the secret is read from env AT CALL TIME (it was injected\n // by ensureRelaySecret in the same CDP block) and is used only to compute the\n // at= code folded into attachUrl. tunnel.up is always true here — the relay\n // tunnel is already up by the time this runs.\n const getDashboardState = () => {\n const secret = process.env.AIT_DEBUG_TOTP_SECRET;\n const totpCode = secret ? generateTotp(secret, Date.now()) : undefined;\n const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode, {\n name: opts.name,\n });\n // pages: null — env 2(unplugin)는 데몬이 아니라 vite 플러그인 안이라\n // startChiiRelay 핸들이 connected target을 노출하지 않는다. 라이브 page 목록을\n // 알 수 없으므로 거짓 빈 목록 대신 \"연결된 Pages\" 섹션 자체를 숨긴다(#411).\n // env 3/4(debug-server.ts)는 router.active.listTargets()로 실제 목록을 채운다.\n // mode: 'relay-mobile' — 이 대시보드는 항상 환경 2(AITC Sandbox PWA) 전용이므로\n // /attach 카피가 launcher PWA 절차(sandbox family)로 분기된다(#468).\n // inspectorUrl: null — env 2에서는 unplugin relay가 connected target ID를 노출하지\n // 않아 buildChiiInspectorUrl에 필요한 targetId를 알 수 없다. target attach 후\n // target ID가 필요하므로 env 3/4에서만 non-null이 된다(#503).\n return {\n tunnel: { up: true, wssUrl: opts.relayWssUrl },\n pages: null,\n attachUrl,\n inspectorUrl: null,\n mode: 'relay-mobile' as const,\n };\n };\n\n let server: Awaited<ReturnType<typeof startQrHttpServer>>;\n try {\n server = await startQrHttpServer(getDashboardState);\n } catch {\n // SECRET-HANDLING: do not surface the error (could embed paths/hosts). The\n // ASCII QR printed by printTunnelBanner stays as the fallback.\n return undefined;\n }\n\n // TOTP periodic refresh timer — pushes a fresh at= code to SSE clients every\n // 20 s so a page left open never stales past the 90 s acceptance window (#448).\n // tunnel.ts always has relayWssUrl available here (gated above), so no\n // lastAttachParts guard is needed — getDashboardState mints a fresh TOTP on\n // every call unconditionally.\n // SECRET-HANDLING: callback is a plain trigger only — TOTP value and at= code\n // must never be logged or written to stdout.\n const TOTP_REFRESH_INTERVAL_MS = 20_000;\n let totpRefreshHandle: ReturnType<typeof setInterval> | null = setInterval(() => {\n server.notifyStateChange();\n }, TOTP_REFRESH_INTERVAL_MS);\n totpRefreshHandle.unref();\n\n const dashboardUrl = `http://127.0.0.1:${server.port}`;\n\n const { openUrlInBrowser } = await import('../mcp/devtools-opener.js');\n const opened = openUrlInBrowser(dashboardUrl);\n // SECRET-HANDLING: only the local 127.0.0.1 URL is logged — never the tunnel\n // host, relay wssUrl, or TOTP code.\n log(\n opened\n ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}`\n : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`,\n );\n\n return {\n url: dashboardUrl,\n close: () => {\n if (totpRefreshHandle) {\n clearInterval(totpRefreshHandle);\n totpRefreshHandle = null;\n }\n return server.close();\n },\n };\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\n/**\n * Sanitize cloudflared stderr output for error diagnostics (#421).\n *\n * Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs\n * that carry those hostnames so tunnel host values never appear in error\n * messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.\n *\n * SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only\n * placeholder text is emitted.\n */\nexport function sanitizeCloudflaredOutput(line: string): string {\n // Full URL forms: https://xxx.trycloudflare.com/… and wss://xxx.trycloudflare.com/…\n let s = line.replace(/(?:https?|wss?):\\/\\/[a-z0-9-]+\\.trycloudflare\\.com(?:\\/[^\\s]*)*/gi, (m) =>\n m.replace(/[a-z0-9-]+\\.trycloudflare\\.com/i, '<HOST>.trycloudflare.com'),\n );\n // Bare hostname without scheme (e.g. printed in cloudflared JSON logs)\n s = s.replace(/[a-z0-9-]+\\.trycloudflare\\.com/gi, '<HOST>.trycloudflare.com');\n return s;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n // #421: accumulate stderr to attach as diagnostics on failure.\n // SECRET-HANDLING: lines are sanitized before inclusion in error messages.\n const stderrLines: string[] = [];\n\n /**\n * Format the last `n` sanitized stderr lines as a diagnostic appendix.\n * Returns an empty string when no lines have been collected.\n */\n const stderrTail = (n = 15): string => {\n if (stderrLines.length === 0) return '';\n const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join('');\n return `\\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\\n${tail}`;\n };\n\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.${stderrTail()}`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n // Accumulate stderr lines for diagnostics (#421). Named so it can be\n // removed from the listener list when cleanup() runs.\n const pushStderr = (line: string) => {\n stderrLines.push(line);\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n tunnel.off('stderr', pushStderr);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n // Second stderr listener: accumulate all lines for error diagnostics.\n tunnel.on('stderr', pushStderr);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.${stderrTail()}`,\n ),\n );\n });\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tunnel-CepDBgEc.js","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, the deep-link carries `&navBarType=game`\n * so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the default) is the launcher's implicit default — not added to\n * keep the URL clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, the deep-link carries `&navBarTransparent=1` so the launcher\n * partner bar renders with a transparent background (content shows through).\n * `false` / omitted → not added (URL clean, back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (`granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, the deep-link carries\n * `&navBarTheme=<v>` so the launcher partner bar uses the matching foreground\n * colour. Omitted / other values → not added (URL clean, back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Options for {@link buildLauncherDeepLink}.\n */\nexport interface BuildLauncherDeepLinkOptions {\n /**\n * `wss://` relay URL for env-2 CDP wiring. When present the deep-link carries\n * `&debug=1&relay=<wss>`.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param, #498).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, adds `&navBarType=game` to the\n * deep-link so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the launcher's implicit default) is not added to keep the URL\n * clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, adds `&navBarTransparent=1` to the deep-link so the launcher\n * partner bar renders with a transparent background. Omitted when `false` /\n * undefined to keep the URL clean (back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, adds `&navBarTheme=<v>` to\n * the deep-link so the launcher partner bar uses the matching foreground colour.\n * Omitted when undefined / other values to keep the URL clean (back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `opts.relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the launcher\n * partner bar shows the app name instead of the generic default (#498).\n *\n * When `opts.webViewType` is `'game'`, `&navBarType=game` is appended so the\n * launcher enters game nav chrome (floating capsule, no full bar) automatically\n * on scan. `'partner'` is the launcher's implicit default and is not added to\n * keep the URL clean (#584).\n *\n * When `opts.navBarTransparent` is `true`, `&navBarTransparent=1` is appended\n * so the launcher partner bar renders with a transparent background (#587).\n *\n * When `opts.navBarTheme` is `'light'` or `'dark'`, `&navBarTheme=<v>` is\n * appended so the launcher partner bar uses the matching foreground colour (#587).\n *\n * Back-compat: the second argument may also be a plain string (`relayWssUrl`)\n * for callers that haven't migrated to the options object yet.\n */\nexport function buildLauncherDeepLink(\n tunnelUrl: string,\n optsOrRelay?: string | BuildLauncherDeepLinkOptions,\n): string {\n // Normalise the overloaded second argument.\n const opts: BuildLauncherDeepLinkOptions =\n typeof optsOrRelay === 'string' ? { relayWssUrl: optsOrRelay } : (optsOrRelay ?? {});\n\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n let url = base;\n if (opts.relayWssUrl) {\n url += `&debug=1&relay=${encodeURIComponent(opts.relayWssUrl)}`;\n }\n if (opts.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts.webViewType === 'game') {\n url += '&navBarType=game';\n }\n if (opts.navBarTransparent === true) {\n url += '&navBarTransparent=1';\n }\n if (opts.navBarTheme === 'light' || opts.navBarTheme === 'dark') {\n url += `&navBarTheme=${opts.navBarTheme}`;\n }\n return url;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, {\n relayWssUrl: opts.relayWssUrl,\n name: opts.name,\n webViewType: opts.webViewType,\n navBarTransparent: opts.navBarTransparent,\n navBarTheme: opts.navBarTheme,\n });\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\n/**\n * Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in\n * `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path\n * does not statically pull the heavy MCP `tools.ts` module graph into the lazy\n * `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.\n *\n * - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).\n * - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.\n * - CI (`CI=true`/`CI=1`) → no.\n */\nfunction canOpenBrowser(): boolean {\n if (process.env.CI === 'true' || process.env.CI === '1') return false;\n const platform = process.platform;\n if (platform === 'darwin' || platform === 'win32') return true;\n if (platform === 'linux') {\n return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);\n }\n return false;\n}\n\n/** Handle returned by {@link startTunnelDashboard}. */\nexport interface TunnelDashboard {\n /** `http://127.0.0.1:<port>` — the local dashboard URL opened in the browser. */\n url: string;\n /** Tear down the local HTTP server. Idempotent via the underlying server. */\n close: () => Promise<void>;\n}\n\nexport interface StartTunnelDashboardOptions {\n /** The public `https://*.trycloudflare.com` app tunnel URL the launcher frames. */\n tunnelUrl: string;\n /** The `wss://` relay URL of the env-2 CDP tunnel. REQUIRED — the dashboard is a CDP-only UX. */\n relayWssUrl: string;\n /** Mirror of `tunnel.qr` — when `false` the dashboard is skipped (no browser open). */\n qr?: boolean;\n /**\n * Override the GUI/opt-out gate (testing only). When omitted the real\n * `canOpenBrowser()` + `AIT_AUTO_DEVTOOLS` checks decide.\n */\n shouldOpen?: () => boolean;\n /** Sink for the one-line \"opened in browser\" note (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n}\n\n/**\n * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is\n * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps\n * + FAQ) that the MCP `build_attach_url` path serves, and auto-open it in the\n * browser. headless / opt-out falls back to the terminal ASCII QR (printed\n * separately by {@link printTunnelBanner}).\n *\n * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP\n * server, the opener) is reached only through dynamic `import()` here, inside\n * the already-lazy `tunnel.js` chunk — nothing is added to the common build\n * graph or the MCP-only install graph.\n *\n * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH\n * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds\n * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on\n * each SSE push / page reload from this closure, the code a phone scans is always\n * within its 30 s window — no stale code is baked into static HTML.\n *\n * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`\n * value/path are NEVER written to stdout/stderr/logs here. They live only inside\n * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).\n * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).\n *\n * @returns the dashboard handle when it started (caller wires `close()` into the\n * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,\n * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.\n */\nexport async function startTunnelDashboard(\n opts: StartTunnelDashboardOptions,\n): Promise<TunnelDashboard | undefined> {\n const log = opts.log ?? ((m: string) => console.log(m));\n\n // Gate: dashboard is a CDP-only UX (needs a relay to attach to).\n if (!opts.relayWssUrl) return undefined;\n // Opt-out via `tunnel.qr:false` (same toggle that suppresses the ASCII QR).\n if (opts.qr === false) return undefined;\n\n // GUI + AIT_AUTO_DEVTOOLS gate. Reuse the MCP opener's opt-out predicate so\n // the env-2 path honours the same `AIT_AUTO_DEVTOOLS=0` switch as env 3/4.\n const { isAutoDevtoolsDisabled } = await import('../mcp/devtools-opener.js');\n const gateOpen = opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser());\n if (!gateOpen()) return undefined;\n\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n const { buildLauncherAttachUrl } = await import('../mcp/deeplink.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // getDashboardState — mints a fresh TOTP + attach URL on every call so the QR\n // the dashboard renders (on load and on each SSE push) is never expired.\n // SECRET-HANDLING: the secret is read from env AT CALL TIME (it was injected\n // by ensureRelaySecret in the same CDP block) and is used only to compute the\n // at= code folded into attachUrl. tunnel.up is always true here — the relay\n // tunnel is already up by the time this runs.\n const getDashboardState = () => {\n const secret = process.env.AIT_DEBUG_TOTP_SECRET;\n const totpCode = secret ? generateTotp(secret, Date.now()) : undefined;\n const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode, {\n name: opts.name,\n });\n // pages: null — env 2(unplugin)는 데몬이 아니라 vite 플러그인 안이라\n // startChiiRelay 핸들이 connected target을 노출하지 않는다. 라이브 page 목록을\n // 알 수 없으므로 거짓 빈 목록 대신 \"연결된 Pages\" 섹션 자체를 숨긴다(#411).\n // env 3/4(debug-server.ts)는 router.active.listTargets()로 실제 목록을 채운다.\n // mode: 'relay-mobile' — 이 대시보드는 항상 환경 2(AITC Sandbox PWA) 전용이므로\n // /attach 카피가 launcher PWA 절차(sandbox family)로 분기된다(#468).\n // inspectorUrl: null — env 2에서는 unplugin relay가 connected target ID를 노출하지\n // 않아 buildChiiInspectorUrl에 필요한 targetId를 알 수 없다. target attach 후\n // target ID가 필요하므로 env 3/4에서만 non-null이 된다(#503).\n return {\n tunnel: { up: true, wssUrl: opts.relayWssUrl },\n pages: null,\n attachUrl,\n inspectorUrl: null,\n mode: 'relay-mobile' as const,\n };\n };\n\n let server: Awaited<ReturnType<typeof startQrHttpServer>>;\n try {\n server = await startQrHttpServer(getDashboardState);\n } catch {\n // SECRET-HANDLING: do not surface the error (could embed paths/hosts). The\n // ASCII QR printed by printTunnelBanner stays as the fallback.\n return undefined;\n }\n\n // TOTP periodic refresh timer — pushes a fresh at= code to SSE clients every\n // 20 s so a page left open never stales past the 90 s acceptance window (#448).\n // tunnel.ts always has relayWssUrl available here (gated above), so no\n // lastAttachParts guard is needed — getDashboardState mints a fresh TOTP on\n // every call unconditionally.\n // SECRET-HANDLING: callback is a plain trigger only — TOTP value and at= code\n // must never be logged or written to stdout.\n const TOTP_REFRESH_INTERVAL_MS = 20_000;\n let totpRefreshHandle: ReturnType<typeof setInterval> | null = setInterval(() => {\n server.notifyStateChange();\n }, TOTP_REFRESH_INTERVAL_MS);\n totpRefreshHandle.unref();\n\n const dashboardUrl = `http://127.0.0.1:${server.port}`;\n\n const { openUrlInBrowser } = await import('../mcp/devtools-opener.js');\n const opened = openUrlInBrowser(dashboardUrl);\n // SECRET-HANDLING: only the local 127.0.0.1 URL is logged — never the tunnel\n // host, relay wssUrl, or TOTP code.\n log(\n opened\n ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}`\n : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`,\n );\n\n return {\n url: dashboardUrl,\n close: () => {\n if (totpRefreshHandle) {\n clearInterval(totpRefreshHandle);\n totpRefreshHandle = null;\n }\n return server.close();\n },\n };\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\n/**\n * Sanitize cloudflared stderr output for error diagnostics (#421).\n *\n * Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs\n * that carry those hostnames so tunnel host values never appear in error\n * messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.\n *\n * SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only\n * placeholder text is emitted.\n */\nexport function sanitizeCloudflaredOutput(line: string): string {\n // Full URL forms: https://xxx.trycloudflare.com/… and wss://xxx.trycloudflare.com/…\n let s = line.replace(/(?:https?|wss?):\\/\\/[a-z0-9-]+\\.trycloudflare\\.com(?:\\/[^\\s]*)*/gi, (m) =>\n m.replace(/[a-z0-9-]+\\.trycloudflare\\.com/i, '<HOST>.trycloudflare.com'),\n );\n // Bare hostname without scheme (e.g. printed in cloudflared JSON logs)\n s = s.replace(/[a-z0-9-]+\\.trycloudflare\\.com/gi, '<HOST>.trycloudflare.com');\n return s;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n // #421: accumulate stderr to attach as diagnostics on failure.\n // SECRET-HANDLING: lines are sanitized before inclusion in error messages.\n const stderrLines: string[] = [];\n\n /**\n * Format the last `n` sanitized stderr lines as a diagnostic appendix.\n * Returns an empty string when no lines have been collected.\n */\n const stderrTail = (n = 15): string => {\n if (stderrLines.length === 0) return '';\n const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join('');\n return `\\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\\n${tail}`;\n };\n\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.${stderrTail()}`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n // Accumulate stderr lines for diagnostics (#421). Named so it can be\n // removed from the listener list when cleanup() runs.\n const pushStderr = (line: string) => {\n stderrLines.push(line);\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n tunnel.off('stderr', pushStderr);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n // Second stderr listener: accumulate all lines for error diagnostics.\n tunnel.on('stderr', pushStderr);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.${stderrTail()}`,\n ),\n );\n });\n });\n}\n"],"mappings":"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"}
@@ -154,7 +154,7 @@ async function startTunnelDashboard(opts) {
154
154
  if (opts.qr === false) return void 0;
155
155
  const { isAutoDevtoolsDisabled } = await Promise.resolve().then(() => require("./devtools-opener-mDgeg_MX.cjs"));
156
156
  if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
157
- const { startQrHttpServer } = await Promise.resolve().then(() => require("./qr-http-server-Clvk1weS.cjs"));
157
+ const { startQrHttpServer } = await Promise.resolve().then(() => require("./qr-http-server-Dqb3GQju.cjs"));
158
158
  const { buildLauncherAttachUrl } = await Promise.resolve().then(() => require("./deeplink-DDOe0FQl.cjs"));
159
159
  const { generateTotp } = await Promise.resolve().then(() => require("./totp-D9fjaVak.cjs"));
160
160
  const getDashboardState = () => {
@@ -290,4 +290,4 @@ exports.printTunnelBanner = printTunnelBanner;
290
290
  exports.startQuickTunnel = startQuickTunnel;
291
291
  exports.startTunnelDashboard = startTunnelDashboard;
292
292
 
293
- //# sourceMappingURL=tunnel-BmDfjkQI.cjs.map
293
+ //# sourceMappingURL=tunnel-D0QnxKsF.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel-BmDfjkQI.cjs","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, the deep-link carries `&navBarType=game`\n * so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the default) is the launcher's implicit default — not added to\n * keep the URL clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, the deep-link carries `&navBarTransparent=1` so the launcher\n * partner bar renders with a transparent background (content shows through).\n * `false` / omitted → not added (URL clean, back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (`granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, the deep-link carries\n * `&navBarTheme=<v>` so the launcher partner bar uses the matching foreground\n * colour. Omitted / other values → not added (URL clean, back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Options for {@link buildLauncherDeepLink}.\n */\nexport interface BuildLauncherDeepLinkOptions {\n /**\n * `wss://` relay URL for env-2 CDP wiring. When present the deep-link carries\n * `&debug=1&relay=<wss>`.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param, #498).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, adds `&navBarType=game` to the\n * deep-link so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the launcher's implicit default) is not added to keep the URL\n * clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, adds `&navBarTransparent=1` to the deep-link so the launcher\n * partner bar renders with a transparent background. Omitted when `false` /\n * undefined to keep the URL clean (back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, adds `&navBarTheme=<v>` to\n * the deep-link so the launcher partner bar uses the matching foreground colour.\n * Omitted when undefined / other values to keep the URL clean (back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `opts.relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the launcher\n * partner bar shows the app name instead of the generic default (#498).\n *\n * When `opts.webViewType` is `'game'`, `&navBarType=game` is appended so the\n * launcher enters game nav chrome (floating capsule, no full bar) automatically\n * on scan. `'partner'` is the launcher's implicit default and is not added to\n * keep the URL clean (#584).\n *\n * When `opts.navBarTransparent` is `true`, `&navBarTransparent=1` is appended\n * so the launcher partner bar renders with a transparent background (#587).\n *\n * When `opts.navBarTheme` is `'light'` or `'dark'`, `&navBarTheme=<v>` is\n * appended so the launcher partner bar uses the matching foreground colour (#587).\n *\n * Back-compat: the second argument may also be a plain string (`relayWssUrl`)\n * for callers that haven't migrated to the options object yet.\n */\nexport function buildLauncherDeepLink(\n tunnelUrl: string,\n optsOrRelay?: string | BuildLauncherDeepLinkOptions,\n): string {\n // Normalise the overloaded second argument.\n const opts: BuildLauncherDeepLinkOptions =\n typeof optsOrRelay === 'string' ? { relayWssUrl: optsOrRelay } : (optsOrRelay ?? {});\n\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n let url = base;\n if (opts.relayWssUrl) {\n url += `&debug=1&relay=${encodeURIComponent(opts.relayWssUrl)}`;\n }\n if (opts.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts.webViewType === 'game') {\n url += '&navBarType=game';\n }\n if (opts.navBarTransparent === true) {\n url += '&navBarTransparent=1';\n }\n if (opts.navBarTheme === 'light' || opts.navBarTheme === 'dark') {\n url += `&navBarTheme=${opts.navBarTheme}`;\n }\n return url;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, {\n relayWssUrl: opts.relayWssUrl,\n name: opts.name,\n webViewType: opts.webViewType,\n navBarTransparent: opts.navBarTransparent,\n navBarTheme: opts.navBarTheme,\n });\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\n/**\n * Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in\n * `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path\n * does not statically pull the heavy MCP `tools.ts` module graph into the lazy\n * `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.\n *\n * - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).\n * - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.\n * - CI (`CI=true`/`CI=1`) → no.\n */\nfunction canOpenBrowser(): boolean {\n if (process.env.CI === 'true' || process.env.CI === '1') return false;\n const platform = process.platform;\n if (platform === 'darwin' || platform === 'win32') return true;\n if (platform === 'linux') {\n return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);\n }\n return false;\n}\n\n/** Handle returned by {@link startTunnelDashboard}. */\nexport interface TunnelDashboard {\n /** `http://127.0.0.1:<port>` — the local dashboard URL opened in the browser. */\n url: string;\n /** Tear down the local HTTP server. Idempotent via the underlying server. */\n close: () => Promise<void>;\n}\n\nexport interface StartTunnelDashboardOptions {\n /** The public `https://*.trycloudflare.com` app tunnel URL the launcher frames. */\n tunnelUrl: string;\n /** The `wss://` relay URL of the env-2 CDP tunnel. REQUIRED — the dashboard is a CDP-only UX. */\n relayWssUrl: string;\n /** Mirror of `tunnel.qr` — when `false` the dashboard is skipped (no browser open). */\n qr?: boolean;\n /**\n * Override the GUI/opt-out gate (testing only). When omitted the real\n * `canOpenBrowser()` + `AIT_AUTO_DEVTOOLS` checks decide.\n */\n shouldOpen?: () => boolean;\n /** Sink for the one-line \"opened in browser\" note (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n}\n\n/**\n * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is\n * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps\n * + FAQ) that the MCP `build_attach_url` path serves, and auto-open it in the\n * browser. headless / opt-out falls back to the terminal ASCII QR (printed\n * separately by {@link printTunnelBanner}).\n *\n * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP\n * server, the opener) is reached only through dynamic `import()` here, inside\n * the already-lazy `tunnel.js` chunk — nothing is added to the common build\n * graph or the MCP-only install graph.\n *\n * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH\n * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds\n * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on\n * each SSE push / page reload from this closure, the code a phone scans is always\n * within its 30 s window — no stale code is baked into static HTML.\n *\n * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`\n * value/path are NEVER written to stdout/stderr/logs here. They live only inside\n * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).\n * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).\n *\n * @returns the dashboard handle when it started (caller wires `close()` into the\n * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,\n * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.\n */\nexport async function startTunnelDashboard(\n opts: StartTunnelDashboardOptions,\n): Promise<TunnelDashboard | undefined> {\n const log = opts.log ?? ((m: string) => console.log(m));\n\n // Gate: dashboard is a CDP-only UX (needs a relay to attach to).\n if (!opts.relayWssUrl) return undefined;\n // Opt-out via `tunnel.qr:false` (same toggle that suppresses the ASCII QR).\n if (opts.qr === false) return undefined;\n\n // GUI + AIT_AUTO_DEVTOOLS gate. Reuse the MCP opener's opt-out predicate so\n // the env-2 path honours the same `AIT_AUTO_DEVTOOLS=0` switch as env 3/4.\n const { isAutoDevtoolsDisabled } = await import('../mcp/devtools-opener.js');\n const gateOpen = opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser());\n if (!gateOpen()) return undefined;\n\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n const { buildLauncherAttachUrl } = await import('../mcp/deeplink.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // getDashboardState — mints a fresh TOTP + attach URL on every call so the QR\n // the dashboard renders (on load and on each SSE push) is never expired.\n // SECRET-HANDLING: the secret is read from env AT CALL TIME (it was injected\n // by ensureRelaySecret in the same CDP block) and is used only to compute the\n // at= code folded into attachUrl. tunnel.up is always true here — the relay\n // tunnel is already up by the time this runs.\n const getDashboardState = () => {\n const secret = process.env.AIT_DEBUG_TOTP_SECRET;\n const totpCode = secret ? generateTotp(secret, Date.now()) : undefined;\n const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode, {\n name: opts.name,\n });\n // pages: null — env 2(unplugin)는 데몬이 아니라 vite 플러그인 안이라\n // startChiiRelay 핸들이 connected target을 노출하지 않는다. 라이브 page 목록을\n // 알 수 없으므로 거짓 빈 목록 대신 \"연결된 Pages\" 섹션 자체를 숨긴다(#411).\n // env 3/4(debug-server.ts)는 router.active.listTargets()로 실제 목록을 채운다.\n // mode: 'relay-mobile' — 이 대시보드는 항상 환경 2(AITC Sandbox PWA) 전용이므로\n // /attach 카피가 launcher PWA 절차(sandbox family)로 분기된다(#468).\n // inspectorUrl: null — env 2에서는 unplugin relay가 connected target ID를 노출하지\n // 않아 buildChiiInspectorUrl에 필요한 targetId를 알 수 없다. target attach 후\n // target ID가 필요하므로 env 3/4에서만 non-null이 된다(#503).\n return {\n tunnel: { up: true, wssUrl: opts.relayWssUrl },\n pages: null,\n attachUrl,\n inspectorUrl: null,\n mode: 'relay-mobile' as const,\n };\n };\n\n let server: Awaited<ReturnType<typeof startQrHttpServer>>;\n try {\n server = await startQrHttpServer(getDashboardState);\n } catch {\n // SECRET-HANDLING: do not surface the error (could embed paths/hosts). The\n // ASCII QR printed by printTunnelBanner stays as the fallback.\n return undefined;\n }\n\n // TOTP periodic refresh timer — pushes a fresh at= code to SSE clients every\n // 20 s so a page left open never stales past the 90 s acceptance window (#448).\n // tunnel.ts always has relayWssUrl available here (gated above), so no\n // lastAttachParts guard is needed — getDashboardState mints a fresh TOTP on\n // every call unconditionally.\n // SECRET-HANDLING: callback is a plain trigger only — TOTP value and at= code\n // must never be logged or written to stdout.\n const TOTP_REFRESH_INTERVAL_MS = 20_000;\n let totpRefreshHandle: ReturnType<typeof setInterval> | null = setInterval(() => {\n server.notifyStateChange();\n }, TOTP_REFRESH_INTERVAL_MS);\n totpRefreshHandle.unref();\n\n const dashboardUrl = `http://127.0.0.1:${server.port}`;\n\n const { openUrlInBrowser } = await import('../mcp/devtools-opener.js');\n const opened = openUrlInBrowser(dashboardUrl);\n // SECRET-HANDLING: only the local 127.0.0.1 URL is logged — never the tunnel\n // host, relay wssUrl, or TOTP code.\n log(\n opened\n ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}`\n : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`,\n );\n\n return {\n url: dashboardUrl,\n close: () => {\n if (totpRefreshHandle) {\n clearInterval(totpRefreshHandle);\n totpRefreshHandle = null;\n }\n return server.close();\n },\n };\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\n/**\n * Sanitize cloudflared stderr output for error diagnostics (#421).\n *\n * Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs\n * that carry those hostnames so tunnel host values never appear in error\n * messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.\n *\n * SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only\n * placeholder text is emitted.\n */\nexport function sanitizeCloudflaredOutput(line: string): string {\n // Full URL forms: https://xxx.trycloudflare.com/… and wss://xxx.trycloudflare.com/…\n let s = line.replace(/(?:https?|wss?):\\/\\/[a-z0-9-]+\\.trycloudflare\\.com(?:\\/[^\\s]*)*/gi, (m) =>\n m.replace(/[a-z0-9-]+\\.trycloudflare\\.com/i, '<HOST>.trycloudflare.com'),\n );\n // Bare hostname without scheme (e.g. printed in cloudflared JSON logs)\n s = s.replace(/[a-z0-9-]+\\.trycloudflare\\.com/gi, '<HOST>.trycloudflare.com');\n return s;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n // #421: accumulate stderr to attach as diagnostics on failure.\n // SECRET-HANDLING: lines are sanitized before inclusion in error messages.\n const stderrLines: string[] = [];\n\n /**\n * Format the last `n` sanitized stderr lines as a diagnostic appendix.\n * Returns an empty string when no lines have been collected.\n */\n const stderrTail = (n = 15): string => {\n if (stderrLines.length === 0) return '';\n const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join('');\n return `\\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\\n${tail}`;\n };\n\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.${stderrTail()}`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n // Accumulate stderr lines for diagnostics (#421). Named so it can be\n // removed from the listener list when cleanup() runs.\n const pushStderr = (line: string) => {\n stderrLines.push(line);\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n tunnel.off('stderr', pushStderr);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n // Second stderr listener: accumulate all lines for error diagnostics.\n tunnel.on('stderr', pushStderr);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.${stderrTail()}`,\n ),\n );\n });\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tunnel-D0QnxKsF.cjs","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, the deep-link carries `&navBarType=game`\n * so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the default) is the launcher's implicit default — not added to\n * keep the URL clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, the deep-link carries `&navBarTransparent=1` so the launcher\n * partner bar renders with a transparent background (content shows through).\n * `false` / omitted → not added (URL clean, back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (`granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, the deep-link carries\n * `&navBarTheme=<v>` so the launcher partner bar uses the matching foreground\n * colour. Omitted / other values → not added (URL clean, back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Options for {@link buildLauncherDeepLink}.\n */\nexport interface BuildLauncherDeepLinkOptions {\n /**\n * `wss://` relay URL for env-2 CDP wiring. When present the deep-link carries\n * `&debug=1&relay=<wss>`.\n */\n relayWssUrl?: string;\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param, #498).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * The miniapp's webViewType. When `'game'`, adds `&navBarType=game` to the\n * deep-link so the launcher enters game nav chrome automatically on scan (#584).\n * `'partner'` (the launcher's implicit default) is not added to keep the URL\n * clean.\n */\n webViewType?: 'partner' | 'game';\n /**\n * Whether the miniapp's navigationBar has `transparentBackground: true`\n * (granite.config `navigationBar.transparentBackground`, SDK 2.8.0, #587).\n * When `true`, adds `&navBarTransparent=1` to the deep-link so the launcher\n * partner bar renders with a transparent background. Omitted when `false` /\n * undefined to keep the URL clean (back-compat).\n */\n navBarTransparent?: boolean;\n /**\n * The miniapp's navigationBar theme (granite.config `navigationBar.theme`,\n * SDK 2.8.0, #587). When `'light'` or `'dark'`, adds `&navBarTheme=<v>` to\n * the deep-link so the launcher partner bar uses the matching foreground colour.\n * Omitted when undefined / other values to keep the URL clean (back-compat).\n */\n navBarTheme?: 'light' | 'dark';\n}\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `opts.relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the launcher\n * partner bar shows the app name instead of the generic default (#498).\n *\n * When `opts.webViewType` is `'game'`, `&navBarType=game` is appended so the\n * launcher enters game nav chrome (floating capsule, no full bar) automatically\n * on scan. `'partner'` is the launcher's implicit default and is not added to\n * keep the URL clean (#584).\n *\n * When `opts.navBarTransparent` is `true`, `&navBarTransparent=1` is appended\n * so the launcher partner bar renders with a transparent background (#587).\n *\n * When `opts.navBarTheme` is `'light'` or `'dark'`, `&navBarTheme=<v>` is\n * appended so the launcher partner bar uses the matching foreground colour (#587).\n *\n * Back-compat: the second argument may also be a plain string (`relayWssUrl`)\n * for callers that haven't migrated to the options object yet.\n */\nexport function buildLauncherDeepLink(\n tunnelUrl: string,\n optsOrRelay?: string | BuildLauncherDeepLinkOptions,\n): string {\n // Normalise the overloaded second argument.\n const opts: BuildLauncherDeepLinkOptions =\n typeof optsOrRelay === 'string' ? { relayWssUrl: optsOrRelay } : (optsOrRelay ?? {});\n\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n let url = base;\n if (opts.relayWssUrl) {\n url += `&debug=1&relay=${encodeURIComponent(opts.relayWssUrl)}`;\n }\n if (opts.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts.webViewType === 'game') {\n url += '&navBarType=game';\n }\n if (opts.navBarTransparent === true) {\n url += '&navBarTransparent=1';\n }\n if (opts.navBarTheme === 'light' || opts.navBarTheme === 'dark') {\n url += `&navBarTheme=${opts.navBarTheme}`;\n }\n return url;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, {\n relayWssUrl: opts.relayWssUrl,\n name: opts.name,\n webViewType: opts.webViewType,\n navBarTransparent: opts.navBarTransparent,\n navBarTheme: opts.navBarTheme,\n });\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\n/**\n * Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in\n * `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path\n * does not statically pull the heavy MCP `tools.ts` module graph into the lazy\n * `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.\n *\n * - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).\n * - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.\n * - CI (`CI=true`/`CI=1`) → no.\n */\nfunction canOpenBrowser(): boolean {\n if (process.env.CI === 'true' || process.env.CI === '1') return false;\n const platform = process.platform;\n if (platform === 'darwin' || platform === 'win32') return true;\n if (platform === 'linux') {\n return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);\n }\n return false;\n}\n\n/** Handle returned by {@link startTunnelDashboard}. */\nexport interface TunnelDashboard {\n /** `http://127.0.0.1:<port>` — the local dashboard URL opened in the browser. */\n url: string;\n /** Tear down the local HTTP server. Idempotent via the underlying server. */\n close: () => Promise<void>;\n}\n\nexport interface StartTunnelDashboardOptions {\n /** The public `https://*.trycloudflare.com` app tunnel URL the launcher frames. */\n tunnelUrl: string;\n /** The `wss://` relay URL of the env-2 CDP tunnel. REQUIRED — the dashboard is a CDP-only UX. */\n relayWssUrl: string;\n /** Mirror of `tunnel.qr` — when `false` the dashboard is skipped (no browser open). */\n qr?: boolean;\n /**\n * Override the GUI/opt-out gate (testing only). When omitted the real\n * `canOpenBrowser()` + `AIT_AUTO_DEVTOOLS` checks decide.\n */\n shouldOpen?: () => boolean;\n /** Sink for the one-line \"opened in browser\" note (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * Human-readable app name to embed as `name=` in the launcher deep-link (#498).\n * When provided (non-blank), the launcher partner bar shows this name instead of\n * the generic default.\n */\n name?: string;\n}\n\n/**\n * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is\n * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps\n * + FAQ) that the MCP `build_attach_url` path serves, and auto-open it in the\n * browser. headless / opt-out falls back to the terminal ASCII QR (printed\n * separately by {@link printTunnelBanner}).\n *\n * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP\n * server, the opener) is reached only through dynamic `import()` here, inside\n * the already-lazy `tunnel.js` chunk — nothing is added to the common build\n * graph or the MCP-only install graph.\n *\n * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH\n * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds\n * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on\n * each SSE push / page reload from this closure, the code a phone scans is always\n * within its 30 s window — no stale code is baked into static HTML.\n *\n * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`\n * value/path are NEVER written to stdout/stderr/logs here. They live only inside\n * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).\n * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).\n *\n * @returns the dashboard handle when it started (caller wires `close()` into the\n * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,\n * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.\n */\nexport async function startTunnelDashboard(\n opts: StartTunnelDashboardOptions,\n): Promise<TunnelDashboard | undefined> {\n const log = opts.log ?? ((m: string) => console.log(m));\n\n // Gate: dashboard is a CDP-only UX (needs a relay to attach to).\n if (!opts.relayWssUrl) return undefined;\n // Opt-out via `tunnel.qr:false` (same toggle that suppresses the ASCII QR).\n if (opts.qr === false) return undefined;\n\n // GUI + AIT_AUTO_DEVTOOLS gate. Reuse the MCP opener's opt-out predicate so\n // the env-2 path honours the same `AIT_AUTO_DEVTOOLS=0` switch as env 3/4.\n const { isAutoDevtoolsDisabled } = await import('../mcp/devtools-opener.js');\n const gateOpen = opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser());\n if (!gateOpen()) return undefined;\n\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n const { buildLauncherAttachUrl } = await import('../mcp/deeplink.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // getDashboardState — mints a fresh TOTP + attach URL on every call so the QR\n // the dashboard renders (on load and on each SSE push) is never expired.\n // SECRET-HANDLING: the secret is read from env AT CALL TIME (it was injected\n // by ensureRelaySecret in the same CDP block) and is used only to compute the\n // at= code folded into attachUrl. tunnel.up is always true here — the relay\n // tunnel is already up by the time this runs.\n const getDashboardState = () => {\n const secret = process.env.AIT_DEBUG_TOTP_SECRET;\n const totpCode = secret ? generateTotp(secret, Date.now()) : undefined;\n const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode, {\n name: opts.name,\n });\n // pages: null — env 2(unplugin)는 데몬이 아니라 vite 플러그인 안이라\n // startChiiRelay 핸들이 connected target을 노출하지 않는다. 라이브 page 목록을\n // 알 수 없으므로 거짓 빈 목록 대신 \"연결된 Pages\" 섹션 자체를 숨긴다(#411).\n // env 3/4(debug-server.ts)는 router.active.listTargets()로 실제 목록을 채운다.\n // mode: 'relay-mobile' — 이 대시보드는 항상 환경 2(AITC Sandbox PWA) 전용이므로\n // /attach 카피가 launcher PWA 절차(sandbox family)로 분기된다(#468).\n // inspectorUrl: null — env 2에서는 unplugin relay가 connected target ID를 노출하지\n // 않아 buildChiiInspectorUrl에 필요한 targetId를 알 수 없다. target attach 후\n // target ID가 필요하므로 env 3/4에서만 non-null이 된다(#503).\n return {\n tunnel: { up: true, wssUrl: opts.relayWssUrl },\n pages: null,\n attachUrl,\n inspectorUrl: null,\n mode: 'relay-mobile' as const,\n };\n };\n\n let server: Awaited<ReturnType<typeof startQrHttpServer>>;\n try {\n server = await startQrHttpServer(getDashboardState);\n } catch {\n // SECRET-HANDLING: do not surface the error (could embed paths/hosts). The\n // ASCII QR printed by printTunnelBanner stays as the fallback.\n return undefined;\n }\n\n // TOTP periodic refresh timer — pushes a fresh at= code to SSE clients every\n // 20 s so a page left open never stales past the 90 s acceptance window (#448).\n // tunnel.ts always has relayWssUrl available here (gated above), so no\n // lastAttachParts guard is needed — getDashboardState mints a fresh TOTP on\n // every call unconditionally.\n // SECRET-HANDLING: callback is a plain trigger only — TOTP value and at= code\n // must never be logged or written to stdout.\n const TOTP_REFRESH_INTERVAL_MS = 20_000;\n let totpRefreshHandle: ReturnType<typeof setInterval> | null = setInterval(() => {\n server.notifyStateChange();\n }, TOTP_REFRESH_INTERVAL_MS);\n totpRefreshHandle.unref();\n\n const dashboardUrl = `http://127.0.0.1:${server.port}`;\n\n const { openUrlInBrowser } = await import('../mcp/devtools-opener.js');\n const opened = openUrlInBrowser(dashboardUrl);\n // SECRET-HANDLING: only the local 127.0.0.1 URL is logged — never the tunnel\n // host, relay wssUrl, or TOTP code.\n log(\n opened\n ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}`\n : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`,\n );\n\n return {\n url: dashboardUrl,\n close: () => {\n if (totpRefreshHandle) {\n clearInterval(totpRefreshHandle);\n totpRefreshHandle = null;\n }\n return server.close();\n },\n };\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\n/**\n * Sanitize cloudflared stderr output for error diagnostics (#421).\n *\n * Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs\n * that carry those hostnames so tunnel host values never appear in error\n * messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.\n *\n * SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only\n * placeholder text is emitted.\n */\nexport function sanitizeCloudflaredOutput(line: string): string {\n // Full URL forms: https://xxx.trycloudflare.com/… and wss://xxx.trycloudflare.com/…\n let s = line.replace(/(?:https?|wss?):\\/\\/[a-z0-9-]+\\.trycloudflare\\.com(?:\\/[^\\s]*)*/gi, (m) =>\n m.replace(/[a-z0-9-]+\\.trycloudflare\\.com/i, '<HOST>.trycloudflare.com'),\n );\n // Bare hostname without scheme (e.g. printed in cloudflared JSON logs)\n s = s.replace(/[a-z0-9-]+\\.trycloudflare\\.com/gi, '<HOST>.trycloudflare.com');\n return s;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n // #421: accumulate stderr to attach as diagnostics on failure.\n // SECRET-HANDLING: lines are sanitized before inclusion in error messages.\n const stderrLines: string[] = [];\n\n /**\n * Format the last `n` sanitized stderr lines as a diagnostic appendix.\n * Returns an empty string when no lines have been collected.\n */\n const stderrTail = (n = 15): string => {\n if (stderrLines.length === 0) return '';\n const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join('');\n return `\\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\\n${tail}`;\n };\n\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.${stderrTail()}`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n // Accumulate stderr lines for diagnostics (#421). Named so it can be\n // removed from the listener list when cleanup() runs.\n const pushStderr = (line: string) => {\n stderrLines.push(line);\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n tunnel.off('stderr', pushStderr);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n // Second stderr listener: accumulate all lines for error diagnostics.\n tunnel.on('stderr', pushStderr);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.${stderrTail()}`,\n ),\n );\n });\n });\n}\n"],"mappings":"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"}
@@ -333,14 +333,14 @@ const aitDevtoolsPlugin = (0, unplugin.createUnplugin)((options) => {
333
333
  console.warn("[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.");
334
334
  return;
335
335
  }
336
- Promise.resolve().then(() => require("../tunnel-BmDfjkQI.cjs")).then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {
336
+ Promise.resolve().then(() => require("../tunnel-D0QnxKsF.cjs")).then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {
337
337
  const t = await startQuickTunnel(port);
338
338
  tunnel = t;
339
339
  let relayWssUrl;
340
340
  let relayHttpUrl;
341
341
  let relayLocalHttpUrl;
342
342
  if (tunnelConfig.cdp) try {
343
- const { ensureRelaySecret } = await Promise.resolve().then(() => require("../relay-secret-store-B5WAozDv.cjs"));
343
+ const { ensureRelaySecret } = await Promise.resolve().then(() => require("../relay-secret-store-CqDaaFW1.cjs"));
344
344
  await ensureRelaySecret({ projectRoot: server.config.root });
345
345
  const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await Promise.resolve().then(() => require("../totp-D9fjaVak.cjs"));
346
346
  assertRelayAuthConfigured();
@@ -382,7 +382,7 @@ const aitDevtoolsPlugin = (0, unplugin.createUnplugin)((options) => {
382
382
  navBarTransparent,
383
383
  navBarTheme
384
384
  });
385
- const { writeRelayUrls, deleteRelayUrls } = await Promise.resolve().then(() => require("../relay-url-store-D2lX9POP.cjs"));
385
+ const { writeRelayUrls, deleteRelayUrls } = await Promise.resolve().then(() => require("../relay-url-store-CIZlFBkR.cjs"));
386
386
  await writeRelayUrls({
387
387
  projectRoot: server.config.root,
388
388
  tunnelBaseUrl: t.url,
@@ -329,14 +329,14 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
329
329
  console.warn("[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.");
330
330
  return;
331
331
  }
332
- import("../tunnel-C_qpse3-.js").then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {
332
+ import("../tunnel-CepDBgEc.js").then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {
333
333
  const t = await startQuickTunnel(port);
334
334
  tunnel = t;
335
335
  let relayWssUrl;
336
336
  let relayHttpUrl;
337
337
  let relayLocalHttpUrl;
338
338
  if (tunnelConfig.cdp) try {
339
- const { ensureRelaySecret } = await import("../relay-secret-store-BvNWdSjV.js");
339
+ const { ensureRelaySecret } = await import("../relay-secret-store-DKuoAJmA.js");
340
340
  await ensureRelaySecret({ projectRoot: server.config.root });
341
341
  const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import("../totp-CauHjkdE.js");
342
342
  assertRelayAuthConfigured();
@@ -378,7 +378,7 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
378
378
  navBarTransparent,
379
379
  navBarTheme
380
380
  });
381
- const { writeRelayUrls, deleteRelayUrls } = await import("../relay-url-store-1CXVqNDL.js");
381
+ const { writeRelayUrls, deleteRelayUrls } = await import("../relay-url-store-DASEZiT9.js");
382
382
  await writeRelayUrls({
383
383
  projectRoot: server.config.root,
384
384
  tunnelBaseUrl: t.url,
@@ -155,7 +155,7 @@ async function startTunnelDashboard(opts) {
155
155
  if (opts.qr === false) return void 0;
156
156
  const { isAutoDevtoolsDisabled } = await Promise.resolve().then(() => require("../devtools-opener-BDY0w3_0.cjs"));
157
157
  if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
158
- const { startQrHttpServer } = await Promise.resolve().then(() => require("../qr-http-server-C9NUBysQ.cjs"));
158
+ const { startQrHttpServer } = await Promise.resolve().then(() => require("../qr-http-server-B7DsRdN1.cjs"));
159
159
  const { buildLauncherAttachUrl } = await Promise.resolve().then(() => require("../deeplink-DCScMYcp.cjs"));
160
160
  const { generateTotp } = await Promise.resolve().then(() => require("../totp-BwDZ6dUT.cjs"));
161
161
  const getDashboardState = () => {
@@ -154,7 +154,7 @@ async function startTunnelDashboard(opts) {
154
154
  if (opts.qr === false) return void 0;
155
155
  const { isAutoDevtoolsDisabled } = await import("../devtools-opener-BTl5A6Cd.js");
156
156
  if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
157
- const { startQrHttpServer } = await import("../qr-http-server-B1fmICC4.js");
157
+ const { startQrHttpServer } = await import("../qr-http-server-DI3A6f5L.js");
158
158
  const { buildLauncherAttachUrl } = await import("../deeplink-BpO9qc-D.js");
159
159
  const { generateTotp } = await import("../totp-BmKSPb5d.js");
160
160
  const getDashboardState = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ait-co/devtools",
3
- "version": "0.1.101",
3
+ "version": "0.1.102",
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":"relay-secret-store-B5WAozDv.cjs","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAmFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,cAAA,GAAA,UAAA,MAAkB,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,UAAA,GAAA,UAAA,SAAiB,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,SAAA,GAAA,UAAA,MAAY,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;AAsBjF,eAAsB,kBAAkB,MAAuC;CAC7E,MAAM,EACJ,aACA,MAAM,QAAQ,KACd,aAAa,eACb,IAAI,OACJ,YAAY,eACZ,KAAK,OACL,QACE,QAAQ,EAAE;CAEd,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAIvF,MAAM,EAAE,2BAA2B,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,sBAAA,CAAA;AAGzC,KAAI,uBAAuB,IAAI,sBAAsB,CACnD;CAIF,MAAM,KAA4B,kBAAkB,MAAM,OAAO,gBAAgB;CACjF,MAAM,KAAoB,SAAU,MAAM,OAAO;CACjD,MAAM,eAA0C,iBAAiB,GAAG;CAGpE,MAAM,aAAa,oBADL,gBAAgB,gBAAgB,QAAQ,KAAK,IAAI,EACjB,aAAa;AAG3D,KAAI,GAAG,WAAW,WAAW,CAC3B,QAAO,cAAc,YAAY,IAAI,KAAK,OAAO,wBAAwB,GAAG;AAI9E,QAAO,eAAe,YAAY,IAAI,KAAK,IAAI,OAAO,uBAAuB;;AA0E/E,eAAe,cACb,YACA,IACA,KACA,OACA,wBACA,IACe;CACf,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;UAC5C,KAAK;EACZ,MAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI;AAC5D,QAAM,IAAI,MAAM,0CAA0C,MAAM;;AAGlE,KAAI,CAAC,uBAAuB,OAAO,EAAE;AAEnC,QAAM,2DAA2D;AACjE,SAAO,eAAe,YAAY,IAAI,KAAK,IAAI,OAAO,wBAAwB,KAAK;;AAIrF,KAAI,wBAAwB;;AAG9B,eAAe,eACb,YACA,IACA,KACA,IACA,OACA,wBAEA,YAAY,OACG;CAGf,MAAM,SAAS,GAAG,GAAG,CAAC,SAAS,MAAM;AAGrC,KAAI,CAAC,uBAAuB,OAAO,CACjC,OAAM,IAAI,MACR,2DACD;CAOH,MAAM,OAAO,YAAY,MAAM;AAC/B,KAAI;AACF,KAAG,cAAc,YAAY,QAAQ;GAAE,MAAM;GAAO;GAAM,CAAC;AAE3D,KAAG,UAAU,YAAY,IAAM;UACxB,KAAK;AACZ,MAAK,IAA8B,SAAS,UAAU;GAEpD,IAAI;AACJ,OAAI;AACF,aAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;YAC5C,SAAS;IAChB,MAAM,MAAM,mBAAmB,QAAQ,QAAQ,UAAU,OAAO,QAAQ;AACxE,UAAM,IAAI,MAAM,8CAA8C,MAAM;;AAEtE,OAAI,CAAC,uBAAuB,OAAO,CACjC,OAAM,IAAI,MAAM,qDAAqD;AAEvE,OAAI,wBAAwB;AAC5B;;AAEF,QAAM;;AAKR,KAAI,wBAAwB;AAI5B,OACE,8CAA8C,uBAAuB,0KAItE"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"relay-secret-store-BvNWdSjV.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAmFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;AAsBjF,eAAsB,kBAAkB,MAAuC;CAC7E,MAAM,EACJ,aACA,MAAM,QAAQ,KACd,aAAa,eACb,IAAI,OACJ,YAAY,eACZ,KAAK,OACL,QACE,QAAQ,EAAE;CAEd,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAIvF,MAAM,EAAE,2BAA2B,MAAM,OAAO;AAGhD,KAAI,uBAAuB,IAAI,sBAAsB,CACnD;CAIF,MAAM,KAA4B,kBAAkB,MAAM,OAAO,gBAAgB;CACjF,MAAM,KAAoB,SAAU,MAAM,OAAO;CACjD,MAAM,eAA0C,iBAAiB,GAAG;CAGpE,MAAM,aAAa,oBADL,gBAAgB,gBAAgB,QAAQ,KAAK,IAAI,EACjB,aAAa;AAG3D,KAAI,GAAG,WAAW,WAAW,CAC3B,QAAO,cAAc,YAAY,IAAI,KAAK,OAAO,wBAAwB,GAAG;AAI9E,QAAO,eAAe,YAAY,IAAI,KAAK,IAAI,OAAO,uBAAuB;;AA0E/E,eAAe,cACb,YACA,IACA,KACA,OACA,wBACA,IACe;CACf,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;UAC5C,KAAK;EACZ,MAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI;AAC5D,QAAM,IAAI,MAAM,0CAA0C,MAAM;;AAGlE,KAAI,CAAC,uBAAuB,OAAO,EAAE;AAEnC,QAAM,2DAA2D;AACjE,SAAO,eAAe,YAAY,IAAI,KAAK,IAAI,OAAO,wBAAwB,KAAK;;AAIrF,KAAI,wBAAwB;;AAG9B,eAAe,eACb,YACA,IACA,KACA,IACA,OACA,wBAEA,YAAY,OACG;CAGf,MAAM,SAAS,GAAG,GAAG,CAAC,SAAS,MAAM;AAGrC,KAAI,CAAC,uBAAuB,OAAO,CACjC,OAAM,IAAI,MACR,2DACD;CAOH,MAAM,OAAO,YAAY,MAAM;AAC/B,KAAI;AACF,KAAG,cAAc,YAAY,QAAQ;GAAE,MAAM;GAAO;GAAM,CAAC;AAE3D,KAAG,UAAU,YAAY,IAAM;UACxB,KAAK;AACZ,MAAK,IAA8B,SAAS,UAAU;GAEpD,IAAI;AACJ,OAAI;AACF,aAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;YAC5C,SAAS;IAChB,MAAM,MAAM,mBAAmB,QAAQ,QAAQ,UAAU,OAAO,QAAQ;AACxE,UAAM,IAAI,MAAM,8CAA8C,MAAM;;AAEtE,OAAI,CAAC,uBAAuB,OAAO,CACjC,OAAM,IAAI,MAAM,qDAAqD;AAEvE,OAAI,wBAAwB;AAC5B;;AAEF,QAAM;;AAKR,KAAI,wBAAwB;AAI5B,OACE,8CAA8C,uBAAuB,0KAItE"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"relay-secret-store-J0SUUXjH.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAmFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAuFjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,kBAAkB,QAAQ,EAAE;CAE3F,MAAM,EAAE,2BAA2B,MAAM,OAAO;AAGhD,KAAI,uBAAuB,IAAI,sBAAsB,CACnD;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB"}