@ekairos/sandbox 1.22.33-beta.development.0 → 1.22.35-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/dist/service.js CHANGED
@@ -1,8 +1,9 @@
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
4
  import { resolveRuntime } from "@ekairos/domain/runtime";
5
5
  import { runCommandInSandbox } from "./commands.js";
6
+ import { resolveVercelSandboxConfig, safeVercelConfigForRecord, } from "./vercel-options.js";
6
7
  import { execFile } from "node:child_process";
7
8
  import { randomUUID } from "node:crypto";
8
9
  import { existsSync, promises as fs } from "node:fs";
@@ -10,6 +11,14 @@ import os from "node:os";
10
11
  import path from "node:path";
11
12
  import { promisify } from "node:util";
12
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
+ }
13
22
  const EKAIROS_ROOT_DIR = "/vercel/sandbox/.ekairos";
14
23
  const EKAIROS_RUNTIME_MANIFEST_PATH = `${EKAIROS_ROOT_DIR}/runtime.json`;
15
24
  const EKAIROS_HTTP_HELPER_PATH = `${EKAIROS_ROOT_DIR}/instant-http.mjs`;
@@ -17,6 +26,8 @@ const EKAIROS_QUERY_SCRIPT_PATH = `${EKAIROS_ROOT_DIR}/query.mjs`;
17
26
  const CODEX_HOME_DIR = "/vercel/sandbox/.codex";
18
27
  const CODEX_SKILLS_DIR = `${CODEX_HOME_DIR}/skills`;
