@sandboxrouter/sdk 0.2.0 → 0.3.0
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 +224 -48
- package/dist/bench.js +4 -4
- package/dist/catalog.d.ts +159 -0
- package/dist/catalog.js +448 -15
- package/dist/check.d.ts +28 -0
- package/dist/check.js +185 -0
- package/dist/cli.js +148 -11
- package/dist/computers.d.ts +55 -13
- package/dist/computers.js +132 -6
- package/dist/conformance.d.ts +46 -0
- package/dist/conformance.js +65 -0
- package/dist/cost.d.ts +67 -0
- package/dist/cost.js +153 -0
- package/dist/gc.d.ts +29 -0
- package/dist/gc.js +54 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +28 -2
- package/dist/leaderboard.d.ts +6 -0
- package/dist/leaderboard.js +5 -4
- package/dist/ledger.d.ts +38 -0
- package/dist/ledger.js +89 -0
- package/dist/providers/agent37.d.ts +40 -0
- package/dist/providers/agent37.js +224 -0
- package/dist/providers/agentcore.d.ts +71 -0
- package/dist/providers/agentcore.js +446 -0
- package/dist/providers/alibaba.d.ts +18 -0
- package/dist/providers/alibaba.js +106 -0
- package/dist/providers/azure.d.ts +43 -0
- package/dist/providers/azure.js +330 -0
- package/dist/providers/beam.d.ts +11 -0
- package/dist/providers/beam.js +179 -0
- package/dist/providers/blaxel.d.ts +3 -1
- package/dist/providers/blaxel.js +32 -22
- package/dist/providers/boat.js +8 -3
- package/dist/providers/cloudflare.js +8 -3
- package/dist/providers/codesandbox.d.ts +59 -0
- package/dist/providers/codesandbox.js +287 -0
- package/dist/providers/daytona.d.ts +25 -4
- package/dist/providers/daytona.js +449 -28
- package/dist/providers/dedalus.d.ts +12 -0
- package/dist/providers/dedalus.js +244 -0
- package/dist/providers/deno.d.ts +50 -0
- package/dist/providers/deno.js +170 -0
- package/dist/providers/docker.js +11 -5
- package/dist/providers/e2b.d.ts +70 -3
- package/dist/providers/e2b.js +376 -8
- package/dist/providers/exedev.js +17 -5
- package/dist/providers/freestyle.d.ts +49 -0
- package/dist/providers/freestyle.js +261 -0
- package/dist/providers/hopx.js +27 -18
- package/dist/providers/instabox.d.ts +76 -0
- package/dist/providers/instabox.js +360 -0
- package/dist/providers/koyeb.d.ts +71 -0
- package/dist/providers/koyeb.js +379 -0
- package/dist/providers/lambda.d.ts +51 -0
- package/dist/providers/lambda.js +467 -0
- package/dist/providers/langsmith.d.ts +16 -0
- package/dist/providers/langsmith.js +149 -0
- package/dist/providers/limrun.d.ts +77 -0
- package/dist/providers/limrun.js +626 -0
- package/dist/providers/maritime.d.ts +5 -1
- package/dist/providers/maritime.js +39 -8
- package/dist/providers/mobilerun.d.ts +44 -0
- package/dist/providers/mobilerun.js +417 -0
- package/dist/providers/modal.js +12 -4
- package/dist/providers/morph.js +12 -4
- package/dist/providers/northflank.js +11 -13
- package/dist/providers/novita.d.ts +12 -0
- package/dist/providers/novita.js +81 -0
- package/dist/providers/openscale.d.ts +17 -0
- package/dist/providers/openscale.js +87 -0
- package/dist/providers/orgo.d.ts +45 -0
- package/dist/providers/orgo.js +414 -0
- package/dist/providers/sail.d.ts +32 -0
- package/dist/providers/sail.js +372 -0
- package/dist/providers/superserve.d.ts +25 -0
- package/dist/providers/superserve.js +337 -0
- package/dist/providers/tensorlake.js +17 -14
- package/dist/providers/upstash.d.ts +30 -0
- package/dist/providers/upstash.js +258 -0
- package/dist/providers/vercel.js +14 -3
- package/dist/providers/workspaces.d.ts +60 -0
- package/dist/providers/workspaces.js +662 -0
- package/dist/router.d.ts +36 -5
- package/dist/router.js +149 -19
- package/dist/server.d.ts +1 -1
- package/dist/server.js +22 -3
- package/dist/types.d.ts +13 -5
- package/dist/util.d.ts +28 -1
- package/dist/util.js +56 -7
- package/package.json +80 -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/providers/modal.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/dist/providers/morph.js
CHANGED
|
@@ -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
|
|
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:
|
|
110
|
+
timeout: seconds + 15,
|
|
108
111
|
onStdout: opts.onStdout,
|
|
109
112
|
onStderr: opts.onStderr,
|
|
110
113
|
});
|
|
111
|
-
|
|
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
|
}
|