@ait-co/devtools 0.1.32 → 0.1.34
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/in-app/index.d.ts +68 -14
- package/dist/in-app/index.d.ts.map +1 -1
- package/dist/in-app/index.js +7 -0
- package/dist/in-app/index.js.map +1 -1
- package/dist/mcp/cli.js +372 -51
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +64 -7
- package/dist/mcp/server.js.map +1 -1
- package/dist/mock/index.d.ts +124 -20
- package/dist/mock/index.d.ts.map +1 -1
- package/dist/mock/index.js +293 -51
- package/dist/mock/index.js.map +1 -1
- package/dist/panel/index.js +503 -67
- package/dist/panel/index.js.map +1 -1
- package/package.json +5 -3
package/dist/mcp/cli.js
CHANGED
|
@@ -8,9 +8,8 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
8
8
|
import { EventEmitter } from "node:events";
|
|
9
9
|
import { WebSocket } from "ws";
|
|
10
10
|
import { createServer } from "node:http";
|
|
11
|
-
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
12
12
|
import { Tunnel, bin, install } from "cloudflared";
|
|
13
|
-
import qrcode from "qrcode-terminal";
|
|
14
13
|
//#region src/mcp/ait-chii-source.ts
|
|
15
14
|
function isObject$2(value) {
|
|
16
15
|
return typeof value === "object" && value !== null;
|
|
@@ -239,6 +238,23 @@ var ChiiCdpConnection = class {
|
|
|
239
238
|
*
|
|
240
239
|
* Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app
|
|
241
240
|
* entries.
|
|
241
|
+
*
|
|
242
|
+
* TOTP auth (relay-side, authoritative gate):
|
|
243
|
+
* When `verifyAuth` is provided, this module registers an HTTP upgrade
|
|
244
|
+
* listener on the server BEFORE calling `chii.start({server})`. Node's
|
|
245
|
+
* `http.Server` allows multiple 'upgrade' listeners; the first to call
|
|
246
|
+
* `socket.destroy()` wins. Invalid auth → 401 + destroy (chii never sees
|
|
247
|
+
* the connection). Valid auth → return without side-effect (chii handles it).
|
|
248
|
+
*
|
|
249
|
+
* Threat model: "URL leak" — someone obtains the tunnel URL (Slack paste, QR
|
|
250
|
+
* screenshot, shoulder-surfing) but does not have the shared TOTP secret.
|
|
251
|
+
* Rotating 6-digit code makes the URL stale after 30 s.
|
|
252
|
+
* A determined attacker who extracts the secret from the dogfood bundle can
|
|
253
|
+
* still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).
|
|
254
|
+
*
|
|
255
|
+
* SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear
|
|
256
|
+
* in any log, error message, or process output. `verifyAuth` is a black-box
|
|
257
|
+
* predicate from the caller's perspective; this module only forwards pass/fail.
|
|
242
258
|
*/
|
|
243
259
|
const require = createRequire(import.meta.url);
|
|
244
260
|
function loadChiiServer() {
|
|
@@ -246,26 +262,49 @@ function loadChiiServer() {
|
|
|
246
262
|
if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
|
|
247
263
|
throw new Error("chii server module did not expose start()");
|
|
248
264
|
}
|
|
249
|
-
/**
|
|
265
|
+
/**
|
|
266
|
+
* Starts the Chii relay and resolves once listening.
|
|
267
|
+
*
|
|
268
|
+
* Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral
|
|
269
|
+
* port on every start, so a stale cloudflared orphan holding any particular
|
|
270
|
+
* port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`
|
|
271
|
+
* always reflect the actual bound port.
|
|
272
|
+
*
|
|
273
|
+
* chii.start() is called with `server` (our pre-created httpServer) BEFORE
|
|
274
|
+
* httpServer.listen(). This is intentional: chii attaches its Koa handler and
|
|
275
|
+
* WS upgrade listener to the server object, but the actual TCP bind is
|
|
276
|
+
* performed by our httpServer.listen() call below. The `port`/`domain` values
|
|
277
|
+
* passed to chii.start() are used for display/banner purposes inside chii and
|
|
278
|
+
* do not affect which port the server binds. The connection path (clients
|
|
279
|
+
* connecting to `relay.baseUrl`) always uses the post-listen confirmed port.
|
|
280
|
+
*/
|
|
250
281
|
async function startChiiRelay(options = {}) {
|
|
251
|
-
const
|
|
282
|
+
const requestedPort = options.port ?? 0;
|
|
252
283
|
const host = options.host ?? "127.0.0.1";
|
|
284
|
+
const { verifyAuth } = options;
|
|
253
285
|
const httpServer = createServer();
|
|
286
|
+
if (verifyAuth) httpServer.on("upgrade", (req, socket) => {
|
|
287
|
+
if (!verifyAuth(req)) {
|
|
288
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n");
|
|
289
|
+
socket.destroy();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
254
293
|
await loadChiiServer().start({
|
|
255
294
|
server: httpServer,
|
|
256
|
-
domain: `${host}:${
|
|
257
|
-
port
|
|
295
|
+
domain: `${host}:${requestedPort}`,
|
|
296
|
+
port: requestedPort
|
|
258
297
|
});
|
|
259
|
-
await new Promise((resolve, reject) => {
|
|
298
|
+
const actualPort = await new Promise((resolve, reject) => {
|
|
260
299
|
httpServer.once("error", reject);
|
|
261
|
-
httpServer.listen(
|
|
300
|
+
httpServer.listen(requestedPort, host, () => {
|
|
262
301
|
httpServer.off("error", reject);
|
|
263
|
-
resolve();
|
|
302
|
+
resolve(httpServer.address().port);
|
|
264
303
|
});
|
|
265
304
|
});
|
|
266
305
|
return {
|
|
267
|
-
port,
|
|
268
|
-
baseUrl: `http://${host}:${
|
|
306
|
+
port: actualPort,
|
|
307
|
+
baseUrl: `http://${host}:${actualPort}`,
|
|
269
308
|
close: () => new Promise((resolve) => {
|
|
270
309
|
httpServer.close(() => resolve());
|
|
271
310
|
})
|
|
@@ -278,21 +317,25 @@ function stripExisting(query, key) {
|
|
|
278
317
|
return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
|
|
279
318
|
}
|
|
280
319
|
/**
|
|
281
|
-
* Splices `debug=1` and `
|
|
282
|
-
* preserving everything else (scheme, authority,
|
|
283
|
-
* `_deploymentId` param). If
|
|
284
|
-
* replaced so the helper is idempotent.
|
|
320
|
+
* Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a
|
|
321
|
+
* scheme URL's query string, preserving everything else (scheme, authority,
|
|
322
|
+
* path, hash, and the existing `_deploymentId` param). If any of the spliced
|
|
323
|
+
* params is already present it is replaced so the helper is idempotent.
|
|
285
324
|
*
|
|
286
325
|
* @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed
|
|
287
326
|
* by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B
|
|
288
327
|
* of the gate); this helper does not invent one.
|
|
289
328
|
* @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the
|
|
290
329
|
* running debug MCP server's quick tunnel.
|
|
291
|
-
* @
|
|
330
|
+
* @param totpCode - Optional current TOTP code (6 digits). When provided, it
|
|
331
|
+
* is spliced as `at=<totpCode>`. Must be computed at call time — it rotates
|
|
332
|
+
* every 30 s. Pass `undefined` or omit when TOTP is disabled.
|
|
333
|
+
* @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`
|
|
334
|
+
* appended.
|
|
292
335
|
* @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so
|
|
293
336
|
* producing such a link would be a silent dead end).
|
|
294
337
|
*/
|
|
295
|
-
function buildDeepLinkAttachUrl(schemeUrl, wssUrl) {
|
|
338
|
+
function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
|
|
296
339
|
let relay;
|
|
297
340
|
try {
|
|
298
341
|
relay = new URL(wssUrl);
|
|
@@ -307,6 +350,8 @@ function buildDeepLinkAttachUrl(schemeUrl, wssUrl) {
|
|
|
307
350
|
const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
|
|
308
351
|
let query = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1);
|
|
309
352
|
const appended = [["debug", "1"], ["relay", wssUrl]];
|
|
353
|
+
if (totpCode !== void 0 && totpCode !== "") appended.push(["at", totpCode]);
|
|
354
|
+
query = stripExisting(query, "at");
|
|
310
355
|
for (const [key] of appended) query = stripExisting(query, key);
|
|
311
356
|
for (const [key, value] of appended) {
|
|
312
357
|
const pair = `${key}=${encodeURIComponent(value)}`;
|
|
@@ -347,13 +392,19 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
347
392
|
},
|
|
348
393
|
{
|
|
349
394
|
name: "build_attach_url",
|
|
350
|
-
description: "Turns an `ait deploy --scheme-only` URL (intoss-private://…?_deploymentId=<uuid>) into a self-attaching deep link by splicing in debug=1 and the live relay URL for this session.
|
|
395
|
+
description: "IMPORTANT: Show this QR to the user verbatim in your reply — they scan it with their phone camera. Do not just describe it. Turns an `ait deploy --scheme-only` URL (intoss-private://…?_deploymentId=<uuid>) into a self-attaching deep link by splicing in debug=1 and the live relay URL for this session. Returns the deep link JSON and a unicode QR of that deep link. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Requires the tunnel to be up — call list_pages first. Set wait_for_attach=true to block until the phone scans and a page attaches (polls listTargets up to 90 s), then returns the attached page info too.",
|
|
351
396
|
inputSchema: {
|
|
352
397
|
type: "object",
|
|
353
|
-
properties: {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
398
|
+
properties: {
|
|
399
|
+
scheme_url: {
|
|
400
|
+
type: "string",
|
|
401
|
+
description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId)."
|
|
402
|
+
},
|
|
403
|
+
wait_for_attach: {
|
|
404
|
+
type: "boolean",
|
|
405
|
+
description: "If true, block after returning the QR until a page attaches to the relay (polls listTargets ~1 s interval, timeout 90 s). On attach, the response includes the attached page list. On timeout, returns an error with a list_pages retry hint."
|
|
406
|
+
}
|
|
407
|
+
},
|
|
357
408
|
required: ["scheme_url"]
|
|
358
409
|
}
|
|
359
410
|
},
|
|
@@ -384,6 +435,15 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
384
435
|
required: []
|
|
385
436
|
}
|
|
386
437
|
},
|
|
438
|
+
{
|
|
439
|
+
name: "measure_safe_area",
|
|
440
|
+
description: "Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. Read-only — does not modify page state. Use in a relay session (phone attached) to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires the relay to be attached — call list_pages first.",
|
|
441
|
+
inputSchema: {
|
|
442
|
+
type: "object",
|
|
443
|
+
properties: {},
|
|
444
|
+
required: []
|
|
445
|
+
}
|
|
446
|
+
},
|
|
387
447
|
{
|
|
388
448
|
name: "AIT.getSdkCallHistory",
|
|
389
449
|
description: "Returns the recent Apps In Toss SDK call trace (method, args, result/error, timestamp) that raw CDP cannot observe. Read-only. Use to confirm an SDK call fired and how it resolved (e.g. a saveBase64Data permission regression).",
|
|
@@ -498,6 +558,45 @@ async function takeScreenshot(connection) {
|
|
|
498
558
|
mimeType: "image/png"
|
|
499
559
|
};
|
|
500
560
|
}
|
|
561
|
+
`
|
|
562
|
+
(function() {
|
|
563
|
+
var el = document.createElement('div');
|
|
564
|
+
el.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;visibility:hidden;' +
|
|
565
|
+
'padding-top:env(safe-area-inset-top,0px);' +
|
|
566
|
+
'padding-right:env(safe-area-inset-right,0px);' +
|
|
567
|
+
'padding-bottom:env(safe-area-inset-bottom,0px);' +
|
|
568
|
+
'padding-left:env(safe-area-inset-left,0px)';
|
|
569
|
+
document.documentElement.appendChild(el);
|
|
570
|
+
var cs = window.getComputedStyle(el);
|
|
571
|
+
var cssEnv = {
|
|
572
|
+
top: parseFloat(cs.paddingTop) || 0,
|
|
573
|
+
right: parseFloat(cs.paddingRight) || 0,
|
|
574
|
+
bottom: parseFloat(cs.paddingBottom) || 0,
|
|
575
|
+
left: parseFloat(cs.paddingLeft) || 0
|
|
576
|
+
};
|
|
577
|
+
document.documentElement.removeChild(el);
|
|
578
|
+
var sdkInsets = null;
|
|
579
|
+
try {
|
|
580
|
+
if (typeof SafeAreaInsets !== 'undefined' && SafeAreaInsets && typeof SafeAreaInsets.get === 'function') {
|
|
581
|
+
sdkInsets = SafeAreaInsets.get();
|
|
582
|
+
}
|
|
583
|
+
} catch(_) {}
|
|
584
|
+
var navBarHeight = null;
|
|
585
|
+
try {
|
|
586
|
+
var nb = document.querySelector('.ait-navbar');
|
|
587
|
+
if (nb) navBarHeight = nb.getBoundingClientRect().height;
|
|
588
|
+
} catch(_) {}
|
|
589
|
+
return JSON.stringify({
|
|
590
|
+
cssEnv: cssEnv,
|
|
591
|
+
sdkInsets: sdkInsets,
|
|
592
|
+
navBarHeight: navBarHeight,
|
|
593
|
+
innerWidth: window.innerWidth,
|
|
594
|
+
innerHeight: window.innerHeight,
|
|
595
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
596
|
+
userAgent: navigator.userAgent
|
|
597
|
+
});
|
|
598
|
+
})()
|
|
599
|
+
`.trim();
|
|
501
600
|
/** Set of tool names served by the AIT source rather than the CDP connection. */
|
|
502
601
|
const AIT_TOOL_NAMES = new Set([
|
|
503
602
|
"AIT.getSdkCallHistory",
|
|
@@ -521,16 +620,97 @@ function getOperationalEnvironment(source) {
|
|
|
521
620
|
return source.get("AIT.getOperationalEnvironment");
|
|
522
621
|
}
|
|
523
622
|
//#endregion
|
|
623
|
+
//#region src/mcp/totp.ts
|
|
624
|
+
/**
|
|
625
|
+
* RFC 6238 TOTP implementation (Node.js, node:crypto only).
|
|
626
|
+
*
|
|
627
|
+
* External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
|
|
628
|
+
* to keep the dependency surface minimal. This hand-roll is ~30 lines and
|
|
629
|
+
* covers exactly what relay-side auth needs.
|
|
630
|
+
*
|
|
631
|
+
* Algorithm summary (RFC 6238 + RFC 4226):
|
|
632
|
+
* T = floor(now / 30) — 30-second time step counter
|
|
633
|
+
* K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
|
|
634
|
+
* MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
|
|
635
|
+
* offset = MAC[19] & 0x0f
|
|
636
|
+
* code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
|
|
637
|
+
*
|
|
638
|
+
* Security note (keep this comment accurate):
|
|
639
|
+
* The baked-in secret in a dogfood build is extractable from the bundle by a
|
|
640
|
+
* determined reverse engineer. This mechanism raises the bar from
|
|
641
|
+
* "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
|
|
642
|
+
* Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
|
|
643
|
+
* blocked; deliberate reverse engineering is not. See threat model in
|
|
644
|
+
* src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
|
|
645
|
+
*
|
|
646
|
+
* SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
|
|
647
|
+
* log, error message, or string visible outside this module. Only boolean
|
|
648
|
+
* pass/fail and reason enum values are safe to surface.
|
|
649
|
+
*/
|
|
650
|
+
/** Time step window in seconds (RFC 6238 default). */
|
|
651
|
+
const TIME_STEP = 30;
|
|
652
|
+
/** Number of digits in the generated code. */
|
|
653
|
+
const DIGITS = 6;
|
|
654
|
+
/**
|
|
655
|
+
* Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
|
|
656
|
+
* clock time.
|
|
657
|
+
*
|
|
658
|
+
* @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
|
|
659
|
+
* bytes). Must be the output of `generateAttachToken()` or compatible.
|
|
660
|
+
* @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
|
|
661
|
+
* @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
|
|
662
|
+
*/
|
|
663
|
+
function generateTotp(secret, when = Date.now()) {
|
|
664
|
+
const key = Buffer.from(secret, "hex");
|
|
665
|
+
const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
|
|
666
|
+
const counterBuf = Buffer.alloc(8);
|
|
667
|
+
const hi = Math.floor(counter / 4294967296);
|
|
668
|
+
const lo = counter >>> 0;
|
|
669
|
+
counterBuf.writeUInt32BE(hi, 0);
|
|
670
|
+
counterBuf.writeUInt32BE(lo, 4);
|
|
671
|
+
const mac = createHmac("sha1", key).update(counterBuf).digest();
|
|
672
|
+
const offset = mac[19] & 15;
|
|
673
|
+
return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Verifies a TOTP code against the secret, accepting ±`skew` time steps to
|
|
677
|
+
* tolerate clock drift between the relay host and the client device.
|
|
678
|
+
*
|
|
679
|
+
* Uses `timingSafeEqual` for constant-time comparison to prevent timing
|
|
680
|
+
* side-channel attacks.
|
|
681
|
+
*
|
|
682
|
+
* @param secret - Hex-encoded shared secret.
|
|
683
|
+
* @param code - The 6-digit code to verify (string or numeric).
|
|
684
|
+
* @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
|
|
685
|
+
* @param skew - Number of adjacent steps to accept on either side. Default 1
|
|
686
|
+
* (accepts T-1, T, T+1 — a 90-second acceptance window).
|
|
687
|
+
* @returns `true` if the code matches any accepted step, `false` otherwise.
|
|
688
|
+
*/
|
|
689
|
+
function verifyTotp(secret, code, when = Date.now(), skew = 1) {
|
|
690
|
+
const normalised = String(code).padStart(DIGITS, "0");
|
|
691
|
+
if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
|
|
692
|
+
const candidateBuf = Buffer.from(normalised, "utf8");
|
|
693
|
+
for (let delta = -skew; delta <= skew; delta++) {
|
|
694
|
+
const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
|
|
695
|
+
if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
|
|
696
|
+
}
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
//#endregion
|
|
524
700
|
//#region src/mcp/tunnel.ts
|
|
525
701
|
/**
|
|
526
702
|
* cloudflared quick tunnel + attach banner for the debug-mode MCP server.
|
|
527
703
|
*
|
|
528
704
|
* On spawn, the debug server opens an accountless `*.trycloudflare.com` quick
|
|
529
705
|
* tunnel to the local Chii relay so the phone can attach over a public wss URL,
|
|
530
|
-
* then prints
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
706
|
+
* then prints a unicode half-block QR + attach instructions. When TOTP auth is
|
|
707
|
+
* enabled (`AIT_DEBUG_TOTP_SECRET` is set), the QR encodes only the base relay
|
|
708
|
+
* URL — the TOTP code (`at=`) is NOT included because it rotates every 30 s
|
|
709
|
+
* and would be stale by the time a human scans. The in-app deep-link builder
|
|
710
|
+
* splices the live code at attach time.
|
|
711
|
+
*
|
|
712
|
+
* SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear
|
|
713
|
+
* in any output from this module.
|
|
534
714
|
*
|
|
535
715
|
* Node-only: spawns the cloudflared binary and writes to stdout/stderr.
|
|
536
716
|
*/
|
|
@@ -580,22 +760,68 @@ async function startQuickTunnel(localPort) {
|
|
|
580
760
|
}
|
|
581
761
|
};
|
|
582
762
|
}
|
|
583
|
-
/**
|
|
763
|
+
/**
|
|
764
|
+
* Renders a pure unicode half-block QR string for the given text.
|
|
765
|
+
*
|
|
766
|
+
* Uses `qrcode` (Node full lib) to get the raw bit matrix, then encodes every
|
|
767
|
+
* two vertical modules into a single half-block character:
|
|
768
|
+
* - both dark → `█`
|
|
769
|
+
* - top only → `▀`
|
|
770
|
+
* - bottom only → `▄`
|
|
771
|
+
* - both light → ` ` (space)
|
|
772
|
+
*
|
|
773
|
+
* The output contains **zero ANSI escape codes**, so it renders correctly in
|
|
774
|
+
* every surface (terminal, VS Code, JetBrains, web) and can be scanned by a
|
|
775
|
+
* phone camera when shown verbatim in an agent response.
|
|
776
|
+
*
|
|
777
|
+
* Shared by `renderAttachBanner` (relay wssUrl QR) and the `build_attach_url`
|
|
778
|
+
* MCP tool response (attach deep-link QR).
|
|
779
|
+
*/
|
|
780
|
+
async function renderQr(text) {
|
|
781
|
+
const { default: QRCode } = await import("qrcode");
|
|
782
|
+
const qr = QRCode.create(text, { errorCorrectionLevel: "M" });
|
|
783
|
+
const size = qr.modules.size;
|
|
784
|
+
const data = qr.modules.data;
|
|
785
|
+
const isDark = (x, y) => {
|
|
786
|
+
if (x < 0 || y < 0 || x >= size || y >= size) return false;
|
|
787
|
+
return data[y * size + x] === 1;
|
|
788
|
+
};
|
|
789
|
+
const QUIET = 1;
|
|
790
|
+
const lines = [];
|
|
791
|
+
for (let y = -QUIET; y < size + QUIET; y += 2) {
|
|
792
|
+
let line = "";
|
|
793
|
+
for (let x = -QUIET; x < size + QUIET; x++) {
|
|
794
|
+
const top = isDark(x, y);
|
|
795
|
+
const bot = isDark(x, y + 1);
|
|
796
|
+
line += top && bot ? "█" : top ? "▀" : bot ? "▄" : " ";
|
|
797
|
+
}
|
|
798
|
+
lines.push(line);
|
|
799
|
+
}
|
|
800
|
+
return `${lines.join("\n")}\n`;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Renders the attach banner (relay URL + ASCII QR) as a string.
|
|
804
|
+
*
|
|
805
|
+
* The QR encodes the base `wssUrl` only. When `totpEnabled` is true, a note
|
|
806
|
+
* is added that attach URLs generated by `build_attach_url` will include a
|
|
807
|
+
* live TOTP code (`at=`) appended at call time.
|
|
808
|
+
*
|
|
809
|
+
* SECRET-HANDLING: no secret value, TOTP code, or intermediate value is
|
|
810
|
+
* included in this output.
|
|
811
|
+
*/
|
|
584
812
|
async function renderAttachBanner(input) {
|
|
585
|
-
const
|
|
586
|
-
const
|
|
587
|
-
qrcode.generate(payload, { small: true }, (rendered) => resolve(rendered));
|
|
588
|
-
});
|
|
813
|
+
const qr = await renderQr(input.wssUrl);
|
|
814
|
+
const authNote = input.totpEnabled ? " auth: TOTP enabled — attach URLs include a rotating code (at=)." : " auth: none (set AIT_DEBUG_TOTP_SECRET to enable TOTP).";
|
|
589
815
|
return [
|
|
590
816
|
"",
|
|
591
817
|
"AIT debug — attach a mini-app to this session",
|
|
592
818
|
"",
|
|
593
819
|
` relay (wss): ${input.wssUrl}`,
|
|
594
|
-
|
|
595
|
-
` (token is a pairing hint — relay-side validation lands in a later phase)`,
|
|
820
|
+
authNote,
|
|
596
821
|
"",
|
|
597
|
-
"
|
|
598
|
-
"
|
|
822
|
+
" Use build_attach_url to generate a deep link with the current TOTP code.",
|
|
823
|
+
" Scan the QR to locate the relay (open the dogfood URL separately with",
|
|
824
|
+
" ?debug=1&relay=<wss>&at=<code> or use the build_attach_url tool):",
|
|
599
825
|
"",
|
|
600
826
|
qr
|
|
601
827
|
].join("\n");
|
|
@@ -613,12 +839,24 @@ async function printAttachBanner(input) {
|
|
|
613
839
|
* Lets an AI coding agent attach to a running mini-app (real Toss WebView, or a
|
|
614
840
|
* browser in dev mode) and read its console/network/DOM/screenshot over CDP plus
|
|
615
841
|
* the AIT.* domain, without a human watching a phone. Transport is CDP-via-Chii:
|
|
616
|
-
* a local Chii relay
|
|
617
|
-
* attaches over the public wss URL.
|
|
842
|
+
* a local Chii relay on an OS-assigned port (default 0) exposed through a
|
|
843
|
+
* cloudflared quick tunnel; the phone attaches over the public wss URL.
|
|
618
844
|
*
|
|
619
|
-
* AI host --stdio--> this server --CDP client WS--> Chii relay
|
|
845
|
+
* AI host --stdio--> this server --CDP client WS--> Chii relay :<OS-port>
|
|
620
846
|
* ^-- target WS -- phone
|
|
621
847
|
*
|
|
848
|
+
* Port 0 (default): the OS picks a free ephemeral port on every startup.
|
|
849
|
+
* This prevents EADDRINUSE when a stale cloudflared child (orphaned after
|
|
850
|
+
* SIGKILL, PPID 1) still holds a fixed port — which previously caused the MCP
|
|
851
|
+
* handshake to fail with -32000. With port 0 any orphaned cloudflared is
|
|
852
|
+
* harmless; the new relay always gets a fresh port.
|
|
853
|
+
*
|
|
854
|
+
* Best-effort child cleanup: SIGINT/SIGTERM/SIGHUP handlers call shutdown() to
|
|
855
|
+
* stop cloudflared and the relay. uncaughtException/unhandledRejection also
|
|
856
|
+
* call shutdown() before exit. SIGKILL cannot be intercepted by Node, so
|
|
857
|
+
* cloudflared orphans from SIGKILL remain (port 0 makes them harmless). Users
|
|
858
|
+
* can clean up manually: `pkill -f 'cloudflared.*trycloudflare'`.
|
|
859
|
+
*
|
|
622
860
|
* The tool layer reads from an injectable `CdpConnection` (CDP) and `AitSource`
|
|
623
861
|
* (AIT.*), so every tool is unit-testable with a fake (no phone). This module
|
|
624
862
|
* wires the live pieces (relay + tunnel + production connection); the phone
|
|
@@ -632,10 +870,10 @@ async function printAttachBanner(input) {
|
|
|
632
870
|
* tunnel, which is what makes the tool surface unit-testable.
|
|
633
871
|
*/
|
|
634
872
|
function createDebugServer(deps) {
|
|
635
|
-
const { connection, aitSource, getTunnelStatus } = deps;
|
|
873
|
+
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4 } = deps;
|
|
636
874
|
const server = new Server({
|
|
637
875
|
name: "ait-debug",
|
|
638
|
-
version: "0.1.
|
|
876
|
+
version: "0.1.34"
|
|
639
877
|
}, { capabilities: { tools: {} } });
|
|
640
878
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEBUG_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
641
879
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -667,8 +905,39 @@ function createDebugServer(deps) {
|
|
|
667
905
|
}],
|
|
668
906
|
isError: true
|
|
669
907
|
};
|
|
908
|
+
const waitForAttach = request.params.arguments?.wait_for_attach === true;
|
|
670
909
|
try {
|
|
671
|
-
|
|
910
|
+
const { attachUrl, relayUrl } = buildAttachUrl(schemeUrl, getTunnelStatus());
|
|
911
|
+
const qr = await renderQr(attachUrl);
|
|
912
|
+
const baseText = `IMPORTANT: Show this QR to the user verbatim in your reply — they scan it with their phone camera. Do not just describe it.\n${JSON.stringify({
|
|
913
|
+
attachUrl,
|
|
914
|
+
relayUrl
|
|
915
|
+
}, null, 2)}\n\n${qr}`;
|
|
916
|
+
if (!waitForAttach) return { content: [{
|
|
917
|
+
type: "text",
|
|
918
|
+
text: baseText
|
|
919
|
+
}] };
|
|
920
|
+
const POLL_INTERVAL_MS = 1e3;
|
|
921
|
+
const TIMEOUT_MS = waitForAttachTimeoutMs;
|
|
922
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
923
|
+
let attachedPages = [];
|
|
924
|
+
while (Date.now() < deadline) {
|
|
925
|
+
attachedPages = connection.listTargets();
|
|
926
|
+
if (attachedPages.length > 0) break;
|
|
927
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
928
|
+
}
|
|
929
|
+
if (attachedPages.length === 0) return {
|
|
930
|
+
content: [{
|
|
931
|
+
type: "text",
|
|
932
|
+
text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
|
|
933
|
+
}],
|
|
934
|
+
isError: true
|
|
935
|
+
};
|
|
936
|
+
const pagesResult = listPages(connection, getTunnelStatus());
|
|
937
|
+
return { content: [{
|
|
938
|
+
type: "text",
|
|
939
|
+
text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
|
|
940
|
+
}] };
|
|
672
941
|
} catch (err) {
|
|
673
942
|
return errorResult(err, name);
|
|
674
943
|
}
|
|
@@ -734,30 +1003,59 @@ function errorResult(err, name) {
|
|
|
734
1003
|
};
|
|
735
1004
|
}
|
|
736
1005
|
/**
|
|
1006
|
+
* Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
|
|
1007
|
+
* `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
|
|
1008
|
+
*
|
|
1009
|
+
* The predicate checks the `at` query parameter against the current and
|
|
1010
|
+
* adjacent TOTP time steps (±1 skew) using `verifyTotp`.
|
|
1011
|
+
*
|
|
1012
|
+
* Returns `undefined` when the env var is not set — callers treat that as
|
|
1013
|
+
* "auth disabled" (no predicate registered on the relay).
|
|
1014
|
+
*
|
|
1015
|
+
* SECRET-HANDLING: The secret value read from env is captured in a closure and
|
|
1016
|
+
* is NEVER written to any log, error message, or process output.
|
|
1017
|
+
*/
|
|
1018
|
+
function buildRelayVerifyAuth() {
|
|
1019
|
+
const secret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
1020
|
+
if (!secret) return void 0;
|
|
1021
|
+
return (req) => {
|
|
1022
|
+
const rawUrl = req.url ?? "";
|
|
1023
|
+
const qIndex = rawUrl.indexOf("?");
|
|
1024
|
+
const queryStr = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
|
|
1025
|
+
return verifyTotp(secret, new URLSearchParams(queryStr).get("at") ?? "");
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
737
1029
|
* Boots the live debug stack and serves it over stdio:
|
|
738
|
-
* 1. start the Chii relay
|
|
739
|
-
*
|
|
740
|
-
*
|
|
1030
|
+
* 1. start the Chii relay on an OS-assigned port (with TOTP auth if
|
|
1031
|
+
* AIT_DEBUG_TOTP_SECRET is set),
|
|
1032
|
+
* 2. open a cloudflared quick tunnel to the relay's confirmed port,
|
|
1033
|
+
* 3. print relay URL + attach instructions,
|
|
741
1034
|
* 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
|
|
742
1035
|
*/
|
|
743
1036
|
async function runDebugServer(options = {}) {
|
|
744
|
-
const relayPort = options.relayPort ??
|
|
745
|
-
const
|
|
1037
|
+
const relayPort = options.relayPort ?? 0;
|
|
1038
|
+
const verifyAuth = buildRelayVerifyAuth();
|
|
1039
|
+
const totpEnabled = verifyAuth !== void 0;
|
|
1040
|
+
const relay = await startChiiRelay({
|
|
1041
|
+
port: relayPort,
|
|
1042
|
+
verifyAuth
|
|
1043
|
+
});
|
|
746
1044
|
let tunnel = null;
|
|
747
1045
|
let tunnelStatus = {
|
|
748
1046
|
up: false,
|
|
749
1047
|
wssUrl: null
|
|
750
1048
|
};
|
|
751
|
-
|
|
1049
|
+
generateAttachToken();
|
|
752
1050
|
try {
|
|
753
|
-
tunnel = await startQuickTunnel(
|
|
1051
|
+
tunnel = await startQuickTunnel(relay.port);
|
|
754
1052
|
tunnelStatus = {
|
|
755
1053
|
up: true,
|
|
756
1054
|
wssUrl: tunnel.wssUrl
|
|
757
1055
|
};
|
|
758
1056
|
await printAttachBanner({
|
|
759
1057
|
wssUrl: tunnel.wssUrl,
|
|
760
|
-
|
|
1058
|
+
totpEnabled
|
|
761
1059
|
});
|
|
762
1060
|
} catch (err) {
|
|
763
1061
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -771,7 +1069,10 @@ async function runDebugServer(options = {}) {
|
|
|
771
1069
|
getTunnelStatus: () => tunnelStatus
|
|
772
1070
|
});
|
|
773
1071
|
const transport = new StdioServerTransport();
|
|
1072
|
+
let closed = false;
|
|
774
1073
|
const shutdown = () => {
|
|
1074
|
+
if (closed) return;
|
|
1075
|
+
closed = true;
|
|
775
1076
|
connection.close();
|
|
776
1077
|
tunnel?.stop();
|
|
777
1078
|
relay.close();
|
|
@@ -779,6 +1080,23 @@ async function runDebugServer(options = {}) {
|
|
|
779
1080
|
};
|
|
780
1081
|
process.once("SIGINT", shutdown);
|
|
781
1082
|
process.once("SIGTERM", shutdown);
|
|
1083
|
+
process.once("SIGHUP", shutdown);
|
|
1084
|
+
process.on("exit", () => {
|
|
1085
|
+
if (!closed) {
|
|
1086
|
+
closed = true;
|
|
1087
|
+
tunnel?.stop();
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
process.on("uncaughtException", (err) => {
|
|
1091
|
+
process.stderr.write(`[ait-debug] uncaughtException: ${String(err)}\n`);
|
|
1092
|
+
shutdown();
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
});
|
|
1095
|
+
process.on("unhandledRejection", (reason) => {
|
|
1096
|
+
process.stderr.write(`[ait-debug] unhandledRejection: ${String(reason)}\n`);
|
|
1097
|
+
shutdown();
|
|
1098
|
+
process.exit(1);
|
|
1099
|
+
});
|
|
782
1100
|
await server.connect(transport);
|
|
783
1101
|
}
|
|
784
1102
|
//#endregion
|
|
@@ -809,7 +1127,10 @@ var HttpAitSource = class {
|
|
|
809
1127
|
sdkVersion: typeof state.appVersion === "string" ? state.appVersion : null
|
|
810
1128
|
};
|
|
811
1129
|
}
|
|
812
|
-
case "AIT.getSdkCallHistory":
|
|
1130
|
+
case "AIT.getSdkCallHistory": {
|
|
1131
|
+
const raw = (await this.fetchState()).sdkCallLog;
|
|
1132
|
+
return { calls: Array.isArray(raw) ? raw : [] };
|
|
1133
|
+
}
|
|
813
1134
|
default: throw new Error(`Unknown AIT method: ${String(method)}`);
|
|
814
1135
|
}
|
|
815
1136
|
}
|
|
@@ -897,7 +1218,7 @@ function createDevServer(deps = {}) {
|
|
|
897
1218
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
898
1219
|
const server = new Server({
|
|
899
1220
|
name: "ait-devtools",
|
|
900
|
-
version: "0.1.
|
|
1221
|
+
version: "0.1.34"
|
|
901
1222
|
}, { capabilities: { tools: {} } });
|
|
902
1223
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
903
1224
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|