@ait-co/devtools 0.1.43 → 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 +668 -139
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +35 -30
- package/dist/mcp/server.js.map +1 -1
- package/dist/panel/index.js +2 -2
- package/package.json +1 -1
package/dist/mcp/cli.js
CHANGED
|
@@ -53,6 +53,98 @@ var ChiiAitSource = class {
|
|
|
53
53
|
}
|
|
54
54
|
};
|
|
55
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
|
|
56
148
|
//#region src/mcp/chii-connection.ts
|
|
57
149
|
/**
|
|
58
150
|
* Production `CdpConnection` backed by the local Chii relay.
|
|
@@ -176,7 +268,7 @@ var ChiiCdpConnection = class {
|
|
|
176
268
|
}
|
|
177
269
|
if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
|
|
178
270
|
const prevId = this.activeTargetId;
|
|
179
|
-
|
|
271
|
+
logInfo("page.detached", { prevTargetId: prevId });
|
|
180
272
|
this.evictTarget(prevId);
|
|
181
273
|
}
|
|
182
274
|
this.targets.clear();
|
|
@@ -824,6 +916,88 @@ function getEnvironmentReason(input = {}) {
|
|
|
824
916
|
return "default-mock";
|
|
825
917
|
}
|
|
826
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
|
|
827
1001
|
//#region src/mcp/local-connection.ts
|
|
828
1002
|
/**
|
|
829
1003
|
* Local-browser `CdpConnection` — attaches directly to a Chromium instance
|
|
@@ -1427,6 +1601,15 @@ function removeLock(lockPath) {
|
|
|
1427
1601
|
} catch {}
|
|
1428
1602
|
}
|
|
1429
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
|
+
/**
|
|
1430
1613
|
* Attempts to acquire the server lock.
|
|
1431
1614
|
*
|
|
1432
1615
|
* - If no lock exists (or the lock is stale): writes a new lock and returns a
|
|
@@ -1757,7 +1940,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1757
1940
|
},
|
|
1758
1941
|
{
|
|
1759
1942
|
name: "list_pages",
|
|
1760
|
-
description: "Returns the single active page (at most one) the relay sees attached. When a second page attaches, the previous one is evicted (last-attach wins — single-attach model). The result includes `singleAttachModel: true` so the agent knows the array is always 0 or 1 entries. Also returns whether the cloudflared tunnel is up and the public wss relay URL. Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from that target — useful to detect stale entries when the phone app backgrounded). The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint to re-attach. Call this first to confirm a page is attached before reading console/network.",
|
|
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.",
|
|
1761
1944
|
inputSchema: {
|
|
1762
1945
|
type: "object",
|
|
1763
1946
|
properties: {},
|
|
@@ -1903,6 +2086,19 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1903
2086
|
required: []
|
|
1904
2087
|
},
|
|
1905
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"
|
|
1906
2102
|
}
|
|
1907
2103
|
];
|
|
1908
2104
|
const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
|
|
@@ -1946,7 +2142,11 @@ function filterToolsByEnvironment(tools, env) {
|
|
|
1946
2142
|
* All other tools require an attached page (`enableDomains` must succeed) and
|
|
1947
2143
|
* are only advertised in `tools/list` once a target appears.
|
|
1948
2144
|
*/
|
|
1949
|
-
const BOOTSTRAP_TOOL_NAMES = new Set([
|
|
2145
|
+
const BOOTSTRAP_TOOL_NAMES = new Set([
|
|
2146
|
+
"build_attach_url",
|
|
2147
|
+
"get_diagnostics",
|
|
2148
|
+
"list_pages"
|
|
2149
|
+
]);
|
|
1950
2150
|
/** Renders a CDP `RemoteObject` console arg to a stable display string. */
|
|
1951
2151
|
function renderRemoteObject(arg) {
|
|
1952
2152
|
if (arg.value !== void 0) {
|
|
@@ -2058,7 +2258,7 @@ function listPages(connection, tunnel) {
|
|
|
2058
2258
|
* the scheme authority which is in the caller's input, not ours to own).
|
|
2059
2259
|
*/
|
|
2060
2260
|
function buildAttachUrl(schemeUrl, tunnel) {
|
|
2061
|
-
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로 터널 상태를 다시 확인하세요.");
|
|
2062
2262
|
const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
|
|
2063
2263
|
return {
|
|
2064
2264
|
attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
|
|
@@ -2173,8 +2373,9 @@ function isLaunchFailureStderr(stderr) {
|
|
|
2173
2373
|
/**
|
|
2174
2374
|
* 로컬 HTTP 서버 URL(`http://127.0.0.1:<port>/attach?u=...`)을 OS 기본 브라우저로 연다.
|
|
2175
2375
|
*
|
|
2176
|
-
* platform별 fallback chain으로 시도하며, 모두
|
|
2177
|
-
*
|
|
2376
|
+
* platform별 fallback chain으로 시도하며, 모두 실패하면 1회 retry를 수행한다
|
|
2377
|
+
* (ephemeral process launch 타이밍 문제 대응). retry까지 실패해도 `opened: false` +
|
|
2378
|
+
* `httpUrl`을 반환해 사용자가 직접 브라우저에 붙여넣을 수 있게 한다.
|
|
2178
2379
|
*
|
|
2179
2380
|
* SECRET-HANDLING:
|
|
2180
2381
|
* - tmp 파일을 만들지 않는다 (HTML/PNG는 HTTP 서버가 메모리에서 응답).
|
|
@@ -2187,25 +2388,39 @@ function isLaunchFailureStderr(stderr) {
|
|
|
2187
2388
|
*/
|
|
2188
2389
|
async function openQrInBrowser(httpUrl, pngUrl) {
|
|
2189
2390
|
const { spawnSync } = await import("node:child_process");
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
})
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
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;
|
|
2200
2409
|
}
|
|
2201
|
-
|
|
2202
|
-
if (stderr) stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);
|
|
2203
|
-
if (result.status === 0 && !isLaunchFailureStderr(stderr)) return {
|
|
2204
|
-
opened: true,
|
|
2205
|
-
httpUrl,
|
|
2206
|
-
pngUrl
|
|
2207
|
-
};
|
|
2410
|
+
return false;
|
|
2208
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
|
+
};
|
|
2209
2424
|
return {
|
|
2210
2425
|
opened: false,
|
|
2211
2426
|
httpUrl,
|
|
@@ -2451,7 +2666,7 @@ async function evaluate(connection, expression) {
|
|
|
2451
2666
|
* any log or stderr by the caller.
|
|
2452
2667
|
*/
|
|
2453
2668
|
function buildCallSdkExpression(name, args) {
|
|
2454
|
-
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)}); }})()`;
|
|
2455
2670
|
}
|
|
2456
2671
|
/**
|
|
2457
2672
|
* Parses the JSON envelope string returned by the `call_sdk` expression.
|
|
@@ -2570,6 +2785,145 @@ function getMockState(source) {
|
|
|
2570
2785
|
function getOperationalEnvironment(source) {
|
|
2571
2786
|
return source.get("AIT.getOperationalEnvironment");
|
|
2572
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
|
+
}
|
|
2573
2927
|
//#endregion
|
|
2574
2928
|
//#region src/mcp/totp.ts
|
|
2575
2929
|
/**
|
|
@@ -2660,6 +3014,15 @@ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
|
|
|
2660
3014
|
* and would be stale by the time a human scans. The in-app deep-link builder
|
|
2661
3015
|
* splices the live code at attach time.
|
|
2662
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
|
+
*
|
|
2663
3026
|
* SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear
|
|
2664
3027
|
* in any output from this module.
|
|
2665
3028
|
*
|
|
@@ -2782,6 +3145,118 @@ async function printAttachBanner(input) {
|
|
|
2782
3145
|
const banner = await renderAttachBanner(input);
|
|
2783
3146
|
process.stderr.write(`${banner}\n`);
|
|
2784
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
|
+
}
|
|
2785
3260
|
//#endregion
|
|
2786
3261
|
//#region src/mcp/debug-server.ts
|
|
2787
3262
|
/**
|
|
@@ -2899,12 +3374,13 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
|
|
|
2899
3374
|
* naturally via `enableDomains`). The tier only controls visibility.
|
|
2900
3375
|
*/
|
|
2901
3376
|
function createDebugServer(deps) {
|
|
2902
|
-
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep } = deps;
|
|
3377
|
+
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep } = deps;
|
|
2903
3378
|
const resolveEnvironment = getEnvDep ?? (() => getEnvironment({ connection }));
|
|
2904
3379
|
const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({ connection }));
|
|
3380
|
+
const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
|
|
2905
3381
|
const server = new Server({
|
|
2906
3382
|
name: "ait-debug",
|
|
2907
|
-
version: "0.1.
|
|
3383
|
+
version: "0.1.44"
|
|
2908
3384
|
}, { capabilities: { tools: { listChanged: true } } });
|
|
2909
3385
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
2910
3386
|
const env = resolveEnvironment();
|
|
@@ -2923,15 +3399,16 @@ function createDebugServer(deps) {
|
|
|
2923
3399
|
};
|
|
2924
3400
|
const env = resolveEnvironment();
|
|
2925
3401
|
if (!isToolAvailableIn(name, env)) {
|
|
2926
|
-
const
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
};
|
|
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);
|
|
2935
3412
|
}
|
|
2936
3413
|
if (isAitToolName(name)) try {
|
|
2937
3414
|
await connection.enableDomains();
|
|
@@ -2944,19 +3421,31 @@ function createDebugServer(deps) {
|
|
|
2944
3421
|
} catch (err) {
|
|
2945
3422
|
return errorResult(err, name);
|
|
2946
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
|
+
}
|
|
2947
3439
|
if (name === "build_attach_url") {
|
|
2948
3440
|
const schemeUrl = request.params.arguments?.scheme_url;
|
|
2949
|
-
if (typeof schemeUrl !== "string" || schemeUrl === "") return
|
|
2950
|
-
content: [{
|
|
2951
|
-
type: "text",
|
|
2952
|
-
text: "build_attach_url requires a non-empty scheme_url."
|
|
2953
|
-
}],
|
|
2954
|
-
isError: true
|
|
2955
|
-
};
|
|
3441
|
+
if (typeof schemeUrl !== "string" || schemeUrl === "") return mcpError("build_attach_url: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요.");
|
|
2956
3442
|
const waitForAttach = request.params.arguments?.wait_for_attach === true;
|
|
2957
3443
|
const openInBrowser = request.params.arguments?.open_in_browser !== false;
|
|
2958
3444
|
const deploymentId = extractDeploymentId(schemeUrl);
|
|
2959
|
-
if (!deploymentId)
|
|
3445
|
+
if (!deploymentId) logInfo("tool.call", {
|
|
3446
|
+
tool: "build_attach_url",
|
|
3447
|
+
msg: "no _deploymentId in scheme_url; matching on presence only"
|
|
3448
|
+
});
|
|
2960
3449
|
/** Returns true when the page list satisfies the attach condition. */
|
|
2961
3450
|
const isMatchingPage = (pages) => {
|
|
2962
3451
|
if (pages.length === 0) return false;
|
|
@@ -2973,10 +3462,50 @@ function createDebugServer(deps) {
|
|
|
2973
3462
|
const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
|
|
2974
3463
|
const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
|
|
2975
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).";
|
|
2976
|
-
|
|
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) {
|
|
2977
3497
|
const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
|
|
2978
3498
|
if (browserResult.opened) {
|
|
2979
|
-
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}`;
|
|
2980
3509
|
if (!waitForAttach) return { content: [{
|
|
2981
3510
|
type: "text",
|
|
2982
3511
|
text: shortText
|
|
@@ -3000,12 +3529,21 @@ function createDebugServer(deps) {
|
|
|
3000
3529
|
text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
|
|
3001
3530
|
}] };
|
|
3002
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
|
+
};
|
|
3003
3539
|
const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
|
|
3004
|
-
const fallbackNote =
|
|
3540
|
+
const fallbackNote = `[open_in_browser] 브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:
|
|
3541
|
+
${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
|
|
3005
3542
|
const qr = await renderQr(attachUrl);
|
|
3006
3543
|
const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
|
|
3007
3544
|
attachUrl,
|
|
3008
|
-
relayUrl
|
|
3545
|
+
relayUrl,
|
|
3546
|
+
openResult
|
|
3009
3547
|
}, null, 2)}\n\n${qr}`;
|
|
3010
3548
|
if (!waitForAttach) return { content: [{
|
|
3011
3549
|
type: "text",
|
|
@@ -3064,20 +3602,13 @@ function createDebugServer(deps) {
|
|
|
3064
3602
|
try {
|
|
3065
3603
|
await connection.enableDomains();
|
|
3066
3604
|
} catch (err) {
|
|
3067
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3068
3605
|
if (name === "list_pages") {
|
|
3069
3606
|
if (connection instanceof ChiiCdpConnection) try {
|
|
3070
3607
|
await connection.refreshTargets();
|
|
3071
3608
|
} catch {}
|
|
3072
3609
|
return jsonResult$1(listPages(connection, getTunnelStatus()));
|
|
3073
3610
|
}
|
|
3074
|
-
return
|
|
3075
|
-
content: [{
|
|
3076
|
-
type: "text",
|
|
3077
|
-
text: `${message}\nCall list_pages to confirm a mini-app has attached over the relay.`
|
|
3078
|
-
}],
|
|
3079
|
-
isError: true
|
|
3080
|
-
};
|
|
3611
|
+
return classifyEnableDomainError(err, name);
|
|
3081
3612
|
}
|
|
3082
3613
|
try {
|
|
3083
3614
|
switch (name) {
|
|
@@ -3105,26 +3636,16 @@ function createDebugServer(deps) {
|
|
|
3105
3636
|
case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection, resolveEnvironment()));
|
|
3106
3637
|
case "evaluate": {
|
|
3107
3638
|
const expression = request.params.arguments?.expression;
|
|
3108
|
-
if (typeof expression !== "string" || expression === "") return
|
|
3109
|
-
content: [{
|
|
3110
|
-
type: "text",
|
|
3111
|
-
text: "evaluate requires a non-empty expression."
|
|
3112
|
-
}],
|
|
3113
|
-
isError: true
|
|
3114
|
-
};
|
|
3639
|
+
if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
|
|
3115
3640
|
return jsonResult$1(await evaluate(connection, expression));
|
|
3116
3641
|
}
|
|
3117
3642
|
case "call_sdk": {
|
|
3118
3643
|
const sdkName = request.params.arguments?.name;
|
|
3119
|
-
if (typeof sdkName !== "string" || sdkName === "") return
|
|
3120
|
-
content: [{
|
|
3121
|
-
type: "text",
|
|
3122
|
-
text: "call_sdk requires a non-empty name."
|
|
3123
|
-
}],
|
|
3124
|
-
isError: true
|
|
3125
|
-
};
|
|
3644
|
+
if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
|
|
3126
3645
|
const rawArgs = request.params.arguments?.args;
|
|
3127
|
-
|
|
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);
|
|
3128
3649
|
}
|
|
3129
3650
|
default: return unknownTool(name);
|
|
3130
3651
|
}
|
|
@@ -3141,33 +3662,29 @@ function jsonResult$1(value) {
|
|
|
3141
3662
|
}] };
|
|
3142
3663
|
}
|
|
3143
3664
|
function unknownTool(name) {
|
|
3144
|
-
return {
|
|
3145
|
-
content: [{
|
|
3146
|
-
type: "text",
|
|
3147
|
-
text: `Unknown tool: ${name}`
|
|
3148
|
-
}],
|
|
3149
|
-
isError: true
|
|
3150
|
-
};
|
|
3665
|
+
return mcpError(`알 수 없는 tool: ${name}`);
|
|
3151
3666
|
}
|
|
3152
3667
|
/**
|
|
3153
|
-
*
|
|
3154
|
-
*
|
|
3155
|
-
*
|
|
3156
|
-
*
|
|
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 안내
|
|
3157
3674
|
*/
|
|
3158
|
-
function
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
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);
|
|
3162
3681
|
}
|
|
3682
|
+
/**
|
|
3683
|
+
* CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
|
|
3684
|
+
* debug-server 내부 try/catch 블록에서 공통으로 사용한다.
|
|
3685
|
+
*/
|
|
3163
3686
|
function errorResult(err, name) {
|
|
3164
|
-
return
|
|
3165
|
-
content: [{
|
|
3166
|
-
type: "text",
|
|
3167
|
-
text: `${name} failed: ${err instanceof Error ? err.message : String(err)}${isDisconnectError(err) ? "\n\nrelay 연결이 끊겼습니다. list_pages → enableDomains() 재호출로 재연결하세요. 폰이 백그라운드로 내려갔거나 미니앱이 종료됐을 수 있습니다." : "\nCall list_pages to confirm a mini-app has attached over the relay."}`
|
|
3168
|
-
}],
|
|
3169
|
-
isError: true
|
|
3170
|
-
};
|
|
3687
|
+
return classifyToolError(err, name);
|
|
3171
3688
|
}
|
|
3172
3689
|
/**
|
|
3173
3690
|
* Starts a polling watcher that detects the first 0→N target transition on
|
|
@@ -3247,27 +3764,45 @@ async function runDebugServer(options = {}) {
|
|
|
3247
3764
|
port: relayPort,
|
|
3248
3765
|
verifyAuth
|
|
3249
3766
|
});
|
|
3767
|
+
logInfo("server.start", {
|
|
3768
|
+
port: relay.port,
|
|
3769
|
+
totpEnabled
|
|
3770
|
+
});
|
|
3250
3771
|
let tunnel = null;
|
|
3251
|
-
let tunnelStatus =
|
|
3252
|
-
up: false,
|
|
3253
|
-
wssUrl: null
|
|
3254
|
-
};
|
|
3772
|
+
let tunnelStatus = makeTunnelStatus(false, null);
|
|
3255
3773
|
generateAttachToken();
|
|
3774
|
+
let tunnelProbe = null;
|
|
3256
3775
|
startQuickTunnel(relay.port).then((t) => {
|
|
3257
3776
|
tunnel = t;
|
|
3258
|
-
tunnelStatus =
|
|
3259
|
-
up: true,
|
|
3260
|
-
wssUrl: t.wssUrl
|
|
3261
|
-
};
|
|
3777
|
+
tunnelStatus = makeTunnelStatus(true, t.wssUrl);
|
|
3262
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
|
+
});
|
|
3263
3800
|
return printAttachBanner({
|
|
3264
3801
|
wssUrl: t.wssUrl,
|
|
3265
3802
|
totpEnabled
|
|
3266
3803
|
});
|
|
3267
3804
|
}, (err) => {
|
|
3268
|
-
|
|
3269
|
-
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.
|
|
3270
|
-
`);
|
|
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.` });
|
|
3271
3806
|
});
|
|
3272
3807
|
const connection = new ChiiCdpConnection({ relayBaseUrl: relay.baseUrl });
|
|
3273
3808
|
const aitSource = new ChiiAitSource(connection);
|
|
@@ -3275,17 +3810,18 @@ async function runDebugServer(options = {}) {
|
|
|
3275
3810
|
try {
|
|
3276
3811
|
qrServer = await startQrHttpServer();
|
|
3277
3812
|
} catch (err) {
|
|
3278
|
-
|
|
3279
|
-
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)}` });
|
|
3280
3814
|
}
|
|
3281
3815
|
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
3816
|
+
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
3282
3817
|
const server = createDebugServer({
|
|
3283
3818
|
connection,
|
|
3284
3819
|
aitSource,
|
|
3285
3820
|
getTunnelStatus: () => tunnelStatus,
|
|
3286
3821
|
get qrHttpServer() {
|
|
3287
3822
|
return qrServer;
|
|
3288
|
-
}
|
|
3823
|
+
},
|
|
3824
|
+
diagnosticsCollector
|
|
3289
3825
|
});
|
|
3290
3826
|
const transport = new StdioServerTransport();
|
|
3291
3827
|
let closed = false;
|
|
@@ -3294,6 +3830,7 @@ async function runDebugServer(options = {}) {
|
|
|
3294
3830
|
if (closed) return;
|
|
3295
3831
|
closed = true;
|
|
3296
3832
|
attachWatcher?.stop();
|
|
3833
|
+
tunnelProbe?.stop();
|
|
3297
3834
|
connection.close();
|
|
3298
3835
|
tunnel?.stop();
|
|
3299
3836
|
relay.close();
|
|
@@ -3308,22 +3845,30 @@ async function runDebugServer(options = {}) {
|
|
|
3308
3845
|
if (!closed) {
|
|
3309
3846
|
closed = true;
|
|
3310
3847
|
attachWatcher?.stop();
|
|
3848
|
+
tunnelProbe?.stop();
|
|
3311
3849
|
tunnel?.stop();
|
|
3312
3850
|
lockHandle.release();
|
|
3313
3851
|
}
|
|
3314
3852
|
});
|
|
3315
3853
|
process.on("uncaughtException", (err) => {
|
|
3316
|
-
|
|
3854
|
+
logError("tool.error", {
|
|
3855
|
+
msg: `uncaughtException: ${String(err)}`,
|
|
3856
|
+
errorKind: "uncaught"
|
|
3857
|
+
});
|
|
3317
3858
|
shutdown();
|
|
3318
3859
|
process.exit(1);
|
|
3319
3860
|
});
|
|
3320
3861
|
process.on("unhandledRejection", (reason) => {
|
|
3321
|
-
|
|
3862
|
+
logError("tool.error", {
|
|
3863
|
+
msg: `unhandledRejection: ${String(reason)}`,
|
|
3864
|
+
errorKind: "unhandled-rejection"
|
|
3865
|
+
});
|
|
3322
3866
|
shutdown();
|
|
3323
3867
|
process.exit(1);
|
|
3324
3868
|
});
|
|
3325
3869
|
await server.connect(transport);
|
|
3326
3870
|
attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
|
|
3871
|
+
diagnosticsCollector.recordAttach();
|
|
3327
3872
|
devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({ connection }));
|
|
3328
3873
|
});
|
|
3329
3874
|
}
|
|
@@ -3387,12 +3932,20 @@ async function runLocalDebugServer(options = {}) {
|
|
|
3387
3932
|
}
|
|
3388
3933
|
});
|
|
3389
3934
|
process.on("uncaughtException", (err) => {
|
|
3390
|
-
|
|
3935
|
+
logError("tool.error", {
|
|
3936
|
+
msg: `uncaughtException: ${String(err)}`,
|
|
3937
|
+
errorKind: "uncaught",
|
|
3938
|
+
mode: "local"
|
|
3939
|
+
});
|
|
3391
3940
|
shutdown();
|
|
3392
3941
|
process.exit(1);
|
|
3393
3942
|
});
|
|
3394
3943
|
process.on("unhandledRejection", (reason) => {
|
|
3395
|
-
|
|
3944
|
+
logError("tool.error", {
|
|
3945
|
+
msg: `unhandledRejection: ${String(reason)}`,
|
|
3946
|
+
errorKind: "unhandled-rejection",
|
|
3947
|
+
mode: "local"
|
|
3948
|
+
});
|
|
3396
3949
|
shutdown();
|
|
3397
3950
|
process.exit(1);
|
|
3398
3951
|
});
|
|
@@ -3528,47 +4081,23 @@ function createDevServer(deps = {}) {
|
|
|
3528
4081
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
3529
4082
|
const server = new Server({
|
|
3530
4083
|
name: "ait-devtools",
|
|
3531
|
-
version: "0.1.
|
|
4084
|
+
version: "0.1.44"
|
|
3532
4085
|
}, { capabilities: { tools: {} } });
|
|
3533
4086
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
3534
4087
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3535
4088
|
const name = request.params.name;
|
|
3536
|
-
if (!DEV_TOOL_NAMES.has(name)) return {
|
|
3537
|
-
content: [{
|
|
3538
|
-
type: "text",
|
|
3539
|
-
text: `Unknown tool: ${name}`
|
|
3540
|
-
}],
|
|
3541
|
-
isError: true
|
|
3542
|
-
};
|
|
4089
|
+
if (!DEV_TOOL_NAMES.has(name)) return mcpError(`알 수 없는 tool: ${name}`);
|
|
3543
4090
|
try {
|
|
3544
4091
|
const effective = name === "devtools_get_mock_state" ? "AIT.getMockState" : name;
|
|
3545
|
-
if (!isAitToolName(effective)) return {
|
|
3546
|
-
content: [{
|
|
3547
|
-
type: "text",
|
|
3548
|
-
text: `Unknown tool: ${name}`
|
|
3549
|
-
}],
|
|
3550
|
-
isError: true
|
|
3551
|
-
};
|
|
4092
|
+
if (!isAitToolName(effective)) return mcpError(`알 수 없는 tool: ${name}`);
|
|
3552
4093
|
switch (effective) {
|
|
3553
4094
|
case "AIT.getMockState": return jsonResult(await getMockState(aitSource));
|
|
3554
4095
|
case "AIT.getOperationalEnvironment": return jsonResult(await getOperationalEnvironment(aitSource));
|
|
3555
4096
|
case "AIT.getSdkCallHistory": return jsonResult(await getSdkCallHistory(aitSource));
|
|
3556
|
-
default: return {
|
|
3557
|
-
content: [{
|
|
3558
|
-
type: "text",
|
|
3559
|
-
text: `Unknown tool: ${name}`
|
|
3560
|
-
}],
|
|
3561
|
-
isError: true
|
|
3562
|
-
};
|
|
4097
|
+
default: return mcpError(`알 수 없는 tool: ${name}`);
|
|
3563
4098
|
}
|
|
3564
4099
|
} catch (err) {
|
|
3565
|
-
return {
|
|
3566
|
-
content: [{
|
|
3567
|
-
type: "text",
|
|
3568
|
-
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?`
|
|
3569
|
-
}],
|
|
3570
|
-
isError: true
|
|
3571
|
-
};
|
|
4100
|
+
return mcpError(`${name} 실패: ${err instanceof Error ? err.message : String(err)}\nVite dev 서버가 @ait-co/devtools unplugin \`mcp: true\` 옵션으로 실행 중인지 확인하세요. AIT_DEVTOOLS_URL 환경변수가 올바르게 설정됐는지도 확인하세요.`);
|
|
3572
4101
|
}
|
|
3573
4102
|
});
|
|
3574
4103
|
return server;
|