@sandboxrouter/sdk 0.2.0 → 0.3.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.
Files changed (91) hide show
  1. package/README.md +229 -50
  2. package/dist/bench.js +4 -4
  3. package/dist/catalog.d.ts +159 -0
  4. package/dist/catalog.js +450 -15
  5. package/dist/check.d.ts +28 -0
  6. package/dist/check.js +185 -0
  7. package/dist/cli.js +171 -12
  8. package/dist/computers.d.ts +55 -13
  9. package/dist/computers.js +132 -6
  10. package/dist/conformance.d.ts +46 -0
  11. package/dist/conformance.js +65 -0
  12. package/dist/cost.d.ts +67 -0
  13. package/dist/cost.js +153 -0
  14. package/dist/gc.d.ts +29 -0
  15. package/dist/gc.js +54 -0
  16. package/dist/index.d.ts +28 -2
  17. package/dist/index.js +28 -2
  18. package/dist/leaderboard.d.ts +6 -0
  19. package/dist/leaderboard.js +5 -4
  20. package/dist/ledger.d.ts +38 -0
  21. package/dist/ledger.js +89 -0
  22. package/dist/providers/agent37.d.ts +40 -0
  23. package/dist/providers/agent37.js +224 -0
  24. package/dist/providers/agentcore.d.ts +71 -0
  25. package/dist/providers/agentcore.js +446 -0
  26. package/dist/providers/alibaba.d.ts +18 -0
  27. package/dist/providers/alibaba.js +106 -0
  28. package/dist/providers/azure.d.ts +43 -0
  29. package/dist/providers/azure.js +330 -0
  30. package/dist/providers/beam.d.ts +11 -0
  31. package/dist/providers/beam.js +179 -0
  32. package/dist/providers/blaxel.d.ts +3 -1
  33. package/dist/providers/blaxel.js +32 -22
  34. package/dist/providers/boat.js +8 -3
  35. package/dist/providers/cloudflare.js +8 -3
  36. package/dist/providers/codesandbox.d.ts +59 -0
  37. package/dist/providers/codesandbox.js +287 -0
  38. package/dist/providers/daytona.d.ts +25 -4
  39. package/dist/providers/daytona.js +449 -28
  40. package/dist/providers/dedalus.d.ts +12 -0
  41. package/dist/providers/dedalus.js +244 -0
  42. package/dist/providers/deno.d.ts +50 -0
  43. package/dist/providers/deno.js +170 -0
  44. package/dist/providers/docker.js +11 -5
  45. package/dist/providers/e2b.d.ts +70 -3
  46. package/dist/providers/e2b.js +376 -8
  47. package/dist/providers/exedev.js +17 -5
  48. package/dist/providers/freestyle.d.ts +49 -0
  49. package/dist/providers/freestyle.js +261 -0
  50. package/dist/providers/hopx.js +27 -18
  51. package/dist/providers/instabox.d.ts +76 -0
  52. package/dist/providers/instabox.js +360 -0
  53. package/dist/providers/koyeb.d.ts +71 -0
  54. package/dist/providers/koyeb.js +379 -0
  55. package/dist/providers/lambda.d.ts +51 -0
  56. package/dist/providers/lambda.js +467 -0
  57. package/dist/providers/langsmith.d.ts +16 -0
  58. package/dist/providers/langsmith.js +149 -0
  59. package/dist/providers/limrun.d.ts +77 -0
  60. package/dist/providers/limrun.js +626 -0
  61. package/dist/providers/maritime.d.ts +5 -1
  62. package/dist/providers/maritime.js +39 -8
  63. package/dist/providers/mobilerun.d.ts +44 -0
  64. package/dist/providers/mobilerun.js +417 -0
  65. package/dist/providers/modal.js +12 -4
  66. package/dist/providers/morph.js +12 -4
  67. package/dist/providers/northflank.js +11 -13
  68. package/dist/providers/novita.d.ts +12 -0
  69. package/dist/providers/novita.js +81 -0
  70. package/dist/providers/openscale.d.ts +17 -0
  71. package/dist/providers/openscale.js +87 -0
  72. package/dist/providers/orgo.d.ts +45 -0
  73. package/dist/providers/orgo.js +414 -0
  74. package/dist/providers/sail.d.ts +32 -0
  75. package/dist/providers/sail.js +372 -0
  76. package/dist/providers/superserve.d.ts +25 -0
  77. package/dist/providers/superserve.js +337 -0
  78. package/dist/providers/tensorlake.js +17 -14
  79. package/dist/providers/upstash.d.ts +30 -0
  80. package/dist/providers/upstash.js +258 -0
  81. package/dist/providers/vercel.js +14 -3
  82. package/dist/providers/workspaces.d.ts +60 -0
  83. package/dist/providers/workspaces.js +662 -0
  84. package/dist/router.d.ts +41 -5
  85. package/dist/router.js +179 -23
  86. package/dist/server.d.ts +1 -1
  87. package/dist/server.js +22 -3
  88. package/dist/types.d.ts +13 -5
  89. package/dist/util.d.ts +28 -1
  90. package/dist/util.js +56 -7
  91. package/package.json +81 -5
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
2
2
  import { ProviderError } from "../types.js";
