@beeos-ai/device-mcp-server 0.3.0 → 0.4.3

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.
Files changed (58) hide show
  1. package/dist/backends/android-adb.d.ts +149 -6
  2. package/dist/backends/android-adb.js +854 -42
  3. package/dist/backends/android-adb.js.map +1 -1
  4. package/dist/backends/base.d.ts +243 -7
  5. package/dist/backends/base.js +81 -2
  6. package/dist/backends/base.js.map +1 -1
  7. package/dist/backends/desktop.d.ts +3 -2
  8. package/dist/backends/desktop.js +9 -3
  9. package/dist/backends/desktop.js.map +1 -1
  10. package/dist/backends/linux.js +3 -0
  11. package/dist/backends/linux.js.map +1 -1
  12. package/dist/backends/mac.d.ts +11 -2
  13. package/dist/backends/mac.js +39 -1
  14. package/dist/backends/mac.js.map +1 -1
  15. package/dist/backends/stubs/windows.js +3 -0
  16. package/dist/backends/stubs/windows.js.map +1 -1
  17. package/dist/cli.d.ts +40 -26
  18. package/dist/cli.js +118 -84
  19. package/dist/cli.js.map +1 -1
  20. package/dist/index.d.ts +9 -6
  21. package/dist/index.js +9 -6
  22. package/dist/index.js.map +1 -1
  23. package/dist/server/app.d.ts +60 -17
  24. package/dist/server/app.js +182 -138
  25. package/dist/server/app.js.map +1 -1
  26. package/dist/server/mcp-server.d.ts +25 -0
  27. package/dist/server/mcp-server.js +33 -0
  28. package/dist/server/mcp-server.js.map +1 -0
  29. package/dist/server/registry.d.ts +111 -0
  30. package/dist/server/registry.js +191 -0
  31. package/dist/server/registry.js.map +1 -0
  32. package/dist/server/stdio.d.ts +29 -0
  33. package/dist/server/stdio.js +35 -0
  34. package/dist/server/stdio.js.map +1 -0
  35. package/dist/server/tool-registry.d.ts +60 -35
  36. package/dist/server/tool-registry.js +911 -434
  37. package/dist/server/tool-registry.js.map +1 -1
  38. package/dist/util/adb-files.d.ts +25 -1
  39. package/dist/util/adb-files.js +95 -0
  40. package/dist/util/adb-files.js.map +1 -1
  41. package/dist/util/android-apps.js +209 -9
  42. package/dist/util/android-apps.js.map +1 -1
  43. package/dist/util/locale.d.ts +16 -0
  44. package/dist/util/locale.js +31 -0
  45. package/dist/util/locale.js.map +1 -0
  46. package/dist/util/logger.d.ts +27 -0
  47. package/dist/util/logger.js +27 -0
  48. package/dist/util/logger.js.map +1 -0
  49. package/dist/util/output-path.d.ts +60 -0
  50. package/dist/util/output-path.js +123 -0
  51. package/dist/util/output-path.js.map +1 -0
  52. package/dist/util/package-name.d.ts +26 -0
  53. package/dist/util/package-name.js +41 -0
  54. package/dist/util/package-name.js.map +1 -0
  55. package/package.json +5 -3
  56. package/dist/server/action-mapping.d.ts +0 -21
  57. package/dist/server/action-mapping.js +0 -153
  58. package/dist/server/action-mapping.js.map +0 -1
@@ -50,9 +50,14 @@
50
50
  * agent layer decides whether to resize.
51
51
  */
52
52
  import { spawn } from "node:child_process";
53
+ import { mkdtemp } from "node:fs/promises";
54
+ import * as os from "node:os";
55
+ import * as path from "node:path";
56
+ import { randomUUID } from "node:crypto";
53
57
  import sharp from "sharp";
54
58
  import { COMMON_TOOLS, DeviceError, MOBILE_TOOLS, } from "@beeos-ai/device-common";
