@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,177 @@
|
|
|
1
|
+
import { ProviderError } from "../types.js";
|
|
2
|
+
import { DEFAULT_EXEC_TIMEOUT_S, optionalImport, timeoutError, 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
|
+
const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
|
|
58
|
+
try {
|
|
59
|
+
// The agent kills the command at `timeout` and reports it as an EXECUTION_TIMEOUT error.
|
|
60
|
+
const r = await this.sbx.commands.run(cmd, {
|
|
61
|
+
timeout: seconds,
|
|
62
|
+
workingDir: opts.cwd,
|
|
63
|
+
env: opts.env,
|
|
64
|
+
});
|
|
65
|
+
// A nonzero exit comes back as a normal response with exit_code set.
|
|
66
|
+
return { exitCode: r.exit_code ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "", durationMs: Date.now() - t0 };
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw fail(err, seconds);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Streams over the agent's WebSocket (/execute/stream) with the code run as bash. */
|
|
73
|
+
async execStreaming(cmd, opts) {
|
|
74
|
+
const t0 = Date.now();
|
|
75
|
+
const seconds = opts.timeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_S;
|
|
76
|
+
let stdout = "";
|
|
77
|
+
let stderr = "";
|
|
78
|
+
let exitCode;
|
|
79
|
+
const stream = this.sbx.runCodeStream(cmd, {
|
|
80
|
+
language: "bash",
|
|
81
|
+
timeout: seconds,
|
|
82
|
+
workingDir: opts.cwd,
|
|
83
|
+
env: opts.env,
|
|
84
|
+
});
|
|
85
|
+
const run = async () => {
|
|
86
|
+
for await (const m of stream) {
|
|
87
|
+
if (m.type === "stdout") {
|
|
88
|
+
stdout += m.data;
|
|
89
|
+
opts.onStdout?.(m.data);
|
|
90
|
+
}
|
|
91
|
+
else if (m.type === "stderr") {
|
|
92
|
+
stderr += m.data;
|
|
93
|
+
opts.onStderr?.(m.data);
|
|
94
|
+
}
|
|
95
|
+
else if (m.type === "result") {
|
|
96
|
+
exitCode = m.exit_code;
|
|
97
|
+
}
|
|
98
|
+
else if (m.type === "error") {
|
|
99
|
+
throw m.code === "EXECUTION_TIMEOUT" ? timeoutError("hopx", seconds) : new ProviderError("hopx", m.error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
// The agent enforces the timeout; the extra grace covers a stream that never closes.
|
|
105
|
+
const graceMs = (seconds + 15) * 1000;
|
|
106
|
+
const finished = run();
|
|
107
|
+
const timedOut = await Promise.race([finished.then(() => false), sleep(graceMs).then(() => true)]);
|
|
108
|
+
if (timedOut) {
|
|
109
|
+
// Close the stream (and its WebSocket) now that nobody will read it.
|
|
110
|
+
finished.catch(() => { });
|
|
111
|
+
await stream.return?.().catch(() => { });
|
|
112
|
+
throw timeoutError("hopx", seconds);
|
|
113
|
+
}
|
|
114
|
+
if (exitCode === undefined)
|
|
115
|
+
throw new ProviderError("hopx", "stream closed before the command finished");
|
|
116
|
+
return { exitCode, stdout, stderr, durationMs: Date.now() - t0 };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
throw fail(err, seconds);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async writeFile(path, content) {
|
|
123
|
+
try {
|
|
124
|
+
await this.sbx.files.writeBytes(path, Buffer.from(toBytes(content)));
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
throw fail(err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async readFile(path) {
|
|
131
|
+
try {
|
|
132
|
+
return new Uint8Array(await this.sbx.files.readBytes(path));
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
throw fail(err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Pause with memory and disk kept. router.get() (Sandbox.connect) resumes it. */
|
|
139
|
+
async pause() {
|
|
140
|
+
try {
|
|
141
|
+
await this.sbx.pause();
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
throw fail(err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async kill() {
|
|
148
|
+
try {
|
|
149
|
+
await this.sbx.kill();
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
throw fail(err);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** HTTP status for SDK errors that drop it (only APIError keeps statusCode). */
|
|
157
|
+
const STATUS_BY_NAME = {
|
|
158
|
+
AuthenticationError: 401,
|
|
159
|
+
TokenExpiredError: 401,
|
|
160
|
+
RateLimitError: 429,
|
|
161
|
+
NotFoundError: 404,
|
|
162
|
+
TemplateNotFoundError: 404,
|
|
163
|
+
FileNotFoundError: 404,
|
|
164
|
+
SandboxExpiredError: 410,
|
|
165
|
+
};
|
|
166
|
+
/** Map an SDK error; with `timeoutSeconds`, the agent's execution-timeout errors become the contract's timeout error. */
|
|
167
|
+
function fail(err, timeoutSeconds) {
|
|
168
|
+
if (err instanceof ProviderError)
|
|
169
|
+
return err;
|
|
170
|
+
if (timeoutSeconds !== undefined && (err?.code === "EXECUTION_TIMEOUT" || err?.name === "TimeoutError"))
|
|
171
|
+
return timeoutError("hopx", timeoutSeconds);
|
|
172
|
+
const status = err?.statusCode ?? err?.response?.status ?? STATUS_BY_NAME[err?.name];
|
|
173
|
+
return new ProviderError("hopx", err?.message ?? String(err), status);
|
|
174
|
+
}
|
|
175
|
+
function sleep(ms) {
|
|
176
|
+
return new Promise((r) => setTimeout(r, ms).unref());
|
|
177
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/** Sizes on api.instabox.sh (vCPUs / GiB); which ones an org may use depends on its plan. */
|
|
3
|
+
export declare const INSTABOX_SIZES: readonly [{
|
|
4
|
+
readonly vcpu: 2;
|
|
5
|
+
readonly memoryGb: 4;
|
|
6
|
+
}, {
|
|
7
|
+
readonly vcpu: 4;
|
|
8
|
+
readonly memoryGb: 8;
|
|
9
|
+
}, {
|
|
10
|
+
readonly vcpu: 8;
|
|
11
|
+
readonly memoryGb: 16;
|
|
12
|
+
}, {
|
|
13
|
+
readonly vcpu: 16;
|
|
14
|
+
readonly memoryGb: 32;
|
|
15
|
+
}, {
|
|
16
|
+
readonly vcpu: 32;
|
|
17
|
+
readonly memoryGb: 64;
|
|
18
|
+
}];
|
|
19
|
+
/** The smallest size with at least the requested vCPUs and memory, or undefined if none fits. */
|
|
20
|
+
export declare function instaboxSize(vcpu: number, memoryGb: number): {
|
|
21
|
+
readonly vcpu: 2;
|
|
22
|
+
readonly memoryGb: 4;
|
|
23
|
+
} | {
|
|
24
|
+
readonly vcpu: 4;
|
|
25
|
+
readonly memoryGb: 8;
|
|
26
|
+
} | {
|
|
27
|
+
readonly vcpu: 8;
|
|
28
|
+
readonly memoryGb: 16;
|
|
29
|
+
} | {
|
|
30
|
+
readonly vcpu: 16;
|
|
31
|
+
readonly memoryGb: 32;
|
|
32
|
+
} | {
|
|
33
|
+
readonly vcpu: 32;
|
|
34
|
+
readonly memoryGb: 64;
|
|
35
|
+
} | undefined;
|
|
36
|
+
interface Operation {
|
|
37
|
+
id?: string;
|
|
38
|
+
machineId?: string;
|
|
39
|
+
type?: string;
|
|
40
|
+
state?: string;
|
|
41
|
+
phase?: string;
|
|
42
|
+
error?: {
|
|
43
|
+
code?: string;
|
|
44
|
+
message?: string;
|
|
45
|
+
retryable?: boolean;
|
|
46
|
+
retryAfterSeconds?: number;
|
|
47
|
+
} | null;
|
|
48
|
+
}
|
|
49
|
+
export declare class InstaboxProvider implements Provider {
|
|
50
|
+
private apiKey;
|
|
51
|
+
private baseUrl;
|
|
52
|
+
readonly id: "instabox";
|
|
53
|
+
constructor(apiKey?: string | undefined, baseUrl?: string);
|
|
54
|
+
configured(): boolean;
|
|
55
|
+
/** @internal Send a request; non-2xx answers throw InstaboxError. */
|
|
56
|
+
request(method: string, path: string, init?: {
|
|
57
|
+
json?: unknown;
|
|
58
|
+
body?: Uint8Array;
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
idempotent?: boolean;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
}): Promise<Response>;
|
|
63
|
+
/** @internal */
|
|
64
|
+
json<T>(method: string, path: string, json?: unknown, opts?: {
|
|
65
|
+
idempotent?: boolean;
|
|
66
|
+
headers?: Record<string, string>;
|
|
67
|
+
}): Promise<T>;
|
|
68
|
+
/** @internal Follow an operation until it succeeds; a failed one throws. */
|
|
69
|
+
waitOperation(op: Operation, what: string): Promise<Operation>;
|
|
70
|
+
/** @internal Wait until the machine runs and takes commands, starting or resuming it when it is stopped or paused. */
|
|
71
|
+
ensureRunning(id: string): Promise<void>;
|
|
72
|
+
private createMachine;
|
|
73
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
74
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
75
|
+
}
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readSSE } from "../sse.js";
|
|
3
|
+
import { ProviderError } from "../types.js";
|
|
4
|
+
import { toBytes } from "../util.js";
|
|
5
|
+
/**
|
|
6
|
+
* InstaBox (instabox.sh, by InsForge, YC P26) over plain REST. Full Linux (Debian 13, systemd,
|
|
7
|
+
* sudo) Firecracker microVMs with a persistent /data disk; billed per second while running.
|
|
8
|
+
*
|
|
9
|
+
* https://api.instabox.sh, X-API-Key: <INSTABOX_API_KEY> (ibk_...; Authorization: Bearer also works):
|
|
10
|
+
* POST /v1/machines {vcpus, memMiB, templateId|snapshotId, env, metadata, deleteAfterSeconds}
|
|
11
|
+
* -> 202 Operation {id, machineId, type, state, phase, error}
|
|
12
|
+
* GET /v1/operations/:id?wait=25s long poll; state queued | running | succeeded | failed
|
|
13
|
+
* GET /v1/machines/:id -> Machine {id, state, runtime:{ready}, operationId, lastError}
|
|
14
|
+
* POST /v1/machines/:id/suspend {} pause: memory and disk kept, compute billing stops
|
|
15
|
+
* POST /v1/machines/:id/start {} starts a stopped machine, or resumes a paused one
|
|
16
|
+
* DELETE /v1/machines/:id needs X-Confirm-Delete: <id>
|
|
17
|
+
* POST /v1/machines/:id/exec {cmd, cwd, envs, timeoutSeconds<=600} runs `bash -lc cmd`, blocks;
|
|
18
|
+
* -> ExecResult {stdout, stderr, exitCode, timedOut, truncated} (1 MiB per stream), or with
|
|
19
|
+
* Accept: text/event-stream: `stdout`/`stderr` events {data}, then `exit` (ExecResult) or `error`
|
|
20
|
+
* PUT /v1/machines/:id/files?path=&parents=true raw body, any bytes, up to 5 GiB
|
|
21
|
+
* GET /v1/machines/:id/files/content?path= raw bytes
|
|
22
|
+
* Create, start, suspend and delete need an Idempotency-Key header and answer an Operation.
|
|
23
|
+
* Machine state: creating, starting, running, stopping, stopped, suspending, suspended, resuming,
|
|
24
|
+
* deleting, deleted, error. Errors are {error:{code, message, retryable, retryAfterSeconds}}.
|
|
25
|
+
* Sizes are fixed at 1 vCPU : 2 GiB (Pro 2/4, 4/8, 8/16; Builder to 16/32; Team to 32/64; Free 4/8
|
|
26
|
+
* only). There is no lifetime timeout: deleteAfterSeconds (60-86400) deletes the machine in any state.
|
|
27
|
+
* An idle machine pauses itself after 30 minutes and wakes on the next exec or file call.
|
|
28
|
+
* Docs: https://instabox.sh/docs/api.md, https://instabox.sh/docs/machines.md,
|
|
29
|
+
* https://instabox.sh/docs/idle-suspend.md, https://instabox.sh/docs/limits.md,
|
|
30
|
+
* https://instabox.sh/docs/pricing.md, https://api.instabox.sh/v1/openapi.json
|
|
31
|
+
*/
|
|
32
|
+
const ID = "instabox";
|
|
33
|
+
const READY_TIMEOUT_MS = 5 * 60_000;
|
|
34
|
+
const POLL_MS = 250;
|
|
35
|
+
const MAX_EXEC_SECONDS = 600;
|
|
36
|
+
const HOME = "/data/home";
|
|
37
|
+
/** Sizes on api.instabox.sh (vCPUs / GiB); which ones an org may use depends on its plan. */
|
|
38
|
+
export const INSTABOX_SIZES = [
|
|
39
|
+
{ vcpu: 2, memoryGb: 4 },
|
|
40
|
+
{ vcpu: 4, memoryGb: 8 },
|
|
41
|
+
{ vcpu: 8, memoryGb: 16 },
|
|
42
|
+
{ vcpu: 16, memoryGb: 32 },
|
|
43
|
+
{ vcpu: 32, memoryGb: 64 },
|
|
44
|
+
];
|
|
45
|
+
/** The smallest size with at least the requested vCPUs and memory, or undefined if none fits. */
|
|
46
|
+
export function instaboxSize(vcpu, memoryGb) {
|
|
47
|
+
return INSTABOX_SIZES.find((s) => s.vcpu >= vcpu && s.memoryGb >= memoryGb);
|
|
48
|
+
}
|
|
49
|
+
/** The default size, and the only one on the free plan. */
|
|
50
|
+
const DEFAULT_SIZE = INSTABOX_SIZES[1];
|
|
51
|
+
class InstaboxError extends ProviderError {
|
|
52
|
+
code;
|
|
53
|
+
retryable;
|
|
54
|
+
retryAfterSeconds;
|
|
55
|
+
constructor(message, status, code, retryable = false, retryAfterSeconds) {
|
|
56
|
+
super(ID, message, status);
|
|
57
|
+
this.code = code;
|
|
58
|
+
this.retryable = retryable;
|
|
59
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class InstaboxProvider {
|
|
63
|
+
apiKey;
|
|
64
|
+
baseUrl;
|
|
65
|
+
id = ID;
|
|
66
|
+
constructor(apiKey = process.env.INSTABOX_API_KEY, baseUrl = process.env.INSTABOX_API_URL ?? "https://api.instabox.sh") {
|
|
67
|
+
this.apiKey = apiKey;
|
|
68
|
+
this.baseUrl = baseUrl;
|
|
69
|
+
}
|
|
70
|
+
configured() {
|
|
71
|
+
return !!this.apiKey;
|
|
72
|
+
}
|
|
73
|
+
/** @internal Send a request; non-2xx answers throw InstaboxError. */
|
|
74
|
+
async request(method, path, init = {}) {
|
|
75
|
+
if (!this.apiKey)
|
|
76
|
+
throw new ProviderError(ID, "INSTABOX_API_KEY is not set");
|
|
77
|
+
const headers = { "X-API-Key": this.apiKey, ...init.headers };
|
|
78
|
+
if (init.idempotent)
|
|
79
|
+
headers["Idempotency-Key"] = randomUUID();
|
|
80
|
+
let body;
|
|
81
|
+
if (init.json !== undefined) {
|
|
82
|
+
headers["Content-Type"] = "application/json";
|
|
83
|
+
body = JSON.stringify(init.json);
|
|
84
|
+
}
|
|
85
|
+
else if (init.body) {
|
|
86
|
+
headers["Content-Type"] = "application/octet-stream";
|
|
87
|
+
body = init.body;
|
|
88
|
+
}
|
|
89
|
+
const res = await fetch(`${this.baseUrl}${path}`, { method, headers, body, signal: AbortSignal.timeout(init.timeoutMs ?? 120_000) });
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
const text = await res.text();
|
|
92
|
+
let detail = text.slice(0, 300);
|
|
93
|
+
let err;
|
|
94
|
+
try {
|
|
95
|
+
err = JSON.parse(text)?.error;
|
|
96
|
+
if (err?.code)
|
|
97
|
+
detail = `${err.code}: ${err.message ?? ""}`;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* plain-text body */
|
|
101
|
+
}
|
|
102
|
+
const retryAfter = err?.retryAfterSeconds ?? (Number(res.headers.get("retry-after")) || undefined);
|
|
103
|
+
throw new InstaboxError(`${method} ${path.split("?")[0]} -> ${res.status} ${detail}`, res.status, err?.code, !!err?.retryable, retryAfter);
|
|
104
|
+
}
|
|
105
|
+
return res;
|
|
106
|
+
}
|
|
107
|
+
/** @internal */
|
|
108
|
+
async json(method, path, json, opts = {}) {
|
|
109
|
+
const text = await (await this.request(method, path, { json, ...opts })).text();
|
|
110
|
+
return (text ? JSON.parse(text) : {});
|
|
111
|
+
}
|
|
112
|
+
/** @internal Follow an operation until it succeeds; a failed one throws. */
|
|
113
|
+
async waitOperation(op, what) {
|
|
114
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
115
|
+
while (op.state !== "succeeded" && op.state !== "failed") {
|
|
116
|
+
if (Date.now() > deadline)
|
|
117
|
+
throw new ProviderError(ID, `${what} not done after ${READY_TIMEOUT_MS / 1000}s (${op.phase || op.state || "unknown"})`);
|
|
118
|
+
if (!op.id)
|
|
119
|
+
throw new ProviderError(ID, `${what} returned no operation id`);
|
|
120
|
+
// Long poll: answers as soon as the state or phase changes, or after 25 s.
|
|
121
|
+
op = await this.json("GET", `/v1/operations/${encodeURIComponent(op.id)}?wait=25s`);
|
|
122
|
+
if (op.state !== "succeeded" && op.state !== "failed")
|
|
123
|
+
await sleep(POLL_MS);
|
|
124
|
+
}
|
|
125
|
+
if (op.state === "failed") {
|
|
126
|
+
const e = op.error;
|
|
127
|
+
throw new InstaboxError(`${what} failed: ${e?.code ? `${e.code}: ` : ""}${e?.message ?? "no reason given"}`, e?.code === "no_capacity" ? 503 : undefined, e?.code, !!e?.retryable);
|
|
128
|
+
}
|
|
129
|
+
return op;
|
|
130
|
+
}
|
|
131
|
+
/** @internal Wait until the machine runs and takes commands, starting or resuming it when it is stopped or paused. */
|
|
132
|
+
async ensureRunning(id) {
|
|
133
|
+
const path = `/v1/machines/${encodeURIComponent(id)}`;
|
|
134
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
135
|
+
for (;;) {
|
|
136
|
+
const m = await this.json("GET", path);
|
|
137
|
+
const state = m.state ?? "";
|
|
138
|
+
if (state === "running" && m.runtime?.ready)
|
|
139
|
+
return;
|
|
140
|
+
if (state === "deleting" || state === "deleted")
|
|
141
|
+
throw new InstaboxError(`machine ${id} is ${state}`, 404, "not_found");
|
|
142
|
+
if (state === "error")
|
|
143
|
+
throw new InstaboxError(`machine ${id} is in error${m.lastError?.message ? `: ${m.lastError.message}` : ""}`, undefined, m.lastError?.code);
|
|
144
|
+
if (Date.now() > deadline)
|
|
145
|
+
throw new ProviderError(ID, `machine ${id} not ready after ${READY_TIMEOUT_MS / 1000}s (${state || "unknown"})`);
|
|
146
|
+
if (state === "stopped" || state === "suspended") {
|
|
147
|
+
try {
|
|
148
|
+
await this.waitOperation(await this.json("POST", `${path}/start`, {}, { idempotent: true }), `start of ${id}`);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// Another operation (an automatic resume, say) got there first: look again.
|
|
152
|
+
if (!(err instanceof InstaboxError && err.code === "operation_conflict" && err.retryable))
|
|
153
|
+
throw err;
|
|
154
|
+
await sleep(1000);
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (m.operationId) {
|
|
159
|
+
// Creating, starting, resuming, stopping or pausing: follow that operation, then look again.
|
|
160
|
+
await this.waitOperation({ id: m.operationId, state: "running" }, `operation on ${id}`).catch(() => { });
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
await sleep(POLL_MS);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async createMachine(body) {
|
|
168
|
+
const op = await this.json("POST", "/v1/machines", body, { idempotent: true });
|
|
169
|
+
const id = op.machineId;
|
|
170
|
+
if (!id)
|
|
171
|
+
throw new ProviderError(ID, "create returned no machineId");
|
|
172
|
+
try {
|
|
173
|
+
await this.waitOperation(op, `create of ${id}`);
|
|
174
|
+
// A machine from a snapshot with storage is created stopped; this starts it.
|
|
175
|
+
await this.ensureRunning(id);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
await new InstaboxBox(this, id).kill().catch(() => { });
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
return id;
|
|
182
|
+
}
|
|
183
|
+
async create(spec) {
|
|
184
|
+
const vcpu = spec.vcpu ?? 2;
|
|
185
|
+
const memoryGb = spec.memoryGb ?? 4;
|
|
186
|
+
const size = instaboxSize(vcpu, memoryGb);
|
|
187
|
+
if (!size)
|
|
188
|
+
throw new ProviderError(ID, `no InstaBox size has ${vcpu} vCPU / ${memoryGb} GB (largest: 32 vCPU / 64 GiB, Team plan)`);
|
|
189
|
+
const body = {
|
|
190
|
+
vcpus: size.vcpu,
|
|
191
|
+
memMiB: size.memoryGb * 1024,
|
|
192
|
+
// There is no lifetime timeout; this deletes the machine (in any state) so a leaked one stops billing.
|
|
193
|
+
deleteAfterSeconds: Math.min(86_400, Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60))),
|
|
194
|
+
};
|
|
195
|
+
const image = spec.image?.instabox;
|
|
196
|
+
if (image) {
|
|
197
|
+
if (image.startsWith("snapshot:"))
|
|
198
|
+
body.snapshotId = image.slice("snapshot:".length);
|
|
199
|
+
else if (/^(sn|im)_/.test(image))
|
|
200
|
+
body.snapshotId = image;
|
|
201
|
+
else
|
|
202
|
+
body.templateId = image;
|
|
203
|
+
}
|
|
204
|
+
if (spec.env && Object.keys(spec.env).length)
|
|
205
|
+
body.env = spec.env;
|
|
206
|
+
if (spec.metadata && Object.keys(spec.metadata).length)
|
|
207
|
+
body.metadata = spec.metadata;
|
|
208
|
+
let id;
|
|
209
|
+
try {
|
|
210
|
+
id = await this.createMachine(body);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
// The free plan offers only the default 4 vCPU / 8 GiB; a smaller request fits in it.
|
|
214
|
+
const smaller = size.vcpu < DEFAULT_SIZE.vcpu;
|
|
215
|
+
if (!(smaller && err instanceof InstaboxError && err.code === "spec_not_allowed"))
|
|
216
|
+
throw err;
|
|
217
|
+
id = await this.createMachine({ ...body, vcpus: DEFAULT_SIZE.vcpu, memMiB: DEFAULT_SIZE.memoryGb * 1024 });
|
|
218
|
+
}
|
|
219
|
+
return new InstaboxBox(this, id);
|
|
220
|
+
}
|
|
221
|
+
async connect(nativeId) {
|
|
222
|
+
await this.ensureRunning(nativeId);
|
|
223
|
+
return new InstaboxBox(this, nativeId);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
class InstaboxBox {
|
|
227
|
+
p;
|
|
228
|
+
nativeId;
|
|
229
|
+
constructor(p, nativeId) {
|
|
230
|
+
this.p = p;
|
|
231
|
+
this.nativeId = nativeId;
|
|
232
|
+
}
|
|
233
|
+
get path() {
|
|
234
|
+
return `/v1/machines/${encodeURIComponent(this.nativeId)}`;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Retry calls the machine refused before doing anything: a paused or stopped machine is started,
|
|
238
|
+
* a machine busy resuming or restarting is waited for.
|
|
239
|
+
*/
|
|
240
|
+
async whenRunning(fn) {
|
|
241
|
+
const deadline = Date.now() + 120_000;
|
|
242
|
+
for (let attempt = 0;; attempt++) {
|
|
243
|
+
try {
|
|
244
|
+
return await fn();
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
if (!(err instanceof InstaboxError) || attempt >= 5 || Date.now() > deadline)
|
|
248
|
+
throw err;
|
|
249
|
+
if (err.code === "machine_not_running" && !err.retryable) {
|
|
250
|
+
await this.p.ensureRunning(this.nativeId);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const transient = ["machine_not_running", "operation_conflict", "host_unavailable", "quota_exceeded", "rate_limited"];
|
|
254
|
+
if (!err.retryable || !transient.includes(err.code ?? ""))
|
|
255
|
+
throw err;
|
|
256
|
+
await sleep(Math.min(10, err.retryAfterSeconds ?? 2) * 1000);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async exec(cmd, opts = {}) {
|
|
261
|
+
const t0 = Date.now();
|
|
262
|
+
const secs = Math.min(MAX_EXEC_SECONDS, Math.max(1, Math.round(opts.timeoutSeconds ?? 60)));
|
|
263
|
+
const body = {
|
|
264
|
+
cmd,
|
|
265
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
266
|
+
...(opts.env && Object.keys(opts.env).length ? { envs: opts.env } : {}),
|
|
267
|
+
timeoutSeconds: secs,
|
|
268
|
+
};
|
|
269
|
+
const streaming = !!(opts.onStdout || opts.onStderr);
|
|
270
|
+
const res = await this.whenRunning(() => this.p.request("POST", `${this.path}/exec`, {
|
|
271
|
+
json: body,
|
|
272
|
+
headers: streaming ? { Accept: "text/event-stream" } : {},
|
|
273
|
+
// The server kills the command at its timeout; allow for a wake in front of it.
|
|
274
|
+
timeoutMs: secs * 1000 + 60_000,
|
|
275
|
+
}));
|
|
276
|
+
let out;
|
|
277
|
+
let stdout = "";
|
|
278
|
+
let stderr = "";
|
|
279
|
+
if (streaming && (res.headers.get("content-type") ?? "").includes("text/event-stream")) {
|
|
280
|
+
let exit;
|
|
281
|
+
for await (const ev of readSSE(res)) {
|
|
282
|
+
if (ev.event === "stdout" || ev.event === "stderr") {
|
|
283
|
+
const chunk = String(JSON.parse(ev.data).data ?? "");
|
|
284
|
+
if (!chunk)
|
|
285
|
+
continue;
|
|
286
|
+
if (ev.event === "stdout") {
|
|
287
|
+
stdout += chunk;
|
|
288
|
+
opts.onStdout?.(chunk);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
stderr += chunk;
|
|
292
|
+
opts.onStderr?.(chunk);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else if (ev.event === "exit") {
|
|
296
|
+
exit = JSON.parse(ev.data);
|
|
297
|
+
}
|
|
298
|
+
else if (ev.event === "error") {
|
|
299
|
+
const j = JSON.parse(ev.data);
|
|
300
|
+
const e = j?.error ?? j;
|
|
301
|
+
throw new InstaboxError(`exec: ${e?.code ? `${e.code}: ` : ""}${e?.message ?? ev.data.slice(0, 300)}`, undefined, e?.code, false);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!exit)
|
|
305
|
+
throw new ProviderError(ID, "exec stream ended without an exit event");
|
|
306
|
+
out = exit;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
out = (await res.json());
|
|
310
|
+
stdout = out.stdout ?? "";
|
|
311
|
+
stderr = out.stderr ?? "";
|
|
312
|
+
if (stdout)
|
|
313
|
+
opts.onStdout?.(stdout);
|
|
314
|
+
if (stderr)
|
|
315
|
+
opts.onStderr?.(stderr);
|
|
316
|
+
}
|
|
317
|
+
if (out.timedOut)
|
|
318
|
+
throw new ProviderError(ID, `command timed out after ${secs}s`);
|
|
319
|
+
if (typeof out.exitCode !== "number")
|
|
320
|
+
throw new ProviderError(ID, "exec returned no exit code");
|
|
321
|
+
return { exitCode: out.exitCode, stdout, stderr, durationMs: Date.now() - t0 };
|
|
322
|
+
}
|
|
323
|
+
async writeFile(path, content) {
|
|
324
|
+
const q = `?path=${encodeURIComponent(absolute(path))}&parents=true`;
|
|
325
|
+
await this.whenRunning(() => this.p.request("PUT", `${this.path}/files${q}`, { body: toBytes(content), timeoutMs: 30 * 60_000 }));
|
|
326
|
+
}
|
|
327
|
+
async readFile(path) {
|
|
328
|
+
const q = `?path=${encodeURIComponent(absolute(path))}`;
|
|
329
|
+
const res = await this.whenRunning(() => this.p.request("GET", `${this.path}/files/content${q}`, { timeoutMs: 30 * 60_000 }));
|
|
330
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
331
|
+
}
|
|
332
|
+
async pause() {
|
|
333
|
+
const op = await this.p.json("POST", `${this.path}/suspend`, {}, { idempotent: true });
|
|
334
|
+
await this.p.waitOperation(op, `pause of ${this.nativeId}`);
|
|
335
|
+
}
|
|
336
|
+
async kill() {
|
|
337
|
+
const deadline = Date.now() + 60_000;
|
|
338
|
+
for (;;) {
|
|
339
|
+
try {
|
|
340
|
+
await this.p.request("DELETE", this.path, { idempotent: true, headers: { "X-Confirm-Delete": this.nativeId } });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
if (err instanceof InstaboxError && err.status === 404)
|
|
345
|
+
return; // already gone
|
|
346
|
+
// A start or pause in progress blocks the delete until it ends.
|
|
347
|
+
if (!(err instanceof InstaboxError && err.code === "operation_conflict" && Date.now() < deadline))
|
|
348
|
+
throw err;
|
|
349
|
+
await sleep(1000);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** The files API takes absolute paths; relative ones are under HOME, where exec starts. */
|
|
355
|
+
function absolute(path) {
|
|
356
|
+
return path.startsWith("/") ? path : `${HOME}/${path.replace(/^\.\//, "")}`;
|
|
357
|
+
}
|
|
358
|
+
function sleep(ms) {
|
|
359
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
360
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Koyeb Standard instance types, from https://www.koyeb.com/docs/reference/instances
|
|
4
|
+
* (checked 2026-11-04). Billed per second for the whole instance.
|
|
5
|
+
*/
|
|
6
|
+
export declare const KOYEB_INSTANCES: readonly [{
|
|
7
|
+
readonly type: "nano";
|
|
8
|
+
readonly vcpu: 0.25;
|
|
9
|
+
readonly memoryGb: 0.25;
|
|
10
|
+
readonly usdHour: 0.0036;
|
|
11
|
+
}, {
|
|
12
|
+
readonly type: "micro";
|
|
13
|
+
readonly vcpu: 0.5;
|
|
14
|
+
readonly memoryGb: 0.5;
|
|
15
|
+
readonly usdHour: 0.0072;
|
|
16
|
+
}, {
|
|
17
|
+
readonly type: "small";
|
|
18
|
+
readonly vcpu: 1;
|
|
19
|
+
readonly memoryGb: 1;
|
|
20
|
+
readonly usdHour: 0.0144;
|
|
21
|
+
}, {
|
|
22
|
+
readonly type: "medium";
|
|
23
|
+
readonly vcpu: 2;
|
|
24
|
+
readonly memoryGb: 2;
|
|
25
|
+
readonly usdHour: 0.0288;
|
|
26
|
+
}, {
|
|
27
|
+
readonly type: "large";
|
|
28
|
+
readonly vcpu: 4;
|
|
29
|
+
readonly memoryGb: 4;
|
|
30
|
+
readonly usdHour: 0.0576;
|
|
31
|
+
}, {
|
|
32
|
+
readonly type: "xlarge";
|
|
33
|
+
readonly vcpu: 8;
|
|
34
|
+
readonly memoryGb: 8;
|
|
35
|
+
readonly usdHour: 0.1152;
|
|
36
|
+
}, {
|
|
37
|
+
readonly type: "2xlarge";
|
|
38
|
+
readonly vcpu: 16;
|
|
39
|
+
readonly memoryGb: 16;
|
|
40
|
+
readonly usdHour: 0.2304;
|
|
41
|
+
}, {
|
|
42
|
+
readonly type: "3xlarge";
|
|
43
|
+
readonly vcpu: 24;
|
|
44
|
+
readonly memoryGb: 32;
|
|
45
|
+
readonly usdHour: 0.4608;
|
|
46
|
+
}, {
|
|
47
|
+
readonly type: "4xlarge";
|
|
48
|
+
readonly vcpu: 32;
|
|
49
|
+
readonly memoryGb: 64;
|
|
50
|
+
readonly usdHour: 0.9216;
|
|
51
|
+
}, {
|
|
52
|
+
readonly type: "5xlarge";
|
|
53
|
+
readonly vcpu: 40;
|
|
54
|
+
readonly memoryGb: 128;
|
|
55
|
+
readonly usdHour: 1.8432;
|
|
56
|
+
}];
|
|
57
|
+
/** Smallest Standard instance with at least the requested vCPU and memory, or undefined. */
|
|
58
|
+
export declare function koyebInstance(vcpu: number, memoryGb: number): (typeof KOYEB_INSTANCES)[number] | undefined;
|
|
59
|
+
export declare class KoyebProvider implements Provider {
|
|
60
|
+
private token;
|
|
61
|
+
private baseUrl;
|
|
62
|
+
private region;
|
|
63
|
+
readonly id: "koyeb";
|
|
64
|
+
constructor(token?: string | undefined, baseUrl?: string, region?: string);
|
|
65
|
+
configured(): boolean;
|
|
66
|
+
/** @internal Control-plane JSON call. */
|
|
67
|
+
api<T>(method: string, path: string, json?: unknown): Promise<T>;
|
|
68
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
69
|
+
/** Reattach by service id. The executor secret is read back from the deployment's env. */
|
|
70
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
71
|
+
}
|