3
- import { toBytes } from "../util.js";
3
+ import { DEFAULT_EXEC_TIMEOUT_S, killedByTimeout, timeoutError, toBytes } from "../util.js";
4
4
  import { readSSE } from "../sse.js";
5
5
  /**
6
6
  * Maritime over plain REST (no SDK needed).
@@ -23,7 +23,10 @@ export class MaritimeProvider {
23
23
  configured() {
24
24
  return !!this.apiKey;
25
25
  }
26
- /** @internal */
26
+ /**
27
+ * @internal Every request has a deadline (`timeoutMs`, default 60 s), so a server that accepts
28
+ * the connection and never answers is an error the router can fall back from, not a hang.
29
+ */
27
30
  async request(method, url, init = {}) {
28
31
  const headers = { ...init.headers };
29
32
  let body;
@@ -35,7 +38,16 @@ export class MaritimeProvider {
35
38
  headers["Content-Type"] = "application/octet-stream";
36
39
  body = init.bytes;
37
40
  }
38
- const res = await fetch(url, { method, headers, body });
41
+ const ms = init.timeoutMs ?? REQUEST_TIMEOUT_MS;
42
+ let res;
43
+ try {
44
+ res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(ms) });
45
+ }
46
+ catch (err) {
47
+ if (err?.name === "TimeoutError")
48
+ throw new ProviderError(this.id, `${method} ${new URL(url).pathname}: no answer after ${ms / 1000}s`);
49
+ throw err;
50
+ }
39
51
  if (!res.ok) {
40
52
  const text = await res.text();
41
53
  let detail = text.slice(0, 300);
@@ -131,11 +143,18 @@ class MaritimeSandbox {
131
143
  }
132
144
  async exec(cmd, opts = {}) {
133
145
  const t0 = Date.now();
134
- const body = { cmd, cwd: opts.cwd, env: opts.env, timeoutSeconds: opts.timeoutSeconds ?? 60 };
146
+ const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
147
+ const body = { cmd, cwd: opts.cwd, env: opts.env, timeoutSeconds: seconds };
135
148
  const streaming = !!(opts.onStdout || opts.onStderr);
149
+ // A real account answers a command that passes its limit with 504 command_timeout (or, while
150
+ // streaming, an "error" event with that code); either is the contract's timeout error.
136
151
  const res = await this.data("POST", "/commands", {
137
152
  json: body,
138
153
  headers: streaming ? { Accept: "text/event-stream" } : {},
154
+ // Covers reading the stream too; the server stops the command at its own limit first.
155
+ timeoutMs: (seconds + 30) * 1000,
156
+ }).catch((err) => {
157
+ throw isCommandTimeout(err) ? timeoutError("maritime", seconds) : err;
139
158
  });
140
159
  let result;
141
160
  if (streaming && res.headers.get("content-type")?.includes("text/event-stream")) {
@@ -147,8 +166,11 @@ class MaritimeSandbox {
147
166
  opts.onStderr?.(payload);
148
167
  else if (ev.event === "result")
149
168
  result = payload;
150
- else if (ev.event === "error")
169
+ else if (ev.event === "error") {
170
+ if (payload?.code === "command_timeout")
171
+ throw timeoutError("maritime", seconds);
151
172
  throw new ProviderError("maritime", `${payload?.code ?? "error"}: ${payload?.message ?? ev.data}`);
173
+ }
152
174
  }
153
175
  if (!result)
154
176
  throw new ProviderError("maritime", "output stream closed before the command finished");
@@ -161,13 +183,17 @@ class MaritimeSandbox {
161
183
  if (result.stderr)
162
184
  opts.onStderr?.(result.stderr);
163
185
  }
164
- return { exitCode: result.exitCode ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "", durationMs: Date.now() - t0 };
186
+ const durationMs = Date.now() - t0;
187
+ // The server stops the command at timeoutSeconds and reports no exit code (or a kill code).
188
+ if (killedByTimeout(result.exitCode ?? -1, durationMs, seconds))
189
+ throw timeoutError("maritime", seconds);
190
+ return { exitCode: result.exitCode ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "", durationMs };
165
191
  }
166
192
  async writeFile(path, content) {
167
- await this.data("PUT", `/files?path=${encodeURIComponent(path)}`, { bytes: toBytes(content) });
193
+ await this.data("PUT", `/files?path=${encodeURIComponent(path)}`, { bytes: toBytes(content), timeoutMs: FILE_TIMEOUT_MS });
168
194
  }
169
195
  async readFile(path) {
170
- const res = await this.data("GET", `/files?path=${encodeURIComponent(path)}`);
196
+ const res = await this.data("GET", `/files?path=${encodeURIComponent(path)}`, { timeoutMs: FILE_TIMEOUT_MS });
171
197
  return new Uint8Array(await res.arrayBuffer());
172
198
  }
173
199
  async pause() {
@@ -238,3 +264,8 @@ class MaritimeComputer {
238
264
  function clamp(n, lo, hi) {
239
265
  return Math.min(hi, Math.max(lo, n));
240
266
  }
267
+ function isCommandTimeout(err) {
268
+ return err instanceof ProviderError && err.status === 504 && /command_timeout/.test(err.message);
269
+ }
270
+ const REQUEST_TIMEOUT_MS = 60_000;
271
+ const FILE_TIMEOUT_MS = 300_000;
@@ -0,0 +1,44 @@
1
+ import type { ComputerOs, ComputerProvider, ComputerSpec, ProviderComputer } from "../computers.js";
2
+ type MobilerunModule = typeof import("@mobilerun/sdk");
3
+ type MobilerunClient = InstanceType<MobilerunModule["Mobilerun"]>;
4
+ /**
5
+ * Mobilerun (droidrun) cloud Android phones via `@mobilerun/sdk`. Desktop-only: no sandboxes.
6
+ *
7
+ * createComputer provisions an `android_cloud_phone` (billing `auto`: plan minutes, then
8
+ * $0.03/min overage on paid plans) and blocks on `devices.waitReady`. Input is touch: taps,
9
+ * swipes, IME text and single Android key events. There is no pointer, so hover, right/middle
10
+ * click, key chords and held keys come back as `{ ok: false }`.
11
+ *
12
+ * Not available here, by the API's design:
13
+ * - exec: no command endpoint. adb shell exists only on request, over a WebSocket adb tunnel
14
+ * (docs.mobilerun.ai/integrations/adb), which needs a local adb binary.
15
+ * - JPEG screenshots: the capture endpoint returns PNG only.
16
+ *
17
+ * Docs: https://docs.mobilerun.ai (API reference: /api-reference/navigation/*, /devices/*)
18
+ */
19
+ export declare class MobilerunProvider implements ComputerProvider {
20
+ private apiKey;
21
+ private baseURL;
22
+ readonly id: "mobilerun";
23
+ readonly computerOs: ComputerOs[];
24
+ private client?;
25
+ /** The SDK reads MOBILERUN_CLOUD_API_KEY itself; we read it too so configured() is honest. */
26
+ constructor(apiKey?: string | undefined, baseURL?: string | undefined);
27
+ configured(): boolean;
28
+ /** @internal */
29
+ sdk(): Promise<MobilerunClient>;
30
+ createComputer(spec: ComputerSpec): Promise<ProviderComputer>;
31
+ /** Re-attach by device id. A parked (stopped) phone is resumed and waited on. */
32
+ connectComputer(nativeId: string): Promise<ProviderComputer>;
33
+ }
34
+ /**
35
+ * xdotool-style key names to Android KeyEvent codes, sent one at a time with
36
+ * `devices.keyboard.key`. Back, Home and Recents become system navigation; Enter, BackSpace,
37
+ * Delete and Tab are handled by Mobilerun's Portal through accessibility even without its IME.
38
+ * Arrows, End and page keys need the Mobilerun keyboard (IME) to be the active input method.
39
+ * Single letters and digits map to their KEYCODE_A..Z / KEYCODE_0..9.
40
+ */
41
+ export declare const MOBILERUN_KEYS: Record<string, number>;
42
+ /** One xdotool key name (no modifiers) to an Android key code. */
43
+ export declare function mobilerunKey(name: string): number;
44
+ export {};
@@ -0,0 +1,417 @@
1
+ import { ProviderError } from "../types.js";
2
+ import { dirname, optionalImport, toBytes } from "../util.js";
3
+ /** Desktop-only provider: not in the sandbox ProviderId union. */
4
+ const ID = "mobilerun";
5
+ /** Device names carry the router's user id, so the same user gets the same phone back. */
6
+ const USER_PREFIX = "sandboxrouter:";
7
+ /** How long create/connect wait for a phone to reach `ready`. */
8
+ const READY_DEADLINE_MS = 10 * 60_000;
9
+ /**
10
+ * Mobilerun (droidrun) cloud Android phones via `@mobilerun/sdk`. Desktop-only: no sandboxes.
11
+ *
12
+ * createComputer provisions an `android_cloud_phone` (billing `auto`: plan minutes, then
13
+ * $0.03/min overage on paid plans) and blocks on `devices.waitReady`. Input is touch: taps,
14
+ * swipes, IME text and single Android key events. There is no pointer, so hover, right/middle
15
+ * click, key chords and held keys come back as `{ ok: false }`.
16
+ *
17
+ * Not available here, by the API's design:
18
+ * - exec: no command endpoint. adb shell exists only on request, over a WebSocket adb tunnel
19
+ * (docs.mobilerun.ai/integrations/adb), which needs a local adb binary.
20
+ * - JPEG screenshots: the capture endpoint returns PNG only.
21
+ *
22
+ * Docs: https://docs.mobilerun.ai (API reference: /api-reference/navigation/*, /devices/*)
23
+ */
24
+ export class MobilerunProvider {
25
+ apiKey;
26
+ baseURL;
27
+ id = "mobilerun";
28
+ computerOs = ["android"];
29
+ client;
30
+ /** The SDK reads MOBILERUN_CLOUD_API_KEY itself; we read it too so configured() is honest. */
31
+ constructor(apiKey = process.env.MOBILERUN_CLOUD_API_KEY, baseURL = process.env.MOBILERUN_BASE_URL) {
32
+ this.apiKey = apiKey;
33
+ this.baseURL = baseURL;
34
+ }
35
+ configured() {
36
+ return !!this.apiKey;
37
+ }
38
+ /** @internal */
39
+ async sdk() {
40
+ if (!this.apiKey)
41
+ throw new ProviderError(ID, "MOBILERUN_CLOUD_API_KEY is not set");
42
+ if (!this.client) {
43
+ const { Mobilerun } = await optionalImport("@mobilerun/sdk", "mobilerun");
44
+ this.client = new Mobilerun({ apiKey: this.apiKey, ...(this.baseURL ? { baseURL: this.baseURL } : {}) });
45
+ }
46
+ return this.client;
47
+ }
48
+ async createComputer(spec) {
49
+ const os = spec.os ?? "android";
50
+ if (os !== "android")
51
+ throw new ProviderError(ID, `no ${os} computers (Android cloud phones only)`);
52
+ const client = await this.sdk();
53
+ const name = spec.externalUserId ? `${USER_PREFIX}${spec.externalUserId}` : spec.name;
54
+ // Same user, same phone: reuse a live or parked device that carries their name.
55
+ if (spec.externalUserId) {
56
+ const existing = await guard(client.devices.list({ name, platform: "android", type: "android_cloud_phone" }));
57
+ const mine = (existing.items ?? []).find((d) => d.name === name && d.state !== "terminated" && d.state !== "unknown");
58
+ if (mine) {
59
+ if (mine.state === "stopped")
60
+ await guard(client.devices.resume(mine.id));
61
+ return new MobilerunComputer(this, await waitReady(client, mine.id));
62
+ }
63
+ }
64
+ const created = await guard(client.devices.create({ deviceType: "android_cloud_phone", billing: "auto", ...(name ? { name } : {}) }));
65
+ const ready = created.state === "ready" ? created : await waitReady(client, created.id);
66
+ return new MobilerunComputer(this, ready);
67
+ }
68
+ /** Re-attach by device id. A parked (stopped) phone is resumed and waited on. */
69
+ async connectComputer(nativeId) {
70
+ const client = await this.sdk();
71
+ const d = await guard(client.devices.retrieve(nativeId));
72
+ if (d.state === "terminated")
73
+ throw new ProviderError(ID, `device ${nativeId} is terminated`, 404);
74
+ if (d.state === "stopped")
75
+ await guard(client.devices.resume(nativeId));
76
+ return new MobilerunComputer(this, d.state === "ready" ? d : await waitReady(client, nativeId));
77
+ }
78
+ }
79
+ /**
80
+ * The SDK's APIConnectionTimeoutError (its error classes don't set `name`, so the class name is
81
+ * what identifies it), or a gateway timeout from the server-side wait.
82
+ */
83
+ function isWaitTimeout(err) {
84
+ const e = err;
85
+ return e?.constructor?.name === "APIConnectionTimeoutError" || e?.name === "APIConnectionTimeoutError" || e?.status === 408 || e?.status === 504;
86
+ }
87
+ /** Block until the device is ready; the server-side wait can time out, so retry until our deadline. */
88
+ async function waitReady(client, id) {
89
+ const deadline = Date.now() + READY_DEADLINE_MS;
90
+ for (;;) {
91
+ try {
92
+ return await client.devices.waitReady(id, { timeout: 120_000 });
93
+ }
94
+ catch (err) {
95
+ if (!isWaitTimeout(err) || Date.now() > deadline)
96
+ throw toProviderError(err, `device ${id} did not become ready`);
97
+ const d = await guard(client.devices.retrieve(id));
98
+ if (d.state === "ready")
99
+ return d;
100
+ if (d.state === "terminated")
101
+ throw new ProviderError(ID, `device ${id} was terminated while starting: ${d.stateMessage}`);
102
+ }
103
+ }
104
+ }
105
+ function toProviderError(err, prefix) {
106
+ if (err instanceof ProviderError)
107
+ return err;
108
+ const e = err;
109
+ const msg = e?.message ?? String(err);
110
+ return new ProviderError(ID, prefix ? `${prefix}: ${msg}` : msg, typeof e?.status === "number" ? e.status : undefined);
111
+ }
112
+ /** Await an SDK call, rethrowing its errors as ProviderError. */
113
+ async function guard(p) {
114
+ try {
115
+ return await p;
116
+ }
117
+ catch (err) {
118
+ throw toProviderError(err);
119
+ }
120
+ }
121
+ /**
122
+ * xdotool-style key names to Android KeyEvent codes, sent one at a time with
123
+ * `devices.keyboard.key`. Back, Home and Recents become system navigation; Enter, BackSpace,
124
+ * Delete and Tab are handled by Mobilerun's Portal through accessibility even without its IME.
125
+ * Arrows, End and page keys need the Mobilerun keyboard (IME) to be the active input method.
126
+ * Single letters and digits map to their KEYCODE_A..Z / KEYCODE_0..9.
127
+ */
128
+ export const MOBILERUN_KEYS = {
129
+ return: 66, // KEYCODE_ENTER
130
+ enter: 66,
131
+ kp_enter: 66,
132
+ backspace: 67, // KEYCODE_DEL
133
+ delete: 112, // KEYCODE_FORWARD_DEL
134
+ kp_delete: 112,
135
+ tab: 61,
136
+ escape: 4, // KEYCODE_BACK: on a phone, Escape means "dismiss / go back"
137
+ esc: 4,
138
+ back: 4,
139
+ xf86back: 4,
140
+ home: 3, // KEYCODE_HOME: the home screen, not line start
141
+ xf86homepage: 3,
142
+ end: 123, // KEYCODE_MOVE_END
143
+ up: 19, // KEYCODE_DPAD_UP
144
+ down: 20,
145
+ left: 21,
146
+ right: 22,
147
+ kp_up: 19,
148
+ kp_down: 20,
149
+ kp_left: 21,
150
+ kp_right: 22,
151
+ page_up: 92,
152
+ prior: 92,
153
+ page_down: 93,
154
+ next: 93,
155
+ space: 62,
156
+ menu: 82,
157
+ search: 84,
158
+ xf86search: 84,
159
+ recents: 187, // KEYCODE_APP_SWITCH
160
+ app_switch: 187,
161
+ power: 26,
162
+ xf86poweroff: 26,
163
+ xf86audioraisevolume: 24,
164
+ xf86audiolowervolume: 25,
165
+ xf86audiomute: 164,
166
+ };
167
+ /** One xdotool key name (no modifiers) to an Android key code. */
168
+ export function mobilerunKey(name) {
169
+ const k = name.trim();
170
+ if (k.includes("+") && k.length > 1) {
171
+ throw new Error(`mobilerun sends one Android key at a time; "${k}" is a chord (modifiers are not supported)`);
172
+ }
173
+ const code = MOBILERUN_KEYS[k.toLowerCase()];
174
+ if (code !== undefined)
175
+ return code;
176
+ if (/^[a-zA-Z]$/.test(k))
177
+ return 29 + (k.toLowerCase().charCodeAt(0) - 97); // KEYCODE_A = 29
178
+ if (/^[0-9]$/.test(k))
179
+ return 7 + Number(k); // KEYCODE_0 = 7
180
+ throw new Error(`mobilerun has no Android key for "${k}" (use type for text)`);
181
+ }
182
+ /** Width and height from a PNG's IHDR. */
183
+ function pngSize(b) {
184
+ if (b.length < 24 || b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47)
185
+ return {};
186
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
187
+ return { width: v.getUint32(16), height: v.getUint32(20) };
188
+ }
189
+ /**
190
+ * Read a binary body. The API serves raw bytes (image/png, octet-stream); its OpenAPI spec also
191
+ * describes a base64 JSON string, so accept that too.
192
+ */
193
+ async function bytesOf(p) {
194
+ const res = await p.asResponse();
195
+ const type = res.headers.get("content-type")?.split(";")[0]?.trim() ?? "";
196
+ if (type.includes("json")) {
197
+ const j = (await res.json());
198
+ if (typeof j !== "string")
199
+ throw new Error("expected a base64 string body");
200
+ return new Uint8Array(Buffer.from(j.replace(/^data:[^,]*,/, ""), "base64"));
201
+ }
202
+ return new Uint8Array(await res.arrayBuffer());
203
+ }
204
+ /** Gestures take the canonical frame (the last screenshot), which is the phone's native pixels. */
205
+ class MobilerunComputer {
206
+ p;
207
+ nativeId;
208
+ frame = 0;
209
+ size;
210
+ constructor(p, device) {
211
+ this.p = p;
212
+ this.nativeId = device.id;
213
+ }
214
+ sdk() {
215
+ return this.p.sdk();
216
+ }
217
+ async info() {
218
+ const d = await guard((await this.sdk()).devices.retrieve(this.nativeId));
219
+ const status = d.state === "ready" ? "ready" : d.state === "stopped" ? "sleeping" : d.state === "terminated" ? "deleted" : d.state;
220
+ const info = {
221
+ id: d.id,
222
+ os: "android",
223
+ status,
224
+ externalUserId: d.name?.startsWith(USER_PREFIX) ? d.name.slice(USER_PREFIX.length) : null,
225
+ };
226
+ if (this.size)
227
+ info.screen = { ...this.size };
228
+ return info;
229
+ }
230
+ async act(a) {
231
+ try {
232
+ const extra = await this.perform(a);
233
+ if (a.noScreenshot)
234
+ return { action: a.action, ok: true, ...extra };
235
+ return { action: a.action, ok: true, ...extra, ...(await this.frameResult()) };
236
+ }
237
+ catch (err) {
238
+ return { action: a.action, ok: false, error: err?.message ?? String(err) };
239
+ }
240
+ }
241
+ /** Runs in order and stops at the first error; only the last action returns a screenshot. */
242
+ async actBatch(actions) {
243
+ const out = [];
244
+ for (const [i, a] of actions.entries()) {
245
+ const r = await this.act(i === actions.length - 1 ? a : { ...a, noScreenshot: true });
246
+ out.push(r);
247
+ if (!r.ok)
248
+ break;
249
+ }
250
+ return out;
251
+ }
252
+ async capture() {
253
+ const client = await this.sdk();
254
+ const png = await bytesOf(client.devices.screenshots.capture(this.nativeId, {}, { headers: { "Accept-Encoding": "identity" } }));
255
+ if (!png.length)
256
+ throw new Error("screenshot came back empty");
257
+ const s = pngSize(png);
258
+ if (s.width && s.height)
259
+ this.size = { width: s.width, height: s.height };
260
+ return png;
261
+ }
262
+ async frameResult() {
263
+ const png = await this.capture();
264
+ return { frameId: ++this.frame, imageB64: Buffer.from(png).toString("base64"), mime: "image/png", ...pngSize(png) };
265
+ }
266
+ async screenSize() {
267
+ if (!this.size)
268
+ await this.capture();
269
+ if (!this.size)
270
+ throw new Error("could not read the screen size from a screenshot");
271
+ return this.size;
272
+ }
273
+ async perform(a) {
274
+ const client = await this.sdk();
275
+ const id = this.nativeId;
276
+ switch (a.action) {
277
+ case "screenshot":
278
+ return {};
279
+ case "left_click": {
280
+ if (!a.coordinate)
281
+ throw new Error("left_click needs coordinate (a phone has no cursor)");
282
+ if (a.modifier)
283
+ throw new Error("mobilerun taps can't hold modifier keys");
284
+ const [x, y] = a.coordinate;
285
+ await client.devices.actions.tap(id, { x: Math.round(x), y: Math.round(y) });
286
+ return {};
287
+ }
288
+ case "left_click_drag": {
289
+ if (!a.startCoordinate || !a.coordinate)
290
+ throw new Error("left_click_drag needs startCoordinate and coordinate (a phone has no cursor)");
291
+ const [sx, sy] = a.startCoordinate;
292
+ const [ex, ey] = a.coordinate;
293
+ await client.devices.actions.swipe(id, { startX: Math.round(sx), startY: Math.round(sy), endX: Math.round(ex), endY: Math.round(ey), duration: 600 });
294
+ return {};
295
+ }
296
+ case "scroll": {
297
+ if (!a.scrollDirection)
298
+ throw new Error("scroll needs scrollDirection");
299
+ const { width, height } = await this.screenSize();
300
+ const [cx, cy] = a.coordinate ?? [width / 2, height / 2];
301
+ const clicks = Math.min(10, Math.max(1, Math.round(a.scrollAmount ?? 3)));
302
+ const vertical = a.scrollDirection === "up" || a.scrollDirection === "down";
303
+ // One scroll click moves about 15% of the screen; the finger travels against the scroll direction.
304
+ const span = (vertical ? height : width) * Math.min(0.8, 0.15 * clicks);
305
+ const sign = a.scrollDirection === "down" || a.scrollDirection === "right" ? 1 : -1;
306
+ const clampX = (v) => Math.round(Math.min(width - 2, Math.max(1, v)));
307
+ const clampY = (v) => Math.round(Math.min(height - 2, Math.max(1, v)));
308
+ const [sx, sy, ex, ey] = vertical
309
+ ? [cx, cy + (sign * span) / 2, cx, cy - (sign * span) / 2]
310
+ : [cx + (sign * span) / 2, cy, cx - (sign * span) / 2, cy];
311
+ await client.devices.actions.swipe(id, { startX: clampX(sx), startY: clampY(sy), endX: clampX(ex), endY: clampY(ey), duration: 400 });
312
+ return {};
313
+ }
314
+ case "type":
315
+ if (!a.text)
316
+ throw new Error("type needs text");
317
+ await client.devices.keyboard.write(id, { text: a.text });
318
+ return {};
319
+ case "key": {
320
+ if (!a.text)
321
+ throw new Error("key needs text");
322
+ // xdotool accepts several space-separated keys: "Down Down Return".
323
+ const codes = a.text.trim().split(/\s+/).map(mobilerunKey);
324
+ for (let n = 0; n < Math.max(1, a.repeat ?? 1); n++) {
325
+ for (const key of codes)
326
+ await client.devices.keyboard.key(id, { key });
327
+ }
328
+ return {};
329
+ }
330
+ case "wait":
331
+ await sleep(seconds(a.duration ?? 1) * 1000);
332
+ return {};
333
+ case "right_click":
334
+ case "middle_click":
335
+ case "double_click":
336
+ case "triple_click":
337
+ case "mouse_move":
338
+ case "left_mouse_down":
339
+ case "left_mouse_up":
340
+ case "cursor_position":
341
+ throw new Error(`mobilerun does not support action "${a.action}": a touchscreen has no cursor or mouse buttons`);
342
+ case "hold_key":
343
+ throw new Error(`mobilerun does not support action "hold_key": keys are sent as single events`);
344
+ case "zoom":
345
+ throw new Error(`mobilerun does not support action "zoom": the API has no region capture`);
346
+ default:
347
+ throw new Error(`mobilerun does not support action "${a.action}"`);
348
+ }
349
+ }
350
+ async screenshot(opts = {}) {
351
+ if (opts.format === "jpeg")
352
+ throw new ProviderError(ID, "screenshots are PNG only");
353
+ try {
354
+ return await this.capture();
355
+ }
356
+ catch (err) {
357
+ throw toProviderError(err);
358
+ }
359
+ }
360
+ async exec(_command, _timeoutSeconds) {
361
+ throw new ProviderError(ID, "no shell: the Mobilerun API has no command endpoint (adb shell is only available on request over the adb WebSocket tunnel, docs.mobilerun.ai/integrations/adb)");
362
+ }
363
+ /** Pulls a file from a device path, e.g. /sdcard/Download/report.pdf. */
364
+ async readFile(path) {
365
+ const client = await this.sdk();
366
+ try {
367
+ return await bytesOf(client.devices.files.download(this.nativeId, { path }, { headers: { "Accept-Encoding": "identity" } }));
368
+ }
369
+ catch (err) {
370
+ throw toProviderError(err);
371
+ }
372
+ }
373
+ /** The API uploads into a directory under the file's own name, so split the path. */
374
+ async writeFile(path, content) {
375
+ const name = path.slice(path.lastIndexOf("/") + 1);
376
+ if (!name)
377
+ throw new ProviderError(ID, `writeFile needs a file path, got directory "${path}"`);
378
+ const client = await this.sdk();
379
+ const { toFile } = await optionalImport("@mobilerun/sdk", "mobilerun");
380
+ const file = await toFile(Buffer.from(toBytes(content)), name);
381
+ await guard(client.devices.files.upload(this.nativeId, { path: dirname(path), file }));
382
+ }
383
+ /**
384
+ * The device's WebRTC stream: `url` is the device's streamUrl and `token` its device-scoped
385
+ * streamToken, for `@mobilerun/react`'s DeviceStream (it is not a page a browser opens on its
386
+ * own). The credentials always carry control; `watch` is honoured only by the embedding UI
387
+ * (DeviceStream hasControl={false}). Mobilerun does not publish an expiry, so expiresAt is null.
388
+ */
389
+ async viewer(mode = "watch", _opts = {}) {
390
+ const d = await guard((await this.sdk()).devices.retrieve(this.nativeId));
391
+ if (!d.streamUrl || !d.streamToken)
392
+ throw new ProviderError(ID, `no live stream for device ${this.nativeId} (state: ${d.state})`);
393
+ return {
394
+ url: d.streamUrl,
395
+ token: d.streamToken,
396
+ viewOnlyEnforced: false,
397
+ client: "@mobilerun/react DeviceStream",
398
+ expiresAt: null,
399
+ };
400
+ }
401
+ /** Park: apps, data and identity kept, no minutes billed. connectComputer resumes it. 404 where capabilities.stop is false. */
402
+ async sleep() {
403
+ await guard((await this.sdk()).devices.stop(this.nativeId));
404
+ }
405
+ /** Terminate and release. Subscription-billed (dedicated) phones refuse this with 401. */
406
+ async delete() {
407
+ await guard((await this.sdk()).devices.terminate(this.nativeId));
408
+ }
409
+ }
410
+ function seconds(n) {
411
+ if (!(n >= 0 && n <= 100))
412
+ throw new Error("duration must be 0 to 100 seconds");
413
+ return n;
414
+ }
415
+ function sleep(ms) {
416
+ return new Promise((r) => setTimeout(r, ms).unref());
417
+ }
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
5
5
  import { ProviderError } from "../types.js";
6
- import { optionalImport, toBytes } from "../util.js";
6
+ import { DEFAULT_EXEC_TIMEOUT_S, killedByTimeout, optionalImport, timeoutError, toBytes } from "../util.js";
7
7
  const APP_NAME = process.env.SANDBOXROUTER_MODAL_APP ?? "sandboxrouter";
8
8
  /** Modal via the `modal` JS SDK. The only adapter here that takes `gpu`. */
9
9
  export class ModalProvider {
@@ -24,7 +24,8 @@ export class ModalProvider {
24
24
  const app = await modal.apps.fromName(APP_NAME, { createIfMissing: true });
25
25
  const image = modal.images.fromRegistry(spec.image?.modal ?? "python:3.13-slim");
26
26
  const sb = await modal.sandboxes.create(app, image, {
27
- cpu: spec.vcpu ?? DEFAULT_VCPU,
27
+ // Modal's cpu is physical cores (1 core = 2 vCPUs); the catalog and the spec count vCPUs.
28
+ cpu: (spec.vcpu ?? DEFAULT_VCPU) / 2,
28
29
  memoryMiB: Math.round((spec.memoryGb ?? DEFAULT_MEMORY_GB) * 1024),
29
30
  gpu: spec.gpu,
30
31
  timeoutMs: (spec.timeoutMinutes ?? 30) * 60_000,
@@ -57,16 +58,23 @@ class ModalBox {
57
58
  }
58
59
  async exec(cmd, opts = {}) {
59
60
  const t0 = Date.now();
61
+ const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
60
62
  try {
63
+ // Modal kills the process at timeoutMs; the kill shows up as a signal exit code.
61
64
  const p = await this.sb.exec(["sh", "-c", cmd], {
62
65
  workdir: opts.cwd,
63
66
  env: opts.env,
64
- timeoutMs: (opts.timeoutSeconds ?? 60) * 1000,
67
+ timeoutMs: seconds * 1000,
65
68
  });
66
69
  const [stdout, stderr, exitCode] = await Promise.all([drain(p.stdout, opts.onStdout), drain(p.stderr, opts.onStderr), p.wait()]);
67
- return { exitCode, stdout, stderr, durationMs: Date.now() - t0 };
70
+ const durationMs = Date.now() - t0;
71
+ if (killedByTimeout(exitCode, durationMs, seconds))
72
+ throw timeoutError("modal", seconds);
73
+ return { exitCode, stdout, stderr, durationMs };
68
74
  }
69
75
  catch (err) {
76
+ if (err instanceof ProviderError)
77
+ throw err;
70
78
  throw new ProviderError("modal", err?.message ?? String(err));
71
79
  }
72
80
  }
@@ -1,5 +1,5 @@
1
1
  import { ProviderError } from "../types.js";
2
- import { dirname, optionalImport, shq, toBytes, wrapCommand } from "../util.js";
2
+ import { DEFAULT_EXEC_TIMEOUT_S, dirname, guardTimeout, killedByTimeout, optionalImport, shq, timeoutError, toBytes, wrapCommand } from "../util.js";
3
3
  /** Base image for the default snapshot (Morph's minimal Ubuntu VM). */
4
4
  const BASE_IMAGE = "morphvm-minimal";
5
5
  const DISK_MB = 8192;
@@ -100,17 +100,25 @@ class MorphBox {
100
100
  }
101
101
  async exec(cmd, opts = {}) {
102
102
  const t0 = Date.now();
103
- const script = `[ -r ${ENV_FILE} ] && . ${ENV_FILE}; ${wrapCommand(cmd, { cwd: opts.cwd, env: opts.env })}`;
103
+ const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
104
+ // The VM kills the command at the deadline: the SDK's `timeout` only abandons the HTTP request
105
+ // and would leave the process running, so it is set past ours as a fallback.
106
+ const script = guardTimeout(`[ -r ${ENV_FILE} ] && . ${ENV_FILE}; ${wrapCommand(cmd, { cwd: opts.cwd, env: opts.env })}`, seconds);
104
107
  try {
105
108
  // With callbacks the SDK switches to its SSE endpoint and streams; otherwise one request.
106
109
  const r = await this.inst.exec(["sh", "-c", script], {
107
- timeout: opts.timeoutSeconds ?? 60,
110
+ timeout: seconds + 15,
108
111
  onStdout: opts.onStdout,
109
112
  onStderr: opts.onStderr,
110
113
  });
111
- return { exitCode: r.exit_code, stdout: r.stdout ?? "", stderr: r.stderr ?? "", durationMs: Date.now() - t0 };
114
+ const durationMs = Date.now() - t0;
115
+ if (killedByTimeout(r.exit_code, durationMs, seconds))
116
+ throw timeoutError("morph", seconds);
117
+ return { exitCode: r.exit_code, stdout: r.stdout ?? "", stderr: r.stderr ?? "", durationMs };
112
118
  }
113
119
  catch (err) {
120
+ if (/timed out/i.test(err?.message ?? ""))
121
+ throw timeoutError("morph", seconds);
114
122
  throw wrap(err);
115
123
  }
116
124
  }