55
- import { BaseSandboxBackend, } from "./base.js";
59
+ import { BaseSandboxBackend, normalizeLaunchAppOptions, } from "./base.js";
60
+ import { searchUiXmlNodes } from "../util/ui-xml.js";
56
61
  const ADB_TOOLS = new Set([
57
62
  ...COMMON_TOOLS,
58
63
  ...MOBILE_TOOLS,
@@ -63,14 +68,26 @@ import { adbListDir, adbPull, adbPush } from "../util/adb-files.js";
63
68
  import { parsePngDimensions } from "../util/image-dim.js";
64
69
  const SS_MAX_EDGE_RAW = process.env.GUI_AGENT_SS_MAX_EDGE;
65
70
  /**
66
- * Resize-on-capture threshold for screenshots. v0.2.3 changes the default
67
- * from `1280` to "no resize" (`POSITIVE_INFINITY`) so the bytes the agent
68
- * receives match the device's logical dimensions exactly. Set the env to
69
- * an integer (e.g. `1280`) to opt back into resizing.
71
+ * Resize-on-capture threshold for screenshots.
72
+ *
73
+ * 0.4.0 (paired with the SDK migration that removes the binary
74
+ * `/screen.png` fast-path): default back to **1280px max edge** so the
75
+ * base64 payload over the SDK Streamable HTTP transport stays bounded.
76
+ * Capturing a raw 4K Android screenshot every step balloons the JSON-RPC
77
+ * envelope to ~4MB which dominates round-trip time on slow links.
78
+ *
79
+ * Override via env:
80
+ * - `GUI_AGENT_SS_MAX_EDGE=4096` to keep full resolution
81
+ * - `GUI_AGENT_SS_MAX_EDGE=0` (or negative) to disable resizing entirely
70
82
  */
71
- const SS_MAX_EDGE = SS_MAX_EDGE_RAW
72
- ? parseInt(SS_MAX_EDGE_RAW, 10)
73
- : Number.POSITIVE_INFINITY;
83
+ const SS_MAX_EDGE = (() => {
84
+ if (SS_MAX_EDGE_RAW === undefined)
85
+ return 1280;
86
+ const n = parseInt(SS_MAX_EDGE_RAW, 10);
87
+ if (!Number.isFinite(n) || n <= 0)
88
+ return Number.POSITIVE_INFINITY;
89
+ return n;
90
+ })();
74
91
  const SS_JPEG_QUALITY = parseInt(process.env.GUI_AGENT_SS_JPEG_QUALITY ?? "80", 10);
75
92
  const DEFAULT_TIMEOUT_MS = 10_000;
76
93
  const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
@@ -93,6 +110,83 @@ const DELAYS = {
93
110
  keyboardRestore: 300,
94
111
  };
95
112
  /* ----------------------------------------------------------------------- */
113
+ /* Known browser packages — browser-specific launch fallback */
114
+ /* ----------------------------------------------------------------------- */
115
+ const BROWSER_PACKAGES = new Set([
116
+ "com.android.chrome",
117
+ "com.android.browser",
118
+ "org.mozilla.firefox",
119
+ "org.mozilla.firefox_beta",
120
+ "org.mozilla.fenix",
121
+ "com.microsoft.emmx",
122
+ "com.opera.browser",
123
+ "com.opera.mini.native",
124
+ "com.UCMobile.intl",
125
+ "com.UCMobile",
126
+ "com.quark.browser",
127
+ "com.baidu.searchbox",
128
+ "com.brave.browser",
129
+ "com.duckduckgo.mobile.android",
130
+ "com.vivaldi.browser",
131
+ "com.sec.android.app.sbrowser",
132
+ "com.huawei.browser",
133
+ "com.mi.globalbrowser",
134
+ "com.heytap.browser",
135
+ "com.vivo.browser",
136
+ ]);
137
+ /* ----------------------------------------------------------------------- */
138
+ /* uiautomator dump retry — 0.4.1 */
139
+ /* ----------------------------------------------------------------------- */
140
+ /**
141
+ * `uiautomator dump` racing the foreground accessibility window can return
142
+ * `ERROR: null root node returned by UiTestAutomationBridge` (real-device
143
+ * regression on devices with frequent fullscreen transitions). Mobile-mcp
144
+ * documents the same flake. Retry the dump up to this many times — the
145
+ * window-state usually settles within 2-3 attempts.
146
+ */
147
+ const UIDUMP_MAX_RETRIES = 10;
148
+ const UIDUMP_RETRY_INTERVAL_MS = 200;
149
+ const UIDUMP_RETRY_SUBSTRINGS = [
150
+ "null root node returned by UiTestAutomationBridge",
151
+ // toybox `cat` reports this when uiautomator failed to write the file.
152
+ "No such file or directory",
153
+ ];
154
+ /* ----------------------------------------------------------------------- */
155
+ /* press_button keymap — 0.4.1 */
156
+ /* ----------------------------------------------------------------------- */
157
+ /**
158
+ * Friendly button name → Android `KEYCODE_*`. Names are case-insensitive.
159
+ *
160
+ * The vocabulary mirrors mobile-mcp `mobile_press_button` so prompts that
161
+ * already work against mobile-mcp port over without rewording. The
162
+ * registry exposes `Object.keys(PRESS_BUTTON_KEYMAP)` so the MCP tool's
163
+ * `enum` is auto-derived rather than maintained in two places.
164
+ */
165
+ export const PRESS_BUTTON_KEYMAP = {
166
+ BACK: "KEYCODE_BACK",
167
+ HOME: "KEYCODE_HOME",
168
+ POWER: "KEYCODE_POWER",
169
+ MENU: "KEYCODE_MENU",
170
+ CAMERA: "KEYCODE_CAMERA",
171
+ ENTER: "KEYCODE_ENTER",
172
+ // Volume + media — Android TV agents rely on these.
173
+ VOLUME_UP: "KEYCODE_VOLUME_UP",
174
+ VOLUME_DOWN: "KEYCODE_VOLUME_DOWN",
175
+ VOLUME_MUTE: "KEYCODE_VOLUME_MUTE",
176
+ MUTE: "KEYCODE_MUTE",
177
+ MEDIA_PLAY_PAUSE: "KEYCODE_MEDIA_PLAY_PAUSE",
178
+ MEDIA_PLAY: "KEYCODE_MEDIA_PLAY",
179
+ MEDIA_PAUSE: "KEYCODE_MEDIA_PAUSE",
180
+ MEDIA_NEXT: "KEYCODE_MEDIA_NEXT",
181
+ MEDIA_PREVIOUS: "KEYCODE_MEDIA_PREVIOUS",
182
+ // DPAD — Android TV / car-head-unit / accessibility navigation.
183
+ DPAD_UP: "KEYCODE_DPAD_UP",
184
+ DPAD_DOWN: "KEYCODE_DPAD_DOWN",
185
+ DPAD_LEFT: "KEYCODE_DPAD_LEFT",
186
+ DPAD_RIGHT: "KEYCODE_DPAD_RIGHT",
187
+ DPAD_CENTER: "KEYCODE_DPAD_CENTER",
188
+ };
189
+ /* ----------------------------------------------------------------------- */
96
190
  /* Default spawn-backed runner */
97
191
  /* ----------------------------------------------------------------------- */
98
192
  function defaultRunner(file, args, opts = {}) {
@@ -188,6 +282,14 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
188
282
  screenshotTimeoutMs;
189
283
  installTimeoutMs;
190
284
  fileTimeoutMs;
285
+ processSpawner;
286
+ recordKillGraceMs;
287
+ recordTimeLimitS;
288
+ recordings = new Map();
289
+ /** Cached `android.software.leanback` system feature — drives `info().isTv`. */
290
+ isLeanback = false;
291
+ /** True after `probeLeanback()` has resolved (success OR failure). */
292
+ leanbackProbed = false;
191
293
  constructor(opts = {}) {
192
294
  super();
193
295
  this.serial = opts.serial;
@@ -201,6 +303,13 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
201
303
  opts.screenshotTimeoutMs ?? DEFAULT_SCREENSHOT_TIMEOUT_MS;
202
304
  this.installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
203
305
  this.fileTimeoutMs = opts.fileTimeoutMs ?? DEFAULT_FILE_TIMEOUT_MS;
306
+ // `spawn` itself is overloaded; wrap it so the resolved overload
307
+ // matches `AdbProcessSpawner`'s `(file, args, opts)` shape.
308
+ this.processSpawner =
309
+ opts.processSpawner ??
310
+ ((file, args, spawnOpts) => spawn(file, args, spawnOpts ?? {}));
311
+ this.recordKillGraceMs = opts.recordKillGraceMs ?? 5_000;
312
+ this.recordTimeLimitS = opts.recordTimeLimitS ?? 300;
204
313
  }
205
314
  async connect() {
206
315
  if (this.skipConnectValidation)
@@ -213,6 +322,31 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
213
322
  });
214
323
  }
215
324
  }
325
+ /**
326
+ * Detect Android TV / leanback so `info().isTv` can hint at LLM
327
+ * prompts that should default to DPAD navigation. Cached for the
328
+ * lifetime of the backend — running `pm has-feature` once per device
329
+ * is enough; the answer doesn't change at runtime. Lazy on purpose —
330
+ * `connect()` with `skipConnectValidation: true` (the registry path)
331
+ * MUST NOT issue any adb commands, so we defer this probe to the
332
+ * first `info()` call.
333
+ */
334
+ async ensureLeanbackProbed() {
335
+ if (this.leanbackProbed)
336
+ return;
337
+ this.leanbackProbed = true;
338
+ try {
339
+ const { stdout } = await this.adbShell(sh `pm has-feature android.software.leanback`);
340
+ const text = stdout.trim().toLowerCase();
341
+ // Newer Android prints `true` / `false`; older builds emit the
342
+ // raw feature line `feature:android.software.leanback`. Accept
343
+ // either positive shape.
344
+ this.isLeanback = text === "true" || /leanback/.test(text);
345
+ }
346
+ catch {
347
+ this.isLeanback = false;
348
+ }
349
+ }
216
350
  async disconnect() {
217
351
  // No-op — adb daemon stays alive between commands.
218
352
  }
@@ -220,11 +354,15 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
220
354
  /* Info / screen */
221
355
  /* ------------------------------------------------------------------ */
