@ait-co/devtools 0.1.142 → 0.1.144
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 +87 -1
- package/README.md +87 -1
- package/dist/{chii-relay-BZ3HqWL5.js → chii-relay-B3ZhjGMi.js} +1 -1
- package/dist/{chii-relay-BZ3HqWL5.js.map → chii-relay-B3ZhjGMi.js.map} +1 -1
- package/dist/{chii-relay-D7eK2acz.cjs → chii-relay-CGMlePMd.cjs} +1 -1
- package/dist/{chii-relay-D7eK2acz.cjs.map → chii-relay-CGMlePMd.cjs.map} +1 -1
- package/dist/mcp/cli.js +3 -3
- package/dist/mcp/server.js +1 -1
- package/dist/mock/index.d.ts +32 -9
- package/dist/mock/index.d.ts.map +1 -1
- package/dist/mock/index.js +31 -19
- package/dist/mock/index.js.map +1 -1
- package/dist/optional-peers-CDEFhlhJ.cjs +131 -0
- package/dist/optional-peers-CDEFhlhJ.cjs.map +1 -0
- package/dist/optional-peers-FdVbUJst.js +96 -0
- package/dist/optional-peers-FdVbUJst.js.map +1 -0
- package/dist/panel/index.js +3 -3
- package/dist/panel/index.js.map +1 -1
- package/dist/{pool-CrP5CPvU.d.ts → pool-DcaaOwUq.d.ts} +2 -2
- package/dist/{pool-CrP5CPvU.d.ts.map → pool-DcaaOwUq.d.ts.map} +1 -1
- package/dist/{relay-secret-store-DWKdV-eY.cjs → relay-secret-store-BR0YIkNv.cjs} +2 -2
- package/dist/{relay-secret-store-DWKdV-eY.cjs.map → relay-secret-store-BR0YIkNv.cjs.map} +1 -1
- package/dist/{relay-secret-store-BPhN1upr.js → relay-secret-store-CYM8CBIF.js} +2 -2
- package/dist/{relay-secret-store-BPhN1upr.js.map → relay-secret-store-CYM8CBIF.js.map} +1 -1
- package/dist/{relay-url-store-1FGuSYAn.cjs → relay-url-store-C1as_m5G.cjs} +2 -2
- package/dist/{relay-url-store-1FGuSYAn.cjs.map → relay-url-store-C1as_m5G.cjs.map} +1 -1
- package/dist/{relay-url-store-Bskcyeg8.js → relay-url-store-CzFo_84F.js} +2 -2
- package/dist/{relay-url-store-Bskcyeg8.js.map → relay-url-store-CzFo_84F.js.map} +1 -1
- package/dist/{relay-worker-DcyboK43.d.ts → relay-worker-YdlpZQl9.d.ts} +2 -2
- package/dist/{relay-worker-DcyboK43.d.ts.map → relay-worker-YdlpZQl9.d.ts.map} +1 -1
- package/dist/{runtime-BiigOuvb.d.ts → runtime-kn9DxOeg.d.ts} +2 -1
- package/dist/runtime-kn9DxOeg.d.ts.map +1 -0
- package/dist/test-runner/config.d.ts +1 -1
- package/dist/test-runner/pool.d.ts +1 -1
- package/dist/test-runner/relay-worker.d.ts +1 -1
- package/dist/test-runner/report.d.ts +2 -2
- package/dist/test-runner/rpc.d.ts +1 -1
- package/dist/test-runner/runtime.d.ts +1 -1
- package/dist/test-runner/runtime.js +4 -0
- package/dist/test-runner/runtime.js.map +1 -1
- package/dist/test-runner/task-graph.d.ts +1 -1
- package/dist/{totp-DIbrZtI7.js → totp-BqmCLSNA.js} +2 -2
- package/dist/{totp-DIbrZtI7.js.map → totp-BqmCLSNA.js.map} +1 -1
- package/dist/{totp-Df252ZdA.cjs → totp-CMHR5lsW.cjs} +1 -2
- package/dist/{totp-Df252ZdA.cjs.map → totp-CMHR5lsW.cjs.map} +1 -1
- package/dist/{tunnel-BVSMXctM.cjs → tunnel-BGT9Curk.cjs} +27 -75
- package/dist/tunnel-BGT9Curk.cjs.map +1 -0
- package/dist/{tunnel-D7xkimBu.js → tunnel-BOKmLzBO.js} +27 -75
- package/dist/tunnel-BOKmLzBO.js.map +1 -0
- package/dist/unplugin/index.cjs +13 -17
- package/dist/unplugin/index.cjs.map +1 -1
- package/dist/unplugin/index.d.cts +12 -2
- package/dist/unplugin/index.d.cts.map +1 -1
- package/dist/unplugin/index.d.ts +12 -2
- package/dist/unplugin/index.d.ts.map +1 -1
- package/dist/unplugin/index.js +13 -17
- package/dist/unplugin/index.js.map +1 -1
- package/dist/unplugin/tunnel.cjs +61 -74
- package/dist/unplugin/tunnel.cjs.map +1 -1
- package/dist/unplugin/tunnel.d.cts +22 -16
- package/dist/unplugin/tunnel.d.cts.map +1 -1
- package/dist/unplugin/tunnel.d.ts +22 -16
- package/dist/unplugin/tunnel.d.ts.map +1 -1
- package/dist/unplugin/tunnel.js +61 -74
- package/dist/unplugin/tunnel.js.map +1 -1
- package/package.json +11 -1
- package/dist/deeplink-B5-Hxu0Q.js +0 -62
- package/dist/deeplink-B5-Hxu0Q.js.map +0 -1
- package/dist/deeplink-BpO9qc-D.js +0 -62
- package/dist/deeplink-BpO9qc-D.js.map +0 -1
- package/dist/deeplink-BzdbA1gV.cjs +0 -62
- package/dist/deeplink-BzdbA1gV.cjs.map +0 -1
- package/dist/deeplink-DCScMYcp.cjs +0 -62
- package/dist/deeplink-DCScMYcp.cjs.map +0 -1
- package/dist/devtools-opener-B8nxrxqu.js +0 -71
- package/dist/devtools-opener-B8nxrxqu.js.map +0 -1
- package/dist/devtools-opener-BDY0w3_0.cjs +0 -68
- package/dist/devtools-opener-BDY0w3_0.cjs.map +0 -1
- package/dist/devtools-opener-BTl5A6Cd.js +0 -71
- package/dist/devtools-opener-BTl5A6Cd.js.map +0 -1
- package/dist/devtools-opener-iv1OwfJN.cjs +0 -68
- package/dist/devtools-opener-iv1OwfJN.cjs.map +0 -1
- package/dist/qr-http-server--gl2-WKc.cjs +0 -1644
- package/dist/qr-http-server--gl2-WKc.cjs.map +0 -1
- package/dist/qr-http-server-Bb7lMeqi.js +0 -1644
- package/dist/qr-http-server-Bb7lMeqi.js.map +0 -1
- package/dist/qr-http-server-D-Off6K1.cjs +0 -1644
- package/dist/qr-http-server-D-Off6K1.cjs.map +0 -1
- package/dist/qr-http-server-n1twN18z.js +0 -1644
- package/dist/qr-http-server-n1twN18z.js.map +0 -1
- package/dist/runtime-BiigOuvb.d.ts.map +0 -1
- package/dist/totp-95OAa20j.js +0 -64
- package/dist/totp-95OAa20j.js.map +0 -1
- package/dist/totp-BjtoQNfu.cjs +0 -64
- package/dist/totp-BjtoQNfu.cjs.map +0 -1
- package/dist/tunnel-BVSMXctM.cjs.map +0 -1
- package/dist/tunnel-D7xkimBu.js.map +0 -1
package/README.en.md
CHANGED
|
@@ -47,7 +47,7 @@ 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 environment 3).
|
|
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 environment 3). CDP needs two extra packages — see [Debugging packages](#debugging-packages-environments-2-and-3) below.
|
|
51
51
|
|
|
52
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)
|
|
53
53
|
|
|
@@ -138,6 +138,26 @@ devtools runs two npm dist-tags off the same code at once. Pick the channel that
|
|
|
138
138
|
|
|
139
139
|
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.
|
|
140
140
|
|
|
141
|
+
### Debugging packages (environments 2 and 3)
|
|
142
|
+
|
|
143
|
+
**If you only use environment 1 (local browser + mock + panel), the install above is all you need.** Nothing else to add.
|
|
144
|
+
|
|
145
|
+
For on-device CDP debugging — `tunnel: { cdp: true }` on environment 2, or relay attach on environment 3 — install the two debugging packages as well:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pnpm add -D @ait-co/debugger @ait-co/debug-console
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
| Package | Role | Can enter a bundle |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| [`@ait-co/debugger`](https://www.npmjs.com/package/@ait-co/debugger) | MCP daemon · real-device test runner · dev-bridge (environment 2 CDP relay + QR dashboard) | No — devDependency / `npx` only |
|
|
154
|
+
| [`@ait-co/debug-console`](https://www.npmjs.com/package/@ait-co/debug-console) | On-device attach + in-app eruda console | Yes — the only one that enters a debug build |
|
|
155
|
+
|
|
156
|
+
Both are **optional peers** of devtools.
|
|
157
|
+
|
|
158
|
+
- Without `@ait-co/debugger`, `tunnel: { cdp: true }` skips the CDP wiring, degrades to the plain screen-preview tunnel, and prints the install hint once.
|
|
159
|
+
- Without `@ait-co/debug-console`, the unplugin injects no in-app attach at all — the attach code cannot structurally enter your bundle, which is the technical boundary of the debug surface.
|
|
160
|
+
|
|
141
161
|
## Reference consumer
|
|
142
162
|
|
|
143
163
|
[`sdk-example`](https://github.com/apps-in-toss-community/sdk-example) is the reference consumer of devtools. It's a catalog app where every SDK API can be run interactively, and the web demo is live at <https://sdk-example.aitc.dev/>. When you add a new mock, confirming that it works on the sdk-example card is the first sanity check. That said, this repo's E2E suite runs against an **internal self-contained fixture (`e2e/fixture/`)** without cloning sdk-example — so a broken sdk-example won't affect devtools CI.
|
|
@@ -621,6 +641,7 @@ __ait.update({ networkStatus: 'OFFLINE' });
|
|
|
621
641
|
__ait.patch('permissions', { camera: 'denied' });
|
|
622
642
|
__ait.patch('deviceModes', { location: 'web' });
|
|
623
643
|
__ait.patch('iap', { nextResult: 'USER_CANCELED' });
|
|
644
|
+
__ait.patch('failureModes', { loadAdMob: 'PLACEMENT_ID_FETCH_FAILED' }); // reproduce a real-device ad placement lookup failure
|
|
624
645
|
|
|
625
646
|
// Trigger events
|
|
626
647
|
__ait.trigger('backEvent');
|
|
@@ -735,6 +756,8 @@ unsubscribe(); // unsubscribe
|
|
|
735
756
|
| `TossAds.destroy/destroyAll` | No-op |
|
|
736
757
|
| `loadFullScreenAd` / `showFullScreenAd` | Similar flow to GoogleAdMob |
|
|
737
758
|
|
|
759
|
+
> Unless a failure dial is set (`failureModes.loadAdMob`, panel `forceNoFill`), the events above fire the same way every time — the mock does not use `adGroupId` to decide the outcome, and it does not model a server-side placement-resolution step. A real device can fail placement lookup itself before any ad exists (e.g. `PLACEMENT_ID_FETCH_FAILED`) and reject immediately, so a mock `loaded` event is not a signal that an ad will actually serve on a real device. To reproduce this failure locally: `__ait.patch('failureModes', { loadAdMob: 'PLACEMENT_ID_FETCH_FAILED' })`.
|
|
760
|
+
|
|
738
761
|
### Events
|
|
739
762
|
|
|
740
763
|
| API | Mock behavior |
|
|
@@ -842,6 +865,69 @@ it('can write and read from Storage', async () => {
|
|
|
842
865
|
});
|
|
843
866
|
```
|
|
844
867
|
|
|
868
|
+
## On-device test runner (`devtools-test`)
|
|
869
|
+
|
|
870
|
+
"Using in tests" above verifies mocks in desktop jsdom. `devtools-test` is a separate executable that runs tests written in the same style **against the real SDK inside the Toss app WebView on a real phone (environment 3)**. Installing the package also installs the `devtools-test` bin.
|
|
871
|
+
|
|
872
|
+
```bash
|
|
873
|
+
# shape
|
|
874
|
+
devtools-test <glob> --scheme-url <intoss-private URL> [--manual-blocking] \
|
|
875
|
+
--cell-sdk-line <2.x|3.x> --cell-platform <ios|android> [--report-dir <dir>]
|
|
876
|
+
|
|
877
|
+
# example
|
|
878
|
+
pnpm exec devtools-test 'src/**/*.ait.test.ts' \
|
|
879
|
+
--scheme-url "intoss-private://my-mini-app?_deploymentId=<uuid>" \
|
|
880
|
+
--cell-sdk-line 3.x --cell-platform ios --report-dir .ait-report
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
| Flag | Meaning |
|
|
884
|
+
|---|---|
|
|
885
|
+
| `<glob>` | Test file glob (more than one may be given) |
|
|
886
|
+
| `--scheme-url` | The `intoss-private://` URL printed by `ait deploy --scheme-only`. Required for environment 3 attach |
|
|
887
|
+
| `--cell-sdk-line` | SDK line axis stamped into the report (`2.x` / `3.x`, defaults to `2.x`) |
|
|
888
|
+
| `--cell-platform` | Platform axis (`mock` / `ios` / `android` / `ios-pwa`). Resolution order: flag → `AIT_CELL_PLATFORM` env → `mock` |
|
|
889
|
+
| `--manual-blocking` | Run `*.manual.ait.test.ts` last, with a human driving the native sheets |
|
|
890
|
+
| `--report-dir` | Directory for the report + captures. Omitted means nothing is written |
|
|
891
|
+
|
|
892
|
+
Four things need to be in place:
|
|
893
|
+
|
|
894
|
+
| Item | Detail |
|
|
895
|
+
|---|---|
|
|
896
|
+
| A relay TOTP secret | The runner requires this before it boots the relay — without it, it exits 1 before the QR ever appears. Booting `pnpm dev:phone:cdp` once (unplugin's `tunnel.cdp` option) auto-generates `.ait_relay` in the project root; otherwise set `AIT_DEBUG_TOTP_SECRET` yourself (`openssl rand -hex 32`). Which directory the runner looks for `.ait_relay` in is decided by `--project-root` (defaults to cwd) |
|
|
897
|
+
| Dog-food bundle | `ait build && ait deploy --scheme-only` → the printed `intoss-private://…?_deploymentId=…` URL is your `--scheme-url` value |
|
|
898
|
+
| One line in the mini-app entry | `import '@ait-co/devtools/in-app/auto'` — wires attach + the `window.__sdk` bridge ([section above](#on-device-debugging-in-one-line)) |
|
|
899
|
+
| Test files | `*.ait.test.ts`. `describe`/`it`/`test`/`expect` are installed as globals by the runner (no import needed), and `@apps-in-toss/web-framework` imports are redirected to `window.__sdk` at bundle time |
|
|
900
|
+
|
|
901
|
+
### Scan the dashboard QR, not the raw scheme URL
|
|
902
|
+
|
|
903
|
+
On start, the runner boots its own Chii relay + cloudflared tunnel + a local QR dashboard, and prints the dashboard address to stderr (`http://127.0.0.1:8317/` by default — if that port is taken it scans up to 20 ports, incrementing by 1, then falls back to an ephemeral port; override with `--dashboard-port` or `AIT_DEBUG_HTTP_PORT`).
|
|
904
|
+
|
|
905
|
+
**The QR you scan with the phone is the one on that dashboard.** Only that QR carries the scheme URL, the relay wss URL, and the always-present rotating `at=` code in a single capsule, so one scan cold-loads the bundle in the Toss app and attaches CDP at the same time. Once the attach succeeds a `Debugger Connected` badge appears in the phone's bottom-left corner and the runner starts executing tests.
|
|
906
|
+
|
|
907
|
+
> Turning the bare `intoss-private://` URL from `ait deploy --scheme-only` into a QR and scanning that opens the app but **does not attach the debugger** — without `debug=1` and `relay=` the in-app gate blocks the attach. The runner waits indefinitely for a scan (`--attach-timeout` bounds the wait) and runs no tests until one arrives.
|
|
908
|
+
|
|
909
|
+
### The debugger disconnects when the run ends (this is normal)
|
|
910
|
+
|
|
911
|
+
The runner is run-then-exit. Once the last test file finishes it prints the summary, tears down the relay, tunnel, and dashboard, and exits (exit code 1 if any test failed) — at that moment the badge on the phone flips to a disconnected notice and then dismisses itself. The debug session closed; the app did not crash, and the mini-app stays open. To run again, restart the runner and scan the new dashboard QR.
|
|
912
|
+
|
|
913
|
+
### Tests that open native sheets (`--manual-blocking`)
|
|
914
|
+
|
|
915
|
+
Tests that need a human to tap through a native sheet — photo picker, permission dialog, fullscreen ad — go in files named `*.manual.ait.test.ts`. Those files are **excluded** from a default run and are only included with `--manual-blocking`, where they run **last**, after every regular file. Before each manual file the dashboard and stdout show its filename plus progress (k/n), and the per-file timeout is raised to 5 minutes.
|
|
916
|
+
|
|
917
|
+
### Artifacts (`--report-dir`)
|
|
918
|
+
|
|
919
|
+
| Path | Contents |
|
|
920
|
+
|---|---|
|
|
921
|
+
| `<dir>/<sdkLine>.<platform>.json` | Runner-agnostic report. File paths are relative to the project root, and no relay / scheme / TOTP values are stored |
|
|
922
|
+
| `<dir>/<sdkLine>.<platform>.manual.json` | The manual files included in a `--manual-blocking` run — written alongside the standard report, never replacing it |
|
|
923
|
+
| `<dir>/.ait-capture/<category>.<sdkLine>.<platform>.json` | `__AIT_CAPTURE__` lines emitted by the tests (for offline 2.x↔3.0 comparison) |
|
|
924
|
+
|
|
925
|
+
`<sdkLine>` and `<platform>` are the `--cell-sdk-line` / `--cell-platform` values verbatim. Without `--report-dir`, neither the report nor the captures are collected.
|
|
926
|
+
|
|
927
|
+
### Everything else
|
|
928
|
+
|
|
929
|
+
`devtools-test --help` is the source of truth for the full flag reference. Attach/run timeouts, bridge-call pacing, and the `--attach-launcher --app-url` mode that attaches to the environment 2 launcher PWA all live there.
|
|
930
|
+
|
|
845
931
|
## SDK update tracking
|
|
846
932
|
|
|
847
933
|
devtools tracks [`@apps-in-toss/web-framework`](https://www.npmjs.com/package/@apps-in-toss/web-framework), and [`sdk-example`](https://github.com/apps-in-toss-community/sdk-example) tracks both the original SDK and devtools. When a new SDK version is released, the flow is: (1) devtools catches up on mock/type signatures → (2) sdk-example incorporates both new versions together. If a devtools-only PR breaks sdk-example, both are addressed together.
|
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ 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).
|
|
50
|
+
`tunnel: { cdp: true }`를 켜면 같은 QR 한 번으로 화면 미리보기 + on-device CDP가 함께 열려 실기기 WebKit의 DOM·콘솔·예외를 MCP로 관측합니다 (`call_sdk`는 환경 2에서 mock — 실 SDK는 환경 3). CDP를 쓰려면 디버깅 패키지 두 개를 추가로 설치하세요 — 아래 [디버깅 패키지](#디버깅-패키지-환경-23) 참고.
|
|
51
51
|
|
|
52
52
|
사전: 폰에 `https://devtools.aitc.dev/launcher/` 를 홈 화면에 한 번 추가. 상세: [`docs/scenarios/env-2.md`](./docs/scenarios/env-2.md)
|
|
53
53
|
|
|
@@ -138,6 +138,26 @@ devtools는 같은 코드에서 두 개의 npm dist-tag를 동시에 운영합
|
|
|
138
138
|
|
|
139
139
|
3.0이 정식(GA) 출시되면 stable `latest` peer가 3.0 라인으로 올라가고 beta 채널은 정리됩니다. devtools가 아직 mock하지 않은 API를 호출하면 런타임에 에러가 발생합니다 — 누락된 API는 [이슈](https://github.com/apps-in-toss-community/devtools/issues)로 알려주세요.
|
|
140
140
|
|
|
141
|
+
### 디버깅 패키지 (환경 2·3)
|
|
142
|
+
|
|
143
|
+
**환경 1(로컬 브라우저 + mock + 패널)만 쓴다면 위 설치가 전부입니다.** 아무것도 더 설치하지 않아도 됩니다.
|
|
144
|
+
|
|
145
|
+
on-device CDP 디버깅(환경 2의 `tunnel: { cdp: true }`, 환경 3의 relay attach)을 쓰려면 디버깅 패키지 두 개를 추가로 설치하세요:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pnpm add -D @ait-co/debugger @ait-co/debug-console
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
| 패키지 | 역할 | 번들 반입 |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| [`@ait-co/debugger`](https://www.npmjs.com/package/@ait-co/debugger) | MCP 데몬 · 실기기 테스트 러너 · dev-bridge(환경 2 CDP relay + QR 대시보드) | 안 됨 — devDependency / `npx` 전용 |
|
|
154
|
+
| [`@ait-co/debug-console`](https://www.npmjs.com/package/@ait-co/debug-console) | on-device attach + 인앱 eruda 콘솔 | 됨 — debug 빌드에만 들어가는 유일한 패키지 |
|
|
155
|
+
|
|
156
|
+
두 패키지 모두 devtools의 **optional peer**입니다.
|
|
157
|
+
|
|
158
|
+
- `@ait-co/debugger`가 없으면 `tunnel: { cdp: true }`는 CDP 배선을 건너뛰고 일반 화면 미리보기 터널로 degrade하며, 터미널에 설치 안내를 한 번 출력합니다.
|
|
159
|
+
- `@ait-co/debug-console`이 없으면 unplugin이 in-app attach를 아예 주입하지 않습니다 — attach 코드가 번들에 구조적으로 들어갈 수 없다는 뜻이고, 이게 디버그 표면의 기술적 경계입니다.
|
|
160
|
+
|
|
141
161
|
## Reference consumer
|
|
142
162
|
|
|
143
163
|
[`sdk-example`](https://github.com/apps-in-toss-community/sdk-example)이 devtools의 reference consumer다. 모든 SDK API를 인터랙티브하게 실행해볼 수 있는 카탈로그 앱으로, 웹 데모는 <https://sdk-example.aitc.dev/>에서 바로 확인할 수 있다. 새 mock을 추가하면 sdk-example의 카드에서 그대로 동작하는 게 1차 sanity check. 단, 이 repo의 E2E suite는 sdk-example을 clone하지 않고 **내부 자기완결 fixture(`e2e/fixture/`)** 로 운영한다 — sdk-example이 깨져도 devtools CI는 영향받지 않는다.
|
|
@@ -632,6 +652,7 @@ __ait.update({ networkStatus: 'OFFLINE' });
|
|
|
632
652
|
__ait.patch('permissions', { camera: 'denied' });
|
|
633
653
|
__ait.patch('deviceModes', { location: 'web' });
|
|
634
654
|
__ait.patch('iap', { nextResult: 'USER_CANCELED' });
|
|
655
|
+
__ait.patch('failureModes', { loadAdMob: 'PLACEMENT_ID_FETCH_FAILED' }); // 실기기 광고 지면 조회 실패 재현
|
|
635
656
|
|
|
636
657
|
// 이벤트 트리거
|
|
637
658
|
__ait.trigger('backEvent');
|
|
@@ -746,6 +767,8 @@ unsubscribe(); // 구독 해제
|
|
|
746
767
|
| `TossAds.destroy/destroyAll` | no-op |
|
|
747
768
|
| `loadFullScreenAd` / `showFullScreenAd` | GoogleAdMob과 유사한 흐름 |
|
|
748
769
|
|
|
770
|
+
> 실패 다이얼(`failureModes.loadAdMob`, 패널 `forceNoFill`)을 설정하지 않으면 위 이벤트들은 매번 동일하게 발화합니다 — mock은 `adGroupId`를 판정에 쓰지 않고, 서버 측 지면(placement) 조회 단계를 모델링하지 않습니다. 실기기는 광고가 존재하기도 전에 지면 조회 자체가 실패해(예: `PLACEMENT_ID_FETCH_FAILED`) 즉시 거부될 수 있으므로, mock의 `loaded` 발화가 "실기기에서 광고가 실제로 나간다"는 신호는 아닙니다. 로컬에서 이 실패를 재현하려면: `__ait.patch('failureModes', { loadAdMob: 'PLACEMENT_ID_FETCH_FAILED' })`.
|
|
771
|
+
|
|
749
772
|
### 이벤트
|
|
750
773
|
|
|
751
774
|
| API | Mock 동작 |
|
|
@@ -853,6 +876,69 @@ it('Storage에 값을 저장하고 읽을 수 있다', async () => {
|
|
|
853
876
|
});
|
|
854
877
|
```
|
|
855
878
|
|
|
879
|
+
## 실기기 테스트 러너 (`devtools-test`)
|
|
880
|
+
|
|
881
|
+
위 "테스트에서의 활용"이 데스크톱 jsdom에서 mock을 검증하는 경로라면, `devtools-test`는 같은 스타일의 테스트를 **실기기 토스 앱 WebView(환경 3)에서 실 SDK로** 돌리는 별도 실행 파일입니다. 패키지를 설치하면 `devtools-test` bin이 함께 들어옵니다.
|
|
882
|
+
|
|
883
|
+
```bash
|
|
884
|
+
# 형태
|
|
885
|
+
devtools-test <glob> --scheme-url <intoss-private URL> [--manual-blocking] \
|
|
886
|
+
--cell-sdk-line <2.x|3.x> --cell-platform <ios|android> [--report-dir <dir>]
|
|
887
|
+
|
|
888
|
+
# 예시
|
|
889
|
+
pnpm exec devtools-test 'src/**/*.ait.test.ts' \
|
|
890
|
+
--scheme-url "intoss-private://my-mini-app?_deploymentId=<uuid>" \
|
|
891
|
+
--cell-sdk-line 3.x --cell-platform ios --report-dir .ait-report
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
| 플래그 | 의미 |
|
|
895
|
+
|---|---|
|
|
896
|
+
| `<glob>` | 테스트 파일 glob (여러 개 지정 가능) |
|
|
897
|
+
| `--scheme-url` | `ait deploy --scheme-only`가 출력한 `intoss-private://` URL. 환경 3 attach에 필수 |
|
|
898
|
+
| `--cell-sdk-line` | 리포트에 박히는 SDK 라인 축 (`2.x` / `3.x`, 생략 시 `2.x`) |
|
|
899
|
+
| `--cell-platform` | 플랫폼 축 (`mock` / `ios` / `android` / `ios-pwa`). 해석 순서: 플래그 → `AIT_CELL_PLATFORM` env → `mock` |
|
|
900
|
+
| `--manual-blocking` | `*.manual.ait.test.ts`를 사람이 조작하며 맨 마지막에 실행 |
|
|
901
|
+
| `--report-dir` | 리포트·capture 저장 디렉토리. 생략하면 아무것도 저장하지 않습니다 |
|
|
902
|
+
|
|
903
|
+
준비물은 네 가지입니다:
|
|
904
|
+
|
|
905
|
+
| 항목 | 내용 |
|
|
906
|
+
|---|---|
|
|
907
|
+
| relay TOTP 시크릿 | 러너가 relay를 띄우기 전에 필수로 검사하며, 없으면 QR이 뜨기도 전에 exit 1 합니다. `pnpm dev:phone:cdp`(unplugin `tunnel.cdp` 옵션)를 한 번 띄우면 프로젝트 루트에 `.ait_relay`가 자동 생성되고, 없으면 `AIT_DEBUG_TOTP_SECRET`을 직접 설정하세요 (`openssl rand -hex 32`). `.ait_relay`를 찾는 디렉토리는 `--project-root`(생략 시 cwd)가 정합니다 |
|
|
908
|
+
| dog-food 번들 | `ait build && ait deploy --scheme-only` → 출력된 `intoss-private://…?_deploymentId=…` URL이 `--scheme-url` 값 |
|
|
909
|
+
| 미니앱 entry 한 줄 | `import '@ait-co/devtools/in-app/auto'` — attach + `window.__sdk` 브리지 설치 ([위 섹션](#on-device-디버깅-한-줄-설정)) |
|
|
910
|
+
| 테스트 파일 | `*.ait.test.ts`. `describe`/`it`/`test`/`expect`는 러너가 글로벌로 주입하므로 import가 필요 없고, `@apps-in-toss/web-framework` import는 번들 시 `window.__sdk`로 리다이렉트됩니다 |
|
|
911
|
+
|
|
912
|
+
### 스캔할 QR은 대시보드 QR입니다
|
|
913
|
+
|
|
914
|
+
실행하면 러너가 자체적으로 Chii relay + cloudflared 터널 + 로컬 QR 대시보드를 띄우고 그 주소를 stderr에 출력합니다 (기본 `http://127.0.0.1:8317/` — 포트가 점유돼 있으면 +1씩 최대 20개 포트를 훑고 그래도 안 되면 임의 포트. `--dashboard-port` 또는 `AIT_DEBUG_HTTP_PORT`로 변경).
|
|
915
|
+
|
|
916
|
+
**폰으로 스캔할 QR은 이 대시보드의 QR입니다.** 이 QR만 scheme URL + relay wss + 항상 실리는 회전 코드 `at=`을 한 캡슐에 담고 있어서, 한 번 스캔하면 토스 앱이 번들을 cold-load하면서 동시에 CDP가 attach됩니다. attach에 성공하면 폰 화면 좌하단에 `Debugger Connected` 배지가 뜨고 러너가 곧바로 테스트를 실행합니다.
|
|
917
|
+
|
|
918
|
+
> `ait deploy --scheme-only`가 출력한 맨 `intoss-private://` URL을 그대로 QR로 만들어 스캔하면 앱은 열리지만 **디버거는 붙지 않습니다** — `debug=1`·`relay=`가 없어 in-app gate가 attach를 막습니다. 러너는 스캔이 올 때까지 무한 대기하며(`--attach-timeout`으로 상한을 줄 수 있습니다) 그 사이 테스트는 한 줄도 실행되지 않습니다.
|
|
919
|
+
|
|
920
|
+
### 끝나면 "디버거 연결 끊김"이 뜹니다 (정상)
|
|
921
|
+
|
|
922
|
+
run-then-exit 모델입니다. 마지막 테스트 파일이 끝나면 러너는 요약을 출력하고 relay·터널·대시보드를 정리한 뒤 종료하며(실패한 테스트가 있으면 exit code 1), 그 순간 폰의 배지가 "디버거 연결 끊김"으로 바뀌었다가 잠시 후 사라집니다. 디버그 세션이 닫힌 것이지 앱이 죽은 게 아니라서 미니앱 자체는 계속 떠 있습니다. 다시 돌리려면 러너를 재실행하고 새 대시보드 QR을 스캔하세요.
|
|
923
|
+
|
|
924
|
+
### 네이티브 시트가 뜨는 테스트 (`--manual-blocking`)
|
|
925
|
+
|
|
926
|
+
사진 선택기·권한 다이얼로그·전면 광고처럼 사람이 직접 눌러야 넘어가는 테스트는 파일명을 `*.manual.ait.test.ts`로 둡니다. 이 파일들은 기본 실행에서 **제외**되고 `--manual-blocking`을 줄 때만 포함되며, 일반 파일이 전부 끝난 뒤 **맨 마지막에** 실행됩니다. 각 수동 파일 직전에 대시보드와 stdout에 파일명 + 진행도(k/n) 안내가 뜨고, 파일당 타임아웃도 5분으로 늘어납니다.
|
|
927
|
+
|
|
928
|
+
### 산출물 (`--report-dir`)
|
|
929
|
+
|
|
930
|
+
| 경로 | 내용 |
|
|
931
|
+
|---|---|
|
|
932
|
+
| `<dir>/<sdkLine>.<platform>.json` | 러너 중립 리포트. 파일 경로는 프로젝트 루트 기준 상대 경로이고 relay·scheme·TOTP 값은 담기지 않습니다 |
|
|
933
|
+
| `<dir>/<sdkLine>.<platform>.manual.json` | `--manual-blocking` 실행에 포함된 수동 파일들 — 표준 리포트를 대체하지 않고 나란히 기록됩니다 |
|
|
934
|
+
| `<dir>/.ait-capture/<category>.<sdkLine>.<platform>.json` | 테스트가 남긴 `__AIT_CAPTURE__` 라인 (2.x↔3.0 오프라인 비교용) |
|
|
935
|
+
|
|
936
|
+
`<sdkLine>`·`<platform>`은 `--cell-sdk-line`·`--cell-platform` 값이 그대로 들어갑니다. `--report-dir`을 생략하면 리포트도 capture도 수집하지 않습니다.
|
|
937
|
+
|
|
938
|
+
### 나머지 플래그
|
|
939
|
+
|
|
940
|
+
전체 플래그 레퍼런스는 `devtools-test --help`가 정본입니다. attach/실행 타임아웃, 브리지 호출 pacing, 환경 2(launcher PWA)에 붙는 `--attach-launcher --app-url` 모드처럼 위에 없는 것은 그쪽에서 확인하세요.
|
|
941
|
+
|
|
856
942
|
## SDK 업데이트 대응
|
|
857
943
|
|
|
858
944
|
devtools는 [`@apps-in-toss/web-framework`](https://www.npmjs.com/package/@apps-in-toss/web-framework)를 추적하고, [`sdk-example`](https://github.com/apps-in-toss-community/sdk-example)은 원본 SDK와 devtools를 모두 추적한다. 즉 새 SDK 버전이 나오면 (1) devtools가 mock/타입 시그니처를 따라잡고 → (2) sdk-example이 양쪽 새 버전을 동시에 반영하는 흐름. devtools 단독 PR이 sdk-example을 깨뜨리면 양쪽을 함께 본다.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chii-relay-BZ3HqWL5.js","names":[],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * WS keepalive ping interval (ms).\n *\n * Cloudflare proxied connections are dropped after ~100 s of no traffic.\n * 45 s comfortably fits inside that window and lets both the phone-target leg\n * and the daemon-client leg survive idle CDP sessions.\n */\nconst DEFAULT_KEEPALIVE_INTERVAL_MS = 45_000;\n\n/**\n * Minimal shape of chii's internal WebSocketServer instance.\n *\n * `chii/server/lib/WebSocketServer` holds the real `ws.Server` in `_wss`.\n * `_wss.clients` is the standard `Set<WebSocket>` tracking all live sockets.\n * We access this to ping every connected socket — no chii internals beyond\n * this single field are touched.\n */\ninterface ChiiInternalWss {\n _wss: { clients: Set<WebSocket> };\n start(server: import('node:http').Server): void;\n}\n\n/**\n * Loads chii's internal WebSocketServer class and returns it together with a\n * flag indicating whether the real class was found.\n *\n * Returns `null` if the internal path is not resolvable (future chii release\n * changes the layout) — callers skip keepalive gracefully.\n */\nfunction tryLoadChiiWssClass(): (new () => ChiiInternalWss) | null {\n try {\n const mod: unknown = require('chii/server/lib/WebSocketServer');\n if (typeof mod === 'function') {\n return mod as new () => ChiiInternalWss;\n }\n } catch {\n // Module not found or shape changed — keepalive will be skipped.\n }\n return null;\n}\n\n/**\n * Calls `chii.start()` and returns the chii `WebSocketServer` instance that\n * was constructed during the call.\n *\n * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`\n * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`\n * at module load time. The class reference is stable, so we can temporarily\n * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —\n * to record `this` before the original `start` runs.\n *\n * The patch is installed before `chii.start()` and removed (via `finally`)\n * immediately after, so concurrent `startChiiRelay` calls nest correctly: each\n * call's patch overrides the previous in the prototype chain for the duration\n * of its own `chii.start()` call, restoring the prior descriptor on exit.\n *\n * If `ChiiWssClass` is null (internal path changed in a future chii release),\n * `chii.start()` runs unpatched and the function returns null — callers skip\n * keepalive gracefully without affecting relay correctness.\n */\nasync function startChiiWithCapture(\n chii: ChiiServerModule,\n startOptions: Parameters<ChiiServerModule['start']>[0],\n ChiiWssClass: (new () => ChiiInternalWss) | null,\n): Promise<ChiiInternalWss | null> {\n if (ChiiWssClass === null) {\n await chii.start(startOptions);\n return null;\n }\n\n let captured: ChiiInternalWss | null = null;\n const proto = ChiiWssClass.prototype as ChiiInternalWss;\n const originalStart = proto.start;\n\n proto.start = function (this: ChiiInternalWss, server) {\n captured = this;\n return originalStart.call(this, server);\n };\n\n try {\n await chii.start(startOptions);\n } finally {\n // Always restore — even if chii.start() throws.\n proto.start = originalStart;\n }\n\n return captured;\n}\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n /**\n * WS protocol ping interval in milliseconds (issue #483).\n *\n * The relay sends a ping frame to every connected WebSocket at this interval\n * so that Cloudflare's proxied-connection idle timer (~100 s) is reset for\n * both the phone-target leg and the daemon-client leg. The peer responds with\n * a pong automatically (browser / ws library behaviour) — no application\n * code change is needed on either end.\n *\n * Default: 45 000 ms (45 s). Set to 0 to disable keepalive entirely.\n *\n * Pass a small value in tests to avoid real-time waits — pair with fake\n * timers (`vi.useFakeTimers()`) or a short sleep.\n */\n keepaliveIntervalMs?: number;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n const keepaliveIntervalMs =\n options.keepaliveIntervalMs !== undefined\n ? options.keepaliveIntervalMs\n : DEFAULT_KEEPALIVE_INTERVAL_MS;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: two cases are gated, everything else passes through.\n //\n // Case 1 — path-prefixed form (`/at/<code>/…`): the phone fetches\n // `target.js` via `https://<host>/at/<code>/target.js` (issue #466).\n // The prefix is rewritten to the query form and then verified; chii's Koa\n // static handler sees the stripped URL.\n //\n // Case 2 — `/targets` read route (issue #474): without gating this route a\n // URL-leaker (threat model: someone who obtained the tunnel URL but not\n // the secret) can read session metadata (id/url/title, including any query\n // params in the page URL) without a code. Debugger attach stays blocked by\n // the WS gate, but /targets is an HTTP read that was previously ungated.\n // The `at` code may arrive as a query param (`/targets?at=<code>`) — which\n // buildRelayVerifyAuth already handles — or via the `/at/<code>/` path\n // prefix (rewriteAtPathPrefix normalises that to the query form first).\n //\n // Static assets (target.js, chii front-end HTML/JS/CSS) and any other\n // non-prefixed, non-/targets request keep today's ungated pass-through — the\n // phone fetches some via the legacy no-prefix path and gating them would\n // break env-2/3/4.\n //\n // SECRET-HANDLING: We do NOT log req.url or any auth value in this listener.\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n // Path-prefix form: normalise to query form, then verify.\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed (or was rejected above): return so the path-prefix branch\n // never falls through to the /targets check below.\n return;\n }\n\n // Non-prefixed request: check if this is the /targets read route (issue\n // #474). Extract the pathname robustly — req.url is a raw path+query\n // string like `/targets?at=123456` so we split on `?`.\n const pathname = (req.url ?? '').split('?')[0];\n if (pathname === '/targets' || pathname === '/targets/') {\n // The `at` code must be present as a query param — verifyAuth reads it\n // from req.url via URLSearchParams, which already handles `?at=<code>`\n // without any URL rewrite needed.\n if (!verifyAuth(req)) {\n // Same 401 shape as the path-prefix branch (issue #478 contract).\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n // res.end() wins — chii's Koa handler will not write.\n return;\n }\n // Auth passed: return without ending the response. Node invokes every\n // 'request' listener in registration order, so chii's Koa listener\n // (registered later by chii.start) still runs and serves the /targets\n // JSON — this return only means \"this gate listener is done\", not \"end\n // the response\".\n return;\n }\n\n // Any other non-prefixed request (static assets, chii front-end, etc.):\n // ungated pass-through to chii. Auth passed: no-op — chii's Koa\n // 'request' listener (registered below by chii.start) serves the URL.\n // (Koa skips writing when an earlier listener already ended the response,\n // so the 401 paths above are safe even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n // devtools#755: 업그레이드된 WebSocket 소켓(폰-target leg + daemon/\n // relay-worker client leg — 위 keepalive 주석과 동일 대상)은 일단\n // 'upgrade'로 넘어가면 Node의 HTTP 서버 커넥션 트래킹에서 빠져나가,\n // `httpServer.close()`(그리고 `closeAllConnections()`도)가 더 이상\n // 그 소켓을 보지 못한다 — 열려 있는 CDP WS 연결이 하나라도 있으면\n // close 콜백이 영원히 안 불려 devtools-test CLI가 종료되지 않는다\n // (run7~10 재현). chii의 `_wss.clients`(위에서 이미 keepalive ping에\n // 쓰는 것과 같은 Set)를 순회해 명시적으로 terminate한 뒤 close한다.\n // capturedChiiWss가 null(캡처 실패/keepalive 비활성)이어도 안전 —\n // 이 경로에선 그냥 스킵하고 기존 close만 수행(회귀 없음).\n if (capturedChiiWss !== null) {\n for (const client of capturedChiiWss._wss.clients) {\n client.terminate();\n }\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"chii-relay-B3ZhjGMi.js","names":[],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * WS keepalive ping interval (ms).\n *\n * Cloudflare proxied connections are dropped after ~100 s of no traffic.\n * 45 s comfortably fits inside that window and lets both the phone-target leg\n * and the daemon-client leg survive idle CDP sessions.\n */\nconst DEFAULT_KEEPALIVE_INTERVAL_MS = 45_000;\n\n/**\n * Minimal shape of chii's internal WebSocketServer instance.\n *\n * `chii/server/lib/WebSocketServer` holds the real `ws.Server` in `_wss`.\n * `_wss.clients` is the standard `Set<WebSocket>` tracking all live sockets.\n * We access this to ping every connected socket — no chii internals beyond\n * this single field are touched.\n */\ninterface ChiiInternalWss {\n _wss: { clients: Set<WebSocket> };\n start(server: import('node:http').Server): void;\n}\n\n/**\n * Loads chii's internal WebSocketServer class and returns it together with a\n * flag indicating whether the real class was found.\n *\n * Returns `null` if the internal path is not resolvable (future chii release\n * changes the layout) — callers skip keepalive gracefully.\n */\nfunction tryLoadChiiWssClass(): (new () => ChiiInternalWss) | null {\n try {\n const mod: unknown = require('chii/server/lib/WebSocketServer');\n if (typeof mod === 'function') {\n return mod as new () => ChiiInternalWss;\n }\n } catch {\n // Module not found or shape changed — keepalive will be skipped.\n }\n return null;\n}\n\n/**\n * Calls `chii.start()` and returns the chii `WebSocketServer` instance that\n * was constructed during the call.\n *\n * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`\n * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`\n * at module load time. The class reference is stable, so we can temporarily\n * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —\n * to record `this` before the original `start` runs.\n *\n * The patch is installed before `chii.start()` and removed (via `finally`)\n * immediately after, so concurrent `startChiiRelay` calls nest correctly: each\n * call's patch overrides the previous in the prototype chain for the duration\n * of its own `chii.start()` call, restoring the prior descriptor on exit.\n *\n * If `ChiiWssClass` is null (internal path changed in a future chii release),\n * `chii.start()` runs unpatched and the function returns null — callers skip\n * keepalive gracefully without affecting relay correctness.\n */\nasync function startChiiWithCapture(\n chii: ChiiServerModule,\n startOptions: Parameters<ChiiServerModule['start']>[0],\n ChiiWssClass: (new () => ChiiInternalWss) | null,\n): Promise<ChiiInternalWss | null> {\n if (ChiiWssClass === null) {\n await chii.start(startOptions);\n return null;\n }\n\n let captured: ChiiInternalWss | null = null;\n const proto = ChiiWssClass.prototype as ChiiInternalWss;\n const originalStart = proto.start;\n\n proto.start = function (this: ChiiInternalWss, server) {\n captured = this;\n return originalStart.call(this, server);\n };\n\n try {\n await chii.start(startOptions);\n } finally {\n // Always restore — even if chii.start() throws.\n proto.start = originalStart;\n }\n\n return captured;\n}\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n /**\n * WS protocol ping interval in milliseconds (issue #483).\n *\n * The relay sends a ping frame to every connected WebSocket at this interval\n * so that Cloudflare's proxied-connection idle timer (~100 s) is reset for\n * both the phone-target leg and the daemon-client leg. The peer responds with\n * a pong automatically (browser / ws library behaviour) — no application\n * code change is needed on either end.\n *\n * Default: 45 000 ms (45 s). Set to 0 to disable keepalive entirely.\n *\n * Pass a small value in tests to avoid real-time waits — pair with fake\n * timers (`vi.useFakeTimers()`) or a short sleep.\n */\n keepaliveIntervalMs?: number;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n const keepaliveIntervalMs =\n options.keepaliveIntervalMs !== undefined\n ? options.keepaliveIntervalMs\n : DEFAULT_KEEPALIVE_INTERVAL_MS;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: two cases are gated, everything else passes through.\n //\n // Case 1 — path-prefixed form (`/at/<code>/…`): the phone fetches\n // `target.js` via `https://<host>/at/<code>/target.js` (issue #466).\n // The prefix is rewritten to the query form and then verified; chii's Koa\n // static handler sees the stripped URL.\n //\n // Case 2 — `/targets` read route (issue #474): without gating this route a\n // URL-leaker (threat model: someone who obtained the tunnel URL but not\n // the secret) can read session metadata (id/url/title, including any query\n // params in the page URL) without a code. Debugger attach stays blocked by\n // the WS gate, but /targets is an HTTP read that was previously ungated.\n // The `at` code may arrive as a query param (`/targets?at=<code>`) — which\n // buildRelayVerifyAuth already handles — or via the `/at/<code>/` path\n // prefix (rewriteAtPathPrefix normalises that to the query form first).\n //\n // Static assets (target.js, chii front-end HTML/JS/CSS) and any other\n // non-prefixed, non-/targets request keep today's ungated pass-through — the\n // phone fetches some via the legacy no-prefix path and gating them would\n // break env-2/3/4.\n //\n // SECRET-HANDLING: We do NOT log req.url or any auth value in this listener.\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n // Path-prefix form: normalise to query form, then verify.\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed (or was rejected above): return so the path-prefix branch\n // never falls through to the /targets check below.\n return;\n }\n\n // Non-prefixed request: check if this is the /targets read route (issue\n // #474). Extract the pathname robustly — req.url is a raw path+query\n // string like `/targets?at=123456` so we split on `?`.\n const pathname = (req.url ?? '').split('?')[0];\n if (pathname === '/targets' || pathname === '/targets/') {\n // The `at` code must be present as a query param — verifyAuth reads it\n // from req.url via URLSearchParams, which already handles `?at=<code>`\n // without any URL rewrite needed.\n if (!verifyAuth(req)) {\n // Same 401 shape as the path-prefix branch (issue #478 contract).\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n // res.end() wins — chii's Koa handler will not write.\n return;\n }\n // Auth passed: return without ending the response. Node invokes every\n // 'request' listener in registration order, so chii's Koa listener\n // (registered later by chii.start) still runs and serves the /targets\n // JSON — this return only means \"this gate listener is done\", not \"end\n // the response\".\n return;\n }\n\n // Any other non-prefixed request (static assets, chii front-end, etc.):\n // ungated pass-through to chii. Auth passed: no-op — chii's Koa\n // 'request' listener (registered below by chii.start) serves the URL.\n // (Koa skips writing when an earlier listener already ended the response,\n // so the 401 paths above are safe even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n // devtools#755: 업그레이드된 WebSocket 소켓(폰-target leg + daemon/\n // relay-worker client leg — 위 keepalive 주석과 동일 대상)은 일단\n // 'upgrade'로 넘어가면 Node의 HTTP 서버 커넥션 트래킹에서 빠져나가,\n // `httpServer.close()`(그리고 `closeAllConnections()`도)가 더 이상\n // 그 소켓을 보지 못한다 — 열려 있는 CDP WS 연결이 하나라도 있으면\n // close 콜백이 영원히 안 불려 devtools-test CLI가 종료되지 않는다\n // (run7~10 재현). chii의 `_wss.clients`(위에서 이미 keepalive ping에\n // 쓰는 것과 같은 Set)를 순회해 명시적으로 terminate한 뒤 close한다.\n // capturedChiiWss가 null(캡처 실패/keepalive 비활성)이어도 안전 —\n // 이 경로에선 그냥 스킵하고 기존 close만 수행(회귀 없음).\n if (capturedChiiWss !== null) {\n for (const client of capturedChiiWss._wss.clients) {\n client.terminate();\n }\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,+BAA+B;;;;;;AAO5C,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACiCxC,MAAM,UAAU,cAAc,OAAO,KAAK,IAAI;;;;;;;;AAS9C,MAAM,gCAAgC;;;;;;;;AAsBtC,SAAS,sBAA0D;AACjE,KAAI;EACF,MAAM,MAAe,QAAQ,kCAAkC;AAC/D,MAAI,OAAO,QAAQ,WACjB,QAAO;SAEH;AAGR,QAAO;;;;;;;;;;;;;;;;;;;;;AAsBT,eAAe,qBACb,MACA,cACA,cACiC;AACjC,KAAI,iBAAiB,MAAM;AACzB,QAAM,KAAK,MAAM,aAAa;AAC9B,SAAO;;CAGT,IAAI,WAAmC;CACvC,MAAM,QAAQ,aAAa;CAC3B,MAAM,gBAAgB,MAAM;AAE5B,OAAM,QAAQ,SAAiC,QAAQ;AACrD,aAAW;AACX,SAAO,cAAc,KAAK,MAAM,OAAO;;AAGzC,KAAI;AACF,QAAM,KAAK,MAAM,aAAa;WACtB;AAER,QAAM,QAAQ;;AAGhB,QAAO;;AAcT,SAAS,iBAAmC;CAE1C,MAAM,MAAe,QAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;;;;AAyC9D,SAAgB,oBAAoB,QAA+B;CACjE,MAAM,QAAQ,oCAAoC,KAAK,OAAO;AAC9D,KAAI,UAAU,KAAM,QAAO;CAC3B,MAAM,OAAO,MAAM;CACnB,MAAM,OAAO,MAAM,OAAO,KAAA,KAAa,MAAM,OAAO,KAAK,MAAM,MAAM;CACrE,MAAM,QAAQ,MAAM,MAAM;AAE1B,QAAO,GAAG,OAAO,QADC,UAAU,KAAK,MAAM,IACJ,KAAK;;;;;;;;;;;;;;;;;;AA+E1C,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,YAAY,iBAAiB;CACrC,MAAM,sBACJ,QAAQ,wBAAwB,KAAA,IAC5B,QAAQ,sBACR;CAEN,MAAM,aAAa,cAAc;CAIjC,MAAM,oBAAoB,SAA6C;AACrE,MAAI,iBAAiB,KAAA,EAAW;AAChC,MAAI;AACF,gBAAa,EAAE,MAAM,CAAC;UAChB;;AAiBV,KAAI,WAuBF,YAAW,GAAG,YAAY,KAAK,QAAQ;EACrC,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,MAAI,cAAc,MAAM;AAEtB,OAAI,MAAM;AACV,OAAI,CAAC,WAAW,IAAI,EAAE;AAMpB,QAAI,aAAa;AACjB,QAAI,UAAU,+BAA+B,IAAI;AACjD,QAAI,UAAU,gBAAgB,mBAAmB;AACjD,QAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,qBAAiB,eAAe;;AAIlC;;EAMF,MAAM,YAAY,IAAI,OAAO,IAAI,MAAM,IAAI,CAAC;AAC5C,MAAI,aAAa,cAAc,aAAa,aAAa;AAIvD,OAAI,CAAC,WAAW,IAAI,EAAE;AAEpB,QAAI,aAAa;AACjB,QAAI,UAAU,+BAA+B,IAAI;AACjD,QAAI,UAAU,gBAAgB,mBAAmB;AACjD,QAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,qBAAiB,eAAe;AAEhC;;AAOF;;GAQF;CAgBJ,MAAM,eAAe,sBAAsB,IAAI,qBAAqB,GAAG;CACvE,MAAM,kBAAkB,MAAM,qBAC5B,gBAAgB,EAChB;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,EAC/E,aACD;AAUD,KAAI,YAAY;EACd,MAAM,uBAAuB,WAAW,UAAU,UAAU;AAG5D,aAAW,mBAAmB,UAAU;EAGxC,MAAM,YAAY,IAAI,gBAAgB,EAAE,UAAU,MAAM,CAAC;AACzD,aAAW,GAAG,YAAY,KAAsB,QAAgB,SAAiB;GAK/E,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,OAAI,cAAc,KAChB,KAAI,MAAM;AAEZ,OAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,cAAU,cAAc,KAAK,QAAQ,OAAO,OAAO;AACjD,QAAG,MAAM,8BAA8B,yBAAyB;MAChE;AACF,qBAAiB,aAAa;AAE9B;;AAIF,QAAK,MAAM,YAAY,qBACrB,UAAS,KAAK,QAAQ,KAAK;IAE7B;;CAGJ,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;CAYF,IAAI,kBAAyD;AAC7D,KAAI,sBAAsB,KAAK,oBAAoB,MAAM;EACvD,MAAM,UAAU;AAChB,oBAAkB,kBAAkB;AAClC,QAAK,MAAM,UAAU,QAAQ,KAAK,QAEhC,KAAI,OAAO,eAAe,EACxB,QAAO,MAAM;KAGhB,oBAAoB;;AAGzB,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,OAAI,oBAAoB,MAAM;AAC5B,kBAAc,gBAAgB;AAC9B,sBAAkB;;AAYpB,OAAI,oBAAoB,KACtB,MAAK,MAAM,UAAU,gBAAgB,KAAK,QACxC,QAAO,WAAW;AAGtB,cAAW,YAAY,SAAS,CAAC;IACjC;EACL"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chii-relay-D7eK2acz.cjs","names":["require","WebSocketServer"],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * WS keepalive ping interval (ms).\n *\n * Cloudflare proxied connections are dropped after ~100 s of no traffic.\n * 45 s comfortably fits inside that window and lets both the phone-target leg\n * and the daemon-client leg survive idle CDP sessions.\n */\nconst DEFAULT_KEEPALIVE_INTERVAL_MS = 45_000;\n\n/**\n * Minimal shape of chii's internal WebSocketServer instance.\n *\n * `chii/server/lib/WebSocketServer` holds the real `ws.Server` in `_wss`.\n * `_wss.clients` is the standard `Set<WebSocket>` tracking all live sockets.\n * We access this to ping every connected socket — no chii internals beyond\n * this single field are touched.\n */\ninterface ChiiInternalWss {\n _wss: { clients: Set<WebSocket> };\n start(server: import('node:http').Server): void;\n}\n\n/**\n * Loads chii's internal WebSocketServer class and returns it together with a\n * flag indicating whether the real class was found.\n *\n * Returns `null` if the internal path is not resolvable (future chii release\n * changes the layout) — callers skip keepalive gracefully.\n */\nfunction tryLoadChiiWssClass(): (new () => ChiiInternalWss) | null {\n try {\n const mod: unknown = require('chii/server/lib/WebSocketServer');\n if (typeof mod === 'function') {\n return mod as new () => ChiiInternalWss;\n }\n } catch {\n // Module not found or shape changed — keepalive will be skipped.\n }\n return null;\n}\n\n/**\n * Calls `chii.start()` and returns the chii `WebSocketServer` instance that\n * was constructed during the call.\n *\n * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`\n * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`\n * at module load time. The class reference is stable, so we can temporarily\n * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —\n * to record `this` before the original `start` runs.\n *\n * The patch is installed before `chii.start()` and removed (via `finally`)\n * immediately after, so concurrent `startChiiRelay` calls nest correctly: each\n * call's patch overrides the previous in the prototype chain for the duration\n * of its own `chii.start()` call, restoring the prior descriptor on exit.\n *\n * If `ChiiWssClass` is null (internal path changed in a future chii release),\n * `chii.start()` runs unpatched and the function returns null — callers skip\n * keepalive gracefully without affecting relay correctness.\n */\nasync function startChiiWithCapture(\n chii: ChiiServerModule,\n startOptions: Parameters<ChiiServerModule['start']>[0],\n ChiiWssClass: (new () => ChiiInternalWss) | null,\n): Promise<ChiiInternalWss | null> {\n if (ChiiWssClass === null) {\n await chii.start(startOptions);\n return null;\n }\n\n let captured: ChiiInternalWss | null = null;\n const proto = ChiiWssClass.prototype as ChiiInternalWss;\n const originalStart = proto.start;\n\n proto.start = function (this: ChiiInternalWss, server) {\n captured = this;\n return originalStart.call(this, server);\n };\n\n try {\n await chii.start(startOptions);\n } finally {\n // Always restore — even if chii.start() throws.\n proto.start = originalStart;\n }\n\n return captured;\n}\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n /**\n * WS protocol ping interval in milliseconds (issue #483).\n *\n * The relay sends a ping frame to every connected WebSocket at this interval\n * so that Cloudflare's proxied-connection idle timer (~100 s) is reset for\n * both the phone-target leg and the daemon-client leg. The peer responds with\n * a pong automatically (browser / ws library behaviour) — no application\n * code change is needed on either end.\n *\n * Default: 45 000 ms (45 s). Set to 0 to disable keepalive entirely.\n *\n * Pass a small value in tests to avoid real-time waits — pair with fake\n * timers (`vi.useFakeTimers()`) or a short sleep.\n */\n keepaliveIntervalMs?: number;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n const keepaliveIntervalMs =\n options.keepaliveIntervalMs !== undefined\n ? options.keepaliveIntervalMs\n : DEFAULT_KEEPALIVE_INTERVAL_MS;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: two cases are gated, everything else passes through.\n //\n // Case 1 — path-prefixed form (`/at/<code>/…`): the phone fetches\n // `target.js` via `https://<host>/at/<code>/target.js` (issue #466).\n // The prefix is rewritten to the query form and then verified; chii's Koa\n // static handler sees the stripped URL.\n //\n // Case 2 — `/targets` read route (issue #474): without gating this route a\n // URL-leaker (threat model: someone who obtained the tunnel URL but not\n // the secret) can read session metadata (id/url/title, including any query\n // params in the page URL) without a code. Debugger attach stays blocked by\n // the WS gate, but /targets is an HTTP read that was previously ungated.\n // The `at` code may arrive as a query param (`/targets?at=<code>`) — which\n // buildRelayVerifyAuth already handles — or via the `/at/<code>/` path\n // prefix (rewriteAtPathPrefix normalises that to the query form first).\n //\n // Static assets (target.js, chii front-end HTML/JS/CSS) and any other\n // non-prefixed, non-/targets request keep today's ungated pass-through — the\n // phone fetches some via the legacy no-prefix path and gating them would\n // break env-2/3/4.\n //\n // SECRET-HANDLING: We do NOT log req.url or any auth value in this listener.\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n // Path-prefix form: normalise to query form, then verify.\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed (or was rejected above): return so the path-prefix branch\n // never falls through to the /targets check below.\n return;\n }\n\n // Non-prefixed request: check if this is the /targets read route (issue\n // #474). Extract the pathname robustly — req.url is a raw path+query\n // string like `/targets?at=123456` so we split on `?`.\n const pathname = (req.url ?? '').split('?')[0];\n if (pathname === '/targets' || pathname === '/targets/') {\n // The `at` code must be present as a query param — verifyAuth reads it\n // from req.url via URLSearchParams, which already handles `?at=<code>`\n // without any URL rewrite needed.\n if (!verifyAuth(req)) {\n // Same 401 shape as the path-prefix branch (issue #478 contract).\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n // res.end() wins — chii's Koa handler will not write.\n return;\n }\n // Auth passed: return without ending the response. Node invokes every\n // 'request' listener in registration order, so chii's Koa listener\n // (registered later by chii.start) still runs and serves the /targets\n // JSON — this return only means \"this gate listener is done\", not \"end\n // the response\".\n return;\n }\n\n // Any other non-prefixed request (static assets, chii front-end, etc.):\n // ungated pass-through to chii. Auth passed: no-op — chii's Koa\n // 'request' listener (registered below by chii.start) serves the URL.\n // (Koa skips writing when an earlier listener already ended the response,\n // so the 401 paths above are safe even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n // devtools#755: 업그레이드된 WebSocket 소켓(폰-target leg + daemon/\n // relay-worker client leg — 위 keepalive 주석과 동일 대상)은 일단\n // 'upgrade'로 넘어가면 Node의 HTTP 서버 커넥션 트래킹에서 빠져나가,\n // `httpServer.close()`(그리고 `closeAllConnections()`도)가 더 이상\n // 그 소켓을 보지 못한다 — 열려 있는 CDP WS 연결이 하나라도 있으면\n // close 콜백이 영원히 안 불려 devtools-test CLI가 종료되지 않는다\n // (run7~10 재현). chii의 `_wss.clients`(위에서 이미 keepalive ping에\n // 쓰는 것과 같은 Set)를 순회해 명시적으로 terminate한 뒤 close한다.\n // capturedChiiWss가 null(캡처 실패/keepalive 비활성)이어도 안전 —\n // 이 경로에선 그냥 스킵하고 기존 close만 수행(회귀 없음).\n if (capturedChiiWss !== null) {\n for (const client of capturedChiiWss._wss.clients) {\n client.terminate();\n }\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"chii-relay-CGMlePMd.cjs","names":["require","WebSocketServer"],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/**\n * WS keepalive ping interval (ms).\n *\n * Cloudflare proxied connections are dropped after ~100 s of no traffic.\n * 45 s comfortably fits inside that window and lets both the phone-target leg\n * and the daemon-client leg survive idle CDP sessions.\n */\nconst DEFAULT_KEEPALIVE_INTERVAL_MS = 45_000;\n\n/**\n * Minimal shape of chii's internal WebSocketServer instance.\n *\n * `chii/server/lib/WebSocketServer` holds the real `ws.Server` in `_wss`.\n * `_wss.clients` is the standard `Set<WebSocket>` tracking all live sockets.\n * We access this to ping every connected socket — no chii internals beyond\n * this single field are touched.\n */\ninterface ChiiInternalWss {\n _wss: { clients: Set<WebSocket> };\n start(server: import('node:http').Server): void;\n}\n\n/**\n * Loads chii's internal WebSocketServer class and returns it together with a\n * flag indicating whether the real class was found.\n *\n * Returns `null` if the internal path is not resolvable (future chii release\n * changes the layout) — callers skip keepalive gracefully.\n */\nfunction tryLoadChiiWssClass(): (new () => ChiiInternalWss) | null {\n try {\n const mod: unknown = require('chii/server/lib/WebSocketServer');\n if (typeof mod === 'function') {\n return mod as new () => ChiiInternalWss;\n }\n } catch {\n // Module not found or shape changed — keepalive will be skipped.\n }\n return null;\n}\n\n/**\n * Calls `chii.start()` and returns the chii `WebSocketServer` instance that\n * was constructed during the call.\n *\n * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`\n * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`\n * at module load time. The class reference is stable, so we can temporarily\n * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —\n * to record `this` before the original `start` runs.\n *\n * The patch is installed before `chii.start()` and removed (via `finally`)\n * immediately after, so concurrent `startChiiRelay` calls nest correctly: each\n * call's patch overrides the previous in the prototype chain for the duration\n * of its own `chii.start()` call, restoring the prior descriptor on exit.\n *\n * If `ChiiWssClass` is null (internal path changed in a future chii release),\n * `chii.start()` runs unpatched and the function returns null — callers skip\n * keepalive gracefully without affecting relay correctness.\n */\nasync function startChiiWithCapture(\n chii: ChiiServerModule,\n startOptions: Parameters<ChiiServerModule['start']>[0],\n ChiiWssClass: (new () => ChiiInternalWss) | null,\n): Promise<ChiiInternalWss | null> {\n if (ChiiWssClass === null) {\n await chii.start(startOptions);\n return null;\n }\n\n let captured: ChiiInternalWss | null = null;\n const proto = ChiiWssClass.prototype as ChiiInternalWss;\n const originalStart = proto.start;\n\n proto.start = function (this: ChiiInternalWss, server) {\n captured = this;\n return originalStart.call(this, server);\n };\n\n try {\n await chii.start(startOptions);\n } finally {\n // Always restore — even if chii.start() throws.\n proto.start = originalStart;\n }\n\n return captured;\n}\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n /**\n * WS protocol ping interval in milliseconds (issue #483).\n *\n * The relay sends a ping frame to every connected WebSocket at this interval\n * so that Cloudflare's proxied-connection idle timer (~100 s) is reset for\n * both the phone-target leg and the daemon-client leg. The peer responds with\n * a pong automatically (browser / ws library behaviour) — no application\n * code change is needed on either end.\n *\n * Default: 45 000 ms (45 s). Set to 0 to disable keepalive entirely.\n *\n * Pass a small value in tests to avoid real-time waits — pair with fake\n * timers (`vi.useFakeTimers()`) or a short sleep.\n */\n keepaliveIntervalMs?: number;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n const keepaliveIntervalMs =\n options.keepaliveIntervalMs !== undefined\n ? options.keepaliveIntervalMs\n : DEFAULT_KEEPALIVE_INTERVAL_MS;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: two cases are gated, everything else passes through.\n //\n // Case 1 — path-prefixed form (`/at/<code>/…`): the phone fetches\n // `target.js` via `https://<host>/at/<code>/target.js` (issue #466).\n // The prefix is rewritten to the query form and then verified; chii's Koa\n // static handler sees the stripped URL.\n //\n // Case 2 — `/targets` read route (issue #474): without gating this route a\n // URL-leaker (threat model: someone who obtained the tunnel URL but not\n // the secret) can read session metadata (id/url/title, including any query\n // params in the page URL) without a code. Debugger attach stays blocked by\n // the WS gate, but /targets is an HTTP read that was previously ungated.\n // The `at` code may arrive as a query param (`/targets?at=<code>`) — which\n // buildRelayVerifyAuth already handles — or via the `/at/<code>/` path\n // prefix (rewriteAtPathPrefix normalises that to the query form first).\n //\n // Static assets (target.js, chii front-end HTML/JS/CSS) and any other\n // non-prefixed, non-/targets request keep today's ungated pass-through — the\n // phone fetches some via the legacy no-prefix path and gating them would\n // break env-2/3/4.\n //\n // SECRET-HANDLING: We do NOT log req.url or any auth value in this listener.\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n // Path-prefix form: normalise to query form, then verify.\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed (or was rejected above): return so the path-prefix branch\n // never falls through to the /targets check below.\n return;\n }\n\n // Non-prefixed request: check if this is the /targets read route (issue\n // #474). Extract the pathname robustly — req.url is a raw path+query\n // string like `/targets?at=123456` so we split on `?`.\n const pathname = (req.url ?? '').split('?')[0];\n if (pathname === '/targets' || pathname === '/targets/') {\n // The `at` code must be present as a query param — verifyAuth reads it\n // from req.url via URLSearchParams, which already handles `?at=<code>`\n // without any URL rewrite needed.\n if (!verifyAuth(req)) {\n // Same 401 shape as the path-prefix branch (issue #478 contract).\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n // res.end() wins — chii's Koa handler will not write.\n return;\n }\n // Auth passed: return without ending the response. Node invokes every\n // 'request' listener in registration order, so chii's Koa listener\n // (registered later by chii.start) still runs and serves the /targets\n // JSON — this return only means \"this gate listener is done\", not \"end\n // the response\".\n return;\n }\n\n // Any other non-prefixed request (static assets, chii front-end, etc.):\n // ungated pass-through to chii. Auth passed: no-op — chii's Koa\n // 'request' listener (registered below by chii.start) serves the URL.\n // (Koa skips writing when an earlier listener already ended the response,\n // so the 401 paths above are safe even though Koa still runs.)\n });\n }\n\n // WS keepalive (issue #483): capture chii's WebSocketServer instance so we\n // can read `_wss.clients` and send periodic ping frames.\n //\n // `chii/server/index.js`'s start() creates `new WebSocketServer()` but\n // doesn't expose the instance. We capture it by temporarily patching\n // `ChiiWssClass.prototype.start` — that method runs on the instance, so\n // `this` gives us the reference we need.\n //\n // The patch is installed for the duration of one `chii.start()` call and\n // removed in a `finally` block, so concurrent relays nest correctly. If the\n // internal path changes in a future chii release (tryLoadChiiWssClass returns\n // null), chii.start() runs unpatched and the keepalive loop is silently\n // skipped — relay correctness is unaffected.\n const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;\n const capturedChiiWss = await startChiiWithCapture(\n loadChiiServer(),\n { server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort },\n chiiWssClass,\n );\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n // WS keepalive interval (issue #483): send a ping frame to every connected\n // socket on each tick. Both the phone-target leg and the daemon-client leg\n // terminate as WebSocket connections on this relay, so pinging chii's\n // `_wss.clients` covers both.\n //\n // Per-ping log output is intentionally absent — pings happen every 45 s and\n // logging each one would flood the MCP console without adding signal.\n //\n // `ws` clients respond to ping frames with pong automatically (RFC 6455 §5.5)\n // — no application code is needed on either end.\n let keepaliveHandle: ReturnType<typeof setInterval> | null = null;\n if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {\n const chiiWss = capturedChiiWss;\n keepaliveHandle = setInterval(() => {\n for (const client of chiiWss._wss.clients) {\n // readyState 1 = OPEN (ws library constant). Only ping live sockets.\n if (client.readyState === 1) {\n client.ping();\n }\n }\n }, keepaliveIntervalMs);\n }\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n if (keepaliveHandle !== null) {\n clearInterval(keepaliveHandle);\n keepaliveHandle = null;\n }\n // devtools#755: 업그레이드된 WebSocket 소켓(폰-target leg + daemon/\n // relay-worker client leg — 위 keepalive 주석과 동일 대상)은 일단\n // 'upgrade'로 넘어가면 Node의 HTTP 서버 커넥션 트래킹에서 빠져나가,\n // `httpServer.close()`(그리고 `closeAllConnections()`도)가 더 이상\n // 그 소켓을 보지 못한다 — 열려 있는 CDP WS 연결이 하나라도 있으면\n // close 콜백이 영원히 안 불려 devtools-test CLI가 종료되지 않는다\n // (run7~10 재현). chii의 `_wss.clients`(위에서 이미 keepalive ping에\n // 쓰는 것과 같은 Set)를 순회해 명시적으로 terminate한 뒤 close한다.\n // capturedChiiWss가 null(캡처 실패/keepalive 비활성)이어도 안전 —\n // 이 경로에선 그냥 스킵하고 기존 close만 수행(회귀 없음).\n if (capturedChiiWss !== null) {\n for (const client of capturedChiiWss._wss.clients) {\n client.terminate();\n }\n }\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,+BAA+B;;;;;;AAO5C,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACiCxC,MAAMA,aAAAA,GAAAA,YAAAA,eAAAA,QAAAA,MAAAA,CAAAA,cAAAA,WAAAA,CAAAA,KAAwC;;;;;;;;AAS9C,MAAM,gCAAgC;;;;;;;;AAsBtC,SAAS,sBAA0D;AACjE,KAAI;EACF,MAAM,MAAeA,UAAQ,kCAAkC;AAC/D,MAAI,OAAO,QAAQ,WACjB,QAAO;SAEH;AAGR,QAAO;;;;;;;;;;;;;;;;;;;;;AAsBT,eAAe,qBACb,MACA,cACA,cACiC;AACjC,KAAI,iBAAiB,MAAM;AACzB,QAAM,KAAK,MAAM,aAAa;AAC9B,SAAO;;CAGT,IAAI,WAAmC;CACvC,MAAM,QAAQ,aAAa;CAC3B,MAAM,gBAAgB,MAAM;AAE5B,OAAM,QAAQ,SAAiC,QAAQ;AACrD,aAAW;AACX,SAAO,cAAc,KAAK,MAAM,OAAO;;AAGzC,KAAI;AACF,QAAM,KAAK,MAAM,aAAa;WACtB;AAER,QAAM,QAAQ;;AAGhB,QAAO;;AAcT,SAAS,iBAAmC;CAE1C,MAAM,MAAeA,UAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;;;;AAyC9D,SAAgB,oBAAoB,QAA+B;CACjE,MAAM,QAAQ,oCAAoC,KAAK,OAAO;AAC9D,KAAI,UAAU,KAAM,QAAO;CAC3B,MAAM,OAAO,MAAM;CACnB,MAAM,OAAO,MAAM,OAAO,KAAA,KAAa,MAAM,OAAO,KAAK,MAAM,MAAM;CACrE,MAAM,QAAQ,MAAM,MAAM;AAE1B,QAAO,GAAG,OAAO,QADC,UAAU,KAAK,MAAM,IACJ,KAAK;;;;;;;;;;;;;;;;;;AA+E1C,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,YAAY,iBAAiB;CACrC,MAAM,sBACJ,QAAQ,wBAAwB,KAAA,IAC5B,QAAQ,sBACR;CAEN,MAAM,cAAA,GAAA,UAAA,eAA2B;CAIjC,MAAM,oBAAoB,SAA6C;AACrE,MAAI,iBAAiB,KAAA,EAAW;AAChC,MAAI;AACF,gBAAa,EAAE,MAAM,CAAC;UAChB;;AAiBV,KAAI,WAuBF,YAAW,GAAG,YAAY,KAAK,QAAQ;EACrC,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,MAAI,cAAc,MAAM;AAEtB,OAAI,MAAM;AACV,OAAI,CAAC,WAAW,IAAI,EAAE;AAMpB,QAAI,aAAa;AACjB,QAAI,UAAU,+BAA+B,IAAI;AACjD,QAAI,UAAU,gBAAgB,mBAAmB;AACjD,QAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,qBAAiB,eAAe;;AAIlC;;EAMF,MAAM,YAAY,IAAI,OAAO,IAAI,MAAM,IAAI,CAAC;AAC5C,MAAI,aAAa,cAAc,aAAa,aAAa;AAIvD,OAAI,CAAC,WAAW,IAAI,EAAE;AAEpB,QAAI,aAAa;AACjB,QAAI,UAAU,+BAA+B,IAAI;AACjD,QAAI,UAAU,gBAAgB,mBAAmB;AACjD,QAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,qBAAiB,eAAe;AAEhC;;AAOF;;GAQF;CAgBJ,MAAM,eAAe,sBAAsB,IAAI,qBAAqB,GAAG;CACvE,MAAM,kBAAkB,MAAM,qBAC5B,gBAAgB,EAChB;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,EAC/E,aACD;AAUD,KAAI,YAAY;EACd,MAAM,uBAAuB,WAAW,UAAU,UAAU;AAG5D,aAAW,mBAAmB,UAAU;EAGxC,MAAM,YAAY,IAAIC,GAAAA,gBAAgB,EAAE,UAAU,MAAM,CAAC;AACzD,aAAW,GAAG,YAAY,KAAsB,QAAgB,SAAiB;GAK/E,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,OAAI,cAAc,KAChB,KAAI,MAAM;AAEZ,OAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,cAAU,cAAc,KAAK,QAAQ,OAAO,SAAO;AACjD,UAAG,MAAM,8BAA8B,yBAAyB;MAChE;AACF,qBAAiB,aAAa;AAE9B;;AAIF,QAAK,MAAM,YAAY,qBACrB,UAAS,KAAK,QAAQ,KAAK;IAE7B;;CAGJ,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;CAYF,IAAI,kBAAyD;AAC7D,KAAI,sBAAsB,KAAK,oBAAoB,MAAM;EACvD,MAAM,UAAU;AAChB,oBAAkB,kBAAkB;AAClC,QAAK,MAAM,UAAU,QAAQ,KAAK,QAEhC,KAAI,OAAO,eAAe,EACxB,QAAO,MAAM;KAGhB,oBAAoB;;AAGzB,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,OAAI,oBAAoB,MAAM;AAC5B,kBAAc,gBAAgB;AAC9B,sBAAkB;;AAYpB,OAAI,oBAAoB,KACtB,MAAK,MAAM,UAAU,gBAAgB,KAAK,QACxC,QAAO,WAAW;AAGtB,cAAW,YAAY,SAAS,CAAC;IACjC;EACL"}
|