@cloudflare/sandbox 0.13.0-next.681.1 → 0.13.0-next.709.1
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/Dockerfile +6 -6
- package/README.md +49 -3
- package/dist/bridge/index.d.ts.map +1 -1
- package/dist/bridge/index.js +1364 -1072
- package/dist/bridge/index.js.map +1 -1
- package/dist/{contexts-DY1LHU1v.d.ts → contexts-1EsLHByO.d.ts} +156 -20
- package/dist/contexts-1EsLHByO.d.ts.map +1 -0
- package/dist/{dist-BStBkGIC.js → dist-Duor5GbS.js} +7 -56
- package/dist/dist-Duor5GbS.js.map +1 -0
- package/dist/errors/index.d.ts +4 -4
- package/dist/errors/index.js +3 -3
- package/dist/{errors-k3B8orjH.js → errors-CXR0xBpw.js} +42 -10
- package/dist/errors-CXR0xBpw.js.map +1 -0
- package/dist/{errors-ewgSNicb.js → errors-QYlSkVGz.js} +402 -250
- package/dist/errors-QYlSkVGz.js.map +1 -0
- package/dist/extensions/index.d.ts +4 -2
- package/dist/extensions/index.js +5 -3
- package/dist/extensions-CFB2xHqY.js +1023 -0
- package/dist/extensions-CFB2xHqY.js.map +1 -0
- package/dist/filesystem-BWAZCZER.d.ts +732 -0
- package/dist/filesystem-BWAZCZER.d.ts.map +1 -0
- package/dist/git/index.d.ts +8 -19
- package/dist/git/index.d.ts.map +1 -1
- package/dist/git/index.js +16 -23
- package/dist/git/index.js.map +1 -1
- package/dist/index-Bs4bqXDR.d.ts +438 -0
- package/dist/index-Bs4bqXDR.d.ts.map +1 -0
- package/dist/{index-Dy6u9r60.d.ts → index-HNYBk-az.d.ts} +233 -179
- package/dist/index-HNYBk-az.d.ts.map +1 -0
- package/dist/index.d.ts +480 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/interpreter/index.d.ts +3 -1
- package/dist/interpreter/index.d.ts.map +1 -1
- package/dist/interpreter/index.js +46 -18
- package/dist/interpreter/index.js.map +1 -1
- package/dist/openai/index.d.ts +5 -5
- package/dist/openai/index.d.ts.map +1 -1
- package/dist/openai/index.js +11 -6
- package/dist/openai/index.js.map +1 -1
- package/dist/opencode/index.d.ts +7 -11
- package/dist/opencode/index.d.ts.map +1 -1
- package/dist/opencode/index.js +56 -85
- package/dist/opencode/index.js.map +1 -1
- package/dist/process-types-GStiZ8f8.d.ts +73 -0
- package/dist/process-types-GStiZ8f8.d.ts.map +1 -0
- package/dist/{sandbox-boKWPIcd.js → sandbox-Auuwfnur.js} +4152 -3359
- package/dist/sandbox-Auuwfnur.js.map +1 -0
- package/dist/sandbox-BbAabq93.d.ts +42 -0
- package/dist/sandbox-BbAabq93.d.ts.map +1 -0
- package/dist/xterm/index.d.ts +5 -1
- package/dist/xterm/index.d.ts.map +1 -1
- package/dist/xterm/index.js +55 -12
- package/dist/xterm/index.js.map +1 -1
- package/package.json +3 -2
- package/dist/codes-y-U76vnQ.d.ts +0 -79
- package/dist/codes-y-U76vnQ.d.ts.map +0 -1
- package/dist/contexts-DY1LHU1v.d.ts.map +0 -1
- package/dist/dist-BStBkGIC.js.map +0 -1
- package/dist/errors-ewgSNicb.js.map +0 -1
- package/dist/errors-k3B8orjH.js.map +0 -1
- package/dist/extensions-CepYdzro.js +0 -191
- package/dist/extensions-CepYdzro.js.map +0 -1
- package/dist/index-B7QgIs0N.d.ts +0 -1841
- package/dist/index-B7QgIs0N.d.ts.map +0 -1
- package/dist/index-Dy6u9r60.d.ts.map +0 -1
- package/dist/sandbox-DHNO89IF.d.ts +0 -828
- package/dist/sandbox-DHNO89IF.d.ts.map +0 -1
- package/dist/sandbox-boKWPIcd.js.map +0 -1
package/dist/bridge/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import "../
|
|
2
|
-
import "../errors-
|
|
3
|
-
import "../
|
|
4
|
-
import {
|
|
5
|
-
import "../extensions-
|
|
1
|
+
import "../errors-CXR0xBpw.js";
|
|
2
|
+
import "../errors-QYlSkVGz.js";
|
|
3
|
+
import "../dist-Duor5GbS.js";
|
|
4
|
+
import { g as validateTunnelName, h as validatePort, m as SandboxSecurityError, n as getSandbox$1, x as streamFile } from "../sandbox-Auuwfnur.js";
|
|
5
|
+
import "../extensions-CFB2xHqY.js";
|
|
6
6
|
import { DurableObject, env } from "cloudflare:workers";
|
|
7
7
|
import { Hono } from "hono";
|
|
8
8
|
|
|
@@ -29,16 +29,6 @@ async function primePool(env$1, warmPoolBinding) {
|
|
|
29
29
|
/**
|
|
30
30
|
* Utility functions used by the bridge routes.
|
|
31
31
|
*/
|
|
32
|
-
/**
|
|
33
|
-
* UTF-8-safe base64 encoding.
|
|
34
|
-
* btoa() only handles latin-1; encode to UTF-8 bytes first via TextEncoder.
|
|
35
|
-
*/
|
|
36
|
-
function toBase64(str) {
|
|
37
|
-
const bytes = new TextEncoder().encode(str);
|
|
38
|
-
let binary = "";
|
|
39
|
-
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
40
|
-
return btoa(binary);
|
|
41
|
-
}
|
|
42
32
|
/** RFC 4648 base32 encoding (lowercase). Returns only [a-z2-7]. */
|
|
43
33
|
function base32Encode(data) {
|
|
44
34
|
const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
|
|
@@ -96,10 +86,10 @@ function resolveWorkspacePath(userPath) {
|
|
|
96
86
|
return null;
|
|
97
87
|
}
|
|
98
88
|
/**
|
|
99
|
-
* Validate a
|
|
89
|
+
* Validate a terminal ID. Rejects path traversal, control chars, and excessive length.
|
|
100
90
|
* Returns the validated ID or null if invalid.
|
|
101
91
|
*/
|
|
102
|
-
function
|
|
92
|
+
function validateTerminalId(id) {
|
|
103
93
|
if (!/^[a-zA-Z0-9._-]{1,128}$/.test(id)) return null;
|
|
104
94
|
if (id.includes("..")) return null;
|
|
105
95
|
return id;
|
|
@@ -110,24 +100,73 @@ function validateSessionId(id) {
|
|
|
110
100
|
*/
|
|
111
101
|
function sseToByteStream(sse) {
|
|
112
102
|
const encoder = new TextEncoder();
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
103
|
+
const iterator = streamFile(sse)[Symbol.asyncIterator]();
|
|
104
|
+
let finished = false;
|
|
105
|
+
async function finish(cancelSource) {
|
|
106
|
+
if (finished) return;
|
|
107
|
+
finished = true;
|
|
108
|
+
if (!cancelSource) return;
|
|
109
|
+
await iterator.return?.().catch(() => void 0);
|
|
110
|
+
}
|
|
111
|
+
return new ReadableStream({
|
|
112
|
+
async pull(controller) {
|
|
113
|
+
try {
|
|
114
|
+
const result = await iterator.next();
|
|
115
|
+
if (result.done) {
|
|
116
|
+
await finish(false);
|
|
117
|
+
controller.close();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
controller.enqueue(result.value instanceof Uint8Array ? result.value : encoder.encode(result.value));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
await finish(true);
|
|
123
|
+
controller.error(error);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
async cancel() {
|
|
127
|
+
await finish(true);
|
|
128
|
+
}
|
|
129
|
+
}, { highWaterMark: 0 });
|
|
130
|
+
}
|
|
131
|
+
function withStreamCleanup(source, cleanup) {
|
|
132
|
+
const reader = source.getReader();
|
|
133
|
+
let finished = false;
|
|
134
|
+
async function finish(cancelSource) {
|
|
135
|
+
if (finished) return;
|
|
136
|
+
finished = true;
|
|
137
|
+
if (cancelSource) await reader.cancel().catch(() => void 0);
|
|
138
|
+
reader.releaseLock();
|
|
139
|
+
await cleanup().catch(() => void 0);
|
|
140
|
+
}
|
|
141
|
+
return new ReadableStream({
|
|
142
|
+
async pull(controller) {
|
|
143
|
+
try {
|
|
144
|
+
const result = await reader.read();
|
|
145
|
+
if (result.done) {
|
|
146
|
+
await finish(false);
|
|
147
|
+
controller.close();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
controller.enqueue(result.value);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
await finish(true);
|
|
153
|
+
controller.error(error);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
async cancel() {
|
|
157
|
+
await finish(true);
|
|
119
158
|
}
|
|
120
|
-
} });
|
|
159
|
+
}, { highWaterMark: 0 });
|
|
121
160
|
}
|
|
122
161
|
|
|
123
162
|
//#endregion
|
|
124
|
-
//#region src/bridge/openapi.ts
|
|
163
|
+
//#region src/bridge/openapi/common.ts
|
|
125
164
|
/**
|
|
126
165
|
* OpenAPI 3.1 schema for the Cloudflare Sandbox Service API.
|
|
127
166
|
*
|
|
128
167
|
* Served at GET /v1/openapi.json (requires Bearer token auth).
|
|
129
168
|
*/
|
|
130
|
-
const
|
|
169
|
+
const OPENAPI_BASE = {
|
|
131
170
|
openapi: "3.1.0",
|
|
132
171
|
info: {
|
|
133
172
|
title: "Cloudflare Sandbox Service API",
|
|
@@ -141,7 +180,7 @@ const OPENAPI_SCHEMA = {
|
|
|
141
180
|
description: "API token set via `wrangler secret put SANDBOX_API_KEY`. The /openapi.* routes also accept the token as a `?token=` query parameter."
|
|
142
181
|
} },
|
|
143
182
|
schemas: {
|
|
144
|
-
|
|
183
|
+
ProcessCreateRequest: {
|
|
145
184
|
type: "object",
|
|
146
185
|
required: ["argv"],
|
|
147
186
|
properties: {
|
|
@@ -149,25 +188,121 @@ const OPENAPI_SCHEMA = {
|
|
|
149
188
|
type: "array",
|
|
150
189
|
items: { type: "string" },
|
|
151
190
|
minItems: 1,
|
|
152
|
-
description: "Argv array
|
|
191
|
+
description: "Argv array to launch directly.",
|
|
153
192
|
example: [
|
|
154
193
|
"sh",
|
|
155
194
|
"-lc",
|
|
156
195
|
"echo hello"
|
|
157
196
|
]
|
|
158
197
|
},
|
|
159
|
-
|
|
198
|
+
timeout: {
|
|
160
199
|
type: "integer",
|
|
161
|
-
description: "
|
|
200
|
+
description: "Remote process lifetime deadline in milliseconds. When reached, the sandbox may stop the process and the process exit outcome reports `timedOut: true`; this is not caller-local observation cancellation.",
|
|
162
201
|
example: 3e4
|
|
163
202
|
},
|
|
164
203
|
cwd: {
|
|
165
204
|
type: "string",
|
|
166
205
|
description: "Working directory for the command (defaults to sandbox cwd).",
|
|
167
206
|
example: "/workspace"
|
|
207
|
+
},
|
|
208
|
+
env: {
|
|
209
|
+
type: "object",
|
|
210
|
+
additionalProperties: { type: "string" },
|
|
211
|
+
description: "Environment overrides for the process."
|
|
168
212
|
}
|
|
169
213
|
}
|
|
170
214
|
},
|
|
215
|
+
ProcessStatus: {
|
|
216
|
+
oneOf: [
|
|
217
|
+
{
|
|
218
|
+
type: "object",
|
|
219
|
+
required: [
|
|
220
|
+
"id",
|
|
221
|
+
"pid",
|
|
222
|
+
"command",
|
|
223
|
+
"state",
|
|
224
|
+
"startedAt"
|
|
225
|
+
],
|
|
226
|
+
properties: {
|
|
227
|
+
id: { type: "string" },
|
|
228
|
+
pid: { type: "integer" },
|
|
229
|
+
command: {
|
|
230
|
+
type: "array",
|
|
231
|
+
items: { type: "string" }
|
|
232
|
+
},
|
|
233
|
+
cwd: { type: "string" },
|
|
234
|
+
state: { const: "running" },
|
|
235
|
+
startedAt: { type: "string" }
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
type: "object",
|
|
240
|
+
required: [
|
|
241
|
+
"id",
|
|
242
|
+
"pid",
|
|
243
|
+
"command",
|
|
244
|
+
"state",
|
|
245
|
+
"startedAt",
|
|
246
|
+
"endedAt",
|
|
247
|
+
"exit"
|
|
248
|
+
],
|
|
249
|
+
properties: {
|
|
250
|
+
id: { type: "string" },
|
|
251
|
+
pid: { type: "integer" },
|
|
252
|
+
command: {
|
|
253
|
+
type: "array",
|
|
254
|
+
items: { type: "string" }
|
|
255
|
+
},
|
|
256
|
+
cwd: { type: "string" },
|
|
257
|
+
state: { const: "exited" },
|
|
258
|
+
startedAt: { type: "string" },
|
|
259
|
+
endedAt: { type: "string" },
|
|
260
|
+
exit: {
|
|
261
|
+
type: "object",
|
|
262
|
+
required: ["code", "timedOut"],
|
|
263
|
+
properties: {
|
|
264
|
+
code: { type: "integer" },
|
|
265
|
+
signal: { type: "integer" },
|
|
266
|
+
timedOut: { type: "boolean" }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
type: "object",
|
|
273
|
+
required: [
|
|
274
|
+
"id",
|
|
275
|
+
"pid",
|
|
276
|
+
"command",
|
|
277
|
+
"state",
|
|
278
|
+
"startedAt",
|
|
279
|
+
"endedAt",
|
|
280
|
+
"error"
|
|
281
|
+
],
|
|
282
|
+
properties: {
|
|
283
|
+
id: { type: "string" },
|
|
284
|
+
pid: { type: "integer" },
|
|
285
|
+
command: {
|
|
286
|
+
type: "array",
|
|
287
|
+
items: { type: "string" }
|
|
288
|
+
},
|
|
289
|
+
cwd: { type: "string" },
|
|
290
|
+
state: { const: "error" },
|
|
291
|
+
startedAt: { type: "string" },
|
|
292
|
+
endedAt: { type: "string" },
|
|
293
|
+
error: {
|
|
294
|
+
type: "object",
|
|
295
|
+
required: ["code", "message"],
|
|
296
|
+
properties: {
|
|
297
|
+
code: { type: "string" },
|
|
298
|
+
message: { type: "string" }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
],
|
|
304
|
+
discriminator: { propertyName: "state" }
|
|
305
|
+
},
|
|
171
306
|
WriteResponse: {
|
|
172
307
|
type: "object",
|
|
173
308
|
required: ["ok"],
|
|
@@ -341,7 +476,6 @@ const OPENAPI_SCHEMA = {
|
|
|
341
476
|
"pool_error",
|
|
342
477
|
"mount_error",
|
|
343
478
|
"unmount_error",
|
|
344
|
-
"session_error",
|
|
345
479
|
"tunnel_error"
|
|
346
480
|
]
|
|
347
481
|
}
|
|
@@ -403,445 +537,106 @@ const OPENAPI_SCHEMA = {
|
|
|
403
537
|
code: "invalid_request"
|
|
404
538
|
}
|
|
405
539
|
} }
|
|
540
|
+
},
|
|
541
|
+
NotFound: {
|
|
542
|
+
description: "Requested resource was not found.",
|
|
543
|
+
content: { "application/json": {
|
|
544
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
545
|
+
example: {
|
|
546
|
+
error: "Process not found",
|
|
547
|
+
code: "not_found"
|
|
548
|
+
}
|
|
549
|
+
} }
|
|
550
|
+
},
|
|
551
|
+
BadGateway: {
|
|
552
|
+
description: "Sandbox operation failed.",
|
|
553
|
+
content: { "application/json": {
|
|
554
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
555
|
+
example: {
|
|
556
|
+
error: "process launch failed",
|
|
557
|
+
code: "process_error"
|
|
558
|
+
}
|
|
559
|
+
} }
|
|
406
560
|
}
|
|
407
561
|
}
|
|
408
562
|
},
|
|
409
|
-
security: [{ BearerAuth: [] }]
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}],
|
|
420
|
-
responses: {
|
|
421
|
-
"200": {
|
|
422
|
-
description: "New sandbox session created.",
|
|
423
|
-
content: { "application/json": { schema: {
|
|
424
|
-
type: "object",
|
|
425
|
-
required: ["id"],
|
|
426
|
-
properties: { id: {
|
|
427
|
-
type: "string",
|
|
428
|
-
description: "Unique sandbox ID for use with `/v1/sandbox/{id}/*` routes.",
|
|
429
|
-
example: "mfrggzdfmy2tqnrzgezdgnbv"
|
|
430
|
-
} }
|
|
431
|
-
} } }
|
|
432
|
-
},
|
|
433
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
434
|
-
}
|
|
435
|
-
} },
|
|
436
|
-
"/v1/sandbox/{id}/exec": { post: {
|
|
437
|
-
operationId: "execCommand",
|
|
438
|
-
summary: "Execute a command in the sandbox",
|
|
439
|
-
description: "Runs a shell command inside the named sandbox and streams output as Server-Sent Events (SSE). Events: `stdout` (base64 chunk), `stderr` (base64 chunk), `exit` (JSON with exit_code), `error` (JSON with error and code). The stream terminates after an `exit` or `error` event.",
|
|
563
|
+
security: [{ BearerAuth: [] }]
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
//#endregion
|
|
567
|
+
//#region src/bridge/openapi/files.ts
|
|
568
|
+
const FILESPaths = {
|
|
569
|
+
"/v1/sandbox/{id}/file/{path}": {
|
|
570
|
+
get: {
|
|
571
|
+
operationId: "readFile",
|
|
572
|
+
summary: "Read a file from the sandbox filesystem",
|
|
440
573
|
"x-codeSamples": [{
|
|
441
574
|
lang: "curl",
|
|
442
575
|
label: "cURL",
|
|
443
|
-
source: "curl -
|
|
576
|
+
source: "curl -X GET https://$HOST/v1/sandbox/my-sandbox/file/workspace/main.py \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -o main.py"
|
|
444
577
|
}],
|
|
445
578
|
parameters: [{
|
|
446
579
|
name: "id",
|
|
447
580
|
in: "path",
|
|
448
581
|
required: true,
|
|
449
582
|
schema: { type: "string" },
|
|
450
|
-
description: "Sandbox instance name
|
|
583
|
+
description: "Sandbox instance name."
|
|
451
584
|
}, {
|
|
452
|
-
name: "
|
|
453
|
-
in: "
|
|
454
|
-
required: false,
|
|
455
|
-
schema: {
|
|
456
|
-
type: "string",
|
|
457
|
-
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
458
|
-
},
|
|
459
|
-
description: "Scope this operation to an explicit session. If omitted, the operation uses top-level sandbox behavior."
|
|
460
|
-
}],
|
|
461
|
-
requestBody: {
|
|
585
|
+
name: "path",
|
|
586
|
+
in: "path",
|
|
462
587
|
required: true,
|
|
463
|
-
|
|
464
|
-
|
|
588
|
+
schema: { type: "string" },
|
|
589
|
+
description: "File path inside the sandbox, without leading slash (e.g. workspace/main.py). Must resolve within /workspace."
|
|
590
|
+
}],
|
|
465
591
|
responses: {
|
|
466
592
|
"200": {
|
|
467
|
-
description: "
|
|
468
|
-
content: { "
|
|
593
|
+
description: "Raw file bytes.",
|
|
594
|
+
content: { "application/octet-stream": { schema: {
|
|
595
|
+
type: "string",
|
|
596
|
+
format: "binary"
|
|
597
|
+
} } }
|
|
469
598
|
},
|
|
470
599
|
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
471
600
|
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
472
601
|
"403": {
|
|
473
|
-
description: "
|
|
602
|
+
description: "Path resolves outside /workspace.",
|
|
474
603
|
content: { "application/json": {
|
|
475
604
|
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
476
605
|
example: {
|
|
477
|
-
error: "
|
|
606
|
+
error: "path must resolve to a location within /workspace",
|
|
478
607
|
code: "invalid_request"
|
|
479
608
|
}
|
|
480
609
|
} }
|
|
481
610
|
},
|
|
482
|
-
"
|
|
483
|
-
description: "
|
|
484
|
-
content: { "application/json": {
|
|
485
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
486
|
-
example: {
|
|
487
|
-
error: "exec failed: connection reset",
|
|
488
|
-
code: "exec_transport_error"
|
|
489
|
-
}
|
|
490
|
-
} }
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
} },
|
|
494
|
-
"/v1/sandbox/{id}/tunnel/{port}": {
|
|
495
|
-
post: {
|
|
496
|
-
operationId: "createTunnel",
|
|
497
|
-
summary: "Create or reuse a tunnel for a sandbox port",
|
|
498
|
-
description: "Returns an existing tunnel for the port when one is already recorded, or provisions one when needed. The service must already be listening inside the sandbox. Omit `name` for an ephemeral `*.trycloudflare.com` tunnel, or pass `name` to choose the subdomain prefix for a named tunnel. Use a value such as `app`, not a full hostname.",
|
|
499
|
-
"x-codeSamples": [{
|
|
500
|
-
lang: "curl",
|
|
501
|
-
label: "Ephemeral tunnel",
|
|
502
|
-
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/tunnel/8080 \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
503
|
-
}, {
|
|
504
|
-
lang: "curl",
|
|
505
|
-
label: "Named tunnel",
|
|
506
|
-
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/tunnel/8080 \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"app\"}'"
|
|
507
|
-
}],
|
|
508
|
-
parameters: [{
|
|
509
|
-
name: "id",
|
|
510
|
-
in: "path",
|
|
511
|
-
required: true,
|
|
512
|
-
schema: { type: "string" },
|
|
513
|
-
description: "Sandbox instance name."
|
|
514
|
-
}, {
|
|
515
|
-
name: "port",
|
|
516
|
-
in: "path",
|
|
517
|
-
required: true,
|
|
518
|
-
schema: {
|
|
519
|
-
type: "integer",
|
|
520
|
-
minimum: 1024,
|
|
521
|
-
maximum: 65535
|
|
522
|
-
},
|
|
523
|
-
description: "Container port to tunnel. Port 3000 is reserved."
|
|
524
|
-
}],
|
|
525
|
-
requestBody: {
|
|
526
|
-
required: false,
|
|
527
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/TunnelRequest" } } }
|
|
528
|
-
},
|
|
529
|
-
responses: {
|
|
530
|
-
"200": {
|
|
531
|
-
description: "Tunnel created or reused.",
|
|
532
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/Tunnel" } } }
|
|
533
|
-
},
|
|
534
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
535
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
536
|
-
"502": {
|
|
537
|
-
description: "Tunnel creation failed.",
|
|
538
|
-
content: { "application/json": {
|
|
539
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
540
|
-
example: {
|
|
541
|
-
error: "tunnel failed: cloudflared could not be found",
|
|
542
|
-
code: "tunnel_error"
|
|
543
|
-
}
|
|
544
|
-
} }
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
},
|
|
548
|
-
delete: {
|
|
549
|
-
operationId: "deleteTunnel",
|
|
550
|
-
summary: "Delete the tunnel for a sandbox port",
|
|
551
|
-
description: "Stops the tunnel process for the port and removes any named-tunnel Cloudflare resources tracked by the sandbox.",
|
|
552
|
-
"x-codeSamples": [{
|
|
553
|
-
lang: "curl",
|
|
554
|
-
label: "Delete tunnel",
|
|
555
|
-
source: "curl -X DELETE https://$HOST/v1/sandbox/my-sandbox/tunnel/8080 \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
556
|
-
}],
|
|
557
|
-
parameters: [{
|
|
558
|
-
name: "id",
|
|
559
|
-
in: "path",
|
|
560
|
-
required: true,
|
|
561
|
-
schema: { type: "string" },
|
|
562
|
-
description: "Sandbox instance name."
|
|
563
|
-
}, {
|
|
564
|
-
name: "port",
|
|
565
|
-
in: "path",
|
|
566
|
-
required: true,
|
|
567
|
-
schema: {
|
|
568
|
-
type: "integer",
|
|
569
|
-
minimum: 1024,
|
|
570
|
-
maximum: 65535
|
|
571
|
-
},
|
|
572
|
-
description: "Container port whose tunnel should be deleted. Port 3000 is reserved."
|
|
573
|
-
}],
|
|
574
|
-
responses: {
|
|
575
|
-
"204": { description: "Tunnel deleted or already absent." },
|
|
576
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
577
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
578
|
-
"502": {
|
|
579
|
-
description: "Tunnel deletion failed.",
|
|
580
|
-
content: { "application/json": {
|
|
581
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
582
|
-
example: {
|
|
583
|
-
error: "tunnel failed: cleanup failed",
|
|
584
|
-
code: "tunnel_error"
|
|
585
|
-
}
|
|
586
|
-
} }
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
},
|
|
591
|
-
"/v1/sandbox/{id}/file/{path}": {
|
|
592
|
-
get: {
|
|
593
|
-
operationId: "readFile",
|
|
594
|
-
summary: "Read a file from the sandbox filesystem",
|
|
595
|
-
"x-codeSamples": [{
|
|
596
|
-
lang: "curl",
|
|
597
|
-
label: "cURL",
|
|
598
|
-
source: "curl -X GET https://$HOST/v1/sandbox/my-sandbox/file/workspace/main.py \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -o main.py"
|
|
599
|
-
}],
|
|
600
|
-
parameters: [
|
|
601
|
-
{
|
|
602
|
-
name: "id",
|
|
603
|
-
in: "path",
|
|
604
|
-
required: true,
|
|
605
|
-
schema: { type: "string" },
|
|
606
|
-
description: "Sandbox instance name."
|
|
607
|
-
},
|
|
608
|
-
{
|
|
609
|
-
name: "path",
|
|
610
|
-
in: "path",
|
|
611
|
-
required: true,
|
|
612
|
-
schema: { type: "string" },
|
|
613
|
-
description: "File path inside the sandbox, without leading slash (e.g. workspace/main.py). Must resolve within /workspace."
|
|
614
|
-
},
|
|
615
|
-
{
|
|
616
|
-
name: "Session-Id",
|
|
617
|
-
in: "header",
|
|
618
|
-
required: false,
|
|
619
|
-
schema: {
|
|
620
|
-
type: "string",
|
|
621
|
-
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
622
|
-
},
|
|
623
|
-
description: "Scope this operation to an explicit session. If omitted, the operation uses top-level sandbox behavior."
|
|
624
|
-
}
|
|
625
|
-
],
|
|
626
|
-
responses: {
|
|
627
|
-
"200": {
|
|
628
|
-
description: "Raw file bytes.",
|
|
629
|
-
content: { "application/octet-stream": { schema: {
|
|
630
|
-
type: "string",
|
|
631
|
-
format: "binary"
|
|
632
|
-
} } }
|
|
633
|
-
},
|
|
634
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
635
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
636
|
-
"403": {
|
|
637
|
-
description: "Path resolves outside /workspace.",
|
|
638
|
-
content: { "application/json": {
|
|
639
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
640
|
-
example: {
|
|
641
|
-
error: "path must resolve to a location within /workspace",
|
|
642
|
-
code: "invalid_request"
|
|
643
|
-
}
|
|
644
|
-
} }
|
|
645
|
-
},
|
|
646
|
-
"404": {
|
|
647
|
-
description: "File not found in the sandbox.",
|
|
648
|
-
content: { "application/json": {
|
|
649
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
650
|
-
example: {
|
|
651
|
-
error: "File not found: /workspace/foo.txt",
|
|
652
|
-
code: "workspace_read_not_found"
|
|
653
|
-
}
|
|
654
|
-
} }
|
|
655
|
-
},
|
|
656
|
-
"502": {
|
|
657
|
-
description: "SDK read call failed.",
|
|
658
|
-
content: { "application/json": {
|
|
659
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
660
|
-
example: {
|
|
661
|
-
error: "read failed: connection reset",
|
|
662
|
-
code: "exec_transport_error"
|
|
663
|
-
}
|
|
664
|
-
} }
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
},
|
|
668
|
-
put: {
|
|
669
|
-
operationId: "writeFile",
|
|
670
|
-
summary: "Write a file into the sandbox filesystem",
|
|
671
|
-
"x-codeSamples": [{
|
|
672
|
-
lang: "curl",
|
|
673
|
-
label: "cURL",
|
|
674
|
-
source: "curl -X PUT https://$HOST/v1/sandbox/my-sandbox/file/workspace/main.py \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/octet-stream\" \\\n --data-binary @main.py"
|
|
675
|
-
}],
|
|
676
|
-
parameters: [
|
|
677
|
-
{
|
|
678
|
-
name: "id",
|
|
679
|
-
in: "path",
|
|
680
|
-
required: true,
|
|
681
|
-
schema: { type: "string" },
|
|
682
|
-
description: "Sandbox instance name."
|
|
683
|
-
},
|
|
684
|
-
{
|
|
685
|
-
name: "path",
|
|
686
|
-
in: "path",
|
|
687
|
-
required: true,
|
|
688
|
-
schema: { type: "string" },
|
|
689
|
-
description: "File path inside the sandbox, without leading slash (e.g. workspace/main.py). Must resolve within /workspace."
|
|
690
|
-
},
|
|
691
|
-
{
|
|
692
|
-
name: "Session-Id",
|
|
693
|
-
in: "header",
|
|
694
|
-
required: false,
|
|
695
|
-
schema: {
|
|
696
|
-
type: "string",
|
|
697
|
-
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
698
|
-
},
|
|
699
|
-
description: "Scope this operation to an explicit session. If omitted, the operation uses top-level sandbox behavior."
|
|
700
|
-
}
|
|
701
|
-
],
|
|
702
|
-
requestBody: {
|
|
703
|
-
required: true,
|
|
704
|
-
description: "Raw file content to write.",
|
|
705
|
-
content: { "application/octet-stream": { schema: {
|
|
706
|
-
type: "string",
|
|
707
|
-
format: "binary"
|
|
708
|
-
} } }
|
|
709
|
-
},
|
|
710
|
-
responses: {
|
|
711
|
-
"200": {
|
|
712
|
-
description: "File written successfully.",
|
|
713
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/WriteResponse" } } }
|
|
714
|
-
},
|
|
715
|
-
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
716
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
717
|
-
"403": {
|
|
718
|
-
description: "Path resolves outside /workspace.",
|
|
719
|
-
content: { "application/json": {
|
|
720
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
721
|
-
example: {
|
|
722
|
-
error: "path must resolve to a location within /workspace",
|
|
723
|
-
code: "invalid_request"
|
|
724
|
-
}
|
|
725
|
-
} }
|
|
726
|
-
},
|
|
727
|
-
"502": {
|
|
728
|
-
description: "SDK write call failed.",
|
|
729
|
-
content: { "application/json": {
|
|
730
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
731
|
-
example: {
|
|
732
|
-
error: "write failed: connection reset",
|
|
733
|
-
code: "workspace_archive_write_error"
|
|
734
|
-
}
|
|
735
|
-
} }
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
},
|
|
740
|
-
"/v1/sandbox/{id}/pty": { get: {
|
|
741
|
-
operationId: "ptyTerminal",
|
|
742
|
-
summary: "Open a terminal via WebSocket",
|
|
743
|
-
description: "Upgrades the HTTP connection to a WebSocket and proxies it to a PTY shell inside the sandbox.\n\n**A terminal ID is required**, supplied via either the `terminalId` query parameter or the `Terminal-Id` header (header takes precedence). The terminal ID is a stable resource name: reconnecting with the same ID reattaches to the same live PTY while the sandbox container is alive. If the sandbox container restarts (for example after it sleeps), connecting with the same ID creates a fresh shell — prior PTY state and process history are not preserved.\n\n**WebSocket frame protocol:**\n\n| Direction | Frame type | Content |\n|-----------|------------|--------------------------------------------------|\n| Client → Server | Binary | UTF-8 encoded keystrokes / input |\n| Server → Client | Binary | Terminal output (including ANSI escape sequences) |\n| Client → Server | Text (JSON) | Control messages, e.g. `{\"type\":\"resize\",\"cols\":120,\"rows\":30}` |\n| Server → Client | Text (JSON) | Status messages: `ready`, `exit`, `error` |\n\n**Status messages (server → client):**\n- `{\"type\":\"ready\"}` — PTY is accepting input\n- `{\"type\":\"exit\",\"code\":0,\"signal\":\"SIGTERM\"}` — PTY exited\n- `{\"type\":\"error\",\"message\":\"...\"}` — error occurred\n\nIf the client disconnects, the terminal stays alive while the sandbox container is alive; reconnect with the same terminal ID to reattach.",
|
|
744
|
-
"x-codeSamples": [{
|
|
745
|
-
lang: "JavaScript",
|
|
746
|
-
label: "WebSocket",
|
|
747
|
-
source: "const ws = new WebSocket(\"wss://$HOST/v1/sandbox/my-sandbox/pty?terminalId=term-1&cols=120&rows=30\");\nws.binaryType = \"arraybuffer\";\nws.onmessage = (e) => { /* handle binary output or JSON status */ };"
|
|
748
|
-
}],
|
|
749
|
-
parameters: [
|
|
750
|
-
{
|
|
751
|
-
name: "id",
|
|
752
|
-
in: "path",
|
|
753
|
-
required: true,
|
|
754
|
-
schema: { type: "string" },
|
|
755
|
-
description: "Sandbox instance name."
|
|
756
|
-
},
|
|
757
|
-
{
|
|
758
|
-
name: "cols",
|
|
759
|
-
in: "query",
|
|
760
|
-
required: false,
|
|
761
|
-
schema: {
|
|
762
|
-
type: "integer",
|
|
763
|
-
default: 80
|
|
764
|
-
},
|
|
765
|
-
description: "Terminal width in columns."
|
|
766
|
-
},
|
|
767
|
-
{
|
|
768
|
-
name: "rows",
|
|
769
|
-
in: "query",
|
|
770
|
-
required: false,
|
|
771
|
-
schema: {
|
|
772
|
-
type: "integer",
|
|
773
|
-
default: 24
|
|
774
|
-
},
|
|
775
|
-
description: "Terminal height in rows."
|
|
776
|
-
},
|
|
777
|
-
{
|
|
778
|
-
name: "shell",
|
|
779
|
-
in: "query",
|
|
780
|
-
required: false,
|
|
781
|
-
schema: { type: "string" },
|
|
782
|
-
description: "Shell binary to run (e.g. `/bin/bash`). Uses the container default if omitted."
|
|
783
|
-
},
|
|
784
|
-
{
|
|
785
|
-
name: "cwd",
|
|
786
|
-
in: "query",
|
|
787
|
-
required: false,
|
|
788
|
-
schema: { type: "string" },
|
|
789
|
-
description: "Initial working directory for newly created terminal resources."
|
|
790
|
-
},
|
|
791
|
-
{
|
|
792
|
-
name: "terminalId",
|
|
793
|
-
in: "query",
|
|
794
|
-
required: false,
|
|
795
|
-
schema: {
|
|
796
|
-
type: "string",
|
|
797
|
-
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
798
|
-
},
|
|
799
|
-
description: "Terminal resource ID. Required unless the `Terminal-Id` request header is supplied. Stable name for this terminal resource: reuse the same ID across reconnects to reattach to the same live PTY. After the sandbox container restarts, the same ID creates a new shell. Ignored when `Terminal-Id` header is also present (header takes precedence)."
|
|
800
|
-
},
|
|
801
|
-
{
|
|
802
|
-
name: "Terminal-Id",
|
|
803
|
-
in: "header",
|
|
804
|
-
required: false,
|
|
805
|
-
schema: {
|
|
806
|
-
type: "string",
|
|
807
|
-
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
808
|
-
},
|
|
809
|
-
description: "Terminal resource ID. Required unless the `terminalId` query parameter is supplied. Takes precedence over the `terminalId` query parameter when both are present. Same stable-ID semantics apply: same ID reattaches to the live PTY; after the sandbox container restarts a new shell is created."
|
|
810
|
-
}
|
|
811
|
-
],
|
|
812
|
-
responses: {
|
|
813
|
-
"101": { description: "WebSocket upgrade successful. Binary and text frames flow bidirectionally as described above." },
|
|
814
|
-
"400": {
|
|
815
|
-
description: "Missing `Upgrade: websocket` header or invalid query parameters.",
|
|
611
|
+
"404": {
|
|
612
|
+
description: "File not found in the sandbox.",
|
|
816
613
|
content: { "application/json": {
|
|
817
614
|
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
818
615
|
example: {
|
|
819
|
-
error: "
|
|
820
|
-
code: "
|
|
616
|
+
error: "File not found: /workspace/foo.txt",
|
|
617
|
+
code: "workspace_read_not_found"
|
|
821
618
|
}
|
|
822
619
|
} }
|
|
823
620
|
},
|
|
824
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
825
621
|
"502": {
|
|
826
|
-
description: "SDK
|
|
622
|
+
description: "SDK read call failed.",
|
|
827
623
|
content: { "application/json": {
|
|
828
624
|
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
829
625
|
example: {
|
|
830
|
-
error: "
|
|
626
|
+
error: "read failed: connection reset",
|
|
831
627
|
code: "exec_transport_error"
|
|
832
628
|
}
|
|
833
629
|
} }
|
|
834
630
|
}
|
|
835
631
|
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
operationId: "
|
|
839
|
-
summary: "
|
|
840
|
-
description: "Executes a no-op command inside the sandbox. Always returns HTTP 200; inspect the `running` field.",
|
|
632
|
+
},
|
|
633
|
+
put: {
|
|
634
|
+
operationId: "writeFile",
|
|
635
|
+
summary: "Write a file into the sandbox filesystem",
|
|
841
636
|
"x-codeSamples": [{
|
|
842
637
|
lang: "curl",
|
|
843
638
|
label: "cURL",
|
|
844
|
-
source: "curl -X
|
|
639
|
+
source: "curl -X PUT https://$HOST/v1/sandbox/my-sandbox/file/workspace/main.py \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/octet-stream\" \\\n --data-binary @main.py"
|
|
845
640
|
}],
|
|
846
641
|
parameters: [{
|
|
847
642
|
name: "id",
|
|
@@ -849,399 +644,724 @@ const OPENAPI_SCHEMA = {
|
|
|
849
644
|
required: true,
|
|
850
645
|
schema: { type: "string" },
|
|
851
646
|
description: "Sandbox instance name."
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
"200": {
|
|
855
|
-
description: "Liveness status (always returned, even when not running).",
|
|
856
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/RunningResponse" } } }
|
|
857
|
-
},
|
|
858
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
859
|
-
}
|
|
860
|
-
} },
|
|
861
|
-
"/v1/sandbox/{id}/persist": { post: {
|
|
862
|
-
operationId: "persistWorkspace",
|
|
863
|
-
summary: "Serialize the sandbox workspace to a tar archive",
|
|
864
|
-
description: "Archives the /workspace directory inside the sandbox and streams the resulting tar back as raw bytes.",
|
|
865
|
-
"x-codeSamples": [{
|
|
866
|
-
lang: "curl",
|
|
867
|
-
label: "cURL",
|
|
868
|
-
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/persist \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -o workspace.tar"
|
|
869
|
-
}],
|
|
870
|
-
parameters: [{
|
|
871
|
-
name: "id",
|
|
647
|
+
}, {
|
|
648
|
+
name: "path",
|
|
872
649
|
in: "path",
|
|
873
650
|
required: true,
|
|
874
651
|
schema: { type: "string" },
|
|
875
|
-
description: "
|
|
876
|
-
}, {
|
|
877
|
-
name: "excludes",
|
|
878
|
-
in: "query",
|
|
879
|
-
required: false,
|
|
880
|
-
schema: { type: "string" },
|
|
881
|
-
description: "Comma-separated list of relative paths to exclude from the archive.",
|
|
882
|
-
example: "__pycache__,.venv"
|
|
652
|
+
description: "File path inside the sandbox, without leading slash (e.g. workspace/main.py). Must resolve within /workspace."
|
|
883
653
|
}],
|
|
654
|
+
requestBody: {
|
|
655
|
+
required: true,
|
|
656
|
+
description: "Raw file content to write.",
|
|
657
|
+
content: { "application/octet-stream": { schema: {
|
|
658
|
+
type: "string",
|
|
659
|
+
format: "binary"
|
|
660
|
+
} } }
|
|
661
|
+
},
|
|
884
662
|
responses: {
|
|
885
663
|
"200": {
|
|
886
|
-
description: "
|
|
887
|
-
content: { "application/
|
|
888
|
-
type: "string",
|
|
889
|
-
format: "binary"
|
|
890
|
-
} } }
|
|
664
|
+
description: "File written successfully.",
|
|
665
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/WriteResponse" } } }
|
|
891
666
|
},
|
|
892
|
-
"400": {
|
|
893
|
-
|
|
667
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
668
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
669
|
+
"403": {
|
|
670
|
+
description: "Path resolves outside /workspace.",
|
|
894
671
|
content: { "application/json": {
|
|
895
672
|
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
896
673
|
example: {
|
|
897
|
-
error: "
|
|
674
|
+
error: "path must resolve to a location within /workspace",
|
|
898
675
|
code: "invalid_request"
|
|
899
676
|
}
|
|
900
677
|
} }
|
|
901
678
|
},
|
|
902
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
903
679
|
"502": {
|
|
904
|
-
description: "
|
|
680
|
+
description: "SDK write call failed.",
|
|
905
681
|
content: { "application/json": {
|
|
906
682
|
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
907
683
|
example: {
|
|
908
|
-
error: "
|
|
909
|
-
code: "
|
|
684
|
+
error: "write failed: connection reset",
|
|
685
|
+
code: "workspace_archive_write_error"
|
|
910
686
|
}
|
|
911
687
|
} }
|
|
912
688
|
}
|
|
913
689
|
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
"/v1/sandbox/{id}/persist": { post: {
|
|
693
|
+
operationId: "persistWorkspace",
|
|
694
|
+
summary: "Serialize the sandbox workspace to a tar archive",
|
|
695
|
+
description: "Archives the /workspace directory inside the sandbox and streams the resulting tar back as raw bytes.",
|
|
696
|
+
"x-codeSamples": [{
|
|
697
|
+
lang: "curl",
|
|
698
|
+
label: "cURL",
|
|
699
|
+
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/persist \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -o workspace.tar"
|
|
700
|
+
}],
|
|
701
|
+
parameters: [{
|
|
702
|
+
name: "id",
|
|
703
|
+
in: "path",
|
|
704
|
+
required: true,
|
|
705
|
+
schema: { type: "string" },
|
|
706
|
+
description: "Sandbox instance name."
|
|
707
|
+
}, {
|
|
708
|
+
name: "excludes",
|
|
709
|
+
in: "query",
|
|
710
|
+
required: false,
|
|
711
|
+
schema: { type: "string" },
|
|
712
|
+
description: "Comma-separated list of relative paths to exclude from the archive.",
|
|
713
|
+
example: "__pycache__,.venv"
|
|
714
|
+
}],
|
|
715
|
+
responses: {
|
|
716
|
+
"200": {
|
|
933
717
|
description: "Raw tar archive bytes.",
|
|
934
718
|
content: { "application/octet-stream": { schema: {
|
|
935
719
|
type: "string",
|
|
936
720
|
format: "binary"
|
|
937
721
|
} } }
|
|
938
722
|
},
|
|
939
|
-
|
|
940
|
-
"
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
723
|
+
"400": {
|
|
724
|
+
description: "Invalid exclude paths (e.g. path traversal).",
|
|
725
|
+
content: { "application/json": {
|
|
726
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
727
|
+
example: {
|
|
728
|
+
error: "exclude paths must not contain \"..\"",
|
|
729
|
+
code: "invalid_request"
|
|
730
|
+
}
|
|
731
|
+
} }
|
|
732
|
+
},
|
|
733
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
734
|
+
"502": {
|
|
735
|
+
description: "tar command failed inside the sandbox.",
|
|
736
|
+
content: { "application/json": {
|
|
737
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
738
|
+
example: {
|
|
739
|
+
error: "tar failed (exit 1): ...",
|
|
740
|
+
code: "workspace_archive_read_error"
|
|
741
|
+
}
|
|
742
|
+
} }
|
|
956
743
|
}
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
744
|
+
}
|
|
745
|
+
} },
|
|
746
|
+
"/v1/sandbox/{id}/hydrate": { post: {
|
|
747
|
+
operationId: "hydrateWorkspace",
|
|
748
|
+
summary: "Populate the sandbox workspace from a tar archive",
|
|
749
|
+
description: "Accepts a raw tar archive as the request body and extracts it into /workspace inside the sandbox.",
|
|
750
|
+
"x-codeSamples": [{
|
|
751
|
+
lang: "curl",
|
|
752
|
+
label: "cURL",
|
|
753
|
+
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/hydrate \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/octet-stream\" \\\n --data-binary @workspace.tar"
|
|
754
|
+
}],
|
|
755
|
+
parameters: [{
|
|
756
|
+
name: "id",
|
|
757
|
+
in: "path",
|
|
758
|
+
required: true,
|
|
759
|
+
schema: { type: "string" },
|
|
760
|
+
description: "Sandbox instance name."
|
|
761
|
+
}],
|
|
762
|
+
requestBody: {
|
|
763
|
+
required: true,
|
|
764
|
+
description: "Raw tar archive bytes.",
|
|
765
|
+
content: { "application/octet-stream": { schema: {
|
|
766
|
+
type: "string",
|
|
767
|
+
format: "binary"
|
|
768
|
+
} } }
|
|
769
|
+
},
|
|
770
|
+
responses: {
|
|
771
|
+
"200": {
|
|
772
|
+
description: "Archive extracted successfully.",
|
|
773
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
774
|
+
},
|
|
775
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
776
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
777
|
+
"502": {
|
|
778
|
+
description: "tar extract failed inside the sandbox.",
|
|
779
|
+
content: { "application/json": {
|
|
780
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
781
|
+
example: {
|
|
782
|
+
error: "tar extract failed (exit 1): ...",
|
|
783
|
+
code: "workspace_archive_write_error"
|
|
784
|
+
}
|
|
785
|
+
} }
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
} },
|
|
789
|
+
"/v1/sandbox/{id}/mount": { post: {
|
|
790
|
+
operationId: "mountBucket",
|
|
791
|
+
summary: "Mount an S3-compatible bucket into the container",
|
|
792
|
+
description: "Mounts an S3-compatible bucket (R2, S3, GCS, etc.) as a local directory via s3fs-FUSE. Credentials are optional — the SDK auto-detects from Worker secrets when omitted.",
|
|
793
|
+
"x-codeSamples": [{
|
|
794
|
+
lang: "curl",
|
|
795
|
+
label: "cURL",
|
|
796
|
+
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/mount \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"bucket\":\"my-bucket\",\"mountPath\":\"/mnt/data\",\"options\":{\"endpoint\":\"https://ACCT.r2.cloudflarestorage.com\"}}'"
|
|
797
|
+
}],
|
|
798
|
+
parameters: [{
|
|
799
|
+
name: "id",
|
|
800
|
+
in: "path",
|
|
801
|
+
required: true,
|
|
802
|
+
schema: { type: "string" },
|
|
803
|
+
description: "Sandbox instance name."
|
|
804
|
+
}],
|
|
805
|
+
requestBody: {
|
|
806
|
+
required: true,
|
|
807
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/MountBucketRequest" } } }
|
|
808
|
+
},
|
|
809
|
+
responses: {
|
|
810
|
+
"200": {
|
|
811
|
+
description: "Bucket mounted successfully.",
|
|
812
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
813
|
+
},
|
|
814
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
815
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
816
|
+
"502": {
|
|
817
|
+
description: "SDK mount call failed (invalid config, duplicate mount, or s3fs error).",
|
|
818
|
+
content: { "application/json": {
|
|
819
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
820
|
+
example: {
|
|
821
|
+
error: "mount failed: Mount path already in use",
|
|
822
|
+
code: "mount_error"
|
|
823
|
+
}
|
|
824
|
+
} }
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
} },
|
|
828
|
+
"/v1/sandbox/{id}/unmount": { post: {
|
|
829
|
+
operationId: "unmountBucket",
|
|
830
|
+
summary: "Unmount a previously mounted bucket",
|
|
831
|
+
description: "Unmounts a bucket filesystem that was previously mounted via `/v1/sandbox/{id}/mount`.",
|
|
832
|
+
"x-codeSamples": [{
|
|
833
|
+
lang: "curl",
|
|
834
|
+
label: "cURL",
|
|
835
|
+
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/unmount \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"mountPath\":\"/mnt/data\"}'"
|
|
836
|
+
}],
|
|
837
|
+
parameters: [{
|
|
838
|
+
name: "id",
|
|
839
|
+
in: "path",
|
|
840
|
+
required: true,
|
|
841
|
+
schema: { type: "string" },
|
|
842
|
+
description: "Sandbox instance name."
|
|
843
|
+
}],
|
|
844
|
+
requestBody: {
|
|
845
|
+
required: true,
|
|
846
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/UnmountBucketRequest" } } }
|
|
847
|
+
},
|
|
848
|
+
responses: {
|
|
849
|
+
"200": {
|
|
850
|
+
description: "Bucket unmounted successfully.",
|
|
851
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
852
|
+
},
|
|
853
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
854
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
855
|
+
"502": {
|
|
856
|
+
description: "SDK unmount call failed (no active mount or unmount error).",
|
|
857
|
+
content: { "application/json": {
|
|
858
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
859
|
+
example: {
|
|
860
|
+
error: "unmount failed: No active mount found",
|
|
861
|
+
code: "unmount_error"
|
|
862
|
+
}
|
|
863
|
+
} }
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
} }
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
//#endregion
|
|
870
|
+
//#region src/bridge/openapi/lifecycle.ts
|
|
871
|
+
const LIFECYCLEPaths = {
|
|
872
|
+
"/v1/sandbox": { post: {
|
|
873
|
+
operationId: "createSandbox",
|
|
874
|
+
summary: "Create a new sandbox",
|
|
875
|
+
description: "Generates a new unique sandbox ID. Use this ID with all `/v1/sandbox/{id}/*` routes.",
|
|
876
|
+
"x-codeSamples": [{
|
|
877
|
+
lang: "curl",
|
|
878
|
+
label: "cURL",
|
|
879
|
+
source: "curl -X POST https://$HOST/v1/sandbox \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
880
|
+
}],
|
|
881
|
+
responses: {
|
|
882
|
+
"200": {
|
|
883
|
+
description: "New sandbox created.",
|
|
884
|
+
content: { "application/json": { schema: {
|
|
885
|
+
type: "object",
|
|
886
|
+
required: ["id"],
|
|
887
|
+
properties: { id: {
|
|
888
|
+
type: "string",
|
|
889
|
+
description: "Unique sandbox ID for use with `/v1/sandbox/{id}/*` routes.",
|
|
890
|
+
example: "mfrggzdfmy2tqnrzgezdgnbv"
|
|
891
|
+
} }
|
|
892
|
+
} } }
|
|
893
|
+
},
|
|
894
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
895
|
+
}
|
|
896
|
+
} },
|
|
897
|
+
"/v1/sandbox/{id}": { delete: {
|
|
898
|
+
operationId: "deleteSandbox",
|
|
899
|
+
summary: "Destroy a sandbox instance (best-effort)",
|
|
900
|
+
description: "Calls destroy() on the sandbox Durable Object to release container resources. Best-effort: unknown sandbox IDs return 204 without allocating a container.",
|
|
901
|
+
"x-codeSamples": [{
|
|
902
|
+
lang: "curl",
|
|
903
|
+
label: "cURL",
|
|
904
|
+
source: "curl -X DELETE https://$HOST/v1/sandbox/my-sandbox \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
905
|
+
}],
|
|
906
|
+
parameters: [{
|
|
907
|
+
name: "id",
|
|
908
|
+
in: "path",
|
|
909
|
+
required: true,
|
|
910
|
+
schema: { type: "string" },
|
|
911
|
+
description: "Sandbox instance name."
|
|
912
|
+
}],
|
|
913
|
+
responses: {
|
|
914
|
+
"204": { description: "Sandbox destroyed (best-effort). Container resources are released." },
|
|
915
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
916
|
+
}
|
|
917
|
+
} },
|
|
918
|
+
"/health": { get: {
|
|
919
|
+
operationId: "healthCheck",
|
|
920
|
+
summary: "Worker health check",
|
|
921
|
+
description: "Simple liveness probe. Not protected by authentication.",
|
|
922
|
+
"x-codeSamples": [{
|
|
923
|
+
lang: "curl",
|
|
924
|
+
label: "cURL",
|
|
925
|
+
source: "curl https://$HOST/health"
|
|
926
|
+
}],
|
|
927
|
+
security: [],
|
|
928
|
+
responses: { "200": {
|
|
929
|
+
description: "Worker is up.",
|
|
930
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
931
|
+
} }
|
|
932
|
+
} },
|
|
933
|
+
"/v1/openapi.json": { get: {
|
|
934
|
+
operationId: "getOpenApiSchema",
|
|
935
|
+
summary: "OpenAPI schema",
|
|
936
|
+
description: "Returns this OpenAPI 3.1 schema document.",
|
|
937
|
+
"x-codeSamples": [{
|
|
938
|
+
lang: "curl",
|
|
939
|
+
label: "cURL",
|
|
940
|
+
source: "curl https://$HOST/v1/openapi.json \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
941
|
+
}],
|
|
942
|
+
responses: {
|
|
943
|
+
"200": {
|
|
944
|
+
description: "OpenAPI schema document.",
|
|
945
|
+
content: { "application/json": { schema: { type: "object" } } }
|
|
946
|
+
},
|
|
947
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
948
|
+
}
|
|
949
|
+
} },
|
|
950
|
+
"/v1/pool/stats": { get: {
|
|
951
|
+
operationId: "getPoolStats",
|
|
952
|
+
summary: "Pool statistics",
|
|
953
|
+
description: "Returns current warm pool statistics including warm/assigned counts and configuration.",
|
|
954
|
+
"x-codeSamples": [{
|
|
955
|
+
lang: "curl",
|
|
956
|
+
label: "cURL",
|
|
957
|
+
source: "curl https://$HOST/v1/pool/stats \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
958
|
+
}],
|
|
959
|
+
responses: {
|
|
960
|
+
"200": {
|
|
961
|
+
description: "Pool statistics.",
|
|
962
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/PoolStats" } } }
|
|
963
|
+
},
|
|
964
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
965
|
+
}
|
|
966
|
+
} },
|
|
967
|
+
"/v1/pool/shutdown-prewarmed": { post: {
|
|
968
|
+
operationId: "shutdownPrewarmed",
|
|
969
|
+
summary: "Shutdown pre-warmed containers",
|
|
970
|
+
description: "Destroys all idle (unassigned) warm containers. Does not affect containers assigned to sandbox instances.",
|
|
971
|
+
"x-codeSamples": [{
|
|
972
|
+
lang: "curl",
|
|
973
|
+
label: "cURL",
|
|
974
|
+
source: "curl -X POST https://$HOST/v1/pool/shutdown-prewarmed \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
975
|
+
}],
|
|
976
|
+
responses: {
|
|
977
|
+
"200": {
|
|
978
|
+
description: "All pre-warmed containers destroyed.",
|
|
979
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
980
|
+
},
|
|
981
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
982
|
+
}
|
|
983
|
+
} },
|
|
984
|
+
"/v1/pool/prime": { post: {
|
|
985
|
+
operationId: "primePool",
|
|
986
|
+
summary: "Prime the warm pool",
|
|
987
|
+
description: "Pushes the current pool configuration and starts the alarm loop. Called automatically by the cron trigger; can also be called manually after deploy.",
|
|
988
|
+
"x-codeSamples": [{
|
|
989
|
+
lang: "curl",
|
|
990
|
+
label: "cURL",
|
|
991
|
+
source: "curl -X POST https://$HOST/v1/pool/prime \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
992
|
+
}],
|
|
993
|
+
responses: {
|
|
994
|
+
"200": {
|
|
995
|
+
description: "Pool primed successfully.",
|
|
996
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
997
|
+
},
|
|
998
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
999
|
+
}
|
|
1000
|
+
} }
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
//#endregion
|
|
1004
|
+
//#region src/bridge/openapi/processes.ts
|
|
1005
|
+
const sandboxIdParameter$1 = {
|
|
1006
|
+
name: "id",
|
|
1007
|
+
in: "path",
|
|
1008
|
+
required: true,
|
|
1009
|
+
schema: { type: "string" },
|
|
1010
|
+
description: "Sandbox instance name."
|
|
1011
|
+
};
|
|
1012
|
+
const processIdParameter = {
|
|
1013
|
+
name: "processId",
|
|
1014
|
+
in: "path",
|
|
1015
|
+
required: true,
|
|
1016
|
+
schema: { type: "string" }
|
|
1017
|
+
};
|
|
1018
|
+
const processStatusResponse = {
|
|
1019
|
+
description: "Process status.",
|
|
1020
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ProcessStatus" } } }
|
|
1021
|
+
};
|
|
1022
|
+
const PROCESSESPaths = {
|
|
1023
|
+
"/v1/sandbox/{id}/processes": {
|
|
1024
|
+
post: {
|
|
1025
|
+
operationId: "createProcess",
|
|
1026
|
+
summary: "Launch a sandbox process",
|
|
1027
|
+
parameters: [sandboxIdParameter$1],
|
|
974
1028
|
requestBody: {
|
|
975
1029
|
required: true,
|
|
976
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/
|
|
1030
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ProcessCreateRequest" } } }
|
|
977
1031
|
},
|
|
978
1032
|
responses: {
|
|
979
|
-
"200":
|
|
980
|
-
description: "Bucket mounted successfully.",
|
|
981
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
982
|
-
},
|
|
1033
|
+
"200": processStatusResponse,
|
|
983
1034
|
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
984
1035
|
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
985
|
-
"
|
|
986
|
-
|
|
987
|
-
content: { "application/json": {
|
|
988
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
989
|
-
example: {
|
|
990
|
-
error: "mount failed: Mount path already in use",
|
|
991
|
-
code: "mount_error"
|
|
992
|
-
}
|
|
993
|
-
} }
|
|
994
|
-
}
|
|
1036
|
+
"403": { $ref: "#/components/responses/InvalidRequest" },
|
|
1037
|
+
"502": { $ref: "#/components/responses/BadGateway" }
|
|
995
1038
|
}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
operationId: "
|
|
999
|
-
summary: "
|
|
1000
|
-
|
|
1001
|
-
"x-codeSamples": [{
|
|
1002
|
-
lang: "curl",
|
|
1003
|
-
label: "cURL",
|
|
1004
|
-
source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/unmount \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"mountPath\":\"/mnt/data\"}'"
|
|
1005
|
-
}],
|
|
1006
|
-
parameters: [{
|
|
1007
|
-
name: "id",
|
|
1008
|
-
in: "path",
|
|
1009
|
-
required: true,
|
|
1010
|
-
schema: { type: "string" },
|
|
1011
|
-
description: "Sandbox instance name."
|
|
1012
|
-
}],
|
|
1013
|
-
requestBody: {
|
|
1014
|
-
required: true,
|
|
1015
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/UnmountBucketRequest" } } }
|
|
1016
|
-
},
|
|
1039
|
+
},
|
|
1040
|
+
get: {
|
|
1041
|
+
operationId: "listProcesses",
|
|
1042
|
+
summary: "List sandbox processes",
|
|
1043
|
+
parameters: [sandboxIdParameter$1],
|
|
1017
1044
|
responses: {
|
|
1018
1045
|
"200": {
|
|
1019
|
-
description: "
|
|
1020
|
-
content: { "application/json": { schema: {
|
|
1046
|
+
description: "Process statuses.",
|
|
1047
|
+
content: { "application/json": { schema: {
|
|
1048
|
+
type: "array",
|
|
1049
|
+
items: { $ref: "#/components/schemas/ProcessStatus" }
|
|
1050
|
+
} } }
|
|
1021
1051
|
},
|
|
1022
|
-
"
|
|
1023
|
-
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1024
|
-
"502": {
|
|
1025
|
-
description: "SDK unmount call failed (no active mount or unmount error).",
|
|
1026
|
-
content: { "application/json": {
|
|
1027
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
1028
|
-
example: {
|
|
1029
|
-
error: "unmount failed: No active mount found",
|
|
1030
|
-
code: "unmount_error"
|
|
1031
|
-
}
|
|
1032
|
-
} }
|
|
1033
|
-
}
|
|
1052
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
1034
1053
|
}
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
"/v1/sandbox/{id}/processes/{processId}": { get: {
|
|
1057
|
+
operationId: "getProcess",
|
|
1058
|
+
summary: "Get a sandbox process",
|
|
1059
|
+
parameters: [sandboxIdParameter$1, processIdParameter],
|
|
1060
|
+
responses: {
|
|
1061
|
+
"200": processStatusResponse,
|
|
1062
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1063
|
+
"404": { $ref: "#/components/responses/NotFound" }
|
|
1064
|
+
}
|
|
1065
|
+
} },
|
|
1066
|
+
"/v1/sandbox/{id}/processes/{processId}/logs": { get: {
|
|
1067
|
+
operationId: "readProcessLogs",
|
|
1068
|
+
summary: "Stream sandbox process logs",
|
|
1069
|
+
description: "Returns Server-Sent Events. stdout/stderr event data is base64-encoded at the bridge boundary only.",
|
|
1070
|
+
parameters: [
|
|
1071
|
+
sandboxIdParameter$1,
|
|
1072
|
+
processIdParameter,
|
|
1073
|
+
{
|
|
1074
|
+
name: "since",
|
|
1075
|
+
in: "query",
|
|
1076
|
+
schema: { type: "string" }
|
|
1077
|
+
},
|
|
1078
|
+
{
|
|
1079
|
+
name: "replay",
|
|
1080
|
+
in: "query",
|
|
1081
|
+
schema: { type: "boolean" }
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
name: "follow",
|
|
1085
|
+
in: "query",
|
|
1086
|
+
schema: { type: "boolean" }
|
|
1087
|
+
}
|
|
1088
|
+
],
|
|
1089
|
+
responses: {
|
|
1090
|
+
"200": {
|
|
1091
|
+
description: "SSE stream of process log events.",
|
|
1092
|
+
content: { "text/event-stream": { schema: { type: "string" } } }
|
|
1093
|
+
},
|
|
1094
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1095
|
+
"404": { $ref: "#/components/responses/NotFound" }
|
|
1096
|
+
}
|
|
1097
|
+
} },
|
|
1098
|
+
"/v1/sandbox/{id}/processes/{processId}/kill": { post: {
|
|
1099
|
+
operationId: "signalSandboxProcess",
|
|
1100
|
+
summary: "Signal a sandbox process",
|
|
1101
|
+
parameters: [sandboxIdParameter$1, processIdParameter],
|
|
1102
|
+
requestBody: {
|
|
1103
|
+
required: false,
|
|
1104
|
+
content: { "application/json": { schema: {
|
|
1105
|
+
type: "object",
|
|
1106
|
+
properties: { signal: {
|
|
1107
|
+
type: "integer",
|
|
1108
|
+
minimum: 1,
|
|
1109
|
+
maximum: 64,
|
|
1110
|
+
default: 15
|
|
1111
|
+
} }
|
|
1112
|
+
} } }
|
|
1113
|
+
},
|
|
1114
|
+
responses: {
|
|
1115
|
+
"204": { description: "Signal sent." },
|
|
1116
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
1117
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1118
|
+
"404": { $ref: "#/components/responses/NotFound" }
|
|
1119
|
+
}
|
|
1120
|
+
} },
|
|
1121
|
+
"/v1/sandbox/{id}/tunnel/{port}": {
|
|
1122
|
+
post: {
|
|
1123
|
+
operationId: "createTunnel",
|
|
1124
|
+
summary: "Create or reuse a tunnel for a sandbox port",
|
|
1125
|
+
parameters: [sandboxIdParameter$1, {
|
|
1126
|
+
name: "port",
|
|
1047
1127
|
in: "path",
|
|
1048
1128
|
required: true,
|
|
1049
|
-
schema: {
|
|
1050
|
-
|
|
1129
|
+
schema: {
|
|
1130
|
+
type: "integer",
|
|
1131
|
+
minimum: 1024,
|
|
1132
|
+
maximum: 65535
|
|
1133
|
+
}
|
|
1051
1134
|
}],
|
|
1052
1135
|
responses: {
|
|
1053
|
-
"
|
|
1054
|
-
"
|
|
1136
|
+
"200": { description: "Tunnel created or reused." },
|
|
1137
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
1138
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1139
|
+
"502": { $ref: "#/components/responses/BadGateway" }
|
|
1055
1140
|
}
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
operationId: "
|
|
1059
|
-
summary: "
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
name: "id",
|
|
1141
|
+
},
|
|
1142
|
+
delete: {
|
|
1143
|
+
operationId: "deleteTunnel",
|
|
1144
|
+
summary: "Delete the tunnel for a sandbox port",
|
|
1145
|
+
parameters: [sandboxIdParameter$1, {
|
|
1146
|
+
name: "port",
|
|
1063
1147
|
in: "path",
|
|
1064
1148
|
required: true,
|
|
1065
|
-
schema: {
|
|
1066
|
-
|
|
1149
|
+
schema: {
|
|
1150
|
+
type: "integer",
|
|
1151
|
+
minimum: 1024,
|
|
1152
|
+
maximum: 65535
|
|
1153
|
+
}
|
|
1067
1154
|
}],
|
|
1155
|
+
responses: {
|
|
1156
|
+
"204": { description: "Tunnel deleted or already absent." },
|
|
1157
|
+
"400": { $ref: "#/components/responses/InvalidRequest" },
|
|
1158
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1159
|
+
"502": { $ref: "#/components/responses/BadGateway" }
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
"/v1/sandbox/{id}/running": { get: {
|
|
1164
|
+
operationId: "isSandboxRunning",
|
|
1165
|
+
summary: "Check whether the sandbox container is alive",
|
|
1166
|
+
parameters: [sandboxIdParameter$1],
|
|
1167
|
+
responses: {
|
|
1168
|
+
"200": {
|
|
1169
|
+
description: "Liveness status.",
|
|
1170
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/RunningResponse" } } }
|
|
1171
|
+
},
|
|
1172
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
1173
|
+
}
|
|
1174
|
+
} }
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
//#endregion
|
|
1178
|
+
//#region src/bridge/openapi/terminals.ts
|
|
1179
|
+
const terminalSnapshotSchema = {
|
|
1180
|
+
type: "object",
|
|
1181
|
+
required: [
|
|
1182
|
+
"id",
|
|
1183
|
+
"command",
|
|
1184
|
+
"status"
|
|
1185
|
+
],
|
|
1186
|
+
properties: {
|
|
1187
|
+
id: { type: "string" },
|
|
1188
|
+
pid: { type: "integer" },
|
|
1189
|
+
command: {
|
|
1190
|
+
type: "array",
|
|
1191
|
+
items: { type: "string" }
|
|
1192
|
+
},
|
|
1193
|
+
cwd: { type: "string" },
|
|
1194
|
+
status: {
|
|
1195
|
+
type: "string",
|
|
1196
|
+
enum: ["running", "exited"]
|
|
1197
|
+
},
|
|
1198
|
+
exit: {
|
|
1199
|
+
type: "object",
|
|
1200
|
+
properties: {
|
|
1201
|
+
code: {
|
|
1202
|
+
type: "integer",
|
|
1203
|
+
nullable: true
|
|
1204
|
+
},
|
|
1205
|
+
signal: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
|
1206
|
+
timedOut: { type: "boolean" }
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
const terminalIdParameter = {
|
|
1212
|
+
name: "terminalId",
|
|
1213
|
+
in: "path",
|
|
1214
|
+
required: true,
|
|
1215
|
+
schema: {
|
|
1216
|
+
type: "string",
|
|
1217
|
+
pattern: "^[a-zA-Z0-9._-]{1,128}$"
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
const sandboxIdParameter = {
|
|
1221
|
+
name: "id",
|
|
1222
|
+
in: "path",
|
|
1223
|
+
required: true,
|
|
1224
|
+
schema: { type: "string" },
|
|
1225
|
+
description: "Sandbox instance name."
|
|
1226
|
+
};
|
|
1227
|
+
const terminalErrorResponses = {
|
|
1228
|
+
"400": {
|
|
1229
|
+
description: "Invalid request.",
|
|
1230
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
|
|
1231
|
+
},
|
|
1232
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
1233
|
+
"502": {
|
|
1234
|
+
description: "Terminal operation failed.",
|
|
1235
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
const TERMINALSPaths = {
|
|
1239
|
+
"/v1/sandbox/{id}/terminals": {
|
|
1240
|
+
post: {
|
|
1241
|
+
operationId: "createTerminal",
|
|
1242
|
+
summary: "Create a terminal",
|
|
1068
1243
|
requestBody: {
|
|
1069
|
-
required:
|
|
1244
|
+
required: true,
|
|
1070
1245
|
content: { "application/json": { schema: {
|
|
1071
1246
|
type: "object",
|
|
1247
|
+
required: ["argv"],
|
|
1072
1248
|
properties: {
|
|
1073
|
-
|
|
1074
|
-
type: "
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
cwd: {
|
|
1078
|
-
type: "string",
|
|
1079
|
-
description: "Initial working directory for the session."
|
|
1249
|
+
argv: {
|
|
1250
|
+
type: "array",
|
|
1251
|
+
items: { type: "string" },
|
|
1252
|
+
minItems: 1
|
|
1080
1253
|
},
|
|
1254
|
+
cwd: { type: "string" },
|
|
1081
1255
|
env: {
|
|
1082
1256
|
type: "object",
|
|
1083
|
-
additionalProperties: { type: "string" }
|
|
1084
|
-
|
|
1085
|
-
}
|
|
1257
|
+
additionalProperties: { type: "string" }
|
|
1258
|
+
},
|
|
1259
|
+
cols: { type: "integer" },
|
|
1260
|
+
rows: { type: "integer" }
|
|
1086
1261
|
}
|
|
1087
1262
|
} } }
|
|
1088
1263
|
},
|
|
1264
|
+
parameters: [sandboxIdParameter],
|
|
1089
1265
|
responses: {
|
|
1090
1266
|
"200": {
|
|
1091
|
-
description: "
|
|
1092
|
-
content: { "application/json": { schema:
|
|
1093
|
-
type: "object",
|
|
1094
|
-
required: ["id"],
|
|
1095
|
-
properties: { id: {
|
|
1096
|
-
type: "string",
|
|
1097
|
-
description: "Session ID to pass via `Session-Id` header."
|
|
1098
|
-
} }
|
|
1099
|
-
} } }
|
|
1267
|
+
description: "Terminal created with a generated ID.",
|
|
1268
|
+
content: { "application/json": { schema: terminalSnapshotSchema } }
|
|
1100
1269
|
},
|
|
1101
|
-
|
|
1102
|
-
"502": {
|
|
1103
|
-
description: "Session creation failed.",
|
|
1104
|
-
content: { "application/json": {
|
|
1105
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
1106
|
-
example: {
|
|
1107
|
-
error: "session creation failed",
|
|
1108
|
-
code: "session_error"
|
|
1109
|
-
}
|
|
1110
|
-
} }
|
|
1111
|
-
}
|
|
1270
|
+
...terminalErrorResponses
|
|
1112
1271
|
}
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
operationId: "
|
|
1116
|
-
summary: "
|
|
1117
|
-
|
|
1118
|
-
parameters: [{
|
|
1119
|
-
name: "id",
|
|
1120
|
-
in: "path",
|
|
1121
|
-
required: true,
|
|
1122
|
-
schema: { type: "string" },
|
|
1123
|
-
description: "Sandbox instance name."
|
|
1124
|
-
}, {
|
|
1125
|
-
name: "sid",
|
|
1126
|
-
in: "path",
|
|
1127
|
-
required: true,
|
|
1128
|
-
schema: { type: "string" },
|
|
1129
|
-
description: "Session ID to delete."
|
|
1130
|
-
}],
|
|
1272
|
+
},
|
|
1273
|
+
get: {
|
|
1274
|
+
operationId: "listTerminals",
|
|
1275
|
+
summary: "List terminals",
|
|
1276
|
+
parameters: [sandboxIdParameter],
|
|
1131
1277
|
responses: {
|
|
1132
1278
|
"200": {
|
|
1133
|
-
description: "
|
|
1279
|
+
description: "Terminal snapshots.",
|
|
1134
1280
|
content: { "application/json": { schema: {
|
|
1135
|
-
type: "
|
|
1136
|
-
|
|
1137
|
-
properties: {
|
|
1138
|
-
success: {
|
|
1139
|
-
type: "boolean",
|
|
1140
|
-
description: "`true` if the session was deleted."
|
|
1141
|
-
},
|
|
1142
|
-
sessionId: {
|
|
1143
|
-
type: "string",
|
|
1144
|
-
description: "ID of the deleted session."
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1281
|
+
type: "array",
|
|
1282
|
+
items: terminalSnapshotSchema
|
|
1147
1283
|
} } }
|
|
1148
1284
|
},
|
|
1149
|
-
|
|
1150
|
-
"502": {
|
|
1151
|
-
description: "Session deletion failed.",
|
|
1152
|
-
content: { "application/json": {
|
|
1153
|
-
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
1154
|
-
example: {
|
|
1155
|
-
error: "session deletion failed",
|
|
1156
|
-
code: "session_error"
|
|
1157
|
-
}
|
|
1158
|
-
} }
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
} },
|
|
1162
|
-
"/health": { get: {
|
|
1163
|
-
operationId: "healthCheck",
|
|
1164
|
-
summary: "Worker health check",
|
|
1165
|
-
description: "Simple liveness probe. Not protected by authentication.",
|
|
1166
|
-
"x-codeSamples": [{
|
|
1167
|
-
lang: "curl",
|
|
1168
|
-
label: "cURL",
|
|
1169
|
-
source: "curl https://$HOST/health"
|
|
1170
|
-
}],
|
|
1171
|
-
security: [],
|
|
1172
|
-
responses: { "200": {
|
|
1173
|
-
description: "Worker is up.",
|
|
1174
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } }
|
|
1175
|
-
} }
|
|
1176
|
-
} },
|
|
1177
|
-
"/v1/openapi.json": { get: {
|
|
1178
|
-
operationId: "getOpenApiSchema",
|
|
1179
|
-
summary: "OpenAPI schema",
|
|
1180
|
-
description: "Returns this OpenAPI 3.1 schema document.",
|
|
1181
|
-
"x-codeSamples": [{
|
|
1182
|
-
lang: "curl",
|
|
1183
|
-
label: "cURL",
|
|
1184
|
-
source: "curl https://$HOST/v1/openapi.json \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
1185
|
-
}],
|
|
1186
|
-
responses: {
|
|
1187
|
-
"200": {
|
|
1188
|
-
description: "OpenAPI schema document.",
|
|
1189
|
-
content: { "application/json": { schema: { type: "object" } } }
|
|
1190
|
-
},
|
|
1191
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
1192
|
-
}
|
|
1193
|
-
} },
|
|
1194
|
-
"/v1/pool/stats": { get: {
|
|
1195
|
-
operationId: "getPoolStats",
|
|
1196
|
-
summary: "Pool statistics",
|
|
1197
|
-
description: "Returns current warm pool statistics including warm/assigned counts and configuration.",
|
|
1198
|
-
"x-codeSamples": [{
|
|
1199
|
-
lang: "curl",
|
|
1200
|
-
label: "cURL",
|
|
1201
|
-
source: "curl https://$HOST/v1/pool/stats \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
|
|
1202
|
-
}],
|
|
1203
|
-
responses: {
|
|
1204
|
-
"200": {
|
|
1205
|
-
description: "Pool statistics.",
|
|
1206
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/PoolStats" } } }
|
|
1207
|
-
},
|
|
1208
|
-
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
1285
|
+
...terminalErrorResponses
|
|
1209
1286
|
}
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1287
|
+
}
|
|
1288
|
+
},
|
|
1289
|
+
"/v1/sandbox/{id}/terminals/{terminalId}": { get: {
|
|
1290
|
+
operationId: "getTerminal",
|
|
1291
|
+
summary: "Get a terminal snapshot",
|
|
1292
|
+
parameters: [sandboxIdParameter, terminalIdParameter],
|
|
1293
|
+
responses: {
|
|
1294
|
+
"200": {
|
|
1295
|
+
description: "Terminal snapshot.",
|
|
1296
|
+
content: { "application/json": { schema: terminalSnapshotSchema } }
|
|
1297
|
+
},
|
|
1298
|
+
"404": { description: "Terminal not found." },
|
|
1299
|
+
...terminalErrorResponses
|
|
1300
|
+
}
|
|
1301
|
+
} },
|
|
1302
|
+
"/v1/sandbox/{id}/terminals/{terminalId}/connect": { get: {
|
|
1303
|
+
operationId: "connectTerminal",
|
|
1304
|
+
summary: "Connect to a terminal WebSocket",
|
|
1305
|
+
description: "WebSocket binary client frames are terminal input bytes. JSON client controls are resize, interrupt, and terminate. Server output is a JSON chunk control immediately followed by one binary frame. Cursor advances after the binary frame is consumed.",
|
|
1306
|
+
parameters: [
|
|
1307
|
+
sandboxIdParameter,
|
|
1308
|
+
terminalIdParameter,
|
|
1309
|
+
{
|
|
1310
|
+
name: "cursor",
|
|
1311
|
+
in: "query",
|
|
1312
|
+
required: false,
|
|
1313
|
+
schema: { type: "string" }
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
name: "cols",
|
|
1317
|
+
in: "query",
|
|
1318
|
+
required: false,
|
|
1319
|
+
schema: { type: "integer" }
|
|
1320
|
+
},
|
|
1321
|
+
{
|
|
1322
|
+
name: "rows",
|
|
1323
|
+
in: "query",
|
|
1324
|
+
required: false,
|
|
1325
|
+
schema: { type: "integer" }
|
|
1243
1326
|
}
|
|
1244
|
-
|
|
1327
|
+
],
|
|
1328
|
+
responses: {
|
|
1329
|
+
"101": { description: "WebSocket upgrade successful." },
|
|
1330
|
+
"404": { description: "Terminal not found." },
|
|
1331
|
+
...terminalErrorResponses
|
|
1332
|
+
}
|
|
1333
|
+
} },
|
|
1334
|
+
"/v1/sandbox/{id}/terminals/{terminalId}/interrupt": { post: {
|
|
1335
|
+
operationId: "interruptTerminal",
|
|
1336
|
+
summary: "Interrupt a terminal",
|
|
1337
|
+
parameters: [sandboxIdParameter, terminalIdParameter],
|
|
1338
|
+
responses: {
|
|
1339
|
+
"204": { description: "Interrupt signal sent." },
|
|
1340
|
+
"404": { description: "Terminal not found." },
|
|
1341
|
+
...terminalErrorResponses
|
|
1342
|
+
}
|
|
1343
|
+
} },
|
|
1344
|
+
"/v1/sandbox/{id}/terminals/{terminalId}/terminate": { post: {
|
|
1345
|
+
operationId: "terminateTerminal",
|
|
1346
|
+
summary: "Terminate a terminal",
|
|
1347
|
+
parameters: [sandboxIdParameter, terminalIdParameter],
|
|
1348
|
+
responses: {
|
|
1349
|
+
"204": { description: "Terminate signal sent." },
|
|
1350
|
+
"404": { description: "Terminal not found." },
|
|
1351
|
+
...terminalErrorResponses
|
|
1352
|
+
}
|
|
1353
|
+
} }
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
//#endregion
|
|
1357
|
+
//#region src/bridge/openapi.ts
|
|
1358
|
+
const OPENAPI_SCHEMA = {
|
|
1359
|
+
...OPENAPI_BASE,
|
|
1360
|
+
paths: {
|
|
1361
|
+
...LIFECYCLEPaths,
|
|
1362
|
+
...PROCESSESPaths,
|
|
1363
|
+
...FILESPaths,
|
|
1364
|
+
...TERMINALSPaths
|
|
1245
1365
|
}
|
|
1246
1366
|
};
|
|
1247
1367
|
|
|
@@ -1774,10 +1894,20 @@ function esc(s) {
|
|
|
1774
1894
|
}
|
|
1775
1895
|
|
|
1776
1896
|
//#endregion
|
|
1777
|
-
//#region src/bridge/routes.ts
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1897
|
+
//#region src/bridge/routes/common.ts
|
|
1898
|
+
function getSandbox(ns, containerUUID) {
|
|
1899
|
+
return getSandbox$1(ns, containerUUID);
|
|
1900
|
+
}
|
|
1901
|
+
function getSandboxNs(env$1, sandboxBinding) {
|
|
1902
|
+
return env$1[sandboxBinding];
|
|
1903
|
+
}
|
|
1904
|
+
function getWarmPoolNs(env$1, warmPoolBinding) {
|
|
1905
|
+
return env$1[warmPoolBinding];
|
|
1906
|
+
}
|
|
1907
|
+
function getWarmPoolStub(env$1, warmPoolBinding) {
|
|
1908
|
+
const poolNs = getWarmPoolNs(env$1, warmPoolBinding);
|
|
1909
|
+
const poolId = poolNs.idFromName("global-pool");
|
|
1910
|
+
return poolNs.get(poolId);
|
|
1781
1911
|
}
|
|
1782
1912
|
function hasEndpoint(options) {
|
|
1783
1913
|
return "endpoint" in options && typeof options.endpoint === "string";
|
|
@@ -1821,177 +1951,37 @@ function validateMountOptions(options, binding) {
|
|
|
1821
1951
|
if ("credentials" in options && options.credentials !== void 0 && (typeof options.credentials !== "object" || options.credentials === null || typeof options.credentials.accessKeyId !== "string" || typeof options.credentials.secretAccessKey !== "string")) return errorJson("options.credentials must include string accessKeyId and secretAccessKey", "invalid_request", 400);
|
|
1822
1952
|
return null;
|
|
1823
1953
|
}
|
|
1824
|
-
function resolveMountBucketName(body) {
|
|
1825
|
-
if (hasEndpoint(body.options)) {
|
|
1826
|
-
if (body.bucket && typeof body.bucket === "string") return body.bucket;
|
|
1827
|
-
return errorJson("bucket must be a non-empty string for remote mounts", "invalid_request", 400);
|
|
1828
|
-
}
|
|
1829
|
-
if (body.binding !== void 0) return body.binding;
|
|
1830
|
-
return errorJson("binding must be a non-empty string for R2 binding mounts", "invalid_request", 400);
|
|
1831
|
-
}
|
|
1832
|
-
function toSDKMountOptions(options) {
|
|
1833
|
-
if (hasEndpoint(options)) {
|
|
1834
|
-
const remoteOptions = { endpoint: options.endpoint };
|
|
1835
|
-
if (options.readOnly !== void 0) remoteOptions.readOnly = options.readOnly;
|
|
1836
|
-
if (options.prefix !== void 0) remoteOptions.prefix = options.prefix;
|
|
1837
|
-
if (hasCredentials(options)) remoteOptions.credentials = {
|
|
1838
|
-
accessKeyId: options.credentials.accessKeyId,
|
|
1839
|
-
secretAccessKey: options.credentials.secretAccessKey
|
|
1840
|
-
};
|
|
1841
|
-
if (options.s3fsOptions !== void 0) remoteOptions.s3fsOptions = options.s3fsOptions;
|
|
1842
|
-
if ("credentialProxy" in options && typeof options.credentialProxy === "boolean") remoteOptions.credentialProxy = options.credentialProxy;
|
|
1843
|
-
return remoteOptions;
|
|
1844
|
-
}
|
|
1845
|
-
const r2BindingOptions = {};
|
|
1846
|
-
if (options.readOnly !== void 0) r2BindingOptions.readOnly = options.readOnly;
|
|
1847
|
-
if (options.prefix !== void 0) r2BindingOptions.prefix = options.prefix;
|
|
1848
|
-
if (options.s3fsOptions !== void 0) r2BindingOptions.s3fsOptions = options.s3fsOptions;
|
|
1849
|
-
return r2BindingOptions;
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
return env$1[sandboxBinding];
|
|
1856
|
-
}
|
|
1857
|
-
function getWarmPoolNs(env$1) {
|
|
1858
|
-
return env$1[warmPoolBinding];
|
|
1859
|
-
}
|
|
1860
|
-
app.use(`${apiPrefix}/sandbox/*`, async (c, next) => {
|
|
1861
|
-
const sandboxId = new URL(c.req.url).pathname.split("/")[apiPrefix.split("/").filter(Boolean).length + 2];
|
|
1862
|
-
if (sandboxId && !/^[a-z2-7]{1,128}$/.test(sandboxId)) return errorJson("Invalid sandbox ID format", "invalid_request", 400);
|
|
1863
|
-
const token = c.env.SANDBOX_API_KEY;
|
|
1864
|
-
if (token) {
|
|
1865
|
-
const authHeader = c.req.header("Authorization") ?? "";
|
|
1866
|
-
if ((authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7) : "") !== token) return errorJson("Unauthorized", "unauthorized", 401);
|
|
1867
|
-
}
|
|
1868
|
-
return next();
|
|
1869
|
-
});
|
|
1870
|
-
app.post(`${apiPrefix}/sandbox`, async (c) => {
|
|
1871
|
-
const token = c.env.SANDBOX_API_KEY;
|
|
1872
|
-
if (token) {
|
|
1873
|
-
const authHeader = c.req.header("Authorization") ?? "";
|
|
1874
|
-
if ((authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7) : "") !== token) return errorJson("Unauthorized", "unauthorized", 401);
|
|
1875
|
-
}
|
|
1876
|
-
const bytes = new Uint8Array(16);
|
|
1877
|
-
crypto.getRandomValues(bytes);
|
|
1878
|
-
const id = base32Encode(bytes);
|
|
1879
|
-
return c.json({ id });
|
|
1880
|
-
});
|
|
1881
|
-
app.use(`${apiPrefix}/sandbox/:id/*`, async (c, next) => {
|
|
1882
|
-
const sandboxId = c.req.param("id");
|
|
1883
|
-
const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
|
|
1884
|
-
const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
1885
|
-
const poolNs = getWarmPoolNs(c.env);
|
|
1886
|
-
const poolId = poolNs.idFromName("global-pool");
|
|
1887
|
-
const poolStub = poolNs.get(poolId);
|
|
1888
|
-
try {
|
|
1889
|
-
await poolStub.configure({
|
|
1890
|
-
warmTarget,
|
|
1891
|
-
refreshInterval
|
|
1892
|
-
});
|
|
1893
|
-
const containerUUID = await poolStub.getContainer(sandboxId);
|
|
1894
|
-
c.set("containerUUID", containerUUID);
|
|
1895
|
-
} catch (err) {
|
|
1896
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1897
|
-
if (msg.includes("instance limit reached")) return errorJson(msg, "capacity_exceeded", 503);
|
|
1898
|
-
return errorJson(`pool error: ${msg}`, "pool_error", 502);
|
|
1899
|
-
}
|
|
1900
|
-
return next();
|
|
1901
|
-
});
|
|
1902
|
-
app.use(`${apiPrefix}/sandbox/:id`, async (c, next) => {
|
|
1903
|
-
if (c.req.method !== "DELETE") return next();
|
|
1904
|
-
const sandboxId = c.req.param("id");
|
|
1905
|
-
const poolNs = getWarmPoolNs(c.env);
|
|
1906
|
-
const poolId = poolNs.idFromName("global-pool");
|
|
1907
|
-
const poolStub = poolNs.get(poolId);
|
|
1908
|
-
try {
|
|
1909
|
-
const containerUUID = await poolStub.lookupContainer(sandboxId);
|
|
1910
|
-
if (!containerUUID) return new Response(null, { status: 204 });
|
|
1911
|
-
c.set("containerUUID", containerUUID);
|
|
1912
|
-
} catch (err) {
|
|
1913
|
-
return errorJson(`pool error: ${err instanceof Error ? err.message : String(err)}`, "pool_error", 502);
|
|
1914
|
-
}
|
|
1915
|
-
return next();
|
|
1916
|
-
});
|
|
1917
|
-
app.post(`${apiPrefix}/sandbox/:id/exec`, async (c) => {
|
|
1918
|
-
let body;
|
|
1919
|
-
try {
|
|
1920
|
-
body = await c.req.json();
|
|
1921
|
-
} catch {
|
|
1922
|
-
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
1923
|
-
}
|
|
1924
|
-
if (!Array.isArray(body.argv) || body.argv.length === 0) return errorJson("argv must be a non-empty array", "invalid_request", 400);
|
|
1925
|
-
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
1926
|
-
const rawSessionId = c.req.header("Session-Id");
|
|
1927
|
-
let executor = sandbox;
|
|
1928
|
-
if (rawSessionId) {
|
|
1929
|
-
const sessionId = validateSessionId(rawSessionId);
|
|
1930
|
-
if (!sessionId) return errorJson("Invalid session ID format", "invalid_request", 400);
|
|
1931
|
-
executor = await sandbox.getSession(sessionId);
|
|
1932
|
-
}
|
|
1933
|
-
const command = body.argv.map(shellQuote).join(" ");
|
|
1934
|
-
const opts = {};
|
|
1935
|
-
if (typeof body.timeout_ms === "number") opts.timeout = body.timeout_ms;
|
|
1936
|
-
if (typeof body.cwd === "string") {
|
|
1937
|
-
const resolvedCwd = resolveWorkspacePath(body.cwd);
|
|
1938
|
-
if (!resolvedCwd) return errorJson("cwd must resolve to a location within /workspace", "invalid_request", 403);
|
|
1939
|
-
opts.cwd = resolvedCwd;
|
|
1940
|
-
}
|
|
1941
|
-
const { readable, writable } = new TransformStream();
|
|
1942
|
-
const writer = writable.getWriter();
|
|
1943
|
-
const encoder = new TextEncoder();
|
|
1944
|
-
let closed = false;
|
|
1945
|
-
let lastWrite = Promise.resolve();
|
|
1946
|
-
/** Write a single SSE event. Chains on the previous write to respect backpressure. */
|
|
1947
|
-
function writeSSE(event, data) {
|
|
1948
|
-
if (closed) return;
|
|
1949
|
-
const payload = data.split("\n").map((line) => `data: ${line}`).join("\n");
|
|
1950
|
-
lastWrite = lastWrite.then(() => writer.write(encoder.encode(`event: ${event}\n${payload}\n\n`)));
|
|
1951
|
-
}
|
|
1952
|
-
function closeStream() {
|
|
1953
|
-
if (closed) return;
|
|
1954
|
-
closed = true;
|
|
1955
|
-
lastWrite.then(() => writer.close()).catch(() => {});
|
|
1956
|
-
}
|
|
1957
|
-
(async () => {
|
|
1958
|
-
try {
|
|
1959
|
-
const proc = await executor.exec(command, opts);
|
|
1960
|
-
const decoder = new TextDecoder();
|
|
1961
|
-
const pump = async (stream, name) => {
|
|
1962
|
-
if (!stream) return;
|
|
1963
|
-
const reader = stream.getReader();
|
|
1964
|
-
try {
|
|
1965
|
-
for (;;) {
|
|
1966
|
-
const { value, done } = await reader.read();
|
|
1967
|
-
if (done) break;
|
|
1968
|
-
if (value && value.byteLength > 0) writeSSE(name, toBase64(decoder.decode(value)));
|
|
1969
|
-
}
|
|
1970
|
-
} finally {
|
|
1971
|
-
reader.releaseLock();
|
|
1972
|
-
}
|
|
1973
|
-
};
|
|
1974
|
-
await Promise.all([pump(proc.stdout, "stdout"), pump(proc.stderr, "stderr")]);
|
|
1975
|
-
const exitCode = await proc.exitCode;
|
|
1976
|
-
writeSSE("exit", JSON.stringify({ exit_code: exitCode }));
|
|
1977
|
-
} catch (err) {
|
|
1978
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1979
|
-
writeSSE("error", JSON.stringify({
|
|
1980
|
-
error: `exec failed: ${msg}`,
|
|
1981
|
-
code: "exec_transport_error"
|
|
1982
|
-
}));
|
|
1983
|
-
} finally {
|
|
1984
|
-
closeStream();
|
|
1985
|
-
}
|
|
1986
|
-
})();
|
|
1987
|
-
return new Response(readable, {
|
|
1988
|
-
status: 200,
|
|
1989
|
-
headers: {
|
|
1990
|
-
"Content-Type": "text/event-stream",
|
|
1991
|
-
"Cache-Control": "no-cache"
|
|
1992
|
-
}
|
|
1993
|
-
});
|
|
1994
|
-
});
|
|
1954
|
+
function resolveMountBucketName(body) {
|
|
1955
|
+
if (hasEndpoint(body.options)) {
|
|
1956
|
+
if (body.bucket && typeof body.bucket === "string") return body.bucket;
|
|
1957
|
+
return errorJson("bucket must be a non-empty string for remote mounts", "invalid_request", 400);
|
|
1958
|
+
}
|
|
1959
|
+
if (body.binding !== void 0) return body.binding;
|
|
1960
|
+
return errorJson("binding must be a non-empty string for R2 binding mounts", "invalid_request", 400);
|
|
1961
|
+
}
|
|
1962
|
+
function toSDKMountOptions(options) {
|
|
1963
|
+
if (hasEndpoint(options)) {
|
|
1964
|
+
const remoteOptions = { endpoint: options.endpoint };
|
|
1965
|
+
if (options.readOnly !== void 0) remoteOptions.readOnly = options.readOnly;
|
|
1966
|
+
if (options.prefix !== void 0) remoteOptions.prefix = options.prefix;
|
|
1967
|
+
if (hasCredentials(options)) remoteOptions.credentials = {
|
|
1968
|
+
accessKeyId: options.credentials.accessKeyId,
|
|
1969
|
+
secretAccessKey: options.credentials.secretAccessKey
|
|
1970
|
+
};
|
|
1971
|
+
if (options.s3fsOptions !== void 0) remoteOptions.s3fsOptions = options.s3fsOptions;
|
|
1972
|
+
if ("credentialProxy" in options && typeof options.credentialProxy === "boolean") remoteOptions.credentialProxy = options.credentialProxy;
|
|
1973
|
+
return remoteOptions;
|
|
1974
|
+
}
|
|
1975
|
+
const r2BindingOptions = {};
|
|
1976
|
+
if (options.readOnly !== void 0) r2BindingOptions.readOnly = options.readOnly;
|
|
1977
|
+
if (options.prefix !== void 0) r2BindingOptions.prefix = options.prefix;
|
|
1978
|
+
if (options.s3fsOptions !== void 0) r2BindingOptions.s3fsOptions = options.s3fsOptions;
|
|
1979
|
+
return r2BindingOptions;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
//#endregion
|
|
1983
|
+
//#region src/bridge/routes/files.ts
|
|
1984
|
+
function registerFileRoutes(app, apiPrefix, sandboxBinding) {
|
|
1995
1985
|
app.get(`${apiPrefix}/sandbox/:id/file/*`, async (c) => {
|
|
1996
1986
|
const sandboxId = c.req.param("id");
|
|
1997
1987
|
const fullPath = c.req.path;
|
|
@@ -2000,16 +1990,9 @@ function createBridgeApp(config) {
|
|
|
2000
1990
|
if (!relativePath) return errorJson("file path must not be empty", "invalid_request", 400);
|
|
2001
1991
|
const resolvedPath = resolveWorkspacePath(`/${relativePath}`);
|
|
2002
1992
|
if (!resolvedPath) return errorJson("path must resolve to a location within /workspace", "invalid_request", 403);
|
|
2003
|
-
const sandbox = getSandbox
|
|
2004
|
-
const rawSessionId = c.req.header("Session-Id");
|
|
2005
|
-
let executor = sandbox;
|
|
2006
|
-
if (rawSessionId) {
|
|
2007
|
-
const sessionId = validateSessionId(rawSessionId);
|
|
2008
|
-
if (!sessionId) return errorJson("Invalid session ID format", "invalid_request", 400);
|
|
2009
|
-
executor = await sandbox.getSession(sessionId);
|
|
2010
|
-
}
|
|
1993
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2011
1994
|
try {
|
|
2012
|
-
const stream = await
|
|
1995
|
+
const stream = await sandbox.readFileStream(resolvedPath);
|
|
2013
1996
|
return new Response(sseToByteStream(stream), {
|
|
2014
1997
|
status: 200,
|
|
2015
1998
|
headers: { "Content-Type": "application/octet-stream" }
|
|
@@ -2027,14 +2010,7 @@ function createBridgeApp(config) {
|
|
|
2027
2010
|
if (!relativePath) return errorJson("file path must not be empty", "invalid_request", 400);
|
|
2028
2011
|
const resolvedPath = resolveWorkspacePath(`/${relativePath}`);
|
|
2029
2012
|
if (!resolvedPath) return errorJson("path must resolve to a location within /workspace", "invalid_request", 403);
|
|
2030
|
-
const sandbox = getSandbox
|
|
2031
|
-
const rawSessionId = c.req.header("Session-Id");
|
|
2032
|
-
let executor = sandbox;
|
|
2033
|
-
if (rawSessionId) {
|
|
2034
|
-
const sessionId = validateSessionId(rawSessionId);
|
|
2035
|
-
if (!sessionId) return errorJson("Invalid session ID format", "invalid_request", 400);
|
|
2036
|
-
executor = await sandbox.getSession(sessionId);
|
|
2037
|
-
}
|
|
2013
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2038
2014
|
try {
|
|
2039
2015
|
const buffer = await c.req.arrayBuffer();
|
|
2040
2016
|
const MAX_WRITE_BYTES = 32 * 1024 * 1024;
|
|
@@ -2043,89 +2019,43 @@ function createBridgeApp(config) {
|
|
|
2043
2019
|
let b64 = "";
|
|
2044
2020
|
const CHUNK = 6144;
|
|
2045
2021
|
for (let i = 0; i < bytes.length; i += CHUNK) b64 += btoa(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
|
|
2046
|
-
await
|
|
2022
|
+
await sandbox.writeFile(resolvedPath, b64, { encoding: "base64" });
|
|
2047
2023
|
return c.json({ ok: true });
|
|
2048
2024
|
} catch (err) {
|
|
2049
2025
|
return errorJson(`write failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_write_error", 502);
|
|
2050
2026
|
}
|
|
2051
2027
|
});
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
} catch (err) {
|
|
2062
|
-
return errorJson(`tunnel failed: ${err instanceof Error ? err.message : String(err)}`, "tunnel_error", 502);
|
|
2063
|
-
}
|
|
2064
|
-
});
|
|
2065
|
-
app.delete(`${apiPrefix}/sandbox/:id/tunnel/:port`, async (c) => {
|
|
2066
|
-
const port = Number(c.req.param("port"));
|
|
2067
|
-
if (!validatePort(port)) return errorJson("Invalid port", "invalid_request", 400);
|
|
2068
|
-
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
2069
|
-
try {
|
|
2070
|
-
await sandbox.tunnels.destroy(port);
|
|
2071
|
-
return c.body(null, 204);
|
|
2072
|
-
} catch (err) {
|
|
2073
|
-
return errorJson(`tunnel failed: ${err instanceof Error ? err.message : String(err)}`, "tunnel_error", 502);
|
|
2074
|
-
}
|
|
2075
|
-
});
|
|
2076
|
-
app.get(`${apiPrefix}/sandbox/:id/running`, async (c) => {
|
|
2077
|
-
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
2078
|
-
try {
|
|
2079
|
-
await sandbox.exec("true");
|
|
2080
|
-
return c.json({ running: true });
|
|
2081
|
-
} catch {
|
|
2082
|
-
return c.json({ running: false });
|
|
2083
|
-
}
|
|
2084
|
-
});
|
|
2085
|
-
app.get(`${apiPrefix}/sandbox/:id/pty`, async (c) => {
|
|
2086
|
-
const upgrade = c.req.header("Upgrade");
|
|
2087
|
-
if (!upgrade || upgrade.toLowerCase() !== "websocket") return errorJson("WebSocket upgrade required", "invalid_request", 400);
|
|
2088
|
-
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
2089
|
-
const colsParam = c.req.query("cols");
|
|
2090
|
-
const rowsParam = c.req.query("rows");
|
|
2091
|
-
const shell = c.req.query("shell");
|
|
2092
|
-
const cwd = c.req.query("cwd");
|
|
2093
|
-
const terminalId = c.req.header("Terminal-Id") ?? c.req.query("terminalId") ?? c.req.query("terminal");
|
|
2094
|
-
const cols = colsParam ? Number(colsParam) : 80;
|
|
2095
|
-
const rows = rowsParam ? Number(rowsParam) : 24;
|
|
2096
|
-
if (Number.isNaN(cols) || Number.isNaN(rows)) return errorJson("cols and rows must be valid numbers", "invalid_request", 400);
|
|
2097
|
-
const terminalOptions = {};
|
|
2098
|
-
const connectOptions = {
|
|
2099
|
-
cols,
|
|
2100
|
-
rows
|
|
2101
|
-
};
|
|
2102
|
-
if (shell) terminalOptions.shell = shell;
|
|
2103
|
-
if (cwd) terminalOptions.cwd = cwd;
|
|
2104
|
-
if (!terminalId) return errorJson("terminalId query parameter or Terminal-Id header required", "invalid_request", 400);
|
|
2105
|
-
const validatedId = validateSessionId(terminalId);
|
|
2106
|
-
if (!validatedId) return errorJson("Invalid terminal ID format", "invalid_request", 400);
|
|
2107
|
-
terminalOptions.id = validatedId;
|
|
2108
|
-
try {
|
|
2109
|
-
return await sandbox.terminal(terminalOptions).connect(c.req.raw, connectOptions);
|
|
2110
|
-
} catch (err) {
|
|
2111
|
-
return errorJson(`terminal failed: ${err instanceof Error ? err.message : String(err)}`, "exec_transport_error", 502);
|
|
2112
|
-
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
//#endregion
|
|
2031
|
+
//#region src/bridge/routes/lifecycle.ts
|
|
2032
|
+
function registerLifecycleRoutes(app, apiPrefix, healthPath, sandboxBinding, warmPoolBinding) {
|
|
2033
|
+
app.post(`${apiPrefix}/sandbox`, (c) => {
|
|
2034
|
+
const bytes = new Uint8Array(15);
|
|
2035
|
+
crypto.getRandomValues(bytes);
|
|
2036
|
+
return c.json({ id: base32Encode(bytes) });
|
|
2113
2037
|
});
|
|
2114
2038
|
app.post(`${apiPrefix}/sandbox/:id/persist`, async (c) => {
|
|
2115
2039
|
const root = "/workspace";
|
|
2116
2040
|
const excludesParam = c.req.query("excludes") ?? "";
|
|
2117
2041
|
const excludes = excludesParam ? excludesParam.split(",").filter((s) => s.length > 0) : [];
|
|
2118
2042
|
for (const ex of excludes) if (ex.includes("..")) return errorJson("exclude paths must not contain \"..\"", "invalid_request", 400);
|
|
2119
|
-
const sandbox = getSandbox
|
|
2120
|
-
const tmpPath = `/tmp/sandbox-persist-${Date.now()}.tar`;
|
|
2121
|
-
const excludeArgs = excludes.map((rel) => `--exclude ${shellQuote(`./${rel.replace(/^\.\//, "")}`)}`).join(" ");
|
|
2122
|
-
const tarCmd = excludeArgs ? `tar cf ${shellQuote(tmpPath)} ${excludeArgs} -C ${shellQuote(root)} .` : `tar cf ${shellQuote(tmpPath)} -C ${shellQuote(root)} .`;
|
|
2043
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2123
2044
|
try {
|
|
2124
|
-
const
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2045
|
+
const archivePath = await sandbox.createWorkspaceArchive({
|
|
2046
|
+
root,
|
|
2047
|
+
excludes
|
|
2048
|
+
});
|
|
2049
|
+
let stream;
|
|
2050
|
+
try {
|
|
2051
|
+
stream = await sandbox.readFileStream(archivePath);
|
|
2052
|
+
} catch (error) {
|
|
2053
|
+
await sandbox.cleanupWorkspaceArchive(archivePath).catch(() => void 0);
|
|
2054
|
+
throw error;
|
|
2055
|
+
}
|
|
2056
|
+
const cleanupPath = archivePath;
|
|
2057
|
+
const responseStream = withStreamCleanup(sseToByteStream(stream), () => sandbox.cleanupWorkspaceArchive(cleanupPath));
|
|
2058
|
+
return new Response(responseStream, {
|
|
2129
2059
|
status: 200,
|
|
2130
2060
|
headers: { "Content-Type": "application/octet-stream" }
|
|
2131
2061
|
});
|
|
@@ -2135,7 +2065,7 @@ function createBridgeApp(config) {
|
|
|
2135
2065
|
});
|
|
2136
2066
|
app.post(`${apiPrefix}/sandbox/:id/hydrate`, async (c) => {
|
|
2137
2067
|
const root = "/workspace";
|
|
2138
|
-
const sandbox = getSandbox
|
|
2068
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2139
2069
|
let tarBytes;
|
|
2140
2070
|
try {
|
|
2141
2071
|
const buffer = await c.req.arrayBuffer();
|
|
@@ -2146,18 +2076,21 @@ function createBridgeApp(config) {
|
|
|
2146
2076
|
if (tarBytes.byteLength === 0) return errorJson("Empty tar payload", "invalid_request", 400);
|
|
2147
2077
|
const MAX_HYDRATE_BYTES = 32 * 1024 * 1024;
|
|
2148
2078
|
if (tarBytes.byteLength > MAX_HYDRATE_BYTES) return errorJson(`tar payload too large: ${tarBytes.byteLength} bytes exceeds the ${MAX_HYDRATE_BYTES}-byte limit`, "invalid_request", 400);
|
|
2079
|
+
const tmpPath = `/tmp/sandbox-hydrate-${Date.now()}-${crypto.randomUUID()}.tar`;
|
|
2149
2080
|
try {
|
|
2150
|
-
await sandbox.exec(`mkdir -p ${shellQuote(root)}`);
|
|
2151
|
-
const tmpPath = `/tmp/sandbox-hydrate-${Date.now()}.tar`;
|
|
2152
2081
|
let b64 = "";
|
|
2153
2082
|
const CHUNK = 6144;
|
|
2154
2083
|
for (let i = 0; i < tarBytes.length; i += CHUNK) b64 += btoa(String.fromCharCode(...tarBytes.subarray(i, i + CHUNK)));
|
|
2155
2084
|
await sandbox.writeFile(tmpPath, b64, { encoding: "base64" });
|
|
2156
|
-
|
|
2157
|
-
|
|
2085
|
+
await sandbox.extractWorkspaceArchive({
|
|
2086
|
+
root,
|
|
2087
|
+
archivePath: tmpPath
|
|
2088
|
+
});
|
|
2158
2089
|
return c.json({ ok: true });
|
|
2159
2090
|
} catch (err) {
|
|
2160
2091
|
return errorJson(`hydrate failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_write_error", 502);
|
|
2092
|
+
} finally {
|
|
2093
|
+
await sandbox.cleanupWorkspaceArchive(tmpPath).catch(() => void 0);
|
|
2161
2094
|
}
|
|
2162
2095
|
});
|
|
2163
2096
|
app.post(`${apiPrefix}/sandbox/:id/mount`, async (c) => {
|
|
@@ -2175,7 +2108,7 @@ function createBridgeApp(config) {
|
|
|
2175
2108
|
if (optionsError) return optionsError;
|
|
2176
2109
|
const bucketName = resolveMountBucketName(body);
|
|
2177
2110
|
if (bucketName instanceof Response) return bucketName;
|
|
2178
|
-
const sandbox = getSandbox
|
|
2111
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2179
2112
|
const sdkOptions = toSDKMountOptions(body.options);
|
|
2180
2113
|
try {
|
|
2181
2114
|
await sandbox.mountBucket(bucketName, body.mountPath, sdkOptions);
|
|
@@ -2195,55 +2128,29 @@ function createBridgeApp(config) {
|
|
|
2195
2128
|
if (!body.mountPath.startsWith("/")) return errorJson("mountPath must be an absolute path (start with /)", "invalid_request", 400);
|
|
2196
2129
|
const normalizedPath = new URL(body.mountPath, "file:///").pathname;
|
|
2197
2130
|
if (normalizedPath === "/") return errorJson("mountPath must not resolve to / (filesystem root)", "invalid_request", 400);
|
|
2198
|
-
const sandbox = getSandbox
|
|
2131
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2199
2132
|
try {
|
|
2200
2133
|
await sandbox.unmountBucket(normalizedPath);
|
|
2201
|
-
const quoted = shellQuote(normalizedPath);
|
|
2202
2134
|
try {
|
|
2203
|
-
await sandbox.
|
|
2135
|
+
await sandbox.cleanupMountDirectory(normalizedPath);
|
|
2204
2136
|
} catch {}
|
|
2205
2137
|
return c.json({ ok: true });
|
|
2206
2138
|
} catch (err) {
|
|
2207
2139
|
return errorJson(`unmount failed: ${err instanceof Error ? err.message : String(err)}`, "unmount_error", 502);
|
|
2208
2140
|
}
|
|
2209
2141
|
});
|
|
2210
|
-
app.post(`${apiPrefix}/sandbox/:id/session`, async (c) => {
|
|
2211
|
-
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
2212
|
-
let body = {};
|
|
2213
|
-
try {
|
|
2214
|
-
body = await c.req.json();
|
|
2215
|
-
} catch {}
|
|
2216
|
-
try {
|
|
2217
|
-
const session = await sandbox.createSession(body);
|
|
2218
|
-
return c.json({ id: session.id });
|
|
2219
|
-
} catch (err) {
|
|
2220
|
-
return errorJson(`session create failed: ${err instanceof Error ? err.message : String(err)}`, "session_error", 502);
|
|
2221
|
-
}
|
|
2222
|
-
});
|
|
2223
|
-
app.delete(`${apiPrefix}/sandbox/:id/session/:sid`, async (c) => {
|
|
2224
|
-
const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
|
|
2225
|
-
const sid = c.req.param("sid");
|
|
2226
|
-
try {
|
|
2227
|
-
const result = await sandbox.deleteSession(sid);
|
|
2228
|
-
return c.json(result);
|
|
2229
|
-
} catch (err) {
|
|
2230
|
-
return errorJson(`session delete failed: ${err instanceof Error ? err.message : String(err)}`, "session_error", 502);
|
|
2231
|
-
}
|
|
2232
|
-
});
|
|
2233
2142
|
app.delete(`${apiPrefix}/sandbox/:id`, async (c) => {
|
|
2234
2143
|
const containerUUID = c.get("containerUUID");
|
|
2235
|
-
const sandbox = getSandbox
|
|
2144
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), containerUUID);
|
|
2236
2145
|
try {
|
|
2237
2146
|
await sandbox.destroy();
|
|
2238
2147
|
} catch {}
|
|
2239
2148
|
try {
|
|
2240
|
-
|
|
2241
|
-
const poolId = poolNs.idFromName("global-pool");
|
|
2242
|
-
await poolNs.get(poolId).reportStopped(containerUUID);
|
|
2149
|
+
await getWarmPoolStub(c.env, warmPoolBinding).reportStopped(containerUUID);
|
|
2243
2150
|
} catch {}
|
|
2244
2151
|
return new Response(null, { status: 204 });
|
|
2245
2152
|
});
|
|
2246
|
-
app.get(
|
|
2153
|
+
app.get(healthPath, (c) => {
|
|
2247
2154
|
const errors = [];
|
|
2248
2155
|
if (!c.env[sandboxBinding]) errors.push(`Missing required Durable Object binding "${sandboxBinding}". Ensure your wrangler.jsonc has a binding named "${sandboxBinding}".`);
|
|
2249
2156
|
if (!c.env[warmPoolBinding]) errors.push(`Missing required Durable Object binding "${warmPoolBinding}". Ensure your wrangler.jsonc has a binding named "${warmPoolBinding}".`);
|
|
@@ -2264,9 +2171,7 @@ function createBridgeApp(config) {
|
|
|
2264
2171
|
app.get(`${apiPrefix}/pool/stats`, async (c) => {
|
|
2265
2172
|
const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
|
|
2266
2173
|
const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
2267
|
-
const
|
|
2268
|
-
const poolId = poolNs.idFromName("global-pool");
|
|
2269
|
-
const poolStub = poolNs.get(poolId);
|
|
2174
|
+
const poolStub = getWarmPoolStub(c.env, warmPoolBinding);
|
|
2270
2175
|
try {
|
|
2271
2176
|
await poolStub.configure({
|
|
2272
2177
|
warmTarget,
|
|
@@ -2279,9 +2184,7 @@ function createBridgeApp(config) {
|
|
|
2279
2184
|
app.post(`${apiPrefix}/pool/shutdown-prewarmed`, async (c) => {
|
|
2280
2185
|
const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
|
|
2281
2186
|
const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
2282
|
-
const
|
|
2283
|
-
const poolId = poolNs.idFromName("global-pool");
|
|
2284
|
-
const poolStub = poolNs.get(poolId);
|
|
2187
|
+
const poolStub = getWarmPoolStub(c.env, warmPoolBinding);
|
|
2285
2188
|
try {
|
|
2286
2189
|
await poolStub.configure({
|
|
2287
2190
|
warmTarget,
|
|
@@ -2295,6 +2198,399 @@ function createBridgeApp(config) {
|
|
|
2295
2198
|
await primePool(c.env, warmPoolBinding);
|
|
2296
2199
|
return c.json({ ok: true });
|
|
2297
2200
|
});
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
//#endregion
|
|
2204
|
+
//#region src/bridge/routes/processes.ts
|
|
2205
|
+
function registerProcessRoutes(app, apiPrefix, sandboxBinding) {
|
|
2206
|
+
app.post(`${apiPrefix}/sandbox/:id/processes`, async (c) => {
|
|
2207
|
+
const body = await parseProcessRequest(c.req.raw);
|
|
2208
|
+
if (body instanceof Response) return body;
|
|
2209
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2210
|
+
const options = processOptions(body);
|
|
2211
|
+
if (options instanceof Response) return options;
|
|
2212
|
+
try {
|
|
2213
|
+
const process = await sandbox.exec(body.argv, options);
|
|
2214
|
+
return c.json(await process.status());
|
|
2215
|
+
} catch (err) {
|
|
2216
|
+
return errorJson(`process launch failed: ${err instanceof Error ? err.message : String(err)}`, "process_error", 502);
|
|
2217
|
+
}
|
|
2218
|
+
});
|
|
2219
|
+
app.get(`${apiPrefix}/sandbox/:id/processes`, async (c) => {
|
|
2220
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2221
|
+
return c.json(await sandbox.listProcesses());
|
|
2222
|
+
});
|
|
2223
|
+
app.get(`${apiPrefix}/sandbox/:id/processes/:processId`, async (c) => {
|
|
2224
|
+
const process = await getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID")).getProcess(c.req.param("processId"));
|
|
2225
|
+
if (!process) return errorJson("Process not found", "not_found", 404);
|
|
2226
|
+
return c.json(await process.status());
|
|
2227
|
+
});
|
|
2228
|
+
app.get(`${apiPrefix}/sandbox/:id/processes/:processId/logs`, async (c) => {
|
|
2229
|
+
const process = await getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID")).getProcess(c.req.param("processId"));
|
|
2230
|
+
if (!process) return errorJson("Process not found", "not_found", 404);
|
|
2231
|
+
const replay = parseBoolean(c.req.query("replay"));
|
|
2232
|
+
const follow = parseBoolean(c.req.query("follow"));
|
|
2233
|
+
const logs = await process.logs({
|
|
2234
|
+
since: c.req.query("since"),
|
|
2235
|
+
replay,
|
|
2236
|
+
follow
|
|
2237
|
+
});
|
|
2238
|
+
return new Response(processLogsToSSE(logs), { headers: {
|
|
2239
|
+
"Content-Type": "text/event-stream",
|
|
2240
|
+
"Cache-Control": "no-cache"
|
|
2241
|
+
} });
|
|
2242
|
+
});
|
|
2243
|
+
app.post(`${apiPrefix}/sandbox/:id/processes/:processId/kill`, async (c) => {
|
|
2244
|
+
const process = await getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID")).getProcess(c.req.param("processId"));
|
|
2245
|
+
if (!process) return errorJson("Process not found", "not_found", 404);
|
|
2246
|
+
const signal = await parseKillRequest(c.req.raw);
|
|
2247
|
+
if (signal instanceof Response) return signal;
|
|
2248
|
+
await process.kill(signal);
|
|
2249
|
+
return c.body(null, 204);
|
|
2250
|
+
});
|
|
2251
|
+
app.post(`${apiPrefix}/sandbox/:id/tunnel/:port`, async (c) => {
|
|
2252
|
+
const port = Number(c.req.param("port"));
|
|
2253
|
+
if (!validatePort(port)) return errorJson("Invalid port", "invalid_request", 400);
|
|
2254
|
+
const options = parseTunnelOptions(await c.req.text());
|
|
2255
|
+
if (options instanceof Response) return options;
|
|
2256
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2257
|
+
try {
|
|
2258
|
+
const tunnel = await sandbox.tunnels.get(port, options);
|
|
2259
|
+
return c.json(tunnel);
|
|
2260
|
+
} catch (err) {
|
|
2261
|
+
return errorJson(`tunnel failed: ${err instanceof Error ? err.message : String(err)}`, "tunnel_error", 502);
|
|
2262
|
+
}
|
|
2263
|
+
});
|
|
2264
|
+
app.delete(`${apiPrefix}/sandbox/:id/tunnel/:port`, async (c) => {
|
|
2265
|
+
const port = Number(c.req.param("port"));
|
|
2266
|
+
if (!validatePort(port)) return errorJson("Invalid port", "invalid_request", 400);
|
|
2267
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2268
|
+
try {
|
|
2269
|
+
await sandbox.tunnels.destroy(port);
|
|
2270
|
+
return c.body(null, 204);
|
|
2271
|
+
} catch (err) {
|
|
2272
|
+
return errorJson(`tunnel failed: ${err instanceof Error ? err.message : String(err)}`, "tunnel_error", 502);
|
|
2273
|
+
}
|
|
2274
|
+
});
|
|
2275
|
+
app.get(`${apiPrefix}/sandbox/:id/running`, async (c) => {
|
|
2276
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2277
|
+
try {
|
|
2278
|
+
const response = { running: await sandbox.isRuntimeActive() };
|
|
2279
|
+
return c.json(response);
|
|
2280
|
+
} catch {
|
|
2281
|
+
return c.json({ running: false });
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
async function parseProcessRequest(request) {
|
|
2286
|
+
let body;
|
|
2287
|
+
try {
|
|
2288
|
+
body = await request.json();
|
|
2289
|
+
} catch {
|
|
2290
|
+
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
2291
|
+
}
|
|
2292
|
+
if (!Array.isArray(body.argv) || body.argv.length === 0) return errorJson("argv must be a non-empty array", "invalid_request", 400);
|
|
2293
|
+
if (body.argv.some((item) => typeof item !== "string")) return errorJson("argv items must be strings", "invalid_request", 400);
|
|
2294
|
+
if (body.argv[0].length === 0) return errorJson("argv executable must be non-empty", "invalid_request", 400);
|
|
2295
|
+
return body;
|
|
2296
|
+
}
|
|
2297
|
+
function processOptions(body) {
|
|
2298
|
+
const options = {};
|
|
2299
|
+
if (body.timeout !== void 0) {
|
|
2300
|
+
if (typeof body.timeout !== "number" || !Number.isFinite(body.timeout) || body.timeout <= 0) return errorJson("timeout must be a positive finite number", "invalid_request", 400);
|
|
2301
|
+
options.timeout = body.timeout;
|
|
2302
|
+
}
|
|
2303
|
+
if (typeof body.cwd === "string") {
|
|
2304
|
+
const cwd = resolveWorkspacePath(body.cwd);
|
|
2305
|
+
if (!cwd) return errorJson("cwd must resolve to a location within /workspace", "invalid_request", 403);
|
|
2306
|
+
options.cwd = cwd;
|
|
2307
|
+
}
|
|
2308
|
+
if ("env" in body && body.env !== void 0) {
|
|
2309
|
+
if (!isStringRecord(body.env)) return errorJson("env must be an object of strings", "invalid_request", 400);
|
|
2310
|
+
options.env = body.env;
|
|
2311
|
+
}
|
|
2312
|
+
return options;
|
|
2313
|
+
}
|
|
2314
|
+
async function parseKillRequest(request) {
|
|
2315
|
+
if (!request.headers.get("content-type")) return 15;
|
|
2316
|
+
let body;
|
|
2317
|
+
try {
|
|
2318
|
+
body = await request.json();
|
|
2319
|
+
} catch {
|
|
2320
|
+
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
2321
|
+
}
|
|
2322
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return errorJson("Request body must be an object", "invalid_request", 400);
|
|
2323
|
+
const signal = body.signal;
|
|
2324
|
+
if (signal === void 0) return 15;
|
|
2325
|
+
if (typeof signal !== "number" || !Number.isInteger(signal) || signal < 1 || signal > 64) return errorJson("signal must be an integer between 1 and 64", "invalid_request", 400);
|
|
2326
|
+
return signal;
|
|
2327
|
+
}
|
|
2328
|
+
function parseBoolean(value) {
|
|
2329
|
+
if (value === void 0) return void 0;
|
|
2330
|
+
return value === "true" || value === "1";
|
|
2331
|
+
}
|
|
2332
|
+
function isStringRecord(value) {
|
|
2333
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
|
|
2334
|
+
}
|
|
2335
|
+
function processLogsToSSE(logs) {
|
|
2336
|
+
const reader = logs.getReader();
|
|
2337
|
+
const encoder = new TextEncoder();
|
|
2338
|
+
let finished = false;
|
|
2339
|
+
async function finish(cancelSource) {
|
|
2340
|
+
if (finished) return;
|
|
2341
|
+
finished = true;
|
|
2342
|
+
if (cancelSource) await reader.cancel().catch(() => void 0);
|
|
2343
|
+
reader.releaseLock();
|
|
2344
|
+
}
|
|
2345
|
+
return new ReadableStream({
|
|
2346
|
+
async pull(controller) {
|
|
2347
|
+
try {
|
|
2348
|
+
const result = await reader.read();
|
|
2349
|
+
if (result.done) {
|
|
2350
|
+
await finish(false);
|
|
2351
|
+
controller.close();
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
const event = result.value;
|
|
2355
|
+
const data = event.type === "stdout" || event.type === "stderr" ? {
|
|
2356
|
+
type: event.type,
|
|
2357
|
+
cursor: event.cursor,
|
|
2358
|
+
timestamp: event.timestamp,
|
|
2359
|
+
data: bytesToBase64(event.data)
|
|
2360
|
+
} : event;
|
|
2361
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
2362
|
+
} catch (error) {
|
|
2363
|
+
await finish(true);
|
|
2364
|
+
controller.error(error);
|
|
2365
|
+
}
|
|
2366
|
+
},
|
|
2367
|
+
async cancel() {
|
|
2368
|
+
await finish(true);
|
|
2369
|
+
}
|
|
2370
|
+
}, { highWaterMark: 0 });
|
|
2371
|
+
}
|
|
2372
|
+
function bytesToBase64(data) {
|
|
2373
|
+
let binary = "";
|
|
2374
|
+
for (const byte of data) binary += String.fromCharCode(byte);
|
|
2375
|
+
return btoa(binary);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
//#endregion
|
|
2379
|
+
//#region src/bridge/routes/terminals.ts
|
|
2380
|
+
function parsePositiveInteger(value, name) {
|
|
2381
|
+
if (value === void 0) return void 0;
|
|
2382
|
+
const parsed = Number(value);
|
|
2383
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return errorJson(`${name} must be a positive integer`, "invalid_request", 400);
|
|
2384
|
+
return parsed;
|
|
2385
|
+
}
|
|
2386
|
+
function isSandboxCommand(value) {
|
|
2387
|
+
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string");
|
|
2388
|
+
}
|
|
2389
|
+
function parseCreateTerminalBody(rawBody) {
|
|
2390
|
+
let body;
|
|
2391
|
+
try {
|
|
2392
|
+
body = JSON.parse(rawBody);
|
|
2393
|
+
} catch {
|
|
2394
|
+
return errorJson("Invalid JSON body", "invalid_request", 400);
|
|
2395
|
+
}
|
|
2396
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return errorJson("Request body must be an object", "invalid_request", 400);
|
|
2397
|
+
const record = body;
|
|
2398
|
+
const argv = record.argv;
|
|
2399
|
+
if (!isSandboxCommand(argv)) return errorJson("argv must be a non-empty string array", "invalid_request", 400);
|
|
2400
|
+
const options = { command: argv };
|
|
2401
|
+
if (record.cwd !== void 0) {
|
|
2402
|
+
if (typeof record.cwd !== "string") return errorJson("cwd must be a string", "invalid_request", 400);
|
|
2403
|
+
options.cwd = record.cwd;
|
|
2404
|
+
}
|
|
2405
|
+
if (record.env !== void 0) {
|
|
2406
|
+
if (!record.env || typeof record.env !== "object" || Array.isArray(record.env)) return errorJson("env must be an object", "invalid_request", 400);
|
|
2407
|
+
const env$1 = {};
|
|
2408
|
+
for (const [key, value] of Object.entries(record.env)) {
|
|
2409
|
+
if (typeof value !== "string") return errorJson("env values must be strings", "invalid_request", 400);
|
|
2410
|
+
env$1[key] = value;
|
|
2411
|
+
}
|
|
2412
|
+
options.env = env$1;
|
|
2413
|
+
}
|
|
2414
|
+
if (record.cols !== void 0) {
|
|
2415
|
+
if (typeof record.cols !== "number" || !Number.isInteger(record.cols) || record.cols <= 0) return errorJson("cols must be a positive integer", "invalid_request", 400);
|
|
2416
|
+
options.cols = record.cols;
|
|
2417
|
+
}
|
|
2418
|
+
if (record.rows !== void 0) {
|
|
2419
|
+
if (typeof record.rows !== "number" || !Number.isInteger(record.rows) || record.rows <= 0) return errorJson("rows must be a positive integer", "invalid_request", 400);
|
|
2420
|
+
options.rows = record.rows;
|
|
2421
|
+
}
|
|
2422
|
+
return options;
|
|
2423
|
+
}
|
|
2424
|
+
function terminalError(error) {
|
|
2425
|
+
return errorJson(`terminal failed: ${error instanceof Error ? error.message : String(error)}`, "exec_transport_error", 502);
|
|
2426
|
+
}
|
|
2427
|
+
function registerTerminalRoutes(app, apiPrefix, sandboxBinding) {
|
|
2428
|
+
app.post(`${apiPrefix}/sandbox/:id/terminals`, async (c) => {
|
|
2429
|
+
const parsed = parseCreateTerminalBody(await c.req.text());
|
|
2430
|
+
if (parsed instanceof Response) return parsed;
|
|
2431
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2432
|
+
try {
|
|
2433
|
+
const terminal = await sandbox.createTerminal(parsed);
|
|
2434
|
+
return c.json(await terminal.getSnapshot());
|
|
2435
|
+
} catch (error) {
|
|
2436
|
+
return terminalError(error);
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
app.get(`${apiPrefix}/sandbox/:id/terminals`, async (c) => {
|
|
2440
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2441
|
+
try {
|
|
2442
|
+
const terminals = await sandbox.listTerminals();
|
|
2443
|
+
return c.json(await Promise.all(terminals.map((terminal) => terminal.getSnapshot())));
|
|
2444
|
+
} catch (error) {
|
|
2445
|
+
return terminalError(error);
|
|
2446
|
+
}
|
|
2447
|
+
});
|
|
2448
|
+
app.get(`${apiPrefix}/sandbox/:id/terminals/:terminalId`, async (c) => {
|
|
2449
|
+
const terminalId = validateTerminalId(c.req.param("terminalId"));
|
|
2450
|
+
if (!terminalId) return errorJson("Invalid terminal ID format", "invalid_request", 400);
|
|
2451
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2452
|
+
try {
|
|
2453
|
+
const terminal = await sandbox.getTerminal(terminalId);
|
|
2454
|
+
if (!terminal) return errorJson("Terminal not found", "not_found", 404);
|
|
2455
|
+
return c.json(await terminal.getSnapshot());
|
|
2456
|
+
} catch (error) {
|
|
2457
|
+
return terminalError(error);
|
|
2458
|
+
}
|
|
2459
|
+
});
|
|
2460
|
+
app.get(`${apiPrefix}/sandbox/:id/terminals/:terminalId/connect`, async (c) => {
|
|
2461
|
+
const upgrade = c.req.header("Upgrade");
|
|
2462
|
+
if (!upgrade || upgrade.toLowerCase() !== "websocket") return errorJson("WebSocket upgrade required", "invalid_request", 400);
|
|
2463
|
+
const terminalId = validateTerminalId(c.req.param("terminalId"));
|
|
2464
|
+
if (!terminalId) return errorJson("Invalid terminal ID format", "invalid_request", 400);
|
|
2465
|
+
const cols = parsePositiveInteger(c.req.query("cols"), "cols");
|
|
2466
|
+
if (cols instanceof Response) return cols;
|
|
2467
|
+
const rows = parsePositiveInteger(c.req.query("rows"), "rows");
|
|
2468
|
+
if (rows instanceof Response) return rows;
|
|
2469
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2470
|
+
try {
|
|
2471
|
+
const terminal = await sandbox.getTerminal(terminalId);
|
|
2472
|
+
if (!terminal) return errorJson("Terminal not found", "not_found", 404);
|
|
2473
|
+
return await terminal.connect(c.req.raw, {
|
|
2474
|
+
cursor: c.req.query("cursor"),
|
|
2475
|
+
cols,
|
|
2476
|
+
rows
|
|
2477
|
+
});
|
|
2478
|
+
} catch (error) {
|
|
2479
|
+
return terminalError(error);
|
|
2480
|
+
}
|
|
2481
|
+
});
|
|
2482
|
+
app.post(`${apiPrefix}/sandbox/:id/terminals/:terminalId/interrupt`, async (c) => {
|
|
2483
|
+
const terminalId = validateTerminalId(c.req.param("terminalId"));
|
|
2484
|
+
if (!terminalId) return errorJson("Invalid terminal ID format", "invalid_request", 400);
|
|
2485
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2486
|
+
try {
|
|
2487
|
+
const terminal = await sandbox.getTerminal(terminalId);
|
|
2488
|
+
if (!terminal) return errorJson("Terminal not found", "not_found", 404);
|
|
2489
|
+
await terminal.interrupt();
|
|
2490
|
+
return new Response(null, { status: 204 });
|
|
2491
|
+
} catch (error) {
|
|
2492
|
+
return terminalError(error);
|
|
2493
|
+
}
|
|
2494
|
+
});
|
|
2495
|
+
app.post(`${apiPrefix}/sandbox/:id/terminals/:terminalId/terminate`, async (c) => {
|
|
2496
|
+
const terminalId = validateTerminalId(c.req.param("terminalId"));
|
|
2497
|
+
if (!terminalId) return errorJson("Invalid terminal ID format", "invalid_request", 400);
|
|
2498
|
+
const sandbox = getSandbox(getSandboxNs(c.env, sandboxBinding), c.get("containerUUID"));
|
|
2499
|
+
try {
|
|
2500
|
+
const terminal = await sandbox.getTerminal(terminalId);
|
|
2501
|
+
if (!terminal) return errorJson("Terminal not found", "not_found", 404);
|
|
2502
|
+
await terminal.terminate();
|
|
2503
|
+
return new Response(null, { status: 204 });
|
|
2504
|
+
} catch (error) {
|
|
2505
|
+
return terminalError(error);
|
|
2506
|
+
}
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
//#endregion
|
|
2511
|
+
//#region src/bridge/routes.ts
|
|
2512
|
+
function sandboxRoutePoolMode(method, routePath) {
|
|
2513
|
+
if (method === "GET" && routePath === "/running") return "lookup";
|
|
2514
|
+
if (method === "GET" && (routePath === "/processes" || /^\/processes\/[^/]+(?:\/logs)?$/.test(routePath))) return "lookup";
|
|
2515
|
+
if (method === "POST" && /^\/processes\/[^/]+\/kill$/.test(routePath)) return "lookup";
|
|
2516
|
+
if (method === "GET" && (routePath === "/terminals" || /^\/terminals\/[^/]+(?:\/connect)?$/.test(routePath))) return "lookup";
|
|
2517
|
+
if (method === "POST" && /^\/terminals\/[^/]+\/(interrupt|terminate)$/.test(routePath)) return "lookup";
|
|
2518
|
+
if (method === "POST" && [
|
|
2519
|
+
"/processes",
|
|
2520
|
+
"/terminals",
|
|
2521
|
+
"/persist",
|
|
2522
|
+
"/hydrate",
|
|
2523
|
+
"/mount",
|
|
2524
|
+
"/unmount"
|
|
2525
|
+
].includes(routePath)) return "allocate";
|
|
2526
|
+
if ((method === "GET" || method === "PUT") && routePath.startsWith("/file/")) return "allocate";
|
|
2527
|
+
if ((method === "POST" || method === "DELETE") && /^\/tunnel\/[^/]+$/.test(routePath)) return "allocate";
|
|
2528
|
+
return "unmatched";
|
|
2529
|
+
}
|
|
2530
|
+
function missingRuntimeResponse(routePath) {
|
|
2531
|
+
if (routePath === "/running") return Response.json({ running: false });
|
|
2532
|
+
if (routePath === "/processes" || routePath === "/terminals") return Response.json([]);
|
|
2533
|
+
if (routePath.startsWith("/terminals/")) return errorJson("Terminal not found", "not_found", 404);
|
|
2534
|
+
return errorJson("Process not found", "not_found", 404);
|
|
2535
|
+
}
|
|
2536
|
+
function createBridgeApp(config) {
|
|
2537
|
+
const app = new Hono();
|
|
2538
|
+
const { sandboxBinding, warmPoolBinding, apiPrefix } = config;
|
|
2539
|
+
app.use(`${apiPrefix}/sandbox/*`, async (c, next) => {
|
|
2540
|
+
const sandboxId = new URL(c.req.url).pathname.split("/")[apiPrefix.split("/").filter(Boolean).length + 2];
|
|
2541
|
+
if (sandboxId && !/^[a-z2-7]{1,128}$/.test(sandboxId)) return errorJson("Invalid sandbox ID format", "invalid_request", 400);
|
|
2542
|
+
const token = c.env.SANDBOX_API_KEY;
|
|
2543
|
+
if (token) {
|
|
2544
|
+
const authHeader = c.req.header("Authorization") ?? "";
|
|
2545
|
+
if ((authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7) : "") !== token) return errorJson("Unauthorized", "unauthorized", 401);
|
|
2546
|
+
}
|
|
2547
|
+
return next();
|
|
2548
|
+
});
|
|
2549
|
+
app.use(`${apiPrefix}/sandbox/:id/*`, async (c, next) => {
|
|
2550
|
+
const url = new URL(c.req.url);
|
|
2551
|
+
const sandboxId = c.req.param("id");
|
|
2552
|
+
const routePath = url.pathname.slice(`${apiPrefix}/sandbox/${sandboxId}`.length);
|
|
2553
|
+
const poolMode = sandboxRoutePoolMode(c.req.method, routePath);
|
|
2554
|
+
if (poolMode === "unmatched") return next();
|
|
2555
|
+
const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
|
|
2556
|
+
const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
|
|
2557
|
+
const poolStub = getWarmPoolStub(c.env, warmPoolBinding);
|
|
2558
|
+
try {
|
|
2559
|
+
await poolStub.configure({
|
|
2560
|
+
warmTarget,
|
|
2561
|
+
refreshInterval
|
|
2562
|
+
});
|
|
2563
|
+
const containerUUID = poolMode === "lookup" ? await poolStub.lookupContainer(sandboxId) : await poolStub.getContainer(sandboxId);
|
|
2564
|
+
if (!containerUUID) return missingRuntimeResponse(routePath);
|
|
2565
|
+
c.set("containerUUID", containerUUID);
|
|
2566
|
+
} catch (err) {
|
|
2567
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2568
|
+
if (msg.includes("instance limit reached")) return errorJson(msg, "capacity_exceeded", 503);
|
|
2569
|
+
return errorJson(`pool error: ${msg}`, "pool_error", 502);
|
|
2570
|
+
}
|
|
2571
|
+
return next();
|
|
2572
|
+
});
|
|
2573
|
+
app.use(`${apiPrefix}/sandbox/:id`, async (c, next) => {
|
|
2574
|
+
if (c.req.method !== "DELETE") return next();
|
|
2575
|
+
const sandboxId = c.req.param("id");
|
|
2576
|
+
const poolStub = getWarmPoolStub(c.env, warmPoolBinding);
|
|
2577
|
+
try {
|
|
2578
|
+
const containerUUID = await poolStub.lookupContainer(sandboxId);
|
|
2579
|
+
if (!containerUUID) return new Response(null, { status: 204 });
|
|
2580
|
+
c.set("containerUUID", containerUUID);
|
|
2581
|
+
} catch (err) {
|
|
2582
|
+
return errorJson(`pool error: ${err instanceof Error ? err.message : String(err)}`, "pool_error", 502);
|
|
2583
|
+
}
|
|
2584
|
+
return next();
|
|
2585
|
+
});
|
|
2586
|
+
registerLifecycleRoutes(app, apiPrefix, config.healthPath, sandboxBinding, warmPoolBinding);
|
|
2587
|
+
registerProcessRoutes(app, apiPrefix, sandboxBinding);
|
|
2588
|
+
registerFileRoutes(app, apiPrefix, sandboxBinding);
|
|
2589
|
+
registerTerminalRoutes(app, apiPrefix, sandboxBinding);
|
|
2590
|
+
registerOpenApiRoutes(app, apiPrefix);
|
|
2591
|
+
return app;
|
|
2592
|
+
}
|
|
2593
|
+
function registerOpenApiRoutes(app, apiPrefix) {
|
|
2298
2594
|
const openapiAuth = async (c, next) => {
|
|
2299
2595
|
const token = c.env.SANDBOX_API_KEY;
|
|
2300
2596
|
if (token) {
|
|
@@ -2309,7 +2605,6 @@ function createBridgeApp(config) {
|
|
|
2309
2605
|
app.get(`${apiPrefix}/openapi.json`, openapiAuth, (c) => c.json(OPENAPI_SCHEMA));
|
|
2310
2606
|
app.get(`${apiPrefix}/openapi.html`, openapiAuth, openapiHtmlHandler);
|
|
2311
2607
|
app.get(`${apiPrefix}/openapi`, openapiAuth, openapiHtmlHandler);
|
|
2312
|
-
return app;
|
|
2313
2608
|
}
|
|
2314
2609
|
|
|
2315
2610
|
//#endregion
|
|
@@ -2317,12 +2612,9 @@ function createBridgeApp(config) {
|
|
|
2317
2612
|
/**
|
|
2318
2613
|
* WarmPool — Durable Object that maintains a pool of pre-started sandbox containers.
|
|
2319
2614
|
*
|
|
2320
|
-
*
|
|
2321
|
-
*
|
|
2322
|
-
*
|
|
2323
|
-
* The pool keeps N idle containers standing by so new sandbox sessions boot
|
|
2324
|
-
* instantly. Once a container is assigned to a sandbox ID it is consumed and
|
|
2325
|
-
* never returned to the pool.
|
|
2615
|
+
* The pool keeps N idle containers standing by so new sandboxes boot instantly.
|
|
2616
|
+
* Once a container is assigned to a sandbox ID, it is consumed and never returned
|
|
2617
|
+
* to the pool.
|
|
2326
2618
|
*
|
|
2327
2619
|
* Configuration is pushed in via `configure()` on every request (idempotent)
|
|
2328
2620
|
* so changes to wrangler vars take effect without manual intervention.
|
|
@@ -2424,11 +2716,11 @@ var WarmPool = class extends DurableObject {
|
|
|
2424
2716
|
async shutdownPrewarmed() {
|
|
2425
2717
|
await this.init();
|
|
2426
2718
|
for (const containerUUID of [...this.warmContainers]) try {
|
|
2427
|
-
await this.getSandboxStub(containerUUID).
|
|
2719
|
+
await this.getSandboxStub(containerUUID).destroy();
|
|
2428
2720
|
this.warmContainers.delete(containerUUID);
|
|
2429
2721
|
} catch (error) {
|
|
2430
2722
|
console.error({
|
|
2431
|
-
message: "Failed to
|
|
2723
|
+
message: "Failed to destroy container",
|
|
2432
2724
|
component: "warm-pool",
|
|
2433
2725
|
containerUUID,
|
|
2434
2726
|
error
|
|
@@ -2608,22 +2900,22 @@ var WarmPool = class extends DurableObject {
|
|
|
2608
2900
|
console.info({
|
|
2609
2901
|
message: "Scaling down pool",
|
|
2610
2902
|
component: "warm-pool",
|
|
2611
|
-
|
|
2903
|
+
destroying: excess
|
|
2612
2904
|
});
|
|
2613
|
-
const
|
|
2614
|
-
const
|
|
2615
|
-
for (const uuid of
|
|
2616
|
-
await this.getSandboxStub(uuid).
|
|
2617
|
-
|
|
2905
|
+
const toDestroy = [...this.warmContainers].slice(0, excess);
|
|
2906
|
+
const destroyed = [];
|
|
2907
|
+
for (const uuid of toDestroy) try {
|
|
2908
|
+
await this.getSandboxStub(uuid).destroy();
|
|
2909
|
+
destroyed.push(uuid);
|
|
2618
2910
|
} catch (error) {
|
|
2619
2911
|
console.error({
|
|
2620
|
-
message: "Failed to
|
|
2912
|
+
message: "Failed to destroy container",
|
|
2621
2913
|
component: "warm-pool",
|
|
2622
2914
|
containerUUID: uuid,
|
|
2623
2915
|
error
|
|
2624
2916
|
});
|
|
2625
2917
|
}
|
|
2626
|
-
for (const uuid of
|
|
2918
|
+
for (const uuid of destroyed) this.warmContainers.delete(uuid);
|
|
2627
2919
|
await this.persist();
|
|
2628
2920
|
}
|
|
2629
2921
|
}
|