@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
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ProviderError } from "../types.js";
|
|
3
|
+
import { toBytes } from "../util.js";
|
|
4
|
+
/**
|
|
5
|
+
* Orgo (orgo.ai): hosted Linux and Windows desktops, over plain REST (no SDK needed).
|
|
6
|
+
* Base https://www.orgo.ai/api, `Authorization: Bearer sk_live_...`, snake_case JSON.
|
|
7
|
+
* POST /computers create (returns once running); needs a workspace_id
|
|
8
|
+
* GET /computers/{id} status, resolution, project_id (= workspace)
|
|
9
|
+
* GET /computers/{id}/screenshot ?response_format=base64|binary&format=png|jpeg
|
|
10
|
+
* POST /computers/{id}/click|mouse-move|drag|type|key|scroll|wait|bash
|
|
11
|
+
* POST /computers/{id}/stop|start|resume
|
|
12
|
+
* GET /computers/{id}/vnc-password the per-computer VNC token (root on that computer)
|
|
13
|
+
* POST /files/upload (multipart, destPath) / POST /files/export -> signed url
|
|
14
|
+
* DELETE /computers/{id}
|
|
15
|
+
* Docs: https://docs.orgo.ai/api-reference/introduction, spec https://docs.orgo.ai/api-reference/openapi.json
|
|
16
|
+
*/
|
|
17
|
+
// ProviderId in types.ts is the sandbox-provider union; computer-only providers are plain strings.
|
|
18
|
+
const ID = "orgo";
|
|
19
|
+
/** A resume takes about a minute and a start up to a few; this is how long to poll for running. */
|
|
20
|
+
const RUNNING_TIMEOUT_MS = 5 * 60_000;
|
|
21
|
+
const RUNNING_POLL_MS = 1000;
|
|
22
|
+
/** Medium: 2 vCPU, 8 GB, 40 GB disk. The only size a free account can run, the dashboard default, and Windows needs 8 GB. */
|
|
23
|
+
const MEDIUM = { cpu: 2, ram: 8, disk_size_gb: 40 };
|
|
24
|
+
/** POST /files/upload caps a file at 10 MB. */
|
|
25
|
+
const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
|
26
|
+
export class OrgoProvider {
|
|
27
|
+
apiKey;
|
|
28
|
+
baseUrl;
|
|
29
|
+
workspaceId;
|
|
30
|
+
id = "orgo";
|
|
31
|
+
/**
|
|
32
|
+
* Linux on every account. Windows is created with `os: "windows"` but needs a Scale plan or a
|
|
33
|
+
* purchased Windows licence (else 403 WINDOWS_REQUIRES_SCALE) and is never offered by the hour.
|
|
34
|
+
* Android, macOS and iPhone are devices set up on request, not self-serve.
|
|
35
|
+
*/
|
|
36
|
+
computerOs = ["linux", "windows"];
|
|
37
|
+
workspace;
|
|
38
|
+
constructor(apiKey = process.env.ORGO_API_KEY, baseUrl = process.env.ORGO_API_URL ?? "https://www.orgo.ai/api",
|
|
39
|
+
/** Workspace to create computers in. Unset: the first workspace this key can write to. */
|
|
40
|
+
workspaceId = process.env.ORGO_WORKSPACE_ID) {
|
|
41
|
+
this.apiKey = apiKey;
|
|
42
|
+
this.baseUrl = baseUrl;
|
|
43
|
+
this.workspaceId = workspaceId;
|
|
44
|
+
this.baseUrl = this.baseUrl.replace(/\/+$/, "");
|
|
45
|
+
}
|
|
46
|
+
configured() {
|
|
47
|
+
return !!this.apiKey;
|
|
48
|
+
}
|
|
49
|
+
/** @internal Authenticated call to the Orgo API. Throws ProviderError with the HTTP status. */
|
|
50
|
+
async request(method, path, init = {}) {
|
|
51
|
+
if (!this.apiKey)
|
|
52
|
+
throw new ProviderError(ID, "ORGO_API_KEY is not set");
|
|
53
|
+
const headers = { Authorization: `Bearer ${this.apiKey}` };
|
|
54
|
+
let body;
|
|
55
|
+
if (init.json !== undefined) {
|
|
56
|
+
headers["Content-Type"] = "application/json";
|
|
57
|
+
body = JSON.stringify(init.json);
|
|
58
|
+
}
|
|
59
|
+
else if (init.form) {
|
|
60
|
+
body = init.form;
|
|
61
|
+
}
|
|
62
|
+
const q = init.query ? `?${new URLSearchParams(init.query)}` : "";
|
|
63
|
+
const res = await fetch(`${this.baseUrl}${path}${q}`, { method, headers, body });
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const text = await res.text();
|
|
66
|
+
let detail = text.slice(0, 300);
|
|
67
|
+
try {
|
|
68
|
+
// Every failure carries {error}; most add a machine-readable {code}.
|
|
69
|
+
const j = JSON.parse(text);
|
|
70
|
+
if (typeof j?.error === "string")
|
|
71
|
+
detail = j.code ? `${j.code}: ${j.error}` : j.error;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* non-JSON body */
|
|
75
|
+
}
|
|
76
|
+
throw new ProviderError(ID, `${method} ${path} -> ${res.status} ${detail}`, res.status);
|
|
77
|
+
}
|
|
78
|
+
return res;
|
|
79
|
+
}
|
|
80
|
+
/** @internal */
|
|
81
|
+
async json(method, path, init = {}) {
|
|
82
|
+
const text = await (await this.request(method, path, init)).text();
|
|
83
|
+
return (text ? JSON.parse(text) : {});
|
|
84
|
+
}
|
|
85
|
+
/** @internal The workspace new computers go in: ORGO_WORKSPACE_ID, else the first one the key can write to. */
|
|
86
|
+
resolveWorkspace() {
|
|
87
|
+
if (this.workspaceId)
|
|
88
|
+
return Promise.resolve(this.workspaceId);
|
|
89
|
+
this.workspace ??= (async () => {
|
|
90
|
+
// Owned workspaces come first. An account with none gets one made on this call.
|
|
91
|
+
const r = await this.json("GET", "/workspaces");
|
|
92
|
+
const ws = (r.workspaces ?? []).find((w) => w?.permissions?.canWrite !== false);
|
|
93
|
+
if (!ws?.id)
|
|
94
|
+
throw new ProviderError(ID, "no Orgo workspace this key can create computers in; set ORGO_WORKSPACE_ID");
|
|
95
|
+
return String(ws.id);
|
|
96
|
+
})();
|
|
97
|
+
this.workspace.catch(() => (this.workspace = undefined));
|
|
98
|
+
return this.workspace;
|
|
99
|
+
}
|
|
100
|
+
async createComputer(spec) {
|
|
101
|
+
const os = spec.os ?? "linux";
|
|
102
|
+
if (!this.computerOs.includes(os))
|
|
103
|
+
throw new ProviderError(ID, `no ${os} computers (Orgo self-serve is Linux and Windows)`);
|
|
104
|
+
const body = { workspace_id: await this.resolveWorkspace(), name: computerName(spec), os, ...MEDIUM };
|
|
105
|
+
if (spec.screen) {
|
|
106
|
+
if (os !== "linux")
|
|
107
|
+
throw new ProviderError(ID, "screen size is Linux only");
|
|
108
|
+
const { width, height } = spec.screen;
|
|
109
|
+
if (!(width >= 640 && width <= 1920 && height >= 640 && height <= 1920)) {
|
|
110
|
+
throw new ProviderError(ID, `screen ${width}x${height} is outside 640-1920 per side`);
|
|
111
|
+
}
|
|
112
|
+
body.resolution = `${Math.round(width)}x${Math.round(height)}x24`;
|
|
113
|
+
}
|
|
114
|
+
// Orgo has no metadata field, so externalUserId only shapes the (workspace-unique) name.
|
|
115
|
+
// Each create makes a new computer; reconnect with connectComputer(id) to keep files and logins.
|
|
116
|
+
let c = await this.json("POST", "/computers", { json: body });
|
|
117
|
+
if (c.status !== "running")
|
|
118
|
+
c = await this.waitRunning(`/computers/${encodeURIComponent(String(c.id))}`);
|
|
119
|
+
return new OrgoComputer(this, c, spec.externalUserId ?? null);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Reattach to a computer. A stopped (frozen) one is started, a suspended one resumed. Both answer
|
|
123
|
+
* 202 while the computer comes back, so it is polled until it reports running.
|
|
124
|
+
*/
|
|
125
|
+
async connectComputer(nativeId) {
|
|
126
|
+
const path = `/computers/${encodeURIComponent(nativeId)}`;
|
|
127
|
+
let c = await this.json("GET", path);
|
|
128
|
+
if (c.status === "frozen" || c.status === "stopped")
|
|
129
|
+
await this.json("POST", `${path}/start`);
|
|
130
|
+
else if (c.status === "suspended")
|
|
131
|
+
await this.json("POST", `${path}/resume`);
|
|
132
|
+
if (c.status !== "running")
|
|
133
|
+
c = await this.waitRunning(path);
|
|
134
|
+
return new OrgoComputer(this, c, null);
|
|
135
|
+
}
|
|
136
|
+
/** Poll a computer until it is running; a computer that errored or was deleted meanwhile is reported. */
|
|
137
|
+
async waitRunning(path) {
|
|
138
|
+
const deadline = Date.now() + RUNNING_TIMEOUT_MS;
|
|
139
|
+
for (;;) {
|
|
140
|
+
const c = await this.json("GET", path);
|
|
141
|
+
const status = String(c.status ?? "unknown");
|
|
142
|
+
if (status === "running")
|
|
143
|
+
return c;
|
|
144
|
+
if (["error", "failed", "deleted"].includes(status))
|
|
145
|
+
throw new ProviderError(ID, `computer ${path.split("/").pop()} is ${status}`);
|
|
146
|
+
if (Date.now() > deadline)
|
|
147
|
+
throw new ProviderError(ID, `computer ${path.split("/").pop()} still ${status} after ${RUNNING_TIMEOUT_MS / 1000}s`);
|
|
148
|
+
await new Promise((r) => setTimeout(r, RUNNING_POLL_MS).unref());
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** @internal wss:// origin for the computer's VNC WebSocket (same host as the API). */
|
|
152
|
+
wsOrigin() {
|
|
153
|
+
const u = new URL(this.baseUrl);
|
|
154
|
+
return `${u.protocol === "http:" ? "ws:" : "wss:"}//${u.host}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** A name unique within the workspace (80 chars max): spec.name as given, else from externalUserId. */
|
|
158
|
+
function computerName(spec) {
|
|
159
|
+
if (spec.name)
|
|
160
|
+
return spec.name.slice(0, 80);
|
|
161
|
+
const base = (spec.externalUserId ?? "computer").replace(/[^A-Za-z0-9_.-]+/g, "-").slice(0, 60);
|
|
162
|
+
return `${base}-${randomUUID().slice(0, 8)}`;
|
|
163
|
+
}
|
|
164
|
+
const BUTTON = {
|
|
165
|
+
left_click: { button: "left" },
|
|
166
|
+
right_click: { button: "right" },
|
|
167
|
+
middle_click: { button: "middle" },
|
|
168
|
+
double_click: { button: "left", double: true },
|
|
169
|
+
triple_click: { button: "left", repeat: 3 },
|
|
170
|
+
};
|
|
171
|
+
const MODIFIERS = new Set(["ctrl", "control", "alt", "option", "shift", "super", "cmd", "meta", "win"]);
|
|
172
|
+
/** "ctrl+shift" -> ["ctrl", "shift"], in the names /click accepts. */
|
|
173
|
+
function clickModifiers(m) {
|
|
174
|
+
const out = m
|
|
175
|
+
.split("+")
|
|
176
|
+
.map((k) => k.trim().toLowerCase().replace(/_[lr]$/, ""))
|
|
177
|
+
.filter(Boolean);
|
|
178
|
+
for (const k of out)
|
|
179
|
+
if (!MODIFIERS.has(k))
|
|
180
|
+
throw new Error(`orgo can't hold modifier "${k}" during a click`);
|
|
181
|
+
if (out.length > 4)
|
|
182
|
+
throw new Error("orgo holds at most 4 modifiers during a click");
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
function point(c, what) {
|
|
186
|
+
if (!c)
|
|
187
|
+
throw new Error(`${what} needs coordinate (orgo can't read the pointer position)`);
|
|
188
|
+
const [x, y] = c.map((n) => Math.round(n));
|
|
189
|
+
if (!(x >= 0 && y >= 0))
|
|
190
|
+
throw new Error(`${what} coordinate must be non-negative`);
|
|
191
|
+
return { x, y };
|
|
192
|
+
}
|
|
193
|
+
/** An Orgo desktop. Coordinates are real screen pixels (screenshots are full size). */
|
|
194
|
+
class OrgoComputer {
|
|
195
|
+
p;
|
|
196
|
+
externalUserId;
|
|
197
|
+
nativeId;
|
|
198
|
+
base;
|
|
199
|
+
os;
|
|
200
|
+
workspaceId;
|
|
201
|
+
resolution;
|
|
202
|
+
frame = 0;
|
|
203
|
+
constructor(p, c, externalUserId) {
|
|
204
|
+
this.p = p;
|
|
205
|
+
this.externalUserId = externalUserId;
|
|
206
|
+
this.nativeId = String(c.id);
|
|
207
|
+
this.base = `/computers/${encodeURIComponent(this.nativeId)}`;
|
|
208
|
+
this.os = c.os === "windows" ? "windows" : "linux";
|
|
209
|
+
this.remember(c);
|
|
210
|
+
}
|
|
211
|
+
remember(c) {
|
|
212
|
+
this.workspaceId = c.workspace_id ?? c.project_id ?? this.workspaceId;
|
|
213
|
+
this.resolution = c.resolution ?? c.instance_details?.resolution ?? this.resolution;
|
|
214
|
+
}
|
|
215
|
+
async info() {
|
|
216
|
+
const c = await this.p.json("GET", this.base);
|
|
217
|
+
this.remember(c);
|
|
218
|
+
const s = String(c.status ?? "unknown");
|
|
219
|
+
const status = s === "running" ? "ready" : ["frozen", "stopped", "suspended"].includes(s) ? "sleeping" : s;
|
|
220
|
+
const info = { id: this.nativeId, os: this.os, status, externalUserId: this.externalUserId };
|
|
221
|
+
const m = /^(\d+)x(\d+)/.exec(this.resolution ?? "");
|
|
222
|
+
if (m)
|
|
223
|
+
info.screen = { width: Number(m[1]), height: Number(m[2]) };
|
|
224
|
+
return info;
|
|
225
|
+
}
|
|
226
|
+
async act(a) {
|
|
227
|
+
try {
|
|
228
|
+
await this.perform(a);
|
|
229
|
+
if (a.noScreenshot && a.action !== "screenshot")
|
|
230
|
+
return { action: a.action, ok: true };
|
|
231
|
+
return { action: a.action, ok: true, ...(await this.frameResult()) };
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
return { action: a.action, ok: false, error: err?.message ?? String(err) };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Runs in order and stops at the first error; only the last action returns a screenshot. */
|
|
238
|
+
async actBatch(actions) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const [i, a] of actions.entries()) {
|
|
241
|
+
const r = await this.act(i === actions.length - 1 ? a : { ...a, noScreenshot: true });
|
|
242
|
+
out.push(r);
|
|
243
|
+
if (!r.ok)
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
async frameResult() {
|
|
249
|
+
const s = await this.p.json("GET", `${this.base}/screenshot`, { query: { response_format: "base64", format: "png" } });
|
|
250
|
+
if (!s.image)
|
|
251
|
+
throw new Error("screenshot came back empty");
|
|
252
|
+
return { frameId: ++this.frame, imageB64: s.image, mime: s.mime_type ?? "image/png", width: s.width, height: s.height };
|
|
253
|
+
}
|
|
254
|
+
post(endpoint, json) {
|
|
255
|
+
return this.p.json("POST", `${this.base}/${endpoint}`, { json });
|
|
256
|
+
}
|
|
257
|
+
/** One canonical action to Orgo calls. Throws on anything Orgo has no endpoint for. */
|
|
258
|
+
async perform(a) {
|
|
259
|
+
const click = BUTTON[a.action];
|
|
260
|
+
if (click) {
|
|
261
|
+
const body = { ...point(a.coordinate, a.action), ...click };
|
|
262
|
+
if (a.modifier) {
|
|
263
|
+
// /click refuses modifiers with double or repeat > 1.
|
|
264
|
+
if (click.double || click.repeat)
|
|
265
|
+
throw new Error(`orgo can't hold a modifier during ${a.action}`);
|
|
266
|
+
body.modifiers = clickModifiers(a.modifier);
|
|
267
|
+
}
|
|
268
|
+
await this.post("click", body);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
switch (a.action) {
|
|
272
|
+
case "screenshot":
|
|
273
|
+
return;
|
|
274
|
+
case "mouse_move":
|
|
275
|
+
await this.post("mouse-move", point(a.coordinate, a.action));
|
|
276
|
+
return;
|
|
277
|
+
case "left_click_drag": {
|
|
278
|
+
if (a.modifier)
|
|
279
|
+
throw new Error("orgo can't hold a modifier during a drag");
|
|
280
|
+
// No pointer-position endpoint, so the start has to be given.
|
|
281
|
+
const s = point(a.startCoordinate, "left_click_drag start (startCoordinate)");
|
|
282
|
+
const e = point(a.coordinate, a.action);
|
|
283
|
+
await this.post("drag", { start_x: s.x, start_y: s.y, end_x: e.x, end_y: e.y });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
case "scroll": {
|
|
287
|
+
const dir = a.scrollDirection;
|
|
288
|
+
if (dir !== "up" && dir !== "down")
|
|
289
|
+
throw new Error(`orgo scrolls up or down only, not ${dir ?? "(none)"}`);
|
|
290
|
+
if (a.modifier)
|
|
291
|
+
throw new Error("orgo can't hold a modifier during a scroll");
|
|
292
|
+
const amount = Math.max(1, Math.round(a.scrollAmount ?? 3));
|
|
293
|
+
await this.post("scroll", { ...point(a.coordinate, a.action), direction: dir, amount });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
case "type":
|
|
297
|
+
if (!a.text)
|
|
298
|
+
throw new Error("type needs text");
|
|
299
|
+
await this.post("type", { text: a.text });
|
|
300
|
+
return;
|
|
301
|
+
case "key": {
|
|
302
|
+
if (!a.text)
|
|
303
|
+
throw new Error("key needs text");
|
|
304
|
+
// xdotool-style "ctrl+a Delete" is several chords; Orgo presses one per call (X keysym names).
|
|
305
|
+
const chords = a.text.trim().split(/\s+/);
|
|
306
|
+
for (let n = 0; n < Math.max(1, a.repeat ?? 1); n++)
|
|
307
|
+
for (const key of chords)
|
|
308
|
+
await this.post("key", { key });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
case "wait": {
|
|
312
|
+
const d = a.duration ?? 1;
|
|
313
|
+
if (!(d >= 0 && d <= 100))
|
|
314
|
+
throw new Error("duration must be 0 to 100 seconds");
|
|
315
|
+
if (this.os === "windows") {
|
|
316
|
+
// Windows computers have no /wait endpoint.
|
|
317
|
+
await new Promise((r) => setTimeout(r, d * 1000).unref());
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
for (let left = d; left > 0; left -= 60)
|
|
321
|
+
await this.post("wait", { seconds: Math.min(60, left) });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
default:
|
|
325
|
+
// left_mouse_down/up, hold_key, zoom, cursor_position: no Orgo endpoint.
|
|
326
|
+
throw new Error(`orgo does not support action "${a.action}"`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async screenshot(opts = {}) {
|
|
330
|
+
const query = { response_format: "binary", format: opts.format ?? "png" };
|
|
331
|
+
if (opts.format === "jpeg" && opts.quality)
|
|
332
|
+
query.quality = String(Math.min(100, Math.max(1, Math.round(opts.quality))));
|
|
333
|
+
const res = await this.p.request("GET", `${this.base}/screenshot`, { query });
|
|
334
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Bash on Linux, PowerShell on Windows; timeout 1 to 300 s. Linux returns stdout and stderr
|
|
338
|
+
* combined in `output`, so stderr is empty there. Windows returns them separately.
|
|
339
|
+
*/
|
|
340
|
+
async exec(command, timeoutSeconds = 60) {
|
|
341
|
+
const timeout = Math.min(300, Math.max(1, Math.round(timeoutSeconds)));
|
|
342
|
+
const r = await this.post("bash", { command, timeout });
|
|
343
|
+
return {
|
|
344
|
+
exitCode: typeof r.exit_code === "number" ? r.exit_code : -1,
|
|
345
|
+
stdout: String(r.stdout ?? r.output ?? ""),
|
|
346
|
+
stderr: String(r.stderr ?? ""),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/** Files API: export copies the file into workspace storage and signs a one-hour URL; the copy is then removed. */
|
|
350
|
+
async readFile(path) {
|
|
351
|
+
const r = await this.p.json("POST", "/files/export", { json: { desktopId: this.nativeId, path } });
|
|
352
|
+
if (!r.url)
|
|
353
|
+
throw new ProviderError(ID, `export of ${path} returned no download url`);
|
|
354
|
+
try {
|
|
355
|
+
const res = await fetch(r.url);
|
|
356
|
+
if (!res.ok)
|
|
357
|
+
throw new ProviderError(ID, `download of ${path} -> ${res.status}`, res.status);
|
|
358
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
if (r.file?.id)
|
|
362
|
+
await this.p.request("DELETE", "/files/delete", { query: { id: String(r.file.id) } }).catch(() => { });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/** Files API upload with destPath: placed straight into that folder on this computer, nothing stored. 10 MB max. */
|
|
366
|
+
async writeFile(path, content) {
|
|
367
|
+
const bytes = toBytes(content);
|
|
368
|
+
if (bytes.byteLength > UPLOAD_MAX_BYTES)
|
|
369
|
+
throw new ProviderError(ID, `upload is capped at 10 MB (${bytes.byteLength} bytes)`);
|
|
370
|
+
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
371
|
+
const name = path.slice(cut + 1);
|
|
372
|
+
const absolute = path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path);
|
|
373
|
+
if (!absolute || !name)
|
|
374
|
+
throw new ProviderError(ID, `writeFile needs an absolute file path, got ${path}`);
|
|
375
|
+
// The folder keeps the file's own name; "/x" -> "/", "C:\x" -> "C:\".
|
|
376
|
+
let dir = cut === 0 ? "/" : path.slice(0, cut);
|
|
377
|
+
if (/^[A-Za-z]:$/.test(dir))
|
|
378
|
+
dir += "\\";
|
|
379
|
+
if (!this.workspaceId)
|
|
380
|
+
this.remember(await this.p.json("GET", this.base));
|
|
381
|
+
const form = new FormData();
|
|
382
|
+
form.append("file", new Blob([bytes]), name);
|
|
383
|
+
form.append("desktopId", this.nativeId);
|
|
384
|
+
form.append("destPath", dir);
|
|
385
|
+
form.append("workspaceId", this.workspaceId ?? (await this.p.resolveWorkspace()));
|
|
386
|
+
await this.p.request("POST", "/files/upload", { form });
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* The documented embed URL: wss://www.orgo.ai/desktops/{id}/ws/websockify?token={vnc password},
|
|
390
|
+
* for noVNC or any websockify VNC client (not a page a browser opens on its own). The token is the
|
|
391
|
+
* computer's full VNC credential (also root on its terminal) and Orgo has no view-only token, so
|
|
392
|
+
* only `control` is offered. It rotates on start/restart/RAM resize; no published expiry.
|
|
393
|
+
*/
|
|
394
|
+
async viewer(mode = "watch", _opts = {}) {
|
|
395
|
+
if (mode !== "control") {
|
|
396
|
+
throw new ProviderError(ID, "orgo has no view-only link: its VNC token controls the computer. Ask for mode \"control\"");
|
|
397
|
+
}
|
|
398
|
+
const r = await this.p.json("GET", `${this.base}/vnc-password`);
|
|
399
|
+
if (!r.password)
|
|
400
|
+
throw new ProviderError(ID, "no VNC password for this computer (view-only access or a restricted key)");
|
|
401
|
+
const url = `${this.p.wsOrigin()}/desktops/${encodeURIComponent(this.nativeId)}/ws/websockify?token=${encodeURIComponent(r.password)}`;
|
|
402
|
+
return { url, expiresAt: null };
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Stop: the disk is archived and the computer kept (status `frozen`); by the hour it then pays
|
|
406
|
+
* only for its disk. Memory is not kept. connectComputer starts it again.
|
|
407
|
+
*/
|
|
408
|
+
async sleep() {
|
|
409
|
+
await this.p.request("POST", `${this.base}/stop`);
|
|
410
|
+
}
|
|
411
|
+
async delete() {
|
|
412
|
+
await this.p.request("DELETE", this.base);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
export declare class SailProvider implements Provider {
|
|
3
|
+
private apiKey;
|
|
4
|
+
private appName;
|
|
5
|
+
readonly id: "sail";
|
|
6
|
+
/** @internal Sailbox API, with /v1. */
|
|
7
|
+
readonly sailboxApi: string;
|
|
8
|
+
/** @internal Main API (apps), with /v1. */
|
|
9
|
+
readonly mainApi: string;
|
|
10
|
+
private appId?;
|
|
11
|
+
/**
|
|
12
|
+
* @param sailboxUrl Sailbox API (SAILBOX_API_URL, as the SDKs read it), default https://sailbox-api.sailresearch.com
|
|
13
|
+
* @param apiUrl main API for apps (SAIL_API_URL), default https://api.sailresearch.com; defaults to
|
|
14
|
+
* sailboxUrl when only that is given, so one mock or proxy serves both
|
|
15
|
+
* @param appName the app new Sailboxes go in (SAIL_APP), created if missing
|
|
16
|
+
*/
|
|
17
|
+
constructor(apiKey?: string | undefined, sailboxUrl?: string | undefined, apiUrl?: string | undefined, appName?: string);
|
|
18
|
+
configured(): boolean;
|
|
19
|
+
/** @internal One request; throws a ProviderError carrying the HTTP status on failure. */
|
|
20
|
+
request(method: string, url: string, init?: {
|
|
21
|
+
json?: unknown;
|
|
22
|
+
body?: Uint8Array;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}): Promise<Response>;
|
|
26
|
+
/** @internal JSON call on the Sailbox API. */
|
|
27
|
+
api<T>(method: string, path: string, json?: unknown, headers?: Record<string, string>): Promise<T>;
|
|
28
|
+
private app;
|
|
29
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
30
|
+
/** Reach an existing Sailbox, resuming it if it is paused or asleep. */
|
|
31
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
32
|
+
}
|