@ait-co/devtools 0.1.40 → 0.1.41
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 +530 -33
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +155 -5
- package/dist/mcp/server.js.map +1 -1
- package/dist/panel/index.js +2 -2
- package/package.json +3 -2
package/dist/mcp/cli.js
CHANGED
|
@@ -15,23 +15,23 @@ import { platform } from "node:os";
|
|
|
15
15
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
16
16
|
import { Tunnel, bin, install } from "cloudflared";
|
|
17
17
|
//#region src/mcp/ait-chii-source.ts
|
|
18
|
-
function isObject$
|
|
18
|
+
function isObject$4(value) {
|
|
19
19
|
return typeof value === "object" && value !== null;
|
|
20
20
|
}
|
|
21
21
|
/** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
|
|
22
22
|
function asSdkCallHistory(raw) {
|
|
23
|
-
if (isObject$
|
|
23
|
+
if (isObject$4(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
|
|
24
24
|
return { calls: [] };
|
|
25
25
|
}
|
|
26
26
|
/** Narrows an `AIT.getMockState` response to an opaque record. */
|
|
27
27
|
function asMockState(raw) {
|
|
28
|
-
return isObject$
|
|
28
|
+
return isObject$4(raw) ? raw : {};
|
|
29
29
|
}
|
|
30
30
|
/** Narrows an `AIT.getOperationalEnvironment` response. */
|
|
31
31
|
function asOperationalEnvironment(raw) {
|
|
32
32
|
return {
|
|
33
|
-
environment: isObject$
|
|
34
|
-
sdkVersion: isObject$
|
|
33
|
+
environment: isObject$4(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
|
|
34
|
+
sdkVersion: isObject$4(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
var ChiiAitSource = class {
|
|
@@ -65,7 +65,7 @@ var ChiiAitSource = class {
|
|
|
65
65
|
*/
|
|
66
66
|
/** Max events retained per domain ring buffer. */
|
|
67
67
|
const DEFAULT_BUFFER_SIZE$1 = 500;
|
|
68
|
-
function isObject$
|
|
68
|
+
function isObject$3(value) {
|
|
69
69
|
return typeof value === "object" && value !== null;
|
|
70
70
|
}
|
|
71
71
|
function parseInbound$1(raw) {
|
|
@@ -75,13 +75,13 @@ function parseInbound$1(raw) {
|
|
|
75
75
|
} catch {
|
|
76
76
|
return null;
|
|
77
77
|
}
|
|
78
|
-
if (!isObject$
|
|
78
|
+
if (!isObject$3(parsed)) return null;
|
|
79
79
|
const message = {};
|
|
80
80
|
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
81
81
|
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
82
82
|
if ("params" in parsed) message.params = parsed.params;
|
|
83
83
|
if ("result" in parsed) message.result = parsed.result;
|
|
84
|
-
if (isObject$
|
|
84
|
+
if (isObject$3(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
85
85
|
return message;
|
|
86
86
|
}
|
|
87
87
|
const PHASE_1_EVENTS$1 = [
|
|
@@ -90,25 +90,67 @@ const PHASE_1_EVENTS$1 = [
|
|
|
90
90
|
"Network.responseReceived"
|
|
91
91
|
];
|
|
92
92
|
/**
|
|
93
|
+
* Ring buffer size for `Runtime.exceptionThrown`.
|
|
94
|
+
*
|
|
95
|
+
* Exceptions are rarer than console messages but each is heavier (stack
|
|
96
|
+
* trace). 50 is generous enough to cover a crash scenario while keeping
|
|
97
|
+
* memory bounded.
|
|
98
|
+
*
|
|
99
|
+
* **Lifecycle note**: the exception buffer intentionally survives `replaced` /
|
|
100
|
+
* `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
|
|
101
|
+
* transitions. Rationale: an exception fired just before a crash is exactly
|
|
102
|
+
* the signal we want to preserve for root-cause analysis. The buffer
|
|
103
|
+
* represents "exceptions seen in this MCP session", not "exceptions in the
|
|
104
|
+
* current page".
|
|
105
|
+
*/
|
|
106
|
+
const EXCEPTION_BUFFER_SIZE = 50;
|
|
107
|
+
/** Default per-command timeout if neither option nor env var is set. */
|
|
108
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
|
|
109
|
+
/**
|
|
93
110
|
* Production CDP connection. Polls the relay for the first attached target,
|
|
94
111
|
* opens a client websocket to it, enables Phase 1 domains, and buffers events.
|
|
95
112
|
*/
|
|
96
113
|
var ChiiCdpConnection = class {
|
|
97
114
|
relayBaseUrl;
|
|
98
115
|
bufferSize;
|
|
116
|
+
commandTimeoutMs;
|
|
99
117
|
emitter = new EventEmitter();
|
|
100
118
|
buffers = /* @__PURE__ */ new Map();
|
|
101
119
|
targets = /* @__PURE__ */ new Map();
|
|
102
120
|
ws = null;
|
|
121
|
+
connectionState = "idle";
|
|
103
122
|
nextCommandId = 1;
|
|
123
|
+
/**
|
|
124
|
+
* The single active target id under the single-attach model.
|
|
125
|
+
* Updated by `refreshTargets()` whenever a non-null target is present.
|
|
126
|
+
* Used to detect a new (different) target attach and evict the previous one.
|
|
127
|
+
*/
|
|
128
|
+
activeTargetId = null;
|
|
104
129
|
/** In-flight enableDomains() promise — concurrent callers share it. */
|
|
105
130
|
enablingPromise = null;
|
|
106
131
|
/** Pending request→response commands keyed by CDP message id. */
|
|
107
132
|
pending = /* @__PURE__ */ new Map();
|
|
133
|
+
/**
|
|
134
|
+
* Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
|
|
135
|
+
* or `null` if no crash has been detected since the last `enableDomains()`.
|
|
136
|
+
*/
|
|
137
|
+
lastCrashDetectedAt = null;
|
|
138
|
+
/**
|
|
139
|
+
* Per-target last-seen timestamp (ms since epoch). Updated on any inbound
|
|
140
|
+
* CDP message carrying data from a target. Keyed by target id.
|
|
141
|
+
*/
|
|
142
|
+
targetLastSeenAt = /* @__PURE__ */ new Map();
|
|
143
|
+
/** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
|
|
144
|
+
heartbeatHandle = null;
|
|
145
|
+
/** Lifecycle event listeners (crash / destroyed / detached). */
|
|
146
|
+
lifecycleListeners = [];
|
|
108
147
|
constructor(options) {
|
|
109
148
|
this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
|
|
110
149
|
this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE$1;
|
|
150
|
+
const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
|
|
151
|
+
this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
111
152
|
for (const event of PHASE_1_EVENTS$1) this.buffers.set(event, []);
|
|
153
|
+
this.buffers.set("Runtime.exceptionThrown", []);
|
|
112
154
|
this.emitter.setMaxListeners(0);
|
|
113
155
|
}
|
|
114
156
|
/** Refresh the attached-target list from the relay's `GET /targets`. */
|
|
@@ -116,22 +158,57 @@ var ChiiCdpConnection = class {
|
|
|
116
158
|
const res = await fetch(`${this.relayBaseUrl}/targets`);
|
|
117
159
|
if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
|
|
118
160
|
const body = await res.json();
|
|
119
|
-
const list = isObject$
|
|
161
|
+
const list = isObject$3(body) && Array.isArray(body.targets) ? body.targets : [];
|
|
162
|
+
let newestTargetId = null;
|
|
163
|
+
for (const item of list) {
|
|
164
|
+
if (!isObject$3(item) || typeof item.id !== "string") continue;
|
|
165
|
+
newestTargetId = item.id;
|
|
166
|
+
}
|
|
167
|
+
if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
|
|
168
|
+
const prevId = this.activeTargetId;
|
|
169
|
+
process.stderr.write(`[ait-debug] 이전 page 세션 종료 — 새 attach로 교체 (prev=${prevId})\n`);
|
|
170
|
+
this.evictTarget(prevId);
|
|
171
|
+
}
|
|
120
172
|
this.targets.clear();
|
|
121
173
|
for (const item of list) {
|
|
122
|
-
if (!isObject$
|
|
174
|
+
if (!isObject$3(item) || typeof item.id !== "string") continue;
|
|
175
|
+
if (item.id !== newestTargetId) continue;
|
|
123
176
|
this.targets.set(item.id, {
|
|
124
177
|
id: item.id,
|
|
125
178
|
title: typeof item.title === "string" ? item.title : "",
|
|
126
179
|
url: typeof item.url === "string" ? item.url : ""
|
|
127
180
|
});
|
|
128
181
|
}
|
|
182
|
+
if (newestTargetId !== null) this.activeTargetId = newestTargetId;
|
|
183
|
+
else this.activeTargetId = null;
|
|
129
184
|
return [...this.targets.values()];
|
|
130
185
|
}
|
|
131
186
|
listTargets() {
|
|
132
187
|
return [...this.targets.values()];
|
|
133
188
|
}
|
|
134
189
|
/**
|
|
190
|
+
* Timestamp (ms since epoch) of the most recent crash/destroy/detach event
|
|
191
|
+
* detected since the last `enableDomains()` call, or `null` if none.
|
|
192
|
+
*/
|
|
193
|
+
getLastCrashDetectedAt() {
|
|
194
|
+
return this.lastCrashDetectedAt;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Last-seen timestamp (ms since epoch) for a given target id, or `null` if
|
|
198
|
+
* the target is unknown / no message has been received from it yet.
|
|
199
|
+
*/
|
|
200
|
+
getTargetLastSeenAt(targetId) {
|
|
201
|
+
return this.targetLastSeenAt.get(targetId) ?? null;
|
|
202
|
+
}
|
|
203
|
+
/** Subscribe to target lifecycle events (crash / destroyed / detached). */
|
|
204
|
+
onLifecycle(listener) {
|
|
205
|
+
this.lifecycleListeners.push(listener);
|
|
206
|
+
return () => {
|
|
207
|
+
const idx = this.lifecycleListeners.indexOf(listener);
|
|
208
|
+
if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
135
212
|
* Connect a client websocket to the first attached target and enable Phase 1
|
|
136
213
|
* domains. Resolves once the socket is open and enable commands are sent.
|
|
137
214
|
*/
|
|
@@ -152,11 +229,19 @@ var ChiiCdpConnection = class {
|
|
|
152
229
|
ws.once("open", () => resolve());
|
|
153
230
|
ws.once("error", (err) => reject(err));
|
|
154
231
|
});
|
|
232
|
+
this.lastCrashDetectedAt = null;
|
|
233
|
+
this.targetLastSeenAt.clear();
|
|
234
|
+
this.connectionState = "connected";
|
|
155
235
|
ws.on("message", (data) => this.handleMessage(data.toString()));
|
|
236
|
+
ws.on("close", () => this.handleDisconnect("relay WebSocket 연결이 끊겼습니다"));
|
|
237
|
+
ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
|
|
156
238
|
this.sendFireAndForget("Runtime.enable");
|
|
157
239
|
this.sendFireAndForget("Network.enable");
|
|
158
240
|
this.sendFireAndForget("DOM.enable");
|
|
159
241
|
this.sendFireAndForget("Page.enable");
|
|
242
|
+
this.sendFireAndForget("Inspector.enable");
|
|
243
|
+
this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
|
|
244
|
+
this.startHeartbeat(target.id);
|
|
160
245
|
}
|
|
161
246
|
/** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
|
|
162
247
|
sendFireAndForget(method, params = {}) {
|
|
@@ -179,15 +264,35 @@ var ChiiCdpConnection = class {
|
|
|
179
264
|
* Issue an arbitrary request→response command over the relay and resolve with
|
|
180
265
|
* its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
|
|
181
266
|
* `AIT.*` methods, forwarded over the same Chii channel) build on this.
|
|
267
|
+
*
|
|
268
|
+
* Rejects immediately if the connection is disconnected (fail-fast — no
|
|
269
|
+
* auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
|
|
270
|
+
* reattach.
|
|
271
|
+
*
|
|
272
|
+
* Times out after `commandTimeoutMs` (default 30s, env
|
|
273
|
+
* `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
|
|
274
|
+
* and the promise rejects with a descriptive Korean error.
|
|
182
275
|
*/
|
|
183
276
|
sendCommand(method, params = {}) {
|
|
277
|
+
if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
|
|
184
278
|
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
279
|
const id = this.nextCommandId++;
|
|
186
280
|
const ws = this.ws;
|
|
281
|
+
const timeoutMs = this.commandTimeoutMs;
|
|
187
282
|
return new Promise((resolve, reject) => {
|
|
283
|
+
const handle = setTimeout(() => {
|
|
284
|
+
this.pending.delete(id);
|
|
285
|
+
reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
|
|
286
|
+
}, timeoutMs);
|
|
188
287
|
this.pending.set(id, {
|
|
189
|
-
resolve
|
|
190
|
-
|
|
288
|
+
resolve: (v) => {
|
|
289
|
+
clearTimeout(handle);
|
|
290
|
+
resolve(v);
|
|
291
|
+
},
|
|
292
|
+
reject: (e) => {
|
|
293
|
+
clearTimeout(handle);
|
|
294
|
+
reject(e);
|
|
295
|
+
}
|
|
191
296
|
});
|
|
192
297
|
ws.send(JSON.stringify({
|
|
193
298
|
id,
|
|
@@ -196,6 +301,117 @@ var ChiiCdpConnection = class {
|
|
|
196
301
|
}));
|
|
197
302
|
});
|
|
198
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Called on WebSocket `close` or `error` after a successful connection.
|
|
306
|
+
* Rejects all pending commands and marks the connection as disconnected so
|
|
307
|
+
* subsequent `sendCommand` calls fail fast (no auto-reconnect).
|
|
308
|
+
*/
|
|
309
|
+
handleDisconnect(reason) {
|
|
310
|
+
if (this.connectionState === "disconnected") return;
|
|
311
|
+
this.connectionState = "disconnected";
|
|
312
|
+
this.ws = null;
|
|
313
|
+
this.stopHeartbeat();
|
|
314
|
+
const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
|
|
315
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
316
|
+
this.pending.clear();
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Evict a previously active target under the single-attach model.
|
|
320
|
+
* Rejects pending commands with a 'replaced-by-new-attach' reason and emits
|
|
321
|
+
* a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
|
|
322
|
+
* targetId. The caller is responsible for rebuilding the targets map afterwards.
|
|
323
|
+
*
|
|
324
|
+
* The error message uses 'replaced-by-new-attach' so test assertions can match it.
|
|
325
|
+
*/
|
|
326
|
+
evictTarget(targetId) {
|
|
327
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
328
|
+
this.targets.delete(targetId);
|
|
329
|
+
this.targetLastSeenAt.delete(targetId);
|
|
330
|
+
const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
|
|
331
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
332
|
+
this.pending.clear();
|
|
333
|
+
const event = {
|
|
334
|
+
kind: "replaced",
|
|
335
|
+
targetId,
|
|
336
|
+
detectedAt
|
|
337
|
+
};
|
|
338
|
+
for (const listener of this.lifecycleListeners) try {
|
|
339
|
+
listener(event);
|
|
340
|
+
} catch {}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Handle a page-level crash or target destruction event.
|
|
344
|
+
* Removes the target from the in-memory map, rejects all pending commands,
|
|
345
|
+
* and emits a lifecycle event.
|
|
346
|
+
*
|
|
347
|
+
* @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
|
|
348
|
+
* @param targetId - The target ID from the event params (may be null for
|
|
349
|
+
* Inspector.targetCrashed which has no targetId in the params).
|
|
350
|
+
*/
|
|
351
|
+
handleTargetGone(kind, targetId) {
|
|
352
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
353
|
+
this.lastCrashDetectedAt = Date.now();
|
|
354
|
+
if (targetId !== null) {
|
|
355
|
+
this.targets.delete(targetId);
|
|
356
|
+
this.targetLastSeenAt.delete(targetId);
|
|
357
|
+
if (this.activeTargetId === targetId) this.activeTargetId = null;
|
|
358
|
+
} else {
|
|
359
|
+
this.targets.clear();
|
|
360
|
+
this.targetLastSeenAt.clear();
|
|
361
|
+
this.activeTargetId = null;
|
|
362
|
+
}
|
|
363
|
+
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()로 재연결).`);
|
|
364
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
365
|
+
this.pending.clear();
|
|
366
|
+
const event = {
|
|
367
|
+
kind,
|
|
368
|
+
targetId,
|
|
369
|
+
detectedAt
|
|
370
|
+
};
|
|
371
|
+
for (const listener of this.lifecycleListeners) try {
|
|
372
|
+
listener(event);
|
|
373
|
+
} catch {}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Start the optional CDP heartbeat loop.
|
|
377
|
+
*
|
|
378
|
+
* When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
|
|
379
|
+
* we send `Runtime.evaluate({expression: '1'})` to each active target. If
|
|
380
|
+
* the command times out (2 s hard deadline) or errors, we treat the target
|
|
381
|
+
* as dead and call `handleTargetGone`.
|
|
382
|
+
*
|
|
383
|
+
* This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
|
|
384
|
+
* even when the phone app has crashed, so the ws-level disconnect (#252) won't
|
|
385
|
+
* fire. The heartbeat catches this gap.
|
|
386
|
+
*
|
|
387
|
+
* Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
|
|
388
|
+
*/
|
|
389
|
+
startHeartbeat(initialTargetId) {
|
|
390
|
+
this.stopHeartbeat();
|
|
391
|
+
const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
|
|
392
|
+
if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
|
|
393
|
+
const PING_TIMEOUT_MS = 2e3;
|
|
394
|
+
this.heartbeatHandle = setInterval(() => {
|
|
395
|
+
const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
|
|
396
|
+
for (const targetId of targetIds) {
|
|
397
|
+
const pingPromise = this.sendCommand("Runtime.evaluate", {
|
|
398
|
+
expression: "1",
|
|
399
|
+
returnByValue: true,
|
|
400
|
+
timeout: PING_TIMEOUT_MS
|
|
401
|
+
});
|
|
402
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
|
|
403
|
+
Promise.race([pingPromise, timeoutPromise]).catch(() => {
|
|
404
|
+
if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}, envMs);
|
|
408
|
+
}
|
|
409
|
+
stopHeartbeat() {
|
|
410
|
+
if (this.heartbeatHandle !== null) {
|
|
411
|
+
clearInterval(this.heartbeatHandle);
|
|
412
|
+
this.heartbeatHandle = null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
199
415
|
handleMessage(raw) {
|
|
200
416
|
const message = parseInbound$1(raw);
|
|
201
417
|
if (!message) return;
|
|
@@ -206,13 +422,30 @@ var ChiiCdpConnection = class {
|
|
|
206
422
|
else waiter.resolve(message.result);
|
|
207
423
|
return;
|
|
208
424
|
}
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
for (const targetId of this.targets.keys()) this.targetLastSeenAt.set(targetId, now);
|
|
209
427
|
if (typeof message.method !== "string") return;
|
|
428
|
+
if (message.method === "Inspector.targetCrashed") {
|
|
429
|
+
this.handleTargetGone("crashed", null);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (message.method === "Target.targetDestroyed") {
|
|
433
|
+
const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
|
|
434
|
+
this.handleTargetGone("destroyed", targetId);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (message.method === "Target.detachedFromTarget") {
|
|
438
|
+
const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
|
|
439
|
+
this.handleTargetGone("detached", targetId);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
210
442
|
if (!this.buffers.has(message.method)) return;
|
|
211
443
|
const event = message.method;
|
|
212
444
|
const buffer = this.buffers.get(event);
|
|
213
445
|
if (!buffer) return;
|
|
214
446
|
buffer.push(message.params);
|
|
215
|
-
|
|
447
|
+
const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
|
|
448
|
+
if (buffer.length > cap) buffer.shift();
|
|
216
449
|
this.emitter.emit(event, message.params);
|
|
217
450
|
}
|
|
218
451
|
getBufferedEvents(event) {
|
|
@@ -224,10 +457,10 @@ var ChiiCdpConnection = class {
|
|
|
224
457
|
}
|
|
225
458
|
/** Close the relay client websocket and reject any in-flight commands. */
|
|
226
459
|
close() {
|
|
227
|
-
this.ws
|
|
228
|
-
this.
|
|
229
|
-
|
|
230
|
-
|
|
460
|
+
const ws = this.ws;
|
|
461
|
+
this.stopHeartbeat();
|
|
462
|
+
this.handleDisconnect("Chii relay connection closed");
|
|
463
|
+
ws?.close();
|
|
231
464
|
}
|
|
232
465
|
};
|
|
233
466
|
//#endregion
|
|
@@ -335,7 +568,7 @@ async function startChiiRelay(options = {}) {
|
|
|
335
568
|
*/
|
|
336
569
|
/** Max events retained per domain ring buffer. */
|
|
337
570
|
const DEFAULT_BUFFER_SIZE = 500;
|
|
338
|
-
function isObject$
|
|
571
|
+
function isObject$2(value) {
|
|
339
572
|
return typeof value === "object" && value !== null;
|
|
340
573
|
}
|
|
341
574
|
function parseInbound(raw) {
|
|
@@ -345,13 +578,13 @@ function parseInbound(raw) {
|
|
|
345
578
|
} catch {
|
|
346
579
|
return null;
|
|
347
580
|
}
|
|
348
|
-
if (!isObject$
|
|
581
|
+
if (!isObject$2(parsed)) return null;
|
|
349
582
|
const message = {};
|
|
350
583
|
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
351
584
|
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
352
585
|
if ("params" in parsed) message.params = parsed.params;
|
|
353
586
|
if ("result" in parsed) message.result = parsed.result;
|
|
354
|
-
if (isObject$
|
|
587
|
+
if (isObject$2(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
355
588
|
return message;
|
|
356
589
|
}
|
|
357
590
|
const PHASE_1_EVENTS = [
|
|
@@ -404,7 +637,7 @@ var LocalCdpConnection = class {
|
|
|
404
637
|
this.targets.clear();
|
|
405
638
|
let selected = null;
|
|
406
639
|
for (const item of list) {
|
|
407
|
-
if (!isObject$
|
|
640
|
+
if (!isObject$2(item) || typeof item.id !== "string") continue;
|
|
408
641
|
const cdpTarget = {
|
|
409
642
|
id: item.id,
|
|
410
643
|
title: typeof item.title === "string" ? item.title : "",
|
|
@@ -928,6 +1161,162 @@ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
|
|
|
928
1161
|
return `${base}?${query}${hash}`;
|
|
929
1162
|
}
|
|
930
1163
|
//#endregion
|
|
1164
|
+
//#region src/mcp/sdk-signatures.ts
|
|
1165
|
+
function isObject$1(v) {
|
|
1166
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1167
|
+
}
|
|
1168
|
+
function describeArgs(args) {
|
|
1169
|
+
try {
|
|
1170
|
+
return JSON.stringify(args);
|
|
1171
|
+
} catch {
|
|
1172
|
+
return String(args);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* 등록된 메서드 목록.
|
|
1177
|
+
*
|
|
1178
|
+
* 시그니처 출처 확인:
|
|
1179
|
+
* - 함수가 인자를 받지 않으면 args[0] 없음 → `args.length === 0`을 체크하지 않고
|
|
1180
|
+
* 그냥 통과시킨다(args 무시하는 stub가 많아서 noArgs 체크가 noise).
|
|
1181
|
+
* - 실 SDK 시그니처는 `src/__typecheck.ts`의 `Assert<Mock, Original>` 줄로 보장.
|
|
1182
|
+
*/
|
|
1183
|
+
const SIGNATURES = [
|
|
1184
|
+
{
|
|
1185
|
+
name: "setDeviceOrientation",
|
|
1186
|
+
validateArgs(args) {
|
|
1187
|
+
const arg = args[0];
|
|
1188
|
+
if (!isObject$1(arg)) return {
|
|
1189
|
+
ok: false,
|
|
1190
|
+
expected: "{ type: 'portrait' | 'landscape' }",
|
|
1191
|
+
received: describeArgs(args)
|
|
1192
|
+
};
|
|
1193
|
+
const type = arg.type;
|
|
1194
|
+
if (type !== "portrait" && type !== "landscape") return {
|
|
1195
|
+
ok: false,
|
|
1196
|
+
expected: "{ type: 'portrait' | 'landscape' }",
|
|
1197
|
+
received: describeArgs(args)
|
|
1198
|
+
};
|
|
1199
|
+
return { ok: true };
|
|
1200
|
+
},
|
|
1201
|
+
example: "call_sdk('setDeviceOrientation', [{ type: 'landscape' }])"
|
|
1202
|
+
},
|
|
1203
|
+
{
|
|
1204
|
+
name: "setIosSwipeGestureEnabled",
|
|
1205
|
+
validateArgs(args) {
|
|
1206
|
+
const arg = args[0];
|
|
1207
|
+
if (!isObject$1(arg) || typeof arg.isEnabled !== "boolean") return {
|
|
1208
|
+
ok: false,
|
|
1209
|
+
expected: "{ isEnabled: boolean }",
|
|
1210
|
+
received: describeArgs(args)
|
|
1211
|
+
};
|
|
1212
|
+
return { ok: true };
|
|
1213
|
+
},
|
|
1214
|
+
example: "call_sdk('setIosSwipeGestureEnabled', [{ isEnabled: false }])"
|
|
1215
|
+
},
|
|
1216
|
+
{
|
|
1217
|
+
name: "setSecureScreen",
|
|
1218
|
+
validateArgs(args) {
|
|
1219
|
+
const arg = args[0];
|
|
1220
|
+
if (!isObject$1(arg) || typeof arg.enabled !== "boolean") return {
|
|
1221
|
+
ok: false,
|
|
1222
|
+
expected: "{ enabled: boolean }",
|
|
1223
|
+
received: describeArgs(args)
|
|
1224
|
+
};
|
|
1225
|
+
return { ok: true };
|
|
1226
|
+
},
|
|
1227
|
+
example: "call_sdk('setSecureScreen', [{ enabled: true }])"
|
|
1228
|
+
},
|
|
1229
|
+
{
|
|
1230
|
+
name: "setScreenAwakeMode",
|
|
1231
|
+
validateArgs(args) {
|
|
1232
|
+
const arg = args[0];
|
|
1233
|
+
if (!isObject$1(arg) || typeof arg.enabled !== "boolean") return {
|
|
1234
|
+
ok: false,
|
|
1235
|
+
expected: "{ enabled: boolean }",
|
|
1236
|
+
received: describeArgs(args)
|
|
1237
|
+
};
|
|
1238
|
+
return { ok: true };
|
|
1239
|
+
},
|
|
1240
|
+
example: "call_sdk('setScreenAwakeMode', [{ enabled: true }])"
|
|
1241
|
+
},
|
|
1242
|
+
{
|
|
1243
|
+
name: "getOperationalEnvironment",
|
|
1244
|
+
validateArgs(_args) {
|
|
1245
|
+
return { ok: true };
|
|
1246
|
+
},
|
|
1247
|
+
example: "call_sdk('getOperationalEnvironment', [])"
|
|
1248
|
+
},
|
|
1249
|
+
{
|
|
1250
|
+
name: "getPlatformOS",
|
|
1251
|
+
validateArgs(_args) {
|
|
1252
|
+
return { ok: true };
|
|
1253
|
+
},
|
|
1254
|
+
example: "call_sdk('getPlatformOS', [])"
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
name: "getDeviceId",
|
|
1258
|
+
validateArgs(_args) {
|
|
1259
|
+
return { ok: true };
|
|
1260
|
+
},
|
|
1261
|
+
example: "call_sdk('getDeviceId', [])"
|
|
1262
|
+
},
|
|
1263
|
+
{
|
|
1264
|
+
name: "getLocale",
|
|
1265
|
+
validateArgs(_args) {
|
|
1266
|
+
return { ok: true };
|
|
1267
|
+
},
|
|
1268
|
+
example: "call_sdk('getLocale', [])"
|
|
1269
|
+
},
|
|
1270
|
+
{
|
|
1271
|
+
name: "getNetworkStatus",
|
|
1272
|
+
validateArgs(_args) {
|
|
1273
|
+
return { ok: true };
|
|
1274
|
+
},
|
|
1275
|
+
example: "call_sdk('getNetworkStatus', [])"
|
|
1276
|
+
},
|
|
1277
|
+
{
|
|
1278
|
+
name: "getSchemeUri",
|
|
1279
|
+
validateArgs(_args) {
|
|
1280
|
+
return { ok: true };
|
|
1281
|
+
},
|
|
1282
|
+
example: "call_sdk('getSchemeUri', [])"
|
|
1283
|
+
},
|
|
1284
|
+
{
|
|
1285
|
+
name: "requestReview",
|
|
1286
|
+
validateArgs(_args) {
|
|
1287
|
+
return { ok: true };
|
|
1288
|
+
},
|
|
1289
|
+
example: "call_sdk('requestReview', [])"
|
|
1290
|
+
},
|
|
1291
|
+
{
|
|
1292
|
+
name: "closeView",
|
|
1293
|
+
validateArgs(_args) {
|
|
1294
|
+
return { ok: true };
|
|
1295
|
+
},
|
|
1296
|
+
example: "call_sdk('closeView', [])"
|
|
1297
|
+
}
|
|
1298
|
+
];
|
|
1299
|
+
const SIGNATURE_MAP = new Map(SIGNATURES.map((s) => [s.name, s]));
|
|
1300
|
+
/** 세션 내 passthrough 경고를 한 번만 emit하기 위한 Set */
|
|
1301
|
+
const _warnedPassthrough = /* @__PURE__ */ new Set();
|
|
1302
|
+
/**
|
|
1303
|
+
* 메서드 이름으로 시그니처를 조회한다.
|
|
1304
|
+
* 등록된 메서드이면 `SdkSignature`를 반환하고, 미등록이면 `undefined`.
|
|
1305
|
+
*/
|
|
1306
|
+
function lookupSignature(name) {
|
|
1307
|
+
return SIGNATURE_MAP.get(name);
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* 미등록 메서드에 대해 stderr에 passthrough 경고를 1회 출력한다.
|
|
1311
|
+
* 세션 내 동일 메서드 이름은 최초 1회만 출력.
|
|
1312
|
+
*/
|
|
1313
|
+
function warnPassthrough(name) {
|
|
1314
|
+
if (_warnedPassthrough.has(name)) return;
|
|
1315
|
+
_warnedPassthrough.add(name);
|
|
1316
|
+
process.stderr.write(`[ait-debug] call_sdk: "${name}" 시그니처가 등록되지 않음 — passthrough\n`);
|
|
1317
|
+
}
|
|
1318
|
+
SIGNATURES.map((s) => s.name);
|
|
1319
|
+
//#endregion
|
|
931
1320
|
//#region src/mcp/tools.ts
|
|
932
1321
|
/** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
|
|
933
1322
|
const DEBUG_TOOL_DEFINITIONS = [
|
|
@@ -951,7 +1340,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
951
1340
|
},
|
|
952
1341
|
{
|
|
953
1342
|
name: "list_pages",
|
|
954
|
-
description: "
|
|
1343
|
+
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
1344
|
inputSchema: {
|
|
956
1345
|
type: "object",
|
|
957
1346
|
properties: {},
|
|
@@ -1028,9 +1417,21 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1028
1417
|
required: ["expression"]
|
|
1029
1418
|
}
|
|
1030
1419
|
},
|
|
1420
|
+
{
|
|
1421
|
+
name: "list_exceptions",
|
|
1422
|
+
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.",
|
|
1423
|
+
inputSchema: {
|
|
1424
|
+
type: "object",
|
|
1425
|
+
properties: { limit: {
|
|
1426
|
+
type: "number",
|
|
1427
|
+
description: "Maximum number of exceptions to return (default 50, max 50)."
|
|
1428
|
+
} },
|
|
1429
|
+
required: []
|
|
1430
|
+
}
|
|
1431
|
+
},
|
|
1031
1432
|
{
|
|
1032
1433
|
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)
|
|
1434
|
+
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
1435
|
inputSchema: {
|
|
1035
1436
|
type: "object",
|
|
1036
1437
|
properties: {
|
|
@@ -1133,10 +1534,58 @@ function listNetworkRequests(connection) {
|
|
|
1133
1534
|
};
|
|
1134
1535
|
});
|
|
1135
1536
|
}
|
|
1537
|
+
/** Formats a single CDP call frame into `at fn (url:line:col)`. */
|
|
1538
|
+
function formatCallFrame(frame) {
|
|
1539
|
+
return `at ${frame.functionName || "(anonymous)"} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`;
|
|
1540
|
+
}
|
|
1541
|
+
/** Normalizes a raw `Runtime.exceptionThrown` event into a `BufferedException`. */
|
|
1542
|
+
function normalizeException(event) {
|
|
1543
|
+
const { timestamp, exceptionDetails } = event;
|
|
1544
|
+
const frames = exceptionDetails.stackTrace?.callFrames;
|
|
1545
|
+
const stack = frames && frames.length > 0 ? frames.map(formatCallFrame).join("\n") : void 0;
|
|
1546
|
+
const exceptionText = exceptionDetails.exception?.description ?? void 0;
|
|
1547
|
+
const result = {
|
|
1548
|
+
timestamp,
|
|
1549
|
+
text: exceptionDetails.text,
|
|
1550
|
+
raw: event
|
|
1551
|
+
};
|
|
1552
|
+
if (exceptionDetails.url !== void 0) result.url = exceptionDetails.url;
|
|
1553
|
+
if (exceptionDetails.lineNumber !== void 0) result.lineNumber = exceptionDetails.lineNumber;
|
|
1554
|
+
if (exceptionDetails.columnNumber !== void 0) result.columnNumber = exceptionDetails.columnNumber;
|
|
1555
|
+
if (exceptionText !== void 0) result.exceptionText = exceptionText;
|
|
1556
|
+
if (stack !== void 0) result.stack = stack;
|
|
1557
|
+
return result;
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Returns the most recent buffered `Runtime.exceptionThrown` events, normalized.
|
|
1561
|
+
* Oldest-first; limited to `limit` entries (default 50, max 50).
|
|
1562
|
+
*/
|
|
1563
|
+
function listExceptions(connection, limit = 50) {
|
|
1564
|
+
const cap = Math.min(Math.max(1, limit), 50);
|
|
1565
|
+
const events = connection.getBufferedEvents("Runtime.exceptionThrown");
|
|
1566
|
+
return (events.length > cap ? events.slice(events.length - cap) : events).map((e) => normalizeException(e));
|
|
1567
|
+
}
|
|
1568
|
+
function isCrashAware(conn) {
|
|
1569
|
+
return typeof conn.getLastCrashDetectedAt === "function" && typeof conn.getTargetLastSeenAt === "function";
|
|
1570
|
+
}
|
|
1136
1571
|
function listPages(connection, tunnel) {
|
|
1572
|
+
const pages = connection.listTargets().map((t) => {
|
|
1573
|
+
const lastSeenMs = isCrashAware(connection) ? connection.getTargetLastSeenAt(t.id) : null;
|
|
1574
|
+
return {
|
|
1575
|
+
id: t.id,
|
|
1576
|
+
title: t.title,
|
|
1577
|
+
url: t.url,
|
|
1578
|
+
lastSeenAt: lastSeenMs !== null ? new Date(lastSeenMs).toISOString() : null
|
|
1579
|
+
};
|
|
1580
|
+
});
|
|
1581
|
+
const crashMs = isCrashAware(connection) ? connection.getLastCrashDetectedAt() : null;
|
|
1582
|
+
const crashDetectedAt = crashMs !== null ? new Date(crashMs).toISOString() : null;
|
|
1137
1583
|
return {
|
|
1138
|
-
pages
|
|
1139
|
-
tunnel
|
|
1584
|
+
pages,
|
|
1585
|
+
tunnel,
|
|
1586
|
+
crashDetectedAt,
|
|
1587
|
+
crashWarning: crashDetectedAt ? `[ait-debug] page crash 감지됨 — 새 attach 필요 (관측 시각: ${crashDetectedAt})` : null,
|
|
1588
|
+
singleAttachModel: true
|
|
1140
1589
|
};
|
|
1141
1590
|
}
|
|
1142
1591
|
/**
|
|
@@ -1542,29 +1991,73 @@ function normalizeCallSdkResult(rawValue) {
|
|
|
1542
1991
|
throw new Error("call_sdk: bridge result missing \"ok\" field");
|
|
1543
1992
|
}
|
|
1544
1993
|
/**
|
|
1994
|
+
* Looks up the most recent exception from the buffer that falls within the
|
|
1995
|
+
* triage window [windowStart, windowEnd]. Returns `undefined` if none found.
|
|
1996
|
+
*
|
|
1997
|
+
* The heuristic window is:
|
|
1998
|
+
* - windowStart = callStart - 50ms (catch sync throws before bridge fires)
|
|
1999
|
+
* - windowEnd = callEnd + 200ms (catch async throws resolved soon after)
|
|
2000
|
+
*
|
|
2001
|
+
* Only the most recent exception within the window is returned (the one most
|
|
2002
|
+
* likely to be causally related to the SDK call).
|
|
2003
|
+
*/
|
|
2004
|
+
function findRecentException(connection, windowStart, windowEnd) {
|
|
2005
|
+
const events = connection.getBufferedEvents("Runtime.exceptionThrown");
|
|
2006
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
2007
|
+
const e = events[i];
|
|
2008
|
+
if (e.timestamp >= windowStart && e.timestamp <= windowEnd) return normalizeException(e);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
1545
2012
|
* Calls a dogfood SDK method via `window.__sdkCall` on the attached page.
|
|
1546
2013
|
* NOT read-only — SDK calls may have side effects.
|
|
1547
2014
|
*
|
|
1548
2015
|
* On env 2/3 (real device relay) this hits the real SDK; on env 1 (local
|
|
1549
2016
|
* mock) it hits the mock SDK.
|
|
1550
2017
|
*
|
|
2018
|
+
* 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면
|
|
2019
|
+
* `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).
|
|
2020
|
+
* 미등록 메서드는 passthrough + stderr 경고 1회.
|
|
2021
|
+
*
|
|
1551
2022
|
* Throws on CDP error or result parse failure. Returns `{ok:false, error}`
|
|
1552
|
-
* for bridge-level errors (method not found, SDK threw, bridge absent)
|
|
2023
|
+
* for bridge-level errors (method not found, SDK threw, bridge absent) or
|
|
2024
|
+
* argument schema violations.
|
|
2025
|
+
*
|
|
2026
|
+
* If a `Runtime.exceptionThrown` event was observed within the triage window
|
|
2027
|
+
* [callStart-50ms, callEnd+200ms], the result includes `recentException` for
|
|
2028
|
+
* crash triage. This window is a heuristic — it catches the common case of an
|
|
2029
|
+
* SDK throw immediately before/after the bridge resolves.
|
|
1553
2030
|
*
|
|
1554
2031
|
* SECRET-HANDLING: name, args, and the result value are NOT written to any log.
|
|
1555
2032
|
*/
|
|
1556
2033
|
async function callSdk(connection, name, args) {
|
|
2034
|
+
const signature = lookupSignature(name);
|
|
2035
|
+
if (signature !== void 0) {
|
|
2036
|
+
const validation = signature.validateArgs(args);
|
|
2037
|
+
if (!validation.ok) return {
|
|
2038
|
+
ok: false,
|
|
2039
|
+
error: `call_sdk("${name}") 인자 시그니처 오류.\n받음: ${validation.received}\n기대: ${validation.expected}\n올바른 예시: ${signature.example}`
|
|
2040
|
+
};
|
|
2041
|
+
} else warnPassthrough(name);
|
|
2042
|
+
const callStart = Date.now();
|
|
1557
2043
|
const expression = buildCallSdkExpression(name, args);
|
|
1558
2044
|
const result = await connection.send("Runtime.evaluate", {
|
|
1559
2045
|
expression,
|
|
1560
2046
|
returnByValue: true,
|
|
1561
2047
|
awaitPromise: true
|
|
1562
2048
|
});
|
|
2049
|
+
const callEnd = Date.now();
|
|
1563
2050
|
if (result.exceptionDetails) {
|
|
1564
2051
|
const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
|
|
1565
2052
|
throw new Error(`call_sdk threw: ${msg}`);
|
|
1566
2053
|
}
|
|
1567
|
-
|
|
2054
|
+
const sdkResult = normalizeCallSdkResult(result.result.value);
|
|
2055
|
+
const recentException = findRecentException(connection, callStart - 50, callEnd + 200);
|
|
2056
|
+
if (recentException !== void 0) return {
|
|
2057
|
+
...sdkResult,
|
|
2058
|
+
recentException
|
|
2059
|
+
};
|
|
2060
|
+
return sdkResult;
|
|
1568
2061
|
}
|
|
1569
2062
|
/** Set of tool names served by the AIT source rather than the CDP connection. */
|
|
1570
2063
|
const AIT_TOOL_NAMES = new Set([
|
|
@@ -1861,7 +2354,7 @@ function createDebugServer(deps) {
|
|
|
1861
2354
|
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer } = deps;
|
|
1862
2355
|
const server = new Server({
|
|
1863
2356
|
name: "ait-debug",
|
|
1864
|
-
version: "0.1.
|
|
2357
|
+
version: "0.1.41"
|
|
1865
2358
|
}, { capabilities: { tools: { listChanged: true } } });
|
|
1866
2359
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
1867
2360
|
return { tools: connection.listTargets().length > 0 ? DEBUG_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) : DEBUG_TOOL_DEFINITIONS.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
|
|
@@ -2014,6 +2507,10 @@ function createDebugServer(deps) {
|
|
|
2014
2507
|
try {
|
|
2015
2508
|
switch (name) {
|
|
2016
2509
|
case "list_console_messages": return jsonResult$1(listConsoleMessages(connection));
|
|
2510
|
+
case "list_exceptions": {
|
|
2511
|
+
const rawLimit = request.params.arguments?.limit;
|
|
2512
|
+
return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
|
|
2513
|
+
}
|
|
2017
2514
|
case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
|
|
2018
2515
|
case "list_pages": return jsonResult$1(listPages(connection, getTunnelStatus()));
|
|
2019
2516
|
case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
|
|
@@ -2174,12 +2671,12 @@ async function runDebugServer(options = {}) {
|
|
|
2174
2671
|
const connection = new ChiiCdpConnection({ relayBaseUrl: relay.baseUrl });
|
|
2175
2672
|
const aitSource = new ChiiAitSource(connection);
|
|
2176
2673
|
let qrServer;
|
|
2177
|
-
|
|
2178
|
-
qrServer =
|
|
2179
|
-
}
|
|
2674
|
+
try {
|
|
2675
|
+
qrServer = await startQrHttpServer();
|
|
2676
|
+
} catch (err) {
|
|
2180
2677
|
const message = err instanceof Error ? err.message : String(err);
|
|
2181
2678
|
process.stderr.write(`[ait-debug] QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}\n`);
|
|
2182
|
-
}
|
|
2679
|
+
}
|
|
2183
2680
|
const server = createDebugServer({
|
|
2184
2681
|
connection,
|
|
2185
2682
|
aitSource,
|
|
@@ -2412,7 +2909,7 @@ function createDevServer(deps = {}) {
|
|
|
2412
2909
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
2413
2910
|
const server = new Server({
|
|
2414
2911
|
name: "ait-devtools",
|
|
2415
|
-
version: "0.1.
|
|
2912
|
+
version: "0.1.41"
|
|
2416
2913
|
}, { capabilities: { tools: {} } });
|
|
2417
2914
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
2418
2915
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|