@ait-co/devtools 0.1.31 → 0.1.33

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/mcp/cli.js CHANGED
@@ -8,7 +8,7 @@ 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
13
  import qrcode from "qrcode-terminal";
14
14
  //#region src/mcp/ait-chii-source.ts
@@ -239,6 +239,23 @@ var ChiiCdpConnection = class {
239
239
  *
240
240
  * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app
241
241
  * entries.
242
+ *
243
+ * TOTP auth (relay-side, authoritative gate):
244
+ * When `verifyAuth` is provided, this module registers an HTTP upgrade
245
+ * listener on the server BEFORE calling `chii.start({server})`. Node's
246
+ * `http.Server` allows multiple 'upgrade' listeners; the first to call
247
+ * `socket.destroy()` wins. Invalid auth → 401 + destroy (chii never sees
248
+ * the connection). Valid auth → return without side-effect (chii handles it).
249
+ *
250
+ * Threat model: "URL leak" — someone obtains the tunnel URL (Slack paste, QR
251
+ * screenshot, shoulder-surfing) but does not have the shared TOTP secret.
252
+ * Rotating 6-digit code makes the URL stale after 30 s.
253
+ * A determined attacker who extracts the secret from the dogfood bundle can
254
+ * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).
255
+ *
256
+ * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear
257
+ * in any log, error message, or process output. `verifyAuth` is a black-box
258
+ * predicate from the caller's perspective; this module only forwards pass/fail.
242
259
  */
243
260
  const require = createRequire(import.meta.url);
