@ait-co/devtools 0.1.101 → 0.1.103

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 (67) hide show
  1. package/README.en.md +0 -27
  2. package/README.md +0 -27
  3. package/dist/{chii-relay-CUS9FJKB.js → chii-relay-DkTOopRj.js} +1 -1
  4. package/dist/{chii-relay-CUS9FJKB.js.map → chii-relay-DkTOopRj.js.map} +1 -1
  5. package/dist/{chii-relay-BVVTS3tE.cjs → chii-relay-P6SKmB1g.cjs} +1 -1
  6. package/dist/{chii-relay-BVVTS3tE.cjs.map → chii-relay-P6SKmB1g.cjs.map} +1 -1
  7. package/dist/{deeplink-D1HXJ2YG.js → deeplink-B5-Hxu0Q.js} +1 -1
  8. package/dist/{deeplink-D1HXJ2YG.js.map → deeplink-B5-Hxu0Q.js.map} +1 -1
  9. package/dist/{deeplink-DDOe0FQl.cjs → deeplink-BzdbA1gV.cjs} +1 -1
  10. package/dist/{deeplink-DDOe0FQl.cjs.map → deeplink-BzdbA1gV.cjs.map} +1 -1
  11. package/dist/{devtools-opener-XpwL3fZ9.js → devtools-opener-B8nxrxqu.js} +2 -12
  12. package/dist/{devtools-opener-XpwL3fZ9.js.map → devtools-opener-B8nxrxqu.js.map} +1 -1
  13. package/dist/{devtools-opener-mDgeg_MX.cjs → devtools-opener-iv1OwfJN.cjs} +1 -1
  14. package/dist/{devtools-opener-mDgeg_MX.cjs.map → devtools-opener-iv1OwfJN.cjs.map} +1 -1
  15. package/dist/mcp/cli.js +20 -65
  16. package/dist/mcp/cli.js.map +1 -1
  17. package/dist/mcp/server.js +1 -1
  18. package/dist/panel/index.js +3 -751
  19. package/dist/panel/index.js.map +1 -1
  20. package/dist/{qr-http-server-Clvk1weS.cjs → qr-http-server-C9YPBo6H.cjs} +13 -58
  21. package/dist/qr-http-server-C9YPBo6H.cjs.map +1 -0
  22. package/dist/{qr-http-server-B1fmICC4.js → qr-http-server-Ck8o4PLI.js} +13 -58
  23. package/dist/qr-http-server-Ck8o4PLI.js.map +1 -0
  24. package/dist/{qr-http-server-ofopTUL-.js → qr-http-server-D09oMVit.js} +13 -58
  25. package/dist/qr-http-server-D09oMVit.js.map +1 -0
  26. package/dist/{qr-http-server-C9NUBysQ.cjs → qr-http-server-DegdwsSj.cjs} +13 -58
  27. package/dist/qr-http-server-DegdwsSj.cjs.map +1 -0
  28. package/dist/{relay-secret-store-J0SUUXjH.js → relay-secret-store-B0DH-8Qb.js} +46 -3
  29. package/dist/relay-secret-store-B0DH-8Qb.js.map +1 -0
  30. package/dist/{relay-secret-store-BvNWdSjV.js → relay-secret-store-Bns5rndt.js} +44 -3
  31. package/dist/relay-secret-store-Bns5rndt.js.map +1 -0
  32. package/dist/{relay-secret-store-B5WAozDv.cjs → relay-secret-store-I5q2Wvvv.cjs} +44 -3
  33. package/dist/relay-secret-store-I5q2Wvvv.cjs.map +1 -0
  34. package/dist/{relay-url-store-RKcao_yG.js → relay-url-store-BPeUZsiY.js} +2 -2
  35. package/dist/{relay-url-store-RKcao_yG.js.map → relay-url-store-BPeUZsiY.js.map} +1 -1
  36. package/dist/{relay-url-store-D2lX9POP.cjs → relay-url-store-CvmnevcO.cjs} +2 -2
  37. package/dist/{relay-url-store-D2lX9POP.cjs.map → relay-url-store-CvmnevcO.cjs.map} +1 -1
  38. package/dist/{relay-url-store-1CXVqNDL.js → relay-url-store-DJHZjk8o.js} +2 -2
  39. package/dist/{relay-url-store-1CXVqNDL.js.map → relay-url-store-DJHZjk8o.js.map} +1 -1
  40. package/dist/{totp-D9fjaVak.cjs → totp-CNw0w89F.cjs} +1 -1
  41. package/dist/{totp-D9fjaVak.cjs.map → totp-CNw0w89F.cjs.map} +1 -1
  42. package/dist/{totp-CauHjkdE.js → totp-DYdP9N3o.js} +1 -1
  43. package/dist/{totp-CauHjkdE.js.map → totp-DYdP9N3o.js.map} +1 -1
  44. package/dist/{tunnel-BmDfjkQI.cjs → tunnel-3RCjGaND.cjs} +6 -6
  45. package/dist/{tunnel-BmDfjkQI.cjs.map → tunnel-3RCjGaND.cjs.map} +1 -1
  46. package/dist/{tunnel-C_qpse3-.js → tunnel-qB2Soaaz.js} +6 -6
  47. package/dist/{tunnel-C_qpse3-.js.map → tunnel-qB2Soaaz.js.map} +1 -1
  48. package/dist/unplugin/index.cjs +5 -90
  49. package/dist/unplugin/index.cjs.map +1 -1
  50. package/dist/unplugin/index.d.cts.map +1 -1
  51. package/dist/unplugin/index.d.ts.map +1 -1
  52. package/dist/unplugin/index.js +5 -90
  53. package/dist/unplugin/index.js.map +1 -1
  54. package/dist/unplugin/tunnel.cjs +1 -1
  55. package/dist/unplugin/tunnel.js +1 -1
  56. package/package.json +1 -1
  57. package/dist/machine-state-Chg_6SPq.js +0 -188
  58. package/dist/machine-state-Chg_6SPq.js.map +0 -1
  59. package/dist/machine-state-DOUweFsJ.cjs +0 -216
  60. package/dist/machine-state-DOUweFsJ.cjs.map +0 -1
  61. package/dist/qr-http-server-B1fmICC4.js.map +0 -1
  62. package/dist/qr-http-server-C9NUBysQ.cjs.map +0 -1
  63. package/dist/qr-http-server-Clvk1weS.cjs.map +0 -1
  64. package/dist/qr-http-server-ofopTUL-.js.map +0 -1
  65. package/dist/relay-secret-store-B5WAozDv.cjs.map +0 -1
  66. package/dist/relay-secret-store-BvNWdSjV.js.map +0 -1
  67. package/dist/relay-secret-store-J0SUUXjH.js.map +0 -1
