@sandboxrouter/sdk 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +293 -2
- package/dist/bench-store.d.ts +12 -0
- package/dist/bench-store.js +23 -0
- package/dist/bench.d.ts +24 -0
- package/dist/bench.js +65 -0
- package/dist/catalog.d.ts +105 -0
- package/dist/catalog.js +320 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +195 -0
- package/dist/computers.d.ts +124 -0
- package/dist/computers.js +66 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/leaderboard.d.ts +39 -0
- package/dist/leaderboard.js +184 -0
- package/dist/providers/blaxel.d.ts +21 -0
- package/dist/providers/blaxel.js +152 -0
- package/dist/providers/boat.d.ts +25 -0
- package/dist/providers/boat.js +258 -0
- package/dist/providers/cloudflare.d.ts +30 -0
- package/dist/providers/cloudflare.js +147 -0
- package/dist/providers/daytona.d.ts +13 -0
- package/dist/providers/daytona.js +121 -0
- package/dist/providers/docker.d.ts +17 -0
- package/dist/providers/docker.js +148 -0
- package/dist/providers/e2b.d.ts +10 -0
- package/dist/providers/e2b.js +69 -0
- package/dist/providers/exedev.d.ts +23 -0
- package/dist/providers/exedev.js +278 -0
- package/dist/providers/hopx.d.ts +15 -0
- package/dist/providers/hopx.js +168 -0
- package/dist/providers/maritime.d.ts +38 -0
- package/dist/providers/maritime.js +240 -0
- package/dist/providers/modal.d.ts +10 -0
- package/dist/providers/modal.js +82 -0
- package/dist/providers/morph.d.ts +19 -0
- package/dist/providers/morph.js +181 -0
- package/dist/providers/northflank.d.ts +21 -0
- package/dist/providers/northflank.js +261 -0
- package/dist/providers/runloop.d.ts +16 -0
- package/dist/providers/runloop.js +145 -0
- package/dist/providers/sprites.d.ts +15 -0
- package/dist/providers/sprites.js +135 -0
- package/dist/providers/tensorlake.d.ts +14 -0
- package/dist/providers/tensorlake.js +192 -0
- package/dist/providers/vercel.d.ts +12 -0
- package/dist/providers/vercel.js +87 -0
- package/dist/router.d.ts +65 -0
- package/dist/router.js +199 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +257 -0
- package/dist/sse.d.ts +6 -0
- package/dist/sse.js +33 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +19 -0
- package/dist/util.d.ts +11 -0
- package/dist/util.js +37 -0
- package/package.json +127 -4
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { ProviderError } from "../types.js";
|
|
2
|
+
import { optionalImport, toBytes } from "../util.js";
|
|
3
|
+
/** Public template used when spec.image.hopx is not set. */
|
|
4
|
+
const DEFAULT_TEMPLATE = "code-interpreter";
|
|
5
|
+
/**
|
|
6
|
+
* HOPX via `@hopx-ai/sdk`. Auth: HOPX_API_KEY. vCPU and memory come from the template, not the
|
|
7
|
+
* create call (HOPX does not take custom resources at create). Set image.hopx to a template name
|
|
8
|
+
* (or a numeric template id) to start from your own; default is the public "code-interpreter".
|
|
9
|
+
*/
|
|
10
|
+
export class HopxProvider {
|
|
11
|
+
apiKey;
|
|
12
|
+
id = "hopx";
|
|
13
|
+
constructor(apiKey = process.env.HOPX_API_KEY) {
|
|
14
|
+
this.apiKey = apiKey;
|
|
15
|
+
}
|
|
16
|
+
configured() {
|
|
17
|
+
return !!this.apiKey;
|
|
18
|
+
}
|
|
19
|
+
async create(spec) {
|
|
20
|
+
const { Sandbox } = await optionalImport("@hopx-ai/sdk", "hopx");
|
|
21
|
+
const image = spec.image?.hopx ?? DEFAULT_TEMPLATE;
|
|
22
|
+
try {
|
|
23
|
+
const sbx = await Sandbox.create({
|
|
24
|
+
...(/^\d+$/.test(image) ? { templateId: image } : { template: image }),
|
|
25
|
+
apiKey: this.apiKey,
|
|
26
|
+
timeoutSeconds: Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60)),
|
|
27
|
+
envVars: spec.env,
|
|
28
|
+
});
|
|
29
|
+
return new HopxBox(sbx);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
throw fail(err);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Reattach; the SDK resumes a paused sandbox. */
|
|
36
|
+
async connect(nativeId) {
|
|
37
|
+
const { Sandbox } = await optionalImport("@hopx-ai/sdk", "hopx");
|
|
38
|
+
try {
|
|
39
|
+
return new HopxBox(await Sandbox.connect(nativeId, this.apiKey));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw fail(err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
class HopxBox {
|
|
47
|
+
sbx;
|
|
48
|
+
nativeId;
|
|
49
|
+
constructor(sbx) {
|
|
50
|
+
this.sbx = sbx;
|
|
51
|
+
this.nativeId = sbx.sandboxId;
|
|
52
|
+
}
|
|
53
|
+
async exec(cmd, opts = {}) {
|
|
54
|
+
if (opts.onStdout || opts.onStderr)
|
|
55
|
+
return this.execStreaming(cmd, opts);
|
|
56
|
+
const t0 = Date.now();
|
|
57
|
+
try {
|
|
58
|
+
const r = await this.sbx.commands.run(cmd, {
|
|
59
|
+
timeout: opts.timeoutSeconds ?? 60,
|
|
60
|
+
workingDir: opts.cwd,
|
|
61
|
+
env: opts.env,
|
|
62
|
+
});
|
|
63
|
+
// A nonzero exit comes back as a normal response with exit_code set.
|
|
64
|
+
return { exitCode: r.exit_code ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "", durationMs: Date.now() - t0 };
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw fail(err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Streams over the agent's WebSocket (/execute/stream) with the code run as bash. */
|
|
71
|
+
async execStreaming(cmd, opts) {
|
|
72
|
+
const t0 = Date.now();
|
|
73
|
+
const seconds = opts.timeoutSeconds ?? 60;
|
|
74
|
+
let stdout = "";
|
|
75
|
+
let stderr = "";
|
|
76
|
+
let exitCode;
|
|
77
|
+
const run = async () => {
|
|
78
|
+
const stream = this.sbx.runCodeStream(cmd, {
|
|
79
|
+
language: "bash",
|
|
80
|
+
timeout: seconds,
|
|
81
|
+
workingDir: opts.cwd,
|
|
82
|
+
env: opts.env,
|
|
83
|
+
});
|
|
84
|
+
for await (const m of stream) {
|
|
85
|
+
if (m.type === "stdout") {
|
|
86
|
+
stdout += m.data;
|
|
87
|
+
opts.onStdout?.(m.data);
|
|
88
|
+
}
|
|
89
|
+
else if (m.type === "stderr") {
|
|
90
|
+
stderr += m.data;
|
|
91
|
+
opts.onStderr?.(m.data);
|
|
92
|
+
}
|
|
93
|
+
else if (m.type === "result") {
|
|
94
|
+
exitCode = m.exit_code;
|
|
95
|
+
}
|
|
96
|
+
else if (m.type === "error") {
|
|
97
|
+
const msg = m.code === "EXECUTION_TIMEOUT" ? `command timed out after ${seconds}s` : m.error;
|
|
98
|
+
throw new ProviderError("hopx", msg);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
// The agent enforces the timeout; the extra grace covers a stream that never closes.
|
|
104
|
+
const graceMs = (seconds + 30) * 1000;
|
|
105
|
+
const timedOut = await Promise.race([run().then(() => false), sleep(graceMs).then(() => true)]);
|
|
106
|
+
if (timedOut)
|
|
107
|
+
throw new ProviderError("hopx", `command timed out after ${seconds}s`);
|
|
108
|
+
if (exitCode === undefined)
|
|
109
|
+
throw new ProviderError("hopx", "stream closed before the command finished");
|
|
110
|
+
return { exitCode, stdout, stderr, durationMs: Date.now() - t0 };
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
throw fail(err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async writeFile(path, content) {
|
|
117
|
+
try {
|
|
118
|
+
await this.sbx.files.writeBytes(path, Buffer.from(toBytes(content)));
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
throw fail(err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async readFile(path) {
|
|
125
|
+
try {
|
|
126
|
+
return new Uint8Array(await this.sbx.files.readBytes(path));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
throw fail(err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Pause with memory and disk kept. router.get() (Sandbox.connect) resumes it. */
|
|
133
|
+
async pause() {
|
|
134
|
+
try {
|
|
135
|
+
await this.sbx.pause();
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
throw fail(err);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async kill() {
|
|
142
|
+
try {
|
|
143
|
+
await this.sbx.kill();
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
throw fail(err);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** HTTP status for SDK errors that drop it (only APIError keeps statusCode). */
|
|
151
|
+
const STATUS_BY_NAME = {
|
|
152
|
+
AuthenticationError: 401,
|
|
153
|
+
TokenExpiredError: 401,
|
|
154
|
+
RateLimitError: 429,
|
|
155
|
+
NotFoundError: 404,
|
|
156
|
+
TemplateNotFoundError: 404,
|
|
157
|
+
FileNotFoundError: 404,
|
|
158
|
+
SandboxExpiredError: 410,
|
|
159
|
+
};
|
|
160
|
+
function fail(err) {
|
|
161
|
+
if (err instanceof ProviderError)
|
|
162
|
+
return err;
|
|
163
|
+
const status = err?.statusCode ?? err?.response?.status ?? STATUS_BY_NAME[err?.name];
|
|
164
|
+
return new ProviderError("hopx", err?.message ?? String(err), status);
|
|
165
|
+
}
|
|
166
|
+
function sleep(ms) {
|
|
167
|
+
return new Promise((r) => setTimeout(r, ms).unref());
|
|
168
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
import type { ComputerOs, ComputerProvider, ComputerSpec, ProviderComputer } from "../computers.js";
|
|
3
|
+
/**
|
|
4
|
+
* Maritime over plain REST (no SDK needed).
|
|
5
|
+
* Control plane: https://api.maritime.sh (Bearer mk_ key)
|
|
6
|
+
* Data plane: sandbox.dataPlaneUrl (X-Maritime-Token: sandbox.secret)
|
|
7
|
+
* POST /commands run; with Accept: text/event-stream it streams stdout/stderr events
|
|
8
|
+
* GET|PUT /files?path= raw bytes
|
|
9
|
+
* Any data plane call resumes a paused sandbox.
|
|
10
|
+
* Docs: https://maritime.sh/docs/ephemeral-sandboxes
|
|
11
|
+
*/
|
|
12
|
+
export declare class MaritimeProvider implements Provider, ComputerProvider {
|
|
13
|
+
private apiKey;
|
|
14
|
+
private baseUrl;
|
|
15
|
+
readonly id: "maritime";
|
|
16
|
+
readonly computerOs: ComputerOs[];
|
|
17
|
+
constructor(apiKey?: string | undefined, baseUrl?: string);
|
|
18
|
+
configured(): boolean;
|
|
19
|
+
/** @internal */
|
|
20
|
+
request(method: string, url: string, init?: {
|
|
21
|
+
json?: unknown;
|
|
22
|
+
bytes?: Uint8Array;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
}): Promise<Response>;
|
|
25
|
+
/** @internal */
|
|
26
|
+
json<T>(method: string, url: string, init?: {
|
|
27
|
+
json?: unknown;
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
}): Promise<T>;
|
|
30
|
+
/** @internal */
|
|
31
|
+
control<T>(method: string, path: string, json?: unknown): Promise<T>;
|
|
32
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
33
|
+
/** @internal Control-plane call that returns the raw Response (binary bodies). */
|
|
34
|
+
controlRaw(method: string, path: string, bytes?: Uint8Array): Promise<Response>;
|
|
35
|
+
createComputer(spec: ComputerSpec): Promise<ProviderComputer>;
|
|
36
|
+
connectComputer(nativeId: string): Promise<ProviderComputer>;
|
|
37
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
|
|
2
|
+
import { ProviderError } from "../types.js";
|
|
3
|
+
import { toBytes } from "../util.js";
|
|
4
|
+
import { readSSE } from "../sse.js";
|
|
5
|
+
/**
|
|
6
|
+
* Maritime over plain REST (no SDK needed).
|
|
7
|
+
* Control plane: https://api.maritime.sh (Bearer mk_ key)
|
|
8
|
+
* Data plane: sandbox.dataPlaneUrl (X-Maritime-Token: sandbox.secret)
|
|
9
|
+
* POST /commands run; with Accept: text/event-stream it streams stdout/stderr events
|
|
10
|
+
* GET|PUT /files?path= raw bytes
|
|
11
|
+
* Any data plane call resumes a paused sandbox.
|
|
12
|
+
* Docs: https://maritime.sh/docs/ephemeral-sandboxes
|
|
13
|
+
*/
|
|
14
|
+
export class MaritimeProvider {
|
|
15
|
+
apiKey;
|
|
16
|
+
baseUrl;
|
|
17
|
+
id = "maritime";
|
|
18
|
+
computerOs = ["linux", "windows", "android"];
|
|
19
|
+
constructor(apiKey = process.env.MARITIME_API_KEY, baseUrl = process.env.MARITIME_API_URL ?? "https://api.maritime.sh") {
|
|
20
|
+
this.apiKey = apiKey;
|
|
21
|
+
this.baseUrl = baseUrl;
|
|
22
|
+
}
|
|
23
|
+
configured() {
|
|
24
|
+
return !!this.apiKey;
|
|
25
|
+
}
|
|
26
|
+
/** @internal */
|
|
27
|
+
async request(method, url, init = {}) {
|
|
28
|
+
const headers = { ...init.headers };
|
|
29
|
+
let body;
|
|
30
|
+
if (init.json !== undefined) {
|
|
31
|
+
headers["Content-Type"] = "application/json";
|
|
32
|
+
body = JSON.stringify(init.json);
|
|
33
|
+
}
|
|
34
|
+
else if (init.bytes) {
|
|
35
|
+
headers["Content-Type"] = "application/octet-stream";
|
|
36
|
+
body = init.bytes;
|
|
37
|
+
}
|
|
38
|
+
const res = await fetch(url, { method, headers, body });
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
let detail = text.slice(0, 300);
|
|
42
|
+
try {
|
|
43
|
+
const j = JSON.parse(text);
|
|
44
|
+
// Sandboxes: {code, message}. Computers: {error, message, retryAfterS}. Agents: {detail}.
|
|
45
|
+
const code = j?.code ?? (typeof j?.error === "string" ? j.error : undefined);
|
|
46
|
+
detail = code ? `${code}: ${j.message ?? ""}` : j?.detail ? JSON.stringify(j.detail) : detail;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* non-JSON body */
|
|
50
|
+
}
|
|
51
|
+
throw new ProviderError(this.id, `${method} ${new URL(url).pathname} -> ${res.status} ${detail}`, res.status);
|
|
52
|
+
}
|
|
53
|
+
return res;
|
|
54
|
+
}
|
|
55
|
+
/** @internal */
|
|
56
|
+
async json(method, url, init = {}) {
|
|
57
|
+
const text = await (await this.request(method, url, init)).text();
|
|
58
|
+
return (text ? JSON.parse(text) : {});
|
|
59
|
+
}
|
|
60
|
+
/** @internal */
|
|
61
|
+
control(method, path, json) {
|
|
62
|
+
if (!this.apiKey)
|
|
63
|
+
throw new ProviderError(this.id, "MARITIME_API_KEY is not set");
|
|
64
|
+
return this.json(method, `${this.baseUrl}${path}`, { json, headers: { Authorization: `Bearer ${this.apiKey}` } });
|
|
65
|
+
}
|
|
66
|
+
async create(spec) {
|
|
67
|
+
const timeLimit = clamp(Math.round(spec.timeoutMinutes ?? 30), 5, 1440);
|
|
68
|
+
const body = {
|
|
69
|
+
from: spec.image?.maritime ?? "base",
|
|
70
|
+
vcpu: clamp(Math.round(spec.vcpu ?? DEFAULT_VCPU), 1, 16),
|
|
71
|
+
memoryMib: clamp(Math.ceil(spec.memoryGb ?? DEFAULT_MEMORY_GB), 1, 64) * 1024,
|
|
72
|
+
timeLimitMinutes: timeLimit,
|
|
73
|
+
// The router promises a hard lifetime, so don't let the idle timer end it much earlier.
|
|
74
|
+
idleMinutes: clamp(timeLimit, 1, 60),
|
|
75
|
+
};
|
|
76
|
+
if (spec.env)
|
|
77
|
+
body.env = spec.env;
|
|
78
|
+
if (spec.metadata)
|
|
79
|
+
body.metadata = spec.metadata;
|
|
80
|
+
return new MaritimeSandbox(this, await this.control("POST", "/api/sandboxes/ephemeral", body));
|
|
81
|
+
}
|
|
82
|
+
/** @internal Control-plane call that returns the raw Response (binary bodies). */
|
|
83
|
+
controlRaw(method, path, bytes) {
|
|
84
|
+
if (!this.apiKey)
|
|
85
|
+
throw new ProviderError(this.id, "MARITIME_API_KEY is not set");
|
|
86
|
+
return this.request(method, `${this.baseUrl}${path}`, { bytes, headers: { Authorization: `Bearer ${this.apiKey}` } });
|
|
87
|
+
}
|
|
88
|
+
async createComputer(spec) {
|
|
89
|
+
const body = { os: spec.os ?? "linux" };
|
|
90
|
+
for (const k of ["externalUserId", "name", "screen", "arch"])
|
|
91
|
+
if (spec[k] !== undefined)
|
|
92
|
+
body[k] = spec[k];
|
|
93
|
+
const c = await this.control("POST", "/api/v1/computers", body);
|
|
94
|
+
return new MaritimeComputer(this, c.id);
|
|
95
|
+
}
|
|
96
|
+
async connectComputer(nativeId) {
|
|
97
|
+
const c = await this.control("GET", `/api/v1/computers/${encodeURIComponent(nativeId)}`);
|
|
98
|
+
return new MaritimeComputer(this, c.id);
|
|
99
|
+
}
|
|
100
|
+
async connect(nativeId) {
|
|
101
|
+
const sbx = await this.control("GET", `/v1/sandboxes/${encodeURIComponent(nativeId)}`);
|
|
102
|
+
if (!sbx.dataPlaneUrl || !sbx.secret)
|
|
103
|
+
throw new ProviderError(this.id, `sandbox ${nativeId} has no data plane (state: ${sbx.state})`);
|
|
104
|
+
return new MaritimeSandbox(this, sbx);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
class MaritimeSandbox {
|
|
108
|
+
p;
|
|
109
|
+
sbx;
|
|
110
|
+
nativeId;
|
|
111
|
+
constructor(p, sbx) {
|
|
112
|
+
this.p = p;
|
|
113
|
+
this.sbx = sbx;
|
|
114
|
+
this.nativeId = sbx.id;
|
|
115
|
+
}
|
|
116
|
+
/** Data plane call; on 401 re-reads the sandbox once in case the secret was rotated. */
|
|
117
|
+
async data(method, path, init = {}) {
|
|
118
|
+
const go = () => this.p.request(method, `${this.sbx.dataPlaneUrl}${path}`, {
|
|
119
|
+
...init,
|
|
120
|
+
headers: { ...init.headers, "X-Maritime-Token": this.sbx.secret ?? "" },
|
|
121
|
+
});
|
|
122
|
+
try {
|
|
123
|
+
return await go();
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (!(err instanceof ProviderError && err.status === 401))
|
|
127
|
+
throw err;
|
|
128
|
+
this.sbx = await this.p.control("GET", `/v1/sandboxes/${encodeURIComponent(this.nativeId)}`);
|
|
129
|
+
return go();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async exec(cmd, opts = {}) {
|
|
133
|
+
const t0 = Date.now();
|
|
134
|
+
const body = { cmd, cwd: opts.cwd, env: opts.env, timeoutSeconds: opts.timeoutSeconds ?? 60 };
|
|
135
|
+
const streaming = !!(opts.onStdout || opts.onStderr);
|
|
136
|
+
const res = await this.data("POST", "/commands", {
|
|
137
|
+
json: body,
|
|
138
|
+
headers: streaming ? { Accept: "text/event-stream" } : {},
|
|
139
|
+
});
|
|
140
|
+
let result;
|
|
141
|
+
if (streaming && res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
142
|
+
for await (const ev of readSSE(res)) {
|
|
143
|
+
const payload = JSON.parse(ev.data);
|
|
144
|
+
if (ev.event === "stdout")
|
|
145
|
+
opts.onStdout?.(payload);
|
|
146
|
+
else if (ev.event === "stderr")
|
|
147
|
+
opts.onStderr?.(payload);
|
|
148
|
+
else if (ev.event === "result")
|
|
149
|
+
result = payload;
|
|
150
|
+
else if (ev.event === "error")
|
|
151
|
+
throw new ProviderError("maritime", `${payload?.code ?? "error"}: ${payload?.message ?? ev.data}`);
|
|
152
|
+
}
|
|
153
|
+
if (!result)
|
|
154
|
+
throw new ProviderError("maritime", "output stream closed before the command finished");
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
result = JSON.parse(await res.text());
|
|
158
|
+
// Server answered without streaming: deliver the output in one chunk.
|
|
159
|
+
if (result.stdout)
|
|
160
|
+
opts.onStdout?.(result.stdout);
|
|
161
|
+
if (result.stderr)
|
|
162
|
+
opts.onStderr?.(result.stderr);
|
|
163
|
+
}
|
|
164
|
+
return { exitCode: result.exitCode ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "", durationMs: Date.now() - t0 };
|
|
165
|
+
}
|
|
166
|
+
async writeFile(path, content) {
|
|
167
|
+
await this.data("PUT", `/files?path=${encodeURIComponent(path)}`, { bytes: toBytes(content) });
|
|
168
|
+
}
|
|
169
|
+
async readFile(path) {
|
|
170
|
+
const res = await this.data("GET", `/files?path=${encodeURIComponent(path)}`);
|
|
171
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
172
|
+
}
|
|
173
|
+
async pause() {
|
|
174
|
+
await this.p.control("POST", `/v1/sandboxes/${encodeURIComponent(this.nativeId)}/pause`);
|
|
175
|
+
}
|
|
176
|
+
async kill() {
|
|
177
|
+
await this.p.control("DELETE", `/api/sandboxes/ephemeral/${encodeURIComponent(this.nativeId)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** A Maritime desktop or phone. REST: /api/v1/computers/{id}/... (camelCase fields). */
|
|
181
|
+
class MaritimeComputer {
|
|
182
|
+
p;
|
|
183
|
+
nativeId;
|
|
184
|
+
base;
|
|
185
|
+
constructor(p, nativeId) {
|
|
186
|
+
this.p = p;
|
|
187
|
+
this.nativeId = nativeId;
|
|
188
|
+
this.base = `/api/v1/computers/${encodeURIComponent(nativeId)}`;
|
|
189
|
+
}
|
|
190
|
+
info() {
|
|
191
|
+
return this.p.control("GET", this.base);
|
|
192
|
+
}
|
|
193
|
+
act(action) {
|
|
194
|
+
return this.p.control("POST", `${this.base}/actions`, action);
|
|
195
|
+
}
|
|
196
|
+
async actBatch(actions) {
|
|
197
|
+
const r = await this.p.control("POST", `${this.base}/actions`, { actions });
|
|
198
|
+
return r.results;
|
|
199
|
+
}
|
|
200
|
+
async screenshot(opts = {}) {
|
|
201
|
+
const q = new URLSearchParams();
|
|
202
|
+
if (opts.format)
|
|
203
|
+
q.set("format", opts.format);
|
|
204
|
+
if (opts.quality)
|
|
205
|
+
q.set("quality", String(opts.quality));
|
|
206
|
+
const res = await this.p.controlRaw("GET", `${this.base}/screenshot${q.size ? `?${q}` : ""}`);
|
|
207
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
208
|
+
}
|
|
209
|
+
async exec(command, timeoutSeconds = 30) {
|
|
210
|
+
const r = await this.p.control("POST", `${this.base}/exec`, {
|
|
211
|
+
command,
|
|
212
|
+
timeoutS: Math.min(30, timeoutSeconds),
|
|
213
|
+
});
|
|
214
|
+
return { exitCode: r.exitCode ?? -1, stdout: r.stdout, stderr: r.stderr };
|
|
215
|
+
}
|
|
216
|
+
async readFile(path) {
|
|
217
|
+
const res = await this.p.controlRaw("GET", `${this.base}/files?path=${encodeURIComponent(path)}`);
|
|
218
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
219
|
+
}
|
|
220
|
+
async writeFile(path, content) {
|
|
221
|
+
await this.p.controlRaw("PUT", `${this.base}/files?path=${encodeURIComponent(path)}`, toBytes(content));
|
|
222
|
+
}
|
|
223
|
+
viewer(mode = "watch", opts = {}) {
|
|
224
|
+
const body = { mode };
|
|
225
|
+
if (opts.ttlSeconds !== undefined)
|
|
226
|
+
body.ttlS = opts.ttlSeconds;
|
|
227
|
+
if (opts.reason !== undefined)
|
|
228
|
+
body.reason = opts.reason;
|
|
229
|
+
return this.p.control("POST", `${this.base}/viewer`, body);
|
|
230
|
+
}
|
|
231
|
+
async sleep() {
|
|
232
|
+
await this.p.control("POST", `${this.base}/sleep`);
|
|
233
|
+
}
|
|
234
|
+
async delete() {
|
|
235
|
+
await this.p.control("DELETE", this.base);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function clamp(n, lo, hi) {
|
|
239
|
+
return Math.min(hi, Math.max(lo, n));
|
|
240
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/** Modal via the `modal` JS SDK. The only adapter here that takes `gpu`. */
|
|
3
|
+
export declare class ModalProvider implements Provider {
|
|
4
|
+
readonly id: "modal";
|
|
5
|
+
private client?;
|
|
6
|
+
configured(): boolean;
|
|
7
|
+
private modal;
|
|
8
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
9
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
|
|
5
|
+
import { ProviderError } from "../types.js";
|
|
6
|
+
import { optionalImport, toBytes } from "../util.js";
|
|
7
|
+
const APP_NAME = process.env.SANDBOXROUTER_MODAL_APP ?? "sandboxrouter";
|
|
8
|
+
/** Modal via the `modal` JS SDK. The only adapter here that takes `gpu`. */
|
|
9
|
+
export class ModalProvider {
|
|
10
|
+
id = "modal";
|
|
11
|
+
client;
|
|
12
|
+
configured() {
|
|
13
|
+
return (!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) || existsSync(join(homedir(), ".modal.toml"));
|
|
14
|
+
}
|
|
15
|
+
async modal() {
|
|
16
|
+
if (!this.client) {
|
|
17
|
+
const { ModalClient } = await optionalImport("modal", "modal");
|
|
18
|
+
this.client = new ModalClient();
|
|
19
|
+
}
|
|
20
|
+
return this.client;
|
|
21
|
+
}
|
|
22
|
+
async create(spec) {
|
|
23
|
+
const modal = await this.modal();
|
|
24
|
+
const app = await modal.apps.fromName(APP_NAME, { createIfMissing: true });
|
|
25
|
+
const image = modal.images.fromRegistry(spec.image?.modal ?? "python:3.13-slim");
|
|
26
|
+
const sb = await modal.sandboxes.create(app, image, {
|
|
27
|
+
cpu: spec.vcpu ?? DEFAULT_VCPU,
|
|
28
|
+
memoryMiB: Math.round((spec.memoryGb ?? DEFAULT_MEMORY_GB) * 1024),
|
|
29
|
+
gpu: spec.gpu,
|
|
30
|
+
timeoutMs: (spec.timeoutMinutes ?? 30) * 60_000,
|
|
31
|
+
env: spec.env,
|
|
32
|
+
});
|
|
33
|
+
return new ModalBox(sb);
|
|
34
|
+
}
|
|
35
|
+
async connect(nativeId) {
|
|
36
|
+
return new ModalBox(await (await this.modal()).sandboxes.fromId(nativeId));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Read a text stream to the end, passing each chunk to `on`. */
|
|
40
|
+
async function drain(stream, on) {
|
|
41
|
+
const reader = stream.getReader();
|
|
42
|
+
let out = "";
|
|
43
|
+
for (;;) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done)
|
|
46
|
+
return out;
|
|
47
|
+
out += value;
|
|
48
|
+
on?.(value);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
class ModalBox {
|
|
52
|
+
sb;
|
|
53
|
+
nativeId;
|
|
54
|
+
constructor(sb) {
|
|
55
|
+
this.sb = sb;
|
|
56
|
+
this.nativeId = sb.sandboxId;
|
|
57
|
+
}
|
|
58
|
+
async exec(cmd, opts = {}) {
|
|
59
|
+
const t0 = Date.now();
|
|
60
|
+
try {
|
|
61
|
+
const p = await this.sb.exec(["sh", "-c", cmd], {
|
|
62
|
+
workdir: opts.cwd,
|
|
63
|
+
env: opts.env,
|
|
64
|
+
timeoutMs: (opts.timeoutSeconds ?? 60) * 1000,
|
|
65
|
+
});
|
|
66
|
+
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 };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
throw new ProviderError("modal", err?.message ?? String(err));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async writeFile(path, content) {
|
|
74
|
+
await this.sb.filesystem.writeBytes(toBytes(content), path);
|
|
75
|
+
}
|
|
76
|
+
async readFile(path) {
|
|
77
|
+
return await this.sb.filesystem.readBytes(path);
|
|
78
|
+
}
|
|
79
|
+
async kill() {
|
|
80
|
+
await this.sb.terminate();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Morph Cloud via `morphcloud`. Instances start from snapshots: by default a cached snapshot of
|
|
4
|
+
* morphvm-minimal sized to spec.vcpu / spec.memoryGb; set image.morph to a snapshot id to start
|
|
5
|
+
* from your own (its resources come from the snapshot).
|
|
6
|
+
*/
|
|
7
|
+
export declare class MorphProvider implements Provider {
|
|
8
|
+
private apiKey;
|
|
9
|
+
readonly id: "morph";
|
|
10
|
+
private client?;
|
|
11
|
+
constructor(apiKey?: string | undefined);
|
|
12
|
+
configured(): boolean;
|
|
13
|
+
private morph;
|
|
14
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
15
|
+
/** Reattach; a paused instance is resumed. */
|
|
16
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
17
|
+
/** Reuse (or create once) a morphvm-minimal snapshot with this shape, keyed by digest. */
|
|
18
|
+
private baseSnapshot;
|
|
19
|
+
}
|