@ait-co/devtools 0.1.122 → 0.1.124
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/dist/{attach-orchestrator-EnZEr4Uu.js → attach-orchestrator-CpE0dMew.js} +2 -2
- package/dist/{attach-orchestrator-EnZEr4Uu.js.map → attach-orchestrator-CpE0dMew.js.map} +1 -1
- package/dist/{cell-3bIG9538.js → cell-Ba9lCI3B.js} +2 -2
- package/dist/{cell-3bIG9538.js.map → cell-Ba9lCI3B.js.map} +1 -1
- package/dist/{cell-Cad-vi7X.js → cell-CX92lDI0.js} +3 -3
- package/dist/{cell-CgKUkDxY.js.map → cell-CX92lDI0.js.map} +1 -1
- package/dist/{cell-CgKUkDxY.js → cell-D0dJSGHi.js} +2 -2
- package/dist/{cell-Cad-vi7X.js.map → cell-D0dJSGHi.js.map} +1 -1
- package/dist/{debug-server-D79E3hTa.js → debug-server-B3W_0Ejf.js} +6 -226
- package/dist/debug-server-B3W_0Ejf.js.map +1 -0
- package/dist/{debug-server-CF-1s3ks.js → debug-server-BDfeWytB.js} +6 -944
- package/dist/debug-server-BDfeWytB.js.map +1 -0
- package/dist/{debug-server-D_28CxC1.js → debug-server-BzzNkETg.js} +6 -6
- package/dist/{debug-server-D_28CxC1.js.map → debug-server-BzzNkETg.js.map} +1 -1
- package/dist/mcp/cli.js +8214 -4
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.js +1 -1
- package/dist/{qr-http-server-BsWlOA85.js → qr-http-server-BH0UFeUn.js} +11 -1
- package/dist/{qr-http-server-gHBd-z7T.js.map → qr-http-server-BH0UFeUn.js.map} +1 -1
- package/dist/{qr-http-server-UBV2qUEd.js → qr-http-server-Bo8LMOJQ.js} +11 -1
- package/dist/{qr-http-server-BJjw0hDr.js.map → qr-http-server-Bo8LMOJQ.js.map} +1 -1
- package/dist/{qr-http-server-CrVKno9V.cjs → qr-http-server-Bt_RAxFn.cjs} +11 -1
- package/dist/{qr-http-server-BpTvfeku.cjs.map → qr-http-server-Bt_RAxFn.cjs.map} +1 -1
- package/dist/{qr-http-server-CKbtqvKy.js → qr-http-server-CrGohOwC.js} +11 -1
- package/dist/{qr-http-server-CKbtqvKy.js.map → qr-http-server-CrGohOwC.js.map} +1 -1
- package/dist/{qr-http-server-BpTvfeku.cjs → qr-http-server-DM2R25jP.cjs} +11 -1
- package/dist/{qr-http-server-CrVKno9V.cjs.map → qr-http-server-DM2R25jP.cjs.map} +1 -1
- package/dist/{qr-http-server-BJjw0hDr.js → qr-http-server-s1iW_5VQ.js} +11 -1
- package/dist/{qr-http-server-BsWlOA85.js.map → qr-http-server-s1iW_5VQ.js.map} +1 -1
- package/dist/{relay-factory-BBnZM-an.js → relay-factory-CqY264j0.js} +22 -5
- package/dist/relay-factory-CqY264j0.js.map +1 -0
- package/dist/{relay-secret-store-BJjoxes1.js → relay-secret-store-CFc9n0OA.js} +1 -1
- package/dist/{relay-secret-store-BJjoxes1.js.map → relay-secret-store-CFc9n0OA.js.map} +1 -1
- package/dist/{relay-secret-store-CtK1eqoV.js → relay-secret-store-DhzAnnj-.js} +3 -3
- package/dist/{relay-secret-store-CtK1eqoV.js.map → relay-secret-store-DhzAnnj-.js.map} +1 -1
- package/dist/{relay-url-store-D3H067u0.js → relay-url-store-CwKT7i04.js} +2 -2
- package/dist/{relay-url-store-D3H067u0.js.map → relay-url-store-CwKT7i04.js.map} +1 -1
- package/dist/{relay-url-store-uyVNe-Az.js → relay-url-store-DGQ-HPQC.js} +2 -2
- package/dist/{relay-url-store-uyVNe-Az.js.map → relay-url-store-DGQ-HPQC.js.map} +1 -1
- package/dist/test-runner/bin.d.ts +2 -0
- package/dist/{cli-CaJ-vnBj.js → test-runner/bin.js} +49 -9
- package/dist/test-runner/bin.js.map +1 -0
- package/dist/test-runner/config.js +1 -1
- package/dist/test-runner/relay-factory.js +21 -4
- package/dist/test-runner/relay-factory.js.map +1 -1
- package/dist/totp-D1pulXLa.js +3 -0
- package/dist/{totp-0bvlo9Yb.js → totp-WY6l0ysP.js} +1 -1
- package/dist/{totp-0bvlo9Yb.js.map → totp-WY6l0ysP.js.map} +1 -1
- package/dist/{tunnel-B7U5k1xa.js → tunnel-BFwOSwT4.js} +2 -2
- package/dist/{tunnel-B7U5k1xa.js.map → tunnel-BFwOSwT4.js.map} +1 -1
- package/dist/{tunnel-C-cgMnyY.cjs → tunnel-DyEV6WwL.cjs} +2 -2
- package/dist/{tunnel-C-cgMnyY.cjs.map → tunnel-DyEV6WwL.cjs.map} +1 -1
- package/dist/unplugin/index.cjs +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/unplugin/tunnel.cjs +1 -1
- package/dist/unplugin/tunnel.js +1 -1
- package/package.json +2 -2
- package/dist/attach-orchestrator-BYMCSPjf.js +0 -2797
- package/dist/attach-orchestrator-BYMCSPjf.js.map +0 -1
- package/dist/attach-orchestrator-LOjvsE9R.js +0 -3
- package/dist/cell-Bfbke0cT.js +0 -3
- package/dist/cell-SKNUQTr8.js +0 -85
- package/dist/cell-SKNUQTr8.js.map +0 -1
- package/dist/cli-CaJ-vnBj.js.map +0 -1
- package/dist/debug-server-CF-1s3ks.js.map +0 -1
- package/dist/debug-server-D79E3hTa.js.map +0 -1
- package/dist/debug-server-IVaJ9vHH.js +0 -4278
- package/dist/debug-server-IVaJ9vHH.js.map +0 -1
- package/dist/qr-http-server-BpcDt-cp.js +0 -3
- package/dist/qr-http-server-UBV2qUEd.js.map +0 -1
- package/dist/qr-http-server-gHBd-z7T.js +0 -1506
- package/dist/relay-factory-BBnZM-an.js.map +0 -1
- package/dist/relay-secret-store-BIM_0y18.js +0 -3
- package/dist/test-runner/cli.d.ts +0 -547
- package/dist/test-runner/cli.d.ts.map +0 -1
- package/dist/test-runner/cli.js +0 -3
- package/dist/totp-zGIlDsZS.js +0 -3
|
@@ -1,4278 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { $ as buildDeepLinkAttachUrl, A as isDebugToolName, B as readServerLock, C as getDiagnostics, D as getSdkCallHistory, E as getOperationalEnvironment, F as listPages, G as logWarn, H as isRelayEnv, I as measureSafeArea, J as pageCrashError, K as classifyToolError, L as takeScreenshot, M as listConsoleMessages, N as listExceptions, O as getToolAvailability, P as listNetworkRequests, Q as tierRejectionError, R as takeSnapshot, S as filterToolsByEnvironment, T as getMockState, U as logError, V as deriveEnvironment, W as logInfo, X as relayDisconnectError, Y as pageMissingError, Z as sdkAbsentError, _ as BOOTSTRAP_TOOL_NAMES, b as callSdk, et as buildLauncherAttachUrl, f as generateAttachToken, g as startTunnelHealthProbe, h as startQuickTunnel, j as isToolAvailableIn, k as isAitToolName, l as prepareAttach, m as printAttachBanner, nt as startParentWatcher, p as makeTunnelStatus, q as mcpError, s as isSandboxPageFresh, t as RELAY_SANDBOX_STALE_PAGE_MS, tt as startMaxAgeWatchdog, u as renderAndMaybeWait, v as DEBUG_TOOL_DEFINITIONS, w as getDomDocument, x as evaluate, y as InMemoryDiagnosticsCollector, z as acquireLock } from "./attach-orchestrator-BYMCSPjf.js";
|
|
3
|
-
import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "./totp-0bvlo9Yb.js";
|
|
4
|
-
import { n as injectGlobals, t as injectDebugIndicator } from "./cell-SKNUQTr8.js";
|
|
5
|
-
import { t as startQrHttpServer } from "./qr-http-server-gHBd-z7T.js";
|
|
6
|
-
import { n as loadRelaySecretReadOnly } from "./relay-secret-store-CtK1eqoV.js";
|
|
7
|
-
import { createRequire } from "node:module";
|
|
8
|
-
import { accessSync, existsSync } from "node:fs";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
11
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
-
import { platform } from "node:os";
|
|
14
|
-
import * as path$1 from "node:path";
|
|
15
|
-
import path, { isAbsolute, resolve } from "node:path";
|
|
16
|
-
import { parseArgs } from "node:util";
|
|
17
|
-
import * as fs from "node:fs/promises";
|
|
18
|
-
import { glob, mkdir, writeFile } from "node:fs/promises";
|
|
19
|
-
import { EventEmitter } from "node:events";
|
|
20
|
-
import { WebSocket, WebSocketServer } from "ws";
|
|
21
|
-
import { createServer } from "node:http";
|
|
22
|
-
import { spawn } from "node:child_process";
|
|
23
|
-
import net from "node:net";
|
|
24
|
-
//#region \0rolldown/runtime.js
|
|
25
|
-
var __defProp = Object.defineProperty;
|
|
26
|
-
var __exportAll = (all, no_symbols) => {
|
|
27
|
-
let target = {};
|
|
28
|
-
for (var name in all) __defProp(target, name, {
|
|
29
|
-
get: all[name],
|
|
30
|
-
enumerable: true
|
|
31
|
-
});
|
|
32
|
-
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
33
|
-
return target;
|
|
34
|
-
};
|
|
35
|
-
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
36
|
-
//#endregion
|
|
37
|
-
//#region src/in-app/gate.ts
|
|
38
|
-
/**
|
|
39
|
-
* The host suffix the Toss app uses to serve dogfood / private mini-apps.
|
|
40
|
-
*
|
|
41
|
-
* A `intoss-private://` (dogfood) entry maps to a host such as
|
|
42
|
-
* `aitc-sdk-example.private-apps.tossmini.com`. A production `intoss://`
|
|
43
|
-
* entry is served from `*.apps.tossmini.com` — the `.private-apps.` segment
|
|
44
|
-
* is absent. Confirmed live over CDP for mini-app 31146; the exact production
|
|
45
|
-
* host is to be re-confirmed once 31146 passes review (spec open question 2).
|
|
46
|
-
*/
|
|
47
|
-
const PRIVATE_APPS_HOST_SUFFIX = ".private-apps.tossmini.com";
|
|
48
|
-
/**
|
|
49
|
-
* The host suffix Cloudflare quick-tunnels serve from — the env 2 (PWA) entry.
|
|
50
|
-
* See {@link isTrycloudflareHost} for why this host kind bypasses Layer B1.
|
|
51
|
-
*/
|
|
52
|
-
const TRYCLOUDFLARE_HOST_SUFFIX = ".trycloudflare.com";
|
|
53
|
-
/**
|
|
54
|
-
* Returns whether `hostname` is a `*.private-apps.tossmini.com` subdomain —
|
|
55
|
-
* the host the Toss app reserves for dogfood / private mini-app entries.
|
|
56
|
-
*
|
|
57
|
-
* The match is an exact suffix check, not a substring `.includes()`: a
|
|
58
|
-
* substring test would also accept an attacker-controlled host like
|
|
59
|
-
* `private-apps.tossmini.com.evil.example`, which ends in `.example`, not in
|
|
60
|
-
* `.tossmini.com`. Requiring the string to END with the suffix closes that.
|
|
61
|
-
* The leading `.` in the suffix also forces a real subdomain label, so a
|
|
62
|
-
* bare `private-apps.tossmini.com` (no mini-app subdomain) does not match.
|
|
63
|
-
*/
|
|
64
|
-
function isPrivateAppsHost(hostname) {
|
|
65
|
-
return hostname.endsWith(PRIVATE_APPS_HOST_SUFFIX);
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* The host suffix Cloudflare quick-tunnels use — the env 2 (PWA) entry.
|
|
69
|
-
*
|
|
70
|
-
* Env 2 serves the local Vite dev server through a `*.trycloudflare.com` quick
|
|
71
|
-
* tunnel (`src/unplugin/tunnel.ts`). It has no Toss app, no `intoss-private://`
|
|
72
|
-
* scheme, and — critically — no production runtime: the SDK is the devtools
|
|
73
|
-
* mock, and the page is the developer's own dev build. The Layer B1 safety net
|
|
74
|
-
* (which stops a dogfood build that lands on a Toss *production* host from
|
|
75
|
-
* attaching) has nothing to protect against here, because env 2 has no
|
|
76
|
-
* production host. So a trycloudflare host is allowed past B1 — but ONLY past
|
|
77
|
-
* B1: the remaining layers (C1 opt-in, C2 relay, C3 TOTP) still apply, so a
|
|
78
|
-
* leaked tunnel URL is still blocked by TOTP exactly as on the Toss path.
|
|
79
|
-
*
|
|
80
|
-
* The match is the same exact-suffix `endsWith` check as
|
|
81
|
-
* {@link isPrivateAppsHost} — never a substring `.includes()`, which would
|
|
82
|
-
* accept an attacker-controlled `evil.trycloudflare.com.example.com`. The
|
|
83
|
-
* leading `.` forces a real subdomain label, so a bare `trycloudflare.com`
|
|
84
|
-
* (no tunnel subdomain) does not match.
|
|
85
|
-
*/
|
|
86
|
-
function isTrycloudflareHost(hostname) {
|
|
87
|
-
return hostname.endsWith(TRYCLOUDFLARE_HOST_SUFFIX);
|
|
88
|
-
}
|
|
89
|
-
/**
|
|
90
|
-
* Returns true when the hostname is a localhost/loopback address.
|
|
91
|
-
* Allowed: `localhost`, `127.x.x.x` (full RFC 5735 loopback block), `[::1]`,
|
|
92
|
-
* `0.0.0.0`, `*.localhost`.
|
|
93
|
-
*
|
|
94
|
-
* Security note: `hostname.startsWith('127.')` is intentionally NOT used —
|
|
95
|
-
* that pattern would accept `127.evil.com`, which starts with "127." but is an
|
|
96
|
-
* attacker-controlled hostname, not a loopback address. Instead, the 127/8
|
|
97
|
-
* loopback block is matched with a strict numeric-quad regex so only valid
|
|
98
|
-
* dotted-decimal IPv4 in the 127.x.x.x range pass (#665 작업 A fix).
|
|
99
|
-
*/
|
|
100
|
-
function isLocalhostHost(hostname) {
|
|
101
|
-
if (hostname === "localhost" || hostname === "0.0.0.0") return true;
|
|
102
|
-
if (hostname === "[::1]") return true;
|
|
103
|
-
if (/^127\.\d+\.\d+\.\d+$/.test(hostname)) return true;
|
|
104
|
-
if (hostname.endsWith(".localhost")) return true;
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Positive-allowlist kill-switch (#665): returns true when the hostname is a
|
|
109
|
-
* known debug-allowed host. The debug surface is ONLY active on:
|
|
110
|
-
* - localhost / loopback (env 1 desktop dev)
|
|
111
|
-
* - *.trycloudflare.com (env 2 PWA tunnel)
|
|
112
|
-
* - *.private-apps.tossmini.com (env 3 dog-food)
|
|
113
|
-
*
|
|
114
|
-
* Any other host (including apps.tossmini.com — the former env 4 LIVE host)
|
|
115
|
-
* is silently blocked. This is a positive allowlist — unlisted hosts never
|
|
116
|
-
* had debug surface regardless, but this function makes it explicit and
|
|
117
|
-
* auditable in a single place.
|
|
118
|
-
*
|
|
119
|
-
* SECRET-HANDLING: the hostname value MUST NOT be logged or included in any
|
|
120
|
-
* error reason string — only benign labels ('host not in allowlist') are safe.
|
|
121
|
-
*/
|
|
122
|
-
function isDebugAllowedHost(hostname) {
|
|
123
|
-
return isLocalhostHost(hostname) || isTrycloudflareHost(hostname) || isPrivateAppsHost(hostname);
|
|
124
|
-
}
|
|
125
|
-
//#endregion
|
|
126
|
-
//#region src/test-runner/discover.ts
|
|
127
|
-
/**
|
|
128
|
-
* Test-file discovery shared by the `devtools-test` CLI and the `run_tests`
|
|
129
|
-
* MCP tool, so both expand glob patterns with identical semantics.
|
|
130
|
-
*
|
|
131
|
-
* Uses Node's built-in `fs/promises` `glob` (Node 22+) — no extra dependency,
|
|
132
|
-
* which keeps the MCP daemon install graph lean (a plain glob lib would land in
|
|
133
|
-
* the `npx … devtools-mcp` path for no benefit).
|
|
134
|
-
*
|
|
135
|
-
* Pure Node IO only (`node:fs/promises` + `node:path`) — react-free, so it is
|
|
136
|
-
* safe to import from the MCP daemon graph.
|
|
137
|
-
*/
|
|
138
|
-
/**
|
|
139
|
-
* Expands `patterns` (globs or plain paths) into a sorted, de-duplicated list of
|
|
140
|
-
* ABSOLUTE test file paths, resolved relative to `cwd`.
|
|
141
|
-
*
|
|
142
|
-
* A plain (non-glob) path passes through when it matches a real file; a glob
|
|
143
|
-
* expands against `cwd`. Absolute matches are kept as-is; relative matches are
|
|
144
|
-
* resolved against `cwd`. `bundleTestFile` requires an absolute path, so the
|
|
145
|
-
* absolute output feeds it directly.
|
|
146
|
-
*
|
|
147
|
-
* @param patterns Glob patterns or file paths (e.g. `['src/**\/*.ait.test.ts']`).
|
|
148
|
-
* @param cwd Base directory for relative patterns/results.
|
|
149
|
-
* @returns Sorted, de-duplicated absolute file paths. Empty when nothing matches.
|
|
150
|
-
*/
|
|
151
|
-
async function discoverTestFiles(patterns, cwd) {
|
|
152
|
-
const out = /* @__PURE__ */ new Set();
|
|
153
|
-
for await (const match of glob(patterns, { cwd })) out.add(isAbsolute(match) ? match : resolve(cwd, match));
|
|
154
|
-
return [...out].sort();
|
|
155
|
-
}
|
|
156
|
-
//#endregion
|
|
157
|
-
//#region src/test-runner/relay-factory.ts
|
|
158
|
-
/**
|
|
159
|
-
* Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay
|
|
160
|
-
* connection.
|
|
161
|
-
*
|
|
162
|
-
* `open()` performs the full attach lifecycle and BLOCKS for tens of seconds (up
|
|
163
|
-
* to `timeoutMs`) while a human scans the rendered QR with their phone — there
|
|
164
|
-
* is no way around the manual scan for env3. It resolves with the live
|
|
165
|
-
* `CdpConnection` once a matching page attaches; `close()` tears the relay
|
|
166
|
-
* family down.
|
|
167
|
-
*
|
|
168
|
-
* The factory holds the booted relay family in a closure so `close()` can stop
|
|
169
|
-
* it. A second `open()` on the same factory boots a fresh family (the previous
|
|
170
|
-
* one should have been `close()`d first).
|
|
171
|
-
*/
|
|
172
|
-
function createRelayConnectionFactory(opts) {
|
|
173
|
-
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
174
|
-
const timeoutMs = opts.timeoutMs ?? 6e5;
|
|
175
|
-
const headless = opts.headless === true;
|
|
176
|
-
let family;
|
|
177
|
-
let qrServer;
|
|
178
|
-
return {
|
|
179
|
-
async open() {
|
|
180
|
-
const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("./attach-orchestrator-LOjvsE9R.js");
|
|
181
|
-
const { injectDebugIndicator, injectGlobals } = await import("./cell-Bfbke0cT.js");
|
|
182
|
-
const { loadRelaySecretReadOnly } = await import("./relay-secret-store-BIM_0y18.js");
|
|
183
|
-
const { bootRelayFamily, buildRelayVerifyAuth } = await Promise.resolve().then(() => debug_server_exports);
|
|
184
|
-
await loadRelaySecretReadOnly({ projectRoot });
|
|
185
|
-
const booted = await bootRelayFamily({ verifyAuth: buildRelayVerifyAuth() });
|
|
186
|
-
family = booted;
|
|
187
|
-
let lastAttachParts;
|
|
188
|
-
const attachDeps = {
|
|
189
|
-
getTunnelStatus: booted.getTunnelStatus ?? (() => ({
|
|
190
|
-
up: false,
|
|
191
|
-
wssUrl: null
|
|
192
|
-
})),
|
|
193
|
-
getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
|
|
194
|
-
qrHttpServer: void 0,
|
|
195
|
-
onAttachUrlBuilt: void 0,
|
|
196
|
-
canOpenBrowser: () => !headless
|
|
197
|
-
};
|
|
198
|
-
try {
|
|
199
|
-
const { startQrHttpServer } = await import("./qr-http-server-BpcDt-cp.js");
|
|
200
|
-
const getDashboardState = () => ({
|
|
201
|
-
tunnel: attachDeps.getTunnelStatus(),
|
|
202
|
-
pages: null,
|
|
203
|
-
attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,
|
|
204
|
-
mode: "relay-dev"
|
|
205
|
-
});
|
|
206
|
-
qrServer = await startQrHttpServer(getDashboardState);
|
|
207
|
-
attachDeps.qrHttpServer = qrServer;
|
|
208
|
-
attachDeps.onAttachUrlBuilt = (parts) => {
|
|
209
|
-
lastAttachParts = parts;
|
|
210
|
-
qrServer?.notifyStateChange();
|
|
211
|
-
};
|
|
212
|
-
process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\n`);
|
|
213
|
-
} catch {}
|
|
214
|
-
const prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
|
|
215
|
-
if (!prep.ok) {
|
|
216
|
-
booted.stop();
|
|
217
|
-
family = void 0;
|
|
218
|
-
throw new Error("createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up");
|
|
219
|
-
}
|
|
220
|
-
const waitResult = await renderAndMaybeWait(attachDeps, prep, true, timeoutMs, booted.connection);
|
|
221
|
-
if (waitResult.isError) {
|
|
222
|
-
booted.stop();
|
|
223
|
-
family = void 0;
|
|
224
|
-
const timeoutSec = Math.round(timeoutMs / 1e3);
|
|
225
|
-
throw new Error(`createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`);
|
|
226
|
-
}
|
|
227
|
-
const qrChunks = waitResult.content.filter((c) => c.type === "text").map((c) => c.text);
|
|
228
|
-
opts.onQrContent(qrChunks);
|
|
229
|
-
await injectDebugIndicator(booted.connection);
|
|
230
|
-
if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
|
|
231
|
-
await booted.connection.enableDomains();
|
|
232
|
-
return booted.connection;
|
|
233
|
-
},
|
|
234
|
-
async close(_connection) {
|
|
235
|
-
family?.stop();
|
|
236
|
-
family = void 0;
|
|
237
|
-
await qrServer?.close();
|
|
238
|
-
qrServer = void 0;
|
|
239
|
-
}
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
//#endregion
|
|
243
|
-
//#region src/test-runner/bundle.ts
|
|
244
|
-
/**
|
|
245
|
-
* esbuild-based bundler for user test files.
|
|
246
|
-
*
|
|
247
|
-
* Bundles a single test file into a self-contained IIFE string that can be
|
|
248
|
-
* injected into a WebView via `Runtime.evaluate`. The bundle includes the
|
|
249
|
-
* test runtime (`runtime.ts`), which provides `describe/it/test/expect` and
|
|
250
|
-
* the `runTestModule(factory)` entry point.
|
|
251
|
-
*
|
|
252
|
-
* ## How the wiring works
|
|
253
|
-
*
|
|
254
|
-
* The bundle exposes two exports on `globalThis.__testBundle`:
|
|
255
|
-
* - `runTestModule` — the runtime's entry function.
|
|
256
|
-
* - `__userFactory` — an async function whose body is the user's top-level
|
|
257
|
-
* test registration code (describe/it/test calls).
|
|
258
|
-
*
|
|
259
|
-
* The Node-side RPC (`rpc.ts`) calls:
|
|
260
|
-
* `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
|
|
261
|
-
*
|
|
262
|
-
* `runTestModule` then installs `describe/it/test/expect` as globals, invokes
|
|
263
|
-
* the factory (which registers all tests), runs them, and returns a `RunReport`.
|
|
264
|
-
*
|
|
265
|
-
* ## Why a factory wrapper is needed
|
|
266
|
-
*
|
|
267
|
-
* Naively adding the runtime to `entryPoints` and bundling the user file would
|
|
268
|
-
* fail for two reasons:
|
|
269
|
-
* 1. `describe/it/test/expect` from the runtime are module-local in the IIFE
|
|
270
|
-
* scope. The user's top-level `describe(...)` calls expect them as globals —
|
|
271
|
-
* they are not globals until `runTestModule` installs them.
|
|
272
|
-
* 2. Even with globals pre-installed, the user file runs at IIFE-evaluation
|
|
273
|
-
* time, before the RPC layer calls `runTestModule` to reset state and start
|
|
274
|
-
* the test clock.
|
|
275
|
-
*
|
|
276
|
-
* The factory approach solves both: the user's registration code is deferred
|
|
277
|
-
* into a function that `runTestModule` calls AFTER installing the globals.
|
|
278
|
-
*
|
|
279
|
-
* ## Factory extraction algorithm
|
|
280
|
-
*
|
|
281
|
-
* The `userFactoryPlugin` reads the user file and splits lines into:
|
|
282
|
-
* - **top-level**: `import …` and re-export lines — kept at module scope
|
|
283
|
-
* (the only valid position for static `import` in ESM).
|
|
284
|
-
* - **body**: all other statements — moved into the body of the exported
|
|
285
|
-
* `__userFactory` async function.
|
|
286
|
-
*
|
|
287
|
-
* esbuild processes the re-generated module, following each static import
|
|
288
|
-
* through the normal dependency graph (including the SDK-redirect plugin).
|
|
289
|
-
*
|
|
290
|
-
* ## SDK redirect
|
|
291
|
-
*
|
|
292
|
-
* Imports of `@apps-in-toss/web-framework` (and sub-paths) are intercepted via
|
|
293
|
-
* the `sdkRedirectPlugin` and replaced with a virtual `window.__sdk` proxy that
|
|
294
|
-
* `src/in-app/auto.ts` installs at runtime. This works for both 2.x and 3.x SDK.
|
|
295
|
-
*
|
|
296
|
-
* SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
|
|
297
|
-
*/
|
|
298
|
-
/** The SDK package name that mini-app test code imports from. */
|
|
299
|
-
const SDK_PACKAGE = "@apps-in-toss/web-framework";
|
|
300
|
-
/**
|
|
301
|
-
* Names the runtime installs as globals before invoking the user factory.
|
|
302
|
-
* The `vitest` virtual module re-exports each as a lazy getter that reads from
|
|
303
|
-
* `globalThis` at access time. Keep in sync with the globals installed in
|
|
304
|
-
* `runtime.ts#runTestModule`.
|
|
305
|
-
*/
|
|
306
|
-
const VITEST_GLOBAL_NAMES = [
|
|
307
|
-
"describe",
|
|
308
|
-
"it",
|
|
309
|
-
"test",
|
|
310
|
-
"expect",
|
|
311
|
-
"beforeAll",
|
|
312
|
-
"afterAll",
|
|
313
|
-
"beforeEach",
|
|
314
|
-
"afterEach",
|
|
315
|
-
"vi"
|
|
316
|
-
];
|
|
317
|
-
/**
|
|
318
|
-
* Matches the bare SDK package and any sub-path import
|
|
319
|
-
* (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
|
|
320
|
-
* Built from {@link SDK_PACKAGE} so the package name has a single source.
|
|
321
|
-
*/
|
|
322
|
-
const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
|
323
|
-
/**
|
|
324
|
-
* esbuild plugin that intercepts SDK imports and redirects them to the
|
|
325
|
-
* `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
|
|
326
|
-
*
|
|
327
|
-
* Strategy: for every import of `@apps-in-toss/web-framework` (or sub-paths),
|
|
328
|
-
* esbuild resolves it to a virtual module that re-exports all named exports
|
|
329
|
-
* via `window.__sdk[name]`. This avoids bundling the real SDK (which may not
|
|
330
|
-
* be available in the test environment) while still making named imports work.
|
|
331
|
-
*
|
|
332
|
-
* If `window.__sdk` is absent (non-dog-food build), every access throws a
|
|
333
|
-
* descriptive error rather than returning `undefined` silently.
|
|
334
|
-
*/
|
|
335
|
-
function sdkRedirectPlugin() {
|
|
336
|
-
return {
|
|
337
|
-
name: "sdk-redirect",
|
|
338
|
-
setup(build) {
|
|
339
|
-
build.onResolve({ filter: SDK_IMPORT_FILTER }, (args) => ({
|
|
340
|
-
path: args.path,
|
|
341
|
-
namespace: "sdk-redirect"
|
|
342
|
-
}));
|
|
343
|
-
build.onLoad({
|
|
344
|
-
filter: /.*/,
|
|
345
|
-
namespace: "sdk-redirect"
|
|
346
|
-
}, () => ({
|
|
347
|
-
contents: `
|
|
348
|
-
var __proxy = (typeof window !== 'undefined' && window.__sdk)
|
|
349
|
-
? window.__sdk
|
|
350
|
-
: new Proxy({}, {
|
|
351
|
-
get: function(_t, p) {
|
|
352
|
-
throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
|
|
353
|
-
}
|
|
354
|
-
});
|
|
355
|
-
module.exports = __proxy;
|
|
356
|
-
`,
|
|
357
|
-
loader: "js"
|
|
358
|
-
}));
|
|
359
|
-
}
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* esbuild plugin that intercepts `import … from 'vitest'` and replaces it with
|
|
364
|
-
* a virtual module that delegates every named import to `globalThis` at ACCESS
|
|
365
|
-
* time (not at bundle-evaluation time).
|
|
366
|
-
*
|
|
367
|
-
* The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/
|
|
368
|
-
* afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle
|
|
369
|
-
* IIFE is evaluated. A value-copy redirect (`export var describe =
|
|
370
|
-
* globalThis.describe`) would therefore capture `undefined` at evaluation time
|
|
371
|
-
* and the user's `describe(...)` calls would be no-ops — registering zero tests.
|
|
372
|
-
*
|
|
373
|
-
* The fix defers the lookup to call time using per-name **getter** exports.
|
|
374
|
-
* We emit a CommonJS module that:
|
|
375
|
-
* 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named
|
|
376
|
-
* import directly to a property access on the module (NOT wrapped under a
|
|
377
|
-
* `default` shim — which is what happens for a bare Proxy whose own-keys
|
|
378
|
-
* are empty, leaving every named import `undefined`);
|
|
379
|
-
* 2. defines each global name as a getter that reads `globalThis[name]` on
|
|
380
|
-
* every access. So `import { describe } from 'vitest'` compiles to
|
|
381
|
-
* `import_vitest.describe`, whose getter returns the real `describe` only
|
|
382
|
-
* when the factory calls it — after `runTestModule` installs the globals.
|
|
383
|
-
*
|
|
384
|
-
* A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes
|
|
385
|
-
* the virtual module through `__toESM`, which enumerates own-keys (none on an
|
|
386
|
-
* empty Proxy target) and therefore exposes zero named exports. Explicit getter
|
|
387
|
-
* properties give `__toESM` real keys to map while keeping access lazy.
|
|
388
|
-
*/
|
|
389
|
-
function vitestRedirectPlugin() {
|
|
390
|
-
return {
|
|
391
|
-
name: "vitest-redirect",
|
|
392
|
-
setup(build) {
|
|
393
|
-
build.onResolve({ filter: /^vitest$/ }, () => ({
|
|
394
|
-
path: "vitest",
|
|
395
|
-
namespace: "vitest-redirect"
|
|
396
|
-
}));
|
|
397
|
-
build.onLoad({
|
|
398
|
-
filter: /^vitest$/,
|
|
399
|
-
namespace: "vitest-redirect"
|
|
400
|
-
}, () => {
|
|
401
|
-
return {
|
|
402
|
-
contents: `Object.defineProperty(exports, '__esModule', { value: true });\n${VITEST_GLOBAL_NAMES.map((name) => `Object.defineProperty(exports, ${JSON.stringify(name)}, { enumerable: true, get: function() { return globalThis[${JSON.stringify(name)}]; } });`).join("\n")}\n`,
|
|
403
|
-
loader: "js"
|
|
404
|
-
};
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
/**
|
|
410
|
-
* esbuild plugin that transforms the user test file into a module that exports
|
|
411
|
-
* an async `__userFactory` function. The factory defers the user's top-level
|
|
412
|
-
* test registration code (describe/it/test calls) so it only runs when
|
|
413
|
-
* `runTestModule(__userFactory)` explicitly invokes it — AFTER the runtime has
|
|
414
|
-
* installed describe/it/test/expect as globals.
|
|
415
|
-
*
|
|
416
|
-
* Algorithm:
|
|
417
|
-
* - Import declarations and re-export statements are kept at module top-level
|
|
418
|
-
* (the only valid ESM position for static `import`). A statement that spans
|
|
419
|
-
* multiple lines — e.g. a named import with one member per line:
|
|
420
|
-
* import {
|
|
421
|
-
* appLogin,
|
|
422
|
-
* getAnonymousKey,
|
|
423
|
-
* } from '@apps-in-toss/web-framework';
|
|
424
|
-
* is tracked as a single block: every line from the opening `import {` /
|
|
425
|
-
* `export {` through the closing `from '…'` (or side-effect `'…'`) line is
|
|
426
|
-
* kept together at top-level. This prevents the member lines and the
|
|
427
|
-
* closing `} from '…'` line from leaking into the factory body, which would
|
|
428
|
-
* leave an unterminated `import {` at module scope (the #678 env3 failure:
|
|
429
|
-
* esbuild threw `Expected "as" but found "{"` on multi-line SDK imports).
|
|
430
|
-
* - All other lines (describe/it/test calls, local declarations, etc.) are
|
|
431
|
-
* moved into the body of the exported async factory function.
|
|
432
|
-
*
|
|
433
|
-
* This preserves SDK import resolution (the sdk-redirect plugin processes
|
|
434
|
-
* top-level imports normally) while deferring test registration to the factory.
|
|
435
|
-
*/
|
|
436
|
-
function userFactoryPlugin(absPath) {
|
|
437
|
-
const NAMESPACE = "user-test-factory";
|
|
438
|
-
return {
|
|
439
|
-
name: "user-test-factory",
|
|
440
|
-
setup(build) {
|
|
441
|
-
build.onResolve({ filter: /^user-test-factory$/ }, () => ({
|
|
442
|
-
path: absPath,
|
|
443
|
-
namespace: NAMESPACE
|
|
444
|
-
}));
|
|
445
|
-
build.onLoad({
|
|
446
|
-
filter: /.*/,
|
|
447
|
-
namespace: NAMESPACE
|
|
448
|
-
}, async (args) => {
|
|
449
|
-
const lines = (await fs.readFile(args.path, "utf8")).split("\n");
|
|
450
|
-
const topLevelLines = [];
|
|
451
|
-
const bodyLines = [];
|
|
452
|
-
const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
|
|
453
|
-
const isImportStart = (trimmed) => trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"");
|
|
454
|
-
const endsStatement = (trimmed) => /['"]\s*;?\s*$/.test(trimmed.replace(/\/\/.*$/, "").trimEnd());
|
|
455
|
-
let inImportBlock = false;
|
|
456
|
-
for (const line of lines) {
|
|
457
|
-
const trimmed = line.trimStart();
|
|
458
|
-
const indent = line.slice(0, line.length - trimmed.length);
|
|
459
|
-
if (inImportBlock) {
|
|
460
|
-
topLevelLines.push(line);
|
|
461
|
-
if (endsStatement(trimmed)) inImportBlock = false;
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
if (isImportStart(trimmed)) {
|
|
465
|
-
topLevelLines.push(line);
|
|
466
|
-
if (!endsStatement(trimmed)) inImportBlock = true;
|
|
467
|
-
} else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
|
|
468
|
-
else {
|
|
469
|
-
topLevelLines.push(line);
|
|
470
|
-
if (/\bfrom\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith("{")) inImportBlock = true;
|
|
471
|
-
}
|
|
472
|
-
else bodyLines.push(line);
|
|
473
|
-
}
|
|
474
|
-
return {
|
|
475
|
-
contents: [
|
|
476
|
-
...topLevelLines,
|
|
477
|
-
"",
|
|
478
|
-
"// biome-ignore lint: generated factory wrapper",
|
|
479
|
-
"export default async function __userFactory(): Promise<void> {",
|
|
480
|
-
...bodyLines.map((l) => ` ${l}`),
|
|
481
|
-
"}"
|
|
482
|
-
].join("\n"),
|
|
483
|
-
loader: "ts",
|
|
484
|
-
resolveDir: path$1.dirname(absPath)
|
|
485
|
-
};
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
/**
|
|
491
|
-
* Returns the absolute filesystem path to the test-runner runtime module
|
|
492
|
-
* (dist/test-runner/runtime.js — a fully self-contained page-side bundle).
|
|
493
|
-
*
|
|
494
|
-
* Rolldown code-splitting duplicates this bundling logic into shared chunks
|
|
495
|
-
* emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
|
|
496
|
-
* dist/test-runner/bundle.js (dir = dist/test-runner/), while the `devtools-mcp`
|
|
497
|
-
* daemon (dist/mcp/cli.js) pulls it through a ROOT chunk
|
|
498
|
-
* (dist/debug-server-<hash>.js, dir = dist/). A fixed `..`-hop candidate list
|
|
499
|
-
* is therefore wrong from at least one chunk — the live #697 regression.
|
|
500
|
-
*
|
|
501
|
-
* This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it
|
|
502
|
-
* probes the co-located `runtime.js` and the nested `test-runner/runtime.js`,
|
|
503
|
-
* then ascends one directory at a time (bounded) repeating both probes. The
|
|
504
|
-
* nested probe catches dist/test-runner/runtime.js from the dist/ root level no
|
|
505
|
-
* matter which depth the chunk was hoisted to (root, dist/mcp/, or a future
|
|
506
|
-
* relocation). The build always emits dist/test-runner/runtime.js (tsdown entry
|
|
507
|
-
* `'test-runner/runtime'`; guarded by scripts/check-test-runner-dist.sh).
|
|
508
|
-
*
|
|
509
|
-
* An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
|
|
510
|
-
* read, bypassing Node module resolution entirely, so this works identically in
|
|
511
|
-
* the npx-daemon context (its own dist tree) and the consumer-CLI context
|
|
512
|
-
* (the mini-app's installed @ait-co/devtools dist) — neither needs the package
|
|
513
|
-
* to be node-resolvable from the caller.
|
|
514
|
-
*/
|
|
515
|
-
function getRuntimePath() {
|
|
516
|
-
const startDir = path$1.dirname(fileURLToPath(import.meta.url));
|
|
517
|
-
const RELATIVE_PROBES = [
|
|
518
|
-
["runtime.js"],
|
|
519
|
-
["test-runner", "runtime.js"],
|
|
520
|
-
["runtime.ts"],
|
|
521
|
-
["test-runner", "runtime.ts"]
|
|
522
|
-
];
|
|
523
|
-
let dir = startDir;
|
|
524
|
-
for (let i = 0; i < 12; i++) {
|
|
525
|
-
for (const segs of RELATIVE_PROBES) {
|
|
526
|
-
const candidate = path$1.join(dir, ...segs);
|
|
527
|
-
try {
|
|
528
|
-
accessSync(candidate);
|
|
529
|
-
return candidate;
|
|
530
|
-
} catch {}
|
|
531
|
-
}
|
|
532
|
-
const parent = path$1.dirname(dir);
|
|
533
|
-
if (parent === dir) break;
|
|
534
|
-
dir = parent;
|
|
535
|
-
}
|
|
536
|
-
return path$1.join(startDir, "runtime.js");
|
|
537
|
-
}
|
|
538
|
-
/**
|
|
539
|
-
* Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
|
|
540
|
-
*
|
|
541
|
-
* The IIFE installs `window.__testBundle` (or the custom `globalName`) with:
|
|
542
|
-
* - `runTestModule` — the runtime entry (from `runtime.ts`).
|
|
543
|
-
* - `__userFactory` — an async function wrapping the user's test registration
|
|
544
|
-
* code so it runs AFTER `runTestModule` installs the globals.
|
|
545
|
-
*
|
|
546
|
-
* Callers (rpc.ts) invoke:
|
|
547
|
-
* `globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory)`
|
|
548
|
-
*
|
|
549
|
-
* @param absPath - Absolute path to the user test file.
|
|
550
|
-
* @param opts - Optional bundling overrides.
|
|
551
|
-
*/
|
|
552
|
-
async function bundleTestFile(absPath, opts) {
|
|
553
|
-
const globalName = opts?.globalName ?? "__testBundle";
|
|
554
|
-
const extraExternals = opts?.extraExternals ?? [];
|
|
555
|
-
const esbuild = await import("esbuild");
|
|
556
|
-
const runtimePath = getRuntimePath();
|
|
557
|
-
const wrapperContent = [
|
|
558
|
-
`import { runTestModule } from ${JSON.stringify(runtimePath)};`,
|
|
559
|
-
`import __userFactory from "user-test-factory";`,
|
|
560
|
-
`export { runTestModule, __userFactory };`
|
|
561
|
-
].join("\n");
|
|
562
|
-
const result = await esbuild.build({
|
|
563
|
-
stdin: {
|
|
564
|
-
contents: wrapperContent,
|
|
565
|
-
loader: "ts",
|
|
566
|
-
resolveDir: path$1.dirname(absPath)
|
|
567
|
-
},
|
|
568
|
-
bundle: true,
|
|
569
|
-
format: "iife",
|
|
570
|
-
globalName,
|
|
571
|
-
platform: "browser",
|
|
572
|
-
target: "es2022",
|
|
573
|
-
write: false,
|
|
574
|
-
plugins: [
|
|
575
|
-
userFactoryPlugin(absPath),
|
|
576
|
-
vitestRedirectPlugin(),
|
|
577
|
-
sdkRedirectPlugin()
|
|
578
|
-
],
|
|
579
|
-
external: extraExternals,
|
|
580
|
-
treeShaking: true,
|
|
581
|
-
footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
|
|
582
|
-
});
|
|
583
|
-
const warnings = result.warnings.map((w) => `${path$1.relative(process.cwd(), w.location?.file ?? "")}:${w.location?.line ?? "?"}: ${w.text}`);
|
|
584
|
-
const outputFile = result.outputFiles?.[0];
|
|
585
|
-
if (!outputFile) throw new Error("bundleTestFile: esbuild produced no output — check entryPoints");
|
|
586
|
-
return {
|
|
587
|
-
code: outputFile.text,
|
|
588
|
-
warnings
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
//#endregion
|
|
592
|
-
//#region src/test-runner/capture.ts
|
|
593
|
-
/** The exact console-line prefix sdk-example's `flushCapture` emits. */
|
|
594
|
-
const CAPTURE_PREFIX = "__AIT_CAPTURE__ ";
|
|
595
|
-
/**
|
|
596
|
-
* Parses raw console line texts into {@link AitCaptureLine}s.
|
|
597
|
-
*
|
|
598
|
-
* Filtering rules (each independently drops a line — never throws):
|
|
599
|
-
* - the line text must `startsWith(CAPTURE_PREFIX)` exactly (allowlist);
|
|
600
|
-
* - there must be a non-empty category token (up to the next space);
|
|
601
|
-
* - the remaining payload must be valid JSON (`JSON.parse` succeeds).
|
|
602
|
-
*
|
|
603
|
-
* Lines that fail any rule (wss/scheme noise, truncated, broken JSON) are
|
|
604
|
-
* silently discarded — capture harvesting is best-effort and must never fail a
|
|
605
|
-
* run or leak a malformed/secret-bearing line.
|
|
606
|
-
*
|
|
607
|
-
* @param raw - Console line objects (only `.text` is read).
|
|
608
|
-
* @returns The captured lines, in input order.
|
|
609
|
-
*/
|
|
610
|
-
function parseCaptureLines(raw) {
|
|
611
|
-
const out = [];
|
|
612
|
-
for (const { text } of raw) {
|
|
613
|
-
if (!text.startsWith(CAPTURE_PREFIX)) continue;
|
|
614
|
-
const body = text.slice(16);
|
|
615
|
-
const spaceIdx = body.indexOf(" ");
|
|
616
|
-
if (spaceIdx === -1) continue;
|
|
617
|
-
const category = body.slice(0, spaceIdx);
|
|
618
|
-
const json = body.slice(spaceIdx + 1);
|
|
619
|
-
if (category === "" || json === "") continue;
|
|
620
|
-
try {
|
|
621
|
-
JSON.parse(json);
|
|
622
|
-
} catch {
|
|
623
|
-
continue;
|
|
624
|
-
}
|
|
625
|
-
out.push({
|
|
626
|
-
category,
|
|
627
|
-
json
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
return out;
|
|
631
|
-
}
|
|
632
|
-
//#endregion
|
|
633
|
-
//#region src/test-runner/rpc.ts
|
|
634
|
-
/** Maximum milliseconds to wait for a single evaluate round-trip. */
|
|
635
|
-
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
636
|
-
/**
|
|
637
|
-
* Wraps bundle code in a self-executing IIFE that:
|
|
638
|
-
* 1. Evaluates the bundle (registering describe/it/test).
|
|
639
|
-
* 2. Calls `__testBundle.runTestModule(...)` — the entry the runtime exports.
|
|
640
|
-
* 3. Returns a JSON-serialised `RunReport` string.
|
|
641
|
-
*
|
|
642
|
-
* The double-serialisation (RunReport → JSON string → returnByValue string)
|
|
643
|
-
* is intentional: CDP `returnByValue` reliably transports strings; deeply
|
|
644
|
-
* nested objects can lose fidelity across the Chii relay.
|
|
645
|
-
*
|
|
646
|
-
* SECRET-HANDLING: `bundleCode` MUST NOT be logged by callers.
|
|
647
|
-
*/
|
|
648
|
-
function buildRunTestsExpression(bundleCode) {
|
|
649
|
-
return `(async () => { try { ${bundleCode} } catch(e) { return JSON.stringify({ok:false,error:'bundle-eval: ' + String(e && e.message || e)}); } if (typeof globalThis.__testBundle !== 'object' || typeof globalThis.__testBundle.runTestModule !== 'function' || typeof globalThis.__testBundle.__userFactory !== 'function') { return JSON.stringify({ok:false,error:'bundle-missing-export: __testBundle.runTestModule or __userFactory is not a function'}); } try { const report = await globalThis.__testBundle.runTestModule(globalThis.__testBundle.__userFactory); return JSON.stringify({ok:true,value:report}); } catch(e) { return JSON.stringify({ok:false,error:'test-run: ' + String(e && e.message || e)}); }})()`;
|
|
650
|
-
}
|
|
651
|
-
/**
|
|
652
|
-
* Parses the raw CDP `returnByValue` result from a `buildRunTestsExpression`
|
|
653
|
-
* evaluate call into a typed `RpcRunResult`.
|
|
654
|
-
*
|
|
655
|
-
* Throws only on parse failure — an `ok:false` envelope is a normal result.
|
|
656
|
-
*
|
|
657
|
-
* SECRET-HANDLING: `rawValue` is not included in error messages.
|
|
658
|
-
*/
|
|
659
|
-
function parseRunTestsResult(rawValue) {
|
|
660
|
-
if (typeof rawValue !== "string") throw new Error(`rpc.parseRunTestsResult: unexpected return type "${typeof rawValue}" — expected JSON string`);
|
|
661
|
-
let parsed;
|
|
662
|
-
try {
|
|
663
|
-
parsed = JSON.parse(rawValue);
|
|
664
|
-
} catch {
|
|
665
|
-
throw new Error("rpc.parseRunTestsResult: bridge returned non-JSON string");
|
|
666
|
-
}
|
|
667
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("rpc.parseRunTestsResult: parsed result is not an object");
|
|
668
|
-
const obj = parsed;
|
|
669
|
-
if (obj.ok === true) return {
|
|
670
|
-
ok: true,
|
|
671
|
-
report: obj.value
|
|
672
|
-
};
|
|
673
|
-
if (obj.ok === false) return {
|
|
674
|
-
ok: false,
|
|
675
|
-
error: typeof obj.error === "string" ? obj.error : String(obj.error)
|
|
676
|
-
};
|
|
677
|
-
throw new Error("rpc.parseRunTestsResult: result missing \"ok\" field");
|
|
678
|
-
}
|
|
679
|
-
/**
|
|
680
|
-
* Injects `bundleCode` into the attached page and awaits test execution.
|
|
681
|
-
*
|
|
682
|
-
* Uses `Runtime.evaluate` with `awaitPromise: true` to wait for the
|
|
683
|
-
* async IIFE to settle. The 30-second CDP command timeout covers even
|
|
684
|
-
* long-running test suites; split into smaller files if you hit it.
|
|
685
|
-
*
|
|
686
|
-
* @param connection - Active CDP connection (relay or local).
|
|
687
|
-
* @param bundleCode - IIFE bundle string from `bundleTestFile`.
|
|
688
|
-
* @param timeoutMs - Override the default 30 s timeout.
|
|
689
|
-
*
|
|
690
|
-
* SECRET-HANDLING: `bundleCode` and the raw CDP result value are never logged.
|
|
691
|
-
*/
|
|
692
|
-
async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
693
|
-
const expression = buildRunTestsExpression(bundleCode);
|
|
694
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`rpc: evaluate timed out after ${timeoutMs}ms`)), timeoutMs));
|
|
695
|
-
const evalPromise = connection.send("Runtime.evaluate", {
|
|
696
|
-
expression,
|
|
697
|
-
returnByValue: true,
|
|
698
|
-
awaitPromise: true
|
|
699
|
-
});
|
|
700
|
-
const cdpResult = await Promise.race([evalPromise, timeoutPromise]);
|
|
701
|
-
if (cdpResult.exceptionDetails) {
|
|
702
|
-
const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
|
|
703
|
-
throw new Error(`rpc.injectAndRunBundle: ${msg}`);
|
|
704
|
-
}
|
|
705
|
-
return parseRunTestsResult(cdpResult.result.value);
|
|
706
|
-
}
|
|
707
|
-
//#endregion
|
|
708
|
-
//#region src/test-runner/relay-worker.ts
|
|
709
|
-
/**
|
|
710
|
-
* Runs all `files` sequentially over the given CDP `connection`.
|
|
711
|
-
*
|
|
712
|
-
* For each file:
|
|
713
|
-
* 1. Bundle with esbuild (includes SDK shim + runtime).
|
|
714
|
-
* 2. Inject into the attached page via `Runtime.evaluate`.
|
|
715
|
-
* 3. Await the `RunReport` JSON response.
|
|
716
|
-
* 4. Accumulate results.
|
|
717
|
-
*
|
|
718
|
-
* Returns a `RelayRunReport` with per-file results and flattened totals.
|
|
719
|
-
*
|
|
720
|
-
* This function does NOT open or manage the relay connection — the caller
|
|
721
|
-
* is responsible for attaching and closing it.
|
|
722
|
-
*
|
|
723
|
-
* TODO (#645): implement the Vitest `PoolRunnerInitializer` interface here
|
|
724
|
-
* so that `runTestFilesOverRelay` can be used as a Vitest pool entry.
|
|
725
|
-
*
|
|
726
|
-
* @param connection - Active CDP connection (relay or local kind).
|
|
727
|
-
* @param files - Absolute paths to test files, run in order.
|
|
728
|
-
* @param opts - Optional per-run overrides.
|
|
729
|
-
*/
|
|
730
|
-
async function runTestFilesOverRelay(connection, files, opts) {
|
|
731
|
-
const wallStart = Date.now();
|
|
732
|
-
const startedAt = new Date(wallStart).toISOString();
|
|
733
|
-
const fileResults = [];
|
|
734
|
-
let domainsEnabled = false;
|
|
735
|
-
try {
|
|
736
|
-
await connection.enableDomains();
|
|
737
|
-
domainsEnabled = true;
|
|
738
|
-
} catch (e) {
|
|
739
|
-
process.stderr.write(`relay-worker: enableDomains() failed before run — console capture may be empty (${e instanceof Error ? e.message : String(e)})\n`);
|
|
740
|
-
}
|
|
741
|
-
const collectCaptures = opts?.collectCaptures === true;
|
|
742
|
-
const liveConsole = [];
|
|
743
|
-
let unsubscribeConsole;
|
|
744
|
-
if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
|
|
745
|
-
liveConsole.push(event);
|
|
746
|
-
});
|
|
747
|
-
try {
|
|
748
|
-
for (const file of files) {
|
|
749
|
-
let fileEntry;
|
|
750
|
-
try {
|
|
751
|
-
const { code } = await bundleTestFile(file, opts?.bundleOptions);
|
|
752
|
-
const rpcResult = await injectAndRunBundle(connection, code, opts?.timeoutMs);
|
|
753
|
-
if (rpcResult.ok) fileEntry = {
|
|
754
|
-
file,
|
|
755
|
-
result: rpcResult.report
|
|
756
|
-
};
|
|
757
|
-
else fileEntry = {
|
|
758
|
-
file,
|
|
759
|
-
result: { error: rpcResult.error }
|
|
760
|
-
};
|
|
761
|
-
} catch (e) {
|
|
762
|
-
fileEntry = {
|
|
763
|
-
file,
|
|
764
|
-
result: { error: e instanceof Error ? e.message : String(e) }
|
|
765
|
-
};
|
|
766
|
-
}
|
|
767
|
-
fileResults.push(fileEntry);
|
|
768
|
-
}
|
|
769
|
-
} finally {
|
|
770
|
-
unsubscribeConsole?.();
|
|
771
|
-
}
|
|
772
|
-
const captures = collectCaptures ? parseCaptureLines(liveConsole.map((e) => ({ text: renderConsoleLineText(e) }))) : [];
|
|
773
|
-
const totals = fileResults.reduce((acc, { result }) => {
|
|
774
|
-
if ("error" in result) {
|
|
775
|
-
acc.failed += 1;
|
|
776
|
-
acc.total += 1;
|
|
777
|
-
} else {
|
|
778
|
-
acc.passed += result.passed;
|
|
779
|
-
acc.failed += result.failed;
|
|
780
|
-
acc.skipped += result.skipped;
|
|
781
|
-
acc.total += result.passed + result.failed + result.skipped;
|
|
782
|
-
}
|
|
783
|
-
return acc;
|
|
784
|
-
}, {
|
|
785
|
-
passed: 0,
|
|
786
|
-
failed: 0,
|
|
787
|
-
skipped: 0,
|
|
788
|
-
total: 0
|
|
789
|
-
});
|
|
790
|
-
return {
|
|
791
|
-
startedAt,
|
|
792
|
-
duration: Date.now() - wallStart,
|
|
793
|
-
files: fileResults,
|
|
794
|
-
totals,
|
|
795
|
-
captures
|
|
796
|
-
};
|
|
797
|
-
}
|
|
798
|
-
/**
|
|
799
|
-
* Renders one `Runtime.consoleAPICalled` event to a single line of text, the
|
|
800
|
-
* same way `tools.ts#normalizeConsoleMessage` does (args rendered + space-
|
|
801
|
-
* joined). Inlined here (≈8 lines) so this module avoids importing `tools.ts`,
|
|
802
|
-
* which would drag the heavy MCP/Node graph (server-lock, parent-watcher, …)
|
|
803
|
-
* onto the test-runner entry.
|
|
804
|
-
*
|
|
805
|
-
* SECRET-HANDLING: this only stringifies console args; the caller's
|
|
806
|
-
* allowlist-prefix parser then discards everything that is not a genuine
|
|
807
|
-
* `__AIT_CAPTURE__` line.
|
|
808
|
-
*/
|
|
809
|
-
function renderConsoleLineText(event) {
|
|
810
|
-
return event.args.map((arg) => {
|
|
811
|
-
if (arg.value !== void 0) {
|
|
812
|
-
if (typeof arg.value === "string") return arg.value;
|
|
813
|
-
try {
|
|
814
|
-
return JSON.stringify(arg.value);
|
|
815
|
-
} catch {
|
|
816
|
-
return String(arg.value);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
if (arg.description !== void 0) return arg.description;
|
|
820
|
-
if (arg.className !== void 0) return arg.className;
|
|
821
|
-
return arg.subtype ?? arg.type;
|
|
822
|
-
}).join(" ");
|
|
823
|
-
}
|
|
824
|
-
//#endregion
|
|
825
|
-
//#region src/test-runner/report.ts
|
|
826
|
-
/**
|
|
827
|
-
* Runner-agnostic report serialisation for env3 test runs (devtools#696).
|
|
828
|
-
*
|
|
829
|
-
* Both env3 execution paths — the Vitest custom pool (`pool.ts`) and the
|
|
830
|
-
* standalone `devtools-test` CLI (`cli.ts`) — call the same core
|
|
831
|
-
* `runTestFilesOverRelay` and so produce the same {@link RelayRunReport}. This
|
|
832
|
-
* module is the single, runner-neutral place that turns that in-memory report
|
|
833
|
-
* into a stable on-disk artifact so a 2.x run and a 3.0 run can be diffed
|
|
834
|
-
* cell-by-cell after the fact.
|
|
835
|
-
*
|
|
836
|
-
* The serialised schema is deliberately MINIMAL and secret-free:
|
|
837
|
-
*
|
|
838
|
-
* - file paths are stored RELATIVE to `projectRoot` (no absolute `/Users/...`
|
|
839
|
-
* leakage — see {@link RunnerAgnosticReport.files});
|
|
840
|
-
* - the cell metadata (sdkLine/platform) is baked INTO the body, not only the
|
|
841
|
-
* filename, so a moved artifact never loses its provenance;
|
|
842
|
-
* - NO relay wss / scheme / TOTP / relayUrl fields exist in the schema at all
|
|
843
|
-
* (enforced by the type + this comment) — error strings are the matcher
|
|
844
|
-
* message only, inherited from rpc.ts which already strips expression/value.
|
|
845
|
-
*
|
|
846
|
-
* react-free — depends only on the type-level `RelayRunReport` and `node:fs` /
|
|
847
|
-
* `node:path`. Safe to bundle without pulling the chii/cloudflared graph.
|
|
848
|
-
*/
|
|
849
|
-
/**
|
|
850
|
-
* Converts an absolute (or already-relative) file path to a projectRoot-relative
|
|
851
|
-
* one. `path.relative` returns `''` when the paths are equal — guard that to the
|
|
852
|
-
* basename so the field is never empty.
|
|
853
|
-
*
|
|
854
|
-
* SECRET-HANDLING: this is the single choke point that strips absolute project
|
|
855
|
-
* paths from the artifact.
|
|
856
|
-
*/
|
|
857
|
-
function relativise(projectRoot, file) {
|
|
858
|
-
const rel = path.relative(projectRoot, file);
|
|
859
|
-
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return path.basename(file);
|
|
860
|
-
return rel;
|
|
861
|
-
}
|
|
862
|
-
/**
|
|
863
|
-
* Serialises a {@link RelayRunReport} into the runner-agnostic, secret-free
|
|
864
|
-
* on-disk shape. Pure — no IO; testable with a plain report + meta.
|
|
865
|
-
*
|
|
866
|
-
* @param report - The core relay run report.
|
|
867
|
-
* @param meta - Cell axes + projectRoot (projectRoot is consumed, not stored).
|
|
868
|
-
*/
|
|
869
|
-
function serializeRelayReport(report, meta) {
|
|
870
|
-
return {
|
|
871
|
-
cell: {
|
|
872
|
-
sdkLine: meta.sdkLine,
|
|
873
|
-
platform: meta.platform
|
|
874
|
-
},
|
|
875
|
-
startedAt: report.startedAt,
|
|
876
|
-
duration: report.duration,
|
|
877
|
-
totals: report.totals,
|
|
878
|
-
files: report.files.map((f) => {
|
|
879
|
-
const file = relativise(meta.projectRoot, f.file);
|
|
880
|
-
if ("error" in f.result) return {
|
|
881
|
-
file,
|
|
882
|
-
error: f.result.error
|
|
883
|
-
};
|
|
884
|
-
return {
|
|
885
|
-
file,
|
|
886
|
-
duration: f.result.duration,
|
|
887
|
-
passed: f.result.passed,
|
|
888
|
-
failed: f.result.failed,
|
|
889
|
-
skipped: f.result.skipped,
|
|
890
|
-
tests: f.result.tests
|
|
891
|
-
};
|
|
892
|
-
})
|
|
893
|
-
};
|
|
894
|
-
}
|
|
895
|
-
/**
|
|
896
|
-
* Writes the serialised report to `<dir>/<sdkLine>.<platform>.json`, creating
|
|
897
|
-
* `dir` if needed. Returns the absolute path written.
|
|
898
|
-
*
|
|
899
|
-
* The cell-suffixed filename keeps 2.x and 3.0 (and per-platform) runs as
|
|
900
|
-
* distinct artifacts in the same directory; the same cell metadata is also baked
|
|
901
|
-
* into the body so a renamed/moved file still carries its provenance.
|
|
902
|
-
*
|
|
903
|
-
* SECRET-HANDLING: the written body contains no relay/secret fields (the schema
|
|
904
|
-
* has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
|
|
905
|
-
*
|
|
906
|
-
* @param report - The core relay run report.
|
|
907
|
-
* @param dir - Output directory (created recursively if missing).
|
|
908
|
-
* @param meta - Cell axes + projectRoot.
|
|
909
|
-
* @returns The absolute path of the written file.
|
|
910
|
-
*/
|
|
911
|
-
async function writeReportArtifact(report, dir, meta) {
|
|
912
|
-
const serialised = serializeRelayReport(report, meta);
|
|
913
|
-
await mkdir(dir, { recursive: true });
|
|
914
|
-
const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
|
|
915
|
-
await writeFile(outFile, `${JSON.stringify(serialised, null, 2)}\n`, "utf8");
|
|
916
|
-
return outFile;
|
|
917
|
-
}
|
|
918
|
-
/**
|
|
919
|
-
* Writes harvested `__AIT_CAPTURE__` lines to per-category files under `dir`,
|
|
920
|
-
* named `<category>.<sdkLine>.<platform>.json` — the SAME convention
|
|
921
|
-
* sdk-example's env1 `flushCapture` uses on the filesystem, so env1 and env3
|
|
922
|
-
* capture artifacts line up for diffing.
|
|
923
|
-
*
|
|
924
|
-
* Each line's `json` payload is an opaque JSON array of capture records. Lines
|
|
925
|
-
* sharing a category are concatenated into one array, in harvest order.
|
|
926
|
-
*
|
|
927
|
-
* SECRET-HANDLING: only allowlist-prefixed capture lines reach here (the parser
|
|
928
|
-
* dropped wss/scheme noise); the `json` payload is written verbatim but is a
|
|
929
|
-
* capture record array, not a relay/secret.
|
|
930
|
-
*
|
|
931
|
-
* @param captures - Parsed capture lines (from `RelayRunReport.captures`).
|
|
932
|
-
* @param dir - Output directory (created recursively if missing).
|
|
933
|
-
* @param cell - Cell axes for the filename suffix.
|
|
934
|
-
* @returns The absolute paths written (one per category), in category order.
|
|
935
|
-
*/
|
|
936
|
-
async function writeCaptureArtifacts(captures, dir, cell) {
|
|
937
|
-
if (captures.length === 0) return [];
|
|
938
|
-
const byCategory = /* @__PURE__ */ new Map();
|
|
939
|
-
for (const { category, json } of captures) {
|
|
940
|
-
let merged = byCategory.get(category);
|
|
941
|
-
if (!merged) {
|
|
942
|
-
merged = [];
|
|
943
|
-
byCategory.set(category, merged);
|
|
944
|
-
}
|
|
945
|
-
const parsed = JSON.parse(json);
|
|
946
|
-
if (Array.isArray(parsed)) merged.push(...parsed);
|
|
947
|
-
else merged.push(parsed);
|
|
948
|
-
}
|
|
949
|
-
await mkdir(dir, { recursive: true });
|
|
950
|
-
const written = [];
|
|
951
|
-
for (const [category, records] of byCategory) {
|
|
952
|
-
const outFile = path.join(dir, `${category}.${cell.sdkLine}.${cell.platform}.json`);
|
|
953
|
-
await writeFile(outFile, `${JSON.stringify(records, null, 2)}\n`, "utf8");
|
|
954
|
-
written.push(outFile);
|
|
955
|
-
}
|
|
956
|
-
return written;
|
|
957
|
-
}
|
|
958
|
-
//#endregion
|
|
959
|
-
//#region src/test-runner/cli.ts
|
|
960
|
-
/**
|
|
961
|
-
* `devtools-test` CLI.
|
|
962
|
-
*
|
|
963
|
-
* Shares test-file discovery with the `run_tests` MCP tool (`discoverTestFiles`)
|
|
964
|
-
* and exposes `runWithConnection` — the pure run core that bundles, injects, and
|
|
965
|
-
* collects each file over a CDP connection. The CLI's `main()` performs a
|
|
966
|
-
* standalone relay attach (boot relay → QR → phone scan → cell inject → run).
|
|
967
|
-
*
|
|
968
|
-
* NOTE: no shebang in this source file — the tsdown entry's `banner` option
|
|
969
|
-
* injects `#!/usr/bin/env node` into the compiled output (same pattern as
|
|
970
|
-
* `src/mcp/cli.ts`).
|
|
971
|
-
*/
|
|
972
|
-
const USAGE = `
|
|
973
|
-
devtools-test — run mini-app tests on a real device WebView over the CDP relay
|
|
974
|
-
|
|
975
|
-
USAGE
|
|
976
|
-
devtools-test <glob> [<glob> ...] [options]
|
|
977
|
-
|
|
978
|
-
OPTIONS
|
|
979
|
-
--scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
|
|
980
|
-
(required for standalone relay attach / env3)
|
|
981
|
-
--timeout <ms> Per-file evaluate timeout in ms (default: 30000)
|
|
982
|
-
--cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
|
|
983
|
-
--cell-platform <plat> Platform to inject as __AIT_CELL__.platform
|
|
984
|
-
(mock|ios|android, default: AIT_CELL_PLATFORM env)
|
|
985
|
-
--report-dir <dir> Persist a runner-agnostic report + captures to <dir>
|
|
986
|
-
(report: <sdkLine>.<platform>.json; captures:
|
|
987
|
-
<dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
|
|
988
|
-
Omitted = nothing saved. Enables console capture.
|
|
989
|
-
--no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
|
|
990
|
-
non-interactive stdout / CI / AIT_NO_QR_STDOUT)
|
|
991
|
-
--headless Disable browser auto-open (text QR only)
|
|
992
|
-
--project-root <dir> Project root for .ait_relay secret lookup
|
|
993
|
-
(default: current working directory)
|
|
994
|
-
--help, -h Show this help message
|
|
995
|
-
|
|
996
|
-
DESCRIPTION
|
|
997
|
-
Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real
|
|
998
|
-
device to scan and attach, injects the cell globals (__AIT_CELL__), bundles
|
|
999
|
-
each matched test file with esbuild (SDK imports redirected to window.__sdk),
|
|
1000
|
-
injects the bundle into the attached WebView via Runtime.evaluate, and prints
|
|
1001
|
-
a summary.
|
|
1002
|
-
|
|
1003
|
-
With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
|
|
1004
|
-
runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
|
|
1005
|
-
compared offline.
|
|
1006
|
-
|
|
1007
|
-
The test files run against the live relay connection started by this process;
|
|
1008
|
-
no separate MCP daemon is required.
|
|
1009
|
-
|
|
1010
|
-
EXAMPLE
|
|
1011
|
-
devtools-test 'src/**/*.ait.test.ts' \\
|
|
1012
|
-
--scheme-url "intoss-private://..." \\
|
|
1013
|
-
--cell-sdk-line 3.x \\
|
|
1014
|
-
--cell-platform ios \\
|
|
1015
|
-
--report-dir .ait-report \\
|
|
1016
|
-
--timeout 60000
|
|
1017
|
-
|
|
1018
|
-
`.trimStart();
|
|
1019
|
-
/**
|
|
1020
|
-
* Runs `files` over `connection` and returns the aggregate report.
|
|
1021
|
-
* This pure function is the testable core of the CLI (and is what the
|
|
1022
|
-
* `run_tests` MCP tool calls against the daemon's attached connection); it is
|
|
1023
|
-
* separate from `main()` so tests can call it without spawning a subprocess.
|
|
1024
|
-
*/
|
|
1025
|
-
async function runWithConnection(connection, files, opts) {
|
|
1026
|
-
const report = await runTestFilesOverRelay(connection, files, opts);
|
|
1027
|
-
if (opts?.printSummary) {
|
|
1028
|
-
const { totals } = report;
|
|
1029
|
-
process.stdout.write(`\ndevtools-test: ${totals.passed} passed, ${totals.failed} failed, ${totals.skipped} skipped (${report.duration}ms)\n`);
|
|
1030
|
-
}
|
|
1031
|
-
return report;
|
|
1032
|
-
}
|
|
1033
|
-
/**
|
|
1034
|
-
* Decides whether to suppress the QR/attach block on stdout.
|
|
1035
|
-
*
|
|
1036
|
-
* Suppress when EITHER the user passed `--no-qr-stdout`, OR stdout is not a TTY
|
|
1037
|
-
* / `CI` is set / `AIT_NO_QR_STDOUT` is set (non-interactive — a captured stdout
|
|
1038
|
-
* must not leak the relay wss + TOTP `at=` code that the QR block encodes). The
|
|
1039
|
-
* suppression is whole-chunk: `attachUrl` AND `relayUrl` ride in the same block.
|
|
1040
|
-
*
|
|
1041
|
-
* Exported for unit testing.
|
|
1042
|
-
*/
|
|
1043
|
-
function shouldSuppressQr(noQrFlag) {
|
|
1044
|
-
return noQrFlag || !process.stdout.isTTY || process.env.CI !== void 0 || process.env.AIT_NO_QR_STDOUT !== void 0;
|
|
1045
|
-
}
|
|
1046
|
-
/**
|
|
1047
|
-
* CLI entry point.
|
|
1048
|
-
*
|
|
1049
|
-
* Performs a standalone relay attach → run lifecycle, sharing the attach
|
|
1050
|
-
* assembly with the Vitest pool via `createRelayConnectionFactory` (single
|
|
1051
|
-
* source — no drift):
|
|
1052
|
-
*
|
|
1053
|
-
* 1. Parse args: globs, --timeout, --cell-sdk-line, --cell-platform,
|
|
1054
|
-
* --scheme-url (required for env3), --report-dir, --no-qr-stdout,
|
|
1055
|
-
* --headless, --project-root.
|
|
1056
|
-
* 2. Discover test files; exit 1 if none.
|
|
1057
|
-
* 3. factory.open() — boot relay → render QR (suppressed on non-interactive
|
|
1058
|
-
* stdout) → wait for phone → inject cell → enableDomains. Returns the conn.
|
|
1059
|
-
* 4. runWithConnection(conn, files, { timeoutMs, collectCaptures, printSummary }).
|
|
1060
|
-
* 5. With --report-dir: write the runner-agnostic report + capture files.
|
|
1061
|
-
* 6. factory.close(); process.exitCode = failed > 0 ? 1 : 0.
|
|
1062
|
-
*
|
|
1063
|
-
* The CLI is not a daemon — no lock, router, SSE, or tools_list is needed.
|
|
1064
|
-
* Attach timeout exits with code 1; test failures exit with code 1.
|
|
1065
|
-
*
|
|
1066
|
-
* SECRET-HANDLING: scheme_url / relay wssUrl / TOTP codes are never written to
|
|
1067
|
-
* stdout/stderr directly. The QR block (which encodes the TOTP `at=` code) is
|
|
1068
|
-
* printed only when stdout is interactive AND not suppressed.
|
|
1069
|
-
*/
|
|
1070
|
-
async function main(argv = process.argv.slice(2)) {
|
|
1071
|
-
let parsed;
|
|
1072
|
-
try {
|
|
1073
|
-
parsed = parseArgs({
|
|
1074
|
-
args: argv,
|
|
1075
|
-
options: {
|
|
1076
|
-
help: {
|
|
1077
|
-
type: "boolean",
|
|
1078
|
-
short: "h"
|
|
1079
|
-
},
|
|
1080
|
-
timeout: { type: "string" },
|
|
1081
|
-
"scheme-url": { type: "string" },
|
|
1082
|
-
"cell-sdk-line": { type: "string" },
|
|
1083
|
-
"cell-platform": { type: "string" },
|
|
1084
|
-
"report-dir": { type: "string" },
|
|
1085
|
-
"no-qr-stdout": { type: "boolean" },
|
|
1086
|
-
headless: { type: "boolean" },
|
|
1087
|
-
"project-root": { type: "string" }
|
|
1088
|
-
},
|
|
1089
|
-
allowPositionals: true
|
|
1090
|
-
});
|
|
1091
|
-
} catch (e) {
|
|
1092
|
-
process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1093
|
-
process.exitCode = 1;
|
|
1094
|
-
return;
|
|
1095
|
-
}
|
|
1096
|
-
if (parsed.values.help || argv.length === 0) {
|
|
1097
|
-
process.stdout.write(USAGE);
|
|
1098
|
-
return;
|
|
1099
|
-
}
|
|
1100
|
-
const vals = parsed.values;
|
|
1101
|
-
const rawTimeout = typeof vals.timeout === "string" ? vals.timeout : void 0;
|
|
1102
|
-
const timeoutMs = rawTimeout !== void 0 ? parseInt(rawTimeout, 10) : 3e4;
|
|
1103
|
-
if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
|
|
1104
|
-
process.stderr.write(`devtools-test: --timeout must be a positive integer\n`);
|
|
1105
|
-
process.exitCode = 1;
|
|
1106
|
-
return;
|
|
1107
|
-
}
|
|
1108
|
-
const schemeUrl = typeof vals["scheme-url"] === "string" ? vals["scheme-url"] : "";
|
|
1109
|
-
if (schemeUrl === "") {
|
|
1110
|
-
process.stderr.write("devtools-test: --scheme-url is required for standalone relay attach.\n Pass the intoss-private:// URL from `ait deploy --scheme-only`.\n");
|
|
1111
|
-
process.exitCode = 1;
|
|
1112
|
-
return;
|
|
1113
|
-
}
|
|
1114
|
-
const headless = vals.headless === true;
|
|
1115
|
-
const projectRoot = typeof vals["project-root"] === "string" ? vals["project-root"] : process.cwd();
|
|
1116
|
-
const reportDir = typeof vals["report-dir"] === "string" ? vals["report-dir"] : void 0;
|
|
1117
|
-
const suppressQr = shouldSuppressQr(vals["no-qr-stdout"] === true);
|
|
1118
|
-
const cellSdkLine = typeof vals["cell-sdk-line"] === "string" ? vals["cell-sdk-line"] : void 0;
|
|
1119
|
-
const cellPlatform = typeof vals["cell-platform"] === "string" ? vals["cell-platform"] : process.env.AIT_CELL_PLATFORM;
|
|
1120
|
-
const hasCell = cellSdkLine !== void 0 || cellPlatform !== void 0;
|
|
1121
|
-
const cell = {
|
|
1122
|
-
sdkLine: cellSdkLine ?? "2.x",
|
|
1123
|
-
platform: cellPlatform ?? "mock"
|
|
1124
|
-
};
|
|
1125
|
-
const globs = parsed.positionals;
|
|
1126
|
-
if (globs.length === 0) {
|
|
1127
|
-
process.stderr.write(`devtools-test: at least one glob pattern is required\n`);
|
|
1128
|
-
process.stdout.write(USAGE);
|
|
1129
|
-
process.exitCode = 1;
|
|
1130
|
-
return;
|
|
1131
|
-
}
|
|
1132
|
-
const files = await discoverTestFiles(globs, process.cwd());
|
|
1133
|
-
if (files.length === 0) {
|
|
1134
|
-
process.stderr.write(`devtools-test: no test files matched ${globs.join(", ")}\n`);
|
|
1135
|
-
process.exitCode = 1;
|
|
1136
|
-
return;
|
|
1137
|
-
}
|
|
1138
|
-
process.stderr.write(`devtools-test: found ${files.length} test file(s)\n`);
|
|
1139
|
-
if (hasCell) process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\n`);
|
|
1140
|
-
const factory = createRelayConnectionFactory({
|
|
1141
|
-
schemeUrl,
|
|
1142
|
-
projectRoot,
|
|
1143
|
-
timeoutMs,
|
|
1144
|
-
headless,
|
|
1145
|
-
cell: hasCell ? cell : void 0,
|
|
1146
|
-
onQrContent: (chunks) => {
|
|
1147
|
-
if (suppressQr) {
|
|
1148
|
-
process.stdout.write("QR suppressed (non-interactive)\n");
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
for (const chunk of chunks) process.stdout.write(`${chunk}\n`);
|
|
1152
|
-
}
|
|
1153
|
-
});
|
|
1154
|
-
let connection;
|
|
1155
|
-
try {
|
|
1156
|
-
connection = await factory.open();
|
|
1157
|
-
} catch (e) {
|
|
1158
|
-
process.stderr.write(`devtools-test: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1159
|
-
process.exitCode = 1;
|
|
1160
|
-
return;
|
|
1161
|
-
}
|
|
1162
|
-
let exitCode = 0;
|
|
1163
|
-
try {
|
|
1164
|
-
const report = await runWithConnection(connection, files, {
|
|
1165
|
-
timeoutMs,
|
|
1166
|
-
printSummary: true,
|
|
1167
|
-
collectCaptures: reportDir !== void 0
|
|
1168
|
-
});
|
|
1169
|
-
if (reportDir !== void 0) try {
|
|
1170
|
-
const reportPath = await writeReportArtifact(report, reportDir, {
|
|
1171
|
-
sdkLine: cell.sdkLine,
|
|
1172
|
-
platform: cell.platform,
|
|
1173
|
-
projectRoot
|
|
1174
|
-
});
|
|
1175
|
-
process.stderr.write(`devtools-test: wrote report ${reportPath}\n`);
|
|
1176
|
-
const capturePaths = await writeCaptureArtifacts(report.captures, `${reportDir}/.ait-capture`, cell);
|
|
1177
|
-
if (capturePaths.length > 0) process.stderr.write(`devtools-test: wrote ${capturePaths.length} capture file(s)\n`);
|
|
1178
|
-
} catch (e) {
|
|
1179
|
-
process.stderr.write(`devtools-test: failed to write report artifacts: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1180
|
-
}
|
|
1181
|
-
exitCode = report.totals.failed > 0 ? 1 : 0;
|
|
1182
|
-
} finally {
|
|
1183
|
-
await factory.close(connection);
|
|
1184
|
-
process.exitCode = exitCode;
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
if (import.meta.url === new URL(process.argv[1], "file://").href) main().catch((e) => {
|
|
1188
|
-
process.stderr.write(`devtools-test: unexpected error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1189
|
-
process.exitCode = 1;
|
|
1190
|
-
});
|
|
1191
|
-
//#endregion
|
|
1192
|
-
//#region src/mcp/ait-chii-source.ts
|
|
1193
|
-
function isObject$2(value) {
|
|
1194
|
-
return typeof value === "object" && value !== null;
|
|
1195
|
-
}
|
|
1196
|
-
/** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
|
|
1197
|
-
function asSdkCallHistory(raw) {
|
|
1198
|
-
if (isObject$2(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
|
|
1199
|
-
return { calls: [] };
|
|
1200
|
-
}
|
|
1201
|
-
/** Narrows an `AIT.getMockState` response to an opaque record. */
|
|
1202
|
-
function asMockState(raw) {
|
|
1203
|
-
return isObject$2(raw) ? raw : {};
|
|
1204
|
-
}
|
|
1205
|
-
/** Narrows an `AIT.getOperationalEnvironment` response. */
|
|
1206
|
-
function asOperationalEnvironment(raw) {
|
|
1207
|
-
return {
|
|
1208
|
-
environment: isObject$2(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
|
|
1209
|
-
sdkVersion: isObject$2(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
var ChiiAitSource = class {
|
|
1213
|
-
constructor(sender) {
|
|
1214
|
-
this.sender = sender;
|
|
1215
|
-
}
|
|
1216
|
-
async get(method) {
|
|
1217
|
-
const raw = await this.sender.sendCommand(method);
|
|
1218
|
-
switch (method) {
|
|
1219
|
-
case "AIT.getSdkCallHistory": return asSdkCallHistory(raw);
|
|
1220
|
-
case "AIT.getMockState": return asMockState(raw);
|
|
1221
|
-
case "AIT.getOperationalEnvironment": return asOperationalEnvironment(raw);
|
|
1222
|
-
default: throw new Error(`Unknown AIT method: ${String(method)}`);
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
};
|
|
1226
|
-
//#endregion
|
|
1227
|
-
//#region src/shared/relay-auth-close.ts
|
|
1228
|
-
/**
|
|
1229
|
-
* Shared constants for the relay's named TOTP-auth rejection (issue #478).
|
|
1230
|
-
*
|
|
1231
|
-
* Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
|
|
1232
|
-
* raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
|
|
1233
|
-
* indistinguishable from a network failure on the browser side — the
|
|
1234
|
-
* WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
|
|
1235
|
-
* could not tell "stale TOTP code" apart from "tunnel down" and stayed
|
|
1236
|
-
* silent. The fix is accept-then-close: complete the handshake, then close
|
|
1237
|
-
* with an application close code that NAMES the rejection.
|
|
1238
|
-
*
|
|
1239
|
-
* Three parties share this contract:
|
|
1240
|
-
* - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
|
|
1241
|
-
* - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
|
|
1242
|
-
* surfaces the code to the launcher shell;
|
|
1243
|
-
* - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
|
|
1244
|
-
* as an auth failure on its own `/client` dial (defensive — #439's fresh
|
|
1245
|
-
* code mint means it should not normally hit this).
|
|
1246
|
-
*
|
|
1247
|
-
* This module is intentionally dependency-free (no Node, no DOM) so it is
|
|
1248
|
-
* safe to import from both the browser in-app bundle and the MCP daemon
|
|
1249
|
-
* bundle.
|
|
1250
|
-
*
|
|
1251
|
-
* SECRET-HANDLING: these are fixed enum values. The close reason / error body
|
|
1252
|
-
* must never grow to carry a secret, a TOTP code, or a host.
|
|
1253
|
-
*/
|
|
1254
|
-
/**
|
|
1255
|
-
* WebSocket close code sent by the relay when TOTP auth is rejected.
|
|
1256
|
-
*
|
|
1257
|
-
* 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
|
|
1258
|
-
* HTTP 401 so it reads as "unauthorized" at a glance.
|
|
1259
|
-
*/
|
|
1260
|
-
const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
|
|
1261
|
-
/**
|
|
1262
|
-
* Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
|
|
1263
|
-
* the `error` value of the relay's HTTP 401 JSON body. Enum string only —
|
|
1264
|
-
* never interpolated with request data.
|
|
1265
|
-
*/
|
|
1266
|
-
const RELAY_AUTH_REJECT_REASON = "totp-rejected";
|
|
1267
|
-
//#endregion
|
|
1268
|
-
//#region src/mcp/chii-connection.ts
|
|
1269
|
-
/**
|
|
1270
|
-
* Production `CdpConnection` backed by the local Chii relay.
|
|
1271
|
-
*
|
|
1272
|
-
* Topology (debug mode):
|
|
1273
|
-
* phone target.js --WS--> Chii relay :9100 <--WS-- this connection
|
|
1274
|
-
*
|
|
1275
|
-
* The phone connects to the relay as a `target`; this module connects as a
|
|
1276
|
-
* `client` (the role a CDP frontend would take) so CDP events the page emits
|
|
1277
|
-
* (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
|
|
1278
|
-
* events in ring buffers the tool layer reads via `getBufferedEvents`.
|
|
1279
|
-
*
|
|
1280
|
-
* Node-only: imports `ws`. Never bundled into the browser/in-app entries.
|
|
1281
|
-
*
|
|
1282
|
-
* Attach reliability (#281):
|
|
1283
|
-
* `refreshTargets()` emits an internal 'target:attached' event whenever a
|
|
1284
|
-
* new target is added to the relay. `waitForFirstTarget()` awaits that event
|
|
1285
|
-
* (with a polling-interval fallback) so `start_attach`'s attach wait
|
|
1286
|
-
* resolves deterministically rather than racing between polling rounds.
|
|
1287
|
-
*/
|
|
1288
|
-
/** Max events retained per domain ring buffer. */
|
|
1289
|
-
const DEFAULT_BUFFER_SIZE$1 = 500;
|
|
1290
|
-
function isObject$1(value) {
|
|
1291
|
-
return typeof value === "object" && value !== null;
|
|
1292
|
-
}
|
|
1293
|
-
function parseInbound$1(raw) {
|
|
1294
|
-
let parsed;
|
|
1295
|
-
try {
|
|
1296
|
-
parsed = JSON.parse(raw);
|
|
1297
|
-
} catch {
|
|
1298
|
-
return null;
|
|
1299
|
-
}
|
|
1300
|
-
if (!isObject$1(parsed)) return null;
|
|
1301
|
-
const message = {};
|
|
1302
|
-
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
1303
|
-
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
1304
|
-
if ("params" in parsed) message.params = parsed.params;
|
|
1305
|
-
if ("result" in parsed) message.result = parsed.result;
|
|
1306
|
-
if (isObject$1(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
1307
|
-
return message;
|
|
1308
|
-
}
|
|
1309
|
-
const PHASE_1_EVENTS$1 = [
|
|
1310
|
-
"Runtime.consoleAPICalled",
|
|
1311
|
-
"Network.requestWillBeSent",
|
|
1312
|
-
"Network.responseReceived"
|
|
1313
|
-
];
|
|
1314
|
-
/**
|
|
1315
|
-
* Ring buffer size for `Runtime.exceptionThrown`.
|
|
1316
|
-
*
|
|
1317
|
-
* Exceptions are rarer than console messages but each is heavier (stack
|
|
1318
|
-
* trace). 50 is generous enough to cover a crash scenario while keeping
|
|
1319
|
-
* memory bounded.
|
|
1320
|
-
*
|
|
1321
|
-
* **Lifecycle note**: the exception buffer intentionally survives `replaced` /
|
|
1322
|
-
* `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
|
|
1323
|
-
* transitions. Rationale: an exception fired just before a crash is exactly
|
|
1324
|
-
* the signal we want to preserve for root-cause analysis. The buffer
|
|
1325
|
-
* represents "exceptions seen in this MCP session", not "exceptions in the
|
|
1326
|
-
* current page".
|
|
1327
|
-
*/
|
|
1328
|
-
const EXCEPTION_BUFFER_SIZE = 50;
|
|
1329
|
-
/** Default per-command timeout if neither option nor env var is set. */
|
|
1330
|
-
const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
|
|
1331
|
-
/**
|
|
1332
|
-
* Production CDP connection. Polls the relay for the first attached target,
|
|
1333
|
-
* opens a client websocket to it, enables Phase 1 domains, and buffers events.
|
|
1334
|
-
*/
|
|
1335
|
-
var ChiiCdpConnection = class {
|
|
1336
|
-
/** Authoritative connection kind (issue #348) — relay-backed. */
|
|
1337
|
-
kind = "relay";
|
|
1338
|
-
relayBaseUrl;
|
|
1339
|
-
bufferSize;
|
|
1340
|
-
commandTimeoutMs;
|
|
1341
|
-
totpSecret;
|
|
1342
|
-
emitter = new EventEmitter();
|
|
1343
|
-
buffers = /* @__PURE__ */ new Map();
|
|
1344
|
-
targets = /* @__PURE__ */ new Map();
|
|
1345
|
-
ws = null;
|
|
1346
|
-
connectionState = "idle";
|
|
1347
|
-
nextCommandId = 1;
|
|
1348
|
-
/**
|
|
1349
|
-
* The single active target id under the single-attach model.
|
|
1350
|
-
* Updated by `refreshTargets()` whenever a non-null target is present.
|
|
1351
|
-
* Used to detect a new (different) target attach and evict the previous one.
|
|
1352
|
-
*/
|
|
1353
|
-
activeTargetId = null;
|
|
1354
|
-
/** In-flight enableDomains() promise — concurrent callers share it. */
|
|
1355
|
-
enablingPromise = null;
|
|
1356
|
-
/** Pending request→response commands keyed by CDP message id. */
|
|
1357
|
-
pending = /* @__PURE__ */ new Map();
|
|
1358
|
-
/**
|
|
1359
|
-
* Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
|
|
1360
|
-
* or `null` if no crash has been detected since the last `enableDomains()`.
|
|
1361
|
-
*/
|
|
1362
|
-
lastCrashDetectedAt = null;
|
|
1363
|
-
/**
|
|
1364
|
-
* Per-target last-seen timestamp (ms since epoch). Updated on any inbound
|
|
1365
|
-
* CDP message carrying data from a target. Keyed by target id.
|
|
1366
|
-
*/
|
|
1367
|
-
targetLastSeenAt = /* @__PURE__ */ new Map();
|
|
1368
|
-
/** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
|
|
1369
|
-
heartbeatHandle = null;
|
|
1370
|
-
/** Lifecycle event listeners (crash / destroyed / detached). */
|
|
1371
|
-
lifecycleListeners = [];
|
|
1372
|
-
constructor(options) {
|
|
1373
|
-
this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
|
|
1374
|
-
this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE$1;
|
|
1375
|
-
this.totpSecret = options.totpSecret;
|
|
1376
|
-
const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
|
|
1377
|
-
this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
1378
|
-
for (const event of PHASE_1_EVENTS$1) this.buffers.set(event, []);
|
|
1379
|
-
this.buffers.set("Runtime.exceptionThrown", []);
|
|
1380
|
-
this.emitter.setMaxListeners(0);
|
|
1381
|
-
}
|
|
1382
|
-
/** Refresh the attached-target list from the relay's `GET /targets`. */
|
|
1383
|
-
async refreshTargets() {
|
|
1384
|
-
let targetsUrl = `${this.relayBaseUrl}/targets`;
|
|
1385
|
-
if (this.totpSecret) {
|
|
1386
|
-
const code = generateTotp(this.totpSecret);
|
|
1387
|
-
targetsUrl += `?at=${encodeURIComponent(code)}`;
|
|
1388
|
-
}
|
|
1389
|
-
const res = await fetch(targetsUrl);
|
|
1390
|
-
if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
|
|
1391
|
-
const body = await res.json();
|
|
1392
|
-
const list = isObject$1(body) && Array.isArray(body.targets) ? body.targets : [];
|
|
1393
|
-
let newestTargetId = null;
|
|
1394
|
-
for (const item of list) {
|
|
1395
|
-
if (!isObject$1(item) || typeof item.id !== "string") continue;
|
|
1396
|
-
newestTargetId = item.id;
|
|
1397
|
-
}
|
|
1398
|
-
if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
|
|
1399
|
-
const prevId = this.activeTargetId;
|
|
1400
|
-
logInfo("page.detached", { prevTargetId: prevId });
|
|
1401
|
-
this.evictTarget(prevId);
|
|
1402
|
-
}
|
|
1403
|
-
this.targets.clear();
|
|
1404
|
-
for (const item of list) {
|
|
1405
|
-
if (!isObject$1(item) || typeof item.id !== "string") continue;
|
|
1406
|
-
if (item.id !== newestTargetId) continue;
|
|
1407
|
-
this.targets.set(item.id, {
|
|
1408
|
-
id: item.id,
|
|
1409
|
-
title: typeof item.title === "string" ? item.title : "",
|
|
1410
|
-
url: typeof item.url === "string" ? item.url : ""
|
|
1411
|
-
});
|
|
1412
|
-
}
|
|
1413
|
-
if (newestTargetId !== null) this.activeTargetId = newestTargetId;
|
|
1414
|
-
else this.activeTargetId = null;
|
|
1415
|
-
const result = [...this.targets.values()];
|
|
1416
|
-
if (newestTargetId !== null) this.emitter.emit("target:attached", result);
|
|
1417
|
-
return result;
|
|
1418
|
-
}
|
|
1419
|
-
listTargets() {
|
|
1420
|
-
return [...this.targets.values()];
|
|
1421
|
-
}
|
|
1422
|
-
/**
|
|
1423
|
-
* Waits until at least one target matching `filterFn` is attached, then
|
|
1424
|
-
* resolves with the full target list at that moment.
|
|
1425
|
-
*
|
|
1426
|
-
* Resolution happens on whichever comes first:
|
|
1427
|
-
* (a) a `'target:attached'` event from `refreshTargets()` (triggered by
|
|
1428
|
-
* the /targets poll finding a new target), OR
|
|
1429
|
-
* (b) a `'target:attached'` event from `handleMessage()` (triggered by
|
|
1430
|
-
* the first inbound CDP message from a target — confirms the relay
|
|
1431
|
-
* websocket has data from the phone, not just a target entry in the map).
|
|
1432
|
-
*
|
|
1433
|
-
* This dual-signal approach eliminates the polling race that previously
|
|
1434
|
-
* caused `wait_for_attach` to resolve before the first CDP message arrived.
|
|
1435
|
-
*
|
|
1436
|
-
* Falls back to checking `listTargets()` every `pollIntervalMs` in case the
|
|
1437
|
-
* EventEmitter is missed (defensive belt-and-suspenders).
|
|
1438
|
-
*
|
|
1439
|
-
* @param filterFn - Predicate that the returned targets must satisfy.
|
|
1440
|
-
* @param timeoutMs - Reject after this many ms (default 90 000).
|
|
1441
|
-
* @param pollIntervalMs - Fallback poll interval (default 500ms).
|
|
1442
|
-
*/
|
|
1443
|
-
waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
|
|
1444
|
-
const current = this.listTargets();
|
|
1445
|
-
if (filterFn(current)) return Promise.resolve(current);
|
|
1446
|
-
return new Promise((resolve, reject) => {
|
|
1447
|
-
let settled = false;
|
|
1448
|
-
let pollHandle = null;
|
|
1449
|
-
const settle = (targets) => {
|
|
1450
|
-
if (settled) return;
|
|
1451
|
-
settled = true;
|
|
1452
|
-
clearTimeout(timeoutHandle);
|
|
1453
|
-
if (pollHandle !== null) {
|
|
1454
|
-
clearInterval(pollHandle);
|
|
1455
|
-
pollHandle = null;
|
|
1456
|
-
}
|
|
1457
|
-
this.emitter.off("target:attached", onAttach);
|
|
1458
|
-
resolve(targets);
|
|
1459
|
-
};
|
|
1460
|
-
const onAttach = (targets) => {
|
|
1461
|
-
if (filterFn(targets)) settle(targets);
|
|
1462
|
-
};
|
|
1463
|
-
const timeoutHandle = setTimeout(() => {
|
|
1464
|
-
if (settled) return;
|
|
1465
|
-
settled = true;
|
|
1466
|
-
if (pollHandle !== null) {
|
|
1467
|
-
clearInterval(pollHandle);
|
|
1468
|
-
pollHandle = null;
|
|
1469
|
-
}
|
|
1470
|
-
this.emitter.off("target:attached", onAttach);
|
|
1471
|
-
reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
|
|
1472
|
-
}, timeoutMs);
|
|
1473
|
-
this.emitter.on("target:attached", onAttach);
|
|
1474
|
-
pollHandle = setInterval(() => {
|
|
1475
|
-
this.refreshTargets().then((targets) => {
|
|
1476
|
-
if (filterFn(targets)) settle(targets);
|
|
1477
|
-
}, () => {});
|
|
1478
|
-
}, pollIntervalMs);
|
|
1479
|
-
});
|
|
1480
|
-
}
|
|
1481
|
-
/**
|
|
1482
|
-
* Timestamp (ms since epoch) of the most recent crash/destroy/detach event
|
|
1483
|
-
* detected since the last `enableDomains()` call, or `null` if none.
|
|
1484
|
-
*/
|
|
1485
|
-
getLastCrashDetectedAt() {
|
|
1486
|
-
return this.lastCrashDetectedAt;
|
|
1487
|
-
}
|
|
1488
|
-
/**
|
|
1489
|
-
* Last-seen timestamp (ms since epoch) for a given target id, or `null` if
|
|
1490
|
-
* the target is unknown / no message has been received from it yet.
|
|
1491
|
-
*/
|
|
1492
|
-
getTargetLastSeenAt(targetId) {
|
|
1493
|
-
return this.targetLastSeenAt.get(targetId) ?? null;
|
|
1494
|
-
}
|
|
1495
|
-
/** Subscribe to target lifecycle events (crash / destroyed / detached). */
|
|
1496
|
-
onLifecycle(listener) {
|
|
1497
|
-
this.lifecycleListeners.push(listener);
|
|
1498
|
-
return () => {
|
|
1499
|
-
const idx = this.lifecycleListeners.indexOf(listener);
|
|
1500
|
-
if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
|
|
1501
|
-
};
|
|
1502
|
-
}
|
|
1503
|
-
/**
|
|
1504
|
-
* Connect a client websocket to the first attached target and enable Phase 1
|
|
1505
|
-
* domains. Resolves once the socket is open and enable commands are sent.
|
|
1506
|
-
*/
|
|
1507
|
-
async enableDomains() {
|
|
1508
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
|
1509
|
-
if (this.enablingPromise) return this.enablingPromise;
|
|
1510
|
-
this.enablingPromise = this._doEnableDomains().finally(() => {
|
|
1511
|
-
this.enablingPromise = null;
|
|
1512
|
-
});
|
|
1513
|
-
return this.enablingPromise;
|
|
1514
|
-
}
|
|
1515
|
-
async _doEnableDomains() {
|
|
1516
|
-
const target = (await this.refreshTargets())[0];
|
|
1517
|
-
if (!target) throw new Error("No mini-app page attached to the Chii relay yet.");
|
|
1518
|
-
let clientUrl = `${this.relayBaseUrl.replace(/^http/, "ws")}/client/${`devtools-mcp-${Date.now()}`}?target=${encodeURIComponent(target.id)}`;
|
|
1519
|
-
if (this.totpSecret) {
|
|
1520
|
-
const code = generateTotp(this.totpSecret);
|
|
1521
|
-
clientUrl += `&at=${encodeURIComponent(code)}`;
|
|
1522
|
-
}
|
|
1523
|
-
const ws = new WebSocket(clientUrl);
|
|
1524
|
-
this.ws = ws;
|
|
1525
|
-
await new Promise((resolve, reject) => {
|
|
1526
|
-
ws.once("open", () => resolve());
|
|
1527
|
-
ws.once("error", (err) => reject(err));
|
|
1528
|
-
ws.once("close", (code) => {
|
|
1529
|
-
if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
|
|
1530
|
-
});
|
|
1531
|
-
});
|
|
1532
|
-
this.lastCrashDetectedAt = null;
|
|
1533
|
-
this.targetLastSeenAt.clear();
|
|
1534
|
-
this.connectionState = "connected";
|
|
1535
|
-
ws.on("message", (data) => this.handleMessage(data.toString()));
|
|
1536
|
-
ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
|
|
1537
|
-
ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
|
|
1538
|
-
this.sendFireAndForget("Runtime.enable");
|
|
1539
|
-
this.sendFireAndForget("Network.enable");
|
|
1540
|
-
this.sendFireAndForget("DOM.enable");
|
|
1541
|
-
this.sendFireAndForget("Page.enable");
|
|
1542
|
-
this.sendFireAndForget("Inspector.enable");
|
|
1543
|
-
this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
|
|
1544
|
-
this.startHeartbeat(target.id);
|
|
1545
|
-
}
|
|
1546
|
-
/** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
|
|
1547
|
-
sendFireAndForget(method, params = {}) {
|
|
1548
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
1549
|
-
const id = this.nextCommandId++;
|
|
1550
|
-
this.ws.send(JSON.stringify({
|
|
1551
|
-
id,
|
|
1552
|
-
method,
|
|
1553
|
-
params
|
|
1554
|
-
}));
|
|
1555
|
-
}
|
|
1556
|
-
/**
|
|
1557
|
-
* Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
|
|
1558
|
-
* error frame or when no websocket is open (no page attached yet).
|
|
1559
|
-
*/
|
|
1560
|
-
send(method, params) {
|
|
1561
|
-
return this.sendCommand(method, params ?? {});
|
|
1562
|
-
}
|
|
1563
|
-
/**
|
|
1564
|
-
* Issue an arbitrary request→response command over the relay and resolve with
|
|
1565
|
-
* its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
|
|
1566
|
-
* `AIT.*` methods, forwarded over the same Chii channel) build on this.
|
|
1567
|
-
*
|
|
1568
|
-
* Rejects immediately if the connection is disconnected (fail-fast — no
|
|
1569
|
-
* auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
|
|
1570
|
-
* reattach.
|
|
1571
|
-
*
|
|
1572
|
-
* Times out after `commandTimeoutMs` (default 30s, env
|
|
1573
|
-
* `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
|
|
1574
|
-
* and the promise rejects with a descriptive Korean error.
|
|
1575
|
-
*/
|
|
1576
|
-
sendCommand(method, params = {}) {
|
|
1577
|
-
if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
|
|
1578
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No mini-app page attached to the Chii relay yet. Call enableDomains() first."));
|
|
1579
|
-
const id = this.nextCommandId++;
|
|
1580
|
-
const ws = this.ws;
|
|
1581
|
-
const timeoutMs = this.commandTimeoutMs;
|
|
1582
|
-
return new Promise((resolve, reject) => {
|
|
1583
|
-
const handle = setTimeout(() => {
|
|
1584
|
-
this.pending.delete(id);
|
|
1585
|
-
reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
|
|
1586
|
-
}, timeoutMs);
|
|
1587
|
-
this.pending.set(id, {
|
|
1588
|
-
resolve: (v) => {
|
|
1589
|
-
clearTimeout(handle);
|
|
1590
|
-
resolve(v);
|
|
1591
|
-
},
|
|
1592
|
-
reject: (e) => {
|
|
1593
|
-
clearTimeout(handle);
|
|
1594
|
-
reject(e);
|
|
1595
|
-
}
|
|
1596
|
-
});
|
|
1597
|
-
ws.send(JSON.stringify({
|
|
1598
|
-
id,
|
|
1599
|
-
method,
|
|
1600
|
-
params
|
|
1601
|
-
}));
|
|
1602
|
-
});
|
|
1603
|
-
}
|
|
1604
|
-
/**
|
|
1605
|
-
* Called on WebSocket `close` or `error` after a successful connection.
|
|
1606
|
-
* Rejects all pending commands and marks the connection as disconnected so
|
|
1607
|
-
* subsequent `sendCommand` calls fail fast (no auto-reconnect).
|
|
1608
|
-
*/
|
|
1609
|
-
handleDisconnect(reason) {
|
|
1610
|
-
if (this.connectionState === "disconnected") return;
|
|
1611
|
-
this.connectionState = "disconnected";
|
|
1612
|
-
this.ws = null;
|
|
1613
|
-
this.stopHeartbeat();
|
|
1614
|
-
const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
|
|
1615
|
-
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
1616
|
-
this.pending.clear();
|
|
1617
|
-
}
|
|
1618
|
-
/**
|
|
1619
|
-
* Evict a previously active target under the single-attach model.
|
|
1620
|
-
* Rejects pending commands with a 'replaced-by-new-attach' reason and emits
|
|
1621
|
-
* a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
|
|
1622
|
-
* targetId. The caller is responsible for rebuilding the targets map afterwards.
|
|
1623
|
-
*
|
|
1624
|
-
* The error message uses 'replaced-by-new-attach' so test assertions can match it.
|
|
1625
|
-
*/
|
|
1626
|
-
evictTarget(targetId) {
|
|
1627
|
-
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1628
|
-
this.targets.delete(targetId);
|
|
1629
|
-
this.targetLastSeenAt.delete(targetId);
|
|
1630
|
-
const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
|
|
1631
|
-
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
1632
|
-
this.pending.clear();
|
|
1633
|
-
const event = {
|
|
1634
|
-
kind: "replaced",
|
|
1635
|
-
targetId,
|
|
1636
|
-
detectedAt
|
|
1637
|
-
};
|
|
1638
|
-
for (const listener of this.lifecycleListeners) try {
|
|
1639
|
-
listener(event);
|
|
1640
|
-
} catch {}
|
|
1641
|
-
}
|
|
1642
|
-
/**
|
|
1643
|
-
* Handle a page-level crash or target destruction event.
|
|
1644
|
-
* Removes the target from the in-memory map, rejects all pending commands,
|
|
1645
|
-
* and emits a lifecycle event.
|
|
1646
|
-
*
|
|
1647
|
-
* @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
|
|
1648
|
-
* @param targetId - The target ID from the event params (may be null for
|
|
1649
|
-
* Inspector.targetCrashed which has no targetId in the params).
|
|
1650
|
-
*/
|
|
1651
|
-
handleTargetGone(kind, targetId) {
|
|
1652
|
-
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1653
|
-
this.lastCrashDetectedAt = Date.now();
|
|
1654
|
-
if (targetId !== null) {
|
|
1655
|
-
this.targets.delete(targetId);
|
|
1656
|
-
this.targetLastSeenAt.delete(targetId);
|
|
1657
|
-
if (this.activeTargetId === targetId) this.activeTargetId = null;
|
|
1658
|
-
} else {
|
|
1659
|
-
this.targets.clear();
|
|
1660
|
-
this.targetLastSeenAt.clear();
|
|
1661
|
-
this.activeTargetId = null;
|
|
1662
|
-
}
|
|
1663
|
-
const err = /* @__PURE__ */ new Error(`[ait-debug] ${kind === "crashed" ? "page crash (Inspector.targetCrashed)" : kind === "destroyed" ? "target 종료 (Target.targetDestroyed)" : "target detach (Target.detachedFromTarget)"} 감지됨 — relay에서 제거됐습니다. 새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).`);
|
|
1664
|
-
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
1665
|
-
this.pending.clear();
|
|
1666
|
-
const event = {
|
|
1667
|
-
kind,
|
|
1668
|
-
targetId,
|
|
1669
|
-
detectedAt
|
|
1670
|
-
};
|
|
1671
|
-
for (const listener of this.lifecycleListeners) try {
|
|
1672
|
-
listener(event);
|
|
1673
|
-
} catch {}
|
|
1674
|
-
}
|
|
1675
|
-
/**
|
|
1676
|
-
* Start the optional CDP heartbeat loop.
|
|
1677
|
-
*
|
|
1678
|
-
* When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
|
|
1679
|
-
* we send `Runtime.evaluate({expression: '1'})` to each active target. If
|
|
1680
|
-
* the command times out (2 s hard deadline) or errors, we treat the target
|
|
1681
|
-
* as dead and call `handleTargetGone`.
|
|
1682
|
-
*
|
|
1683
|
-
* This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
|
|
1684
|
-
* even when the phone app has crashed, so the ws-level disconnect (#252) won't
|
|
1685
|
-
* fire. The heartbeat catches this gap.
|
|
1686
|
-
*
|
|
1687
|
-
* Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
|
|
1688
|
-
*/
|
|
1689
|
-
startHeartbeat(initialTargetId) {
|
|
1690
|
-
this.stopHeartbeat();
|
|
1691
|
-
const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
|
|
1692
|
-
if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
|
|
1693
|
-
const PING_TIMEOUT_MS = 2e3;
|
|
1694
|
-
this.heartbeatHandle = setInterval(() => {
|
|
1695
|
-
const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
|
|
1696
|
-
for (const targetId of targetIds) {
|
|
1697
|
-
const pingPromise = this.sendCommand("Runtime.evaluate", {
|
|
1698
|
-
expression: "1",
|
|
1699
|
-
returnByValue: true,
|
|
1700
|
-
timeout: PING_TIMEOUT_MS
|
|
1701
|
-
});
|
|
1702
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
|
|
1703
|
-
Promise.race([pingPromise, timeoutPromise]).catch(() => {
|
|
1704
|
-
if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
|
|
1705
|
-
});
|
|
1706
|
-
}
|
|
1707
|
-
}, envMs);
|
|
1708
|
-
}
|
|
1709
|
-
stopHeartbeat() {
|
|
1710
|
-
if (this.heartbeatHandle !== null) {
|
|
1711
|
-
clearInterval(this.heartbeatHandle);
|
|
1712
|
-
this.heartbeatHandle = null;
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
handleMessage(raw) {
|
|
1716
|
-
const message = parseInbound$1(raw);
|
|
1717
|
-
if (!message) return;
|
|
1718
|
-
if (typeof message.id === "number" && this.pending.has(message.id)) {
|
|
1719
|
-
const waiter = this.pending.get(message.id);
|
|
1720
|
-
this.pending.delete(message.id);
|
|
1721
|
-
if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
|
|
1722
|
-
else waiter.resolve(message.result);
|
|
1723
|
-
return;
|
|
1724
|
-
}
|
|
1725
|
-
const now = Date.now();
|
|
1726
|
-
let firstMessageSeen = false;
|
|
1727
|
-
for (const targetId of this.targets.keys()) {
|
|
1728
|
-
if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
|
|
1729
|
-
this.targetLastSeenAt.set(targetId, now);
|
|
1730
|
-
}
|
|
1731
|
-
if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
|
|
1732
|
-
if (typeof message.method !== "string") return;
|
|
1733
|
-
if (message.method === "Inspector.targetCrashed") {
|
|
1734
|
-
this.handleTargetGone("crashed", null);
|
|
1735
|
-
return;
|
|
1736
|
-
}
|
|
1737
|
-
if (message.method === "Target.targetDestroyed") {
|
|
1738
|
-
const targetId = isObject$1(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
|
|
1739
|
-
this.handleTargetGone("destroyed", targetId);
|
|
1740
|
-
return;
|
|
1741
|
-
}
|
|
1742
|
-
if (message.method === "Target.detachedFromTarget") {
|
|
1743
|
-
const targetId = isObject$1(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
|
|
1744
|
-
this.handleTargetGone("detached", targetId);
|
|
1745
|
-
return;
|
|
1746
|
-
}
|
|
1747
|
-
if (!this.buffers.has(message.method)) return;
|
|
1748
|
-
const event = message.method;
|
|
1749
|
-
const buffer = this.buffers.get(event);
|
|
1750
|
-
if (!buffer) return;
|
|
1751
|
-
buffer.push(message.params);
|
|
1752
|
-
const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
|
|
1753
|
-
if (buffer.length > cap) buffer.shift();
|
|
1754
|
-
this.emitter.emit(event, message.params);
|
|
1755
|
-
}
|
|
1756
|
-
getBufferedEvents(event) {
|
|
1757
|
-
return this.buffers.get(event) ?? [];
|
|
1758
|
-
}
|
|
1759
|
-
on(event, listener) {
|
|
1760
|
-
this.emitter.on(event, listener);
|
|
1761
|
-
return () => this.emitter.off(event, listener);
|
|
1762
|
-
}
|
|
1763
|
-
/** Close the relay client websocket and reject any in-flight commands. */
|
|
1764
|
-
close() {
|
|
1765
|
-
const ws = this.ws;
|
|
1766
|
-
this.stopHeartbeat();
|
|
1767
|
-
this.handleDisconnect("Chii relay connection closed");
|
|
1768
|
-
ws?.close();
|
|
1769
|
-
}
|
|
1770
|
-
};
|
|
1771
|
-
//#endregion
|
|
1772
|
-
//#region src/mcp/chii-relay.ts
|
|
1773
|
-
/**
|
|
1774
|
-
* Boots the local Chii relay server.
|
|
1775
|
-
*
|
|
1776
|
-
* Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome
|
|
1777
|
-
* WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.
|
|
1778
|
-
* The relay accepts a `target` websocket from the phone's injected `target.js`
|
|
1779
|
-
* and `client` websockets from CDP frontends (our MCP connection).
|
|
1780
|
-
*
|
|
1781
|
-
* Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app
|
|
1782
|
-
* entries.
|
|
1783
|
-
*
|
|
1784
|
-
* TOTP auth (relay-side, authoritative gate):
|
|
1785
|
-
* When `verifyAuth` is provided, this module gates both inbound surfaces:
|
|
1786
|
-
*
|
|
1787
|
-
* - HTTP 'request': a listener registered BEFORE `chii.start({server})`.
|
|
1788
|
-
* Node's `http.Server` calls listeners in registration order; the first
|
|
1789
|
-
* to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny
|
|
1790
|
-
* JSON body (`{"error":"totp-rejected"}`) so a cross-origin script
|
|
1791
|
-
* `fetch()` probe can READ the status (issue #478). Valid auth → return
|
|
1792
|
-
* without side-effect (chii's Koa handler serves it).
|
|
1793
|
-
*
|
|
1794
|
-
* - WS 'upgrade': after `chii.start()` has registered chii's own upgrade
|
|
1795
|
-
* listener, we take over the upgrade chain (remove chii's listeners,
|
|
1796
|
-
* re-dispatch manually). Invalid auth → accept-then-close: complete the
|
|
1797
|
-
* handshake via a `noServer` WebSocketServer, then immediately close
|
|
1798
|
-
* with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +
|
|
1799
|
-
* `socket.destroy()` only ever surfaced as close code 1006 in the
|
|
1800
|
-
* browser — indistinguishable from a tunnel failure, which left the
|
|
1801
|
-
* env-2 phone UI silent. The explicit dispatch (not listener ordering)
|
|
1802
|
-
* is what keeps chii away from rejected sockets: accept-then-close
|
|
1803
|
-
* leaves the socket alive, so an order-based early-return would let
|
|
1804
|
-
* chii's later listener complete a SECOND handshake on the same socket
|
|
1805
|
-
* — an auth bypass. Valid auth → forward to chii's captured listeners.
|
|
1806
|
-
*
|
|
1807
|
-
* TOTP code transports (issue #466) — two equivalent ways to carry the code:
|
|
1808
|
-
* 1. Query param `at=<code>` — used by the daemon-side `/client` connection
|
|
1809
|
-
* (`chii-connection.ts` appends it; it holds the secret).
|
|
1810
|
-
* 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's
|
|
1811
|
-
* stock `target.js` derives its WS endpoint from the script `src`
|
|
1812
|
-
* (`scriptEl.src.replace('target.js','')`), so the only way for the
|
|
1813
|
-
* phone to carry a code is to embed it in the script URL path. The
|
|
1814
|
-
* in-app attach injects `https://<host>/at/<code>/target.js`; both the
|
|
1815
|
-
* script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS
|
|
1816
|
-
* dial then carry the prefix. The listeners below rewrite the prefix
|
|
1817
|
-
* into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`
|
|
1818
|
-
* before chii's own handlers (registered later) parse it — chii only
|
|
1819
|
-
* ever sees the stripped URL.
|
|
1820
|
-
*
|
|
1821
|
-
* Threat model: "URL leak" — someone obtains the tunnel URL (Slack paste, QR
|
|
1822
|
-
* screenshot, shoulder-surfing) but does not have the shared TOTP secret.
|
|
1823
|
-
* Rotating 6-digit code makes the URL stale after 30 s.
|
|
1824
|
-
* A determined attacker who extracts the secret from the dogfood bundle can
|
|
1825
|
-
* still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).
|
|
1826
|
-
*
|
|
1827
|
-
* SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear
|
|
1828
|
-
* in any log, error message, or process output. `verifyAuth` is a black-box
|
|
1829
|
-
* predicate from the caller's perspective; this module only forwards pass/fail.
|
|
1830
|
-
*/
|
|
1831
|
-
const require$1 = createRequire(import.meta.url);
|
|
1832
|
-
/**
|
|
1833
|
-
* WS keepalive ping interval (ms).
|
|
1834
|
-
*
|
|
1835
|
-
* Cloudflare proxied connections are dropped after ~100 s of no traffic.
|
|
1836
|
-
* 45 s comfortably fits inside that window and lets both the phone-target leg
|
|
1837
|
-
* and the daemon-client leg survive idle CDP sessions.
|
|
1838
|
-
*/
|
|
1839
|
-
const DEFAULT_KEEPALIVE_INTERVAL_MS = 45e3;
|
|
1840
|
-
/**
|
|
1841
|
-
* Loads chii's internal WebSocketServer class and returns it together with a
|
|
1842
|
-
* flag indicating whether the real class was found.
|
|
1843
|
-
*
|
|
1844
|
-
* Returns `null` if the internal path is not resolvable (future chii release
|
|
1845
|
-
* changes the layout) — callers skip keepalive gracefully.
|
|
1846
|
-
*/
|
|
1847
|
-
function tryLoadChiiWssClass() {
|
|
1848
|
-
try {
|
|
1849
|
-
const mod = require$1("chii/server/lib/WebSocketServer");
|
|
1850
|
-
if (typeof mod === "function") return mod;
|
|
1851
|
-
} catch {}
|
|
1852
|
-
return null;
|
|
1853
|
-
}
|
|
1854
|
-
/**
|
|
1855
|
-
* Calls `chii.start()` and returns the chii `WebSocketServer` instance that
|
|
1856
|
-
* was constructed during the call.
|
|
1857
|
-
*
|
|
1858
|
-
* How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`
|
|
1859
|
-
* where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`
|
|
1860
|
-
* at module load time. The class reference is stable, so we can temporarily
|
|
1861
|
-
* patch `ChiiWssClass.prototype.start` — which runs *on the instance* —
|
|
1862
|
-
* to record `this` before the original `start` runs.
|
|
1863
|
-
*
|
|
1864
|
-
* The patch is installed before `chii.start()` and removed (via `finally`)
|
|
1865
|
-
* immediately after, so concurrent `startChiiRelay` calls nest correctly: each
|
|
1866
|
-
* call's patch overrides the previous in the prototype chain for the duration
|
|
1867
|
-
* of its own `chii.start()` call, restoring the prior descriptor on exit.
|
|
1868
|
-
*
|
|
1869
|
-
* If `ChiiWssClass` is null (internal path changed in a future chii release),
|
|
1870
|
-
* `chii.start()` runs unpatched and the function returns null — callers skip
|
|
1871
|
-
* keepalive gracefully without affecting relay correctness.
|
|
1872
|
-
*/
|
|
1873
|
-
async function startChiiWithCapture(chii, startOptions, ChiiWssClass) {
|
|
1874
|
-
if (ChiiWssClass === null) {
|
|
1875
|
-
await chii.start(startOptions);
|
|
1876
|
-
return null;
|
|
1877
|
-
}
|
|
1878
|
-
let captured = null;
|
|
1879
|
-
const proto = ChiiWssClass.prototype;
|
|
1880
|
-
const originalStart = proto.start;
|
|
1881
|
-
proto.start = function(server) {
|
|
1882
|
-
captured = this;
|
|
1883
|
-
return originalStart.call(this, server);
|
|
1884
|
-
};
|
|
1885
|
-
try {
|
|
1886
|
-
await chii.start(startOptions);
|
|
1887
|
-
} finally {
|
|
1888
|
-
proto.start = originalStart;
|
|
1889
|
-
}
|
|
1890
|
-
return captured;
|
|
1891
|
-
}
|
|
1892
|
-
function loadChiiServer() {
|
|
1893
|
-
const mod = require$1("chii");
|
|
1894
|
-
if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
|
|
1895
|
-
throw new Error("chii server module did not expose start()");
|
|
1896
|
-
}
|
|
1897
|
-
/**
|
|
1898
|
-
* Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent
|
|
1899
|
-
* query-based form, e.g.:
|
|
1900
|
-
*
|
|
1901
|
-
* `/at/123456/target.js` → `/target.js?at=123456`
|
|
1902
|
-
* `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`
|
|
1903
|
-
* `/at/123456/` → `/?at=123456`
|
|
1904
|
-
*
|
|
1905
|
-
* Returns `null` when the URL does not carry the prefix (including an empty
|
|
1906
|
-
* code segment) — callers fall back to the unmodified URL and the existing
|
|
1907
|
-
* query-based auth path.
|
|
1908
|
-
*
|
|
1909
|
-
* Pure string surgery — this function knows nothing about secrets or code
|
|
1910
|
-
* validity; verification stays inside the caller-provided `verifyAuth`
|
|
1911
|
-
* predicate (which parses the query). The raw path segment is appended
|
|
1912
|
-
* verbatim to the query: both path segments and query values are
|
|
1913
|
-
* percent-decoded exactly once by their consumers, so no re-encoding is
|
|
1914
|
-
* needed (TOTP codes are 6 digits and never percent-encoded in practice).
|
|
1915
|
-
*/
|
|
1916
|
-
function rewriteAtPathPrefix(rawUrl) {
|
|
1917
|
-
const match = /^\/at\/([^/?]+)(\/[^?]*)?(\?.*)?$/.exec(rawUrl);
|
|
1918
|
-
if (match === null) return null;
|
|
1919
|
-
const code = match[1];
|
|
1920
|
-
const path = match[2] === void 0 || match[2] === "" ? "/" : match[2];
|
|
1921
|
-
const query = match[3] ?? "";
|
|
1922
|
-
return `${path}${query}${query === "" ? "?" : "&"}at=${code}`;
|
|
1923
|
-
}
|
|
1924
|
-
/**
|
|
1925
|
-
* Starts the Chii relay and resolves once listening.
|
|
1926
|
-
*
|
|
1927
|
-
* Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral
|
|
1928
|
-
* port on every start, so a stale cloudflared orphan holding any particular
|
|
1929
|
-
* port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`
|
|
1930
|
-
* always reflect the actual bound port.
|
|
1931
|
-
*
|
|
1932
|
-
* chii.start() is called with `server` (our pre-created httpServer) BEFORE
|
|
1933
|
-
* httpServer.listen(). This is intentional: chii attaches its Koa handler and
|
|
1934
|
-
* WS upgrade listener to the server object, but the actual TCP bind is
|
|
1935
|
-
* performed by our httpServer.listen() call below. The `port`/`domain` values
|
|
1936
|
-
* passed to chii.start() are used for display/banner purposes inside chii and
|
|
1937
|
-
* do not affect which port the server binds. The connection path (clients
|
|
1938
|
-
* connecting to `relay.baseUrl`) always uses the post-listen confirmed port.
|
|
1939
|
-
*/
|
|
1940
|
-
async function startChiiRelay(options = {}) {
|
|
1941
|
-
const requestedPort = options.port ?? 0;
|
|
1942
|
-
const host = options.host ?? "127.0.0.1";
|
|
1943
|
-
const { verifyAuth, onAuthReject } = options;
|
|
1944
|
-
const keepaliveIntervalMs = options.keepaliveIntervalMs !== void 0 ? options.keepaliveIntervalMs : DEFAULT_KEEPALIVE_INTERVAL_MS;
|
|
1945
|
-
const httpServer = createServer();
|
|
1946
|
-
const notifyAuthReject = (kind) => {
|
|
1947
|
-
if (onAuthReject === void 0) return;
|
|
1948
|
-
try {
|
|
1949
|
-
onAuthReject({ kind });
|
|
1950
|
-
} catch {}
|
|
1951
|
-
};
|
|
1952
|
-
if (verifyAuth) httpServer.on("request", (req, res) => {
|
|
1953
|
-
const rewritten = rewriteAtPathPrefix(req.url ?? "");
|
|
1954
|
-
if (rewritten !== null) {
|
|
1955
|
-
req.url = rewritten;
|
|
1956
|
-
if (!verifyAuth(req)) {
|
|
1957
|
-
res.statusCode = 401;
|
|
1958
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1959
|
-
res.setHeader("Content-Type", "application/json");
|
|
1960
|
-
res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));
|
|
1961
|
-
notifyAuthReject("http-request");
|
|
1962
|
-
}
|
|
1963
|
-
return;
|
|
1964
|
-
}
|
|
1965
|
-
const pathname = (req.url ?? "").split("?")[0];
|
|
1966
|
-
if (pathname === "/targets" || pathname === "/targets/") {
|
|
1967
|
-
if (!verifyAuth(req)) {
|
|
1968
|
-
res.statusCode = 401;
|
|
1969
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1970
|
-
res.setHeader("Content-Type", "application/json");
|
|
1971
|
-
res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));
|
|
1972
|
-
notifyAuthReject("http-request");
|
|
1973
|
-
return;
|
|
1974
|
-
}
|
|
1975
|
-
return;
|
|
1976
|
-
}
|
|
1977
|
-
});
|
|
1978
|
-
const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;
|
|
1979
|
-
const capturedChiiWss = await startChiiWithCapture(loadChiiServer(), {
|
|
1980
|
-
server: httpServer,
|
|
1981
|
-
domain: `${host}:${requestedPort}`,
|
|
1982
|
-
port: requestedPort
|
|
1983
|
-
}, chiiWssClass);
|
|
1984
|
-
if (verifyAuth) {
|
|
1985
|
-
const chiiUpgradeListeners = httpServer.listeners("upgrade");
|
|
1986
|
-
httpServer.removeAllListeners("upgrade");
|
|
1987
|
-
const rejectWss = new WebSocketServer({ noServer: true });
|
|
1988
|
-
httpServer.on("upgrade", (req, socket, head) => {
|
|
1989
|
-
const rewritten = rewriteAtPathPrefix(req.url ?? "");
|
|
1990
|
-
if (rewritten !== null) req.url = rewritten;
|
|
1991
|
-
if (!verifyAuth(req)) {
|
|
1992
|
-
rejectWss.handleUpgrade(req, socket, head, (ws) => {
|
|
1993
|
-
ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);
|
|
1994
|
-
});
|
|
1995
|
-
notifyAuthReject("ws-upgrade");
|
|
1996
|
-
return;
|
|
1997
|
-
}
|
|
1998
|
-
for (const listener of chiiUpgradeListeners) listener(req, socket, head);
|
|
1999
|
-
});
|
|
2000
|
-
}
|
|
2001
|
-
const actualPort = await new Promise((resolve, reject) => {
|
|
2002
|
-
httpServer.once("error", reject);
|
|
2003
|
-
httpServer.listen(requestedPort, host, () => {
|
|
2004
|
-
httpServer.off("error", reject);
|
|
2005
|
-
resolve(httpServer.address().port);
|
|
2006
|
-
});
|
|
2007
|
-
});
|
|
2008
|
-
let keepaliveHandle = null;
|
|
2009
|
-
if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {
|
|
2010
|
-
const chiiWss = capturedChiiWss;
|
|
2011
|
-
keepaliveHandle = setInterval(() => {
|
|
2012
|
-
for (const client of chiiWss._wss.clients) if (client.readyState === 1) client.ping();
|
|
2013
|
-
}, keepaliveIntervalMs);
|
|
2014
|
-
}
|
|
2015
|
-
return {
|
|
2016
|
-
port: actualPort,
|
|
2017
|
-
baseUrl: `http://${host}:${actualPort}`,
|
|
2018
|
-
close: () => new Promise((resolve) => {
|
|
2019
|
-
if (keepaliveHandle !== null) {
|
|
2020
|
-
clearInterval(keepaliveHandle);
|
|
2021
|
-
keepaliveHandle = null;
|
|
2022
|
-
}
|
|
2023
|
-
httpServer.close(() => resolve());
|
|
2024
|
-
})
|
|
2025
|
-
};
|
|
2026
|
-
}
|
|
2027
|
-
//#endregion
|
|
2028
|
-
//#region src/mcp/devtools-opener.ts
|
|
2029
|
-
/**
|
|
2030
|
-
* Assembles the Chii self-hosted DevTools inspector URL for a given relay
|
|
2031
|
-
* and target.
|
|
2032
|
-
*
|
|
2033
|
-
* Chii serves its own DevTools frontend at
|
|
2034
|
-
* `<relayHttpBaseUrl>/front_end/chii_app.html`. The `ws=` (plain HTTP relay)
|
|
2035
|
-
* or `wss=` (HTTPS relay) query parameter is a URL-encoded string of the form
|
|
2036
|
-
* `<relay-host>/client/<uuid>?target=<id>&at=<totp>` — the same format used
|
|
2037
|
-
* by Chii's own target list page (derived from `chii/public/index.js`).
|
|
2038
|
-
*
|
|
2039
|
-
* The `at=` TOTP code is minted at call time via `mintTotp()`. It is valid
|
|
2040
|
-
* for ~3 minutes (relay gate accepts ±RELAY_VERIFY_SKEW_STEPS=6 steps =
|
|
2041
|
-
* 180–210 s). The developer must open the returned URL within that window.
|
|
2042
|
-
* If the window expires before the browser connects, the relay will reject the
|
|
2043
|
-
* WebSocket upgrade with close code 4401.
|
|
2044
|
-
*
|
|
2045
|
-
* FAIL-CLOSED (issue #509): `mintTotp` is REQUIRED. When omitted (i.e.
|
|
2046
|
-
* `undefined`), this function returns `null` — the caller must treat `null` as
|
|
2047
|
-
* "inspector not yet available" and show a waiting hint instead of a broken
|
|
2048
|
-
* link. Relay sessions gate every WS upgrade with TOTP (#452), so a URL built
|
|
2049
|
-
* without `at=` would be rejected with WS 4401 immediately — there is no
|
|
2050
|
-
* non-TOTP relay path in production. Returning `null` surfaces this cleanly as
|
|
2051
|
-
* a "TOTP not yet configured" state rather than silently producing a URL that
|
|
2052
|
-
* will always fail at the WS handshake.
|
|
2053
|
-
*
|
|
2054
|
-
* SECRET-HANDLING: `mintTotp` returns a code, not a secret. The code is
|
|
2055
|
-
* embedded in the `wss=` parameter (inside the `at=` param) of the returned
|
|
2056
|
-
* URL. Callers MUST NOT log the returned URL to stdout (stderr is OK — it is
|
|
2057
|
-
* the intended fallback surface for the developer to copy the URL).
|
|
2058
|
-
*
|
|
2059
|
-
* @param relayHttpBaseUrl - Local HTTP base URL of the Chii relay, e.g.
|
|
2060
|
-
* `http://127.0.0.1:9100`. No trailing slash.
|
|
2061
|
-
* @param targetId - Chii target id (from `GET <relay>/targets`).
|
|
2062
|
-
* @param mintTotp - Function that returns a fresh 6-digit TOTP code string.
|
|
2063
|
-
* Called at most once. **Required** — when `undefined`, the function returns
|
|
2064
|
-
* `null` (fail-closed: no `at=` param means the relay WS gate rejects the
|
|
2065
|
-
* handshake, so a null result is safer than a URL that always 404s).
|
|
2066
|
-
* @param panel - Initial panel. Defaults to `"console"`.
|
|
2067
|
-
*
|
|
2068
|
-
* @returns The inspector URL string, or `null` when `mintTotp` is absent.
|
|
2069
|
-
*
|
|
2070
|
-
* @example
|
|
2071
|
-
* buildChiiInspectorUrl(
|
|
2072
|
-
* 'http://127.0.0.1:9100',
|
|
2073
|
-
* 'abc123',
|
|
2074
|
-
* () => generateTotp(secret),
|
|
2075
|
-
* )
|
|
2076
|
-
* // → 'http://127.0.0.1:9100/front_end/chii_app.html?ws=127.0.0.1%3A9100%2Fclient%2F<uuid>%3Ftarget%3Dabc123%26at%3D<code>'
|
|
2077
|
-
*/
|
|
2078
|
-
function buildChiiInspectorUrl(relayHttpBaseUrl, targetId, mintTotp, panel = "console") {
|
|
2079
|
-
if (!mintTotp) return null;
|
|
2080
|
-
let relayHost;
|
|
2081
|
-
let wsParamName;
|
|
2082
|
-
try {
|
|
2083
|
-
const parsed = new URL(relayHttpBaseUrl);
|
|
2084
|
-
relayHost = parsed.host;
|
|
2085
|
-
wsParamName = parsed.protocol === "https:" ? "wss" : "ws";
|
|
2086
|
-
} catch {
|
|
2087
|
-
relayHost = relayHttpBaseUrl.replace(/^https?:\/\//i, "");
|
|
2088
|
-
wsParamName = /^https:/i.test(relayHttpBaseUrl) ? "wss" : "ws";
|
|
2089
|
-
}
|
|
2090
|
-
const clientId = `devtools-opener-${Date.now().toString(36)}`;
|
|
2091
|
-
const code = mintTotp();
|
|
2092
|
-
const wsPath = `${relayHost}/client/${clientId}?target=${encodeURIComponent(targetId)}&at=${encodeURIComponent(code)}`;
|
|
2093
|
-
const params = new URLSearchParams({
|
|
2094
|
-
[wsParamName]: wsPath,
|
|
2095
|
-
panel
|
|
2096
|
-
});
|
|
2097
|
-
return `${relayHttpBaseUrl.replace(/\/$/, "")}/front_end/chii_app.html?${params.toString()}`;
|
|
2098
|
-
}
|
|
2099
|
-
/**
|
|
2100
|
-
* Returns `true` when auto-open is **disabled**.
|
|
2101
|
-
*
|
|
2102
|
-
* Default (env var absent or any value other than `"1"`) is **disabled** —
|
|
2103
|
-
* the developer uses the "디버그 툴 열기" button on the /attach or dashboard
|
|
2104
|
-
* page instead. Set `AIT_AUTO_DEVTOOLS=1` to restore the old automatic
|
|
2105
|
-
* browser-open behaviour on device attach.
|
|
2106
|
-
*
|
|
2107
|
-
* `AIT_AUTO_DEVTOOLS=0` retains its explicit opt-out meaning for backward
|
|
2108
|
-
* compatibility (same effect as absent).
|
|
2109
|
-
*/
|
|
2110
|
-
function isAutoDevtoolsDisabled() {
|
|
2111
|
-
return process.env.AIT_AUTO_DEVTOOLS !== "1";
|
|
2112
|
-
}
|
|
2113
|
-
/**
|
|
2114
|
-
* Opens the given URL in the OS default browser using a platform-appropriate
|
|
2115
|
-
* command. Returns `true` on success.
|
|
2116
|
-
*
|
|
2117
|
-
* Failures are silent from the caller's perspective — the caller should log
|
|
2118
|
-
* the URL to stderr as a fallback before calling this function.
|
|
2119
|
-
*/
|
|
2120
|
-
function openUrlInBrowser(url) {
|
|
2121
|
-
if (process.env.AIT_AUTO_DEVTOOLS_TEST_SKIP_SPAWN === "1") return false;
|
|
2122
|
-
const { spawnSync } = __require("node:child_process");
|
|
2123
|
-
const platform = process.platform;
|
|
2124
|
-
let candidates;
|
|
2125
|
-
if (platform === "darwin") candidates = [{
|
|
2126
|
-
cmd: "open",
|
|
2127
|
-
args: [url]
|
|
2128
|
-
}];
|
|
2129
|
-
else if (platform === "win32") candidates = [{
|
|
2130
|
-
cmd: "cmd",
|
|
2131
|
-
args: [
|
|
2132
|
-
"/c",
|
|
2133
|
-
"start",
|
|
2134
|
-
"",
|
|
2135
|
-
url
|
|
2136
|
-
]
|
|
2137
|
-
}];
|
|
2138
|
-
else candidates = [
|
|
2139
|
-
{
|
|
2140
|
-
cmd: "xdg-open",
|
|
2141
|
-
args: [url]
|
|
2142
|
-
},
|
|
2143
|
-
{
|
|
2144
|
-
cmd: "sensible-browser",
|
|
2145
|
-
args: [url]
|
|
2146
|
-
},
|
|
2147
|
-
{
|
|
2148
|
-
cmd: "x-www-browser",
|
|
2149
|
-
args: [url]
|
|
2150
|
-
}
|
|
2151
|
-
];
|
|
2152
|
-
for (const { cmd, args } of candidates) try {
|
|
2153
|
-
const result = spawnSync(cmd, args, {
|
|
2154
|
-
encoding: "utf8",
|
|
2155
|
-
timeout: 5e3
|
|
2156
|
-
});
|
|
2157
|
-
if (!result.error && result.status === 0) return true;
|
|
2158
|
-
} catch {}
|
|
2159
|
-
return false;
|
|
2160
|
-
}
|
|
2161
|
-
/**
|
|
2162
|
-
* Manages auto-opening Chrome DevTools on every NEW target attach (issue #530).
|
|
2163
|
-
*
|
|
2164
|
-
* Create one instance per `runDebugServer` call and pass its `open()` method
|
|
2165
|
-
* as the `onAttach` callback to the attach watcher (via `DualConnectionRouter`).
|
|
2166
|
-
*
|
|
2167
|
-
* The open fires for each NEW `targetId` — subsequent notifications for the
|
|
2168
|
-
* same target are de-duplicated. Re-attach with a fresh targetId (e.g. after
|
|
2169
|
-
* page reload on the phone) fires a new open. The URL opened is the stable
|
|
2170
|
-
* `/inspector` endpoint (issue #530) when `inspectorStableUrl` is provided —
|
|
2171
|
-
* it mints a fresh TOTP at click time so there is no expiry race. Falls back to
|
|
2172
|
-
* building a direct `front_end/chii_app.html?wss=…` URL when
|
|
2173
|
-
* `inspectorStableUrl` is absent.
|
|
2174
|
-
*
|
|
2175
|
-
* Opt-out and mock-environment guard are checked at call time.
|
|
2176
|
-
*/
|
|
2177
|
-
var AutoDevtoolsOpener = class {
|
|
2178
|
-
/** Per-target de-dupe set (issue #530 — target-unit guard replaces once-per-daemon). */
|
|
2179
|
-
_openedTargets = /* @__PURE__ */ new Set();
|
|
2180
|
-
/**
|
|
2181
|
-
* Attempts to auto-open Chii DevTools in the developer's browser.
|
|
2182
|
-
*
|
|
2183
|
-
* Opens when:
|
|
2184
|
-
* - `options.targetId` is a NEW target (not yet in `_openedTargets`).
|
|
2185
|
-
*
|
|
2186
|
-
* No-op when any of the following conditions hold:
|
|
2187
|
-
* 1. `targetId` has already been opened (`_openedTargets` has it).
|
|
2188
|
-
* 2. `AIT_AUTO_DEVTOOLS=0` opt-out is set.
|
|
2189
|
-
* 3. `options.env` is `mock` (env 1 — F12 is already available).
|
|
2190
|
-
* 4. `options.targetId` is null/undefined/empty (no page attached yet).
|
|
2191
|
-
* 5. Neither `inspectorStableUrl` nor `relayHttpBaseUrl` is available.
|
|
2192
|
-
*
|
|
2193
|
-
* When `inspectorStableUrl` is provided (issue #530 stable URL): opens
|
|
2194
|
-
* `http://127.0.0.1:<port>/inspector` directly and writes it to stderr.
|
|
2195
|
-
* The URL contains no tunnel host or TOTP code — safe to log anywhere.
|
|
2196
|
-
*
|
|
2197
|
-
* Legacy path (no `inspectorStableUrl`): builds a direct
|
|
2198
|
-
* `<relay-base>/front_end/chii_app.html?wss=…` URL from `relayHttpBaseUrl`
|
|
2199
|
-
* + `mintTotp`, writes to stderr. TOTP expiry caveat applies (~3 min window).
|
|
2200
|
-
*
|
|
2201
|
-
* SECRET-HANDLING: direct inspector URL (written to stderr) may contain relay
|
|
2202
|
-
* host and TOTP code. Stable URL is secret-free. Neither must go to stdout or
|
|
2203
|
-
* persistent logs.
|
|
2204
|
-
*/
|
|
2205
|
-
open(options) {
|
|
2206
|
-
if (isAutoDevtoolsDisabled()) return;
|
|
2207
|
-
if (options.env === "mock") return;
|
|
2208
|
-
if (!options.targetId) return;
|
|
2209
|
-
const targetId = options.targetId;
|
|
2210
|
-
if (this._openedTargets.has(targetId)) return;
|
|
2211
|
-
if (options.inspectorStableUrl) {
|
|
2212
|
-
this._openedTargets.add(targetId);
|
|
2213
|
-
const stableUrl = options.inspectorStableUrl;
|
|
2214
|
-
process.stderr.write(`[ait-debug] 기기가 연결됐습니다.
|
|
2215
|
-
[ait-debug] QR 페이지 또는 대시보드(${stableUrl.replace("/inspector", "")})의 "디버그 툴 열기" 버튼을 눌러 DevTools를 여세요.\n[ait-debug] (AIT_AUTO_DEVTOOLS=1 로 설정하면 연결 시 자동으로 열립니다)
|
|
2216
|
-
`);
|
|
2217
|
-
if (!openUrlInBrowser(stableUrl)) process.stderr.write(`[ait-debug] 브라우저 자동 열기 실패 — ${stableUrl} 을 브라우저에서 직접 여세요.\n`);
|
|
2218
|
-
return;
|
|
2219
|
-
}
|
|
2220
|
-
if (!options.relayHttpBaseUrl) return;
|
|
2221
|
-
this._openedTargets.add(targetId);
|
|
2222
|
-
const inspectorUrl = buildChiiInspectorUrl(options.relayHttpBaseUrl, targetId, options.mintTotp);
|
|
2223
|
-
if (inspectorUrl === null) {
|
|
2224
|
-
process.stderr.write("[ait-debug] 기기가 연결됐습니다 — TOTP secret 미설정으로 인스펙터 URL을 생성할 수 없습니다.\n[ait-debug] relay 세션은 AIT_DEBUG_TOTP_SECRET 설정이 필요합니다.\n");
|
|
2225
|
-
return;
|
|
2226
|
-
}
|
|
2227
|
-
process.stderr.write(`[ait-debug] 기기가 연결됐습니다.
|
|
2228
|
-
[ait-debug] DevTools URL: ${inspectorUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=1 로 설정하면 연결 시 자동으로 열립니다)
|
|
2229
|
-
[ait-debug] 주의: URL의 at= 코드는 ~3분 안에서만 유효합니다.
|
|
2230
|
-
`);
|
|
2231
|
-
if (!openUrlInBrowser(inspectorUrl)) process.stderr.write("[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\n");
|
|
2232
|
-
}
|
|
2233
|
-
/**
|
|
2234
|
-
* Returns `true` if `open()` has been called for at least one target.
|
|
2235
|
-
* (Replaces the old once-per-session `_opened` flag; kept for interface
|
|
2236
|
-
* compatibility with tests that read `opener.opened`.)
|
|
2237
|
-
*/
|
|
2238
|
-
get opened() {
|
|
2239
|
-
return this._openedTargets.size > 0;
|
|
2240
|
-
}
|
|
2241
|
-
/** Returns the set of target IDs that have already been auto-opened. */
|
|
2242
|
-
get openedTargets() {
|
|
2243
|
-
return this._openedTargets;
|
|
2244
|
-
}
|
|
2245
|
-
};
|
|
2246
|
-
//#endregion
|
|
2247
|
-
//#region src/mcp/envelope.ts
|
|
2248
|
-
/**
|
|
2249
|
-
* Returns `true` when `AIT_MCP_COMPAT=chrome-devtools` is set, which bypasses
|
|
2250
|
-
* envelope wrapping and returns raw payloads (0.1.x back-compat).
|
|
2251
|
-
*/
|
|
2252
|
-
function isCompatMode() {
|
|
2253
|
-
return process.env.AIT_MCP_COMPAT === "chrome-devtools";
|
|
2254
|
-
}
|
|
2255
|
-
/**
|
|
2256
|
-
* Maps `McpEnvironment` to `EnvelopeEnv`. These are now the same 3-value
|
|
2257
|
-
* union (`mock | relay-dev | relay-mobile`; `relay-live` removed in #665),
|
|
2258
|
-
* so this is identity — kept as a named export for surface stability if
|
|
2259
|
-
* envelope env diverges in the future.
|
|
2260
|
-
*/
|
|
2261
|
-
function toEnvelopeEnv(env) {
|
|
2262
|
-
return env;
|
|
2263
|
-
}
|
|
2264
|
-
/**
|
|
2265
|
-
* Wraps `data` in a `ToolEnvelope<T>` **unless** compat mode is active, in
|
|
2266
|
-
* which case `data` is returned as-is.
|
|
2267
|
-
*
|
|
2268
|
-
* Use this at every tool call-site in `debug-server.ts` and `server.ts`.
|
|
2269
|
-
*
|
|
2270
|
-
* @example
|
|
2271
|
-
* ```ts
|
|
2272
|
-
* return jsonResult(wrapEnvelope(listPages(connection, tunnel), {
|
|
2273
|
-
* tool: 'list_pages',
|
|
2274
|
-
* env: resolveEnvironment(),
|
|
2275
|
-
* attached: connection.listTargets().length > 0,
|
|
2276
|
-
* }));
|
|
2277
|
-
* ```
|
|
2278
|
-
*/
|
|
2279
|
-
function wrapEnvelope(data, ctx) {
|
|
2280
|
-
if (isCompatMode()) return data;
|
|
2281
|
-
return {
|
|
2282
|
-
ok: true,
|
|
2283
|
-
data,
|
|
2284
|
-
meta: {
|
|
2285
|
-
tool: ctx.tool,
|
|
2286
|
-
env: toEnvelopeEnv(ctx.env),
|
|
2287
|
-
attached: ctx.attached,
|
|
2288
|
-
contentType: ctx.contentType ?? "json"
|
|
2289
|
-
}
|
|
2290
|
-
};
|
|
2291
|
-
}
|
|
2292
|
-
//#endregion
|
|
2293
|
-
//#region src/mcp/local-connection.ts
|
|
2294
|
-
/**
|
|
2295
|
-
* Local-browser `CdpConnection` — attaches directly to a Chromium instance
|
|
2296
|
-
* started with `--remote-debugging-port=<port>`.
|
|
2297
|
-
*
|
|
2298
|
-
* Topology (local debug mode, env 1):
|
|
2299
|
-
* Chromium --CDP WS--> this connection <--stdio--> MCP host
|
|
2300
|
-
*
|
|
2301
|
-
* The core insight: local Chromium and the phone's Toss WebView both speak
|
|
2302
|
-
* Chrome DevTools Protocol. The only difference is the attach strategy — how
|
|
2303
|
-
* you reach the CDP endpoint. Here we hit the Chromium DevTools HTTP endpoint
|
|
2304
|
-
* (`GET /json`) to discover per-target websocket URLs, then connect directly.
|
|
2305
|
-
* The Chii relay (env 2/3) uses `GET /targets` + `/client/<id>?target=<id>`.
|
|
2306
|
-
* Every tool (list_console_messages, get_dom_document, take_screenshot, …)
|
|
2307
|
-
* reads only the `CdpConnection` interface and works unchanged on both.
|
|
2308
|
-
*
|
|
2309
|
-
* Node-only: imports `ws`. Never bundled into the browser/in-app entries.
|
|
2310
|
-
*/
|
|
2311
|
-
/** Max events retained per domain ring buffer. */
|
|
2312
|
-
const DEFAULT_BUFFER_SIZE = 500;
|
|
2313
|
-
function isObject(value) {
|
|
2314
|
-
return typeof value === "object" && value !== null;
|
|
2315
|
-
}
|
|
2316
|
-
function parseInbound(raw) {
|
|
2317
|
-
let parsed;
|
|
2318
|
-
try {
|
|
2319
|
-
parsed = JSON.parse(raw);
|
|
2320
|
-
} catch {
|
|
2321
|
-
return null;
|
|
2322
|
-
}
|
|
2323
|
-
if (!isObject(parsed)) return null;
|
|
2324
|
-
const message = {};
|
|
2325
|
-
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
2326
|
-
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
2327
|
-
if ("params" in parsed) message.params = parsed.params;
|
|
2328
|
-
if ("result" in parsed) message.result = parsed.result;
|
|
2329
|
-
if (isObject(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
2330
|
-
return message;
|
|
2331
|
-
}
|
|
2332
|
-
const PHASE_1_EVENTS = [
|
|
2333
|
-
"Runtime.consoleAPICalled",
|
|
2334
|
-
"Network.requestWillBeSent",
|
|
2335
|
-
"Network.responseReceived"
|
|
2336
|
-
];
|
|
2337
|
-
/**
|
|
2338
|
-
* `CdpConnection` that attaches directly to a local Chromium over its built-in
|
|
2339
|
-
* CDP websocket. Mirrors `ChiiCdpConnection`'s buffering/command-routing/event
|
|
2340
|
-
* logic — same `parseInbound`, ring-buffer, `pending` map patterns — but the
|
|
2341
|
-
* attach strategy differs:
|
|
2342
|
-
*
|
|
2343
|
-
* Chii relay: `GET /targets` → open `/client/<id>?target=<id>` WS
|
|
2344
|
-
* Local CDP: `GET /json` → open `webSocketDebuggerUrl` per target directly
|
|
2345
|
-
*
|
|
2346
|
-
* Target selection: first `type === 'page'` target whose URL is not
|
|
2347
|
-
* `about:blank`, `about:newtab`, or a devtools:// URL.
|
|
2348
|
-
*/
|
|
2349
|
-
var LocalCdpConnection = class {
|
|
2350
|
-
/** Authoritative connection kind (issue #348) — local Chromium CDP. */
|
|
2351
|
-
kind = "local";
|
|
2352
|
-
devtoolsHttpUrl;
|
|
2353
|
-
bufferSize;
|
|
2354
|
-
emitter = new EventEmitter();
|
|
2355
|
-
buffers = /* @__PURE__ */ new Map();
|
|
2356
|
-
targets = /* @__PURE__ */ new Map();
|
|
2357
|
-
ws = null;
|
|
2358
|
-
nextCommandId = 1;
|
|
2359
|
-
/** In-flight enableDomains() promise — concurrent callers share it. */
|
|
2360
|
-
enablingPromise = null;
|
|
2361
|
-
/** Pending request→response commands keyed by CDP message id. */
|
|
2362
|
-
pending = /* @__PURE__ */ new Map();
|
|
2363
|
-
constructor(options) {
|
|
2364
|
-
this.devtoolsHttpUrl = options.devtoolsHttpUrl.replace(/\/$/, "");
|
|
2365
|
-
this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
|
|
2366
|
-
for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);
|
|
2367
|
-
this.emitter.setMaxListeners(0);
|
|
2368
|
-
}
|
|
2369
|
-
/**
|
|
2370
|
-
* Fetch the target list from the Chromium DevTools `/json` (or `/json/list`)
|
|
2371
|
-
* endpoint and pick the first non-blank page target.
|
|
2372
|
-
*
|
|
2373
|
-
* Returns the selected target's `webSocketDebuggerUrl` alongside the
|
|
2374
|
-
* normalized `CdpTarget` list (all page targets visible to the server).
|
|
2375
|
-
*/
|
|
2376
|
-
async fetchTargets() {
|
|
2377
|
-
const res = await fetch(`${this.devtoolsHttpUrl}/json`);
|
|
2378
|
-
if (!res.ok) throw new Error(`Chromium DevTools /json returned HTTP ${res.status} ${res.statusText}. Is the browser running with --remote-debugging-port?`);
|
|
2379
|
-
const body = await res.json();
|
|
2380
|
-
const list = Array.isArray(body) ? body : [];
|
|
2381
|
-
this.targets.clear();
|
|
2382
|
-
let selected = null;
|
|
2383
|
-
for (const item of list) {
|
|
2384
|
-
if (!isObject(item) || typeof item.id !== "string") continue;
|
|
2385
|
-
const cdpTarget = {
|
|
2386
|
-
id: item.id,
|
|
2387
|
-
title: typeof item.title === "string" ? item.title : "",
|
|
2388
|
-
url: typeof item.url === "string" ? item.url : ""
|
|
2389
|
-
};
|
|
2390
|
-
this.targets.set(item.id, cdpTarget);
|
|
2391
|
-
if (selected === null && item.type === "page" && typeof item.webSocketDebuggerUrl === "string" && !isBlankOrDevtoolsUrl(item.url)) selected = item;
|
|
2392
|
-
}
|
|
2393
|
-
return {
|
|
2394
|
-
selected,
|
|
2395
|
-
all: [...this.targets.values()]
|
|
2396
|
-
};
|
|
2397
|
-
}
|
|
2398
|
-
listTargets() {
|
|
2399
|
-
return [...this.targets.values()];
|
|
2400
|
-
}
|
|
2401
|
-
/**
|
|
2402
|
-
* Discover the target, open a direct CDP websocket to its
|
|
2403
|
-
* `webSocketDebuggerUrl`, and enable Phase 1+2 domains. Resolves once the
|
|
2404
|
-
* socket is open and domain-enable commands are sent. Idempotent — concurrent
|
|
2405
|
-
* callers share the in-flight promise.
|
|
2406
|
-
*/
|
|
2407
|
-
async enableDomains() {
|
|
2408
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
|
2409
|
-
if (this.enablingPromise) return this.enablingPromise;
|
|
2410
|
-
this.enablingPromise = this._doEnableDomains().finally(() => {
|
|
2411
|
-
this.enablingPromise = null;
|
|
2412
|
-
});
|
|
2413
|
-
return this.enablingPromise;
|
|
2414
|
-
}
|
|
2415
|
-
async _doEnableDomains() {
|
|
2416
|
-
const { selected } = await this.fetchTargets();
|
|
2417
|
-
if (!selected) throw new Error("No suitable page target found in the local Chromium instance. Ensure the browser has a non-blank page open and was started with --remote-debugging-port matching devtoolsHttpUrl.");
|
|
2418
|
-
const wsUrl = selected.webSocketDebuggerUrl;
|
|
2419
|
-
const ws = new WebSocket(wsUrl);
|
|
2420
|
-
this.ws = ws;
|
|
2421
|
-
await new Promise((resolve, reject) => {
|
|
2422
|
-
ws.once("open", () => resolve());
|
|
2423
|
-
ws.once("error", (err) => reject(err));
|
|
2424
|
-
});
|
|
2425
|
-
ws.on("message", (data) => this.handleMessage(data.toString()));
|
|
2426
|
-
this.sendFireAndForget("Runtime.enable");
|
|
2427
|
-
this.sendFireAndForget("Network.enable");
|
|
2428
|
-
this.sendFireAndForget("DOM.enable");
|
|
2429
|
-
this.sendFireAndForget("Page.enable");
|
|
2430
|
-
}
|
|
2431
|
-
/** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
|
|
2432
|
-
sendFireAndForget(method, params = {}) {
|
|
2433
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
2434
|
-
const id = this.nextCommandId++;
|
|
2435
|
-
this.ws.send(JSON.stringify({
|
|
2436
|
-
id,
|
|
2437
|
-
method,
|
|
2438
|
-
params
|
|
2439
|
-
}));
|
|
2440
|
-
}
|
|
2441
|
-
/**
|
|
2442
|
-
* Issue a CDP command and resolve with its typed result. Rejects on a CDP
|
|
2443
|
-
* error frame or when no websocket is open.
|
|
2444
|
-
*/
|
|
2445
|
-
send(method, params) {
|
|
2446
|
-
return this.sendCommand(method, params ?? {});
|
|
2447
|
-
}
|
|
2448
|
-
/**
|
|
2449
|
-
* Issue an arbitrary request→response command and resolve with its raw
|
|
2450
|
-
* result. Both the typed CDP `send` and any AIT domain commands build on this.
|
|
2451
|
-
*/
|
|
2452
|
-
sendCommand(method, params = {}) {
|
|
2453
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No local Chromium page attached yet. Call enableDomains() first and ensure the browser is running with --remote-debugging-port."));
|
|
2454
|
-
const id = this.nextCommandId++;
|
|
2455
|
-
const ws = this.ws;
|
|
2456
|
-
return new Promise((resolve, reject) => {
|
|
2457
|
-
this.pending.set(id, {
|
|
2458
|
-
resolve,
|
|
2459
|
-
reject
|
|
2460
|
-
});
|
|
2461
|
-
ws.send(JSON.stringify({
|
|
2462
|
-
id,
|
|
2463
|
-
method,
|
|
2464
|
-
params
|
|
2465
|
-
}));
|
|
2466
|
-
});
|
|
2467
|
-
}
|
|
2468
|
-
handleMessage(raw) {
|
|
2469
|
-
const message = parseInbound(raw);
|
|
2470
|
-
if (!message) return;
|
|
2471
|
-
if (typeof message.id === "number" && this.pending.has(message.id)) {
|
|
2472
|
-
const waiter = this.pending.get(message.id);
|
|
2473
|
-
this.pending.delete(message.id);
|
|
2474
|
-
if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
|
|
2475
|
-
else waiter.resolve(message.result);
|
|
2476
|
-
return;
|
|
2477
|
-
}
|
|
2478
|
-
if (typeof message.method !== "string") return;
|
|
2479
|
-
if (!this.buffers.has(message.method)) return;
|
|
2480
|
-
const event = message.method;
|
|
2481
|
-
const buffer = this.buffers.get(event);
|
|
2482
|
-
if (!buffer) return;
|
|
2483
|
-
buffer.push(message.params);
|
|
2484
|
-
if (buffer.length > this.bufferSize) buffer.shift();
|
|
2485
|
-
this.emitter.emit(event, message.params);
|
|
2486
|
-
}
|
|
2487
|
-
getBufferedEvents(event) {
|
|
2488
|
-
return this.buffers.get(event) ?? [];
|
|
2489
|
-
}
|
|
2490
|
-
on(event, listener) {
|
|
2491
|
-
this.emitter.on(event, listener);
|
|
2492
|
-
return () => this.emitter.off(event, listener);
|
|
2493
|
-
}
|
|
2494
|
-
/** Close the local CDP websocket and reject any in-flight commands. */
|
|
2495
|
-
close() {
|
|
2496
|
-
this.ws?.close();
|
|
2497
|
-
this.ws = null;
|
|
2498
|
-
for (const waiter of this.pending.values()) waiter.reject(/* @__PURE__ */ new Error("Local Chromium CDP connection closed."));
|
|
2499
|
-
this.pending.clear();
|
|
2500
|
-
}
|
|
2501
|
-
};
|
|
2502
|
-
/** True for URLs that should be skipped when selecting a page target. */
|
|
2503
|
-
function isBlankOrDevtoolsUrl(url) {
|
|
2504
|
-
return url === "" || url === "about:blank" || url === "about:newtab" || url.startsWith("devtools://") || url.startsWith("chrome://") || url.startsWith("chrome-extension://");
|
|
2505
|
-
}
|
|
2506
|
-
//#endregion
|
|
2507
|
-
//#region src/mcp/local-launcher.ts
|
|
2508
|
-
/**
|
|
2509
|
-
* Chromium launcher for the local debug mode (env 1).
|
|
2510
|
-
*
|
|
2511
|
-
* Launch decision rationale:
|
|
2512
|
-
* - `chrome-launcher` (npm) is purpose-built and finds installed Chrome, but
|
|
2513
|
-
* adds a runtime dependency to the MCP bundle. The repo already has a clear
|
|
2514
|
-
* "external dependency minimization" policy; `chrome-launcher` is not worth
|
|
2515
|
-
* pulling in for what is essentially `spawn(chromeBin, [...flags])`.
|
|
2516
|
-
* - Playwright is a devDependency used for E2E only — pulling `chromium.launch`
|
|
2517
|
-
* into the runtime MCP path would add ~100 MB of bundled Chromium to the
|
|
2518
|
-
* production install and break the "devDep = e2e only" boundary.
|
|
2519
|
-
* - `child_process.spawn` with a platform-aware binary search is the lightest
|
|
2520
|
-
* option: zero new dependencies, portable across macOS/Linux/Windows, and
|
|
2521
|
-
* trivially testable by injecting a `spawnFn`.
|
|
2522
|
-
*
|
|
2523
|
-
* The launcher finds an installed Chrome/Chromium using a prioritized list of
|
|
2524
|
-
* well-known binary paths per platform, then spawns it with:
|
|
2525
|
-
* --remote-debugging-port=<port>
|
|
2526
|
-
* --no-first-run
|
|
2527
|
-
* --no-default-browser-check
|
|
2528
|
-
* <devUrl>
|
|
2529
|
-
*
|
|
2530
|
-
* `pnpm dev` is started by the user; the MCP only launches the browser pointing
|
|
2531
|
-
* at it.
|
|
2532
|
-
*
|
|
2533
|
-
* Node-only.
|
|
2534
|
-
*/
|
|
2535
|
-
/**
|
|
2536
|
-
* Find an ephemeral free TCP port by briefly binding a server on port 0.
|
|
2537
|
-
* Resolves with the OS-assigned port number.
|
|
2538
|
-
*/
|
|
2539
|
-
function findFreePort() {
|
|
2540
|
-
return new Promise((resolve, reject) => {
|
|
2541
|
-
const server = net.createServer();
|
|
2542
|
-
server.listen(0, "127.0.0.1", () => {
|
|
2543
|
-
const addr = server.address();
|
|
2544
|
-
const port = typeof addr === "object" && addr !== null ? addr.port : null;
|
|
2545
|
-
server.close(() => {
|
|
2546
|
-
if (port === null) reject(/* @__PURE__ */ new Error("Failed to determine free port from net.Server."));
|
|
2547
|
-
else resolve(port);
|
|
2548
|
-
});
|
|
2549
|
-
});
|
|
2550
|
-
server.on("error", reject);
|
|
2551
|
-
});
|
|
2552
|
-
}
|
|
2553
|
-
/**
|
|
2554
|
-
* Returns an ordered list of Chromium/Chrome binary paths to try for the
|
|
2555
|
-
* current platform.
|
|
2556
|
-
*/
|
|
2557
|
-
function candidateChromePaths() {
|
|
2558
|
-
const os = platform();
|
|
2559
|
-
if (os === "darwin") return [
|
|
2560
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
2561
|
-
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
2562
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
2563
|
-
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
|
|
2564
|
-
];
|
|
2565
|
-
if (os === "linux") return [
|
|
2566
|
-
"/usr/bin/google-chrome",
|
|
2567
|
-
"/usr/bin/google-chrome-stable",
|
|
2568
|
-
"/usr/bin/chromium",
|
|
2569
|
-
"/usr/bin/chromium-browser",
|
|
2570
|
-
"/usr/local/bin/google-chrome",
|
|
2571
|
-
"/usr/local/bin/chromium",
|
|
2572
|
-
"/snap/bin/chromium"
|
|
2573
|
-
];
|
|
2574
|
-
if (os === "win32") {
|
|
2575
|
-
const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files";
|
|
2576
|
-
const programFilesX86 = process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
|
|
2577
|
-
return [
|
|
2578
|
-
`${programFiles}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
2579
|
-
`${programFilesX86}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
2580
|
-
`${programFiles}\\Chromium\\Application\\chrome.exe`
|
|
2581
|
-
];
|
|
2582
|
-
}
|
|
2583
|
-
return [];
|
|
2584
|
-
}
|
|
2585
|
-
/** Find the first Chrome/Chromium binary that exists on this machine. */
|
|
2586
|
-
function findChromeBinary() {
|
|
2587
|
-
for (const p of candidateChromePaths()) if (existsSync(p)) return p;
|
|
2588
|
-
return null;
|
|
2589
|
-
}
|
|
2590
|
-
/**
|
|
2591
|
-
* Launch a local Chromium instance with CDP remote debugging enabled.
|
|
2592
|
-
*
|
|
2593
|
-
* The caller is responsible for calling `handle.stop()` when done.
|
|
2594
|
-
*
|
|
2595
|
-
* @throws if no Chrome/Chromium binary is found on the system.
|
|
2596
|
-
*/
|
|
2597
|
-
async function launchChromium(options = {}) {
|
|
2598
|
-
const spawnImpl = options.spawnFn ?? spawn;
|
|
2599
|
-
const requestedPort = options.port ?? 0;
|
|
2600
|
-
const port = requestedPort === 0 ? await findFreePort() : requestedPort;
|
|
2601
|
-
const devUrl = options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173";
|
|
2602
|
-
const binary = findChromeBinary();
|
|
2603
|
-
if (binary === null) throw new Error("No Chrome/Chromium binary found on this system. Install Google Chrome or Chromium and try again. Searched: " + candidateChromePaths().join(", "));
|
|
2604
|
-
const child = spawnImpl(binary, [
|
|
2605
|
-
`--remote-debugging-port=${port}`,
|
|
2606
|
-
"--no-first-run",
|
|
2607
|
-
"--no-default-browser-check",
|
|
2608
|
-
"--user-data-dir=/tmp/ait-devtools-chromium-profile",
|
|
2609
|
-
...options.extraArgs ?? [],
|
|
2610
|
-
devUrl
|
|
2611
|
-
], {
|
|
2612
|
-
stdio: "ignore",
|
|
2613
|
-
detached: false
|
|
2614
|
-
});
|
|
2615
|
-
child.unref();
|
|
2616
|
-
const devtoolsUrl = `http://127.0.0.1:${port}`;
|
|
2617
|
-
process.stderr.write(`[ait-local-debug] Launched Chromium: ${binary}\n[ait-local-debug] CDP endpoint: ${devtoolsUrl}\n[ait-local-debug] Opening: ${devUrl}\n`);
|
|
2618
|
-
return {
|
|
2619
|
-
port,
|
|
2620
|
-
devtoolsUrl,
|
|
2621
|
-
stop() {
|
|
2622
|
-
try {
|
|
2623
|
-
child.kill();
|
|
2624
|
-
} catch {}
|
|
2625
|
-
}
|
|
2626
|
-
};
|
|
2627
|
-
}
|
|
2628
|
-
//#endregion
|
|
2629
|
-
//#region src/mcp/debug-server.ts
|
|
2630
|
-
/**
|
|
2631
|
-
* @ait-co/devtools debug-mode MCP server (stdio).
|
|
2632
|
-
*
|
|
2633
|
-
* Lets an AI coding agent attach to a running mini-app (real Toss WebView, or a
|
|
2634
|
-
* browser in dev mode) and read its console/network/DOM/screenshot over CDP plus
|
|
2635
|
-
* the AIT.* domain, without a human watching a phone. Transport is CDP-via-Chii:
|
|
2636
|
-
* a local Chii relay on an OS-assigned port (default 0) exposed through a
|
|
2637
|
-
* cloudflared quick tunnel; the phone attaches over the public wss URL.
|
|
2638
|
-
*
|
|
2639
|
-
* AI host --stdio--> this server --CDP client WS--> Chii relay :<OS-port>
|
|
2640
|
-
* ^-- target WS -- phone
|
|
2641
|
-
*
|
|
2642
|
-
* Port 0 (default): the OS picks a free ephemeral port on every startup.
|
|
2643
|
-
* This prevents EADDRINUSE when a stale cloudflared child (orphaned after
|
|
2644
|
-
* SIGKILL, PPID 1) still holds a fixed port — which previously caused the MCP
|
|
2645
|
-
* handshake to fail with -32000. With port 0 any orphaned cloudflared is
|
|
2646
|
-
* harmless; the new relay always gets a fresh port.
|
|
2647
|
-
*
|
|
2648
|
-
* Best-effort child cleanup: SIGINT/SIGTERM/SIGHUP handlers call shutdown() to
|
|
2649
|
-
* stop cloudflared and the relay. uncaughtException/unhandledRejection also
|
|
2650
|
-
* call shutdown() before exit. SIGKILL cannot be intercepted by Node, so
|
|
2651
|
-
* cloudflared orphans from SIGKILL remain (port 0 makes them harmless). Users
|
|
2652
|
-
* can clean up manually: `pkill -f 'cloudflared.*trycloudflare'`.
|
|
2653
|
-
*
|
|
2654
|
-
* The tool layer reads from an injectable `CdpConnection` (CDP) and `AitSource`
|
|
2655
|
-
* (AIT.*), so every tool is unit-testable with a fake (no phone). This module
|
|
2656
|
-
* wires the live pieces (relay + tunnel + production connection); the phone
|
|
2657
|
-
* roundtrip is fully wired and pending only on-device acceptance.
|
|
2658
|
-
*
|
|
2659
|
-
* Dynamic tool registration (issue #208):
|
|
2660
|
-
* The server advertises `listChanged: true` so MCP clients can subscribe to
|
|
2661
|
-
* `notifications/tools/list_changed`. Before any page attaches, only bootstrap
|
|
2662
|
-
* tools (`start_attach`, `list_pages`) are listed. Once a target appears,
|
|
2663
|
-
* the full attach-dependent tool set is added and a `list_changed` notification
|
|
2664
|
-
* is sent — without requiring a session restart. `runDebugServer` and
|
|
2665
|
-
* `runLocalDebugServer` start a polling watcher that detects the 0→N target
|
|
2666
|
-
* transition and calls `server.sendToolListChanged()`.
|
|
2667
|
-
*
|
|
2668
|
-
* Note: `src/mcp/server.ts` (dev mode, HTTP mock-state) is NOT subject to this
|
|
2669
|
-
* model — it has no attach concept and always exposes the full tool surface.
|
|
2670
|
-
*
|
|
2671
|
-
* Node-only.
|
|
2672
|
-
*/
|
|
2673
|
-
var debug_server_exports = /* @__PURE__ */ __exportAll({
|
|
2674
|
-
DualConnectionRouter: () => DualConnectionRouter,
|
|
2675
|
-
MOBILE_RELAY_BASE_URL_MISSING_MESSAGE: () => MOBILE_RELAY_BASE_URL_MISSING_MESSAGE,
|
|
2676
|
-
RELAY_SANDBOX_STALE_PAGE_MS: () => RELAY_SANDBOX_STALE_PAGE_MS,
|
|
2677
|
-
START_ATTACH_REMINT_THRESHOLD_MS: () => START_ATTACH_REMINT_THRESHOLD_MS,
|
|
2678
|
-
START_ATTACH_SEGMENT_MS: () => START_ATTACH_SEGMENT_MS,
|
|
2679
|
-
bootExternalRelayFamily: () => bootExternalRelayFamily,
|
|
2680
|
-
bootLocalFamily: () => bootLocalFamily,
|
|
2681
|
-
bootRelayFamily: () => bootRelayFamily,
|
|
2682
|
-
buildRelayVerifyAuth: () => buildRelayVerifyAuth,
|
|
2683
|
-
connectionHostsAllowed: () => connectionHostsAllowed,
|
|
2684
|
-
createDebugServer: () => createDebugServer,
|
|
2685
|
-
envForMode: () => envForMode,
|
|
2686
|
-
familyKeyForMode: () => familyKeyForMode,
|
|
2687
|
-
isRelayMode: () => isRelayMode,
|
|
2688
|
-
makeSingleConnectionRouter: () => makeSingleConnectionRouter,
|
|
2689
|
-
normalizeStartDebugMode: () => normalizeStartDebugMode,
|
|
2690
|
-
readMobileRelayBaseUrl: () => readMobileRelayBaseUrl,
|
|
2691
|
-
readRelayLocalUrl: () => readRelayLocalUrl,
|
|
2692
|
-
runDebugServer: () => runDebugServer,
|
|
2693
|
-
runLocalDebugServer: () => runLocalDebugServer,
|
|
2694
|
-
runMobileDebugServer: () => runMobileDebugServer,
|
|
2695
|
-
startAttachWatcher: () => startAttachWatcher
|
|
2696
|
-
});
|
|
2697
|
-
/**
|
|
2698
|
-
* Returns `true` when the mode routes to a relay connection (`relay-sandbox` or
|
|
2699
|
-
* `relay-staging`). Both surface the Tier B / relay-only tool set.
|
|
2700
|
-
*/
|
|
2701
|
-
function isRelayMode(mode) {
|
|
2702
|
-
return mode === "relay-sandbox" || mode === "relay-staging";
|
|
2703
|
-
}
|
|
2704
|
-
/**
|
|
2705
|
-
* Maps a `StartDebugMode` to the `McpEnvironment` it routes to (issue #626).
|
|
2706
|
-
* Used by `start_attach`'s mode prologue to decide whether a `switchMode` is
|
|
2707
|
-
* needed: when the active env already equals `envForMode(mode)`, the switch is
|
|
2708
|
-
* skipped (no `tools/list_changed` churn).
|
|
2709
|
-
*
|
|
2710
|
-
* - `local-browser` → `mock`
|
|
2711
|
-
* - `relay-sandbox` → `relay-mobile` (env 2 external-PWA relay)
|
|
2712
|
-
* - `relay-staging` → `relay-dev` (env 3 intoss-private relay)
|
|
2713
|
-
*/
|
|
2714
|
-
function envForMode(mode) {
|
|
2715
|
-
switch (mode) {
|
|
2716
|
-
case "local-browser": return "mock";
|
|
2717
|
-
case "relay-sandbox": return "relay-mobile";
|
|
2718
|
-
case "relay-staging": return "relay-dev";
|
|
2719
|
-
}
|
|
2720
|
-
}
|
|
2721
|
-
/**
|
|
2722
|
-
* Single-attach guard for `run_tests` (#646). Two concurrent runs injecting
|
|
2723
|
-
* into the same single-attach page would interleave `Runtime.evaluate` and
|
|
2724
|
-
* corrupt each other's `globalThis.__testBundle`. The model is "reject the
|
|
2725
|
-
* second", not "queue" — a module-level flag is process-wide, which matches the
|
|
2726
|
-
* single physical attached page (only one target is live at a time). The
|
|
2727
|
-
* entry-time `conn` snapshot ensures a run finishes on the connection it started
|
|
2728
|
-
* on even if `router.active` flips mid-run.
|
|
2729
|
-
*/
|
|
2730
|
-
let runTestsInFlight = false;
|
|
2731
|
-
/**
|
|
2732
|
-
* Builds the debug-mode MCP server around an injected CDP connection + AIT
|
|
2733
|
-
* source + tunnel status getter. Pure wiring — does not start a relay or
|
|
2734
|
-
* tunnel, which is what makes the tool surface unit-testable.
|
|
2735
|
-
*
|
|
2736
|
-
* `tools/list` is two-tiered (issue #208):
|
|
2737
|
-
* - bootstrap (always): `start_attach`, `list_pages`
|
|
2738
|
-
* - attach-dependent (after `connection.listTargets().length > 0`): all others
|
|
2739
|
-
*
|
|
2740
|
-
* `CallTool` is NOT tiered — hidden tools still execute (attach errors surface
|
|
2741
|
-
* naturally via `enableDomains`). The tier only controls visibility.
|
|
2742
|
-
*/
|
|
2743
|
-
function createDebugServer(deps) {
|
|
2744
|
-
const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 6e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret, onAttachUrlBuilt, getTunnelChildPid, readLock: readLockDep, stalePageThresholdMs = RELAY_SANDBOX_STALE_PAGE_MS, nowMs = () => Date.now() } = deps;
|
|
2745
|
-
const getTotpSecret = deps.getTotpSecret ?? (() => totpSecret);
|
|
2746
|
-
const readLockFn = readLockDep ?? readServerLock;
|
|
2747
|
-
const router = routerDep ?? makeSingleConnectionRouter(connection);
|
|
2748
|
-
const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, router.activeRelayOrigin));
|
|
2749
|
-
const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},relayOrigin=${router.activeRelayOrigin ?? "none"}`);
|
|
2750
|
-
const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
|
|
2751
|
-
const attachDeps = {
|
|
2752
|
-
getTunnelStatus,
|
|
2753
|
-
getTotpSecret,
|
|
2754
|
-
qrHttpServer,
|
|
2755
|
-
onAttachUrlBuilt,
|
|
2756
|
-
stalePageThresholdMs,
|
|
2757
|
-
nowMs
|
|
2758
|
-
};
|
|
2759
|
-
const server = new Server({
|
|
2760
|
-
name: "ait-debug",
|
|
2761
|
-
version: "0.1.122"
|
|
2762
|
-
}, { capabilities: { tools: { listChanged: true } } });
|
|
2763
|
-
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
2764
|
-
const conn = router.active;
|
|
2765
|
-
const env = resolveEnvironment();
|
|
2766
|
-
const attached = conn.listTargets().length > 0;
|
|
2767
|
-
const envFiltered = filterToolsByEnvironment(DEBUG_TOOL_DEFINITIONS, env);
|
|
2768
|
-
return { tools: attached ? envFiltered.map((tool) => ({ ...tool })) : envFiltered.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
|
|
2769
|
-
});
|
|
2770
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2771
|
-
const name = request.params.name;
|
|
2772
|
-
if (!isDebugToolName(name)) return {
|
|
2773
|
-
content: [{
|
|
2774
|
-
type: "text",
|
|
2775
|
-
text: `Unknown tool: ${name}`
|
|
2776
|
-
}],
|
|
2777
|
-
isError: true
|
|
2778
|
-
};
|
|
2779
|
-
const conn = router.active;
|
|
2780
|
-
if (name === "start_debug") {
|
|
2781
|
-
const rawMode = request.params.arguments?.mode;
|
|
2782
|
-
const mode = normalizeStartDebugMode(rawMode);
|
|
2783
|
-
if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser' | 'relay-sandbox' | 'relay-staging' 중 하나를 전달하세요. (relay-live / env 4는 #665에서 제거됐습니다.)");
|
|
2784
|
-
const rawProjectRoot = request.params.arguments?.projectRoot;
|
|
2785
|
-
const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
|
|
2786
|
-
try {
|
|
2787
|
-
return jsonResult(await router.switchMode(mode, projectRoot));
|
|
2788
|
-
} catch (err) {
|
|
2789
|
-
return errorResult(err, name);
|
|
2790
|
-
}
|
|
2791
|
-
}
|
|
2792
|
-
if (name === "start_attach") {
|
|
2793
|
-
const args = request.params.arguments;
|
|
2794
|
-
let attachConn = conn;
|
|
2795
|
-
const rawMode = args?.mode;
|
|
2796
|
-
if (rawMode !== void 0) {
|
|
2797
|
-
const mode = normalizeStartDebugMode(rawMode);
|
|
2798
|
-
if (mode === null || mode === "local-browser") return mcpError("start_attach: mode가 올바르지 않습니다. 'relay-sandbox' | 'relay-staging' 중 하나를 전달하세요 (local-browser는 QR attach가 없어 start_attach에서 지원하지 않습니다).");
|
|
2799
|
-
const targetEnv = envForMode(mode);
|
|
2800
|
-
if (resolveEnvironment() !== targetEnv) {
|
|
2801
|
-
const rawProjectRoot = args?.projectRoot;
|
|
2802
|
-
const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
|
|
2803
|
-
try {
|
|
2804
|
-
await router.switchMode(mode, projectRoot);
|
|
2805
|
-
} catch (err) {
|
|
2806
|
-
return errorResult(err, name);
|
|
2807
|
-
}
|
|
2808
|
-
attachConn = router.active;
|
|
2809
|
-
}
|
|
2810
|
-
}
|
|
2811
|
-
const attachEnv = resolveEnvironment();
|
|
2812
|
-
if (!isRelayEnv(attachEnv)) return mcpError("start_attach: relay 전용 tool입니다 (env 2 / relay-sandbox 또는 env 3 / relay-staging). 현재 환경은 'local-browser'(mock)입니다 — mode 인자로 'relay-sandbox' 또는 'relay-staging'을 전달하거나, 먼저 relay 모드로 전환하세요.");
|
|
2813
|
-
const waitForAttach = true;
|
|
2814
|
-
const rawWaitTimeout = args?.wait_timeout_seconds;
|
|
2815
|
-
const callTimeoutMs = (() => {
|
|
2816
|
-
if (typeof rawWaitTimeout !== "number" || !Number.isFinite(rawWaitTimeout)) return waitForAttachTimeoutMs;
|
|
2817
|
-
if (rawWaitTimeout <= 0) return waitForAttachTimeoutMs;
|
|
2818
|
-
return Math.round(Math.max(1, Math.min(600, rawWaitTimeout))) * 1e3;
|
|
2819
|
-
})();
|
|
2820
|
-
try {
|
|
2821
|
-
const prep = await prepareAttach(attachDeps, attachEnv, args, attachConn);
|
|
2822
|
-
if (!prep.ok) return prep.error;
|
|
2823
|
-
const attachResult = await renderAndMaybeWait(attachDeps, prep, waitForAttach, callTimeoutMs, attachConn);
|
|
2824
|
-
if (!attachResult.isError) await injectDebugIndicator(attachConn);
|
|
2825
|
-
return attachResult;
|
|
2826
|
-
} catch (err) {
|
|
2827
|
-
return errorResult(err, name);
|
|
2828
|
-
}
|
|
2829
|
-
}
|
|
2830
|
-
const env = resolveEnvironment();
|
|
2831
|
-
const envReason = resolveEnvironmentReason();
|
|
2832
|
-
if (!isToolAvailableIn(name, env)) {
|
|
2833
|
-
const requiredEnv = getToolAvailability(name) ?? "unknown";
|
|
2834
|
-
logWarn("tool.error", {
|
|
2835
|
-
tool: name,
|
|
2836
|
-
errorKind: "tier-filter",
|
|
2837
|
-
requiredEnv,
|
|
2838
|
-
currentEnv: env,
|
|
2839
|
-
envReason
|
|
2840
|
-
});
|
|
2841
|
-
return tierRejectionError(name, requiredEnv, env, envReason);
|
|
2842
|
-
}
|
|
2843
|
-
if (isAitToolName(name)) try {
|
|
2844
|
-
await conn.enableDomains();
|
|
2845
|
-
switch (name) {
|
|
2846
|
-
case "AIT.getSdkCallHistory": return jsonResult(await getSdkCallHistory(aitSource));
|
|
2847
|
-
case "AIT.getMockState": return jsonResult(await getMockState(aitSource));
|
|
2848
|
-
case "AIT.getOperationalEnvironment": return jsonResult(await getOperationalEnvironment(aitSource));
|
|
2849
|
-
default: return unknownTool(name);
|
|
2850
|
-
}
|
|
2851
|
-
} catch (err) {
|
|
2852
|
-
return errorResult(err, name);
|
|
2853
|
-
}
|
|
2854
|
-
if (name === "get_debug_status") try {
|
|
2855
|
-
const rawLimit = request.params.arguments?.recent_errors_limit;
|
|
2856
|
-
const recentErrorsLimit = typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 10;
|
|
2857
|
-
return envelopeResult(await getDiagnostics({
|
|
2858
|
-
tunnel: getTunnelStatus(),
|
|
2859
|
-
connection: conn,
|
|
2860
|
-
env,
|
|
2861
|
-
envReason,
|
|
2862
|
-
collector,
|
|
2863
|
-
readLock: readLockFn,
|
|
2864
|
-
recentErrorsLimit,
|
|
2865
|
-
tunnelChildPid: getTunnelChildPid?.() ?? void 0
|
|
2866
|
-
}), name, env, conn.listTargets().length > 0);
|
|
2867
|
-
} catch (err) {
|
|
2868
|
-
return errorResult(err, name);
|
|
2869
|
-
}
|
|
2870
|
-
try {
|
|
2871
|
-
await conn.enableDomains();
|
|
2872
|
-
} catch (err) {
|
|
2873
|
-
if (name === "list_pages") {
|
|
2874
|
-
try {
|
|
2875
|
-
await conn.refreshTargets?.();
|
|
2876
|
-
} catch {}
|
|
2877
|
-
return envelopeResult(listPages(conn, getTunnelStatus()), name, env, conn.listTargets().length > 0);
|
|
2878
|
-
}
|
|
2879
|
-
return classifyEnableDomainError(err, name);
|
|
2880
|
-
}
|
|
2881
|
-
try {
|
|
2882
|
-
switch (name) {
|
|
2883
|
-
case "list_console_messages": return jsonResult(listConsoleMessages(conn));
|
|
2884
|
-
case "list_exceptions": {
|
|
2885
|
-
const rawLimit = request.params.arguments?.limit;
|
|
2886
|
-
return jsonResult({ exceptions: listExceptions(conn, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
|
|
2887
|
-
}
|
|
2888
|
-
case "list_network_requests": return jsonResult(listNetworkRequests(conn));
|
|
2889
|
-
case "list_pages":
|
|
2890
|
-
try {
|
|
2891
|
-
await conn.refreshTargets?.();
|
|
2892
|
-
} catch {}
|
|
2893
|
-
return envelopeResult(listPages(conn, getTunnelStatus()), name, env, conn.listTargets().length > 0);
|
|
2894
|
-
case "get_dom_document": return jsonResult(await getDomDocument(conn));
|
|
2895
|
-
case "take_snapshot": return jsonResult(await takeSnapshot(conn));
|
|
2896
|
-
case "take_screenshot": {
|
|
2897
|
-
const shot = await takeScreenshot(conn);
|
|
2898
|
-
return { content: [{
|
|
2899
|
-
type: "image",
|
|
2900
|
-
data: shot.data,
|
|
2901
|
-
mimeType: shot.mimeType
|
|
2902
|
-
}] };
|
|
2903
|
-
}
|
|
2904
|
-
case "measure_safe_area": return envelopeResult(await measureSafeArea(conn, env), name, env, conn.listTargets().length > 0);
|
|
2905
|
-
case "evaluate": {
|
|
2906
|
-
const expression = request.params.arguments?.expression;
|
|
2907
|
-
if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
|
|
2908
|
-
if (!connectionHostsAllowed(conn)) return mcpError("evaluate: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
|
|
2909
|
-
return jsonResult(await evaluate(conn, expression));
|
|
2910
|
-
}
|
|
2911
|
-
case "call_sdk": {
|
|
2912
|
-
const sdkName = request.params.arguments?.name;
|
|
2913
|
-
if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
|
|
2914
|
-
const rawArgs = request.params.arguments?.args;
|
|
2915
|
-
const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
|
|
2916
|
-
if (!connectionHostsAllowed(conn)) return mcpError("call_sdk: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
|
|
2917
|
-
const sdkResult = await callSdk(conn, sdkName, sdkArgs);
|
|
2918
|
-
if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk", conn.kind === "local");
|
|
2919
|
-
return envelopeResult(sdkResult, name, env, conn.listTargets().length > 0);
|
|
2920
|
-
}
|
|
2921
|
-
case "run_tests": {
|
|
2922
|
-
const rawFiles = request.params.arguments?.files;
|
|
2923
|
-
if (!Array.isArray(rawFiles) || rawFiles.length === 0) return mcpError("run_tests: files 인자가 비어 있습니다. 실행할 테스트 파일 glob을 배열로 전달하세요.");
|
|
2924
|
-
const patterns = rawFiles.filter((p) => typeof p === "string" && p !== "");
|
|
2925
|
-
if (patterns.length === 0) return mcpError("run_tests: files 인자에 유효한 문자열 glob이 없습니다.");
|
|
2926
|
-
const rawRoot = request.params.arguments?.projectRoot;
|
|
2927
|
-
const projectRoot = typeof rawRoot === "string" ? rawRoot : process.cwd();
|
|
2928
|
-
const rawTimeout = request.params.arguments?.timeout_ms;
|
|
2929
|
-
const timeoutMs = typeof rawTimeout === "number" && rawTimeout >= 1e3 && rawTimeout <= 6e5 ? rawTimeout : void 0;
|
|
2930
|
-
const runTestPages = conn.listTargets();
|
|
2931
|
-
const connAsAny = conn;
|
|
2932
|
-
const hasLivePage = isSandboxPageFresh(runTestPages, typeof connAsAny.getTargetLastSeenAt === "function" ? (id) => connAsAny.getTargetLastSeenAt(id) : null, nowMs(), stalePageThresholdMs);
|
|
2933
|
-
if (!hasLivePage && isRelayEnv(env)) {
|
|
2934
|
-
const autoAttachArgs = request.params.arguments;
|
|
2935
|
-
const prep = await prepareAttach(attachDeps, env, autoAttachArgs, conn);
|
|
2936
|
-
if (!prep.ok) return prep.error;
|
|
2937
|
-
const autoAttachResult = await renderAndMaybeWait(attachDeps, prep, true, waitForAttachTimeoutMs, conn);
|
|
2938
|
-
if (autoAttachResult.isError) return autoAttachResult;
|
|
2939
|
-
const rawCell = autoAttachArgs?.cell;
|
|
2940
|
-
if (rawCell !== null && typeof rawCell === "object" && !Array.isArray(rawCell)) await injectGlobals(conn, rawCell);
|
|
2941
|
-
if (!connectionHostsAllowed(conn)) return mcpError("run_tests: 연결된 페이지가 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
|
|
2942
|
-
if (runTestsInFlight) return mcpError("run_tests: 이미 다른 테스트 실행이 진행 중입니다 (single-attach 모델: 페이지는 한 번에 하나의 실행만 처리). 완료 후 다시 시도하세요.");
|
|
2943
|
-
runTestsInFlight = true;
|
|
2944
|
-
try {
|
|
2945
|
-
const files = await discoverTestFiles(patterns, projectRoot);
|
|
2946
|
-
if (files.length === 0) return mcpError(`run_tests: 매칭된 테스트 파일이 없습니다 (patterns: ${patterns.join(", ")}).`);
|
|
2947
|
-
if (conn.listTargets().length === 0) return pageMissingError("run_tests");
|
|
2948
|
-
logInfo("run_tests.start", {
|
|
2949
|
-
fileCount: files.length,
|
|
2950
|
-
autoAttach: true
|
|
2951
|
-
});
|
|
2952
|
-
const report = await runWithConnection(conn, files, {
|
|
2953
|
-
timeoutMs,
|
|
2954
|
-
collectCaptures: true
|
|
2955
|
-
});
|
|
2956
|
-
logInfo("run_tests.done", {
|
|
2957
|
-
passed: report.totals.passed,
|
|
2958
|
-
failed: report.totals.failed,
|
|
2959
|
-
skipped: report.totals.skipped
|
|
2960
|
-
});
|
|
2961
|
-
const runAttached = conn.listTargets().length > 0;
|
|
2962
|
-
return envelopeResult(toRunTestsResult(report), name, env, runAttached);
|
|
2963
|
-
} finally {
|
|
2964
|
-
runTestsInFlight = false;
|
|
2965
|
-
}
|
|
2966
|
-
}
|
|
2967
|
-
if (!hasLivePage) return mcpError("run_tests: 연결된 페이지가 없습니다. mock(로컬) 환경에서는 auto-attach가 지원되지 않습니다. list_pages로 연결 상태를 확인하고 페이지가 붙어 있는지 확인하세요.");
|
|
2968
|
-
if (!connectionHostsAllowed(conn)) return mcpError("run_tests: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
|
|
2969
|
-
if (runTestsInFlight) return mcpError("run_tests: 이미 다른 테스트 실행이 진행 중입니다 (single-attach 모델: 페이지는 한 번에 하나의 실행만 처리). 완료 후 다시 시도하세요.");
|
|
2970
|
-
runTestsInFlight = true;
|
|
2971
|
-
try {
|
|
2972
|
-
const files = await discoverTestFiles(patterns, projectRoot);
|
|
2973
|
-
if (files.length === 0) return mcpError(`run_tests: 매칭된 테스트 파일이 없습니다 (patterns: ${patterns.join(", ")}).`);
|
|
2974
|
-
if (conn.listTargets().length === 0) return pageMissingError("run_tests");
|
|
2975
|
-
logInfo("run_tests.start", { fileCount: files.length });
|
|
2976
|
-
const report = await runWithConnection(conn, files, {
|
|
2977
|
-
timeoutMs,
|
|
2978
|
-
collectCaptures: true
|
|
2979
|
-
});
|
|
2980
|
-
logInfo("run_tests.done", {
|
|
2981
|
-
passed: report.totals.passed,
|
|
2982
|
-
failed: report.totals.failed,
|
|
2983
|
-
skipped: report.totals.skipped
|
|
2984
|
-
});
|
|
2985
|
-
const runAttached = conn.listTargets().length > 0;
|
|
2986
|
-
return envelopeResult(toRunTestsResult(report), name, env, runAttached);
|
|
2987
|
-
} finally {
|
|
2988
|
-
runTestsInFlight = false;
|
|
2989
|
-
}
|
|
2990
|
-
}
|
|
2991
|
-
default: return unknownTool(name);
|
|
2992
|
-
}
|
|
2993
|
-
} catch (err) {
|
|
2994
|
-
return errorResult(err, name, conn.kind === "local");
|
|
2995
|
-
}
|
|
2996
|
-
});
|
|
2997
|
-
return server;
|
|
2998
|
-
}
|
|
2999
|
-
/**
|
|
3000
|
-
* Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or
|
|
3001
|
-
* `null` when the value is not one of the three accepted modes:
|
|
3002
|
-
* 'local-browser' | 'relay-sandbox' | 'relay-staging'
|
|
3003
|
-
*
|
|
3004
|
-
* Hard rename (issue #398): the older `local`/`mobile`/`staging`/`live` names
|
|
3005
|
-
* and their aliases are no longer accepted — pre-1.0, no back-compat.
|
|
3006
|
-
* `relay-live` (env 4) removed in #665.
|
|
3007
|
-
*/
|
|
3008
|
-
function normalizeStartDebugMode(raw) {
|
|
3009
|
-
if (raw === "local-browser" || raw === "relay-sandbox" || raw === "relay-staging") return raw;
|
|
3010
|
-
return null;
|
|
3011
|
-
}
|
|
3012
|
-
/**
|
|
3013
|
-
* Positive-allowlist kill-switch for side-effect MCP tools (#665).
|
|
3014
|
-
*
|
|
3015
|
-
* Returns `true` when the connection's attached targets are all on allowed
|
|
3016
|
-
* debug hosts (localhost / trycloudflare / private-apps). Returns `false` when
|
|
3017
|
-
* any target's page URL is on a non-allowed host (e.g. `apps.tossmini.com`).
|
|
3018
|
-
*
|
|
3019
|
-
* For local connections this always returns `true` — the local Chromium is
|
|
3020
|
-
* always on localhost. For relay connections without any pages it returns
|
|
3021
|
-
* `true` (no pages = nothing to block; the caller's page-missing guard fires
|
|
3022
|
-
* first).
|
|
3023
|
-
*
|
|
3024
|
-
* SECRET-HANDLING: hostnames are NEVER logged here — only the boolean result
|
|
3025
|
-
* is returned to the caller.
|
|
3026
|
-
*/
|
|
3027
|
-
function connectionHostsAllowed(conn) {
|
|
3028
|
-
if (conn.kind === "local") return true;
|
|
3029
|
-
const pages = conn.listTargets();
|
|
3030
|
-
if (pages.length === 0) return true;
|
|
3031
|
-
return pages.every((p) => {
|
|
3032
|
-
try {
|
|
3033
|
-
return isDebugAllowedHost(new URL(p.url ?? "").hostname);
|
|
3034
|
-
} catch {
|
|
3035
|
-
return false;
|
|
3036
|
-
}
|
|
3037
|
-
});
|
|
3038
|
-
}
|
|
3039
|
-
/**
|
|
3040
|
-
* Builds a trivial `ConnectionRouter` pinned to a single connection (issue
|
|
3041
|
-
* #348). Used by `createDebugServer` when no real dual router is injected —
|
|
3042
|
-
* every existing single-connection test and the `local`-only / `relay`-only
|
|
3043
|
-
* boot path. `switchMode` here cannot lazily boot another family, so it only
|
|
3044
|
-
* honors a request that matches the connection's own kind; any cross-family
|
|
3045
|
-
* request is rejected with a clear "dynamic switch unavailable in this session"
|
|
3046
|
-
* error. `confirm` parameter and `relay-live` gate removed (#665).
|
|
3047
|
-
*/
|
|
3048
|
-
function makeSingleConnectionRouter(connection) {
|
|
3049
|
-
return {
|
|
3050
|
-
get active() {
|
|
3051
|
-
return connection;
|
|
3052
|
-
},
|
|
3053
|
-
activeRelayOrigin: void 0,
|
|
3054
|
-
switchMode(mode, _projectRoot) {
|
|
3055
|
-
if (mode === "relay-sandbox") return Promise.reject(/* @__PURE__ */ new Error("start_debug: 이 세션은 단일 연결만 보유합니다 — 'relay-sandbox'(환경 2 PWA, 외부 relay)로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 relay-sandbox 모드로 재시작하세요."));
|
|
3056
|
-
if (isRelayMode(mode) !== (connection.kind === "relay")) return Promise.reject(/* @__PURE__ */ new Error(`start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — '${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 원하는 모드로 재시작하세요.`));
|
|
3057
|
-
const environment = deriveEnvironment(connection.kind);
|
|
3058
|
-
return Promise.resolve({
|
|
3059
|
-
mode,
|
|
3060
|
-
environment,
|
|
3061
|
-
kind: connection.kind,
|
|
3062
|
-
nextStep: connection.kind === "relay" ? "start_attach로 attach QR 생성 + 폰 attach까지 한 번에 진행하세요." : "list_pages로 로컬 페이지 attach를 확인하세요."
|
|
3063
|
-
});
|
|
3064
|
-
}
|
|
3065
|
-
};
|
|
3066
|
-
}
|
|
3067
|
-
/**
|
|
3068
|
-
* Re-builds an attach URL from stored components with a FRESHLY-minted TOTP code,
|
|
3069
|
-
* so the dashboard/`/attach` QR is never an expired bake-in (Defect 1).
|
|
3070
|
-
* SECRET-HANDLING: reads AIT_DEBUG_TOTP_SECRET at call time (mirrors tunnel.ts
|
|
3071
|
-
* getDashboardState). The minted code rides inside attachUrl's at= param only —
|
|
3072
|
-
* never logged. generateTotp() relies on its Date.now() default.
|
|
3073
|
-
*/
|
|
3074
|
-
function rebuildAttachUrl(parts) {
|
|
3075
|
-
const secret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
3076
|
-
const code = secret ? generateTotp(secret) : void 0;
|
|
3077
|
-
return parts.kind === "launcher" ? buildLauncherAttachUrl(parts.tunnelHttpUrl, parts.wssUrl, code, {
|
|
3078
|
-
name: parts.appName,
|
|
3079
|
-
...parts.selfdebug ? { selfdebug: true } : {}
|
|
3080
|
-
}) : buildDeepLinkAttachUrl(parts.schemeUrl, parts.wssUrl, code);
|
|
3081
|
-
}
|
|
3082
|
-
function jsonResult(value) {
|
|
3083
|
-
return { content: [{
|
|
3084
|
-
type: "text",
|
|
3085
|
-
text: JSON.stringify(value, null, 2)
|
|
3086
|
-
}] };
|
|
3087
|
-
}
|
|
3088
|
-
/**
|
|
3089
|
-
* Wraps `value` in a `ToolEnvelope` (when compat mode is off) and returns it
|
|
3090
|
-
* as a text content block. When `AIT_MCP_COMPAT=chrome-devtools` is set the
|
|
3091
|
-
* envelope is skipped and the raw value is returned — identical to `jsonResult`.
|
|
3092
|
-
*/
|
|
3093
|
-
function envelopeResult(value, tool, env, attached) {
|
|
3094
|
-
const wrapped = wrapEnvelope(value, {
|
|
3095
|
-
tool,
|
|
3096
|
-
env,
|
|
3097
|
-
attached
|
|
3098
|
-
});
|
|
3099
|
-
return { content: [{
|
|
3100
|
-
type: "text",
|
|
3101
|
-
text: JSON.stringify(wrapped, null, 2)
|
|
3102
|
-
}] };
|
|
3103
|
-
}
|
|
3104
|
-
/**
|
|
3105
|
-
* Maps a {@link RelayRunReport} to a flat, agent-friendly object for the
|
|
3106
|
-
* `run_tests` tool result. SECRET-HANDLING: a RelayRunReport carries only
|
|
3107
|
-
* startedAt/duration/totals, per-file `{file, result}`, and capture lines —
|
|
3108
|
-
* file paths are surfaced (allowed), relay wss/TOTP URLs never appear in it.
|
|
3109
|
-
* No stripping needed; this only reshapes for readability.
|
|
3110
|
-
*
|
|
3111
|
-
* Captures (#696): the envelope surfaces a COUNT-LEVEL summary only
|
|
3112
|
-
* (per-category line counts) — never the line bodies. Capture bodies belong in
|
|
3113
|
-
* the on-disk artifact, not the `run_tests` log (keeps the tool result small and
|
|
3114
|
-
* avoids dumping large capture arrays into the agent's context).
|
|
3115
|
-
*/
|
|
3116
|
-
function toRunTestsResult(report) {
|
|
3117
|
-
const captureCounts = {};
|
|
3118
|
-
for (const { category } of report.captures) captureCounts[category] = (captureCounts[category] ?? 0) + 1;
|
|
3119
|
-
return {
|
|
3120
|
-
startedAt: report.startedAt,
|
|
3121
|
-
duration: report.duration,
|
|
3122
|
-
totals: report.totals,
|
|
3123
|
-
files: report.files.map((f) => "error" in f.result ? {
|
|
3124
|
-
file: f.file,
|
|
3125
|
-
error: f.result.error
|
|
3126
|
-
} : {
|
|
3127
|
-
file: f.file,
|
|
3128
|
-
duration: f.result.duration,
|
|
3129
|
-
passed: f.result.passed,
|
|
3130
|
-
failed: f.result.failed,
|
|
3131
|
-
skipped: f.result.skipped,
|
|
3132
|
-
tests: f.result.tests
|
|
3133
|
-
}),
|
|
3134
|
-
captures: captureCounts
|
|
3135
|
-
};
|
|
3136
|
-
}
|
|
3137
|
-
function unknownTool(name) {
|
|
3138
|
-
return mcpError(`알 수 없는 tool: ${name}`);
|
|
3139
|
-
}
|
|
3140
|
-
/**
|
|
3141
|
-
* enableDomains()가 던진 에러를 4상태로 분류해 적절한 메시지를 반환한다.
|
|
3142
|
-
*
|
|
3143
|
-
* - "No mini-app page attached" → page 미attach (상태 2)
|
|
3144
|
-
* - crash/destroy/replaced 패턴 → page crash (상태 3)
|
|
3145
|
-
* - relay disconnect 패턴 → relay 연결 끊김
|
|
3146
|
-
* - 그 외 → 원본 메시지 + list_pages 안내
|
|
3147
|
-
*/
|
|
3148
|
-
function classifyEnableDomainError(err, toolName) {
|
|
3149
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3150
|
-
if (message.includes("No mini-app page attached") || message.includes("페이지가 attach 안")) return pageMissingError(toolName);
|
|
3151
|
-
if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
|
|
3152
|
-
if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket") || message.includes("Chii relay connection closed")) return relayDisconnectError(toolName);
|
|
3153
|
-
return classifyToolError(err, toolName);
|
|
3154
|
-
}
|
|
3155
|
-
/**
|
|
3156
|
-
* CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
|
|
3157
|
-
* debug-server 내부 try/catch 블록에서 공통으로 사용한다.
|
|
3158
|
-
*/
|
|
3159
|
-
function errorResult(err, name, isLocal = false) {
|
|
3160
|
-
return classifyToolError(err, name, isLocal);
|
|
3161
|
-
}
|
|
3162
|
-
/**
|
|
3163
|
-
* Starts a polling watcher that detects target-set changes on
|
|
3164
|
-
* `connection.listTargets()` and sends a `notifications/tools/list_changed`
|
|
3165
|
-
* notification on the given server.
|
|
3166
|
-
*
|
|
3167
|
-
* The watcher polls every `intervalMs` (default 1 000 ms). On each tick it
|
|
3168
|
-
* calls `connection.refreshTargets?.()` first (fix #705-B) so that silent
|
|
3169
|
-
* disconnects (no CDP event, phone backgrounded / tunnel quiet) are picked up
|
|
3170
|
-
* before the signature is read. If `refreshTargets` throws — e.g. a transient
|
|
3171
|
-
* relay error — the tick is skipped entirely to avoid a spurious detach signal.
|
|
3172
|
-
*
|
|
3173
|
-
* After the refresh, it fires `server.sendToolListChanged()` + `onAttach()`
|
|
3174
|
-
* whenever the sorted target-id signature changes AND the new target set is
|
|
3175
|
-
* non-empty. This covers:
|
|
3176
|
-
* - 0→N first attach
|
|
3177
|
-
* - 1→1 target replacement (same count, different id — e.g. rescan)
|
|
3178
|
-
* - N→M any change where the result is still non-empty
|
|
3179
|
-
*
|
|
3180
|
-
* Full detach (→ empty) fires `onDetach()` (fix #705-A) on the exact
|
|
3181
|
-
* non-empty→empty edge — i.e. only when the previous signature was non-empty.
|
|
3182
|
-
* This lets callers push an immediate "disconnected" SSE update to the
|
|
3183
|
-
* dashboard without waiting for the next periodic interval.
|
|
3184
|
-
*
|
|
3185
|
-
* The interval is **never cleared automatically** — it keeps running until
|
|
3186
|
-
* `stop()` is called during shutdown. This ensures that a target replacement
|
|
3187
|
-
* after the first attach is always detected.
|
|
3188
|
-
*
|
|
3189
|
-
* `onAttach` is called on every non-empty signature change (or immediately when
|
|
3190
|
-
* already attached). Use this to trigger side-effects such as pushing a fresh
|
|
3191
|
-
* SSE state to open dashboard tabs (issue #509). Both callbacks are optional;
|
|
3192
|
-
* omitting them preserves the previous behaviour exactly.
|
|
3193
|
-
*
|
|
3194
|
-
* SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
|
|
3195
|
-
* Only an attach-detected stderr line is emitted (no target details).
|
|
3196
|
-
*
|
|
3197
|
-
* @returns `stop` — call this during shutdown to clear the interval.
|
|
3198
|
-
*/
|
|
3199
|
-
function startAttachWatcher(connection, server, intervalMs = 1e3, onAttach, onDetach) {
|
|
3200
|
-
/** Sorted, comma-joined target-id string — '' means no targets attached. */
|
|
3201
|
-
function signature() {
|
|
3202
|
-
return connection.listTargets().map((t) => t.id).sort().join(",");
|
|
3203
|
-
}
|
|
3204
|
-
let lastSignature = signature();
|
|
3205
|
-
if (lastSignature !== "") {
|
|
3206
|
-
server.sendToolListChanged();
|
|
3207
|
-
onAttach?.();
|
|
3208
|
-
}
|
|
3209
|
-
/** Compare current vs last signature and fire the appropriate callback. */
|
|
3210
|
-
function tick() {
|
|
3211
|
-
const current = signature();
|
|
3212
|
-
if (current !== lastSignature) {
|
|
3213
|
-
const wasNonEmpty = lastSignature !== "";
|
|
3214
|
-
lastSignature = current;
|
|
3215
|
-
if (current !== "") {
|
|
3216
|
-
server.sendToolListChanged();
|
|
3217
|
-
onAttach?.();
|
|
3218
|
-
} else if (wasNonEmpty) onDetach?.();
|
|
3219
|
-
}
|
|
3220
|
-
}
|
|
3221
|
-
const handle = setInterval(() => {
|
|
3222
|
-
if (connection.refreshTargets) connection.refreshTargets().then(() => {
|
|
3223
|
-
tick();
|
|
3224
|
-
}, (_err) => {});
|
|
3225
|
-
else tick();
|
|
3226
|
-
}, intervalMs);
|
|
3227
|
-
return { stop() {
|
|
3228
|
-
clearInterval(handle);
|
|
3229
|
-
} };
|
|
3230
|
-
}
|
|
3231
|
-
/**
|
|
3232
|
-
* Factory that constructs a `ChiiCdpConnection` for the given relay base URL.
|
|
3233
|
-
*
|
|
3234
|
-
* Introduced as a named seam so PR-2 (dual-connection, #348) can defer
|
|
3235
|
-
* construction to first-activation time by moving or replacing this call. Since
|
|
3236
|
-
* #396 every family (relay included) is constructed lazily on its first
|
|
3237
|
-
* `start_debug`, so this is always called from the lazy boot path.
|
|
3238
|
-
*
|
|
3239
|
-
* The relay base URL is only available after `startChiiRelay()` resolves, so
|
|
3240
|
-
* the factory is called right after that point (same as before this refactor).
|
|
3241
|
-
*/
|
|
3242
|
-
function createRelayConnection(relayBaseUrl) {
|
|
3243
|
-
return new ChiiCdpConnection({
|
|
3244
|
-
relayBaseUrl,
|
|
3245
|
-
totpSecret: process.env.AIT_DEBUG_TOTP_SECRET
|
|
3246
|
-
});
|
|
3247
|
-
}
|
|
3248
|
-
/**
|
|
3249
|
-
* AIT source that always forwards over the *currently active* connection
|
|
3250
|
-
* (issue #348). The single-connection `ChiiAitSource` binds one sender at
|
|
3251
|
-
* construction; in the dual-connection daemon the AIT.* domain must follow the
|
|
3252
|
-
* active connection across `start_debug` swaps, so this indirection reads
|
|
3253
|
-
* `getActive()` on every call.
|
|
3254
|
-
*
|
|
3255
|
-
* Both `ChiiCdpConnection` and `LocalCdpConnection` expose `sendCommand`, so
|
|
3256
|
-
* the active connection is a valid `AitCommandSender`.
|
|
3257
|
-
*/
|
|
3258
|
-
var RoutingAitSource = class extends ChiiAitSource {
|
|
3259
|
-
constructor(getActive) {
|
|
3260
|
-
super({ sendCommand: (method, params) => getActive().sendCommand(method, params) });
|
|
3261
|
-
}
|
|
3262
|
-
};
|
|
3263
|
-
/**
|
|
3264
|
-
* Boots the local-browser family (issues #348, #356). Launches a Chromium with
|
|
3265
|
-
* `--remote-debugging-port` and returns a `LocalCdpConnection` attached to it,
|
|
3266
|
-
* plus a `stop()` that kills both.
|
|
3267
|
-
*
|
|
3268
|
-
* Booted lazily via the dual router's `bootLazyFor('local-browser')` callback,
|
|
3269
|
-
* at most once on the first `start_debug({ mode: 'local-browser' })` (all-lazy,
|
|
3270
|
-
* #396 — no run function boots a family at startup anymore).
|
|
3271
|
-
*/
|
|
3272
|
-
async function bootLocalFamily() {
|
|
3273
|
-
const chromium = await launchChromium({
|
|
3274
|
-
port: 0,
|
|
3275
|
-
devUrl: process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
|
|
3276
|
-
});
|
|
3277
|
-
await new Promise((r) => setTimeout(r, 800));
|
|
3278
|
-
const connection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
|
|
3279
|
-
return {
|
|
3280
|
-
connection,
|
|
3281
|
-
stop() {
|
|
3282
|
-
connection.close();
|
|
3283
|
-
chromium.stop();
|
|
3284
|
-
}
|
|
3285
|
-
};
|
|
3286
|
-
}
|
|
3287
|
-
/**
|
|
3288
|
-
* Boots the relay family (issues #348, #356): starts the Chii relay on an
|
|
3289
|
-
* OS-assigned port (with optional TOTP gate), opens a cloudflared quick tunnel
|
|
3290
|
-
* to the relay's confirmed port in the background, prints the attach banner,
|
|
3291
|
-
* and arms the tunnel health probe. Returns a {@link BootedFamily} whose
|
|
3292
|
-
* `getTunnelStatus()` reflects the live tunnel (it flips up once the background
|
|
3293
|
-
* tunnel resolves and follows reissues).
|
|
3294
|
-
*
|
|
3295
|
-
* Booted lazily via the dual router's `bootLazyFor('relay-intoss')` callback
|
|
3296
|
-
* (symmetry with {@link bootLocalFamily}), at most once on the first
|
|
3297
|
-
* `start_debug({ mode: 'relay-staging' })` (all-lazy, #396 — every relay boot now
|
|
3298
|
-
* flows through `switchMode` after the project-local secret load). `relay-live`
|
|
3299
|
-
* removed (#665).
|
|
3300
|
-
*
|
|
3301
|
-
* The relay base URL is only known after `startChiiRelay()` resolves, so the
|
|
3302
|
-
* `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside
|
|
3303
|
-
* this function, after the relay port is confirmed.
|
|
3304
|
-
*
|
|
3305
|
-
* SECRET-HANDLING: the TOTP secret rides only inside `verifyAuth`; the wssUrl
|
|
3306
|
-
* (relay host) is never logged here directly.
|
|
3307
|
-
*/
|
|
3308
|
-
async function bootRelayFamily(options = {}) {
|
|
3309
|
-
assertRelayAuthConfigured();
|
|
3310
|
-
const relayPort = options.relayPort ?? 0;
|
|
3311
|
-
const totpEnabled = options.verifyAuth !== void 0;
|
|
3312
|
-
const relay = await startChiiRelay({
|
|
3313
|
-
port: relayPort,
|
|
3314
|
-
verifyAuth: options.verifyAuth,
|
|
3315
|
-
onAuthReject: options.onAuthReject
|
|
3316
|
-
});
|
|
3317
|
-
logInfo("server.start", {
|
|
3318
|
-
port: relay.port,
|
|
3319
|
-
totpEnabled
|
|
3320
|
-
});
|
|
3321
|
-
let tunnel = null;
|
|
3322
|
-
let tunnelStatus = makeTunnelStatus(false, null);
|
|
3323
|
-
let tunnelProbe = null;
|
|
3324
|
-
generateAttachToken();
|
|
3325
|
-
startQuickTunnel(relay.port).then((t) => {
|
|
3326
|
-
tunnel = t;
|
|
3327
|
-
tunnelStatus = makeTunnelStatus(true, t.wssUrl);
|
|
3328
|
-
options.onWssUrl?.(t.wssUrl);
|
|
3329
|
-
if (t.childPid !== void 0) options.onTunnelChildPid?.(t.childPid);
|
|
3330
|
-
logInfo("tunnel.up", { totpEnabled });
|
|
3331
|
-
tunnelProbe = startTunnelHealthProbe(t, relay.port, {
|
|
3332
|
-
onReissue: (newTunnel) => {
|
|
3333
|
-
tunnel = newTunnel;
|
|
3334
|
-
tunnelStatus = makeTunnelStatus(true, newTunnel.wssUrl, null, 0);
|
|
3335
|
-
options.onWssUrl?.(newTunnel.wssUrl);
|
|
3336
|
-
if (newTunnel.childPid !== void 0) options.onTunnelChildPid?.(newTunnel.childPid);
|
|
3337
|
-
printAttachBanner({
|
|
3338
|
-
wssUrl: newTunnel.wssUrl,
|
|
3339
|
-
totpEnabled
|
|
3340
|
-
}).then(() => {
|
|
3341
|
-
logInfo("tunnel.up", {
|
|
3342
|
-
totpEnabled,
|
|
3343
|
-
reissued: true
|
|
3344
|
-
});
|
|
3345
|
-
});
|
|
3346
|
-
},
|
|
3347
|
-
onPermanentDrop: (droppedAt) => {
|
|
3348
|
-
tunnelStatus = makeTunnelStatus(false, null, droppedAt, 3);
|
|
3349
|
-
logError("tunnel.down", { msg: `tunnel permanently dropped (${droppedAt}). Restart: npx @ait-co/devtools devtools-mcp` });
|
|
3350
|
-
options.onTunnelDown?.();
|
|
3351
|
-
}
|
|
3352
|
-
});
|
|
3353
|
-
return printAttachBanner({
|
|
3354
|
-
wssUrl: t.wssUrl,
|
|
3355
|
-
totpEnabled
|
|
3356
|
-
});
|
|
3357
|
-
}, (err) => {
|
|
3358
|
-
logError("tunnel.down", { msg: `Failed to open cloudflared quick tunnel: ${err instanceof Error ? err.message : String(err)}. The relay is up locally; attach over the public URL is unavailable until the tunnel starts.` });
|
|
3359
|
-
});
|
|
3360
|
-
const connection = createRelayConnection(relay.baseUrl);
|
|
3361
|
-
return {
|
|
3362
|
-
connection,
|
|
3363
|
-
relayOrigin: "intoss-webview",
|
|
3364
|
-
relayHttpUrl: relay.baseUrl,
|
|
3365
|
-
getTunnelStatus: () => tunnelStatus,
|
|
3366
|
-
stop() {
|
|
3367
|
-
tunnelProbe?.stop();
|
|
3368
|
-
tunnel?.stop();
|
|
3369
|
-
connection.close();
|
|
3370
|
-
relay.close();
|
|
3371
|
-
}
|
|
3372
|
-
};
|
|
3373
|
-
}
|
|
3374
|
-
/**
|
|
3375
|
-
* Boots the EXTERNAL relay family for env 2 (real-device PWA, issue #378).
|
|
3376
|
-
*
|
|
3377
|
-
* Unlike {@link bootRelayFamily}, this does NOT start a relay or a tunnel —
|
|
3378
|
-
* the unplugin (`tunnel: { cdp: true }`) already brought up a Chii relay for
|
|
3379
|
-
* the env-2 PWA and exposed its public base URL via `AIT_RELAY_BASE_URL`. Here
|
|
3380
|
-
* the MCP only opens a CDP client (`createRelayConnection`) against that
|
|
3381
|
-
* external relay. The relay's lifecycle is owned by the unplugin, so `stop()`
|
|
3382
|
-
* closes ONLY the CDP client — it must never tear down the relay or a tunnel
|
|
3383
|
-
* we did not start.
|
|
3384
|
-
*
|
|
3385
|
-
* `getTunnelStatus()` reports `up: true` with a `wssUrl` derived from
|
|
3386
|
-
* `relayBaseUrl` (http→ws, https→wss) so the `start_attach` gate
|
|
3387
|
-
* (`up: true && wssUrl !== null`) is satisfied even though we never opened a
|
|
3388
|
-
* cloudflared tunnel ourselves.
|
|
3389
|
-
*
|
|
3390
|
-
* SECRET-HANDLING: `relayBaseUrl` carries the relay host (same sensitivity as a
|
|
3391
|
-
* wss URL) — it is NEVER logged here. The caller validates presence and passes
|
|
3392
|
-
* the value straight to the CDP client.
|
|
3393
|
-
*/
|
|
3394
|
-
/**
|
|
3395
|
-
* Attempts to read the local loopback HTTP base URL of the env-2 Chii relay
|
|
3396
|
-
* (issue #530). Resolution order:
|
|
3397
|
-
* 1. `AIT_RELAY_LOCAL_URL` env var, if set and non-empty.
|
|
3398
|
-
* 2. `relayLocalUrl` from the `.ait_urls` file, if `projectRoot` is given.
|
|
3399
|
-
* 3. `undefined` — caller falls back to the tunnel base (existing behavior).
|
|
3400
|
-
*
|
|
3401
|
-
* This is a best-effort read — never throws. The returned value is a plain
|
|
3402
|
-
* `http://127.0.0.1:<port>` loopback URL; no secret exposure.
|
|
3403
|
-
*/
|
|
3404
|
-
async function readRelayLocalUrl(env = process.env, projectRoot) {
|
|
3405
|
-
const envValue = (env.AIT_RELAY_LOCAL_URL ?? "").trim();
|
|
3406
|
-
if (envValue !== "") return envValue;
|
|
3407
|
-
if (projectRoot !== void 0) try {
|
|
3408
|
-
const { readRelayUrls } = await import("./relay-url-store-D3H067u0.js");
|
|
3409
|
-
const stored = await readRelayUrls({ projectRoot });
|
|
3410
|
-
if (stored?.relayLocalUrl) return stored.relayLocalUrl;
|
|
3411
|
-
} catch {}
|
|
3412
|
-
}
|
|
3413
|
-
async function bootExternalRelayFamily(relayBaseUrl, relayLocalUrl) {
|
|
3414
|
-
assertRelayAuthConfigured();
|
|
3415
|
-
const connection = createRelayConnection(relayBaseUrl);
|
|
3416
|
-
const tunnelStatus = makeTunnelStatus(true, relayBaseUrl.replace(/^http/, "ws"));
|
|
3417
|
-
return {
|
|
3418
|
-
connection,
|
|
3419
|
-
relayOrigin: "external-pwa",
|
|
3420
|
-
relayHttpUrl: relayBaseUrl,
|
|
3421
|
-
relayLocalHttpUrl: relayLocalUrl,
|
|
3422
|
-
getTunnelStatus: () => tunnelStatus,
|
|
3423
|
-
stop() {
|
|
3424
|
-
connection.close();
|
|
3425
|
-
}
|
|
3426
|
-
};
|
|
3427
|
-
}
|
|
3428
|
-
/**
|
|
3429
|
-
* Maps a `StartDebugMode` to the {@link FamilyKey} that serves it (issue #378).
|
|
3430
|
-
* local-browser → 'local-browser'; relay-sandbox → 'relay-sandbox';
|
|
3431
|
-
* relay-staging → 'relay-intoss' (the intoss-private relay slot).
|
|
3432
|
-
* `relay-live` removed (#665).
|
|
3433
|
-
*/
|
|
3434
|
-
function familyKeyForMode(mode) {
|
|
3435
|
-
switch (mode) {
|
|
3436
|
-
case "local-browser": return "local-browser";
|
|
3437
|
-
case "relay-sandbox": return "relay-sandbox";
|
|
3438
|
-
case "relay-staging": return "relay-intoss";
|
|
3439
|
-
}
|
|
3440
|
-
}
|
|
3441
|
-
/** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */
|
|
3442
|
-
const MOBILE_RELAY_BASE_URL_MISSING_MESSAGE = "start_debug(mobile): AIT_RELAY_BASE_URL이 설정되지 않았습니다. dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. 자동 발견이 되지 않을 경우 relay base URL을 AIT_RELAY_BASE_URL 환경변수로 직접 전달하세요. 환경 2(실기기 PWA) 진입은 외부 relay base가 필요합니다.";
|
|
3443
|
-
/**
|
|
3444
|
-
* Reads the env-2 relay base URL for the `mobile` boot site (issue #378, #424).
|
|
3445
|
-
*
|
|
3446
|
-
* Resolution order (env wins — file is the fallback):
|
|
3447
|
-
* 1. `env.AIT_RELAY_BASE_URL` set and non-empty → return it (operator override).
|
|
3448
|
-
* 2. `projectRoot` given → read `<nearest package.json dir>/.ait_urls`;
|
|
3449
|
-
* if `relayBaseUrl` is present → return it (auto-discovered from dev server).
|
|
3450
|
-
* 3. Neither → throw {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE}.
|
|
3451
|
-
*
|
|
3452
|
-
* SECRET-HANDLING: `AIT_RELAY_BASE_URL` and the file-discovered value carry the
|
|
3453
|
-
* relay host. On the missing path the thrown message names the env var and notes
|
|
3454
|
-
* that the dev server auto-publishes it — it NEVER echoes any URL value. The
|
|
3455
|
-
* present value is returned to the caller (the CDP client) but never logged.
|
|
3456
|
-
*/
|
|
3457
|
-
async function readMobileRelayBaseUrl(env = process.env, projectRoot) {
|
|
3458
|
-
const raw = env.AIT_RELAY_BASE_URL;
|
|
3459
|
-
const envValue = typeof raw === "string" ? raw.trim() : "";
|
|
3460
|
-
if (envValue !== "") return envValue;
|
|
3461
|
-
if (projectRoot !== void 0) {
|
|
3462
|
-
const { readRelayUrls } = await import("./relay-url-store-D3H067u0.js");
|
|
3463
|
-
const stored = await readRelayUrls({ projectRoot });
|
|
3464
|
-
if (stored?.relayBaseUrl !== void 0) return stored.relayBaseUrl;
|
|
3465
|
-
}
|
|
3466
|
-
throw new Error(MOBILE_RELAY_BASE_URL_MISSING_MESSAGE);
|
|
3467
|
-
}
|
|
3468
|
-
/**
|
|
3469
|
-
* Sentinel connection returned by {@link DualConnectionRouter.active} before the
|
|
3470
|
-
* first `start_debug` boots a family (all-lazy, issue #396). It satisfies the
|
|
3471
|
-
* full {@link CdpConnection} interface but holds nothing: `listTargets()` is
|
|
3472
|
-
* empty, every command rejects with a clear "call start_debug first" message,
|
|
3473
|
-
* and all event/teardown members are safe no-ops. Callers that read tools before
|
|
3474
|
-
* any switchMode therefore get an honest empty/down state instead of an NPE.
|
|
3475
|
-
*/
|
|
3476
|
-
const NULL_CDP_CONNECTION = {
|
|
3477
|
-
kind: "local",
|
|
3478
|
-
enableDomains: () => Promise.resolve(),
|
|
3479
|
-
listTargets: () => [],
|
|
3480
|
-
getBufferedEvents: () => [],
|
|
3481
|
-
on: () => () => {},
|
|
3482
|
-
send: () => Promise.reject(/* @__PURE__ */ new Error("no family booted yet — call start_debug first")),
|
|
3483
|
-
close: () => {}
|
|
3484
|
-
};
|
|
3485
|
-
/**
|
|
3486
|
-
* Production `ConnectionRouter` (issues #348, #356, #378 — DUAL-CONNECTION-COEXIST).
|
|
3487
|
-
*
|
|
3488
|
-
* Holds a keyed set of lazily-booted families ({@link FamilyKey} →
|
|
3489
|
-
* `BootedFamily`, issue #378) with NO family active at startup (issue #396); the
|
|
3490
|
-
* first `start_debug` boots and activates one. Plus an `active` pointer and the
|
|
3491
|
-
* single attach watcher armed on the active connection. The router is
|
|
3492
|
-
* **direction-neutral** (#356): any family can be the first one booted, so a
|
|
3493
|
-
* `--target=local` session can hot-switch into relay (and vice versa) without
|
|
3494
|
-
* restarting the MCP server.
|
|
3495
|
-
*
|
|
3496
|
-
* Why a KEYED map and not a single lazy slot (#378): `relay-sandbox` (env-2
|
|
3497
|
-
* external relay) and `relay-staging` (intoss relay) are BOTH `kind: 'relay'`.
|
|
3498
|
-
* A single "opposite-kind" slot could not warm-keep both at once — they would
|
|
3499
|
-
* collide. The three `FamilyKey`s (`local-browser` / `relay-intoss` /
|
|
3500
|
-
* `relay-sandbox`) give each its own warm slot. `relay-live` (env 4) removed
|
|
3501
|
-
* (#665) — `relay-intoss` slot now maps only to `relay-staging`.
|
|
3502
|
-
*
|
|
3503
|
-
* Why all-lazy (#396): the relay TOTP secret now lives in a project-local
|
|
3504
|
-
* `.ait_relay` file loaded read-only by `switchMode` BEFORE a relay family boots.
|
|
3505
|
-
* Booting any family eagerly at startup would bypass that load. With NO eager
|
|
3506
|
-
* boot every relay boot flows through `switchMode → loadRelaySecretReadOnly`, so
|
|
3507
|
-
* the secret is always populated before `assertRelayAuthConfigured()` /
|
|
3508
|
-
* `buildRelayVerifyAuth()` run at the boot site.
|
|
3509
|
-
*
|
|
3510
|
-
* `switchMode`:
|
|
3511
|
-
* 1. rejects re-entrant swaps (`swapInFlight`);
|
|
3512
|
-
* 2. resolves the requested mode's `FamilyKey`:
|
|
3513
|
-
* `lazyFamilies.get(key) ?? (boot via bootLazyFor(key), store)`;
|
|
3514
|
-
* 3. flips `active` (the MCP `Server` never re-handshakes — it reads through
|
|
3515
|
-
* `active` per request);
|
|
3516
|
-
* 4. stops the old attach watcher and re-arms one on the new connection
|
|
3517
|
-
* (the watcher self-clears, so re-arm is mandatory);
|
|
3518
|
-
* 5. emits `tools/list_changed`.
|
|
3519
|
-
*
|
|
3520
|
-
* Inactive infra is left WARM — teardown happens only at process exit (the
|
|
3521
|
-
* unified shutdown in the run functions), which is what keeps a phone attach
|
|
3522
|
-
* alive across a local→relay→local round trip.
|
|
3523
|
-
*/
|
|
3524
|
-
var DualConnectionRouter = class {
|
|
3525
|
-
deps;
|
|
3526
|
-
/** Families, booted lazily and warm-kept per {@link FamilyKey} (#378, #396). */
|
|
3527
|
-
lazyFamilies = /* @__PURE__ */ new Map();
|
|
3528
|
-
/** `null` until the first `start_debug` boots a family (all-lazy, #396). */
|
|
3529
|
-
activeFamily = null;
|
|
3530
|
-
server = null;
|
|
3531
|
-
attachWatcher = null;
|
|
3532
|
-
swapInFlight = false;
|
|
3533
|
-
constructor(deps) {
|
|
3534
|
-
this.deps = deps;
|
|
3535
|
-
}
|
|
3536
|
-
get active() {
|
|
3537
|
-
return this.activeFamily ? this.activeFamily.connection : NULL_CDP_CONNECTION;
|
|
3538
|
-
}
|
|
3539
|
-
/** Relay origin of the currently-active family (issue #378). */
|
|
3540
|
-
get activeRelayOrigin() {
|
|
3541
|
-
return this.activeFamily?.relayOrigin;
|
|
3542
|
-
}
|
|
3543
|
-
/**
|
|
3544
|
-
* HTTP base URL of the Chii relay to use for inspector URL assembly (#503,
|
|
3545
|
-
* #530). Prefers the LOCAL loopback base (`relayLocalHttpUrl`) when available
|
|
3546
|
-
* so front_end page load + client WS do not traverse a cloudflare tunnel —
|
|
3547
|
-
* falls back to `relayHttpUrl` (the tunnel base for env-2, loopback for env-3/4)
|
|
3548
|
-
* when not set. Returns `undefined` when no relay family is active.
|
|
3549
|
-
*
|
|
3550
|
-
* SECRET-HANDLING: when relayLocalHttpUrl is absent this falls back to
|
|
3551
|
-
* relayHttpUrl which may carry the tunnel host — callers must not log it.
|
|
3552
|
-
*/
|
|
3553
|
-
get activeRelayHttpUrl() {
|
|
3554
|
-
if (!this.activeFamily) return void 0;
|
|
3555
|
-
return this.activeFamily.relayLocalHttpUrl ?? this.activeFamily.relayHttpUrl;
|
|
3556
|
-
}
|
|
3557
|
-
/** Every booted family (for unified shutdown). All families are lazy (#396). */
|
|
3558
|
-
bootedFamilies() {
|
|
3559
|
-
return [...this.lazyFamilies.values()];
|
|
3560
|
-
}
|
|
3561
|
-
/**
|
|
3562
|
-
* Live tunnel status of the active relay family (issues #356, #378). Reads
|
|
3563
|
-
* the ACTIVE family's tunnel when it has one (so `relay-sandbox` surfaces the
|
|
3564
|
-
* external relay wss and `relay-staging` the intoss relay wss); otherwise
|
|
3565
|
-
* falls back to the first booted family that has a tunnel. Returns "down"
|
|
3566
|
-
* until any relay family is booted (any session before the first relay
|
|
3567
|
-
* start_debug) — the correct signal for `start_attach` (no tunnel yet).
|
|
3568
|
-
*/
|
|
3569
|
-
relayTunnelStatus() {
|
|
3570
|
-
if (this.activeFamily?.getTunnelStatus) return this.activeFamily.getTunnelStatus();
|
|
3571
|
-
for (const family of this.bootedFamilies()) if (family.getTunnelStatus) return family.getTunnelStatus();
|
|
3572
|
-
return {
|
|
3573
|
-
up: false,
|
|
3574
|
-
wssUrl: null
|
|
3575
|
-
};
|
|
3576
|
-
}
|
|
3577
|
-
/**
|
|
3578
|
-
* Binds the MCP `Server`; the attach watcher is armed by the first
|
|
3579
|
-
* `start_debug` since no family is active at startup (all-lazy, #396). Called
|
|
3580
|
-
* once after `createDebugServer` + `connect`.
|
|
3581
|
-
*/
|
|
3582
|
-
start(server) {
|
|
3583
|
-
this.server = server;
|
|
3584
|
-
this.armWatcher();
|
|
3585
|
-
}
|
|
3586
|
-
/** Stops the current attach watcher (for shutdown). */
|
|
3587
|
-
stopWatcher() {
|
|
3588
|
-
this.attachWatcher?.stop();
|
|
3589
|
-
this.attachWatcher = null;
|
|
3590
|
-
}
|
|
3591
|
-
/** Arms a fresh attach watcher on the current active connection. */
|
|
3592
|
-
armWatcher() {
|
|
3593
|
-
const server = this.server;
|
|
3594
|
-
if (!server) return;
|
|
3595
|
-
const activeFamily = this.activeFamily;
|
|
3596
|
-
if (!activeFamily) return;
|
|
3597
|
-
this.attachWatcher = startAttachWatcher(activeFamily.connection, server, this.deps.attachWatcherIntervalMs ?? 1e3, () => {
|
|
3598
|
-
this.deps.diagnosticsCollector.recordAttach();
|
|
3599
|
-
this.deps.onPageAttach?.();
|
|
3600
|
-
if (activeFamily.connection.kind === "relay") {
|
|
3601
|
-
const firstTarget = activeFamily.connection.listTargets()[0];
|
|
3602
|
-
const env = deriveEnvironment(activeFamily.connection.kind, activeFamily.relayOrigin);
|
|
3603
|
-
const inspectorStableUrl = this.deps.getInspectorStableUrl?.() ?? null;
|
|
3604
|
-
this.deps.devtoolsOpener.open({
|
|
3605
|
-
inspectorStableUrl,
|
|
3606
|
-
relayHttpBaseUrl: activeFamily.relayHttpUrl,
|
|
3607
|
-
targetId: firstTarget?.id,
|
|
3608
|
-
mintTotp: process.env.AIT_DEBUG_TOTP_SECRET ? () => generateTotp(process.env.AIT_DEBUG_TOTP_SECRET) : void 0,
|
|
3609
|
-
env
|
|
3610
|
-
});
|
|
3611
|
-
}
|
|
3612
|
-
}, () => {
|
|
3613
|
-
this.deps.onPageDetach?.();
|
|
3614
|
-
});
|
|
3615
|
-
}
|
|
3616
|
-
/**
|
|
3617
|
-
* Resolves the `BootedFamily` for `key`: the warm family if already booted,
|
|
3618
|
-
* otherwise boots it via `bootLazyFor(key, projectRoot)` and stores it (once
|
|
3619
|
-
* per key). Since #396 every family is lazy, so this is the single boot path
|
|
3620
|
-
* for all three keys.
|
|
3621
|
-
*
|
|
3622
|
-
* `projectRoot` is forwarded to `bootLazyFor` so `relay-sandbox` boot can
|
|
3623
|
-
* fall back to `.ait_urls` file discovery (#424) when `AIT_RELAY_BASE_URL` is
|
|
3624
|
-
* not set in the environment.
|
|
3625
|
-
*
|
|
3626
|
-
* **Relay-sandbox stale-URL rebuild (issue #610):** when the `relay-sandbox`
|
|
3627
|
-
* family is already warm, reads the current relay URL via
|
|
3628
|
-
* `deps.readSandboxRelayUrl` and compares it against the cached
|
|
3629
|
-
* `relayHttpUrl`. If they differ (dev server was restarted → new tunnel),
|
|
3630
|
-
* the stale family is torn down, evicted from the map, and a fresh one is
|
|
3631
|
-
* booted. If they match, or if the URL cannot be read, the warm family is
|
|
3632
|
-
* reused (fail-open — no unnecessary teardown on transient read errors).
|
|
3633
|
-
*
|
|
3634
|
-
* SECRET-HANDLING: fresh and cached relay URLs carry the tunnel host. The
|
|
3635
|
-
* comparison result (same/different) is the only thing surfaced — URLs are
|
|
3636
|
-
* never logged.
|
|
3637
|
-
*/
|
|
3638
|
-
async familyFor(key, projectRoot) {
|
|
3639
|
-
const warm = this.lazyFamilies.get(key);
|
|
3640
|
-
if (warm) {
|
|
3641
|
-
if (key === "relay-sandbox" && this.deps.readSandboxRelayUrl !== void 0) {
|
|
3642
|
-
let freshUrl = null;
|
|
3643
|
-
try {
|
|
3644
|
-
freshUrl = await this.deps.readSandboxRelayUrl(projectRoot);
|
|
3645
|
-
} catch {
|
|
3646
|
-
freshUrl = null;
|
|
3647
|
-
}
|
|
3648
|
-
if (freshUrl !== null && freshUrl !== warm.relayHttpUrl) {
|
|
3649
|
-
warm.stop();
|
|
3650
|
-
this.lazyFamilies.delete(key);
|
|
3651
|
-
const booted = await this.deps.bootLazyFor(key, projectRoot);
|
|
3652
|
-
this.lazyFamilies.set(key, booted);
|
|
3653
|
-
return booted;
|
|
3654
|
-
}
|
|
3655
|
-
}
|
|
3656
|
-
return warm;
|
|
3657
|
-
}
|
|
3658
|
-
const booted = await this.deps.bootLazyFor(key, projectRoot);
|
|
3659
|
-
this.lazyFamilies.set(key, booted);
|
|
3660
|
-
return booted;
|
|
3661
|
-
}
|
|
3662
|
-
async switchMode(mode, projectRoot) {
|
|
3663
|
-
if (this.swapInFlight) throw new Error("start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.");
|
|
3664
|
-
this.swapInFlight = true;
|
|
3665
|
-
try {
|
|
3666
|
-
if (isRelayMode(mode)) await loadRelaySecretReadOnly({ projectRoot });
|
|
3667
|
-
const target = await this.familyFor(familyKeyForMode(mode), projectRoot);
|
|
3668
|
-
this.activeFamily = target;
|
|
3669
|
-
this.stopWatcher();
|
|
3670
|
-
this.armWatcher();
|
|
3671
|
-
this.server?.sendToolListChanged();
|
|
3672
|
-
const wantRelay = isRelayMode(mode);
|
|
3673
|
-
return {
|
|
3674
|
-
mode,
|
|
3675
|
-
environment: deriveEnvironment(target.connection.kind, target.relayOrigin),
|
|
3676
|
-
kind: target.connection.kind,
|
|
3677
|
-
nextStep: wantRelay ? "start_attach로 attach QR 생성 + 폰 attach까지 한 번에 진행하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
|
|
3678
|
-
};
|
|
3679
|
-
} finally {
|
|
3680
|
-
this.swapInFlight = false;
|
|
3681
|
-
}
|
|
3682
|
-
}
|
|
3683
|
-
};
|
|
3684
|
-
/**
|
|
3685
|
-
* Boots the live debug stack and serves it over stdio:
|
|
3686
|
-
* 1. start the Chii relay on an OS-assigned port (with TOTP auth if
|
|
3687
|
-
* AIT_DEBUG_TOTP_SECRET is set),
|
|
3688
|
-
* 2. open a cloudflared quick tunnel to the relay's confirmed port,
|
|
3689
|
-
* 3. print relay URL + attach instructions,
|
|
3690
|
-
* 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
|
|
3691
|
-
*/
|
|
3692
|
-
async function runDebugServer(options = {}) {
|
|
3693
|
-
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
3694
|
-
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
3695
|
-
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
3696
|
-
let activeTunnelChildPid = null;
|
|
3697
|
-
const router = new DualConnectionRouter({
|
|
3698
|
-
bootLazyFor: async (key, projectRoot) => key === "relay-sandbox" ? bootExternalRelayFamily(await readMobileRelayBaseUrl(process.env, projectRoot), await readRelayLocalUrl(process.env, projectRoot)) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
|
|
3699
|
-
relayPort: options.relayPort,
|
|
3700
|
-
verifyAuth: buildRelayVerifyAuth(),
|
|
3701
|
-
onWssUrl: (wssUrl) => {
|
|
3702
|
-
lockHandle.updateWssUrl(wssUrl);
|
|
3703
|
-
qrServer?.notifyStateChange();
|
|
3704
|
-
},
|
|
3705
|
-
onTunnelChildPid: (pid) => {
|
|
3706
|
-
activeTunnelChildPid = pid;
|
|
3707
|
-
lockHandle.updateTunnelChildPid(pid);
|
|
3708
|
-
},
|
|
3709
|
-
onAuthReject: () => diagnosticsCollector.recordAuthReject(),
|
|
3710
|
-
onTunnelDown: () => qrServer?.notifyStateChange()
|
|
3711
|
-
}),
|
|
3712
|
-
diagnosticsCollector,
|
|
3713
|
-
devtoolsOpener,
|
|
3714
|
-
onPageAttach: () => qrServer?.notifyStateChange(),
|
|
3715
|
-
onPageDetach: () => qrServer?.notifyStateChange(),
|
|
3716
|
-
getInspectorStableUrl: () => qrServer?.inspectorStableUrl ?? null,
|
|
3717
|
-
readSandboxRelayUrl: (pr) => readMobileRelayBaseUrl(process.env, pr).catch(() => null)
|
|
3718
|
-
});
|
|
3719
|
-
const aitSource = new RoutingAitSource(() => {
|
|
3720
|
-
return router.active;
|
|
3721
|
-
});
|
|
3722
|
-
let lastAttachParts = null;
|
|
3723
|
-
const getDashboardState = () => {
|
|
3724
|
-
const targets = router.active.listTargets();
|
|
3725
|
-
const inspectorUrl = qrServer?.inspectorStableUrl ?? null;
|
|
3726
|
-
return {
|
|
3727
|
-
tunnel: {
|
|
3728
|
-
up: router.relayTunnelStatus().up,
|
|
3729
|
-
wssUrl: router.relayTunnelStatus().wssUrl
|
|
3730
|
-
},
|
|
3731
|
-
pages: targets.map((t) => ({
|
|
3732
|
-
id: t.id,
|
|
3733
|
-
url: t.url
|
|
3734
|
-
})),
|
|
3735
|
-
attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
|
|
3736
|
-
inspectorUrl,
|
|
3737
|
-
mode: deriveEnvironment(router.active.kind, router.activeRelayOrigin)
|
|
3738
|
-
};
|
|
3739
|
-
};
|
|
3740
|
-
const getDirectInspectorUrl = () => {
|
|
3741
|
-
const relayHttpUrl = router.activeRelayHttpUrl;
|
|
3742
|
-
if (!relayHttpUrl) return {
|
|
3743
|
-
ok: false,
|
|
3744
|
-
reason: "relayDown"
|
|
3745
|
-
};
|
|
3746
|
-
const targets = router.active.listTargets();
|
|
3747
|
-
if (targets.length === 0) return {
|
|
3748
|
-
ok: false,
|
|
3749
|
-
reason: "noTarget"
|
|
3750
|
-
};
|
|
3751
|
-
const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
3752
|
-
if (!totpSecret) return {
|
|
3753
|
-
ok: false,
|
|
3754
|
-
reason: "totpUnavailable"
|
|
3755
|
-
};
|
|
3756
|
-
const url = buildChiiInspectorUrl(relayHttpUrl, targets[0].id, () => generateTotp(totpSecret, Date.now()));
|
|
3757
|
-
if (url === null) return {
|
|
3758
|
-
ok: false,
|
|
3759
|
-
reason: "totpUnavailable"
|
|
3760
|
-
};
|
|
3761
|
-
return {
|
|
3762
|
-
ok: true,
|
|
3763
|
-
url
|
|
3764
|
-
};
|
|
3765
|
-
};
|
|
3766
|
-
let qrServer;
|
|
3767
|
-
try {
|
|
3768
|
-
qrServer = await startQrHttpServer(getDashboardState, { getDirectInspectorUrl });
|
|
3769
|
-
} catch (err) {
|
|
3770
|
-
logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
|
|
3771
|
-
}
|
|
3772
|
-
const TOTP_REFRESH_INTERVAL_MS = 2e4;
|
|
3773
|
-
let totpRefreshHandle = null;
|
|
3774
|
-
totpRefreshHandle = setInterval(() => {
|
|
3775
|
-
if (lastAttachParts !== null) qrServer?.notifyStateChange();
|
|
3776
|
-
}, TOTP_REFRESH_INTERVAL_MS);
|
|
3777
|
-
totpRefreshHandle.unref();
|
|
3778
|
-
const server = createDebugServer({
|
|
3779
|
-
connection: router.active,
|
|
3780
|
-
router,
|
|
3781
|
-
aitSource,
|
|
3782
|
-
getTunnelStatus: () => router.relayTunnelStatus(),
|
|
3783
|
-
getTunnelChildPid: () => activeTunnelChildPid,
|
|
3784
|
-
get qrHttpServer() {
|
|
3785
|
-
return qrServer;
|
|
3786
|
-
},
|
|
3787
|
-
diagnosticsCollector,
|
|
3788
|
-
getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
|
|
3789
|
-
onAttachUrlBuilt: (parts) => {
|
|
3790
|
-
lastAttachParts = parts;
|
|
3791
|
-
qrServer?.notifyStateChange();
|
|
3792
|
-
}
|
|
3793
|
-
});
|
|
3794
|
-
const transport = new StdioServerTransport();
|
|
3795
|
-
let closed = false;
|
|
3796
|
-
let parentWatcher = null;
|
|
3797
|
-
let maxAgeWatchdog = null;
|
|
3798
|
-
const shutdown = () => {
|
|
3799
|
-
if (closed) return;
|
|
3800
|
-
closed = true;
|
|
3801
|
-
parentWatcher?.stop();
|
|
3802
|
-
maxAgeWatchdog?.stop();
|
|
3803
|
-
if (totpRefreshHandle) clearInterval(totpRefreshHandle);
|
|
3804
|
-
router.stopWatcher();
|
|
3805
|
-
for (const family of router.bootedFamilies()) family.stop();
|
|
3806
|
-
server.close();
|
|
3807
|
-
qrServer?.close();
|
|
3808
|
-
lockHandle.release();
|
|
3809
|
-
};
|
|
3810
|
-
process.once("SIGINT", shutdown);
|
|
3811
|
-
process.once("SIGTERM", shutdown);
|
|
3812
|
-
process.once("SIGHUP", shutdown);
|
|
3813
|
-
process.on("exit", () => {
|
|
3814
|
-
if (!closed) {
|
|
3815
|
-
closed = true;
|
|
3816
|
-
parentWatcher?.stop();
|
|
3817
|
-
maxAgeWatchdog?.stop();
|
|
3818
|
-
if (totpRefreshHandle) clearInterval(totpRefreshHandle);
|
|
3819
|
-
router.stopWatcher();
|
|
3820
|
-
for (const family of router.bootedFamilies()) family.stop();
|
|
3821
|
-
lockHandle.release();
|
|
3822
|
-
}
|
|
3823
|
-
});
|
|
3824
|
-
process.on("uncaughtException", (err) => {
|
|
3825
|
-
logError("tool.error", {
|
|
3826
|
-
msg: `uncaughtException: ${String(err)}`,
|
|
3827
|
-
errorKind: "uncaught"
|
|
3828
|
-
});
|
|
3829
|
-
shutdown();
|
|
3830
|
-
process.exit(1);
|
|
3831
|
-
});
|
|
3832
|
-
process.on("unhandledRejection", (reason) => {
|
|
3833
|
-
logError("tool.error", {
|
|
3834
|
-
msg: `unhandledRejection: ${String(reason)}`,
|
|
3835
|
-
errorKind: "unhandled-rejection"
|
|
3836
|
-
});
|
|
3837
|
-
shutdown();
|
|
3838
|
-
process.exit(1);
|
|
3839
|
-
});
|
|
3840
|
-
await server.connect(transport);
|
|
3841
|
-
router.start(server);
|
|
3842
|
-
if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
|
|
3843
|
-
parentWatcher = startParentWatcher(() => {
|
|
3844
|
-
shutdown();
|
|
3845
|
-
process.exit(0);
|
|
3846
|
-
}, { intervalMs: 5e3 });
|
|
3847
|
-
process.stdin.once("end", () => {
|
|
3848
|
-
shutdown();
|
|
3849
|
-
process.exit(0);
|
|
3850
|
-
});
|
|
3851
|
-
process.stdin.once("close", () => {
|
|
3852
|
-
shutdown();
|
|
3853
|
-
process.exit(0);
|
|
3854
|
-
});
|
|
3855
|
-
}
|
|
3856
|
-
if (process.env.AIT_DEBUG_NO_MAX_AGE !== "1") maxAgeWatchdog = startMaxAgeWatchdog(() => {
|
|
3857
|
-
process.stderr.write("[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\n");
|
|
3858
|
-
shutdown();
|
|
3859
|
-
process.exit(0);
|
|
3860
|
-
}, { maxAgeMs: process.env.AIT_DEBUG_MAX_AGE_MS ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || void 0 : void 0 });
|
|
3861
|
-
}
|
|
3862
|
-
/**
|
|
3863
|
-
* Serves the debug stack over stdio with the local browser as the default
|
|
3864
|
-
* target. Since #396 NOTHING boots at startup — every family (including the
|
|
3865
|
-
* local Chromium) is lazy-booted on its first `start_debug`:
|
|
3866
|
-
* 1. `start_debug({ mode: 'local-browser' })` launches a local Chromium with
|
|
3867
|
-
* `--remote-debugging-port=<port>` and attaches a `LocalCdpConnection`;
|
|
3868
|
-
* 2. the intoss/external relay families lazy-boot on the first
|
|
3869
|
-
* `start_debug({ mode: 'relay-staging' | 'relay-sandbox' })` (#665: relay-live removed);
|
|
3870
|
-
* 3. all of this runs through the SAME direction-neutral
|
|
3871
|
-
* `DualConnectionRouter` that `runDebugServer` uses (issue #356).
|
|
3872
|
-
*
|
|
3873
|
-
* Symmetry with `runDebugServer` (#356): starting with `--target=local` no
|
|
3874
|
-
* longer pins a single-connection router. A `--target=local` session can
|
|
3875
|
-
* hot-switch into relay (env 1 → env 3) without restarting the MCP server,
|
|
3876
|
-
* closing the asymmetry where only the default (relay-target) entry point had
|
|
3877
|
-
* bidirectional hot-switch. The intended fidelity-ladder flow — "validate in
|
|
3878
|
-
* env 1 (local), then env 3 (intoss-private) in ONE session, no restart" — now
|
|
3879
|
-
* works from either entry point.
|
|
3880
|
-
*
|
|
3881
|
-
* `start_attach` (relay-specific) stays effectively hidden / non-applicable
|
|
3882
|
-
* until the relay family is booted: before the first relay switch the env
|
|
3883
|
-
* derives to `mock` and `relayTunnelStatus()` reports "down", so the tool fails
|
|
3884
|
-
* with a clear "tunnel not up" message. After a relay switch the relay tunnel
|
|
3885
|
-
* is live and the tool works.
|
|
3886
|
-
*
|
|
3887
|
-
* The AIT.* tools (`AIT.getSdkCallHistory`, `AIT.getMockState`,
|
|
3888
|
-
* `AIT.getOperationalEnvironment`) ride the *active* connection's CDP channel
|
|
3889
|
-
* via `RoutingAitSource`, so they follow `start_debug` swaps.
|
|
3890
|
-
*/
|
|
3891
|
-
async function runLocalDebugServer(options = {}) {
|
|
3892
|
-
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
3893
|
-
const cdpPort = options.cdpPort ?? 0;
|
|
3894
|
-
const devUrl = options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173";
|
|
3895
|
-
const bootLocalFamilyForEntry = async () => {
|
|
3896
|
-
const chromium = await launchChromium({
|
|
3897
|
-
port: cdpPort,
|
|
3898
|
-
devUrl
|
|
3899
|
-
});
|
|
3900
|
-
await new Promise((r) => setTimeout(r, 800));
|
|
3901
|
-
const localConnection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
|
|
3902
|
-
return {
|
|
3903
|
-
connection: localConnection,
|
|
3904
|
-
stop() {
|
|
3905
|
-
localConnection.close();
|
|
3906
|
-
chromium.stop();
|
|
3907
|
-
}
|
|
3908
|
-
};
|
|
3909
|
-
};
|
|
3910
|
-
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
3911
|
-
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
3912
|
-
let activeTunnelChildPid = null;
|
|
3913
|
-
const router = new DualConnectionRouter({
|
|
3914
|
-
bootLazyFor: async (key, projectRoot) => key === "relay-sandbox" ? bootExternalRelayFamily(await readMobileRelayBaseUrl(process.env, projectRoot), await readRelayLocalUrl(process.env, projectRoot)) : key === "local-browser" ? bootLocalFamilyForEntry() : bootRelayFamily({
|
|
3915
|
-
verifyAuth: buildRelayVerifyAuth(),
|
|
3916
|
-
onWssUrl: (wssUrl) => {
|
|
3917
|
-
lockHandle.updateWssUrl(wssUrl);
|
|
3918
|
-
qrServer?.notifyStateChange();
|
|
3919
|
-
},
|
|
3920
|
-
onTunnelChildPid: (pid) => {
|
|
3921
|
-
activeTunnelChildPid = pid;
|
|
3922
|
-
lockHandle.updateTunnelChildPid(pid);
|
|
3923
|
-
},
|
|
3924
|
-
onAuthReject: () => diagnosticsCollector.recordAuthReject(),
|
|
3925
|
-
onTunnelDown: () => qrServer?.notifyStateChange()
|
|
3926
|
-
}),
|
|
3927
|
-
diagnosticsCollector,
|
|
3928
|
-
devtoolsOpener,
|
|
3929
|
-
onPageAttach: () => qrServer?.notifyStateChange(),
|
|
3930
|
-
onPageDetach: () => qrServer?.notifyStateChange(),
|
|
3931
|
-
getInspectorStableUrl: () => qrServer?.inspectorStableUrl ?? null,
|
|
3932
|
-
readSandboxRelayUrl: (pr) => readMobileRelayBaseUrl(process.env, pr).catch(() => null)
|
|
3933
|
-
});
|
|
3934
|
-
const aitSource = new RoutingAitSource(() => {
|
|
3935
|
-
return router.active;
|
|
3936
|
-
});
|
|
3937
|
-
let lastAttachParts = null;
|
|
3938
|
-
const getDashboardState = () => {
|
|
3939
|
-
const targets = router.active.listTargets();
|
|
3940
|
-
const inspectorUrl = qrServer?.inspectorStableUrl ?? null;
|
|
3941
|
-
return {
|
|
3942
|
-
tunnel: {
|
|
3943
|
-
up: router.relayTunnelStatus().up,
|
|
3944
|
-
wssUrl: router.relayTunnelStatus().wssUrl
|
|
3945
|
-
},
|
|
3946
|
-
pages: targets.map((t) => ({
|
|
3947
|
-
id: t.id,
|
|
3948
|
-
url: t.url
|
|
3949
|
-
})),
|
|
3950
|
-
attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
|
|
3951
|
-
inspectorUrl
|
|
3952
|
-
};
|
|
3953
|
-
};
|
|
3954
|
-
const getDirectInspectorUrl = () => {
|
|
3955
|
-
const relayHttpUrl = router.activeRelayHttpUrl;
|
|
3956
|
-
if (!relayHttpUrl) return {
|
|
3957
|
-
ok: false,
|
|
3958
|
-
reason: "relayDown"
|
|
3959
|
-
};
|
|
3960
|
-
const targets = router.active.listTargets();
|
|
3961
|
-
if (targets.length === 0) return {
|
|
3962
|
-
ok: false,
|
|
3963
|
-
reason: "noTarget"
|
|
3964
|
-
};
|
|
3965
|
-
const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
3966
|
-
if (!totpSecret) return {
|
|
3967
|
-
ok: false,
|
|
3968
|
-
reason: "totpUnavailable"
|
|
3969
|
-
};
|
|
3970
|
-
const url = buildChiiInspectorUrl(relayHttpUrl, targets[0].id, () => generateTotp(totpSecret, Date.now()));
|
|
3971
|
-
if (url === null) return {
|
|
3972
|
-
ok: false,
|
|
3973
|
-
reason: "totpUnavailable"
|
|
3974
|
-
};
|
|
3975
|
-
return {
|
|
3976
|
-
ok: true,
|
|
3977
|
-
url
|
|
3978
|
-
};
|
|
3979
|
-
};
|
|
3980
|
-
let qrServer;
|
|
3981
|
-
try {
|
|
3982
|
-
qrServer = await startQrHttpServer(getDashboardState, { getDirectInspectorUrl });
|
|
3983
|
-
} catch (err) {
|
|
3984
|
-
logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
|
|
3985
|
-
}
|
|
3986
|
-
const TOTP_REFRESH_INTERVAL_MS = 2e4;
|
|
3987
|
-
let totpRefreshHandle = null;
|
|
3988
|
-
totpRefreshHandle = setInterval(() => {
|
|
3989
|
-
if (lastAttachParts !== null) qrServer?.notifyStateChange();
|
|
3990
|
-
}, TOTP_REFRESH_INTERVAL_MS);
|
|
3991
|
-
totpRefreshHandle.unref();
|
|
3992
|
-
const server = createDebugServer({
|
|
3993
|
-
connection: router.active,
|
|
3994
|
-
router,
|
|
3995
|
-
aitSource,
|
|
3996
|
-
getTunnelStatus: () => router.relayTunnelStatus(),
|
|
3997
|
-
getTunnelChildPid: () => activeTunnelChildPid,
|
|
3998
|
-
get qrHttpServer() {
|
|
3999
|
-
return qrServer;
|
|
4000
|
-
},
|
|
4001
|
-
diagnosticsCollector,
|
|
4002
|
-
getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
|
|
4003
|
-
onAttachUrlBuilt: (parts) => {
|
|
4004
|
-
lastAttachParts = parts;
|
|
4005
|
-
qrServer?.notifyStateChange();
|
|
4006
|
-
}
|
|
4007
|
-
});
|
|
4008
|
-
const transport = new StdioServerTransport();
|
|
4009
|
-
let closed = false;
|
|
4010
|
-
let parentWatcher = null;
|
|
4011
|
-
let maxAgeWatchdog = null;
|
|
4012
|
-
const shutdown = () => {
|
|
4013
|
-
if (closed) return;
|
|
4014
|
-
closed = true;
|
|
4015
|
-
parentWatcher?.stop();
|
|
4016
|
-
maxAgeWatchdog?.stop();
|
|
4017
|
-
if (totpRefreshHandle) clearInterval(totpRefreshHandle);
|
|
4018
|
-
router.stopWatcher();
|
|
4019
|
-
for (const family of router.bootedFamilies()) family.stop();
|
|
4020
|
-
server.close();
|
|
4021
|
-
qrServer?.close();
|
|
4022
|
-
lockHandle.release();
|
|
4023
|
-
};
|
|
4024
|
-
process.once("SIGINT", shutdown);
|
|
4025
|
-
process.once("SIGTERM", shutdown);
|
|
4026
|
-
process.once("SIGHUP", shutdown);
|
|
4027
|
-
process.on("exit", () => {
|
|
4028
|
-
if (!closed) {
|
|
4029
|
-
closed = true;
|
|
4030
|
-
parentWatcher?.stop();
|
|
4031
|
-
maxAgeWatchdog?.stop();
|
|
4032
|
-
if (totpRefreshHandle) clearInterval(totpRefreshHandle);
|
|
4033
|
-
router.stopWatcher();
|
|
4034
|
-
for (const family of router.bootedFamilies()) family.stop();
|
|
4035
|
-
lockHandle.release();
|
|
4036
|
-
}
|
|
4037
|
-
});
|
|
4038
|
-
process.on("uncaughtException", (err) => {
|
|
4039
|
-
logError("tool.error", {
|
|
4040
|
-
msg: `uncaughtException: ${String(err)}`,
|
|
4041
|
-
errorKind: "uncaught",
|
|
4042
|
-
mode: "local-browser"
|
|
4043
|
-
});
|
|
4044
|
-
shutdown();
|
|
4045
|
-
process.exit(1);
|
|
4046
|
-
});
|
|
4047
|
-
process.on("unhandledRejection", (reason) => {
|
|
4048
|
-
logError("tool.error", {
|
|
4049
|
-
msg: `unhandledRejection: ${String(reason)}`,
|
|
4050
|
-
errorKind: "unhandled-rejection",
|
|
4051
|
-
mode: "local-browser"
|
|
4052
|
-
});
|
|
4053
|
-
shutdown();
|
|
4054
|
-
process.exit(1);
|
|
4055
|
-
});
|
|
4056
|
-
await server.connect(transport);
|
|
4057
|
-
router.start(server);
|
|
4058
|
-
if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
|
|
4059
|
-
parentWatcher = startParentWatcher(() => {
|
|
4060
|
-
shutdown();
|
|
4061
|
-
process.exit(0);
|
|
4062
|
-
}, { intervalMs: 5e3 });
|
|
4063
|
-
process.stdin.once("end", () => {
|
|
4064
|
-
shutdown();
|
|
4065
|
-
process.exit(0);
|
|
4066
|
-
});
|
|
4067
|
-
process.stdin.once("close", () => {
|
|
4068
|
-
shutdown();
|
|
4069
|
-
process.exit(0);
|
|
4070
|
-
});
|
|
4071
|
-
}
|
|
4072
|
-
if (process.env.AIT_DEBUG_NO_MAX_AGE !== "1") maxAgeWatchdog = startMaxAgeWatchdog(() => {
|
|
4073
|
-
process.stderr.write("[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\n");
|
|
4074
|
-
shutdown();
|
|
4075
|
-
process.exit(0);
|
|
4076
|
-
}, { maxAgeMs: process.env.AIT_DEBUG_MAX_AGE_MS ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || void 0 : void 0 });
|
|
4077
|
-
}
|
|
4078
|
-
/**
|
|
4079
|
-
* Serves the env-2 (real-device PWA) debug stack over stdio with the external
|
|
4080
|
-
* Chii relay as the default target (issue #378). Since #396 NOTHING boots at
|
|
4081
|
-
* startup — the external relay family is lazy-booted on the first
|
|
4082
|
-
* `start_debug({ mode: 'relay-sandbox' })`.
|
|
4083
|
-
*
|
|
4084
|
-
* Unlike `runDebugServer` (which starts its own relay + cloudflared tunnel),
|
|
4085
|
-
* `runMobileDebugServer` attaches to a relay the unplugin ALREADY brought up
|
|
4086
|
-
* (`tunnel: { cdp: true }`) and exposed via `AIT_RELAY_BASE_URL`. The MCP only
|
|
4087
|
-
* opens a CDP client against that external relay — it never starts or tears down
|
|
4088
|
-
* a relay or a tunnel it did not own (see {@link bootExternalRelayFamily}).
|
|
4089
|
-
*
|
|
4090
|
-
* Symmetry with `runDebugServer` / `runLocalDebugServer` (#356, #378, #396): all
|
|
4091
|
-
* three families are lazy-booted — the env-2 external relay on the first
|
|
4092
|
-
* `start_debug({ mode: 'relay-sandbox' })`, the local family on `local-browser`,
|
|
4093
|
-
* the intoss relay on `relay-staging` (#665: relay-live removed) — so a
|
|
4094
|
-
* `--target=mobile` session can hot-switch without a restart. The active env
|
|
4095
|
-
* derives to `relay-mobile` (external-PWA origin).
|
|
4096
|
-
*
|
|
4097
|
-
* SECRET-HANDLING: `AIT_RELAY_BASE_URL` is read once here via
|
|
4098
|
-
* {@link readMobileRelayBaseUrl}; when unset it throws
|
|
4099
|
-
* {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} — a message that names the env
|
|
4100
|
-
* var and how to obtain it, never echoing any URL value. The error propagates to
|
|
4101
|
-
* the bin entry's fatal handler (the missing-URL path prints the guidance, not a
|
|
4102
|
-
* value). The present value is passed straight to the CDP client, never logged.
|
|
4103
|
-
*/
|
|
4104
|
-
async function runMobileDebugServer(options = {}) {
|
|
4105
|
-
const relayBaseUrl = await readMobileRelayBaseUrl(process.env, options.projectRoot ?? process.cwd());
|
|
4106
|
-
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
4107
|
-
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
4108
|
-
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
4109
|
-
let activeTunnelChildPid = null;
|
|
4110
|
-
const router = new DualConnectionRouter({
|
|
4111
|
-
bootLazyFor: async (key) => key === "relay-sandbox" ? bootExternalRelayFamily(relayBaseUrl, await readRelayLocalUrl(process.env, options.projectRoot ?? process.cwd())) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
|
|
4112
|
-
verifyAuth: buildRelayVerifyAuth(),
|
|
4113
|
-
onWssUrl: (wssUrl) => {
|
|
4114
|
-
lockHandle.updateWssUrl(wssUrl);
|
|
4115
|
-
qrServer?.notifyStateChange();
|
|
4116
|
-
},
|
|
4117
|
-
onTunnelChildPid: (pid) => {
|
|
4118
|
-
activeTunnelChildPid = pid;
|
|
4119
|
-
lockHandle.updateTunnelChildPid(pid);
|
|
4120
|
-
},
|
|
4121
|
-
onAuthReject: () => diagnosticsCollector.recordAuthReject(),
|
|
4122
|
-
onTunnelDown: () => qrServer?.notifyStateChange()
|
|
4123
|
-
}),
|
|
4124
|
-
diagnosticsCollector,
|
|
4125
|
-
devtoolsOpener,
|
|
4126
|
-
onPageAttach: () => qrServer?.notifyStateChange(),
|
|
4127
|
-
onPageDetach: () => qrServer?.notifyStateChange(),
|
|
4128
|
-
getInspectorStableUrl: () => qrServer?.inspectorStableUrl ?? null,
|
|
4129
|
-
readSandboxRelayUrl: (pr) => readMobileRelayBaseUrl(process.env, pr ?? options.projectRoot ?? process.cwd()).catch(() => null)
|
|
4130
|
-
});
|
|
4131
|
-
const aitSource = new RoutingAitSource(() => {
|
|
4132
|
-
return router.active;
|
|
4133
|
-
});
|
|
4134
|
-
let lastAttachParts = null;
|
|
4135
|
-
const getDashboardState = () => {
|
|
4136
|
-
const targets = router.active.listTargets();
|
|
4137
|
-
const inspectorUrl = qrServer?.inspectorStableUrl ?? null;
|
|
4138
|
-
return {
|
|
4139
|
-
tunnel: {
|
|
4140
|
-
up: router.relayTunnelStatus().up,
|
|
4141
|
-
wssUrl: router.relayTunnelStatus().wssUrl
|
|
4142
|
-
},
|
|
4143
|
-
pages: targets.map((t) => ({
|
|
4144
|
-
id: t.id,
|
|
4145
|
-
url: t.url
|
|
4146
|
-
})),
|
|
4147
|
-
attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
|
|
4148
|
-
inspectorUrl
|
|
4149
|
-
};
|
|
4150
|
-
};
|
|
4151
|
-
const getDirectInspectorUrl = () => {
|
|
4152
|
-
const relayHttpUrl = router.activeRelayHttpUrl;
|
|
4153
|
-
if (!relayHttpUrl) return {
|
|
4154
|
-
ok: false,
|
|
4155
|
-
reason: "relayDown"
|
|
4156
|
-
};
|
|
4157
|
-
const targets = router.active.listTargets();
|
|
4158
|
-
if (targets.length === 0) return {
|
|
4159
|
-
ok: false,
|
|
4160
|
-
reason: "noTarget"
|
|
4161
|
-
};
|
|
4162
|
-
const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
4163
|
-
if (!totpSecret) return {
|
|
4164
|
-
ok: false,
|
|
4165
|
-
reason: "totpUnavailable"
|
|
4166
|
-
};
|
|
4167
|
-
const url = buildChiiInspectorUrl(relayHttpUrl, targets[0].id, () => generateTotp(totpSecret, Date.now()));
|
|
4168
|
-
if (url === null) return {
|
|
4169
|
-
ok: false,
|
|
4170
|
-
reason: "totpUnavailable"
|
|
4171
|
-
};
|
|
4172
|
-
return {
|
|
4173
|
-
ok: true,
|
|
4174
|
-
url
|
|
4175
|
-
};
|
|
4176
|
-
};
|
|
4177
|
-
let qrServer;
|
|
4178
|
-
try {
|
|
4179
|
-
qrServer = await startQrHttpServer(getDashboardState, { getDirectInspectorUrl });
|
|
4180
|
-
} catch (err) {
|
|
4181
|
-
logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
|
|
4182
|
-
}
|
|
4183
|
-
const TOTP_REFRESH_INTERVAL_MS = 2e4;
|
|
4184
|
-
let totpRefreshHandle = null;
|
|
4185
|
-
totpRefreshHandle = setInterval(() => {
|
|
4186
|
-
if (lastAttachParts !== null) qrServer?.notifyStateChange();
|
|
4187
|
-
}, TOTP_REFRESH_INTERVAL_MS);
|
|
4188
|
-
totpRefreshHandle.unref();
|
|
4189
|
-
const server = createDebugServer({
|
|
4190
|
-
connection: router.active,
|
|
4191
|
-
router,
|
|
4192
|
-
aitSource,
|
|
4193
|
-
getTunnelStatus: () => router.relayTunnelStatus(),
|
|
4194
|
-
getTunnelChildPid: () => activeTunnelChildPid,
|
|
4195
|
-
get qrHttpServer() {
|
|
4196
|
-
return qrServer;
|
|
4197
|
-
},
|
|
4198
|
-
diagnosticsCollector,
|
|
4199
|
-
getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
|
|
4200
|
-
onAttachUrlBuilt: (parts) => {
|
|
4201
|
-
lastAttachParts = parts;
|
|
4202
|
-
qrServer?.notifyStateChange();
|
|
4203
|
-
}
|
|
4204
|
-
});
|
|
4205
|
-
const transport = new StdioServerTransport();
|
|
4206
|
-
let closed = false;
|
|
4207
|
-
let parentWatcher = null;
|
|
4208
|
-
let maxAgeWatchdog = null;
|
|
4209
|
-
const shutdown = () => {
|
|
4210
|
-
if (closed) return;
|
|
4211
|
-
closed = true;
|
|
4212
|
-
parentWatcher?.stop();
|
|
4213
|
-
maxAgeWatchdog?.stop();
|
|
4214
|
-
if (totpRefreshHandle) clearInterval(totpRefreshHandle);
|
|
4215
|
-
router.stopWatcher();
|
|
4216
|
-
for (const family of router.bootedFamilies()) family.stop();
|
|
4217
|
-
server.close();
|
|
4218
|
-
qrServer?.close();
|
|
4219
|
-
lockHandle.release();
|
|
4220
|
-
};
|
|
4221
|
-
process.once("SIGINT", shutdown);
|
|
4222
|
-
process.once("SIGTERM", shutdown);
|
|
4223
|
-
process.once("SIGHUP", shutdown);
|
|
4224
|
-
process.on("exit", () => {
|
|
4225
|
-
if (!closed) {
|
|
4226
|
-
closed = true;
|
|
4227
|
-
parentWatcher?.stop();
|
|
4228
|
-
maxAgeWatchdog?.stop();
|
|
4229
|
-
if (totpRefreshHandle) clearInterval(totpRefreshHandle);
|
|
4230
|
-
router.stopWatcher();
|
|
4231
|
-
for (const family of router.bootedFamilies()) family.stop();
|
|
4232
|
-
lockHandle.release();
|
|
4233
|
-
}
|
|
4234
|
-
});
|
|
4235
|
-
process.on("uncaughtException", (err) => {
|
|
4236
|
-
logError("tool.error", {
|
|
4237
|
-
msg: `uncaughtException: ${String(err)}`,
|
|
4238
|
-
errorKind: "uncaught",
|
|
4239
|
-
mode: "relay-sandbox"
|
|
4240
|
-
});
|
|
4241
|
-
shutdown();
|
|
4242
|
-
process.exit(1);
|
|
4243
|
-
});
|
|
4244
|
-
process.on("unhandledRejection", (reason) => {
|
|
4245
|
-
logError("tool.error", {
|
|
4246
|
-
msg: `unhandledRejection: ${String(reason)}`,
|
|
4247
|
-
errorKind: "unhandled-rejection",
|
|
4248
|
-
mode: "relay-sandbox"
|
|
4249
|
-
});
|
|
4250
|
-
shutdown();
|
|
4251
|
-
process.exit(1);
|
|
4252
|
-
});
|
|
4253
|
-
await server.connect(transport);
|
|
4254
|
-
router.start(server);
|
|
4255
|
-
if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
|
|
4256
|
-
parentWatcher = startParentWatcher(() => {
|
|
4257
|
-
shutdown();
|
|
4258
|
-
process.exit(0);
|
|
4259
|
-
}, { intervalMs: 5e3 });
|
|
4260
|
-
process.stdin.once("end", () => {
|
|
4261
|
-
shutdown();
|
|
4262
|
-
process.exit(0);
|
|
4263
|
-
});
|
|
4264
|
-
process.stdin.once("close", () => {
|
|
4265
|
-
shutdown();
|
|
4266
|
-
process.exit(0);
|
|
4267
|
-
});
|
|
4268
|
-
}
|
|
4269
|
-
if (process.env.AIT_DEBUG_NO_MAX_AGE !== "1") maxAgeWatchdog = startMaxAgeWatchdog(() => {
|
|
4270
|
-
process.stderr.write("[ait-debug] max-age watchdog: daemon lifetime exceeded — shutting down for a fresh start.\n");
|
|
4271
|
-
shutdown();
|
|
4272
|
-
process.exit(0);
|
|
4273
|
-
}, { maxAgeMs: process.env.AIT_DEBUG_MAX_AGE_MS ? Number.parseInt(process.env.AIT_DEBUG_MAX_AGE_MS, 10) || void 0 : void 0 });
|
|
4274
|
-
}
|
|
4275
|
-
//#endregion
|
|
4276
|
-
export { wrapEnvelope as a, runMobileDebugServer as i, runDebugServer as n, runLocalDebugServer as r, debug_server_exports as t };
|
|
4277
|
-
|
|
4278
|
-
//# sourceMappingURL=debug-server-IVaJ9vHH.js.map
|