@ait-co/devtools 0.1.81 → 0.1.85

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 (76) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/dist/{chii-relay-DSVG4Ui1.js → chii-relay-BASitNMw.js} +1 -1
  4. package/dist/{chii-relay-DSVG4Ui1.js.map → chii-relay-BASitNMw.js.map} +1 -1
  5. package/dist/{chii-relay-BcnVJBqm.cjs → chii-relay-BzUf0LH3.cjs} +1 -1
  6. package/dist/{chii-relay-BcnVJBqm.cjs.map → chii-relay-BzUf0LH3.cjs.map} +1 -1
  7. package/dist/{deeplink-B-94XmWA.js → deeplink-BpO9qc-D.js} +5 -3
  8. package/dist/{deeplink-BLU2_hg6.cjs.map → deeplink-BpO9qc-D.js.map} +1 -1
  9. package/dist/{deeplink-CYqDwVYs.js → deeplink-D1HXJ2YG.js} +5 -3
  10. package/dist/{deeplink-CU6opogq.cjs.map → deeplink-D1HXJ2YG.js.map} +1 -1
  11. package/dist/{deeplink-BLU2_hg6.cjs → deeplink-DCScMYcp.cjs} +5 -3
  12. package/dist/deeplink-DCScMYcp.cjs.map +1 -0
  13. package/dist/{deeplink-CU6opogq.cjs → deeplink-DDOe0FQl.cjs} +5 -3
  14. package/dist/deeplink-DDOe0FQl.cjs.map +1 -0
  15. package/dist/{devtools-opener-Bp671YXu.cjs → devtools-opener-BDY0w3_0.cjs} +11 -5
  16. package/dist/devtools-opener-BDY0w3_0.cjs.map +1 -0
  17. package/dist/{devtools-opener-D84kZFtR.js → devtools-opener-BTl5A6Cd.js} +11 -5
  18. package/dist/devtools-opener-BTl5A6Cd.js.map +1 -0
  19. package/dist/{devtools-opener-BbUXBzgA.js → devtools-opener-XpwL3fZ9.js} +22 -6
  20. package/dist/devtools-opener-XpwL3fZ9.js.map +1 -0
  21. package/dist/{devtools-opener-h6A-UjzC.cjs → devtools-opener-mDgeg_MX.cjs} +11 -5
  22. package/dist/devtools-opener-mDgeg_MX.cjs.map +1 -0
  23. package/dist/machine-state-Chg_6SPq.js +188 -0
  24. package/dist/machine-state-Chg_6SPq.js.map +1 -0
  25. package/dist/machine-state-DOUweFsJ.cjs +216 -0
  26. package/dist/machine-state-DOUweFsJ.cjs.map +1 -0
  27. package/dist/mcp/cli.js +112 -54
  28. package/dist/mcp/cli.js.map +1 -1
  29. package/dist/mcp/server.d.ts.map +1 -1
  30. package/dist/mcp/server.js +6 -10
  31. package/dist/mcp/server.js.map +1 -1
  32. package/dist/panel/index.js +109 -8
  33. package/dist/panel/index.js.map +1 -1
  34. package/dist/{qr-http-server-DJ5K3Odk.js → qr-http-server-Buorblrx.js} +73 -23
  35. package/dist/qr-http-server-Buorblrx.js.map +1 -0
  36. package/dist/{qr-http-server-Dkx2-pKF.cjs → qr-http-server-BvAtX9Lc.cjs} +73 -23
  37. package/dist/qr-http-server-BvAtX9Lc.cjs.map +1 -0
  38. package/dist/{qr-http-server-DkOFfZsR.js → qr-http-server-BxjrJr9t.js} +73 -23
  39. package/dist/qr-http-server-BxjrJr9t.js.map +1 -0
  40. package/dist/{qr-http-server-MIUHaiYw.cjs → qr-http-server-CAUyOrCm.cjs} +73 -23
  41. package/dist/qr-http-server-CAUyOrCm.cjs.map +1 -0
  42. package/dist/{relay-secret-store-CsCOfpWt.cjs → relay-secret-store-B5WAozDv.cjs} +2 -2
  43. package/dist/{relay-secret-store-CsCOfpWt.cjs.map → relay-secret-store-B5WAozDv.cjs.map} +1 -1
  44. package/dist/{relay-secret-store-6pPzLkUO.js → relay-secret-store-BvNWdSjV.js} +2 -2
  45. package/dist/{relay-secret-store-6pPzLkUO.js.map → relay-secret-store-BvNWdSjV.js.map} +1 -1
  46. package/dist/{relay-url-store-DH8-VUFc.js → relay-url-store-1CXVqNDL.js} +2 -2
  47. package/dist/{relay-url-store-DH8-VUFc.js.map → relay-url-store-1CXVqNDL.js.map} +1 -1
  48. package/dist/{relay-url-store-BiEK9BN1.cjs → relay-url-store-D2lX9POP.cjs} +2 -2
  49. package/dist/{relay-url-store-BiEK9BN1.cjs.map → relay-url-store-D2lX9POP.cjs.map} +1 -1
  50. package/dist/{totp-DYdP9N3o.js → totp-CauHjkdE.js} +1 -1
  51. package/dist/{totp-DYdP9N3o.js.map → totp-CauHjkdE.js.map} +1 -1
  52. package/dist/{totp-CNw0w89F.cjs → totp-D9fjaVak.cjs} +1 -1
  53. package/dist/{totp-CNw0w89F.cjs.map → totp-D9fjaVak.cjs.map} +1 -1
  54. package/dist/{tunnel-C-AFdAVL.cjs → tunnel-CfT31xho.cjs} +6 -6
  55. package/dist/{tunnel-C-AFdAVL.cjs.map → tunnel-CfT31xho.cjs.map} +1 -1
  56. package/dist/{tunnel-BTlq1mmH.js → tunnel-DPwJBn1u.js} +6 -6
  57. package/dist/{tunnel-BTlq1mmH.js.map → tunnel-DPwJBn1u.js.map} +1 -1
  58. package/dist/unplugin/index.cjs +90 -5
  59. package/dist/unplugin/index.cjs.map +1 -1
  60. package/dist/unplugin/index.d.cts.map +1 -1
  61. package/dist/unplugin/index.d.ts.map +1 -1
  62. package/dist/unplugin/index.js +90 -5
  63. package/dist/unplugin/index.js.map +1 -1
  64. package/dist/unplugin/tunnel.cjs +4 -4
  65. package/dist/unplugin/tunnel.js +4 -4
  66. package/package.json +1 -1
  67. package/dist/deeplink-B-94XmWA.js.map +0 -1
  68. package/dist/deeplink-CYqDwVYs.js.map +0 -1
  69. package/dist/devtools-opener-BbUXBzgA.js.map +0 -1
  70. package/dist/devtools-opener-Bp671YXu.cjs.map +0 -1
  71. package/dist/devtools-opener-D84kZFtR.js.map +0 -1
  72. package/dist/devtools-opener-h6A-UjzC.cjs.map +0 -1
  73. package/dist/qr-http-server-DJ5K3Odk.js.map +0 -1
  74. package/dist/qr-http-server-DkOFfZsR.js.map +0 -1
  75. package/dist/qr-http-server-Dkx2-pKF.cjs.map +0 -1
  76. package/dist/qr-http-server-MIUHaiYw.cjs.map +0 -1
package/README.en.md CHANGED
@@ -106,7 +106,7 @@ For environments 3 and 4 (intoss-private relay), the relay QR deep-link carries
106
106
 
107
107
  **"QR window doesn't open"**
108
108
 
109
- Either `build_attach_url` wasn't called first, or `open_in_browser` failed silently in a headless environment. The terminal output includes a path to a saved PNG open that file directly, or scan the text QR printed in the terminal. (Related: [#288](https://github.com/apps-in-toss-community/devtools/issues/288))
109
+ Either `build_attach_url` wasn't called first, or the MCP server is running in a headless environment where no browser can be opened. The tool result always includes a text QR scan it directly with your phone camera. On a local GUI machine, the dashboard opens automatically in the browser.
110
110
 
111
111
  **"Page not attached" — list_pages returns an empty array**
112
112
 
package/README.md CHANGED
@@ -106,7 +106,7 @@ import '@ait-co/devtools/in-app/auto';
106
106
 
107
107
  **"QR 창이 안 열림"**
108
108
 
