@ait-co/devtools 0.1.40 → 0.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +26 -0
- package/README.md +26 -0
- package/dist/mcp/cli.js +1235 -119
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +213 -27
- package/dist/mcp/server.js.map +1 -1
- package/dist/mock/index.d.ts +0 -4
- package/dist/mock/index.d.ts.map +1 -1
- package/dist/mock/index.js +0 -1
- package/dist/mock/index.js.map +1 -1
- package/dist/panel/index.js +40 -52
- package/dist/panel/index.js.map +1 -1
- package/package.json +3 -2
package/dist/mcp/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { argv } from "node:process";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -11,27 +11,31 @@ import { WebSocket } from "ws";
|
|
|
11
11
|
import { createServer } from "node:http";
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
13
|
import net from "node:net";
|
|
14
|
-
import { platform } from "node:os";
|
|
14
|
+
import { homedir, platform } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
15
16
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
16
17
|
import { Tunnel, bin, install } from "cloudflared";
|
|
18
|
+
//#region \0rolldown/runtime.js
|
|
19
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
20
|
+
//#endregion
|
|
17
21
|
//#region src/mcp/ait-chii-source.ts
|
|
18
|
-
function isObject$
|
|
22
|
+
function isObject$4(value) {
|
|
19
23
|
return typeof value === "object" && value !== null;
|
|
20
24
|
}
|
|
21
25
|
/** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
|
|
22
26
|
function asSdkCallHistory(raw) {
|
|
23
|
-
if (isObject$
|
|
27
|
+
if (isObject$4(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
|
|
24
28
|
return { calls: [] };
|
|
25
29
|
}
|
|
26
30
|
/** Narrows an `AIT.getMockState` response to an opaque record. */
|
|
27
31
|
function asMockState(raw) {
|
|
28
|
-
return isObject$
|
|
32
|
+
return isObject$4(raw) ? raw : {};
|
|
29
33
|
}
|
|
30
34
|
/** Narrows an `AIT.getOperationalEnvironment` response. */
|
|
31
35
|
function asOperationalEnvironment(raw) {
|
|
32
36
|
return {
|
|
33
|
-
environment: isObject$
|
|
34
|
-
sdkVersion: isObject$
|
|
37
|
+
environment: isObject$4(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
|
|
38
|
+
sdkVersion: isObject$4(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
|
|
35
39
|
};
|
|
36
40
|
}
|
|
37
41
|
var ChiiAitSource = class {
|
|
@@ -62,10 +66,16 @@ var ChiiAitSource = class {
|
|
|
62
66
|
* events in ring buffers the tool layer reads via `getBufferedEvents`.
|
|
63
67
|
*
|
|
64
68
|
* Node-only: imports `ws`. Never bundled into the browser/in-app entries.
|
|
69
|
+
*
|
|
70
|
+
* Attach reliability (#281):
|
|
71
|
+
* `refreshTargets()` emits an internal 'target:attached' event whenever a
|
|
72
|
+
* new target is added to the relay. `waitForFirstTarget()` awaits that event
|
|
73
|
+
* (with a polling-interval fallback) so `build_attach_url wait_for_attach`
|
|
74
|
+
* resolves deterministically rather than racing between polling rounds.
|
|
65
75
|
*/
|
|
66
76
|
/** Max events retained per domain ring buffer. */
|
|
67
77
|
const DEFAULT_BUFFER_SIZE$1 = 500;
|
|
68
|
-
function isObject$
|
|
78
|
+
function isObject$3(value) {
|
|
69
79
|
return typeof value === "object" && value !== null;
|
|
70
80
|
}
|
|
71
81
|
function parseInbound$1(raw) {
|
|
@@ -75,13 +85,13 @@ function parseInbound$1(raw) {
|
|
|
75
85
|
} catch {
|
|
76
86
|
return null;
|
|
77
87
|
}
|
|
78
|
-
if (!isObject$
|
|
88
|
+
if (!isObject$3(parsed)) return null;
|
|
79
89
|
const message = {};
|
|
80
90
|
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
81
91
|
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
82
92
|
if ("params" in parsed) message.params = parsed.params;
|
|
83
93
|
if ("result" in parsed) message.result = parsed.result;
|
|
84
|
-
if (isObject$
|
|
94
|
+
if (isObject$3(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
85
95
|
return message;
|
|
86
96
|
}
|
|
87
97
|
const PHASE_1_EVENTS$1 = [
|
|
@@ -90,25 +100,67 @@ const PHASE_1_EVENTS$1 = [
|
|
|
90
100
|
"Network.responseReceived"
|
|
91
101
|
];
|
|
92
102
|
/**
|
|
103
|
+
* Ring buffer size for `Runtime.exceptionThrown`.
|
|
104
|
+
*
|
|
105
|
+
* Exceptions are rarer than console messages but each is heavier (stack
|
|
106
|
+
* trace). 50 is generous enough to cover a crash scenario while keeping
|
|
107
|
+
* memory bounded.
|
|
108
|
+
*
|
|
109
|
+
* **Lifecycle note**: the exception buffer intentionally survives `replaced` /
|
|
110
|
+
* `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
|
|
111
|
+
* transitions. Rationale: an exception fired just before a crash is exactly
|
|
112
|
+
* the signal we want to preserve for root-cause analysis. The buffer
|
|
113
|
+
* represents "exceptions seen in this MCP session", not "exceptions in the
|
|
114
|
+
* current page".
|
|
115
|
+
*/
|
|
116
|
+
const EXCEPTION_BUFFER_SIZE = 50;
|
|
117
|
+
/** Default per-command timeout if neither option nor env var is set. */
|
|
118
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
|
|
119
|
+
/**
|
|
93
120
|
* Production CDP connection. Polls the relay for the first attached target,
|
|
94
121
|
* opens a client websocket to it, enables Phase 1 domains, and buffers events.
|
|
95
122
|
*/
|
|
96
123
|
var ChiiCdpConnection = class {
|
|
97
124
|
relayBaseUrl;
|
|
98
125
|
bufferSize;
|
|
126
|
+
commandTimeoutMs;
|
|
99
127
|
emitter = new EventEmitter();
|
|
100
128
|
buffers = /* @__PURE__ */ new Map();
|
|
101
129
|
targets = /* @__PURE__ */ new Map();
|
|
102
130
|
ws = null;
|
|
131
|
+
connectionState = "idle";
|
|
103
132
|
nextCommandId = 1;
|
|
133
|
+
/**
|
|
134
|
+
* The single active target id under the single-attach model.
|
|
135
|
+
* Updated by `refreshTargets()` whenever a non-null target is present.
|
|
136
|
+
* Used to detect a new (different) target attach and evict the previous one.
|
|
137
|
+
*/
|
|
138
|
+
activeTargetId = null;
|
|
104
139
|
/** In-flight enableDomains() promise — concurrent callers share it. */
|
|
105
140
|
enablingPromise = null;
|
|
106
141
|
/** Pending request→response commands keyed by CDP message id. */
|
|
107
142
|
pending = /* @__PURE__ */ new Map();
|
|
143
|
+
/**
|
|
144
|
+
* Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
|
|
145
|
+
* or `null` if no crash has been detected since the last `enableDomains()`.
|
|
146
|
+
*/
|
|
147
|
+
lastCrashDetectedAt = null;
|
|
148
|
+
/**
|
|
149
|
+
* Per-target last-seen timestamp (ms since epoch). Updated on any inbound
|
|
150
|
+
* CDP message carrying data from a target. Keyed by target id.
|
|
151
|
+
*/
|
|
152
|
+
targetLastSeenAt = /* @__PURE__ */ new Map();
|
|
153
|
+
/** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
|
|
154
|
+
heartbeatHandle = null;
|
|
155
|
+
/** Lifecycle event listeners (crash / destroyed / detached). */
|
|
156
|
+
lifecycleListeners = [];
|
|
108
157
|
constructor(options) {
|
|
109
158
|
this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
|
|
110
159
|
this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE$1;
|
|
160
|
+
const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
|
|
161
|
+
this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
111
162
|
for (const event of PHASE_1_EVENTS$1) this.buffers.set(event, []);
|
|
163
|
+
this.buffers.set("Runtime.exceptionThrown", []);
|
|
112
164
|
this.emitter.setMaxListeners(0);
|
|
113
165
|
}
|
|
114
166
|
/** Refresh the attached-target list from the relay's `GET /targets`. */
|
|
@@ -116,22 +168,118 @@ var ChiiCdpConnection = class {
|
|
|
116
168
|
const res = await fetch(`${this.relayBaseUrl}/targets`);
|
|
117
169
|
if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
|
|
118
170
|
const body = await res.json();
|
|
119
|
-
const list = isObject$
|
|
171
|
+
const list = isObject$3(body) && Array.isArray(body.targets) ? body.targets : [];
|
|
172
|
+
let newestTargetId = null;
|
|
173
|
+
for (const item of list) {
|
|
174
|
+
if (!isObject$3(item) || typeof item.id !== "string") continue;
|
|
175
|
+
newestTargetId = item.id;
|
|
176
|
+
}
|
|
177
|
+
if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
|
|
178
|
+
const prevId = this.activeTargetId;
|
|
179
|
+
process.stderr.write(`[ait-debug] 이전 page 세션 종료 — 새 attach로 교체 (prev=${prevId})\n`);
|
|
180
|
+
this.evictTarget(prevId);
|
|
181
|
+
}
|
|
120
182
|
this.targets.clear();
|
|
121
183
|
for (const item of list) {
|
|
122
|
-
if (!isObject$
|
|
184
|
+
if (!isObject$3(item) || typeof item.id !== "string") continue;
|
|
185
|
+
if (item.id !== newestTargetId) continue;
|
|
123
186
|
this.targets.set(item.id, {
|
|
124
187
|
id: item.id,
|
|
125
188
|
title: typeof item.title === "string" ? item.title : "",
|
|
126
189
|
url: typeof item.url === "string" ? item.url : ""
|
|
127
190
|
});
|
|
128
191
|
}
|
|
129
|
-
|
|
192
|
+
if (newestTargetId !== null) this.activeTargetId = newestTargetId;
|
|
193
|
+
else this.activeTargetId = null;
|
|
194
|
+
const result = [...this.targets.values()];
|
|
195
|
+
if (newestTargetId !== null) this.emitter.emit("target:attached", result);
|
|
196
|
+
return result;
|
|
130
197
|
}
|
|
131
198
|
listTargets() {
|
|
132
199
|
return [...this.targets.values()];
|
|
133
200
|
}
|
|
134
201
|
/**
|
|
202
|
+
* Waits until at least one target matching `filterFn` is attached, then
|
|
203
|
+
* resolves with the full target list at that moment.
|
|
204
|
+
*
|
|
205
|
+
* Resolution happens on whichever comes first:
|
|
206
|
+
* (a) a `'target:attached'` event from `refreshTargets()` (triggered by
|
|
207
|
+
* the /targets poll finding a new target), OR
|
|
208
|
+
* (b) a `'target:attached'` event from `handleMessage()` (triggered by
|
|
209
|
+
* the first inbound CDP message from a target — confirms the relay
|
|
210
|
+
* websocket has data from the phone, not just a target entry in the map).
|
|
211
|
+
*
|
|
212
|
+
* This dual-signal approach eliminates the polling race that previously
|
|
213
|
+
* caused `wait_for_attach` to resolve before the first CDP message arrived.
|
|
214
|
+
*
|
|
215
|
+
* Falls back to checking `listTargets()` every `pollIntervalMs` in case the
|
|
216
|
+
* EventEmitter is missed (defensive belt-and-suspenders).
|
|
217
|
+
*
|
|
218
|
+
* @param filterFn - Predicate that the returned targets must satisfy.
|
|
219
|
+
* @param timeoutMs - Reject after this many ms (default 90 000).
|
|
220
|
+
* @param pollIntervalMs - Fallback poll interval (default 500ms).
|
|
221
|
+
*/
|
|
222
|
+
waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
|
|
223
|
+
const current = this.listTargets();
|
|
224
|
+
if (filterFn(current)) return Promise.resolve(current);
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
let settled = false;
|
|
227
|
+
let pollHandle = null;
|
|
228
|
+
const settle = (targets) => {
|
|
229
|
+
if (settled) return;
|
|
230
|
+
settled = true;
|
|
231
|
+
clearTimeout(timeoutHandle);
|
|
232
|
+
if (pollHandle !== null) {
|
|
233
|
+
clearInterval(pollHandle);
|
|
234
|
+
pollHandle = null;
|
|
235
|
+
}
|
|
236
|
+
this.emitter.off("target:attached", onAttach);
|
|
237
|
+
resolve(targets);
|
|
238
|
+
};
|
|
239
|
+
const onAttach = (targets) => {
|
|
240
|
+
if (filterFn(targets)) settle(targets);
|
|
241
|
+
};
|
|
242
|
+
const timeoutHandle = setTimeout(() => {
|
|
243
|
+
if (settled) return;
|
|
244
|
+
settled = true;
|
|
245
|
+
if (pollHandle !== null) {
|
|
246
|
+
clearInterval(pollHandle);
|
|
247
|
+
pollHandle = null;
|
|
248
|
+
}
|
|
249
|
+
this.emitter.off("target:attached", onAttach);
|
|
250
|
+
reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
|
|
251
|
+
}, timeoutMs);
|
|
252
|
+
this.emitter.on("target:attached", onAttach);
|
|
253
|
+
pollHandle = setInterval(() => {
|
|
254
|
+
this.refreshTargets().then((targets) => {
|
|
255
|
+
if (filterFn(targets)) settle(targets);
|
|
256
|
+
}, () => {});
|
|
257
|
+
}, pollIntervalMs);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Timestamp (ms since epoch) of the most recent crash/destroy/detach event
|
|
262
|
+
* detected since the last `enableDomains()` call, or `null` if none.
|
|
263
|
+
*/
|
|
264
|
+
getLastCrashDetectedAt() {
|
|
265
|
+
return this.lastCrashDetectedAt;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Last-seen timestamp (ms since epoch) for a given target id, or `null` if
|
|
269
|
+
* the target is unknown / no message has been received from it yet.
|
|
270
|
+
*/
|
|
271
|
+
getTargetLastSeenAt(targetId) {
|
|
272
|
+
return this.targetLastSeenAt.get(targetId) ?? null;
|
|
273
|
+
}
|
|
274
|
+
/** Subscribe to target lifecycle events (crash / destroyed / detached). */
|
|
275
|
+
onLifecycle(listener) {
|
|
276
|
+
this.lifecycleListeners.push(listener);
|
|
277
|
+
return () => {
|
|
278
|
+
const idx = this.lifecycleListeners.indexOf(listener);
|
|
279
|
+
if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
135
283
|
* Connect a client websocket to the first attached target and enable Phase 1
|
|
136
284
|
* domains. Resolves once the socket is open and enable commands are sent.
|
|
137
285
|
*/
|
|
@@ -152,11 +300,19 @@ var ChiiCdpConnection = class {
|
|
|
152
300
|
ws.once("open", () => resolve());
|
|
153
301
|
ws.once("error", (err) => reject(err));
|
|
154
302
|
});
|
|
303
|
+
this.lastCrashDetectedAt = null;
|
|
304
|
+
this.targetLastSeenAt.clear();
|
|
305
|
+
this.connectionState = "connected";
|
|
155
306
|
ws.on("message", (data) => this.handleMessage(data.toString()));
|
|
307
|
+
ws.on("close", () => this.handleDisconnect("relay WebSocket 연결이 끊겼습니다"));
|
|
308
|
+
ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
|
|
156
309
|
this.sendFireAndForget("Runtime.enable");
|
|
157
310
|
this.sendFireAndForget("Network.enable");
|
|
158
311
|
this.sendFireAndForget("DOM.enable");
|
|
159
312
|
this.sendFireAndForget("Page.enable");
|
|
313
|
+
this.sendFireAndForget("Inspector.enable");
|
|
314
|
+
this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
|
|
315
|
+
this.startHeartbeat(target.id);
|
|
160
316
|
}
|
|
161
317
|
/** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
|
|
162
318
|
sendFireAndForget(method, params = {}) {
|
|
@@ -179,15 +335,35 @@ var ChiiCdpConnection = class {
|
|
|
179
335
|
* Issue an arbitrary request→response command over the relay and resolve with
|
|
180
336
|
* its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
|
|
181
337
|
* `AIT.*` methods, forwarded over the same Chii channel) build on this.
|
|
338
|
+
*
|
|
339
|
+
* Rejects immediately if the connection is disconnected (fail-fast — no
|
|
340
|
+
* auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
|
|
341
|
+
* reattach.
|
|
342
|
+
*
|
|
343
|
+
* Times out after `commandTimeoutMs` (default 30s, env
|
|
344
|
+
* `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
|
|
345
|
+
* and the promise rejects with a descriptive Korean error.
|
|
182
346
|
*/
|
|
183
347
|
sendCommand(method, params = {}) {
|
|
348
|
+
if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
|
|
184
349
|
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."));
|
|
185
350
|
const id = this.nextCommandId++;
|
|
186
351
|
const ws = this.ws;
|
|
352
|
+
const timeoutMs = this.commandTimeoutMs;
|
|
187
353
|
return new Promise((resolve, reject) => {
|
|
354
|
+
const handle = setTimeout(() => {
|
|
355
|
+
this.pending.delete(id);
|
|
356
|
+
reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
|
|
357
|
+
}, timeoutMs);
|
|
188
358
|
this.pending.set(id, {
|
|
189
|
-
resolve
|
|
190
|
-
|
|
359
|
+
resolve: (v) => {
|
|
360
|
+
clearTimeout(handle);
|
|
361
|
+
resolve(v);
|
|
362
|
+
},
|
|
363
|
+
reject: (e) => {
|
|
364
|
+
clearTimeout(handle);
|
|
365
|
+
reject(e);
|
|
366
|
+
}
|
|
191
367
|
});
|
|
192
368
|
ws.send(JSON.stringify({
|
|
193
369
|
id,
|
|
@@ -196,6 +372,117 @@ var ChiiCdpConnection = class {
|
|
|
196
372
|
}));
|
|
197
373
|
});
|
|
198
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* Called on WebSocket `close` or `error` after a successful connection.
|
|
377
|
+
* Rejects all pending commands and marks the connection as disconnected so
|
|
378
|
+
* subsequent `sendCommand` calls fail fast (no auto-reconnect).
|
|
379
|
+
*/
|
|
380
|
+
handleDisconnect(reason) {
|
|
381
|
+
if (this.connectionState === "disconnected") return;
|
|
382
|
+
this.connectionState = "disconnected";
|
|
383
|
+
this.ws = null;
|
|
384
|
+
this.stopHeartbeat();
|
|
385
|
+
const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
|
|
386
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
387
|
+
this.pending.clear();
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Evict a previously active target under the single-attach model.
|
|
391
|
+
* Rejects pending commands with a 'replaced-by-new-attach' reason and emits
|
|
392
|
+
* a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
|
|
393
|
+
* targetId. The caller is responsible for rebuilding the targets map afterwards.
|
|
394
|
+
*
|
|
395
|
+
* The error message uses 'replaced-by-new-attach' so test assertions can match it.
|
|
396
|
+
*/
|
|
397
|
+
evictTarget(targetId) {
|
|
398
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
399
|
+
this.targets.delete(targetId);
|
|
400
|
+
this.targetLastSeenAt.delete(targetId);
|
|
401
|
+
const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
|
|
402
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
403
|
+
this.pending.clear();
|
|
404
|
+
const event = {
|
|
405
|
+
kind: "replaced",
|
|
406
|
+
targetId,
|
|
407
|
+
detectedAt
|
|
408
|
+
};
|
|
409
|
+
for (const listener of this.lifecycleListeners) try {
|
|
410
|
+
listener(event);
|
|
411
|
+
} catch {}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Handle a page-level crash or target destruction event.
|
|
415
|
+
* Removes the target from the in-memory map, rejects all pending commands,
|
|
416
|
+
* and emits a lifecycle event.
|
|
417
|
+
*
|
|
418
|
+
* @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
|
|
419
|
+
* @param targetId - The target ID from the event params (may be null for
|
|
420
|
+
* Inspector.targetCrashed which has no targetId in the params).
|
|
421
|
+
*/
|
|
422
|
+
handleTargetGone(kind, targetId) {
|
|
423
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
424
|
+
this.lastCrashDetectedAt = Date.now();
|
|
425
|
+
if (targetId !== null) {
|
|
426
|
+
this.targets.delete(targetId);
|
|
427
|
+
this.targetLastSeenAt.delete(targetId);
|
|
428
|
+
if (this.activeTargetId === targetId) this.activeTargetId = null;
|
|
429
|
+
} else {
|
|
430
|
+
this.targets.clear();
|
|
431
|
+
this.targetLastSeenAt.clear();
|
|
432
|
+
this.activeTargetId = null;
|
|
433
|
+
}
|
|
434
|
+
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()로 재연결).`);
|
|
435
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
436
|
+
this.pending.clear();
|
|
437
|
+
const event = {
|
|
438
|
+
kind,
|
|
439
|
+
targetId,
|
|
440
|
+
detectedAt
|
|
441
|
+
};
|
|
442
|
+
for (const listener of this.lifecycleListeners) try {
|
|
443
|
+
listener(event);
|
|
444
|
+
} catch {}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Start the optional CDP heartbeat loop.
|
|
448
|
+
*
|
|
449
|
+
* When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
|
|
450
|
+
* we send `Runtime.evaluate({expression: '1'})` to each active target. If
|
|
451
|
+
* the command times out (2 s hard deadline) or errors, we treat the target
|
|
452
|
+
* as dead and call `handleTargetGone`.
|
|
453
|
+
*
|
|
454
|
+
* This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
|
|
455
|
+
* even when the phone app has crashed, so the ws-level disconnect (#252) won't
|
|
456
|
+
* fire. The heartbeat catches this gap.
|
|
457
|
+
*
|
|
458
|
+
* Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
|
|
459
|
+
*/
|
|
460
|
+
startHeartbeat(initialTargetId) {
|
|
461
|
+
this.stopHeartbeat();
|
|
462
|
+
const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
|
|
463
|
+
if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
|
|
464
|
+
const PING_TIMEOUT_MS = 2e3;
|
|
465
|
+
this.heartbeatHandle = setInterval(() => {
|
|
466
|
+
const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
|
|
467
|
+
for (const targetId of targetIds) {
|
|
468
|
+
const pingPromise = this.sendCommand("Runtime.evaluate", {
|
|
469
|
+
expression: "1",
|
|
470
|
+
returnByValue: true,
|
|
471
|
+
timeout: PING_TIMEOUT_MS
|
|
472
|
+
});
|
|
473
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
|
|
474
|
+
Promise.race([pingPromise, timeoutPromise]).catch(() => {
|
|
475
|
+
if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}, envMs);
|
|
479
|
+
}
|
|
480
|
+
stopHeartbeat() {
|
|
481
|
+
if (this.heartbeatHandle !== null) {
|
|
482
|
+
clearInterval(this.heartbeatHandle);
|
|
483
|
+
this.heartbeatHandle = null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
199
486
|
handleMessage(raw) {
|
|
200
487
|
const message = parseInbound$1(raw);
|
|
201
488
|
if (!message) return;
|
|
@@ -206,13 +493,35 @@ var ChiiCdpConnection = class {
|
|
|
206
493
|
else waiter.resolve(message.result);
|
|
207
494
|
return;
|
|
208
495
|
}
|
|
496
|
+
const now = Date.now();
|
|
497
|
+
let firstMessageSeen = false;
|
|
498
|
+
for (const targetId of this.targets.keys()) {
|
|
499
|
+
if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
|
|
500
|
+
this.targetLastSeenAt.set(targetId, now);
|
|
501
|
+
}
|
|
502
|
+
if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
|
|
209
503
|
if (typeof message.method !== "string") return;
|
|
504
|
+
if (message.method === "Inspector.targetCrashed") {
|
|
505
|
+
this.handleTargetGone("crashed", null);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (message.method === "Target.targetDestroyed") {
|
|
509
|
+
const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
|
|
510
|
+
this.handleTargetGone("destroyed", targetId);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (message.method === "Target.detachedFromTarget") {
|
|
514
|
+
const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
|
|
515
|
+
this.handleTargetGone("detached", targetId);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
210
518
|
if (!this.buffers.has(message.method)) return;
|
|
211
519
|
const event = message.method;
|
|
212
520
|
const buffer = this.buffers.get(event);
|
|
213
521
|
if (!buffer) return;
|
|
214
522
|
buffer.push(message.params);
|
|
215
|
-
|
|
523
|
+
const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
|
|
524
|
+
if (buffer.length > cap) buffer.shift();
|
|
216
525
|
this.emitter.emit(event, message.params);
|
|
217
526
|
}
|
|
218
527
|
getBufferedEvents(event) {
|
|
@@ -224,10 +533,10 @@ var ChiiCdpConnection = class {
|
|
|
224
533
|
}
|
|
225
534
|
/** Close the relay client websocket and reject any in-flight commands. */
|
|
226
535
|
close() {
|
|
227
|
-
this.ws
|
|
228
|
-
this.
|
|
229
|
-
|
|
230
|
-
|
|
536
|
+
const ws = this.ws;
|
|
537
|
+
this.stopHeartbeat();
|
|
538
|
+
this.handleDisconnect("Chii relay connection closed");
|
|
539
|
+
ws?.close();
|
|
231
540
|
}
|
|
232
541
|
};
|
|
233
542
|
//#endregion
|
|
@@ -260,9 +569,9 @@ var ChiiCdpConnection = class {
|
|
|
260
569
|
* in any log, error message, or process output. `verifyAuth` is a black-box
|
|
261
570
|
* predicate from the caller's perspective; this module only forwards pass/fail.
|
|
262
571
|
*/
|
|
263
|
-
const require = createRequire(import.meta.url);
|
|
572
|
+
const require$1 = createRequire(import.meta.url);
|
|
264
573
|
function loadChiiServer() {
|
|
265
|
-
const mod = require("chii");
|
|
574
|
+
const mod = require$1("chii");
|
|
266
575
|
if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
|
|
267
576
|
throw new Error("chii server module did not expose start()");
|
|
268
577
|
}
|
|
@@ -315,6 +624,206 @@ async function startChiiRelay(options = {}) {
|
|
|
315
624
|
};
|
|
316
625
|
}
|
|
317
626
|
//#endregion
|
|
627
|
+
//#region src/mcp/devtools-opener.ts
|
|
628
|
+
/**
|
|
629
|
+
* Base URL for the Chrome DevTools inspector hosted on appspot.
|
|
630
|
+
*
|
|
631
|
+
* The `@` path segment is the "latest / bleeding edge" alias which tracks the
|
|
632
|
+
* current Chrome stable CDP protocol version — compatible with the chobitsu-
|
|
633
|
+
* based CDP that Chii injects. A specific commit hash may be pinned here if
|
|
634
|
+
* a regression is observed.
|
|
635
|
+
*/
|
|
636
|
+
const DEVTOOLS_FRONTEND_BASE = "https://chrome-devtools-frontend.appspot.com/serve_file/@/inspector.html";
|
|
637
|
+
/**
|
|
638
|
+
* Assembles the Chrome DevTools inspector URL that connects to a Chii relay
|
|
639
|
+
* WebSocket.
|
|
640
|
+
*
|
|
641
|
+
* The `wss=` parameter expects a host-and-path string without the `wss://`
|
|
642
|
+
* scheme prefix — the DevTools frontend prepends it automatically.
|
|
643
|
+
*
|
|
644
|
+
* @param wssRelayUrl - Full `wss://` URL of the Chii relay (public tunnel).
|
|
645
|
+
* Example: `wss://abc.trycloudflare.com`
|
|
646
|
+
* @param panel - Initial panel. Defaults to `"console"`.
|
|
647
|
+
*
|
|
648
|
+
* @example
|
|
649
|
+
* buildChromeDevtoolsUrl('wss://abc.trycloudflare.com')
|
|
650
|
+
* // → 'https://chrome-devtools-frontend.appspot.com/serve_file/@/inspector.html?wss=abc.trycloudflare.com&panel=console'
|
|
651
|
+
*/
|
|
652
|
+
function buildChromeDevtoolsUrl(wssRelayUrl, panel = "console") {
|
|
653
|
+
const wssParam = wssRelayUrl.replace(/^wss:\/\//i, "");
|
|
654
|
+
return `${DEVTOOLS_FRONTEND_BASE}?${new URLSearchParams({
|
|
655
|
+
wss: wssParam,
|
|
656
|
+
panel
|
|
657
|
+
}).toString()}`;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Returns `true` when auto-open is **disabled** via the `AIT_AUTO_DEVTOOLS`
|
|
661
|
+
* env var. Only the explicit `"0"` value disables it; anything else (including
|
|
662
|
+
* absent) leaves auto-open enabled.
|
|
663
|
+
*/
|
|
664
|
+
function isAutoDevtoolsDisabled() {
|
|
665
|
+
return process.env.AIT_AUTO_DEVTOOLS === "0";
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Opens the given URL in the OS default browser using a platform-appropriate
|
|
669
|
+
* command. Returns `true` on success.
|
|
670
|
+
*
|
|
671
|
+
* Failures are silent from the caller's perspective — the caller should log
|
|
672
|
+
* the URL to stderr as a fallback before calling this function.
|
|
673
|
+
*/
|
|
674
|
+
function openUrlInBrowser(url) {
|
|
675
|
+
if (process.env.AIT_AUTO_DEVTOOLS_TEST_SKIP_SPAWN === "1") return false;
|
|
676
|
+
const { spawnSync } = __require("node:child_process");
|
|
677
|
+
const platform = process.platform;
|
|
678
|
+
let candidates;
|
|
679
|
+
if (platform === "darwin") candidates = [{
|
|
680
|
+
cmd: "open",
|
|
681
|
+
args: [url]
|
|
682
|
+
}];
|
|
683
|
+
else if (platform === "win32") candidates = [{
|
|
684
|
+
cmd: "cmd",
|
|
685
|
+
args: [
|
|
686
|
+
"/c",
|
|
687
|
+
"start",
|
|
688
|
+
"",
|
|
689
|
+
url
|
|
690
|
+
]
|
|
691
|
+
}];
|
|
692
|
+
else candidates = [
|
|
693
|
+
{
|
|
694
|
+
cmd: "xdg-open",
|
|
695
|
+
args: [url]
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
cmd: "sensible-browser",
|
|
699
|
+
args: [url]
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
cmd: "x-www-browser",
|
|
703
|
+
args: [url]
|
|
704
|
+
}
|
|
705
|
+
];
|
|
706
|
+
for (const { cmd, args } of candidates) try {
|
|
707
|
+
const result = spawnSync(cmd, args, {
|
|
708
|
+
encoding: "utf8",
|
|
709
|
+
timeout: 5e3
|
|
710
|
+
});
|
|
711
|
+
if (!result.error && result.status === 0) return true;
|
|
712
|
+
} catch {}
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Manages auto-opening Chrome DevTools exactly once per relay attach session.
|
|
717
|
+
*
|
|
718
|
+
* Create one instance per `runDebugServer` call and pass its `open()` method
|
|
719
|
+
* as the `onFirstAttach` callback to `startAttachWatcher`.
|
|
720
|
+
*
|
|
721
|
+
* The open fires at most once. Subsequent `open()` calls are no-ops.
|
|
722
|
+
* Opt-out and mock-environment guard are checked at call time.
|
|
723
|
+
*/
|
|
724
|
+
var AutoDevtoolsOpener = class {
|
|
725
|
+
_opened = false;
|
|
726
|
+
/**
|
|
727
|
+
* Attempts to auto-open Chrome DevTools.
|
|
728
|
+
*
|
|
729
|
+
* No-op when any of the following conditions hold:
|
|
730
|
+
* 1. Already opened this session (`_opened` is true).
|
|
731
|
+
* 2. `AIT_AUTO_DEVTOOLS=0` opt-out is set.
|
|
732
|
+
* 3. Environment is `mock` (env 1 — F12 is already available).
|
|
733
|
+
* 4. `wssRelayUrl` is null/undefined/empty (tunnel not yet up).
|
|
734
|
+
*
|
|
735
|
+
* Always writes the DevTools URL to stderr so the developer can copy it
|
|
736
|
+
* if the browser open fails or the popup is blocked.
|
|
737
|
+
*
|
|
738
|
+
* @param wssRelayUrl - The public `wss://` relay URL (from tunnel status).
|
|
739
|
+
* @param env - Current MCP environment (`mock` | `relay`).
|
|
740
|
+
*/
|
|
741
|
+
open(wssRelayUrl, env) {
|
|
742
|
+
if (this._opened) return;
|
|
743
|
+
if (isAutoDevtoolsDisabled()) return;
|
|
744
|
+
if (env === "mock") return;
|
|
745
|
+
if (!wssRelayUrl) return;
|
|
746
|
+
this._opened = true;
|
|
747
|
+
const devtoolsUrl = buildChromeDevtoolsUrl(wssRelayUrl);
|
|
748
|
+
process.stderr.write(`[ait-debug] 기기가 연결됐습니다 — Chrome DevTools를 자동으로 엽니다.
|
|
749
|
+
[ait-debug] Chrome DevTools URL: ${devtoolsUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)
|
|
750
|
+
`);
|
|
751
|
+
if (!openUrlInBrowser(devtoolsUrl)) process.stderr.write("[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\n");
|
|
752
|
+
}
|
|
753
|
+
/** Returns `true` if `open()` has passed all guards and fired once. */
|
|
754
|
+
get opened() {
|
|
755
|
+
return this._opened;
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
//#endregion
|
|
759
|
+
//#region src/mcp/environment.ts
|
|
760
|
+
/**
|
|
761
|
+
* URL patterns that mark a CDP target as a real-device WebView relay.
|
|
762
|
+
*
|
|
763
|
+
* - `intoss-private://` is the Toss in-app private scheme — only ever observed
|
|
764
|
+
* inside the real Toss app WebView.
|
|
765
|
+
* - `*.trycloudflare.com` (host suffix) is the cloudflared quick tunnel used as
|
|
766
|
+
* the relay transport. A target whose URL is on that host is, by construction,
|
|
767
|
+
* reached over the relay.
|
|
768
|
+
*
|
|
769
|
+
* Pattern-only matches — no specific tunnel host or deploymentId is hard-coded.
|
|
770
|
+
*/
|
|
771
|
+
const RELAY_URL_PATTERNS = [/^intoss-private:\/\//i, /:\/\/[a-z0-9-]+\.trycloudflare\.com(\/|$|:|\?)/i];
|
|
772
|
+
/**
|
|
773
|
+
* Returns true when the URL string looks like a real-device WebView attached
|
|
774
|
+
* over the Chii relay. Used for `getEnvironment()` precedence step 2.
|
|
775
|
+
*/
|
|
776
|
+
function isRelayUrl(url) {
|
|
777
|
+
if (typeof url !== "string" || url.length === 0) return false;
|
|
778
|
+
return RELAY_URL_PATTERNS.some((p) => p.test(url));
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Test/override hook — when non-null, `getEnvironment()` returns this value
|
|
782
|
+
* regardless of env vars or connection state. Cleared with `null`.
|
|
783
|
+
*/
|
|
784
|
+
let envOverride = null;
|
|
785
|
+
/** Parses the `MCP_ENV` env var into a `McpEnvironment` if valid. */
|
|
786
|
+
function readEnvVar() {
|
|
787
|
+
const raw = process.env.MCP_ENV;
|
|
788
|
+
if (raw === "mock" || raw === "relay") return raw;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Returns the current MCP environment, applying the precedence rules:
|
|
792
|
+
* 1. test override (if set)
|
|
793
|
+
* 2. `MCP_ENV` env var
|
|
794
|
+
* 3. CDP target URL pattern match
|
|
795
|
+
* 4. default `mock`
|
|
796
|
+
*/
|
|
797
|
+
function getEnvironment(input = {}) {
|
|
798
|
+
if (envOverride !== null) return envOverride;
|
|
799
|
+
const fromEnv = readEnvVar();
|
|
800
|
+
if (fromEnv !== void 0) return fromEnv;
|
|
801
|
+
const { connection } = input;
|
|
802
|
+
if (connection !== void 0) {
|
|
803
|
+
const targets = connection.listTargets();
|
|
804
|
+
for (const t of targets) if (isRelayUrl(t.url)) return "relay";
|
|
805
|
+
}
|
|
806
|
+
return "mock";
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Returns the `EnvironmentReason` that drove the current `getEnvironment()`
|
|
810
|
+
* result. Used by stderr logs and the rejection-reason payload on Tier A/B
|
|
811
|
+
* mismatch errors. SECRET-HANDLING: only stable enum strings — no URL or
|
|
812
|
+
* secret value is ever returned.
|
|
813
|
+
*/
|
|
814
|
+
function getEnvironmentReason(input = {}) {
|
|
815
|
+
if (envOverride !== null) return envOverride === "mock" ? "env-var-mock" : "env-var-relay";
|
|
816
|
+
const fromEnv = readEnvVar();
|
|
817
|
+
if (fromEnv === "mock") return "env-var-mock";
|
|
818
|
+
if (fromEnv === "relay") return "env-var-relay";
|
|
819
|
+
const { connection } = input;
|
|
820
|
+
if (connection !== void 0) {
|
|
821
|
+
const targets = connection.listTargets();
|
|
822
|
+
for (const t of targets) if (isRelayUrl(t.url)) return "cdp-target-url-relay-pattern";
|
|
823
|
+
}
|
|
824
|
+
return "default-mock";
|
|
825
|
+
}
|
|
826
|
+
//#endregion
|
|
318
827
|
//#region src/mcp/local-connection.ts
|
|
319
828
|
/**
|
|
320
829
|
* Local-browser `CdpConnection` — attaches directly to a Chromium instance
|
|
@@ -335,7 +844,7 @@ async function startChiiRelay(options = {}) {
|
|
|
335
844
|
*/
|
|
336
845
|
/** Max events retained per domain ring buffer. */
|
|
337
846
|
const DEFAULT_BUFFER_SIZE = 500;
|
|
338
|
-
function isObject$
|
|
847
|
+
function isObject$2(value) {
|
|
339
848
|
return typeof value === "object" && value !== null;
|
|
340
849
|
}
|
|
341
850
|
function parseInbound(raw) {
|
|
@@ -345,13 +854,13 @@ function parseInbound(raw) {
|
|
|
345
854
|
} catch {
|
|
346
855
|
return null;
|
|
347
856
|
}
|
|
348
|
-
if (!isObject$
|
|
857
|
+
if (!isObject$2(parsed)) return null;
|
|
349
858
|
const message = {};
|
|
350
859
|
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
351
860
|
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
352
861
|
if ("params" in parsed) message.params = parsed.params;
|
|
353
862
|
if ("result" in parsed) message.result = parsed.result;
|
|
354
|
-
if (isObject$
|
|
863
|
+
if (isObject$2(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
355
864
|
return message;
|
|
356
865
|
}
|
|
357
866
|
const PHASE_1_EVENTS = [
|
|
@@ -404,7 +913,7 @@ var LocalCdpConnection = class {
|
|
|
404
913
|
this.targets.clear();
|
|
405
914
|
let selected = null;
|
|
406
915
|
for (const item of list) {
|
|
407
|
-
if (!isObject$
|
|
916
|
+
if (!isObject$2(item) || typeof item.id !== "string") continue;
|
|
408
917
|
const cdpTarget = {
|
|
409
918
|
id: item.id,
|
|
410
919
|
title: typeof item.title === "string" ? item.title : "",
|
|
@@ -816,6 +1325,145 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl) {
|
|
|
816
1325
|
</html>`;
|
|
817
1326
|
}
|
|
818
1327
|
//#endregion
|
|
1328
|
+
//#region src/mcp/server-lock.ts
|
|
1329
|
+
/**
|
|
1330
|
+
* Single debug session lock for the `devtools-mcp` debug server.
|
|
1331
|
+
*
|
|
1332
|
+
* At most one debug server process should run on a given machine at a time —
|
|
1333
|
+
* multiple concurrent instances create duplicate cloudflared tunnels, waste
|
|
1334
|
+
* resources, and confuse the user about which wssUrl to use.
|
|
1335
|
+
*
|
|
1336
|
+
* ## Lock file
|
|
1337
|
+
*
|
|
1338
|
+
* Location: `~/.ait-devtools/server.lock`
|
|
1339
|
+
*
|
|
1340
|
+
* Schema (JSON):
|
|
1341
|
+
* ```json
|
|
1342
|
+
* { "pid": 12345, "wssUrl": "wss://xxx.trycloudflare.com", "startedAt": "2026-01-01T00:00:00.000Z" }
|
|
1343
|
+
* ```
|
|
1344
|
+
*
|
|
1345
|
+
* ## Behaviour
|
|
1346
|
+
*
|
|
1347
|
+
* - **Acquire**: write PID + wssUrl + startedAt. Returns a `release()` handle.
|
|
1348
|
+
* - **Stale lock recovery**: if the stored PID is no longer alive
|
|
1349
|
+
* (`process.kill(pid, 0)` throws ESRCH), the lock is silently replaced.
|
|
1350
|
+
* - **Live conflict (option B)**: if the stored PID is alive, `acquireLock`
|
|
1351
|
+
* throws `ServerLockConflictError` with the existing PID and wssUrl so the
|
|
1352
|
+
* caller can surface a clear message to the agent.
|
|
1353
|
+
* - **Release**: remove the lock file. Called on graceful shutdown (SIGINT /
|
|
1354
|
+
* SIGTERM / SIGHUP). SIGKILL survivors leave a stale file — the next startup
|
|
1355
|
+
* recovers it automatically via the alive check.
|
|
1356
|
+
*
|
|
1357
|
+
* ## wssUrl update
|
|
1358
|
+
*
|
|
1359
|
+
* The lock is written before cloudflared starts, so `wssUrl` begins as `null`
|
|
1360
|
+
* and is updated in place once the tunnel URL is known via `updateWssUrl`.
|
|
1361
|
+
*
|
|
1362
|
+
* Node-only.
|
|
1363
|
+
*/
|
|
1364
|
+
/** Thrown when a live server process already holds the lock. */
|
|
1365
|
+
var ServerLockConflictError = class extends Error {
|
|
1366
|
+
/** PID of the existing server process. */
|
|
1367
|
+
existingPid;
|
|
1368
|
+
/** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */
|
|
1369
|
+
existingWssUrl;
|
|
1370
|
+
constructor(existingPid, existingWssUrl) {
|
|
1371
|
+
const urlNote = existingWssUrl != null ? ` relay URL: ${existingWssUrl}\n` : " relay URL: (tunnel still starting — retry in a moment)\n";
|
|
1372
|
+
super(`A debug server is already running (PID ${existingPid}).\n` + urlNote + `Stop the existing session before starting a new one.
|
|
1373
|
+
If it is already stopped but this error persists, remove the lock file:
|
|
1374
|
+
rm "${lockFilePath()}"`);
|
|
1375
|
+
this.name = "ServerLockConflictError";
|
|
1376
|
+
this.existingPid = existingPid;
|
|
1377
|
+
this.existingWssUrl = existingWssUrl;
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
/** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */
|
|
1381
|
+
function lockFilePath() {
|
|
1382
|
+
return join(process.env.AIT_DEVTOOLS_LOCK_DIR ?? join(homedir(), ".ait-devtools"), "server.lock");
|
|
1383
|
+
}
|
|
1384
|
+
function ensureLockDir(lockPath) {
|
|
1385
|
+
mkdirSync(join(lockPath, ".."), { recursive: true });
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Returns `true` when the given PID refers to a running process.
|
|
1389
|
+
*
|
|
1390
|
+
* Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process
|
|
1391
|
+
* exists and we have permission to signal it; throws ESRCH when it doesn't exist.
|
|
1392
|
+
*/
|
|
1393
|
+
function isPidAlive(pid) {
|
|
1394
|
+
try {
|
|
1395
|
+
process.kill(pid, 0);
|
|
1396
|
+
return true;
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
if (err.code === "EPERM") return true;
|
|
1399
|
+
return false;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
function readLock(lockPath) {
|
|
1403
|
+
if (!existsSync(lockPath)) return null;
|
|
1404
|
+
try {
|
|
1405
|
+
const raw = readFileSync(lockPath, "utf8");
|
|
1406
|
+
const parsed = JSON.parse(raw);
|
|
1407
|
+
if (typeof parsed === "object" && parsed !== null && "pid" in parsed && typeof parsed.pid === "number" && "startedAt" in parsed && typeof parsed.startedAt === "string") {
|
|
1408
|
+
const p = parsed;
|
|
1409
|
+
return {
|
|
1410
|
+
pid: p.pid,
|
|
1411
|
+
wssUrl: typeof p.wssUrl === "string" ? p.wssUrl : null,
|
|
1412
|
+
startedAt: p.startedAt
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
return null;
|
|
1416
|
+
} catch {
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
function writeLock(lockPath, data) {
|
|
1421
|
+
ensureLockDir(lockPath);
|
|
1422
|
+
writeFileSync(lockPath, JSON.stringify(data, null, 2), { encoding: "utf8" });
|
|
1423
|
+
}
|
|
1424
|
+
function removeLock(lockPath) {
|
|
1425
|
+
try {
|
|
1426
|
+
rmSync(lockPath);
|
|
1427
|
+
} catch {}
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Attempts to acquire the server lock.
|
|
1431
|
+
*
|
|
1432
|
+
* - If no lock exists (or the lock is stale): writes a new lock and returns a
|
|
1433
|
+
* `LockHandle` with `updateWssUrl` + `release`.
|
|
1434
|
+
* - If a live process holds the lock: throws `ServerLockConflictError`.
|
|
1435
|
+
*
|
|
1436
|
+
* The initial `wssUrl` in the lock file is `null` — call
|
|
1437
|
+
* `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.
|
|
1438
|
+
*/
|
|
1439
|
+
function acquireLock() {
|
|
1440
|
+
const lockPath = lockFilePath();
|
|
1441
|
+
const existing = readLock(lockPath);
|
|
1442
|
+
if (existing !== null) {
|
|
1443
|
+
if (isPidAlive(existing.pid)) throw new ServerLockConflictError(existing.pid, existing.wssUrl);
|
|
1444
|
+
process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
|
|
1445
|
+
}
|
|
1446
|
+
const data = {
|
|
1447
|
+
pid: process.pid,
|
|
1448
|
+
wssUrl: null,
|
|
1449
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1450
|
+
};
|
|
1451
|
+
writeLock(lockPath, data);
|
|
1452
|
+
let released = false;
|
|
1453
|
+
return {
|
|
1454
|
+
updateWssUrl(wssUrl) {
|
|
1455
|
+
if (released) return;
|
|
1456
|
+
data.wssUrl = wssUrl;
|
|
1457
|
+
writeLock(lockPath, data);
|
|
1458
|
+
},
|
|
1459
|
+
release() {
|
|
1460
|
+
if (released) return;
|
|
1461
|
+
released = true;
|
|
1462
|
+
removeLock(lockPath);
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
//#endregion
|
|
819
1467
|
//#region src/mcp/deeplink.ts
|
|
820
1468
|
/**
|
|
821
1469
|
* Build a self-attaching dogfood deep link.
|
|
@@ -928,6 +1576,162 @@ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
|
|
|
928
1576
|
return `${base}?${query}${hash}`;
|
|
929
1577
|
}
|
|
930
1578
|
//#endregion
|
|
1579
|
+
//#region src/mcp/sdk-signatures.ts
|
|
1580
|
+
function isObject$1(v) {
|
|
1581
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1582
|
+
}
|
|
1583
|
+
function describeArgs(args) {
|
|
1584
|
+
try {
|
|
1585
|
+
return JSON.stringify(args);
|
|
1586
|
+
} catch {
|
|
1587
|
+
return String(args);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* 등록된 메서드 목록.
|
|
1592
|
+
*
|
|
1593
|
+
* 시그니처 출처 확인:
|
|
1594
|
+
* - 함수가 인자를 받지 않으면 args[0] 없음 → `args.length === 0`을 체크하지 않고
|
|
1595
|
+
* 그냥 통과시킨다(args 무시하는 stub가 많아서 noArgs 체크가 noise).
|
|
1596
|
+
* - 실 SDK 시그니처는 `src/__typecheck.ts`의 `Assert<Mock, Original>` 줄로 보장.
|
|
1597
|
+
*/
|
|
1598
|
+
const SIGNATURES = [
|
|
1599
|
+
{
|
|
1600
|
+
name: "setDeviceOrientation",
|
|
1601
|
+
validateArgs(args) {
|
|
1602
|
+
const arg = args[0];
|
|
1603
|
+
if (!isObject$1(arg)) return {
|
|
1604
|
+
ok: false,
|
|
1605
|
+
expected: "{ type: 'portrait' | 'landscape' }",
|
|
1606
|
+
received: describeArgs(args)
|
|
1607
|
+
};
|
|
1608
|
+
const type = arg.type;
|
|
1609
|
+
if (type !== "portrait" && type !== "landscape") return {
|
|
1610
|
+
ok: false,
|
|
1611
|
+
expected: "{ type: 'portrait' | 'landscape' }",
|
|
1612
|
+
received: describeArgs(args)
|
|
1613
|
+
};
|
|
1614
|
+
return { ok: true };
|
|
1615
|
+
},
|
|
1616
|
+
example: "call_sdk('setDeviceOrientation', [{ type: 'landscape' }])"
|
|
1617
|
+
},
|
|
1618
|
+
{
|
|
1619
|
+
name: "setIosSwipeGestureEnabled",
|
|
1620
|
+
validateArgs(args) {
|
|
1621
|
+
const arg = args[0];
|
|
1622
|
+
if (!isObject$1(arg) || typeof arg.isEnabled !== "boolean") return {
|
|
1623
|
+
ok: false,
|
|
1624
|
+
expected: "{ isEnabled: boolean }",
|
|
1625
|
+
received: describeArgs(args)
|
|
1626
|
+
};
|
|
1627
|
+
return { ok: true };
|
|
1628
|
+
},
|
|
1629
|
+
example: "call_sdk('setIosSwipeGestureEnabled', [{ isEnabled: false }])"
|
|
1630
|
+
},
|
|
1631
|
+
{
|
|
1632
|
+
name: "setSecureScreen",
|
|
1633
|
+
validateArgs(args) {
|
|
1634
|
+
const arg = args[0];
|
|
1635
|
+
if (!isObject$1(arg) || typeof arg.enabled !== "boolean") return {
|
|
1636
|
+
ok: false,
|
|
1637
|
+
expected: "{ enabled: boolean }",
|
|
1638
|
+
received: describeArgs(args)
|
|
1639
|
+
};
|
|
1640
|
+
return { ok: true };
|
|
1641
|
+
},
|
|
1642
|
+
example: "call_sdk('setSecureScreen', [{ enabled: true }])"
|
|
1643
|
+
},
|
|
1644
|
+
{
|
|
1645
|
+
name: "setScreenAwakeMode",
|
|
1646
|
+
validateArgs(args) {
|
|
1647
|
+
const arg = args[0];
|
|
1648
|
+
if (!isObject$1(arg) || typeof arg.enabled !== "boolean") return {
|
|
1649
|
+
ok: false,
|
|
1650
|
+
expected: "{ enabled: boolean }",
|
|
1651
|
+
received: describeArgs(args)
|
|
1652
|
+
};
|
|
1653
|
+
return { ok: true };
|
|
1654
|
+
},
|
|
1655
|
+
example: "call_sdk('setScreenAwakeMode', [{ enabled: true }])"
|
|
1656
|
+
},
|
|
1657
|
+
{
|
|
1658
|
+
name: "getOperationalEnvironment",
|
|
1659
|
+
validateArgs(_args) {
|
|
1660
|
+
return { ok: true };
|
|
1661
|
+
},
|
|
1662
|
+
example: "call_sdk('getOperationalEnvironment', [])"
|
|
1663
|
+
},
|
|
1664
|
+
{
|
|
1665
|
+
name: "getPlatformOS",
|
|
1666
|
+
validateArgs(_args) {
|
|
1667
|
+
return { ok: true };
|
|
1668
|
+
},
|
|
1669
|
+
example: "call_sdk('getPlatformOS', [])"
|
|
1670
|
+
},
|
|
1671
|
+
{
|
|
1672
|
+
name: "getDeviceId",
|
|
1673
|
+
validateArgs(_args) {
|
|
1674
|
+
return { ok: true };
|
|
1675
|
+
},
|
|
1676
|
+
example: "call_sdk('getDeviceId', [])"
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
name: "getLocale",
|
|
1680
|
+
validateArgs(_args) {
|
|
1681
|
+
return { ok: true };
|
|
1682
|
+
},
|
|
1683
|
+
example: "call_sdk('getLocale', [])"
|
|
1684
|
+
},
|
|
1685
|
+
{
|
|
1686
|
+
name: "getNetworkStatus",
|
|
1687
|
+
validateArgs(_args) {
|
|
1688
|
+
return { ok: true };
|
|
1689
|
+
},
|
|
1690
|
+
example: "call_sdk('getNetworkStatus', [])"
|
|
1691
|
+
},
|
|
1692
|
+
{
|
|
1693
|
+
name: "getSchemeUri",
|
|
1694
|
+
validateArgs(_args) {
|
|
1695
|
+
return { ok: true };
|
|
1696
|
+
},
|
|
1697
|
+
example: "call_sdk('getSchemeUri', [])"
|
|
1698
|
+
},
|
|
1699
|
+
{
|
|
1700
|
+
name: "requestReview",
|
|
1701
|
+
validateArgs(_args) {
|
|
1702
|
+
return { ok: true };
|
|
1703
|
+
},
|
|
1704
|
+
example: "call_sdk('requestReview', [])"
|
|
1705
|
+
},
|
|
1706
|
+
{
|
|
1707
|
+
name: "closeView",
|
|
1708
|
+
validateArgs(_args) {
|
|
1709
|
+
return { ok: true };
|
|
1710
|
+
},
|
|
1711
|
+
example: "call_sdk('closeView', [])"
|
|
1712
|
+
}
|
|
1713
|
+
];
|
|
1714
|
+
const SIGNATURE_MAP = new Map(SIGNATURES.map((s) => [s.name, s]));
|
|
1715
|
+
/** 세션 내 passthrough 경고를 한 번만 emit하기 위한 Set */
|
|
1716
|
+
const _warnedPassthrough = /* @__PURE__ */ new Set();
|
|
1717
|
+
/**
|
|
1718
|
+
* 메서드 이름으로 시그니처를 조회한다.
|
|
1719
|
+
* 등록된 메서드이면 `SdkSignature`를 반환하고, 미등록이면 `undefined`.
|
|
1720
|
+
*/
|
|
1721
|
+
function lookupSignature(name) {
|
|
1722
|
+
return SIGNATURE_MAP.get(name);
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* 미등록 메서드에 대해 stderr에 passthrough 경고를 1회 출력한다.
|
|
1726
|
+
* 세션 내 동일 메서드 이름은 최초 1회만 출력.
|
|
1727
|
+
*/
|
|
1728
|
+
function warnPassthrough(name) {
|
|
1729
|
+
if (_warnedPassthrough.has(name)) return;
|
|
1730
|
+
_warnedPassthrough.add(name);
|
|
1731
|
+
process.stderr.write(`[ait-debug] call_sdk: "${name}" 시그니처가 등록되지 않음 — passthrough\n`);
|
|
1732
|
+
}
|
|
1733
|
+
SIGNATURES.map((s) => s.name);
|
|
1734
|
+
//#endregion
|
|
931
1735
|
//#region src/mcp/tools.ts
|
|
932
1736
|
/** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
|
|
933
1737
|
const DEBUG_TOOL_DEFINITIONS = [
|
|
@@ -938,7 +1742,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
938
1742
|
type: "object",
|
|
939
1743
|
properties: {},
|
|
940
1744
|
required: []
|
|
941
|
-
}
|
|
1745
|
+
},
|
|
1746
|
+
availableIn: "both"
|
|
942
1747
|
},
|
|
943
1748
|
{
|
|
944
1749
|
name: "list_network_requests",
|
|
@@ -947,16 +1752,18 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
947
1752
|
type: "object",
|
|
948
1753
|
properties: {},
|
|
949
1754
|
required: []
|
|
950
|
-
}
|
|
1755
|
+
},
|
|
1756
|
+
availableIn: "both"
|
|
951
1757
|
},
|
|
952
1758
|
{
|
|
953
1759
|
name: "list_pages",
|
|
954
|
-
description: "
|
|
1760
|
+
description: "Returns the single active page (at most one) the relay sees attached. When a second page attaches, the previous one is evicted (last-attach wins — single-attach model). The result includes `singleAttachModel: true` so the agent knows the array is always 0 or 1 entries. Also returns whether the cloudflared tunnel is up and the public wss relay URL. Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from that target — useful to detect stale entries when the phone app backgrounded). The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint to re-attach. Call this first to confirm a page is attached before reading console/network.",
|
|
955
1761
|
inputSchema: {
|
|
956
1762
|
type: "object",
|
|
957
1763
|
properties: {},
|
|
958
1764
|
required: []
|
|
959
|
-
}
|
|
1765
|
+
},
|
|
1766
|
+
availableIn: "both"
|
|
960
1767
|
},
|
|
961
1768
|
{
|
|
962
1769
|
name: "build_attach_url",
|
|
@@ -978,7 +1785,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
978
1785
|
}
|
|
979
1786
|
},
|
|
980
1787
|
required: ["scheme_url"]
|
|
981
|
-
}
|
|
1788
|
+
},
|
|
1789
|
+
availableIn: "relay"
|
|
982
1790
|
},
|
|
983
1791
|
{
|
|
984
1792
|
name: "get_dom_document",
|
|
@@ -987,7 +1795,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
987
1795
|
type: "object",
|
|
988
1796
|
properties: {},
|
|
989
1797
|
required: []
|
|
990
|
-
}
|
|
1798
|
+
},
|
|
1799
|
+
availableIn: "both"
|
|
991
1800
|
},
|
|
992
1801
|
{
|
|
993
1802
|
name: "take_snapshot",
|
|
@@ -996,7 +1805,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
996
1805
|
type: "object",
|
|
997
1806
|
properties: {},
|
|
998
1807
|
required: []
|
|
999
|
-
}
|
|
1808
|
+
},
|
|
1809
|
+
availableIn: "both"
|
|
1000
1810
|
},
|
|
1001
1811
|
{
|
|
1002
1812
|
name: "take_screenshot",
|
|
@@ -1005,16 +1815,18 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1005
1815
|
type: "object",
|
|
1006
1816
|
properties: {},
|
|
1007
1817
|
required: []
|
|
1008
|
-
}
|
|
1818
|
+
},
|
|
1819
|
+
availableIn: "both"
|
|
1009
1820
|
},
|
|
1010
1821
|
{
|
|
1011
1822
|
name: "measure_safe_area",
|
|
1012
|
-
description: "Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. Read-only — does not modify page state. Use in a relay session (phone attached) to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires
|
|
1823
|
+
description: "Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. Read-only — does not modify page state. Tier C per RFC #277: the same Runtime.evaluate probe runs in both `mock` (devtools panel page with window.__ait state) and `relay` (real-device WebView with window.__sdk). The result includes a `source: \"mock\" | \"relay\"` field so consumers can identify provenance without inspecting payload values. Use in a relay session (phone attached) to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires a page to be attached — call list_pages first.",
|
|
1013
1824
|
inputSchema: {
|
|
1014
1825
|
type: "object",
|
|
1015
1826
|
properties: {},
|
|
1016
1827
|
required: []
|
|
1017
|
-
}
|
|
1828
|
+
},
|
|
1829
|
+
availableIn: "both"
|
|
1018
1830
|
},
|
|
1019
1831
|
{
|
|
1020
1832
|
name: "evaluate",
|
|
@@ -1026,11 +1838,25 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1026
1838
|
description: "JavaScript expression to evaluate in the page context."
|
|
1027
1839
|
} },
|
|
1028
1840
|
required: ["expression"]
|
|
1029
|
-
}
|
|
1841
|
+
},
|
|
1842
|
+
availableIn: "both"
|
|
1843
|
+
},
|
|
1844
|
+
{
|
|
1845
|
+
name: "list_exceptions",
|
|
1846
|
+
description: "Lists JS-level exceptions captured via `Runtime.exceptionThrown` from the relay attached page. Includes timestamp, exception text, source URL/line, and stack trace. Use to root-cause SDK throws that may precede a Toss app crash (#265 / #267). The buffer holds up to 50 most recent exceptions and survives target replaced/crashed/destroyed events so an exception just before a crash is preserved. Returns up to 50 most recent by default.",
|
|
1847
|
+
inputSchema: {
|
|
1848
|
+
type: "object",
|
|
1849
|
+
properties: { limit: {
|
|
1850
|
+
type: "number",
|
|
1851
|
+
description: "Maximum number of exceptions to return (default 50, max 50)."
|
|
1852
|
+
} },
|
|
1853
|
+
required: []
|
|
1854
|
+
},
|
|
1855
|
+
availableIn: "both"
|
|
1030
1856
|
},
|
|
1031
1857
|
{
|
|
1032
1858
|
name: "call_sdk",
|
|
1033
|
-
description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 2/3 (real device relay) this hits the real SDK; on env 1 (local mock) it hits the mock SDK. Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle)
|
|
1859
|
+
description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 2/3 (real device relay) this hits the real SDK; on env 1 (local mock) it hits the mock SDK. Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle).\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
|
|
1034
1860
|
inputSchema: {
|
|
1035
1861
|
type: "object",
|
|
1036
1862
|
properties: {
|
|
@@ -1045,7 +1871,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1045
1871
|
}
|
|
1046
1872
|
},
|
|
1047
1873
|
required: ["name"]
|
|
1048
|
-
}
|
|
1874
|
+
},
|
|
1875
|
+
availableIn: "both"
|
|
1049
1876
|
},
|
|
1050
1877
|
{
|
|
1051
1878
|
name: "AIT.getSdkCallHistory",
|
|
@@ -1054,7 +1881,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1054
1881
|
type: "object",
|
|
1055
1882
|
properties: {},
|
|
1056
1883
|
required: []
|
|
1057
|
-
}
|
|
1884
|
+
},
|
|
1885
|
+
availableIn: "both"
|
|
1058
1886
|
},
|
|
1059
1887
|
{
|
|
1060
1888
|
name: "AIT.getMockState",
|
|
@@ -1063,7 +1891,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1063
1891
|
type: "object",
|
|
1064
1892
|
properties: {},
|
|
1065
1893
|
required: []
|
|
1066
|
-
}
|
|
1894
|
+
},
|
|
1895
|
+
availableIn: "both"
|
|
1067
1896
|
},
|
|
1068
1897
|
{
|
|
1069
1898
|
name: "AIT.getOperationalEnvironment",
|
|
@@ -1072,7 +1901,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1072
1901
|
type: "object",
|
|
1073
1902
|
properties: {},
|
|
1074
1903
|
required: []
|
|
1075
|
-
}
|
|
1904
|
+
},
|
|
1905
|
+
availableIn: "both"
|
|
1076
1906
|
}
|
|
1077
1907
|
];
|
|
1078
1908
|
const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
|
|
@@ -1080,6 +1910,34 @@ function isDebugToolName(name) {
|
|
|
1080
1910
|
return DEBUG_TOOL_NAMES.has(name);
|
|
1081
1911
|
}
|
|
1082
1912
|
/**
|
|
1913
|
+
* Returns the `ToolAvailability` declared on a registered debug tool, or
|
|
1914
|
+
* `undefined` when the name is not a known debug tool. Used by the tool
|
|
1915
|
+
* registry to filter `tools/list` by current env and by the call handler to
|
|
1916
|
+
* reject env-mismatch invocations.
|
|
1917
|
+
*/
|
|
1918
|
+
function getToolAvailability(name) {
|
|
1919
|
+
for (const t of DEBUG_TOOL_DEFINITIONS) if (t.name === name) return t.availableIn;
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Returns true when the named tool is available in the given environment.
|
|
1923
|
+
* Unknown tools return `false` — callers should reject them as unknown rather
|
|
1924
|
+
* than as env-mismatched.
|
|
1925
|
+
*/
|
|
1926
|
+
function isToolAvailableIn(name, env) {
|
|
1927
|
+
const availability = getToolAvailability(name);
|
|
1928
|
+
if (availability === void 0) return false;
|
|
1929
|
+
if (availability === "both") return true;
|
|
1930
|
+
return availability === env;
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`
|
|
1934
|
+
* matches the given env. Pure — preserves order; both Tier C ("both") and the
|
|
1935
|
+
* matching single-env tier pass through.
|
|
1936
|
+
*/
|
|
1937
|
+
function filterToolsByEnvironment(tools, env) {
|
|
1938
|
+
return tools.filter((t) => t.availableIn === "both" || t.availableIn === env);
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1083
1941
|
* Tool names that are available before any page attaches (bootstrap tier).
|
|
1084
1942
|
*
|
|
1085
1943
|
* `build_attach_url` — pure URL synthesis, no attach needed.
|
|
@@ -1133,10 +1991,58 @@ function listNetworkRequests(connection) {
|
|
|
1133
1991
|
};
|
|
1134
1992
|
});
|
|
1135
1993
|
}
|
|
1994
|
+
/** Formats a single CDP call frame into `at fn (url:line:col)`. */
|
|
1995
|
+
function formatCallFrame(frame) {
|
|
1996
|
+
return `at ${frame.functionName || "(anonymous)"} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`;
|
|
1997
|
+
}
|
|
1998
|
+
/** Normalizes a raw `Runtime.exceptionThrown` event into a `BufferedException`. */
|
|
1999
|
+
function normalizeException(event) {
|
|
2000
|
+
const { timestamp, exceptionDetails } = event;
|
|
2001
|
+
const frames = exceptionDetails.stackTrace?.callFrames;
|
|
2002
|
+
const stack = frames && frames.length > 0 ? frames.map(formatCallFrame).join("\n") : void 0;
|
|
2003
|
+
const exceptionText = exceptionDetails.exception?.description ?? void 0;
|
|
2004
|
+
const result = {
|
|
2005
|
+
timestamp,
|
|
2006
|
+
text: exceptionDetails.text,
|
|
2007
|
+
raw: event
|
|
2008
|
+
};
|
|
2009
|
+
if (exceptionDetails.url !== void 0) result.url = exceptionDetails.url;
|
|
2010
|
+
if (exceptionDetails.lineNumber !== void 0) result.lineNumber = exceptionDetails.lineNumber;
|
|
2011
|
+
if (exceptionDetails.columnNumber !== void 0) result.columnNumber = exceptionDetails.columnNumber;
|
|
2012
|
+
if (exceptionText !== void 0) result.exceptionText = exceptionText;
|
|
2013
|
+
if (stack !== void 0) result.stack = stack;
|
|
2014
|
+
return result;
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* Returns the most recent buffered `Runtime.exceptionThrown` events, normalized.
|
|
2018
|
+
* Oldest-first; limited to `limit` entries (default 50, max 50).
|
|
2019
|
+
*/
|
|
2020
|
+
function listExceptions(connection, limit = 50) {
|
|
2021
|
+
const cap = Math.min(Math.max(1, limit), 50);
|
|
2022
|
+
const events = connection.getBufferedEvents("Runtime.exceptionThrown");
|
|
2023
|
+
return (events.length > cap ? events.slice(events.length - cap) : events).map((e) => normalizeException(e));
|
|
2024
|
+
}
|
|
2025
|
+
function isCrashAware(conn) {
|
|
2026
|
+
return typeof conn.getLastCrashDetectedAt === "function" && typeof conn.getTargetLastSeenAt === "function";
|
|
2027
|
+
}
|
|
1136
2028
|
function listPages(connection, tunnel) {
|
|
2029
|
+
const pages = connection.listTargets().map((t) => {
|
|
2030
|
+
const lastSeenMs = isCrashAware(connection) ? connection.getTargetLastSeenAt(t.id) : null;
|
|
2031
|
+
return {
|
|
2032
|
+
id: t.id,
|
|
2033
|
+
title: t.title,
|
|
2034
|
+
url: t.url,
|
|
2035
|
+
lastSeenAt: lastSeenMs !== null ? new Date(lastSeenMs).toISOString() : null
|
|
2036
|
+
};
|
|
2037
|
+
});
|
|
2038
|
+
const crashMs = isCrashAware(connection) ? connection.getLastCrashDetectedAt() : null;
|
|
2039
|
+
const crashDetectedAt = crashMs !== null ? new Date(crashMs).toISOString() : null;
|
|
1137
2040
|
return {
|
|
1138
|
-
pages
|
|
1139
|
-
tunnel
|
|
2041
|
+
pages,
|
|
2042
|
+
tunnel,
|
|
2043
|
+
crashDetectedAt,
|
|
2044
|
+
crashWarning: crashDetectedAt ? `[ait-debug] page crash 감지됨 — 새 attach 필요 (관측 시각: ${crashDetectedAt})` : null,
|
|
2045
|
+
singleAttachModel: true
|
|
1140
2046
|
};
|
|
1141
2047
|
}
|
|
1142
2048
|
/**
|
|
@@ -1332,11 +2238,13 @@ async function takeScreenshot(connection) {
|
|
|
1332
2238
|
* The JS probe injected via `Runtime.evaluate`. It reads:
|
|
1333
2239
|
* 1. `env(safe-area-inset-*)` via a temporary element with padding set to
|
|
1334
2240
|
* those CSS env vars, then `getComputedStyle`.
|
|
1335
|
-
* 2.
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
2241
|
+
* 2. SDK insets via a priority chain so the SAME probe works on both relay
|
|
2242
|
+
* (real device) and mock (devtools panel page):
|
|
2243
|
+
* a. `window.__sdk.SafeAreaInsets.get()` — dogfood bundle on real device.
|
|
2244
|
+
* b. `window.__sdk.getSafeAreaInsets()` — dogfood bundle (deprecated).
|
|
2245
|
+
* c. `window.__ait.state.safeAreaInsets` — devtools mock state (mock env).
|
|
2246
|
+
* The probe records `sdkInsetsSource` = `'window.__sdk'` | `'window.__ait'`
|
|
2247
|
+
* | `null`. If all paths fail the result carries `sdkInsetsError`.
|
|
1340
2248
|
* 3. nav bar geometry: the SDK does not expose navBar height as a standalone
|
|
1341
2249
|
* API — `.ait-navbar` DOM height is read as a cross-check, and
|
|
1342
2250
|
* `navBarHeightSource` records where it came from.
|
|
@@ -1344,9 +2252,15 @@ async function takeScreenshot(connection) {
|
|
|
1344
2252
|
*
|
|
1345
2253
|
* Returns a plain JSON-serialisable object so `returnByValue: true` works.
|
|
1346
2254
|
*
|
|
1347
|
-
* NOTE: This expression is evaluated in the page context on the real device
|
|
1348
|
-
* It does not mutate any page state — the
|
|
1349
|
-
* reading. No secret or auth token is read
|
|
2255
|
+
* NOTE: This expression is evaluated in the page context — on the real device
|
|
2256
|
+
* (relay) or on the mock panel page. It does not mutate any page state — the
|
|
2257
|
+
* temporary element is removed after reading. No secret or auth token is read
|
|
2258
|
+
* or returned.
|
|
2259
|
+
*
|
|
2260
|
+
* RFC #277 Tier C parity: the SAME probe string runs in both envs. Mock fidelity
|
|
2261
|
+
* comes from the panel's `applyViewport` / `computeSafeAreaInsets` correctly
|
|
2262
|
+
* setting `window.__ait.state.safeAreaInsets` (#275). When that is correct,
|
|
2263
|
+
* the cssEnv + sdkInsets pair returned here matches the relay's shape.
|
|
1350
2264
|
*/
|
|
1351
2265
|
const SAFE_AREA_PROBE_EXPRESSION = `
|
|
1352
2266
|
(function() {
|
|
@@ -1366,17 +2280,28 @@ const SAFE_AREA_PROBE_EXPRESSION = `
|
|
|
1366
2280
|
};
|
|
1367
2281
|
document.documentElement.removeChild(el);
|
|
1368
2282
|
var sdkInsets = null;
|
|
2283
|
+
var sdkInsetsSource = null;
|
|
1369
2284
|
var sdkInsetsError = undefined;
|
|
1370
2285
|
try {
|
|
1371
2286
|
var sdk = window.__sdk;
|
|
2287
|
+
var ait = window.__ait;
|
|
1372
2288
|
if (sdk && sdk.SafeAreaInsets && typeof sdk.SafeAreaInsets.get === 'function') {
|
|
1373
2289
|
sdkInsets = sdk.SafeAreaInsets.get();
|
|
2290
|
+
sdkInsetsSource = 'window.__sdk';
|
|
1374
2291
|
} else if (sdk && typeof sdk.getSafeAreaInsets === 'function') {
|
|
1375
2292
|
sdkInsets = sdk.getSafeAreaInsets();
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
2293
|
+
sdkInsetsSource = 'window.__sdk';
|
|
2294
|
+
} else if (ait && ait.state && ait.state.safeAreaInsets &&
|
|
2295
|
+
typeof ait.state.safeAreaInsets.top === 'number') {
|
|
2296
|
+
var s = ait.state.safeAreaInsets;
|
|
2297
|
+
sdkInsets = { top: s.top, bottom: s.bottom, left: s.left, right: s.right };
|
|
2298
|
+
sdkInsetsSource = 'window.__ait';
|
|
2299
|
+
} else if (!sdk && !ait) {
|
|
2300
|
+
sdkInsetsError = 'neither window.__sdk (relay) nor window.__ait (mock) available';
|
|
2301
|
+
} else if (sdk) {
|
|
1379
2302
|
sdkInsetsError = 'neither SafeAreaInsets.get nor getSafeAreaInsets found on window.__sdk';
|
|
2303
|
+
} else {
|
|
2304
|
+
sdkInsetsError = 'window.__ait.state.safeAreaInsets is missing or malformed';
|
|
1380
2305
|
}
|
|
1381
2306
|
} catch(e) {
|
|
1382
2307
|
sdkInsetsError = String(e && e.message || e);
|
|
@@ -1393,6 +2318,7 @@ const SAFE_AREA_PROBE_EXPRESSION = `
|
|
|
1393
2318
|
var result = {
|
|
1394
2319
|
cssEnv: cssEnv,
|
|
1395
2320
|
sdkInsets: sdkInsets,
|
|
2321
|
+
sdkInsetsSource: sdkInsetsSource,
|
|
1396
2322
|
navBarHeight: navBarHeight,
|
|
1397
2323
|
navBarHeightSource: navBarHeightSource,
|
|
1398
2324
|
innerWidth: window.innerWidth,
|
|
@@ -1409,9 +2335,11 @@ const SAFE_AREA_PROBE_EXPRESSION = `
|
|
|
1409
2335
|
* The probe returns a JSON string (because `returnByValue:true` with a plain
|
|
1410
2336
|
* object works unreliably across Chii relay versions — stringifying is safer).
|
|
1411
2337
|
*
|
|
2338
|
+
* `source` is supplied by the caller (`measureSafeArea`) from the env SSoT.
|
|
2339
|
+
*
|
|
1412
2340
|
* Throws if the result is missing, contains an exception, or cannot be parsed.
|
|
1413
2341
|
*/
|
|
1414
|
-
function normalizeSafeAreaResult(rawValue) {
|
|
2342
|
+
function normalizeSafeAreaResult(rawValue, source) {
|
|
1415
2343
|
if (typeof rawValue !== "string") throw new Error(`measure_safe_area: probe returned unexpected type "${typeof rawValue}" — expected JSON string`);
|
|
1416
2344
|
let parsed;
|
|
1417
2345
|
try {
|
|
@@ -1440,6 +2368,7 @@ function normalizeSafeAreaResult(rawValue) {
|
|
|
1440
2368
|
left: 0
|
|
1441
2369
|
};
|
|
1442
2370
|
const sdkInsets = requireInsets("sdkInsets");
|
|
2371
|
+
const sdkInsetsSource = obj.sdkInsetsSource === "window.__sdk" || obj.sdkInsetsSource === "window.__ait" ? obj.sdkInsetsSource : null;
|
|
1443
2372
|
const sdkInsetsError = typeof obj.sdkInsetsError === "string" ? obj.sdkInsetsError : void 0;
|
|
1444
2373
|
const navBarHeight = typeof obj.navBarHeight === "number" ? obj.navBarHeight : null;
|
|
1445
2374
|
const navBarHeightSource = typeof obj.navBarHeightSource === "string" ? obj.navBarHeightSource : "not-exposed-by-sdk";
|
|
@@ -1448,8 +2377,10 @@ function normalizeSafeAreaResult(rawValue) {
|
|
|
1448
2377
|
const devicePixelRatio = typeof obj.devicePixelRatio === "number" ? obj.devicePixelRatio : 1;
|
|
1449
2378
|
const userAgent = typeof obj.userAgent === "string" ? obj.userAgent : "";
|
|
1450
2379
|
return {
|
|
2380
|
+
source,
|
|
1451
2381
|
cssEnv,
|
|
1452
2382
|
sdkInsets,
|
|
2383
|
+
sdkInsetsSource,
|
|
1453
2384
|
...sdkInsetsError !== void 0 ? { sdkInsetsError } : {},
|
|
1454
2385
|
navBarHeight,
|
|
1455
2386
|
navBarHeightSource,
|
|
@@ -1463,9 +2394,16 @@ function normalizeSafeAreaResult(rawValue) {
|
|
|
1463
2394
|
* Runs the safe-area probe on the attached page and returns a normalized
|
|
1464
2395
|
* `SafeAreaMeasurement`. Read-only — does not mutate page state.
|
|
1465
2396
|
*
|
|
2397
|
+
* `source` is supplied by the caller from the env detection SSoT (see
|
|
2398
|
+
* `src/mcp/environment.ts`). The same `Runtime.evaluate` call runs in both
|
|
2399
|
+
* envs — the probe expression tries `window.__sdk` first (relay) then
|
|
2400
|
+
* `window.__ait` (mock), so mock fidelity is enforced by the panel's
|
|
2401
|
+
* `applyViewport`/`computeSafeAreaInsets` keeping `__ait.state.safeAreaInsets`
|
|
2402
|
+
* correct (RFC #277 Tier C parity, #275 model).
|
|
2403
|
+
*
|
|
1466
2404
|
* Throws on CDP error, probe exception, or result parse failure.
|
|
1467
2405
|
*/
|
|
1468
|
-
async function measureSafeArea(connection) {
|
|
2406
|
+
async function measureSafeArea(connection, source) {
|
|
1469
2407
|
const result = await connection.send("Runtime.evaluate", {
|
|
1470
2408
|
expression: SAFE_AREA_PROBE_EXPRESSION,
|
|
1471
2409
|
returnByValue: true,
|
|
@@ -1475,7 +2413,7 @@ async function measureSafeArea(connection) {
|
|
|
1475
2413
|
const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
|
|
1476
2414
|
throw new Error(`measure_safe_area: probe threw — ${msg}`);
|
|
1477
2415
|
}
|
|
1478
|
-
return normalizeSafeAreaResult(result.result.value);
|
|
2416
|
+
return normalizeSafeAreaResult(result.result.value, source);
|
|
1479
2417
|
}
|
|
1480
2418
|
/**
|
|
1481
2419
|
* Evaluates an arbitrary JS expression on the attached page via
|
|
@@ -1542,29 +2480,73 @@ function normalizeCallSdkResult(rawValue) {
|
|
|
1542
2480
|
throw new Error("call_sdk: bridge result missing \"ok\" field");
|
|
1543
2481
|
}
|
|
1544
2482
|
/**
|
|
2483
|
+
* Looks up the most recent exception from the buffer that falls within the
|
|
2484
|
+
* triage window [windowStart, windowEnd]. Returns `undefined` if none found.
|
|
2485
|
+
*
|
|
2486
|
+
* The heuristic window is:
|
|
2487
|
+
* - windowStart = callStart - 50ms (catch sync throws before bridge fires)
|
|
2488
|
+
* - windowEnd = callEnd + 200ms (catch async throws resolved soon after)
|
|
2489
|
+
*
|
|
2490
|
+
* Only the most recent exception within the window is returned (the one most
|
|
2491
|
+
* likely to be causally related to the SDK call).
|
|
2492
|
+
*/
|
|
2493
|
+
function findRecentException(connection, windowStart, windowEnd) {
|
|
2494
|
+
const events = connection.getBufferedEvents("Runtime.exceptionThrown");
|
|
2495
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
2496
|
+
const e = events[i];
|
|
2497
|
+
if (e.timestamp >= windowStart && e.timestamp <= windowEnd) return normalizeException(e);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
1545
2501
|
* Calls a dogfood SDK method via `window.__sdkCall` on the attached page.
|
|
1546
2502
|
* NOT read-only — SDK calls may have side effects.
|
|
1547
2503
|
*
|
|
1548
2504
|
* On env 2/3 (real device relay) this hits the real SDK; on env 1 (local
|
|
1549
2505
|
* mock) it hits the mock SDK.
|
|
1550
2506
|
*
|
|
2507
|
+
* 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면
|
|
2508
|
+
* `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).
|
|
2509
|
+
* 미등록 메서드는 passthrough + stderr 경고 1회.
|
|
2510
|
+
*
|
|
1551
2511
|
* Throws on CDP error or result parse failure. Returns `{ok:false, error}`
|
|
1552
|
-
* for bridge-level errors (method not found, SDK threw, bridge absent)
|
|
2512
|
+
* for bridge-level errors (method not found, SDK threw, bridge absent) or
|
|
2513
|
+
* argument schema violations.
|
|
2514
|
+
*
|
|
2515
|
+
* If a `Runtime.exceptionThrown` event was observed within the triage window
|
|
2516
|
+
* [callStart-50ms, callEnd+200ms], the result includes `recentException` for
|
|
2517
|
+
* crash triage. This window is a heuristic — it catches the common case of an
|
|
2518
|
+
* SDK throw immediately before/after the bridge resolves.
|
|
1553
2519
|
*
|
|
1554
2520
|
* SECRET-HANDLING: name, args, and the result value are NOT written to any log.
|
|
1555
2521
|
*/
|
|
1556
2522
|
async function callSdk(connection, name, args) {
|
|
2523
|
+
const signature = lookupSignature(name);
|
|
2524
|
+
if (signature !== void 0) {
|
|
2525
|
+
const validation = signature.validateArgs(args);
|
|
2526
|
+
if (!validation.ok) return {
|
|
2527
|
+
ok: false,
|
|
2528
|
+
error: `call_sdk("${name}") 인자 시그니처 오류.\n받음: ${validation.received}\n기대: ${validation.expected}\n올바른 예시: ${signature.example}`
|
|
2529
|
+
};
|
|
2530
|
+
} else warnPassthrough(name);
|
|
2531
|
+
const callStart = Date.now();
|
|
1557
2532
|
const expression = buildCallSdkExpression(name, args);
|
|
1558
2533
|
const result = await connection.send("Runtime.evaluate", {
|
|
1559
2534
|
expression,
|
|
1560
2535
|
returnByValue: true,
|
|
1561
2536
|
awaitPromise: true
|
|
1562
2537
|
});
|
|
2538
|
+
const callEnd = Date.now();
|
|
1563
2539
|
if (result.exceptionDetails) {
|
|
1564
2540
|
const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
|
|
1565
2541
|
throw new Error(`call_sdk threw: ${msg}`);
|
|
1566
2542
|
}
|
|
1567
|
-
|
|
2543
|
+
const sdkResult = normalizeCallSdkResult(result.result.value);
|
|
2544
|
+
const recentException = findRecentException(connection, callStart - 50, callEnd + 200);
|
|
2545
|
+
if (recentException !== void 0) return {
|
|
2546
|
+
...sdkResult,
|
|
2547
|
+
recentException
|
|
2548
|
+
};
|
|
2549
|
+
return sdkResult;
|
|
1568
2550
|
}
|
|
1569
2551
|
/** Set of tool names served by the AIT source rather than the CDP connection. */
|
|
1570
2552
|
const AIT_TOOL_NAMES = new Set([
|
|
@@ -1846,6 +2828,65 @@ async function printAttachBanner(input) {
|
|
|
1846
2828
|
* Node-only.
|
|
1847
2829
|
*/
|
|
1848
2830
|
/**
|
|
2831
|
+
* Parses `_deploymentId` from the query string of a scheme URL.
|
|
2832
|
+
*
|
|
2833
|
+
* Returns `null` when the param is absent or empty — callers treat that as
|
|
2834
|
+
* "no deploymentId filter; match on presence only" and fall back to the
|
|
2835
|
+
* original `attachedPages.length > 0` condition.
|
|
2836
|
+
*
|
|
2837
|
+
* SECRET-HANDLING: deploymentId is a public identifier and may appear in
|
|
2838
|
+
* debug output. Never confuse it with TOTP secrets or relay tunnel URLs.
|
|
2839
|
+
*/
|
|
2840
|
+
function extractDeploymentId(schemeUrl) {
|
|
2841
|
+
try {
|
|
2842
|
+
const qIndex = schemeUrl.indexOf("?");
|
|
2843
|
+
if (qIndex === -1) return null;
|
|
2844
|
+
const id = new URLSearchParams(schemeUrl.slice(qIndex + 1)).get("_deploymentId");
|
|
2845
|
+
return id && id.length > 0 ? id : null;
|
|
2846
|
+
} catch {
|
|
2847
|
+
return null;
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Waits for the first target matching `filterFn` to attach, using the
|
|
2852
|
+
* event-driven `waitForFirstTarget()` on `ChiiCdpConnection` instances, or
|
|
2853
|
+
* falling back to a polling loop for generic `CdpConnection` fakes (tests).
|
|
2854
|
+
*
|
|
2855
|
+
* This eliminates the polling-only race that previously caused `wait_for_attach`
|
|
2856
|
+
* to resolve before the relay had observed the first inbound CDP message from
|
|
2857
|
+
* the phone.
|
|
2858
|
+
*
|
|
2859
|
+
* @param connection - The CDP connection (production or fake).
|
|
2860
|
+
* @param filterFn - Resolves when this predicate is satisfied.
|
|
2861
|
+
* @param timeoutMs - Maximum wait time in ms.
|
|
2862
|
+
* @param pollIntervalMs - Fallback poll interval for non-ChiiCdpConnection.
|
|
2863
|
+
*/
|
|
2864
|
+
function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs = 1e3) {
|
|
2865
|
+
if (connection instanceof ChiiCdpConnection) return connection.waitForFirstTarget(filterFn, timeoutMs, pollIntervalMs);
|
|
2866
|
+
return new Promise((resolve, reject) => {
|
|
2867
|
+
const deadline = Date.now() + timeoutMs;
|
|
2868
|
+
let settled = false;
|
|
2869
|
+
const poll = setInterval(() => {
|
|
2870
|
+
const targets = connection.listTargets();
|
|
2871
|
+
if (filterFn(targets)) {
|
|
2872
|
+
settled = true;
|
|
2873
|
+
clearInterval(poll);
|
|
2874
|
+
resolve(targets);
|
|
2875
|
+
} else if (Date.now() >= deadline) {
|
|
2876
|
+
settled = true;
|
|
2877
|
+
clearInterval(poll);
|
|
2878
|
+
reject(/* @__PURE__ */ new Error(`waitForAttachWithEvents: 타임아웃 (${timeoutMs}ms)`));
|
|
2879
|
+
}
|
|
2880
|
+
}, pollIntervalMs);
|
|
2881
|
+
const targets = connection.listTargets();
|
|
2882
|
+
if (!settled && filterFn(targets)) {
|
|
2883
|
+
settled = true;
|
|
2884
|
+
clearInterval(poll);
|
|
2885
|
+
resolve(targets);
|
|
2886
|
+
}
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
1849
2890
|
* Builds the debug-mode MCP server around an injected CDP connection + AIT
|
|
1850
2891
|
* source + tunnel status getter. Pure wiring — does not start a relay or
|
|
1851
2892
|
* tunnel, which is what makes the tool surface unit-testable.
|
|
@@ -1858,13 +2899,18 @@ async function printAttachBanner(input) {
|
|
|
1858
2899
|
* naturally via `enableDomains`). The tier only controls visibility.
|
|
1859
2900
|
*/
|
|
1860
2901
|
function createDebugServer(deps) {
|
|
1861
|
-
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer } = deps;
|
|
2902
|
+
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep } = deps;
|
|
2903
|
+
const resolveEnvironment = getEnvDep ?? (() => getEnvironment({ connection }));
|
|
2904
|
+
const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({ connection }));
|
|
1862
2905
|
const server = new Server({
|
|
1863
2906
|
name: "ait-debug",
|
|
1864
|
-
version: "0.1.
|
|
2907
|
+
version: "0.1.43"
|
|
1865
2908
|
}, { capabilities: { tools: { listChanged: true } } });
|
|
1866
2909
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
1867
|
-
|
|
2910
|
+
const env = resolveEnvironment();
|
|
2911
|
+
const attached = connection.listTargets().length > 0;
|
|
2912
|
+
const envFiltered = filterToolsByEnvironment(DEBUG_TOOL_DEFINITIONS, env);
|
|
2913
|
+
return { tools: attached ? envFiltered.map((tool) => ({ ...tool })) : envFiltered.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
|
|
1868
2914
|
});
|
|
1869
2915
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1870
2916
|
const name = request.params.name;
|
|
@@ -1875,6 +2921,18 @@ function createDebugServer(deps) {
|
|
|
1875
2921
|
}],
|
|
1876
2922
|
isError: true
|
|
1877
2923
|
};
|
|
2924
|
+
const env = resolveEnvironment();
|
|
2925
|
+
if (!isToolAvailableIn(name, env)) {
|
|
2926
|
+
const reason = `tool ${name} is available only in ${getToolAvailability(name)}. Current environment is ${env} (${resolveEnvironmentReason()}).`;
|
|
2927
|
+
process.stderr.write(`[ait-debug] tier-filter rejected ${name}: ${reason}\n`);
|
|
2928
|
+
return {
|
|
2929
|
+
content: [{
|
|
2930
|
+
type: "text",
|
|
2931
|
+
text: reason
|
|
2932
|
+
}],
|
|
2933
|
+
isError: true
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
1878
2936
|
if (isAitToolName(name)) try {
|
|
1879
2937
|
await connection.enableDomains();
|
|
1880
2938
|
switch (name) {
|
|
@@ -1897,6 +2955,20 @@ function createDebugServer(deps) {
|
|
|
1897
2955
|
};
|
|
1898
2956
|
const waitForAttach = request.params.arguments?.wait_for_attach === true;
|
|
1899
2957
|
const openInBrowser = request.params.arguments?.open_in_browser !== false;
|
|
2958
|
+
const deploymentId = extractDeploymentId(schemeUrl);
|
|
2959
|
+
if (!deploymentId) process.stderr.write("[ait-debug] build_attach_url: no _deploymentId in scheme_url; matching on presence only\n");
|
|
2960
|
+
/** Returns true when the page list satisfies the attach condition. */
|
|
2961
|
+
const isMatchingPage = (pages) => {
|
|
2962
|
+
if (pages.length === 0) return false;
|
|
2963
|
+
if (deploymentId === null) return true;
|
|
2964
|
+
return pages.some((p) => p.url.includes(deploymentId));
|
|
2965
|
+
};
|
|
2966
|
+
/** Builds a timeout error message with diagnostic context. */
|
|
2967
|
+
const buildTimeoutError = (baseText, timeoutSec, observed) => {
|
|
2968
|
+
const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
|
|
2969
|
+
const observedNote = observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : "";
|
|
2970
|
+
return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
|
|
2971
|
+
};
|
|
1900
2972
|
try {
|
|
1901
2973
|
const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
|
|
1902
2974
|
const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
|
|
@@ -1909,22 +2981,19 @@ function createDebugServer(deps) {
|
|
|
1909
2981
|
type: "text",
|
|
1910
2982
|
text: shortText
|
|
1911
2983
|
}] };
|
|
1912
|
-
const POLL_INTERVAL_MS = 1e3;
|
|
1913
|
-
const TIMEOUT_MS = waitForAttachTimeoutMs;
|
|
1914
|
-
const deadline = Date.now() + TIMEOUT_MS;
|
|
1915
2984
|
let attachedPages = [];
|
|
1916
|
-
|
|
2985
|
+
try {
|
|
2986
|
+
attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
|
|
2987
|
+
} catch {
|
|
1917
2988
|
attachedPages = connection.listTargets();
|
|
1918
|
-
|
|
1919
|
-
|
|
2989
|
+
return {
|
|
2990
|
+
content: [{
|
|
2991
|
+
type: "text",
|
|
2992
|
+
text: buildTimeoutError(shortText, waitForAttachTimeoutMs / 1e3, attachedPages)
|
|
2993
|
+
}],
|
|
2994
|
+
isError: true
|
|
2995
|
+
};
|
|
1920
2996
|
}
|
|
1921
|
-
if (attachedPages.length === 0) return {
|
|
1922
|
-
content: [{
|
|
1923
|
-
type: "text",
|
|
1924
|
-
text: `${shortText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
|
|
1925
|
-
}],
|
|
1926
|
-
isError: true
|
|
1927
|
-
};
|
|
1928
2997
|
const pagesResult = listPages(connection, getTunnelStatus());
|
|
1929
2998
|
return { content: [{
|
|
1930
2999
|
type: "text",
|
|
@@ -1942,22 +3011,19 @@ function createDebugServer(deps) {
|
|
|
1942
3011
|
type: "text",
|
|
1943
3012
|
text: baseText
|
|
1944
3013
|
}] };
|
|
1945
|
-
const POLL_INTERVAL_MS_FB = 1e3;
|
|
1946
|
-
const TIMEOUT_MS_FB = waitForAttachTimeoutMs;
|
|
1947
|
-
const deadline2 = Date.now() + TIMEOUT_MS_FB;
|
|
1948
3014
|
let attachedPagesFb = [];
|
|
1949
|
-
|
|
3015
|
+
try {
|
|
3016
|
+
attachedPagesFb = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
|
|
3017
|
+
} catch {
|
|
1950
3018
|
attachedPagesFb = connection.listTargets();
|
|
1951
|
-
|
|
1952
|
-
|
|
3019
|
+
return {
|
|
3020
|
+
content: [{
|
|
3021
|
+
type: "text",
|
|
3022
|
+
text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPagesFb)
|
|
3023
|
+
}],
|
|
3024
|
+
isError: true
|
|
3025
|
+
};
|
|
1953
3026
|
}
|
|
1954
|
-
if (attachedPagesFb.length === 0) return {
|
|
1955
|
-
content: [{
|
|
1956
|
-
type: "text",
|
|
1957
|
-
text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS_FB / 1e3}s — call list_pages to retry.`
|
|
1958
|
-
}],
|
|
1959
|
-
isError: true
|
|
1960
|
-
};
|
|
1961
3027
|
const pagesResultFb = listPages(connection, getTunnelStatus());
|
|
1962
3028
|
return { content: [{
|
|
1963
3029
|
type: "text",
|
|
@@ -1973,22 +3039,19 @@ function createDebugServer(deps) {
|
|
|
1973
3039
|
type: "text",
|
|
1974
3040
|
text: baseText
|
|
1975
3041
|
}] };
|
|
1976
|
-
const POLL_INTERVAL_MS = 1e3;
|
|
1977
|
-
const TIMEOUT_MS = waitForAttachTimeoutMs;
|
|
1978
|
-
const deadline = Date.now() + TIMEOUT_MS;
|
|
1979
3042
|
let attachedPages = [];
|
|
1980
|
-
|
|
3043
|
+
try {
|
|
3044
|
+
attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
|
|
3045
|
+
} catch {
|
|
1981
3046
|
attachedPages = connection.listTargets();
|
|
1982
|
-
|
|
1983
|
-
|
|
3047
|
+
return {
|
|
3048
|
+
content: [{
|
|
3049
|
+
type: "text",
|
|
3050
|
+
text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPages)
|
|
3051
|
+
}],
|
|
3052
|
+
isError: true
|
|
3053
|
+
};
|
|
1984
3054
|
}
|
|
1985
|
-
if (attachedPages.length === 0) return {
|
|
1986
|
-
content: [{
|
|
1987
|
-
type: "text",
|
|
1988
|
-
text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
|
|
1989
|
-
}],
|
|
1990
|
-
isError: true
|
|
1991
|
-
};
|
|
1992
3055
|
const pagesResult = listPages(connection, getTunnelStatus());
|
|
1993
3056
|
return { content: [{
|
|
1994
3057
|
type: "text",
|
|
@@ -2002,7 +3065,12 @@ function createDebugServer(deps) {
|
|
|
2002
3065
|
await connection.enableDomains();
|
|
2003
3066
|
} catch (err) {
|
|
2004
3067
|
const message = err instanceof Error ? err.message : String(err);
|
|
2005
|
-
if (name === "list_pages")
|
|
3068
|
+
if (name === "list_pages") {
|
|
3069
|
+
if (connection instanceof ChiiCdpConnection) try {
|
|
3070
|
+
await connection.refreshTargets();
|
|
3071
|
+
} catch {}
|
|
3072
|
+
return jsonResult$1(listPages(connection, getTunnelStatus()));
|
|
3073
|
+
}
|
|
2006
3074
|
return {
|
|
2007
3075
|
content: [{
|
|
2008
3076
|
type: "text",
|
|
@@ -2014,8 +3082,16 @@ function createDebugServer(deps) {
|
|
|
2014
3082
|
try {
|
|
2015
3083
|
switch (name) {
|
|
2016
3084
|
case "list_console_messages": return jsonResult$1(listConsoleMessages(connection));
|
|
3085
|
+
case "list_exceptions": {
|
|
3086
|
+
const rawLimit = request.params.arguments?.limit;
|
|
3087
|
+
return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
|
|
3088
|
+
}
|
|
2017
3089
|
case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
|
|
2018
|
-
case "list_pages":
|
|
3090
|
+
case "list_pages":
|
|
3091
|
+
if (connection instanceof ChiiCdpConnection) try {
|
|
3092
|
+
await connection.refreshTargets();
|
|
3093
|
+
} catch {}
|
|
3094
|
+
return jsonResult$1(listPages(connection, getTunnelStatus()));
|
|
2019
3095
|
case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
|
|
2020
3096
|
case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
|
|
2021
3097
|
case "take_screenshot": {
|
|
@@ -2026,7 +3102,7 @@ function createDebugServer(deps) {
|
|
|
2026
3102
|
mimeType: shot.mimeType
|
|
2027
3103
|
}] };
|
|
2028
3104
|
}
|
|
2029
|
-
case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection));
|
|
3105
|
+
case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection, resolveEnvironment()));
|
|
2030
3106
|
case "evaluate": {
|
|
2031
3107
|
const expression = request.params.arguments?.expression;
|
|
2032
3108
|
if (typeof expression !== "string" || expression === "") return {
|
|
@@ -2073,11 +3149,22 @@ function unknownTool(name) {
|
|
|
2073
3149
|
isError: true
|
|
2074
3150
|
};
|
|
2075
3151
|
}
|
|
3152
|
+
/**
|
|
3153
|
+
* Detects whether an error is a relay/websocket disconnect error.
|
|
3154
|
+
* These are distinguished from "no page attached yet" errors because they
|
|
3155
|
+
* require enableDomains() to be called again (re-establish the websocket),
|
|
3156
|
+
* not just waiting for a target to appear.
|
|
3157
|
+
*/
|
|
3158
|
+
function isDisconnectError(err) {
|
|
3159
|
+
if (!(err instanceof Error)) return false;
|
|
3160
|
+
const msg = err.message;
|
|
3161
|
+
return msg.includes("relay에 연결되어 있지 않습니다") || msg.includes("relay WebSocket") || msg.includes("replaced-by-new-attach") || msg.includes("Chii relay connection closed");
|
|
3162
|
+
}
|
|
2076
3163
|
function errorResult(err, name) {
|
|
2077
3164
|
return {
|
|
2078
3165
|
content: [{
|
|
2079
3166
|
type: "text",
|
|
2080
|
-
text: `${name} failed: ${err instanceof Error ? err.message : String(err)}\nCall list_pages to confirm a mini-app has attached over the relay
|
|
3167
|
+
text: `${name} failed: ${err instanceof Error ? err.message : String(err)}${isDisconnectError(err) ? "\n\nrelay 연결이 끊겼습니다. list_pages → enableDomains() 재호출로 재연결하세요. 폰이 백그라운드로 내려갔거나 미니앱이 종료됐을 수 있습니다." : "\nCall list_pages to confirm a mini-app has attached over the relay."}`
|
|
2081
3168
|
}],
|
|
2082
3169
|
isError: true
|
|
2083
3170
|
};
|
|
@@ -2091,19 +3178,28 @@ function errorResult(err, name) {
|
|
|
2091
3178
|
* `server.sendToolListChanged()` exactly once — on the first transition — then
|
|
2092
3179
|
* clears itself. Shutdown calls `stop()` to clear the interval.
|
|
2093
3180
|
*
|
|
3181
|
+
* `onFirstAttach` is called once on the 0→N transition (or immediately when
|
|
3182
|
+
* already attached). Use this to trigger side-effects such as auto-opening
|
|
3183
|
+
* Chrome DevTools (issue #282). The callback is optional; omitting it preserves
|
|
3184
|
+
* the previous behaviour exactly.
|
|
3185
|
+
*
|
|
2094
3186
|
* SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
|
|
2095
3187
|
* Only an attach-detected stderr line is emitted (no target details).
|
|
2096
3188
|
*
|
|
2097
3189
|
* @returns `stop` — call this during shutdown to clear the interval.
|
|
2098
3190
|
*/
|
|
2099
|
-
function startAttachWatcher(connection, server, intervalMs = 1e3) {
|
|
3191
|
+
function startAttachWatcher(connection, server, intervalMs = 1e3, onFirstAttach) {
|
|
2100
3192
|
let wasAttached = connection.listTargets().length > 0;
|
|
2101
|
-
if (wasAttached)
|
|
3193
|
+
if (wasAttached) {
|
|
3194
|
+
server.sendToolListChanged();
|
|
3195
|
+
onFirstAttach?.();
|
|
3196
|
+
}
|
|
2102
3197
|
const handle = setInterval(() => {
|
|
2103
3198
|
const isAttached = connection.listTargets().length > 0;
|
|
2104
3199
|
if (!wasAttached && isAttached) {
|
|
2105
3200
|
wasAttached = true;
|
|
2106
3201
|
server.sendToolListChanged();
|
|
3202
|
+
onFirstAttach?.();
|
|
2107
3203
|
clearInterval(handle);
|
|
2108
3204
|
}
|
|
2109
3205
|
}, intervalMs);
|
|
@@ -2143,6 +3239,7 @@ function buildRelayVerifyAuth() {
|
|
|
2143
3239
|
* 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
|
|
2144
3240
|
*/
|
|
2145
3241
|
async function runDebugServer(options = {}) {
|
|
3242
|
+
const lockHandle = acquireLock();
|
|
2146
3243
|
const relayPort = options.relayPort ?? 0;
|
|
2147
3244
|
const verifyAuth = buildRelayVerifyAuth();
|
|
2148
3245
|
const totpEnabled = verifyAuth !== void 0;
|
|
@@ -2162,6 +3259,7 @@ async function runDebugServer(options = {}) {
|
|
|
2162
3259
|
up: true,
|
|
2163
3260
|
wssUrl: t.wssUrl
|
|
2164
3261
|
};
|
|
3262
|
+
lockHandle.updateWssUrl(t.wssUrl);
|
|
2165
3263
|
return printAttachBanner({
|
|
2166
3264
|
wssUrl: t.wssUrl,
|
|
2167
3265
|
totpEnabled
|
|
@@ -2174,12 +3272,13 @@ async function runDebugServer(options = {}) {
|
|
|
2174
3272
|
const connection = new ChiiCdpConnection({ relayBaseUrl: relay.baseUrl });
|
|
2175
3273
|
const aitSource = new ChiiAitSource(connection);
|
|
2176
3274
|
let qrServer;
|
|
2177
|
-
|
|
2178
|
-
qrServer =
|
|
2179
|
-
}
|
|
3275
|
+
try {
|
|
3276
|
+
qrServer = await startQrHttpServer();
|
|
3277
|
+
} catch (err) {
|
|
2180
3278
|
const message = err instanceof Error ? err.message : String(err);
|
|
2181
3279
|
process.stderr.write(`[ait-debug] QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}\n`);
|
|
2182
|
-
}
|
|
3280
|
+
}
|
|
3281
|
+
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
2183
3282
|
const server = createDebugServer({
|
|
2184
3283
|
connection,
|
|
2185
3284
|
aitSource,
|
|
@@ -2200,6 +3299,7 @@ async function runDebugServer(options = {}) {
|
|
|
2200
3299
|
relay.close();
|
|
2201
3300
|
server.close();
|
|
2202
3301
|
qrServer?.close();
|
|
3302
|
+
lockHandle.release();
|
|
2203
3303
|
};
|
|
2204
3304
|
process.once("SIGINT", shutdown);
|
|
2205
3305
|
process.once("SIGTERM", shutdown);
|
|
@@ -2209,6 +3309,7 @@ async function runDebugServer(options = {}) {
|
|
|
2209
3309
|
closed = true;
|
|
2210
3310
|
attachWatcher?.stop();
|
|
2211
3311
|
tunnel?.stop();
|
|
3312
|
+
lockHandle.release();
|
|
2212
3313
|
}
|
|
2213
3314
|
});
|
|
2214
3315
|
process.on("uncaughtException", (err) => {
|
|
@@ -2222,7 +3323,9 @@ async function runDebugServer(options = {}) {
|
|
|
2222
3323
|
process.exit(1);
|
|
2223
3324
|
});
|
|
2224
3325
|
await server.connect(transport);
|
|
2225
|
-
attachWatcher = startAttachWatcher(connection, server)
|
|
3326
|
+
attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
|
|
3327
|
+
devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({ connection }));
|
|
3328
|
+
});
|
|
2226
3329
|
}
|
|
2227
3330
|
/**
|
|
2228
3331
|
* Boots the local-browser debug stack and serves it over stdio:
|
|
@@ -2243,6 +3346,7 @@ async function runDebugServer(options = {}) {
|
|
|
2243
3346
|
* expected and noted in the PR as an explicit out-of-scope follow-up.
|
|
2244
3347
|
*/
|
|
2245
3348
|
async function runLocalDebugServer(options = {}) {
|
|
3349
|
+
const lockHandle = acquireLock();
|
|
2246
3350
|
const chromium = await launchChromium({
|
|
2247
3351
|
port: options.cdpPort ?? 0,
|
|
2248
3352
|
devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
|
|
@@ -2269,6 +3373,7 @@ async function runLocalDebugServer(options = {}) {
|
|
|
2269
3373
|
connection.close();
|
|
2270
3374
|
chromium.stop();
|
|
2271
3375
|
server.close();
|
|
3376
|
+
lockHandle.release();
|
|
2272
3377
|
};
|
|
2273
3378
|
process.once("SIGINT", shutdown);
|
|
2274
3379
|
process.once("SIGTERM", shutdown);
|
|
@@ -2278,6 +3383,7 @@ async function runLocalDebugServer(options = {}) {
|
|
|
2278
3383
|
closed = true;
|
|
2279
3384
|
attachWatcher?.stop();
|
|
2280
3385
|
chromium.stop();
|
|
3386
|
+
lockHandle.release();
|
|
2281
3387
|
}
|
|
2282
3388
|
});
|
|
2283
3389
|
process.on("uncaughtException", (err) => {
|
|
@@ -2366,7 +3472,13 @@ var HttpAitSource = class {
|
|
|
2366
3472
|
* }
|
|
2367
3473
|
* }
|
|
2368
3474
|
*/
|
|
2369
|
-
/**
|
|
3475
|
+
/**
|
|
3476
|
+
* Tool descriptors served by the dev-mode server.
|
|
3477
|
+
*
|
|
3478
|
+
* All dev-mode tools are Tier C (both envs) per RFC #277 — the dev-mode server
|
|
3479
|
+
* itself is the mock-side embodiment of those Tier C tools. `availableIn` is
|
|
3480
|
+
* declared so the surface stays consistent with the debug-mode registry.
|
|
3481
|
+
*/
|
|
2370
3482
|
const DEV_TOOL_DEFINITIONS = [
|
|
2371
3483
|
{
|
|
2372
3484
|
name: "AIT.getMockState",
|
|
@@ -2375,7 +3487,8 @@ const DEV_TOOL_DEFINITIONS = [
|
|
|
2375
3487
|
type: "object",
|
|
2376
3488
|
properties: {},
|
|
2377
3489
|
required: []
|
|
2378
|
-
}
|
|
3490
|
+
},
|
|
3491
|
+
availableIn: "both"
|
|
2379
3492
|
},
|
|
2380
3493
|
{
|
|
2381
3494
|
name: "AIT.getOperationalEnvironment",
|
|
@@ -2384,7 +3497,8 @@ const DEV_TOOL_DEFINITIONS = [
|
|
|
2384
3497
|
type: "object",
|
|
2385
3498
|
properties: {},
|
|
2386
3499
|
required: []
|
|
2387
|
-
}
|
|
3500
|
+
},
|
|
3501
|
+
availableIn: "both"
|
|
2388
3502
|
},
|
|
2389
3503
|
{
|
|
2390
3504
|
name: "AIT.getSdkCallHistory",
|
|
@@ -2393,7 +3507,8 @@ const DEV_TOOL_DEFINITIONS = [
|
|
|
2393
3507
|
type: "object",
|
|
2394
3508
|
properties: {},
|
|
2395
3509
|
required: []
|
|
2396
|
-
}
|
|
3510
|
+
},
|
|
3511
|
+
availableIn: "both"
|
|
2397
3512
|
},
|
|
2398
3513
|
{
|
|
2399
3514
|
name: "devtools_get_mock_state",
|
|
@@ -2402,7 +3517,8 @@ const DEV_TOOL_DEFINITIONS = [
|
|
|
2402
3517
|
type: "object",
|
|
2403
3518
|
properties: {},
|
|
2404
3519
|
required: []
|
|
2405
|
-
}
|
|
3520
|
+
},
|
|
3521
|
+
availableIn: "both"
|
|
2406
3522
|
}
|
|
2407
3523
|
];
|
|
2408
3524
|
const DEV_TOOL_NAMES = new Set(DEV_TOOL_DEFINITIONS.map((t) => t.name));
|
|
@@ -2412,7 +3528,7 @@ function createDevServer(deps = {}) {
|
|
|
2412
3528
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
2413
3529
|
const server = new Server({
|
|
2414
3530
|
name: "ait-devtools",
|
|
2415
|
-
version: "0.1.
|
|
3531
|
+
version: "0.1.43"
|
|
2416
3532
|
}, { capabilities: { tools: {} } });
|
|
2417
3533
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
2418
3534
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|