package/README.en.md CHANGED
@@ -1145,33 +1145,6 @@ Returns the full current mock state (permissions, location, auth, network, IAP,
1145
1145
  | `@ait-co/devtools/in-app` | In-app debug attach — runtime gate (layers B/C) + Chii target.js injection. The consumer wraps the import in `if (__DEBUG_BUILD__)` so it is DCE'd from release builds — dogfood builds only |
1146
1146
  | `@ait-co/devtools/in-app/auto` | Self-gating side-effect entry — a single `import '@ait-co/devtools/in-app/auto'` line wires attach + SDK bridge. Active only when `?debug=1` / `?relay=` are in the URL or it is a DEV build; stays dormant on normal production loads. See the [section above](#on-device-debugging-in-one-line) |
1147
1147
 
1148
- ## Telemetry
1149
-
1150
- devtools uses a two-tier telemetry model.
1151
-
1152
- ### Tier 0 — anonymous usage signal (ON by default, opt-out)
1153
-
1154
- Sends a one-time anonymous ping per calendar day when the panel is opened.
1155
-
1156
- Collected fields: `source`, `version`, `ts` — no PII, no `anon_id`. The server generates an IP+UA daily hash but never stores it.
1157
-
1158
- How to opt out:
1159
- - Panel Environment tab → "Anonymous usage signal (Tier 0)" toggle OFF
1160
- - `localStorage.setItem('__ait_telemetry:t0_off', '1')` (from the browser console)
1161
- - Environment variable: `AITC_TELEMETRY=off`
1162
-
1163
- ### Tier 1 — extended telemetry (OFF by default, opt-in)
1164
-
1165
- A consent toast appears on first panel use. Data is only collected if you accept.
1166
-
1167
- Collected fields: `panel_open`, `tab_view`, `session_duration` events + an anonymous UUID (`anon_id`).
1168
-
1169
- How to opt out:
1170
- - Panel Environment tab → "Extended telemetry (Tier 1)" toggle OFF
1171
- - Delete collected data: Panel Environment tab → "Delete my data"
1172
-
1173
- Privacy policy: <https://docs.aitc.dev/privacy>
1174
-
1175
1148
  ## License
1176
1149
 
1177
1150
  BSD 3-Clause
package/README.md CHANGED
@@ -1185,33 +1185,6 @@ export default {
1185
1185
  | `@ait-co/devtools/in-app` | In-app debug attach — 런타임 gate(layer B·C) + Chii target.js 주입. 소비자가 `if (__DEBUG_BUILD__)`로 import를 감싸 release 빌드에서 DCE — dogfood 빌드 전용 |
1186
1186
  | `@ait-co/devtools/in-app/auto` | Self-gating side-effect entry — `import '@ait-co/devtools/in-app/auto'` 한 줄로 attach + SDK 브리지 설치. URL 파라미터(`?debug=1` / `?relay=`) 또는 DEV 빌드에서만 활성화, 일반 프로덕션 로드는 dormant. [위 섹션](#on-device-디버깅-한-줄-설정) 참고 |
1187
1187
 
1188
- ## 텔레메트리
1189
-
1190
- devtools는 두 단계의 텔레메트리를 사용합니다.
1191
-
1192
- ### Tier 0 — 익명 사용 신호 (기본 ON, opt-out)
1193
-
1194
- 패널이 열릴 때 하루 1회 익명 ping을 전송합니다.
1195
-
1196
- 수집 항목: `source`, `version`, `ts` — PII 없음, `anon_id` 없음. 서버가 IP+UA 기반 daily hash를 생성하지만 저장하지 않습니다.
1197
-
1198
- 끄는 방법:
1199
- - 패널 Environment 탭 → "익명 사용 신호 (Tier 0)" 토글 OFF
1200
- - `localStorage.setItem('__ait_telemetry:t0_off', '1')` (콘솔에서 직접)
1201
- - 환경 변수: `AITC_TELEMETRY=off`
1202
-
1203
- ### Tier 1 — 확장 텔레메트리 (기본 OFF, opt-in)
1204
-
1205
- 패널 최초 실행 시 동의 토스트로 묻습니다. 동의한 경우에만 수집됩니다.
1206
-
1207
- 수집 항목: `panel_open`, `tab_view`, `session_duration` 이벤트 + 익명 UUID(`anon_id`).
1208
-
1209
- 끄는 방법:
1210
- - 패널 Environment 탭 → "확장 텔레메트리 (Tier 1)" 토글 OFF
1211
- - 수집된 데이터 삭제: 패널 Environment 탭 → "내 데이터 삭제"
1212
-
1213
- 개인정보 처리방침: <https://docs.aitc.dev/privacy>
1214
-
1215
1188
  ## 라이센스
1216
1189
 
1217
1190
  BSD 3-Clause
@@ -300,4 +300,4 @@ async function startChiiRelay(options = {}) {
300
300
  //#endregion
301
301
  export { startChiiRelay };
302
302
 
303
- //# sourceMappingURL=chii-relay-CUS9FJKB.js.map
303
+ //# sourceMappingURL=chii-relay-DkTOopRj.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chii-relay-CUS9FJKB.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: two cases are gated, everything else passes through.\n //\n // Case 1 — path-prefixed form (`/at/<code>/…`): the phone fetches\n // `target.js` via `https://<host>/at/<code>/target.js` (issue #466).\n // The prefix is rewritten to the query form and then verified; chii's Koa\n // static handler sees the stripped URL.\n //\n // Case 2 — `/targets` read route (issue #474): without gating this route a\n // URL-leaker (threat model: someone who obtained the tunnel URL but not\n // the secret) can read session metadata (id/url/title, including any query\n // params in the page URL) without a code. Debugger attach stays blocked by\n // the WS gate, but /targets is an HTTP read that was previously ungated.\n // The `at` code may arrive as a query param (`/targets?at=<code>`) — which\n // buildRelayVerifyAuth already handles — or via the `/at/<code>/` path\n // prefix (rewriteAtPathPrefix normalises that to the query form first).\n //\n // Static assets (target.js, chii front-end HTML/JS/CSS) and any other\n // non-prefixed, non-/targets request keep today's ungated pass-through — the\n // phone fetches some via the legacy no-prefix path and gating them would\n // break env-2/3/4.\n //\n // SECRET-HANDLING: We do NOT log req.url or any auth value in this listener.\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n // Path-prefix form: normalise to query form, then verify.\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed (or was rejected above): return so the path-prefix branch\n // never falls through to the /targets check below.\n return;\n }\n\n // Non-prefixed request: check if this is the /targets read route (issue\n // #474). Extract the pathname robustly — req.url is a raw path+query\n // string like `/targets?at=123456` so we split on `?`.\n const pathname = (req.url ?? '').split('?')[0];\n if (pathname === '/targets' || pathname === '/targets/') {\n // The `at` code must be present as a query param — verifyAuth reads it\n // from req.url via URLSearchParams, which already handles `?at=<code>`\n // without any URL rewrite needed.\n if (!verifyAuth(req)) {\n // Same 401 shape as the path-prefix branch (issue #478 contract).\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n // res.end() wins — chii's Koa handler will not write.\n return;\n }\n // Auth passed: return without ending the response. Node invokes every\n // 'request' listener in registration order, so chii's Koa listener\n // (registered later by chii.start) still runs and serves the /targets\n // JSON — this return only means \"this gate listener is done\", not \"end\n // the response\".\n return;\n }\n\n // Any other non-prefixed request (static assets, chii front-end, etc.):\n // ungated pass-through to chii. Auth passed: no-op — chii's Koa\n // 'request' listener (registered below by chii.start) serves the URL.\n // (Koa skips writing when an earlier listener already ended the response,\n // so the 401 paths above are safe even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"chii-relay-DkTOopRj.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: two cases are gated, everything else passes through.\n //\n // Case 1 — path-prefixed form (`/at/<code>/…`): the phone fetches\n // `target.js` via `https://<host>/at/<code>/target.js` (issue #466).\n // The prefix is rewritten to the query form and then verified; chii's Koa\n // static handler sees the stripped URL.\n //\n // Case 2 — `/targets` read route (issue #474): without gating this route a\n // URL-leaker (threat model: someone who obtained the tunnel URL but not\n // the secret) can read session metadata (id/url/title, including any query\n // params in the page URL) without a code. Debugger attach stays blocked by\n // the WS gate, but /targets is an HTTP read that was previously ungated.\n // The `at` code may arrive as a query param (`/targets?at=<code>`) — which\n // buildRelayVerifyAuth already handles — or via the `/at/<code>/` path\n // prefix (rewriteAtPathPrefix normalises that to the query form first).\n //\n // Static assets (target.js, chii front-end HTML/JS/CSS) and any other\n // non-prefixed, non-/targets request keep today's ungated pass-through — the\n // phone fetches some via the legacy no-prefix path and gating them would\n // break env-2/3/4.\n //\n // SECRET-HANDLING: We do NOT log req.url or any auth value in this listener.\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n // Path-prefix form: normalise to query form, then verify.\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed (or was rejected above): return so the path-prefix branch\n // never falls through to the /targets check below.\n return;\n }\n\n // Non-prefixed request: check if this is the /targets read route (issue\n // #474). Extract the pathname robustly — req.url is a raw path+query\n // string like `/targets?at=123456` so we split on `?`.\n const pathname = (req.url ?? '').split('?')[0];\n if (pathname === '/targets' || pathname === '/targets/') {\n // The `at` code must be present as a query param — verifyAuth reads it\n // from req.url via URLSearchParams, which already handles `?at=<code>`\n // without any URL rewrite needed.\n if (!verifyAuth(req)) {\n // Same 401 shape as the path-prefix branch (issue #478 contract).\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n // res.end() wins — chii's Koa handler will not write.\n return;\n }\n // Auth passed: return without ending the response. Node invokes every\n // 'request' listener in registration order, so chii's Koa listener\n // (registered later by chii.start) still runs and serves the /targets\n // JSON — this return only means \"this gate listener is done\", not \"end\n // the response\".\n return;\n }\n\n // Any other non-prefixed request (static assets, chii front-end, etc.):\n // ungated pass-through to chii. Auth passed: no-op — chii's Koa\n // 'request' listener (registered below by chii.start) serves the URL.\n // (Koa skips writing when an earlier listener already ended the response,\n // so the 401 paths above are safe even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":"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"}
@@ -300,4 +300,4 @@ async function startChiiRelay(options = {}) {
300
300
  //#endregion
301
301
  exports.startChiiRelay = startChiiRelay;
302
302
 
303
- //# sourceMappingURL=chii-relay-BVVTS3tE.cjs.map
303
+ //# sourceMappingURL=chii-relay-P6SKmB1g.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"chii-relay-BVVTS3tE.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: two cases are gated, everything else passes through.\n //\n // Case 1 — path-prefixed form (`/at/<code>/…`): the phone fetches\n // `target.js` via `https://<host>/at/<code>/target.js` (issue #466).\n // The prefix is rewritten to the query form and then verified; chii's Koa\n // static handler sees the stripped URL.\n //\n // Case 2 — `/targets` read route (issue #474): without gating this route a\n // URL-leaker (threat model: someone who obtained the tunnel URL but not\n // the secret) can read session metadata (id/url/title, including any query\n // params in the page URL) without a code. Debugger attach stays blocked by\n // the WS gate, but /targets is an HTTP read that was previously ungated.\n // The `at` code may arrive as a query param (`/targets?at=<code>`) — which\n // buildRelayVerifyAuth already handles — or via the `/at/<code>/` path\n // prefix (rewriteAtPathPrefix normalises that to the query form first).\n //\n // Static assets (target.js, chii front-end HTML/JS/CSS) and any other\n // non-prefixed, non-/targets request keep today's ungated pass-through — the\n // phone fetches some via the legacy no-prefix path and gating them would\n // break env-2/3/4.\n //\n // SECRET-HANDLING: We do NOT log req.url or any auth value in this listener.\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n // Path-prefix form: normalise to query form, then verify.\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed (or was rejected above): return so the path-prefix branch\n // never falls through to the /targets check below.\n return;\n }\n\n // Non-prefixed request: check if this is the /targets read route (issue\n // #474). Extract the pathname robustly — req.url is a raw path+query\n // string like `/targets?at=123456` so we split on `?`.\n const pathname = (req.url ?? '').split('?')[0];\n if (pathname === '/targets' || pathname === '/targets/') {\n // The `at` code must be present as a query param — verifyAuth reads it\n // from req.url via URLSearchParams, which already handles `?at=<code>`\n // without any URL rewrite needed.\n if (!verifyAuth(req)) {\n // Same 401 shape as the path-prefix branch (issue #478 contract).\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n // res.end() wins — chii's Koa handler will not write.\n return;\n }\n // Auth passed: return without ending the response. Node invokes every\n // 'request' listener in registration order, so chii's Koa listener\n // (registered later by chii.start) still runs and serves the /targets\n // JSON — this return only means \"this gate listener is done\", not \"end\n // the response\".\n return;\n }\n\n // Any other non-prefixed request (static assets, chii front-end, etc.):\n // ungated pass-through to chii. Auth passed: no-op — chii's Koa\n // 'request' listener (registered below by chii.start) serves the URL.\n // (Koa skips writing when an earlier listener already ended the response,\n // so the 401 paths above are safe even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"chii-relay-P6SKmB1g.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: two cases are gated, everything else passes through.\n //\n // Case 1 — path-prefixed form (`/at/<code>/…`): the phone fetches\n // `target.js` via `https://<host>/at/<code>/target.js` (issue #466).\n // The prefix is rewritten to the query form and then verified; chii's Koa\n // static handler sees the stripped URL.\n //\n // Case 2 — `/targets` read route (issue #474): without gating this route a\n // URL-leaker (threat model: someone who obtained the tunnel URL but not\n // the secret) can read session metadata (id/url/title, including any query\n // params in the page URL) without a code. Debugger attach stays blocked by\n // the WS gate, but /targets is an HTTP read that was previously ungated.\n // The `at` code may arrive as a query param (`/targets?at=<code>`) — which\n // buildRelayVerifyAuth already handles — or via the `/at/<code>/` path\n // prefix (rewriteAtPathPrefix normalises that to the query form first).\n //\n // Static assets (target.js, chii front-end HTML/JS/CSS) and any other\n // non-prefixed, non-/targets request keep today's ungated pass-through — the\n // phone fetches some via the legacy no-prefix path and gating them would\n // break env-2/3/4.\n //\n // SECRET-HANDLING: We do NOT log req.url or any auth value in this listener.\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n // Path-prefix form: normalise to query form, then verify.\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed (or was rejected above): return so the path-prefix branch\n // never falls through to the /targets check below.\n return;\n }\n\n // Non-prefixed request: check if this is the /targets read route (issue\n // #474). Extract the pathname robustly — req.url is a raw path+query\n // string like `/targets?at=123456` so we split on `?`.\n const pathname = (req.url ?? '').split('?')[0];\n if (pathname === '/targets' || pathname === '/targets/') {\n // The `at` code must be present as a query param — verifyAuth reads it\n // from req.url via URLSearchParams, which already handles `?at=<code>`\n // without any URL rewrite needed.\n if (!verifyAuth(req)) {\n // Same 401 shape as the path-prefix branch (issue #478 contract).\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n // res.end() wins — chii's Koa handler will not write.\n return;\n }\n // Auth passed: return without ending the response. Node invokes every\n // 'request' listener in registration order, so chii's Koa listener\n // (registered later by chii.start) still runs and serves the /targets\n // JSON — this return only means \"this gate listener is done\", not \"end\n // the response\".\n return;\n }\n\n // Any other non-prefixed request (static assets, chii front-end, etc.):\n // ungated pass-through to chii. Auth passed: no-op — chii's Koa\n // 'request' listener (registered below by chii.start) serves the URL.\n // (Koa skips writing when an earlier listener already ended the response,\n // so the 401 paths above are safe even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":"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"}
@@ -59,4 +59,4 @@ function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
59
59
  //#endregion
60
60
  export { buildLauncherAttachUrl };
61
61
 
62
- //# sourceMappingURL=deeplink-D1HXJ2YG.js.map
62
+ //# sourceMappingURL=deeplink-B5-Hxu0Q.js.map
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"deeplink-B5-Hxu0Q.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"}
@@ -59,4 +59,4 @@ function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
59
59
  //#endregion
60
60
  exports.buildLauncherAttachUrl = buildLauncherAttachUrl;
61
61
 
62
- //# sourceMappingURL=deeplink-DDOe0FQl.cjs.map
62
+ //# sourceMappingURL=deeplink-BzdbA1gV.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"deeplink-DDOe0FQl.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"}
1
+ {"version":3,"file":"deeplink-BzdbA1gV.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"}
@@ -1,15 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
2
  //#region \0rolldown/runtime.js
3
- var __defProp = Object.defineProperty;
4
- var __exportAll = (all, no_symbols) => {
5
- let target = {};
6
- for (var name in all) __defProp(target, name, {
7
- get: all[name],
8
- enumerable: true
9
- });
10
- if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
11
- return target;
12
- };
13
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
14
4
  //#endregion
15
5
  //#region src/mcp/devtools-opener.ts
@@ -76,6 +66,6 @@ function openUrlInBrowser(url) {
76
66
  return false;
77
67
  }
78
68
  //#endregion
79
- export { isAutoDevtoolsDisabled, openUrlInBrowser, __exportAll as t };
69
+ export { isAutoDevtoolsDisabled, openUrlInBrowser };
80
70
 
81
- //# sourceMappingURL=devtools-opener-XpwL3fZ9.js.map
71
+ //# sourceMappingURL=devtools-opener-B8nxrxqu.js.map