@sanlabs/sanbox-cli 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,8 +15,14 @@ installed_cli_version="$(sanbox --version)"
15
15
  test "$installed_cli_version" = "$latest_cli_version"
16
16
  ```
17
17
 
18
- Always use the latest published CLI. CLI 0.0.11 adds OpenCode Computer templates and short-lived
19
- native SDK connections for HTTP, SSE streaming, and interactive steering.
18
+ Always use the latest published CLI. CLI 0.0.13 allows service templates to start without an
19
+ initial task, including OpenCode Computers that are ready for later SDK steering, and supports
20
+ Custom OpenAI Proxy providers when creating templates, including Browser Use templates.
21
+ CLI 0.0.12 adopted the Stop, Resume, and Delete lifecycle,
22
+ separates run state from execution outcome, retains scrubbed deleted-run tombstones, and adds
23
+ administrator-confirmed template deletion.
24
+ CLI 0.0.11 added OpenCode Computer templates and short-lived native SDK connections for HTTP, SSE
25
+ streaming, and interactive steering.
20
26
  CLI 0.0.10 added per-run Hermes email and Telegram channels, Supabase user authorization, and
21
27
  removed the retired run-chat commands.
22
28
  CLI 0.0.9 added user login for private SSH access to supported running sandboxes.
@@ -113,10 +119,20 @@ sanbox templates create \
113
119
  Omit both `--channel` flags for a WebUI-only computer. Channel credentials never belong to the
114
120
  template.
115
121
 
122
+ Delete a template when it should no longer be available for new runs:
123
+
124
+ ```bash
125
+ sanbox templates delete <template-id-or-slug> --force --json
126
+ ```
127
+
128
+ Deletion preserves existing runs and retained sandboxes. It requires organization-admin access
129
+ and `--force` so scripts cannot remove a template accidentally.
130
+
116
131
  ## Create A Browser Use Template
117
132
 
118
133
  Browser Use runs local headless Chromium inside the Firecracker sandbox. It requires OpenAI,
119
- Anthropic, Google Gemini, or Hetzner Inference and at least one explicit browser target:
134
+ Anthropic, Google Gemini, Hetzner Inference, or a Custom OpenAI Proxy and at least one
135
+ explicit browser target:
120
136
 
121
137
  ```bash
122
138
  sanbox templates create \
@@ -139,6 +155,8 @@ allowed downloads. Browser Use templates are one-shot agent executions.
139
155
 
140
156
  ## Run Idempotently
141
157
 
158
+ Task templates require a positional task or `--task`. Service templates may omit it:
159
+
142
160
  ```bash
143
161
  sanbox run "Review this repo and write output/report.md" \
144
162
  --input src/ \
@@ -151,8 +169,18 @@ sanbox run "Review this repo and write output/report.md" \
151
169
  --input src/ \
152
170
  --wait \
153
171
  --json
172
+
173
+ sanbox run \
174
+ --template "<service-template-id>" \
175
+ --external-run-id "<stable-service-id>" \
176
+ --json
154
177
  ```
155
178
 
179
+ A taskless OpenCode Computer starts `opencode serve` without making an initial model call. A
180
+ taskless Hermes service starts its gateways and waits for WebUI or configured channel messages.
181
+ Supplying instructions starts the initial OpenCode task or seeds Hermes's persistent operating
182
+ context, preserving the existing behavior.
183
+
156
184
  Repeat `--input` for files, directories, or globs. The CLI excludes common secrets and applies `.sanboxignore`. `--include` is a deprecated compatibility alias.
157
185
 
158
186
  For a Hermes template that allows `email`, optionally request an address from one of the AgentMail
@@ -200,7 +228,7 @@ callback lands on a Sanbox-hosted completion page, so a CLI demo does not need a
200
228
  Customer integrations may use `--return-url <https-url>` when its origin matches the return origin
201
229
  configured on the organization Supabase connection.
202
230
 
203
- Reuse the same external ID when retrying an ambiguous submission. To stream activity, replace `--wait --json` with `--jsonl`. Ctrl-C detaches without canceling unless `--cancel-on-interrupt` is supplied.
231
+ Reuse the same external ID when retrying an ambiguous submission. To stream activity, replace `--wait --json` with `--jsonl`. Ctrl-C detaches without stopping unless `--stop-on-interrupt` is supplied.
204
232
 
205
233
  ## Inspect And Recover
206
234
 
@@ -243,20 +271,23 @@ working when the exact sandbox session stops, the link expires, or it is revoked
243
271
  when created and must be handled as a bearer secret. See
244
272
  [Live Filesystem Access](../docs/live-filesystem-access.md) for the HTTP contract and exclusions.
245
273
 
246
- ## Resume Or Pause A Sandbox
274
+ ## Stop, Resume, Or Delete A Run
247
275
 
248
276
  ```bash
249
277
  sanbox runs get <run-id> --json
250
278
  sanbox runs resume <run-id> --wait --json
251
279
  sanbox runs share <run-id> --expires 1h --json
252
- sanbox runs pause <run-id> --wait --json
280
+ sanbox runs stop <run-id> --wait --json
281
+ sanbox runs delete <run-id> --yes --json
253
282
  ```
254
283
 
