@ait-co/devtools 0.1.55 → 0.1.57
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.
- package/README.en.md +77 -32
- package/README.md +76 -31
- package/dist/chii-relay-57BfqF_5.cjs +88 -0
- package/dist/chii-relay-57BfqF_5.cjs.map +1 -0
- package/dist/chii-relay-itXOz7kS.js +89 -0
- package/dist/chii-relay-itXOz7kS.js.map +1 -0
- package/dist/in-app/index.d.ts +45 -12
- package/dist/in-app/index.d.ts.map +1 -1
- package/dist/in-app/index.js +38 -7
- package/dist/in-app/index.js.map +1 -1
- package/dist/mcp/cli.d.ts +8 -3
- package/dist/mcp/cli.d.ts.map +1 -1
- package/dist/mcp/cli.js +1160 -381
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +22 -11
- package/dist/mcp/server.js.map +1 -1
- package/dist/panel/index.js +2 -2
- package/dist/relay-secret-store-DnTNl-9z.cjs +140 -0
- package/dist/relay-secret-store-DnTNl-9z.cjs.map +1 -0
- package/dist/relay-secret-store-DqyUoeXy.js +140 -0
- package/dist/relay-secret-store-DqyUoeXy.js.map +1 -0
- package/dist/totp-BkP5yU2K.js +186 -0
- package/dist/totp-BkP5yU2K.js.map +1 -0
- package/dist/totp-CQFmgOhM.js +3 -0
- package/dist/totp-D0a8VwoR.js +187 -0
- package/dist/totp-D0a8VwoR.js.map +1 -0
- package/dist/totp-DLgGbySX.cjs +188 -0
- package/dist/totp-DLgGbySX.cjs.map +1 -0
- package/dist/{tunnel-D0_TwDNE.js → tunnel-CI61NvPI.js} +13 -5
- package/dist/tunnel-CI61NvPI.js.map +1 -0
- package/dist/{tunnel-BYP0yRBN.cjs → tunnel-nKYPtc-g.cjs} +13 -5
- package/dist/tunnel-nKYPtc-g.cjs.map +1 -0
- package/dist/unplugin/index.cjs +31 -3
- package/dist/unplugin/index.cjs.map +1 -1
- package/dist/unplugin/index.d.cts +11 -0
- package/dist/unplugin/index.d.cts.map +1 -1
- package/dist/unplugin/index.d.ts +12 -1
- package/dist/unplugin/index.d.ts.map +1 -1
- package/dist/unplugin/index.js +31 -3
- package/dist/unplugin/index.js.map +1 -1
- package/dist/unplugin/tunnel.cjs +11 -3
- package/dist/unplugin/tunnel.cjs.map +1 -1
- package/dist/unplugin/tunnel.d.cts +13 -1
- package/dist/unplugin/tunnel.d.cts.map +1 -1
- package/dist/unplugin/tunnel.d.ts +13 -1
- package/dist/unplugin/tunnel.d.ts.map +1 -1
- package/dist/unplugin/tunnel.js +11 -3
- package/dist/unplugin/tunnel.js.map +1 -1
- package/package.json +2 -2
- package/dist/tunnel-BYP0yRBN.cjs.map +0 -1
- 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,13 @@ 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
|
-
|
|
75
|
+
devtools-mcp # start MCP server
|
|
76
|
+
# In Claude Code: start_debug({mode: 'relay-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)
|
|
76
79
|
```
|
|
77
80
|
|
|
78
|
-
`
|
|
81
|
+
`start_debug({mode: 'relay-live', confirm: true})` arms the LIVE guard in-session. Details: [`docs/scenarios/env-4.md`](./docs/scenarios/env-4.md)
|
|
79
82
|
|
|
80
83
|
---
|
|
81
84
|
|
|
@@ -115,9 +118,20 @@ npm install -D @ait-co/devtools
|
|
|
115
118
|
pnpm add -D @ait-co/devtools
|
|
116
119
|
```
|
|
117
120
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
### Two channels — stable and beta
|
|
122
|
+
|
|
123
|
+
devtools runs two npm dist-tags off the same code at once. Pick the channel that matches your web-framework version.
|
|
124
|
+
|
|
125
|
+
| Channel | Install | web-framework peer |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| **stable** (`latest`, default) | `pnpm add -D @ait-co/devtools` | `>=2.6.0 <2.7.0` (2.x) |
|
|
128
|
+
| **beta** | `pnpm add -D @ait-co/devtools@beta` | `>=3.0.0-beta <4.0.0` (3.0 line) |
|
|
129
|
+
|
|
130
|
+
- On web-framework **2.x**, the default install (stable) is all you need.
|
|
131
|
+
- On the web-framework **3.0.0-beta** pre-release, install the `@beta` channel. It is a snapshot auto-published on every main push (`0.0.0-beta-<datetime>-<sha>`), so the versions are hard to pin — install with the `@beta` tag.
|
|
132
|
+
- Both channels keep the web-framework peer `optional`, so MCP-only debugging users are never forced to pull the SDK.
|
|
133
|
+
|
|
134
|
+
When 3.0 ships GA, the stable `latest` peer moves up to the 3.0 line and the beta channel is retired. Calling an API that devtools has not yet mocked will throw a runtime error — please [file an issue](https://github.com/apps-in-toss-community/devtools/issues) for missing APIs.
|
|
121
135
|
|
|
122
136
|
## Reference consumer
|
|
123
137
|
|
|
@@ -248,7 +262,7 @@ module.exports = {
|
|
|
248
262
|
| `forceEnable` | `boolean` | `false` | Enable devtools even in production |
|
|
249
263
|
| `mock` | `boolean` | `true` (dev) / `false` (prod+forceEnable) | Enable mock alias |
|
|
250
264
|
| `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** |
|
|
265
|
+
| `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
266
|
|
|
253
267
|
```ts
|
|
254
268
|
aitDevtools.vite({ panel: false }); // mock only, no panel
|
|
@@ -256,6 +270,7 @@ aitDevtools.vite({ forceEnable: true }); // enable in production (mock OFF by de
|
|
|
256
270
|
aitDevtools.vite({ forceEnable: true, mock: true }); // enable mock in production too
|
|
257
271
|
aitDevtools.vite({ mcp: true }); // enable MCP endpoint for AI agents
|
|
258
272
|
aitDevtools.vite({ tunnel: true }); // expose dev server at *.trycloudflare.com
|
|
273
|
+
aitDevtools.vite({ tunnel: { cdp: true } }); // real-device preview + on-device CDP debugging
|
|
259
274
|
```
|
|
260
275
|
|
|
261
276
|
## Production builds
|
|
@@ -324,6 +339,8 @@ export default defineConfig({
|
|
|
324
339
|
|
|
325
340
|
> `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
341
|
|
|
342
|
+
> 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).
|
|
343
|
+
|
|
327
344
|
(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
345
|
|
|
329
346
|
```json
|
|
@@ -938,14 +955,58 @@ AI coding agents (Claude Code, Cursor, etc.) can observe a running mini-app dire
|
|
|
938
955
|
|
|
939
956
|
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
957
|
|
|
941
|
-
| Mode + target | Invocation | Env
|
|
958
|
+
| Mode + target | Invocation | Env vars | Target | Tools |
|
|
942
959
|
|---|---|---|---|---|
|
|
943
|
-
| `--
|
|
944
|
-
| `--mode=debug --target=relay`
|
|
945
|
-
| `--mode=debug --target=
|
|
960
|
+
| `--target=mobile` (env 2) | `devtools-mcp` → `start_debug({mode:'relay-sandbox'})` | `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 |
|
|
961
|
+
| `--mode=debug --target=relay` (default, env 3) | `devtools-mcp` → `start_debug({mode: 'relay-staging'})` | — | Dogfood bundle on a phone (CDP/Chii relay + cloudflared tunnel, env 3) | same + `AIT.*` |
|
|
962
|
+
| `--mode=debug --target=relay` LIVE (env 4) | `devtools-mcp` → `start_debug({mode: 'relay-live', confirm: true})` | — (env 4 LIVE guard) | Live deployed app (env 4) — `call_sdk`/`evaluate` require `confirm: true` | same |
|
|
963
|
+
| `--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
964
|
| `--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
965
|
|
|
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.
|
|
966
|
+
`--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)`: `relay-sandbox` (env 2 PWA), `relay-staging` (env 3 dogfood), `relay-live` (env 4, arms LIVE guard — `confirm: true` required), `local-browser` (env 1).
|
|
967
|
+
|
|
968
|
+
#### Environment 2 (real-device PWA CDP) — `--target=mobile`
|
|
969
|
+
|
|
970
|
+
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.
|
|
971
|
+
|
|
972
|
+
**Setup procedure:**
|
|
973
|
+
|
|
974
|
+
1. Start the Vite dev server in CDP tunnel mode:
|
|
975
|
+
```bash
|
|
976
|
+
AIT_TUNNEL_CDP=1 pnpm exec vite --config e2e/fixture/vite.config.ts
|
|
977
|
+
```
|
|
978
|
+
The terminal banner prints two URLs:
|
|
979
|
+
- **App HTTP tunnel** `https://<A>.trycloudflare.com` → set as `AIT_TUNNEL_BASE_URL`
|
|
980
|
+
- **Relay wss tunnel** `wss://<B>.trycloudflare.com` → set `AIT_RELAY_BASE_URL` to its `https://` form
|
|
981
|
+
|
|
982
|
+
2. Start the MCP server in mobile mode (separate terminal):
|
|
983
|
+
```json
|
|
984
|
+
{
|
|
985
|
+
"mcpServers": {
|
|
986
|
+
"ait-debug": {
|
|
987
|
+
"command": "npx",
|
|
988
|
+
"args": ["-y", "@ait-co/devtools", "devtools-mcp"],
|
|
989
|
+
"env": {
|
|
990
|
+
"AIT_RELAY_BASE_URL": "https://<B>.trycloudflare.com",
|
|
991
|
+
"AIT_TUNNEL_BASE_URL": "https://<A>.trycloudflare.com"
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
3. In a Claude Code session:
|
|
999
|
+
```
|
|
1000
|
+
start_debug({mode: 'relay-sandbox'})
|
|
1001
|
+
build_attach_url()
|
|
1002
|
+
```
|
|
1003
|
+
Scan the QR with your phone camera. The launcher PWA opens the app in a frame and injects Chii target.js.
|
|
1004
|
+
|
|
1005
|
+
4. `list_pages()` → expect one page. Use `take_screenshot()` and other CDP tools.
|
|
1006
|
+
|
|
1007
|
+
**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.
|
|
1008
|
+
|
|
1009
|
+
**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
1010
|
|
|
950
1011
|
### Debug mode (CDP via Chii)
|
|
951
1012
|
|
|
@@ -955,39 +1016,23 @@ Read-only tools only. Tools are registered in two tiers based on attach state
|
|
|
955
1016
|
|
|
956
1017
|
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
1018
|
|
|
958
|
-
|
|
1019
|
+
Environments 3 and 4 (intoss-private relay) — start `devtools-mcp` as-is, then enter via `start_debug(mode)`:
|
|
959
1020
|
|
|
960
1021
|
```json
|
|
961
1022
|
{
|
|
962
1023
|
"mcpServers": {
|
|
963
1024
|
"ait-debug": {
|
|
964
1025
|
"command": "pnpm",
|
|
965
|
-
"args": ["exec", "devtools-mcp"]
|
|
966
|
-
"env": {
|
|
967
|
-
"MCP_ENV": "relay-dev"
|
|
968
|
-
}
|
|
1026
|
+
"args": ["exec", "devtools-mcp"]
|
|
969
1027
|
}
|
|
970
1028
|
}
|
|
971
1029
|
}
|
|
972
1030
|
```
|
|
973
1031
|
|
|
974
|
-
Environment
|
|
975
|
-
|
|
976
|
-
```json
|
|
977
|
-
{
|
|
978
|
-
"mcpServers": {
|
|
979
|
-
"ait-debug": {
|
|
980
|
-
"command": "pnpm",
|
|
981
|
-
"args": ["exec", "devtools-mcp"],
|
|
982
|
-
"env": {
|
|
983
|
-
"MCP_ENV": "relay-live"
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
```
|
|
1032
|
+
- Environment 3 (dogfood relay): `start_debug({mode: 'relay-staging'})`
|
|
1033
|
+
- Environment 4 (LIVE relay, LIVE guard enabled): `start_debug({mode: 'relay-live', confirm: true})`
|
|
989
1034
|
|
|
990
|
-
|
|
1035
|
+
**`start_debug(mode)` is the single in-session entry path.** `MCP_ENV=relay-live` remains only as a deprecated alias that seeds `liveIntent` at boot — in a new session, enter via `start_debug({mode: 'relay-live', confirm: true})`.
|
|
991
1036
|
|
|
992
1037
|
| Tool | CDP / AIT backing | Description |
|
|
993
1038
|
|---|---|---|
|
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,13 @@ HMR 없음(토스 WebView cold-load만). 상세: [`docs/scenarios/env-3.md`](./d
|
|
|
70
72
|
검수를 통과하고 OPENED 상태인 실 출시 앱에 relay를 붙여 런타임을 관측합니다.
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
|
-
|
|
75
|
+
devtools-mcp # MCP 서버 시작
|
|
76
|
+
# Claude Code에서: start_debug({mode: 'relay-live', confirm: true}) ← LIVE guard 활성화
|
|
74
77
|
# build_attach_url 호출 → QR 스캔 → LIVE 앱 로드 + relay attach
|
|
75
78
|
# call_sdk / evaluate 는 confirm: true 필수 (LIVE guard — 실유저 영향)
|
|
76
79
|
```
|
|
77
80
|
|
|
78
|
-
`
|
|
81
|
+
`start_debug({mode: 'relay-live', confirm: true})`가 LIVE guard를 활성화합니다. 상세: [`docs/scenarios/env-4.md`](./docs/scenarios/env-4.md)
|
|
79
82
|
|
|
80
83
|
---
|
|
81
84
|
|
|
@@ -115,9 +118,20 @@ npm install -D @ait-co/devtools
|
|
|
115
118
|
pnpm add -D @ait-co/devtools
|
|
116
119
|
```
|
|
117
120
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
### 두 채널 — stable과 beta
|
|
122
|
+
|
|
123
|
+
devtools는 같은 코드에서 두 개의 npm dist-tag를 동시에 운영합니다. 쓰는 web-framework 버전에 맞는 채널을 고르세요.
|
|
124
|
+
|
|
125
|
+
| 채널 | 설치 | web-framework peer |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| **stable** (`latest`, 기본) | `pnpm add -D @ait-co/devtools` | `>=2.6.0 <2.7.0` (2.x) |
|
|
128
|
+
| **beta** | `pnpm add -D @ait-co/devtools@beta` | `>=3.0.0-beta <4.0.0` (3.0 라인) |
|
|
129
|
+
|
|
130
|
+
- web-framework **2.x**를 쓰면 위 기본 설치(stable)면 됩니다.
|
|
131
|
+
- web-framework **3.0.0-beta** 프리릴리즈를 쓰면 `@beta` 채널을 설치하세요. 이 채널은 main push마다 자동 publish되는 스냅샷(`0.0.0-beta-<datetime>-<sha>`)이라 버전을 핀하기 어려우니 `@beta` 태그로 설치하는 걸 권장합니다.
|
|
132
|
+
- 두 채널 모두 web-framework peer는 `optional`이라 MCP 디버깅만 쓰는 경우 SDK를 강제로 끌어오지 않습니다.
|
|
133
|
+
|
|
134
|
+
3.0이 정식(GA) 출시되면 stable `latest` peer가 3.0 라인으로 올라가고 beta 채널은 정리됩니다. devtools가 아직 mock하지 않은 API를 호출하면 런타임에 에러가 발생합니다 — 누락된 API는 [이슈](https://github.com/apps-in-toss-community/devtools/issues)로 알려주세요.
|
|
121
135
|
|
|
122
136
|
## Reference consumer
|
|
123
137
|
|
|
@@ -248,7 +262,7 @@ module.exports = {
|
|
|
248
262
|
| `forceEnable` | `boolean` | `false` | production에서도 devtools 활성화 |
|
|
249
263
|
| `mock` | `boolean` | `true` (dev) / `false` (prod+forceEnable) | mock alias 활성화 여부 |
|
|
250
264
|
| `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 모드 전용** |
|
|
265
|
+
| `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
266
|
|
|
253
267
|
```ts
|
|
254
268
|
aitDevtools.vite({ panel: false }); // Panel 없이 mock만 사용
|
|
@@ -256,6 +270,7 @@ aitDevtools.vite({ forceEnable: true }); // production에서도 활성화 (mock
|
|
|
256
270
|
aitDevtools.vite({ forceEnable: true, mock: true }); // production에서 mock도 활성화
|
|
257
271
|
aitDevtools.vite({ mcp: true }); // AI 에이전트용 MCP endpoint 활성화
|
|
258
272
|
aitDevtools.vite({ tunnel: true }); // dev 서버를 *.trycloudflare.com으로 노출
|
|
273
|
+
aitDevtools.vite({ tunnel: { cdp: true } }); // 실기기 미리보기 + on-device CDP 디버깅
|
|
259
274
|
```
|
|
260
275
|
|
|
261
276
|
## Production 빌드
|
|
@@ -324,6 +339,8 @@ export default defineConfig({
|
|
|
324
339
|
|
|
325
340
|
> `process.env.AIT_TUNNEL`은 `vite.config.ts`를 로드하는 시점(= vite 프로세스 기동 시)에 평가됩니다. 따라서 env 변수는 **vite를 띄우기 전에** 설정되어 있어야 합니다 (아래 (c)의 `dev:phone` 스크립트가 이를 자동으로 해결합니다).
|
|
326
341
|
|
|
342
|
+
> 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).
|
|
343
|
+
|
|
327
344
|
(b) **`package.json`에 pnpm 10+ 빌드 스크립트 허용** — pnpm은 보안상 dependency의 postinstall을 기본 차단합니다. `cloudflared`는 postinstall에서 바이너리(~38 MB)를 받으므로 명시 허용 필요:
|
|
328
345
|
|
|
329
346
|
```json
|
|
@@ -970,12 +987,56 @@ AI 코딩 에이전트(Claude Code, Cursor 등)가 [MCP(Model Context Protocol)]
|
|
|
970
987
|
|
|
971
988
|
| 모드 + 타깃 | 호출 | 환경 변수 | 대상 | tool |
|
|
972
989
|
|---|---|---|---|---|
|
|
973
|
-
| `--
|
|
974
|
-
| `--mode=debug --target=relay`
|
|
975
|
-
| `--mode=debug --target=
|
|
990
|
+
| `--target=mobile` (env 2) | `devtools-mcp` → `start_debug({mode:'relay-sandbox'})` | `AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL` | 실기기 Safari/WebKit PWA (외부 Chii relay + cloudflared 터널, 환경 2) | console/network/page + DOM/snapshot/screenshot |
|
|
991
|
+
| `--mode=debug --target=relay` (기본값, env 3) | `devtools-mcp` → `start_debug({mode: 'relay-staging'})` | — | 폰 안 dogfood 번들 (CDP/Chii relay + cloudflared 터널, 환경 3) | 동일 + `AIT.*` |
|
|
992
|
+
| `--mode=debug --target=relay` LIVE (env 4) | `devtools-mcp` → `start_debug({mode: 'relay-live', confirm: true})` | — (**환경 4 LIVE guard**) | LIVE 배포 앱 (환경 4) — `call_sdk`/`evaluate`에 `confirm: true` 필요 | 동일 |
|
|
993
|
+
| `--mode=debug --target=local` (env 1) | `devtools-mcp --target=local` | `MCP_ENV=mock` (자동) | MCP가 직접 기동한 로컬 Chromium (CDP direct-attach, relay 불필요, 환경 1) | 동일 |
|
|
976
994
|
| `--mode=dev` | `devtools-mcp --mode=dev` | `MCP_ENV=mock` (자동) | 실행 중인 Vite dev server의 mock state (AIT.* 전용, CDP 없음) | `AIT.*` (+ `devtools_get_mock_state` alias) |
|
|
977
995
|
|
|
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은 제공하지 않습니다.
|
|
996
|
+
`--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)` 한 번으로 처리됩니다: `relay-sandbox`(env 2 PWA), `relay-staging`(env 3 dogfood), `relay-live`(env 4 LIVE guard 활성화, `confirm: true` 필수), `local-browser`(env 1).
|
|
997
|
+
|
|
998
|
+
#### 환경 2 (실기기 PWA CDP) — `--target=mobile`
|
|
999
|
+
|
|
1000
|
+
토스 검수 없이 실기기 WebKit 엔진에서 CDP 디버깅이 가능한 모드입니다. [`tunnel:{cdp:true}`](#tunnel-옵션)를 켠 Vite dev server가 앱 HTTP 터널과 Chii relay 터널을 두 개 띄우고, MCP는 그 relay에 붙어 `build_attach_url` → 런처 QR을 제공합니다.
|
|
1001
|
+
|
|
1002
|
+
**진입 절차:**
|
|
1003
|
+
|
|
1004
|
+
1. Vite dev server를 CDP 터널 모드로 기동:
|
|
1005
|
+
```bash
|
|
1006
|
+
AIT_TUNNEL_CDP=1 pnpm exec vite --config e2e/fixture/vite.config.ts
|
|
1007
|
+
```
|
|
1008
|
+
터미널 배너에 두 URL이 출력됩니다:
|
|
1009
|
+
- **앱 HTTP 터널** `https://<A>.trycloudflare.com` → `AIT_TUNNEL_BASE_URL`로 설정
|
|
1010
|
+
- **relay wss 터널** `wss://<B>.trycloudflare.com` → `AIT_RELAY_BASE_URL`의 `https://` 형으로 설정
|
|
1011
|
+
|
|
1012
|
+
2. MCP server를 mobile 모드로 기동 (별도 터미널):
|
|
1013
|
+
```json
|
|
1014
|
+
{
|
|
1015
|
+
"mcpServers": {
|
|
1016
|
+
"ait-debug": {
|
|
1017
|
+
"command": "npx",
|
|
1018
|
+
"args": ["-y", "@ait-co/devtools", "devtools-mcp"],
|
|
1019
|
+
"env": {
|
|
1020
|
+
"AIT_RELAY_BASE_URL": "https://<B>.trycloudflare.com",
|
|
1021
|
+
"AIT_TUNNEL_BASE_URL": "https://<A>.trycloudflare.com"
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
3. Claude Code 세션에서 진입:
|
|
1029
|
+
```
|
|
1030
|
+
start_debug({mode: 'relay-sandbox'})
|
|
1031
|
+
build_attach_url()
|
|
1032
|
+
```
|
|
1033
|
+
QR을 폰 카메라로 스캔하면 런처 PWA가 앱을 프레임에 열고 Chii target.js를 주입합니다.
|
|
1034
|
+
|
|
1035
|
+
4. `list_pages()` → 페이지 1개 확인. `take_screenshot()` 등 CDP tool을 사용합니다.
|
|
1036
|
+
|
|
1037
|
+
**env 2의 fidelity 경계**: SDK mock을 씁니다 (실 SDK 호출 불가) — `call_sdk`는 환경 2에서 mock을 칩니다. 실 SDK fidelity가 필요하면 환경 3으로 올라가세요. CDP는 실 WebKit 엔진 위에서 동작하므로 DOM·console·screenshot은 실기기 화면을 그대로 반영합니다.
|
|
1038
|
+
|
|
1039
|
+
**로컬 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
1040
|
|
|
980
1041
|
### Debug 모드 (CDP via Chii)
|
|
981
1042
|
|
|
@@ -993,39 +1054,23 @@ tunnel로 공개 `wss://*.trycloudflare.com` URL을 발급한 뒤 QR을 터미
|
|
|
993
1054
|
에이전트가 `chrome-devtools-mcp` 호환 tool로 console/network/page 상태를 read합니다. 사람이 폰을
|
|
994
1055
|
지켜볼 필요 없이 회귀를 단독 진단하는 것이 목표입니다.
|
|
995
1056
|
|
|
996
|
-
환경 3 (
|
|
1057
|
+
환경 3·4 (intoss-private relay) — `devtools-mcp`를 그대로 기동한 뒤 `start_debug(mode)`로 진입합니다:
|
|
997
1058
|
|
|
998
1059
|
```json
|
|
999
1060
|
{
|
|
1000
1061
|
"mcpServers": {
|
|
1001
1062
|
"ait-debug": {
|
|
1002
1063
|
"command": "pnpm",
|
|
1003
|
-
"args": ["exec", "devtools-mcp"]
|
|
1004
|
-
"env": {
|
|
1005
|
-
"MCP_ENV": "relay-dev"
|
|
1006
|
-
}
|
|
1064
|
+
"args": ["exec", "devtools-mcp"]
|
|
1007
1065
|
}
|
|
1008
1066
|
}
|
|
1009
1067
|
}
|
|
1010
1068
|
```
|
|
1011
1069
|
|
|
1012
|
-
환경
|
|
1013
|
-
|
|
1014
|
-
```json
|
|
1015
|
-
{
|
|
1016
|
-
"mcpServers": {
|
|
1017
|
-
"ait-debug": {
|
|
1018
|
-
"command": "pnpm",
|
|
1019
|
-
"args": ["exec", "devtools-mcp"],
|
|
1020
|
-
"env": {
|
|
1021
|
-
"MCP_ENV": "relay-live"
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
```
|
|
1070
|
+
- 환경 3 (dogfood relay): `start_debug({mode: 'relay-staging'})`
|
|
1071
|
+
- 환경 4 (LIVE relay, LIVE guard 활성화): `start_debug({mode: 'relay-live', confirm: true})`
|
|
1027
1072
|
|
|
1028
|
-
|
|
1073
|
+
**세션 내 환경 전환은 `start_debug(mode)`가 단일 진입 경로**입니다. `MCP_ENV=relay-live`는 부팅 시 `liveIntent`를 미리 시드하는 deprecated 별칭으로만 남아 있습니다 — 새 세션에서는 `start_debug({mode: 'relay-live', confirm: true})`로 진입하세요.
|
|
1029
1074
|
|
|
1030
1075
|
| Tool | CDP / AIT 백킹 | 설명 |
|
|
1031
1076
|
|---|---|---|
|
|
@@ -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"}
|