@botim/mp-debug-sdk 0.4.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ Mini-programs run on user devices in environments you can't easily attach a debu
8
8
 
9
9
  - **Live logs** of `console.*`, `fetch`, `XMLHttpRequest`, and uncaught errors.
10
10
  - **AI-observable sessions** — every event indexed by `(miniProgramId, deviceId, sid)` so resolver agents can pull errors and reproduce bugs without a human in the loop.
11
- - **Safe AI command channel** — agents can `reload`, `dump-state`, `set-feature-flag`, `screenshot`, `ping`, or any custom command you allow. Anything not registered is rejected.
11
+ - **Safe AI command channel** — agents can `reload`, `dump-state`, `set-feature-flag`, `screenshot`, `ping`, `exec` (default-on remote REPL), or any custom command you allow. Anything not registered is rejected.
12
12
  - **Built-in redaction** before the event ever enters the in-memory buffer.
13
13
 
14
14
  ## Install
@@ -128,7 +128,58 @@ The SDK posts directly to `endpoint` — there's no proxy required. Your relay m
128
128
  CORS_ORIGINS=https://my-mp.example.com,https://staging.example.com
129
129
  ```
130
130
 
131
- ## 5. Custom commands (optional)
131
+ ## 5. Remote REPL (`exec`) — default-on
132
+
133
+ Once `enableRemoteDebug` returns, an attached agent can send a JS snippet to the device and read back the value, captured `console.*` output, or thrown error. No host wiring needed.
134
+
135
+ ```bash
136
+ # From any terminal that can reach your relay:
137
+ curl -sX POST "https://debug.botim.dev/v1/mp/<MP_ID>/devices/<DEVICE_ID>/commands" \
138
+ -H 'content-type: application/json' \
139
+ -d '{"name":"exec","args":{"code":"console.log(\"hi\"); return window.location.href"}}'
140
+ ```
141
+
142
+ Result event:
143
+
144
+ ```jsonc
145
+ {
146
+ "type": "command-ack",
147
+ "payload": {
148
+ "command": "exec",
149
+ "ok": true,
150
+ "result": {
151
+ "value": "https://my-mp.example.com/page",
152
+ "logs": [{"method":"log","args":["hi"],"ts": 1735324800123}],
153
+ "durationMs": 2
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+ Inside the snippet, `BOT`, `window`, and `document` are bound as locals (with `BOT` resolved best-effort from `window.BOT` or Angular DI; `null` if unavailable). Top-level `await` works. Code is capped at 8 KB and 30 s.
160
+
161
+ **To opt out** (e.g. you ship a non-debug release that still uses the SDK for telemetry only):
162
+
163
+ ```ts
164
+ await enableRemoteDebug({
165
+ // ...,
166
+ builtins: { exec: false },
167
+ });
168
+ ```
169
+
170
+ **To inject extra locals** for your app:
171
+
172
+ ```ts
173
+ await enableRemoteDebug({
174
+ // ...,
175
+ builtins: { exec: { globals: { app: myApp, store: myStore } } },
176
+ });
177
+ // agent can then call: { code: "return store.getState().user" }
178
+ ```
179
+
180
+ > Pre-buffer redaction (headers, JWT/long-token regex) is applied to `result.value` and `result.logs` before they leave the device buffer. The existing prod consent gate (`hostToken` or `userOptIn`) gates whether attach happens at all. See `docs/recipes/exec.md` for deeper notes.
181
+
182
+ ## 6. Custom commands (optional)
132
183
 
133
184
  Any AI agent with the right scope can request a command. The SDK only runs commands registered via `registerCommand`:
134
185
 
@@ -147,7 +198,7 @@ handle.registerCommand('set-locale', async (args) => {
147
198
 
148
199
  Anything not registered gets a `command-rejected` event with reason `unknown-command`.
149
200
 
150
- ## 6. Lifecycle
201
+ ## 7. Lifecycle
151
202
 
152
203
  ```ts
153
204
  await handle.flush(); // force-send buffered events
package/dist/index.cjs CHANGED
@@ -272,7 +272,7 @@ function resolveAgainstEndpoint(url, base) {
272
272
  function detectDeviceInfo(app, override) {
273
273
  const ua = typeof navigator !== "undefined" ? navigator.userAgent : void 0;
274
274
  return {
275
- deviceId: override?.deviceId ?? generateDeviceId(),
275
+ deviceId: override?.deviceId ?? loadOrCreateDeviceId(),
276
276
  platform: override?.platform ?? detectPlatform(ua),
277
277
  osVersion: override?.osVersion,
278
278
  appName: override?.appName ?? app.name,
@@ -288,6 +288,21 @@ function detectPlatform(ua) {
288
288
  if (/Mozilla|Chrome|Safari|Firefox/i.test(ua)) return "web";
289
289
  return "unknown";
290
290
  }
291
+ var DEVICE_ID_STORAGE_KEY = "botim-debug-sdk:device-id";
292
+ function loadOrCreateDeviceId() {
293
+ try {
294
+ const ls = typeof localStorage !== "undefined" ? localStorage : null;
295
+ if (ls) {
296
+ const stored = ls.getItem(DEVICE_ID_STORAGE_KEY);
297
+ if (stored && stored.length > 0) return stored;
298
+ const fresh = generateDeviceId();
299
+ ls.setItem(DEVICE_ID_STORAGE_KEY, fresh);
300
+ return fresh;
301
+ }
302
+ } catch {
303
+ }
304
+ return generateDeviceId();
305
+ }
291
306
  function generateDeviceId() {
292
307
  const c = typeof crypto !== "undefined" ? crypto : void 0;
293
308
  if (c?.randomUUID) return c.randomUUID();
@@ -562,6 +577,11 @@ function wrapFetch(opts) {
562
577
  method,
563
578
  url,
564
579
  status: res.status,
580
+ // statusText carries the human label ("OK", "Not Found"). Pre-HTTP/2
581
+ // responses always have it; HTTP/2+ defines it as empty by spec but
582
+ // most browsers synthesize one from the code, so this is reliable
583
+ // enough to display alongside the status code.
584
+ statusText: res.statusText || void 0,
565
585
  durationMs: Date.now() - start,
566
586
  resHeaders: headersFromResponse(res),
567
587
  resBody
@@ -575,7 +595,14 @@ function wrapFetch(opts) {
575
595
  url,
576
596
  durationMs: Date.now() - start,
577
597
  errorMessage: err instanceof Error ? err.message : String(err),
578
- errorName: err instanceof Error ? err.name : void 0
598
+ errorName: err instanceof Error ? err.name : void 0,
599
+ // Stack from the rejected promise — points into fetch internals
600
+ // and (when present) the call site that issued the request.
601
+ errorStack: err instanceof Error ? err.stack : void 0,
602
+ // undici frequently wraps the real reason in `cause` (e.g.
603
+ // `TypeError: fetch failed` outside, `Error: ECONNREFUSED` inside).
604
+ // Flatten the chain so operators don't have to dig.
605
+ errorCause: collectCauseChain(err)
579
606
  });
580
607
  throw err;
581
608
  }
@@ -584,6 +611,24 @@ function wrapFetch(opts) {
584
611
  target.fetch = original;
585
612
  };
586
613
  }
614
+ function collectCauseChain(err) {
615
+ if (!err || typeof err !== "object") return void 0;
616
+ const lines = [];
617
+ let cur = err.cause;
618
+ const seen = /* @__PURE__ */ new Set();
619
+ while (cur && lines.length < 5) {
620
+ if (seen.has(cur)) break;
621
+ seen.add(cur);
622
+ if (cur instanceof Error) {
623
+ lines.push(`${cur.name}: ${cur.message}`);
624
+ cur = cur.cause;
625
+ } else {
626
+ lines.push(String(cur));
627
+ cur = cur?.cause;
628
+ }
629
+ }
630
+ return lines.length ? lines.join("\n") : void 0;
631
+ }
587
632
  function wrapXHR(opts) {
588
633
  if (typeof XMLHttpRequest === "undefined") return () => {
589
634
  };
@@ -619,6 +664,7 @@ function wrapXHR(opts) {
619
664
  }
620
665
  s.start = Date.now();
621
666
  s.reqBody = typeof body === "string" ? body : void 0;
667
+ const sendSiteStack = captureCallSiteStack();
622
668
  opts.emit({
623
669
  phase: "request",
624
670
  reqId: s.reqId,
@@ -636,25 +682,32 @@ function wrapXHR(opts) {
636
682
  method: s.method,
637
683
  url: s.url,
638
684
  status: this.status,
685
+ // XHR exposes statusText directly; same display purpose as fetch.
686
+ statusText: this.statusText || void 0,
639
687
  durationMs: Date.now() - s.start,
640
688
  resHeaders: headers,
641
689
  resBody
642
690
  });
643
691
  };
644
- const onError = () => {
692
+ const onError = (kind) => () => {
645
693
  opts.emit({
646
694
  phase: "error",
647
695
  reqId: s.reqId,
648
696
  method: s.method,
649
697
  url: s.url,
650
698
  durationMs: Date.now() - s.start,
651
- errorMessage: this.statusText || "xhr error"
699
+ // Distinguish error/timeout/abort in the message — the standard
700
+ // XHR `statusText` is empty for `error` and unhelpful for the
701
+ // others, so we synthesise a clear label.
702
+ errorMessage: this.statusText || `xhr ${kind}`,
703
+ errorName: `XHR${kind[0].toUpperCase()}${kind.slice(1)}`,
704
+ errorStack: sendSiteStack
652
705
  });
653
706
  };
654
707
  this.addEventListener("load", onLoad);
655
- this.addEventListener("error", onError);
656
- this.addEventListener("timeout", onError);
657
- this.addEventListener("abort", onError);
708
+ this.addEventListener("error", onError("error"));
709
+ this.addEventListener("timeout", onError("timeout"));
710
+ this.addEventListener("abort", onError("abort"));
658
711
  return origSend.apply(this, [body]);
659
712
  };
660
713
  return () => {
@@ -663,6 +716,15 @@ function wrapXHR(opts) {
663
716
  proto.setRequestHeader = origSetReqHeader;
664
717
  };
665
718
  }
719
+ function captureCallSiteStack() {
720
+ try {
721
+ throw new Error("xhr-callsite");
722
+ } catch (err) {
723
+ if (!(err instanceof Error) || !err.stack) return void 0;
724
+ const lines = err.stack.split("\n");
725
+ return lines.slice(2).join("\n") || void 0;
726
+ }
727
+ }
666
728
  function parseXhrHeaders(raw) {
667
729
  const out = {};
668
730
  if (!raw) return out;
@@ -757,6 +819,10 @@ var CommandRegistry = class {
757
819
  };
758
820
  var MAX_DUMP_BYTES = 64 * 1024;
759
821
  var MAX_SCREENSHOT_BYTES = 1024 * 1024;
822
+ var MAX_EXEC_CODE_BYTES = 8 * 1024;
823
+ var DEFAULT_EXEC_TIMEOUT_MS = 5e3;
824
+ var MIN_EXEC_TIMEOUT_MS = 250;
825
+ var MAX_EXEC_TIMEOUT_MS = 3e4;
760
826
  async function defaultDomScreenshot() {
761
827
  if (typeof document === "undefined" || typeof window === "undefined") {
762
828
  throw new Error(
@@ -871,6 +937,9 @@ function registerBuiltins(registry, hooks = {}) {
871
937
  registry.register("dump-state", makeDumpState(hooks.getState));
872
938
  registry.register("set-feature-flag", makeSetFlag(hooks.setFeatureFlag));
873
939
  registry.register("screenshot", makeScreenshot(hooks.screenshot));
940
+ if (hooks.exec !== false) {
941
+ registry.register("exec", makeExec(hooks.exec ?? {}));
942
+ }
874
943
  }
875
944
  var ping = () => ({ ok: true, ts: Date.now() });
876
945
  function makeReload(reload) {
@@ -933,6 +1002,168 @@ function makeScreenshot(screenshot) {
933
1002
  return { format, data, bytes: data.length };
934
1003
  };
935
1004
  }
1005
+ var cachedBOT = void 0;
1006
+ function getBOT() {
1007
+ if (cachedBOT !== void 0) return cachedBOT;
1008
+ if (typeof window === "undefined") {
1009
+ cachedBOT = null;
1010
+ return null;
1011
+ }
1012
+ try {
1013
+ const w = window;
1014
+ if (w.BOT) {
1015
+ cachedBOT = w.BOT;
1016
+ return cachedBOT;
1017
+ }
1018
+ } catch {
1019
+ }
1020
+ try {
1021
+ const w = window;
1022
+ const ng = w.angular;
1023
+ if (ng?.element && typeof document !== "undefined") {
1024
+ const injector = ng.element(document).injector?.();
1025
+ const bot = injector?.get?.("BOT");
1026
+ if (bot != null) {
1027
+ cachedBOT = bot;
1028
+ return cachedBOT;
1029
+ }
1030
+ }
1031
+ } catch {
1032
+ }
1033
+ return null;
1034
+ }
1035
+ function makeExec(opts) {
1036
+ const extraGlobalNames = Object.keys(opts.globals ?? {});
1037
+ const extraGlobalValues = extraGlobalNames.map((k) => opts.globals[k]);
1038
+ return async (args, ctx) => {
1039
+ if (typeof args.code !== "string" || args.code.length === 0) {
1040
+ throw new Error("args.code (non-empty string) is required");
1041
+ }
1042
+ if (args.code.length > MAX_EXEC_CODE_BYTES) {
1043
+ throw new Error(`args.code ${args.code.length} bytes exceeds limit ${MAX_EXEC_CODE_BYTES}`);
1044
+ }
1045
+ const timeoutMs = clamp(
1046
+ typeof args.timeoutMs === "number" ? args.timeoutMs : DEFAULT_EXEC_TIMEOUT_MS,
1047
+ MIN_EXEC_TIMEOUT_MS,
1048
+ MAX_EXEC_TIMEOUT_MS
1049
+ );
1050
+ const start = Date.now();
1051
+ const logs = [];
1052
+ const consoleMethods = ["log", "info", "warn", "error", "debug"];
1053
+ const originals = {};
1054
+ if (typeof console !== "undefined") {
1055
+ for (const m of consoleMethods) {
1056
+ const orig = console[m];
1057
+ if (typeof orig !== "function") continue;
1058
+ originals[m] = orig;
1059
+ const captured = (...callArgs) => {
1060
+ logs.push({ method: m, args: callArgs.map(execSafeClone), ts: Date.now() });
1061
+ try {
1062
+ orig.call(console, ...callArgs);
1063
+ } catch {
1064
+ }
1065
+ };
1066
+ console[m] = captured;
1067
+ }
1068
+ }
1069
+ let value;
1070
+ let threw = void 0;
1071
+ try {
1072
+ const AsyncFunction = Object.getPrototypeOf(async function() {
1073
+ }).constructor;
1074
+ const localNames = ["BOT", "window", "document", ...extraGlobalNames];
1075
+ const localValues = [
1076
+ getBOT(),
1077
+ typeof window !== "undefined" ? window : void 0,
1078
+ typeof document !== "undefined" ? document : void 0,
1079
+ ...extraGlobalValues
1080
+ ];
1081
+ let fnTry = null;
1082
+ try {
1083
+ fnTry = new AsyncFunction(...localNames, `return (${args.code});`);
1084
+ } catch (e) {
1085
+ if (!(e instanceof SyntaxError)) throw e;
1086
+ }
1087
+ const fn = fnTry ?? new AsyncFunction(...localNames, args.code);
1088
+ let timer = null;
1089
+ let abortReject = null;
1090
+ const onAbort = () => abortReject?.(new Error("exec cancelled"));
1091
+ ctx.signal?.addEventListener?.("abort", onAbort);
1092
+ try {
1093
+ value = await Promise.race([
1094
+ fn(...localValues),
1095
+ new Promise((_, reject) => {
1096
+ timer = setTimeout(
1097
+ () => reject(new Error(`exec exceeded ${timeoutMs}ms`)),
1098
+ timeoutMs
1099
+ );
1100
+ }),
1101
+ new Promise((_, reject) => {
1102
+ abortReject = reject;
1103
+ })
1104
+ ]);
1105
+ } finally {
1106
+ if (timer) clearTimeout(timer);
1107
+ ctx.signal?.removeEventListener?.("abort", onAbort);
1108
+ }
1109
+ } catch (err) {
1110
+ threw = err;
1111
+ } finally {
1112
+ if (typeof console !== "undefined") {
1113
+ for (const m of consoleMethods) {
1114
+ if (originals[m]) {
1115
+ console[m] = originals[m];
1116
+ }
1117
+ }
1118
+ }
1119
+ }
1120
+ if (threw !== void 0) {
1121
+ const e = threw instanceof Error ? threw : new Error(String(threw));
1122
+ const stackTruncated = typeof e.stack === "string" ? e.stack.split("\n").slice(0, 20).join("\n") : void 0;
1123
+ const detail = {
1124
+ name: e.name,
1125
+ message: e.message,
1126
+ stack: stackTruncated,
1127
+ logs,
1128
+ durationMs: Date.now() - start
1129
+ };
1130
+ const wrapped = new Error(JSON.stringify(detail));
1131
+ wrapped.detail = detail;
1132
+ throw wrapped;
1133
+ }
1134
+ return {
1135
+ ok: true,
1136
+ value: execSafeClone(value),
1137
+ logs,
1138
+ durationMs: Date.now() - start
1139
+ };
1140
+ };
1141
+ }
1142
+ function clamp(n, lo, hi) {
1143
+ if (!Number.isFinite(n)) return lo;
1144
+ return Math.min(Math.max(n, lo), hi);
1145
+ }
1146
+ function execSafeClone(v) {
1147
+ try {
1148
+ return JSON.parse(JSON.stringify(v, (_k, val) => {
1149
+ if (typeof val === "function") {
1150
+ return `[Function: ${val.name || "anonymous"}]`;
1151
+ }
1152
+ if (val instanceof Error) {
1153
+ return { name: val.name, message: val.message, stack: val.stack };
1154
+ }
1155
+ if (typeof val === "bigint") return val.toString() + "n";
1156
+ if (typeof val === "undefined") return null;
1157
+ if (val && typeof val === "object" && val.nodeType === 1) {
1158
+ const el = val;
1159
+ return `[${el.tagName ?? "Element"}${el.id ? "#" + el.id : ""}]`;
1160
+ }
1161
+ return val;
1162
+ }));
1163
+ } catch {
1164
+ return { __unserializable: typeof v };
1165
+ }
1166
+ }
936
1167
  function safeStringify(v) {
937
1168
  try {
938
1169
  const seen = /* @__PURE__ */ new WeakSet();
@@ -1380,5 +1611,6 @@ exports.DEFAULT_MAX_BODY_BYTES = DEFAULT_MAX_BODY_BYTES;
1380
1611
  exports.DEFAULT_REDACT_HEADERS = DEFAULT_REDACT_HEADERS;
1381
1612
  exports.SCHEMA_VERSION = SCHEMA_VERSION;
1382
1613
  exports.enableRemoteDebug = enableRemoteDebug;
1614
+ exports.getBOT = getBOT;
1383
1615
  //# sourceMappingURL=index.cjs.map
1384
1616
  //# sourceMappingURL=index.cjs.map