255
- Resume restores the latest writable Firecracker snapshot without starting the configured agent
256
- harness. It acquires an exclusive manual lease and leaves the sandbox running for live filesystem
257
- access until Pause creates the next snapshot generation. Before resuming, require
258
- `sandbox_state: "paused"` and a positive `snapshot_generation`. Do not submit agent work while that
259
- lease is active. Paused state has no retention TTL and remains available until explicitly deleted.
284
+ Stop terminates compute and durably syncs the workspace. It does not retain RAM, process state, or
285
+ network connections. Resume fresh-boots the pinned runtime artifact with the retained workspace; it
286
+ does not silently replay a completed task. `state` is the single run phase; inspect
287
+ `latest_execution.outcome` for the last bounded execution outcome and `workspace.saved_at` for disk
288
+ persistence proof. Stopped runs have no automatic TTL. Delete permanently removes the workspace
289
+ after archiving usage. The control plane retains a scrubbed run tombstone, but normal CLI run listings
290
+ exclude deleted runs.
260
291
 
261
292
  ## SSH Into A Running Sandbox
262
293
 
@@ -274,8 +305,9 @@ authenticated WebSocket. The temporary key is deleted when the connection closes
274
305
  user token is not passed to the OpenSSH child process.
275
306
 
276
307
  OpenCode and Browser Use runs are one-shot agent executions. OpenCode Computer keeps its private
277
- server running after the initial task so it can accept later steering. A persisted task sandbox can
278
- still be resumed manually for inspection when a snapshot exists.
308
+ server running so it can accept later steering; when an initial task is supplied, Sanbox submits it
309
+ after the server becomes ready. A persisted task workspace can still be fresh-booted manually for
310
+ inspection after the run stops.
279
311
 
280
312
  ## Connect The OpenCode SDK
281
313
 
package/dist/activity.js CHANGED
@@ -35,6 +35,23 @@ export const formatActivityLine = (event, runCreatedAt) => {
35
35
  return `${elapsed(runCreatedAt, event.created_at).padStart(8)} ${event.kind.padEnd(22)} ${bounded}`.trimEnd();
36
36
  };
37
37
  const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
38
+ const legacyGuestEventKinds = new Set([
39
+ "log.stdout",
40
+ "log.stderr",
41
+ "runner.event_invalid",
42
+ "sandbox.stderr",
43
+ "anthropic.worker.stdout",
44
+ "anthropic.worker.stderr"
45
+ ]);
46
+ const legacyEventSource = (event) => {
47
+ if (legacyGuestEventKinds.has(event.kind)) {
48
+ return { plane: "sandbox", trust: "legacy_guest_content" };
49
+ }
50
+ if (event.kind === "sandbox.status") {
51
+ return { plane: "unknown", trust: "legacy_mixed_origin" };
52
+ }
53
+ return { plane: "control_plane", trust: "control_plane" };
54
+ };
38
55
  const safeEventData = (value) => {
39
56
  if (Array.isArray(value))
40
57
  return value.map(safeEventData);
@@ -61,7 +78,7 @@ export const activityEnvelope = (event) => {
61
78
  kind: event.kind,
62
79
  level: event.level,
63
80
  summary: event.message,
64
- source: normalized ? event.payload.source : { plane: "control_plane", trust: "control_plane" },
81
+ source: normalized ? event.payload.source : legacyEventSource(event),
65
82
  sequence: normalized && typeof event.payload.sequence === "number" ? event.payload.sequence : null,
66
83
  correlation: normalized && isRecord(event.payload.correlation) ? event.payload.correlation : {},
67
84
  data: safeEventData(normalized ? event.payload.data : event.payload),
package/dist/api.js CHANGED
@@ -174,6 +174,9 @@ export class SanboxClient {
174
174
  async validateTemplate(templateId) {
175
175
  return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}/validate`));
176
176
  }
177
+ async deleteTemplate(templateId) {
178
+ return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}`), { method: "DELETE" });
179
+ }
177
180
  async createTemplate(body) {
178
181
  return this.request(await this.orgPath("/templates"), {
179
182
  method: "POST",
@@ -198,20 +201,19 @@ export class SanboxClient {
198
201
  async listEvents(runId, afterEventId = 0, limit = 200, signal) {
199
202
  return this.request(`${await this.orgPath(`/runs/${encodeURIComponent(runId)}/events`)}?after_event_id=${afterEventId}&limit=${limit}`, { signal });
200
203
  }
201
- async cancelRun(runId) {
202
- return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/cancel`), {
204
+ async stopRun(runId) {
205
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/stop`), {
203
206
  method: "POST",
204
207
  body: "{}"
205
208
  });
206
209
  }
207
- async resumeRun(runId) {
208
- return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/resume`), {
209
- method: "POST",
210
- body: "{}"
210
+ async deleteRun(runId) {
211
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}`), {
212
+ method: "DELETE"
211
213
  });
212
214
  }
213
- async pauseRun(runId) {
214
- return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/pause`), {
215
+ async resumeRun(runId) {
216
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/resume`), {
215
217
  method: "POST",
216
218
  body: "{}"
217
219
  });
