@ait-co/devtools 0.1.55 → 0.1.56

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 (44) hide show
  1. package/README.en.md +62 -11
  2. package/README.md +61 -10
  3. package/dist/chii-relay-57BfqF_5.cjs +88 -0
  4. package/dist/chii-relay-57BfqF_5.cjs.map +1 -0
  5. package/dist/chii-relay-itXOz7kS.js +89 -0
  6. package/dist/chii-relay-itXOz7kS.js.map +1 -0
  7. package/dist/in-app/index.d.ts +45 -12
  8. package/dist/in-app/index.d.ts.map +1 -1
  9. package/dist/in-app/index.js +38 -7
  10. package/dist/in-app/index.js.map +1 -1
  11. package/dist/mcp/cli.d.ts +8 -3
  12. package/dist/mcp/cli.d.ts.map +1 -1
  13. package/dist/mcp/cli.js +782 -226
  14. package/dist/mcp/cli.js.map +1 -1
  15. package/dist/mcp/server.js +18 -13
  16. package/dist/mcp/server.js.map +1 -1
  17. package/dist/panel/index.js +2 -2
  18. package/dist/totp-BkKP4m8H.cjs +185 -0
  19. package/dist/totp-BkKP4m8H.cjs.map +1 -0
  20. package/dist/totp-CxHsagqY.js +184 -0
  21. package/dist/totp-CxHsagqY.js.map +1 -0
  22. package/dist/{tunnel-D0_TwDNE.js → tunnel-Cj8g1LIL.js} +12 -4
  23. package/dist/tunnel-Cj8g1LIL.js.map +1 -0
  24. package/dist/{tunnel-BYP0yRBN.cjs → tunnel-p-q6eVWT.cjs} +12 -4
  25. package/dist/tunnel-p-q6eVWT.cjs.map +1 -0
  26. package/dist/unplugin/index.cjs +29 -3
  27. package/dist/unplugin/index.cjs.map +1 -1
  28. package/dist/unplugin/index.d.cts +11 -0
  29. package/dist/unplugin/index.d.cts.map +1 -1
  30. package/dist/unplugin/index.d.ts +12 -1
  31. package/dist/unplugin/index.d.ts.map +1 -1
  32. package/dist/unplugin/index.js +29 -3
  33. package/dist/unplugin/index.js.map +1 -1
  34. package/dist/unplugin/tunnel.cjs +11 -3
  35. package/dist/unplugin/tunnel.cjs.map +1 -1
  36. package/dist/unplugin/tunnel.d.cts +13 -1
  37. package/dist/unplugin/tunnel.d.cts.map +1 -1
  38. package/dist/unplugin/tunnel.d.ts +13 -1
  39. package/dist/unplugin/tunnel.d.ts.map +1 -1
  40. package/dist/unplugin/tunnel.js +11 -3
  41. package/dist/unplugin/tunnel.js.map +1 -1
  42. package/package.json +2 -2
  43. package/dist/tunnel-BYP0yRBN.cjs.map +0 -1
  44. package/dist/tunnel-D0_TwDNE.js.map +0 -1
package/README.en.md CHANGED
@@ -47,6 +47,8 @@ pnpm dev:phone # same as AIT_TUNNEL=1 pnpm dev
47
47
  # QR appears in the terminal → scan with your phone camera → opens in the launcher PWA
48
48
  ```
49
49
 
50
+ With `tunnel: { cdp: true }`, a single QR scan opens both the screen preview and on-device CDP — inspect the real WebKit DOM, console, and exceptions from your MCP host (`call_sdk` still hits the mock on environment 2; the real SDK lives on environments 3·4).
51
+
50
52
  One-time prerequisite: add `https://devtools.aitc.dev/launcher/` to your phone's home screen. Details: [`docs/scenarios/env-2.md`](./docs/scenarios/env-2.md)
51
53
 
52
54
  ---