244
261
  function loadChiiServer() {
@@ -250,7 +267,15 @@ function loadChiiServer() {
250
267
  async function startChiiRelay(options = {}) {
251
268
  const port = options.port ?? 9100;
252
269
  const host = options.host ?? "127.0.0.1";
270
+ const { verifyAuth } = options;
253
271
  const httpServer = createServer();
272
+ if (verifyAuth) httpServer.on("upgrade", (req, socket) => {
273
+ if (!verifyAuth(req)) {
274
+ socket.write("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n");
275
+ socket.destroy();
276
+ return;
277
+ }
278
+ });
254
279
  await loadChiiServer().start({
255
280
  server: httpServer,
256
281
  domain: `${host}:${port}`,
@@ -278,21 +303,25 @@ function stripExisting(query, key) {
278
303
  return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
279
304
  }
280
305
  /**
281
- * Splices `debug=1` and `relay=<wssUrl>` into a scheme URL's query string,
282
- * preserving everything else (scheme, authority, path, hash, and the existing
283
- * `_deploymentId` param). If `debug` or `relay` is already present it is
284
- * replaced so the helper is idempotent.
306
+ * Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a
307
+ * scheme URL's query string, preserving everything else (scheme, authority,
308
+ * path, hash, and the existing `_deploymentId` param). If any of the spliced
309
+ * params is already present it is replaced so the helper is idempotent.
285
310
  *
286
311
  * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed
287
312
  * by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B
288
313
  * of the gate); this helper does not invent one.
289
314
  * @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the
290
315
  * running debug MCP server's quick tunnel.
291
- * @returns The same URL with `debug=1&relay=<encoded wssUrl>` appended.
316
+ * @param totpCode - Optional current TOTP code (6 digits). When provided, it
317
+ * is spliced as `at=<totpCode>`. Must be computed at call time — it rotates
318
+ * every 30 s. Pass `undefined` or omit when TOTP is disabled.
319
+ * @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`
320
+ * appended.
292
321
  * @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so
293
322
  * producing such a link would be a silent dead end).
294
323
  */
295
- function buildDeepLinkAttachUrl(schemeUrl, wssUrl) {
324
+ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
296
325
  let relay;
297
326
  try {
298
327
  relay = new URL(wssUrl);
@@ -307,6 +336,8 @@ function buildDeepLinkAttachUrl(schemeUrl, wssUrl) {
307
336
  const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
308
337
  let query = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1);
309
338
  const appended = [["debug", "1"], ["relay", wssUrl]];
339
+ if (totpCode !== void 0 && totpCode !== "") appended.push(["at", totpCode]);
340
+ query = stripExisting(query, "at");
310
341
  for (const [key] of appended) query = stripExisting(query, key);
311
342
  for (const [key, value] of appended) {
312
343
  const pair = `${key}=${encodeURIComponent(value)}`;
@@ -384,6 +415,15 @@ const DEBUG_TOOL_DEFINITIONS = [
384
415
  required: []
385
416
  }
386
417
  },
418
+ {
419
+ name: "measure_safe_area",
420
+ 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.",
421
+ inputSchema: {
422
+ type: "object",
423
+ properties: {},
424
+ required: []
425
+ }
426
+ },
387
427
  {
388
428
  name: "AIT.getSdkCallHistory",
389
429
  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 +538,45 @@ async function takeScreenshot(connection) {
498
538
  mimeType: "image/png"
499
539
  };
500
540
  }
541
+ `
542
+ (function() {
543
+ var el = document.createElement('div');
544
+ el.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;visibility:hidden;' +
545
+ 'padding-top:env(safe-area-inset-top,0px);' +
546
+ 'padding-right:env(safe-area-inset-right,0px);' +
547
+ 'padding-bottom:env(safe-area-inset-bottom,0px);' +
548
+ 'padding-left:env(safe-area-inset-left,0px)';
549
+ document.documentElement.appendChild(el);
550
+ var cs = window.getComputedStyle(el);
551
+ var cssEnv = {
552
+ top: parseFloat(cs.paddingTop) || 0,
553
+ right: parseFloat(cs.paddingRight) || 0,
554
+ bottom: parseFloat(cs.paddingBottom) || 0,
555
+ left: parseFloat(cs.paddingLeft) || 0
556
+ };
557
+ document.documentElement.removeChild(el);
558
+ var sdkInsets = null;
559
+ try {
560
+ if (typeof SafeAreaInsets !== 'undefined' && SafeAreaInsets && typeof SafeAreaInsets.get === 'function') {
561
+ sdkInsets = SafeAreaInsets.get();
562
+ }
563
+ } catch(_) {}
564
+ var navBarHeight = null;
565
+ try {
566
+ var nb = document.querySelector('.ait-navbar');
567
+ if (nb) navBarHeight = nb.getBoundingClientRect().height;
568
+ } catch(_) {}
569
+ return JSON.stringify({
570
+ cssEnv: cssEnv,
571
+ sdkInsets: sdkInsets,
572
+ navBarHeight: navBarHeight,
573
+ innerWidth: window.innerWidth,
574
+ innerHeight: window.innerHeight,
575
+ devicePixelRatio: window.devicePixelRatio,
576
+ userAgent: navigator.userAgent
577
+ });
578
+ })()
579
+ `.trim();
501
580
  /** Set of tool names served by the AIT source rather than the CDP connection. */
502
581
  const AIT_TOOL_NAMES = new Set([
503
582
  "AIT.getSdkCallHistory",
@@ -521,16 +600,97 @@ function getOperationalEnvironment(source) {
521
600
  return source.get("AIT.getOperationalEnvironment");
522
601
  }
523
602
  //#endregion
603
+ //#region src/mcp/totp.ts
604
+ /**
605
+ * RFC 6238 TOTP implementation (Node.js, node:crypto only).
606
+ *
607
+ * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
608
+ * to keep the dependency surface minimal. This hand-roll is ~30 lines and
609
+ * covers exactly what relay-side auth needs.
610
+ *
611
+ * Algorithm summary (RFC 6238 + RFC 4226):
612
+ * T = floor(now / 30) — 30-second time step counter
613
+ * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
614
+ * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
615
+ * offset = MAC[19] & 0x0f
616
+ * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
617
+ *
618
+ * Security note (keep this comment accurate):
619
+ * The baked-in secret in a dogfood build is extractable from the bundle by a
620
+ * determined reverse engineer. This mechanism raises the bar from
621
+ * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
622
+ * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
623
+ * blocked; deliberate reverse engineering is not. See threat model in
624
+ * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
625
+ *
626
+ * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
627
+ * log, error message, or string visible outside this module. Only boolean
628
+ * pass/fail and reason enum values are safe to surface.
629
+ */
630
+ /** Time step window in seconds (RFC 6238 default). */
631
+ const TIME_STEP = 30;
632
+ /** Number of digits in the generated code. */
633
+ const DIGITS = 6;
634
+ /**
635
+ * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
636
+ * clock time.
637
+ *
638
+ * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
639
+ * bytes). Must be the output of `generateAttachToken()` or compatible.
640
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
641
+ * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
642
+ */
643
+ function generateTotp(secret, when = Date.now()) {
644
+ const key = Buffer.from(secret, "hex");
645
+ const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
646
+ const counterBuf = Buffer.alloc(8);
647
+ const hi = Math.floor(counter / 4294967296);
648
+ const lo = counter >>> 0;
649
+ counterBuf.writeUInt32BE(hi, 0);
650
+ counterBuf.writeUInt32BE(lo, 4);
651
+ const mac = createHmac("sha1", key).update(counterBuf).digest();
652
+ const offset = mac[19] & 15;
653
+ 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");
654
+ }
655
+ /**
656
+ * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
657
+ * tolerate clock drift between the relay host and the client device.
658
+ *
659
+ * Uses `timingSafeEqual` for constant-time comparison to prevent timing
660
+ * side-channel attacks.
661
+ *
662
+ * @param secret - Hex-encoded shared secret.
663
+ * @param code - The 6-digit code to verify (string or numeric).
664
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
665
+ * @param skew - Number of adjacent steps to accept on either side. Default 1
666
+ * (accepts T-1, T, T+1 — a 90-second acceptance window).
667
+ * @returns `true` if the code matches any accepted step, `false` otherwise.
668
+ */
669
+ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
670
+ const normalised = String(code).padStart(DIGITS, "0");
671
+ if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
672
+ const candidateBuf = Buffer.from(normalised, "utf8");
673
+ for (let delta = -skew; delta <= skew; delta++) {
674
+ const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
675
+ if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
676
+ }
677
+ return false;
678
+ }
679
+ //#endregion
524
680
  //#region src/mcp/tunnel.ts
525
681
  /**
526
682
  * cloudflared quick tunnel + attach banner for the debug-mode MCP server.
527
683
  *
528
684
  * On spawn, the debug server opens an accountless `*.trycloudflare.com` quick
529
685
  * tunnel to the local Chii relay so the phone can attach over a public wss URL,
530
- * then prints that URL + an attach token + an ASCII QR to the terminal. The
531
- * phone scans the QR (or pastes the URL) to attach; the in-app side passes the
532
- * token back. The token is generated + displayed as a pairing hint; relay-side
533
- * validation (ACL enforcement) is a later phase.
686
+ * then prints an ASCII QR + attach instructions. When TOTP auth is enabled
687
+ * (`AIT_DEBUG_TOTP_SECRET` is set), the QR encodes only the base relay URL
688
+ * the TOTP code (`at=`) is NOT included because it rotates every 30 s and
689
+ * would be stale by the time a human scans. The in-app deep-link builder
690
+ * splices the live code at attach time.
691
+ *
692
+ * SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear
693
+ * in any output from this module.
534
694
  *
535
695
  * Node-only: spawns the cloudflared binary and writes to stdout/stderr.
536
696
  */
@@ -580,22 +740,31 @@ async function startQuickTunnel(localPort) {
580
740
  }
581
741
  };
582
742
  }
583
- /** Renders the attach banner (URL + token + ASCII QR) as a string. */
743
+ /**
744
+ * Renders the attach banner (relay URL + ASCII QR) as a string.
745
+ *
746
+ * The QR encodes the base `wssUrl` only. When `totpEnabled` is true, a note
747
+ * is added that attach URLs generated by `build_attach_url` will include a
748
+ * live TOTP code (`at=`) appended at call time.
749
+ *
750
+ * SECRET-HANDLING: no secret value, TOTP code, or intermediate value is
751
+ * included in this output.
752
+ */
584
753
  async function renderAttachBanner(input) {
585
- const payload = `${input.wssUrl}?token=${input.token}`;
586
754
  const qr = await new Promise((resolve) => {
587
- qrcode.generate(payload, { small: true }, (rendered) => resolve(rendered));
755
+ qrcode.generate(input.wssUrl, { small: true }, (rendered) => resolve(rendered));
588
756
  });
757
+ 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
758
  return [
590
759
  "",
591
760
  "AIT debug — attach a mini-app to this session",
592
761
  "",
593
762
  ` relay (wss): ${input.wssUrl}`,
594
- ` attach token: ${input.token}`,
595
- ` (token is a pairing hint — relay-side validation lands in a later phase)`,
763
+ authNote,
596
764
  "",
597
- " Open the dogfood mini-app with ?debug=1, then scan the QR",
598
- " (or paste the relay URL + token in the in-app attach form):",
765
+ " Use build_attach_url to generate a deep link with the current TOTP code.",
766
+ " Scan the QR to locate the relay (open the dogfood URL separately with",
767
+ " ?debug=1&relay=<wss>&at=<code> or use the build_attach_url tool):",
599
768
  "",
600
769
  qr
601
770
  ].join("\n");
@@ -635,7 +804,7 @@ function createDebugServer(deps) {
635
804
  const { connection, aitSource, getTunnelStatus } = deps;
636
805
  const server = new Server({
637
806
  name: "ait-debug",
638
- version: "0.1.31"
807
+ version: "0.1.33"
639
808
  }, { capabilities: { tools: {} } });
640
809
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEBUG_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
641
810
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -734,21 +903,49 @@ function errorResult(err, name) {
734
903
  };
735
904
  }
736
905
  /**
906
+ * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
907
+ * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
908
+ *
909
+ * The predicate checks the `at` query parameter against the current and
910
+ * adjacent TOTP time steps (±1 skew) using `verifyTotp`.
911
+ *
912
+ * Returns `undefined` when the env var is not set — callers treat that as
913
+ * "auth disabled" (no predicate registered on the relay).
914
+ *
915
+ * SECRET-HANDLING: The secret value read from env is captured in a closure and
916
+ * is NEVER written to any log, error message, or process output.
917
+ */
918
+ function buildRelayVerifyAuth() {
919
+ const secret = process.env.AIT_DEBUG_TOTP_SECRET;
920
+ if (!secret) return void 0;
921
+ return (req) => {
922
+ const rawUrl = req.url ?? "";
923
+ const qIndex = rawUrl.indexOf("?");
924
+ const queryStr = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
925
+ return verifyTotp(secret, new URLSearchParams(queryStr).get("at") ?? "");
926
+ };
927
+ }
928
+ /**
737
929
  * Boots the live debug stack and serves it over stdio:
738
- * 1. start the Chii relay,
930
+ * 1. start the Chii relay (with TOTP auth if AIT_DEBUG_TOTP_SECRET is set),
739
931
  * 2. open a cloudflared quick tunnel to it,
740
- * 3. print QR + secret token,
932
+ * 3. print relay URL + attach instructions,
741
933
  * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
742
934
  */
743
935
  async function runDebugServer(options = {}) {
744
936
  const relayPort = options.relayPort ?? 9100;
745
- const relay = await startChiiRelay({ port: relayPort });
937
+ const verifyAuth = buildRelayVerifyAuth();
938
+ const totpEnabled = verifyAuth !== void 0;
939
+ const relay = await startChiiRelay({
940
+ port: relayPort,
941
+ verifyAuth
942
+ });
746
943
  let tunnel = null;
747
944
  let tunnelStatus = {
748
945
  up: false,
749
946
  wssUrl: null
750
947
  };
751
- const token = generateAttachToken();
948
+ generateAttachToken();
752
949
  try {
753
950
  tunnel = await startQuickTunnel(relayPort);
754
951
  tunnelStatus = {
@@ -757,7 +954,7 @@ async function runDebugServer(options = {}) {
757
954
  };
758
955
  await printAttachBanner({
759
956
  wssUrl: tunnel.wssUrl,
760
- token
957
+ totpEnabled
761
958
  });
762
959
  } catch (err) {
763
960
  const message = err instanceof Error ? err.message : String(err);
@@ -809,7 +1006,10 @@ var HttpAitSource = class {
809
1006
  sdkVersion: typeof state.appVersion === "string" ? state.appVersion : null
810
1007
  };
811
1008
  }
812
- case "AIT.getSdkCallHistory": return { calls: [] };
1009
+ case "AIT.getSdkCallHistory": {
1010
+ const raw = (await this.fetchState()).sdkCallLog;
1011
+ return { calls: Array.isArray(raw) ? raw : [] };
1012
+ }
813
1013
  default: throw new Error(`Unknown AIT method: ${String(method)}`);
814
1014
  }
815
1015
  }
@@ -897,7 +1097,7 @@ function createDevServer(deps = {}) {
897
1097
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
898
1098
  const server = new Server({
899
1099
  name: "ait-devtools",
900
- version: "0.1.31"
1100
+ version: "0.1.33"
901
1101
  }, { capabilities: { tools: {} } });
902
1102
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
903
1103
  server.setRequestHandler(CallToolRequestSchema, async (request) => {