package/dist/args.js CHANGED
@@ -15,9 +15,10 @@ export const booleanFlags = new Set([
15
15
  "wait",
16
16
  "watch",
17
17
  "jsonl",
18
- "cancel-on-interrupt",
18
+ "stop-on-interrupt",
19
19
  "verbose",
20
20
  "force",
21
+ "yes",
21
22
  "write",
22
23
  "overwrite",
23
24
  "open",
package/dist/cli.js CHANGED
@@ -10,8 +10,8 @@ import { loginWithBrowser, openBrowser, revokeUserSession } from "./deviceLogin.
10
10
  import { CliError, commandAction, consoleAction } from "./errors.js";
11
11
  import { parseFileAccessExpiry } from "./fileAccess.js";
12
12
  import { previewInputs } from "./inputs.js";
13
- import { printError, printJsonlError, printRun, printSuccess, publicRun, publicRunPayload } from "./output.js";
14
- import { createRun, isTerminalRun, readTasks, runPool, stableBatchId, waitForRun, waitForSandboxState } from "./runs.js";
13
+ import { isoUtcTimestamp, printError, printJsonlError, printRun, printSuccess, publicRun, publicRunPayload } from "./output.js";
14
+ import { createRun, isTerminalRun, runSucceeded, readTasks, runPool, stableBatchId, waitForRun, waitForRunState } from "./runs.js";
15
15
  import { version } from "./version.js";
16
16
  import { WatchInterruptedError, watchRun } from "./watch.js";
17
17
  import { openSSH, runSSHProxy } from "./ssh.js";
@@ -41,8 +41,10 @@ Commands:
41
41
  sanbox templates get <template-id> [--json]
42
42
  sanbox templates validate <template-id> [--json]
43
43
  sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--channel email|telegram] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
44
+ sanbox templates delete <template-id> --force [--json]
44
45
  sanbox run "task" --template <template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
45
46
  sanbox run --task "..." --template <template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
47
+ sanbox run --template <service-template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--json]
46
48
  sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
47
49
  sanbox runs list [--limit 50] [--json]
48
50
  sanbox runs get <run-id> [--json]
@@ -52,10 +54,10 @@ Commands:
52
54
  sanbox runs shares <run-id> [--json]
53
55
  sanbox runs unshare <run-id> <access-point-id> [--json]
54
56
  sanbox runs download <run-id> --output <directory> [--artifact <path>] [--overwrite] [--json]
55
- sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--jsonl]
56
- sanbox runs cancel <run-id> [--json]
57
+ sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--stop-on-interrupt] [--jsonl]
58
+ sanbox runs stop <run-id> [--wait] [--json]
57
59
  sanbox runs resume <run-id> [--wait] [--json]
58
- sanbox runs pause <run-id> [--wait] [--json]
60
+ sanbox runs delete <run-id> --yes [--json]
59
61
  sanbox runs supabase authorize <run-id> [--open] [--return-url <https-url>] [--json]
60
62
  sanbox opencode connect <run-id> [--expires 1h] [--json]
61
63
  sanbox opencode connections list <run-id> [--json]
@@ -92,8 +94,10 @@ const runHelp = `Sanbox run
92
94
  Usage:
93
95
  sanbox run "Review these files" --template <template-id> --input report.pdf --input data/ --wait
94
96
  sanbox run --task "Review these files" --template <template-id> --input report.pdf --wait --json
97
+ sanbox run --template <service-template-id> --json
95
98
 
96
99
  Options:
100
+ --task <instruction> Required for task templates; optional for service templates.
97
101
  --input <path> File, directory, or glob to upload. Repeatable.
98
102
  --template <id> Template id or slug. Required unless SANBOX_TEMPLATE or project config sets it.
99
103
  --external-run-id <id> Idempotency key for retries.
@@ -102,12 +106,12 @@ Options:
102
106
  --telegram-bot-token <token> Optional Telegram bot token; SANBOX_TELEGRAM_BOT_TOKEN is also supported.
103
107
  --telegram-allowed-user <id> Numeric Telegram user allowed to reach this run. Repeatable.
104
108
  --dry-run Preview included files without creating a run.
105
- --wait Poll until terminal status.
106
- --watch Stream activity until terminal status.
109
+ --wait Poll until a terminal run state.
110
+ --watch Stream activity until a terminal run state.
107
111
  --jsonl Stream one versioned activity event per line. Implies --watch.
108
- --view <name> activity, logs, or compact. Default: activity.
112
+ --view <name> activity, compact, or historical logs. Default: activity.
109
113
  --after-event-id <id> Resume after an event cursor. Default: 0.
110
- --cancel-on-interrupt Request run cancellation when Ctrl-C is pressed.
114
+ --stop-on-interrupt Request Stop when Ctrl-C is pressed.
111
115
  --json Print JSON.
112
116
  `;
113
117
  const runsSupabaseHelp = `Sanbox Supabase run authorization
@@ -160,6 +164,7 @@ Usage:
160
164
  sanbox templates get <template-id> [--json]
161
165
  sanbox templates validate <template-id> [--json]
162
166
  sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
167
+ sanbox templates delete <template-id> --force [--json]
163
168
 
164
169
  Template creation requires an exact provider id and that provider's exact model id.
165
170
  LiteLLM budgets are optional USD amounts and apply separately to each run.
@@ -172,6 +177,7 @@ or Anthropic and at least one repeatable --browser-domain hostname or leading wi
172
177
  Optional controls: --browser-max-steps, --browser-step-timeout-seconds,
173
178
  --browser-vision-mode auto|always|never, --browser-viewport WIDTHxHEIGHT,
174
179
  --browser-download-policy allow|deny, and --browser-additional-instructions.
180
+ Deleting a template requires --force. Existing runs and their retained sandboxes are preserved.
175
181
  `;