222
356
  async info() {
357
+ // Lazy leanback probe — see comment on `ensureLeanbackProbed`. Run
358
+ // it in parallel with the other info shell-outs so the call adds no
359
+ // latency to the first `info()` invocation.
223
360
  const [size, density, propDump, currentApp] = await Promise.all([
224
361
  this.runShell("wm size").catch(() => ""),
225
362
  this.runShell("wm density").catch(() => ""),
226
363
  this.runShell("getprop").catch(() => ""),
227
364
  this.getCurrentApp().catch(() => "System Home"),
365
+ this.ensureLeanbackProbed(),
228
366
  ]);
229
367
  const sizeMatch = size.match(/Physical size:\s*(\d+)x(\d+)/i);
230
368
  const width = sizeMatch ? Number(sizeMatch[1]) : 1080;
@@ -266,37 +404,82 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
266
404
  abi,
267
405
  manufacturer,
268
406
  brand,
407
+ // 0.4.1: leanback feature is the canonical AOSP marker for TV /
408
+ // car-head-unit form factors. Surface as a string in `metadata`
409
+ // (DeviceInfo.metadata is `Record<string, string>` per
410
+ // common/device-info.ts) so LLM prompts can default to DPAD
411
+ // navigation without parsing extra fields.
412
+ isTv: this.isLeanback ? "true" : "false",
269
413
  },
270
414
  };
271
415
  }
