@ait-co/devtools 0.1.41 → 0.1.44
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 +85 -0
- package/README.md +85 -0
- package/dist/mcp/cli.js +1355 -207
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +94 -53
- 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 +1 -1
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,9 +11,13 @@ 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
22
|
function isObject$4(value) {
|
|
19
23
|
return typeof value === "object" && value !== null;
|
|
@@ -49,6 +53,98 @@ var ChiiAitSource = class {
|
|
|
49
53
|
}
|
|
50
54
|
};
|
|
51
55
|
//#endregion
|
|
56
|
+
//#region src/mcp/log.ts
|
|
57
|
+
/**
|
|
58
|
+
* Allowed field keys that may pass through to a log line.
|
|
59
|
+
* Unknown keys are dropped. Values are still redact-scanned.
|
|
60
|
+
*/
|
|
61
|
+
const ALLOWED_KEYS = new Set([
|
|
62
|
+
"ts",
|
|
63
|
+
"level",
|
|
64
|
+
"event",
|
|
65
|
+
"msg",
|
|
66
|
+
"port",
|
|
67
|
+
"totpEnabled",
|
|
68
|
+
"env",
|
|
69
|
+
"tool",
|
|
70
|
+
"deploymentId",
|
|
71
|
+
"errorKind",
|
|
72
|
+
"reason",
|
|
73
|
+
"prevTargetId",
|
|
74
|
+
"mode"
|
|
75
|
+
]);
|
|
76
|
+
/**
|
|
77
|
+
* Patterns that match secret values.
|
|
78
|
+
* Match order matters — more-specific patterns first.
|
|
79
|
+
*
|
|
80
|
+
* #268 redact script covers: relay=wss://…, at=<TOTP>, _deploymentId=<uuid>.
|
|
81
|
+
* Here we extend to in-process value-level patterns used in server logs.
|
|
82
|
+
*/
|
|
83
|
+
const SECRET_PATTERNS = [
|
|
84
|
+
/^\d{6}$/,
|
|
85
|
+
/^(aitcc_|AITCC_)/i,
|
|
86
|
+
/^[A-Za-z0-9_-]+=.{4,}/,
|
|
87
|
+
/^wss:\/\//,
|
|
88
|
+
/(?:^|[?&])at=[A-Z0-9]{6}/i
|
|
89
|
+
];
|
|
90
|
+
/**
|
|
91
|
+
* Returns `true` when the string value matches any known-secret pattern.
|
|
92
|
+
* Only string values are tested — numbers/booleans are always safe.
|
|
93
|
+
*/
|
|
94
|
+
function isSecretValue(value) {
|
|
95
|
+
return SECRET_PATTERNS.some((re) => re.test(value));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Redacts a single scalar value.
|
|
99
|
+
* - strings: return "***" if the value matches a secret pattern.
|
|
100
|
+
* - other: return as-is.
|
|
101
|
+
*/
|
|
102
|
+
function redactValue(value) {
|
|
103
|
+
if (typeof value === "string" && isSecretValue(value)) return "***";
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Builds a safe log payload from raw fields.
|
|
108
|
+
*
|
|
109
|
+
* - Only keys in `ALLOWED_KEYS` are included.
|
|
110
|
+
* - String values are scanned for secret patterns and replaced with "***".
|
|
111
|
+
* - `ts` and `level` and `event` are always included (they are injected by the
|
|
112
|
+
* logger functions below, not by callers).
|
|
113
|
+
*/
|
|
114
|
+
function buildPayload(level, event, fields) {
|
|
115
|
+
const out = {
|
|
116
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
117
|
+
level,
|
|
118
|
+
event
|
|
119
|
+
};
|
|
120
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
121
|
+
if (!ALLOWED_KEYS.has(key)) continue;
|
|
122
|
+
if (key === "ts" || key === "level" || key === "event") continue;
|
|
123
|
+
out[key] = redactValue(value);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Writes a single JSON log line to stderr.
|
|
129
|
+
* MCP stdio transport uses stdout; all diagnostics go to stderr.
|
|
130
|
+
*/
|
|
131
|
+
function writeLog(level, event, fields = {}) {
|
|
132
|
+
const payload = buildPayload(level, event, fields);
|
|
133
|
+
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
|
134
|
+
}
|
|
135
|
+
/** Log an informational structured event. */
|
|
136
|
+
function logInfo(event, fields = {}) {
|
|
137
|
+
writeLog("info", event, fields);
|
|
138
|
+
}
|
|
139
|
+
/** Log a warning structured event. */
|
|
140
|
+
function logWarn(event, fields = {}) {
|
|
141
|
+
writeLog("warn", event, fields);
|
|
142
|
+
}
|
|
143
|
+
/** Log an error structured event. */
|
|
144
|
+
function logError(event, fields = {}) {
|
|
145
|
+
writeLog("error", event, fields);
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
52
148
|
//#region src/mcp/chii-connection.ts
|
|
53
149
|
/**
|
|
54
150
|
* Production `CdpConnection` backed by the local Chii relay.
|
|
@@ -62,6 +158,12 @@ var ChiiAitSource = class {
|
|
|
62
158
|
* events in ring buffers the tool layer reads via `getBufferedEvents`.
|
|
63
159
|
*
|
|
64
160
|
* Node-only: imports `ws`. Never bundled into the browser/in-app entries.
|
|
161
|
+
*
|
|
162
|
+
* Attach reliability (#281):
|
|
163
|
+
* `refreshTargets()` emits an internal 'target:attached' event whenever a
|
|
164
|
+
* new target is added to the relay. `waitForFirstTarget()` awaits that event
|
|
165
|
+
* (with a polling-interval fallback) so `build_attach_url wait_for_attach`
|
|
166
|
+
* resolves deterministically rather than racing between polling rounds.
|
|
65
167
|
*/
|
|
66
168
|
/** Max events retained per domain ring buffer. */
|
|
67
169
|
const DEFAULT_BUFFER_SIZE$1 = 500;
|
|
@@ -166,7 +268,7 @@ var ChiiCdpConnection = class {
|
|
|
166
268
|
}
|
|
167
269
|
if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
|
|
168
270
|
const prevId = this.activeTargetId;
|
|
169
|
-
|
|
271
|
+
logInfo("page.detached", { prevTargetId: prevId });
|
|
170
272
|
this.evictTarget(prevId);
|
|
171
273
|
}
|
|
172
274
|
this.targets.clear();
|
|
@@ -181,12 +283,73 @@ var ChiiCdpConnection = class {
|
|
|
181
283
|
}
|
|
182
284
|
if (newestTargetId !== null) this.activeTargetId = newestTargetId;
|
|
183
285
|
else this.activeTargetId = null;
|
|
184
|
-
|
|
286
|
+
const result = [...this.targets.values()];
|
|
287
|
+
if (newestTargetId !== null) this.emitter.emit("target:attached", result);
|
|
288
|
+
return result;
|
|
185
289
|
}
|
|
186
290
|
listTargets() {
|
|
187
291
|
return [...this.targets.values()];
|
|
188
292
|
}
|
|
189
293
|
/**
|
|
294
|
+
* Waits until at least one target matching `filterFn` is attached, then
|
|
295
|
+
* resolves with the full target list at that moment.
|
|
296
|
+
*
|
|
297
|
+
* Resolution happens on whichever comes first:
|
|
298
|
+
* (a) a `'target:attached'` event from `refreshTargets()` (triggered by
|
|
299
|
+
* the /targets poll finding a new target), OR
|
|
300
|
+
* (b) a `'target:attached'` event from `handleMessage()` (triggered by
|
|
301
|
+
* the first inbound CDP message from a target — confirms the relay
|
|
302
|
+
* websocket has data from the phone, not just a target entry in the map).
|
|
303
|
+
*
|
|
304
|
+
* This dual-signal approach eliminates the polling race that previously
|
|
305
|
+
* caused `wait_for_attach` to resolve before the first CDP message arrived.
|
|
306
|
+
*
|
|
307
|
+
* Falls back to checking `listTargets()` every `pollIntervalMs` in case the
|
|
308
|
+
* EventEmitter is missed (defensive belt-and-suspenders).
|
|
309
|
+
*
|
|
310
|
+
* @param filterFn - Predicate that the returned targets must satisfy.
|
|
311
|
+
* @param timeoutMs - Reject after this many ms (default 90 000).
|
|
312
|
+
* @param pollIntervalMs - Fallback poll interval (default 500ms).
|
|
313
|
+
*/
|
|
314
|
+
waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
|
|
315
|
+
const current = this.listTargets();
|
|
316
|
+
if (filterFn(current)) return Promise.resolve(current);
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
let settled = false;
|
|
319
|
+
let pollHandle = null;
|
|
320
|
+
const settle = (targets) => {
|
|
321
|
+
if (settled) return;
|
|
322
|
+
settled = true;
|
|
323
|
+
clearTimeout(timeoutHandle);
|
|
324
|
+
if (pollHandle !== null) {
|
|
325
|
+
clearInterval(pollHandle);
|
|
326
|
+
pollHandle = null;
|
|
327
|
+
}
|
|
328
|
+
this.emitter.off("target:attached", onAttach);
|
|
329
|
+
resolve(targets);
|
|
330
|
+
};
|
|
331
|
+
const onAttach = (targets) => {
|
|
332
|
+
if (filterFn(targets)) settle(targets);
|
|
333
|
+
};
|
|
334
|
+
const timeoutHandle = setTimeout(() => {
|
|
335
|
+
if (settled) return;
|
|
336
|
+
settled = true;
|
|
337
|
+
if (pollHandle !== null) {
|
|
338
|
+
clearInterval(pollHandle);
|
|
339
|
+
pollHandle = null;
|
|
340
|
+
}
|
|
341
|
+
this.emitter.off("target:attached", onAttach);
|
|
342
|
+
reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
|
|
343
|
+
}, timeoutMs);
|
|
344
|
+
this.emitter.on("target:attached", onAttach);
|
|
345
|
+
pollHandle = setInterval(() => {
|
|
346
|
+
this.refreshTargets().then((targets) => {
|
|
347
|
+
if (filterFn(targets)) settle(targets);
|
|
348
|
+
}, () => {});
|
|
349
|
+
}, pollIntervalMs);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
190
353
|
* Timestamp (ms since epoch) of the most recent crash/destroy/detach event
|
|
191
354
|
* detected since the last `enableDomains()` call, or `null` if none.
|
|
192
355
|
*/
|
|
@@ -423,7 +586,12 @@ var ChiiCdpConnection = class {
|
|
|
423
586
|
return;
|
|
424
587
|
}
|
|
425
588
|
const now = Date.now();
|
|
426
|
-
|
|
589
|
+
let firstMessageSeen = false;
|
|
590
|
+
for (const targetId of this.targets.keys()) {
|
|
591
|
+
if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
|
|
592
|
+
this.targetLastSeenAt.set(targetId, now);
|
|
593
|
+
}
|
|
594
|
+
if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
|
|
427
595
|
if (typeof message.method !== "string") return;
|
|
428
596
|
if (message.method === "Inspector.targetCrashed") {
|
|
429
597
|
this.handleTargetGone("crashed", null);
|
|
@@ -493,9 +661,9 @@ var ChiiCdpConnection = class {
|
|
|
493
661
|
* in any log, error message, or process output. `verifyAuth` is a black-box
|
|
494
662
|
* predicate from the caller's perspective; this module only forwards pass/fail.
|
|
495
663
|
*/
|
|
496
|
-
const require = createRequire(import.meta.url);
|
|
664
|
+
const require$1 = createRequire(import.meta.url);
|
|
497
665
|
function loadChiiServer() {
|
|
498
|
-
const mod = require("chii");
|
|
666
|
+
const mod = require$1("chii");
|
|
499
667
|
if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
|
|
500
668
|
throw new Error("chii server module did not expose start()");
|
|
501
669
|
}
|
|
@@ -548,6 +716,288 @@ async function startChiiRelay(options = {}) {
|
|
|
548
716
|
};
|
|
549
717
|
}
|
|
550
718
|
//#endregion
|
|
719
|
+
//#region src/mcp/devtools-opener.ts
|
|
720
|
+
/**
|
|
721
|
+
* Base URL for the Chrome DevTools inspector hosted on appspot.
|
|
722
|
+
*
|
|
723
|
+
* The `@` path segment is the "latest / bleeding edge" alias which tracks the
|
|
724
|
+
* current Chrome stable CDP protocol version — compatible with the chobitsu-
|
|
725
|
+
* based CDP that Chii injects. A specific commit hash may be pinned here if
|
|
726
|
+
* a regression is observed.
|
|
727
|
+
*/
|
|
728
|
+
const DEVTOOLS_FRONTEND_BASE = "https://chrome-devtools-frontend.appspot.com/serve_file/@/inspector.html";
|
|
729
|
+
/**
|
|
730
|
+
* Assembles the Chrome DevTools inspector URL that connects to a Chii relay
|
|
731
|
+
* WebSocket.
|
|
732
|
+
*
|
|
733
|
+
* The `wss=` parameter expects a host-and-path string without the `wss://`
|
|
734
|
+
* scheme prefix — the DevTools frontend prepends it automatically.
|
|
735
|
+
*
|
|
736
|
+
* @param wssRelayUrl - Full `wss://` URL of the Chii relay (public tunnel).
|
|
737
|
+
* Example: `wss://abc.trycloudflare.com`
|
|
738
|
+
* @param panel - Initial panel. Defaults to `"console"`.
|
|
739
|
+
*
|
|
740
|
+
* @example
|
|
741
|
+
* buildChromeDevtoolsUrl('wss://abc.trycloudflare.com')
|
|
742
|
+
* // → 'https://chrome-devtools-frontend.appspot.com/serve_file/@/inspector.html?wss=abc.trycloudflare.com&panel=console'
|
|
743
|
+
*/
|
|
744
|
+
function buildChromeDevtoolsUrl(wssRelayUrl, panel = "console") {
|
|
745
|
+
const wssParam = wssRelayUrl.replace(/^wss:\/\//i, "");
|
|
746
|
+
return `${DEVTOOLS_FRONTEND_BASE}?${new URLSearchParams({
|
|
747
|
+
wss: wssParam,
|
|
748
|
+
panel
|
|
749
|
+
}).toString()}`;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Returns `true` when auto-open is **disabled** via the `AIT_AUTO_DEVTOOLS`
|
|
753
|
+
* env var. Only the explicit `"0"` value disables it; anything else (including
|
|
754
|
+
* absent) leaves auto-open enabled.
|
|
755
|
+
*/
|
|
756
|
+
function isAutoDevtoolsDisabled() {
|
|
757
|
+
return process.env.AIT_AUTO_DEVTOOLS === "0";
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Opens the given URL in the OS default browser using a platform-appropriate
|
|
761
|
+
* command. Returns `true` on success.
|
|
762
|
+
*
|
|
763
|
+
* Failures are silent from the caller's perspective — the caller should log
|
|
764
|
+
* the URL to stderr as a fallback before calling this function.
|
|
765
|
+
*/
|
|
766
|
+
function openUrlInBrowser(url) {
|
|
767
|
+
if (process.env.AIT_AUTO_DEVTOOLS_TEST_SKIP_SPAWN === "1") return false;
|
|
768
|
+
const { spawnSync } = __require("node:child_process");
|
|
769
|
+
const platform = process.platform;
|
|
770
|
+
let candidates;
|
|
771
|
+
if (platform === "darwin") candidates = [{
|
|
772
|
+
cmd: "open",
|
|
773
|
+
args: [url]
|
|
774
|
+
}];
|
|
775
|
+
else if (platform === "win32") candidates = [{
|
|
776
|
+
cmd: "cmd",
|
|
777
|
+
args: [
|
|
778
|
+
"/c",
|
|
779
|
+
"start",
|
|
780
|
+
"",
|
|
781
|
+
url
|
|
782
|
+
]
|
|
783
|
+
}];
|
|
784
|
+
else candidates = [
|
|
785
|
+
{
|
|
786
|
+
cmd: "xdg-open",
|
|
787
|
+
args: [url]
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
cmd: "sensible-browser",
|
|
791
|
+
args: [url]
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
cmd: "x-www-browser",
|
|
795
|
+
args: [url]
|
|
796
|
+
}
|
|
797
|
+
];
|
|
798
|
+
for (const { cmd, args } of candidates) try {
|
|
799
|
+
const result = spawnSync(cmd, args, {
|
|
800
|
+
encoding: "utf8",
|
|
801
|
+
timeout: 5e3
|
|
802
|
+
});
|
|
803
|
+
if (!result.error && result.status === 0) return true;
|
|
804
|
+
} catch {}
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Manages auto-opening Chrome DevTools exactly once per relay attach session.
|
|
809
|
+
*
|
|
810
|
+
* Create one instance per `runDebugServer` call and pass its `open()` method
|
|
811
|
+
* as the `onFirstAttach` callback to `startAttachWatcher`.
|
|
812
|
+
*
|
|
813
|
+
* The open fires at most once. Subsequent `open()` calls are no-ops.
|
|
814
|
+
* Opt-out and mock-environment guard are checked at call time.
|
|
815
|
+
*/
|
|
816
|
+
var AutoDevtoolsOpener = class {
|
|
817
|
+
_opened = false;
|
|
818
|
+
/**
|
|
819
|
+
* Attempts to auto-open Chrome DevTools.
|
|
820
|
+
*
|
|
821
|
+
* No-op when any of the following conditions hold:
|
|
822
|
+
* 1. Already opened this session (`_opened` is true).
|
|
823
|
+
* 2. `AIT_AUTO_DEVTOOLS=0` opt-out is set.
|
|
824
|
+
* 3. Environment is `mock` (env 1 — F12 is already available).
|
|
825
|
+
* 4. `wssRelayUrl` is null/undefined/empty (tunnel not yet up).
|
|
826
|
+
*
|
|
827
|
+
* Always writes the DevTools URL to stderr so the developer can copy it
|
|
828
|
+
* if the browser open fails or the popup is blocked.
|
|
829
|
+
*
|
|
830
|
+
* @param wssRelayUrl - The public `wss://` relay URL (from tunnel status).
|
|
831
|
+
* @param env - Current MCP environment (`mock` | `relay`).
|
|
832
|
+
*/
|
|
833
|
+
open(wssRelayUrl, env) {
|
|
834
|
+
if (this._opened) return;
|
|
835
|
+
if (isAutoDevtoolsDisabled()) return;
|
|
836
|
+
if (env === "mock") return;
|
|
837
|
+
if (!wssRelayUrl) return;
|
|
838
|
+
this._opened = true;
|
|
839
|
+
const devtoolsUrl = buildChromeDevtoolsUrl(wssRelayUrl);
|
|
840
|
+
process.stderr.write(`[ait-debug] 기기가 연결됐습니다 — Chrome DevTools를 자동으로 엽니다.
|
|
841
|
+
[ait-debug] Chrome DevTools URL: ${devtoolsUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)
|
|
842
|
+
`);
|
|
843
|
+
if (!openUrlInBrowser(devtoolsUrl)) process.stderr.write("[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\n");
|
|
844
|
+
}
|
|
845
|
+
/** Returns `true` if `open()` has passed all guards and fired once. */
|
|
846
|
+
get opened() {
|
|
847
|
+
return this._opened;
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
//#endregion
|
|
851
|
+
//#region src/mcp/environment.ts
|
|
852
|
+
/**
|
|
853
|
+
* URL patterns that mark a CDP target as a real-device WebView relay.
|
|
854
|
+
*
|
|
855
|
+
* - `intoss-private://` is the Toss in-app private scheme — only ever observed
|
|
856
|
+
* inside the real Toss app WebView.
|
|
857
|
+
* - `*.trycloudflare.com` (host suffix) is the cloudflared quick tunnel used as
|
|
858
|
+
* the relay transport. A target whose URL is on that host is, by construction,
|
|
859
|
+
* reached over the relay.
|
|
860
|
+
*
|
|
861
|
+
* Pattern-only matches — no specific tunnel host or deploymentId is hard-coded.
|
|
862
|
+
*/
|
|
863
|
+
const RELAY_URL_PATTERNS = [/^intoss-private:\/\//i, /:\/\/[a-z0-9-]+\.trycloudflare\.com(\/|$|:|\?)/i];
|
|
864
|
+
/**
|
|
865
|
+
* Returns true when the URL string looks like a real-device WebView attached
|
|
866
|
+
* over the Chii relay. Used for `getEnvironment()` precedence step 2.
|
|
867
|
+
*/
|
|
868
|
+
function isRelayUrl(url) {
|
|
869
|
+
if (typeof url !== "string" || url.length === 0) return false;
|
|
870
|
+
return RELAY_URL_PATTERNS.some((p) => p.test(url));
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Test/override hook — when non-null, `getEnvironment()` returns this value
|
|
874
|
+
* regardless of env vars or connection state. Cleared with `null`.
|
|
875
|
+
*/
|
|
876
|
+
let envOverride = null;
|
|
877
|
+
/** Parses the `MCP_ENV` env var into a `McpEnvironment` if valid. */
|
|
878
|
+
function readEnvVar() {
|
|
879
|
+
const raw = process.env.MCP_ENV;
|
|
880
|
+
if (raw === "mock" || raw === "relay") return raw;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Returns the current MCP environment, applying the precedence rules:
|
|
884
|
+
* 1. test override (if set)
|
|
885
|
+
* 2. `MCP_ENV` env var
|
|
886
|
+
* 3. CDP target URL pattern match
|
|
887
|
+
* 4. default `mock`
|
|
888
|
+
*/
|
|
889
|
+
function getEnvironment(input = {}) {
|
|
890
|
+
if (envOverride !== null) return envOverride;
|
|
891
|
+
const fromEnv = readEnvVar();
|
|
892
|
+
if (fromEnv !== void 0) return fromEnv;
|
|
893
|
+
const { connection } = input;
|
|
894
|
+
if (connection !== void 0) {
|
|
895
|
+
const targets = connection.listTargets();
|
|
896
|
+
for (const t of targets) if (isRelayUrl(t.url)) return "relay";
|
|
897
|
+
}
|
|
898
|
+
return "mock";
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Returns the `EnvironmentReason` that drove the current `getEnvironment()`
|
|
902
|
+
* result. Used by stderr logs and the rejection-reason payload on Tier A/B
|
|
903
|
+
* mismatch errors. SECRET-HANDLING: only stable enum strings — no URL or
|
|
904
|
+
* secret value is ever returned.
|
|
905
|
+
*/
|
|
906
|
+
function getEnvironmentReason(input = {}) {
|
|
907
|
+
if (envOverride !== null) return envOverride === "mock" ? "env-var-mock" : "env-var-relay";
|
|
908
|
+
const fromEnv = readEnvVar();
|
|
909
|
+
if (fromEnv === "mock") return "env-var-mock";
|
|
910
|
+
if (fromEnv === "relay") return "env-var-relay";
|
|
911
|
+
const { connection } = input;
|
|
912
|
+
if (connection !== void 0) {
|
|
913
|
+
const targets = connection.listTargets();
|
|
914
|
+
for (const t of targets) if (isRelayUrl(t.url)) return "cdp-target-url-relay-pattern";
|
|
915
|
+
}
|
|
916
|
+
return "default-mock";
|
|
917
|
+
}
|
|
918
|
+
//#endregion
|
|
919
|
+
//#region src/mcp/errors.ts
|
|
920
|
+
/**
|
|
921
|
+
* 한국어 한 줄 "원인 + 다음 행동" 포맷으로 에러 결과를 빌드한다.
|
|
922
|
+
*
|
|
923
|
+
* @param message - 사용자에게 보여줄 에러 본문 (원인 + 다음 행동 포함).
|
|
924
|
+
*/
|
|
925
|
+
function mcpError(message) {
|
|
926
|
+
return {
|
|
927
|
+
content: [{
|
|
928
|
+
type: "text",
|
|
929
|
+
text: message
|
|
930
|
+
}],
|
|
931
|
+
isError: true
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Tier A/B 환경 불일치 거부 메시지.
|
|
936
|
+
*
|
|
937
|
+
* @param toolName - 거부된 tool 이름.
|
|
938
|
+
* @param requiredEnv - 해당 tool이 요구하는 환경 ('mock' | 'relay').
|
|
939
|
+
* @param currentEnv - 현재 세션 환경.
|
|
940
|
+
* @param reason - 환경이 결정된 근거 (EnvironmentReason 문자열).
|
|
941
|
+
*/
|
|
942
|
+
function tierRejectionError(toolName, requiredEnv, currentEnv, reason) {
|
|
943
|
+
return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "build_attach_url → QR 스캔으로 실기기를 attach하세요." : "MCP_ENV=mock 또는 relay 환경변수를 확인하세요."}`}\n\n${`tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`}`);
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* 상태 1: tunnel 미가동 — cloudflared 터널이 아직 뜨지 않았다.
|
|
947
|
+
*
|
|
948
|
+
* `build_attach_url` 호출 시 tunnel.up === false 인 경우.
|
|
949
|
+
*/
|
|
950
|
+
function tunnelDownError() {
|
|
951
|
+
return mcpError("cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* 상태 2: page 미attach — 터널은 살아 있으나 아직 페이지가 연결되지 않았다.
|
|
955
|
+
*
|
|
956
|
+
* enableDomains()가 "No mini-app page attached" 에러를 던질 때.
|
|
957
|
+
*/
|
|
958
|
+
function pageMissingError(toolName) {
|
|
959
|
+
return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. build_attach_url로 deep link를 생성하고 QR을 스캔해 미니앱을 attach하세요.`);
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* 상태 3: page crash — 연결됐던 페이지가 crash/destroy됐다.
|
|
963
|
+
*
|
|
964
|
+
* chii-connection 이 'replaced-by-new-attach' / 'targetCrashed' / 'targetDestroyed' 를
|
|
965
|
+
* 던질 때 이 메시지를 사용한다.
|
|
966
|
+
*/
|
|
967
|
+
function pageCrashError(toolName) {
|
|
968
|
+
return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 build_attach_url → QR 스캔으로 재attach하세요.`);
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다 (dogfood 빌드가 아님).
|
|
972
|
+
*
|
|
973
|
+
* call_sdk 호출 시 브리지가 없을 때.
|
|
974
|
+
*/
|
|
975
|
+
function sdkAbsentError(toolName) {
|
|
976
|
+
return mcpError(`${toolName ? `${toolName}: ` : ""}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 번들을 재배포한 뒤 재시도하세요.`);
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* relay WebSocket 연결이 끊겼을 때 — 크래시가 아닌 네트워크/프로세스 종료.
|
|
980
|
+
*/
|
|
981
|
+
function relayDisconnectError(toolName) {
|
|
982
|
+
return mcpError(`${toolName ? `${toolName}: ` : ""}relay 연결이 끊겼습니다. list_pages로 상태를 확인하고, 필요하면 앱을 재실행 후 재attach하세요.`);
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* CDP/AIT 명령 중 발생한 예외를 4상태로 분류해 적절한 에러 결과를 반환한다.
|
|
986
|
+
*
|
|
987
|
+
* - SDK 부재 패턴 (`window.__sdkCall is not available`) → sdkAbsentError
|
|
988
|
+
* - crash 패턴 (`replaced-by-new-attach`, `targetCrashed`, `targetDestroyed`) → pageCrashError
|
|
989
|
+
* - 연결 끊김 패턴 (`relay에 연결되어 있지 않습니다`, `relay WebSocket`) → relayDisconnectError
|
|
990
|
+
* - 그 외 (일반 에러) → 원본 메시지를 포함한 mcpError
|
|
991
|
+
*/
|
|
992
|
+
function classifyToolError(err, toolName) {
|
|
993
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
994
|
+
if (message.startsWith("tunnel-down:") || message.includes("터널이 안 떠 있습니다")) return tunnelDownError();
|
|
995
|
+
if (message.startsWith("sdk-absent:") || message.includes("__sdkCall이 주입되지 않았습니다") || message.includes("window.__sdkCall is not available") || message.includes("__sdkCall") && message.includes("not available")) return sdkAbsentError(toolName);
|
|
996
|
+
if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
|
|
997
|
+
if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket")) return relayDisconnectError(toolName);
|
|
998
|
+
return mcpError(`${toolName} 실패: ${message}\nlist_pages로 미니앱이 relay에 attach됐는지 확인하세요.`);
|
|
999
|
+
}
|
|
1000
|
+
//#endregion
|
|
551
1001
|
//#region src/mcp/local-connection.ts
|
|
552
1002
|
/**
|
|
553
1003
|
* Local-browser `CdpConnection` — attaches directly to a Chromium instance
|
|
@@ -1049,6 +1499,154 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl) {
|
|
|
1049
1499
|
</html>`;
|
|
1050
1500
|
}
|
|
1051
1501
|
//#endregion
|
|
1502
|
+
//#region src/mcp/server-lock.ts
|
|
1503
|
+
/**
|
|
1504
|
+
* Single debug session lock for the `devtools-mcp` debug server.
|
|
1505
|
+
*
|
|
1506
|
+
* At most one debug server process should run on a given machine at a time —
|
|
1507
|
+
* multiple concurrent instances create duplicate cloudflared tunnels, waste
|
|
1508
|
+
* resources, and confuse the user about which wssUrl to use.
|
|
1509
|
+
*
|
|
1510
|
+
* ## Lock file
|
|
1511
|
+
*
|
|
1512
|
+
* Location: `~/.ait-devtools/server.lock`
|
|
1513
|
+
*
|
|
1514
|
+
* Schema (JSON):
|
|
1515
|
+
* ```json
|
|
1516
|
+
* { "pid": 12345, "wssUrl": "wss://xxx.trycloudflare.com", "startedAt": "2026-01-01T00:00:00.000Z" }
|
|
1517
|
+
* ```
|
|
1518
|
+
*
|
|
1519
|
+
* ## Behaviour
|
|
1520
|
+
*
|
|
1521
|
+
* - **Acquire**: write PID + wssUrl + startedAt. Returns a `release()` handle.
|
|
1522
|
+
* - **Stale lock recovery**: if the stored PID is no longer alive
|
|
1523
|
+
* (`process.kill(pid, 0)` throws ESRCH), the lock is silently replaced.
|
|
1524
|
+
* - **Live conflict (option B)**: if the stored PID is alive, `acquireLock`
|
|
1525
|
+
* throws `ServerLockConflictError` with the existing PID and wssUrl so the
|
|
1526
|
+
* caller can surface a clear message to the agent.
|
|
1527
|
+
* - **Release**: remove the lock file. Called on graceful shutdown (SIGINT /
|
|
1528
|
+
* SIGTERM / SIGHUP). SIGKILL survivors leave a stale file — the next startup
|
|
1529
|
+
* recovers it automatically via the alive check.
|
|
1530
|
+
*
|
|
1531
|
+
* ## wssUrl update
|
|
1532
|
+
*
|
|
1533
|
+
* The lock is written before cloudflared starts, so `wssUrl` begins as `null`
|
|
1534
|
+
* and is updated in place once the tunnel URL is known via `updateWssUrl`.
|
|
1535
|
+
*
|
|
1536
|
+
* Node-only.
|
|
1537
|
+
*/
|
|
1538
|
+
/** Thrown when a live server process already holds the lock. */
|
|
1539
|
+
var ServerLockConflictError = class extends Error {
|
|
1540
|
+
/** PID of the existing server process. */
|
|
1541
|
+
existingPid;
|
|
1542
|
+
/** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */
|
|
1543
|
+
existingWssUrl;
|
|
1544
|
+
constructor(existingPid, existingWssUrl) {
|
|
1545
|
+
const urlNote = existingWssUrl != null ? ` relay URL: ${existingWssUrl}\n` : " relay URL: (tunnel still starting — retry in a moment)\n";
|
|
1546
|
+
super(`A debug server is already running (PID ${existingPid}).\n` + urlNote + `Stop the existing session before starting a new one.
|
|
1547
|
+
If it is already stopped but this error persists, remove the lock file:
|
|
1548
|
+
rm "${lockFilePath()}"`);
|
|
1549
|
+
this.name = "ServerLockConflictError";
|
|
1550
|
+
this.existingPid = existingPid;
|
|
1551
|
+
this.existingWssUrl = existingWssUrl;
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
/** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */
|
|
1555
|
+
function lockFilePath() {
|
|
1556
|
+
return join(process.env.AIT_DEVTOOLS_LOCK_DIR ?? join(homedir(), ".ait-devtools"), "server.lock");
|
|
1557
|
+
}
|
|
1558
|
+
function ensureLockDir(lockPath) {
|
|
1559
|
+
mkdirSync(join(lockPath, ".."), { recursive: true });
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Returns `true` when the given PID refers to a running process.
|
|
1563
|
+
*
|
|
1564
|
+
* Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process
|
|
1565
|
+
* exists and we have permission to signal it; throws ESRCH when it doesn't exist.
|
|
1566
|
+
*/
|
|
1567
|
+
function isPidAlive(pid) {
|
|
1568
|
+
try {
|
|
1569
|
+
process.kill(pid, 0);
|
|
1570
|
+
return true;
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
if (err.code === "EPERM") return true;
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
function readLock(lockPath) {
|
|
1577
|
+
if (!existsSync(lockPath)) return null;
|
|
1578
|
+
try {
|
|
1579
|
+
const raw = readFileSync(lockPath, "utf8");
|
|
1580
|
+
const parsed = JSON.parse(raw);
|
|
1581
|
+
if (typeof parsed === "object" && parsed !== null && "pid" in parsed && typeof parsed.pid === "number" && "startedAt" in parsed && typeof parsed.startedAt === "string") {
|
|
1582
|
+
const p = parsed;
|
|
1583
|
+
return {
|
|
1584
|
+
pid: p.pid,
|
|
1585
|
+
wssUrl: typeof p.wssUrl === "string" ? p.wssUrl : null,
|
|
1586
|
+
startedAt: p.startedAt
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
return null;
|
|
1590
|
+
} catch {
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
function writeLock(lockPath, data) {
|
|
1595
|
+
ensureLockDir(lockPath);
|
|
1596
|
+
writeFileSync(lockPath, JSON.stringify(data, null, 2), { encoding: "utf8" });
|
|
1597
|
+
}
|
|
1598
|
+
function removeLock(lockPath) {
|
|
1599
|
+
try {
|
|
1600
|
+
rmSync(lockPath);
|
|
1601
|
+
} catch {}
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Reads the current lock file without acquiring it. Returns the parsed
|
|
1605
|
+
* `LockData` when the file exists and is valid, otherwise `null`. Used by
|
|
1606
|
+
* `get_diagnostics` to surface the `serverLockHolder` field without
|
|
1607
|
+
* interfering with the running lock owner.
|
|
1608
|
+
*/
|
|
1609
|
+
function readServerLock() {
|
|
1610
|
+
return readLock(lockFilePath());
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Attempts to acquire the server lock.
|
|
1614
|
+
*
|
|
1615
|
+
* - If no lock exists (or the lock is stale): writes a new lock and returns a
|
|
1616
|
+
* `LockHandle` with `updateWssUrl` + `release`.
|
|
1617
|
+
* - If a live process holds the lock: throws `ServerLockConflictError`.
|
|
1618
|
+
*
|
|
1619
|
+
* The initial `wssUrl` in the lock file is `null` — call
|
|
1620
|
+
* `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.
|
|
1621
|
+
*/
|
|
1622
|
+
function acquireLock() {
|
|
1623
|
+
const lockPath = lockFilePath();
|
|
1624
|
+
const existing = readLock(lockPath);
|
|
1625
|
+
if (existing !== null) {
|
|
1626
|
+
if (isPidAlive(existing.pid)) throw new ServerLockConflictError(existing.pid, existing.wssUrl);
|
|
1627
|
+
process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
|
|
1628
|
+
}
|
|
1629
|
+
const data = {
|
|
1630
|
+
pid: process.pid,
|
|
1631
|
+
wssUrl: null,
|
|
1632
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1633
|
+
};
|
|
1634
|
+
writeLock(lockPath, data);
|
|
1635
|
+
let released = false;
|
|
1636
|
+
return {
|
|
1637
|
+
updateWssUrl(wssUrl) {
|
|
1638
|
+
if (released) return;
|
|
1639
|
+
data.wssUrl = wssUrl;
|
|
1640
|
+
writeLock(lockPath, data);
|
|
1641
|
+
},
|
|
1642
|
+
release() {
|
|
1643
|
+
if (released) return;
|
|
1644
|
+
released = true;
|
|
1645
|
+
removeLock(lockPath);
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
//#endregion
|
|
1052
1650
|
//#region src/mcp/deeplink.ts
|
|
1053
1651
|
/**
|
|
1054
1652
|
* Build a self-attaching dogfood deep link.
|
|
@@ -1327,7 +1925,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1327
1925
|
type: "object",
|
|
1328
1926
|
properties: {},
|
|
1329
1927
|
required: []
|
|
1330
|
-
}
|
|
1928
|
+
},
|
|
1929
|
+
availableIn: "both"
|
|
1331
1930
|
},
|
|
1332
1931
|
{
|
|
1333
1932
|
name: "list_network_requests",
|
|
@@ -1336,16 +1935,18 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1336
1935
|
type: "object",
|
|
1337
1936
|
properties: {},
|
|
1338
1937
|
required: []
|
|
1339
|
-
}
|
|
1938
|
+
},
|
|
1939
|
+
availableIn: "both"
|
|
1340
1940
|
},
|
|
1341
1941
|
{
|
|
1342
1942
|
name: "list_pages",
|
|
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.",
|
|
1943
|
+
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. The `tunnel` field includes `droppedAt` (ISO timestamp or null/undefined): when non-null the tunnel has permanently dropped after 3 failed reissue attempts — restart the debug server with `npx @ait-co/devtools devtools-mcp`. 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.",
|
|
1344
1944
|
inputSchema: {
|
|
1345
1945
|
type: "object",
|
|
1346
1946
|
properties: {},
|
|
1347
1947
|
required: []
|
|
1348
|
-
}
|
|
1948
|
+
},
|
|
1949
|
+
availableIn: "both"
|
|
1349
1950
|
},
|
|
1350
1951
|
{
|
|
1351
1952
|
name: "build_attach_url",
|
|
@@ -1367,7 +1968,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1367
1968
|
}
|
|
1368
1969
|
},
|
|
1369
1970
|
required: ["scheme_url"]
|
|
1370
|
-
}
|
|
1971
|
+
},
|
|
1972
|
+
availableIn: "relay"
|
|
1371
1973
|
},
|
|
1372
1974
|
{
|
|
1373
1975
|
name: "get_dom_document",
|
|
@@ -1376,7 +1978,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1376
1978
|
type: "object",
|
|
1377
1979
|
properties: {},
|
|
1378
1980
|
required: []
|
|
1379
|
-
}
|
|
1981
|
+
},
|
|
1982
|
+
availableIn: "both"
|
|
1380
1983
|
},
|
|
1381
1984
|
{
|
|
1382
1985
|
name: "take_snapshot",
|
|
@@ -1385,7 +1988,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1385
1988
|
type: "object",
|
|
1386
1989
|
properties: {},
|
|
1387
1990
|
required: []
|
|
1388
|
-
}
|
|
1991
|
+
},
|
|
1992
|
+
availableIn: "both"
|
|
1389
1993
|
},
|
|
1390
1994
|
{
|
|
1391
1995
|
name: "take_screenshot",
|
|
@@ -1394,16 +1998,18 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1394
1998
|
type: "object",
|
|
1395
1999
|
properties: {},
|
|
1396
2000
|
required: []
|
|
1397
|
-
}
|
|
2001
|
+
},
|
|
2002
|
+
availableIn: "both"
|
|
1398
2003
|
},
|
|
1399
2004
|
{
|
|
1400
2005
|
name: "measure_safe_area",
|
|
1401
|
-
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
|
|
2006
|
+
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.",
|
|
1402
2007
|
inputSchema: {
|
|
1403
2008
|
type: "object",
|
|
1404
2009
|
properties: {},
|
|
1405
2010
|
required: []
|
|
1406
|
-
}
|
|
2011
|
+
},
|
|
2012
|
+
availableIn: "both"
|
|
1407
2013
|
},
|
|
1408
2014
|
{
|
|
1409
2015
|
name: "evaluate",
|
|
@@ -1415,7 +2021,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1415
2021
|
description: "JavaScript expression to evaluate in the page context."
|
|
1416
2022
|
} },
|
|
1417
2023
|
required: ["expression"]
|
|
1418
|
-
}
|
|
2024
|
+
},
|
|
2025
|
+
availableIn: "both"
|
|
1419
2026
|
},
|
|
1420
2027
|
{
|
|
1421
2028
|
name: "list_exceptions",
|
|
@@ -1427,7 +2034,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1427
2034
|
description: "Maximum number of exceptions to return (default 50, max 50)."
|
|
1428
2035
|
} },
|
|
1429
2036
|
required: []
|
|
1430
|
-
}
|
|
2037
|
+
},
|
|
2038
|
+
availableIn: "both"
|
|
1431
2039
|
},
|
|
1432
2040
|
{
|
|
1433
2041
|
name: "call_sdk",
|
|
@@ -1446,7 +2054,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1446
2054
|
}
|
|
1447
2055
|
},
|
|
1448
2056
|
required: ["name"]
|
|
1449
|
-
}
|
|
2057
|
+
},
|
|
2058
|
+
availableIn: "both"
|
|
1450
2059
|
},
|
|
1451
2060
|
{
|
|
1452
2061
|
name: "AIT.getSdkCallHistory",
|
|
@@ -1455,7 +2064,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1455
2064
|
type: "object",
|
|
1456
2065
|
properties: {},
|
|
1457
2066
|
required: []
|
|
1458
|
-
}
|
|
2067
|
+
},
|
|
2068
|
+
availableIn: "both"
|
|
1459
2069
|
},
|
|
1460
2070
|
{
|
|
1461
2071
|
name: "AIT.getMockState",
|
|
@@ -1464,7 +2074,8 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1464
2074
|
type: "object",
|
|
1465
2075
|
properties: {},
|
|
1466
2076
|
required: []
|
|
1467
|
-
}
|
|
2077
|
+
},
|
|
2078
|
+
availableIn: "both"
|
|
1468
2079
|
},
|
|
1469
2080
|
{
|
|
1470
2081
|
name: "AIT.getOperationalEnvironment",
|
|
@@ -1473,7 +2084,21 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1473
2084
|
type: "object",
|
|
1474
2085
|
properties: {},
|
|
1475
2086
|
required: []
|
|
1476
|
-
}
|
|
2087
|
+
},
|
|
2088
|
+
availableIn: "both"
|
|
2089
|
+
},
|
|
2090
|
+
{
|
|
2091
|
+
name: "get_diagnostics",
|
|
2092
|
+
description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (getEnvironment() result + reason), serverLockHolder (pid + startedAt from the lock file, or null). All fields are nullable — missing data is null, not an error. Tier C (both mock and relay). Call this first when debugging session state.",
|
|
2093
|
+
inputSchema: {
|
|
2094
|
+
type: "object",
|
|
2095
|
+
properties: { recent_errors_limit: {
|
|
2096
|
+
type: "number",
|
|
2097
|
+
description: "Maximum number of recent server-side errors to include (default 10, max 50)."
|
|
2098
|
+
} },
|
|
2099
|
+
required: []
|
|
2100
|
+
},
|
|
2101
|
+
availableIn: "both"
|
|
1477
2102
|
}
|
|
1478
2103
|
];
|
|
1479
2104
|
const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
|
|
@@ -1481,6 +2106,34 @@ function isDebugToolName(name) {
|
|
|
1481
2106
|
return DEBUG_TOOL_NAMES.has(name);
|
|
1482
2107
|
}
|
|
1483
2108
|
/**
|
|
2109
|
+
* Returns the `ToolAvailability` declared on a registered debug tool, or
|
|
2110
|
+
* `undefined` when the name is not a known debug tool. Used by the tool
|
|
2111
|
+
* registry to filter `tools/list` by current env and by the call handler to
|
|
2112
|
+
* reject env-mismatch invocations.
|
|
2113
|
+
*/
|
|
2114
|
+
function getToolAvailability(name) {
|
|
2115
|
+
for (const t of DEBUG_TOOL_DEFINITIONS) if (t.name === name) return t.availableIn;
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Returns true when the named tool is available in the given environment.
|
|
2119
|
+
* Unknown tools return `false` — callers should reject them as unknown rather
|
|
2120
|
+
* than as env-mismatched.
|
|
2121
|
+
*/
|
|
2122
|
+
function isToolAvailableIn(name, env) {
|
|
2123
|
+
const availability = getToolAvailability(name);
|
|
2124
|
+
if (availability === void 0) return false;
|
|
2125
|
+
if (availability === "both") return true;
|
|
2126
|
+
return availability === env;
|
|
2127
|
+
}
|
|
2128
|
+
/**
|
|
2129
|
+
* Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`
|
|
2130
|
+
* matches the given env. Pure — preserves order; both Tier C ("both") and the
|
|
2131
|
+
* matching single-env tier pass through.
|
|
2132
|
+
*/
|
|
2133
|
+
function filterToolsByEnvironment(tools, env) {
|
|
2134
|
+
return tools.filter((t) => t.availableIn === "both" || t.availableIn === env);
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
1484
2137
|
* Tool names that are available before any page attaches (bootstrap tier).
|
|
1485
2138
|
*
|
|
1486
2139
|
* `build_attach_url` — pure URL synthesis, no attach needed.
|
|
@@ -1489,7 +2142,11 @@ function isDebugToolName(name) {
|
|
|
1489
2142
|
* All other tools require an attached page (`enableDomains` must succeed) and
|
|
1490
2143
|
* are only advertised in `tools/list` once a target appears.
|
|
1491
2144
|
*/
|
|
1492
|
-
const BOOTSTRAP_TOOL_NAMES = new Set([
|
|
2145
|
+
const BOOTSTRAP_TOOL_NAMES = new Set([
|
|
2146
|
+
"build_attach_url",
|
|
2147
|
+
"get_diagnostics",
|
|
2148
|
+
"list_pages"
|
|
2149
|
+
]);
|
|
1493
2150
|
/** Renders a CDP `RemoteObject` console arg to a stable display string. */
|
|
1494
2151
|
function renderRemoteObject(arg) {
|
|
1495
2152
|
if (arg.value !== void 0) {
|
|
@@ -1601,7 +2258,7 @@ function listPages(connection, tunnel) {
|
|
|
1601
2258
|
* the scheme authority which is in the caller's input, not ours to own).
|
|
1602
2259
|
*/
|
|
1603
2260
|
function buildAttachUrl(schemeUrl, tunnel) {
|
|
1604
|
-
if (!tunnel.up || tunnel.wssUrl === null) throw new Error("
|
|
2261
|
+
if (!tunnel.up || tunnel.wssUrl === null) throw new Error("tunnel-down: cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
|
|
1605
2262
|
const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
|
|
1606
2263
|
return {
|
|
1607
2264
|
attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
|
|
@@ -1716,8 +2373,9 @@ function isLaunchFailureStderr(stderr) {
|
|
|
1716
2373
|
/**
|
|
1717
2374
|
* 로컬 HTTP 서버 URL(`http://127.0.0.1:<port>/attach?u=...`)을 OS 기본 브라우저로 연다.
|
|
1718
2375
|
*
|
|
1719
|
-
* platform별 fallback chain으로 시도하며, 모두
|
|
1720
|
-
*
|
|
2376
|
+
* platform별 fallback chain으로 시도하며, 모두 실패하면 1회 retry를 수행한다
|
|
2377
|
+
* (ephemeral process launch 타이밍 문제 대응). retry까지 실패해도 `opened: false` +
|
|
2378
|
+
* `httpUrl`을 반환해 사용자가 직접 브라우저에 붙여넣을 수 있게 한다.
|
|
1721
2379
|
*
|
|
1722
2380
|
* SECRET-HANDLING:
|
|
1723
2381
|
* - tmp 파일을 만들지 않는다 (HTML/PNG는 HTTP 서버가 메모리에서 응답).
|
|
@@ -1730,25 +2388,39 @@ function isLaunchFailureStderr(stderr) {
|
|
|
1730
2388
|
*/
|
|
1731
2389
|
async function openQrInBrowser(httpUrl, pngUrl) {
|
|
1732
2390
|
const { spawnSync } = await import("node:child_process");
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
})
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
2391
|
+
/**
|
|
2392
|
+
* 한 번의 fallback chain 시도. 성공하면 열린 후보 cmd를 반환, 실패하면 null.
|
|
2393
|
+
* stderrLines에 각 후보의 stderr를 누적한다.
|
|
2394
|
+
*/
|
|
2395
|
+
function tryOnce(stderrLines) {
|
|
2396
|
+
const candidates = getBrowserCandidates(httpUrl);
|
|
2397
|
+
for (const { cmd, args } of candidates) {
|
|
2398
|
+
const result = spawnSync(cmd, args, {
|
|
2399
|
+
encoding: "utf8",
|
|
2400
|
+
timeout: 5e3
|
|
2401
|
+
});
|
|
2402
|
+
if (result.error) {
|
|
2403
|
+
stderrLines.push(`${cmd}: ${result.error.message}`);
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
2407
|
+
if (stderr) stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);
|
|
2408
|
+
if (result.status === 0 && !isLaunchFailureStderr(stderr)) return true;
|
|
1743
2409
|
}
|
|
1744
|
-
|
|
1745
|
-
if (stderr) stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);
|
|
1746
|
-
if (result.status === 0 && !isLaunchFailureStderr(stderr)) return {
|
|
1747
|
-
opened: true,
|
|
1748
|
-
httpUrl,
|
|
1749
|
-
pngUrl
|
|
1750
|
-
};
|
|
2410
|
+
return false;
|
|
1751
2411
|
}
|
|
2412
|
+
const stderrLines = [];
|
|
2413
|
+
if (tryOnce(stderrLines)) return {
|
|
2414
|
+
opened: true,
|
|
2415
|
+
httpUrl,
|
|
2416
|
+
pngUrl
|
|
2417
|
+
};
|
|
2418
|
+
if (tryOnce(stderrLines)) return {
|
|
2419
|
+
opened: true,
|
|
2420
|
+
httpUrl,
|
|
2421
|
+
pngUrl,
|
|
2422
|
+
retried: true
|
|
2423
|
+
};
|
|
1752
2424
|
return {
|
|
1753
2425
|
opened: false,
|
|
1754
2426
|
httpUrl,
|
|
@@ -1781,11 +2453,13 @@ async function takeScreenshot(connection) {
|
|
|
1781
2453
|
* The JS probe injected via `Runtime.evaluate`. It reads:
|
|
1782
2454
|
* 1. `env(safe-area-inset-*)` via a temporary element with padding set to
|
|
1783
2455
|
* those CSS env vars, then `getComputedStyle`.
|
|
1784
|
-
* 2.
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1787
|
-
*
|
|
1788
|
-
*
|
|
2456
|
+
* 2. SDK insets via a priority chain so the SAME probe works on both relay
|
|
2457
|
+
* (real device) and mock (devtools panel page):
|
|
2458
|
+
* a. `window.__sdk.SafeAreaInsets.get()` — dogfood bundle on real device.
|
|
2459
|
+
* b. `window.__sdk.getSafeAreaInsets()` — dogfood bundle (deprecated).
|
|
2460
|
+
* c. `window.__ait.state.safeAreaInsets` — devtools mock state (mock env).
|
|
2461
|
+
* The probe records `sdkInsetsSource` = `'window.__sdk'` | `'window.__ait'`
|
|
2462
|
+
* | `null`. If all paths fail the result carries `sdkInsetsError`.
|
|
1789
2463
|
* 3. nav bar geometry: the SDK does not expose navBar height as a standalone
|
|
1790
2464
|
* API — `.ait-navbar` DOM height is read as a cross-check, and
|
|
1791
2465
|
* `navBarHeightSource` records where it came from.
|
|
@@ -1793,9 +2467,15 @@ async function takeScreenshot(connection) {
|
|
|
1793
2467
|
*
|
|
1794
2468
|
* Returns a plain JSON-serialisable object so `returnByValue: true` works.
|
|
1795
2469
|
*
|
|
1796
|
-
* NOTE: This expression is evaluated in the page context on the real device
|
|
1797
|
-
* It does not mutate any page state — the
|
|
1798
|
-
* reading. No secret or auth token is read
|
|
2470
|
+
* NOTE: This expression is evaluated in the page context — on the real device
|
|
2471
|
+
* (relay) or on the mock panel page. It does not mutate any page state — the
|
|
2472
|
+
* temporary element is removed after reading. No secret or auth token is read
|
|
2473
|
+
* or returned.
|
|
2474
|
+
*
|
|
2475
|
+
* RFC #277 Tier C parity: the SAME probe string runs in both envs. Mock fidelity
|
|
2476
|
+
* comes from the panel's `applyViewport` / `computeSafeAreaInsets` correctly
|
|
2477
|
+
* setting `window.__ait.state.safeAreaInsets` (#275). When that is correct,
|
|
2478
|
+
* the cssEnv + sdkInsets pair returned here matches the relay's shape.
|
|
1799
2479
|
*/
|
|
1800
2480
|
const SAFE_AREA_PROBE_EXPRESSION = `
|
|
1801
2481
|
(function() {
|
|
@@ -1815,17 +2495,28 @@ const SAFE_AREA_PROBE_EXPRESSION = `
|
|
|
1815
2495
|
};
|
|
1816
2496
|
document.documentElement.removeChild(el);
|
|
1817
2497
|
var sdkInsets = null;
|
|
2498
|
+
var sdkInsetsSource = null;
|
|
1818
2499
|
var sdkInsetsError = undefined;
|
|
1819
2500
|
try {
|
|
1820
2501
|
var sdk = window.__sdk;
|
|
2502
|
+
var ait = window.__ait;
|
|
1821
2503
|
if (sdk && sdk.SafeAreaInsets && typeof sdk.SafeAreaInsets.get === 'function') {
|
|
1822
2504
|
sdkInsets = sdk.SafeAreaInsets.get();
|
|
2505
|
+
sdkInsetsSource = 'window.__sdk';
|
|
1823
2506
|
} else if (sdk && typeof sdk.getSafeAreaInsets === 'function') {
|
|
1824
2507
|
sdkInsets = sdk.getSafeAreaInsets();
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2508
|
+
sdkInsetsSource = 'window.__sdk';
|
|
2509
|
+
} else if (ait && ait.state && ait.state.safeAreaInsets &&
|
|
2510
|
+
typeof ait.state.safeAreaInsets.top === 'number') {
|
|
2511
|
+
var s = ait.state.safeAreaInsets;
|
|
2512
|
+
sdkInsets = { top: s.top, bottom: s.bottom, left: s.left, right: s.right };
|
|
2513
|
+
sdkInsetsSource = 'window.__ait';
|
|
2514
|
+
} else if (!sdk && !ait) {
|
|
2515
|
+
sdkInsetsError = 'neither window.__sdk (relay) nor window.__ait (mock) available';
|
|
2516
|
+
} else if (sdk) {
|
|
1828
2517
|
sdkInsetsError = 'neither SafeAreaInsets.get nor getSafeAreaInsets found on window.__sdk';
|
|
2518
|
+
} else {
|
|
2519
|
+
sdkInsetsError = 'window.__ait.state.safeAreaInsets is missing or malformed';
|
|
1829
2520
|
}
|
|
1830
2521
|
} catch(e) {
|
|
1831
2522
|
sdkInsetsError = String(e && e.message || e);
|
|
@@ -1842,6 +2533,7 @@ const SAFE_AREA_PROBE_EXPRESSION = `
|
|
|
1842
2533
|
var result = {
|
|
1843
2534
|
cssEnv: cssEnv,
|
|
1844
2535
|
sdkInsets: sdkInsets,
|
|
2536
|
+
sdkInsetsSource: sdkInsetsSource,
|
|
1845
2537
|
navBarHeight: navBarHeight,
|
|
1846
2538
|
navBarHeightSource: navBarHeightSource,
|
|
1847
2539
|
innerWidth: window.innerWidth,
|
|
@@ -1858,9 +2550,11 @@ const SAFE_AREA_PROBE_EXPRESSION = `
|
|
|
1858
2550
|
* The probe returns a JSON string (because `returnByValue:true` with a plain
|
|
1859
2551
|
* object works unreliably across Chii relay versions — stringifying is safer).
|
|
1860
2552
|
*
|
|
2553
|
+
* `source` is supplied by the caller (`measureSafeArea`) from the env SSoT.
|
|
2554
|
+
*
|
|
1861
2555
|
* Throws if the result is missing, contains an exception, or cannot be parsed.
|
|
1862
2556
|
*/
|
|
1863
|
-
function normalizeSafeAreaResult(rawValue) {
|
|
2557
|
+
function normalizeSafeAreaResult(rawValue, source) {
|
|
1864
2558
|
if (typeof rawValue !== "string") throw new Error(`measure_safe_area: probe returned unexpected type "${typeof rawValue}" — expected JSON string`);
|
|
1865
2559
|
let parsed;
|
|
1866
2560
|
try {
|
|
@@ -1889,6 +2583,7 @@ function normalizeSafeAreaResult(rawValue) {
|
|
|
1889
2583
|
left: 0
|
|
1890
2584
|
};
|
|
1891
2585
|
const sdkInsets = requireInsets("sdkInsets");
|
|
2586
|
+
const sdkInsetsSource = obj.sdkInsetsSource === "window.__sdk" || obj.sdkInsetsSource === "window.__ait" ? obj.sdkInsetsSource : null;
|
|
1892
2587
|
const sdkInsetsError = typeof obj.sdkInsetsError === "string" ? obj.sdkInsetsError : void 0;
|
|
1893
2588
|
const navBarHeight = typeof obj.navBarHeight === "number" ? obj.navBarHeight : null;
|
|
1894
2589
|
const navBarHeightSource = typeof obj.navBarHeightSource === "string" ? obj.navBarHeightSource : "not-exposed-by-sdk";
|
|
@@ -1897,8 +2592,10 @@ function normalizeSafeAreaResult(rawValue) {
|
|
|
1897
2592
|
const devicePixelRatio = typeof obj.devicePixelRatio === "number" ? obj.devicePixelRatio : 1;
|
|
1898
2593
|
const userAgent = typeof obj.userAgent === "string" ? obj.userAgent : "";
|
|
1899
2594
|
return {
|
|
2595
|
+
source,
|
|
1900
2596
|
cssEnv,
|
|
1901
2597
|
sdkInsets,
|
|
2598
|
+
sdkInsetsSource,
|
|
1902
2599
|
...sdkInsetsError !== void 0 ? { sdkInsetsError } : {},
|
|
1903
2600
|
navBarHeight,
|
|
1904
2601
|
navBarHeightSource,
|
|
@@ -1912,9 +2609,16 @@ function normalizeSafeAreaResult(rawValue) {
|
|
|
1912
2609
|
* Runs the safe-area probe on the attached page and returns a normalized
|
|
1913
2610
|
* `SafeAreaMeasurement`. Read-only — does not mutate page state.
|
|
1914
2611
|
*
|
|
2612
|
+
* `source` is supplied by the caller from the env detection SSoT (see
|
|
2613
|
+
* `src/mcp/environment.ts`). The same `Runtime.evaluate` call runs in both
|
|
2614
|
+
* envs — the probe expression tries `window.__sdk` first (relay) then
|
|
2615
|
+
* `window.__ait` (mock), so mock fidelity is enforced by the panel's
|
|
2616
|
+
* `applyViewport`/`computeSafeAreaInsets` keeping `__ait.state.safeAreaInsets`
|
|
2617
|
+
* correct (RFC #277 Tier C parity, #275 model).
|
|
2618
|
+
*
|
|
1915
2619
|
* Throws on CDP error, probe exception, or result parse failure.
|
|
1916
2620
|
*/
|
|
1917
|
-
async function measureSafeArea(connection) {
|
|
2621
|
+
async function measureSafeArea(connection, source) {
|
|
1918
2622
|
const result = await connection.send("Runtime.evaluate", {
|
|
1919
2623
|
expression: SAFE_AREA_PROBE_EXPRESSION,
|
|
1920
2624
|
returnByValue: true,
|
|
@@ -1924,7 +2628,7 @@ async function measureSafeArea(connection) {
|
|
|
1924
2628
|
const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
|
|
1925
2629
|
throw new Error(`measure_safe_area: probe threw — ${msg}`);
|
|
1926
2630
|
}
|
|
1927
|
-
return normalizeSafeAreaResult(result.result.value);
|
|
2631
|
+
return normalizeSafeAreaResult(result.result.value, source);
|
|
1928
2632
|
}
|
|
1929
2633
|
/**
|
|
1930
2634
|
* Evaluates an arbitrary JS expression on the attached page via
|
|
@@ -1962,7 +2666,7 @@ async function evaluate(connection, expression) {
|
|
|
1962
2666
|
* any log or stderr by the caller.
|
|
1963
2667
|
*/
|
|
1964
2668
|
function buildCallSdkExpression(name, args) {
|
|
1965
|
-
return `(async () => { if (typeof window.__sdkCall !== 'function') { return JSON.stringify({ok:false,error:'window.__sdkCall
|
|
2669
|
+
return `(async () => { if (typeof window.__sdkCall !== 'function') { return JSON.stringify({ok:false,error:'sdk-absent: window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널로 재배포하세요.'}); } try { const r = await window.__sdkCall(${JSON.stringify(name)}, ...${JSON.stringify(args)}); return JSON.stringify({ok:true,value:r}); } catch(e) { return JSON.stringify({ok:false,error:String(e && e.message || e)}); }})()`;
|
|
1966
2670
|
}
|
|
1967
2671
|
/**
|
|
1968
2672
|
* Parses the JSON envelope string returned by the `call_sdk` expression.
|
|
@@ -2081,6 +2785,145 @@ function getMockState(source) {
|
|
|
2081
2785
|
function getOperationalEnvironment(source) {
|
|
2082
2786
|
return source.get("AIT.getOperationalEnvironment");
|
|
2083
2787
|
}
|
|
2788
|
+
/** Secret-redaction patterns applied before error messages enter the buffer. */
|
|
2789
|
+
const SECRET_REDACT_PATTERNS = [
|
|
2790
|
+
[/\bat=([^&\s"']+)/g, "at=<redacted>"],
|
|
2791
|
+
[/((?:set-)?cookie)\s*:\s*.+/gi, "$1: <redacted>"],
|
|
2792
|
+
[/AITCC_API_KEY\s*=\s*\S+/gi, "AITCC_API_KEY=<redacted>"],
|
|
2793
|
+
[/Authorization\s*:\s*.+/gi, "Authorization: <redacted>"],
|
|
2794
|
+
[/\bBearer\s+\S+/g, "Bearer <redacted>"]
|
|
2795
|
+
];
|
|
2796
|
+
/**
|
|
2797
|
+
* Applies all secret-redaction patterns to an error message string.
|
|
2798
|
+
* Used before storing errors in the `DiagnosticsCollector` ring buffer.
|
|
2799
|
+
*
|
|
2800
|
+
* SECRET-HANDLING: this is the single bottleneck for redaction — all error
|
|
2801
|
+
* strings must pass through here before reaching the buffer.
|
|
2802
|
+
*/
|
|
2803
|
+
function redactErrorMessage(message) {
|
|
2804
|
+
let result = message;
|
|
2805
|
+
for (const [pattern, replacement] of SECRET_REDACT_PATTERNS) result = result.replace(pattern, replacement);
|
|
2806
|
+
return result;
|
|
2807
|
+
}
|
|
2808
|
+
/** Default max buffer size for the error ring buffer. */
|
|
2809
|
+
const DEFAULT_ERROR_BUFFER_SIZE = 50;
|
|
2810
|
+
/**
|
|
2811
|
+
* In-memory implementation of `DiagnosticsCollector`. Thread-safe in the
|
|
2812
|
+
* single-threaded Node.js sense (synchronous mutations only).
|
|
2813
|
+
*/
|
|
2814
|
+
var InMemoryDiagnosticsCollector = class {
|
|
2815
|
+
buffer = [];
|
|
2816
|
+
maxSize;
|
|
2817
|
+
lastAttachAt = null;
|
|
2818
|
+
lastDetachAt = null;
|
|
2819
|
+
constructor(maxSize = DEFAULT_ERROR_BUFFER_SIZE) {
|
|
2820
|
+
this.maxSize = maxSize;
|
|
2821
|
+
}
|
|
2822
|
+
recordError(message, category) {
|
|
2823
|
+
const entry = {
|
|
2824
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2825
|
+
message: redactErrorMessage(message),
|
|
2826
|
+
...category !== void 0 ? { category } : {}
|
|
2827
|
+
};
|
|
2828
|
+
this.buffer.push(entry);
|
|
2829
|
+
if (this.buffer.length > this.maxSize) this.buffer.shift();
|
|
2830
|
+
}
|
|
2831
|
+
getRecentErrors(limit) {
|
|
2832
|
+
const cap = Math.min(Math.max(1, limit), DEFAULT_ERROR_BUFFER_SIZE);
|
|
2833
|
+
return this.buffer.length > cap ? this.buffer.slice(this.buffer.length - cap) : [...this.buffer];
|
|
2834
|
+
}
|
|
2835
|
+
recordAttach() {
|
|
2836
|
+
this.lastAttachAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2837
|
+
}
|
|
2838
|
+
recordDetach() {
|
|
2839
|
+
this.lastDetachAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2840
|
+
}
|
|
2841
|
+
getLastAttachAt() {
|
|
2842
|
+
return this.lastAttachAt;
|
|
2843
|
+
}
|
|
2844
|
+
getLastDetachAt() {
|
|
2845
|
+
return this.lastDetachAt;
|
|
2846
|
+
}
|
|
2847
|
+
};
|
|
2848
|
+
/**
|
|
2849
|
+
* Reads the `@modelcontextprotocol/sdk` package version from the installed
|
|
2850
|
+
* package's `package.json`. Returns `null` on any error (missing file, JSON
|
|
2851
|
+
* parse failure, etc.) — diagnostics must never throw.
|
|
2852
|
+
*
|
|
2853
|
+
* Node-only — uses dynamic `import()` so it does not pollute the browser
|
|
2854
|
+
* module graph.
|
|
2855
|
+
*/
|
|
2856
|
+
async function readMcpSdkVersion() {
|
|
2857
|
+
try {
|
|
2858
|
+
const { createRequire } = await import("node:module");
|
|
2859
|
+
const pkgPath = createRequire(import.meta.url).resolve("@modelcontextprotocol/sdk/package.json");
|
|
2860
|
+
const { readFileSync } = await import("node:fs");
|
|
2861
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
2862
|
+
const parsed = JSON.parse(raw);
|
|
2863
|
+
return typeof parsed.version === "string" ? parsed.version : null;
|
|
2864
|
+
} catch {
|
|
2865
|
+
return null;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Returns the `@ait-co/devtools` package version injected at build time via
|
|
2870
|
+
* the `__VERSION__` define. Returns `null` when the global is absent (e.g. in
|
|
2871
|
+
* some test environments that skip the build step).
|
|
2872
|
+
*/
|
|
2873
|
+
function readDevtoolsVersion() {
|
|
2874
|
+
try {
|
|
2875
|
+
const v = globalThis.__VERSION__;
|
|
2876
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
2877
|
+
} catch {
|
|
2878
|
+
return null;
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
/**
|
|
2882
|
+
* Builds the `get_diagnostics` response. Pure — does not throw; missing data
|
|
2883
|
+
* fields are `null`. Async because `readMcpSdkVersion` needs `import()`.
|
|
2884
|
+
*
|
|
2885
|
+
* SECRET-HANDLING:
|
|
2886
|
+
* - `recentErrors` messages are already redacted by `recordError` (via
|
|
2887
|
+
* `redactErrorMessage`). No additional redaction needed here.
|
|
2888
|
+
* - `tunnel.wssUrl` is a public cloudflared hostname — not a secret.
|
|
2889
|
+
* - Lock file data contains only pid + startedAt + wssUrl — no secrets.
|
|
2890
|
+
*/
|
|
2891
|
+
async function getDiagnostics(input) {
|
|
2892
|
+
const { tunnel, connection, env, envReason, collector, readLock: readLockFn, recentErrorsLimit = 10, getMcpVersion = readMcpSdkVersion } = input;
|
|
2893
|
+
const [mcpVersion, devtoolsVersion] = await Promise.all([getMcpVersion(), Promise.resolve(readDevtoolsVersion())]);
|
|
2894
|
+
const lockData = readLockFn();
|
|
2895
|
+
const serverLockHolder = lockData ? {
|
|
2896
|
+
pid: lockData.pid,
|
|
2897
|
+
startedAt: lockData.startedAt,
|
|
2898
|
+
wssUrl: lockData.wssUrl
|
|
2899
|
+
} : null;
|
|
2900
|
+
const tunnelInfo = {
|
|
2901
|
+
up: tunnel.up,
|
|
2902
|
+
wssUrl: tunnel.wssUrl,
|
|
2903
|
+
pid: lockData?.pid ?? null,
|
|
2904
|
+
startedAt: lockData?.startedAt ?? null
|
|
2905
|
+
};
|
|
2906
|
+
let pages = null;
|
|
2907
|
+
if (connection !== void 0) try {
|
|
2908
|
+
pages = listPages(connection, tunnel);
|
|
2909
|
+
} catch {}
|
|
2910
|
+
const limit = Math.min(Math.max(1, recentErrorsLimit), 50);
|
|
2911
|
+
const recentErrors = collector.getRecentErrors(limit);
|
|
2912
|
+
return {
|
|
2913
|
+
mcpVersion,
|
|
2914
|
+
devtoolsVersion,
|
|
2915
|
+
tunnel: tunnelInfo,
|
|
2916
|
+
pages,
|
|
2917
|
+
lastAttachAt: collector.getLastAttachAt(),
|
|
2918
|
+
lastDetachAt: collector.getLastDetachAt(),
|
|
2919
|
+
recentErrors,
|
|
2920
|
+
environment: {
|
|
2921
|
+
env,
|
|
2922
|
+
reason: envReason
|
|
2923
|
+
},
|
|
2924
|
+
serverLockHolder
|
|
2925
|
+
};
|
|
2926
|
+
}
|
|
2084
2927
|
//#endregion
|
|
2085
2928
|
//#region src/mcp/totp.ts
|
|
2086
2929
|
/**
|
|
@@ -2171,6 +3014,15 @@ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
|
|
|
2171
3014
|
* and would be stale by the time a human scans. The in-app deep-link builder
|
|
2172
3015
|
* splices the live code at attach time.
|
|
2173
3016
|
*
|
|
3017
|
+
* Tunnel health probe (`TunnelHealthProbe`):
|
|
3018
|
+
* After the tunnel is up, a periodic HTTP HEAD probe hits the tunnel's
|
|
3019
|
+
* `https://` URL every `probeIntervalMs` (default 60 s). Two consecutive
|
|
3020
|
+
* failures trigger a reissue attempt (spawn a new cloudflared quick tunnel
|
|
3021
|
+
* and redirect traffic). After `MAX_REISSUE_ATTEMPTS` (3) consecutive
|
|
3022
|
+
* reissue failures, the probe gives up and marks the tunnel permanently
|
|
3023
|
+
* dropped — `tunnelStatus.up` becomes false with `droppedAt` set. The caller
|
|
3024
|
+
* should surface this to the agent so the user knows to restart the server.
|
|
3025
|
+
*
|
|
2174
3026
|
* SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear
|
|
2175
3027
|
* in any output from this module.
|
|
2176
3028
|
*
|
|
@@ -2293,6 +3145,118 @@ async function printAttachBanner(input) {
|
|
|
2293
3145
|
const banner = await renderAttachBanner(input);
|
|
2294
3146
|
process.stderr.write(`${banner}\n`);
|
|
2295
3147
|
}
|
|
3148
|
+
/**
|
|
3149
|
+
* Probes `https://` URL with an HTTP HEAD request.
|
|
3150
|
+
* Returns `true` when the server responds (any HTTP status), `false` on
|
|
3151
|
+
* network error or timeout.
|
|
3152
|
+
*
|
|
3153
|
+
* We treat any HTTP response (including 4xx/5xx) as "tunnel alive" because
|
|
3154
|
+
* cloudflared itself responds to the HEAD — if the tunnel process died, the
|
|
3155
|
+
* request fails at the network level rather than returning a status code.
|
|
3156
|
+
*
|
|
3157
|
+
* @param httpsUrl - The `https://` tunnel URL to probe.
|
|
3158
|
+
* @param timeoutMs - Abort timeout in ms. Default 10 000.
|
|
3159
|
+
*/
|
|
3160
|
+
async function probeTunnel(httpsUrl, timeoutMs = 1e4) {
|
|
3161
|
+
const { default: https } = await import("node:https");
|
|
3162
|
+
return new Promise((resolve) => {
|
|
3163
|
+
const url = new URL(httpsUrl);
|
|
3164
|
+
const timer = setTimeout(() => {
|
|
3165
|
+
req.destroy();
|
|
3166
|
+
resolve(false);
|
|
3167
|
+
}, timeoutMs);
|
|
3168
|
+
const req = https.request({
|
|
3169
|
+
hostname: url.hostname,
|
|
3170
|
+
port: 443,
|
|
3171
|
+
path: url.pathname || "/",
|
|
3172
|
+
method: "HEAD"
|
|
3173
|
+
}, (_res) => {
|
|
3174
|
+
clearTimeout(timer);
|
|
3175
|
+
_res.resume();
|
|
3176
|
+
resolve(true);
|
|
3177
|
+
});
|
|
3178
|
+
req.on("error", () => {
|
|
3179
|
+
clearTimeout(timer);
|
|
3180
|
+
resolve(false);
|
|
3181
|
+
});
|
|
3182
|
+
req.end();
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
3185
|
+
/**
|
|
3186
|
+
* Starts a periodic health probe for a cloudflared quick tunnel.
|
|
3187
|
+
*
|
|
3188
|
+
* Every `probeIntervalMs` the probe sends an HTTP HEAD request to the tunnel's
|
|
3189
|
+
* `https://` URL. When `failuresBeforeReissue` consecutive failures are
|
|
3190
|
+
* detected, it attempts to spawn a new tunnel (up to `MAX_REISSUE_ATTEMPTS`
|
|
3191
|
+
* times). On success the caller is notified via `onReissue`; on permanent
|
|
3192
|
+
* failure via `onPermanentDrop`.
|
|
3193
|
+
*
|
|
3194
|
+
* @returns `stop` — call during server shutdown to clear the probe interval.
|
|
3195
|
+
*/
|
|
3196
|
+
function startTunnelHealthProbe(initialTunnel, localPort, options) {
|
|
3197
|
+
const { probeIntervalMs = 6e4, failuresBeforeReissue = 2, onReissue, onPermanentDrop, log = (msg) => process.stderr.write(msg), probe = probeTunnel, spawnTunnel = startQuickTunnel } = options;
|
|
3198
|
+
let currentTunnel = initialTunnel;
|
|
3199
|
+
let consecutiveFailures = 0;
|
|
3200
|
+
let reissueAttempts = 0;
|
|
3201
|
+
let stopped = false;
|
|
3202
|
+
const handle = setInterval(() => {
|
|
3203
|
+
(async () => {
|
|
3204
|
+
if (stopped) return;
|
|
3205
|
+
const httpsUrl = currentTunnel.url;
|
|
3206
|
+
if (await probe(httpsUrl)) {
|
|
3207
|
+
if (consecutiveFailures > 0) log("[ait-debug] tunnel health probe: tunnel recovered\n");
|
|
3208
|
+
consecutiveFailures = 0;
|
|
3209
|
+
reissueAttempts = 0;
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
consecutiveFailures += 1;
|
|
3213
|
+
log(`[ait-debug] tunnel health probe: failure ${consecutiveFailures}/${failuresBeforeReissue} (url=${httpsUrl})\n`);
|
|
3214
|
+
if (consecutiveFailures < failuresBeforeReissue) return;
|
|
3215
|
+
reissueAttempts += 1;
|
|
3216
|
+
if (reissueAttempts > 3) return;
|
|
3217
|
+
log(`[ait-debug] tunnel drop detected — reissuing (attempt ${reissueAttempts}/3)\n`);
|
|
3218
|
+
try {
|
|
3219
|
+
const newTunnel = await spawnTunnel(localPort);
|
|
3220
|
+
try {
|
|
3221
|
+
currentTunnel.stop();
|
|
3222
|
+
} catch {}
|
|
3223
|
+
currentTunnel = newTunnel;
|
|
3224
|
+
consecutiveFailures = 0;
|
|
3225
|
+
log(`[ait-debug] tunnel reissued — new relay: ${newTunnel.wssUrl}\n`);
|
|
3226
|
+
onReissue(newTunnel);
|
|
3227
|
+
} catch (err) {
|
|
3228
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3229
|
+
log(`[ait-debug] tunnel reissue attempt ${reissueAttempts} failed: ${message}\n`);
|
|
3230
|
+
if (reissueAttempts >= 3) {
|
|
3231
|
+
clearInterval(handle);
|
|
3232
|
+
stopped = true;
|
|
3233
|
+
const droppedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3234
|
+
log(`[ait-debug] tunnel permanently dropped after 3 reissue attempts — restart the debug server to continue (npx @ait-co/devtools devtools-mcp).
|
|
3235
|
+
`);
|
|
3236
|
+
onPermanentDrop(droppedAt);
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
})();
|
|
3240
|
+
}, probeIntervalMs);
|
|
3241
|
+
return { stop() {
|
|
3242
|
+
stopped = true;
|
|
3243
|
+
clearInterval(handle);
|
|
3244
|
+
} };
|
|
3245
|
+
}
|
|
3246
|
+
/**
|
|
3247
|
+
* Builds a `TunnelStatus` snapshot that includes drop state.
|
|
3248
|
+
*
|
|
3249
|
+
* Convenience helper for callers (debug-server) that maintain a mutable
|
|
3250
|
+
* `tunnelStatus` object — keeps the shape construction in one place.
|
|
3251
|
+
*/
|
|
3252
|
+
function makeTunnelStatus(up, wssUrl, droppedAt = null, reissueAttempts = 0) {
|
|
3253
|
+
return {
|
|
3254
|
+
up,
|
|
3255
|
+
wssUrl,
|
|
3256
|
+
droppedAt,
|
|
3257
|
+
reissueAttempts
|
|
3258
|
+
};
|
|
3259
|
+
}
|
|
2296
3260
|
//#endregion
|
|
2297
3261
|
//#region src/mcp/debug-server.ts
|
|
2298
3262
|
/**
|
|
@@ -2339,6 +3303,65 @@ async function printAttachBanner(input) {
|
|
|
2339
3303
|
* Node-only.
|
|
2340
3304
|
*/
|
|
2341
3305
|
/**
|
|
3306
|
+
* Parses `_deploymentId` from the query string of a scheme URL.
|
|
3307
|
+
*
|
|
3308
|
+
* Returns `null` when the param is absent or empty — callers treat that as
|
|
3309
|
+
* "no deploymentId filter; match on presence only" and fall back to the
|
|
3310
|
+
* original `attachedPages.length > 0` condition.
|
|
3311
|
+
*
|
|
3312
|
+
* SECRET-HANDLING: deploymentId is a public identifier and may appear in
|
|
3313
|
+
* debug output. Never confuse it with TOTP secrets or relay tunnel URLs.
|
|
3314
|
+
*/
|
|
3315
|
+
function extractDeploymentId(schemeUrl) {
|
|
3316
|
+
try {
|
|
3317
|
+
const qIndex = schemeUrl.indexOf("?");
|
|
3318
|
+
if (qIndex === -1) return null;
|
|
3319
|
+
const id = new URLSearchParams(schemeUrl.slice(qIndex + 1)).get("_deploymentId");
|
|
3320
|
+
return id && id.length > 0 ? id : null;
|
|
3321
|
+
} catch {
|
|
3322
|
+
return null;
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
/**
|
|
3326
|
+
* Waits for the first target matching `filterFn` to attach, using the
|
|
3327
|
+
* event-driven `waitForFirstTarget()` on `ChiiCdpConnection` instances, or
|
|
3328
|
+
* falling back to a polling loop for generic `CdpConnection` fakes (tests).
|
|
3329
|
+
*
|
|
3330
|
+
* This eliminates the polling-only race that previously caused `wait_for_attach`
|
|
3331
|
+
* to resolve before the relay had observed the first inbound CDP message from
|
|
3332
|
+
* the phone.
|
|
3333
|
+
*
|
|
3334
|
+
* @param connection - The CDP connection (production or fake).
|
|
3335
|
+
* @param filterFn - Resolves when this predicate is satisfied.
|
|
3336
|
+
* @param timeoutMs - Maximum wait time in ms.
|
|
3337
|
+
* @param pollIntervalMs - Fallback poll interval for non-ChiiCdpConnection.
|
|
3338
|
+
*/
|
|
3339
|
+
function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs = 1e3) {
|
|
3340
|
+
if (connection instanceof ChiiCdpConnection) return connection.waitForFirstTarget(filterFn, timeoutMs, pollIntervalMs);
|
|
3341
|
+
return new Promise((resolve, reject) => {
|
|
3342
|
+
const deadline = Date.now() + timeoutMs;
|
|
3343
|
+
let settled = false;
|
|
3344
|
+
const poll = setInterval(() => {
|
|
3345
|
+
const targets = connection.listTargets();
|
|
3346
|
+
if (filterFn(targets)) {
|
|
3347
|
+
settled = true;
|
|
3348
|
+
clearInterval(poll);
|
|
3349
|
+
resolve(targets);
|
|
3350
|
+
} else if (Date.now() >= deadline) {
|
|
3351
|
+
settled = true;
|
|
3352
|
+
clearInterval(poll);
|
|
3353
|
+
reject(/* @__PURE__ */ new Error(`waitForAttachWithEvents: 타임아웃 (${timeoutMs}ms)`));
|
|
3354
|
+
}
|
|
3355
|
+
}, pollIntervalMs);
|
|
3356
|
+
const targets = connection.listTargets();
|
|
3357
|
+
if (!settled && filterFn(targets)) {
|
|
3358
|
+
settled = true;
|
|
3359
|
+
clearInterval(poll);
|
|
3360
|
+
resolve(targets);
|
|
3361
|
+
}
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
3364
|
+
/**
|
|
2342
3365
|
* Builds the debug-mode MCP server around an injected CDP connection + AIT
|
|
2343
3366
|
* source + tunnel status getter. Pure wiring — does not start a relay or
|
|
2344
3367
|
* tunnel, which is what makes the tool surface unit-testable.
|
|
@@ -2351,13 +3374,19 @@ async function printAttachBanner(input) {
|
|
|
2351
3374
|
* naturally via `enableDomains`). The tier only controls visibility.
|
|
2352
3375
|
*/
|
|
2353
3376
|
function createDebugServer(deps) {
|
|
2354
|
-
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer } = deps;
|
|
3377
|
+
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep } = deps;
|
|
3378
|
+
const resolveEnvironment = getEnvDep ?? (() => getEnvironment({ connection }));
|
|
3379
|
+
const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({ connection }));
|
|
3380
|
+
const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
|
|
2355
3381
|
const server = new Server({
|
|
2356
3382
|
name: "ait-debug",
|
|
2357
|
-
version: "0.1.
|
|
3383
|
+
version: "0.1.44"
|
|
2358
3384
|
}, { capabilities: { tools: { listChanged: true } } });
|
|
2359
3385
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
2360
|
-
|
|
3386
|
+
const env = resolveEnvironment();
|
|
3387
|
+
const attached = connection.listTargets().length > 0;
|
|
3388
|
+
const envFiltered = filterToolsByEnvironment(DEBUG_TOOL_DEFINITIONS, env);
|
|
3389
|
+
return { tools: attached ? envFiltered.map((tool) => ({ ...tool })) : envFiltered.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
|
|
2361
3390
|
});
|
|
2362
3391
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2363
3392
|
const name = request.params.name;
|
|
@@ -2368,6 +3397,19 @@ function createDebugServer(deps) {
|
|
|
2368
3397
|
}],
|
|
2369
3398
|
isError: true
|
|
2370
3399
|
};
|
|
3400
|
+
const env = resolveEnvironment();
|
|
3401
|
+
if (!isToolAvailableIn(name, env)) {
|
|
3402
|
+
const requiredEnv = getToolAvailability(name) ?? "unknown";
|
|
3403
|
+
const envReason = resolveEnvironmentReason();
|
|
3404
|
+
logWarn("tool.error", {
|
|
3405
|
+
tool: name,
|
|
3406
|
+
errorKind: "tier-filter",
|
|
3407
|
+
requiredEnv,
|
|
3408
|
+
currentEnv: env,
|
|
3409
|
+
envReason
|
|
3410
|
+
});
|
|
3411
|
+
return tierRejectionError(name, requiredEnv, env, envReason);
|
|
3412
|
+
}
|
|
2371
3413
|
if (isAitToolName(name)) try {
|
|
2372
3414
|
await connection.enableDomains();
|
|
2373
3415
|
switch (name) {
|
|
@@ -2379,78 +3421,147 @@ function createDebugServer(deps) {
|
|
|
2379
3421
|
} catch (err) {
|
|
2380
3422
|
return errorResult(err, name);
|
|
2381
3423
|
}
|
|
3424
|
+
if (name === "get_diagnostics") try {
|
|
3425
|
+
const rawLimit = request.params.arguments?.recent_errors_limit;
|
|
3426
|
+
const recentErrorsLimit = typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 10;
|
|
3427
|
+
return jsonResult$1(await getDiagnostics({
|
|
3428
|
+
tunnel: getTunnelStatus(),
|
|
3429
|
+
connection,
|
|
3430
|
+
env: resolveEnvironment(),
|
|
3431
|
+
envReason: resolveEnvironmentReason(),
|
|
3432
|
+
collector,
|
|
3433
|
+
readLock: readServerLock,
|
|
3434
|
+
recentErrorsLimit
|
|
3435
|
+
}));
|
|
3436
|
+
} catch (err) {
|
|
3437
|
+
return errorResult(err, name);
|
|
3438
|
+
}
|
|
2382
3439
|
if (name === "build_attach_url") {
|
|
2383
3440
|
const schemeUrl = request.params.arguments?.scheme_url;
|
|
2384
|
-
if (typeof schemeUrl !== "string" || schemeUrl === "") return
|
|
2385
|
-
content: [{
|
|
2386
|
-
type: "text",
|
|
2387
|
-
text: "build_attach_url requires a non-empty scheme_url."
|
|
2388
|
-
}],
|
|
2389
|
-
isError: true
|
|
2390
|
-
};
|
|
3441
|
+
if (typeof schemeUrl !== "string" || schemeUrl === "") return mcpError("build_attach_url: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요.");
|
|
2391
3442
|
const waitForAttach = request.params.arguments?.wait_for_attach === true;
|
|
2392
3443
|
const openInBrowser = request.params.arguments?.open_in_browser !== false;
|
|
3444
|
+
const deploymentId = extractDeploymentId(schemeUrl);
|
|
3445
|
+
if (!deploymentId) logInfo("tool.call", {
|
|
3446
|
+
tool: "build_attach_url",
|
|
3447
|
+
msg: "no _deploymentId in scheme_url; matching on presence only"
|
|
3448
|
+
});
|
|
3449
|
+
/** Returns true when the page list satisfies the attach condition. */
|
|
3450
|
+
const isMatchingPage = (pages) => {
|
|
3451
|
+
if (pages.length === 0) return false;
|
|
3452
|
+
if (deploymentId === null) return true;
|
|
3453
|
+
return pages.some((p) => p.url.includes(deploymentId));
|
|
3454
|
+
};
|
|
3455
|
+
/** Builds a timeout error message with diagnostic context. */
|
|
3456
|
+
const buildTimeoutError = (baseText, timeoutSec, observed) => {
|
|
3457
|
+
const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
|
|
3458
|
+
const observedNote = observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : "";
|
|
3459
|
+
return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
|
|
3460
|
+
};
|
|
2393
3461
|
try {
|
|
2394
3462
|
const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
|
|
2395
3463
|
const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
|
|
2396
3464
|
const header = "This tool result is shown to the user directly — do NOT re-print the QR below in your reply (it wastes output tokens). Just tell the user to scan the QR in this output (Ctrl+O to expand if collapsed).";
|
|
2397
|
-
|
|
3465
|
+
const guiAvailable = canOpenBrowser();
|
|
3466
|
+
if (openInBrowser && !guiAvailable) {
|
|
3467
|
+
const headlessNote = "[open_in_browser] GUI 환경이 감지되지 않았습니다 (headless/remote 환경). open_in_browser=false로 자동 폴백합니다. 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
|
|
3468
|
+
const qrHeadless = await renderQr(attachUrl);
|
|
3469
|
+
const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
|
|
3470
|
+
attachUrl,
|
|
3471
|
+
relayUrl
|
|
3472
|
+
}, null, 2)}\n\n${qrHeadless}`;
|
|
3473
|
+
if (!waitForAttach) return { content: [{
|
|
3474
|
+
type: "text",
|
|
3475
|
+
text: headlessText
|
|
3476
|
+
}] };
|
|
3477
|
+
let attachedPagesHl = [];
|
|
3478
|
+
try {
|
|
3479
|
+
attachedPagesHl = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
|
|
3480
|
+
} catch {
|
|
3481
|
+
attachedPagesHl = connection.listTargets();
|
|
3482
|
+
return {
|
|
3483
|
+
content: [{
|
|
3484
|
+
type: "text",
|
|
3485
|
+
text: buildTimeoutError(headlessText, waitForAttachTimeoutMs / 1e3, attachedPagesHl)
|
|
3486
|
+
}],
|
|
3487
|
+
isError: true
|
|
3488
|
+
};
|
|
3489
|
+
}
|
|
3490
|
+
const pagesResultHl = listPages(connection, getTunnelStatus());
|
|
3491
|
+
return { content: [{
|
|
3492
|
+
type: "text",
|
|
3493
|
+
text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
|
|
3494
|
+
}] };
|
|
3495
|
+
}
|
|
3496
|
+
if (openInBrowser && guiAvailable && qrHttpServer) {
|
|
2398
3497
|
const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
|
|
2399
3498
|
if (browserResult.opened) {
|
|
2400
|
-
const
|
|
3499
|
+
const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
|
|
3500
|
+
const openResult = {
|
|
3501
|
+
attempted: true,
|
|
3502
|
+
succeeded: true,
|
|
3503
|
+
...browserResult.retried ? { retried: true } : {}
|
|
3504
|
+
};
|
|
3505
|
+
const shortText = `${warningPrefix}${header}\n${JSON.stringify({
|
|
3506
|
+
relayUrl,
|
|
3507
|
+
openResult
|
|
3508
|
+
}, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
|
|
2401
3509
|
if (!waitForAttach) return { content: [{
|
|
2402
3510
|
type: "text",
|
|
2403
3511
|
text: shortText
|
|
2404
3512
|
}] };
|
|
2405
|
-
const POLL_INTERVAL_MS = 1e3;
|
|
2406
|
-
const TIMEOUT_MS = waitForAttachTimeoutMs;
|
|
2407
|
-
const deadline = Date.now() + TIMEOUT_MS;
|
|
2408
3513
|
let attachedPages = [];
|
|
2409
|
-
|
|
3514
|
+
try {
|
|
3515
|
+
attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
|
|
3516
|
+
} catch {
|
|
2410
3517
|
attachedPages = connection.listTargets();
|
|
2411
|
-
|
|
2412
|
-
|
|
3518
|
+
return {
|
|
3519
|
+
content: [{
|
|
3520
|
+
type: "text",
|
|
3521
|
+
text: buildTimeoutError(shortText, waitForAttachTimeoutMs / 1e3, attachedPages)
|
|
3522
|
+
}],
|
|
3523
|
+
isError: true
|
|
3524
|
+
};
|
|
2413
3525
|
}
|
|
2414
|
-
if (attachedPages.length === 0) return {
|
|
2415
|
-
content: [{
|
|
2416
|
-
type: "text",
|
|
2417
|
-
text: `${shortText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
|
|
2418
|
-
}],
|
|
2419
|
-
isError: true
|
|
2420
|
-
};
|
|
2421
3526
|
const pagesResult = listPages(connection, getTunnelStatus());
|
|
2422
3527
|
return { content: [{
|
|
2423
3528
|
type: "text",
|
|
2424
3529
|
text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
|
|
2425
3530
|
}] };
|
|
2426
3531
|
}
|
|
3532
|
+
const openResult = {
|
|
3533
|
+
attempted: true,
|
|
3534
|
+
succeeded: false,
|
|
3535
|
+
failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
|
|
3536
|
+
pngUrl: browserResult.pngUrl,
|
|
3537
|
+
...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
|
|
3538
|
+
};
|
|
2427
3539
|
const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
|
|
2428
|
-
const fallbackNote =
|
|
3540
|
+
const fallbackNote = `[open_in_browser] 브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:
|
|
3541
|
+
${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
|
|
2429
3542
|
const qr = await renderQr(attachUrl);
|
|
2430
3543
|
const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
|
|
2431
3544
|
attachUrl,
|
|
2432
|
-
relayUrl
|
|
3545
|
+
relayUrl,
|
|
3546
|
+
openResult
|
|
2433
3547
|
}, null, 2)}\n\n${qr}`;
|
|
2434
3548
|
if (!waitForAttach) return { content: [{
|
|
2435
3549
|
type: "text",
|
|
2436
3550
|
text: baseText
|
|
2437
3551
|
}] };
|
|
2438
|
-
const POLL_INTERVAL_MS_FB = 1e3;
|
|
2439
|
-
const TIMEOUT_MS_FB = waitForAttachTimeoutMs;
|
|
2440
|
-
const deadline2 = Date.now() + TIMEOUT_MS_FB;
|
|
2441
3552
|
let attachedPagesFb = [];
|
|
2442
|
-
|
|
3553
|
+
try {
|
|
3554
|
+
attachedPagesFb = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
|
|
3555
|
+
} catch {
|
|
2443
3556
|
attachedPagesFb = connection.listTargets();
|
|
2444
|
-
|
|
2445
|
-
|
|
3557
|
+
return {
|
|
3558
|
+
content: [{
|
|
3559
|
+
type: "text",
|
|
3560
|
+
text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPagesFb)
|
|
3561
|
+
}],
|
|
3562
|
+
isError: true
|
|
3563
|
+
};
|
|
2446
3564
|
}
|
|
2447
|
-
if (attachedPagesFb.length === 0) return {
|
|
2448
|
-
content: [{
|
|
2449
|
-
type: "text",
|
|
2450
|
-
text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS_FB / 1e3}s — call list_pages to retry.`
|
|
2451
|
-
}],
|
|
2452
|
-
isError: true
|
|
2453
|
-
};
|
|
2454
3565
|
const pagesResultFb = listPages(connection, getTunnelStatus());
|
|
2455
3566
|
return { content: [{
|
|
2456
3567
|
type: "text",
|
|
@@ -2466,22 +3577,19 @@ function createDebugServer(deps) {
|
|
|
2466
3577
|
type: "text",
|
|
2467
3578
|
text: baseText
|
|
2468
3579
|
}] };
|
|
2469
|
-
const POLL_INTERVAL_MS = 1e3;
|
|
2470
|
-
const TIMEOUT_MS = waitForAttachTimeoutMs;
|
|
2471
|
-
const deadline = Date.now() + TIMEOUT_MS;
|
|
2472
3580
|
let attachedPages = [];
|
|
2473
|
-
|
|
3581
|
+
try {
|
|
3582
|
+
attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
|
|
3583
|
+
} catch {
|
|
2474
3584
|
attachedPages = connection.listTargets();
|
|
2475
|
-
|
|
2476
|
-
|
|
3585
|
+
return {
|
|
3586
|
+
content: [{
|
|
3587
|
+
type: "text",
|
|
3588
|
+
text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPages)
|
|
3589
|
+
}],
|
|
3590
|
+
isError: true
|
|
3591
|
+
};
|
|
2477
3592
|
}
|
|
2478
|
-
if (attachedPages.length === 0) return {
|
|
2479
|
-
content: [{
|
|
2480
|
-
type: "text",
|
|
2481
|
-
text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
|
|
2482
|
-
}],
|
|
2483
|
-
isError: true
|
|
2484
|
-
};
|
|
2485
3593
|
const pagesResult = listPages(connection, getTunnelStatus());
|
|
2486
3594
|
return { content: [{
|
|
2487
3595
|
type: "text",
|
|
@@ -2494,15 +3602,13 @@ function createDebugServer(deps) {
|
|
|
2494
3602
|
try {
|
|
2495
3603
|
await connection.enableDomains();
|
|
2496
3604
|
} catch (err) {
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
isError: true
|
|
2505
|
-
};
|
|
3605
|
+
if (name === "list_pages") {
|
|
3606
|
+
if (connection instanceof ChiiCdpConnection) try {
|
|
3607
|
+
await connection.refreshTargets();
|
|
3608
|
+
} catch {}
|
|
3609
|
+
return jsonResult$1(listPages(connection, getTunnelStatus()));
|
|
3610
|
+
}
|
|
3611
|
+
return classifyEnableDomainError(err, name);
|
|
2506
3612
|
}
|
|
2507
3613
|
try {
|
|
2508
3614
|
switch (name) {
|
|
@@ -2512,7 +3618,11 @@ function createDebugServer(deps) {
|
|
|
2512
3618
|
return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
|
|
2513
3619
|
}
|
|
2514
3620
|
case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
|
|
2515
|
-
case "list_pages":
|
|
3621
|
+
case "list_pages":
|
|
3622
|
+
if (connection instanceof ChiiCdpConnection) try {
|
|
3623
|
+
await connection.refreshTargets();
|
|
3624
|
+
} catch {}
|
|
3625
|
+
return jsonResult$1(listPages(connection, getTunnelStatus()));
|
|
2516
3626
|
case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
|
|
2517
3627
|
case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
|
|
2518
3628
|
case "take_screenshot": {
|
|
@@ -2523,29 +3633,19 @@ function createDebugServer(deps) {
|
|
|
2523
3633
|
mimeType: shot.mimeType
|
|
2524
3634
|
}] };
|
|
2525
3635
|
}
|
|
2526
|
-
case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection));
|
|
3636
|
+
case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection, resolveEnvironment()));
|
|
2527
3637
|
case "evaluate": {
|
|
2528
3638
|
const expression = request.params.arguments?.expression;
|
|
2529
|
-
if (typeof expression !== "string" || expression === "") return
|
|
2530
|
-
content: [{
|
|
2531
|
-
type: "text",
|
|
2532
|
-
text: "evaluate requires a non-empty expression."
|
|
2533
|
-
}],
|
|
2534
|
-
isError: true
|
|
2535
|
-
};
|
|
3639
|
+
if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
|
|
2536
3640
|
return jsonResult$1(await evaluate(connection, expression));
|
|
2537
3641
|
}
|
|
2538
3642
|
case "call_sdk": {
|
|
2539
3643
|
const sdkName = request.params.arguments?.name;
|
|
2540
|
-
if (typeof sdkName !== "string" || sdkName === "") return
|
|
2541
|
-
content: [{
|
|
2542
|
-
type: "text",
|
|
2543
|
-
text: "call_sdk requires a non-empty name."
|
|
2544
|
-
}],
|
|
2545
|
-
isError: true
|
|
2546
|
-
};
|
|
3644
|
+
if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
|
|
2547
3645
|
const rawArgs = request.params.arguments?.args;
|
|
2548
|
-
|
|
3646
|
+
const sdkResult = await callSdk(connection, sdkName, Array.isArray(rawArgs) ? rawArgs : []);
|
|
3647
|
+
if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
|
|
3648
|
+
return jsonResult$1(sdkResult);
|
|
2549
3649
|
}
|
|
2550
3650
|
default: return unknownTool(name);
|
|
2551
3651
|
}
|
|
@@ -2562,22 +3662,29 @@ function jsonResult$1(value) {
|
|
|
2562
3662
|
}] };
|
|
2563
3663
|
}
|
|
2564
3664
|
function unknownTool(name) {
|
|
2565
|
-
return {
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
3665
|
+
return mcpError(`알 수 없는 tool: ${name}`);
|
|
3666
|
+
}
|
|
3667
|
+
/**
|
|
3668
|
+
* enableDomains()가 던진 에러를 4상태로 분류해 적절한 메시지를 반환한다.
|
|
3669
|
+
*
|
|
3670
|
+
* - "No mini-app page attached" → page 미attach (상태 2)
|
|
3671
|
+
* - crash/destroy/replaced 패턴 → page crash (상태 3)
|
|
3672
|
+
* - relay disconnect 패턴 → relay 연결 끊김
|
|
3673
|
+
* - 그 외 → 원본 메시지 + list_pages 안내
|
|
3674
|
+
*/
|
|
3675
|
+
function classifyEnableDomainError(err, toolName) {
|
|
3676
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3677
|
+
if (message.includes("No mini-app page attached") || message.includes("페이지가 attach 안")) return pageMissingError(toolName);
|
|
3678
|
+
if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
|
|
3679
|
+
if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket") || message.includes("Chii relay connection closed")) return relayDisconnectError(toolName);
|
|
3680
|
+
return classifyToolError(err, toolName);
|
|
2572
3681
|
}
|
|
3682
|
+
/**
|
|
3683
|
+
* CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
|
|
3684
|
+
* debug-server 내부 try/catch 블록에서 공통으로 사용한다.
|
|
3685
|
+
*/
|
|
2573
3686
|
function errorResult(err, name) {
|
|
2574
|
-
return
|
|
2575
|
-
content: [{
|
|
2576
|
-
type: "text",
|
|
2577
|
-
text: `${name} failed: ${err instanceof Error ? err.message : String(err)}\nCall list_pages to confirm a mini-app has attached over the relay.`
|
|
2578
|
-
}],
|
|
2579
|
-
isError: true
|
|
2580
|
-
};
|
|
3687
|
+
return classifyToolError(err, name);
|
|
2581
3688
|
}
|
|
2582
3689
|
/**
|
|
2583
3690
|
* Starts a polling watcher that detects the first 0→N target transition on
|
|
@@ -2588,19 +3695,28 @@ function errorResult(err, name) {
|
|
|
2588
3695
|
* `server.sendToolListChanged()` exactly once — on the first transition — then
|
|
2589
3696
|
* clears itself. Shutdown calls `stop()` to clear the interval.
|
|
2590
3697
|
*
|
|
3698
|
+
* `onFirstAttach` is called once on the 0→N transition (or immediately when
|
|
3699
|
+
* already attached). Use this to trigger side-effects such as auto-opening
|
|
3700
|
+
* Chrome DevTools (issue #282). The callback is optional; omitting it preserves
|
|
3701
|
+
* the previous behaviour exactly.
|
|
3702
|
+
*
|
|
2591
3703
|
* SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
|
|
2592
3704
|
* Only an attach-detected stderr line is emitted (no target details).
|
|
2593
3705
|
*
|
|
2594
3706
|
* @returns `stop` — call this during shutdown to clear the interval.
|
|
2595
3707
|
*/
|
|
2596
|
-
function startAttachWatcher(connection, server, intervalMs = 1e3) {
|
|
3708
|
+
function startAttachWatcher(connection, server, intervalMs = 1e3, onFirstAttach) {
|
|
2597
3709
|
let wasAttached = connection.listTargets().length > 0;
|
|
2598
|
-
if (wasAttached)
|
|
3710
|
+
if (wasAttached) {
|
|
3711
|
+
server.sendToolListChanged();
|
|
3712
|
+
onFirstAttach?.();
|
|
3713
|
+
}
|
|
2599
3714
|
const handle = setInterval(() => {
|
|
2600
3715
|
const isAttached = connection.listTargets().length > 0;
|
|
2601
3716
|
if (!wasAttached && isAttached) {
|
|
2602
3717
|
wasAttached = true;
|
|
2603
3718
|
server.sendToolListChanged();
|
|
3719
|
+
onFirstAttach?.();
|
|
2604
3720
|
clearInterval(handle);
|
|
2605
3721
|
}
|
|
2606
3722
|
}, intervalMs);
|
|
@@ -2640,6 +3756,7 @@ function buildRelayVerifyAuth() {
|
|
|
2640
3756
|
* 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
|
|
2641
3757
|
*/
|
|
2642
3758
|
async function runDebugServer(options = {}) {
|
|
3759
|
+
const lockHandle = acquireLock();
|
|
2643
3760
|
const relayPort = options.relayPort ?? 0;
|
|
2644
3761
|
const verifyAuth = buildRelayVerifyAuth();
|
|
2645
3762
|
const totpEnabled = verifyAuth !== void 0;
|
|
@@ -2647,26 +3764,45 @@ async function runDebugServer(options = {}) {
|
|
|
2647
3764
|
port: relayPort,
|
|
2648
3765
|
verifyAuth
|
|
2649
3766
|
});
|
|
3767
|
+
logInfo("server.start", {
|
|
3768
|
+
port: relay.port,
|
|
3769
|
+
totpEnabled
|
|
3770
|
+
});
|
|
2650
3771
|
let tunnel = null;
|
|
2651
|
-
let tunnelStatus =
|
|
2652
|
-
up: false,
|
|
2653
|
-
wssUrl: null
|
|
2654
|
-
};
|
|
3772
|
+
let tunnelStatus = makeTunnelStatus(false, null);
|
|
2655
3773
|
generateAttachToken();
|
|
3774
|
+
let tunnelProbe = null;
|
|
2656
3775
|
startQuickTunnel(relay.port).then((t) => {
|
|
2657
3776
|
tunnel = t;
|
|
2658
|
-
tunnelStatus =
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
3777
|
+
tunnelStatus = makeTunnelStatus(true, t.wssUrl);
|
|
3778
|
+
lockHandle.updateWssUrl(t.wssUrl);
|
|
3779
|
+
logInfo("tunnel.up", { totpEnabled });
|
|
3780
|
+
tunnelProbe = startTunnelHealthProbe(t, relay.port, {
|
|
3781
|
+
onReissue: (newTunnel) => {
|
|
3782
|
+
tunnel = newTunnel;
|
|
3783
|
+
tunnelStatus = makeTunnelStatus(true, newTunnel.wssUrl, null, 0);
|
|
3784
|
+
lockHandle.updateWssUrl(newTunnel.wssUrl);
|
|
3785
|
+
printAttachBanner({
|
|
3786
|
+
wssUrl: newTunnel.wssUrl,
|
|
3787
|
+
totpEnabled
|
|
3788
|
+
}).then(() => {
|
|
3789
|
+
logInfo("tunnel.up", {
|
|
3790
|
+
totpEnabled,
|
|
3791
|
+
reissued: true
|
|
3792
|
+
});
|
|
3793
|
+
});
|
|
3794
|
+
},
|
|
3795
|
+
onPermanentDrop: (droppedAt) => {
|
|
3796
|
+
tunnelStatus = makeTunnelStatus(false, null, droppedAt, 3);
|
|
3797
|
+
logError("tunnel.down", { msg: `tunnel permanently dropped (${droppedAt}). Restart: npx @ait-co/devtools devtools-mcp` });
|
|
3798
|
+
}
|
|
3799
|
+
});
|
|
2662
3800
|
return printAttachBanner({
|
|
2663
3801
|
wssUrl: t.wssUrl,
|
|
2664
3802
|
totpEnabled
|
|
2665
3803
|
});
|
|
2666
3804
|
}, (err) => {
|
|
2667
|
-
|
|
2668
|
-
process.stderr.write(`[ait-debug] Failed to open cloudflared quick tunnel: ${message}\n[ait-debug] The relay is up locally; attach over the public URL is unavailable until the tunnel starts.
|
|
2669
|
-
`);
|
|
3805
|
+
logError("tunnel.down", { msg: `Failed to open cloudflared quick tunnel: ${err instanceof Error ? err.message : String(err)}. The relay is up locally; attach over the public URL is unavailable until the tunnel starts.` });
|
|
2670
3806
|
});
|
|
2671
3807
|
const connection = new ChiiCdpConnection({ relayBaseUrl: relay.baseUrl });
|
|
2672
3808
|
const aitSource = new ChiiAitSource(connection);
|
|
@@ -2674,16 +3810,18 @@ async function runDebugServer(options = {}) {
|
|
|
2674
3810
|
try {
|
|
2675
3811
|
qrServer = await startQrHttpServer();
|
|
2676
3812
|
} catch (err) {
|
|
2677
|
-
|
|
2678
|
-
process.stderr.write(`[ait-debug] QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}\n`);
|
|
3813
|
+
logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
|
|
2679
3814
|
}
|
|
3815
|
+
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
3816
|
+
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
2680
3817
|
const server = createDebugServer({
|
|
2681
3818
|
connection,
|
|
2682
3819
|
aitSource,
|
|
2683
3820
|
getTunnelStatus: () => tunnelStatus,
|
|
2684
3821
|
get qrHttpServer() {
|
|
2685
3822
|
return qrServer;
|
|
2686
|
-
}
|
|
3823
|
+
},
|
|
3824
|
+
diagnosticsCollector
|
|
2687
3825
|
});
|
|
2688
3826
|
const transport = new StdioServerTransport();
|
|
2689
3827
|
let closed = false;
|
|
@@ -2692,11 +3830,13 @@ async function runDebugServer(options = {}) {
|
|
|
2692
3830
|
if (closed) return;
|
|
2693
3831
|
closed = true;
|
|
2694
3832
|
attachWatcher?.stop();
|
|
3833
|
+
tunnelProbe?.stop();
|
|
2695
3834
|
connection.close();
|
|
2696
3835
|
tunnel?.stop();
|
|
2697
3836
|
relay.close();
|
|
2698
3837
|
server.close();
|
|
2699
3838
|
qrServer?.close();
|
|
3839
|
+
lockHandle.release();
|
|
2700
3840
|
};
|
|
2701
3841
|
process.once("SIGINT", shutdown);
|
|
2702
3842
|
process.once("SIGTERM", shutdown);
|
|
@@ -2705,21 +3845,32 @@ async function runDebugServer(options = {}) {
|
|
|
2705
3845
|
if (!closed) {
|
|
2706
3846
|
closed = true;
|
|
2707
3847
|
attachWatcher?.stop();
|
|
3848
|
+
tunnelProbe?.stop();
|
|
2708
3849
|
tunnel?.stop();
|
|
3850
|
+
lockHandle.release();
|
|
2709
3851
|
}
|
|
2710
3852
|
});
|
|
2711
3853
|
process.on("uncaughtException", (err) => {
|
|
2712
|
-
|
|
3854
|
+
logError("tool.error", {
|
|
3855
|
+
msg: `uncaughtException: ${String(err)}`,
|
|
3856
|
+
errorKind: "uncaught"
|
|
3857
|
+
});
|
|
2713
3858
|
shutdown();
|
|
2714
3859
|
process.exit(1);
|
|
2715
3860
|
});
|
|
2716
3861
|
process.on("unhandledRejection", (reason) => {
|
|
2717
|
-
|
|
3862
|
+
logError("tool.error", {
|
|
3863
|
+
msg: `unhandledRejection: ${String(reason)}`,
|
|
3864
|
+
errorKind: "unhandled-rejection"
|
|
3865
|
+
});
|
|
2718
3866
|
shutdown();
|
|
2719
3867
|
process.exit(1);
|
|
2720
3868
|
});
|
|
2721
3869
|
await server.connect(transport);
|
|
2722
|
-
attachWatcher = startAttachWatcher(connection, server)
|
|
3870
|
+
attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
|
|
3871
|
+
diagnosticsCollector.recordAttach();
|
|
3872
|
+
devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({ connection }));
|
|
3873
|
+
});
|
|
2723
3874
|
}
|
|
2724
3875
|
/**
|
|
2725
3876
|
* Boots the local-browser debug stack and serves it over stdio:
|
|
@@ -2740,6 +3891,7 @@ async function runDebugServer(options = {}) {
|
|
|
2740
3891
|
* expected and noted in the PR as an explicit out-of-scope follow-up.
|
|
2741
3892
|
*/
|
|
2742
3893
|
async function runLocalDebugServer(options = {}) {
|
|
3894
|
+
const lockHandle = acquireLock();
|
|
2743
3895
|
const chromium = await launchChromium({
|
|
2744
3896
|
port: options.cdpPort ?? 0,
|
|
2745
3897
|
devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
|
|
@@ -2766,6 +3918,7 @@ async function runLocalDebugServer(options = {}) {
|
|
|
2766
3918
|
connection.close();
|
|
2767
3919
|
chromium.stop();
|
|
2768
3920
|
server.close();
|
|
3921
|
+
lockHandle.release();
|
|
2769
3922
|
};
|
|
2770
3923
|
process.once("SIGINT", shutdown);
|
|
2771
3924
|
process.once("SIGTERM", shutdown);
|
|
@@ -2775,15 +3928,24 @@ async function runLocalDebugServer(options = {}) {
|
|
|
2775
3928
|
closed = true;
|
|
2776
3929
|
attachWatcher?.stop();
|
|
2777
3930
|
chromium.stop();
|
|
3931
|
+
lockHandle.release();
|
|
2778
3932
|
}
|
|
2779
3933
|
});
|
|
2780
3934
|
process.on("uncaughtException", (err) => {
|
|
2781
|
-
|
|
3935
|
+
logError("tool.error", {
|
|
3936
|
+
msg: `uncaughtException: ${String(err)}`,
|
|
3937
|
+
errorKind: "uncaught",
|
|
3938
|
+
mode: "local"
|
|
3939
|
+
});
|
|
2782
3940
|
shutdown();
|
|
2783
3941
|
process.exit(1);
|
|
2784
3942
|
});
|
|
2785
3943
|
process.on("unhandledRejection", (reason) => {
|
|
2786
|
-
|
|
3944
|
+
logError("tool.error", {
|
|
3945
|
+
msg: `unhandledRejection: ${String(reason)}`,
|
|
3946
|
+
errorKind: "unhandled-rejection",
|
|
3947
|
+
mode: "local"
|
|
3948
|
+
});
|
|
2787
3949
|
shutdown();
|
|
2788
3950
|
process.exit(1);
|
|
2789
3951
|
});
|
|
@@ -2863,7 +4025,13 @@ var HttpAitSource = class {
|
|
|
2863
4025
|
* }
|
|
2864
4026
|
* }
|
|
2865
4027
|
*/
|
|
2866
|
-
/**
|
|
4028
|
+
/**
|
|
4029
|
+
* Tool descriptors served by the dev-mode server.
|
|
4030
|
+
*
|
|
4031
|
+
* All dev-mode tools are Tier C (both envs) per RFC #277 — the dev-mode server
|
|
4032
|
+
* itself is the mock-side embodiment of those Tier C tools. `availableIn` is
|
|
4033
|
+
* declared so the surface stays consistent with the debug-mode registry.
|
|
4034
|
+
*/
|
|
2867
4035
|
const DEV_TOOL_DEFINITIONS = [
|
|
2868
4036
|
{
|
|
2869
4037
|
name: "AIT.getMockState",
|
|
@@ -2872,7 +4040,8 @@ const DEV_TOOL_DEFINITIONS = [
|
|
|
2872
4040
|
type: "object",
|
|
2873
4041
|
properties: {},
|
|
2874
4042
|
required: []
|
|
2875
|
-
}
|
|
4043
|
+
},
|
|
4044
|
+
availableIn: "both"
|
|
2876
4045
|
},
|
|
2877
4046
|
{
|
|
2878
4047
|
name: "AIT.getOperationalEnvironment",
|
|
@@ -2881,7 +4050,8 @@ const DEV_TOOL_DEFINITIONS = [
|
|
|
2881
4050
|
type: "object",
|
|
2882
4051
|
properties: {},
|
|
2883
4052
|
required: []
|
|
2884
|
-
}
|
|
4053
|
+
},
|
|
4054
|
+
availableIn: "both"
|
|
2885
4055
|
},
|
|
2886
4056
|
{
|
|
2887
4057
|
name: "AIT.getSdkCallHistory",
|
|
@@ -2890,7 +4060,8 @@ const DEV_TOOL_DEFINITIONS = [
|
|
|
2890
4060
|
type: "object",
|
|
2891
4061
|
properties: {},
|
|
2892
4062
|
required: []
|
|
2893
|
-
}
|
|
4063
|
+
},
|
|
4064
|
+
availableIn: "both"
|
|
2894
4065
|
},
|
|
2895
4066
|
{
|
|
2896
4067
|
name: "devtools_get_mock_state",
|
|
@@ -2899,7 +4070,8 @@ const DEV_TOOL_DEFINITIONS = [
|
|
|
2899
4070
|
type: "object",
|
|
2900
4071
|
properties: {},
|
|
2901
4072
|
required: []
|
|
2902
|
-
}
|
|
4073
|
+
},
|
|
4074
|
+
availableIn: "both"
|
|
2903
4075
|
}
|
|
2904
4076
|
];
|
|
2905
4077
|
const DEV_TOOL_NAMES = new Set(DEV_TOOL_DEFINITIONS.map((t) => t.name));
|
|
@@ -2909,47 +4081,23 @@ function createDevServer(deps = {}) {
|
|
|
2909
4081
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
2910
4082
|
const server = new Server({
|
|
2911
4083
|
name: "ait-devtools",
|
|
2912
|
-
version: "0.1.
|
|
4084
|
+
version: "0.1.44"
|
|
2913
4085
|
}, { capabilities: { tools: {} } });
|
|
2914
4086
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
2915
4087
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2916
4088
|
const name = request.params.name;
|
|
2917
|
-
if (!DEV_TOOL_NAMES.has(name)) return {
|
|
2918
|
-
content: [{
|
|
2919
|
-
type: "text",
|
|
2920
|
-
text: `Unknown tool: ${name}`
|
|
2921
|
-
}],
|
|
2922
|
-
isError: true
|
|
2923
|
-
};
|
|
4089
|
+
if (!DEV_TOOL_NAMES.has(name)) return mcpError(`알 수 없는 tool: ${name}`);
|
|
2924
4090
|
try {
|
|
2925
4091
|
const effective = name === "devtools_get_mock_state" ? "AIT.getMockState" : name;
|
|
2926
|
-
if (!isAitToolName(effective)) return {
|
|
2927
|
-
content: [{
|
|
2928
|
-
type: "text",
|
|
2929
|
-
text: `Unknown tool: ${name}`
|
|
2930
|
-
}],
|
|
2931
|
-
isError: true
|
|
2932
|
-
};
|
|
4092
|
+
if (!isAitToolName(effective)) return mcpError(`알 수 없는 tool: ${name}`);
|
|
2933
4093
|
switch (effective) {
|
|
2934
4094
|
case "AIT.getMockState": return jsonResult(await getMockState(aitSource));
|
|
2935
4095
|
case "AIT.getOperationalEnvironment": return jsonResult(await getOperationalEnvironment(aitSource));
|
|
2936
4096
|
case "AIT.getSdkCallHistory": return jsonResult(await getSdkCallHistory(aitSource));
|
|
2937
|
-
default: return {
|
|
2938
|
-
content: [{
|
|
2939
|
-
type: "text",
|
|
2940
|
-
text: `Unknown tool: ${name}`
|
|
2941
|
-
}],
|
|
2942
|
-
isError: true
|
|
2943
|
-
};
|
|
4097
|
+
default: return mcpError(`알 수 없는 tool: ${name}`);
|
|
2944
4098
|
}
|
|
2945
4099
|
} catch (err) {
|
|
2946
|
-
return {
|
|
2947
|
-
content: [{
|
|
2948
|
-
type: "text",
|
|
2949
|
-
text: `${err instanceof Error ? err.message : String(err)}\nIs the Vite dev server running with the @ait-co/devtools unplugin option \`mcp: true\`? Is AIT_DEVTOOLS_URL set correctly?`
|
|
2950
|
-
}],
|
|
2951
|
-
isError: true
|
|
2952
|
-
};
|
|
4100
|
+
return mcpError(`${name} 실패: ${err instanceof Error ? err.message : String(err)}\nVite dev 서버가 @ait-co/devtools unplugin \`mcp: true\` 옵션으로 실행 중인지 확인하세요. AIT_DEVTOOLS_URL 환경변수가 올바르게 설정됐는지도 확인하세요.`);
|
|
2953
4101
|
}
|
|
2954
4102
|
});
|
|
2955
4103
|
return server;
|