19
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"]);
20
31
  function formatInstantSchemaError(err) {
21
32
  const base = err instanceof Error ? err.message : String(err);
22
33
  const body = err?.body;
@@ -49,6 +60,165 @@ function formatSandboxError(err) {
49
60
  return base;
50
61
  return `${base}: ${detail}`;
51
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
+ }
52
222
  export class SandboxService {
53
223
  constructor(db) {
54
224
  this.adminDb = db;
@@ -442,16 +612,41 @@ export class SandboxService {
442
612
  }
443
613
  static async provisionVercelSandbox(config, extra) {
444
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
+ }
445
634
  return await VercelSandbox.create({
446
635
  teamId: creds.teamId,
447
636
  projectId: creds.projectId,
448
637
  token: creds.token,
449
- timeout: config.timeoutMs ?? 30 * 60 * 1000,
450
- ports: Array.isArray(config.ports) ? config.ports : [],
638
+ ...(resolved.name ? { name: resolved.name } : {}),
639
+ timeout: resolved.timeoutMs,
640
+ ports: resolved.ports,
451
641
  // IMPORTANT: pass runtime as-is (e.g. "python3.13") to match provider expectations.
452
642
  // 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 },
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 } : {}),
455
650
  networkPolicy: extra?.networkPolicy,
456
651
  env: extra?.env,
457
652
  });
@@ -639,8 +834,8 @@ export class SandboxService {
639
834
  : "";
640
835
  return {
641
836
  exitCode: Number.isFinite(exitCode) ? exitCode : 0,
642
- stdout,
643
- stderr,
837
+ stdout: sanitizeInstantString(stdout),
838
+ stderr: sanitizeInstantString(stderr),
644
839
  };
645
840
  }
646
841
  static async spritesExec(params) {
@@ -832,6 +1027,7 @@ export class SandboxService {
832
1027
  const sandboxId = id();
833
1028
  const now = Date.now();
834
1029
  const provider = SandboxService.resolveProvider(config);
1030
+ const resolvedVercel = provider === "vercel" ? resolveVercelSandboxConfig(config, { sandboxId }) : undefined;
835
1031
  let daytonaEphemeral = undefined;
836
1032
  let installedSkills = [];
837
1033
  try {
@@ -841,13 +1037,14 @@ export class SandboxService {
841
1037
  status: "creating",
842
1038
  ...(ekairos ? { sandboxUserId: ekairos.sandboxUserId } : {}),
843
1039
  provider,
844
- timeout: config.timeoutMs,
845
- runtime: config.runtime,
846
- vcpus: config.resources?.vcpus,
847
- 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),
848
1044
  purpose: config.purpose,
849
1045
  params: {
850
1046
  ...baseParams,
1047
+ ...(resolvedVercel ? { vercel: safeVercelConfigForRecord(config, resolvedVercel) } : {}),
851
1048
  ...(ekairos
852
1049
  ? {
853
1050
  ekairos: {
@@ -947,6 +1144,7 @@ export class SandboxService {
947
1144
  sandbox = await SandboxService.provisionVercelSandbox(config, {
948
1145
  networkPolicy: ekairos?.networkPolicy,
949
1146
  env: Object.keys(vercelEnv).length > 0 ? vercelEnv : undefined,
1147
+ resolved: resolvedVercel,
950
1148
  });
951
1149
  if (ekairos) {
952
1150
  await SandboxService.bootstrapEkairosFiles(sandbox, ekairos.manifest);
@@ -960,7 +1158,10 @@ export class SandboxService {
960
1158
  const msg = formatSandboxError(e);
961
1159
  if (sandbox && provider === "vercel") {
962
1160
  try {
963
- await sandbox.stop();
1161
+ await sandbox.stop({ blocking: true });
1162
+ if (resolvedVercel?.deleteOnStop) {
1163
+ await sandbox.delete();
1164
+ }
964
1165
  }
965
1166
  catch {
966
1167
  // ignore cleanup errors during failed bootstrap
@@ -977,7 +1178,7 @@ export class SandboxService {
977
1178
  ? sandbox.id
978
1179
  : provider === "sprites"
979
1180
  ? String(sandbox.name)
980
- : sandbox.sandboxId;
1181
+ : sandbox.name;
981
1182
  const sandboxUrl = provider === "sprites" ? sandbox.url : undefined;
982
1183
  const activateMutations = [
983
1184
  this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
@@ -1009,10 +1210,7 @@ export class SandboxService {
1009
1210
  : {}),
1010
1211
  ...(provider === "vercel"
1011
1212
  ? {
1012
- vercel: {
1013
- ...baseParams?.vercel,
1014
- ...(config.vercel ?? {}),
1015
- },
1213
+ vercel: resolvedVercel ? safeVercelConfigForRecord(config, resolvedVercel) : {},
1016
1214
  }
1017
1215
  : {}),
1018
1216
  ...(provider === "daytona"
@@ -1144,7 +1342,7 @@ export class SandboxService {
1144
1342
  const delayMs = 500;
1145
1343
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
1146
1344
  const sandbox = await VercelSandbox.get({
1147
- sandboxId: String(record.externalSandboxId),
1345
+ name: String(record.externalSandboxId),
1148
1346
  teamId: creds.teamId,
1149
1347
  projectId: creds.projectId,
1150
1348
  token: creds.token,
@@ -1180,6 +1378,254 @@ export class SandboxService {
1180
1378
  });
1181
1379
  return recordResult?.sandbox_sandboxes?.[0] ?? null;
1182
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
+ }
1183
1629
  async stopSandbox(sandboxId) {
1184
1630
  try {
1185
1631
  const result = await this.reconnectToSandbox(sandboxId);
@@ -1190,13 +1636,19 @@ export class SandboxService {
1190
1636
  const deleteOnStop = record?.provider === "sprites"
1191
1637
  ? SandboxService.parseOptionalBoolean(process.env.SANDBOX_SPRITES_DELETE_ON_STOP) ??
1192
1638
  Boolean(record?.params?.sprites?.deleteOnStop ?? true)
1193
- : SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_DELETE_ON_STOP) ??
1194
- Boolean(record?.params?.daytona?.ephemeral);
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);
1195
1644
  if (result.ok) {
1196
1645
  try {
1197
1646
  const sandbox = result.data.sandbox;
1198
- if (sandbox?.sandboxId) {
1199
- await sandbox.stop();
1647
+ if (isVercelSandbox(sandbox)) {
1648
+ await sandbox.stop({ blocking: true });
1649
+ if (deleteOnStop) {
1650
+ await sandbox.delete();
1651
+ }
1200
1652
  }
1201
1653
  else if (sandbox?.__provider === "sprites") {
1202
1654
  // Sprites does not have a reliable "stop" semantic; deleting is the durable cleanup primitive.
@@ -1231,6 +1683,7 @@ export class SandboxService {
1231
1683
  shutdownAt: Date.now(),
1232
1684
  updatedAt: Date.now(),
1233
1685
  }));
1686
+ await this.markOpenProcessesLost(sandboxId, "sandbox_stopped");
1234
1687
  return { ok: true, data: undefined };
1235
1688
  }
1236
1689
  catch (e) {
@@ -1278,7 +1731,7 @@ export class SandboxService {
1278
1731
  if (!sandboxResult.ok)
1279
1732
  return { ok: false, error: sandboxResult.error };
1280
1733
  const sandbox = sandboxResult.data.sandbox;
1281
- if (sandbox.sandboxId) {
1734
+ if (isVercelSandbox(sandbox)) {
1282
1735
  const result = await runCommandInSandbox(sandbox, command, args);
1283
1736
  return { ok: true, data: result };
1284
1737
  }
@@ -1319,13 +1772,279 @@ export class SandboxService {
1319
1772
  return { ok: false, error: formatInstantSchemaError(e) };
1320
1773
  }
1321
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
+ }
1322
2041
  async writeFiles(sandboxId, files) {
1323
2042
  try {
1324
2043
  const sandboxResult = await this.reconnectToSandbox(sandboxId);
1325
2044
  if (!sandboxResult.ok)
1326
2045
  return { ok: false, error: sandboxResult.error };
1327
2046
  const sandbox = sandboxResult.data.sandbox;
1328
- if (sandbox.sandboxId) {
2047
+ if (isVercelSandbox(sandbox)) {
1329
2048
  await sandbox.writeFiles(files.map((f) => ({
1330
2049
  path: f.path,
1331
2050
  content: Buffer.from(f.contentBase64, "base64"),
@@ -1367,7 +2086,7 @@ export class SandboxService {
1367
2086
  if (!sandboxResult.ok)
1368
2087
  return { ok: false, error: sandboxResult.error };
1369
2088
  const sandbox = sandboxResult.data.sandbox;
1370
- if (sandbox.sandboxId) {
2089
+ if (isVercelSandbox(sandbox)) {
1371
2090
  const stream = await sandbox.readFile({ path });
1372
2091
  if (!stream) {
1373
2092
  return { ok: true, data: { contentBase64: "" } };
@@ -1409,6 +2128,38 @@ export class SandboxService {
1409
2128
  return { ok: false, error: formatInstantSchemaError(e) };
1410
2129
  }
1411
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
+ }
1412
2163
  static parseSpritesCheckpointIdFromNdjson(text) {
1413
2164
  const lines = String(text ?? "")
1414
2165
  .split("\n")
@@ -1440,6 +2191,33 @@ export class SandboxService {
1440
2191
  sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 } },
1441
2192
  });
1442
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
+ }
1443
2221
  if (!record?.externalSandboxId || record.provider !== "sprites") {
1444
2222
  return { ok: false, error: "checkpoint_not_supported" };
1445
2223
  }
@@ -1481,6 +2259,21 @@ export class SandboxService {
1481
2259
  sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 } },
1482
2260
  });
1483
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
+ }
1484
2277
  if (!record?.externalSandboxId || record.provider !== "sprites") {
1485
2278
  return { ok: false, error: "checkpoint_not_supported" };
1486
2279
  }