@ekairos/sandbox 1.22.34-beta.development.0 → 1.22.35

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.
Files changed (70) hide show
  1. package/README.md +59 -452
  2. package/dist/action-steps.d.ts +156 -0
  3. package/dist/action-steps.d.ts.map +1 -0
  4. package/dist/action-steps.js +153 -0
  5. package/dist/action-steps.js.map +1 -0
  6. package/dist/actions.d.ts +263 -0
  7. package/dist/actions.d.ts.map +1 -0
  8. package/dist/actions.js +208 -0
  9. package/dist/actions.js.map +1 -0
  10. package/dist/app.js +1 -1
  11. package/dist/app.js.map +1 -1
  12. package/dist/contract.d.ts +86 -0
  13. package/dist/contract.d.ts.map +1 -0
  14. package/dist/contract.js +83 -0
  15. package/dist/contract.js.map +1 -0
  16. package/dist/domain.d.ts +2 -0
  17. package/dist/domain.d.ts.map +1 -0
  18. package/dist/domain.js +2 -0
  19. package/dist/domain.js.map +1 -0
  20. package/dist/index.d.ts +11 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/providers/daytona.d.ts +14 -0
  25. package/dist/providers/daytona.d.ts.map +1 -0
  26. package/dist/providers/daytona.js +153 -0
  27. package/dist/providers/daytona.js.map +1 -0
  28. package/dist/providers/provider.d.ts +3 -0
  29. package/dist/providers/provider.d.ts.map +1 -0
  30. package/dist/providers/provider.js +18 -0
  31. package/dist/providers/provider.js.map +1 -0
  32. package/dist/providers/sprites.d.ts +39 -0
  33. package/dist/providers/sprites.d.ts.map +1 -0
  34. package/dist/providers/sprites.js +234 -0
  35. package/dist/providers/sprites.js.map +1 -0
  36. package/dist/providers/types.d.ts +15 -0
  37. package/dist/providers/types.d.ts.map +1 -0
  38. package/dist/providers/types.js +9 -0
  39. package/dist/providers/types.js.map +1 -0
  40. package/dist/providers/vercel.d.ts +26 -0
  41. package/dist/providers/vercel.d.ts.map +1 -0
  42. package/dist/providers/vercel.js +182 -0
  43. package/dist/providers/vercel.js.map +1 -0
  44. package/dist/public.d.ts +56 -0
  45. package/dist/public.d.ts.map +1 -0
  46. package/dist/public.js +37 -0
  47. package/dist/public.js.map +1 -0
  48. package/dist/runtime.d.ts +4 -0
  49. package/dist/runtime.d.ts.map +1 -1
  50. package/dist/runtime.js +7 -1
  51. package/dist/runtime.js.map +1 -1
  52. package/dist/sandbox.d.ts +76 -0
  53. package/dist/sandbox.d.ts.map +1 -0
  54. package/dist/sandbox.js +154 -0
  55. package/dist/sandbox.js.map +1 -0
  56. package/dist/schema.d.ts +18 -2
  57. package/dist/schema.d.ts.map +1 -1
  58. package/dist/schema.js +43 -15
  59. package/dist/schema.js.map +1 -1
  60. package/dist/service.d.ts +98 -43
  61. package/dist/service.d.ts.map +1 -1
  62. package/dist/service.js +811 -543
  63. package/dist/service.js.map +1 -1
  64. package/dist/types.d.ts +33 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/vercel-options.d.ts +21 -0
  67. package/dist/vercel-options.d.ts.map +1 -0
  68. package/dist/vercel-options.js +149 -0
  69. package/dist/vercel-options.js.map +1 -0
  70. package/package.json +43 -7
package/dist/service.js CHANGED
@@ -1,15 +1,16 @@
1
- import { Sandbox as VercelSandbox } from "@vercel/sandbox";
2
- import { Daytona, Image } from "@daytonaio/sdk";
1
+ import { Sandbox as VercelSandbox, Snapshot as VercelSnapshot } from "@vercel/sandbox";
2
+ import { Daytona } from "@daytonaio/sdk";
3
3
  import { id } from "@instantdb/admin";
4
4
  import { resolveRuntime } from "@ekairos/domain/runtime";
5
5
  import { runCommandInSandbox } from "./commands.js";
6
- import { execFile } from "node:child_process";
6
+ import { buildDeclarativeImage, getDaytonaConfig, resolveDaytonaLanguage, resolveDaytonaVolumes, } from "./providers/daytona.js";
7
+ import { resolveProvider } from "./providers/provider.js";
8
+ import { asSpritesSandbox, getSpritesByName, parseSpritesCheckpointIdFromNdjson, provisionSpritesSandbox, spritesExec, spritesFetch, spritesJson, } from "./providers/sprites.js";
9
+ import { isVercelSandbox, } from "./providers/types.js";
10
+ import { provisionVercelSandbox, resolveVercelCredentials, } from "./providers/vercel.js";
11
+ import { resolveVercelSandboxConfig, safeVercelConfigForRecord, } from "./vercel-options.js";
7
12
  import { randomUUID } from "node:crypto";
8
- import { existsSync, promises as fs } from "node:fs";
9
- import os from "node:os";
10
13
  import path from "node:path";
11
- import { promisify } from "node:util";
12
- const execFileAsync = promisify(execFile);
13
14
  const EKAIROS_ROOT_DIR = "/vercel/sandbox/.ekairos";
14
15
  const EKAIROS_RUNTIME_MANIFEST_PATH = `${EKAIROS_ROOT_DIR}/runtime.json`;
15
16
  const EKAIROS_HTTP_HELPER_PATH = `${EKAIROS_ROOT_DIR}/instant-http.mjs`;
@@ -17,6 +18,8 @@ const EKAIROS_QUERY_SCRIPT_PATH = `${EKAIROS_ROOT_DIR}/query.mjs`;
17
18
  const CODEX_HOME_DIR = "/vercel/sandbox/.codex";
18
19
  const CODEX_SKILLS_DIR = `${CODEX_HOME_DIR}/skills`;
19
20
  const INSTANT_API_BASE_URL = "https://api.instantdb.com";