176
182
  const agentInstructions = `# Operate Sanbox Autonomously
177
183
 
@@ -225,7 +231,8 @@ sanbox run "Investigate one focused task and write output/report.md" \\
225
231
 
226
232
  Reuse the same --external-run-id after ambiguous failures. Retry network errors, HTTP 429, HTTP 5xx,
227
233
  and workspace_busy with bounded backoff. Do not retry other 4xx errors unless next_actions directs
228
- recovery. Terminal statuses are completed, failed, and canceled.
234
+ recovery. A run is inactive when state is stopped; inspect latest_execution.outcome for the most
235
+ recent bounded execution outcome.
229
236
 
230
237
  Recover and retrieve results:
231
238
  \`\`\`bash
@@ -254,20 +261,20 @@ Manage the retained sandbox independently of its agent harness:
254
261
  sanbox runs get <run-id> --json
255
262
  sanbox runs resume <run-id> --wait --json
256
263
  sanbox runs share <run-id> --expires 1h --json
257
- sanbox runs pause <run-id> --wait --json
264
+ sanbox runs stop <run-id> --wait --json
265
+ sanbox runs delete <run-id> --yes --json
258
266
  \`\`\`
259
267
 
260
- Resume restores the latest Firecracker snapshot without starting an agent command. It creates an
261
- exclusive manual lease and leaves the sandbox running until pause snapshots it again. Do not submit
262
- agent work while the manual lease is active. Before resume, require sandbox_state: "paused" and a
263
- positive snapshot_generation.
268
+ Stop terminates compute and durably syncs the workspace without retaining RAM or VM state. Resume
269
+ fresh-boots the pinned runtime artifact with that workspace; it does not restore process state or
270
+ silently replay a finished task. Delete permanently removes the workspace after archiving usage.
264
271
 
265
272
  For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
266
273
  client alive until submission completes.
267
274
 
268
- Do not claim completion until the run is completed and required artifacts are downloaded and verified.
269
- Report run/external/template IDs, status, sandbox state, snapshot
270
- generation, artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
275
+ Do not claim completion until state is stopped, latest_execution.outcome is completed, and required
276
+ artifacts are downloaded and verified. Report run/external/template IDs, run state, execution outcome, workspace save time,
277
+ artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
271
278
  .sanboxignore for project rules.
272
279
  `;
273
280
  const cwd = () => process.cwd();
