@sandboxrouter/sdk 0.1.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 +271 -62
- package/dist/bench.js +4 -4
- package/dist/catalog.d.ts +232 -3
- package/dist/catalog.js +569 -14
- package/dist/check.d.ts +28 -0
- package/dist/check.js +185 -0
- package/dist/cli.js +149 -12
- 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 +34 -2
- package/dist/index.js +34 -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.d.ts +25 -0
- package/dist/providers/boat.js +263 -0
- package/dist/providers/cloudflare.d.ts +30 -0
- package/dist/providers/cloudflare.js +152 -0
- 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.d.ts +17 -0
- package/dist/providers/docker.js +154 -0
- package/dist/providers/e2b.d.ts +70 -3
- package/dist/providers/e2b.js +376 -8
- package/dist/providers/exedev.d.ts +23 -0
- package/dist/providers/exedev.js +290 -0
- package/dist/providers/freestyle.d.ts +49 -0
- package/dist/providers/freestyle.js +261 -0
- package/dist/providers/hopx.d.ts +15 -0
- package/dist/providers/hopx.js +177 -0
- 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.d.ts +21 -0
- package/dist/providers/northflank.js +259 -0
- 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 +174 -25
- package/dist/server.d.ts +1 -1
- package/dist/server.js +22 -3
- package/dist/types.d.ts +18 -6
- package/dist/util.d.ts +28 -1
- package/dist/util.js +56 -7
- package/package.json +106 -17
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { ProviderError } from "../types.js";
|
|
3
|
-
import { optionalImport, toBytes, wrapCommand } from "../util.js";
|
|
4
|
-
/**
|
|
3
|
+
import { DEFAULT_EXEC_TIMEOUT_S, optionalImport, timeoutError, toBytes, wrapCommand } from "../util.js";
|
|
4
|
+
/**
|
|
5
|
+
* Daytona via `@daytonaio/sdk`. Default snapshot; set image.daytona to start from an image.
|
|
6
|
+
*
|
|
7
|
+
* Computers are Linux container sandboxes from a default snapshot (they ship Xvfb, xfce4,
|
|
8
|
+
* x11vnc and noVNC) with the desktop started through `computerUse.start()`. Windows sandboxes
|
|
9
|
+
* exist but need a Tier 3 organization plus a support request, so they aren't routed here.
|
|
10
|
+
* Pass `computerSnapshot` (e.g. "daytona-medium") for a bigger desktop than the 1 vCPU / 1 GiB default.
|
|
11
|
+
* Docs: https://www.daytona.io/docs/en/computer-use
|
|
12
|
+
*/
|
|
5
13
|
export class DaytonaProvider {
|
|
6
14
|
apiKey;
|
|
15
|
+
computerSnapshot;
|
|
7
16
|
id = "daytona";
|
|
17
|
+
computerOs = ["linux"];
|
|
8
18
|
client;
|
|
9
|
-
constructor(apiKey = process.env.DAYTONA_API_KEY) {
|
|
19
|
+
constructor(apiKey = process.env.DAYTONA_API_KEY, computerSnapshot) {
|
|
10
20
|
this.apiKey = apiKey;
|
|
21
|
+
this.computerSnapshot = computerSnapshot;
|
|
11
22
|
}
|
|
12
23
|
configured() {
|
|
13
24
|
return !!this.apiKey;
|
|
@@ -21,29 +32,93 @@ export class DaytonaProvider {
|
|
|
21
32
|
}
|
|
22
33
|
async create(spec) {
|
|
23
34
|
const d = await this.daytona();
|
|
35
|
+
// A per-create tag, so a sandbox Daytona made but couldn't start can be found and deleted: the
|
|
36
|
+
// SDK throws without handing it back, and it would otherwise sit in "error" state.
|
|
37
|
+
const tag = randomUUID();
|
|
24
38
|
const base = {
|
|
25
39
|
envVars: spec.env,
|
|
26
|
-
labels: spec.metadata,
|
|
27
|
-
// Minutes of inactivity before Daytona stops it;
|
|
40
|
+
labels: { ...spec.metadata, "sandboxrouter-create": tag },
|
|
41
|
+
// Minutes of inactivity before Daytona stops it; deleted a minute after it stops. Not 0:
|
|
42
|
+
// that makes the sandbox ephemeral, which is fixed at creation and rules out pause.
|
|
28
43
|
autoStopInterval: Math.max(1, Math.round(spec.timeoutMinutes ?? 30)),
|
|
29
|
-
autoDeleteInterval:
|
|
44
|
+
autoDeleteInterval: 1,
|
|
30
45
|
};
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
try {
|
|
47
|
+
const sbx = spec.image?.daytona
|
|
48
|
+
? await d.create({
|
|
49
|
+
...base,
|
|
50
|
+
image: spec.image.daytona,
|
|
51
|
+
resources: { cpu: spec.vcpu, memory: spec.memoryGb },
|
|
52
|
+
})
|
|
53
|
+
: await d.create(base);
|
|
54
|
+
return new DaytonaBox(sbx);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
await this.discardTagged(d, tag);
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async discardTagged(d, tag) {
|
|
62
|
+
try {
|
|
63
|
+
for await (const sbx of d.list({ labels: { "sandboxrouter-create": tag } }))
|
|
64
|
+
await sbx.delete().catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* best effort: the create error is the one worth reporting */
|
|
68
|
+
}
|
|
39
69
|
}
|
|
40
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Reattach. Daytona stops, archives or pauses idle sandboxes on its own schedule (and pause()
|
|
72
|
+
* stops them), so whatever state the sandbox is in, it is brought back to started first.
|
|
73
|
+
*/
|
|
41
74
|
async connect(nativeId) {
|
|
42
75
|
const sbx = await (await this.daytona()).get(nativeId);
|
|
43
|
-
|
|
44
|
-
await sbx.start();
|
|
76
|
+
await ensureStarted(sbx);
|
|
45
77
|
return new DaytonaBox(sbx);
|
|
46
78
|
}
|
|
79
|
+
/** A new Linux desktop. `externalUserId` is stored as a label only: every call creates a new sandbox. */
|
|
80
|
+
async createComputer(spec) {
|
|
81
|
+
const os = spec.os ?? "linux";
|
|
82
|
+
if (os !== "linux")
|
|
83
|
+
throw new ProviderError(this.id, `no ${os} computers (Windows needs a Tier 3 org and a support request)`);
|
|
84
|
+
const labels = { "sandboxrouter.kind": "computer" };
|
|
85
|
+
if (spec.externalUserId)
|
|
86
|
+
labels.externalUserId = spec.externalUserId;
|
|
87
|
+
const envVars = {};
|
|
88
|
+
if (spec.screen) {
|
|
89
|
+
const { width, height } = spec.screen;
|
|
90
|
+
// Daytona silently falls back to 1024x768 on a bad value, so refuse it here instead.
|
|
91
|
+
if (!(width >= 640 && width <= 7680 && height >= 480 && height <= 4320)) {
|
|
92
|
+
throw new ProviderError(this.id, `screen ${width}x${height} is outside 640-7680 x 480-4320`);
|
|
93
|
+
}
|
|
94
|
+
envVars.VNC_RESOLUTION = `${Math.round(width)}x${Math.round(height)}`;
|
|
95
|
+
}
|
|
96
|
+
const d = await this.daytona();
|
|
97
|
+
const sbx = await d.create({
|
|
98
|
+
name: spec.name,
|
|
99
|
+
snapshot: this.computerSnapshot,
|
|
100
|
+
labels,
|
|
101
|
+
envVars: Object.keys(envVars).length ? envVars : undefined,
|
|
102
|
+
autoStopInterval: COMPUTER_IDLE_MINUTES,
|
|
103
|
+
// Keep the disk when it stops (idle or sleep()), so files and logins survive.
|
|
104
|
+
autoDeleteInterval: -1,
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
await startDesktop(sbx);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
await sbx.delete().catch(() => { });
|
|
111
|
+
throw new ProviderError(this.id, `desktop did not start: ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
return new DaytonaComputer(sbx);
|
|
114
|
+
}
|
|
115
|
+
/** Reattach; a stopped, paused or archived computer is started again, desktop included. */
|
|
116
|
+
async connectComputer(nativeId) {
|
|
117
|
+
const sbx = await (await this.daytona()).get(nativeId);
|
|
118
|
+
await ensureStarted(sbx);
|
|
119
|
+
await startDesktop(sbx);
|
|
120
|
+
return new DaytonaComputer(sbx);
|
|
121
|
+
}
|
|
47
122
|
}
|
|
48
123
|
class DaytonaBox {
|
|
49
124
|
sbx;
|
|
@@ -52,22 +127,45 @@ class DaytonaBox {
|
|
|
52
127
|
this.sbx = sbx;
|
|
53
128
|
this.nativeId = sbx.id;
|
|
54
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* executeCommand merges stderr into stdout, so stderr goes to a temp file that is read back.
|
|
132
|
+
* Daytona enforces the timeout itself: the command is stopped and the SDK throws a 408
|
|
133
|
+
* (DaytonaProcessExecutionTimeoutError). Streaming goes through a throwaway session instead,
|
|
134
|
+
* whose logs keep the two apart and whose deadline is enforced here: deleting the session ends
|
|
135
|
+
* the command. Sessions aren't used for every command because their logs end output that has no
|
|
136
|
+
* trailing newline with one (`printf hi` reads back as "hi\n").
|
|
137
|
+
*/
|
|
55
138
|
async exec(cmd, opts = {}) {
|
|
56
139
|
if (opts.onStdout || opts.onStderr)
|
|
57
|
-
return this.
|
|
140
|
+
return this.execSession(cmd, opts);
|
|
58
141
|
const t0 = Date.now();
|
|
142
|
+
const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
|
|
143
|
+
const errFile = `/tmp/sbr-stderr-${randomUUID()}`;
|
|
144
|
+
// cwd and env go in the command, so a missing cwd stops it from running, as in the session path.
|
|
145
|
+
const command = `{\n${wrapCommand(`(\n${cmd}\n)`, { cwd: opts.cwd, env: opts.env })}\n} 2>${errFile}`;
|
|
59
146
|
try {
|
|
60
|
-
const r = await this.sbx.process.executeCommand(
|
|
61
|
-
|
|
62
|
-
|
|
147
|
+
const r = await this.sbx.process.executeCommand(command, undefined, undefined, seconds);
|
|
148
|
+
let stderr = "";
|
|
149
|
+
try {
|
|
150
|
+
stderr = Buffer.from(await this.sbx.fs.downloadFile(errFile)).toString("utf8");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// No stderr file (e.g. the command removed /tmp): report none.
|
|
154
|
+
}
|
|
155
|
+
this.sbx.process.executeCommand(`rm -f ${errFile}`).catch(() => { });
|
|
156
|
+
return { exitCode: r.exitCode, stdout: r.result, stderr, durationMs: Date.now() - t0 };
|
|
63
157
|
}
|
|
64
158
|
catch (err) {
|
|
159
|
+
if (err?.name === "DaytonaProcessExecutionTimeoutError" || err?.constructor?.name === "DaytonaProcessExecutionTimeoutError" || err?.statusCode === 408) {
|
|
160
|
+
this.sbx.process.executeCommand(`rm -f ${errFile}`).catch(() => { });
|
|
161
|
+
throw timeoutError("daytona", seconds);
|
|
162
|
+
}
|
|
65
163
|
throw new ProviderError("daytona", err?.message ?? String(err));
|
|
66
164
|
}
|
|
67
165
|
}
|
|
68
|
-
|
|
69
|
-
async execStreaming(cmd, opts) {
|
|
166
|
+
async execSession(cmd, opts) {
|
|
70
167
|
const t0 = Date.now();
|
|
168
|
+
const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
|
|
71
169
|
const session = `sbr-${randomUUID()}`;
|
|
72
170
|
const proc = this.sbx.process;
|
|
73
171
|
let stdout = "";
|
|
@@ -75,7 +173,8 @@ class DaytonaBox {
|
|
|
75
173
|
try {
|
|
76
174
|
await proc.createSession(session);
|
|
77
175
|
const { cmdId } = await proc.executeSessionCommand(session, {
|
|
78
|
-
|
|
176
|
+
// A subshell, so `exit` in the command ends it rather than the session's shell.
|
|
177
|
+
command: wrapCommand(`(\n${cmd}\n)`, { cwd: opts.cwd, env: opts.env }),
|
|
79
178
|
runAsync: true,
|
|
80
179
|
});
|
|
81
180
|
const logs = proc.getSessionCommandLogs(session, cmdId, (c) => {
|
|
@@ -85,10 +184,11 @@ class DaytonaBox {
|
|
|
85
184
|
stderr += c;
|
|
86
185
|
opts.onStderr?.(c);
|
|
87
186
|
});
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
throw
|
|
187
|
+
const timedOut = await Promise.race([logs.then(() => false), sleep(seconds * 1000).then(() => true)]);
|
|
188
|
+
if (timedOut) {
|
|
189
|
+
logs.catch(() => { });
|
|
190
|
+
throw timeoutError("daytona", seconds);
|
|
191
|
+
}
|
|
92
192
|
const done = await proc.getSessionCommand(session, cmdId);
|
|
93
193
|
return { exitCode: done.exitCode ?? -1, stdout, stderr, durationMs: Date.now() - t0 };
|
|
94
194
|
}
|
|
@@ -116,6 +216,327 @@ class DaytonaBox {
|
|
|
116
216
|
await this.sbx.delete();
|
|
117
217
|
}
|
|
118
218
|
}
|
|
219
|
+
/** How long start() may take from stopped or paused (seconds). */
|
|
220
|
+
const START_TIMEOUT_S = 60;
|
|
221
|
+
/** Starting an archived sandbox restores its disk from storage first, which grows with its size. */
|
|
222
|
+
const ARCHIVED_START_TIMEOUT_S = 600;
|
|
223
|
+
/** States Daytona moves through on its own; the sandbox is polled until it leaves them. */
|
|
224
|
+
const SETTLING = new Set(["stopping", "pausing", "archiving", "snapshotting", "resizing", "forking"]);
|
|
225
|
+
/** States on the way to started: someone else already woke it, so wait for that rather than start twice. */
|
|
226
|
+
const WAKING = new Set(["starting", "resuming", "restoring", "pulling_snapshot", "creating"]);
|
|
227
|
+
/**
|
|
228
|
+
* Bring a sandbox to "started" from any state Daytona can leave it in: stopped and paused get a
|
|
229
|
+
* plain start, archived gets a long one, transitional states are waited out, and anything that
|
|
230
|
+
* cannot be started (destroyed, error, build failures) is an error up front.
|
|
231
|
+
*/
|
|
232
|
+
async function ensureStarted(sbx) {
|
|
233
|
+
const deadline = Date.now() + ARCHIVED_START_TIMEOUT_S * 1000;
|
|
234
|
+
while (SETTLING.has(String(sbx.state))) {
|
|
235
|
+
if (Date.now() > deadline)
|
|
236
|
+
throw new ProviderError("daytona", `sandbox ${sbx.id} is still ${sbx.state} after ${ARCHIVED_START_TIMEOUT_S}s`);
|
|
237
|
+
await sleep(1000);
|
|
238
|
+
await sbx.refreshData();
|
|
239
|
+
}
|
|
240
|
+
const state = String(sbx.state);
|
|
241
|
+
if (state === "started")
|
|
242
|
+
return;
|
|
243
|
+
if (WAKING.has(state))
|
|
244
|
+
return sbx.waitUntilStarted(ARCHIVED_START_TIMEOUT_S);
|
|
245
|
+
if (state === "archived")
|
|
246
|
+
return sbx.start(ARCHIVED_START_TIMEOUT_S);
|
|
247
|
+
if (state === "stopped" || state === "paused")
|
|
248
|
+
return sbx.start(START_TIMEOUT_S);
|
|
249
|
+
throw new ProviderError("daytona", `sandbox ${sbx.id} is in state "${state}" and cannot be started`);
|
|
250
|
+
}
|
|
251
|
+
/** Minutes without activity before Daytona stops a computer (disk kept, billing stops). */
|
|
252
|
+
const COMPUTER_IDLE_MINUTES = 30;
|
|
253
|
+
/** Where the default snapshots serve noVNC. */
|
|
254
|
+
const NOVNC_PORT = 6080;
|
|
255
|
+
async function startDesktop(sbx) {
|
|
256
|
+
const cu = sbx.computerUse;
|
|
257
|
+
if ((await cu.getStatus().catch(() => undefined))?.status === "active")
|
|
258
|
+
return;
|
|
259
|
+
await cu.start();
|
|
260
|
+
for (let i = 0; i < 30; i++) {
|
|
261
|
+
if ((await cu.getStatus())?.status === "active")
|
|
262
|
+
return;
|
|
263
|
+
await sleep(500);
|
|
264
|
+
}
|
|
265
|
+
throw new Error("computer use processes are not active after 15s");
|
|
266
|
+
}
|
|
267
|
+
/** Width and height from a PNG's IHDR chunk. */
|
|
268
|
+
function pngSize(b64) {
|
|
269
|
+
const head = Buffer.from(b64.slice(0, 44), "base64");
|
|
270
|
+
if (head.length < 24 || head.readUInt32BE(0) !== 0x89504e47)
|
|
271
|
+
return {};
|
|
272
|
+
return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) };
|
|
273
|
+
}
|
|
274
|
+
/** xdotool key names to Daytona's (which already lowercases and maps Return, control, meta, win, option). */
|
|
275
|
+
const KEY_ALIASES = {
|
|
276
|
+
return: "enter",
|
|
277
|
+
kp_enter: "num_enter",
|
|
278
|
+
esc: "escape",
|
|
279
|
+
back_space: "backspace",
|
|
280
|
+
prior: "pageup",
|
|
281
|
+
page_up: "pageup",
|
|
282
|
+
next: "pagedown",
|
|
283
|
+
page_down: "pagedown",
|
|
284
|
+
caps_lock: "capslock",
|
|
285
|
+
num_lock: "num_lock",
|
|
286
|
+
super: "cmd",
|
|
287
|
+
super_l: "cmd",
|
|
288
|
+
super_r: "cmd",
|
|
289
|
+
meta_l: "cmd",
|
|
290
|
+
meta_r: "cmd",
|
|
291
|
+
control_l: "ctrl",
|
|
292
|
+
control_r: "ctrl",
|
|
293
|
+
ctrl_l: "ctrl",
|
|
294
|
+
ctrl_r: "ctrl",
|
|
295
|
+
alt_l: "alt",
|
|
296
|
+
alt_r: "alt",
|
|
297
|
+
shift_l: "shift",
|
|
298
|
+
shift_r: "shift",
|
|
299
|
+
kp_add: "num_plus",
|
|
300
|
+
kp_subtract: "num_minus",
|
|
301
|
+
kp_multiply: "num_asterisk",
|
|
302
|
+
kp_divide: "num_slash",
|
|
303
|
+
kp_decimal: "num_decimal",
|
|
304
|
+
kp_equal: "num_equal",
|
|
305
|
+
minus: "-",
|
|
306
|
+
equal: "=",
|
|
307
|
+
bracketleft: "[",
|
|
308
|
+
bracketright: "]",
|
|
309
|
+
backslash: "\\",
|
|
310
|
+
semicolon: ";",
|
|
311
|
+
apostrophe: "'",
|
|
312
|
+
grave: "`",
|
|
313
|
+
comma: ",",
|
|
314
|
+
period: ".",
|
|
315
|
+
slash: "/",
|
|
316
|
+
};
|
|
317
|
+
/** @internal "ctrl+Return" -> ["ctrl", "enter"]. */
|
|
318
|
+
export function daytonaKeys(chord) {
|
|
319
|
+
return chord
|
|
320
|
+
.split("+")
|
|
321
|
+
.map((k) => k.trim())
|
|
322
|
+
.filter(Boolean)
|
|
323
|
+
.map((k) => {
|
|
324
|
+
const l = k.toLowerCase();
|
|
325
|
+
if (KEY_ALIASES[l])
|
|
326
|
+
return KEY_ALIASES[l];
|
|
327
|
+
const kp = /^kp_(\d)$/.exec(l);
|
|
328
|
+
return kp ? `num${kp[1]}` : l;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
const MOUSE_BUTTON = {
|
|
332
|
+
left_click: { button: "left", clicks: 1 },
|
|
333
|
+
right_click: { button: "right", clicks: 1 },
|
|
334
|
+
middle_click: { button: "middle", clicks: 1 },
|
|
335
|
+
double_click: { button: "left", clicks: 2 },
|
|
336
|
+
triple_click: { button: "left", clicks: 3 },
|
|
337
|
+
};
|
|
338
|
+
/** A Daytona desktop driven through `sandbox.computerUse`. Coordinates are real screen pixels. */
|
|
339
|
+
class DaytonaComputer {
|
|
340
|
+
sbx;
|
|
341
|
+
nativeId;
|
|
342
|
+
frame = 0;
|
|
343
|
+
constructor(sbx) {
|
|
344
|
+
this.sbx = sbx;
|
|
345
|
+
this.nativeId = sbx.id;
|
|
346
|
+
}
|
|
347
|
+
async info() {
|
|
348
|
+
await this.sbx.refreshData();
|
|
349
|
+
const state = String(this.sbx.state ?? "unknown");
|
|
350
|
+
const status = state === "started" ? "ready" : ["stopped", "archived", "paused"].includes(state) ? "sleeping" : state;
|
|
351
|
+
const info = { id: this.nativeId, os: "linux", status, externalUserId: this.sbx.labels?.externalUserId ?? null };
|
|
352
|
+
if (state === "started") {
|
|
353
|
+
const d = (await this.sbx.computerUse.display.getInfo().catch(() => undefined))?.displays?.[0];
|
|
354
|
+
if (d?.width && d.height)
|
|
355
|
+
info.screen = { width: d.width, height: d.height };
|
|
356
|
+
}
|
|
357
|
+
return info;
|
|
358
|
+
}
|
|
359
|
+
async act(a) {
|
|
360
|
+
try {
|
|
361
|
+
const extra = await this.perform(a);
|
|
362
|
+
if (a.action === "zoom" || a.noScreenshot)
|
|
363
|
+
return { action: a.action, ok: true, ...extra };
|
|
364
|
+
return { action: a.action, ok: true, ...extra, ...(await this.frameResult()) };
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
return { action: a.action, ok: false, error: err?.message ?? String(err) };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/** Runs in order and stops at the first error; only the last action returns a screenshot. */
|
|
371
|
+
async actBatch(actions) {
|
|
372
|
+
const out = [];
|
|
373
|
+
for (const [i, a] of actions.entries()) {
|
|
374
|
+
const r = await this.act(i === actions.length - 1 ? a : { ...a, noScreenshot: a.action === "zoom" ? a.noScreenshot : true });
|
|
375
|
+
out.push(r);
|
|
376
|
+
if (!r.ok)
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
async frameResult() {
|
|
382
|
+
const shot = await this.sbx.computerUse.screenshot.takeFullScreen();
|
|
383
|
+
if (!shot.screenshot)
|
|
384
|
+
throw new Error("screenshot came back empty");
|
|
385
|
+
return { frameId: ++this.frame, imageB64: shot.screenshot, mime: "image/png", ...pngSize(shot.screenshot) };
|
|
386
|
+
}
|
|
387
|
+
async here(c) {
|
|
388
|
+
if (c)
|
|
389
|
+
return c;
|
|
390
|
+
const p = await this.sbx.computerUse.mouse.getPosition();
|
|
391
|
+
return [p.x ?? 0, p.y ?? 0];
|
|
392
|
+
}
|
|
393
|
+
/** One canonical action to SDK calls. Returns extra result fields. */
|
|
394
|
+
async perform(a) {
|
|
395
|
+
const { mouse, keyboard, screenshot } = this.sbx.computerUse;
|
|
396
|
+
const mods = a.modifier ? daytonaKeys(a.modifier) : undefined;
|
|
397
|
+
const click = MOUSE_BUTTON[a.action];
|
|
398
|
+
if (click) {
|
|
399
|
+
const [x, y] = await this.here(a.coordinate);
|
|
400
|
+
await mouse.click(x, y, click.button, false, click.clicks, mods);
|
|
401
|
+
return {};
|
|
402
|
+
}
|
|
403
|
+
switch (a.action) {
|
|
404
|
+
case "screenshot":
|
|
405
|
+
return {};
|
|
406
|
+
case "mouse_move": {
|
|
407
|
+
if (!a.coordinate)
|
|
408
|
+
throw new Error("mouse_move needs coordinate");
|
|
409
|
+
await mouse.move(...a.coordinate);
|
|
410
|
+
return {};
|
|
411
|
+
}
|
|
412
|
+
case "left_click_drag": {
|
|
413
|
+
if (!a.coordinate)
|
|
414
|
+
throw new Error("left_click_drag needs coordinate");
|
|
415
|
+
const [sx, sy] = await this.here(a.startCoordinate);
|
|
416
|
+
await mouse.drag(sx, sy, a.coordinate[0], a.coordinate[1], "left", mods);
|
|
417
|
+
return {};
|
|
418
|
+
}
|
|
419
|
+
case "left_mouse_down":
|
|
420
|
+
await mouse.down(a.coordinate?.[0], a.coordinate?.[1], "left");
|
|
421
|
+
return {};
|
|
422
|
+
case "left_mouse_up":
|
|
423
|
+
await mouse.up(a.coordinate?.[0], a.coordinate?.[1], "left");
|
|
424
|
+
return {};
|
|
425
|
+
case "scroll": {
|
|
426
|
+
if (!a.scrollDirection)
|
|
427
|
+
throw new Error("scroll needs scrollDirection");
|
|
428
|
+
const [x, y] = await this.here(a.coordinate);
|
|
429
|
+
await mouse.scroll(x, y, a.scrollDirection, a.scrollAmount ?? 3, mods);
|
|
430
|
+
return {};
|
|
431
|
+
}
|
|
432
|
+
case "type":
|
|
433
|
+
if (!a.text)
|
|
434
|
+
throw new Error("type needs text");
|
|
435
|
+
await keyboard.type(a.text);
|
|
436
|
+
return {};
|
|
437
|
+
case "key": {
|
|
438
|
+
if (!a.text)
|
|
439
|
+
throw new Error("key needs text");
|
|
440
|
+
// xdotool accepts several space-separated chords: "ctrl+a Delete".
|
|
441
|
+
const chords = a.text.trim().split(/\s+/).map(daytonaKeys);
|
|
442
|
+
for (let n = 0; n < Math.max(1, a.repeat ?? 1); n++) {
|
|
443
|
+
for (const keys of chords)
|
|
444
|
+
await keyboard.press(keys[keys.length - 1], keys.slice(0, -1));
|
|
445
|
+
}
|
|
446
|
+
return {};
|
|
447
|
+
}
|
|
448
|
+
case "hold_key": {
|
|
449
|
+
if (!a.text)
|
|
450
|
+
throw new Error("hold_key needs text");
|
|
451
|
+
const keys = daytonaKeys(a.text);
|
|
452
|
+
const held = [];
|
|
453
|
+
try {
|
|
454
|
+
for (const k of keys) {
|
|
455
|
+
await keyboard.down(k);
|
|
456
|
+
held.push(k);
|
|
457
|
+
}
|
|
458
|
+
await sleep(seconds(a.duration ?? 1) * 1000);
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
for (const k of held.reverse())
|
|
462
|
+
await keyboard.up(k).catch(() => { });
|
|
463
|
+
}
|
|
464
|
+
return {};
|
|
465
|
+
}
|
|
466
|
+
case "wait":
|
|
467
|
+
await sleep(seconds(a.duration ?? 1) * 1000);
|
|
468
|
+
return {};
|
|
469
|
+
case "zoom": {
|
|
470
|
+
if (!a.region)
|
|
471
|
+
throw new Error("zoom needs region [x0, y0, x1, y1]");
|
|
472
|
+
const [x0, y0, x1, y1] = a.region;
|
|
473
|
+
if (x1 <= x0 || y1 <= y0)
|
|
474
|
+
throw new Error("zoom region must have x1 > x0 and y1 > y0");
|
|
475
|
+
const shot = await screenshot.takeRegion({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
|
|
476
|
+
if (!shot.screenshot)
|
|
477
|
+
throw new Error("screenshot came back empty");
|
|
478
|
+
// Region crop at native resolution; the image is the result.
|
|
479
|
+
return { frameId: ++this.frame, imageB64: shot.screenshot, mime: "image/png", ...pngSize(shot.screenshot) };
|
|
480
|
+
}
|
|
481
|
+
case "cursor_position": {
|
|
482
|
+
const p = await mouse.getPosition();
|
|
483
|
+
return { coordinate: [p.x ?? 0, p.y ?? 0] };
|
|
484
|
+
}
|
|
485
|
+
default:
|
|
486
|
+
throw new Error(`daytona does not support action "${a.action}"`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async screenshot(opts = {}) {
|
|
490
|
+
const s = this.sbx.computerUse.screenshot;
|
|
491
|
+
const shot = opts.format === "jpeg" ? await s.takeCompressed({ format: "jpeg", quality: opts.quality }) : await s.takeFullScreen();
|
|
492
|
+
if (!shot.screenshot)
|
|
493
|
+
throw new ProviderError("daytona", "screenshot came back empty");
|
|
494
|
+
return new Uint8Array(Buffer.from(shot.screenshot, "base64"));
|
|
495
|
+
}
|
|
496
|
+
/** Daytona merges stderr into stdout here. */
|
|
497
|
+
async exec(command, timeoutSeconds = 60) {
|
|
498
|
+
try {
|
|
499
|
+
const r = await this.sbx.process.executeCommand(command, undefined, undefined, timeoutSeconds);
|
|
500
|
+
return { exitCode: r.exitCode, stdout: r.result, stderr: "" };
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
throw new ProviderError("daytona", err?.message ?? String(err));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async readFile(path) {
|
|
507
|
+
return new Uint8Array(await this.sbx.fs.downloadFile(path));
|
|
508
|
+
}
|
|
509
|
+
async writeFile(path, content) {
|
|
510
|
+
await this.sbx.fs.uploadFile(Buffer.from(toBytes(content)), path);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* noVNC through a signed preview URL (token in the URL, no headers needed). `watch` sets noVNC's
|
|
514
|
+
* view_only flag, which is enforced by the viewer page only: anyone holding the link can drop it.
|
|
515
|
+
*/
|
|
516
|
+
async viewer(mode = "watch", opts = {}) {
|
|
517
|
+
const ttl = Math.max(60, Math.round(opts.ttlSeconds ?? 3600));
|
|
518
|
+
const signed = await this.sbx.getSignedPreviewUrl(NOVNC_PORT, ttl);
|
|
519
|
+
const url = new URL(signed.url);
|
|
520
|
+
url.pathname = "/vnc.html";
|
|
521
|
+
url.searchParams.set("autoconnect", "true");
|
|
522
|
+
url.searchParams.set("resize", "scale");
|
|
523
|
+
url.searchParams.set("view_only", mode === "watch" ? "true" : "false");
|
|
524
|
+
return { url: url.toString(), expiresAt: new Date(Date.now() + ttl * 1000).toISOString() };
|
|
525
|
+
}
|
|
526
|
+
/** Stop: billing ends, the disk is kept. connectComputer starts it and its desktop again. */
|
|
527
|
+
async sleep() {
|
|
528
|
+
await this.sbx.setAutoDeleteInterval(-1);
|
|
529
|
+
await this.sbx.stop();
|
|
530
|
+
}
|
|
531
|
+
async delete() {
|
|
532
|
+
await this.sbx.delete();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function seconds(n) {
|
|
536
|
+
if (!(n >= 0 && n <= 100))
|
|
537
|
+
throw new Error("duration must be 0 to 100 seconds");
|
|
538
|
+
return n;
|
|
539
|
+
}
|
|
119
540
|
function sleep(ms) {
|
|
120
541
|
return new Promise((r) => setTimeout(r, ms).unref());
|
|
121
542
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
export declare class DedalusProvider implements Provider {
|
|
3
|
+
private apiKey;
|
|
4
|
+
private baseUrl;
|
|
5
|
+
readonly id: "dedalus";
|
|
6
|
+
constructor(apiKey?: string | undefined, baseUrl?: string);
|
|
7
|
+
configured(): boolean;
|
|
8
|
+
/** @internal */
|
|
9
|
+
api<T>(method: string, path: string, json?: unknown): Promise<T>;
|
|
10
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
11
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
12
|
+
}
|