21
+ const SANDBOX_PROCESS_STREAM_VERSION = 1;
22
+ const SANDBOX_PROCESS_TERMINAL_STATUSES = new Set(["exited", "failed", "killed", "lost"]);
20
23
  function formatInstantSchemaError(err) {
21
24
  const base = err instanceof Error ? err.message : String(err);
22
25
  const body = err?.body;
@@ -49,19 +52,169 @@ function formatSandboxError(err) {
49
52
  return base;
50
53
  return `${base}: ${detail}`;
51
54
  }
55
+ function nowIso() {
56
+ return new Date().toISOString();
57
+ }
58
+ function asOptionalString(value) {
59
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
60
+ }
61
+ function sanitizeInstantString(value) {
62
+ return value.includes("\0") ? value.replace(/\0/g, "") : value;
63
+ }
64
+ function sanitizeInstantValue(value) {
65
+ if (typeof value === "string") {
66
+ return sanitizeInstantString(value);
67
+ }
68
+ if (Array.isArray(value)) {
69
+ return value.map((item) => sanitizeInstantValue(item));
70
+ }
71
+ if (value && typeof value === "object" && !(value instanceof Date)) {
72
+ const sanitized = {};
73
+ for (const [key, entry] of Object.entries(value)) {
74
+ sanitized[key] = sanitizeInstantValue(entry);
75
+ }
76
+ return sanitized;
77
+ }
78
+ return value;
79
+ }
80
+ function createSandboxProcessStreamClientId(processId) {
81
+ const normalized = String(processId ?? "").trim();
82
+ if (!normalized)
83
+ throw new Error("sandbox_process_id_required");
84
+ return `sandbox-process:${normalized}`;
85
+ }
86
+ function encodeSandboxProcessStreamChunk(chunk) {
87
+ return `${JSON.stringify(chunk)}\n`;
88
+ }
89
+ function parseSandboxProcessStreamChunk(value) {
90
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
91
+ if (!parsed || typeof parsed !== "object") {
92
+ throw new Error("invalid_sandbox_process_stream_chunk");
93
+ }
94
+ const record = parsed;
95
+ if (record.version !== SANDBOX_PROCESS_STREAM_VERSION) {
96
+ throw new Error(`invalid_sandbox_process_stream_version:${String(record.version)}`);
97
+ }
98
+ return record;
99
+ }
100
+ function sandboxProcessFinishedHookToken(processId) {
101
+ return `sandbox-process:${processId}:finished`;
102
+ }
103
+ async function resumeSandboxProcessHook(processId, payload) {
104
+ try {
105
+ const { resumeHook } = await import("workflow/api");
106
+ await resumeHook(sandboxProcessFinishedHookToken(processId), payload);
107
+ }
108
+ catch {
109
+ // No workflow may be listening; process metadata and streams remain the source of truth.
110
+ }
111
+ }
112
+ function commandResultFromProcessStream(params) {
113
+ const stdout = params.chunks
114
+ .filter((chunk) => chunk.type === "stdout")
115
+ .map((chunk) => String(chunk.data?.text ?? ""))
116
+ .join("");
117
+ const stderr = params.chunks
118
+ .filter((chunk) => chunk.type === "stderr" || chunk.type === "error")
119
+ .map((chunk) => String(chunk.data?.text ?? chunk.data?.message ?? ""))
120
+ .join("");
121
+ const exitChunk = [...params.chunks].reverse().find((chunk) => chunk.type === "exit");
122
+ const exitCode = Number(exitChunk?.data?.exitCode ?? params.processRow?.exitCode ?? 1);
123
+ const command = [
124
+ String(params.processRow?.command ?? ""),
125
+ ...(Array.isArray(params.processRow?.args) ? params.processRow.args : []),
126
+ ]
127
+ .filter(Boolean)
128
+ .join(" ");
129
+ return {
130
+ success: exitCode === 0,
131
+ exitCode,
132
+ output: stdout,
133
+ error: stderr,
134
+ command,
135
+ };
136
+ }
137
+ export class SandboxCommandRun {
138
+ constructor(data, service) {
139
+ this.service = null;
140
+ this.data = data;
141
+ this.service = service ?? null;
142
+ }
143
+ get sandboxId() {
144
+ return this.data.sandboxId;
145
+ }
146
+ get processId() {
147
+ return this.data.processId;
148
+ }
149
+ get streamId() {
150
+ return this.data.streamId;
151
+ }
152
+ get streamClientId() {
153
+ return this.data.streamClientId;
154
+ }
155
+ getService() {
156
+ if (!this.service) {
157
+ throw new Error("sandbox_command_run_service_required");
158
+ }
159
+ return this.service;
160
+ }
161
+ async readStream() {
162
+ const stream = await this.getService().readProcessStream(this.processId);
163
+ if (!stream.ok)
164
+ throw new Error(stream.error);
165
+ return stream.data;
166
+ }
167
+ async snapshot() {
168
+ const snapshot = await this.getService().getProcessSnapshot(this.processId);
169
+ if (!snapshot.ok)
170
+ throw new Error(snapshot.error);
171
+ return snapshot.data;
172
+ }
173
+ async wait(params) {
174
+ if (this.data.result)
175
+ return this.data.result;
176
+ const initial = await this.snapshot();
177
+ if (SANDBOX_PROCESS_TERMINAL_STATUSES.has(String(initial.status ?? ""))) {
178
+ const stream = await this.readStream();
179
+ const result = commandResultFromProcessStream({ processRow: initial, chunks: stream.chunks });
180
+ this.data.result = result;
181
+ return result;
182
+ }
183
+ try {
184
+ const { createHook } = await import("workflow");
185
+ const hook = createHook({
186
+ token: sandboxProcessFinishedHookToken(this.processId),
187
+ });
188
+ const result = await hook;
189
+ this.data.result = result;
190
+ return result;
191
+ }
192
+ catch {
193
+ // Outside workflow context, or if hooks are unavailable, poll the durable row.
194
+ }
195
+ const timeoutMs = Math.max(0, Number(params?.timeoutMs ?? 5 * 60 * 1000));
196
+ const pollMs = Math.max(50, Number(params?.pollMs ?? 500));
197
+ const deadline = Date.now() + timeoutMs;
198
+ while (Date.now() <= deadline) {
199
+ const row = await this.snapshot();
200
+ if (SANDBOX_PROCESS_TERMINAL_STATUSES.has(String(row.status ?? ""))) {
201
+ const stream = await this.readStream();
202
+ const result = commandResultFromProcessStream({ processRow: row, chunks: stream.chunks });
203
+ this.data.result = result;
204
+ return result;
205
+ }
206
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
207
+ }
208
+ throw new Error(`sandbox_process_wait_timeout:${this.processId}`);
209
+ }
210
+ then(onfulfilled, onrejected) {
211
+ return this.wait().then(onfulfilled, onrejected);
212
+ }
213
+ }
52
214
  export class SandboxService {
53
215
  constructor(db) {
54
216
  this.adminDb = db;
55
217
  }
56
- static getVercelCredentials() {
57
- const teamId = String(process.env.SANDBOX_VERCEL_TEAM_ID ?? "").trim();
58
- const projectId = String(process.env.SANDBOX_VERCEL_PROJECT_ID ?? "").trim();
59
- const token = String(process.env.SANDBOX_VERCEL_TOKEN ?? "").trim();
60
- if (!teamId || !projectId || !token) {
61
- throw new Error("Missing required Vercel sandbox environment variables");
62
- }
63
- return { teamId, projectId, token };
64
- }
65
218
  static getDomainName(domain) {
66
219
  const metaName = typeof domain?.meta?.name === "string" ? domain.meta.name.trim() : "";
67
220
  const contextName = typeof domain?.context === "function" ? String(domain.context()?.name ?? "").trim() : "";
@@ -105,7 +258,9 @@ export class SandboxService {
105
258
  }
106
259
  static buildEkairosManifest(params) {
107
260
  const contextString = SandboxService.getDomainContextString(params.domain);
108
- const schemaJson = SandboxService.cloneJson(params.domain.toInstantSchema());
261
+ const schemaJson = SandboxService.cloneJson(typeof params.domain.instantSchema === "function"
262
+ ? params.domain.instantSchema()
263
+ : params.domain.toInstantSchema());
109
264
  return {
110
265
  version: 1,
111
266
  instant: {
@@ -245,7 +400,7 @@ export class SandboxService {
245
400
  if (!config.env || !config.domain) {
246
401
  throw new Error("sandbox_runtime_requires_env_and_domain");
247
402
  }
248
- const provider = SandboxService.resolveProvider(config);
403
+ const provider = resolveProvider(config);
249
404
  if (provider !== "vercel") {
250
405
  throw new Error("ekairos_runtime_requires_vercel_provider");
251
406
  }
@@ -347,422 +502,6 @@ export class SandboxService {
347
502
  fileCount: skill.files.length,
348
503
  }));
349
504
  }
350
- static resolveVercelWorkingDirectory(config) {
351
- const fromConfig = String(config.vercel?.cwd ?? "").trim();
352
- if (fromConfig)
353
- return path.resolve(fromConfig);
354
- const fromEnv = String(process.env.SANDBOX_VERCEL_CWD ?? "").trim();
355
- if (fromEnv)
356
- return path.resolve(fromEnv);
357
- return process.cwd();
358
- }
359
- static findLinkedVercelProjectFile(startDir) {
360
- let current = path.resolve(startDir);
361
- while (true) {
362
- const candidate = path.join(current, ".vercel", "project.json");
363
- if (existsSync(candidate))
364
- return candidate;
365
- const parent = path.dirname(current);
366
- if (parent === current)
367
- return null;
368
- current = parent;
369
- }
370
- }
371
- static async readLinkedVercelProject(config) {
372
- const cwd = SandboxService.resolveVercelWorkingDirectory(config);
373
- const file = SandboxService.findLinkedVercelProjectFile(cwd);
374
- if (!file) {
375
- return { cwd };
376
- }
377
- try {
378
- const parsed = JSON.parse(await fs.readFile(file, "utf8"));
379
- return {
380
- cwd,
381
- orgId: typeof parsed?.orgId === "string" ? parsed.orgId : undefined,
382
- projectId: typeof parsed?.projectId === "string" ? parsed.projectId : undefined,
383
- projectName: typeof parsed?.projectName === "string" ? parsed.projectName : undefined,
384
- };
385
- }
386
- catch {
387
- return { cwd };
388
- }
389
- }
390
- static async pullVercelOidcToken(config) {
391
- const cwd = SandboxService.resolveVercelWorkingDirectory(config);
392
- const tmpPath = path.join(os.tmpdir(), `ekairos-vercel-env-${Date.now()}-${Math.random().toString(36).slice(2)}.env`);
393
- const args = ["env", "pull", tmpPath, "--yes", "--environment", String(config.vercel?.environment ?? "development")];
394
- const scope = String(config.vercel?.scope ?? process.env.SANDBOX_VERCEL_SCOPE ?? "").trim();
395
- if (scope) {
396
- args.push("--scope", scope);
397
- }
398
- const token = String(process.env.VERCEL_TOKEN ?? process.env.SANDBOX_VERCEL_TOKEN ?? "").trim();
399
- if (token) {
400
- args.push("--token", token);
401
- }
402
- const isWindows = process.platform === "win32";
403
- const command = isWindows ? (process.env.COMSPEC || "cmd.exe") : "vercel";
404
- const commandArgs = isWindows ? ["/c", "vercel", ...args] : args;
405
- try {
406
- await execFileAsync(command, commandArgs, {
407
- cwd,
408
- windowsHide: true,
409
- timeout: 120000,
410
- maxBuffer: 1024 * 1024 * 10,
411
- });
412
- const content = await fs.readFile(tmpPath, "utf8");
413
- const match = content.match(/VERCEL_OIDC_TOKEN=\"?([^\r\n\"]+)\"?/);
414
- const oidc = String(match?.[1] ?? "").trim();
415
- if (!oidc) {
416
- throw new Error("VERCEL_OIDC_TOKEN missing from vercel env pull output");
417
- }
418
- return oidc;
419
- }
420
- finally {
421
- await fs.rm(tmpPath, { force: true }).catch(() => { });
422
- }
423
- }
424
- static async resolveVercelCredentials(config) {
425
- const explicitTeamId = String(config.vercel?.orgId ?? process.env.SANDBOX_VERCEL_TEAM_ID ?? "").trim();
426
- const explicitProjectId = String(config.vercel?.projectId ?? process.env.SANDBOX_VERCEL_PROJECT_ID ?? "").trim();
427
- const explicitToken = String(config.vercel?.token ?? process.env.SANDBOX_VERCEL_TOKEN ?? process.env.VERCEL_OIDC_TOKEN ?? "").trim();
428
- if (explicitTeamId && explicitProjectId && explicitToken) {
429
- return { teamId: explicitTeamId, projectId: explicitProjectId, token: explicitToken };
430
- }
431
- const linked = await SandboxService.readLinkedVercelProject(config);
432
- const teamId = explicitTeamId || String(linked.orgId ?? "").trim();
433
- const projectId = explicitProjectId || String(linked.projectId ?? "").trim();
434
- let token = explicitToken;
435
- if (!token) {
436
- token = await SandboxService.pullVercelOidcToken(config);
437
- }
438
- if (!teamId || !projectId || !token) {
439
- 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.");
440
- }
441
- return { teamId, projectId, token };
442
- }
443
- static async provisionVercelSandbox(config, extra) {
444
- const creds = await SandboxService.resolveVercelCredentials(config);
445
- return await VercelSandbox.create({
446
- teamId: creds.teamId,
447
- projectId: creds.projectId,
448
- token: creds.token,
449
- timeout: config.timeoutMs ?? 30 * 60 * 1000,
450
- ports: Array.isArray(config.ports) ? config.ports : [],
451
- // IMPORTANT: pass runtime as-is (e.g. "python3.13") to match provider expectations.
452
- // Don't normalize to "python3"/"node22" as that can cause provider-side 400s.
453
- runtime: (config.runtime ?? "node22"),
454
- resources: { vcpus: config.resources?.vcpus ?? 2 },
455
- networkPolicy: extra?.networkPolicy,
456
- env: extra?.env,
457
- });
458
- }
459
- static getDaytonaConfig() {
460
- const apiKey = String(process.env.DAYTONA_API_KEY ?? "").trim();
461
- const apiUrl = String(process.env.DAYTONA_API_URL ?? "").trim() ||
462
- String(process.env.DAYTONA_SERVER_URL ?? "").trim();
463
- const jwtToken = String(process.env.DAYTONA_JWT_TOKEN ?? "").trim();
464
- const organizationId = String(process.env.DAYTONA_ORGANIZATION_ID ?? "").trim();
465
- const target = String(process.env.DAYTONA_TARGET ?? "").trim();
466
- if (!apiUrl) {
467
- throw new Error("Missing required Daytona env var: DAYTONA_API_URL (or DAYTONA_SERVER_URL)");
468
- }
469
- if (!apiKey && !(jwtToken && organizationId)) {
470
- throw new Error("Missing required Daytona env vars: DAYTONA_API_KEY or DAYTONA_JWT_TOKEN + DAYTONA_ORGANIZATION_ID");
471
- }
472
- const config = {
473
- apiUrl,
474
- target: target || undefined,
475
- apiKey: apiKey || undefined,
476
- jwtToken: jwtToken || undefined,
477
- organizationId: organizationId || undefined,
478
- };
479
- return config;
480
- }
481
- static normalizeBaseUrl(raw) {
482
- const trimmed = String(raw ?? "").trim();
483
- if (!trimmed)
484
- return "";
485
- return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
486
- }
487
- static getSpritesConfig() {
488
- const token = String(process.env.SPRITES_API_TOKEN ?? process.env.SPRITE_TOKEN ?? "").trim();
489
- if (!token) {
490
- throw new Error("Missing required Sprites token env var: SPRITES_API_TOKEN (or SPRITE_TOKEN)");
491
- }
492
- const baseUrl = SandboxService.normalizeBaseUrl(String(process.env.SPRITES_API_BASE_URL ?? process.env.SPRITES_API_URL ?? "").trim()) || "https://api.sprites.dev";
493
- return { baseUrl, token };
494
- }
495
- static async spritesFetch(path, init) {
496
- const { baseUrl, token } = SandboxService.getSpritesConfig();
497
- const fetchFn = globalThis?.fetch;
498
- if (typeof fetchFn !== "function") {
499
- throw new Error("fetch_not_available");
500
- }
501
- const normalizedPath = path.startsWith("/") ? path : `/${path}`;
502
- const url = `${baseUrl}${normalizedPath}`;
503
- return await fetchFn(url, {
504
- ...init,
505
- headers: {
506
- Authorization: `Bearer ${token}`,
507
- ...(init?.headers ?? {}),
508
- },
509
- });
510
- }
511
- static async spritesJson(path, init) {
512
- const res = await SandboxService.spritesFetch(path, {
513
- ...init,
514
- headers: {
515
- Accept: "application/json",
516
- ...(init?.headers ?? {}),
517
- },
518
- });
519
- if (!res?.ok) {
520
- const text = await res?.text?.().catch(() => "");
521
- throw new Error(`sprites_http_${res?.status ?? "unknown"}: ${text || "request_failed"}`);
522
- }
523
- return (await res.json().catch(() => ({})));
524
- }
525
- static async spritesText(path, init) {
526
- const res = await SandboxService.spritesFetch(path, init);
527
- const text = await res?.text?.().catch(() => "");
528
- return { ok: Boolean(res?.ok), status: Number(res?.status ?? 0), text: String(text ?? "") };
529
- }
530
- static toSpritesPreviewUrl(spriteUrl, port) {
531
- const base = String(spriteUrl ?? "").trim();
532
- if (!base)
533
- return "";
534
- try {
535
- const u = new URL(base);
536
- if (Number.isFinite(port) && port > 0) {
537
- u.port = String(Math.floor(port));
538
- }
539
- const next = u.toString();
540
- return next.endsWith("/") ? next.slice(0, -1) : next;
541
- }
542
- catch {
543
- // Best effort fallback: append port if missing.
544
- if (!port)
545
- return base;
546
- return base.replace(/\/+$/, "") + ":" + String(Math.floor(port));
547
- }
548
- }
549
- static asSpritesSandbox(sprite) {
550
- const name = String(sprite?.name ?? "").trim();
551
- const url = typeof sprite?.url === "string" ? sprite.url : undefined;
552
- return {
553
- __provider: "sprites",
554
- name,
555
- id: sprite?.id ? String(sprite.id) : undefined,
556
- url,
557
- getPreviewLink: async (port) => {
558
- const base = url ?? "";
559
- const next = SandboxService.toSpritesPreviewUrl(base, port);
560
- return { url: next };
561
- },
562
- domain: async (port) => {
563
- const base = url ?? "";
564
- return SandboxService.toSpritesPreviewUrl(base, port);
565
- },
566
- };
567
- }
568
- static async getSpritesByName(name) {
569
- const safeName = String(name ?? "").trim();
570
- if (!safeName)
571
- return { ok: false, status: 400, error: "sprites_name_required" };
572
- const res = await SandboxService.spritesFetch(`/v1/sprites/${encodeURIComponent(safeName)}`, {
573
- method: "GET",
574
- headers: { Accept: "application/json" },
575
- });
576
- if (!res?.ok) {
577
- const text = await res?.text?.().catch(() => "");
578
- return { ok: false, status: Number(res?.status ?? 0), error: text || `sprites_http_${res?.status ?? "unknown"}` };
579
- }
580
- const json = await res.json().catch(() => ({}));
581
- return { ok: true, sprite: json };
582
- }
583
- static async provisionSpritesSandbox(params) {
584
- const requestedName = String(params.config?.sprites?.name ?? "").trim();
585
- const name = requestedName || `ekairos-${params.sandboxId}`;
586
- // Idempotent: if already exists, reuse.
587
- const existing = await SandboxService.getSpritesByName(name);
588
- if (existing.ok) {
589
- const sprite = existing.sprite ?? {};
590
- return SandboxService.asSpritesSandbox({
591
- name: String(sprite?.name ?? name),
592
- id: sprite?.id ? String(sprite.id) : undefined,
593
- url: typeof sprite?.url === "string" ? sprite.url : undefined,
594
- });
595
- }
596
- const waitForCapacity = params.config?.sprites?.waitForCapacity ?? true;
597
- const auth = params.config?.sprites?.urlSettings?.auth ?? "public";
598
- const body = {
599
- name,
600
- wait_for_capacity: Boolean(waitForCapacity),
601
- url_settings: { auth },
602
- };
603
- const created = await SandboxService.spritesJson("/v1/sprites", {
604
- method: "POST",
605
- headers: { "Content-Type": "application/json" },
606
- body: JSON.stringify(body),
607
- });
608
- return SandboxService.asSpritesSandbox({
609
- name: String(created?.name ?? name),
610
- id: created?.id ? String(created.id) : undefined,
611
- url: typeof created?.url === "string" ? created.url : undefined,
612
- });
613
- }
614
- static normalizeSpritesExecResult(payload) {
615
- const exitCodeRaw = payload?.exit_code ??
616
- payload?.exitCode ??
617
- payload?.code ??
618
- payload?.status ??
619
- payload?.result?.exit_code ??
620
- payload?.result?.exitCode;
621
- const exitCode = Number(exitCodeRaw ?? 0);
622
- const stdout = typeof payload?.stdout === "string"
623
- ? payload.stdout
624
- : typeof payload?.output === "string"
625
- ? payload.output
626
- : typeof payload?.out === "string"
627
- ? payload.out
628
- : typeof payload?.result?.stdout === "string"
629
- ? payload.result.stdout
630
- : "";
631
- const stderr = typeof payload?.stderr === "string"
632
- ? payload.stderr
633
- : typeof payload?.error === "string"
634
- ? payload.error
635
- : typeof payload?.err === "string"
636
- ? payload.err
637
- : typeof payload?.result?.stderr === "string"
638
- ? payload.result.stderr
639
- : "";
640
- return {
641
- exitCode: Number.isFinite(exitCode) ? exitCode : 0,
642
- stdout,
643
- stderr,
644
- };
645
- }
646
- static async spritesExec(params) {
647
- const spriteName = String(params.spriteName ?? "").trim();
648
- if (!spriteName)
649
- throw new Error("sprites_name_required");
650
- const parts = [String(params.command ?? "").trim(), ...(Array.isArray(params.args) ? params.args : [])].filter(Boolean);
651
- if (parts.length === 0)
652
- throw new Error("sprites_command_required");
653
- const search = new URLSearchParams();
654
- for (const part of parts) {
655
- search.append("cmd", String(part));
656
- }
657
- const hasStdin = typeof params.stdin === "string" || Buffer.isBuffer(params.stdin);
658
- if (hasStdin) {
659
- search.set("stdin", "true");
660
- }
661
- const path = `/v1/sprites/${encodeURIComponent(spriteName)}/exec?${search.toString()}`;
662
- const init = {
663
- method: "POST",
664
- };
665
- if (hasStdin) {
666
- init.body = params.stdin;
667
- }
668
- const res = await SandboxService.spritesFetch(path, init);
669
- const text = await res?.text?.().catch(() => "");
670
- const parsed = (() => {
671
- try {
672
- return text ? JSON.parse(text) : {};
673
- }
674
- catch {
675
- return { stdout: String(text ?? "") };
676
- }
677
- })();
678
- if (!res?.ok) {
679
- const err = typeof parsed?.error === "string" ? parsed.error : text;
680
- throw new Error(err || `sprites_exec_http_${res?.status ?? "unknown"}`);
681
- }
682
- return SandboxService.normalizeSpritesExecResult(parsed);
683
- }
684
- static resolveProvider(config) {
685
- const explicit = String(config.provider ?? "").trim().toLowerCase();
686
- if (explicit === "daytona")
687
- return "daytona";
688
- if (explicit === "vercel")
689
- return "vercel";
690
- if (explicit === "sprites")
691
- return "sprites";
692
- const env = String(process.env.SANDBOX_PROVIDER ?? "").trim().toLowerCase();
693
- if (env === "daytona")
694
- return "daytona";
695
- if (env === "vercel")
696
- return "vercel";
697
- if (env === "sprites")
698
- return "sprites";
699
- return "sprites";
700
- }
701
- static resolveDaytonaLanguage(config) {
702
- if (config.daytona?.language)
703
- return config.daytona.language;
704
- const runtime = String(config.runtime ?? "").toLowerCase();
705
- if (runtime.startsWith("python"))
706
- return "python";
707
- if (runtime.startsWith("node"))
708
- return "javascript";
709
- if (runtime.startsWith("ts") || runtime.includes("typescript"))
710
- return "typescript";
711
- return undefined;
712
- }
713
- static async resolveDaytonaVolumes(daytona, volumes) {
714
- if (!volumes || volumes.length === 0)
715
- return [];
716
- const resolved = [];
717
- const shouldLog = SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_LOG_VOLUMES) ?? false;
718
- for (const volume of volumes) {
719
- const mountPath = String(volume.mountPath ?? "").trim();
720
- if (!mountPath)
721
- continue;
722
- const volumeId = String(volume.volumeId ?? "").trim();
723
- if (volumeId) {
724
- resolved.push({ volumeId, mountPath });
725
- continue;
726
- }
727
- const volumeName = String(volume.volumeName ?? "").trim();
728
- if (!volumeName) {
729
- throw new Error("Daytona volume requires volumeId or volumeName");
730
- }
731
- let resolvedVolume = await daytona.volume.get(volumeName, true);
732
- const stateRaw = String(resolvedVolume?.state ?? "").trim().toLowerCase();
733
- const waitStates = new Set(["creating", "provisioning", "pending", "pending_create", "pending-create", "initializing"]);
734
- const readyStates = new Set(["available", "active", "ready"]);
735
- if (waitStates.has(stateRaw)) {
736
- const maxAttempts = 12;
737
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
738
- await new Promise((r) => setTimeout(r, 1000 * attempt));
739
- resolvedVolume = await daytona.volume.get(volumeName, true);
740
- const state = String(resolvedVolume?.state ?? "").trim().toLowerCase();
741
- if (shouldLog) {
742
- console.log(`[daytona:volume] name=${volumeName} state=${state} attempt=${attempt}/${maxAttempts}`);
743
- }
744
- if (readyStates.has(state))
745
- break;
746
- }
747
- }
748
- const finalState = String(resolvedVolume?.state ?? "").trim().toLowerCase();
749
- if (finalState && !readyStates.has(finalState)) {
750
- if (shouldLog) {
751
- console.log(`[daytona:volume] name=${volumeName} state=${finalState} mountPath=${mountPath} (not ready)`);
752
- }
753
- throw new Error(`Daytona volume not ready: ${volumeName} (state=${finalState})`);
754
- }
755
- const resolvedId = String(resolvedVolume?.id ?? "").trim();
756
- if (!resolvedId) {
757
- throw new Error(`Daytona volume not resolved: ${volumeName}`);
758
- }
759
- if (shouldLog) {
760
- console.log(`[daytona:volume] name=${volumeName} id=${resolvedId} mountPath=${mountPath}`);
761
- }
762
- resolved.push({ volumeId: resolvedId, mountPath });
763
- }
764
- return resolved;
765
- }
766
505
  static shellEscapeArg(value) {
767
506
  if (value.length === 0)
768
507
  return "''";
@@ -780,58 +519,11 @@ export class SandboxService {
780
519
  return false;
781
520
  return undefined;
782
521
  }
783
- static parseCsvList(value) {
784
- return String(value ?? "")
785
- .split(",")
786
- .map((entry) => entry.trim())
787
- .filter(Boolean);
788
- }
789
- static resolvePythonVersion(runtime) {
790
- const fromEnv = String(process.env.SANDBOX_DAYTONA_DECLARATIVE_PYTHON ?? "").trim() ||
791
- String(process.env.STRUCTURE_DAYTONA_DECLARATIVE_PYTHON ?? "").trim();
792
- if (fromEnv)
793
- return fromEnv;
794
- const match = String(runtime ?? "").match(/python\s*([0-9]+\.[0-9]+)/i);
795
- if (match?.[1])
796
- return match[1];
797
- return "3.12";
798
- }
799
- static buildDeclarativeImage(config) {
800
- const imageFlag = String(config.daytona?.image ?? "").trim();
801
- const envFlag = SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_DECLARATIVE_IMAGE) ??
802
- SandboxService.parseOptionalBoolean(process.env.STRUCTURE_DAYTONA_DECLARATIVE_IMAGE) ??
803
- false;
804
- const useDeclarative = envFlag || imageFlag.startsWith("declarative");
805
- if (!useDeclarative)
806
- return undefined;
807
- const baseImage = String(process.env.SANDBOX_DAYTONA_DECLARATIVE_BASE ?? "").trim() ||
808
- String(process.env.STRUCTURE_DAYTONA_DECLARATIVE_BASE ?? "").trim();
809
- const pythonVersion = SandboxService.resolvePythonVersion(config.runtime);
810
- const isStructureDataset = config.purpose === "structure.dataset" || typeof config.params?.datasetId === "string";
811
- const defaultPackages = isStructureDataset ? ["pandas", "openpyxl"] : [];
812
- const packages = [
813
- ...SandboxService.parseCsvList(process.env.SANDBOX_DAYTONA_DECLARATIVE_PIP),
814
- ...SandboxService.parseCsvList(process.env.STRUCTURE_DAYTONA_DECLARATIVE_PIP),
815
- ...defaultPackages,
816
- ];
817
- const uniquePackages = Array.from(new Set(packages));
818
- let image;
819
- if (baseImage) {
820
- image = Image.base(baseImage);
821
- }
822
- else {
823
- image = Image.debianSlim(pythonVersion);
824
- }
825
- if (uniquePackages.length > 0) {
826
- image = image.pipInstall(uniquePackages);
827
- }
828
- image = image.workdir("/home/daytona");
829
- return image;
830
- }
831
522
  async createSandbox(config) {
832
523
  const sandboxId = id();
833
524
  const now = Date.now();
834
- const provider = SandboxService.resolveProvider(config);
525
+ const provider = resolveProvider(config);
526
+ const resolvedVercel = provider === "vercel" ? resolveVercelSandboxConfig(config, { sandboxId }) : undefined;
835
527
  let daytonaEphemeral = undefined;
836
528
  let installedSkills = [];
837
529
  try {
@@ -841,13 +533,14 @@ export class SandboxService {
841
533
  status: "creating",
842
534
  ...(ekairos ? { sandboxUserId: ekairos.sandboxUserId } : {}),
843
535
  provider,
844
- timeout: config.timeoutMs,
845
- runtime: config.runtime,
846
- vcpus: config.resources?.vcpus,
847
- ports: config.ports,
536
+ timeout: resolvedVercel?.timeoutMs ?? config.timeoutMs,
537
+ runtime: resolvedVercel?.runtime ?? config.runtime,
538
+ vcpus: resolvedVercel?.vcpus ?? config.resources?.vcpus,
539
+ ports: (resolvedVercel?.ports ?? config.ports),
848
540
  purpose: config.purpose,
849
541
  params: {
850
542
  ...baseParams,
543
+ ...(resolvedVercel ? { vercel: safeVercelConfigForRecord(config, resolvedVercel) } : {}),
851
544
  ...(ekairos
852
545
  ? {
853
546
  ekairos: {
@@ -881,10 +574,10 @@ export class SandboxService {
881
574
  let sandbox = null;
882
575
  try {
883
576
  if (provider === "daytona") {
884
- const daytona = new Daytona(SandboxService.getDaytonaConfig());
885
- const language = SandboxService.resolveDaytonaLanguage(config);
577
+ const daytona = new Daytona(getDaytonaConfig());
578
+ const language = resolveDaytonaLanguage(config);
886
579
  const requestedVolumes = config.daytona?.volumes ?? [];
887
- const volumes = await SandboxService.resolveDaytonaVolumes(daytona, requestedVolumes);
580
+ const volumes = await resolveDaytonaVolumes(daytona, requestedVolumes);
888
581
  const envVars = config.daytona?.envVars;
889
582
  const isPublic = config.daytona?.public;
890
583
  const envEphemeral = SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_EPHEMERAL);
@@ -895,7 +588,7 @@ export class SandboxService {
895
588
  const autoArchiveInterval = config.daytona?.autoArchiveIntervalMin;
896
589
  const autoDeleteInterval = config.daytona?.autoDeleteIntervalMin;
897
590
  const resolvedAutoDeleteInterval = ephemeral ? undefined : autoDeleteInterval;
898
- const declarativeImage = SandboxService.buildDeclarativeImage(config);
591
+ const declarativeImage = buildDeclarativeImage(config);
899
592
  const image = declarativeImage ?? config.daytona?.image;
900
593
  const snapshot = config.daytona?.snapshot;
901
594
  const resources = config.resources?.vcpus ? { cpu: config.resources.vcpus } : undefined;
@@ -934,7 +627,7 @@ export class SandboxService {
934
627
  }
935
628
  }
936
629
  else if (provider === "sprites") {
937
- sandbox = await SandboxService.provisionSpritesSandbox({
630
+ sandbox = await provisionSpritesSandbox({
938
631
  sandboxId,
939
632
  config,
940
633
  });
@@ -944,9 +637,10 @@ export class SandboxService {
944
637
  ...(Array.isArray(config.skills) && config.skills.length > 0 ? { CODEX_HOME: CODEX_HOME_DIR } : {}),
945
638
  ...(ekairos?.env ?? {}),
946
639
  };
947
- sandbox = await SandboxService.provisionVercelSandbox(config, {
640
+ sandbox = await provisionVercelSandbox(config, {
948
641
  networkPolicy: ekairos?.networkPolicy,
949
642
  env: Object.keys(vercelEnv).length > 0 ? vercelEnv : undefined,
643
+ resolved: resolvedVercel,
950
644
  });
951
645
  if (ekairos) {
952
646
  await SandboxService.bootstrapEkairosFiles(sandbox, ekairos.manifest);
@@ -960,7 +654,10 @@ export class SandboxService {
960
654
  const msg = formatSandboxError(e);
961
655
  if (sandbox && provider === "vercel") {
962
656
  try {
963
- await sandbox.stop();
657
+ await sandbox.stop({ blocking: true });
658
+ if (resolvedVercel?.deleteOnStop) {
659
+ await sandbox.delete();
660
+ }
964
661
  }
965
662
  catch {
966
663
  // ignore cleanup errors during failed bootstrap
@@ -977,7 +674,7 @@ export class SandboxService {
977
674
  ? sandbox.id
978
675
  : provider === "sprites"
979
676
  ? String(sandbox.name)
980
- : sandbox.sandboxId;
677
+ : sandbox.name;
981
678
  const sandboxUrl = provider === "sprites" ? sandbox.url : undefined;
982
679
  const activateMutations = [
983
680
  this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
@@ -1009,10 +706,7 @@ export class SandboxService {
1009
706
  : {}),
1010
707
  ...(provider === "vercel"
1011
708
  ? {
1012
- vercel: {
1013
- ...baseParams?.vercel,
1014
- ...(config.vercel ?? {}),
1015
- },
709
+ vercel: resolvedVercel ? safeVercelConfigForRecord(config, resolvedVercel) : {},
1016
710
  }
1017
711
  : {}),
1018
712
  ...(provider === "daytona"
@@ -1058,7 +752,7 @@ export class SandboxService {
1058
752
  return { ok: false, error: "Valid sandbox record not found" };
1059
753
  }
1060
754
  if (record.provider === "daytona") {
1061
- const daytona = new Daytona(SandboxService.getDaytonaConfig());
755
+ const daytona = new Daytona(getDaytonaConfig());
1062
756
  try {
1063
757
  const sandbox = await daytona.get(String(record.externalSandboxId));
1064
758
  const state = String(sandbox.state ?? "").toLowerCase();
@@ -1082,7 +776,7 @@ export class SandboxService {
1082
776
  if (record.provider === "sprites") {
1083
777
  const name = String(record.externalSandboxId ?? "").trim();
1084
778
  try {
1085
- const spriteRes = await SandboxService.getSpritesByName(name);
779
+ const spriteRes = await getSpritesByName(name);
1086
780
  if (!spriteRes.ok) {
1087
781
  if (record.status === "active") {
1088
782
  await this.adminDb.transact(this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
@@ -1094,7 +788,7 @@ export class SandboxService {
1094
788
  return { ok: false, error: spriteRes.error || "sprites_not_found" };
1095
789
  }
1096
790
  const sprite = spriteRes.sprite ?? {};
1097
- const spritesSandbox = SandboxService.asSpritesSandbox({
791
+ const spritesSandbox = asSpritesSandbox({
1098
792
  name: String(sprite?.name ?? name),
1099
793
  id: sprite?.id ? String(sprite.id) : undefined,
1100
794
  url: typeof sprite?.url === "string" ? sprite.url : undefined,
@@ -1138,16 +832,17 @@ export class SandboxService {
1138
832
  if (record.provider !== "vercel") {
1139
833
  return { ok: false, error: "Valid sandbox record not found" };
1140
834
  }
1141
- const creds = await SandboxService.resolveVercelCredentials(record?.params ?? {});
835
+ const creds = await resolveVercelCredentials(record?.params ?? {});
1142
836
  try {
1143
837
  const maxAttempts = 20;
1144
838
  const delayMs = 500;
1145
839
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
1146
840
  const sandbox = await VercelSandbox.get({
1147
- sandboxId: String(record.externalSandboxId),
841
+ name: String(record.externalSandboxId),
1148
842
  teamId: creds.teamId,
1149
843
  projectId: creds.projectId,
1150
844
  token: creds.token,
845
+ resume: true,
1151
846
  });
1152
847
  if (!sandbox)
1153
848
  return { ok: false, error: "Sandbox not found" };
@@ -1175,11 +870,262 @@ export class SandboxService {
1175
870
  }
1176
871
  }
1177
872
  async getSandboxRecord(sandboxId) {
1178
- const recordResult = await this.adminDb.query({
873
+ const query = {
1179
874
  sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 }, user: {} },
1180
- });
875
+ };
876
+ const recordResult = await this.adminDb.query(query);
1181
877
  return recordResult?.sandbox_sandboxes?.[0] ?? null;
1182
878
  }
879
+ async getProcessSnapshot(processId) {
880
+ try {
881
+ const query = {
882
+ sandbox_processes: {
883
+ $: { where: { id: processId }, limit: 1 },
884
+ sandbox: {},
885
+ },
886
+ };
887
+ const processResult = await this.adminDb.query(query);
888
+ const processRow = processResult?.sandbox_processes?.[0];
889
+ if (!processRow)
890
+ return { ok: false, error: "sandbox_process_not_found" };
891
+ return { ok: true, data: processRow };
892
+ }
893
+ catch (e) {
894
+ return { ok: false, error: formatInstantSchemaError(e) };
895
+ }
896
+ }
897
+ async markOpenProcessesLost(sandboxId, reason) {
898
+ try {
899
+ const processResult = await this.adminDb.query({
900
+ sandbox_processes: {
901
+ $: {
902
+ where: { "sandbox.id": sandboxId },
903
+ limit: 500,
904
+ },
905
+ },
906
+ });
907
+ const rows = Array.isArray(processResult?.sandbox_processes)
908
+ ? processResult.sandbox_processes
909
+ : [];
910
+ const now = Date.now();
911
+ const txs = rows
912
+ .filter((row) => !SANDBOX_PROCESS_TERMINAL_STATUSES.has(String(row?.status ?? "")))
913
+ .map((row) => this.adminDb.tx.sandbox_processes[String(row.id)].update({
914
+ status: "lost",
915
+ streamFinishedAt: row.streamFinishedAt ?? now,
916
+ streamAbortReason: reason,
917
+ exitedAt: now,
918
+ updatedAt: now,
919
+ metadata: {
920
+ ...(row.metadata ?? {}),
921
+ lostReason: reason,
922
+ },
923
+ }));
924
+ if (txs.length > 0) {
925
+ await this.adminDb.transact(txs);
926
+ }
927
+ }
928
+ catch {
929
+ // Best-effort cleanup; stopping the sandbox should not fail because process metadata could not be marked.
930
+ }
931
+ }
932
+ async createProcessStream(params) {
933
+ const streams = this.adminDb?.streams;
934
+ if (!streams?.createWriteStream) {
935
+ throw new Error("sandbox_process_streams_unavailable");
936
+ }
937
+ const streamClientId = params.streamClientId || createSandboxProcessStreamClientId(params.processId);
938
+ const stream = streams.createWriteStream({ clientId: streamClientId });
939
+ const streamId = typeof stream.streamId === "function" ? await stream.streamId() : streamClientId;
940
+ return { stream, streamId, streamClientId };
941
+ }
942
+ async writeProcessChunk(params) {
943
+ await params.writer.write(encodeSandboxProcessStreamChunk({
944
+ version: SANDBOX_PROCESS_STREAM_VERSION,
945
+ at: nowIso(),
946
+ seq: params.seq,
947
+ type: params.type,
948
+ sandboxId: params.sandboxId,
949
+ processId: params.processId,
950
+ ...(params.data ? { data: sanitizeInstantValue(params.data) } : {}),
951
+ }));
952
+ }
953
+ async readProcessRow(processId) {
954
+ const query = {
955
+ sandbox_processes: {
956
+ $: { where: { id: processId }, limit: 1 },
957
+ sandbox: {},
958
+ },
959
+ };
960
+ const result = await this.adminDb.query(query);
961
+ return result?.sandbox_processes?.[0] ?? null;
962
+ }
963
+ async writeProcessChunkByProcessId(processId, type, data, opts) {
964
+ const row = await this.readProcessRow(processId);
965
+ if (!row)
966
+ throw new Error("sandbox_process_not_found");
967
+ const linkedSandbox = Array.isArray(row?.sandbox) ? row.sandbox[0] : row?.sandbox;
968
+ const sandboxId = String(linkedSandbox?.id ?? row?.sandboxId ?? "").trim();
969
+ if (!sandboxId)
970
+ throw new Error("sandbox_process_sandbox_missing");
971
+ const streamClientId = String(row.streamClientId ?? "").trim() || createSandboxProcessStreamClientId(processId);
972
+ const streams = this.adminDb?.streams;
973
+ if (!streams?.createWriteStream)
974
+ throw new Error("sandbox_process_streams_unavailable");
975
+ const stream = streams.createWriteStream({ clientId: streamClientId });
976
+ const writer = stream.getWriter();
977
+ try {
978
+ const seq = Number(row.metadata?.lastSeq ?? row.metadata?.chunkCount ?? 0) + 1;
979
+ await this.writeProcessChunk({
980
+ writer,
981
+ sandboxId,
982
+ processId,
983
+ seq,
984
+ type,
985
+ data,
986
+ });
987
+ if (opts?.close) {
988
+ await writer.close();
989
+ }
990
+ await this.adminDb.transact([
991
+ this.adminDb.tx.sandbox_processes[processId].update({
992
+ updatedAt: Date.now(),
993
+ metadata: sanitizeInstantValue({
994
+ ...(row.metadata ?? {}),
995
+ lastSeq: seq,
996
+ chunkCount: seq,
997
+ }),
998
+ }),
999
+ ]);
1000
+ }
1001
+ finally {
1002
+ try {
1003
+ writer.releaseLock();
1004
+ }
1005
+ catch {
1006
+ // ignore
1007
+ }
1008
+ }
1009
+ }
1010
+ async startObservedProcess(sandboxId, opts) {
1011
+ const processId = id();
1012
+ const now = Date.now();
1013
+ try {
1014
+ const record = await this.getSandboxRecord(sandboxId);
1015
+ if (!record)
1016
+ return { ok: false, error: "Valid sandbox record not found" };
1017
+ if (record.status !== "active")
1018
+ return { ok: false, error: `sandbox_not_active:${record.status}` };
1019
+ const streamSession = await this.createProcessStream({ sandboxId, processId });
1020
+ const stream = streamSession.stream;
1021
+ const writer = stream.getWriter();
1022
+ try {
1023
+ await this.adminDb.transact([
1024
+ this.adminDb.tx.sandbox_processes[processId]
1025
+ .update({
1026
+ kind: opts.kind ?? "command",
1027
+ mode: opts.mode ?? "foreground",
1028
+ status: "running",
1029
+ provider: String(record.provider ?? "unknown"),
1030
+ command: sanitizeInstantString(opts.command),
1031
+ args: sanitizeInstantValue(Array.isArray(opts.args) ? opts.args : []),
1032
+ cwd: asOptionalString(opts.cwd),
1033
+ env: sanitizeInstantValue(opts.env),
1034
+ externalProcessId: asOptionalString(opts.externalProcessId),
1035
+ streamId: streamSession.streamId,
1036
+ streamClientId: streamSession.streamClientId,
1037
+ streamStartedAt: now,
1038
+ startedAt: now,
1039
+ updatedAt: now,
1040
+ metadata: sanitizeInstantValue({
1041
+ ...(opts.metadata ?? {}),
1042
+ observed: true,
1043
+ lastSeq: 1,
1044
+ chunkCount: 1,
1045
+ }),
1046
+ })
1047
+ .link({ sandbox: sandboxId, stream: streamSession.streamId }),
1048
+ ]);
1049
+ await this.writeProcessChunk({
1050
+ writer,
1051
+ sandboxId,
1052
+ processId,
1053
+ seq: 1,
1054
+ type: "status",
1055
+ data: {
1056
+ status: "running",
1057
+ command: opts.command,
1058
+ args: Array.isArray(opts.args) ? opts.args : [],
1059
+ cwd: opts.cwd ?? null,
1060
+ externalProcessId: opts.externalProcessId ?? null,
1061
+ },
1062
+ });
1063
+ // Keep observed-process streams open across calls; finishObservedProcess closes them.
1064
+ }
1065
+ finally {
1066
+ try {
1067
+ writer.releaseLock();
1068
+ }
1069
+ catch {
1070
+ // ignore
1071
+ }
1072
+ }
1073
+ return {
1074
+ ok: true,
1075
+ data: {
1076
+ processId,
1077
+ streamId: streamSession.streamId,
1078
+ streamClientId: streamSession.streamClientId,
1079
+ },
1080
+ };
1081
+ }
1082
+ catch (e) {
1083
+ return { ok: false, error: formatInstantSchemaError(e) };
1084
+ }
1085
+ }
1086
+ async appendObservedProcessChunk(processId, type, data) {
1087
+ try {
1088
+ await this.writeProcessChunkByProcessId(processId, type, data);
1089
+ return { ok: true, data: undefined };
1090
+ }
1091
+ catch (e) {
1092
+ return { ok: false, error: formatInstantSchemaError(e) };
1093
+ }
1094
+ }
1095
+ async finishObservedProcess(processId, opts) {
1096
+ try {
1097
+ const row = await this.readProcessRow(processId);
1098
+ if (!row)
1099
+ return { ok: false, error: "sandbox_process_not_found" };
1100
+ const exitCode = Number.isFinite(Number(opts?.exitCode)) ? Number(opts?.exitCode) : undefined;
1101
+ const status = opts?.status ?? (exitCode === undefined || exitCode === 0 ? "exited" : "failed");
1102
+ await this.writeProcessChunkByProcessId(processId, status === "failed" ? "error" : "exit", {
1103
+ exitCode: exitCode ?? null,
1104
+ status,
1105
+ ...(opts?.errorText ? { message: opts.errorText } : {}),
1106
+ }, { close: true });
1107
+ const finishedAt = Date.now();
1108
+ await this.adminDb.transact([
1109
+ this.adminDb.tx.sandbox_processes[processId].update({
1110
+ status,
1111
+ ...(exitCode !== undefined ? { exitCode } : {}),
1112
+ streamFinishedAt: finishedAt,
1113
+ streamAbortReason: opts?.errorText ?? null,
1114
+ exitedAt: finishedAt,
1115
+ updatedAt: finishedAt,
1116
+ metadata: sanitizeInstantValue({
1117
+ ...(row.metadata ?? {}),
1118
+ ...(opts?.metadata ?? {}),
1119
+ ...(opts?.errorText ? { error: opts.errorText } : {}),
1120
+ }),
1121
+ }),
1122
+ ]);
1123
+ return { ok: true, data: undefined };
1124
+ }
1125
+ catch (e) {
1126
+ return { ok: false, error: formatInstantSchemaError(e) };
1127
+ }
1128
+ }
1183
1129
  async stopSandbox(sandboxId) {
1184
1130
  try {
1185
1131
  const result = await this.reconnectToSandbox(sandboxId);
@@ -1190,18 +1136,24 @@ export class SandboxService {
1190
1136
  const deleteOnStop = record?.provider === "sprites"
1191
1137
  ? SandboxService.parseOptionalBoolean(process.env.SANDBOX_SPRITES_DELETE_ON_STOP) ??
1192
1138
  Boolean(record?.params?.sprites?.deleteOnStop ?? true)
1193
- : SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_DELETE_ON_STOP) ??
1194
- Boolean(record?.params?.daytona?.ephemeral);
1139
+ : record?.provider === "vercel"
1140
+ ? SandboxService.parseOptionalBoolean(process.env.SANDBOX_VERCEL_DELETE_ON_STOP) ??
1141
+ Boolean(record?.params?.vercel?.deleteOnStop ?? !record?.params?.vercel?.persistent)
1142
+ : SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_DELETE_ON_STOP) ??
1143
+ Boolean(record?.params?.daytona?.ephemeral);
1195
1144
  if (result.ok) {
1196
1145
  try {
1197
1146
  const sandbox = result.data.sandbox;
1198
- if (sandbox?.sandboxId) {
1199
- await sandbox.stop();
1147
+ if (isVercelSandbox(sandbox)) {
1148
+ await sandbox.stop({ blocking: true });
1149
+ if (deleteOnStop) {
1150
+ await sandbox.delete();
1151
+ }
1200
1152
  }
1201
1153
  else if (sandbox?.__provider === "sprites") {
1202
1154
  // Sprites does not have a reliable "stop" semantic; deleting is the durable cleanup primitive.
1203
1155
  try {
1204
- await SandboxService.spritesFetch(`/v1/sprites/${encodeURIComponent(String(sandbox.name))}`, {
1156
+ await spritesFetch(`/v1/sprites/${encodeURIComponent(String(sandbox.name))}`, {
1205
1157
  method: "DELETE",
1206
1158
  });
1207
1159
  }
@@ -1210,7 +1162,7 @@ export class SandboxService {
1210
1162
  }
1211
1163
  }
1212
1164
  else {
1213
- const daytona = new Daytona(SandboxService.getDaytonaConfig());
1165
+ const daytona = new Daytona(getDaytonaConfig());
1214
1166
  await daytona.stop(sandbox);
1215
1167
  if (deleteOnStop) {
1216
1168
  try {
@@ -1231,6 +1183,7 @@ export class SandboxService {
1231
1183
  shutdownAt: Date.now(),
1232
1184
  updatedAt: Date.now(),
1233
1185
  }));
1186
+ await this.markOpenProcessesLost(sandboxId, "sandbox_stopped");
1234
1187
  return { ok: true, data: undefined };
1235
1188
  }
1236
1189
  catch (e) {
@@ -1278,13 +1231,13 @@ export class SandboxService {
1278
1231
  if (!sandboxResult.ok)
1279
1232
  return { ok: false, error: sandboxResult.error };
1280
1233
  const sandbox = sandboxResult.data.sandbox;
1281
- if (sandbox.sandboxId) {
1234
+ if (isVercelSandbox(sandbox)) {
1282
1235
  const result = await runCommandInSandbox(sandbox, command, args);
1283
1236
  return { ok: true, data: result };
1284
1237
  }
1285
1238
  if (sandbox.__provider === "sprites") {
1286
1239
  const fullCommand = args.length > 0 ? [command, ...args].join(" ") : command;
1287
- const res = await SandboxService.spritesExec({
1240
+ const res = await spritesExec({
1288
1241
  spriteName: String(sandbox.name ?? ""),
1289
1242
  command,
1290
1243
  args,
@@ -1319,13 +1272,279 @@ export class SandboxService {
1319
1272
  return { ok: false, error: formatInstantSchemaError(e) };
1320
1273
  }
1321
1274
  }
1275
+ async runCommandProcess(sandboxId, command, args = [], opts) {
1276
+ const processId = id();
1277
+ const now = Date.now();
1278
+ let writer = null;
1279
+ let stream = null;
1280
+ let seq = 0;
1281
+ try {
1282
+ const record = await this.getSandboxRecord(sandboxId);
1283
+ if (!record)
1284
+ return { ok: false, error: "Valid sandbox record not found" };
1285
+ if (record.status !== "active")
1286
+ return { ok: false, error: `sandbox_not_active:${record.status}` };
1287
+ const streamSession = await this.createProcessStream({ sandboxId, processId });
1288
+ stream = streamSession.stream;
1289
+ writer = stream.getWriter();
1290
+ await this.adminDb.transact([
1291
+ this.adminDb.tx.sandbox_processes[processId]
1292
+ .update({
1293
+ kind: opts?.kind ?? "command",
1294
+ mode: opts?.mode ?? "foreground",
1295
+ status: "running",
1296
+ provider: String(record.provider ?? "unknown"),
1297
+ command: sanitizeInstantString(command),
1298
+ args: sanitizeInstantValue(Array.isArray(args) ? args : []),
1299
+ cwd: asOptionalString(opts?.cwd),
1300
+ env: sanitizeInstantValue(opts?.env),
1301
+ streamId: streamSession.streamId,
1302
+ streamClientId: streamSession.streamClientId,
1303
+ streamStartedAt: now,
1304
+ startedAt: now,
1305
+ updatedAt: now,
1306
+ metadata: sanitizeInstantValue(opts?.metadata),
1307
+ })
1308
+ .link({ sandbox: sandboxId, stream: streamSession.streamId }),
1309
+ ]);
1310
+ seq += 1;
1311
+ await this.writeProcessChunk({
1312
+ writer,
1313
+ sandboxId,
1314
+ processId,
1315
+ seq,
1316
+ type: "status",
1317
+ data: {
1318
+ status: "running",
1319
+ command,
1320
+ args: Array.isArray(args) ? args : [],
1321
+ cwd: opts?.cwd ?? null,
1322
+ },
1323
+ });
1324
+ const result = await this.runCommand(sandboxId, command, args);
1325
+ const finishedAt = Date.now();
1326
+ let finalResult;
1327
+ let status;
1328
+ let exitCode;
1329
+ let errorText;
1330
+ if (result.ok) {
1331
+ finalResult = result.data;
1332
+ exitCode = Number(result.data.exitCode ?? (result.data.success === false ? 1 : 0));
1333
+ status = exitCode === 0 ? "exited" : "failed";
1334
+ const stdout = String(result.data.stdout ?? result.data.output ?? "");
1335
+ const stderr = String(result.data.stderr ?? result.data.error ?? "");
1336
+ if (stdout) {
1337
+ seq += 1;
1338
+ await this.writeProcessChunk({
1339
+ writer,
1340
+ sandboxId,
1341
+ processId,
1342
+ seq,
1343
+ type: "stdout",
1344
+ data: { text: stdout },
1345
+ });
1346
+ }
1347
+ if (stderr) {
1348
+ seq += 1;
1349
+ await this.writeProcessChunk({
1350
+ writer,
1351
+ sandboxId,
1352
+ processId,
1353
+ seq,
1354
+ type: "stderr",
1355
+ data: { text: stderr },
1356
+ });
1357
+ }
1358
+ }
1359
+ else {
1360
+ exitCode = 1;
1361
+ status = "failed";
1362
+ errorText = result.error;
1363
+ finalResult = {
1364
+ success: false,
1365
+ exitCode,
1366
+ output: "",
1367
+ error: result.error,
1368
+ command: [command, ...(Array.isArray(args) ? args : [])].join(" "),
1369
+ };
1370
+ seq += 1;
1371
+ await this.writeProcessChunk({
1372
+ writer,
1373
+ sandboxId,
1374
+ processId,
1375
+ seq,
1376
+ type: "error",
1377
+ data: { message: result.error },
1378
+ });
1379
+ }
1380
+ seq += 1;
1381
+ await this.writeProcessChunk({
1382
+ writer,
1383
+ sandboxId,
1384
+ processId,
1385
+ seq,
1386
+ type: "exit",
1387
+ data: { exitCode, status },
1388
+ });
1389
+ await writer.close();
1390
+ writer = null;
1391
+ await this.adminDb.transact([
1392
+ this.adminDb.tx.sandbox_processes[processId].update({
1393
+ status,
1394
+ exitCode,
1395
+ streamFinishedAt: finishedAt,
1396
+ streamAbortReason: null,
1397
+ exitedAt: finishedAt,
1398
+ updatedAt: finishedAt,
1399
+ metadata: sanitizeInstantValue({
1400
+ ...(opts?.metadata ?? {}),
1401
+ ...(errorText ? { error: errorText } : {}),
1402
+ chunkCount: seq,
1403
+ result: finalResult,
1404
+ }),
1405
+ }),
1406
+ ]);
1407
+ await resumeSandboxProcessHook(processId, finalResult);
1408
+ return {
1409
+ ok: true,
1410
+ data: new SandboxCommandRun({
1411
+ sandboxId,
1412
+ processId,
1413
+ streamId: streamSession.streamId,
1414
+ streamClientId: streamSession.streamClientId,
1415
+ result: finalResult,
1416
+ }, this),
1417
+ };
1418
+ }
1419
+ catch (e) {
1420
+ const message = formatInstantSchemaError(e);
1421
+ const failedAt = Date.now();
1422
+ try {
1423
+ if (writer) {
1424
+ seq += 1;
1425
+ await this.writeProcessChunk({
1426
+ writer,
1427
+ sandboxId,
1428
+ processId,
1429
+ seq,
1430
+ type: "error",
1431
+ data: { message },
1432
+ });
1433
+ await writer.abort(message);
1434
+ writer = null;
1435
+ }
1436
+ else if (stream) {
1437
+ await stream.abort(message);
1438
+ }
1439
+ }
1440
+ catch {
1441
+ // ignore stream cleanup errors
1442
+ }
1443
+ try {
1444
+ const finalResult = {
1445
+ success: false,
1446
+ exitCode: 1,
1447
+ output: "",
1448
+ error: message,
1449
+ command: [command, ...(Array.isArray(args) ? args : [])].join(" "),
1450
+ };
1451
+ await this.adminDb.transact([
1452
+ this.adminDb.tx.sandbox_processes[processId].update({
1453
+ status: "failed",
1454
+ streamFinishedAt: failedAt,
1455
+ streamAbortReason: message,
1456
+ exitedAt: failedAt,
1457
+ updatedAt: failedAt,
1458
+ metadata: sanitizeInstantValue({
1459
+ ...(opts?.metadata ?? {}),
1460
+ error: message,
1461
+ result: finalResult,
1462
+ }),
1463
+ }),
1464
+ ]);
1465
+ await resumeSandboxProcessHook(processId, finalResult);
1466
+ }
1467
+ catch {
1468
+ // ignore partial metadata failures
1469
+ }
1470
+ return { ok: false, error: message };
1471
+ }
1472
+ finally {
1473
+ try {
1474
+ writer?.releaseLock();
1475
+ }
1476
+ catch {
1477
+ // ignore
1478
+ }
1479
+ }
1480
+ }
1481
+ async runCommandWithProcessStream(sandboxId, command, args = [], opts) {
1482
+ const run = await this.runCommandProcess(sandboxId, command, args, opts);
1483
+ if (!run.ok)
1484
+ return run;
1485
+ const result = await run.data;
1486
+ return {
1487
+ ok: true,
1488
+ data: {
1489
+ processId: run.data.processId,
1490
+ streamId: run.data.streamId,
1491
+ streamClientId: run.data.streamClientId,
1492
+ result,
1493
+ },
1494
+ };
1495
+ }
1496
+ async readProcessStream(processId) {
1497
+ try {
1498
+ const processResult = await this.adminDb.query({
1499
+ sandbox_processes: {
1500
+ $: { where: { id: processId }, limit: 1 },
1501
+ },
1502
+ });
1503
+ const processRow = processResult?.sandbox_processes?.[0];
1504
+ if (!processRow)
1505
+ return { ok: false, error: "sandbox_process_not_found" };
1506
+ const streams = this.adminDb?.streams;
1507
+ if (!streams?.createReadStream)
1508
+ return { ok: false, error: "sandbox_process_streams_unavailable" };
1509
+ const clientId = String(processRow.streamClientId ?? "").trim() || undefined;
1510
+ const streamId = String(processRow.streamId ?? "").trim() || undefined;
1511
+ if (!clientId && !streamId)
1512
+ return { ok: false, error: "sandbox_process_stream_missing" };
1513
+ const stream = streams.createReadStream({ clientId, streamId });
1514
+ const chunks = [];
1515
+ let byteOffset = 0;
1516
+ let buffer = "";
1517
+ for await (const raw of stream) {
1518
+ const encoded = typeof raw === "string" ? raw : String(raw ?? "");
1519
+ if (!encoded)
1520
+ continue;
1521
+ byteOffset += new TextEncoder().encode(encoded).length;
1522
+ buffer += encoded;
1523
+ const lines = buffer.split("\n");
1524
+ buffer = lines.pop() ?? "";
1525
+ for (const line of lines) {
1526
+ const trimmed = line.trim();
1527
+ if (!trimmed)
1528
+ continue;
1529
+ chunks.push(parseSandboxProcessStreamChunk(trimmed));
1530
+ }
1531
+ }
1532
+ const trailing = buffer.trim();
1533
+ if (trailing)
1534
+ chunks.push(parseSandboxProcessStreamChunk(trailing));
1535
+ return { ok: true, data: { chunks, byteOffset } };
1536
+ }
1537
+ catch (e) {
1538
+ return { ok: false, error: formatInstantSchemaError(e) };
1539
+ }
1540
+ }
1322
1541
  async writeFiles(sandboxId, files) {
1323
1542
  try {
1324
1543
  const sandboxResult = await this.reconnectToSandbox(sandboxId);
1325
1544
  if (!sandboxResult.ok)
1326
1545
  return { ok: false, error: sandboxResult.error };
1327
1546
  const sandbox = sandboxResult.data.sandbox;
1328
- if (sandbox.sandboxId) {
1547
+ if (isVercelSandbox(sandbox)) {
1329
1548
  await sandbox.writeFiles(files.map((f) => ({
1330
1549
  path: f.path,
1331
1550
  content: Buffer.from(f.contentBase64, "base64"),
@@ -1342,7 +1561,7 @@ export class SandboxService {
1342
1561
  const dirPath = filePath.includes("/") ? filePath.split("/").slice(0, -1).join("/") : "";
1343
1562
  const dirCmd = dirPath ? `mkdir -p ${SandboxService.shellEscapeArg(dirPath)} && ` : "";
1344
1563
  const cmd = `${dirCmd}printf %s ${SandboxService.shellEscapeArg(String(f.contentBase64 ?? ""))} | base64 -d > ${SandboxService.shellEscapeArg(filePath)}`;
1345
- await SandboxService.spritesExec({
1564
+ await spritesExec({
1346
1565
  spriteName,
1347
1566
  command: "sh",
1348
1567
  args: ["-lc", cmd],
@@ -1367,7 +1586,7 @@ export class SandboxService {
1367
1586
  if (!sandboxResult.ok)
1368
1587
  return { ok: false, error: sandboxResult.error };
1369
1588
  const sandbox = sandboxResult.data.sandbox;
1370
- if (sandbox.sandboxId) {
1589
+ if (isVercelSandbox(sandbox)) {
1371
1590
  const stream = await sandbox.readFile({ path });
1372
1591
  if (!stream) {
1373
1592
  return { ok: true, data: { contentBase64: "" } };
@@ -1395,7 +1614,7 @@ export class SandboxService {
1395
1614
  return { ok: false, error: "sprites_name_required" };
1396
1615
  const filePath = String(path ?? "").trim();
1397
1616
  const cmd = `if [ -f ${SandboxService.shellEscapeArg(filePath)} ]; then base64 ${SandboxService.shellEscapeArg(filePath)} | tr -d '\\n'; fi`;
1398
- const res = await SandboxService.spritesExec({
1617
+ const res = await spritesExec({
1399
1618
  spriteName,
1400
1619
  command: "sh",
1401
1620
  args: ["-lc", cmd],
@@ -1409,30 +1628,37 @@ export class SandboxService {
1409
1628
  return { ok: false, error: formatInstantSchemaError(e) };
1410
1629
  }
1411
1630
  }
1412
- static parseSpritesCheckpointIdFromNdjson(text) {
1413
- const lines = String(text ?? "")
1414
- .split("\n")
1415
- .map((l) => l.trim())
1416
- .filter(Boolean);
1417
- const candidates = [];
1418
- for (const line of lines) {
1419
- try {
1420
- const evt = JSON.parse(line);
1421
- const data = typeof evt?.data === "string" ? evt.data : "";
1422
- if (!data)
1423
- continue;
1424
- const m = data.match(/\bID:\s*(v[0-9]+)\b/i) || data.match(/\bCheckpoint\s+(v[0-9]+)\b/i);
1425
- if (m?.[1]) {
1426
- candidates.push(String(m[1]));
1427
- }
1631
+ async getPortUrl(sandboxId, port) {
1632
+ try {
1633
+ const sandboxResult = await this.reconnectToSandbox(sandboxId);
1634
+ if (!sandboxResult.ok)
1635
+ return { ok: false, error: sandboxResult.error };
1636
+ const sandbox = sandboxResult.data.sandbox;
1637
+ const normalizedPort = Math.max(1, Math.floor(Number(port)));
1638
+ if (isVercelSandbox(sandbox)) {
1639
+ const url = sandbox.domain(normalizedPort);
1640
+ return { ok: true, data: { url: String(url ?? "").replace(/\/+$/, "") } };
1428
1641
  }
1429
- catch {
1430
- // ignore invalid ndjson lines
1642
+ if (sandbox.__provider === "sprites") {
1643
+ const base = String(sandbox.url ?? "").trim().replace(/\/+$/, "");
1644
+ if (!base)
1645
+ return { ok: false, error: "sprites_url_missing" };
1646
+ if (normalizedPort === 8080)
1647
+ return { ok: true, data: { url: base } };
1648
+ try {
1649
+ const u = new URL(base);
1650
+ u.port = String(normalizedPort);
1651
+ return { ok: true, data: { url: u.toString().replace(/\/+$/, "") } };
1652
+ }
1653
+ catch {
1654
+ return { ok: true, data: { url: `${base}:${normalizedPort}` } };
1655
+ }
1431
1656
  }
1657
+ return { ok: false, error: "sandbox_port_url_not_supported" };
1658
+ }
1659
+ catch (e) {
1660
+ return { ok: false, error: formatInstantSchemaError(e) };
1432
1661
  }
1433
- if (candidates.length === 0)
1434
- return null;
1435
- return candidates[candidates.length - 1] ?? null;
1436
1662
  }
1437
1663
  async createCheckpoint(sandboxId, params) {
1438
1664
  try {
@@ -1440,13 +1666,40 @@ export class SandboxService {
1440
1666
  sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 } },
1441
1667
  });
1442
1668
  const record = recordResult?.sandbox_sandboxes?.[0];
1669
+ if (record?.externalSandboxId && record.provider === "vercel") {
1670
+ const sandboxResult = await this.reconnectToSandbox(sandboxId);
1671
+ if (!sandboxResult.ok)
1672
+ return { ok: false, error: sandboxResult.error };
1673
+ const sandbox = sandboxResult.data.sandbox;
1674
+ if (!isVercelSandbox(sandbox))
1675
+ return { ok: false, error: "checkpoint_not_supported" };
1676
+ const expiration = Number(record?.params?.vercel?.snapshotExpirationMs);
1677
+ const snapshot = await sandbox.snapshot({
1678
+ ...(Number.isFinite(expiration) ? { expiration } : {}),
1679
+ });
1680
+ const checkpointId = String(snapshot?.snapshotId ?? "").trim();
1681
+ if (!checkpointId)
1682
+ return { ok: false, error: "vercel_snapshot_id_missing" };
1683
+ await this.adminDb.transact(this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
1684
+ updatedAt: Date.now(),
1685
+ params: {
1686
+ ...(record.params ?? {}),
1687
+ vercel: {
1688
+ ...(record.params?.vercel ?? {}),
1689
+ lastCheckpointId: checkpointId,
1690
+ lastCheckpointComment: String(params?.comment ?? "").trim() || undefined,
1691
+ },
1692
+ },
1693
+ }));
1694
+ return { ok: true, data: { checkpointId } };
1695
+ }
1443
1696
  if (!record?.externalSandboxId || record.provider !== "sprites") {
1444
1697
  return { ok: false, error: "checkpoint_not_supported" };
1445
1698
  }
1446
1699
  const name = String(record.externalSandboxId).trim();
1447
1700
  const comment = String(params?.comment ?? "").trim();
1448
1701
  const body = comment ? { comment } : {};
1449
- const res = await SandboxService.spritesFetch(`/v1/sprites/${encodeURIComponent(name)}/checkpoint`, {
1702
+ const res = await spritesFetch(`/v1/sprites/${encodeURIComponent(name)}/checkpoint`, {
1450
1703
  method: "POST",
1451
1704
  headers: { "Content-Type": "application/json" },
1452
1705
  body: JSON.stringify(body),
@@ -1455,7 +1708,7 @@ export class SandboxService {
1455
1708
  if (!res?.ok) {
1456
1709
  return { ok: false, error: text || `sprites_checkpoint_http_${res?.status ?? "unknown"}` };
1457
1710
  }
1458
- const checkpointId = SandboxService.parseSpritesCheckpointIdFromNdjson(text);
1711
+ const checkpointId = parseSpritesCheckpointIdFromNdjson(text);
1459
1712
  if (!checkpointId) {
1460
1713
  return { ok: false, error: "sprites_checkpoint_id_missing" };
1461
1714
  }
@@ -1481,11 +1734,26 @@ export class SandboxService {
1481
1734
  sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 } },
1482
1735
  });
1483
1736
  const record = recordResult?.sandbox_sandboxes?.[0];
1737
+ if (record?.externalSandboxId && record.provider === "vercel") {
1738
+ const creds = await resolveVercelCredentials(record?.params ?? {});
1739
+ const listed = await VercelSnapshot.list({
1740
+ teamId: creds.teamId,
1741
+ projectId: creds.projectId,
1742
+ token: creds.token,
1743
+ name: String(record.externalSandboxId),
1744
+ limit: 50,
1745
+ sortOrder: "desc",
1746
+ });
1747
+ const checkpointIds = (listed.snapshots ?? [])
1748
+ .map((snapshot) => String(snapshot?.id ?? "").trim())
1749
+ .filter(Boolean);
1750
+ return { ok: true, data: { checkpointIds } };
1751
+ }
1484
1752
  if (!record?.externalSandboxId || record.provider !== "sprites") {
1485
1753
  return { ok: false, error: "checkpoint_not_supported" };
1486
1754
  }
1487
1755
  const name = String(record.externalSandboxId).trim();
1488
- const json = await SandboxService.spritesJson(`/v1/sprites/${encodeURIComponent(name)}/checkpoints`, {
1756
+ const json = await spritesJson(`/v1/sprites/${encodeURIComponent(name)}/checkpoints`, {
1489
1757
  method: "GET",
1490
1758
  headers: { Accept: "application/json" },
1491
1759
  });
@@ -1512,7 +1780,7 @@ export class SandboxService {
1512
1780
  const cp = String(checkpointId ?? "").trim();
1513
1781
  if (!cp)
1514
1782
  return { ok: false, error: "checkpoint_id_required" };
1515
- const res = await SandboxService.spritesFetch(`/v1/sprites/${encodeURIComponent(name)}/checkpoints/${encodeURIComponent(cp)}/restore`, { method: "POST" });
1783
+ const res = await spritesFetch(`/v1/sprites/${encodeURIComponent(name)}/checkpoints/${encodeURIComponent(cp)}/restore`, { method: "POST" });
1516
1784
  const text = await res?.text?.().catch(() => "");
1517
1785
  if (!res?.ok) {
1518
1786
  return { ok: false, error: text || `sprites_restore_http_${res?.status ?? "unknown"}` };