109
- `build_attach_url`을 먼저 호출하지 않았거나, GUI 없는 headless 환경에서 `open_in_browser`가 실패한 경우입니다. 터미널에 PNG 저장 경로가 출력됩니다 파일을 직접 열거나, 텍스트 QR을 터미널에서 스캔하세요. (관련: [#288](https://github.com/apps-in-toss-community/devtools/issues/288))
109
+ `build_attach_url`을 먼저 호출하지 않았거나, GUI 없는 headless 환경이라 대시보드를 수 없는 경우입니다. 도구 결과에 텍스트 QR이 출력되므로 카메라로 직접 스캔하세요. 로컬 GUI 환경에서는 대시보드가 자동으로 브라우저에 열립니다.
110
110
 
111
111
  **"page 미attach" — list_pages가 빈 배열 반환**
112
112
 
@@ -286,4 +286,4 @@ async function startChiiRelay(options = {}) {
286
286
  //#endregion
287
287
  export { startChiiRelay };
288
288
 
289
- //# sourceMappingURL=chii-relay-DSVG4Ui1.js.map
289
+ //# sourceMappingURL=chii-relay-BASitNMw.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chii-relay-DSVG4Ui1.js","names":[],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * WS keepalive ping interval (ms).\n *\n * Cloudflare proxied connections are dropped after ~100 s of no traffic.\n * 45 s comfortably fits inside that window and lets both the phone-target leg\n * and the daemon-client leg survive idle CDP sessions.\n */\nconst DEFAULT_KEEPALIVE_INTERVAL_MS = 45_000;\n\n/**\n * Minimal shape of chii's internal WebSocketServer instance.\n *\n * `chii/server/lib/WebSocketServer` holds the real `ws.Server` in `_wss`.\n * `_wss.clients` is the standard `Set<WebSocket>` tracking all live sockets.\n * We access this to ping every connected socket — no chii internals beyond\n * this single field are touched.\n */\ninterface ChiiInternalWss {\n _wss: { clients: Set<WebSocket> };\n start(server: import('node:http').Server): void;\n}\n\n/**\n * Loads chii's internal WebSocketServer class and returns it together with a\n * flag indicating whether the real class was found.\n *\n * Returns `null` if the internal path is not resolvable (future chii release\n * changes the layout) — callers skip keepalive gracefully.\n */\nfunction tryLoadChiiWssClass(): (new () => ChiiInternalWss) | null {\n try {\n const mod: unknown = require('chii/server/lib/WebSocketServer');\n if (typeof mod === 'function') {\n return mod as new () => ChiiInternalWss;\n }\n } catch {\n // Module not found or shape changed — keepalive will be skipped.\n }\n return null;\n}\n\n/**\n * Calls `chii.start()` and returns the chii `WebSocketServer` instance that\n * was constructed during the call.\n *\n * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`\n * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`\n * at module load time. The class reference is stable, so we can temporarily\n * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —\n * to record `this` before the original `start` runs.\n *\n * The patch is installed before `chii.start()` and removed (via `finally`)\n * immediately after, so concurrent `startChiiRelay` calls nest correctly: each\n * call's patch overrides the previous in the prototype chain for the duration\n * of its own `chii.start()` call, restoring the prior descriptor on exit.\n *\n * If `ChiiWssClass` is null (internal path changed in a future chii release),\n * `chii.start()` runs unpatched and the function returns null — callers skip\n * keepalive gracefully without affecting relay correctness.\n */\nasync function startChiiWithCapture(\n chii: ChiiServerModule,\n startOptions: Parameters<ChiiServerModule['start']>[0],\n ChiiWssClass: (new () => ChiiInternalWss) | null,\n): Promise<ChiiInternalWss | null> {\n if (ChiiWssClass === null) {\n await chii.start(startOptions);\n return null;\n }\n\n let captured: ChiiInternalWss | null = null;\n const proto = ChiiWssClass.prototype as ChiiInternalWss;\n const originalStart = proto.start;\n\n proto.start = function (this: ChiiInternalWss, server) {\n captured = this;\n return originalStart.call(this, server);\n };\n\n try {\n await chii.start(startOptions);\n } finally {\n // Always restore — even if chii.start() throws.\n proto.start = originalStart;\n }\n\n return captured;\n}\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n /**\n * WS protocol ping interval in milliseconds (issue #483).\n *\n * The relay sends a ping frame to every connected WebSocket at this interval\n * so that Cloudflare's proxied-connection idle timer (~100 s) is reset for\n * both the phone-target leg and the daemon-client leg. The peer responds with\n * a pong automatically (browser / ws library behaviour) — no application\n * code change is needed on either end.\n *\n * Default: 45 000 ms (45 s). Set to 0 to disable keepalive entirely.\n *\n * Pass a small value in tests to avoid real-time waits — pair with fake\n * timers (`vi.useFakeTimers()`) or a short sleep.\n */\n keepaliveIntervalMs?: number;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n const keepaliveIntervalMs =\n options.keepaliveIntervalMs !== undefined\n ? options.keepaliveIntervalMs\n : DEFAULT_KEEPALIVE_INTERVAL_MS;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: only the path-prefixed form is ours — the phone\n // fetches `target.js` via `https://<host>/at/<code>/target.js` (issue\n // #466), which must be verified + stripped so chii's Koa static handler\n // serves `/target.js`. Non-prefixed requests keep today's behaviour\n // (ungated pass-through to chii).\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten === null) return;\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // We do NOT log req.url or any auth param here to avoid leaking codes.\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed: no-op — chii's Koa 'request' listener (registered below\n // by chii.start) serves the rewritten URL. (Koa skips writing when an\n // earlier listener already ended the response, so the 401 path is safe\n // even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,+BAA+B;;;;;;AAO5C,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACiCxC,MAAM,UAAU,cAAc,OAAO,KAAK,IAAI;;;;;;;;AAS9C,MAAM,gCAAgC;;;;;;;;AAsBtC,SAAS,sBAA0D;AACjE,KAAI;EACF,MAAM,MAAe,QAAQ,kCAAkC;AAC/D,MAAI,OAAO,QAAQ,WACjB,QAAO;SAEH;AAGR,QAAO;;;;;;;;;;;;;;;;;;;;;AAsBT,eAAe,qBACb,MACA,cACA,cACiC;AACjC,KAAI,iBAAiB,MAAM;AACzB,QAAM,KAAK,MAAM,aAAa;AAC9B,SAAO;;CAGT,IAAI,WAAmC;CACvC,MAAM,QAAQ,aAAa;CAC3B,MAAM,gBAAgB,MAAM;AAE5B,OAAM,QAAQ,SAAiC,QAAQ;AACrD,aAAW;AACX,SAAO,cAAc,KAAK,MAAM,OAAO;;AAGzC,KAAI;AACF,QAAM,KAAK,MAAM,aAAa;WACtB;AAER,QAAM,QAAQ;;AAGhB,QAAO;;AAcT,SAAS,iBAAmC;CAE1C,MAAM,MAAe,QAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;;;;AAyC9D,SAAgB,oBAAoB,QAA+B;CACjE,MAAM,QAAQ,oCAAoC,KAAK,OAAO;AAC9D,KAAI,UAAU,KAAM,QAAO;CAC3B,MAAM,OAAO,MAAM;CACnB,MAAM,OAAO,MAAM,OAAO,KAAA,KAAa,MAAM,OAAO,KAAK,MAAM,MAAM;CACrE,MAAM,QAAQ,MAAM,MAAM;AAE1B,QAAO,GAAG,OAAO,QADC,UAAU,KAAK,MAAM,IACJ,KAAK;;;;;;;;;;;;;;;;;;AA+E1C,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,YAAY,iBAAiB;CACrC,MAAM,sBACJ,QAAQ,wBAAwB,KAAA,IAC5B,QAAQ,sBACR;CAEN,MAAM,aAAa,cAAc;CAIjC,MAAM,oBAAoB,SAA6C;AACrE,MAAI,iBAAiB,KAAA,EAAW;AAChC,MAAI;AACF,gBAAa,EAAE,MAAM,CAAC;UAChB;;AAiBV,KAAI,WAMF,YAAW,GAAG,YAAY,KAAK,QAAQ;EACrC,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,MAAI,cAAc,KAAM;AACxB,MAAI,MAAM;AACV,MAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,OAAI,aAAa;AACjB,OAAI,UAAU,+BAA+B,IAAI;AACjD,OAAI,UAAU,gBAAgB,mBAAmB;AACjD,OAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,oBAAiB,eAAe;;GAMlC;CAgBJ,MAAM,eAAe,sBAAsB,IAAI,qBAAqB,GAAG;CACvE,MAAM,kBAAkB,MAAM,qBAC5B,gBAAgB,EAChB;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,EAC/E,aACD;AAUD,KAAI,YAAY;EACd,MAAM,uBAAuB,WAAW,UAAU,UAAU;AAG5D,aAAW,mBAAmB,UAAU;EAGxC,MAAM,YAAY,IAAI,gBAAgB,EAAE,UAAU,MAAM,CAAC;AACzD,aAAW,GAAG,YAAY,KAAsB,QAAgB,SAAiB;GAK/E,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,OAAI,cAAc,KAChB,KAAI,MAAM;AAEZ,OAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,cAAU,cAAc,KAAK,QAAQ,OAAO,OAAO;AACjD,QAAG,MAAM,8BAA8B,yBAAyB;MAChE;AACF,qBAAiB,aAAa;AAE9B;;AAIF,QAAK,MAAM,YAAY,qBACrB,UAAS,KAAK,QAAQ,KAAK;IAE7B;;CAGJ,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;CAYF,IAAI,kBAAyD;AAC7D,KAAI,sBAAsB,KAAK,oBAAoB,MAAM;EACvD,MAAM,UAAU;AAChB,oBAAkB,kBAAkB;AAClC,QAAK,MAAM,UAAU,QAAQ,KAAK,QAEhC,KAAI,OAAO,eAAe,EACxB,QAAO,MAAM;KAGhB,oBAAoB;;AAGzB,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,OAAI,oBAAoB,MAAM;AAC5B,kBAAc,gBAAgB;AAC9B,sBAAkB;;AAEpB,cAAW,YAAY,SAAS,CAAC;IACjC;EACL"}
1
+ {"version":3,"file":"chii-relay-BASitNMw.js","names":[],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * WS keepalive ping interval (ms).\n *\n * Cloudflare proxied connections are dropped after ~100 s of no traffic.\n * 45 s comfortably fits inside that window and lets both the phone-target leg\n * and the daemon-client leg survive idle CDP sessions.\n */\nconst DEFAULT_KEEPALIVE_INTERVAL_MS = 45_000;\n\n/**\n * Minimal shape of chii's internal WebSocketServer instance.\n *\n * `chii/server/lib/WebSocketServer` holds the real `ws.Server` in `_wss`.\n * `_wss.clients` is the standard `Set<WebSocket>` tracking all live sockets.\n * We access this to ping every connected socket — no chii internals beyond\n * this single field are touched.\n */\ninterface ChiiInternalWss {\n _wss: { clients: Set<WebSocket> };\n start(server: import('node:http').Server): void;\n}\n\n/**\n * Loads chii's internal WebSocketServer class and returns it together with a\n * flag indicating whether the real class was found.\n *\n * Returns `null` if the internal path is not resolvable (future chii release\n * changes the layout) — callers skip keepalive gracefully.\n */\nfunction tryLoadChiiWssClass(): (new () => ChiiInternalWss) | null {\n try {\n const mod: unknown = require('chii/server/lib/WebSocketServer');\n if (typeof mod === 'function') {\n return mod as new () => ChiiInternalWss;\n }\n } catch {\n // Module not found or shape changed — keepalive will be skipped.\n }\n return null;\n}\n\n/**\n * Calls `chii.start()` and returns the chii `WebSocketServer` instance that\n * was constructed during the call.\n *\n * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`\n * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`\n * at module load time. The class reference is stable, so we can temporarily\n * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —\n * to record `this` before the original `start` runs.\n *\n * The patch is installed before `chii.start()` and removed (via `finally`)\n * immediately after, so concurrent `startChiiRelay` calls nest correctly: each\n * call's patch overrides the previous in the prototype chain for the duration\n * of its own `chii.start()` call, restoring the prior descriptor on exit.\n *\n * If `ChiiWssClass` is null (internal path changed in a future chii release),\n * `chii.start()` runs unpatched and the function returns null — callers skip\n * keepalive gracefully without affecting relay correctness.\n */\nasync function startChiiWithCapture(\n chii: ChiiServerModule,\n startOptions: Parameters<ChiiServerModule['start']>[0],\n ChiiWssClass: (new () => ChiiInternalWss) | null,\n): Promise<ChiiInternalWss | null> {\n if (ChiiWssClass === null) {\n await chii.start(startOptions);\n return null;\n }\n\n let captured: ChiiInternalWss | null = null;\n const proto = ChiiWssClass.prototype as ChiiInternalWss;\n const originalStart = proto.start;\n\n proto.start = function (this: ChiiInternalWss, server) {\n captured = this;\n return originalStart.call(this, server);\n };\n\n try {\n await chii.start(startOptions);\n } finally {\n // Always restore — even if chii.start() throws.\n proto.start = originalStart;\n }\n\n return captured;\n}\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n /**\n * WS protocol ping interval in milliseconds (issue #483).\n *\n * The relay sends a ping frame to every connected WebSocket at this interval\n * so that Cloudflare's proxied-connection idle timer (~100 s) is reset for\n * both the phone-target leg and the daemon-client leg. The peer responds with\n * a pong automatically (browser / ws library behaviour) — no application\n * code change is needed on either end.\n *\n * Default: 45 000 ms (45 s). Set to 0 to disable keepalive entirely.\n *\n * Pass a small value in tests to avoid real-time waits — pair with fake\n * timers (`vi.useFakeTimers()`) or a short sleep.\n */\n keepaliveIntervalMs?: number;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n const keepaliveIntervalMs =\n options.keepaliveIntervalMs !== undefined\n ? options.keepaliveIntervalMs\n : DEFAULT_KEEPALIVE_INTERVAL_MS;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: only the path-prefixed form is ours — the phone\n // fetches `target.js` via `https://<host>/at/<code>/target.js` (issue\n // #466), which must be verified + stripped so chii's Koa static handler\n // serves `/target.js`. Non-prefixed requests keep today's behaviour\n // (ungated pass-through to chii).\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten === null) return;\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // We do NOT log req.url or any auth param here to avoid leaking codes.\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed: no-op — chii's Koa 'request' listener (registered below\n // by chii.start) serves the rewritten URL. (Koa skips writing when an\n // earlier listener already ended the response, so the 401 path is safe\n // even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":"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"}
@@ -286,4 +286,4 @@ async function startChiiRelay(options = {}) {
286
286
  //#endregion
287
287
  exports.startChiiRelay = startChiiRelay;
288
288
 
289
- //# sourceMappingURL=chii-relay-BcnVJBqm.cjs.map
289
+ //# sourceMappingURL=chii-relay-BzUf0LH3.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"chii-relay-BcnVJBqm.cjs","names":["require","WebSocketServer"],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * WS keepalive ping interval (ms).\n *\n * Cloudflare proxied connections are dropped after ~100 s of no traffic.\n * 45 s comfortably fits inside that window and lets both the phone-target leg\n * and the daemon-client leg survive idle CDP sessions.\n */\nconst DEFAULT_KEEPALIVE_INTERVAL_MS = 45_000;\n\n/**\n * Minimal shape of chii's internal WebSocketServer instance.\n *\n * `chii/server/lib/WebSocketServer` holds the real `ws.Server` in `_wss`.\n * `_wss.clients` is the standard `Set<WebSocket>` tracking all live sockets.\n * We access this to ping every connected socket — no chii internals beyond\n * this single field are touched.\n */\ninterface ChiiInternalWss {\n _wss: { clients: Set<WebSocket> };\n start(server: import('node:http').Server): void;\n}\n\n/**\n * Loads chii's internal WebSocketServer class and returns it together with a\n * flag indicating whether the real class was found.\n *\n * Returns `null` if the internal path is not resolvable (future chii release\n * changes the layout) — callers skip keepalive gracefully.\n */\nfunction tryLoadChiiWssClass(): (new () => ChiiInternalWss) | null {\n try {\n const mod: unknown = require('chii/server/lib/WebSocketServer');\n if (typeof mod === 'function') {\n return mod as new () => ChiiInternalWss;\n }\n } catch {\n // Module not found or shape changed — keepalive will be skipped.\n }\n return null;\n}\n\n/**\n * Calls `chii.start()` and returns the chii `WebSocketServer` instance that\n * was constructed during the call.\n *\n * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`\n * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`\n * at module load time. The class reference is stable, so we can temporarily\n * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —\n * to record `this` before the original `start` runs.\n *\n * The patch is installed before `chii.start()` and removed (via `finally`)\n * immediately after, so concurrent `startChiiRelay` calls nest correctly: each\n * call's patch overrides the previous in the prototype chain for the duration\n * of its own `chii.start()` call, restoring the prior descriptor on exit.\n *\n * If `ChiiWssClass` is null (internal path changed in a future chii release),\n * `chii.start()` runs unpatched and the function returns null — callers skip\n * keepalive gracefully without affecting relay correctness.\n */\nasync function startChiiWithCapture(\n chii: ChiiServerModule,\n startOptions: Parameters<ChiiServerModule['start']>[0],\n ChiiWssClass: (new () => ChiiInternalWss) | null,\n): Promise<ChiiInternalWss | null> {\n if (ChiiWssClass === null) {\n await chii.start(startOptions);\n return null;\n }\n\n let captured: ChiiInternalWss | null = null;\n const proto = ChiiWssClass.prototype as ChiiInternalWss;\n const originalStart = proto.start;\n\n proto.start = function (this: ChiiInternalWss, server) {\n captured = this;\n return originalStart.call(this, server);\n };\n\n try {\n await chii.start(startOptions);\n } finally {\n // Always restore — even if chii.start() throws.\n proto.start = originalStart;\n }\n\n return captured;\n}\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n /**\n * WS protocol ping interval in milliseconds (issue #483).\n *\n * The relay sends a ping frame to every connected WebSocket at this interval\n * so that Cloudflare's proxied-connection idle timer (~100 s) is reset for\n * both the phone-target leg and the daemon-client leg. The peer responds with\n * a pong automatically (browser / ws library behaviour) — no application\n * code change is needed on either end.\n *\n * Default: 45 000 ms (45 s). Set to 0 to disable keepalive entirely.\n *\n * Pass a small value in tests to avoid real-time waits — pair with fake\n * timers (`vi.useFakeTimers()`) or a short sleep.\n */\n keepaliveIntervalMs?: number;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n const keepaliveIntervalMs =\n options.keepaliveIntervalMs !== undefined\n ? options.keepaliveIntervalMs\n : DEFAULT_KEEPALIVE_INTERVAL_MS;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: only the path-prefixed form is ours — the phone\n // fetches `target.js` via `https://<host>/at/<code>/target.js` (issue\n // #466), which must be verified + stripped so chii's Koa static handler\n // serves `/target.js`. Non-prefixed requests keep today's behaviour\n // (ungated pass-through to chii).\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten === null) return;\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // We do NOT log req.url or any auth param here to avoid leaking codes.\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed: no-op — chii's Koa 'request' listener (registered below\n // by chii.start) serves the rewritten URL. (Koa skips writing when an\n // earlier listener already ended the response, so the 401 path is safe\n // even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,+BAA+B;;;;;;AAO5C,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACiCxC,MAAMA,aAAAA,GAAAA,YAAAA,eAAAA,QAAAA,MAAAA,CAAAA,cAAAA,WAAAA,CAAAA,KAAwC;;;;;;;;AAS9C,MAAM,gCAAgC;;;;;;;;AAsBtC,SAAS,sBAA0D;AACjE,KAAI;EACF,MAAM,MAAeA,UAAQ,kCAAkC;AAC/D,MAAI,OAAO,QAAQ,WACjB,QAAO;SAEH;AAGR,QAAO;;;;;;;;;;;;;;;;;;;;;AAsBT,eAAe,qBACb,MACA,cACA,cACiC;AACjC,KAAI,iBAAiB,MAAM;AACzB,QAAM,KAAK,MAAM,aAAa;AAC9B,SAAO;;CAGT,IAAI,WAAmC;CACvC,MAAM,QAAQ,aAAa;CAC3B,MAAM,gBAAgB,MAAM;AAE5B,OAAM,QAAQ,SAAiC,QAAQ;AACrD,aAAW;AACX,SAAO,cAAc,KAAK,MAAM,OAAO;;AAGzC,KAAI;AACF,QAAM,KAAK,MAAM,aAAa;WACtB;AAER,QAAM,QAAQ;;AAGhB,QAAO;;AAcT,SAAS,iBAAmC;CAE1C,MAAM,MAAeA,UAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;;;;AAyC9D,SAAgB,oBAAoB,QAA+B;CACjE,MAAM,QAAQ,oCAAoC,KAAK,OAAO;AAC9D,KAAI,UAAU,KAAM,QAAO;CAC3B,MAAM,OAAO,MAAM;CACnB,MAAM,OAAO,MAAM,OAAO,KAAA,KAAa,MAAM,OAAO,KAAK,MAAM,MAAM;CACrE,MAAM,QAAQ,MAAM,MAAM;AAE1B,QAAO,GAAG,OAAO,QADC,UAAU,KAAK,MAAM,IACJ,KAAK;;;;;;;;;;;;;;;;;;AA+E1C,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,YAAY,iBAAiB;CACrC,MAAM,sBACJ,QAAQ,wBAAwB,KAAA,IAC5B,QAAQ,sBACR;CAEN,MAAM,cAAA,GAAA,UAAA,eAA2B;CAIjC,MAAM,oBAAoB,SAA6C;AACrE,MAAI,iBAAiB,KAAA,EAAW;AAChC,MAAI;AACF,gBAAa,EAAE,MAAM,CAAC;UAChB;;AAiBV,KAAI,WAMF,YAAW,GAAG,YAAY,KAAK,QAAQ;EACrC,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,MAAI,cAAc,KAAM;AACxB,MAAI,MAAM;AACV,MAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,OAAI,aAAa;AACjB,OAAI,UAAU,+BAA+B,IAAI;AACjD,OAAI,UAAU,gBAAgB,mBAAmB;AACjD,OAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,oBAAiB,eAAe;;GAMlC;CAgBJ,MAAM,eAAe,sBAAsB,IAAI,qBAAqB,GAAG;CACvE,MAAM,kBAAkB,MAAM,qBAC5B,gBAAgB,EAChB;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,EAC/E,aACD;AAUD,KAAI,YAAY;EACd,MAAM,uBAAuB,WAAW,UAAU,UAAU;AAG5D,aAAW,mBAAmB,UAAU;EAGxC,MAAM,YAAY,IAAIC,GAAAA,gBAAgB,EAAE,UAAU,MAAM,CAAC;AACzD,aAAW,GAAG,YAAY,KAAsB,QAAgB,SAAiB;GAK/E,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,OAAI,cAAc,KAChB,KAAI,MAAM;AAEZ,OAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,cAAU,cAAc,KAAK,QAAQ,OAAO,SAAO;AACjD,UAAG,MAAM,8BAA8B,yBAAyB;MAChE;AACF,qBAAiB,aAAa;AAE9B;;AAIF,QAAK,MAAM,YAAY,qBACrB,UAAS,KAAK,QAAQ,KAAK;IAE7B;;CAGJ,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;CAYF,IAAI,kBAAyD;AAC7D,KAAI,sBAAsB,KAAK,oBAAoB,MAAM;EACvD,MAAM,UAAU;AAChB,oBAAkB,kBAAkB;AAClC,QAAK,MAAM,UAAU,QAAQ,KAAK,QAEhC,KAAI,OAAO,eAAe,EACxB,QAAO,MAAM;KAGhB,oBAAoB;;AAGzB,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,OAAI,oBAAoB,MAAM;AAC5B,kBAAc,gBAAgB;AAC9B,sBAAkB;;AAEpB,cAAW,YAAY,SAAS,CAAC;IACjC;EACL"}
1
+ {"version":3,"file":"chii-relay-BzUf0LH3.cjs","names":["require","WebSocketServer"],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * WS keepalive ping interval (ms).\n *\n * Cloudflare proxied connections are dropped after ~100 s of no traffic.\n * 45 s comfortably fits inside that window and lets both the phone-target leg\n * and the daemon-client leg survive idle CDP sessions.\n */\nconst DEFAULT_KEEPALIVE_INTERVAL_MS = 45_000;\n\n/**\n * Minimal shape of chii's internal WebSocketServer instance.\n *\n * `chii/server/lib/WebSocketServer` holds the real `ws.Server` in `_wss`.\n * `_wss.clients` is the standard `Set<WebSocket>` tracking all live sockets.\n * We access this to ping every connected socket — no chii internals beyond\n * this single field are touched.\n */\ninterface ChiiInternalWss {\n _wss: { clients: Set<WebSocket> };\n start(server: import('node:http').Server): void;\n}\n\n/**\n * Loads chii's internal WebSocketServer class and returns it together with a\n * flag indicating whether the real class was found.\n *\n * Returns `null` if the internal path is not resolvable (future chii release\n * changes the layout) — callers skip keepalive gracefully.\n */\nfunction tryLoadChiiWssClass(): (new () => ChiiInternalWss) | null {\n try {\n const mod: unknown = require('chii/server/lib/WebSocketServer');\n if (typeof mod === 'function') {\n return mod as new () => ChiiInternalWss;\n }\n } catch {\n // Module not found or shape changed — keepalive will be skipped.\n }\n return null;\n}\n\n/**\n * Calls `chii.start()` and returns the chii `WebSocketServer` instance that\n * was constructed during the call.\n *\n * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`\n * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`\n * at module load time. The class reference is stable, so we can temporarily\n * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —\n * to record `this` before the original `start` runs.\n *\n * The patch is installed before `chii.start()` and removed (via `finally`)\n * immediately after, so concurrent `startChiiRelay` calls nest correctly: each\n * call's patch overrides the previous in the prototype chain for the duration\n * of its own `chii.start()` call, restoring the prior descriptor on exit.\n *\n * If `ChiiWssClass` is null (internal path changed in a future chii release),\n * `chii.start()` runs unpatched and the function returns null — callers skip\n * keepalive gracefully without affecting relay correctness.\n */\nasync function startChiiWithCapture(\n chii: ChiiServerModule,\n startOptions: Parameters<ChiiServerModule['start']>[0],\n ChiiWssClass: (new () => ChiiInternalWss) | null,\n): Promise<ChiiInternalWss | null> {\n if (ChiiWssClass === null) {\n await chii.start(startOptions);\n return null;\n }\n\n let captured: ChiiInternalWss | null = null;\n const proto = ChiiWssClass.prototype as ChiiInternalWss;\n const originalStart = proto.start;\n\n proto.start = function (this: ChiiInternalWss, server) {\n captured = this;\n return originalStart.call(this, server);\n };\n\n try {\n await chii.start(startOptions);\n } finally {\n // Always restore — even if chii.start() throws.\n proto.start = originalStart;\n }\n\n return captured;\n}\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n /**\n * WS protocol ping interval in milliseconds (issue #483).\n *\n * The relay sends a ping frame to every connected WebSocket at this interval\n * so that Cloudflare's proxied-connection idle timer (~100 s) is reset for\n * both the phone-target leg and the daemon-client leg. The peer responds with\n * a pong automatically (browser / ws library behaviour) — no application\n * code change is needed on either end.\n *\n * Default: 45 000 ms (45 s). Set to 0 to disable keepalive entirely.\n *\n * Pass a small value in tests to avoid real-time waits — pair with fake\n * timers (`vi.useFakeTimers()`) or a short sleep.\n */\n keepaliveIntervalMs?: number;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n const keepaliveIntervalMs =\n options.keepaliveIntervalMs !== undefined\n ? options.keepaliveIntervalMs\n : DEFAULT_KEEPALIVE_INTERVAL_MS;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: only the path-prefixed form is ours — the phone\n // fetches `target.js` via `https://<host>/at/<code>/target.js` (issue\n // #466), which must be verified + stripped so chii's Koa static handler\n // serves `/target.js`. Non-prefixed requests keep today's behaviour\n // (ungated pass-through to chii).\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten === null) return;\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // We do NOT log req.url or any auth param here to avoid leaking codes.\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed: no-op — chii's Koa 'request' listener (registered below\n // by chii.start) serves the rewritten URL. (Koa skips writing when an\n // earlier listener already ended the response, so the 401 path is safe\n // even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":"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"}
@@ -35,9 +35,10 @@ const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
35
35
  * @param totpCode - Optional current TOTP code (6 digits). When provided, it
36
36
  * is appended as `at=<totpCode>`. Must be computed at call time — it rotates
37
37
  * every 30 s. Omit when TOTP is disabled.
38
- * @param opts - Optional app identity hints: `name` and `icon` (#498).
38
+ * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`
39
+ * (#498, #543).
39
40
  * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>
40
- * [&at=<code>][&name=<enc>][&icon=<enc>]` params.
41
+ * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.
41
42
  */
42
43
  function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
43
44
  let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}&debug=1&relay=${encodeURIComponent(wssUrl)}`;
@@ -52,9 +53,10 @@ function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
52
53
  }
53
54
  if (iconParsed?.protocol === "https:") url += `&icon=${encodeURIComponent(opts.icon)}`;
54
55
  }
56
+ if (opts?.selfdebug === true) url += "&selfdebug=1";
55
57
  return url;
56
58
  }
57
59
  //#endregion
58
60
  export { buildLauncherAttachUrl };
59
61
 
60
- //# sourceMappingURL=deeplink-B-94XmWA.js.map
62
+ //# sourceMappingURL=deeplink-BpO9qc-D.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"deeplink-BLU2_hg6.cjs","names":[],"sources":["../src/mcp/deeplink.ts"],"sourcesContent":["/**\n * URL of the AITC Sandbox launcher PWA.\n *\n * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the\n * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy\n * for the same reason — keep the two in sync when the URL changes.\n */\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Optional metadata that enriches the launcher deep-link (#498).\n *\n * These fields are added as query params so the launcher PWA can display\n * a recognizable identity (name, icon) without the user having to configure\n * anything extra.\n */\nexport interface LauncherAttachUrlOpts {\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * Absolute `https://` icon URL for the partner nav bar icon slot (`icon=`\n * param). Non-https or falsy values are not added.\n */\n icon?: string;\n}\n\n/**\n * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).\n *\n * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport\n * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the\n * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js\n * is injected. `&at=<totpCode>` is added only when a code is provided (same\n * conditional as {@link buildDeepLinkAttachUrl}).\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the\n * launcher partner bar shows the app name instead of the generic default (#498).\n * When `opts.icon` is an absolute https:// URL, it is added as `&icon=` so the\n * launcher can render an icon next to the title (#498).\n *\n * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL\n * via raw string manipulation), this function uses WHATWG `encodeURIComponent`\n * because the target is a standard `https:` URL.\n *\n * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param\n * only — never logged or returned separately. Callers must NOT log the result\n * of this function to stdout/stderr.\n *\n * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL\n * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.\n * @param wssUrl - The `wss://` relay URL the framed page will attach to.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is appended as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Omit when TOTP is disabled.\n * @param opts - Optional app identity hints: `name` and `icon` (#498).\n * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>\n * [&at=<code>][&name=<enc>][&icon=<enc>]` params.\n */\nexport function buildLauncherAttachUrl(\n tunnelUrl: string,\n wssUrl: string,\n totpCode?: string,\n opts?: LauncherAttachUrlOpts,\n): string {\n let url =\n `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}` +\n `&debug=1&relay=${encodeURIComponent(wssUrl)}`;\n if (totpCode !== undefined && totpCode !== '') {\n url += `&at=${encodeURIComponent(totpCode)}`;\n }\n // App identity hints (#498): add non-blank name and valid https icon.\n if (opts?.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts?.icon !== undefined) {\n let iconParsed: URL;\n try {\n iconParsed = new URL(opts.icon);\n } catch {\n iconParsed = null as unknown as URL;\n }\n if (iconParsed?.protocol === 'https:') {\n url += `&icon=${encodeURIComponent(opts.icon)}`;\n }\n }\n return url;\n}\n\n/**\n * Build a self-attaching dog-food deep-link.\n *\n * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`\n * URL that opens a dog-food bundle on a phone. The in-app debug gate\n * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries\n * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus\n * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result\n * as a QR code and scanning it with the phone camera opens the mini-app and\n * attaches it to the live Chii relay. QR is the single entry path — it needs\n * no USB cable, platform CLI, or driver, and works the same on iOS/Android.\n *\n * The Toss app propagates extra query params from the entry deep link into the\n * mini-app WebView's `location.search` (confirmed behavior), so the gate reads\n * them at attach time.\n *\n * TOTP `at=` param:\n * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional\n * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.\n * The code must be computed by the caller at call time — do NOT pre-compute\n * and cache it, because the 30-second window expires quickly. The in-app gate\n * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.\n *\n * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.\n * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query\n * decomposition you can rely on across runtimes), so query manipulation via\n * `url.searchParams` is not portable here. We splice the query string directly\n * on the raw string instead, which keeps the scheme, authority, path, and any\n * pre-existing params (notably `_deploymentId`) byte-for-byte intact.\n */\n\n/**\n * Suspicious/generic authority values that indicate a malformed or placeholder\n * scheme URL. These are host strings that will almost certainly cause the Toss\n * app to fail with \"bundle not found\" silently.\n *\n * The expected form from `ait deploy --scheme-only` is:\n * intoss-private://<appName>?_deploymentId=<uuid>\n * where `<appName>` is a non-generic string like `aitc-sdk-example`.\n */\nconst SUSPICIOUS_AUTHORITIES = new Set<string>(['', 'web', 'localhost', '127.0.0.1', 'app']);\n\n/**\n * Validates the authority (host) portion of a scheme URL.\n *\n * Returns a warning message if the authority is missing or looks like a\n * placeholder, or `null` if the authority looks valid.\n *\n * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`\n * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).\n */\nexport function validateSchemeAuthority(schemeUrl: string): string | null {\n // Extract authority from `scheme://authority[/path][?query][#hash]`.\n // We cannot use the WHATWG URL parser for non-special schemes reliably\n // (see the deeplink.ts module comment), so we parse the raw string.\n const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\\-.]*:\\/\\//, '');\n if (afterScheme === schemeUrl) {\n // No `://` found — not a scheme URL at all.\n return (\n 'scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). ' +\n 'Use the URL printed by `ait deploy --scheme-only`.'\n );\n }\n\n // authority ends at the first `/`, `?`, `#`, or end of string.\n const authorityEnd = afterScheme.search(/[/?#]/);\n const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);\n\n if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) {\n const displayAuthority = authority === '' ? '(empty)' : `\"${authority}\"`;\n return (\n `scheme_url authority ${displayAuthority} looks like a placeholder. ` +\n 'Expected an app name like `intoss-private://aitc-sdk-example?_deploymentId=<uuid>`. ' +\n 'Use the URL printed by `ait deploy --scheme-only` — it includes the correct app name as the host.'\n );\n }\n\n return null;\n}\n\n/** A param the helper appends. Existing occurrences are replaced, not duplicated. */\ntype AppendParam = readonly [key: string, value: string];\n\nfunction stripExisting(query: string, key: string): string {\n if (query === '') return '';\n return query\n .split('&')\n .filter((pair) => pair !== '' && pair.split('=')[0] !== key)\n .join('&');\n}\n\n/**\n * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a\n * scheme URL's query string, preserving everything else (scheme, authority,\n * path, hash, and the existing `_deploymentId` param). If any of the spliced\n * params is already present it is replaced so the helper is idempotent.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed\n * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B\n * of the gate); this helper does not invent one.\n * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the\n * running debug MCP server's quick tunnel.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Pass `undefined` or omit when TOTP is disabled.\n * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`\n * appended.\n * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so\n * producing such a link would be a silent dead end).\n */\nexport function buildDeepLinkAttachUrl(\n schemeUrl: string,\n wssUrl: string,\n totpCode?: string,\n): string {\n let relay: URL;\n try {\n relay = new URL(wssUrl);\n } catch {\n throw new Error(`relay URL is not a valid URL: ${wssUrl}`);\n }\n if (relay.protocol !== 'wss:') {\n throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);\n }\n\n const hashIndex = schemeUrl.indexOf('#');\n const hash = hashIndex === -1 ? '' : schemeUrl.slice(hashIndex);\n const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);\n\n const queryIndex = beforeHash.indexOf('?');\n const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);\n let query = queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1);\n\n const appended: AppendParam[] = [\n ['debug', '1'],\n ['relay', wssUrl],\n ];\n // Only splice `at=` when a code is provided (TOTP enabled). Omitting it when\n // TOTP is disabled preserves backward compatibility with gate deployments\n // that do not yet evaluate the `at` param.\n if (totpCode !== undefined && totpCode !== '') {\n appended.push(['at', totpCode]);\n }\n\n // Always strip the `at` key from the existing query so a stale code from a\n // previous run is removed even when the caller does not provide a fresh code.\n query = stripExisting(query, 'at');\n\n for (const [key] of appended) {\n query = stripExisting(query, key);\n }\n for (const [key, value] of appended) {\n const pair = `${key}=${encodeURIComponent(value)}`;\n query = query === '' ? pair : `${query}&${pair}`;\n }\n\n return `${base}?${query}${hash}`;\n}\n"],"mappings":";;;;;;;;AAOA,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDrB,SAAgB,uBACd,WACA,QACA,UACA,MACQ;CACR,IAAI,MACF,GAAG,aAAa,OAAO,mBAAmB,UAAU,CAAA,iBAClC,mBAAmB,OAAO;AAC9C,KAAI,aAAa,KAAA,KAAa,aAAa,GACzC,QAAO,OAAO,mBAAmB,SAAS;AAG5C,KAAI,MAAM,SAAS,KAAA,KAAa,KAAK,KAAK,MAAM,KAAK,GACnD,QAAO,SAAS,mBAAmB,KAAK,KAAK,MAAM,CAAC;AAEtD,KAAI,MAAM,SAAS,KAAA,GAAW;EAC5B,IAAI;AACJ,MAAI;AACF,gBAAa,IAAI,IAAI,KAAK,KAAK;UACzB;AACN,gBAAa;;AAEf,MAAI,YAAY,aAAa,SAC3B,QAAO,SAAS,mBAAmB,KAAK,KAAK;;AAGjD,QAAO"}
1
+ {"version":3,"file":"deeplink-BpO9qc-D.js","names":[],"sources":["../src/mcp/deeplink.ts"],"sourcesContent":["/**\n * URL of the AITC Sandbox launcher PWA.\n *\n * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the\n * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy\n * for the same reason — keep the two in sync when the URL changes.\n */\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Optional metadata that enriches the launcher deep-link (#498).\n *\n * These fields are added as query params so the launcher PWA can display\n * a recognizable identity (name, icon) without the user having to configure\n * anything extra.\n */\nexport interface LauncherAttachUrlOpts {\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * Absolute `https://` icon URL for the partner nav bar icon slot (`icon=`\n * param). Non-https or falsy values are not added.\n */\n icon?: string;\n /**\n * When `true`, adds `selfdebug=1` to the launcher URL so the launcher PWA\n * registers its own document as a CDP target (issue #531/#543).\n *\n * **Single-attach model**: attaching the launcher self-target causes any\n * currently-attached mini-app target to be evicted. This is intentional —\n * `selfdebug` is a \"launcher diagnostics mode\" for inspecting the launcher's\n * own DOM/console/safe-area, not simultaneous dual-attach.\n *\n * When `false` or omitted (default), the param is not added and the output\n * is byte-identical to the previous behaviour.\n */\n selfdebug?: boolean;\n}\n\n/**\n * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).\n *\n * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport\n * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the\n * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js\n * is injected. `&at=<totpCode>` is added only when a code is provided (same\n * conditional as {@link buildDeepLinkAttachUrl}).\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the\n * launcher partner bar shows the app name instead of the generic default (#498).\n * When `opts.icon` is an absolute https:// URL, it is added as `&icon=` so the\n * launcher can render an icon next to the title (#498).\n *\n * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL\n * via raw string manipulation), this function uses WHATWG `encodeURIComponent`\n * because the target is a standard `https:` URL.\n *\n * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param\n * only — never logged or returned separately. Callers must NOT log the result\n * of this function to stdout/stderr.\n *\n * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL\n * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.\n * @param wssUrl - The `wss://` relay URL the framed page will attach to.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is appended as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Omit when TOTP is disabled.\n * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`\n * (#498, #543).\n * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>\n * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.\n */\nexport function buildLauncherAttachUrl(\n tunnelUrl: string,\n wssUrl: string,\n totpCode?: string,\n opts?: LauncherAttachUrlOpts,\n): string {\n let url =\n `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}` +\n `&debug=1&relay=${encodeURIComponent(wssUrl)}`;\n if (totpCode !== undefined && totpCode !== '') {\n url += `&at=${encodeURIComponent(totpCode)}`;\n }\n // App identity hints (#498): add non-blank name and valid https icon.\n if (opts?.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts?.icon !== undefined) {\n let iconParsed: URL;\n try {\n iconParsed = new URL(opts.icon);\n } catch {\n iconParsed = null as unknown as URL;\n }\n if (iconParsed?.protocol === 'https:') {\n url += `&icon=${encodeURIComponent(opts.icon)}`;\n }\n }\n // Self-debug opt-in (#543): add selfdebug=1 only when explicitly requested.\n // Without this flag the output is byte-identical to the previous behaviour.\n if (opts?.selfdebug === true) {\n url += '&selfdebug=1';\n }\n return url;\n}\n\n/**\n * Build a self-attaching dog-food deep-link.\n *\n * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`\n * URL that opens a dog-food bundle on a phone. The in-app debug gate\n * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries\n * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus\n * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result\n * as a QR code and scanning it with the phone camera opens the mini-app and\n * attaches it to the live Chii relay. QR is the single entry path — it needs\n * no USB cable, platform CLI, or driver, and works the same on iOS/Android.\n *\n * The Toss app propagates extra query params from the entry deep link into the\n * mini-app WebView's `location.search` (confirmed behavior), so the gate reads\n * them at attach time.\n *\n * TOTP `at=` param:\n * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional\n * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.\n * The code must be computed by the caller at call time — do NOT pre-compute\n * and cache it, because the 30-second window expires quickly. The in-app gate\n * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.\n *\n * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.\n * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query\n * decomposition you can rely on across runtimes), so query manipulation via\n * `url.searchParams` is not portable here. We splice the query string directly\n * on the raw string instead, which keeps the scheme, authority, path, and any\n * pre-existing params (notably `_deploymentId`) byte-for-byte intact.\n */\n\n/**\n * Suspicious/generic authority values that indicate a malformed or placeholder\n * scheme URL. These are host strings that will almost certainly cause the Toss\n * app to fail with \"bundle not found\" silently.\n *\n * The expected form from `ait deploy --scheme-only` is:\n * intoss-private://<appName>?_deploymentId=<uuid>\n * where `<appName>` is a non-generic string like `aitc-sdk-example`.\n */\nconst SUSPICIOUS_AUTHORITIES = new Set<string>(['', 'web', 'localhost', '127.0.0.1', 'app']);\n\n/**\n * Validates the authority (host) portion of a scheme URL.\n *\n * Returns a warning message if the authority is missing or looks like a\n * placeholder, or `null` if the authority looks valid.\n *\n * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`\n * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).\n */\nexport function validateSchemeAuthority(schemeUrl: string): string | null {\n // Extract authority from `scheme://authority[/path][?query][#hash]`.\n // We cannot use the WHATWG URL parser for non-special schemes reliably\n // (see the deeplink.ts module comment), so we parse the raw string.\n const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\\-.]*:\\/\\//, '');\n if (afterScheme === schemeUrl) {\n // No `://` found — not a scheme URL at all.\n return (\n 'scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). ' +\n 'Use the URL printed by `ait deploy --scheme-only`.'\n );\n }\n\n // authority ends at the first `/`, `?`, `#`, or end of string.\n const authorityEnd = afterScheme.search(/[/?#]/);\n const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);\n\n if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) {\n const displayAuthority = authority === '' ? '(empty)' : `\"${authority}\"`;\n return (\n `scheme_url authority ${displayAuthority} looks like a placeholder. ` +\n 'Expected an app name like `intoss-private://aitc-sdk-example?_deploymentId=<uuid>`. ' +\n 'Use the URL printed by `ait deploy --scheme-only` — it includes the correct app name as the host.'\n );\n }\n\n return null;\n}\n\n/** A param the helper appends. Existing occurrences are replaced, not duplicated. */\ntype AppendParam = readonly [key: string, value: string];\n\nfunction stripExisting(query: string, key: string): string {\n if (query === '') return '';\n return query\n .split('&')\n .filter((pair) => pair !== '' && pair.split('=')[0] !== key)\n .join('&');\n}\n\n/**\n * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a\n * scheme URL's query string, preserving everything else (scheme, authority,\n * path, hash, and the existing `_deploymentId` param). If any of the spliced\n * params is already present it is replaced so the helper is idempotent.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed\n * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B\n * of the gate); this helper does not invent one.\n * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the\n * running debug MCP server's quick tunnel.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Pass `undefined` or omit when TOTP is disabled.\n * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`\n * appended.\n * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so\n * producing such a link would be a silent dead end).\n */\nexport function buildDeepLinkAttachUrl(\n schemeUrl: string,\n wssUrl: string,\n totpCode?: string,\n): string {\n let relay: URL;\n try {\n relay = new URL(wssUrl);\n } catch {\n throw new Error(`relay URL is not a valid URL: ${wssUrl}`);\n }\n if (relay.protocol !== 'wss:') {\n throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);\n }\n\n const hashIndex = schemeUrl.indexOf('#');\n const hash = hashIndex === -1 ? '' : schemeUrl.slice(hashIndex);\n const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);\n\n const queryIndex = beforeHash.indexOf('?');\n const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);\n let query = queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1);\n\n const appended: AppendParam[] = [\n ['debug', '1'],\n ['relay', wssUrl],\n ];\n // Only splice `at=` when a code is provided (TOTP enabled). Omitting it when\n // TOTP is disabled preserves backward compatibility with gate deployments\n // that do not yet evaluate the `at` param.\n if (totpCode !== undefined && totpCode !== '') {\n appended.push(['at', totpCode]);\n }\n\n // Always strip the `at` key from the existing query so a stale code from a\n // previous run is removed even when the caller does not provide a fresh code.\n query = stripExisting(query, 'at');\n\n for (const [key] of appended) {\n query = stripExisting(query, key);\n }\n for (const [key, value] of appended) {\n const pair = `${key}=${encodeURIComponent(value)}`;\n query = query === '' ? pair : `${query}&${pair}`;\n }\n\n return `${base}?${query}${hash}`;\n}\n"],"mappings":";;;;;;;;AAOA,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoErB,SAAgB,uBACd,WACA,QACA,UACA,MACQ;CACR,IAAI,MACF,GAAG,aAAa,OAAO,mBAAmB,UAAU,CAAA,iBAClC,mBAAmB,OAAO;AAC9C,KAAI,aAAa,KAAA,KAAa,aAAa,GACzC,QAAO,OAAO,mBAAmB,SAAS;AAG5C,KAAI,MAAM,SAAS,KAAA,KAAa,KAAK,KAAK,MAAM,KAAK,GACnD,QAAO,SAAS,mBAAmB,KAAK,KAAK,MAAM,CAAC;AAEtD,KAAI,MAAM,SAAS,KAAA,GAAW;EAC5B,IAAI;AACJ,MAAI;AACF,gBAAa,IAAI,IAAI,KAAK,KAAK;UACzB;AACN,gBAAa;;AAEf,MAAI,YAAY,aAAa,SAC3B,QAAO,SAAS,mBAAmB,KAAK,KAAK;;AAKjD,KAAI,MAAM,cAAc,KACtB,QAAO;AAET,QAAO"}
@@ -35,9 +35,10 @@ const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
35
35
  * @param totpCode - Optional current TOTP code (6 digits). When provided, it
36
36
  * is appended as `at=<totpCode>`. Must be computed at call time — it rotates
37
37
  * every 30 s. Omit when TOTP is disabled.
38
- * @param opts - Optional app identity hints: `name` and `icon` (#498).
38
+ * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`
39
+ * (#498, #543).
39
40
  * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>
40
- * [&at=<code>][&name=<enc>][&icon=<enc>]` params.
41
+ * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.
41
42
  */
42
43
  function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
43
44
  let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}&debug=1&relay=${encodeURIComponent(wssUrl)}`;
@@ -52,9 +53,10 @@ function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
52
53
  }
53
54
  if (iconParsed?.protocol === "https:") url += `&icon=${encodeURIComponent(opts.icon)}`;
54
55
  }
56
+ if (opts?.selfdebug === true) url += "&selfdebug=1";
55
57
  return url;
56
58
  }
57
59
  //#endregion
58
60
  export { buildLauncherAttachUrl };
59
61
 
60
- //# sourceMappingURL=deeplink-CYqDwVYs.js.map
62
+ //# sourceMappingURL=deeplink-D1HXJ2YG.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"deeplink-CU6opogq.cjs","names":[],"sources":["../src/mcp/deeplink.ts"],"sourcesContent":["/**\n * URL of the AITC Sandbox launcher PWA.\n *\n * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the\n * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy\n * for the same reason — keep the two in sync when the URL changes.\n */\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Optional metadata that enriches the launcher deep-link (#498).\n *\n * These fields are added as query params so the launcher PWA can display\n * a recognizable identity (name, icon) without the user having to configure\n * anything extra.\n */\nexport interface LauncherAttachUrlOpts {\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * Absolute `https://` icon URL for the partner nav bar icon slot (`icon=`\n * param). Non-https or falsy values are not added.\n */\n icon?: string;\n}\n\n/**\n * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).\n *\n * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport\n * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the\n * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js\n * is injected. `&at=<totpCode>` is added only when a code is provided (same\n * conditional as {@link buildDeepLinkAttachUrl}).\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the\n * launcher partner bar shows the app name instead of the generic default (#498).\n * When `opts.icon` is an absolute https:// URL, it is added as `&icon=` so the\n * launcher can render an icon next to the title (#498).\n *\n * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL\n * via raw string manipulation), this function uses WHATWG `encodeURIComponent`\n * because the target is a standard `https:` URL.\n *\n * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param\n * only — never logged or returned separately. Callers must NOT log the result\n * of this function to stdout/stderr.\n *\n * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL\n * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.\n * @param wssUrl - The `wss://` relay URL the framed page will attach to.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is appended as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Omit when TOTP is disabled.\n * @param opts - Optional app identity hints: `name` and `icon` (#498).\n * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>\n * [&at=<code>][&name=<enc>][&icon=<enc>]` params.\n */\nexport function buildLauncherAttachUrl(\n tunnelUrl: string,\n wssUrl: string,\n totpCode?: string,\n opts?: LauncherAttachUrlOpts,\n): string {\n let url =\n `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}` +\n `&debug=1&relay=${encodeURIComponent(wssUrl)}`;\n if (totpCode !== undefined && totpCode !== '') {\n url += `&at=${encodeURIComponent(totpCode)}`;\n }\n // App identity hints (#498): add non-blank name and valid https icon.\n if (opts?.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts?.icon !== undefined) {\n let iconParsed: URL;\n try {\n iconParsed = new URL(opts.icon);\n } catch {\n iconParsed = null as unknown as URL;\n }\n if (iconParsed?.protocol === 'https:') {\n url += `&icon=${encodeURIComponent(opts.icon)}`;\n }\n }\n return url;\n}\n\n/**\n * Build a self-attaching dog-food deep-link.\n *\n * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`\n * URL that opens a dog-food bundle on a phone. The in-app debug gate\n * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries\n * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus\n * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result\n * as a QR code and scanning it with the phone camera opens the mini-app and\n * attaches it to the live Chii relay. QR is the single entry path — it needs\n * no USB cable, platform CLI, or driver, and works the same on iOS/Android.\n *\n * The Toss app propagates extra query params from the entry deep link into the\n * mini-app WebView's `location.search` (confirmed behavior), so the gate reads\n * them at attach time.\n *\n * TOTP `at=` param:\n * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional\n * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.\n * The code must be computed by the caller at call time — do NOT pre-compute\n * and cache it, because the 30-second window expires quickly. The in-app gate\n * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.\n *\n * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.\n * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query\n * decomposition you can rely on across runtimes), so query manipulation via\n * `url.searchParams` is not portable here. We splice the query string directly\n * on the raw string instead, which keeps the scheme, authority, path, and any\n * pre-existing params (notably `_deploymentId`) byte-for-byte intact.\n */\n\n/**\n * Suspicious/generic authority values that indicate a malformed or placeholder\n * scheme URL. These are host strings that will almost certainly cause the Toss\n * app to fail with \"bundle not found\" silently.\n *\n * The expected form from `ait deploy --scheme-only` is:\n * intoss-private://<appName>?_deploymentId=<uuid>\n * where `<appName>` is a non-generic string like `aitc-sdk-example`.\n */\nconst SUSPICIOUS_AUTHORITIES = new Set<string>(['', 'web', 'localhost', '127.0.0.1', 'app']);\n\n/**\n * Validates the authority (host) portion of a scheme URL.\n *\n * Returns a warning message if the authority is missing or looks like a\n * placeholder, or `null` if the authority looks valid.\n *\n * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`\n * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).\n */\nexport function validateSchemeAuthority(schemeUrl: string): string | null {\n // Extract authority from `scheme://authority[/path][?query][#hash]`.\n // We cannot use the WHATWG URL parser for non-special schemes reliably\n // (see the deeplink.ts module comment), so we parse the raw string.\n const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\\-.]*:\\/\\//, '');\n if (afterScheme === schemeUrl) {\n // No `://` found — not a scheme URL at all.\n return (\n 'scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). ' +\n 'Use the URL printed by `ait deploy --scheme-only`.'\n );\n }\n\n // authority ends at the first `/`, `?`, `#`, or end of string.\n const authorityEnd = afterScheme.search(/[/?#]/);\n const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);\n\n if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) {\n const displayAuthority = authority === '' ? '(empty)' : `\"${authority}\"`;\n return (\n `scheme_url authority ${displayAuthority} looks like a placeholder. ` +\n 'Expected an app name like `intoss-private://aitc-sdk-example?_deploymentId=<uuid>`. ' +\n 'Use the URL printed by `ait deploy --scheme-only` — it includes the correct app name as the host.'\n );\n }\n\n return null;\n}\n\n/** A param the helper appends. Existing occurrences are replaced, not duplicated. */\ntype AppendParam = readonly [key: string, value: string];\n\nfunction stripExisting(query: string, key: string): string {\n if (query === '') return '';\n return query\n .split('&')\n .filter((pair) => pair !== '' && pair.split('=')[0] !== key)\n .join('&');\n}\n\n/**\n * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a\n * scheme URL's query string, preserving everything else (scheme, authority,\n * path, hash, and the existing `_deploymentId` param). If any of the spliced\n * params is already present it is replaced so the helper is idempotent.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed\n * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B\n * of the gate); this helper does not invent one.\n * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the\n * running debug MCP server's quick tunnel.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Pass `undefined` or omit when TOTP is disabled.\n * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`\n * appended.\n * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so\n * producing such a link would be a silent dead end).\n */\nexport function buildDeepLinkAttachUrl(\n schemeUrl: string,\n wssUrl: string,\n totpCode?: string,\n): string {\n let relay: URL;\n try {\n relay = new URL(wssUrl);\n } catch {\n throw new Error(`relay URL is not a valid URL: ${wssUrl}`);\n }\n if (relay.protocol !== 'wss:') {\n throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);\n }\n\n const hashIndex = schemeUrl.indexOf('#');\n const hash = hashIndex === -1 ? '' : schemeUrl.slice(hashIndex);\n const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);\n\n const queryIndex = beforeHash.indexOf('?');\n const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);\n let query = queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1);\n\n const appended: AppendParam[] = [\n ['debug', '1'],\n ['relay', wssUrl],\n ];\n // Only splice `at=` when a code is provided (TOTP enabled). Omitting it when\n // TOTP is disabled preserves backward compatibility with gate deployments\n // that do not yet evaluate the `at` param.\n if (totpCode !== undefined && totpCode !== '') {\n appended.push(['at', totpCode]);\n }\n\n // Always strip the `at` key from the existing query so a stale code from a\n // previous run is removed even when the caller does not provide a fresh code.\n query = stripExisting(query, 'at');\n\n for (const [key] of appended) {\n query = stripExisting(query, key);\n }\n for (const [key, value] of appended) {\n const pair = `${key}=${encodeURIComponent(value)}`;\n query = query === '' ? pair : `${query}&${pair}`;\n }\n\n return `${base}?${query}${hash}`;\n}\n"],"mappings":";;;;;;;;AAOA,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDrB,SAAgB,uBACd,WACA,QACA,UACA,MACQ;CACR,IAAI,MACF,GAAG,aAAa,OAAO,mBAAmB,UAAU,CAAA,iBAClC,mBAAmB,OAAO;AAC9C,KAAI,aAAa,KAAA,KAAa,aAAa,GACzC,QAAO,OAAO,mBAAmB,SAAS;AAG5C,KAAI,MAAM,SAAS,KAAA,KAAa,KAAK,KAAK,MAAM,KAAK,GACnD,QAAO,SAAS,mBAAmB,KAAK,KAAK,MAAM,CAAC;AAEtD,KAAI,MAAM,SAAS,KAAA,GAAW;EAC5B,IAAI;AACJ,MAAI;AACF,gBAAa,IAAI,IAAI,KAAK,KAAK;UACzB;AACN,gBAAa;;AAEf,MAAI,YAAY,aAAa,SAC3B,QAAO,SAAS,mBAAmB,KAAK,KAAK;;AAGjD,QAAO"}
1
+ {"version":3,"file":"deeplink-D1HXJ2YG.js","names":[],"sources":["../src/mcp/deeplink.ts"],"sourcesContent":["/**\n * URL of the AITC Sandbox launcher PWA.\n *\n * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the\n * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy\n * for the same reason — keep the two in sync when the URL changes.\n */\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Optional metadata that enriches the launcher deep-link (#498).\n *\n * These fields are added as query params so the launcher PWA can display\n * a recognizable identity (name, icon) without the user having to configure\n * anything extra.\n */\nexport interface LauncherAttachUrlOpts {\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * Absolute `https://` icon URL for the partner nav bar icon slot (`icon=`\n * param). Non-https or falsy values are not added.\n */\n icon?: string;\n /**\n * When `true`, adds `selfdebug=1` to the launcher URL so the launcher PWA\n * registers its own document as a CDP target (issue #531/#543).\n *\n * **Single-attach model**: attaching the launcher self-target causes any\n * currently-attached mini-app target to be evicted. This is intentional —\n * `selfdebug` is a \"launcher diagnostics mode\" for inspecting the launcher's\n * own DOM/console/safe-area, not simultaneous dual-attach.\n *\n * When `false` or omitted (default), the param is not added and the output\n * is byte-identical to the previous behaviour.\n */\n selfdebug?: boolean;\n}\n\n/**\n * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).\n *\n * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport\n * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the\n * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js\n * is injected. `&at=<totpCode>` is added only when a code is provided (same\n * conditional as {@link buildDeepLinkAttachUrl}).\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the\n * launcher partner bar shows the app name instead of the generic default (#498).\n * When `opts.icon` is an absolute https:// URL, it is added as `&icon=` so the\n * launcher can render an icon next to the title (#498).\n *\n * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL\n * via raw string manipulation), this function uses WHATWG `encodeURIComponent`\n * because the target is a standard `https:` URL.\n *\n * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param\n * only — never logged or returned separately. Callers must NOT log the result\n * of this function to stdout/stderr.\n *\n * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL\n * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.\n * @param wssUrl - The `wss://` relay URL the framed page will attach to.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is appended as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Omit when TOTP is disabled.\n * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`\n * (#498, #543).\n * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>\n * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.\n */\nexport function buildLauncherAttachUrl(\n tunnelUrl: string,\n wssUrl: string,\n totpCode?: string,\n opts?: LauncherAttachUrlOpts,\n): string {\n let url =\n `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}` +\n `&debug=1&relay=${encodeURIComponent(wssUrl)}`;\n if (totpCode !== undefined && totpCode !== '') {\n url += `&at=${encodeURIComponent(totpCode)}`;\n }\n // App identity hints (#498): add non-blank name and valid https icon.\n if (opts?.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts?.icon !== undefined) {\n let iconParsed: URL;\n try {\n iconParsed = new URL(opts.icon);\n } catch {\n iconParsed = null as unknown as URL;\n }\n if (iconParsed?.protocol === 'https:') {\n url += `&icon=${encodeURIComponent(opts.icon)}`;\n }\n }\n // Self-debug opt-in (#543): add selfdebug=1 only when explicitly requested.\n // Without this flag the output is byte-identical to the previous behaviour.\n if (opts?.selfdebug === true) {\n url += '&selfdebug=1';\n }\n return url;\n}\n\n/**\n * Build a self-attaching dog-food deep-link.\n *\n * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`\n * URL that opens a dog-food bundle on a phone. The in-app debug gate\n * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries\n * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus\n * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result\n * as a QR code and scanning it with the phone camera opens the mini-app and\n * attaches it to the live Chii relay. QR is the single entry path — it needs\n * no USB cable, platform CLI, or driver, and works the same on iOS/Android.\n *\n * The Toss app propagates extra query params from the entry deep link into the\n * mini-app WebView's `location.search` (confirmed behavior), so the gate reads\n * them at attach time.\n *\n * TOTP `at=` param:\n * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional\n * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.\n * The code must be computed by the caller at call time — do NOT pre-compute\n * and cache it, because the 30-second window expires quickly. The in-app gate\n * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.\n *\n * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.\n * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query\n * decomposition you can rely on across runtimes), so query manipulation via\n * `url.searchParams` is not portable here. We splice the query string directly\n * on the raw string instead, which keeps the scheme, authority, path, and any\n * pre-existing params (notably `_deploymentId`) byte-for-byte intact.\n */\n\n/**\n * Suspicious/generic authority values that indicate a malformed or placeholder\n * scheme URL. These are host strings that will almost certainly cause the Toss\n * app to fail with \"bundle not found\" silently.\n *\n * The expected form from `ait deploy --scheme-only` is:\n * intoss-private://<appName>?_deploymentId=<uuid>\n * where `<appName>` is a non-generic string like `aitc-sdk-example`.\n */\nconst SUSPICIOUS_AUTHORITIES = new Set<string>(['', 'web', 'localhost', '127.0.0.1', 'app']);\n\n/**\n * Validates the authority (host) portion of a scheme URL.\n *\n * Returns a warning message if the authority is missing or looks like a\n * placeholder, or `null` if the authority looks valid.\n *\n * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`\n * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).\n */\nexport function validateSchemeAuthority(schemeUrl: string): string | null {\n // Extract authority from `scheme://authority[/path][?query][#hash]`.\n // We cannot use the WHATWG URL parser for non-special schemes reliably\n // (see the deeplink.ts module comment), so we parse the raw string.\n const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\\-.]*:\\/\\//, '');\n if (afterScheme === schemeUrl) {\n // No `://` found — not a scheme URL at all.\n return (\n 'scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). ' +\n 'Use the URL printed by `ait deploy --scheme-only`.'\n );\n }\n\n // authority ends at the first `/`, `?`, `#`, or end of string.\n const authorityEnd = afterScheme.search(/[/?#]/);\n const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);\n\n if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) {\n const displayAuthority = authority === '' ? '(empty)' : `\"${authority}\"`;\n return (\n `scheme_url authority ${displayAuthority} looks like a placeholder. ` +\n 'Expected an app name like `intoss-private://aitc-sdk-example?_deploymentId=<uuid>`. ' +\n 'Use the URL printed by `ait deploy --scheme-only` — it includes the correct app name as the host.'\n );\n }\n\n return null;\n}\n\n/** A param the helper appends. Existing occurrences are replaced, not duplicated. */\ntype AppendParam = readonly [key: string, value: string];\n\nfunction stripExisting(query: string, key: string): string {\n if (query === '') return '';\n return query\n .split('&')\n .filter((pair) => pair !== '' && pair.split('=')[0] !== key)\n .join('&');\n}\n\n/**\n * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a\n * scheme URL's query string, preserving everything else (scheme, authority,\n * path, hash, and the existing `_deploymentId` param). If any of the spliced\n * params is already present it is replaced so the helper is idempotent.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed\n * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B\n * of the gate); this helper does not invent one.\n * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the\n * running debug MCP server's quick tunnel.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Pass `undefined` or omit when TOTP is disabled.\n * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`\n * appended.\n * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so\n * producing such a link would be a silent dead end).\n */\nexport function buildDeepLinkAttachUrl(\n schemeUrl: string,\n wssUrl: string,\n totpCode?: string,\n): string {\n let relay: URL;\n try {\n relay = new URL(wssUrl);\n } catch {\n throw new Error(`relay URL is not a valid URL: ${wssUrl}`);\n }\n if (relay.protocol !== 'wss:') {\n throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);\n }\n\n const hashIndex = schemeUrl.indexOf('#');\n const hash = hashIndex === -1 ? '' : schemeUrl.slice(hashIndex);\n const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);\n\n const queryIndex = beforeHash.indexOf('?');\n const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);\n let query = queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1);\n\n const appended: AppendParam[] = [\n ['debug', '1'],\n ['relay', wssUrl],\n ];\n // Only splice `at=` when a code is provided (TOTP enabled). Omitting it when\n // TOTP is disabled preserves backward compatibility with gate deployments\n // that do not yet evaluate the `at` param.\n if (totpCode !== undefined && totpCode !== '') {\n appended.push(['at', totpCode]);\n }\n\n // Always strip the `at` key from the existing query so a stale code from a\n // previous run is removed even when the caller does not provide a fresh code.\n query = stripExisting(query, 'at');\n\n for (const [key] of appended) {\n query = stripExisting(query, key);\n }\n for (const [key, value] of appended) {\n const pair = `${key}=${encodeURIComponent(value)}`;\n query = query === '' ? pair : `${query}&${pair}`;\n }\n\n return `${base}?${query}${hash}`;\n}\n"],"mappings":";;;;;;;;AAOA,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoErB,SAAgB,uBACd,WACA,QACA,UACA,MACQ;CACR,IAAI,MACF,GAAG,aAAa,OAAO,mBAAmB,UAAU,CAAA,iBAClC,mBAAmB,OAAO;AAC9C,KAAI,aAAa,KAAA,KAAa,aAAa,GACzC,QAAO,OAAO,mBAAmB,SAAS;AAG5C,KAAI,MAAM,SAAS,KAAA,KAAa,KAAK,KAAK,MAAM,KAAK,GACnD,QAAO,SAAS,mBAAmB,KAAK,KAAK,MAAM,CAAC;AAEtD,KAAI,MAAM,SAAS,KAAA,GAAW;EAC5B,IAAI;AACJ,MAAI;AACF,gBAAa,IAAI,IAAI,KAAK,KAAK;UACzB;AACN,gBAAa;;AAEf,MAAI,YAAY,aAAa,SAC3B,QAAO,SAAS,mBAAmB,KAAK,KAAK;;AAKjD,KAAI,MAAM,cAAc,KACtB,QAAO;AAET,QAAO"}
@@ -35,9 +35,10 @@ const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
35
35
  * @param totpCode - Optional current TOTP code (6 digits). When provided, it
36
36
  * is appended as `at=<totpCode>`. Must be computed at call time — it rotates
37
37
  * every 30 s. Omit when TOTP is disabled.
38
- * @param opts - Optional app identity hints: `name` and `icon` (#498).
38
+ * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`
39
+ * (#498, #543).
39
40
  * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>
40
- * [&at=<code>][&name=<enc>][&icon=<enc>]` params.
41
+ * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.
41
42
  */
42
43
  function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
43
44
  let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}&debug=1&relay=${encodeURIComponent(wssUrl)}`;
@@ -52,9 +53,10 @@ function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
52
53
  }
53
54
  if (iconParsed?.protocol === "https:") url += `&icon=${encodeURIComponent(opts.icon)}`;
54
55
  }
56
+ if (opts?.selfdebug === true) url += "&selfdebug=1";
55
57
  return url;
56
58
  }
57
59
  //#endregion
58
60
  exports.buildLauncherAttachUrl = buildLauncherAttachUrl;
59
61
 
60
- //# sourceMappingURL=deeplink-BLU2_hg6.cjs.map
62
+ //# sourceMappingURL=deeplink-DCScMYcp.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deeplink-DCScMYcp.cjs","names":[],"sources":["../src/mcp/deeplink.ts"],"sourcesContent":["/**\n * URL of the AITC Sandbox launcher PWA.\n *\n * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the\n * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy\n * for the same reason — keep the two in sync when the URL changes.\n */\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Optional metadata that enriches the launcher deep-link (#498).\n *\n * These fields are added as query params so the launcher PWA can display\n * a recognizable identity (name, icon) without the user having to configure\n * anything extra.\n */\nexport interface LauncherAttachUrlOpts {\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * Absolute `https://` icon URL for the partner nav bar icon slot (`icon=`\n * param). Non-https or falsy values are not added.\n */\n icon?: string;\n /**\n * When `true`, adds `selfdebug=1` to the launcher URL so the launcher PWA\n * registers its own document as a CDP target (issue #531/#543).\n *\n * **Single-attach model**: attaching the launcher self-target causes any\n * currently-attached mini-app target to be evicted. This is intentional —\n * `selfdebug` is a \"launcher diagnostics mode\" for inspecting the launcher's\n * own DOM/console/safe-area, not simultaneous dual-attach.\n *\n * When `false` or omitted (default), the param is not added and the output\n * is byte-identical to the previous behaviour.\n */\n selfdebug?: boolean;\n}\n\n/**\n * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).\n *\n * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport\n * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the\n * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js\n * is injected. `&at=<totpCode>` is added only when a code is provided (same\n * conditional as {@link buildDeepLinkAttachUrl}).\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the\n * launcher partner bar shows the app name instead of the generic default (#498).\n * When `opts.icon` is an absolute https:// URL, it is added as `&icon=` so the\n * launcher can render an icon next to the title (#498).\n *\n * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL\n * via raw string manipulation), this function uses WHATWG `encodeURIComponent`\n * because the target is a standard `https:` URL.\n *\n * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param\n * only — never logged or returned separately. Callers must NOT log the result\n * of this function to stdout/stderr.\n *\n * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL\n * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.\n * @param wssUrl - The `wss://` relay URL the framed page will attach to.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is appended as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Omit when TOTP is disabled.\n * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`\n * (#498, #543).\n * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>\n * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.\n */\nexport function buildLauncherAttachUrl(\n tunnelUrl: string,\n wssUrl: string,\n totpCode?: string,\n opts?: LauncherAttachUrlOpts,\n): string {\n let url =\n `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}` +\n `&debug=1&relay=${encodeURIComponent(wssUrl)}`;\n if (totpCode !== undefined && totpCode !== '') {\n url += `&at=${encodeURIComponent(totpCode)}`;\n }\n // App identity hints (#498): add non-blank name and valid https icon.\n if (opts?.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts?.icon !== undefined) {\n let iconParsed: URL;\n try {\n iconParsed = new URL(opts.icon);\n } catch {\n iconParsed = null as unknown as URL;\n }\n if (iconParsed?.protocol === 'https:') {\n url += `&icon=${encodeURIComponent(opts.icon)}`;\n }\n }\n // Self-debug opt-in (#543): add selfdebug=1 only when explicitly requested.\n // Without this flag the output is byte-identical to the previous behaviour.\n if (opts?.selfdebug === true) {\n url += '&selfdebug=1';\n }\n return url;\n}\n\n/**\n * Build a self-attaching dog-food deep-link.\n *\n * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`\n * URL that opens a dog-food bundle on a phone. The in-app debug gate\n * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries\n * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus\n * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result\n * as a QR code and scanning it with the phone camera opens the mini-app and\n * attaches it to the live Chii relay. QR is the single entry path — it needs\n * no USB cable, platform CLI, or driver, and works the same on iOS/Android.\n *\n * The Toss app propagates extra query params from the entry deep link into the\n * mini-app WebView's `location.search` (confirmed behavior), so the gate reads\n * them at attach time.\n *\n * TOTP `at=` param:\n * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional\n * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.\n * The code must be computed by the caller at call time — do NOT pre-compute\n * and cache it, because the 30-second window expires quickly. The in-app gate\n * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.\n *\n * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.\n * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query\n * decomposition you can rely on across runtimes), so query manipulation via\n * `url.searchParams` is not portable here. We splice the query string directly\n * on the raw string instead, which keeps the scheme, authority, path, and any\n * pre-existing params (notably `_deploymentId`) byte-for-byte intact.\n */\n\n/**\n * Suspicious/generic authority values that indicate a malformed or placeholder\n * scheme URL. These are host strings that will almost certainly cause the Toss\n * app to fail with \"bundle not found\" silently.\n *\n * The expected form from `ait deploy --scheme-only` is:\n * intoss-private://<appName>?_deploymentId=<uuid>\n * where `<appName>` is a non-generic string like `aitc-sdk-example`.\n */\nconst SUSPICIOUS_AUTHORITIES = new Set<string>(['', 'web', 'localhost', '127.0.0.1', 'app']);\n\n/**\n * Validates the authority (host) portion of a scheme URL.\n *\n * Returns a warning message if the authority is missing or looks like a\n * placeholder, or `null` if the authority looks valid.\n *\n * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`\n * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).\n */\nexport function validateSchemeAuthority(schemeUrl: string): string | null {\n // Extract authority from `scheme://authority[/path][?query][#hash]`.\n // We cannot use the WHATWG URL parser for non-special schemes reliably\n // (see the deeplink.ts module comment), so we parse the raw string.\n const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\\-.]*:\\/\\//, '');\n if (afterScheme === schemeUrl) {\n // No `://` found — not a scheme URL at all.\n return (\n 'scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). ' +\n 'Use the URL printed by `ait deploy --scheme-only`.'\n );\n }\n\n // authority ends at the first `/`, `?`, `#`, or end of string.\n const authorityEnd = afterScheme.search(/[/?#]/);\n const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);\n\n if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) {\n const displayAuthority = authority === '' ? '(empty)' : `\"${authority}\"`;\n return (\n `scheme_url authority ${displayAuthority} looks like a placeholder. ` +\n 'Expected an app name like `intoss-private://aitc-sdk-example?_deploymentId=<uuid>`. ' +\n 'Use the URL printed by `ait deploy --scheme-only` — it includes the correct app name as the host.'\n );\n }\n\n return null;\n}\n\n/** A param the helper appends. Existing occurrences are replaced, not duplicated. */\ntype AppendParam = readonly [key: string, value: string];\n\nfunction stripExisting(query: string, key: string): string {\n if (query === '') return '';\n return query\n .split('&')\n .filter((pair) => pair !== '' && pair.split('=')[0] !== key)\n .join('&');\n}\n\n/**\n * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a\n * scheme URL's query string, preserving everything else (scheme, authority,\n * path, hash, and the existing `_deploymentId` param). If any of the spliced\n * params is already present it is replaced so the helper is idempotent.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed\n * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B\n * of the gate); this helper does not invent one.\n * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the\n * running debug MCP server's quick tunnel.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Pass `undefined` or omit when TOTP is disabled.\n * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`\n * appended.\n * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so\n * producing such a link would be a silent dead end).\n */\nexport function buildDeepLinkAttachUrl(\n schemeUrl: string,\n wssUrl: string,\n totpCode?: string,\n): string {\n let relay: URL;\n try {\n relay = new URL(wssUrl);\n } catch {\n throw new Error(`relay URL is not a valid URL: ${wssUrl}`);\n }\n if (relay.protocol !== 'wss:') {\n throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);\n }\n\n const hashIndex = schemeUrl.indexOf('#');\n const hash = hashIndex === -1 ? '' : schemeUrl.slice(hashIndex);\n const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);\n\n const queryIndex = beforeHash.indexOf('?');\n const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);\n let query = queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1);\n\n const appended: AppendParam[] = [\n ['debug', '1'],\n ['relay', wssUrl],\n ];\n // Only splice `at=` when a code is provided (TOTP enabled). Omitting it when\n // TOTP is disabled preserves backward compatibility with gate deployments\n // that do not yet evaluate the `at` param.\n if (totpCode !== undefined && totpCode !== '') {\n appended.push(['at', totpCode]);\n }\n\n // Always strip the `at` key from the existing query so a stale code from a\n // previous run is removed even when the caller does not provide a fresh code.\n query = stripExisting(query, 'at');\n\n for (const [key] of appended) {\n query = stripExisting(query, key);\n }\n for (const [key, value] of appended) {\n const pair = `${key}=${encodeURIComponent(value)}`;\n query = query === '' ? pair : `${query}&${pair}`;\n }\n\n return `${base}?${query}${hash}`;\n}\n"],"mappings":";;;;;;;;AAOA,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoErB,SAAgB,uBACd,WACA,QACA,UACA,MACQ;CACR,IAAI,MACF,GAAG,aAAa,OAAO,mBAAmB,UAAU,CAAA,iBAClC,mBAAmB,OAAO;AAC9C,KAAI,aAAa,KAAA,KAAa,aAAa,GACzC,QAAO,OAAO,mBAAmB,SAAS;AAG5C,KAAI,MAAM,SAAS,KAAA,KAAa,KAAK,KAAK,MAAM,KAAK,GACnD,QAAO,SAAS,mBAAmB,KAAK,KAAK,MAAM,CAAC;AAEtD,KAAI,MAAM,SAAS,KAAA,GAAW;EAC5B,IAAI;AACJ,MAAI;AACF,gBAAa,IAAI,IAAI,KAAK,KAAK;UACzB;AACN,gBAAa;;AAEf,MAAI,YAAY,aAAa,SAC3B,QAAO,SAAS,mBAAmB,KAAK,KAAK;;AAKjD,KAAI,MAAM,cAAc,KACtB,QAAO;AAET,QAAO"}
@@ -35,9 +35,10 @@ const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
35
35
  * @param totpCode - Optional current TOTP code (6 digits). When provided, it
36
36
  * is appended as `at=<totpCode>`. Must be computed at call time — it rotates
37
37
  * every 30 s. Omit when TOTP is disabled.
38
- * @param opts - Optional app identity hints: `name` and `icon` (#498).
38
+ * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`
39
+ * (#498, #543).
39
40
  * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>
40
- * [&at=<code>][&name=<enc>][&icon=<enc>]` params.
41
+ * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.
41
42
  */
42
43
  function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
43
44
  let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}&debug=1&relay=${encodeURIComponent(wssUrl)}`;
@@ -52,9 +53,10 @@ function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
52
53
  }
53
54
  if (iconParsed?.protocol === "https:") url += `&icon=${encodeURIComponent(opts.icon)}`;
54
55
  }
56
+ if (opts?.selfdebug === true) url += "&selfdebug=1";
55
57
  return url;
56
58
  }
57
59
  //#endregion
58
60
  exports.buildLauncherAttachUrl = buildLauncherAttachUrl;
59
61
 
60
- //# sourceMappingURL=deeplink-CU6opogq.cjs.map
62
+ //# sourceMappingURL=deeplink-DDOe0FQl.cjs.map