@@ -446,6 +453,7 @@ const flagSets = {
446
453
  "templates.list": commonFlags,
447
454
  "templates.get": commonFlags,
448
455
  "templates.validate": commonFlags,
456
+ "templates.delete": [...commonFlags, "force"],
449
457
  "templates.create": [
450
458
  ...commonFlags,
451
459
  "name",
@@ -468,7 +476,7 @@ const flagSets = {
468
476
  ...commonFlags, "task", "input", "template", "external-run-id", "email-address",
469
477
  "supabase-user-id",
470
478
  "telegram-bot-token", "telegram-allowed-user",
471
- "dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "cancel-on-interrupt",
479
+ "dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "stop-on-interrupt",
472
480
  "poll-interval-ms", "event-page-size", "timeout-seconds", "verbose"
473
481
  ],
474
482
  batch: [
@@ -484,12 +492,12 @@ const flagSets = {
484
492
  "runs.unshare": commonFlags,
485
493
  "runs.download": [...commonFlags, "output", "artifact", "overwrite"],
486
494
  "runs.watch": [
487
- ...commonFlags, "after-event-id", "view", "jsonl", "cancel-on-interrupt",
495
+ ...commonFlags, "after-event-id", "view", "jsonl", "stop-on-interrupt",
488
496
  "poll-interval-ms", "event-page-size", "timeout-seconds"
489
497
  ],
490
- "runs.cancel": commonFlags,
498
+ "runs.stop": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
499
+ "runs.delete": [...commonFlags, "yes"],
491
500
  "runs.resume": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
492
- "runs.pause": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
493
501
  "runs.supabase": [...commonFlags, "open", "return-url"],
494
502
  "opencode.connect": [...commonFlags, "expires"],
495
503
  "opencode.connections": commonFlags,
@@ -598,6 +606,7 @@ const validatePositionals = (command, flags) => {
598
606
  "templates.list": 2,
599
607
  "templates.get": 3,
600
608
  "templates.validate": 3,
609
+ "templates.delete": 3,
601
610
  "templates.create": 2,
602
611
  batch: 1,
603
612
  "runs.list": 2,
@@ -609,9 +618,9 @@ const validatePositionals = (command, flags) => {
609
618
  "runs.unshare": 4,
610
619
  "runs.download": 3,
611
620
  "runs.watch": 3,
612
- "runs.cancel": 3,
621
+ "runs.stop": 3,
622
+ "runs.delete": 3,
613
623
  "runs.resume": 3,
614
- "runs.pause": 3,
615
624
  "runs.supabase": 4,
616
625
  "opencode.connect": 3,
617
626
  "opencode.connections": 5,
@@ -658,7 +667,7 @@ const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
658
667
  const onInterrupt = () => controller.abort();
659
668
  process.once("SIGINT", onInterrupt);
660
669
  if (!jsonl)
661
- process.stdout.write(`Watching run ${runId}. Ctrl-C detaches without canceling.\n`);
670
+ process.stdout.write(`Watching run ${runId}. Ctrl-C detaches without stopping.\n`);
662
671
  try {
663
672
  return await watchRun(client, runId, {
664
673
  afterEventId: integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER),
@@ -680,9 +689,9 @@ const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
680
689
  catch (error) {
681
690
  if (!(error instanceof WatchInterruptedError))
682
691
  throw error;
683
- if (hasFlag(flags, "cancel-on-interrupt")) {
684
- await client.cancelRun(runId);
685
- process.stderr.write(`Cancellation requested for run ${runId}.\n`);
692
+ if (hasFlag(flags, "stop-on-interrupt")) {
693
+ await client.stopRun(runId);
694
+ process.stderr.write(`Stop requested for run ${runId}.\n`);
686
695
  }
687
696
  else {
688
697
  process.stderr.write(`Detached from run ${runId}; the run is still active.\n`);
@@ -720,8 +729,6 @@ const commandRun = async (command, flags) => {
720
729
  throw new CliError("conflicting_arguments", "Use either --task or a positional task, not both.");
721
730
  }
722
731
  const task = runTask(command, flags);
723
- if (!task)
724
- throw new Error("--task or a positional task is required.");
725
732
  if (hasFlag(flags, "wait") && wantsWatch(flags)) {
726
733
  throw new Error("--wait cannot be combined with --watch or --jsonl.");
727
734
  }
@@ -765,7 +772,7 @@ const commandRun = async (command, flags) => {
765
772
  try {
766
773
  payload = await createRun(client, {
767
774
  cwd: cwd(),
768
- instruction: task,
775
+ instruction: task || undefined,
769
776
  inputs: flagList(flags, "input"),
770
777
  externalRunId: flagString(flags, "external-run-id") || undefined,
771
778
  supabaseUserId: supabaseUserId || undefined,
@@ -785,7 +792,7 @@ const commandRun = async (command, flags) => {
785
792
  payload = watched;
786
793
  if (!hasFlag(flags, "jsonl"))
787
794
  printRun(payload);
788
- if (isTerminalRun(payload.run) && payload.run.status !== "completed")
795
+ if (isTerminalRun(payload.run) && !runSucceeded(payload.run))
789
796
  process.exitCode = 2;
790
797
  return;
791
798
  }
@@ -803,7 +810,7 @@ const commandRun = async (command, flags) => {
803
810
  }
804
811
  else
805
812
  printRun(payload);
806
- if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && payload.run.status !== "completed")
813
+ if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && !runSucceeded(payload.run))
807
814
  process.exitCode = 2;
808
815
  };
809
816
  const commandBatch = async (flags) => {
@@ -850,7 +857,7 @@ const commandBatch = async (flags) => {
850
857
  printSuccess("batch.create", output, jsonContext(client));
851
858
  else
852
859
  results.forEach(printRun);
853
- if (wait && results.some((result) => result.run.status !== "completed"))
860
+ if (wait && results.some((result) => !runSucceeded(result.run)))
854
861
  process.exitCode = 2;
855
862
  };
856
863
  const commandAuthCheck = async (flags) => {
@@ -1013,7 +1020,7 @@ const commandAnthropicEnvironments = async (command, flags) => {
1013
1020
  for (const environment of payload.environments) {
1014
1021
  process.stdout.write(`${environment.environment_id}\t${environment.status}` +
1015
1022
  `\tworker=${environment.assigned_worker_id || "unassigned"}` +
1016
- `\tverified=${environment.last_verified_at || "never"}\n`);
1023
+ `\tverified=${environment.last_verified_at ? isoUtcTimestamp(environment.last_verified_at) : "never"}\n`);
1017
1024
  }
1018
1025
  return;
1019
1026
  }
@@ -1033,7 +1040,7 @@ const commandAnthropicEnvironments = async (command, flags) => {
1033
1040
  const environment = payload.environment;
1034
1041
  process.stdout.write(`${environment.environment_id} ${environment.status}` +
1035
1042
  ` worker=${environment.assigned_worker_id || "unassigned"}` +
1036
- ` verified=${environment.last_verified_at || "never"}\n`);
1043
+ ` verified=${environment.last_verified_at ? isoUtcTimestamp(environment.last_verified_at) : "never"}\n`);
1037
1044
  return;
1038
1045
  }
1039
1046
  if (action === "connect") {
@@ -1192,6 +1199,9 @@ const commandTemplates = async (command, flags) => {
1192
1199
  if (harness === "opencode" && mode === "computer" && llmBudgetUsd !== undefined) {
1193
1200
  throw new CliError("opencode_computer_budget_unsupported", "OpenCode Computer templates do not support --llm-budget-usd yet.");
1194
1201
  }
1202
+ if (modelProvider === "custom" && llmBudgetUsd !== undefined) {
1203
+ throw new CliError("custom_provider_budget_unsupported", "Custom OpenAI Proxy providers do not support --llm-budget-usd yet.");
1204
+ }
1195
1205
  const channels = [...new Set(flagList(flags, "channel").map((value) => value.trim()).filter(Boolean))];
1196
1206
  if (channels.some((channel) => channel !== "email" && channel !== "telegram")) {
1197
1207
  throw new CliError("invalid_hermes_channels", "--channel must be email or telegram.");
@@ -1212,8 +1222,9 @@ const commandTemplates = async (command, flags) => {
1212
1222
  modelProvider !== "openai" &&
1213
1223
  modelProvider !== "anthropic" &&
1214
1224
  modelProvider !== "google" &&
1215
- modelProvider !== "hetzner") {
1216
- throw new CliError("browser_use_provider_unsupported", "Browser Use templates require --model-provider openai, anthropic, google, or hetzner.");
1225
+ modelProvider !== "hetzner" &&
1226
+ modelProvider !== "custom") {
1227
+ throw new CliError("browser_use_provider_unsupported", "Browser Use templates require --model-provider openai, anthropic, google, hetzner, or custom.");
1217
1228
  }
1218
1229
  const browserDomains = harness === "browser-use"
1219
1230
  ? parseBrowserDomains(flagList(flags, "browser-domain"))
@@ -1280,7 +1291,39 @@ const commandTemplates = async (command, flags) => {
1280
1291
  `${payload.template.llm_budget_usd ? ` budget=$${payload.template.llm_budget_usd}/run` : ""}\n`);
1281
1292
  return;
1282
1293
  }
1283
- throw new CliError("templates_action_required", "templates requires an action: list, get, validate, or create.");
1294
+ if (action === "delete") {
1295
+ const id = requiredPositional(command[2], "template_id_required", "templates delete requires a template id or slug.");
1296
+ if (!hasFlag(flags, "force")) {
1297
+ throw new CliError("confirmation_required", "templates delete requires --force.", {
1298
+ details: { template_id: id },
1299
+ nextActions: [commandAction(["sanbox", "templates", "delete", id, "--force", "--json"], "Delete the template from this organization while preserving existing runs.")]
1300
+ });
1301
+ }
1302
+ let payload;
1303
+ try {
1304
+ payload = await client.deleteTemplate(id);
1305
+ }
1306
+ catch (error) {
1307
+ if (error instanceof SanboxApiError) {
1308
+ throw new CliError(error.code, error.message, {
1309
+ status: error.status,
1310
+ details: { template_id: id },
1311
+ nextActions: error.code === "template_not_found"
1312
+ ? [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization.")]
1313
+ : []
1314
+ });
1315
+ }
1316
+ throw error;
1317
+ }
1318
+ const nextActions = [commandAction(["sanbox", "templates", "list", "--json"], "Confirm that the template is no longer available for new runs.")];
1319
+ if (hasFlag(flags, "json")) {
1320
+ printSuccess("templates.delete", payload, jsonContext(client), nextActions);
1321
+ return;
1322
+ }
1323
+ process.stdout.write(`deleted ${payload.template_id}\n`);
1324
+ return;
1325
+ }
1326
+ throw new CliError("templates_action_required", "templates requires an action: list, get, validate, create, or delete.");
1284
1327
  };
1285
1328
  const commandRuns = async (command, flags) => {
1286
1329
  const client = makeClient(flags);
@@ -1295,7 +1338,7 @@ const commandRuns = async (command, flags) => {
1295
1338
  return;
1296
1339
  }
1297
1340
  for (const run of payload.runs) {
1298
- process.stdout.write(`${run.id}\t${run.status}\t${run.created_at}\t${run.instruction.replace(/\s+/g, " ").slice(0, 100)}\n`);
1341
+ process.stdout.write(`${run.id}\t${run.state}\t${isoUtcTimestamp(run.created_at)}\t${run.instruction.replace(/\s+/g, " ").slice(0, 100)}\n`);
1299
1342
  }
1300
1343
  return;
1301
1344
  }
@@ -1367,7 +1410,7 @@ const commandRuns = async (command, flags) => {
1367
1410
  return;
1368
1411
  }
1369
1412
  for (const accessPoint of payload.access_points) {
1370
- process.stdout.write(`${accessPoint.id}\t${accessPoint.status}\t${accessPoint.expires_at}\t${accessPoint.name}\n`);
1413
+ process.stdout.write(`${accessPoint.id}\t${accessPoint.status}\t${isoUtcTimestamp(accessPoint.expires_at)}\t${accessPoint.name}\n`);
1371
1414
  }
1372
1415
  return;
1373
1416
  }
@@ -1435,24 +1478,26 @@ const commandRuns = async (command, flags) => {
1435
1478
  return;
1436
1479
  if (!hasFlag(flags, "jsonl"))
1437
1480
  printRun(payload);
1438
- if (isTerminalRun(payload.run) && payload.run.status !== "completed")
1481
+ if (isTerminalRun(payload.run) && !runSucceeded(payload.run))
1439
1482
  process.exitCode = 2;
1440
1483
  return;
1441
1484
  }
1442
- if (action === "cancel") {
1443
- const payload = await client.cancelRun(runId);
1485
+ if (action === "delete") {
1486
+ if (!hasFlag(flags, "yes")) {
1487
+ throw new CliError("confirmation_required", "Run deletion permanently removes its workspace. Re-run with --yes.");
1488
+ }
1489
+ const payload = await client.deleteRun(runId);
1444
1490
  if (hasFlag(flags, "json"))
1445
- printSuccess("runs.cancel", publicRunPayload(payload), jsonContext(client));
1491
+ printSuccess("runs.delete", publicRunPayload(payload), jsonContext(client));
1446
1492
  else
1447
- printRun(payload);
1493
+ process.stdout.write(`deletion requested for run ${runId}\n`);
1448
1494
  return;
1449
1495
  }
1450
- if (action === "resume" || action === "pause") {
1451
- const submitted = action === "resume"
1452
- ? await client.resumeRun(runId)
1453
- : await client.pauseRun(runId);
1496
+ if (action === "stop" || action === "resume") {
1497
+ const submitted = action === "resume" ? await client.resumeRun(runId) : await client.stopRun(runId);
1498
+ const target = action === "resume" ? "running" : "stopped";
1454
1499
  const payload = hasFlag(flags, "wait")
1455
- ? await waitForSandboxState(client, runId, action === "resume" ? "running" : "paused", {
1500
+ ? await waitForRunState(client, runId, target, {
1456
1501
  pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
1457
1502
  timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
1458
1503
  })
@@ -1464,7 +1509,7 @@ const commandRuns = async (command, flags) => {
1464
1509
  printRun(payload);
1465
1510
  }
1466
1511
  else {
1467
- process.stdout.write(`sandbox ${action} requested for run ${runId}\n`);
1512
+ process.stdout.write(`${action} requested for run ${runId}\n`);
1468
1513
  }
1469
1514
  return;
1470
1515
  }
@@ -1486,7 +1531,7 @@ const commandOpenCode = async (command, flags) => {
1486
1531
  ]);
1487
1532
  return;
1488
1533
  }
1489
- process.stdout.write(`URL ${payload.url}\nTOKEN ${payload.access_token}\nEXPIRES ${payload.connection.expires_at}\n`);
1534
+ process.stdout.write(`URL ${payload.url}\nTOKEN ${payload.access_token}\nEXPIRES ${isoUtcTimestamp(payload.connection.expires_at)}\n`);
1490
1535
  return;
1491
1536
  }
1492
1537
  if (action === "connections") {
@@ -1502,7 +1547,7 @@ const commandOpenCode = async (command, flags) => {
1502
1547
  return;
1503
1548
  }
1504
1549
  for (const connection of payload.connections) {
1505
- process.stdout.write(`${connection.id}\t${connection.status}\t${connection.expires_at}\t${connection.token_prefix}\n`);
1550
+ process.stdout.write(`${connection.id}\t${connection.status}\t${isoUtcTimestamp(connection.expires_at)}\t${connection.token_prefix}\n`);
1506
1551
  }
1507
1552
  return;
1508
1553
  }
package/dist/output.js CHANGED
@@ -1,5 +1,11 @@
1
1
  import { CliError } from "./errors.js";
2
2
  import { SanboxApiError } from "./api.js";
3
+ // Human-readable CLI output uses one canonical ISO 8601 UTC representation.
4
+ // Structured JSON output preserves the API payload unchanged.
5
+ export const isoUtcTimestamp = (value) => {
6
+ const date = new Date(value);
7
+ return Number.isFinite(date.getTime()) ? date.toISOString() : value;
8
+ };
3
9
  export const printJson = (value) => {
4
10
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
5
11
  };
@@ -73,18 +79,21 @@ export const publicRunPayload = (payload) => ({
73
79
  });
74
80
  export const summarizeRun = (payload) => {
75
81
  const { run } = payload;
82
+ const execution = run.latest_execution;
76
83
  const templateId = run.template_id || run.workload_id;
77
84
  const selection = [
78
85
  templateId ? `template=${templateId}` : "",
79
86
  run.provider_id ? `provider=${run.provider_id}` : "",
80
87
  run.model_id ? `model=${run.model_id}` : "",
81
- run.sandbox_state ? `sandbox=${run.sandbox_state}` : "",
82
- run.snapshot_generation ? `snapshot=${run.snapshot_generation}` : "",
88
+ `state=${run.state}`,
89
+ execution?.outcome
90
+ ? `result=${execution.outcome}`
91
+ : "",
83
92
  run.email?.address ? `email=${run.email.address}` : run.email ? `email=${run.email.status}` : "",
84
93
  run.channels?.telegram.enabled ? "telegram=enabled" : "",
85
94
  run.supabase ? `supabase=${run.supabase.status}` : ""
86
95
  ].filter(Boolean).join(" ");
87
- return `${run.id} ${run.status}${selection ? ` ${selection}` : ""}${run.exit_code === null ? "" : ` exit=${run.exit_code}`}${run.error ? ` error=${run.error}` : ""}`;
96
+ return `${run.id}${selection ? ` ${selection}` : ""}${execution?.exit_code == null ? "" : ` exit=${execution.exit_code}`}${execution?.error ? ` error=${execution.error}` : ""}`;
88
97
  };
89
98
  export const printRun = (payload) => {
90
99
  process.stdout.write(`${summarizeRun(payload)}\n`);
package/dist/runs.js CHANGED
@@ -2,8 +2,8 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { buildInputBundle } from "./inputs.js";
5
- const terminalStatuses = new Set(["completed", "failed", "canceled"]);
6
- export const isTerminalRun = (run) => terminalStatuses.has(run.status);
5
+ export const isTerminalRun = (run) => run.state === "stopped" || run.state === "deleting" || run.state === "deleted";
6
+ export const runSucceeded = (run) => run.latest_execution?.outcome === "completed";
7
7
  export const waitForRun = async (client, runId, options = {}) => {
8
8
  const pollIntervalMs = options.pollIntervalMs ?? 2000;
9
9
  const deadline = Date.now() + (options.timeoutSeconds ?? 1800) * 1000;
@@ -16,20 +16,23 @@ export const waitForRun = async (client, runId, options = {}) => {
16
16
  }
17
17
  return payload;
18
18
  };
19
- export const waitForSandboxState = async (client, runId, target, options = {}) => {
19
+ export const waitForRunState = async (client, runId, target, options = {}) => {
20
20
  const pollIntervalMs = options.pollIntervalMs ?? 2000;
21
21
  const deadline = Date.now() + (options.timeoutSeconds ?? 1800) * 1000;
22
22
  let payload = await client.getRun(runId);
23
- while (payload.run.sandbox_state !== target) {
24
- if (payload.run.sandbox_state === "error" || payload.run.sandbox_state === "deleted") {
25
- throw new Error(`Sandbox entered ${payload.run.sandbox_state} while waiting for ${target}.`);
23
+ while (payload.run.state !== target) {
24
+ if (payload.run.state === "deleting" || payload.run.state === "deleted") {
25
+ throw new Error("Run is being deleted.");
26
26
  }
27
- if (target === "running" && payload.run.sandbox_state === "paused") {
28
- throw new Error("Sandbox returned to paused before the manual lease became ready.");
29
- }
30
- if (Date.now() > deadline) {
31
- throw new Error(`Timed out waiting for sandbox ${runId} to become ${target}.`);
27
+ if (target === "running" && payload.run.state === "stopped") {
28
+ const lifecycleMessage = typeof payload.run.lifecycle_error?.message === "string"
29
+ ? payload.run.lifecycle_error.message
30
+ : null;
31
+ const detail = lifecycleMessage || payload.run.latest_execution?.error;
32
+ throw new Error(`Run ${runId} stopped before it became running${detail ? `: ${detail}` : "."}`);
32
33
  }
34
+ if (Date.now() > deadline)
35
+ throw new Error(`Timed out waiting for run ${runId} to become ${target}.`);
33
36
  await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
34
37
  payload = await client.getRun(runId);
35
38
  }
@@ -49,7 +52,7 @@ export const createRun = async (client, options) => {
49
52
  return client.createRun({
50
53
  external_run_id: options.externalRunId,
51
54
  workload_id: options.templateId,
52
- instruction: options.instruction,
55
+ ...(options.instruction ? { instruction: options.instruction } : {}),
53
56
  ...(options.supabaseUserId ? { supabase_user_id: options.supabaseUserId } : {}),
54
57
  ...(options.emailAddress ? { email_address: options.emailAddress } : {}),
55
58
  ...(options.telegramBotToken ? {
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.0.11";
1
+ export const version = "0.0.13";
package/dist/watch.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { SanboxApiError } from "./api.js";
2
- const terminalStatuses = new Set(["completed", "failed", "canceled"]);
3
- const terminalEventKinds = new Set(["run.completed", "run.failed", "run.canceled"]);
2
+ const terminalStates = new Set(["stopped", "deleting", "deleted"]);
3
+ const terminalEventKinds = new Set(["run.completed", "run.failed", "run.stopped"]);
4
4
  const retryableNetworkCodes = new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EAI_AGAIN", "ENETUNREACH"]);
5
5
  export class WatchInterruptedError extends Error {
6
6
  constructor() {
@@ -37,7 +37,7 @@ const assertActive = (signal, deadline, now, runId) => {
37
37
  if (now() >= deadline)
38
38
  throw new Error(`Timed out watching run ${runId}.`);
39
39
  };
40
- export const isTerminalStatus = (status) => terminalStatuses.has(status);
40
+ export const isTerminalStatus = (state) => terminalStates.has(state);
41
41
  export const watchEventsUntil = async (client, runId, options) => {
42
42
  const pageSize = Math.max(1, Math.min(500, Math.floor(options.pageSize ?? 200)));
43
43
  const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 2000));
@@ -156,7 +156,7 @@ export const watchRun = async (client, runId, options) => {
156
156
  fetchAnotherPage = reportedMore && cursor > previousCursor;
157
157
  }
158
158
  const payload = await request(() => client.getRun(runId, options.signal));
159
- if (isTerminalStatus(payload.run.status)) {
159
+ if (isTerminalStatus(payload.run.state)) {
160
160
  emptyTerminalReads = deliveredThisCycle === 0 ? emptyTerminalReads + 1 : 0;
161
161
  const requiredEmptyReads = sawTerminalEvent ? 1 : 2;
162
162
  if (emptyTerminalReads >= requiredEmptyReads)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanlabs/sanbox-cli",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",