@ekairos/sandbox 1.22.15-beta.feature-thread-unify.0 → 1.22.16-beta.development.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -452
- package/dist/app.js +1 -1
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +7 -1
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +170 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +305 -3
- package/dist/schema.js.map +1 -1
- package/dist/service.d.ts +109 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1380 -56
- package/dist/service.js.map +1 -1
- package/dist/types.d.ts +83 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vercel-options.d.ts +21 -0
- package/dist/vercel-options.d.ts.map +1 -0
- package/dist/vercel-options.js +149 -0
- package/dist/vercel-options.js.map +1 -0
- package/package.json +11 -7
package/dist/service.js
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
|
-
import { Sandbox as VercelSandbox } from "@vercel/sandbox";
|
|
1
|
+
import { Sandbox as VercelSandbox, Snapshot as VercelSnapshot } from "@vercel/sandbox";
|
|
2
2
|
import { Daytona, Image } from "@daytonaio/sdk";
|
|
3
3
|
import { id } from "@instantdb/admin";
|
|
4
|
+
import { resolveRuntime } from "@ekairos/domain/runtime";
|
|
4
5
|
import { runCommandInSandbox } from "./commands.js";
|
|
6
|
+
import { resolveVercelSandboxConfig, safeVercelConfigForRecord, } from "./vercel-options.js";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
function isVercelSandbox(sandbox) {
|
|
15
|
+
return Boolean(sandbox &&
|
|
16
|
+
typeof sandbox === "object" &&
|
|
17
|
+
typeof sandbox.runCommand === "function" &&
|
|
18
|
+
typeof sandbox.currentSession === "function" &&
|
|
19
|
+
typeof sandbox.name === "string" &&
|
|
20
|
+
sandbox.__provider !== "sprites");
|
|
21
|
+
}
|
|
22
|
+
const EKAIROS_ROOT_DIR = "/vercel/sandbox/.ekairos";
|
|
23
|
+
const EKAIROS_RUNTIME_MANIFEST_PATH = `${EKAIROS_ROOT_DIR}/runtime.json`;
|
|
24
|
+
const EKAIROS_HTTP_HELPER_PATH = `${EKAIROS_ROOT_DIR}/instant-http.mjs`;
|
|
25
|
+
const EKAIROS_QUERY_SCRIPT_PATH = `${EKAIROS_ROOT_DIR}/query.mjs`;
|
|
26
|
+
const CODEX_HOME_DIR = "/vercel/sandbox/.codex";
|
|
27
|
+
const CODEX_SKILLS_DIR = `${CODEX_HOME_DIR}/skills`;
|
|
28
|
+
const INSTANT_API_BASE_URL = "https://api.instantdb.com";
|
|
29
|
+
const SANDBOX_PROCESS_STREAM_VERSION = 1;
|
|
30
|
+
const SANDBOX_PROCESS_TERMINAL_STATUSES = new Set(["exited", "failed", "killed", "lost"]);
|
|
5
31
|
function formatInstantSchemaError(err) {
|
|
6
32
|
const base = err instanceof Error ? err.message : String(err);
|
|
7
33
|
const body = err?.body;
|
|
@@ -25,6 +51,174 @@ function formatInstantSchemaError(err) {
|
|
|
25
51
|
// Keep it short + copy/paste friendly for debugging schema issues.
|
|
26
52
|
return base + " | missing attributes: " + uniq.join(", ");
|
|
27
53
|
}
|
|
54
|
+
function formatSandboxError(err) {
|
|
55
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
56
|
+
const text = typeof err?.text === "string" ? err.text.trim() : "";
|
|
57
|
+
const json = err?.json ? JSON.stringify(err.json) : "";
|
|
58
|
+
const detail = text || json;
|
|
59
|
+
if (!detail)
|
|
60
|
+
return base;
|
|
61
|
+
return `${base}: ${detail}`;
|
|
62
|
+
}
|
|
63
|
+
function nowIso() {
|
|
64
|
+
return new Date().toISOString();
|
|
65
|
+
}
|
|
66
|
+
function asOptionalString(value) {
|
|
67
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
68
|
+
}
|
|
69
|
+
function sanitizeInstantString(value) {
|
|
70
|
+
return value.includes("\0") ? value.replace(/\0/g, "") : value;
|
|
71
|
+
}
|
|
72
|
+
function sanitizeInstantValue(value) {
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
return sanitizeInstantString(value);
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
return value.map((item) => sanitizeInstantValue(item));
|
|
78
|
+
}
|
|
79
|
+
if (value && typeof value === "object" && !(value instanceof Date)) {
|
|
80
|
+
const sanitized = {};
|
|
81
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
82
|
+
sanitized[key] = sanitizeInstantValue(entry);
|
|
83
|
+
}
|
|
84
|
+
return sanitized;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function createSandboxProcessStreamClientId(processId) {
|
|
89
|
+
const normalized = String(processId ?? "").trim();
|
|
90
|
+
if (!normalized)
|
|
91
|
+
throw new Error("sandbox_process_id_required");
|
|
92
|
+
return `sandbox-process:${normalized}`;
|
|
93
|
+
}
|
|
94
|
+
function encodeSandboxProcessStreamChunk(chunk) {
|
|
95
|
+
return `${JSON.stringify(chunk)}\n`;
|
|
96
|
+
}
|
|
97
|
+
function parseSandboxProcessStreamChunk(value) {
|
|
98
|
+
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
99
|
+
if (!parsed || typeof parsed !== "object") {
|
|
100
|
+
throw new Error("invalid_sandbox_process_stream_chunk");
|
|
101
|
+
}
|
|
102
|
+
const record = parsed;
|
|
103
|
+
if (record.version !== SANDBOX_PROCESS_STREAM_VERSION) {
|
|
104
|
+
throw new Error(`invalid_sandbox_process_stream_version:${String(record.version)}`);
|
|
105
|
+
}
|
|
106
|
+
return record;
|
|
107
|
+
}
|
|
108
|
+
function sandboxProcessFinishedHookToken(processId) {
|
|
109
|
+
return `sandbox-process:${processId}:finished`;
|
|
110
|
+
}
|
|
111
|
+
async function resumeSandboxProcessHook(processId, payload) {
|
|
112
|
+
try {
|
|
113
|
+
const { resumeHook } = await import("workflow/api");
|
|
114
|
+
await resumeHook(sandboxProcessFinishedHookToken(processId), payload);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// No workflow may be listening; process metadata and streams remain the source of truth.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function commandResultFromProcessStream(params) {
|
|
121
|
+
const stdout = params.chunks
|
|
122
|
+
.filter((chunk) => chunk.type === "stdout")
|
|
123
|
+
.map((chunk) => String(chunk.data?.text ?? ""))
|
|
124
|
+
.join("");
|
|
125
|
+
const stderr = params.chunks
|
|
126
|
+
.filter((chunk) => chunk.type === "stderr" || chunk.type === "error")
|
|
127
|
+
.map((chunk) => String(chunk.data?.text ?? chunk.data?.message ?? ""))
|
|
128
|
+
.join("");
|
|
129
|
+
const exitChunk = [...params.chunks].reverse().find((chunk) => chunk.type === "exit");
|
|
130
|
+
const exitCode = Number(exitChunk?.data?.exitCode ?? params.processRow?.exitCode ?? 1);
|
|
131
|
+
const command = [
|
|
132
|
+
String(params.processRow?.command ?? ""),
|
|
133
|
+
...(Array.isArray(params.processRow?.args) ? params.processRow.args : []),
|
|
134
|
+
]
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.join(" ");
|
|
137
|
+
return {
|
|
138
|
+
success: exitCode === 0,
|
|
139
|
+
exitCode,
|
|
140
|
+
output: stdout,
|
|
141
|
+
error: stderr,
|
|
142
|
+
command,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export class SandboxCommandRun {
|
|
146
|
+
constructor(data, service) {
|
|
147
|
+
this.service = null;
|
|
148
|
+
this.data = data;
|
|
149
|
+
this.service = service ?? null;
|
|
150
|
+
}
|
|
151
|
+
get sandboxId() {
|
|
152
|
+
return this.data.sandboxId;
|
|
153
|
+
}
|
|
154
|
+
get processId() {
|
|
155
|
+
return this.data.processId;
|
|
156
|
+
}
|
|
157
|
+
get streamId() {
|
|
158
|
+
return this.data.streamId;
|
|
159
|
+
}
|
|
160
|
+
get streamClientId() {
|
|
161
|
+
return this.data.streamClientId;
|
|
162
|
+
}
|
|
163
|
+
getService() {
|
|
164
|
+
if (!this.service) {
|
|
165
|
+
throw new Error("sandbox_command_run_service_required");
|
|
166
|
+
}
|
|
167
|
+
return this.service;
|
|
168
|
+
}
|
|
169
|
+
async readStream() {
|
|
170
|
+
const stream = await this.getService().readProcessStream(this.processId);
|
|
171
|
+
if (!stream.ok)
|
|
172
|
+
throw new Error(stream.error);
|
|
173
|
+
return stream.data;
|
|
174
|
+
}
|
|
175
|
+
async snapshot() {
|
|
176
|
+
const snapshot = await this.getService().getProcessSnapshot(this.processId);
|
|
177
|
+
if (!snapshot.ok)
|
|
178
|
+
throw new Error(snapshot.error);
|
|
179
|
+
return snapshot.data;
|
|
180
|
+
}
|
|
181
|
+
async wait(params) {
|
|
182
|
+
if (this.data.result)
|
|
183
|
+
return this.data.result;
|
|
184
|
+
const initial = await this.snapshot();
|
|
185
|
+
if (SANDBOX_PROCESS_TERMINAL_STATUSES.has(String(initial.status ?? ""))) {
|
|
186
|
+
const stream = await this.readStream();
|
|
187
|
+
const result = commandResultFromProcessStream({ processRow: initial, chunks: stream.chunks });
|
|
188
|
+
this.data.result = result;
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const { createHook } = await import("workflow");
|
|
193
|
+
const hook = createHook({
|
|
194
|
+
token: sandboxProcessFinishedHookToken(this.processId),
|
|
195
|
+
});
|
|
196
|
+
const result = await hook;
|
|
197
|
+
this.data.result = result;
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Outside workflow context, or if hooks are unavailable, poll the durable row.
|
|
202
|
+
}
|
|
203
|
+
const timeoutMs = Math.max(0, Number(params?.timeoutMs ?? 5 * 60 * 1000));
|
|
204
|
+
const pollMs = Math.max(50, Number(params?.pollMs ?? 500));
|
|
205
|
+
const deadline = Date.now() + timeoutMs;
|
|
206
|
+
while (Date.now() <= deadline) {
|
|
207
|
+
const row = await this.snapshot();
|
|
208
|
+
if (SANDBOX_PROCESS_TERMINAL_STATUSES.has(String(row.status ?? ""))) {
|
|
209
|
+
const stream = await this.readStream();
|
|
210
|
+
const result = commandResultFromProcessStream({ processRow: row, chunks: stream.chunks });
|
|
211
|
+
this.data.result = result;
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
215
|
+
}
|
|
216
|
+
throw new Error(`sandbox_process_wait_timeout:${this.processId}`);
|
|
217
|
+
}
|
|
218
|
+
then(onfulfilled, onrejected) {
|
|
219
|
+
return this.wait().then(onfulfilled, onrejected);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
28
222
|
export class SandboxService {
|
|
29
223
|
constructor(db) {
|
|
30
224
|
this.adminDb = db;
|
|
@@ -38,18 +232,423 @@ export class SandboxService {
|
|
|
38
232
|
}
|
|
39
233
|
return { teamId, projectId, token };
|
|
40
234
|
}
|
|
41
|
-
static
|
|
42
|
-
const
|
|
235
|
+
static getDomainName(domain) {
|
|
236
|
+
const metaName = typeof domain?.meta?.name === "string" ? domain.meta.name.trim() : "";
|
|
237
|
+
const contextName = typeof domain?.context === "function" ? String(domain.context()?.name ?? "").trim() : "";
|
|
238
|
+
return metaName || contextName || "domain";
|
|
239
|
+
}
|
|
240
|
+
static getDomainContextString(domain) {
|
|
241
|
+
if (typeof domain?.contextString !== "function")
|
|
242
|
+
return "";
|
|
243
|
+
try {
|
|
244
|
+
return String(domain.contextString() ?? "").trim();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return "";
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
static cloneJson(value) {
|
|
251
|
+
return JSON.parse(JSON.stringify(value));
|
|
252
|
+
}
|
|
253
|
+
static buildEkairosNetworkPolicy(params) {
|
|
254
|
+
const allow = {
|
|
255
|
+
"api.instantdb.com": [
|
|
256
|
+
{
|
|
257
|
+
transform: [
|
|
258
|
+
{
|
|
259
|
+
headers: {
|
|
260
|
+
"as-token": params.scopedToken,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
if (params.datasetEnabled) {
|
|
268
|
+
allow["pypi.org"] = [];
|
|
269
|
+
allow["files.pythonhosted.org"] = [];
|
|
270
|
+
allow["*.pythonhosted.org"] = [];
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
allow,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
static buildEkairosManifest(params) {
|
|
277
|
+
const contextString = SandboxService.getDomainContextString(params.domain);
|
|
278
|
+
const schemaJson = SandboxService.cloneJson(params.domain.toInstantSchema());
|
|
279
|
+
return {
|
|
280
|
+
version: 1,
|
|
281
|
+
instant: {
|
|
282
|
+
apiBaseUrl: INSTANT_API_BASE_URL,
|
|
283
|
+
appId: params.appId,
|
|
284
|
+
},
|
|
285
|
+
sandbox: {
|
|
286
|
+
sandboxUserId: params.sandboxUserId,
|
|
287
|
+
},
|
|
288
|
+
domain: {
|
|
289
|
+
name: SandboxService.getDomainName(params.domain),
|
|
290
|
+
...(contextString ? { contextString } : {}),
|
|
291
|
+
schemaJson,
|
|
292
|
+
},
|
|
293
|
+
...(params.datasetEnabled ? { dataset: { enabled: true } } : {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
static buildEkairosRuntimeFiles(manifest) {
|
|
297
|
+
const httpHelper = [
|
|
298
|
+
"import { readFile } from 'node:fs/promises'",
|
|
299
|
+
"import { randomUUID } from 'node:crypto'",
|
|
300
|
+
"",
|
|
301
|
+
"export async function readRuntimeManifest(manifestPath) {",
|
|
302
|
+
` const resolvedPath = manifestPath || ${JSON.stringify(EKAIROS_RUNTIME_MANIFEST_PATH)}`,
|
|
303
|
+
" return JSON.parse(await readFile(resolvedPath, 'utf8'))",
|
|
304
|
+
"}",
|
|
305
|
+
"",
|
|
306
|
+
"export async function instantQuery(query, manifestPath) {",
|
|
307
|
+
" const manifest = await readRuntimeManifest(manifestPath)",
|
|
308
|
+
" const response = await fetch(`${manifest.instant.apiBaseUrl}/admin/query`, {",
|
|
309
|
+
" method: 'POST',",
|
|
310
|
+
" headers: {",
|
|
311
|
+
" 'content-type': 'application/json',",
|
|
312
|
+
" 'app-id': manifest.instant.appId,",
|
|
313
|
+
" },",
|
|
314
|
+
" body: JSON.stringify({ query }),",
|
|
315
|
+
" })",
|
|
316
|
+
" const text = await response.text()",
|
|
317
|
+
" if (!response.ok) {",
|
|
318
|
+
" throw new Error(JSON.stringify({ status: response.status, body: text }))",
|
|
319
|
+
" }",
|
|
320
|
+
" return text ? JSON.parse(text) : {}",
|
|
321
|
+
"}",
|
|
322
|
+
"",
|
|
323
|
+
"export async function instantTransact(steps, manifestPath) {",
|
|
324
|
+
" const manifest = await readRuntimeManifest(manifestPath)",
|
|
325
|
+
" const response = await fetch(`${manifest.instant.apiBaseUrl}/admin/transact`, {",
|
|
326
|
+
" method: 'POST',",
|
|
327
|
+
" headers: {",
|
|
328
|
+
" 'content-type': 'application/json',",
|
|
329
|
+
" 'app-id': manifest.instant.appId,",
|
|
330
|
+
" },",
|
|
331
|
+
" body: JSON.stringify({ steps, 'throw-on-missing-attrs?': true }),",
|
|
332
|
+
" })",
|
|
333
|
+
" const text = await response.text()",
|
|
334
|
+
" if (!response.ok) {",
|
|
335
|
+
" throw new Error(JSON.stringify({ status: response.status, body: text }))",
|
|
336
|
+
" }",
|
|
337
|
+
" return text ? JSON.parse(text) : {}",
|
|
338
|
+
"}",
|
|
339
|
+
"",
|
|
340
|
+
"export function newId() {",
|
|
341
|
+
" return randomUUID()",
|
|
342
|
+
"}",
|
|
343
|
+
"",
|
|
344
|
+
"export function decodeArg(encodedJson) {",
|
|
345
|
+
" return JSON.parse(Buffer.from(encodedJson, 'base64url').toString('utf8'))",
|
|
346
|
+
"}",
|
|
347
|
+
"",
|
|
348
|
+
].join("\n");
|
|
349
|
+
const queryScript = [
|
|
350
|
+
`import { decodeArg, instantQuery } from ${JSON.stringify(EKAIROS_HTTP_HELPER_PATH)}`,
|
|
351
|
+
"",
|
|
352
|
+
"const encodedQuery = process.argv[2] ?? ''",
|
|
353
|
+
`const manifestPath = process.argv[3] ?? ${JSON.stringify(EKAIROS_RUNTIME_MANIFEST_PATH)}`,
|
|
354
|
+
"",
|
|
355
|
+
"if (!encodedQuery) {",
|
|
356
|
+
" console.error('ekairos_query_required')",
|
|
357
|
+
" process.exit(1)",
|
|
358
|
+
"}",
|
|
359
|
+
"",
|
|
360
|
+
"const query = decodeArg(encodedQuery)",
|
|
361
|
+
"try {",
|
|
362
|
+
" const result = await instantQuery(query, manifestPath)",
|
|
363
|
+
" process.stdout.write(JSON.stringify(result))",
|
|
364
|
+
"} catch (error) {",
|
|
365
|
+
" console.error(error instanceof Error ? error.message : String(error))",
|
|
366
|
+
" process.exit(1)",
|
|
367
|
+
"}",
|
|
368
|
+
"",
|
|
369
|
+
].join("\n");
|
|
370
|
+
const files = [
|
|
371
|
+
{
|
|
372
|
+
path: EKAIROS_HTTP_HELPER_PATH,
|
|
373
|
+
content: Buffer.from(httpHelper, "utf8"),
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
path: EKAIROS_RUNTIME_MANIFEST_PATH,
|
|
377
|
+
content: Buffer.from(JSON.stringify(manifest, null, 2), "utf8"),
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
path: EKAIROS_QUERY_SCRIPT_PATH,
|
|
381
|
+
content: Buffer.from(queryScript, "utf8"),
|
|
382
|
+
},
|
|
383
|
+
];
|
|
384
|
+
return files;
|
|
385
|
+
}
|
|
386
|
+
static async resolveInstantUserIdByRefreshToken(params) {
|
|
387
|
+
const response = await fetch(`${INSTANT_API_BASE_URL}/admin/users?refresh_token=${encodeURIComponent(params.refreshToken)}`, {
|
|
388
|
+
method: "GET",
|
|
389
|
+
headers: {
|
|
390
|
+
authorization: `Bearer ${params.adminToken}`,
|
|
391
|
+
"app-id": params.appId,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
const text = await response.text();
|
|
395
|
+
let parsed = null;
|
|
396
|
+
try {
|
|
397
|
+
parsed = text ? JSON.parse(text) : null;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
parsed = null;
|
|
401
|
+
}
|
|
402
|
+
if (!response.ok) {
|
|
403
|
+
throw new Error(parsed?.message || parsed?.error || text || "instant_refresh_token_lookup_failed");
|
|
404
|
+
}
|
|
405
|
+
const userId = String(parsed?.user?.id ?? "").trim();
|
|
406
|
+
if (!userId) {
|
|
407
|
+
throw new Error("instant_refresh_token_user_id_missing");
|
|
408
|
+
}
|
|
409
|
+
return userId;
|
|
410
|
+
}
|
|
411
|
+
static async resolveEkairosBootstrap(config) {
|
|
412
|
+
const hasRuntimeBinding = config.env !== undefined || config.domain !== undefined;
|
|
413
|
+
if (!hasRuntimeBinding)
|
|
414
|
+
return null;
|
|
415
|
+
if (!config.env || !config.domain) {
|
|
416
|
+
throw new Error("sandbox_runtime_requires_env_and_domain");
|
|
417
|
+
}
|
|
418
|
+
const provider = SandboxService.resolveProvider(config);
|
|
419
|
+
if (provider !== "vercel") {
|
|
420
|
+
throw new Error("ekairos_runtime_requires_vercel_provider");
|
|
421
|
+
}
|
|
422
|
+
const datasetEnabled = Boolean(config.dataset?.enabled);
|
|
423
|
+
const runtime = await resolveRuntime(config.domain, config.env);
|
|
424
|
+
const adminDb = runtime?.db;
|
|
425
|
+
const appId = String(adminDb?.config?.appId ?? "").trim();
|
|
426
|
+
const adminToken = String(adminDb?.config?.adminToken ?? "").trim();
|
|
427
|
+
if (!adminDb || !appId || !adminToken) {
|
|
428
|
+
throw new Error("ekairos_runtime_admin_db_required");
|
|
429
|
+
}
|
|
430
|
+
const provisionalSandboxUserId = randomUUID();
|
|
431
|
+
const scopedToken = await adminDb.auth.createToken({ id: provisionalSandboxUserId });
|
|
432
|
+
const sandboxUserId = await SandboxService.resolveInstantUserIdByRefreshToken({
|
|
433
|
+
appId,
|
|
434
|
+
adminToken,
|
|
435
|
+
refreshToken: scopedToken,
|
|
436
|
+
});
|
|
437
|
+
return {
|
|
438
|
+
appId,
|
|
439
|
+
sandboxUserId,
|
|
440
|
+
scopedToken,
|
|
441
|
+
manifest: SandboxService.buildEkairosManifest({
|
|
442
|
+
appId,
|
|
443
|
+
sandboxUserId,
|
|
444
|
+
domain: config.domain,
|
|
445
|
+
datasetEnabled,
|
|
446
|
+
}),
|
|
447
|
+
networkPolicy: SandboxService.buildEkairosNetworkPolicy({ scopedToken, datasetEnabled }),
|
|
448
|
+
env: {
|
|
449
|
+
EKAIROS_RUNTIME_MANIFEST_PATH: EKAIROS_RUNTIME_MANIFEST_PATH,
|
|
450
|
+
EKAIROS_SANDBOX_USER_ID: sandboxUserId,
|
|
451
|
+
EKAIROS_INSTANT_APP_ID: appId,
|
|
452
|
+
CODEX_HOME: CODEX_HOME_DIR,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
static async bootstrapEkairosFiles(sandbox, manifest) {
|
|
457
|
+
await SandboxService.safeMkDir(sandbox, EKAIROS_ROOT_DIR);
|
|
458
|
+
await SandboxService.safeMkDir(sandbox, CODEX_HOME_DIR);
|
|
459
|
+
await SandboxService.safeMkDir(sandbox, CODEX_SKILLS_DIR);
|
|
460
|
+
await sandbox.writeFiles(SandboxService.buildEkairosRuntimeFiles(manifest));
|
|
461
|
+
}
|
|
462
|
+
static async safeMkDir(sandbox, dirPath) {
|
|
463
|
+
try {
|
|
464
|
+
await sandbox.mkDir(dirPath);
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
const message = formatSandboxError(error);
|
|
468
|
+
if (message.includes("File exists")) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
static buildSkillInstallSet(skills) {
|
|
475
|
+
return skills.map((skill) => {
|
|
476
|
+
const skillName = String(skill.name ?? "").trim();
|
|
477
|
+
if (!skillName) {
|
|
478
|
+
throw new Error("sandbox_skill_name_required");
|
|
479
|
+
}
|
|
480
|
+
const rootDir = `${CODEX_SKILLS_DIR}/${skillName}`;
|
|
481
|
+
const files = (skill.files ?? []).map((file) => {
|
|
482
|
+
const relativePath = String(file.path ?? "").replace(/\\/g, "/").replace(/^\/+/, "").trim();
|
|
483
|
+
if (!relativePath) {
|
|
484
|
+
throw new Error(`sandbox_skill_file_path_required:${skillName}`);
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
path: `${rootDir}/${relativePath}`,
|
|
488
|
+
content: Buffer.from(String(file.contentBase64 ?? ""), "base64"),
|
|
489
|
+
};
|
|
490
|
+
});
|
|
491
|
+
return {
|
|
492
|
+
name: skillName,
|
|
493
|
+
rootDir,
|
|
494
|
+
files,
|
|
495
|
+
};
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
static async bootstrapSkills(sandbox, skills) {
|
|
499
|
+
const installSet = SandboxService.buildSkillInstallSet(skills);
|
|
500
|
+
if (installSet.length === 0)
|
|
501
|
+
return [];
|
|
502
|
+
await SandboxService.safeMkDir(sandbox, CODEX_HOME_DIR);
|
|
503
|
+
await SandboxService.safeMkDir(sandbox, CODEX_SKILLS_DIR);
|
|
504
|
+
for (const skill of installSet) {
|
|
505
|
+
await SandboxService.safeMkDir(sandbox, skill.rootDir);
|
|
506
|
+
const parentDirs = Array.from(new Set(skill.files
|
|
507
|
+
.map((file) => path.posix.dirname(file.path))
|
|
508
|
+
.filter((dirPath) => dirPath && dirPath !== "." && dirPath !== skill.rootDir))).sort((a, b) => a.length - b.length);
|
|
509
|
+
for (const dirPath of parentDirs) {
|
|
510
|
+
await SandboxService.safeMkDir(sandbox, dirPath);
|
|
511
|
+
}
|
|
512
|
+
await sandbox.writeFiles(skill.files);
|
|
513
|
+
}
|
|
514
|
+
return installSet.map((skill) => ({
|
|
515
|
+
name: skill.name,
|
|
516
|
+
rootDir: skill.rootDir,
|
|
517
|
+
fileCount: skill.files.length,
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
static resolveVercelWorkingDirectory(config) {
|
|
521
|
+
const fromConfig = String(config.vercel?.cwd ?? "").trim();
|
|
522
|
+
if (fromConfig)
|
|
523
|
+
return path.resolve(fromConfig);
|
|
524
|
+
const fromEnv = String(process.env.SANDBOX_VERCEL_CWD ?? "").trim();
|
|
525
|
+
if (fromEnv)
|
|
526
|
+
return path.resolve(fromEnv);
|
|
527
|
+
return process.cwd();
|
|
528
|
+
}
|
|
529
|
+
static findLinkedVercelProjectFile(startDir) {
|
|
530
|
+
let current = path.resolve(startDir);
|
|
531
|
+
while (true) {
|
|
532
|
+
const candidate = path.join(current, ".vercel", "project.json");
|
|
533
|
+
if (existsSync(candidate))
|
|
534
|
+
return candidate;
|
|
535
|
+
const parent = path.dirname(current);
|
|
536
|
+
if (parent === current)
|
|
537
|
+
return null;
|
|
538
|
+
current = parent;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
static async readLinkedVercelProject(config) {
|
|
542
|
+
const cwd = SandboxService.resolveVercelWorkingDirectory(config);
|
|
543
|
+
const file = SandboxService.findLinkedVercelProjectFile(cwd);
|
|
544
|
+
if (!file) {
|
|
545
|
+
return { cwd };
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8"));
|
|
549
|
+
return {
|
|
550
|
+
cwd,
|
|
551
|
+
orgId: typeof parsed?.orgId === "string" ? parsed.orgId : undefined,
|
|
552
|
+
projectId: typeof parsed?.projectId === "string" ? parsed.projectId : undefined,
|
|
553
|
+
projectName: typeof parsed?.projectName === "string" ? parsed.projectName : undefined,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return { cwd };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
static async pullVercelOidcToken(config) {
|
|
561
|
+
const cwd = SandboxService.resolveVercelWorkingDirectory(config);
|
|
562
|
+
const tmpPath = path.join(os.tmpdir(), `ekairos-vercel-env-${Date.now()}-${Math.random().toString(36).slice(2)}.env`);
|
|
563
|
+
const args = ["env", "pull", tmpPath, "--yes", "--environment", String(config.vercel?.environment ?? "development")];
|
|
564
|
+
const scope = String(config.vercel?.scope ?? process.env.SANDBOX_VERCEL_SCOPE ?? "").trim();
|
|
565
|
+
if (scope) {
|
|
566
|
+
args.push("--scope", scope);
|
|
567
|
+
}
|
|
568
|
+
const token = String(process.env.VERCEL_TOKEN ?? process.env.SANDBOX_VERCEL_TOKEN ?? "").trim();
|
|
569
|
+
if (token) {
|
|
570
|
+
args.push("--token", token);
|
|
571
|
+
}
|
|
572
|
+
const isWindows = process.platform === "win32";
|
|
573
|
+
const command = isWindows ? (process.env.COMSPEC || "cmd.exe") : "vercel";
|
|
574
|
+
const commandArgs = isWindows ? ["/c", "vercel", ...args] : args;
|
|
575
|
+
try {
|
|
576
|
+
await execFileAsync(command, commandArgs, {
|
|
577
|
+
cwd,
|
|
578
|
+
windowsHide: true,
|
|
579
|
+
timeout: 120000,
|
|
580
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
581
|
+
});
|
|
582
|
+
const content = await fs.readFile(tmpPath, "utf8");
|
|
583
|
+
const match = content.match(/VERCEL_OIDC_TOKEN=\"?([^\r\n\"]+)\"?/);
|
|
584
|
+
const oidc = String(match?.[1] ?? "").trim();
|
|
585
|
+
if (!oidc) {
|
|
586
|
+
throw new Error("VERCEL_OIDC_TOKEN missing from vercel env pull output");
|
|
587
|
+
}
|
|
588
|
+
return oidc;
|
|
589
|
+
}
|
|
590
|
+
finally {
|
|
591
|
+
await fs.rm(tmpPath, { force: true }).catch(() => { });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
static async resolveVercelCredentials(config) {
|
|
595
|
+
const explicitTeamId = String(config.vercel?.orgId ?? process.env.SANDBOX_VERCEL_TEAM_ID ?? "").trim();
|
|
596
|
+
const explicitProjectId = String(config.vercel?.projectId ?? process.env.SANDBOX_VERCEL_PROJECT_ID ?? "").trim();
|
|
597
|
+
const explicitToken = String(config.vercel?.token ?? process.env.SANDBOX_VERCEL_TOKEN ?? process.env.VERCEL_OIDC_TOKEN ?? "").trim();
|
|
598
|
+
if (explicitTeamId && explicitProjectId && explicitToken) {
|
|
599
|
+
return { teamId: explicitTeamId, projectId: explicitProjectId, token: explicitToken };
|
|
600
|
+
}
|
|
601
|
+
const linked = await SandboxService.readLinkedVercelProject(config);
|
|
602
|
+
const teamId = explicitTeamId || String(linked.orgId ?? "").trim();
|
|
603
|
+
const projectId = explicitProjectId || String(linked.projectId ?? "").trim();
|
|
604
|
+
let token = explicitToken;
|
|
605
|
+
if (!token) {
|
|
606
|
+
token = await SandboxService.pullVercelOidcToken(config);
|
|
607
|
+
}
|
|
608
|
+
if (!teamId || !projectId || !token) {
|
|
609
|
+
throw new Error("Missing Vercel sandbox credentials. Link the project (`vercel link`) and ensure `vercel env pull` can resolve VERCEL_OIDC_TOKEN, or provide explicit SANDBOX_VERCEL_* env vars.");
|
|
610
|
+
}
|
|
611
|
+
return { teamId, projectId, token };
|
|
612
|
+
}
|
|
613
|
+
static async provisionVercelSandbox(config, extra) {
|
|
614
|
+
const creds = await SandboxService.resolveVercelCredentials(config);
|
|
615
|
+
const resolved = extra?.resolved ?? resolveVercelSandboxConfig(config);
|
|
616
|
+
if (resolved.reuse && resolved.name) {
|
|
617
|
+
try {
|
|
618
|
+
return await VercelSandbox.get({
|
|
619
|
+
name: resolved.name,
|
|
620
|
+
teamId: creds.teamId,
|
|
621
|
+
projectId: creds.projectId,
|
|
622
|
+
token: creds.token,
|
|
623
|
+
resume: true,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
const status = Number(error?.response?.status ?? 0);
|
|
628
|
+
const message = formatSandboxError(error).toLowerCase();
|
|
629
|
+
if (status !== 404 && !message.includes("not found")) {
|
|
630
|
+
throw error;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
43
634
|
return await VercelSandbox.create({
|
|
44
635
|
teamId: creds.teamId,
|
|
45
636
|
projectId: creds.projectId,
|
|
46
637
|
token: creds.token,
|
|
47
|
-
|
|
48
|
-
|
|
638
|
+
...(resolved.name ? { name: resolved.name } : {}),
|
|
639
|
+
timeout: resolved.timeoutMs,
|
|
640
|
+
ports: resolved.ports,
|
|
49
641
|
// IMPORTANT: pass runtime as-is (e.g. "python3.13") to match provider expectations.
|
|
50
642
|
// Don't normalize to "python3"/"node22" as that can cause provider-side 400s.
|
|
51
|
-
runtime:
|
|
52
|
-
resources: { vcpus:
|
|
643
|
+
runtime: resolved.runtime,
|
|
644
|
+
resources: { vcpus: resolved.vcpus },
|
|
645
|
+
persistent: resolved.persistent,
|
|
646
|
+
...(resolved.snapshotExpirationMs !== undefined
|
|
647
|
+
? { snapshotExpiration: resolved.snapshotExpirationMs }
|
|
648
|
+
: {}),
|
|
649
|
+
...(resolved.tags ? { tags: resolved.tags } : {}),
|
|
650
|
+
networkPolicy: extra?.networkPolicy,
|
|
651
|
+
env: extra?.env,
|
|
53
652
|
});
|
|
54
653
|
}
|
|
55
654
|
static getDaytonaConfig() {
|
|
@@ -235,8 +834,8 @@ export class SandboxService {
|
|
|
235
834
|
: "";
|
|
236
835
|
return {
|
|
237
836
|
exitCode: Number.isFinite(exitCode) ? exitCode : 0,
|
|
238
|
-
stdout,
|
|
239
|
-
stderr,
|
|
837
|
+
stdout: sanitizeInstantString(stdout),
|
|
838
|
+
stderr: sanitizeInstantString(stderr),
|
|
240
839
|
};
|
|
241
840
|
}
|
|
242
841
|
static async spritesExec(params) {
|
|
@@ -428,22 +1027,55 @@ export class SandboxService {
|
|
|
428
1027
|
const sandboxId = id();
|
|
429
1028
|
const now = Date.now();
|
|
430
1029
|
const provider = SandboxService.resolveProvider(config);
|
|
1030
|
+
const resolvedVercel = provider === "vercel" ? resolveVercelSandboxConfig(config, { sandboxId }) : undefined;
|
|
431
1031
|
let daytonaEphemeral = undefined;
|
|
1032
|
+
let installedSkills = [];
|
|
432
1033
|
try {
|
|
1034
|
+
const ekairos = await SandboxService.resolveEkairosBootstrap(config);
|
|
433
1035
|
const baseParams = config.params && typeof config.params === "object" && !Array.isArray(config.params) ? config.params : {};
|
|
434
1036
|
await this.adminDb.transact(this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
|
|
435
1037
|
status: "creating",
|
|
1038
|
+
...(ekairos ? { sandboxUserId: ekairos.sandboxUserId } : {}),
|
|
436
1039
|
provider,
|
|
437
|
-
timeout: config.timeoutMs,
|
|
438
|
-
runtime: config.runtime,
|
|
439
|
-
vcpus: config.resources?.vcpus,
|
|
440
|
-
ports: config.ports,
|
|
1040
|
+
timeout: resolvedVercel?.timeoutMs ?? config.timeoutMs,
|
|
1041
|
+
runtime: resolvedVercel?.runtime ?? config.runtime,
|
|
1042
|
+
vcpus: resolvedVercel?.vcpus ?? config.resources?.vcpus,
|
|
1043
|
+
ports: (resolvedVercel?.ports ?? config.ports),
|
|
441
1044
|
purpose: config.purpose,
|
|
442
|
-
params:
|
|
1045
|
+
params: {
|
|
1046
|
+
...baseParams,
|
|
1047
|
+
...(resolvedVercel ? { vercel: safeVercelConfigForRecord(config, resolvedVercel) } : {}),
|
|
1048
|
+
...(ekairos
|
|
1049
|
+
? {
|
|
1050
|
+
ekairos: {
|
|
1051
|
+
enabled: true,
|
|
1052
|
+
sandboxUserId: ekairos.sandboxUserId,
|
|
1053
|
+
instant: {
|
|
1054
|
+
appId: ekairos.appId,
|
|
1055
|
+
apiBaseUrl: ekairos.manifest.instant.apiBaseUrl,
|
|
1056
|
+
},
|
|
1057
|
+
bootstrap: {
|
|
1058
|
+
manifestPath: EKAIROS_RUNTIME_MANIFEST_PATH,
|
|
1059
|
+
queryScriptPath: EKAIROS_QUERY_SCRIPT_PATH,
|
|
1060
|
+
},
|
|
1061
|
+
domain: ekairos.manifest.domain,
|
|
1062
|
+
...(config.dataset?.enabled ? { dataset: { enabled: true } } : {}),
|
|
1063
|
+
...(Array.isArray(config.skills) && config.skills.length > 0
|
|
1064
|
+
? {
|
|
1065
|
+
skills: config.skills.map((skill) => ({
|
|
1066
|
+
name: skill.name,
|
|
1067
|
+
fileCount: Array.isArray(skill.files) ? skill.files.length : 0,
|
|
1068
|
+
})),
|
|
1069
|
+
}
|
|
1070
|
+
: {}),
|
|
1071
|
+
},
|
|
1072
|
+
}
|
|
1073
|
+
: {}),
|
|
1074
|
+
},
|
|
443
1075
|
createdAt: now,
|
|
444
1076
|
updatedAt: now,
|
|
445
1077
|
}));
|
|
446
|
-
let sandbox;
|
|
1078
|
+
let sandbox = null;
|
|
447
1079
|
try {
|
|
448
1080
|
if (provider === "daytona") {
|
|
449
1081
|
const daytona = new Daytona(SandboxService.getDaytonaConfig());
|
|
@@ -505,11 +1137,36 @@ export class SandboxService {
|
|
|
505
1137
|
});
|
|
506
1138
|
}
|
|
507
1139
|
else {
|
|
508
|
-
|
|
1140
|
+
const vercelEnv = {
|
|
1141
|
+
...(Array.isArray(config.skills) && config.skills.length > 0 ? { CODEX_HOME: CODEX_HOME_DIR } : {}),
|
|
1142
|
+
...(ekairos?.env ?? {}),
|
|
1143
|
+
};
|
|
1144
|
+
sandbox = await SandboxService.provisionVercelSandbox(config, {
|
|
1145
|
+
networkPolicy: ekairos?.networkPolicy,
|
|
1146
|
+
env: Object.keys(vercelEnv).length > 0 ? vercelEnv : undefined,
|
|
1147
|
+
resolved: resolvedVercel,
|
|
1148
|
+
});
|
|
1149
|
+
if (ekairos) {
|
|
1150
|
+
await SandboxService.bootstrapEkairosFiles(sandbox, ekairos.manifest);
|
|
1151
|
+
}
|
|
1152
|
+
if (Array.isArray(config.skills) && config.skills.length > 0) {
|
|
1153
|
+
installedSkills = await SandboxService.bootstrapSkills(sandbox, config.skills);
|
|
1154
|
+
}
|
|
509
1155
|
}
|
|
510
1156
|
}
|
|
511
1157
|
catch (e) {
|
|
512
|
-
const msg =
|
|
1158
|
+
const msg = formatSandboxError(e);
|
|
1159
|
+
if (sandbox && provider === "vercel") {
|
|
1160
|
+
try {
|
|
1161
|
+
await sandbox.stop({ blocking: true });
|
|
1162
|
+
if (resolvedVercel?.deleteOnStop) {
|
|
1163
|
+
await sandbox.delete();
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
catch {
|
|
1167
|
+
// ignore cleanup errors during failed bootstrap
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
513
1170
|
await this.adminDb.transact(this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
|
|
514
1171
|
status: "error",
|
|
515
1172
|
updatedAt: Date.now(),
|
|
@@ -521,37 +1178,68 @@ export class SandboxService {
|
|
|
521
1178
|
? sandbox.id
|
|
522
1179
|
: provider === "sprites"
|
|
523
1180
|
? String(sandbox.name)
|
|
524
|
-
: sandbox.
|
|
1181
|
+
: sandbox.name;
|
|
525
1182
|
const sandboxUrl = provider === "sprites" ? sandbox.url : undefined;
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
1183
|
+
const activateMutations = [
|
|
1184
|
+
this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
|
|
1185
|
+
status: "active",
|
|
1186
|
+
externalSandboxId,
|
|
1187
|
+
...(ekairos ? { sandboxUserId: ekairos.sandboxUserId } : {}),
|
|
1188
|
+
...(sandboxUrl ? { sandboxUrl } : {}),
|
|
1189
|
+
updatedAt: Date.now(),
|
|
1190
|
+
params: {
|
|
1191
|
+
...baseParams,
|
|
1192
|
+
...(ekairos
|
|
1193
|
+
? {
|
|
1194
|
+
ekairos: {
|
|
1195
|
+
enabled: true,
|
|
1196
|
+
sandboxUserId: ekairos.sandboxUserId,
|
|
1197
|
+
instant: {
|
|
1198
|
+
appId: ekairos.appId,
|
|
1199
|
+
apiBaseUrl: ekairos.manifest.instant.apiBaseUrl,
|
|
1200
|
+
},
|
|
1201
|
+
bootstrap: {
|
|
1202
|
+
manifestPath: EKAIROS_RUNTIME_MANIFEST_PATH,
|
|
1203
|
+
queryScriptPath: EKAIROS_QUERY_SCRIPT_PATH,
|
|
1204
|
+
},
|
|
1205
|
+
domain: ekairos.manifest.domain,
|
|
1206
|
+
...(config.dataset?.enabled ? { dataset: { enabled: true } } : {}),
|
|
1207
|
+
...(installedSkills.length > 0 ? { skills: installedSkills } : {}),
|
|
1208
|
+
},
|
|
1209
|
+
}
|
|
1210
|
+
: {}),
|
|
1211
|
+
...(provider === "vercel"
|
|
1212
|
+
? {
|
|
1213
|
+
vercel: resolvedVercel ? safeVercelConfigForRecord(config, resolvedVercel) : {},
|
|
1214
|
+
}
|
|
1215
|
+
: {}),
|
|
1216
|
+
...(provider === "daytona"
|
|
1217
|
+
? {
|
|
1218
|
+
daytona: {
|
|
1219
|
+
...baseParams?.daytona,
|
|
1220
|
+
ephemeral: daytonaEphemeral,
|
|
1221
|
+
},
|
|
1222
|
+
}
|
|
1223
|
+
: {}),
|
|
1224
|
+
...(provider === "sprites"
|
|
1225
|
+
? {
|
|
1226
|
+
sprites: {
|
|
1227
|
+
...baseParams?.sprites,
|
|
1228
|
+
id: sandbox.id,
|
|
1229
|
+
name: sandbox.name,
|
|
1230
|
+
url: sandbox.url,
|
|
1231
|
+
urlSettings: config.sprites?.urlSettings ?? baseParams?.sprites?.urlSettings ?? undefined,
|
|
1232
|
+
deleteOnStop: config.sprites?.deleteOnStop ?? baseParams?.sprites?.deleteOnStop ?? true,
|
|
1233
|
+
},
|
|
1234
|
+
}
|
|
1235
|
+
: {}),
|
|
1236
|
+
},
|
|
1237
|
+
}),
|
|
1238
|
+
];
|
|
1239
|
+
if (ekairos) {
|
|
1240
|
+
activateMutations.push(this.adminDb.tx.sandbox_sandboxes[sandboxId].link({ user: ekairos.sandboxUserId }));
|
|
1241
|
+
}
|
|
1242
|
+
await this.adminDb.transact(activateMutations);
|
|
555
1243
|
return { ok: true, data: { sandboxId } };
|
|
556
1244
|
}
|
|
557
1245
|
catch (e) {
|
|
@@ -648,13 +1336,13 @@ export class SandboxService {
|
|
|
648
1336
|
if (record.provider !== "vercel") {
|
|
649
1337
|
return { ok: false, error: "Valid sandbox record not found" };
|
|
650
1338
|
}
|
|
651
|
-
const creds = SandboxService.
|
|
1339
|
+
const creds = await SandboxService.resolveVercelCredentials(record?.params ?? {});
|
|
652
1340
|
try {
|
|
653
1341
|
const maxAttempts = 20;
|
|
654
1342
|
const delayMs = 500;
|
|
655
1343
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
656
1344
|
const sandbox = await VercelSandbox.get({
|
|
657
|
-
|
|
1345
|
+
name: String(record.externalSandboxId),
|
|
658
1346
|
teamId: creds.teamId,
|
|
659
1347
|
projectId: creds.projectId,
|
|
660
1348
|
token: creds.token,
|
|
@@ -684,6 +1372,260 @@ export class SandboxService {
|
|
|
684
1372
|
return { ok: false, error: formatInstantSchemaError(e) };
|
|
685
1373
|
}
|
|
686
1374
|
}
|
|
1375
|
+
async getSandboxRecord(sandboxId) {
|
|
1376
|
+
const recordResult = await this.adminDb.query({
|
|
1377
|
+
sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 }, user: {} },
|
|
1378
|
+
});
|
|
1379
|
+
return recordResult?.sandbox_sandboxes?.[0] ?? null;
|
|
1380
|
+
}
|
|
1381
|
+
async getProcessSnapshot(processId) {
|
|
1382
|
+
try {
|
|
1383
|
+
const processResult = await this.adminDb.query({
|
|
1384
|
+
sandbox_processes: {
|
|
1385
|
+
$: { where: { id: processId }, limit: 1 },
|
|
1386
|
+
sandbox: {},
|
|
1387
|
+
},
|
|
1388
|
+
});
|
|
1389
|
+
const processRow = processResult?.sandbox_processes?.[0];
|
|
1390
|
+
if (!processRow)
|
|
1391
|
+
return { ok: false, error: "sandbox_process_not_found" };
|
|
1392
|
+
return { ok: true, data: processRow };
|
|
1393
|
+
}
|
|
1394
|
+
catch (e) {
|
|
1395
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
async markOpenProcessesLost(sandboxId, reason) {
|
|
1399
|
+
try {
|
|
1400
|
+
const processResult = await this.adminDb.query({
|
|
1401
|
+
sandbox_processes: {
|
|
1402
|
+
$: {
|
|
1403
|
+
where: { "sandbox.id": sandboxId },
|
|
1404
|
+
limit: 500,
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
1408
|
+
const rows = Array.isArray(processResult?.sandbox_processes)
|
|
1409
|
+
? processResult.sandbox_processes
|
|
1410
|
+
: [];
|
|
1411
|
+
const now = Date.now();
|
|
1412
|
+
const txs = rows
|
|
1413
|
+
.filter((row) => !SANDBOX_PROCESS_TERMINAL_STATUSES.has(String(row?.status ?? "")))
|
|
1414
|
+
.map((row) => this.adminDb.tx.sandbox_processes[String(row.id)].update({
|
|
1415
|
+
status: "lost",
|
|
1416
|
+
streamFinishedAt: row.streamFinishedAt ?? now,
|
|
1417
|
+
streamAbortReason: reason,
|
|
1418
|
+
exitedAt: now,
|
|
1419
|
+
updatedAt: now,
|
|
1420
|
+
metadata: {
|
|
1421
|
+
...(row.metadata ?? {}),
|
|
1422
|
+
lostReason: reason,
|
|
1423
|
+
},
|
|
1424
|
+
}));
|
|
1425
|
+
if (txs.length > 0) {
|
|
1426
|
+
await this.adminDb.transact(txs);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
catch {
|
|
1430
|
+
// Best-effort cleanup; stopping the sandbox should not fail because process metadata could not be marked.
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
async createProcessStream(params) {
|
|
1434
|
+
const streams = this.adminDb?.streams;
|
|
1435
|
+
if (!streams?.createWriteStream) {
|
|
1436
|
+
throw new Error("sandbox_process_streams_unavailable");
|
|
1437
|
+
}
|
|
1438
|
+
const streamClientId = params.streamClientId || createSandboxProcessStreamClientId(params.processId);
|
|
1439
|
+
const stream = streams.createWriteStream({ clientId: streamClientId });
|
|
1440
|
+
const streamId = typeof stream.streamId === "function" ? await stream.streamId() : streamClientId;
|
|
1441
|
+
return { stream, streamId, streamClientId };
|
|
1442
|
+
}
|
|
1443
|
+
async writeProcessChunk(params) {
|
|
1444
|
+
await params.writer.write(encodeSandboxProcessStreamChunk({
|
|
1445
|
+
version: SANDBOX_PROCESS_STREAM_VERSION,
|
|
1446
|
+
at: nowIso(),
|
|
1447
|
+
seq: params.seq,
|
|
1448
|
+
type: params.type,
|
|
1449
|
+
sandboxId: params.sandboxId,
|
|
1450
|
+
processId: params.processId,
|
|
1451
|
+
...(params.data ? { data: sanitizeInstantValue(params.data) } : {}),
|
|
1452
|
+
}));
|
|
1453
|
+
}
|
|
1454
|
+
async readProcessRow(processId) {
|
|
1455
|
+
const result = await this.adminDb.query({
|
|
1456
|
+
sandbox_processes: {
|
|
1457
|
+
$: { where: { id: processId }, limit: 1 },
|
|
1458
|
+
sandbox: {},
|
|
1459
|
+
},
|
|
1460
|
+
});
|
|
1461
|
+
return result?.sandbox_processes?.[0] ?? null;
|
|
1462
|
+
}
|
|
1463
|
+
async writeProcessChunkByProcessId(processId, type, data, opts) {
|
|
1464
|
+
const row = await this.readProcessRow(processId);
|
|
1465
|
+
if (!row)
|
|
1466
|
+
throw new Error("sandbox_process_not_found");
|
|
1467
|
+
const linkedSandbox = Array.isArray(row?.sandbox) ? row.sandbox[0] : row?.sandbox;
|
|
1468
|
+
const sandboxId = String(linkedSandbox?.id ?? row?.sandboxId ?? "").trim();
|
|
1469
|
+
if (!sandboxId)
|
|
1470
|
+
throw new Error("sandbox_process_sandbox_missing");
|
|
1471
|
+
const streamClientId = String(row.streamClientId ?? "").trim() || createSandboxProcessStreamClientId(processId);
|
|
1472
|
+
const streams = this.adminDb?.streams;
|
|
1473
|
+
if (!streams?.createWriteStream)
|
|
1474
|
+
throw new Error("sandbox_process_streams_unavailable");
|
|
1475
|
+
const stream = streams.createWriteStream({ clientId: streamClientId });
|
|
1476
|
+
const writer = stream.getWriter();
|
|
1477
|
+
try {
|
|
1478
|
+
const seq = Number(row.metadata?.lastSeq ?? row.metadata?.chunkCount ?? 0) + 1;
|
|
1479
|
+
await this.writeProcessChunk({
|
|
1480
|
+
writer,
|
|
1481
|
+
sandboxId,
|
|
1482
|
+
processId,
|
|
1483
|
+
seq,
|
|
1484
|
+
type,
|
|
1485
|
+
data,
|
|
1486
|
+
});
|
|
1487
|
+
if (opts?.close) {
|
|
1488
|
+
await writer.close();
|
|
1489
|
+
}
|
|
1490
|
+
await this.adminDb.transact([
|
|
1491
|
+
this.adminDb.tx.sandbox_processes[processId].update({
|
|
1492
|
+
updatedAt: Date.now(),
|
|
1493
|
+
metadata: sanitizeInstantValue({
|
|
1494
|
+
...(row.metadata ?? {}),
|
|
1495
|
+
lastSeq: seq,
|
|
1496
|
+
chunkCount: seq,
|
|
1497
|
+
}),
|
|
1498
|
+
}),
|
|
1499
|
+
]);
|
|
1500
|
+
}
|
|
1501
|
+
finally {
|
|
1502
|
+
try {
|
|
1503
|
+
writer.releaseLock();
|
|
1504
|
+
}
|
|
1505
|
+
catch {
|
|
1506
|
+
// ignore
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
async startObservedProcess(sandboxId, opts) {
|
|
1511
|
+
const processId = id();
|
|
1512
|
+
const now = Date.now();
|
|
1513
|
+
try {
|
|
1514
|
+
const record = await this.getSandboxRecord(sandboxId);
|
|
1515
|
+
if (!record)
|
|
1516
|
+
return { ok: false, error: "Valid sandbox record not found" };
|
|
1517
|
+
if (record.status !== "active")
|
|
1518
|
+
return { ok: false, error: `sandbox_not_active:${record.status}` };
|
|
1519
|
+
const streamSession = await this.createProcessStream({ sandboxId, processId });
|
|
1520
|
+
const stream = streamSession.stream;
|
|
1521
|
+
const writer = stream.getWriter();
|
|
1522
|
+
try {
|
|
1523
|
+
await this.adminDb.transact([
|
|
1524
|
+
this.adminDb.tx.sandbox_processes[processId]
|
|
1525
|
+
.update({
|
|
1526
|
+
kind: opts.kind ?? "command",
|
|
1527
|
+
mode: opts.mode ?? "foreground",
|
|
1528
|
+
status: "running",
|
|
1529
|
+
provider: String(record.provider ?? "unknown"),
|
|
1530
|
+
command: sanitizeInstantString(opts.command),
|
|
1531
|
+
args: sanitizeInstantValue(Array.isArray(opts.args) ? opts.args : []),
|
|
1532
|
+
cwd: asOptionalString(opts.cwd),
|
|
1533
|
+
env: sanitizeInstantValue(opts.env),
|
|
1534
|
+
externalProcessId: asOptionalString(opts.externalProcessId),
|
|
1535
|
+
streamId: streamSession.streamId,
|
|
1536
|
+
streamClientId: streamSession.streamClientId,
|
|
1537
|
+
streamStartedAt: now,
|
|
1538
|
+
startedAt: now,
|
|
1539
|
+
updatedAt: now,
|
|
1540
|
+
metadata: sanitizeInstantValue({
|
|
1541
|
+
...(opts.metadata ?? {}),
|
|
1542
|
+
observed: true,
|
|
1543
|
+
lastSeq: 1,
|
|
1544
|
+
chunkCount: 1,
|
|
1545
|
+
}),
|
|
1546
|
+
})
|
|
1547
|
+
.link({ sandbox: sandboxId, stream: streamSession.streamId }),
|
|
1548
|
+
]);
|
|
1549
|
+
await this.writeProcessChunk({
|
|
1550
|
+
writer,
|
|
1551
|
+
sandboxId,
|
|
1552
|
+
processId,
|
|
1553
|
+
seq: 1,
|
|
1554
|
+
type: "status",
|
|
1555
|
+
data: {
|
|
1556
|
+
status: "running",
|
|
1557
|
+
command: opts.command,
|
|
1558
|
+
args: Array.isArray(opts.args) ? opts.args : [],
|
|
1559
|
+
cwd: opts.cwd ?? null,
|
|
1560
|
+
externalProcessId: opts.externalProcessId ?? null,
|
|
1561
|
+
},
|
|
1562
|
+
});
|
|
1563
|
+
// Keep observed-process streams open across calls; finishObservedProcess closes them.
|
|
1564
|
+
}
|
|
1565
|
+
finally {
|
|
1566
|
+
try {
|
|
1567
|
+
writer.releaseLock();
|
|
1568
|
+
}
|
|
1569
|
+
catch {
|
|
1570
|
+
// ignore
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return {
|
|
1574
|
+
ok: true,
|
|
1575
|
+
data: {
|
|
1576
|
+
processId,
|
|
1577
|
+
streamId: streamSession.streamId,
|
|
1578
|
+
streamClientId: streamSession.streamClientId,
|
|
1579
|
+
},
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
catch (e) {
|
|
1583
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
async appendObservedProcessChunk(processId, type, data) {
|
|
1587
|
+
try {
|
|
1588
|
+
await this.writeProcessChunkByProcessId(processId, type, data);
|
|
1589
|
+
return { ok: true, data: undefined };
|
|
1590
|
+
}
|
|
1591
|
+
catch (e) {
|
|
1592
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
async finishObservedProcess(processId, opts) {
|
|
1596
|
+
try {
|
|
1597
|
+
const row = await this.readProcessRow(processId);
|
|
1598
|
+
if (!row)
|
|
1599
|
+
return { ok: false, error: "sandbox_process_not_found" };
|
|
1600
|
+
const exitCode = Number.isFinite(Number(opts?.exitCode)) ? Number(opts?.exitCode) : undefined;
|
|
1601
|
+
const status = opts?.status ?? (exitCode === undefined || exitCode === 0 ? "exited" : "failed");
|
|
1602
|
+
await this.writeProcessChunkByProcessId(processId, status === "failed" ? "error" : "exit", {
|
|
1603
|
+
exitCode: exitCode ?? null,
|
|
1604
|
+
status,
|
|
1605
|
+
...(opts?.errorText ? { message: opts.errorText } : {}),
|
|
1606
|
+
}, { close: true });
|
|
1607
|
+
const finishedAt = Date.now();
|
|
1608
|
+
await this.adminDb.transact([
|
|
1609
|
+
this.adminDb.tx.sandbox_processes[processId].update({
|
|
1610
|
+
status,
|
|
1611
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
1612
|
+
streamFinishedAt: finishedAt,
|
|
1613
|
+
streamAbortReason: opts?.errorText ?? null,
|
|
1614
|
+
exitedAt: finishedAt,
|
|
1615
|
+
updatedAt: finishedAt,
|
|
1616
|
+
metadata: sanitizeInstantValue({
|
|
1617
|
+
...(row.metadata ?? {}),
|
|
1618
|
+
...(opts?.metadata ?? {}),
|
|
1619
|
+
...(opts?.errorText ? { error: opts.errorText } : {}),
|
|
1620
|
+
}),
|
|
1621
|
+
}),
|
|
1622
|
+
]);
|
|
1623
|
+
return { ok: true, data: undefined };
|
|
1624
|
+
}
|
|
1625
|
+
catch (e) {
|
|
1626
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
687
1629
|
async stopSandbox(sandboxId) {
|
|
688
1630
|
try {
|
|
689
1631
|
const result = await this.reconnectToSandbox(sandboxId);
|
|
@@ -694,13 +1636,19 @@ export class SandboxService {
|
|
|
694
1636
|
const deleteOnStop = record?.provider === "sprites"
|
|
695
1637
|
? SandboxService.parseOptionalBoolean(process.env.SANDBOX_SPRITES_DELETE_ON_STOP) ??
|
|
696
1638
|
Boolean(record?.params?.sprites?.deleteOnStop ?? true)
|
|
697
|
-
:
|
|
698
|
-
|
|
1639
|
+
: record?.provider === "vercel"
|
|
1640
|
+
? SandboxService.parseOptionalBoolean(process.env.SANDBOX_VERCEL_DELETE_ON_STOP) ??
|
|
1641
|
+
Boolean(record?.params?.vercel?.deleteOnStop ?? !record?.params?.vercel?.persistent)
|
|
1642
|
+
: SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_DELETE_ON_STOP) ??
|
|
1643
|
+
Boolean(record?.params?.daytona?.ephemeral);
|
|
699
1644
|
if (result.ok) {
|
|
700
1645
|
try {
|
|
701
1646
|
const sandbox = result.data.sandbox;
|
|
702
|
-
if (sandbox
|
|
703
|
-
await sandbox.stop();
|
|
1647
|
+
if (isVercelSandbox(sandbox)) {
|
|
1648
|
+
await sandbox.stop({ blocking: true });
|
|
1649
|
+
if (deleteOnStop) {
|
|
1650
|
+
await sandbox.delete();
|
|
1651
|
+
}
|
|
704
1652
|
}
|
|
705
1653
|
else if (sandbox?.__provider === "sprites") {
|
|
706
1654
|
// Sprites does not have a reliable "stop" semantic; deleting is the durable cleanup primitive.
|
|
@@ -735,19 +1683,55 @@ export class SandboxService {
|
|
|
735
1683
|
shutdownAt: Date.now(),
|
|
736
1684
|
updatedAt: Date.now(),
|
|
737
1685
|
}));
|
|
1686
|
+
await this.markOpenProcessesLost(sandboxId, "sandbox_stopped");
|
|
738
1687
|
return { ok: true, data: undefined };
|
|
739
1688
|
}
|
|
740
1689
|
catch (e) {
|
|
741
1690
|
return { ok: false, error: formatInstantSchemaError(e) };
|
|
742
1691
|
}
|
|
743
1692
|
}
|
|
1693
|
+
async query(sandboxId, query) {
|
|
1694
|
+
try {
|
|
1695
|
+
const record = await this.getSandboxRecord(sandboxId);
|
|
1696
|
+
if (!record) {
|
|
1697
|
+
return { ok: false, error: "Valid sandbox record not found" };
|
|
1698
|
+
}
|
|
1699
|
+
if (record.provider !== "vercel") {
|
|
1700
|
+
return { ok: false, error: "sandbox_query_requires_vercel_provider" };
|
|
1701
|
+
}
|
|
1702
|
+
const queryScriptPath = String(record?.params?.ekairos?.bootstrap?.queryScriptPath ?? "").trim();
|
|
1703
|
+
if (!queryScriptPath) {
|
|
1704
|
+
return { ok: false, error: "sandbox_query_not_configured" };
|
|
1705
|
+
}
|
|
1706
|
+
const manifestPath = String(record?.params?.ekairos?.bootstrap?.manifestPath ?? "").trim() || EKAIROS_RUNTIME_MANIFEST_PATH;
|
|
1707
|
+
const encodedQuery = Buffer.from(JSON.stringify(query), "utf8").toString("base64url");
|
|
1708
|
+
const result = await this.runCommand(sandboxId, "node", [queryScriptPath, encodedQuery, manifestPath]);
|
|
1709
|
+
if (!result.ok) {
|
|
1710
|
+
return result;
|
|
1711
|
+
}
|
|
1712
|
+
const stdout = String(result.data.output ?? "").trim();
|
|
1713
|
+
if (!stdout) {
|
|
1714
|
+
return { ok: false, error: "sandbox_query_empty_response" };
|
|
1715
|
+
}
|
|
1716
|
+
try {
|
|
1717
|
+
return { ok: true, data: JSON.parse(stdout) };
|
|
1718
|
+
}
|
|
1719
|
+
catch (error) {
|
|
1720
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1721
|
+
return { ok: false, error: `sandbox_query_invalid_json: ${message}` };
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
catch (e) {
|
|
1725
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
744
1728
|
async runCommand(sandboxId, command, args = []) {
|
|
745
1729
|
try {
|
|
746
1730
|
const sandboxResult = await this.reconnectToSandbox(sandboxId);
|
|
747
1731
|
if (!sandboxResult.ok)
|
|
748
1732
|
return { ok: false, error: sandboxResult.error };
|
|
749
1733
|
const sandbox = sandboxResult.data.sandbox;
|
|
750
|
-
if (sandbox
|
|
1734
|
+
if (isVercelSandbox(sandbox)) {
|
|
751
1735
|
const result = await runCommandInSandbox(sandbox, command, args);
|
|
752
1736
|
return { ok: true, data: result };
|
|
753
1737
|
}
|
|
@@ -788,13 +1772,279 @@ export class SandboxService {
|
|
|
788
1772
|
return { ok: false, error: formatInstantSchemaError(e) };
|
|
789
1773
|
}
|
|
790
1774
|
}
|
|
1775
|
+
async runCommandProcess(sandboxId, command, args = [], opts) {
|
|
1776
|
+
const processId = id();
|
|
1777
|
+
const now = Date.now();
|
|
1778
|
+
let writer = null;
|
|
1779
|
+
let stream = null;
|
|
1780
|
+
let seq = 0;
|
|
1781
|
+
try {
|
|
1782
|
+
const record = await this.getSandboxRecord(sandboxId);
|
|
1783
|
+
if (!record)
|
|
1784
|
+
return { ok: false, error: "Valid sandbox record not found" };
|
|
1785
|
+
if (record.status !== "active")
|
|
1786
|
+
return { ok: false, error: `sandbox_not_active:${record.status}` };
|
|
1787
|
+
const streamSession = await this.createProcessStream({ sandboxId, processId });
|
|
1788
|
+
stream = streamSession.stream;
|
|
1789
|
+
writer = stream.getWriter();
|
|
1790
|
+
await this.adminDb.transact([
|
|
1791
|
+
this.adminDb.tx.sandbox_processes[processId]
|
|
1792
|
+
.update({
|
|
1793
|
+
kind: opts?.kind ?? "command",
|
|
1794
|
+
mode: opts?.mode ?? "foreground",
|
|
1795
|
+
status: "running",
|
|
1796
|
+
provider: String(record.provider ?? "unknown"),
|
|
1797
|
+
command: sanitizeInstantString(command),
|
|
1798
|
+
args: sanitizeInstantValue(Array.isArray(args) ? args : []),
|
|
1799
|
+
cwd: asOptionalString(opts?.cwd),
|
|
1800
|
+
env: sanitizeInstantValue(opts?.env),
|
|
1801
|
+
streamId: streamSession.streamId,
|
|
1802
|
+
streamClientId: streamSession.streamClientId,
|
|
1803
|
+
streamStartedAt: now,
|
|
1804
|
+
startedAt: now,
|
|
1805
|
+
updatedAt: now,
|
|
1806
|
+
metadata: sanitizeInstantValue(opts?.metadata),
|
|
1807
|
+
})
|
|
1808
|
+
.link({ sandbox: sandboxId, stream: streamSession.streamId }),
|
|
1809
|
+
]);
|
|
1810
|
+
seq += 1;
|
|
1811
|
+
await this.writeProcessChunk({
|
|
1812
|
+
writer,
|
|
1813
|
+
sandboxId,
|
|
1814
|
+
processId,
|
|
1815
|
+
seq,
|
|
1816
|
+
type: "status",
|
|
1817
|
+
data: {
|
|
1818
|
+
status: "running",
|
|
1819
|
+
command,
|
|
1820
|
+
args: Array.isArray(args) ? args : [],
|
|
1821
|
+
cwd: opts?.cwd ?? null,
|
|
1822
|
+
},
|
|
1823
|
+
});
|
|
1824
|
+
const result = await this.runCommand(sandboxId, command, args);
|
|
1825
|
+
const finishedAt = Date.now();
|
|
1826
|
+
let finalResult;
|
|
1827
|
+
let status;
|
|
1828
|
+
let exitCode;
|
|
1829
|
+
let errorText;
|
|
1830
|
+
if (result.ok) {
|
|
1831
|
+
finalResult = result.data;
|
|
1832
|
+
exitCode = Number(result.data.exitCode ?? (result.data.success === false ? 1 : 0));
|
|
1833
|
+
status = exitCode === 0 ? "exited" : "failed";
|
|
1834
|
+
const stdout = String(result.data.stdout ?? result.data.output ?? "");
|
|
1835
|
+
const stderr = String(result.data.stderr ?? result.data.error ?? "");
|
|
1836
|
+
if (stdout) {
|
|
1837
|
+
seq += 1;
|
|
1838
|
+
await this.writeProcessChunk({
|
|
1839
|
+
writer,
|
|
1840
|
+
sandboxId,
|
|
1841
|
+
processId,
|
|
1842
|
+
seq,
|
|
1843
|
+
type: "stdout",
|
|
1844
|
+
data: { text: stdout },
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
if (stderr) {
|
|
1848
|
+
seq += 1;
|
|
1849
|
+
await this.writeProcessChunk({
|
|
1850
|
+
writer,
|
|
1851
|
+
sandboxId,
|
|
1852
|
+
processId,
|
|
1853
|
+
seq,
|
|
1854
|
+
type: "stderr",
|
|
1855
|
+
data: { text: stderr },
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
else {
|
|
1860
|
+
exitCode = 1;
|
|
1861
|
+
status = "failed";
|
|
1862
|
+
errorText = result.error;
|
|
1863
|
+
finalResult = {
|
|
1864
|
+
success: false,
|
|
1865
|
+
exitCode,
|
|
1866
|
+
output: "",
|
|
1867
|
+
error: result.error,
|
|
1868
|
+
command: [command, ...(Array.isArray(args) ? args : [])].join(" "),
|
|
1869
|
+
};
|
|
1870
|
+
seq += 1;
|
|
1871
|
+
await this.writeProcessChunk({
|
|
1872
|
+
writer,
|
|
1873
|
+
sandboxId,
|
|
1874
|
+
processId,
|
|
1875
|
+
seq,
|
|
1876
|
+
type: "error",
|
|
1877
|
+
data: { message: result.error },
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
seq += 1;
|
|
1881
|
+
await this.writeProcessChunk({
|
|
1882
|
+
writer,
|
|
1883
|
+
sandboxId,
|
|
1884
|
+
processId,
|
|
1885
|
+
seq,
|
|
1886
|
+
type: "exit",
|
|
1887
|
+
data: { exitCode, status },
|
|
1888
|
+
});
|
|
1889
|
+
await writer.close();
|
|
1890
|
+
writer = null;
|
|
1891
|
+
await this.adminDb.transact([
|
|
1892
|
+
this.adminDb.tx.sandbox_processes[processId].update({
|
|
1893
|
+
status,
|
|
1894
|
+
exitCode,
|
|
1895
|
+
streamFinishedAt: finishedAt,
|
|
1896
|
+
streamAbortReason: null,
|
|
1897
|
+
exitedAt: finishedAt,
|
|
1898
|
+
updatedAt: finishedAt,
|
|
1899
|
+
metadata: sanitizeInstantValue({
|
|
1900
|
+
...(opts?.metadata ?? {}),
|
|
1901
|
+
...(errorText ? { error: errorText } : {}),
|
|
1902
|
+
chunkCount: seq,
|
|
1903
|
+
result: finalResult,
|
|
1904
|
+
}),
|
|
1905
|
+
}),
|
|
1906
|
+
]);
|
|
1907
|
+
await resumeSandboxProcessHook(processId, finalResult);
|
|
1908
|
+
return {
|
|
1909
|
+
ok: true,
|
|
1910
|
+
data: new SandboxCommandRun({
|
|
1911
|
+
sandboxId,
|
|
1912
|
+
processId,
|
|
1913
|
+
streamId: streamSession.streamId,
|
|
1914
|
+
streamClientId: streamSession.streamClientId,
|
|
1915
|
+
result: finalResult,
|
|
1916
|
+
}, this),
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
catch (e) {
|
|
1920
|
+
const message = formatInstantSchemaError(e);
|
|
1921
|
+
const failedAt = Date.now();
|
|
1922
|
+
try {
|
|
1923
|
+
if (writer) {
|
|
1924
|
+
seq += 1;
|
|
1925
|
+
await this.writeProcessChunk({
|
|
1926
|
+
writer,
|
|
1927
|
+
sandboxId,
|
|
1928
|
+
processId,
|
|
1929
|
+
seq,
|
|
1930
|
+
type: "error",
|
|
1931
|
+
data: { message },
|
|
1932
|
+
});
|
|
1933
|
+
await writer.abort(message);
|
|
1934
|
+
writer = null;
|
|
1935
|
+
}
|
|
1936
|
+
else if (stream) {
|
|
1937
|
+
await stream.abort(message);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
catch {
|
|
1941
|
+
// ignore stream cleanup errors
|
|
1942
|
+
}
|
|
1943
|
+
try {
|
|
1944
|
+
const finalResult = {
|
|
1945
|
+
success: false,
|
|
1946
|
+
exitCode: 1,
|
|
1947
|
+
output: "",
|
|
1948
|
+
error: message,
|
|
1949
|
+
command: [command, ...(Array.isArray(args) ? args : [])].join(" "),
|
|
1950
|
+
};
|
|
1951
|
+
await this.adminDb.transact([
|
|
1952
|
+
this.adminDb.tx.sandbox_processes[processId].update({
|
|
1953
|
+
status: "failed",
|
|
1954
|
+
streamFinishedAt: failedAt,
|
|
1955
|
+
streamAbortReason: message,
|
|
1956
|
+
exitedAt: failedAt,
|
|
1957
|
+
updatedAt: failedAt,
|
|
1958
|
+
metadata: sanitizeInstantValue({
|
|
1959
|
+
...(opts?.metadata ?? {}),
|
|
1960
|
+
error: message,
|
|
1961
|
+
result: finalResult,
|
|
1962
|
+
}),
|
|
1963
|
+
}),
|
|
1964
|
+
]);
|
|
1965
|
+
await resumeSandboxProcessHook(processId, finalResult);
|
|
1966
|
+
}
|
|
1967
|
+
catch {
|
|
1968
|
+
// ignore partial metadata failures
|
|
1969
|
+
}
|
|
1970
|
+
return { ok: false, error: message };
|
|
1971
|
+
}
|
|
1972
|
+
finally {
|
|
1973
|
+
try {
|
|
1974
|
+
writer?.releaseLock();
|
|
1975
|
+
}
|
|
1976
|
+
catch {
|
|
1977
|
+
// ignore
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
async runCommandWithProcessStream(sandboxId, command, args = [], opts) {
|
|
1982
|
+
const run = await this.runCommandProcess(sandboxId, command, args, opts);
|
|
1983
|
+
if (!run.ok)
|
|
1984
|
+
return run;
|
|
1985
|
+
const result = await run.data;
|
|
1986
|
+
return {
|
|
1987
|
+
ok: true,
|
|
1988
|
+
data: {
|
|
1989
|
+
processId: run.data.processId,
|
|
1990
|
+
streamId: run.data.streamId,
|
|
1991
|
+
streamClientId: run.data.streamClientId,
|
|
1992
|
+
result,
|
|
1993
|
+
},
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
async readProcessStream(processId) {
|
|
1997
|
+
try {
|
|
1998
|
+
const processResult = await this.adminDb.query({
|
|
1999
|
+
sandbox_processes: {
|
|
2000
|
+
$: { where: { id: processId }, limit: 1 },
|
|
2001
|
+
},
|
|
2002
|
+
});
|
|
2003
|
+
const processRow = processResult?.sandbox_processes?.[0];
|
|
2004
|
+
if (!processRow)
|
|
2005
|
+
return { ok: false, error: "sandbox_process_not_found" };
|
|
2006
|
+
const streams = this.adminDb?.streams;
|
|
2007
|
+
if (!streams?.createReadStream)
|
|
2008
|
+
return { ok: false, error: "sandbox_process_streams_unavailable" };
|
|
2009
|
+
const clientId = String(processRow.streamClientId ?? "").trim() || undefined;
|
|
2010
|
+
const streamId = String(processRow.streamId ?? "").trim() || undefined;
|
|
2011
|
+
if (!clientId && !streamId)
|
|
2012
|
+
return { ok: false, error: "sandbox_process_stream_missing" };
|
|
2013
|
+
const stream = streams.createReadStream({ clientId, streamId });
|
|
2014
|
+
const chunks = [];
|
|
2015
|
+
let byteOffset = 0;
|
|
2016
|
+
let buffer = "";
|
|
2017
|
+
for await (const raw of stream) {
|
|
2018
|
+
const encoded = typeof raw === "string" ? raw : String(raw ?? "");
|
|
2019
|
+
if (!encoded)
|
|
2020
|
+
continue;
|
|
2021
|
+
byteOffset += new TextEncoder().encode(encoded).length;
|
|
2022
|
+
buffer += encoded;
|
|
2023
|
+
const lines = buffer.split("\n");
|
|
2024
|
+
buffer = lines.pop() ?? "";
|
|
2025
|
+
for (const line of lines) {
|
|
2026
|
+
const trimmed = line.trim();
|
|
2027
|
+
if (!trimmed)
|
|
2028
|
+
continue;
|
|
2029
|
+
chunks.push(parseSandboxProcessStreamChunk(trimmed));
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
const trailing = buffer.trim();
|
|
2033
|
+
if (trailing)
|
|
2034
|
+
chunks.push(parseSandboxProcessStreamChunk(trailing));
|
|
2035
|
+
return { ok: true, data: { chunks, byteOffset } };
|
|
2036
|
+
}
|
|
2037
|
+
catch (e) {
|
|
2038
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
791
2041
|
async writeFiles(sandboxId, files) {
|
|
792
2042
|
try {
|
|
793
2043
|
const sandboxResult = await this.reconnectToSandbox(sandboxId);
|
|
794
2044
|
if (!sandboxResult.ok)
|
|
795
2045
|
return { ok: false, error: sandboxResult.error };
|
|
796
2046
|
const sandbox = sandboxResult.data.sandbox;
|
|
797
|
-
if (sandbox
|
|
2047
|
+
if (isVercelSandbox(sandbox)) {
|
|
798
2048
|
await sandbox.writeFiles(files.map((f) => ({
|
|
799
2049
|
path: f.path,
|
|
800
2050
|
content: Buffer.from(f.contentBase64, "base64"),
|
|
@@ -836,7 +2086,7 @@ export class SandboxService {
|
|
|
836
2086
|
if (!sandboxResult.ok)
|
|
837
2087
|
return { ok: false, error: sandboxResult.error };
|
|
838
2088
|
const sandbox = sandboxResult.data.sandbox;
|
|
839
|
-
if (sandbox
|
|
2089
|
+
if (isVercelSandbox(sandbox)) {
|
|
840
2090
|
const stream = await sandbox.readFile({ path });
|
|
841
2091
|
if (!stream) {
|
|
842
2092
|
return { ok: true, data: { contentBase64: "" } };
|
|
@@ -878,6 +2128,38 @@ export class SandboxService {
|
|
|
878
2128
|
return { ok: false, error: formatInstantSchemaError(e) };
|
|
879
2129
|
}
|
|
880
2130
|
}
|
|
2131
|
+
async getPortUrl(sandboxId, port) {
|
|
2132
|
+
try {
|
|
2133
|
+
const sandboxResult = await this.reconnectToSandbox(sandboxId);
|
|
2134
|
+
if (!sandboxResult.ok)
|
|
2135
|
+
return { ok: false, error: sandboxResult.error };
|
|
2136
|
+
const sandbox = sandboxResult.data.sandbox;
|
|
2137
|
+
const normalizedPort = Math.max(1, Math.floor(Number(port)));
|
|
2138
|
+
if (isVercelSandbox(sandbox)) {
|
|
2139
|
+
const url = sandbox.domain(normalizedPort);
|
|
2140
|
+
return { ok: true, data: { url: String(url ?? "").replace(/\/+$/, "") } };
|
|
2141
|
+
}
|
|
2142
|
+
if (sandbox.__provider === "sprites") {
|
|
2143
|
+
const base = String(sandbox.url ?? "").trim().replace(/\/+$/, "");
|
|
2144
|
+
if (!base)
|
|
2145
|
+
return { ok: false, error: "sprites_url_missing" };
|
|
2146
|
+
if (normalizedPort === 8080)
|
|
2147
|
+
return { ok: true, data: { url: base } };
|
|
2148
|
+
try {
|
|
2149
|
+
const u = new URL(base);
|
|
2150
|
+
u.port = String(normalizedPort);
|
|
2151
|
+
return { ok: true, data: { url: u.toString().replace(/\/+$/, "") } };
|
|
2152
|
+
}
|
|
2153
|
+
catch {
|
|
2154
|
+
return { ok: true, data: { url: `${base}:${normalizedPort}` } };
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
return { ok: false, error: "sandbox_port_url_not_supported" };
|
|
2158
|
+
}
|
|
2159
|
+
catch (e) {
|
|
2160
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
881
2163
|
static parseSpritesCheckpointIdFromNdjson(text) {
|
|
882
2164
|
const lines = String(text ?? "")
|
|
883
2165
|
.split("\n")
|
|
@@ -909,6 +2191,33 @@ export class SandboxService {
|
|
|
909
2191
|
sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 } },
|
|
910
2192
|
});
|
|
911
2193
|
const record = recordResult?.sandbox_sandboxes?.[0];
|
|
2194
|
+
if (record?.externalSandboxId && record.provider === "vercel") {
|
|
2195
|
+
const sandboxResult = await this.reconnectToSandbox(sandboxId);
|
|
2196
|
+
if (!sandboxResult.ok)
|
|
2197
|
+
return { ok: false, error: sandboxResult.error };
|
|
2198
|
+
const sandbox = sandboxResult.data.sandbox;
|
|
2199
|
+
if (!isVercelSandbox(sandbox))
|
|
2200
|
+
return { ok: false, error: "checkpoint_not_supported" };
|
|
2201
|
+
const expiration = Number(record?.params?.vercel?.snapshotExpirationMs);
|
|
2202
|
+
const snapshot = await sandbox.snapshot({
|
|
2203
|
+
...(Number.isFinite(expiration) ? { expiration } : {}),
|
|
2204
|
+
});
|
|
2205
|
+
const checkpointId = String(snapshot?.snapshotId ?? "").trim();
|
|
2206
|
+
if (!checkpointId)
|
|
2207
|
+
return { ok: false, error: "vercel_snapshot_id_missing" };
|
|
2208
|
+
await this.adminDb.transact(this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
|
|
2209
|
+
updatedAt: Date.now(),
|
|
2210
|
+
params: {
|
|
2211
|
+
...(record.params ?? {}),
|
|
2212
|
+
vercel: {
|
|
2213
|
+
...(record.params?.vercel ?? {}),
|
|
2214
|
+
lastCheckpointId: checkpointId,
|
|
2215
|
+
lastCheckpointComment: String(params?.comment ?? "").trim() || undefined,
|
|
2216
|
+
},
|
|
2217
|
+
},
|
|
2218
|
+
}));
|
|
2219
|
+
return { ok: true, data: { checkpointId } };
|
|
2220
|
+
}
|
|
912
2221
|
if (!record?.externalSandboxId || record.provider !== "sprites") {
|
|
913
2222
|
return { ok: false, error: "checkpoint_not_supported" };
|
|
914
2223
|
}
|
|
@@ -950,6 +2259,21 @@ export class SandboxService {
|
|
|
950
2259
|
sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 } },
|
|
951
2260
|
});
|
|
952
2261
|
const record = recordResult?.sandbox_sandboxes?.[0];
|
|
2262
|
+
if (record?.externalSandboxId && record.provider === "vercel") {
|
|
2263
|
+
const creds = await SandboxService.resolveVercelCredentials(record?.params ?? {});
|
|
2264
|
+
const listed = await VercelSnapshot.list({
|
|
2265
|
+
teamId: creds.teamId,
|
|
2266
|
+
projectId: creds.projectId,
|
|
2267
|
+
token: creds.token,
|
|
2268
|
+
name: String(record.externalSandboxId),
|
|
2269
|
+
limit: 50,
|
|
2270
|
+
sortOrder: "desc",
|
|
2271
|
+
});
|
|
2272
|
+
const checkpointIds = (listed.snapshots ?? [])
|
|
2273
|
+
.map((snapshot) => String(snapshot?.id ?? "").trim())
|
|
2274
|
+
.filter(Boolean);
|
|
2275
|
+
return { ok: true, data: { checkpointIds } };
|
|
2276
|
+
}
|
|
953
2277
|
if (!record?.externalSandboxId || record.provider !== "sprites") {
|
|
954
2278
|
return { ok: false, error: "checkpoint_not_supported" };
|
|
955
2279
|
}
|