@ait-co/devtools 0.1.80 → 0.1.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chii-relay-DSVG4Ui1.js → chii-relay-BASitNMw.js} +1 -1
- package/dist/{chii-relay-DSVG4Ui1.js.map → chii-relay-BASitNMw.js.map} +1 -1
- package/dist/{chii-relay-BcnVJBqm.cjs → chii-relay-BzUf0LH3.cjs} +1 -1
- package/dist/{chii-relay-BcnVJBqm.cjs.map → chii-relay-BzUf0LH3.cjs.map} +1 -1
- package/dist/{deeplink-B-94XmWA.js → deeplink-BpO9qc-D.js} +5 -3
- package/dist/{deeplink-BLU2_hg6.cjs.map → deeplink-BpO9qc-D.js.map} +1 -1
- package/dist/{deeplink-CYqDwVYs.js → deeplink-D1HXJ2YG.js} +5 -3
- package/dist/{deeplink-CU6opogq.cjs.map → deeplink-D1HXJ2YG.js.map} +1 -1
- package/dist/{deeplink-BLU2_hg6.cjs → deeplink-DCScMYcp.cjs} +5 -3
- package/dist/deeplink-DCScMYcp.cjs.map +1 -0
- package/dist/{deeplink-CU6opogq.cjs → deeplink-DDOe0FQl.cjs} +5 -3
- package/dist/deeplink-DDOe0FQl.cjs.map +1 -0
- package/dist/{devtools-opener-Bp671YXu.cjs → devtools-opener-BDY0w3_0.cjs} +11 -5
- package/dist/devtools-opener-BDY0w3_0.cjs.map +1 -0
- package/dist/{devtools-opener-D84kZFtR.js → devtools-opener-BTl5A6Cd.js} +11 -5
- package/dist/devtools-opener-BTl5A6Cd.js.map +1 -0
- package/dist/{devtools-opener-BbUXBzgA.js → devtools-opener-XpwL3fZ9.js} +22 -6
- package/dist/devtools-opener-XpwL3fZ9.js.map +1 -0
- package/dist/{devtools-opener-h6A-UjzC.cjs → devtools-opener-mDgeg_MX.cjs} +11 -5
- package/dist/devtools-opener-mDgeg_MX.cjs.map +1 -0
- package/dist/machine-state-Chg_6SPq.js +188 -0
- package/dist/machine-state-Chg_6SPq.js.map +1 -0
- package/dist/machine-state-DOUweFsJ.cjs +216 -0
- package/dist/machine-state-DOUweFsJ.cjs.map +1 -0
- package/dist/mcp/cli.js +104 -37
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +6 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/panel/index.js +109 -8
- package/dist/panel/index.js.map +1 -1
- package/dist/{qr-http-server-DJ5K3Odk.js → qr-http-server-Buorblrx.js} +73 -23
- package/dist/qr-http-server-Buorblrx.js.map +1 -0
- package/dist/{qr-http-server-Dkx2-pKF.cjs → qr-http-server-BvAtX9Lc.cjs} +73 -23
- package/dist/qr-http-server-BvAtX9Lc.cjs.map +1 -0
- package/dist/{qr-http-server-DkOFfZsR.js → qr-http-server-BxjrJr9t.js} +73 -23
- package/dist/qr-http-server-BxjrJr9t.js.map +1 -0
- package/dist/{qr-http-server-MIUHaiYw.cjs → qr-http-server-CAUyOrCm.cjs} +73 -23
- package/dist/qr-http-server-CAUyOrCm.cjs.map +1 -0
- package/dist/{relay-secret-store-CsCOfpWt.cjs → relay-secret-store-B5WAozDv.cjs} +2 -2
- package/dist/{relay-secret-store-CsCOfpWt.cjs.map → relay-secret-store-B5WAozDv.cjs.map} +1 -1
- package/dist/{relay-secret-store-6pPzLkUO.js → relay-secret-store-BvNWdSjV.js} +2 -2
- package/dist/{relay-secret-store-6pPzLkUO.js.map → relay-secret-store-BvNWdSjV.js.map} +1 -1
- package/dist/{relay-url-store-DH8-VUFc.js → relay-url-store-1CXVqNDL.js} +2 -2
- package/dist/{relay-url-store-DH8-VUFc.js.map → relay-url-store-1CXVqNDL.js.map} +1 -1
- package/dist/{relay-url-store-BiEK9BN1.cjs → relay-url-store-D2lX9POP.cjs} +2 -2
- package/dist/{relay-url-store-BiEK9BN1.cjs.map → relay-url-store-D2lX9POP.cjs.map} +1 -1
- package/dist/{totp-DYdP9N3o.js → totp-CauHjkdE.js} +1 -1
- package/dist/{totp-DYdP9N3o.js.map → totp-CauHjkdE.js.map} +1 -1
- package/dist/{totp-CNw0w89F.cjs → totp-D9fjaVak.cjs} +1 -1
- package/dist/{totp-CNw0w89F.cjs.map → totp-D9fjaVak.cjs.map} +1 -1
- package/dist/{tunnel-C-AFdAVL.cjs → tunnel-CfT31xho.cjs} +6 -6
- package/dist/{tunnel-C-AFdAVL.cjs.map → tunnel-CfT31xho.cjs.map} +1 -1
- package/dist/{tunnel-BTlq1mmH.js → tunnel-DPwJBn1u.js} +6 -6
- package/dist/{tunnel-BTlq1mmH.js.map → tunnel-DPwJBn1u.js.map} +1 -1
- package/dist/unplugin/index.cjs +90 -5
- package/dist/unplugin/index.cjs.map +1 -1
- package/dist/unplugin/index.d.cts.map +1 -1
- package/dist/unplugin/index.d.ts.map +1 -1
- package/dist/unplugin/index.js +90 -5
- package/dist/unplugin/index.js.map +1 -1
- package/dist/unplugin/tunnel.cjs +4 -4
- package/dist/unplugin/tunnel.js +4 -4
- package/package.json +1 -1
- package/dist/deeplink-B-94XmWA.js.map +0 -1
- package/dist/deeplink-CYqDwVYs.js.map +0 -1
- package/dist/devtools-opener-BbUXBzgA.js.map +0 -1
- package/dist/devtools-opener-Bp671YXu.cjs.map +0 -1
- package/dist/devtools-opener-D84kZFtR.js.map +0 -1
- package/dist/devtools-opener-h6A-UjzC.cjs.map +0 -1
- package/dist/qr-http-server-DJ5K3Odk.js.map +0 -1
- package/dist/qr-http-server-DkOFfZsR.js.map +0 -1
- package/dist/qr-http-server-Dkx2-pKF.cjs.map +0 -1
- package/dist/qr-http-server-MIUHaiYw.cjs.map +0 -1
package/dist/unplugin/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { r as writeMachineState, t as ensureMachineConsent } from "../machine-state-Chg_6SPq.js";
|
|
1
2
|
import { fileURLToPath } from "node:url";
|
|
2
3
|
import { createUnplugin } from "unplugin";
|
|
3
4
|
//#region src/shared/parent-watcher.ts
|
|
@@ -65,6 +66,13 @@ function startParentWatcher(onOrphaned, opts) {
|
|
|
65
66
|
} };
|
|
66
67
|
}
|
|
67
68
|
//#endregion
|
|
69
|
+
//#region src/telemetry/state.ts
|
|
70
|
+
/**
|
|
71
|
+
* Current policy version. Bump this string whenever the privacy policy changes.
|
|
72
|
+
* Users who previously granted on an older version will be re-prompted once.
|
|
73
|
+
*/
|
|
74
|
+
const CURRENT_POLICY_VERSION = "2026-05-18";
|
|
75
|
+
//#endregion
|
|
68
76
|
//#region src/unplugin/index.ts
|
|
69
77
|
/**
|
|
70
78
|
* @ait-co/devtools unplugin
|
|
@@ -113,6 +121,18 @@ const WEBVIEW_BRIDGE_ID = "@apps-in-toss/webview-bridge";
|
|
|
113
121
|
/** MCP state endpoint path — browser panel POSTs here, MCP server GETs here */
|
|
114
122
|
const MCP_STATE_PATH = "/api/ait-devtools/state";
|
|
115
123
|
/**
|
|
124
|
+
* Machine-level telemetry consent endpoint (#542).
|
|
125
|
+
*
|
|
126
|
+
* GET → returns current machine consent state as JSON (for the panel to read
|
|
127
|
+
* and skip the toast when already decided).
|
|
128
|
+
* POST → panel or environment-tab toggle writes new consent back to the machine
|
|
129
|
+
* file (body: { consent: 'granted' | 'denied', policy_version: string }).
|
|
130
|
+
*
|
|
131
|
+
* Always registered (not gated on `mcp: true`) — the panel needs this
|
|
132
|
+
* unconditionally when the dev server is running.
|
|
133
|
+
*/
|
|
134
|
+
const TELEMETRY_CONSENT_PATH = "/api/ait-devtools/telemetry-consent";
|
|
135
|
+
/**
|
|
116
136
|
* Resolves the effective tunnel option (#425).
|
|
117
137
|
*
|
|
118
138
|
* An explicit `tunnel` value (including `false`) always takes priority over
|
|
@@ -166,6 +186,71 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
|
|
|
166
186
|
return { server: { allowedHosts: [".trycloudflare.com"] } };
|
|
167
187
|
},
|
|
168
188
|
configureServer(server) {
|
|
189
|
+
let machineConsent = null;
|
|
190
|
+
if (shouldEnable) {
|
|
191
|
+
server.httpServer?.once("listening", () => {
|
|
192
|
+
ensureMachineConsent(CURRENT_POLICY_VERSION).then((state) => {
|
|
193
|
+
machineConsent = state;
|
|
194
|
+
}).catch((err) => {
|
|
195
|
+
console.warn(`[@ait-co/devtools] machine consent init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
server.middlewares.use(TELEMETRY_CONSENT_PATH, (req, res) => {
|
|
199
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
200
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
201
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
202
|
+
if (req.method === "OPTIONS") {
|
|
203
|
+
res.writeHead(204);
|
|
204
|
+
res.end();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (req.method === "GET") {
|
|
208
|
+
if (machineConsent === null) {
|
|
209
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
210
|
+
res.end(JSON.stringify({ error: "Machine consent not yet initialised." }));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
214
|
+
res.end(JSON.stringify(machineConsent));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (req.method === "POST") {
|
|
218
|
+
const chunks = [];
|
|
219
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
220
|
+
req.on("end", () => {
|
|
221
|
+
try {
|
|
222
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
223
|
+
const payload = JSON.parse(body);
|
|
224
|
+
const consent = payload.consent;
|
|
225
|
+
if (consent !== "granted" && consent !== "denied" && consent !== "undecided") {
|
|
226
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
227
|
+
res.end(JSON.stringify({ error: "Invalid consent value. Expected granted | denied | undecided." }));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
writeMachineState({
|
|
231
|
+
consent,
|
|
232
|
+
policy_version: payload.policy_version ?? "2026-05-18"
|
|
233
|
+
}).then(async () => {
|
|
234
|
+
const { readMachineState } = await import("../machine-state-Chg_6SPq.js").then((n) => n.n);
|
|
235
|
+
machineConsent = await readMachineState();
|
|
236
|
+
res.writeHead(204);
|
|
237
|
+
res.end();
|
|
238
|
+
}).catch((err) => {
|
|
239
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
240
|
+
res.end(JSON.stringify({ error: `Write failed: ${err instanceof Error ? err.message : String(err)}` }));
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
} catch {
|
|
244
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
245
|
+
res.end(JSON.stringify({ error: "Invalid JSON body." }));
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
251
|
+
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
169
254
|
if (shouldMcp) server.middlewares.use(MCP_STATE_PATH, (req, res) => {
|
|
170
255
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
171
256
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
@@ -220,19 +305,19 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
|
|
|
220
305
|
console.warn("[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.");
|
|
221
306
|
return;
|
|
222
307
|
}
|
|
223
|
-
import("../tunnel-
|
|
308
|
+
import("../tunnel-DPwJBn1u.js").then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {
|
|
224
309
|
const t = await startQuickTunnel(port);
|
|
225
310
|
tunnel = t;
|
|
226
311
|
let relayWssUrl;
|
|
227
312
|
let relayHttpUrl;
|
|
228
313
|
let relayLocalHttpUrl;
|
|
229
314
|
if (tunnelConfig.cdp) try {
|
|
230
|
-
const { ensureRelaySecret } = await import("../relay-secret-store-
|
|
315
|
+
const { ensureRelaySecret } = await import("../relay-secret-store-BvNWdSjV.js");
|
|
231
316
|
await ensureRelaySecret({ projectRoot: server.config.root });
|
|
232
|
-
const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import("../totp-
|
|
317
|
+
const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import("../totp-CauHjkdE.js");
|
|
233
318
|
assertRelayAuthConfigured();
|
|
234
319
|
const verifyAuth = buildRelayVerifyAuth();
|
|
235
|
-
const { startChiiRelay } = await import("../chii-relay-
|
|
320
|
+
const { startChiiRelay } = await import("../chii-relay-BASitNMw.js");
|
|
236
321
|
let lastAuthRejectWarnAt = 0;
|
|
237
322
|
const r = await startChiiRelay({
|
|
238
323
|
port: 0,
|
|
@@ -266,7 +351,7 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
|
|
|
266
351
|
relayWssUrl,
|
|
267
352
|
name: tunnelAppName
|
|
268
353
|
});
|
|
269
|
-
const { writeRelayUrls, deleteRelayUrls } = await import("../relay-url-store-
|
|
354
|
+
const { writeRelayUrls, deleteRelayUrls } = await import("../relay-url-store-1CXVqNDL.js");
|
|
270
355
|
await writeRelayUrls({
|
|
271
356
|
projectRoot: server.config.root,
|
|
272
357
|
tunnelBaseUrl: t.url,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/shared/parent-watcher.ts","../../src/unplugin/index.ts"],"sourcesContent":["/**\n * Shared parent-PID watcher — used by both the MCP debug daemon and the\n * unplugin tunnel path to self-terminate when the parent process (e.g. Claude\n * Code, vite) has died or been reparented without sending SIGTERM/SIGHUP.\n *\n * Intentionally react-free and Node-stdlib-only so this module is safe to\n * import from the MCP daemon bundle (`dist/mcp/cli.js`) without violating the\n * install-graph invariant.\n */\n\n// ---------------------------------------------------------------------------\n// isPidAlive — extracted from src/mcp/server-lock.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when the given PID refers to a running process.\n *\n * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process\n * exists and we have permission to signal it; throws ESRCH when it doesn't exist.\n */\nexport function isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (err: unknown) {\n // ESRCH = no such process → stale lock.\n // EPERM = process exists but we can't signal it (still alive).\n if ((err as NodeJS.ErrnoException).code === 'EPERM') return true;\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// startParentWatcher — extracted from src/mcp/debug-server.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Starts a periodic watcher that detects when the parent process (e.g. Claude\n * Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the\n * daemon can self-terminate rather than running as a zombie.\n *\n * Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns\n * `{ stop(): void }`, injectable deps for testability.\n *\n * @param onOrphaned - Called once when the parent is gone.\n * @param opts.intervalMs - Poll interval in milliseconds (default 5 000).\n * @param opts.initialPpid - Parent PID to watch (default `process.ppid`).\n * @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).\n * @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).\n * Detects ppid changes as well as death.\n * @param opts.log - Logger (default `process.stderr.write`).\n *\n * @returns `stop` — call during shutdown to clear the interval.\n */\nexport function startParentWatcher(\n onOrphaned: () => void,\n opts?: {\n intervalMs?: number;\n initialPpid?: number;\n isAlive?: (pid: number) => boolean;\n getPpid?: () => number;\n log?: (msg: string) => void;\n },\n): { stop(): void } {\n const {\n intervalMs = 5_000,\n initialPpid = process.ppid,\n isAlive = isPidAlive,\n getPpid = () => process.ppid,\n log = (msg: string) => process.stderr.write(msg),\n } = opts ?? {};\n\n // PID 1 is init/launchd — running under a process manager or as a detached\n // daemon. There is no meaningful parent to watch; skip the watcher entirely.\n if (initialPpid <= 1) {\n log('[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\\n');\n return { stop() {} };\n }\n\n let fired = false;\n\n const handle = setInterval(() => {\n if (fired) return;\n\n const currentPpid = getPpid();\n const orphaned = currentPpid !== initialPpid || !isAlive(initialPpid);\n\n if (orphaned) {\n fired = true;\n clearInterval(handle);\n log(\n `[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\\n`,\n );\n onOrphaned();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n","/**\n * @ait-co/devtools unplugin\n *\n * 모든 주요 번들러를 지원하는 단일 플러그인.\n * @apps-in-toss/web-framework → @ait-co/devtools/mock 으로 alias 설정.\n *\n * Usage:\n * import aitDevtools from '@ait-co/devtools/unplugin';\n *\n * // Vite\n * export default { plugins: [aitDevtools.vite()] };\n *\n * // Webpack / Next.js\n * config.plugins.push(aitDevtools.webpack());\n *\n * // Rspack\n * config.plugins.push(aitDevtools.rspack());\n *\n * // esbuild\n * { plugins: [aitDevtools.esbuild()] }\n *\n * // Rollup\n * { plugins: [aitDevtools.rollup()] }\n */\n\nimport { fileURLToPath } from 'node:url';\nimport { createUnplugin } from 'unplugin';\nimport { startParentWatcher } from '../shared/parent-watcher.js';\n\n/**\n * Resolve `@ait-co/devtools/mock` to its real file path at plugin-load time.\n *\n * Returning the bare specifier from `resolveId` would stop the bundler from\n * walking node_modules for it — Vite 8+ treats such a non-null string as the\n * final resolved id and serves it via the virtual `/@id/` prefix, which 404s\n * because we don't provide a `load` hook. Resolving to an absolute path here\n * lets every supported bundler load the file the normal way.\n */\nconst MOCK_PATH = (() => {\n try {\n return fileURLToPath(import.meta.resolve('@ait-co/devtools/mock'));\n } catch {\n // Fallback for runtimes where `import.meta.resolve` is unavailable.\n return '@ait-co/devtools/mock';\n }\n})();\n\nexport interface AitDevtoolsOptions {\n /**\n * 패널 자동 주입 여부 (default: true)\n * true이면 진입점에 floating panel import를 자동 추가한다.\n */\n panel?: boolean;\n /**\n * production 환경에서도 devtools를 강제로 활성화 (default: false)\n */\n forceEnable?: boolean;\n /**\n * mock alias 활성화 여부. default: true (development), false (production + forceEnable)\n */\n mock?: boolean;\n /**\n * Vite dev server에 MCP state endpoint를 추가할지 여부 (default: false).\n *\n * `true`로 설정하면:\n * - GET /api/ait-devtools/state — 마지막으로 브라우저가 push한 mock state 스냅샷 반환\n * - POST /api/ait-devtools/state — 브라우저 panel이 상태 변경 시 자동 push (panel 내부 처리)\n *\n * 이 endpoint를 `@ait-co/devtools` MCP stdio server가 읽어 AI 에이전트에 mock state를 노출한다.\n * Vite 전용: webpack/rspack/esbuild/rollup 환경에서는 무시된다.\n */\n mcp?: boolean;\n /**\n * Vite dev 서버를 Cloudflare quick tunnel(`*.trycloudflare.com`, 계정 불필요)로\n * 외부 노출해 실제 폰에서 미리보기. **Vite dev 모드 전용** — production은\n * `forceEnable`이어도 터널을 띄우지 않는다 (의도치 않은 노출 방지). 다른 번들러는\n * 무시. `true`면 기본 동작, 객체로 세부 설정 가능.\n */\n tunnel?:\n | boolean\n | {\n /** 노출할 포트 (미지정 시 dev 서버가 실제 listen한 포트 자동 감지). */\n port?: number;\n /** 터미널 ASCII QR 출력 (default: true). */\n qr?: boolean;\n /**\n * 환경 2(실기기 PWA)에 CDP 디버깅 배선 (default: false).\n *\n * `true`면 dev 서버 HTTP 터널과 **별도로** Chii relay를 띄우고 그 relay에\n * 두 번째 quick tunnel을 붙여, launcher QR deep-link에 `&debug=1&relay=<wss>`를\n * 실어 보낸다. 폰의 PWA iframe이 in-app debug gate를 통과해 target.js를 주입받고,\n * AI host MCP가 그 relay에 client로 붙으면 실기기 WebKit 위에서 CDP 디버깅이 열린다.\n * mock SDK는 그대로라 `call_sdk`는 환경 2에서 mock을 친다 (fidelity 사다리의\n * 설계 의도 — SDK fidelity가 필요하면 환경 3로 올라간다).\n */\n cdp?: boolean;\n };\n}\n\nconst FRAMEWORK_ID = '@apps-in-toss/web-framework';\nconst BRIDGE_ID = '@apps-in-toss/web-bridge'; // back-compat (2.x)\nconst ANALYTICS_ID = '@apps-in-toss/web-analytics'; // back-compat (2.x)\nconst WEBVIEW_BRIDGE_ID = '@apps-in-toss/webview-bridge'; // 3.0+\n\n/** MCP state endpoint path — browser panel POSTs here, MCP server GETs here */\nconst MCP_STATE_PATH = '/api/ait-devtools/state';\n\n/**\n * Resolves the effective tunnel option (#425).\n *\n * An explicit `tunnel` value (including `false`) always takes priority over\n * env vars — the `??` operator means `undefined` (= omitted) falls through,\n * but `false` / `true` / an object are preserved as-is (non-breaking).\n *\n * When the option is omitted:\n * - `AIT_TUNNEL=1` enables the base screen-preview tunnel.\n * - `AIT_TUNNEL_CDP=1` (requires `AIT_TUNNEL`) upgrades to the CDP relay.\n * - Neither set → `false` (disabled).\n *\n * Extracted as a pure function so it can be unit-tested without standing up\n * a full Vite dev server.\n *\n * @param explicit - The `tunnel` option as passed by the consumer (or `undefined` when omitted).\n * @param env - The process environment (injectable for testing).\n */\nexport function resolveTunnelOption(\n explicit: AitDevtoolsOptions['tunnel'],\n env: Record<string, string | undefined>,\n): AitDevtoolsOptions['tunnel'] {\n return explicit ?? (env.AIT_TUNNEL ? { cdp: !!env.AIT_TUNNEL_CDP } : false);\n}\n\nconst aitDevtoolsPlugin = createUnplugin((options?: AitDevtoolsOptions) => {\n const isDev = process.env.NODE_ENV !== 'production';\n const shouldEnable = isDev || (options?.forceEnable ?? false);\n const shouldMock = shouldEnable && (options?.mock ?? isDev);\n const shouldPanel = shouldEnable && (options?.panel ?? true);\n const shouldMcp = shouldEnable && (options?.mcp ?? false);\n\n // In-memory store for the last state snapshot pushed by the browser panel.\n // Only allocated when mcp: true to avoid any overhead in the common case.\n let lastState: string | null = null;\n\n // Tunnel is dev-only and Vite-only. Never under production — even with\n // forceEnable — so a production build can't accidentally expose itself.\n //\n // Tunnel toggle resolution (#425): an explicit `tunnel` option always wins;\n // when omitted, fall back to the AIT_TUNNEL / AIT_TUNNEL_CDP env vars so a\n // consumer needs no `tunnel:` line in vite.config to enable env-2 preview.\n // AIT_TUNNEL gates the base (screen preview); AIT_TUNNEL_CDP upgrades to the\n // CDP relay. Production safety is unchanged — the existing\n // `shouldTunnel = isDev && !!tunnelOpt` guard below still blocks prod builds.\n const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);\n const shouldTunnel = isDev && !!tunnelOpt;\n const tunnelConfig = typeof tunnelOpt === 'object' ? tunnelOpt : {};\n\n return {\n name: 'ait-co-devtools',\n enforce: 'pre' as const,\n\n resolveId(id: string) {\n if (!shouldMock) return null;\n // @apps-in-toss/web-framework → @ait-co/devtools/mock (absolute path)\n if (\n id === FRAMEWORK_ID ||\n id === WEBVIEW_BRIDGE_ID ||\n id === BRIDGE_ID ||\n id === ANALYTICS_ID\n ) {\n return MOCK_PATH;\n }\n return null;\n },\n\n transformInclude(id: string) {\n if (!shouldPanel) return false;\n // 진입점 파일에만 패널 import를 주입\n return (\n /\\.(tsx?|jsx?)$/.test(id) &&\n /\\/(main|index|entry|app)\\.[tj]sx?$/i.test(id) &&\n !id.includes('node_modules')\n );\n },\n\n transform(code: string) {\n // transformInclude가 이미 shouldPanel을 확인하지만, 안전망으로 유지\n if (!shouldPanel) return null;\n // 이미 패널이 import 되어있으면 스킵\n if (code.includes('@ait-co/devtools/panel')) return null;\n // transformInclude가 진입점 파일만 통과시키므로 바로 prepend\n return `import '@ait-co/devtools/panel';\\n${code}`;\n },\n\n // Vite-only: register the MCP state HTTP endpoint on the dev server, and\n // optionally start a Cloudflare quick tunnel once the dev server is listening.\n // Non-Vite bundlers do not have a dev server concept so this is silently\n // skipped (unplugin passes `vite` key only when building for Vite).\n vite: {\n config() {\n if (!shouldTunnel) return;\n // Vite blocks requests whose Host header isn't in `server.allowedHosts`\n // (defaults to localhost only). The quick-tunnel hostname is random per\n // run, so allow the whole `.trycloudflare.com` suffix while the tunnel\n // is on. (A leading `.` makes Vite match the domain and its subdomains.)\n return { server: { allowedHosts: ['.trycloudflare.com'] } };\n },\n\n configureServer(server: import('vite').ViteDevServer) {\n // MCP state endpoint: browser panel POSTs state here, MCP stdio server GETs it.\n if (shouldMcp) {\n server.middlewares.use(MCP_STATE_PATH, (req, res) => {\n // Allow Claude Code / AI agents (running locally) to read state\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type');\n\n if (req.method === 'OPTIONS') {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (req.method === 'GET') {\n if (lastState === null) {\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'No state received yet. Open the app in a browser first.',\n }),\n );\n return;\n }\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(lastState);\n return;\n }\n\n if (req.method === 'POST') {\n const chunks: Buffer[] = [];\n req.on('data', (chunk: Buffer) => chunks.push(chunk));\n req.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString('utf-8');\n // Validate it's parseable JSON before caching\n JSON.parse(body);\n lastState = body;\n res.writeHead(204);\n res.end();\n } catch {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Invalid JSON' }));\n }\n });\n return;\n }\n\n res.writeHead(405, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Method not allowed' }));\n });\n }\n\n // Tunnel: start a Cloudflare quick tunnel once the dev server is listening.\n if (shouldTunnel) {\n let tunnel: { stop: () => void } | null = null;\n // env-2 CDP wiring (tunnel.cdp): a second tunnel + Chii relay, torn\n // down alongside the HTTP tunnel. Fire-and-forget close on teardown.\n let relayTunnel: { stop: () => void } | null = null;\n let relay: { close: () => Promise<void> } | null = null;\n // env-2 HTML dashboard (issue #408): local 127.0.0.1 HTTP server that\n // serves the QR + connect-steps + FAQ page (env 3/4 UX parity), opened\n // in the browser when CDP is wired + GUI present. Torn down with the\n // tunnel. Only set when the dashboard actually started.\n let qrDashboard: { close: () => Promise<void> } | null = null;\n // env-2 URL file store (#424): captured after the first writeRelayUrls\n // call so cleanup() can call deleteRelayUrls without a re-import.\n // SECRET-HANDLING: the stored function reference never carries URL values.\n let relayUrlDeleteFn: ((projectRoot: string) => Promise<void>) | null = null;\n // #420: parent-PID watcher — self-terminate when vite's parent dies so\n // cloudflared children don't become zombies holding stale tunnels.\n let parentWatcher: { stop(): void } | null = null;\n const httpServer = server.httpServer;\n\n httpServer?.once('listening', () => {\n const address = httpServer?.address();\n const port =\n tunnelConfig.port ??\n (address && typeof address === 'object' ? address.port : undefined);\n if (!port) {\n console.warn(\n '[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.',\n );\n return;\n }\n // Dynamic import keeps `cloudflared` / `qrcode-terminal` off the\n // module graph unless the tunnel is actually used.\n import('./tunnel.js')\n .then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {\n const t = await startQuickTunnel(port);\n tunnel = t;\n\n // env-2 CDP: boot a Chii relay (OS-assigned local port) and a\n // second quick tunnel to it. The relay's https tunnel URL becomes\n // the `wss://` relay the launcher QR carries (&debug=1&relay=).\n let relayWssUrl: string | undefined;\n // SECRET-HANDLING: relayHttpUrl carries the relay host — never logged.\n let relayHttpUrl: string | undefined;\n // LOCAL relay base — loopback URL, safe to surface (issue #530).\n let relayLocalHttpUrl: string | undefined;\n if (tunnelConfig.cdp) {\n try {\n // Relay-auth baseline (issue #250): the env-2 CDP relay is\n // reachable over a public `*.trycloudflare.com` tunnel, so a\n // configured TOTP secret is MANDATORY and the relay enforces\n // it on every WS upgrade.\n //\n // First-run auto-mint (issue #394, project-local #396): if\n // AIT_DEBUG_TOTP_SECRET is not yet set, ensureRelaySecret()\n // mints a 256-bit random secret, persists it to the project-\n // local file <project>/.ait_relay (0600, anchored at the\n // nearest package.json directory above server.config.root),\n // and injects it into process.env so the following\n // assertRelayAuthConfigured() call succeeds. On subsequent\n // runs the persisted value is loaded silently — no manual\n // export needed. The MCP daemon reads the SAME file read-only\n // via loadRelaySecretReadOnly() when switching to a relay env.\n // SECRET-HANDLING: neither ensureRelaySecret nor the\n // guard/predicate log the secret value.\n const { ensureRelaySecret } = await import('../mcp/relay-secret-store.js');\n await ensureRelaySecret({ projectRoot: server.config.root });\n const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import(\n '../mcp/totp.js'\n );\n assertRelayAuthConfigured();\n const verifyAuth = buildRelayVerifyAuth();\n const { startChiiRelay } = await import('../mcp/chii-relay.js');\n // Issue #467: this relay lives in the vite process, so the\n // MCP daemon's get_debug_status counter cannot see its 401s.\n // Surface a throttled hint in the vite terminal instead.\n // SECRET-HANDLING: fixed message only — no URL, code, host.\n let lastAuthRejectWarnAt = 0;\n const r = await startChiiRelay({\n port: 0,\n verifyAuth,\n onAuthReject: () => {\n const nowMs = Date.now();\n if (nowMs - lastAuthRejectWarnAt < 10_000) return;\n lastAuthRejectWarnAt = nowMs;\n console.warn(\n '[@ait-co/devtools] tunnel: relay 인증(TOTP) 거부 감지 — 폰에서 QR을 다시 스캔하세요 (코드는 ~3분마다 만료)',\n );\n },\n });\n relay = r;\n const rt = await startQuickTunnel(r.port);\n relayTunnel = rt;\n // SECRET-HANDLING: rt.url is the https relay base — stored in\n // relayHttpUrl for .ait_urls write below; never logged.\n relayHttpUrl = rt.url;\n relayWssUrl = rt.url.replace(/^https:/, 'wss:');\n // LOCAL relay base for MCP inspector URL assembly (issue #530):\n // the relay process runs on this machine, so the inspector\n // front_end + client WS can use the loopback address directly —\n // no tunnel round-trip for the developer's browser.\n // Safe to surface: loopback URL contains no tunnel host.\n relayLocalHttpUrl = `http://127.0.0.1:${r.port}`;\n } catch (err: unknown) {\n console.warn(\n `[@ait-co/devtools] tunnel: CDP relay not started — screen preview works without on-device debugging: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n\n // Read the app name from the project's package.json to add to\n // the launcher deep-link (#498). Failure is silently ignored.\n let tunnelAppName: string | undefined;\n try {\n const { readFileSync } = await import('node:fs');\n const pkgPath = `${server.config.root}/package.json`;\n const pkgRaw = readFileSync(pkgPath, 'utf8');\n const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;\n const rawName = typeof pkg.name === 'string' ? pkg.name : '';\n const stripped = rawName.includes('/')\n ? rawName.slice(rawName.indexOf('/') + 1)\n : rawName;\n tunnelAppName = stripped.trim() || undefined;\n } catch {\n // Silently ignore — fail-open.\n }\n\n await printTunnelBanner(t.url, {\n qr: tunnelConfig.qr,\n relayWssUrl,\n name: tunnelAppName,\n });\n\n // env-2 URL file-based discovery (#424): write .ait_urls so the\n // MCP daemon can discover the relay/tunnel URLs without manual env\n // var copy-paste. SECRET-HANDLING: URL values are never logged.\n // Capture deleteRelayUrls in the outer-scope fn so cleanup() can\n // call it without re-importing (no async in signal handlers).\n const { writeRelayUrls, deleteRelayUrls } = await import(\n '../mcp/relay-url-store.js'\n );\n await writeRelayUrls({\n projectRoot: server.config.root,\n tunnelBaseUrl: t.url,\n ...(relayHttpUrl !== undefined ? { relayBaseUrl: relayHttpUrl } : {}),\n // Issue #530: local relay base for inspector URL (loopback, no tunnel host).\n ...(relayLocalHttpUrl !== undefined ? { relayLocalUrl: relayLocalHttpUrl } : {}),\n });\n relayUrlDeleteFn = (root: string) => deleteRelayUrls({ projectRoot: root });\n\n // env-2 HTML dashboard (issue #408): when CDP is wired and a GUI\n // is present, serve the same QR+FAQ dashboard env 3/4 uses and\n // open it in the browser. No-op (returns undefined) for the\n // screen-only tunnel, headless, qr:false, or AIT_AUTO_DEVTOOLS=0\n // — the ASCII QR above remains the fallback in those cases.\n if (relayWssUrl) {\n qrDashboard =\n (await startTunnelDashboard({\n tunnelUrl: t.url,\n relayWssUrl,\n qr: tunnelConfig.qr,\n name: tunnelAppName,\n })) ?? null;\n }\n\n // #420: start watching the parent PID now that tunnel resources\n // are allocated. When the parent dies/reparents, clean up\n // synchronously (stops cloudflared children) then exit.\n parentWatcher = startParentWatcher(() => {\n cleanup();\n process.exit(0);\n });\n })\n .catch((err: unknown) => {\n console.warn(\n `[@ait-co/devtools] tunnel failed to start: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n });\n });\n\n const cleanup = () => {\n parentWatcher?.stop();\n tunnel?.stop();\n relayTunnel?.stop();\n void relay?.close();\n void qrDashboard?.close();\n // env-2 URL file cleanup (#424): remove .ait_urls on teardown so a\n // stale file doesn't cause the MCP daemon to attempt a doomed attach.\n // SECRET-HANDLING: relayUrlDeleteFn never logs the path or URL values.\n void relayUrlDeleteFn?.(server.config.root);\n };\n httpServer?.once('close', cleanup);\n process.once('SIGINT', cleanup);\n process.once('SIGTERM', cleanup);\n process.once('SIGHUP', cleanup);\n process.once('exit', cleanup);\n }\n },\n },\n };\n});\n\nexport const vite = aitDevtoolsPlugin.vite;\nexport const webpack = aitDevtoolsPlugin.webpack;\nexport const rollup = aitDevtoolsPlugin.rollup;\nexport const esbuild = aitDevtoolsPlugin.esbuild;\nexport const rspack = aitDevtoolsPlugin.rspack;\n\nexport default aitDevtoolsPlugin;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/shared/parent-watcher.ts","../../src/telemetry/state.ts","../../src/unplugin/index.ts"],"sourcesContent":["/**\n * Shared parent-PID watcher — used by both the MCP debug daemon and the\n * unplugin tunnel path to self-terminate when the parent process (e.g. Claude\n * Code, vite) has died or been reparented without sending SIGTERM/SIGHUP.\n *\n * Intentionally react-free and Node-stdlib-only so this module is safe to\n * import from the MCP daemon bundle (`dist/mcp/cli.js`) without violating the\n * install-graph invariant.\n */\n\n// ---------------------------------------------------------------------------\n// isPidAlive — extracted from src/mcp/server-lock.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when the given PID refers to a running process.\n *\n * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process\n * exists and we have permission to signal it; throws ESRCH when it doesn't exist.\n */\nexport function isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (err: unknown) {\n // ESRCH = no such process → stale lock.\n // EPERM = process exists but we can't signal it (still alive).\n if ((err as NodeJS.ErrnoException).code === 'EPERM') return true;\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// startParentWatcher — extracted from src/mcp/debug-server.ts\n// ---------------------------------------------------------------------------\n\n/**\n * Starts a periodic watcher that detects when the parent process (e.g. Claude\n * Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the\n * daemon can self-terminate rather than running as a zombie.\n *\n * Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns\n * `{ stop(): void }`, injectable deps for testability.\n *\n * @param onOrphaned - Called once when the parent is gone.\n * @param opts.intervalMs - Poll interval in milliseconds (default 5 000).\n * @param opts.initialPpid - Parent PID to watch (default `process.ppid`).\n * @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).\n * @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).\n * Detects ppid changes as well as death.\n * @param opts.log - Logger (default `process.stderr.write`).\n *\n * @returns `stop` — call during shutdown to clear the interval.\n */\nexport function startParentWatcher(\n onOrphaned: () => void,\n opts?: {\n intervalMs?: number;\n initialPpid?: number;\n isAlive?: (pid: number) => boolean;\n getPpid?: () => number;\n log?: (msg: string) => void;\n },\n): { stop(): void } {\n const {\n intervalMs = 5_000,\n initialPpid = process.ppid,\n isAlive = isPidAlive,\n getPpid = () => process.ppid,\n log = (msg: string) => process.stderr.write(msg),\n } = opts ?? {};\n\n // PID 1 is init/launchd — running under a process manager or as a detached\n // daemon. There is no meaningful parent to watch; skip the watcher entirely.\n if (initialPpid <= 1) {\n log('[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\\n');\n return { stop() {} };\n }\n\n let fired = false;\n\n const handle = setInterval(() => {\n if (fired) return;\n\n const currentPpid = getPpid();\n const orphaned = currentPpid !== initialPpid || !isAlive(initialPpid);\n\n if (orphaned) {\n fired = true;\n clearInterval(handle);\n log(\n `[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\\n`,\n );\n onOrphaned();\n }\n }, intervalMs);\n\n return {\n stop() {\n clearInterval(handle);\n },\n };\n}\n","/**\n * Telemetry consent state machine + localStorage I/O.\n *\n * localStorage keys are LOCKED — do not rename without updating the privacy page.\n */\n\nexport type ConsentState = 'granted' | 'denied' | 'undecided';\n\n// Key names — locked per privacy page spec\nconst KEY_CONSENT = '__ait_telemetry:consent';\nconst KEY_REPROMPT_AFTER = '__ait_telemetry:reprompt_after';\nconst KEY_POLICY_VERSION = '__ait_telemetry:policy_version';\nconst KEY_ANON_ID = '__ait_telemetry:anon_id';\n\n// Tier 0 keys\nexport const KEY_T0_LAST_SENT = '__ait_telemetry:t0_last_sent';\nexport const KEY_T0_OFF = '__ait_telemetry:t0_off';\n\n// ---------------------------------------------------------------------------\n// Tier 0 opt-out helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true if Tier 0 ping is enabled.\n * Disabled when `localStorage.__ait_telemetry:t0_off = '1'`\n * or `process.env.AITC_TELEMETRY === 'off'`.\n */\nexport function isTier0Enabled(): boolean {\n if (typeof process !== 'undefined' && process.env.AITC_TELEMETRY === 'off') return false;\n try {\n return localStorage.getItem(KEY_T0_OFF) !== '1';\n } catch {\n return true;\n }\n}\n\n/**\n * Sets or clears the Tier 0 opt-out marker.\n */\nexport function setTier0Enabled(enabled: boolean): void {\n try {\n if (enabled) {\n localStorage.removeItem(KEY_T0_OFF);\n } else {\n localStorage.setItem(KEY_T0_OFF, '1');\n }\n } catch {\n /* storage unavailable */\n }\n}\n\n/**\n * Returns true if Tier 0 has already been sent today (YYYY-MM-DD).\n */\nexport function hasSentTier0Today(): boolean {\n try {\n const stored = localStorage.getItem(KEY_T0_LAST_SENT);\n if (!stored) return false;\n const today = new Date().toISOString().slice(0, 10);\n return stored === today;\n } catch {\n return false;\n }\n}\n\n/**\n * Records that Tier 0 was sent today.\n */\nexport function markTier0Sent(): void {\n try {\n const today = new Date().toISOString().slice(0, 10);\n localStorage.setItem(KEY_T0_LAST_SENT, today);\n } catch {\n /* storage unavailable */\n }\n}\n\n/**\n * Current policy version. Bump this string whenever the privacy policy changes.\n * Users who previously granted on an older version will be re-prompted once.\n */\nexport const CURRENT_POLICY_VERSION = '2026-05-18';\n\n/** 30 days in milliseconds */\nconst THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;\n\n// ---------------------------------------------------------------------------\n// Reads\n// ---------------------------------------------------------------------------\n\nexport function readConsentState(): ConsentState {\n const raw = localStorage.getItem(KEY_CONSENT);\n if (raw === 'granted' || raw === 'denied') return raw;\n return 'undecided';\n}\n\nexport function readRepromptAfter(): number {\n const raw = localStorage.getItem(KEY_REPROMPT_AFTER);\n if (raw === null) return 0;\n const n = Number(raw);\n return Number.isFinite(n) ? n : 0;\n}\n\nexport function readPolicyVersion(): string | null {\n return localStorage.getItem(KEY_POLICY_VERSION);\n}\n\n/**\n * Returns the stored anon_id, or generates + persists a new UUID v4 on first call.\n * Once generated it is never overwritten.\n */\nexport function getOrCreateAnonId(): string {\n const existing = localStorage.getItem(KEY_ANON_ID);\n if (existing) return existing;\n const id = crypto.randomUUID();\n localStorage.setItem(KEY_ANON_ID, id);\n return id;\n}\n\n// ---------------------------------------------------------------------------\n// Writes / transitions\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve effective consent, handling the policy-version bump rule:\n * - If stored = \"granted\" but stored version ≠ CURRENT → revert to undecided\n * - If stored = \"denied\" and version changed → stay denied (no re-prompt)\n *\n * Call this at init time to normalise state before checking whether to show a toast.\n * Returns the effective ConsentState after applying the version-bump rule.\n */\nexport function resolveEffectiveConsent(): ConsentState {\n const raw = localStorage.getItem(KEY_CONSENT);\n if (raw === 'granted') {\n const storedVersion = readPolicyVersion();\n if (storedVersion !== CURRENT_POLICY_VERSION) {\n // Policy changed — treat as undecided so user gets re-prompted once\n localStorage.removeItem(KEY_CONSENT);\n localStorage.removeItem(KEY_POLICY_VERSION);\n return 'undecided';\n }\n return 'granted';\n }\n if (raw === 'denied') return 'denied';\n return 'undecided';\n}\n\n/**\n * User clicked \"Yes, send\".\n * Sets consent = granted, records policy version.\n */\nexport function acceptConsent(): void {\n localStorage.setItem(KEY_CONSENT, 'granted');\n localStorage.setItem(KEY_POLICY_VERSION, CURRENT_POLICY_VERSION);\n // Ensure reprompt_after is cleared (shouldn't matter, but keep state clean)\n localStorage.removeItem(KEY_REPROMPT_AFTER);\n}\n\n/**\n * User clicked \"No, thanks\".\n * First denial: sets reprompt_after = now + 30 days.\n * Second denial (reprompt_after was already set to a past finite value that triggered\n * re-prompt): sets reprompt_after = MAX_SAFE_INTEGER → permanent silence.\n */\nexport function denyConsent(): void {\n localStorage.setItem(KEY_CONSENT, 'denied');\n const existing = readRepromptAfter();\n if (existing > 0 && existing < Number.MAX_SAFE_INTEGER) {\n // This is the second denial — silence permanently\n localStorage.setItem(KEY_REPROMPT_AFTER, String(Number.MAX_SAFE_INTEGER));\n } else {\n // First denial\n localStorage.setItem(KEY_REPROMPT_AFTER, String(Date.now() + THIRTY_DAYS_MS));\n }\n}\n\n/**\n * Environment-tab toggle: free transition between granted/denied.\n * Does NOT touch reprompt_after.\n */\nexport function setConsentViaToggle(granted: boolean): void {\n if (granted) {\n localStorage.setItem(KEY_CONSENT, 'granted');\n localStorage.setItem(KEY_POLICY_VERSION, CURRENT_POLICY_VERSION);\n } else {\n localStorage.setItem(KEY_CONSENT, 'denied');\n }\n}\n\n/**\n * Returns true if the toast should be shown now.\n * Conditions:\n * - undecided (no prior choice or policy bumped to a newer version)\n * - denied + reprompt_after set + reprompt_after < now (one re-prompt after\n * the configured silence window; `denyConsent` flips to permanent silence\n * on the second denial by setting reprompt_after to MAX_SAFE_INTEGER).\n */\nexport function shouldShowToast(): boolean {\n const state = resolveEffectiveConsent();\n if (state === 'undecided') {\n const repromptAfter = readRepromptAfter();\n if (repromptAfter === 0) return true;\n return Date.now() > repromptAfter;\n }\n if (state === 'denied') {\n const repromptAfter = readRepromptAfter();\n if (repromptAfter === 0 || repromptAfter >= Number.MAX_SAFE_INTEGER) return false;\n return Date.now() > repromptAfter;\n }\n return false;\n}\n\n/**\n * Sends the DELETE request to remove the user's data from the server, and\n * rotates the local anon_id on success so any subsequent events are unlinkable\n * from the deleted history.\n */\nexport async function deleteMyData(endpoint: string): Promise<boolean> {\n const anonId = localStorage.getItem(KEY_ANON_ID);\n if (!anonId) return false;\n try {\n const res = await fetch(`${endpoint}/e?anon_id=${encodeURIComponent(anonId)}`, {\n method: 'DELETE',\n });\n if (!res.ok) return false;\n localStorage.setItem(KEY_ANON_ID, crypto.randomUUID());\n return true;\n } catch {\n return false;\n }\n}\n","/**\n * @ait-co/devtools unplugin\n *\n * 모든 주요 번들러를 지원하는 단일 플러그인.\n * @apps-in-toss/web-framework → @ait-co/devtools/mock 으로 alias 설정.\n *\n * Usage:\n * import aitDevtools from '@ait-co/devtools/unplugin';\n *\n * // Vite\n * export default { plugins: [aitDevtools.vite()] };\n *\n * // Webpack / Next.js\n * config.plugins.push(aitDevtools.webpack());\n *\n * // Rspack\n * config.plugins.push(aitDevtools.rspack());\n *\n * // esbuild\n * { plugins: [aitDevtools.esbuild()] }\n *\n * // Rollup\n * { plugins: [aitDevtools.rollup()] }\n */\n\nimport { fileURLToPath } from 'node:url';\nimport { createUnplugin } from 'unplugin';\nimport { startParentWatcher } from '../shared/parent-watcher.js';\nimport {\n ensureMachineConsent,\n type MachineTelemetryState,\n writeMachineState,\n} from '../telemetry/machine-state.js';\nimport { CURRENT_POLICY_VERSION } from '../telemetry/state.js';\n\n/**\n * Resolve `@ait-co/devtools/mock` to its real file path at plugin-load time.\n *\n * Returning the bare specifier from `resolveId` would stop the bundler from\n * walking node_modules for it — Vite 8+ treats such a non-null string as the\n * final resolved id and serves it via the virtual `/@id/` prefix, which 404s\n * because we don't provide a `load` hook. Resolving to an absolute path here\n * lets every supported bundler load the file the normal way.\n */\nconst MOCK_PATH = (() => {\n try {\n return fileURLToPath(import.meta.resolve('@ait-co/devtools/mock'));\n } catch {\n // Fallback for runtimes where `import.meta.resolve` is unavailable.\n return '@ait-co/devtools/mock';\n }\n})();\n\nexport interface AitDevtoolsOptions {\n /**\n * 패널 자동 주입 여부 (default: true)\n * true이면 진입점에 floating panel import를 자동 추가한다.\n */\n panel?: boolean;\n /**\n * production 환경에서도 devtools를 강제로 활성화 (default: false)\n */\n forceEnable?: boolean;\n /**\n * mock alias 활성화 여부. default: true (development), false (production + forceEnable)\n */\n mock?: boolean;\n /**\n * Vite dev server에 MCP state endpoint를 추가할지 여부 (default: false).\n *\n * `true`로 설정하면:\n * - GET /api/ait-devtools/state — 마지막으로 브라우저가 push한 mock state 스냅샷 반환\n * - POST /api/ait-devtools/state — 브라우저 panel이 상태 변경 시 자동 push (panel 내부 처리)\n *\n * 이 endpoint를 `@ait-co/devtools` MCP stdio server가 읽어 AI 에이전트에 mock state를 노출한다.\n * Vite 전용: webpack/rspack/esbuild/rollup 환경에서는 무시된다.\n */\n mcp?: boolean;\n /**\n * Vite dev 서버를 Cloudflare quick tunnel(`*.trycloudflare.com`, 계정 불필요)로\n * 외부 노출해 실제 폰에서 미리보기. **Vite dev 모드 전용** — production은\n * `forceEnable`이어도 터널을 띄우지 않는다 (의도치 않은 노출 방지). 다른 번들러는\n * 무시. `true`면 기본 동작, 객체로 세부 설정 가능.\n */\n tunnel?:\n | boolean\n | {\n /** 노출할 포트 (미지정 시 dev 서버가 실제 listen한 포트 자동 감지). */\n port?: number;\n /** 터미널 ASCII QR 출력 (default: true). */\n qr?: boolean;\n /**\n * 환경 2(실기기 PWA)에 CDP 디버깅 배선 (default: false).\n *\n * `true`면 dev 서버 HTTP 터널과 **별도로** Chii relay를 띄우고 그 relay에\n * 두 번째 quick tunnel을 붙여, launcher QR deep-link에 `&debug=1&relay=<wss>`를\n * 실어 보낸다. 폰의 PWA iframe이 in-app debug gate를 통과해 target.js를 주입받고,\n * AI host MCP가 그 relay에 client로 붙으면 실기기 WebKit 위에서 CDP 디버깅이 열린다.\n * mock SDK는 그대로라 `call_sdk`는 환경 2에서 mock을 친다 (fidelity 사다리의\n * 설계 의도 — SDK fidelity가 필요하면 환경 3로 올라간다).\n */\n cdp?: boolean;\n };\n}\n\nconst FRAMEWORK_ID = '@apps-in-toss/web-framework';\nconst BRIDGE_ID = '@apps-in-toss/web-bridge'; // back-compat (2.x)\nconst ANALYTICS_ID = '@apps-in-toss/web-analytics'; // back-compat (2.x)\nconst WEBVIEW_BRIDGE_ID = '@apps-in-toss/webview-bridge'; // 3.0+\n\n/** MCP state endpoint path — browser panel POSTs here, MCP server GETs here */\nconst MCP_STATE_PATH = '/api/ait-devtools/state';\n\n/**\n * Machine-level telemetry consent endpoint (#542).\n *\n * GET → returns current machine consent state as JSON (for the panel to read\n * and skip the toast when already decided).\n * POST → panel or environment-tab toggle writes new consent back to the machine\n * file (body: { consent: 'granted' | 'denied', policy_version: string }).\n *\n * Always registered (not gated on `mcp: true`) — the panel needs this\n * unconditionally when the dev server is running.\n */\nconst TELEMETRY_CONSENT_PATH = '/api/ait-devtools/telemetry-consent';\n\n/**\n * Resolves the effective tunnel option (#425).\n *\n * An explicit `tunnel` value (including `false`) always takes priority over\n * env vars — the `??` operator means `undefined` (= omitted) falls through,\n * but `false` / `true` / an object are preserved as-is (non-breaking).\n *\n * When the option is omitted:\n * - `AIT_TUNNEL=1` enables the base screen-preview tunnel.\n * - `AIT_TUNNEL_CDP=1` (requires `AIT_TUNNEL`) upgrades to the CDP relay.\n * - Neither set → `false` (disabled).\n *\n * Extracted as a pure function so it can be unit-tested without standing up\n * a full Vite dev server.\n *\n * @param explicit - The `tunnel` option as passed by the consumer (or `undefined` when omitted).\n * @param env - The process environment (injectable for testing).\n */\nexport function resolveTunnelOption(\n explicit: AitDevtoolsOptions['tunnel'],\n env: Record<string, string | undefined>,\n): AitDevtoolsOptions['tunnel'] {\n return explicit ?? (env.AIT_TUNNEL ? { cdp: !!env.AIT_TUNNEL_CDP } : false);\n}\n\nconst aitDevtoolsPlugin = createUnplugin((options?: AitDevtoolsOptions) => {\n const isDev = process.env.NODE_ENV !== 'production';\n const shouldEnable = isDev || (options?.forceEnable ?? false);\n const shouldMock = shouldEnable && (options?.mock ?? isDev);\n const shouldPanel = shouldEnable && (options?.panel ?? true);\n const shouldMcp = shouldEnable && (options?.mcp ?? false);\n\n // In-memory store for the last state snapshot pushed by the browser panel.\n // Only allocated when mcp: true to avoid any overhead in the common case.\n let lastState: string | null = null;\n\n // Tunnel is dev-only and Vite-only. Never under production — even with\n // forceEnable — so a production build can't accidentally expose itself.\n //\n // Tunnel toggle resolution (#425): an explicit `tunnel` option always wins;\n // when omitted, fall back to the AIT_TUNNEL / AIT_TUNNEL_CDP env vars so a\n // consumer needs no `tunnel:` line in vite.config to enable env-2 preview.\n // AIT_TUNNEL gates the base (screen preview); AIT_TUNNEL_CDP upgrades to the\n // CDP relay. Production safety is unchanged — the existing\n // `shouldTunnel = isDev && !!tunnelOpt` guard below still blocks prod builds.\n const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);\n const shouldTunnel = isDev && !!tunnelOpt;\n const tunnelConfig = typeof tunnelOpt === 'object' ? tunnelOpt : {};\n\n return {\n name: 'ait-co-devtools',\n enforce: 'pre' as const,\n\n resolveId(id: string) {\n if (!shouldMock) return null;\n // @apps-in-toss/web-framework → @ait-co/devtools/mock (absolute path)\n if (\n id === FRAMEWORK_ID ||\n id === WEBVIEW_BRIDGE_ID ||\n id === BRIDGE_ID ||\n id === ANALYTICS_ID\n ) {\n return MOCK_PATH;\n }\n return null;\n },\n\n transformInclude(id: string) {\n if (!shouldPanel) return false;\n // 진입점 파일에만 패널 import를 주입\n return (\n /\\.(tsx?|jsx?)$/.test(id) &&\n /\\/(main|index|entry|app)\\.[tj]sx?$/i.test(id) &&\n !id.includes('node_modules')\n );\n },\n\n transform(code: string) {\n // transformInclude가 이미 shouldPanel을 확인하지만, 안전망으로 유지\n if (!shouldPanel) return null;\n // 이미 패널이 import 되어있으면 스킵\n if (code.includes('@ait-co/devtools/panel')) return null;\n // transformInclude가 진입점 파일만 통과시키므로 바로 prepend\n return `import '@ait-co/devtools/panel';\\n${code}`;\n },\n\n // Vite-only: register the MCP state HTTP endpoint on the dev server, and\n // optionally start a Cloudflare quick tunnel once the dev server is listening.\n // Non-Vite bundlers do not have a dev server concept so this is silently\n // skipped (unplugin passes `vite` key only when building for Vite).\n vite: {\n config() {\n if (!shouldTunnel) return;\n // Vite blocks requests whose Host header isn't in `server.allowedHosts`\n // (defaults to localhost only). The quick-tunnel hostname is random per\n // run, so allow the whole `.trycloudflare.com` suffix while the tunnel\n // is on. (A leading `.` makes Vite match the domain and its subdomains.)\n return { server: { allowedHosts: ['.trycloudflare.com'] } };\n },\n\n configureServer(server: import('vite').ViteDevServer) {\n // Machine-level telemetry consent endpoint (#542): always registered when\n // the dev server is enabled so the panel can read/write consent across\n // origin rotations (tunnel host changes, port changes).\n //\n // We lazily initialise `machineConsent` once the server is ready. The\n // TTY prompt runs synchronously inside the `listening` event so it\n // appears in the same terminal window before any dev-server noise.\n let machineConsent: MachineTelemetryState | null = null;\n\n // Start the machine-consent bootstrap as soon as configureServer runs.\n // Fire-and-forget; errors are caught and logged. The endpoint guards\n // against `machineConsent === null` with a 503 during the brief boot.\n if (shouldEnable) {\n server.httpServer?.once('listening', () => {\n ensureMachineConsent(CURRENT_POLICY_VERSION)\n .then((state) => {\n machineConsent = state;\n })\n .catch((err: unknown) => {\n // Non-fatal — panel will fall back to localStorage behaviour.\n console.warn(\n `[@ait-co/devtools] machine consent init failed: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n });\n });\n\n // Telemetry consent endpoint — CORS open (localhost only in practice).\n server.middlewares.use(TELEMETRY_CONSENT_PATH, (req, res) => {\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type');\n\n if (req.method === 'OPTIONS') {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (req.method === 'GET') {\n if (machineConsent === null) {\n // Still booting — panel should fall back to localStorage.\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Machine consent not yet initialised.' }));\n return;\n }\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(machineConsent));\n return;\n }\n\n if (req.method === 'POST') {\n const chunks: Buffer[] = [];\n req.on('data', (chunk: Buffer) => chunks.push(chunk));\n req.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString('utf-8');\n const payload = JSON.parse(body) as {\n consent?: string;\n policy_version?: string;\n };\n\n const consent = payload.consent;\n if (consent !== 'granted' && consent !== 'denied' && consent !== 'undecided') {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'Invalid consent value. Expected granted | denied | undecided.',\n }),\n );\n return;\n }\n\n writeMachineState({\n consent,\n policy_version: payload.policy_version ?? CURRENT_POLICY_VERSION,\n })\n .then(async () => {\n // Update the in-memory cache.\n const { readMachineState } = await import('../telemetry/machine-state.js');\n machineConsent = await readMachineState();\n res.writeHead(204);\n res.end();\n })\n .catch((err: unknown) => {\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: `Write failed: ${err instanceof Error ? err.message : String(err)}`,\n }),\n );\n });\n return;\n } catch {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Invalid JSON body.' }));\n }\n });\n return;\n }\n\n res.writeHead(405, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Method not allowed' }));\n });\n }\n\n // MCP state endpoint: browser panel POSTs state here, MCP stdio server GETs it.\n if (shouldMcp) {\n server.middlewares.use(MCP_STATE_PATH, (req, res) => {\n // Allow Claude Code / AI agents (running locally) to read state\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type');\n\n if (req.method === 'OPTIONS') {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (req.method === 'GET') {\n if (lastState === null) {\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n error: 'No state received yet. Open the app in a browser first.',\n }),\n );\n return;\n }\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(lastState);\n return;\n }\n\n if (req.method === 'POST') {\n const chunks: Buffer[] = [];\n req.on('data', (chunk: Buffer) => chunks.push(chunk));\n req.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString('utf-8');\n // Validate it's parseable JSON before caching\n JSON.parse(body);\n lastState = body;\n res.writeHead(204);\n res.end();\n } catch {\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Invalid JSON' }));\n }\n });\n return;\n }\n\n res.writeHead(405, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Method not allowed' }));\n });\n }\n\n // Tunnel: start a Cloudflare quick tunnel once the dev server is listening.\n if (shouldTunnel) {\n let tunnel: { stop: () => void } | null = null;\n // env-2 CDP wiring (tunnel.cdp): a second tunnel + Chii relay, torn\n // down alongside the HTTP tunnel. Fire-and-forget close on teardown.\n let relayTunnel: { stop: () => void } | null = null;\n let relay: { close: () => Promise<void> } | null = null;\n // env-2 HTML dashboard (issue #408): local 127.0.0.1 HTTP server that\n // serves the QR + connect-steps + FAQ page (env 3/4 UX parity), opened\n // in the browser when CDP is wired + GUI present. Torn down with the\n // tunnel. Only set when the dashboard actually started.\n let qrDashboard: { close: () => Promise<void> } | null = null;\n // env-2 URL file store (#424): captured after the first writeRelayUrls\n // call so cleanup() can call deleteRelayUrls without a re-import.\n // SECRET-HANDLING: the stored function reference never carries URL values.\n let relayUrlDeleteFn: ((projectRoot: string) => Promise<void>) | null = null;\n // #420: parent-PID watcher — self-terminate when vite's parent dies so\n // cloudflared children don't become zombies holding stale tunnels.\n let parentWatcher: { stop(): void } | null = null;\n const httpServer = server.httpServer;\n\n httpServer?.once('listening', () => {\n const address = httpServer?.address();\n const port =\n tunnelConfig.port ??\n (address && typeof address === 'object' ? address.port : undefined);\n if (!port) {\n console.warn(\n '[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.',\n );\n return;\n }\n // Dynamic import keeps `cloudflared` / `qrcode-terminal` off the\n // module graph unless the tunnel is actually used.\n import('./tunnel.js')\n .then(async ({ startQuickTunnel, printTunnelBanner, startTunnelDashboard }) => {\n const t = await startQuickTunnel(port);\n tunnel = t;\n\n // env-2 CDP: boot a Chii relay (OS-assigned local port) and a\n // second quick tunnel to it. The relay's https tunnel URL becomes\n // the `wss://` relay the launcher QR carries (&debug=1&relay=).\n let relayWssUrl: string | undefined;\n // SECRET-HANDLING: relayHttpUrl carries the relay host — never logged.\n let relayHttpUrl: string | undefined;\n // LOCAL relay base — loopback URL, safe to surface (issue #530).\n let relayLocalHttpUrl: string | undefined;\n if (tunnelConfig.cdp) {\n try {\n // Relay-auth baseline (issue #250): the env-2 CDP relay is\n // reachable over a public `*.trycloudflare.com` tunnel, so a\n // configured TOTP secret is MANDATORY and the relay enforces\n // it on every WS upgrade.\n //\n // First-run auto-mint (issue #394, project-local #396): if\n // AIT_DEBUG_TOTP_SECRET is not yet set, ensureRelaySecret()\n // mints a 256-bit random secret, persists it to the project-\n // local file <project>/.ait_relay (0600, anchored at the\n // nearest package.json directory above server.config.root),\n // and injects it into process.env so the following\n // assertRelayAuthConfigured() call succeeds. On subsequent\n // runs the persisted value is loaded silently — no manual\n // export needed. The MCP daemon reads the SAME file read-only\n // via loadRelaySecretReadOnly() when switching to a relay env.\n // SECRET-HANDLING: neither ensureRelaySecret nor the\n // guard/predicate log the secret value.\n const { ensureRelaySecret } = await import('../mcp/relay-secret-store.js');\n await ensureRelaySecret({ projectRoot: server.config.root });\n const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await import(\n '../mcp/totp.js'\n );\n assertRelayAuthConfigured();\n const verifyAuth = buildRelayVerifyAuth();\n const { startChiiRelay } = await import('../mcp/chii-relay.js');\n // Issue #467: this relay lives in the vite process, so the\n // MCP daemon's get_debug_status counter cannot see its 401s.\n // Surface a throttled hint in the vite terminal instead.\n // SECRET-HANDLING: fixed message only — no URL, code, host.\n let lastAuthRejectWarnAt = 0;\n const r = await startChiiRelay({\n port: 0,\n verifyAuth,\n onAuthReject: () => {\n const nowMs = Date.now();\n if (nowMs - lastAuthRejectWarnAt < 10_000) return;\n lastAuthRejectWarnAt = nowMs;\n console.warn(\n '[@ait-co/devtools] tunnel: relay 인증(TOTP) 거부 감지 — 폰에서 QR을 다시 스캔하세요 (코드는 ~3분마다 만료)',\n );\n },\n });\n relay = r;\n const rt = await startQuickTunnel(r.port);\n relayTunnel = rt;\n // SECRET-HANDLING: rt.url is the https relay base — stored in\n // relayHttpUrl for .ait_urls write below; never logged.\n relayHttpUrl = rt.url;\n relayWssUrl = rt.url.replace(/^https:/, 'wss:');\n // LOCAL relay base for MCP inspector URL assembly (issue #530):\n // the relay process runs on this machine, so the inspector\n // front_end + client WS can use the loopback address directly —\n // no tunnel round-trip for the developer's browser.\n // Safe to surface: loopback URL contains no tunnel host.\n relayLocalHttpUrl = `http://127.0.0.1:${r.port}`;\n } catch (err: unknown) {\n console.warn(\n `[@ait-co/devtools] tunnel: CDP relay not started — screen preview works without on-device debugging: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n\n // Read the app name from the project's package.json to add to\n // the launcher deep-link (#498). Failure is silently ignored.\n let tunnelAppName: string | undefined;\n try {\n const { readFileSync } = await import('node:fs');\n const pkgPath = `${server.config.root}/package.json`;\n const pkgRaw = readFileSync(pkgPath, 'utf8');\n const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;\n const rawName = typeof pkg.name === 'string' ? pkg.name : '';\n const stripped = rawName.includes('/')\n ? rawName.slice(rawName.indexOf('/') + 1)\n : rawName;\n tunnelAppName = stripped.trim() || undefined;\n } catch {\n // Silently ignore — fail-open.\n }\n\n await printTunnelBanner(t.url, {\n qr: tunnelConfig.qr,\n relayWssUrl,\n name: tunnelAppName,\n });\n\n // env-2 URL file-based discovery (#424): write .ait_urls so the\n // MCP daemon can discover the relay/tunnel URLs without manual env\n // var copy-paste. SECRET-HANDLING: URL values are never logged.\n // Capture deleteRelayUrls in the outer-scope fn so cleanup() can\n // call it without re-importing (no async in signal handlers).\n const { writeRelayUrls, deleteRelayUrls } = await import(\n '../mcp/relay-url-store.js'\n );\n await writeRelayUrls({\n projectRoot: server.config.root,\n tunnelBaseUrl: t.url,\n ...(relayHttpUrl !== undefined ? { relayBaseUrl: relayHttpUrl } : {}),\n // Issue #530: local relay base for inspector URL (loopback, no tunnel host).\n ...(relayLocalHttpUrl !== undefined ? { relayLocalUrl: relayLocalHttpUrl } : {}),\n });\n relayUrlDeleteFn = (root: string) => deleteRelayUrls({ projectRoot: root });\n\n // env-2 HTML dashboard (issue #408): when CDP is wired and a GUI\n // is present, serve the same QR+FAQ dashboard env 3/4 uses and\n // open it in the browser. No-op (returns undefined) for the\n // screen-only tunnel, headless, qr:false, or AIT_AUTO_DEVTOOLS=0\n // — the ASCII QR above remains the fallback in those cases.\n if (relayWssUrl) {\n qrDashboard =\n (await startTunnelDashboard({\n tunnelUrl: t.url,\n relayWssUrl,\n qr: tunnelConfig.qr,\n name: tunnelAppName,\n })) ?? null;\n }\n\n // #420: start watching the parent PID now that tunnel resources\n // are allocated. When the parent dies/reparents, clean up\n // synchronously (stops cloudflared children) then exit.\n parentWatcher = startParentWatcher(() => {\n cleanup();\n process.exit(0);\n });\n })\n .catch((err: unknown) => {\n console.warn(\n `[@ait-co/devtools] tunnel failed to start: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n });\n });\n\n const cleanup = () => {\n parentWatcher?.stop();\n tunnel?.stop();\n relayTunnel?.stop();\n void relay?.close();\n void qrDashboard?.close();\n // env-2 URL file cleanup (#424): remove .ait_urls on teardown so a\n // stale file doesn't cause the MCP daemon to attempt a doomed attach.\n // SECRET-HANDLING: relayUrlDeleteFn never logs the path or URL values.\n void relayUrlDeleteFn?.(server.config.root);\n };\n httpServer?.once('close', cleanup);\n process.once('SIGINT', cleanup);\n process.once('SIGTERM', cleanup);\n process.once('SIGHUP', cleanup);\n process.once('exit', cleanup);\n }\n },\n },\n };\n});\n\nexport const vite = aitDevtoolsPlugin.vite;\nexport const webpack = aitDevtoolsPlugin.webpack;\nexport const rollup = aitDevtoolsPlugin.rollup;\nexport const esbuild = aitDevtoolsPlugin.esbuild;\nexport const rspack = aitDevtoolsPlugin.rspack;\n\nexport default aitDevtoolsPlugin;\n"],"mappings":"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"}
|
package/dist/unplugin/tunnel.cjs
CHANGED
|
@@ -136,10 +136,10 @@ async function startTunnelDashboard(opts) {
|
|
|
136
136
|
const log = opts.log ?? ((m) => console.log(m));
|
|
137
137
|
if (!opts.relayWssUrl) return void 0;
|
|
138
138
|
if (opts.qr === false) return void 0;
|
|
139
|
-
const { isAutoDevtoolsDisabled } = await Promise.resolve().then(() => require("../devtools-opener-
|
|
139
|
+
const { isAutoDevtoolsDisabled } = await Promise.resolve().then(() => require("../devtools-opener-BDY0w3_0.cjs"));
|
|
140
140
|
if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
|
|
141
|
-
const { startQrHttpServer } = await Promise.resolve().then(() => require("../qr-http-server-
|
|
142
|
-
const { buildLauncherAttachUrl } = await Promise.resolve().then(() => require("../deeplink-
|
|
141
|
+
const { startQrHttpServer } = await Promise.resolve().then(() => require("../qr-http-server-CAUyOrCm.cjs"));
|
|
142
|
+
const { buildLauncherAttachUrl } = await Promise.resolve().then(() => require("../deeplink-DCScMYcp.cjs"));
|
|
143
143
|
const { generateTotp } = await Promise.resolve().then(() => require("../totp-BwDZ6dUT.cjs"));
|
|
144
144
|
const getDashboardState = () => {
|
|
145
145
|
const secret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
@@ -167,7 +167,7 @@ async function startTunnelDashboard(opts) {
|
|
|
167
167
|
}, 2e4);
|
|
168
168
|
totpRefreshHandle.unref();
|
|
169
169
|
const dashboardUrl = `http://127.0.0.1:${server.port}`;
|
|
170
|
-
const { openUrlInBrowser } = await Promise.resolve().then(() => require("../devtools-opener-
|
|
170
|
+
const { openUrlInBrowser } = await Promise.resolve().then(() => require("../devtools-opener-BDY0w3_0.cjs"));
|
|
171
171
|
log(openUrlInBrowser(dashboardUrl) ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}` : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`);
|
|
172
172
|
return {
|
|
173
173
|
url: dashboardUrl,
|
package/dist/unplugin/tunnel.js
CHANGED
|
@@ -135,10 +135,10 @@ async function startTunnelDashboard(opts) {
|
|
|
135
135
|
const log = opts.log ?? ((m) => console.log(m));
|
|
136
136
|
if (!opts.relayWssUrl) return void 0;
|
|
137
137
|
if (opts.qr === false) return void 0;
|
|
138
|
-
const { isAutoDevtoolsDisabled } = await import("../devtools-opener-
|
|
138
|
+
const { isAutoDevtoolsDisabled } = await import("../devtools-opener-BTl5A6Cd.js");
|
|
139
139
|
if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
|
|
140
|
-
const { startQrHttpServer } = await import("../qr-http-server-
|
|
141
|
-
const { buildLauncherAttachUrl } = await import("../deeplink-
|
|
140
|
+
const { startQrHttpServer } = await import("../qr-http-server-BxjrJr9t.js");
|
|
141
|
+
const { buildLauncherAttachUrl } = await import("../deeplink-BpO9qc-D.js");
|
|
142
142
|
const { generateTotp } = await import("../totp-BmKSPb5d.js");
|
|
143
143
|
const getDashboardState = () => {
|
|
144
144
|
const secret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
@@ -166,7 +166,7 @@ async function startTunnelDashboard(opts) {
|
|
|
166
166
|
}, 2e4);
|
|
167
167
|
totpRefreshHandle.unref();
|
|
168
168
|
const dashboardUrl = `http://127.0.0.1:${server.port}`;
|
|
169
|
-
const { openUrlInBrowser } = await import("../devtools-opener-
|
|
169
|
+
const { openUrlInBrowser } = await import("../devtools-opener-BTl5A6Cd.js");
|
|
170
170
|
log(openUrlInBrowser(dashboardUrl) ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}` : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`);
|
|
171
171
|
return {
|
|
172
172
|
url: dashboardUrl,
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"deeplink-B-94XmWA.js","names":[],"sources":["../src/mcp/deeplink.ts"],"sourcesContent":["/**\n * URL of the AITC Sandbox launcher PWA.\n *\n * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the\n * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy\n * for the same reason — keep the two in sync when the URL changes.\n */\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Optional metadata that enriches the launcher deep-link (#498).\n *\n * These fields are added as query params so the launcher PWA can display\n * a recognizable identity (name, icon) without the user having to configure\n * anything extra.\n */\nexport interface LauncherAttachUrlOpts {\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * Absolute `https://` icon URL for the partner nav bar icon slot (`icon=`\n * param). Non-https or falsy values are not added.\n */\n icon?: string;\n}\n\n/**\n * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).\n *\n * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport\n * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the\n * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js\n * is injected. `&at=<totpCode>` is added only when a code is provided (same\n * conditional as {@link buildDeepLinkAttachUrl}).\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the\n * launcher partner bar shows the app name instead of the generic default (#498).\n * When `opts.icon` is an absolute https:// URL, it is added as `&icon=` so the\n * launcher can render an icon next to the title (#498).\n *\n * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL\n * via raw string manipulation), this function uses WHATWG `encodeURIComponent`\n * because the target is a standard `https:` URL.\n *\n * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param\n * only — never logged or returned separately. Callers must NOT log the result\n * of this function to stdout/stderr.\n *\n * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL\n * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.\n * @param wssUrl - The `wss://` relay URL the framed page will attach to.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is appended as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Omit when TOTP is disabled.\n * @param opts - Optional app identity hints: `name` and `icon` (#498).\n * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>\n * [&at=<code>][&name=<enc>][&icon=<enc>]` params.\n */\nexport function buildLauncherAttachUrl(\n tunnelUrl: string,\n wssUrl: string,\n totpCode?: string,\n opts?: LauncherAttachUrlOpts,\n): string {\n let url =\n `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}` +\n `&debug=1&relay=${encodeURIComponent(wssUrl)}`;\n if (totpCode !== undefined && totpCode !== '') {\n url += `&at=${encodeURIComponent(totpCode)}`;\n }\n // App identity hints (#498): add non-blank name and valid https icon.\n if (opts?.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts?.icon !== undefined) {\n let iconParsed: URL;\n try {\n iconParsed = new URL(opts.icon);\n } catch {\n iconParsed = null as unknown as URL;\n }\n if (iconParsed?.protocol === 'https:') {\n url += `&icon=${encodeURIComponent(opts.icon)}`;\n }\n }\n return url;\n}\n\n/**\n * Build a self-attaching dog-food deep-link.\n *\n * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`\n * URL that opens a dog-food bundle on a phone. The in-app debug gate\n * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries\n * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus\n * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result\n * as a QR code and scanning it with the phone camera opens the mini-app and\n * attaches it to the live Chii relay. QR is the single entry path — it needs\n * no USB cable, platform CLI, or driver, and works the same on iOS/Android.\n *\n * The Toss app propagates extra query params from the entry deep link into the\n * mini-app WebView's `location.search` (confirmed behavior), so the gate reads\n * them at attach time.\n *\n * TOTP `at=` param:\n * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional\n * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.\n * The code must be computed by the caller at call time — do NOT pre-compute\n * and cache it, because the 30-second window expires quickly. The in-app gate\n * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.\n *\n * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.\n * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query\n * decomposition you can rely on across runtimes), so query manipulation via\n * `url.searchParams` is not portable here. We splice the query string directly\n * on the raw string instead, which keeps the scheme, authority, path, and any\n * pre-existing params (notably `_deploymentId`) byte-for-byte intact.\n */\n\n/**\n * Suspicious/generic authority values that indicate a malformed or placeholder\n * scheme URL. These are host strings that will almost certainly cause the Toss\n * app to fail with \"bundle not found\" silently.\n *\n * The expected form from `ait deploy --scheme-only` is:\n * intoss-private://<appName>?_deploymentId=<uuid>\n * where `<appName>` is a non-generic string like `aitc-sdk-example`.\n */\nconst SUSPICIOUS_AUTHORITIES = new Set<string>(['', 'web', 'localhost', '127.0.0.1', 'app']);\n\n/**\n * Validates the authority (host) portion of a scheme URL.\n *\n * Returns a warning message if the authority is missing or looks like a\n * placeholder, or `null` if the authority looks valid.\n *\n * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`\n * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).\n */\nexport function validateSchemeAuthority(schemeUrl: string): string | null {\n // Extract authority from `scheme://authority[/path][?query][#hash]`.\n // We cannot use the WHATWG URL parser for non-special schemes reliably\n // (see the deeplink.ts module comment), so we parse the raw string.\n const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\\-.]*:\\/\\//, '');\n if (afterScheme === schemeUrl) {\n // No `://` found — not a scheme URL at all.\n return (\n 'scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). ' +\n 'Use the URL printed by `ait deploy --scheme-only`.'\n );\n }\n\n // authority ends at the first `/`, `?`, `#`, or end of string.\n const authorityEnd = afterScheme.search(/[/?#]/);\n const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);\n\n if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) {\n const displayAuthority = authority === '' ? '(empty)' : `\"${authority}\"`;\n return (\n `scheme_url authority ${displayAuthority} looks like a placeholder. ` +\n 'Expected an app name like `intoss-private://aitc-sdk-example?_deploymentId=<uuid>`. ' +\n 'Use the URL printed by `ait deploy --scheme-only` — it includes the correct app name as the host.'\n );\n }\n\n return null;\n}\n\n/** A param the helper appends. Existing occurrences are replaced, not duplicated. */\ntype AppendParam = readonly [key: string, value: string];\n\nfunction stripExisting(query: string, key: string): string {\n if (query === '') return '';\n return query\n .split('&')\n .filter((pair) => pair !== '' && pair.split('=')[0] !== key)\n .join('&');\n}\n\n/**\n * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a\n * scheme URL's query string, preserving everything else (scheme, authority,\n * path, hash, and the existing `_deploymentId` param). If any of the spliced\n * params is already present it is replaced so the helper is idempotent.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed\n * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B\n * of the gate); this helper does not invent one.\n * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the\n * running debug MCP server's quick tunnel.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Pass `undefined` or omit when TOTP is disabled.\n * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`\n * appended.\n * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so\n * producing such a link would be a silent dead end).\n */\nexport function buildDeepLinkAttachUrl(\n schemeUrl: string,\n wssUrl: string,\n totpCode?: string,\n): string {\n let relay: URL;\n try {\n relay = new URL(wssUrl);\n } catch {\n throw new Error(`relay URL is not a valid URL: ${wssUrl}`);\n }\n if (relay.protocol !== 'wss:') {\n throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);\n }\n\n const hashIndex = schemeUrl.indexOf('#');\n const hash = hashIndex === -1 ? '' : schemeUrl.slice(hashIndex);\n const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);\n\n const queryIndex = beforeHash.indexOf('?');\n const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);\n let query = queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1);\n\n const appended: AppendParam[] = [\n ['debug', '1'],\n ['relay', wssUrl],\n ];\n // Only splice `at=` when a code is provided (TOTP enabled). Omitting it when\n // TOTP is disabled preserves backward compatibility with gate deployments\n // that do not yet evaluate the `at` param.\n if (totpCode !== undefined && totpCode !== '') {\n appended.push(['at', totpCode]);\n }\n\n // Always strip the `at` key from the existing query so a stale code from a\n // previous run is removed even when the caller does not provide a fresh code.\n query = stripExisting(query, 'at');\n\n for (const [key] of appended) {\n query = stripExisting(query, key);\n }\n for (const [key, value] of appended) {\n const pair = `${key}=${encodeURIComponent(value)}`;\n query = query === '' ? pair : `${query}&${pair}`;\n }\n\n return `${base}?${query}${hash}`;\n}\n"],"mappings":";;;;;;;;AAOA,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDrB,SAAgB,uBACd,WACA,QACA,UACA,MACQ;CACR,IAAI,MACF,GAAG,aAAa,OAAO,mBAAmB,UAAU,CAAA,iBAClC,mBAAmB,OAAO;AAC9C,KAAI,aAAa,KAAA,KAAa,aAAa,GACzC,QAAO,OAAO,mBAAmB,SAAS;AAG5C,KAAI,MAAM,SAAS,KAAA,KAAa,KAAK,KAAK,MAAM,KAAK,GACnD,QAAO,SAAS,mBAAmB,KAAK,KAAK,MAAM,CAAC;AAEtD,KAAI,MAAM,SAAS,KAAA,GAAW;EAC5B,IAAI;AACJ,MAAI;AACF,gBAAa,IAAI,IAAI,KAAK,KAAK;UACzB;AACN,gBAAa;;AAEf,MAAI,YAAY,aAAa,SAC3B,QAAO,SAAS,mBAAmB,KAAK,KAAK;;AAGjD,QAAO"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"deeplink-CYqDwVYs.js","names":[],"sources":["../src/mcp/deeplink.ts"],"sourcesContent":["/**\n * URL of the AITC Sandbox launcher PWA.\n *\n * Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the\n * mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy\n * for the same reason — keep the two in sync when the URL changes.\n */\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Optional metadata that enriches the launcher deep-link (#498).\n *\n * These fields are added as query params so the launcher PWA can display\n * a recognizable identity (name, icon) without the user having to configure\n * anything extra.\n */\nexport interface LauncherAttachUrlOpts {\n /**\n * Human-readable app name shown in the partner nav bar (`name=` param).\n * Blank / whitespace-only values are not added.\n */\n name?: string;\n /**\n * Absolute `https://` icon URL for the partner nav bar icon slot (`icon=`\n * param). Non-https or falsy values are not added.\n */\n icon?: string;\n}\n\n/**\n * Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).\n *\n * The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport\n * iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the\n * framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js\n * is injected. `&at=<totpCode>` is added only when a code is provided (same\n * conditional as {@link buildDeepLinkAttachUrl}).\n *\n * When `opts.name` is given (non-blank), it is added as `&name=` so the\n * launcher partner bar shows the app name instead of the generic default (#498).\n * When `opts.icon` is an absolute https:// URL, it is added as `&icon=` so the\n * launcher can render an icon next to the title (#498).\n *\n * Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL\n * via raw string manipulation), this function uses WHATWG `encodeURIComponent`\n * because the target is a standard `https:` URL.\n *\n * SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param\n * only — never logged or returned separately. Callers must NOT log the result\n * of this function to stdout/stderr.\n *\n * @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL\n * (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.\n * @param wssUrl - The `wss://` relay URL the framed page will attach to.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is appended as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Omit when TOTP is disabled.\n * @param opts - Optional app identity hints: `name` and `icon` (#498).\n * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>\n * [&at=<code>][&name=<enc>][&icon=<enc>]` params.\n */\nexport function buildLauncherAttachUrl(\n tunnelUrl: string,\n wssUrl: string,\n totpCode?: string,\n opts?: LauncherAttachUrlOpts,\n): string {\n let url =\n `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}` +\n `&debug=1&relay=${encodeURIComponent(wssUrl)}`;\n if (totpCode !== undefined && totpCode !== '') {\n url += `&at=${encodeURIComponent(totpCode)}`;\n }\n // App identity hints (#498): add non-blank name and valid https icon.\n if (opts?.name !== undefined && opts.name.trim() !== '') {\n url += `&name=${encodeURIComponent(opts.name.trim())}`;\n }\n if (opts?.icon !== undefined) {\n let iconParsed: URL;\n try {\n iconParsed = new URL(opts.icon);\n } catch {\n iconParsed = null as unknown as URL;\n }\n if (iconParsed?.protocol === 'https:') {\n url += `&icon=${encodeURIComponent(opts.icon)}`;\n }\n }\n return url;\n}\n\n/**\n * Build a self-attaching dog-food deep-link.\n *\n * `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`\n * URL that opens a dog-food bundle on a phone. The in-app debug gate\n * (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries\n * `debug=1` and `relay=<wss-url>`. This helper splices those params (plus\n * `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result\n * as a QR code and scanning it with the phone camera opens the mini-app and\n * attaches it to the live Chii relay. QR is the single entry path — it needs\n * no USB cable, platform CLI, or driver, and works the same on iOS/Android.\n *\n * The Toss app propagates extra query params from the entry deep link into the\n * mini-app WebView's `location.search` (confirmed behavior), so the gate reads\n * them at attach time.\n *\n * TOTP `at=` param:\n * When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional\n * `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.\n * The code must be computed by the caller at call time — do NOT pre-compute\n * and cache it, because the 30-second window expires quickly. The in-app gate\n * (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.\n *\n * Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.\n * The WHATWG `URL` parser treats such schemes opaquely (no host/path/query\n * decomposition you can rely on across runtimes), so query manipulation via\n * `url.searchParams` is not portable here. We splice the query string directly\n * on the raw string instead, which keeps the scheme, authority, path, and any\n * pre-existing params (notably `_deploymentId`) byte-for-byte intact.\n */\n\n/**\n * Suspicious/generic authority values that indicate a malformed or placeholder\n * scheme URL. These are host strings that will almost certainly cause the Toss\n * app to fail with \"bundle not found\" silently.\n *\n * The expected form from `ait deploy --scheme-only` is:\n * intoss-private://<appName>?_deploymentId=<uuid>\n * where `<appName>` is a non-generic string like `aitc-sdk-example`.\n */\nconst SUSPICIOUS_AUTHORITIES = new Set<string>(['', 'web', 'localhost', '127.0.0.1', 'app']);\n\n/**\n * Validates the authority (host) portion of a scheme URL.\n *\n * Returns a warning message if the authority is missing or looks like a\n * placeholder, or `null` if the authority looks valid.\n *\n * Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`\n * The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).\n */\nexport function validateSchemeAuthority(schemeUrl: string): string | null {\n // Extract authority from `scheme://authority[/path][?query][#hash]`.\n // We cannot use the WHATWG URL parser for non-special schemes reliably\n // (see the deeplink.ts module comment), so we parse the raw string.\n const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\\-.]*:\\/\\//, '');\n if (afterScheme === schemeUrl) {\n // No `://` found — not a scheme URL at all.\n return (\n 'scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). ' +\n 'Use the URL printed by `ait deploy --scheme-only`.'\n );\n }\n\n // authority ends at the first `/`, `?`, `#`, or end of string.\n const authorityEnd = afterScheme.search(/[/?#]/);\n const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);\n\n if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) {\n const displayAuthority = authority === '' ? '(empty)' : `\"${authority}\"`;\n return (\n `scheme_url authority ${displayAuthority} looks like a placeholder. ` +\n 'Expected an app name like `intoss-private://aitc-sdk-example?_deploymentId=<uuid>`. ' +\n 'Use the URL printed by `ait deploy --scheme-only` — it includes the correct app name as the host.'\n );\n }\n\n return null;\n}\n\n/** A param the helper appends. Existing occurrences are replaced, not duplicated. */\ntype AppendParam = readonly [key: string, value: string];\n\nfunction stripExisting(query: string, key: string): string {\n if (query === '') return '';\n return query\n .split('&')\n .filter((pair) => pair !== '' && pair.split('=')[0] !== key)\n .join('&');\n}\n\n/**\n * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a\n * scheme URL's query string, preserving everything else (scheme, authority,\n * path, hash, and the existing `_deploymentId` param). If any of the spliced\n * params is already present it is replaced so the helper is idempotent.\n *\n * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed\n * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B\n * of the gate); this helper does not invent one.\n * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the\n * running debug MCP server's quick tunnel.\n * @param totpCode - Optional current TOTP code (6 digits). When provided, it\n * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates\n * every 30 s. Pass `undefined` or omit when TOTP is disabled.\n * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`\n * appended.\n * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so\n * producing such a link would be a silent dead end).\n */\nexport function buildDeepLinkAttachUrl(\n schemeUrl: string,\n wssUrl: string,\n totpCode?: string,\n): string {\n let relay: URL;\n try {\n relay = new URL(wssUrl);\n } catch {\n throw new Error(`relay URL is not a valid URL: ${wssUrl}`);\n }\n if (relay.protocol !== 'wss:') {\n throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);\n }\n\n const hashIndex = schemeUrl.indexOf('#');\n const hash = hashIndex === -1 ? '' : schemeUrl.slice(hashIndex);\n const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);\n\n const queryIndex = beforeHash.indexOf('?');\n const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);\n let query = queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1);\n\n const appended: AppendParam[] = [\n ['debug', '1'],\n ['relay', wssUrl],\n ];\n // Only splice `at=` when a code is provided (TOTP enabled). Omitting it when\n // TOTP is disabled preserves backward compatibility with gate deployments\n // that do not yet evaluate the `at` param.\n if (totpCode !== undefined && totpCode !== '') {\n appended.push(['at', totpCode]);\n }\n\n // Always strip the `at` key from the existing query so a stale code from a\n // previous run is removed even when the caller does not provide a fresh code.\n query = stripExisting(query, 'at');\n\n for (const [key] of appended) {\n query = stripExisting(query, key);\n }\n for (const [key, value] of appended) {\n const pair = `${key}=${encodeURIComponent(value)}`;\n query = query === '' ? pair : `${query}&${pair}`;\n }\n\n return `${base}?${query}${hash}`;\n}\n"],"mappings":";;;;;;;;AAOA,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDrB,SAAgB,uBACd,WACA,QACA,UACA,MACQ;CACR,IAAI,MACF,GAAG,aAAa,OAAO,mBAAmB,UAAU,CAAA,iBAClC,mBAAmB,OAAO;AAC9C,KAAI,aAAa,KAAA,KAAa,aAAa,GACzC,QAAO,OAAO,mBAAmB,SAAS;AAG5C,KAAI,MAAM,SAAS,KAAA,KAAa,KAAK,KAAK,MAAM,KAAK,GACnD,QAAO,SAAS,mBAAmB,KAAK,KAAK,MAAM,CAAC;AAEtD,KAAI,MAAM,SAAS,KAAA,GAAW;EAC5B,IAAI;AACJ,MAAI;AACF,gBAAa,IAAI,IAAI,KAAK,KAAK;UACzB;AACN,gBAAa;;AAEf,MAAI,YAAY,aAAa,SAC3B,QAAO,SAAS,mBAAmB,KAAK,KAAK;;AAGjD,QAAO"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"devtools-opener-BbUXBzgA.js","names":[],"sources":["../src/mcp/devtools-opener.ts"],"sourcesContent":["/**\n * Auto-opens Chrome DevTools when a page attaches over the Chii relay.\n *\n * When a real device attaches (env 2 / 3 / 4 in the 4-environments fidelity\n * ladder), the Chii relay exposes a standard CDP WebSocket endpoint. Chii\n * also self-hosts its DevTools frontend at:\n *\n * <relay-base>/front_end/chii_app.html\n * ?ws|wss=<encodeURIComponent(\"<relay-host>/client/<uuid>?target=<targetId>&at=<totp>\")>\n *\n * The param name follows the relay base scheme — `ws=` for plain HTTP\n * (env 3/4 local relay), `wss=` for HTTPS (env 2 tunnel) — matching the\n * scheme branch in chii/public/index.js.\n *\n * This is the same URL format that Chii's own index-page inspect-links use\n * (derived from `chii/public/index.js` — the JS that powers the target list\n * page at `<relay-base>/`). Opening this URL in the developer's local browser\n * gives a full Chrome DevTools UI connected to the phone via the relay.\n *\n * IMPORTANT — environment guard:\n * Auto-open only fires in relay environments (env 2 / 3 / 4). In env 1\n * (local browser + mock SDK) the developer already has F12 available; opening\n * a DevTools window pointing at the mock relay would be confusing and useless.\n * The caller (`startAttachWatcher` in `debug-server.ts`) passes the current\n * environment and this module bails out when it is `mock`.\n *\n * Opt-out: set `AIT_AUTO_DEVTOOLS=0` in the environment to suppress auto-open\n * entirely. Any other value (or absent) enables the default behaviour.\n *\n * Duplicate-open guard:\n * `AutoDevtoolsOpener` tracks whether open was already triggered for the\n * current session. The open fires at most once per instance — typically one\n * per `runDebugServer` call.\n *\n * TOTP expiry caveat:\n * The `at=` TOTP code embedded in the `wss=` parameter is minted fresh at the\n * moment `open()` is called. The code is valid for ~3 minutes (the relay gate\n * accepts ±RELAY_VERIFY_SKEW_STEPS=6 steps = 180–210 s). If the developer\n * does not open the URL within that window the WebSocket upgrade will be\n * rejected with 4401. In practice the browser opens immediately after the OS\n * `open` command; if needed the developer can copy the wss= param, replace\n * `at=`, and reload. This is documented in the JSDoc below.\n *\n * PWA (WebKit) caveat:\n * The Chii relay injects a chobitsu CDP shim into WebKit-based runtimes (env 2\n * AITC Sandbox PWA). The DevTools frontend will connect and most panels work.\n * However, WebKit does not expose the full CDP domain set that V8/Blink does,\n * so some panels (Network, Layers) may appear empty or show limited data.\n * This is a WebKit runtime constraint, not a relay or devtools-opener issue.\n *\n * Node-only: uses `child_process.spawnSync` to invoke the OS open command.\n */\n\nimport type { McpEnvironment } from './environment.js';\n\n// ---------------------------------------------------------------------------\n// Chii self-hosted DevTools frontend URL\n// ---------------------------------------------------------------------------\n\n/**\n * Assembles the Chii self-hosted DevTools inspector URL for a given relay\n * and target.\n *\n * Chii serves its own DevTools frontend at\n * `<relayHttpBaseUrl>/front_end/chii_app.html`. The `ws=` (plain HTTP relay)\n * or `wss=` (HTTPS relay) query parameter is a URL-encoded string of the form\n * `<relay-host>/client/<uuid>?target=<id>&at=<totp>` — the same format used\n * by Chii's own target list page (derived from `chii/public/index.js`).\n *\n * The `at=` TOTP code is minted at call time via `mintTotp()`. It is valid\n * for ~3 minutes (relay gate accepts ±RELAY_VERIFY_SKEW_STEPS=6 steps =\n * 180–210 s). The developer must open the returned URL within that window.\n * If the window expires before the browser connects, the relay will reject the\n * WebSocket upgrade with close code 4401.\n *\n * FAIL-CLOSED (issue #509): `mintTotp` is REQUIRED. When omitted (i.e.\n * `undefined`), this function returns `null` — the caller must treat `null` as\n * \"inspector not yet available\" and show a waiting hint instead of a broken\n * link. Relay sessions gate every WS upgrade with TOTP (#452), so a URL built\n * without `at=` would be rejected with WS 4401 immediately — there is no\n * non-TOTP relay path in production. Returning `null` surfaces this cleanly as\n * a \"TOTP not yet configured\" state rather than silently producing a URL that\n * will always fail at the WS handshake.\n *\n * SECRET-HANDLING: `mintTotp` returns a code, not a secret. The code is\n * embedded in the `wss=` parameter (inside the `at=` param) of the returned\n * URL. Callers MUST NOT log the returned URL to stdout (stderr is OK — it is\n * the intended fallback surface for the developer to copy the URL).\n *\n * @param relayHttpBaseUrl - Local HTTP base URL of the Chii relay, e.g.\n * `http://127.0.0.1:9100`. No trailing slash.\n * @param targetId - Chii target id (from `GET <relay>/targets`).\n * @param mintTotp - Function that returns a fresh 6-digit TOTP code string.\n * Called at most once. **Required** — when `undefined`, the function returns\n * `null` (fail-closed: no `at=` param means the relay WS gate rejects the\n * handshake, so a null result is safer than a URL that always 404s).\n * @param panel - Initial panel. Defaults to `\"console\"`.\n *\n * @returns The inspector URL string, or `null` when `mintTotp` is absent.\n *\n * @example\n * buildChiiInspectorUrl(\n * 'http://127.0.0.1:9100',\n * 'abc123',\n * () => generateTotp(secret),\n * )\n * // → 'http://127.0.0.1:9100/front_end/chii_app.html?ws=127.0.0.1%3A9100%2Fclient%2F<uuid>%3Ftarget%3Dabc123%26at%3D<code>'\n */\nexport function buildChiiInspectorUrl(\n relayHttpBaseUrl: string,\n targetId: string,\n mintTotp?: () => string,\n panel: 'elements' | 'console' | 'sources' | 'network' = 'console',\n): string | null {\n // FAIL-CLOSED (#509): relay sessions require TOTP for every WS upgrade.\n // Without a mintTotp function we cannot produce a valid at= code, so we\n // return null rather than a URL that will always be rejected by the relay gate\n // with WS 4401 / HTTP 404. Callers show a \"waiting\" hint when they get null.\n if (!mintTotp) {\n return null;\n }\n\n // Extract the host (and port) from the relay HTTP base URL, and pick the\n // query param name chii_app.html expects: `ws=` dials `ws://` (plain-HTTP\n // relay — env 3/4 local 127.0.0.1) while `wss=` dials `wss://` (HTTPS\n // tunnel — env 2). chii/public/index.js does the same scheme branch:\n // `location.protocol === 'https:' ? 'wss' : 'ws'`. Always sending `wss=`\n // would make the frontend attempt TLS against the plain-HTTP local relay.\n let relayHost: string;\n let wsParamName: 'ws' | 'wss';\n try {\n const parsed = new URL(relayHttpBaseUrl);\n relayHost = parsed.host; // e.g. \"127.0.0.1:9100\"\n wsParamName = parsed.protocol === 'https:' ? 'wss' : 'ws';\n } catch {\n // Fallback: strip the scheme prefix manually if URL parsing fails.\n relayHost = relayHttpBaseUrl.replace(/^https?:\\/\\//i, '');\n wsParamName = /^https:/i.test(relayHttpBaseUrl) ? 'wss' : 'ws';\n }\n\n // Generate a client UUID that matches the format Chii's index.js uses\n // (6 random alphanumeric characters).\n const clientId = `devtools-opener-${Date.now().toString(36)}`;\n\n // Build the ws=/wss= value: \"<relay-host>/client/<uuid>?target=<id>&at=<code>\"\n // This mirrors the format from chii/public/index.js:\n // `${domain}${basePath}client/${randomId(6)}?target=${targetId}`\n // SECRET-HANDLING: mintTotp() returns a code (not a secret). The code\n // rides only in the URL's at= param. Callers must not log the URL.\n const code = mintTotp();\n const wsPath = `${relayHost}/client/${clientId}?target=${encodeURIComponent(targetId)}&at=${encodeURIComponent(code)}`;\n\n const params = new URLSearchParams({ [wsParamName]: wsPath, panel });\n return `${relayHttpBaseUrl.replace(/\\/$/, '')}/front_end/chii_app.html?${params.toString()}`;\n}\n\n// ---------------------------------------------------------------------------\n// Opt-out check\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when auto-open is **disabled** via the `AIT_AUTO_DEVTOOLS`\n * env var. Only the explicit `\"0\"` value disables it; anything else (including\n * absent) leaves auto-open enabled.\n */\nexport function isAutoDevtoolsDisabled(): boolean {\n return process.env.AIT_AUTO_DEVTOOLS === '0';\n}\n\n// ---------------------------------------------------------------------------\n// Browser open (Node-only, sync)\n// ---------------------------------------------------------------------------\n\n/**\n * Opens the given URL in the OS default browser using a platform-appropriate\n * command. Returns `true` on success.\n *\n * Failures are silent from the caller's perspective — the caller should log\n * the URL to stderr as a fallback before calling this function.\n */\nexport function openUrlInBrowser(url: string): boolean {\n // Test hook: skip actual spawn when running in vitest / CI where the OS open\n // command may hang or be absent. Production code never sets this.\n if (process.env.AIT_AUTO_DEVTOOLS_TEST_SKIP_SPAWN === '1') return false;\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const { spawnSync } = require('node:child_process') as typeof import('node:child_process');\n const platform = process.platform;\n\n type Candidate = { cmd: string; args: string[] };\n let candidates: Candidate[];\n if (platform === 'darwin') {\n candidates = [{ cmd: 'open', args: [url] }];\n } else if (platform === 'win32') {\n candidates = [{ cmd: 'cmd', args: ['/c', 'start', '', url] }];\n } else {\n // Linux + fallback\n candidates = [\n { cmd: 'xdg-open', args: [url] },\n { cmd: 'sensible-browser', args: [url] },\n { cmd: 'x-www-browser', args: [url] },\n ];\n }\n\n for (const { cmd, args } of candidates) {\n try {\n const result = spawnSync(cmd, args, { encoding: 'utf8', timeout: 5_000 });\n if (!result.error && result.status === 0) return true;\n } catch {\n // Try next candidate.\n }\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// AutoDevtoolsOpener — stateful once-per-session open guard\n// ---------------------------------------------------------------------------\n\n/**\n * Options for {@link AutoDevtoolsOpener.open}.\n *\n * The `relayHttpBaseUrl` and `targetId` fields are required to build a working\n * Chii self-hosted inspector URL. When `relayHttpBaseUrl` is absent the open\n * is skipped (no relay available yet).\n */\nexport interface DevtoolsOpenOptions {\n /**\n * Stable local inspector URL (`http://127.0.0.1:<port>/inspector`) from the\n * QR HTTP server (issue #530). When provided this URL is opened in the browser\n * instead of building a direct `front_end/chii_app.html?wss=…` URL. The\n * `/inspector` endpoint mints a fresh TOTP at click time and redirects, so\n * there is no TOTP-expiry race. Safe to log (no tunnel host, no TOTP code).\n *\n * When absent, falls back to building a direct inspector URL from\n * `relayHttpBaseUrl` + `mintTotp` (legacy path, kept for backward compat).\n */\n inspectorStableUrl?: string | null;\n /**\n * Local HTTP base URL of the Chii relay, e.g. `http://127.0.0.1:9100`.\n * Used to build the `<relay-base>/front_end/chii_app.html?wss=…` URL when\n * `inspectorStableUrl` is not available.\n *\n * For env 3/4 (intoss relay) this is `http://127.0.0.1:<port>`.\n * For env 2 (external PWA relay) this is the relay's external HTTP URL\n * (e.g. `https://<host>.trycloudflare.com`).\n *\n * When absent or empty, `open()` is a no-op.\n *\n * SECRET-HANDLING: this value contains the relay host. Callers MUST NOT\n * log it to stdout; stderr is the intended surface.\n */\n relayHttpBaseUrl: string | null | undefined;\n /**\n * Chii target id of the attached page, from `listTargets()[0].id`.\n * When absent or empty, `open()` is a no-op.\n */\n targetId: string | null | undefined;\n /**\n * Function that mints a fresh TOTP code when called. Called at most once per\n * `open()` invocation, immediately before building the inspector URL.\n * Only used when `inspectorStableUrl` is absent.\n *\n * Pass `undefined` when TOTP is disabled (no `at=` param is added).\n *\n * SECRET-HANDLING: the function MUST return only the code (6 digits), not\n * the secret. The code rides in the URL's `at=` param only.\n */\n mintTotp?: () => string;\n /** Current MCP environment (`mock` | `relay`). `open()` no-ops on `mock`. */\n env: McpEnvironment;\n}\n\n/**\n * Manages auto-opening Chrome DevTools on every NEW target attach (issue #530).\n *\n * Create one instance per `runDebugServer` call and pass its `open()` method\n * as the `onAttach` callback to the attach watcher (via `DualConnectionRouter`).\n *\n * The open fires for each NEW `targetId` — subsequent notifications for the\n * same target are de-duplicated. Re-attach with a fresh targetId (e.g. after\n * page reload on the phone) fires a new open. The URL opened is the stable\n * `/inspector` endpoint (issue #530) when `inspectorStableUrl` is provided —\n * it mints a fresh TOTP at click time so there is no expiry race. Falls back to\n * building a direct `front_end/chii_app.html?wss=…` URL when\n * `inspectorStableUrl` is absent.\n *\n * Opt-out and mock-environment guard are checked at call time.\n */\nexport class AutoDevtoolsOpener {\n /** Per-target de-dupe set (issue #530 — target-unit guard replaces once-per-daemon). */\n private readonly _openedTargets = new Set<string>();\n\n /**\n * Attempts to auto-open Chii DevTools in the developer's browser.\n *\n * Opens when:\n * - `options.targetId` is a NEW target (not yet in `_openedTargets`).\n *\n * No-op when any of the following conditions hold:\n * 1. `targetId` has already been opened (`_openedTargets` has it).\n * 2. `AIT_AUTO_DEVTOOLS=0` opt-out is set.\n * 3. `options.env` is `mock` (env 1 — F12 is already available).\n * 4. `options.targetId` is null/undefined/empty (no page attached yet).\n * 5. Neither `inspectorStableUrl` nor `relayHttpBaseUrl` is available.\n *\n * When `inspectorStableUrl` is provided (issue #530 stable URL): opens\n * `http://127.0.0.1:<port>/inspector` directly and writes it to stderr.\n * The URL contains no tunnel host or TOTP code — safe to log anywhere.\n *\n * Legacy path (no `inspectorStableUrl`): builds a direct\n * `<relay-base>/front_end/chii_app.html?wss=…` URL from `relayHttpBaseUrl`\n * + `mintTotp`, writes to stderr. TOTP expiry caveat applies (~3 min window).\n *\n * SECRET-HANDLING: direct inspector URL (written to stderr) may contain relay\n * host and TOTP code. Stable URL is secret-free. Neither must go to stdout or\n * persistent logs.\n */\n open(options: DevtoolsOpenOptions): void {\n if (isAutoDevtoolsDisabled()) return;\n if (options.env === 'mock') return;\n if (!options.targetId) return;\n\n // Target-unit de-dupe (issue #530): re-attach with a new targetId fires again.\n const targetId = options.targetId;\n if (this._openedTargets.has(targetId)) return;\n\n // Use stable /inspector URL when available (issue #530) — secret-free, no expiry.\n if (options.inspectorStableUrl) {\n this._openedTargets.add(targetId);\n const stableUrl = options.inspectorStableUrl;\n process.stderr.write(\n '[ait-debug] 기기가 연결됐습니다 — Chii DevTools를 자동으로 엽니다.\\n' +\n `[ait-debug] 인스펙터 URL: ${stableUrl}\\n` +\n '[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)\\n',\n );\n const opened = openUrlInBrowser(stableUrl);\n if (!opened) {\n process.stderr.write(\n `[ait-debug] 브라우저 자동 열기 실패 — ${stableUrl} 을 브라우저에서 직접 여세요.\\n`,\n );\n }\n return;\n }\n\n // Legacy path: build direct inspector URL from relayHttpBaseUrl + mintTotp.\n if (!options.relayHttpBaseUrl) return;\n\n this._openedTargets.add(targetId);\n\n const inspectorUrl = buildChiiInspectorUrl(\n options.relayHttpBaseUrl,\n targetId,\n options.mintTotp,\n );\n\n // FAIL-CLOSED (#509): buildChiiInspectorUrl returns null when mintTotp is\n // absent (no valid at= code → relay WS gate would reject the connection).\n // Record targetId in set so this guard fires, but skip browser open.\n if (inspectorUrl === null) {\n process.stderr.write(\n '[ait-debug] 기기가 연결됐습니다 — TOTP secret 미설정으로 인스펙터 URL을 생성할 수 없습니다.\\n' +\n '[ait-debug] relay 세션은 AIT_DEBUG_TOTP_SECRET 설정이 필요합니다.\\n',\n );\n return;\n }\n\n process.stderr.write(\n '[ait-debug] 기기가 연결됐습니다 — Chii DevTools를 자동으로 엽니다.\\n' +\n `[ait-debug] DevTools URL: ${inspectorUrl}\\n` +\n '[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)\\n' +\n '[ait-debug] 주의: URL의 at= 코드는 ~3분 안에서만 유효합니다.\\n',\n );\n\n const opened = openUrlInBrowser(inspectorUrl);\n if (!opened) {\n process.stderr.write(\n '[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\\n',\n );\n }\n }\n\n /**\n * Returns `true` if `open()` has been called for at least one target.\n * (Replaces the old once-per-session `_opened` flag; kept for interface\n * compatibility with tests that read `opener.opened`.)\n */\n get opened(): boolean {\n return this._openedTargets.size > 0;\n }\n\n /** Returns the set of target IDs that have already been auto-opened. */\n get openedTargets(): ReadonlySet<string> {\n return this._openedTargets;\n }\n}\n"],"mappings":";;;;;;;;;;AAqKA,SAAgB,yBAAkC;AAChD,QAAO,QAAQ,IAAI,sBAAsB;;;;;;;;;AAc3C,SAAgB,iBAAiB,KAAsB;AAGrD,KAAI,QAAQ,IAAI,sCAAsC,IAAK,QAAO;CAElE,MAAM,EAAE,cAAA,UAAsB,qBAAqB;CACnD,MAAM,WAAW,QAAQ;CAGzB,IAAI;AACJ,KAAI,aAAa,SACf,cAAa,CAAC;EAAE,KAAK;EAAQ,MAAM,CAAC,IAAI;EAAE,CAAC;UAClC,aAAa,QACtB,cAAa,CAAC;EAAE,KAAK;EAAO,MAAM;GAAC;GAAM;GAAS;GAAI;GAAI;EAAE,CAAC;KAG7D,cAAa;EACX;GAAE,KAAK;GAAY,MAAM,CAAC,IAAI;GAAE;EAChC;GAAE,KAAK;GAAoB,MAAM,CAAC,IAAI;GAAE;EACxC;GAAE,KAAK;GAAiB,MAAM,CAAC,IAAI;GAAE;EACtC;AAGH,MAAK,MAAM,EAAE,KAAK,UAAU,WAC1B,KAAI;EACF,MAAM,SAAS,UAAU,KAAK,MAAM;GAAE,UAAU;GAAQ,SAAS;GAAO,CAAC;AACzE,MAAI,CAAC,OAAO,SAAS,OAAO,WAAW,EAAG,QAAO;SAC3C;AAIV,QAAO"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"devtools-opener-Bp671YXu.cjs","names":[],"sources":["../src/mcp/devtools-opener.ts"],"sourcesContent":["/**\n * Auto-opens Chrome DevTools when a page attaches over the Chii relay.\n *\n * When a real device attaches (env 2 / 3 / 4 in the 4-environments fidelity\n * ladder), the Chii relay exposes a standard CDP WebSocket endpoint. Chii\n * also self-hosts its DevTools frontend at:\n *\n * <relay-base>/front_end/chii_app.html\n * ?ws|wss=<encodeURIComponent(\"<relay-host>/client/<uuid>?target=<targetId>&at=<totp>\")>\n *\n * The param name follows the relay base scheme — `ws=` for plain HTTP\n * (env 3/4 local relay), `wss=` for HTTPS (env 2 tunnel) — matching the\n * scheme branch in chii/public/index.js.\n *\n * This is the same URL format that Chii's own index-page inspect-links use\n * (derived from `chii/public/index.js` — the JS that powers the target list\n * page at `<relay-base>/`). Opening this URL in the developer's local browser\n * gives a full Chrome DevTools UI connected to the phone via the relay.\n *\n * IMPORTANT — environment guard:\n * Auto-open only fires in relay environments (env 2 / 3 / 4). In env 1\n * (local browser + mock SDK) the developer already has F12 available; opening\n * a DevTools window pointing at the mock relay would be confusing and useless.\n * The caller (`startAttachWatcher` in `debug-server.ts`) passes the current\n * environment and this module bails out when it is `mock`.\n *\n * Opt-out: set `AIT_AUTO_DEVTOOLS=0` in the environment to suppress auto-open\n * entirely. Any other value (or absent) enables the default behaviour.\n *\n * Duplicate-open guard:\n * `AutoDevtoolsOpener` tracks whether open was already triggered for the\n * current session. The open fires at most once per instance — typically one\n * per `runDebugServer` call.\n *\n * TOTP expiry caveat:\n * The `at=` TOTP code embedded in the `wss=` parameter is minted fresh at the\n * moment `open()` is called. The code is valid for ~3 minutes (the relay gate\n * accepts ±RELAY_VERIFY_SKEW_STEPS=6 steps = 180–210 s). If the developer\n * does not open the URL within that window the WebSocket upgrade will be\n * rejected with 4401. In practice the browser opens immediately after the OS\n * `open` command; if needed the developer can copy the wss= param, replace\n * `at=`, and reload. This is documented in the JSDoc below.\n *\n * PWA (WebKit) caveat:\n * The Chii relay injects a chobitsu CDP shim into WebKit-based runtimes (env 2\n * AITC Sandbox PWA). The DevTools frontend will connect and most panels work.\n * However, WebKit does not expose the full CDP domain set that V8/Blink does,\n * so some panels (Network, Layers) may appear empty or show limited data.\n * This is a WebKit runtime constraint, not a relay or devtools-opener issue.\n *\n * Node-only: uses `child_process.spawnSync` to invoke the OS open command.\n */\n\nimport type { McpEnvironment } from './environment.js';\n\n// ---------------------------------------------------------------------------\n// Chii self-hosted DevTools frontend URL\n// ---------------------------------------------------------------------------\n\n/**\n * Assembles the Chii self-hosted DevTools inspector URL for a given relay\n * and target.\n *\n * Chii serves its own DevTools frontend at\n * `<relayHttpBaseUrl>/front_end/chii_app.html`. The `ws=` (plain HTTP relay)\n * or `wss=` (HTTPS relay) query parameter is a URL-encoded string of the form\n * `<relay-host>/client/<uuid>?target=<id>&at=<totp>` — the same format used\n * by Chii's own target list page (derived from `chii/public/index.js`).\n *\n * The `at=` TOTP code is minted at call time via `mintTotp()`. It is valid\n * for ~3 minutes (relay gate accepts ±RELAY_VERIFY_SKEW_STEPS=6 steps =\n * 180–210 s). The developer must open the returned URL within that window.\n * If the window expires before the browser connects, the relay will reject the\n * WebSocket upgrade with close code 4401.\n *\n * FAIL-CLOSED (issue #509): `mintTotp` is REQUIRED. When omitted (i.e.\n * `undefined`), this function returns `null` — the caller must treat `null` as\n * \"inspector not yet available\" and show a waiting hint instead of a broken\n * link. Relay sessions gate every WS upgrade with TOTP (#452), so a URL built\n * without `at=` would be rejected with WS 4401 immediately — there is no\n * non-TOTP relay path in production. Returning `null` surfaces this cleanly as\n * a \"TOTP not yet configured\" state rather than silently producing a URL that\n * will always fail at the WS handshake.\n *\n * SECRET-HANDLING: `mintTotp` returns a code, not a secret. The code is\n * embedded in the `wss=` parameter (inside the `at=` param) of the returned\n * URL. Callers MUST NOT log the returned URL to stdout (stderr is OK — it is\n * the intended fallback surface for the developer to copy the URL).\n *\n * @param relayHttpBaseUrl - Local HTTP base URL of the Chii relay, e.g.\n * `http://127.0.0.1:9100`. No trailing slash.\n * @param targetId - Chii target id (from `GET <relay>/targets`).\n * @param mintTotp - Function that returns a fresh 6-digit TOTP code string.\n * Called at most once. **Required** — when `undefined`, the function returns\n * `null` (fail-closed: no `at=` param means the relay WS gate rejects the\n * handshake, so a null result is safer than a URL that always 404s).\n * @param panel - Initial panel. Defaults to `\"console\"`.\n *\n * @returns The inspector URL string, or `null` when `mintTotp` is absent.\n *\n * @example\n * buildChiiInspectorUrl(\n * 'http://127.0.0.1:9100',\n * 'abc123',\n * () => generateTotp(secret),\n * )\n * // → 'http://127.0.0.1:9100/front_end/chii_app.html?ws=127.0.0.1%3A9100%2Fclient%2F<uuid>%3Ftarget%3Dabc123%26at%3D<code>'\n */\nexport function buildChiiInspectorUrl(\n relayHttpBaseUrl: string,\n targetId: string,\n mintTotp?: () => string,\n panel: 'elements' | 'console' | 'sources' | 'network' = 'console',\n): string | null {\n // FAIL-CLOSED (#509): relay sessions require TOTP for every WS upgrade.\n // Without a mintTotp function we cannot produce a valid at= code, so we\n // return null rather than a URL that will always be rejected by the relay gate\n // with WS 4401 / HTTP 404. Callers show a \"waiting\" hint when they get null.\n if (!mintTotp) {\n return null;\n }\n\n // Extract the host (and port) from the relay HTTP base URL, and pick the\n // query param name chii_app.html expects: `ws=` dials `ws://` (plain-HTTP\n // relay — env 3/4 local 127.0.0.1) while `wss=` dials `wss://` (HTTPS\n // tunnel — env 2). chii/public/index.js does the same scheme branch:\n // `location.protocol === 'https:' ? 'wss' : 'ws'`. Always sending `wss=`\n // would make the frontend attempt TLS against the plain-HTTP local relay.\n let relayHost: string;\n let wsParamName: 'ws' | 'wss';\n try {\n const parsed = new URL(relayHttpBaseUrl);\n relayHost = parsed.host; // e.g. \"127.0.0.1:9100\"\n wsParamName = parsed.protocol === 'https:' ? 'wss' : 'ws';\n } catch {\n // Fallback: strip the scheme prefix manually if URL parsing fails.\n relayHost = relayHttpBaseUrl.replace(/^https?:\\/\\//i, '');\n wsParamName = /^https:/i.test(relayHttpBaseUrl) ? 'wss' : 'ws';\n }\n\n // Generate a client UUID that matches the format Chii's index.js uses\n // (6 random alphanumeric characters).\n const clientId = `devtools-opener-${Date.now().toString(36)}`;\n\n // Build the ws=/wss= value: \"<relay-host>/client/<uuid>?target=<id>&at=<code>\"\n // This mirrors the format from chii/public/index.js:\n // `${domain}${basePath}client/${randomId(6)}?target=${targetId}`\n // SECRET-HANDLING: mintTotp() returns a code (not a secret). The code\n // rides only in the URL's at= param. Callers must not log the URL.\n const code = mintTotp();\n const wsPath = `${relayHost}/client/${clientId}?target=${encodeURIComponent(targetId)}&at=${encodeURIComponent(code)}`;\n\n const params = new URLSearchParams({ [wsParamName]: wsPath, panel });\n return `${relayHttpBaseUrl.replace(/\\/$/, '')}/front_end/chii_app.html?${params.toString()}`;\n}\n\n// ---------------------------------------------------------------------------\n// Opt-out check\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when auto-open is **disabled** via the `AIT_AUTO_DEVTOOLS`\n * env var. Only the explicit `\"0\"` value disables it; anything else (including\n * absent) leaves auto-open enabled.\n */\nexport function isAutoDevtoolsDisabled(): boolean {\n return process.env.AIT_AUTO_DEVTOOLS === '0';\n}\n\n// ---------------------------------------------------------------------------\n// Browser open (Node-only, sync)\n// ---------------------------------------------------------------------------\n\n/**\n * Opens the given URL in the OS default browser using a platform-appropriate\n * command. Returns `true` on success.\n *\n * Failures are silent from the caller's perspective — the caller should log\n * the URL to stderr as a fallback before calling this function.\n */\nexport function openUrlInBrowser(url: string): boolean {\n // Test hook: skip actual spawn when running in vitest / CI where the OS open\n // command may hang or be absent. Production code never sets this.\n if (process.env.AIT_AUTO_DEVTOOLS_TEST_SKIP_SPAWN === '1') return false;\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const { spawnSync } = require('node:child_process') as typeof import('node:child_process');\n const platform = process.platform;\n\n type Candidate = { cmd: string; args: string[] };\n let candidates: Candidate[];\n if (platform === 'darwin') {\n candidates = [{ cmd: 'open', args: [url] }];\n } else if (platform === 'win32') {\n candidates = [{ cmd: 'cmd', args: ['/c', 'start', '', url] }];\n } else {\n // Linux + fallback\n candidates = [\n { cmd: 'xdg-open', args: [url] },\n { cmd: 'sensible-browser', args: [url] },\n { cmd: 'x-www-browser', args: [url] },\n ];\n }\n\n for (const { cmd, args } of candidates) {\n try {\n const result = spawnSync(cmd, args, { encoding: 'utf8', timeout: 5_000 });\n if (!result.error && result.status === 0) return true;\n } catch {\n // Try next candidate.\n }\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// AutoDevtoolsOpener — stateful once-per-session open guard\n// ---------------------------------------------------------------------------\n\n/**\n * Options for {@link AutoDevtoolsOpener.open}.\n *\n * The `relayHttpBaseUrl` and `targetId` fields are required to build a working\n * Chii self-hosted inspector URL. When `relayHttpBaseUrl` is absent the open\n * is skipped (no relay available yet).\n */\nexport interface DevtoolsOpenOptions {\n /**\n * Stable local inspector URL (`http://127.0.0.1:<port>/inspector`) from the\n * QR HTTP server (issue #530). When provided this URL is opened in the browser\n * instead of building a direct `front_end/chii_app.html?wss=…` URL. The\n * `/inspector` endpoint mints a fresh TOTP at click time and redirects, so\n * there is no TOTP-expiry race. Safe to log (no tunnel host, no TOTP code).\n *\n * When absent, falls back to building a direct inspector URL from\n * `relayHttpBaseUrl` + `mintTotp` (legacy path, kept for backward compat).\n */\n inspectorStableUrl?: string | null;\n /**\n * Local HTTP base URL of the Chii relay, e.g. `http://127.0.0.1:9100`.\n * Used to build the `<relay-base>/front_end/chii_app.html?wss=…` URL when\n * `inspectorStableUrl` is not available.\n *\n * For env 3/4 (intoss relay) this is `http://127.0.0.1:<port>`.\n * For env 2 (external PWA relay) this is the relay's external HTTP URL\n * (e.g. `https://<host>.trycloudflare.com`).\n *\n * When absent or empty, `open()` is a no-op.\n *\n * SECRET-HANDLING: this value contains the relay host. Callers MUST NOT\n * log it to stdout; stderr is the intended surface.\n */\n relayHttpBaseUrl: string | null | undefined;\n /**\n * Chii target id of the attached page, from `listTargets()[0].id`.\n * When absent or empty, `open()` is a no-op.\n */\n targetId: string | null | undefined;\n /**\n * Function that mints a fresh TOTP code when called. Called at most once per\n * `open()` invocation, immediately before building the inspector URL.\n * Only used when `inspectorStableUrl` is absent.\n *\n * Pass `undefined` when TOTP is disabled (no `at=` param is added).\n *\n * SECRET-HANDLING: the function MUST return only the code (6 digits), not\n * the secret. The code rides in the URL's `at=` param only.\n */\n mintTotp?: () => string;\n /** Current MCP environment (`mock` | `relay`). `open()` no-ops on `mock`. */\n env: McpEnvironment;\n}\n\n/**\n * Manages auto-opening Chrome DevTools on every NEW target attach (issue #530).\n *\n * Create one instance per `runDebugServer` call and pass its `open()` method\n * as the `onAttach` callback to the attach watcher (via `DualConnectionRouter`).\n *\n * The open fires for each NEW `targetId` — subsequent notifications for the\n * same target are de-duplicated. Re-attach with a fresh targetId (e.g. after\n * page reload on the phone) fires a new open. The URL opened is the stable\n * `/inspector` endpoint (issue #530) when `inspectorStableUrl` is provided —\n * it mints a fresh TOTP at click time so there is no expiry race. Falls back to\n * building a direct `front_end/chii_app.html?wss=…` URL when\n * `inspectorStableUrl` is absent.\n *\n * Opt-out and mock-environment guard are checked at call time.\n */\nexport class AutoDevtoolsOpener {\n /** Per-target de-dupe set (issue #530 — target-unit guard replaces once-per-daemon). */\n private readonly _openedTargets = new Set<string>();\n\n /**\n * Attempts to auto-open Chii DevTools in the developer's browser.\n *\n * Opens when:\n * - `options.targetId` is a NEW target (not yet in `_openedTargets`).\n *\n * No-op when any of the following conditions hold:\n * 1. `targetId` has already been opened (`_openedTargets` has it).\n * 2. `AIT_AUTO_DEVTOOLS=0` opt-out is set.\n * 3. `options.env` is `mock` (env 1 — F12 is already available).\n * 4. `options.targetId` is null/undefined/empty (no page attached yet).\n * 5. Neither `inspectorStableUrl` nor `relayHttpBaseUrl` is available.\n *\n * When `inspectorStableUrl` is provided (issue #530 stable URL): opens\n * `http://127.0.0.1:<port>/inspector` directly and writes it to stderr.\n * The URL contains no tunnel host or TOTP code — safe to log anywhere.\n *\n * Legacy path (no `inspectorStableUrl`): builds a direct\n * `<relay-base>/front_end/chii_app.html?wss=…` URL from `relayHttpBaseUrl`\n * + `mintTotp`, writes to stderr. TOTP expiry caveat applies (~3 min window).\n *\n * SECRET-HANDLING: direct inspector URL (written to stderr) may contain relay\n * host and TOTP code. Stable URL is secret-free. Neither must go to stdout or\n * persistent logs.\n */\n open(options: DevtoolsOpenOptions): void {\n if (isAutoDevtoolsDisabled()) return;\n if (options.env === 'mock') return;\n if (!options.targetId) return;\n\n // Target-unit de-dupe (issue #530): re-attach with a new targetId fires again.\n const targetId = options.targetId;\n if (this._openedTargets.has(targetId)) return;\n\n // Use stable /inspector URL when available (issue #530) — secret-free, no expiry.\n if (options.inspectorStableUrl) {\n this._openedTargets.add(targetId);\n const stableUrl = options.inspectorStableUrl;\n process.stderr.write(\n '[ait-debug] 기기가 연결됐습니다 — Chii DevTools를 자동으로 엽니다.\\n' +\n `[ait-debug] 인스펙터 URL: ${stableUrl}\\n` +\n '[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)\\n',\n );\n const opened = openUrlInBrowser(stableUrl);\n if (!opened) {\n process.stderr.write(\n `[ait-debug] 브라우저 자동 열기 실패 — ${stableUrl} 을 브라우저에서 직접 여세요.\\n`,\n );\n }\n return;\n }\n\n // Legacy path: build direct inspector URL from relayHttpBaseUrl + mintTotp.\n if (!options.relayHttpBaseUrl) return;\n\n this._openedTargets.add(targetId);\n\n const inspectorUrl = buildChiiInspectorUrl(\n options.relayHttpBaseUrl,\n targetId,\n options.mintTotp,\n );\n\n // FAIL-CLOSED (#509): buildChiiInspectorUrl returns null when mintTotp is\n // absent (no valid at= code → relay WS gate would reject the connection).\n // Record targetId in set so this guard fires, but skip browser open.\n if (inspectorUrl === null) {\n process.stderr.write(\n '[ait-debug] 기기가 연결됐습니다 — TOTP secret 미설정으로 인스펙터 URL을 생성할 수 없습니다.\\n' +\n '[ait-debug] relay 세션은 AIT_DEBUG_TOTP_SECRET 설정이 필요합니다.\\n',\n );\n return;\n }\n\n process.stderr.write(\n '[ait-debug] 기기가 연결됐습니다 — Chii DevTools를 자동으로 엽니다.\\n' +\n `[ait-debug] DevTools URL: ${inspectorUrl}\\n` +\n '[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)\\n' +\n '[ait-debug] 주의: URL의 at= 코드는 ~3분 안에서만 유효합니다.\\n',\n );\n\n const opened = openUrlInBrowser(inspectorUrl);\n if (!opened) {\n process.stderr.write(\n '[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\\n',\n );\n }\n }\n\n /**\n * Returns `true` if `open()` has been called for at least one target.\n * (Replaces the old once-per-session `_opened` flag; kept for interface\n * compatibility with tests that read `opener.opened`.)\n */\n get opened(): boolean {\n return this._openedTargets.size > 0;\n }\n\n /** Returns the set of target IDs that have already been auto-opened. */\n get openedTargets(): ReadonlySet<string> {\n return this._openedTargets;\n }\n}\n"],"mappings":";;;;;;AAqKA,SAAgB,yBAAkC;AAChD,QAAO,QAAQ,IAAI,sBAAsB;;;;;;;;;AAc3C,SAAgB,iBAAiB,KAAsB;AAGrD,KAAI,QAAQ,IAAI,sCAAsC,IAAK,QAAO;CAElE,MAAM,EAAE,cAAc,QAAQ,qBAAqB;CACnD,MAAM,WAAW,QAAQ;CAGzB,IAAI;AACJ,KAAI,aAAa,SACf,cAAa,CAAC;EAAE,KAAK;EAAQ,MAAM,CAAC,IAAI;EAAE,CAAC;UAClC,aAAa,QACtB,cAAa,CAAC;EAAE,KAAK;EAAO,MAAM;GAAC;GAAM;GAAS;GAAI;GAAI;EAAE,CAAC;KAG7D,cAAa;EACX;GAAE,KAAK;GAAY,MAAM,CAAC,IAAI;GAAE;EAChC;GAAE,KAAK;GAAoB,MAAM,CAAC,IAAI;GAAE;EACxC;GAAE,KAAK;GAAiB,MAAM,CAAC,IAAI;GAAE;EACtC;AAGH,MAAK,MAAM,EAAE,KAAK,UAAU,WAC1B,KAAI;EACF,MAAM,SAAS,UAAU,KAAK,MAAM;GAAE,UAAU;GAAQ,SAAS;GAAO,CAAC;AACzE,MAAI,CAAC,OAAO,SAAS,OAAO,WAAW,EAAG,QAAO;SAC3C;AAIV,QAAO"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"devtools-opener-D84kZFtR.js","names":[],"sources":["../src/mcp/devtools-opener.ts"],"sourcesContent":["/**\n * Auto-opens Chrome DevTools when a page attaches over the Chii relay.\n *\n * When a real device attaches (env 2 / 3 / 4 in the 4-environments fidelity\n * ladder), the Chii relay exposes a standard CDP WebSocket endpoint. Chii\n * also self-hosts its DevTools frontend at:\n *\n * <relay-base>/front_end/chii_app.html\n * ?ws|wss=<encodeURIComponent(\"<relay-host>/client/<uuid>?target=<targetId>&at=<totp>\")>\n *\n * The param name follows the relay base scheme — `ws=` for plain HTTP\n * (env 3/4 local relay), `wss=` for HTTPS (env 2 tunnel) — matching the\n * scheme branch in chii/public/index.js.\n *\n * This is the same URL format that Chii's own index-page inspect-links use\n * (derived from `chii/public/index.js` — the JS that powers the target list\n * page at `<relay-base>/`). Opening this URL in the developer's local browser\n * gives a full Chrome DevTools UI connected to the phone via the relay.\n *\n * IMPORTANT — environment guard:\n * Auto-open only fires in relay environments (env 2 / 3 / 4). In env 1\n * (local browser + mock SDK) the developer already has F12 available; opening\n * a DevTools window pointing at the mock relay would be confusing and useless.\n * The caller (`startAttachWatcher` in `debug-server.ts`) passes the current\n * environment and this module bails out when it is `mock`.\n *\n * Opt-out: set `AIT_AUTO_DEVTOOLS=0` in the environment to suppress auto-open\n * entirely. Any other value (or absent) enables the default behaviour.\n *\n * Duplicate-open guard:\n * `AutoDevtoolsOpener` tracks whether open was already triggered for the\n * current session. The open fires at most once per instance — typically one\n * per `runDebugServer` call.\n *\n * TOTP expiry caveat:\n * The `at=` TOTP code embedded in the `wss=` parameter is minted fresh at the\n * moment `open()` is called. The code is valid for ~3 minutes (the relay gate\n * accepts ±RELAY_VERIFY_SKEW_STEPS=6 steps = 180–210 s). If the developer\n * does not open the URL within that window the WebSocket upgrade will be\n * rejected with 4401. In practice the browser opens immediately after the OS\n * `open` command; if needed the developer can copy the wss= param, replace\n * `at=`, and reload. This is documented in the JSDoc below.\n *\n * PWA (WebKit) caveat:\n * The Chii relay injects a chobitsu CDP shim into WebKit-based runtimes (env 2\n * AITC Sandbox PWA). The DevTools frontend will connect and most panels work.\n * However, WebKit does not expose the full CDP domain set that V8/Blink does,\n * so some panels (Network, Layers) may appear empty or show limited data.\n * This is a WebKit runtime constraint, not a relay or devtools-opener issue.\n *\n * Node-only: uses `child_process.spawnSync` to invoke the OS open command.\n */\n\nimport type { McpEnvironment } from './environment.js';\n\n// ---------------------------------------------------------------------------\n// Chii self-hosted DevTools frontend URL\n// ---------------------------------------------------------------------------\n\n/**\n * Assembles the Chii self-hosted DevTools inspector URL for a given relay\n * and target.\n *\n * Chii serves its own DevTools frontend at\n * `<relayHttpBaseUrl>/front_end/chii_app.html`. The `ws=` (plain HTTP relay)\n * or `wss=` (HTTPS relay) query parameter is a URL-encoded string of the form\n * `<relay-host>/client/<uuid>?target=<id>&at=<totp>` — the same format used\n * by Chii's own target list page (derived from `chii/public/index.js`).\n *\n * The `at=` TOTP code is minted at call time via `mintTotp()`. It is valid\n * for ~3 minutes (relay gate accepts ±RELAY_VERIFY_SKEW_STEPS=6 steps =\n * 180–210 s). The developer must open the returned URL within that window.\n * If the window expires before the browser connects, the relay will reject the\n * WebSocket upgrade with close code 4401.\n *\n * FAIL-CLOSED (issue #509): `mintTotp` is REQUIRED. When omitted (i.e.\n * `undefined`), this function returns `null` — the caller must treat `null` as\n * \"inspector not yet available\" and show a waiting hint instead of a broken\n * link. Relay sessions gate every WS upgrade with TOTP (#452), so a URL built\n * without `at=` would be rejected with WS 4401 immediately — there is no\n * non-TOTP relay path in production. Returning `null` surfaces this cleanly as\n * a \"TOTP not yet configured\" state rather than silently producing a URL that\n * will always fail at the WS handshake.\n *\n * SECRET-HANDLING: `mintTotp` returns a code, not a secret. The code is\n * embedded in the `wss=` parameter (inside the `at=` param) of the returned\n * URL. Callers MUST NOT log the returned URL to stdout (stderr is OK — it is\n * the intended fallback surface for the developer to copy the URL).\n *\n * @param relayHttpBaseUrl - Local HTTP base URL of the Chii relay, e.g.\n * `http://127.0.0.1:9100`. No trailing slash.\n * @param targetId - Chii target id (from `GET <relay>/targets`).\n * @param mintTotp - Function that returns a fresh 6-digit TOTP code string.\n * Called at most once. **Required** — when `undefined`, the function returns\n * `null` (fail-closed: no `at=` param means the relay WS gate rejects the\n * handshake, so a null result is safer than a URL that always 404s).\n * @param panel - Initial panel. Defaults to `\"console\"`.\n *\n * @returns The inspector URL string, or `null` when `mintTotp` is absent.\n *\n * @example\n * buildChiiInspectorUrl(\n * 'http://127.0.0.1:9100',\n * 'abc123',\n * () => generateTotp(secret),\n * )\n * // → 'http://127.0.0.1:9100/front_end/chii_app.html?ws=127.0.0.1%3A9100%2Fclient%2F<uuid>%3Ftarget%3Dabc123%26at%3D<code>'\n */\nexport function buildChiiInspectorUrl(\n relayHttpBaseUrl: string,\n targetId: string,\n mintTotp?: () => string,\n panel: 'elements' | 'console' | 'sources' | 'network' = 'console',\n): string | null {\n // FAIL-CLOSED (#509): relay sessions require TOTP for every WS upgrade.\n // Without a mintTotp function we cannot produce a valid at= code, so we\n // return null rather than a URL that will always be rejected by the relay gate\n // with WS 4401 / HTTP 404. Callers show a \"waiting\" hint when they get null.\n if (!mintTotp) {\n return null;\n }\n\n // Extract the host (and port) from the relay HTTP base URL, and pick the\n // query param name chii_app.html expects: `ws=` dials `ws://` (plain-HTTP\n // relay — env 3/4 local 127.0.0.1) while `wss=` dials `wss://` (HTTPS\n // tunnel — env 2). chii/public/index.js does the same scheme branch:\n // `location.protocol === 'https:' ? 'wss' : 'ws'`. Always sending `wss=`\n // would make the frontend attempt TLS against the plain-HTTP local relay.\n let relayHost: string;\n let wsParamName: 'ws' | 'wss';\n try {\n const parsed = new URL(relayHttpBaseUrl);\n relayHost = parsed.host; // e.g. \"127.0.0.1:9100\"\n wsParamName = parsed.protocol === 'https:' ? 'wss' : 'ws';\n } catch {\n // Fallback: strip the scheme prefix manually if URL parsing fails.\n relayHost = relayHttpBaseUrl.replace(/^https?:\\/\\//i, '');\n wsParamName = /^https:/i.test(relayHttpBaseUrl) ? 'wss' : 'ws';\n }\n\n // Generate a client UUID that matches the format Chii's index.js uses\n // (6 random alphanumeric characters).\n const clientId = `devtools-opener-${Date.now().toString(36)}`;\n\n // Build the ws=/wss= value: \"<relay-host>/client/<uuid>?target=<id>&at=<code>\"\n // This mirrors the format from chii/public/index.js:\n // `${domain}${basePath}client/${randomId(6)}?target=${targetId}`\n // SECRET-HANDLING: mintTotp() returns a code (not a secret). The code\n // rides only in the URL's at= param. Callers must not log the URL.\n const code = mintTotp();\n const wsPath = `${relayHost}/client/${clientId}?target=${encodeURIComponent(targetId)}&at=${encodeURIComponent(code)}`;\n\n const params = new URLSearchParams({ [wsParamName]: wsPath, panel });\n return `${relayHttpBaseUrl.replace(/\\/$/, '')}/front_end/chii_app.html?${params.toString()}`;\n}\n\n// ---------------------------------------------------------------------------\n// Opt-out check\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` when auto-open is **disabled** via the `AIT_AUTO_DEVTOOLS`\n * env var. Only the explicit `\"0\"` value disables it; anything else (including\n * absent) leaves auto-open enabled.\n */\nexport function isAutoDevtoolsDisabled(): boolean {\n return process.env.AIT_AUTO_DEVTOOLS === '0';\n}\n\n// ---------------------------------------------------------------------------\n// Browser open (Node-only, sync)\n// ---------------------------------------------------------------------------\n\n/**\n * Opens the given URL in the OS default browser using a platform-appropriate\n * command. Returns `true` on success.\n *\n * Failures are silent from the caller's perspective — the caller should log\n * the URL to stderr as a fallback before calling this function.\n */\nexport function openUrlInBrowser(url: string): boolean {\n // Test hook: skip actual spawn when running in vitest / CI where the OS open\n // command may hang or be absent. Production code never sets this.\n if (process.env.AIT_AUTO_DEVTOOLS_TEST_SKIP_SPAWN === '1') return false;\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const { spawnSync } = require('node:child_process') as typeof import('node:child_process');\n const platform = process.platform;\n\n type Candidate = { cmd: string; args: string[] };\n let candidates: Candidate[];\n if (platform === 'darwin') {\n candidates = [{ cmd: 'open', args: [url] }];\n } else if (platform === 'win32') {\n candidates = [{ cmd: 'cmd', args: ['/c', 'start', '', url] }];\n } else {\n // Linux + fallback\n candidates = [\n { cmd: 'xdg-open', args: [url] },\n { cmd: 'sensible-browser', args: [url] },\n { cmd: 'x-www-browser', args: [url] },\n ];\n }\n\n for (const { cmd, args } of candidates) {\n try {\n const result = spawnSync(cmd, args, { encoding: 'utf8', timeout: 5_000 });\n if (!result.error && result.status === 0) return true;\n } catch {\n // Try next candidate.\n }\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// AutoDevtoolsOpener — stateful once-per-session open guard\n// ---------------------------------------------------------------------------\n\n/**\n * Options for {@link AutoDevtoolsOpener.open}.\n *\n * The `relayHttpBaseUrl` and `targetId` fields are required to build a working\n * Chii self-hosted inspector URL. When `relayHttpBaseUrl` is absent the open\n * is skipped (no relay available yet).\n */\nexport interface DevtoolsOpenOptions {\n /**\n * Stable local inspector URL (`http://127.0.0.1:<port>/inspector`) from the\n * QR HTTP server (issue #530). When provided this URL is opened in the browser\n * instead of building a direct `front_end/chii_app.html?wss=…` URL. The\n * `/inspector` endpoint mints a fresh TOTP at click time and redirects, so\n * there is no TOTP-expiry race. Safe to log (no tunnel host, no TOTP code).\n *\n * When absent, falls back to building a direct inspector URL from\n * `relayHttpBaseUrl` + `mintTotp` (legacy path, kept for backward compat).\n */\n inspectorStableUrl?: string | null;\n /**\n * Local HTTP base URL of the Chii relay, e.g. `http://127.0.0.1:9100`.\n * Used to build the `<relay-base>/front_end/chii_app.html?wss=…` URL when\n * `inspectorStableUrl` is not available.\n *\n * For env 3/4 (intoss relay) this is `http://127.0.0.1:<port>`.\n * For env 2 (external PWA relay) this is the relay's external HTTP URL\n * (e.g. `https://<host>.trycloudflare.com`).\n *\n * When absent or empty, `open()` is a no-op.\n *\n * SECRET-HANDLING: this value contains the relay host. Callers MUST NOT\n * log it to stdout; stderr is the intended surface.\n */\n relayHttpBaseUrl: string | null | undefined;\n /**\n * Chii target id of the attached page, from `listTargets()[0].id`.\n * When absent or empty, `open()` is a no-op.\n */\n targetId: string | null | undefined;\n /**\n * Function that mints a fresh TOTP code when called. Called at most once per\n * `open()` invocation, immediately before building the inspector URL.\n * Only used when `inspectorStableUrl` is absent.\n *\n * Pass `undefined` when TOTP is disabled (no `at=` param is added).\n *\n * SECRET-HANDLING: the function MUST return only the code (6 digits), not\n * the secret. The code rides in the URL's `at=` param only.\n */\n mintTotp?: () => string;\n /** Current MCP environment (`mock` | `relay`). `open()` no-ops on `mock`. */\n env: McpEnvironment;\n}\n\n/**\n * Manages auto-opening Chrome DevTools on every NEW target attach (issue #530).\n *\n * Create one instance per `runDebugServer` call and pass its `open()` method\n * as the `onAttach` callback to the attach watcher (via `DualConnectionRouter`).\n *\n * The open fires for each NEW `targetId` — subsequent notifications for the\n * same target are de-duplicated. Re-attach with a fresh targetId (e.g. after\n * page reload on the phone) fires a new open. The URL opened is the stable\n * `/inspector` endpoint (issue #530) when `inspectorStableUrl` is provided —\n * it mints a fresh TOTP at click time so there is no expiry race. Falls back to\n * building a direct `front_end/chii_app.html?wss=…` URL when\n * `inspectorStableUrl` is absent.\n *\n * Opt-out and mock-environment guard are checked at call time.\n */\nexport class AutoDevtoolsOpener {\n /** Per-target de-dupe set (issue #530 — target-unit guard replaces once-per-daemon). */\n private readonly _openedTargets = new Set<string>();\n\n /**\n * Attempts to auto-open Chii DevTools in the developer's browser.\n *\n * Opens when:\n * - `options.targetId` is a NEW target (not yet in `_openedTargets`).\n *\n * No-op when any of the following conditions hold:\n * 1. `targetId` has already been opened (`_openedTargets` has it).\n * 2. `AIT_AUTO_DEVTOOLS=0` opt-out is set.\n * 3. `options.env` is `mock` (env 1 — F12 is already available).\n * 4. `options.targetId` is null/undefined/empty (no page attached yet).\n * 5. Neither `inspectorStableUrl` nor `relayHttpBaseUrl` is available.\n *\n * When `inspectorStableUrl` is provided (issue #530 stable URL): opens\n * `http://127.0.0.1:<port>/inspector` directly and writes it to stderr.\n * The URL contains no tunnel host or TOTP code — safe to log anywhere.\n *\n * Legacy path (no `inspectorStableUrl`): builds a direct\n * `<relay-base>/front_end/chii_app.html?wss=…` URL from `relayHttpBaseUrl`\n * + `mintTotp`, writes to stderr. TOTP expiry caveat applies (~3 min window).\n *\n * SECRET-HANDLING: direct inspector URL (written to stderr) may contain relay\n * host and TOTP code. Stable URL is secret-free. Neither must go to stdout or\n * persistent logs.\n */\n open(options: DevtoolsOpenOptions): void {\n if (isAutoDevtoolsDisabled()) return;\n if (options.env === 'mock') return;\n if (!options.targetId) return;\n\n // Target-unit de-dupe (issue #530): re-attach with a new targetId fires again.\n const targetId = options.targetId;\n if (this._openedTargets.has(targetId)) return;\n\n // Use stable /inspector URL when available (issue #530) — secret-free, no expiry.\n if (options.inspectorStableUrl) {\n this._openedTargets.add(targetId);\n const stableUrl = options.inspectorStableUrl;\n process.stderr.write(\n '[ait-debug] 기기가 연결됐습니다 — Chii DevTools를 자동으로 엽니다.\\n' +\n `[ait-debug] 인스펙터 URL: ${stableUrl}\\n` +\n '[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)\\n',\n );\n const opened = openUrlInBrowser(stableUrl);\n if (!opened) {\n process.stderr.write(\n `[ait-debug] 브라우저 자동 열기 실패 — ${stableUrl} 을 브라우저에서 직접 여세요.\\n`,\n );\n }\n return;\n }\n\n // Legacy path: build direct inspector URL from relayHttpBaseUrl + mintTotp.\n if (!options.relayHttpBaseUrl) return;\n\n this._openedTargets.add(targetId);\n\n const inspectorUrl = buildChiiInspectorUrl(\n options.relayHttpBaseUrl,\n targetId,\n options.mintTotp,\n );\n\n // FAIL-CLOSED (#509): buildChiiInspectorUrl returns null when mintTotp is\n // absent (no valid at= code → relay WS gate would reject the connection).\n // Record targetId in set so this guard fires, but skip browser open.\n if (inspectorUrl === null) {\n process.stderr.write(\n '[ait-debug] 기기가 연결됐습니다 — TOTP secret 미설정으로 인스펙터 URL을 생성할 수 없습니다.\\n' +\n '[ait-debug] relay 세션은 AIT_DEBUG_TOTP_SECRET 설정이 필요합니다.\\n',\n );\n return;\n }\n\n process.stderr.write(\n '[ait-debug] 기기가 연결됐습니다 — Chii DevTools를 자동으로 엽니다.\\n' +\n `[ait-debug] DevTools URL: ${inspectorUrl}\\n` +\n '[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)\\n' +\n '[ait-debug] 주의: URL의 at= 코드는 ~3분 안에서만 유효합니다.\\n',\n );\n\n const opened = openUrlInBrowser(inspectorUrl);\n if (!opened) {\n process.stderr.write(\n '[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\\n',\n );\n }\n }\n\n /**\n * Returns `true` if `open()` has been called for at least one target.\n * (Replaces the old once-per-session `_opened` flag; kept for interface\n * compatibility with tests that read `opener.opened`.)\n */\n get opened(): boolean {\n return this._openedTargets.size > 0;\n }\n\n /** Returns the set of target IDs that have already been auto-opened. */\n get openedTargets(): ReadonlySet<string> {\n return this._openedTargets;\n }\n}\n"],"mappings":";;;;;;;;;;AAqKA,SAAgB,yBAAkC;AAChD,QAAO,QAAQ,IAAI,sBAAsB;;;;;;;;;AAc3C,SAAgB,iBAAiB,KAAsB;AAGrD,KAAI,QAAQ,IAAI,sCAAsC,IAAK,QAAO;CAElE,MAAM,EAAE,cAAA,UAAsB,qBAAqB;CACnD,MAAM,WAAW,QAAQ;CAGzB,IAAI;AACJ,KAAI,aAAa,SACf,cAAa,CAAC;EAAE,KAAK;EAAQ,MAAM,CAAC,IAAI;EAAE,CAAC;UAClC,aAAa,QACtB,cAAa,CAAC;EAAE,KAAK;EAAO,MAAM;GAAC;GAAM;GAAS;GAAI;GAAI;EAAE,CAAC;KAG7D,cAAa;EACX;GAAE,KAAK;GAAY,MAAM,CAAC,IAAI;GAAE;EAChC;GAAE,KAAK;GAAoB,MAAM,CAAC,IAAI;GAAE;EACxC;GAAE,KAAK;GAAiB,MAAM,CAAC,IAAI;GAAE;EACtC;AAGH,MAAK,MAAM,EAAE,KAAK,UAAU,WAC1B,KAAI;EACF,MAAM,SAAS,UAAU,KAAK,MAAM;GAAE,UAAU;GAAQ,SAAS;GAAO,CAAC;AACzE,MAAI,CAAC,OAAO,SAAS,OAAO,WAAW,EAAG,QAAO;SAC3C;AAIV,QAAO"}
|