@@ -70,12 +72,14 @@ No HMR (Toss WebView cold-load only). Details: [`docs/scenarios/env-3.md`](./doc
70
72
  Attach a relay to a live OPENED app to observe runtime behavior.
71
73
 
72
74
  ```bash
73
- MCP_ENV=relay-live devtools-mcp # start MCP server (LIVE guard enabled)
75
+ devtools-mcp # start MCP server
76
+ # In Claude Code: start_debug({mode: 'live', confirm: true}) ← arms LIVE guard
74
77
  # call build_attach_url → scan QR → live app loads + relay attaches
75
78
  # call_sdk / evaluate: confirm: true required (LIVE guard — real users affected)
79
+ # (deprecated alias: MCP_ENV=relay-live devtools-mcp — kept for boot-time liveIntent seeding only)
76
80
  ```
77
81
 
78
- `MCP_ENV=relay-live` is required without it the LIVE side-effect guard is inactive and SDK calls can affect real users. Details: [`docs/scenarios/env-4.md`](./docs/scenarios/env-4.md)
82
+ `start_debug({mode: 'live', confirm: true})` arms the LIVE guard in-session. Details: [`docs/scenarios/env-4.md`](./docs/scenarios/env-4.md)
79
83
 
80
84
  ---
81
85
 
@@ -248,7 +252,7 @@ module.exports = {
248
252
  | `forceEnable` | `boolean` | `false` | Enable devtools even in production |
249
253
  | `mock` | `boolean` | `true` (dev) / `false` (prod+forceEnable) | Enable mock alias |
250
254
  | `mcp` | `boolean` | `false` | Add an MCP state endpoint to the Vite dev server (Vite only — see [MCP Server](#mcp-server)) |
251
- | `tunnel` | `boolean \| { port?: number; qr?: boolean }` | `false` | Expose the Vite dev server via a Cloudflare quick tunnel for real-device preview (see [below](#run-on-a-real-phone)). **Vite dev mode only** |
255
+ | `tunnel` | `boolean \| { port?: number; qr?: boolean; cdp?: boolean }` | `false` | Expose the Vite dev server via a Cloudflare quick tunnel for real-device preview (see [below](#run-on-a-real-phone)). `cdp: true` also wires on-device CDP debugging for environment 2 (PWA). **Vite dev mode only** |
252
256
 
253
257
  ```ts
254
258
  aitDevtools.vite({ panel: false }); // mock only, no panel
@@ -256,6 +260,7 @@ aitDevtools.vite({ forceEnable: true }); // enable in production (mock OFF by de
256
260
  aitDevtools.vite({ forceEnable: true, mock: true }); // enable mock in production too
257
261
  aitDevtools.vite({ mcp: true }); // enable MCP endpoint for AI agents
258
262
  aitDevtools.vite({ tunnel: true }); // expose dev server at *.trycloudflare.com
263
+ aitDevtools.vite({ tunnel: { cdp: true } }); // real-device preview + on-device CDP debugging
259
264
  ```
260
265
 
261
266
  ## Production builds
@@ -324,6 +329,8 @@ export default defineConfig({
324
329
 
325
330
  > `process.env.AIT_TUNNEL` is evaluated when `vite.config.ts` is loaded (i.e. when the vite process starts). The env variable must therefore be set **before** vite launches (the `dev:phone` script in step (c) handles this automatically).
326
331
 
332
+ > To also enable on-device CDP debugging, pass the object form: `tunnel: process.env.AIT_TUNNEL ? { cdp: true } : false`. A Chii relay then starts alongside the HTTP tunnel, so a single QR scan opens both the screen preview and a CDP attach. Connect your AI host MCP to that relay to inspect the real WebKit DOM, console, exceptions, and `measure_safe_area` (`call_sdk` still hits the mock on environment 2).
333
+
327
334
  (b) **Allow the pnpm 10+ build script** — pnpm blocks dependency postinstall scripts by default for security. `cloudflared` downloads its binary (~38 MB) in postinstall, so you need to explicitly allow it:
328
335
 
329
336
  ```json
@@ -938,14 +945,58 @@ AI coding agents (Claude Code, Cursor, etc.) can observe a running mini-app dire
938
945
 
939
946
  A local browser (env 1) and a phone Toss WebView (env 2/3) both speak CDP, so every tool works identically in both environments — the only difference is the attach strategy (`--target=relay` vs `--target=local`).
940
947
 
941
- | Mode + target | Invocation | Env var | Target | Tools |
948
+ | Mode + target | Invocation | Env vars | Target | Tools |
942
949
  |---|---|---|---|---|
943
- | `--mode=debug --target=relay` (default) | `MCP_ENV=relay-dev devtools-mcp` | `MCP_ENV=relay-dev` recommended (env 3, dogfood) | Dogfood bundle on a phone (CDP/Chii relay + cloudflared tunnel, env 3) | console/network/page + DOM/snapshot/screenshot + `AIT.*` |
944
- | `--mode=debug --target=relay` LIVE | `MCP_ENV=relay-live devtools-mcp` | `MCP_ENV=relay-live` **required** (env 4, LIVE guard enabled) | Live deployed app (env 4) `call_sdk`/`evaluate` require `confirm: true` | same |
945
- | `--mode=debug --target=local` | `devtools-mcp --target=local` | `MCP_ENV=mock` (auto) | Local Chromium launched by the MCP server (CDP direct-attach, no relay needed, env 1) | same |
950
+ | `--target=mobile` (env 2) | `devtools-mcp` `start_debug({mode:'mobile'})` | `AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL` | Real-device Safari/WebKit PWA (external Chii relay + cloudflared tunnel, env 2) | console/network/page + DOM/snapshot/screenshot |
951
+ | `--mode=debug --target=relay` (default, env 3) | `devtools-mcp` → `start_debug({mode: 'staging'})` | `MCP_ENV=relay-dev` (deprecated boot alias) | Dogfood bundle on a phone (CDP/Chii relay + cloudflared tunnel, env 3) | same + `AIT.*` |
952
+ | `--mode=debug --target=relay` LIVE (env 4) | `devtools-mcp` `start_debug({mode: 'live', confirm: true})` | `MCP_ENV=relay-live` (deprecated boot alias, env 4 LIVE guard) | Live deployed app (env 4) `call_sdk`/`evaluate` require `confirm: true` | same |
953
+ | `--mode=debug --target=local` (env 1) | `devtools-mcp --target=local` | `MCP_ENV=mock` (auto) | Local Chromium launched by the MCP server (CDP direct-attach, no relay needed, env 1) | same |
946
954
  | `--mode=dev` | `devtools-mcp --mode=dev` | `MCP_ENV=mock` (auto) | Mock state from a running Vite dev server (AIT.* only, no CDP) | `AIT.*` (+ `devtools_get_mock_state` alias) |
947
955
 
948
- `--target=local` opens `AIT_DEVTOOLS_URL` (default `http://localhost:5173`) and attaches directly to a local Chromium — no relay or tunnel required. `--mode=dev` reads the mock-state HTTP endpoint of the Vite dev server and does not provide CDP tools. For on-device sessions (env 3), setting `MCP_ENV=relay-dev` explicitly ensures the relay tool surface is visible before the tunnel URL is auto-detected. For env 4 (LIVE), `MCP_ENV=relay-live` is required only this value activates the LIVE side-effect guard that protects real users.
956
+ `--target=local` opens `AIT_DEVTOOLS_URL` (default `http://localhost:5173`) and attaches directly to a local Chromium — no relay or tunnel required. `--mode=dev` reads the mock-state HTTP endpoint of the Vite dev server and does not provide CDP tools. Switch environments in-session with `start_debug(mode)`: `mobile` (env 2 PWA), `staging` (env 3 dogfood), `live` (env 4, arms LIVE guard `confirm: true` required), `local` (env 1). `MCP_ENV=relay-dev`/`MCP_ENV=relay-live` are deprecated boot-time aliases for liveIntent seeding prefer `start_debug` for in-session switching.
957
+
958
+ #### Environment 2 (real-device PWA CDP) — `--target=mobile`
959
+
960
+ Debug on a real phone using Safari/WebKit without Toss review. The Vite dev server with [`tunnel:{cdp:true}`](#tunnel-option) brings up both an app HTTP tunnel and a Chii relay tunnel. The MCP server attaches to that relay and provides `build_attach_url` → launcher QR.
961
+
962
+ **Setup procedure:**
963
+
964
+ 1. Start the Vite dev server in CDP tunnel mode:
965
+ ```bash
966
+ AIT_TUNNEL_CDP=1 pnpm exec vite --config e2e/fixture/vite.config.ts
967
+ ```
968
+ The terminal banner prints two URLs:
969
+ - **App HTTP tunnel** `https://<A>.trycloudflare.com` → set as `AIT_TUNNEL_BASE_URL`
970
+ - **Relay wss tunnel** `wss://<B>.trycloudflare.com` → set `AIT_RELAY_BASE_URL` to its `https://` form
971
+
972
+ 2. Start the MCP server in mobile mode (separate terminal):
973
+ ```json
974
+ {
975
+ "mcpServers": {
976
+ "ait-debug": {
977
+ "command": "npx",
978
+ "args": ["-y", "@ait-co/devtools", "devtools-mcp"],
979
+ "env": {
980
+ "AIT_RELAY_BASE_URL": "https://<B>.trycloudflare.com",
981
+ "AIT_TUNNEL_BASE_URL": "https://<A>.trycloudflare.com"
982
+ }
983
+ }
984
+ }
985
+ }
986
+ ```
987
+
988
+ 3. In a Claude Code session:
989
+ ```
990
+ start_debug({mode: 'mobile'})
991
+ build_attach_url()
992
+ ```
993
+ Scan the QR with your phone camera. The launcher PWA opens the app in a frame and injects Chii target.js.
994
+
995
+ 4. `list_pages()` → expect one page. Use `take_screenshot()` and other CDP tools.
996
+
997
+ **Env 2 fidelity boundary**: uses the mock SDK (`call_sdk` hits the mock). For real SDK fidelity, move to env 3. CDP runs on the real WebKit engine, so DOM, console, and screenshot reflect the real device screen.
998
+
999
+ **Local-PC verification**: `e2e/launcher-cdp.test.ts` automates node-side relay startup (`startChiiRelay({port:0})`) and launcher param forwarding (Playwright). Browser-side Chii target.js injection is not automated in CI due to the localhost host gate (Layer B1) and ws:// vs wss:// constraints — completed by the manual procedure above on a real device with a trycloudflare.com hostname.
949
1000
 
950
1001
  ### Debug mode (CDP via Chii)
951
1002
 
@@ -955,7 +1006,7 @@ Read-only tools only. Tools are registered in two tiers based on attach state
955
1006
 
956
1007
  Running `devtools-mcp` as a stdio server starts a local Chii relay on an OS-assigned port and opens a cloudflared quick tunnel, printing a public `wss://*.trycloudflare.com` URL and a QR code in the terminal (secrets/auth codes are never printed). When the phone enters the dogfood entry point, the in-app attach UI connects to the relay with that URL, and the agent reads console/network/page state via `chrome-devtools-mcp`-compatible tools — diagnosing regressions without anyone watching the phone.
957
1008
 
958
- Environment 3 (dogfood relay):
1009
+ Environment 3 (dogfood relay) — `start_debug({mode: 'staging'})` is the primary in-session entry:
959
1010
 
960
1011
  ```json
961
1012
  {
@@ -971,7 +1022,7 @@ Environment 3 (dogfood relay):
971
1022
  }
972
1023
  ```
973
1024
 
974
- Environment 4 (LIVE relay, LIVE guard enabled):
1025
+ Environment 4 (LIVE relay, LIVE guard enabled) — `start_debug({mode: 'live', confirm: true})` is the primary in-session entry:
975
1026
 
976
1027
  ```json
977
1028
  {
@@ -987,7 +1038,7 @@ Environment 4 (LIVE relay, LIVE guard enabled):
987
1038
  }
988
1039
  ```
989
1040
 
990
- Setting `MCP_ENV=relay-dev` explicitly ensures the relay tool surface is visible before the tunnel URL is auto-detected. `MCP_ENV=relay-live` activates the LIVE side-effect guard any `call_sdk`/`evaluate` call without `confirm: true` is rejected to protect real users. `MCP_ENV=relay` is a backward-compat alias for `relay-dev`, so **always use `relay-live` explicitly for env 4**.
1041
+ `MCP_ENV=relay-dev`/`MCP_ENV=relay-live` are deprecated boot-time aliases that ensure the relay tool surface is visible before the tunnel URL is auto-detected and seed liveIntent on boot. **Prefer `start_debug(mode)` for in-session switching**: `staging` (env 3), `live` (env 4, arms LIVE guard — `confirm: true` required). `MCP_ENV=relay` is a backward-compat alias for `relay-dev`, so **always use `relay-live` (or `start_debug(live)`) explicitly for env 4**.
991
1042
 
992
1043
  | Tool | CDP / AIT backing | Description |
993
1044
  |---|---|---|
package/README.md CHANGED
@@ -47,6 +47,8 @@ pnpm dev:phone # AIT_TUNNEL=1 pnpm dev 와 동일
47
47
  # 터미널에 QR 출력 → 폰 카메라로 스캔 → launcher PWA에서 자동 열림
48
48
  ```
49
49
 
50
+ `tunnel: { cdp: true }`를 켜면 같은 QR 한 번으로 화면 미리보기 + on-device CDP가 함께 열려 실기기 WebKit의 DOM·콘솔·예외를 MCP로 관측합니다 (`call_sdk`는 환경 2에서 mock — 실 SDK는 환경 3·4).
51
+
50
52
  사전: 폰에 `https://devtools.aitc.dev/launcher/` 를 홈 화면에 한 번 추가. 상세: [`docs/scenarios/env-2.md`](./docs/scenarios/env-2.md)
51
53
 
52
54
  ---
@@ -70,12 +72,14 @@ HMR 없음(토스 WebView cold-load만). 상세: [`docs/scenarios/env-3.md`](./d
70
72
  검수를 통과하고 OPENED 상태인 실 출시 앱에 relay를 붙여 런타임을 관측합니다.
71
73
 
72
74
  ```bash
73
- MCP_ENV=relay-live devtools-mcp # MCP 서버 시작 (LIVE guard 활성화)
75
+ devtools-mcp # MCP 서버 시작
76
+ # Claude Code에서: start_debug({mode: 'live', confirm: true}) ← LIVE guard 활성화
74
77
  # build_attach_url 호출 → QR 스캔 → LIVE 앱 로드 + relay attach
75
78
  # call_sdk / evaluate 는 confirm: true 필수 (LIVE guard — 실유저 영향)
79
+ # (deprecated 별칭: MCP_ENV=relay-live devtools-mcp — 부팅 시 liveIntent 시드용으로만 남음)
76
80
  ```
77
81
 
78
- `MCP_ENV=relay-live` 필수 미설정 시 LIVE side-effect guard 비활성화되어 실유저에게 영향을 줄 수 있습니다. 상세: [`docs/scenarios/env-4.md`](./docs/scenarios/env-4.md)
82
+ `start_debug({mode: 'live', confirm: true})`가 LIVE guard 활성화합니다. 상세: [`docs/scenarios/env-4.md`](./docs/scenarios/env-4.md)
79
83
 
80
84
  ---
81
85
 
@@ -248,7 +252,7 @@ module.exports = {
248
252
  | `forceEnable` | `boolean` | `false` | production에서도 devtools 활성화 |
249
253
  | `mock` | `boolean` | `true` (dev) / `false` (prod+forceEnable) | mock alias 활성화 여부 |
250
254
  | `mcp` | `boolean` | `false` | Vite dev server에 MCP state endpoint 추가 (Vite 전용, [MCP 섹션](#mcp-server) 참조) |
251
- | `tunnel` | `boolean \| { port?: number; qr?: boolean }` | `false` | Vite dev 서버를 Cloudflare quick tunnel로 노출 (실기기 미리보기, [아래](#run-on-a-real-phone-실기기-미리보기) 참고). **Vite dev 모드 전용** |
255
+ | `tunnel` | `boolean \| { port?: number; qr?: boolean; cdp?: boolean }` | `false` | Vite dev 서버를 Cloudflare quick tunnel로 노출 (실기기 미리보기, [아래](#run-on-a-real-phone-실기기-미리보기) 참고). `cdp: true`면 환경 2 PWA에 on-device CDP 디버깅도 배선. **Vite dev 모드 전용** |
252
256
 
253
257
  ```ts
254
258
  aitDevtools.vite({ panel: false }); // Panel 없이 mock만 사용
@@ -256,6 +260,7 @@ aitDevtools.vite({ forceEnable: true }); // production에서도 활성화 (mock
256
260
  aitDevtools.vite({ forceEnable: true, mock: true }); // production에서 mock도 활성화
257
261
  aitDevtools.vite({ mcp: true }); // AI 에이전트용 MCP endpoint 활성화
258
262
  aitDevtools.vite({ tunnel: true }); // dev 서버를 *.trycloudflare.com으로 노출
263
+ aitDevtools.vite({ tunnel: { cdp: true } }); // 실기기 미리보기 + on-device CDP 디버깅
259
264
  ```
260
265
 
261
266
  ## Production 빌드
@@ -324,6 +329,8 @@ export default defineConfig({
324
329
 
325
330
  > `process.env.AIT_TUNNEL`은 `vite.config.ts`를 로드하는 시점(= vite 프로세스 기동 시)에 평가됩니다. 따라서 env 변수는 **vite를 띄우기 전에** 설정되어 있어야 합니다 (아래 (c)의 `dev:phone` 스크립트가 이를 자동으로 해결합니다).
326
331
 
332
+ > on-device CDP 디버깅까지 켜려면 `tunnel: process.env.AIT_TUNNEL ? { cdp: true } : false`처럼 객체 형태로 줍니다. 그러면 HTTP 터널과 별도로 Chii relay가 떠서, QR 한 번으로 화면 미리보기와 CDP attach가 동시에 열립니다. AI host MCP를 그 relay에 붙이면 실기기 WebKit의 DOM·콘솔·예외·`measure_safe_area`를 관측합니다 (`call_sdk`는 환경 2에서 mock).
333
+
327
334
  (b) **`package.json`에 pnpm 10+ 빌드 스크립트 허용** — pnpm은 보안상 dependency의 postinstall을 기본 차단합니다. `cloudflared`는 postinstall에서 바이너리(~38 MB)를 받으므로 명시 허용 필요:
328
335
 
329
336
  ```json
@@ -970,12 +977,56 @@ AI 코딩 에이전트(Claude Code, Cursor 등)가 [MCP(Model Context Protocol)]
970
977
 
971
978
  | 모드 + 타깃 | 호출 | 환경 변수 | 대상 | tool |
972
979
  |---|---|---|---|---|
973
- | `--mode=debug --target=relay` (기본값) | `MCP_ENV=relay-dev devtools-mcp` | `MCP_ENV=relay-dev` 권장 (환경 3, dogfood) | dogfood 번들 (CDP/Chii relay + cloudflared 터널, 환경 3) | console/network/page + DOM/snapshot/screenshot + `AIT.*` |
974
- | `--mode=debug --target=relay` LIVE | `MCP_ENV=relay-live devtools-mcp` | `MCP_ENV=relay-live` **필수** (환경 4, LIVE guard 활성화) | LIVE 배포 (환경 4) `call_sdk`/`evaluate`에 `confirm: true` 필요 | 동일 |
975
- | `--mode=debug --target=local` | `devtools-mcp --target=local` | `MCP_ENV=mock` (자동) | MCP가 직접 기동한 로컬 Chromium (CDP direct-attach, relay 불필요, 환경 1) | 동일 |
980
+ | `--target=mobile` (env 2) | `devtools-mcp` `start_debug({mode:'mobile'})` | `AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL` | 실기기 Safari/WebKit PWA (외부 Chii relay + cloudflared 터널, 환경 2) | console/network/page + DOM/snapshot/screenshot |
981
+ | `--mode=debug --target=relay` (기본값, env 3) | `devtools-mcp` → `start_debug({mode: 'staging'})` | `MCP_ENV=relay-dev` (deprecated 부팅 별칭) | dogfood 번들 (CDP/Chii relay + cloudflared 터널, 환경 3) | 동일 + `AIT.*` |
982
+ | `--mode=debug --target=relay` LIVE (env 4) | `devtools-mcp` `start_debug({mode: 'live', confirm: true})` | `MCP_ENV=relay-live` (deprecated 부팅 별칭, **환경 4 LIVE guard**) | LIVE 배포 (환경 4) — `call_sdk`/`evaluate`에 `confirm: true` 필요 | 동일 |
983
+ | `--mode=debug --target=local` (env 1) | `devtools-mcp --target=local` | `MCP_ENV=mock` (자동) | MCP가 직접 기동한 로컬 Chromium (CDP direct-attach, relay 불필요, 환경 1) | 동일 |
976
984
  | `--mode=dev` | `devtools-mcp --mode=dev` | `MCP_ENV=mock` (자동) | 실행 중인 Vite dev server의 mock state (AIT.* 전용, CDP 없음) | `AIT.*` (+ `devtools_get_mock_state` alias) |
977
985
 
978
- `--target=local`은 `AIT_DEVTOOLS_URL`(기본 `http://localhost:5173`)을 열고 로컬 Chromium에 CDP direct-attach합니다 — relay나 터널이 필요하지 않습니다. `--mode=dev`는 Vite dev server의 mock-state HTTP endpoint를 읽으며 CDP tool은 제공하지 않습니다. 실기기(환경 3) 진입 `MCP_ENV=relay-dev`를 명시하면 터널 감지 전에도 relay tool이 올바르게 노출됩니다. 환경 4(LIVE) `MCP_ENV=relay-live` 필수 LIVE side-effect guard(실유저 보호)가 값일 때만 활성화됩니다.
986
+ `--target=local`은 `AIT_DEVTOOLS_URL`(기본 `http://localhost:5173`)을 열고 로컬 Chromium에 CDP direct-attach합니다 — relay나 터널이 필요하지 않습니다. `--mode=dev`는 Vite dev server의 mock-state HTTP endpoint를 읽으며 CDP tool은 제공하지 않습니다. 세션 내 환경 전환은 `start_debug(mode)` 번으로 처리됩니다: `mobile`(env 2 PWA), `staging`(env 3 dogfood), `live`(env 4 LIVE guard 활성화, `confirm: true` 필수), `local`(env 1). `MCP_ENV=relay-dev`/`MCP_ENV=relay-live`는 부팅 liveIntent 시드용 deprecated 별칭 세션에서는 `start_debug`로 전환하세요.
987
+
988
+ #### 환경 2 (실기기 PWA CDP) — `--target=mobile`
989
+
990
+ 토스 검수 없이 실기기 WebKit 엔진에서 CDP 디버깅이 가능한 모드입니다. [`tunnel:{cdp:true}`](#tunnel-옵션)를 켠 Vite dev server가 앱 HTTP 터널과 Chii relay 터널을 두 개 띄우고, MCP는 그 relay에 붙어 `build_attach_url` → 런처 QR을 제공합니다.
991
+
992
+ **진입 절차:**
993
+
994
+ 1. Vite dev server를 CDP 터널 모드로 기동:
995
+ ```bash
996
+ AIT_TUNNEL_CDP=1 pnpm exec vite --config e2e/fixture/vite.config.ts
997
+ ```
998
+ 터미널 배너에 두 URL이 출력됩니다:
999
+ - **앱 HTTP 터널** `https://<A>.trycloudflare.com` → `AIT_TUNNEL_BASE_URL`로 설정
1000
+ - **relay wss 터널** `wss://<B>.trycloudflare.com` → `AIT_RELAY_BASE_URL`의 `https://` 형으로 설정
1001
+
1002
+ 2. MCP server를 mobile 모드로 기동 (별도 터미널):
1003
+ ```json
1004
+ {
1005
+ "mcpServers": {
1006
+ "ait-debug": {
1007
+ "command": "npx",
1008
+ "args": ["-y", "@ait-co/devtools", "devtools-mcp"],
1009
+ "env": {
1010
+ "AIT_RELAY_BASE_URL": "https://<B>.trycloudflare.com",
1011
+ "AIT_TUNNEL_BASE_URL": "https://<A>.trycloudflare.com"
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+ ```
1017
+
1018
+ 3. Claude Code 세션에서 진입:
1019
+ ```
1020
+ start_debug({mode: 'mobile'})
1021
+ build_attach_url()
1022
+ ```
1023
+ QR을 폰 카메라로 스캔하면 런처 PWA가 앱을 프레임에 열고 Chii target.js를 주입합니다.
1024
+
1025
+ 4. `list_pages()` → 페이지 1개 확인. `take_screenshot()` 등 CDP tool을 사용합니다.
1026
+
1027
+ **env 2의 fidelity 경계**: SDK mock을 씁니다 (실 SDK 호출 불가) — `call_sdk`는 환경 2에서 mock을 칩니다. 실 SDK fidelity가 필요하면 환경 3으로 올라가세요. CDP는 실 WebKit 엔진 위에서 동작하므로 DOM·console·screenshot은 실기기 화면을 그대로 반영합니다.
1028
+
1029
+ **로컬 PC 검증**: `e2e/launcher-cdp.test.ts`가 node-side relay 기동(`startChiiRelay({port:0})`)과 launcher 파라미터 포워딩(Playwright)을 자동 검증합니다. browser-side Chii target.js 주입은 localhost 호스트 게이트(Layer B1)와 ws:// vs wss:// 제약으로 CI에서 검증 불가 — 위 수동 절차(실기기 trycloudflare.com 호스트)에서 완성됩니다.
979
1030
 
980
1031
  ### Debug 모드 (CDP via Chii)
981
1032
 
@@ -993,7 +1044,7 @@ tunnel로 공개 `wss://*.trycloudflare.com` URL을 발급한 뒤 QR을 터미
993
1044
  에이전트가 `chrome-devtools-mcp` 호환 tool로 console/network/page 상태를 read합니다. 사람이 폰을
994
1045
  지켜볼 필요 없이 회귀를 단독 진단하는 것이 목표입니다.
995
1046
 
996
- 환경 3 (dogfood relay):
1047
+ 환경 3 (dogfood relay) — `start_debug({mode: 'staging'})`이 세션 내 1차 진입:
997
1048
 
998
1049
  ```json
999
1050
  {
@@ -1009,7 +1060,7 @@ tunnel로 공개 `wss://*.trycloudflare.com` URL을 발급한 뒤 QR을 터미
1009
1060
  }
1010
1061
  ```
1011
1062
 
1012
- 환경 4 (LIVE relay, LIVE guard 활성화):
1063
+ 환경 4 (LIVE relay, LIVE guard 활성화) — `start_debug({mode: 'live', confirm: true})`이 세션 내 1차 진입:
1013
1064
 
1014
1065
  ```json
1015
1066
  {
@@ -1025,7 +1076,7 @@ tunnel로 공개 `wss://*.trycloudflare.com` URL을 발급한 뒤 QR을 터미
1025
1076
  }
1026
1077
  ```
1027
1078
 
1028
- `MCP_ENV=relay-dev`를 명시하면 터널 URL 자동 감지 전에도 relay tool surface 올바르게 노출됩니다. `MCP_ENV=relay-live`는 LIVE side-effect guard 활성화합니다 — `call_sdk`/`evaluate`에 `confirm: true`가 없으면 거부하여 실유저 영향을 방지합니다. `MCP_ENV=relay`는 backward-compat alias로 `relay-dev`로 해석되므로 **환경 4에서는 `relay-live`를 명시해야 합니다**.
1079
+ `MCP_ENV=relay-dev`/`MCP_ENV=relay-live`는 부팅 env 힌트로 relay tool surface 노출을 앞당기는 deprecated 별칭입니다. **세션 내 환경 전환은 `start_debug(mode)` 사용이 1차 경로**: `staging`(env 3), `live`(env 4, LIVE guard 활성화 — `confirm: true` 필수). `MCP_ENV=relay`는 backward-compat alias로 `relay-dev`로 해석되므로 **환경 4에서는 `relay-live` 별칭(또는 `start_debug(live)`)을 명시해야 합니다**.
1029
1080
 
1030
1081
  | Tool | CDP / AIT 백킹 | 설명 |
1031
1082
  |---|---|---|
@@ -0,0 +1,88 @@
1
+ let node_http = require("node:http");
2
+ //#region src/mcp/chii-relay.ts
3
+ /**
4
+ * Boots the local Chii relay server.
5
+ *
6
+ * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome
7
+ * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.
8
+ * The relay accepts a `target` websocket from the phone's injected `target.js`
9
+ * and `client` websockets from CDP frontends (our MCP connection).
10
+ *
11
+ * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app
12
+ * entries.
13
+ *
14
+ * TOTP auth (relay-side, authoritative gate):
15
+ * When `verifyAuth` is provided, this module registers an HTTP upgrade
16
+ * listener on the server BEFORE calling `chii.start({server})`. Node's
17
+ * `http.Server` allows multiple 'upgrade' listeners; the first to call
18
+ * `socket.destroy()` wins. Invalid auth → 401 + destroy (chii never sees
19
+ * the connection). Valid auth → return without side-effect (chii handles it).
20
+ *
21
+ * Threat model: "URL leak" — someone obtains the tunnel URL (Slack paste, QR
22
+ * screenshot, shoulder-surfing) but does not have the shared TOTP secret.
23
+ * Rotating 6-digit code makes the URL stale after 30 s.
24
+ * A determined attacker who extracts the secret from the dogfood bundle can
25
+ * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).
26
+ *
27
+ * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear
28
+ * in any log, error message, or process output. `verifyAuth` is a black-box
29
+ * predicate from the caller's perspective; this module only forwards pass/fail.
30
+ */
31
+ const require$1 = (0, require("node:module").createRequire)(require("url").pathToFileURL(__filename).href);
32
+ function loadChiiServer() {
33
+ const mod = require$1("chii");
34
+ if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
35
+ throw new Error("chii server module did not expose start()");
36
+ }
37
+ /**
38
+ * Starts the Chii relay and resolves once listening.
39
+ *
40
+ * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral
41
+ * port on every start, so a stale cloudflared orphan holding any particular
42
+ * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`
43
+ * always reflect the actual bound port.
44
+ *
45
+ * chii.start() is called with `server` (our pre-created httpServer) BEFORE
46
+ * httpServer.listen(). This is intentional: chii attaches its Koa handler and
47
+ * WS upgrade listener to the server object, but the actual TCP bind is
48
+ * performed by our httpServer.listen() call below. The `port`/`domain` values
49
+ * passed to chii.start() are used for display/banner purposes inside chii and
50
+ * do not affect which port the server binds. The connection path (clients
51
+ * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.
52
+ */
53
+ async function startChiiRelay(options = {}) {
54
+ const requestedPort = options.port ?? 0;
55
+ const host = options.host ?? "127.0.0.1";
56
+ const { verifyAuth } = options;
57
+ const httpServer = (0, node_http.createServer)();
58
+ if (verifyAuth) httpServer.on("upgrade", (req, socket) => {
59
+ if (!verifyAuth(req)) {
60
+ socket.write("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n");
61
+ socket.destroy();
62
+ return;
63
+ }
64
+ });
65
+ await loadChiiServer().start({
66
+ server: httpServer,
67
+ domain: `${host}:${requestedPort}`,
68
+ port: requestedPort
69
+ });
70
+ const actualPort = await new Promise((resolve, reject) => {
71
+ httpServer.once("error", reject);
72
+ httpServer.listen(requestedPort, host, () => {
73
+ httpServer.off("error", reject);
74
+ resolve(httpServer.address().port);
75
+ });
76
+ });
77
+ return {
78
+ port: actualPort,
79
+ baseUrl: `http://${host}:${actualPort}`,
80
+ close: () => new Promise((resolve) => {
81
+ httpServer.close(() => resolve());
82
+ })
83
+ };
84
+ }
85
+ //#endregion
86
+ exports.startChiiRelay = startChiiRelay;
87
+
88
+ //# sourceMappingURL=chii-relay-57BfqF_5.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chii-relay-57BfqF_5.cjs","names":["require"],"sources":["../src/mcp/chii-relay.ts"],"sourcesContent":["/**\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 registers an HTTP upgrade\n * listener on the server BEFORE calling `chii.start({server})`. Node's\n * `http.Server` allows multiple 'upgrade' listeners; the first to call\n * `socket.destroy()` wins. Invalid auth → 401 + destroy (chii never sees\n * the connection). Valid auth → return without side-effect (chii handles it).\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\nconst require = createRequire(import.meta.url);\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\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>`).\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\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 } = options;\n\n const httpServer = createServer();\n\n // Register our auth listener BEFORE chii.start() so it fires first.\n // Node's http.Server emits 'upgrade' to all listeners in registration order;\n // the first to destroy() the socket wins. Valid requests return without\n // side-effect so chii's own upgrade handler takes over normally.\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.\n if (verifyAuth) {\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex) => {\n if (!verifyAuth(req)) {\n // Reject: send a minimal HTTP 401 response and close the socket.\n // We do NOT log req.url or any auth param here to avoid leaking codes.\n socket.write('HTTP/1.1 401 Unauthorized\\r\\nContent-Length: 0\\r\\n\\r\\n');\n socket.destroy();\n // Early return — chii's handler is NOT called for this socket.\n return;\n }\n // Auth passed: no-op. Chii's upgrade listener (registered below by\n // chii.start) will handle the rest.\n });\n }\n\n const chii = loadChiiServer();\n // Passing an existing `server` makes chii attach its Koa handler + WS upgrade\n // to our HTTP server rather than creating its own listener.\n // Note: port/domain here are display-only inside chii — the TCP bind is ours.\n await chii.start({ server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort });\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,MAAMA,aAAAA,0BAAAA,eAAAA,QAAAA,MAAAA,CAAAA,cAAAA,WAAAA,CAAAA,KAAwC;AAa9C,SAAS,iBAAmC;CAE1C,MAAM,MAAeA,UAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;AA0D9D,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,eAAe;CAEvB,MAAM,cAAA,GAAA,UAAA,eAA2B;AASjC,KAAI,WACF,YAAW,GAAG,YAAY,KAAsB,WAAmB;AACjE,MAAI,CAAC,WAAW,IAAI,EAAE;AAGpB,UAAO,MAAM,yDAAyD;AACtE,UAAO,SAAS;AAEhB;;GAIF;AAOJ,OAJa,gBAAgB,CAIlB,MAAM;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,CAAC;CAEjG,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;AAEF,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,cAAW,YAAY,SAAS,CAAC;IACjC;EACL"}
@@ -0,0 +1,89 @@
1
+ import { createRequire } from "node:module";
2
+ import { createServer } from "node:http";
3
+ //#region src/mcp/chii-relay.ts
4
+ /**
5
+ * Boots the local Chii relay server.
6
+ *
7
+ * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome
8
+ * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.
9
+ * The relay accepts a `target` websocket from the phone's injected `target.js`
10
+ * and `client` websockets from CDP frontends (our MCP connection).
11
+ *
12
+ * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app
13
+ * entries.
14
+ *
15
+ * TOTP auth (relay-side, authoritative gate):
16
+ * When `verifyAuth` is provided, this module registers an HTTP upgrade
17
+ * listener on the server BEFORE calling `chii.start({server})`. Node's
18
+ * `http.Server` allows multiple 'upgrade' listeners; the first to call
19
+ * `socket.destroy()` wins. Invalid auth → 401 + destroy (chii never sees
20
+ * the connection). Valid auth → return without side-effect (chii handles it).
21
+ *
22
+ * Threat model: "URL leak" — someone obtains the tunnel URL (Slack paste, QR
23
+ * screenshot, shoulder-surfing) but does not have the shared TOTP secret.
24
+ * Rotating 6-digit code makes the URL stale after 30 s.
25
+ * A determined attacker who extracts the secret from the dogfood bundle can
26
+ * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).
27
+ *
28
+ * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear
29
+ * in any log, error message, or process output. `verifyAuth` is a black-box
30
+ * predicate from the caller's perspective; this module only forwards pass/fail.
31
+ */
32
+ const require = createRequire(import.meta.url);
33
+ function loadChiiServer() {
34
+ const mod = require("chii");
35
+ if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
36
+ throw new Error("chii server module did not expose start()");
37
+ }
38
+ /**
39
+ * Starts the Chii relay and resolves once listening.
40
+ *
41
+ * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral
42
+ * port on every start, so a stale cloudflared orphan holding any particular
43
+ * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`
44
+ * always reflect the actual bound port.
45
+ *
46
+ * chii.start() is called with `server` (our pre-created httpServer) BEFORE
47
+ * httpServer.listen(). This is intentional: chii attaches its Koa handler and
48
+ * WS upgrade listener to the server object, but the actual TCP bind is
49
+ * performed by our httpServer.listen() call below. The `port`/`domain` values
50
+ * passed to chii.start() are used for display/banner purposes inside chii and
51
+ * do not affect which port the server binds. The connection path (clients
52
+ * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.
53
+ */
54
+ async function startChiiRelay(options = {}) {
55
+ const requestedPort = options.port ?? 0;
56
+ const host = options.host ?? "127.0.0.1";
57
+ const { verifyAuth } = options;
58
+ const httpServer = createServer();
59
+ if (verifyAuth) httpServer.on("upgrade", (req, socket) => {
60
+ if (!verifyAuth(req)) {
61
+ socket.write("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n");
62
+ socket.destroy();
63
+ return;
64
+ }
65
+ });
66
+ await loadChiiServer().start({
67
+ server: httpServer,
68
+ domain: `${host}:${requestedPort}`,
69
+ port: requestedPort
70
+ });
71
+ const actualPort = await new Promise((resolve, reject) => {
72
+ httpServer.once("error", reject);
73
+ httpServer.listen(requestedPort, host, () => {
74
+ httpServer.off("error", reject);
75
+ resolve(httpServer.address().port);
76
+ });
77
+ });
78
+ return {
79
+ port: actualPort,
80
+ baseUrl: `http://${host}:${actualPort}`,
81
+ close: () => new Promise((resolve) => {
82
+ httpServer.close(() => resolve());
83
+ })
84
+ };
85
+ }
86
+ //#endregion
87
+ export { startChiiRelay };
88
+
89
+ //# sourceMappingURL=chii-relay-itXOz7kS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chii-relay-itXOz7kS.js","names":[],"sources":["../src/mcp/chii-relay.ts"],"sourcesContent":["/**\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 registers an HTTP upgrade\n * listener on the server BEFORE calling `chii.start({server})`. Node's\n * `http.Server` allows multiple 'upgrade' listeners; the first to call\n * `socket.destroy()` wins. Invalid auth → 401 + destroy (chii never sees\n * the connection). Valid auth → return without side-effect (chii handles it).\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\nconst require = createRequire(import.meta.url);\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\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>`).\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\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 } = options;\n\n const httpServer = createServer();\n\n // Register our auth listener BEFORE chii.start() so it fires first.\n // Node's http.Server emits 'upgrade' to all listeners in registration order;\n // the first to destroy() the socket wins. Valid requests return without\n // side-effect so chii's own upgrade handler takes over normally.\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.\n if (verifyAuth) {\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex) => {\n if (!verifyAuth(req)) {\n // Reject: send a minimal HTTP 401 response and close the socket.\n // We do NOT log req.url or any auth param here to avoid leaking codes.\n socket.write('HTTP/1.1 401 Unauthorized\\r\\nContent-Length: 0\\r\\n\\r\\n');\n socket.destroy();\n // Early return — chii's handler is NOT called for this socket.\n return;\n }\n // Auth passed: no-op. Chii's upgrade listener (registered below by\n // chii.start) will handle the rest.\n });\n }\n\n const chii = loadChiiServer();\n // Passing an existing `server` makes chii attach its Koa handler + WS upgrade\n // to our HTTP server rather than creating its own listener.\n // Note: port/domain here are display-only inside chii — the TCP bind is ours.\n await chii.start({ server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort });\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,MAAM,UAAU,cAAc,OAAO,KAAK,IAAI;AAa9C,SAAS,iBAAmC;CAE1C,MAAM,MAAe,QAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;AA0D9D,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,eAAe;CAEvB,MAAM,aAAa,cAAc;AASjC,KAAI,WACF,YAAW,GAAG,YAAY,KAAsB,WAAmB;AACjE,MAAI,CAAC,WAAW,IAAI,EAAE;AAGpB,UAAO,MAAM,yDAAyD;AACtE,UAAO,SAAS;AAEhB;;GAIF;AAOJ,OAJa,gBAAgB,CAIlB,MAAM;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,CAAC;CAEjG,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;AAEF,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,cAAW,YAAY,SAAS,CAAC;IACjC;EACL"}
@@ -18,14 +18,20 @@
18
18
  * `false` and could never pass. Layer A is the consumer guard; B and C are
19
19
  * here.
20
20
  *
21
- * Layer B has two parts. Both must pass:
21
+ * Layer B has two parts:
22
22
  * B1 — host allowlist: `hostname` must be a `*.private-apps.tossmini.com`
23
- * subdomain. The Toss app serves dogfood / private mini-apps from a
24
- * separate `private-apps` host; a production (`intoss://`) entry is
23
+ * subdomain (Toss dogfood entry) OR a `*.trycloudflare.com` host (env 2
24
+ * PWA dev tunnel). The Toss app serves dogfood / private mini-apps from
25
+ * a separate `private-apps` host; a production (`intoss://`) entry is
25
26
  * served from `*.apps.tossmini.com` WITHOUT the `private-apps` segment.
26
27
  * This is the security gate against a dogfood build that somehow lands
27
28
  * on a production entry — see the comment on {@link isPrivateAppsHost}.
28
- * B2 entry query: `_deploymentId` must be present and non-empty.
29
+ * The env 2 tunnel host is allowed because it has no production runtime
30
+ * (mock SDK, the developer's own dev server) — see {@link
31
+ * isTrycloudflareHost}.
32
+ * B2 — entry query: `_deploymentId` must be present and non-empty. Applies to
33
+ * the Toss path only; the env 2 tunnel has no deployed bundle, so B2 is
34
+ * skipped for `*.trycloudflare.com` hosts.
29
35
  *
30
36
  * Layer C — opt-in + relay + optional TOTP auth:
31
37
  * C1 — opt-in: `debug=1` must be present.
@@ -49,16 +55,23 @@
49
55
  *
50
56
  * Decision matrix (gate only runs in a debug build — Layer A already passed):
51
57
  *
52
- * host ok | _deploymentId | debug=1 | relay ok | TOTP ok* | result
53
- * no | (any) | (any) | (any) | (any) | BLOCKED (host)
54
- * yes | absent | (any) | (any) | (any) | BLOCKED (entry)
55
- * yes | present | absent | (any) | (any) | BLOCKED (opt-in)
56
- * yes | present | present | invalid | (any) | BLOCKED (invalid-relay)
57
- * yes | present | present | valid | fail* | BLOCKED (auth)
58
- * yes | present | present | valid | pass/n/a | ATTACH
58
+ * host | _deploymentId | debug=1 | relay ok | TOTP ok* | result
59
+ * neither | (any) | (any) | (any) | (any) | BLOCKED (host)
60
+ * private-apps| absent | (any) | (any) | (any) | BLOCKED (entry)
61
+ * private-apps| present | absent | (any) | (any) | BLOCKED (opt-in)
62
+ * private-apps| present | present | invalid | (any) | BLOCKED (invalid-relay)
63
+ * private-apps| present | present | valid | fail* | BLOCKED (auth)
64
+ * private-apps| present | present | valid | pass/n/a | ATTACH
65
+ * trycloudflare| (skipped) | absent | (any) | (any) | BLOCKED (opt-in)
66
+ * trycloudflare| (skipped) | present | invalid | (any) | BLOCKED (invalid-relay)
67
+ * trycloudflare| (skipped) | present | valid | fail* | BLOCKED (auth)
68
+ * trycloudflare| (skipped) | present | valid | pass/n/a | ATTACH
59
69
  *
60
70
  * * "TOTP ok" column only applies when `verifyTotpCode` is provided.
61
71
  * When no verifier is injected, TOTP check is skipped entirely.
72
+ * For trycloudflare (env 2 tunnel) hosts B1 is bypassed and B2 is skipped;
73
+ * C1/C2/C3 still apply identically. The ATTACH result carries
74
+ * `deploymentId: ''` for tunnel hosts.
62
75
  */
63
76
  /** Shape returned when the gate allows attachment. */
64
77
  interface GateResultAttach {
@@ -153,6 +166,26 @@ interface GateInput {
153
166
  * bare `private-apps.tossmini.com` (no mini-app subdomain) does not match.
154
167
  */
155
168
  declare function isPrivateAppsHost(hostname: string): boolean;
169
+ /**
170
+ * The host suffix Cloudflare quick-tunnels use — the env 2 (PWA) entry.
171
+ *
172
+ * Env 2 serves the local Vite dev server through a `*.trycloudflare.com` quick
173
+ * tunnel (`src/unplugin/tunnel.ts`). It has no Toss app, no `intoss-private://`
174
+ * scheme, and — critically — no production runtime: the SDK is the devtools
175
+ * mock, and the page is the developer's own dev build. The Layer B1 safety net
176
+ * (which stops a dogfood build that lands on a Toss *production* host from
177
+ * attaching) has nothing to protect against here, because env 2 has no
178
+ * production host. So a trycloudflare host is allowed past B1 — but ONLY past
179
+ * B1: the remaining layers (C1 opt-in, C2 relay, C3 TOTP) still apply, so a
180
+ * leaked tunnel URL is still blocked by TOTP exactly as on the Toss path.
181
+ *
182
+ * The match is the same exact-suffix `endsWith` check as
183
+ * {@link isPrivateAppsHost} — never a substring `.includes()`, which would
184
+ * accept an attacker-controlled `evil.trycloudflare.com.example.com`. The
185
+ * leading `.` forces a real subdomain label, so a bare `trycloudflare.com`
186
+ * (no tunnel subdomain) does not match.
187
+ */
188
+ declare function isTrycloudflareHost(hostname: string): boolean;
156
189
  /**
157
190
  * Pure function that evaluates the runtime debug activation layers (B and C).
158
191
  *
@@ -235,5 +268,5 @@ declare function maybeAttach(gateResult?: GateResult): void;
235
268
  */
236
269
  declare function checkDebugGate(): GateResult;
237
270
  //#endregion
238
- export { type GateInput, type GateResult, type GateResultAttach, type GateResultBlocked, checkDebugGate, deriveTargetScriptUrl, evaluateDebugGate, isPrivateAppsHost, maybeAttach };
271
+ export { type GateInput, type GateResult, type GateResultAttach, type GateResultBlocked, checkDebugGate, deriveTargetScriptUrl, evaluateDebugGate, isPrivateAppsHost, isTrycloudflareHost, maybeAttach };
239
272
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/in-app/gate.ts","../../src/in-app/attach.ts","../../src/in-app/index.ts"],"mappings":";;AA+DA;;;;;;;;;AASA;;;;;AAmBA;;;;;AAQA;;;;;;;;;;;AAwEA;;;;;AAyBA;;;;;;;;;;;;ACtKA;;;;;AAmCA;;;;;;;;ACrBA;AAAA,UFmBiB,gBAAA;EAAA,SACN,MAAA;EEpBuB;EAAA,SFsBvB,QAAA;;WAEA,YAAA;AAAA;;UAIM,iBAAA;EAAA,SACN,MAAA;;;;;;;;;;;;;;;WAeA,MAAA;AAAA;AAAA,KAGC,UAAA,GAAa,gBAAA,GAAmB,iBAAA;;;;;;;UAQ3B,SAAA;;;;;;;;;;;;;WAaN,QAAA;;;;;;;WAQA,YAAA,EAAc,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;WA0Bd,cAAA,IAAkB,IAAA;AAAA;;;;;;;;;;;;iBAyBb,iBAAA,CAAkB,QAAA;;;;;;;;;;;;;;;;;;;;;;iBAyBlB,iBAAA,CAAkB,KAAA,EAAO,SAAA,GAAY,UAAA;;;;;;AAzGrD;;;;;AAQA;;;;;;;iBCrEgB,qBAAA,CAAsB,QAAA;;;;AD6ItC;;;;;AAyBA;;;;;;;;;;;;ACtKA;;;iBAmCgB,WAAA,CAAY,UAAA,GAAY,UAAA;;;;;;;AD0GxC;;;;;AAyBA;;;;;iBExJgB,cAAA,CAAA,GAAkB,UAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/in-app/gate.ts","../../src/in-app/attach.ts","../../src/in-app/index.ts"],"mappings":";;AA4EA;;;;;;;;;AASA;;;;;AAmBA;;;;;AAQA;;;;;;;;;;;AA8EA;;;;;AAuBA;;;;;AAyBA;;;;;;;;;;;;AChNA;;;;;AAmCA;;;;;;;;ACrBA;;;;;;;;;UFgCiB,gBAAA;EAAA,SACN,MAAA;;WAEA,QAAA;;WAEA,YAAA;AAAA;;UAIM,iBAAA;EAAA,SACN,MAAA;;;;;;;;;;;;;;;WAeA,MAAA;AAAA;AAAA,KAGC,UAAA,GAAa,gBAAA,GAAmB,iBAAA;;;;;;;UAQ3B,SAAA;;;;;;;;;;;;;WAaN,QAAA;;;;;;;WAQA,YAAA,EAAc,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;WA0Bd,cAAA,IAAkB,IAAA;AAAA;;;;;;;;;;;;iBA+Bb,iBAAA,CAAkB,QAAA;;;;;;;;;;;;;;;;;;;;iBAuBlB,mBAAA,CAAoB,QAAA;;;;;;;;;;;;;;;;;;;;;;iBAyBpB,iBAAA,CAAkB,KAAA,EAAO,SAAA,GAAY,UAAA;;;;;;AAtIrD;;;;;AAQA;;;;;;;iBClFgB,qBAAA,CAAsB,QAAA;;;;ADgKtC;;;;;AAuBA;;;;;AAyBA;;;;;;;;;;iBC7KgB,WAAA,CAAY,UAAA,GAAY,UAAA;;;;;;;AD6HxC;;;;;AAuBA;;;;;iBEzKgB,cAAA,CAAA,GAAkB,UAAA"}