272
- async screenshot() {
416
+ async screenshot(opts = {}) {
273
417
  // Capture the raw screencap PNG. The 0.2.3 default is to forward
274
418
  // these bytes to the agent unchanged so `width/height` always match
275
419
  // `data`. Callers that need a smaller payload set
276
420
  // `GUI_AGENT_SS_MAX_EDGE` to opt back into the legacy resize path.
277
- const raw = await this.captureRawPng();
421
+ //
422
+ // 0.4.1: per-call overrides. The MCP tool surfaces `maxEdge`,
423
+ // `quality`, and `format` so an LLM running on a 4K device can
424
+ // shrink a single screenshot without restarting the server.
425
+ // - `opts.maxEdge` overrides the env default. `0` means "no
426
+ // resize", matching the env semantics.
427
+ // - `opts.quality` overrides JPEG quality; clamped 1-100 by zod.
428
+ // - `opts.format = "jpeg"` forces re-encode even when the image
429
+ // is already under the resize threshold (for deterministic
430
+ // payload sizing). `"png"` forces raw passthrough.
431
+ const raw = await this.captureRawPng(opts.displayId);
278
432
  const [rawW, rawH] = parsePngDimensions(raw);
279
- if (!Number.isFinite(SS_MAX_EDGE) || Math.max(rawW, rawH) <= SS_MAX_EDGE) {
433
+ const effectiveMaxEdge = (() => {
434
+ if (opts.maxEdge !== undefined) {
435
+ return opts.maxEdge <= 0 ? Number.POSITIVE_INFINITY : opts.maxEdge;
436
+ }
437
+ return SS_MAX_EDGE;
438
+ })();
439
+ const effectiveQuality = (() => {
440
+ const q = opts.quality;
441
+ if (q !== undefined)
442
+ return Math.max(1, Math.min(100, Math.round(q)));
443
+ return SS_JPEG_QUALITY;
444
+ })();
445
+ const formatHint = opts.format;
446
+ const exceedsCap = Number.isFinite(effectiveMaxEdge) &&
447
+ Math.max(rawW, rawH) > effectiveMaxEdge;
448
+ const wantJpeg = formatHint === "jpeg";
449
+ const wantPngPassthrough = formatHint === "png" && !exceedsCap;
450
+ // Fast path: caller asked for raw PNG (or default behaviour with
451
+ // a small enough capture).
452
+ if (!exceedsCap && !wantJpeg) {
280
453
  return { data: raw, width: rawW, height: rawH, format: "png" };
281
454
  }
282
- // Resize-on-capture path: re-encode to JPEG and recompute dimensions
455
+ // Caller explicitly asked for `format: "png"` but the capture is
456
+ // larger than the cap → we still resize, just keep the PNG codec.
457
+ if (wantPngPassthrough) {
458
+ return { data: raw, width: rawW, height: rawH, format: "png" };
459
+ }
460
+ // Resize-on-capture path: re-encode and recompute dimensions
283
461
  // so the wire layer reports the actual encoded payload, not the
284
462
  // original device-side resolution.
285
463
  try {
286
464
  const pipeline = sharp(raw, { failOn: "none" });
287
- if (rawW >= rawH)
288
- pipeline.resize({ width: SS_MAX_EDGE });
289
- else
290
- pipeline.resize({ height: SS_MAX_EDGE });
291
- const data = await pipeline
292
- .jpeg({ quality: SS_JPEG_QUALITY, mozjpeg: true })
293
- .toBuffer();
294
- const meta = await sharp(data).metadata();
465
+ if (Number.isFinite(effectiveMaxEdge)) {
466
+ if (rawW >= rawH)
467
+ pipeline.resize({ width: effectiveMaxEdge });
468
+ else
469
+ pipeline.resize({ height: effectiveMaxEdge });
470
+ }
471
+ const usePng = formatHint === "png";
472
+ const encoded = usePng
473
+ ? await pipeline.png().toBuffer()
474
+ : await pipeline
475
+ .jpeg({ quality: effectiveQuality, mozjpeg: true })
476
+ .toBuffer();
477
+ const meta = await sharp(encoded).metadata();
295
478
  return {
296
- data,
479
+ data: encoded,
297
480
  width: meta.width ?? rawW,
298
481
  height: meta.height ?? rawH,
299
- format: "jpeg",
482
+ format: usePng ? "png" : "jpeg",
300
483
  };
301
484
  }
302
485
  catch {
@@ -309,12 +492,50 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
309
492
  return { w: m ? Number(m[1]) : 1080, h: m ? Number(m[2]) : 1920 };
310
493
  }
311
494
  async uiDump() {
312
- await this.adbShell(sh `uiautomator dump /sdcard/ui_dump.xml`);
313
- const { stdout } = await this.adbShell(sh `cat /sdcard/ui_dump.xml`);
314
- if (!stdout || stdout.includes("ERROR")) {
315
- return { supported: true };
495
+ // Mobile-mcp parity: real devices occasionally surface
496
+ // `null root node returned by UiTestAutomationBridge` while a
497
+ // fullscreen transition is in flight. Retry up to 10× with a
498
+ // 200 ms cool-off — the window state always settles within a
499
+ // few attempts in practice.
500
+ let lastErr;
501
+ for (let attempt = 0; attempt < UIDUMP_MAX_RETRIES; attempt++) {
502
+ try {
503
+ const { stdout: dumpStdout, stderr: dumpStderr } = await this.adbShell(sh `uiautomator dump /sdcard/ui_dump.xml`);
504
+ // `uiautomator dump` reports flake on stdout AND stderr depending
505
+ // on Android version. Check both before assuming success.
506
+ const dumpBlob = `${dumpStdout}\n${dumpStderr}`;
507
+ if (UIDUMP_RETRY_SUBSTRINGS.some((s) => dumpBlob.includes(s))) {
508
+ await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
509
+ continue;
510
+ }
511
+ const { stdout } = await this.adbShell(sh `cat /sdcard/ui_dump.xml`);
512
+ if (!stdout || stdout.includes("ERROR")) {
513
+ // Empty / error output — flake; retry.
514
+ await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
515
+ continue;
516
+ }
517
+ if (UIDUMP_RETRY_SUBSTRINGS.some((s) => stdout.includes(s))) {
518
+ await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
519
+ continue;
520
+ }
521
+ return { supported: true, content: stdout.trim() };
522
+ }
523
+ catch (e) {
524
+ lastErr = e;
525
+ const msg = e instanceof Error ? e.message : String(e);
526
+ if (UIDUMP_RETRY_SUBSTRINGS.some((s) => msg.includes(s))) {
527
+ await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
528
+ continue;
529
+ }
530
+ // Non-flake error — surface immediately so callers see the
531
+ // real cause (timeout, adb gone, permission, …).
532
+ throw e;
533
+ }
534
+ }
535
+ if (lastErr) {
536
+ throw lastErr;
316
537
  }
317
- return { supported: true, content: stdout.trim() };
538
+ return { supported: true };
318
539
  }
319
540
  /* ------------------------------------------------------------------ */
320
541
  /* Pointer / gesture */
@@ -345,15 +566,18 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
345
566
  async drag(x1, y1, x2, y2, durationMs) {
346
567
  return this.swipe(x1, y1, x2, y2, durationMs);
347
568
  }
348
- async scroll(x, y, direction, _amount) {
569
+ async scroll(x, y, direction, amount) {
349
570
  let { w, h } = { w: 1080, h: 1920 };
350
571
  if (!x && !y) {
351
572
  ({ w, h } = await this.screenSize().catch(() => ({ w: 1080, h: 1920 })));
352
573
  x = Math.round(w / 2);
353
574
  y = Math.round(h / 2);
354
575
  }
355
- const horizontal = direction === "left" || direction === "right";
356
- const dist = horizontal ? 300 : 600;
576
+ // 0.4.1: `amount` is now a real multiplier (mobile-mcp parity). Each
577
+ // unit ≈ 300 px of swipe distance regardless of direction; defaults
578
+ // to 1 when caller omits it. Previous behaviour silently ignored
579
+ // `amount` and shipped a fixed 300 px / 600 px asymmetric distance.
580
+ const dist = Math.max(50, Math.round(300 * (amount ?? 1)));
357
581
  const dx = direction === "left" ? dist : direction === "right" ? -dist : 0;
358
582
  const dy = direction === "up" ? dist : direction === "down" ? -dist : 0;
359
583
  await this.swipe(x, y, x + dx, y + dy);
@@ -422,7 +646,8 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
422
646
  /* ------------------------------------------------------------------ */
423
647
  /* App lifecycle */
424
648
  /* ------------------------------------------------------------------ */
425
- async launchApp(pkgOrName, activity) {
649
+ async launchApp(pkgOrName, opts) {
650
+ const { activity, locale } = normalizeLaunchAppOptions(opts);
426
651
  const pkg = looksLikePackage(pkgOrName)
427
652
  ? pkgOrName
428
653
  : (getAppPackage(pkgOrName) ?? pkgOrName);
@@ -437,15 +662,162 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
437
662
  const component = activity.includes("/") ? activity : `${pkg}/${activity}`;
438
663
  await this.adbShell(sh `am start -n ${component}`);
439
664
  }
665
+ else if (BROWSER_PACKAGES.has(pkg)) {
666
+ // Browser apps: 3-level fallback so the launch survives devices where
667
+ // `monkey` flakes (Redroid) or the requested browser isn't installed.
668
+ // 1. VIEW intent → OS picks the default browser (cheapest, most reliable)
669
+ // 2. monkey on the requested pkg (original behaviour)
670
+ // 3. monkey on Chrome as last-resort fallback
671
+ await this.launchBrowserApp(pkg);
672
+ }
440
673
  else {
441
674
  // No activity hint → fall back to the launcher intent. `monkey` is
442
675
  // intentionally used here (not `am start`) because some Redroid
443
676
  // images have flaky `am` behaviour for LAUNCHER intents and `monkey`
444
677
  // is the well-trodden path the legacy adapter has shipped for years.
445
- await this.adbShell(sh `monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`);
678
+ const { stdout } = await this.adbShell(sh `monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`);
679
+ // `monkey` exits 0 even when it cannot find a launchable activity —
680
+ // it just prints "** No activities found to run, monkey aborted" to
681
+ // stdout. Without this check the launch silently "succeeds" and the
682
+ // agent waits on a screen that never changed (the TikTok no-launch
683
+ // incident). Surface it as a hard failure with the real reason so the
684
+ // caller learns the package has no LAUNCHER entry / isn't installed.
685
+ if (/Error|No activities found/i.test(stdout)) {
686
+ throw new DeviceError(`launch_app: '${pkg}' has no launchable activity ` +
687
+ `(monkey: ${stdout.trim() || "no output"})`, { subtype: "launch_failed", retriable: false });
688
+ }
689
+ }
690
+ // 0.4.1: per-app locale (mobile-mcp parity). Best-effort — Android 12
691
+ // and below do not have `cmd locale`, so a non-zero exit just means
692
+ // "the OS predates per-app locales". We swallow that silently and
693
+ // log so operators can spot the platform mismatch.
694
+ if (locale && locale.length > 0) {
695
+ try {
696
+ await this.adbShell(sh `cmd locale set-app-locales ${pkg} --locales ${locale}`);
697
+ }
698
+ catch (e) {
699
+ // best-effort — do NOT fail the launch; Android 12- silently
700
+ // returns a non-zero exit on `cmd locale set-app-locales`.
701
+ // Surface as a debug-level warning so noisy CI doesn't drown
702
+ // every multi-region test run in stderr.
703
+ const msg = e instanceof Error ? e.message : String(e);
704
+ // Use the runner-level log at warn so callers can opt-in via
705
+ // LOG_LEVEL=warn — this never blocks the launch.
706
+ // (No structured logger handle here; rely on stderr.)
707
+ process.stderr.write(`[android] cmd locale set-app-locales failed for ${pkg} (${locale}): ${msg}\n`);
708
+ }
446
709
  }
447
710
  await this.sleep(DELAYS.launch);
448
711
  }
712
+ async launchBrowserApp(pkg) {
713
+ const launched = (await this.tryBrowserLaunch(`am start -a android.intent.action.VIEW -d "https://example.com"`)) ||
714
+ (await this.tryBrowserLaunch(sh `monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`)) ||
715
+ (await this.tryBrowserLaunch(`monkey -p com.android.chrome -c android.intent.category.LAUNCHER 1`));
716
+ if (!launched) {
717
+ throw new DeviceError(`launch_app: failed to open browser '${pkg}' — all fallbacks exhausted`, { subtype: "launch_failed", retriable: true });
718
+ }
719
+ }
720
+ async tryBrowserLaunch(command) {
721
+ try {
722
+ const { stdout } = await this.adbShell(command);
723
+ return !/Error|No activities found/i.test(stdout);
724
+ }
725
+ catch {
726
+ return false;
727
+ }
728
+ }
729
+ /* ------------------------------------------------------------------ */
730
+ /* 0.4.0: cross-OS open_url + mobile-mcp parity */
731
+ /* ------------------------------------------------------------------ */
732
+ async openUrl(url) {
733
+ assertSafeUrl(url);
734
+ await this.adbShell(sh `am start -a android.intent.action.VIEW -d ${url}`);
735
+ }
736
+ async listApps(opts = {}) {
737
+ // 0.4.1 (mobile-mcp parity): `launchableOnly: true` filters to apps
738
+ // that declare a `MAIN`/`LAUNCHER` activity — drops 200+ headless
739
+ // system services that pollute `pm list packages`. Implemented via
740
+ // `cmd package query-activities`, which prints one block per
741
+ // matching activity with a `packageName=...` line we dedupe on.
742
+ if (opts.launchableOnly) {
743
+ const { stdout } = await this.adbShell(sh `cmd package query-activities -a android.intent.action.MAIN -c android.intent.category.LAUNCHER`);
744
+ const pkgs = new Map();
745
+ for (const rawLine of stdout.split("\n")) {
746
+ const line = rawLine.trim();
747
+ if (!line.startsWith("packageName="))
748
+ continue;
749
+ const pkg = line.slice("packageName=".length).trim();
750
+ if (!pkg)
751
+ continue;
752
+ // `system` is best-effort here — `query-activities` doesn't
753
+ // distinguish, so we mark unknown as `false` and let
754
+ // `includeSystem` consumers re-query if they actually care.
755
+ if (!pkgs.has(pkg)) {
756
+ pkgs.set(pkg, { packageId: pkg, system: false });
757
+ }
758
+ }
759
+ return [...pkgs.values()].sort((a, b) => a.packageId.localeCompare(b.packageId));
760
+ }
761
+ // `pm list packages -3` lists user-installed (third-party) packages.
762
+ // `-s` lists system packages. Default to third-party only — system
763
+ // apps are 200+ entries on Android and rarely actionable.
764
+ const args = opts.includeSystem
765
+ ? sh `pm list packages -f`
766
+ : sh `pm list packages -3 -f`;
767
+ const { stdout } = await this.adbShell(args);
768
+ const pkgs = new Map();
769
+ for (const line of stdout.split("\n")) {
770
+ // Format: `package:/data/app/com.foo-1/base.apk=com.foo`
771
+ const m = line.trim().match(/^package:.*=([\w.\-_]+)\s*$/);
772
+ if (m && m[1]) {
773
+ pkgs.set(m[1], { packageId: m[1], system: !!opts.includeSystem });
774
+ }
775
+ }
776
+ return [...pkgs.values()].sort((a, b) => a.packageId.localeCompare(b.packageId));
777
+ }
778
+ async terminateApp(pkg) {
779
+ if (!looksLikePackage(pkg)) {
780
+ throw new DeviceError(`terminate_app: '${pkg}' is not a valid Android package id`, { subtype: "invalid_args", retriable: false });
781
+ }
782
+ await this.adbShell(sh `am force-stop ${pkg}`);
783
+ }
784
+ async listElements(opts = {}) {
785
+ const dump = await this.uiDump();
786
+ if (!dump.supported || !dump.content)
787
+ return [];
788
+ const nodes = searchUiXmlNodes(dump.content, opts.query ?? "");
789
+ return nodes.map((n) => {
790
+ const m = n.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
791
+ const [x1, y1, x2, y2] = m
792
+ ? [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]
793
+ : [0, 0, 0, 0];
794
+ return {
795
+ text: n.text || undefined,
796
+ contentDesc: n.contentDesc || undefined,
797
+ resourceId: n.resourceId || undefined,
798
+ className: n.className || undefined,
799
+ center: n.center,
800
+ rect: {
801
+ x: x1,
802
+ y: y1,
803
+ width: Math.max(0, x2 - x1),
804
+ height: Math.max(0, y2 - y1),
805
+ },
806
+ clickable: n.clickable,
807
+ };
808
+ });
809
+ }
810
+ async getOrientation() {
811
+ const { stdout } = await this.adbShell(sh `settings get system user_rotation`);
812
+ const code = Number.parseInt(stdout.trim(), 10);
813
+ return rotationCodeToOrientation(code);
814
+ }
815
+ async setOrientation(o) {
816
+ const code = orientationToRotationCode(o);
817
+ // Disable accelerometer-driven rotation first so user_rotation sticks.
818
+ await this.adbShell(sh `settings put system accelerometer_rotation 0`);
819
+ await this.adbShell(sh `settings put system user_rotation ${String(code)}`);
820
+ }
449
821
  /* ------------------------------------------------------------------ */
450
822
  /* Shell / files */
451
823
  /* ------------------------------------------------------------------ */
@@ -525,6 +897,259 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
525
897
  }
526
898
  }
527
899
  /* ------------------------------------------------------------------ */
900
+ /* 0.4.1: mobile-mcp parity round 2 */
901
+ /* ------------------------------------------------------------------ */
902
+ async uninstallApp(pkgOrName) {
903
+ const pkg = looksLikePackage(pkgOrName)
904
+ ? pkgOrName
905
+ : (getAppPackage(pkgOrName) ?? pkgOrName);
906
+ if (!looksLikePackage(pkg)) {
907
+ throw new DeviceError(`uninstall_app: '${pkgOrName}' is not a valid Android package id`, { subtype: "invalid_args", retriable: false });
908
+ }
909
+ try {
910
+ // `adb uninstall <pkg>` (host-side) is the canonical pair to
911
+ // `adb install -r`. Going through `pm uninstall` over `adb shell`
912
+ // is the alternative but requires UID 0 / root for system packages
913
+ // and silently no-ops without it.
914
+ await this.runner("adb", this.adbArgs(["uninstall", pkg]), {
915
+ encoding: "utf8",
916
+ timeoutMs: this.installTimeoutMs,
917
+ });
918
+ }
919
+ catch (e) {
920
+ throw wrapAdbFailure("uninstall", ["uninstall", pkg], e, this.installTimeoutMs);
921
+ }
922
+ }
923
+ /**
924
+ * Parse `dumpsys dropbox --print` and return one entry per recorded
925
+ * crash / ANR / WTF. The list is best-effort — the dumpsys output
926
+ * format has shifted slightly across Android releases, so we tolerate
927
+ * unknown lines instead of rejecting them.
928
+ */
929
+ async listCrashes() {
930
+ const { stdout } = await this.adbShell(sh `dumpsys dropbox --print`);
931
+ return parseDropboxEntries(stdout);
932
+ }
933
+ /**
934
+ * Re-run `dumpsys dropbox --print` and return the body of the entry
935
+ * whose `id` matches. Stateless so the result stays correct even if
936
+ * `listCrashes` was called from a different MCP session.
937
+ */
938
+ async getCrash(id) {
939
+ if (!id) {
940
+ throw new DeviceError(`get_crash: id is required`, {
941
+ subtype: "invalid_args",
942
+ retriable: false,
943
+ });
944
+ }
945
+ const { stdout } = await this.adbShell(sh `dumpsys dropbox --print`);
946
+ const body = extractDropboxBody(stdout, id);
947
+ if (!body) {
948
+ throw new DeviceError(`get_crash: no crash entry with id '${id}'`, { subtype: "invalid_args", retriable: false });
949
+ }
950
+ return body;
951
+ }
952
+ /**
953
+ * Spawn `adb shell screenrecord` for a single recording. The device
954
+ * enforces a hard `--time-limit` (default 5 min, capped at 300s by
955
+ * Android itself); the host sends SIGINT on `stopScreenRecord` and
956
+ * falls back to SIGKILL after `recordKillGraceMs` if the child
957
+ * hasn't exited.
958
+ *
959
+ * The same-pid `screenrecord` invocation also lets us pipe the output
960
+ * to a `/sdcard/<id>.mp4` so `adb pull` after stop can fetch the
961
+ * exact file without guessing names.
962
+ *
963
+ * `opts.timeLimitS` (mobile-mcp parity, r3): caller-supplied cap,
964
+ * clamped into `[1, 300]` and passed to `screenrecord --time-limit`.
965
+ * `opts.localPath`: caller-supplied host-side destination registered
966
+ * at start time. Already passed through `assertSafeOutputPathWithExt`
967
+ * — backend treats as trusted.
968
+ */
969
+ async startScreenRecord(opts = {}) {
970
+ const id = `beeos-rec-${Date.now()}-${randomUUID().slice(0, 8)}`;
971
+ const remotePath = `/sdcard/${id}.mp4`;
972
+ // Clamp into the 1-300s range Android `screenrecord` enforces. We
973
+ // do the clamp inside the backend (not just at the tool boundary)
974
+ // so direct backend callers — tests, future server APIs — get the
975
+ // same safety net.
976
+ const requested = opts.timeLimitS ?? this.recordTimeLimitS;
977
+ const limit = Math.min(300, Math.max(1, Math.floor(requested)));
978
+ const args = this.adbArgs([
979
+ "shell",
980
+ // Keep one single quoted shell command — adb shell joins argv
981
+ // with spaces and re-evaluates through `sh -c`, so multiple
982
+ // tokens here would be silently re-tokenised on the device side.
983
+ sh `screenrecord --time-limit ${limit} ${remotePath}`,
984
+ ]);
985
+ const child = this.processSpawner("adb", args, {
986
+ stdio: ["ignore", "pipe", "pipe"],
987
+ });
988
+ if (!child.pid) {
989
+ throw new DeviceError(`screen_record_start: failed to spawn adb`, {
990
+ subtype: "environment",
991
+ retriable: true,
992
+ });
993
+ }
994
+ const exitPromise = new Promise((resolve) => {
995
+ // We deliberately resolve on either `close` or `exit` — different
996
+ // stdio shapes on Linux vs macOS sequence them differently.
997
+ const done = () => resolve();
998
+ child.once("close", done);
999
+ child.once("exit", done);
1000
+ child.once("error", done);
1001
+ });
1002
+ const entry = {
1003
+ id,
1004
+ startedAt: Date.now(),
1005
+ remotePath,
1006
+ child,
1007
+ exitPromise,
1008
+ timeLimitS: limit,
1009
+ localPath: opts.localPath,
1010
+ };
1011
+ this.recordings.set(id, entry);
1012
+ // Detach stderr so the listener doesn't keep the process alive
1013
+ // unnecessarily but we still drain it to avoid a back-pressure
1014
+ // deadlock.
1015
+ child.stdout?.resume();
1016
+ child.stderr?.resume();
1017
+ return {
1018
+ id,
1019
+ startedAt: entry.startedAt,
1020
+ timeLimitS: entry.timeLimitS,
1021
+ remotePath: entry.remotePath,
1022
+ ...(entry.localPath !== undefined ? { localPath: entry.localPath } : {}),
1023
+ };
1024
+ }
1025
+ async stopScreenRecord(id, opts = {}) {
1026
+ const entry = this.recordings.get(id);
1027
+ if (!entry) {
1028
+ throw new DeviceError(`screen_record_stop: no recording with id '${id}'`, { subtype: "invalid_args", retriable: false });
1029
+ }
1030
+ this.recordings.delete(id);
1031
+ // Polite stop first — adb shell forwards SIGINT to the device-side
1032
+ // `screenrecord`, which flushes the MP4 trailer. Then escalate to
1033
+ // SIGKILL after a grace period so we never wedge a recording on a
1034
+ // misbehaving device.
1035
+ const tryKill = (sig) => {
1036
+ try {
1037
+ entry.child.kill(sig);
1038
+ }
1039
+ catch {
1040
+ /* swallow — child already exited */
1041
+ }
1042
+ };
1043
+ tryKill("SIGINT");
1044
+ const killTimer = setTimeout(() => tryKill("SIGKILL"), this.recordKillGraceMs);
1045
+ try {
1046
+ await entry.exitPromise;
1047
+ }
1048
+ finally {
1049
+ clearTimeout(killTimer);
1050
+ }
1051
+ // Determine the host-side destination. Priority order:
1052
+ // 1. `opts.path` from this stop call (overrides the start-time
1053
+ // registration so a caller can re-target a recording at the
1054
+ // last moment without losing the kill+pull machinery).
1055
+ // 2. `entry.localPath` registered at start time (mobile-mcp
1056
+ // parity, r3) — the start-time path is the canonical mobile-
1057
+ // mcp design and the one we expose in MCP tool descriptions.
1058
+ // 3. Fresh per-recording tmp dir under `os.tmpdir()`.
1059
+ // All caller-supplied paths have already passed through
1060
+ // `assertSafeOutputPathWithExt` at the tool boundary; the backend
1061
+ // does not re-validate.
1062
+ const localPath = opts.path
1063
+ ? opts.path
1064
+ : (entry.localPath ??
1065
+ (await (async () => {
1066
+ const dir = await mkdtemp(path.join(os.tmpdir(), "beeos-screenrec-"));
1067
+ return path.join(dir, `${id}.mp4`);
1068
+ })()));
1069
+ try {
1070
+ await adbPull(this.runner, this.serial, entry.remotePath, {
1071
+ timeoutMs: this.fileTimeoutMs,
1072
+ }).then(async (buf) => {
1073
+ // adbPull returns the bytes — write them to our chosen path.
1074
+ // We do this in a tiny helper here rather than going through
1075
+ // `adb pull <remote> <local>` directly so callers asking for a
1076
+ // specific destination get the exact file there.
1077
+ const { writeFile: wf } = await import("node:fs/promises");
1078
+ await wf(localPath, buf);
1079
+ });
1080
+ }
1081
+ catch (e) {
1082
+ // Best-effort cleanup of the device-side artifact even if the
1083
+ // host pull fails — leaving 5-min MP4s on `/sdcard` is rude.
1084
+ this.adbShell(sh `rm -f ${entry.remotePath}`).catch(() => undefined);
1085
+ throw e;
1086
+ }
1087
+ // Best-effort cleanup of the device-side file once we have the
1088
+ // local copy.
1089
+ this.adbShell(sh `rm -f ${entry.remotePath}`).catch(() => undefined);
1090
+ const { stat } = await import("node:fs/promises");
1091
+ const fstat = await stat(localPath);
1092
+ return {
1093
+ path: localPath,
1094
+ bytes: fstat.size,
1095
+ durationMs: Date.now() - entry.startedAt,
1096
+ };
1097
+ }
1098
+ /**
1099
+ * Capture a screenshot via `adb pull` of `/sdcard/screenshot.png`.
1100
+ * Bypasses the JSON-RPC body cap by writing the bytes straight to a
1101
+ * host filesystem path the caller chose. Useful for 4K Android
1102
+ * captures where base64-over-HTTP would otherwise blow past 16 MB.
1103
+ */
1104
+ async saveScreenshot(localPath, opts = {}) {
1105
+ const remote = `/sdcard/beeos-${Date.now()}-${randomUUID().slice(0, 8)}.png`;
1106
+ // `screencap -p` writes to a path; `-d <id>` selects display.
1107
+ const cmd = opts.displayId !== undefined
1108
+ ? sh `screencap -p -d ${String(opts.displayId)} ${remote}`
1109
+ : sh `screencap -p ${remote}`;
1110
+ await this.adbShell(cmd);
1111
+ try {
1112
+ const buf = await adbPull(this.runner, this.serial, remote, {
1113
+ timeoutMs: this.fileTimeoutMs,
1114
+ });
1115
+ const { writeFile: wf } = await import("node:fs/promises");
1116
+ await wf(localPath, buf);
1117
+ const [w, h] = parsePngDimensions(buf);
1118
+ return {
1119
+ path: localPath,
1120
+ bytes: buf.length,
1121
+ width: w,
1122
+ height: h,
1123
+ format: "png",
1124
+ };
1125
+ }
1126
+ finally {
1127
+ this.adbShell(sh `rm -f ${remote}`).catch(() => undefined);
1128
+ }
1129
+ }
1130
+ /**
1131
+ * Count the number of displays the device exposes. Reads
1132
+ * `dumpsys SurfaceFlinger --display-id` (Android 10+) and falls back
1133
+ * to "1" on older builds where the flag isn't recognised.
1134
+ */
1135
+ async getDisplayCount() {
1136
+ try {
1137
+ const { stdout } = await this.adbShell(sh `dumpsys SurfaceFlinger --display-id`);
1138
+ // Output shape (Android 10+):
1139
+ // Display 4630946866250191 (HWC display 0): port=0 ...
1140
+ // Display 4630946866250192 (HWC display 1): port=1 ...
1141
+ const lines = stdout
1142
+ .split("\n")
1143
+ .filter((l) => /^Display\s+\d+/.test(l.trim()));
1144
+ if (lines.length > 0)
1145
+ return lines.length;
1146
+ }
1147
+ catch {
1148
+ /* fall through */
1149
+ }
1150
+ return 1;
1151
+ }
1152
+ /* ------------------------------------------------------------------ */
528
1153
  /* Internal helpers */
529
1154
  /* ------------------------------------------------------------------ */
530
1155
  async getCurrentApp() {
@@ -560,9 +1185,16 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
560
1185
  throw wrapAdbFailure("shell", ["shell", command], e, this.shellTimeoutMs);
561
1186
  }
562
1187
  }
563
- async captureRawPng() {
1188
+ async captureRawPng(displayId) {
1189
+ // Multi-display capture: `screencap -d <id>` first appears on
1190
+ // Android 10+ (API 29). On older builds the flag is silently
1191
+ // ignored and we capture the primary display — that's still a
1192
+ // sensible fallback so we don't gate this behind an SDK check.
1193
+ const cmd = displayId !== undefined
1194
+ ? ["exec-out", "screencap", "-p", "-d", String(displayId)]
1195
+ : ["exec-out", "screencap", "-p"];
564
1196
  try {
565
- const { stdout } = await this.runner("adb", this.adbArgs(["exec-out", "screencap", "-p"]), {
1197
+ const { stdout } = await this.runner("adb", this.adbArgs(cmd), {
566
1198
  encoding: "buffer",
567
1199
  maxBufferBytes: 32 * 1024 * 1024,
568
1200
  timeoutMs: this.screenshotTimeoutMs,
@@ -570,7 +1202,7 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
570
1202
  return Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout);
571
1203
  }
572
1204
  catch (e) {
573
- throw wrapAdbFailure("screencap", ["exec-out", "screencap", "-p"], e, this.screenshotTimeoutMs);
1205
+ throw wrapAdbFailure("screencap", cmd, e, this.screenshotTimeoutMs);
574
1206
  }
575
1207
  }
576
1208
  /**
@@ -639,6 +1271,37 @@ function parseGetpropDump(out) {
639
1271
  }
640
1272
  return map;
641
1273
  }
1274
+ function rotationCodeToOrientation(code) {
1275
+ switch (code) {
1276
+ case 0:
1277
+ return "portrait";
1278
+ case 1:
1279
+ return "landscape";
1280
+ case 2:
1281
+ return "reverse-portrait";
1282
+ case 3:
1283
+ return "reverse-landscape";
1284
+ default:
1285
+ return "portrait";
1286
+ }
1287
+ }
1288
+ function orientationToRotationCode(o) {
1289
+ switch (o) {
1290
+ case "portrait":
1291
+ return 0;
1292
+ case "landscape":
1293
+ return 1;
1294
+ case "reverse-portrait":
1295
+ return 2;
1296
+ case "reverse-landscape":
1297
+ return 3;
1298
+ default:
1299
+ throw new DeviceError(`unknown orientation '${o}'`, {
1300
+ subtype: "invalid_args",
1301
+ retriable: false,
1302
+ });
1303
+ }
1304
+ }
642
1305
  /**
643
1306
  * Local POSIX-quote helper used ONLY by `executeCommand`'s `cwd` prefix.
644
1307
  * The general-purpose `sh\`...\`` template lives in `util/adb-shell.ts`
@@ -700,18 +1363,36 @@ export function wrapAdbFailure(verb, args, cause, timeoutMs) {
700
1363
  cause: err,
701
1364
  });
702
1365
  }
703
- const summary = err.stderr?.trim() || err.message || "unknown error";
1366
+ // Observability: the actionable reason often lives in STDOUT, not stderr —
1367
+ // e.g. `monkey` prints "** No activities found to run, monkey aborted" to
1368
+ // stdout (and frequently exits non-zero with only its verbose arg echo on
1369
+ // the wire). Folding both streams + the exit code into the summary means the
1370
+ // agent (and the message-bus error log) sees WHY the command failed instead
1371
+ // of an opaque arg dump.
1372
+ const stderrTxt = err.stderr?.trim() ?? "";
1373
+ const stdoutTxt = typeof err.stdout === "string"
1374
+ ? err.stdout.trim()
1375
+ : Buffer.isBuffer(err.stdout)
1376
+ ? err.stdout.toString("utf8").trim()
1377
+ : "";
1378
+ const detail = [stderrTxt, stdoutTxt].filter(Boolean).join(" | ");
1379
+ let summary = detail || err.message || "unknown error";
1380
+ if (typeof err.code === "number") {
1381
+ summary = `${summary} (exit ${err.code})`;
1382
+ }
704
1383
  const subtype = verb === "shell"
705
1384
  ? "adb_shell_failed"
706
1385
  : verb === "install"
707
1386
  ? "adb_install_failed"
708
- : verb === "screencap"
709
- ? "screenshot_failed"
710
- : verb === "devices"
711
- ? "adb_failed"
712
- : verb === "exec-out"
1387
+ : verb === "uninstall"
1388
+ ? "adb_uninstall_failed"
1389
+ : verb === "screencap"
1390
+ ? "screenshot_failed"
1391
+ : verb === "devices"
713
1392
  ? "adb_failed"
714
- : "adb_failed";
1393
+ : verb === "exec-out"
1394
+ ? "adb_failed"
1395
+ : "adb_failed";
715
1396
  // Reference unused args param for symmetry with previous signature.
716
1397
  void args;
717
1398
  return new DeviceError(`adb ${verb} failed: ${summary}`, {
@@ -720,4 +1401,135 @@ export function wrapAdbFailure(verb, args, cause, timeoutMs) {
720
1401
  cause: err,
721
1402
  });
722
1403
  }
1404
+ /* ----------------------------------------------------------------------- */
1405
+ /* 0.4.1 helpers — open_url whitelist + dropbox crash parser */
1406
+ /* ----------------------------------------------------------------------- */
1407
+ /**
1408
+ * Default URL scheme allow-list for `open_url`. Mirrors mobile-mcp's
1409
+ * default — `http` / `https` only. Operators who need to launch
1410
+ * `intent://` / `file://` / custom schemes set
1411
+ * `BEEOS_DEVICE_ALLOW_UNSAFE_URLS=1` to opt out (mobile-mcp uses
1412
+ * `MOBILEMCP_ALLOW_UNSAFE_URLS=1`; we accept both for compatibility).
1413
+ */
1414
+ const SAFE_URL_SCHEMES = new Set(["http", "https"]);
1415
+ export function assertSafeUrl(url) {
1416
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) {
1417
+ throw new DeviceError(`open_url: '${url}' is not a fully-qualified URL`, {
1418
+ subtype: "invalid_args",
1419
+ retriable: false,
1420
+ });
1421
+ }
1422
+ // Allow opt-out via either env name so a mobile-mcp prompt that sets
1423
+ // the upstream variable keeps working.
1424
+ if (process.env.BEEOS_DEVICE_ALLOW_UNSAFE_URLS === "1" ||
1425
+ process.env.MOBILEMCP_ALLOW_UNSAFE_URLS === "1") {
1426
+ return;
1427
+ }
1428
+ const colon = url.indexOf(":");
1429
+ const scheme = url.slice(0, colon).toLowerCase();
1430
+ if (!SAFE_URL_SCHEMES.has(scheme)) {
1431
+ throw new DeviceError(`open_url: scheme '${scheme}' is not in the default allow-list ` +
1432
+ `(http/https). Set BEEOS_DEVICE_ALLOW_UNSAFE_URLS=1 to permit ` +
1433
+ `intent:/file:/content:/custom URLs.`, { subtype: "invalid_args", retriable: false });
1434
+ }
1435
+ }
1436
+ /**
1437
+ * Parse the textual output of `dumpsys dropbox --print` into one
1438
+ * `CrashSummary` per crash / ANR / WTF entry.
1439
+ *
1440
+ * Output shape (Android 7+):
1441
+ *
1442
+ * 2024-08-15 12:34:56 system_app_crash (text, 1234 bytes):
1443
+ * Process: com.foo.bar
1444
+ * ...stack trace lines...
1445
+ * 2024-08-15 12:35:00 data_app_anr (text, 5678 bytes):
1446
+ * ...
1447
+ *
1448
+ * We extract the leading `<timestamp> <tag> (text, N bytes):` row and
1449
+ * include the first non-blank body line as a `headline` for at-a-glance
1450
+ * triage. The id encodes both the tag and the timestamp so `getCrash`
1451
+ * can re-locate the same row on the next `dumpsys` invocation without
1452
+ * holding state.
1453
+ */
1454
+ export function parseDropboxEntries(out) {
1455
+ const headerRe = /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+([A-Za-z0-9_]+)(?:\s*\(text,\s*(\d+)\s*bytes\))?:?\s*$/;
1456
+ const TAG_RE = /(crash|anr|wtf|kernel_panic|tombstone)/i;
1457
+ const lines = out.split(/\r?\n/);
1458
+ const entries = [];
1459
+ for (let i = 0; i < lines.length; i++) {
1460
+ const line = lines[i] ?? "";
1461
+ const m = headerRe.exec(line.trim());
1462
+ if (!m)
1463
+ continue;
1464
+ const tag = m[2];
1465
+ if (!TAG_RE.test(tag))
1466
+ continue;
1467
+ const timestamp = m[1];
1468
+ const bytes = m[3] ? Number(m[3]) : undefined;
1469
+ // Pull a one-line headline from the immediate body — usually the
1470
+ // `Process:` row for crashes and `Cmd line:` for ANRs.
1471
+ let headline;
1472
+ let app;
1473
+ for (let j = i + 1; j < Math.min(lines.length, i + 8); j++) {
1474
+ const body = (lines[j] ?? "").trim();
1475
+ if (!body)
1476
+ continue;
1477
+ if (headerRe.test(body))
1478
+ break;
1479
+ if (!headline)
1480
+ headline = body.length > 240 ? body.slice(0, 240) + "…" : body;
1481
+ const procMatch = /^(?:Process|Cmd ?line):\s*(\S+)/.exec(body);
1482
+ if (procMatch) {
1483
+ app = procMatch[1].split(/[\s,]/)[0];
1484
+ break;
1485
+ }
1486
+ }
1487
+ const type = TAG_RE.exec(tag)?.[1]?.toLowerCase() ?? "crash";
1488
+ entries.push({
1489
+ id: `${tag}:${timestamp}`,
1490
+ type,
1491
+ timestamp,
1492
+ app,
1493
+ headline,
1494
+ bytes,
1495
+ });
1496
+ }
1497
+ // Newest first — dumpsys output is already chronological so we reverse.
1498
+ return entries.reverse();
1499
+ }
1500
+ /**
1501
+ * Pull the body of a single dumpsys-dropbox entry out of the `--print`
1502
+ * blob, indexed by `<tag>:<timestamp>` (the same id `parseDropboxEntries`
1503
+ * returns). The body is everything between the matching header and the
1504
+ * next entry header.
1505
+ */
1506
+ export function extractDropboxBody(out, id) {
1507
+ const colon = id.indexOf(":");
1508
+ if (colon < 0)
1509
+ return undefined;
1510
+ const tag = id.slice(0, colon);
1511
+ const timestamp = id.slice(colon + 1);
1512
+ const headerRe = /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+([A-Za-z0-9_]+)(?:\s*\(text,\s*\d+\s*bytes\))?:?\s*$/;
1513
+ const lines = out.split(/\r?\n/);
1514
+ let startIdx = -1;
1515
+ for (let i = 0; i < lines.length; i++) {
1516
+ const m = headerRe.exec(lines[i]?.trim() ?? "");
1517
+ if (!m)
1518
+ continue;
1519
+ if (m[1] === timestamp && m[2] === tag) {
1520
+ startIdx = i + 1;
1521
+ break;
1522
+ }
1523
+ }
1524
+ if (startIdx === -1)
1525
+ return undefined;
1526
+ let endIdx = lines.length;
1527
+ for (let j = startIdx; j < lines.length; j++) {
1528
+ if (headerRe.test(lines[j]?.trim() ?? "")) {
1529
+ endIdx = j;
1530
+ break;
1531
+ }
1532
+ }
1533
+ return lines.slice(startIdx, endIdx).join("\n").trim();
1534
+ }
723
1535
  //# sourceMappingURL=android-adb.js.map