@sanlabs/sanbox-cli 0.0.10 → 0.0.11

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,9 +15,11 @@ 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.10 adds per-run Hermes email and Telegram channels,
19
- Supabase user authorization, and removes the retired run-chat commands.
20
- CLI 0.0.9 added user login for private SSH access to running Hermes Computers.
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.
20
+ CLI 0.0.10 added per-run Hermes email and Telegram channels, Supabase user authorization, and
21
+ removed the retired run-chat commands.
22
+ CLI 0.0.9 added user login for private SSH access to supported running sandboxes.
21
23
  CLI 0.0.6 added Anthropic self-hosted environment inspection and administration.
22
24
 
23
25
  ## Configure
@@ -82,8 +84,20 @@ sanbox doctor --json
82
84
 
83
85
  Model IDs are provider-scoped. The CLI never guesses or silently substitutes a provider, model, or template.
84
86
  For waited task runs, choose a template with `runnable: true`, `template_type: "runner"`, and
85
- `runner_config.harness: "opencode"` or `"browser-use"`. Browser Use is for one-shot web tasks whose
86
- target domains are already approved. Hermes service templates are always-on. A Hermes template
87
+ `runner_config.harness: "opencode"` or `"browser-use"`. OpenCode remains the default one-shot mode,
88
+ while OpenCode Computer runs a retained private OpenCode server:
89
+
90
+ ```bash
91
+ sanbox templates create \
92
+ --name "OpenCode Computer" \
93
+ --harness opencode \
94
+ --mode computer \
95
+ --model-provider openai \
96
+ --model '<model-id>'
97
+ ```
98
+
99
+ Existing templates keep their current names, IDs, slugs, and task behavior. Browser Use is for one-shot
100
+ web tasks whose target domains are already approved. Hermes service templates are always-on. A Hermes template
87
101
  declares which optional channels its runs may activate:
88
102
 
89
103
  ```bash
@@ -244,22 +258,57 @@ access until Pause creates the next snapshot generation. Before resuming, requir
244
258
  `sandbox_state: "paused"` and a positive `snapshot_generation`. Do not submit agent work while that
245
259
  lease is active. Paused state has no retention TTL and remains available until explicitly deleted.
246
260
 
247
- ## SSH Into A Hermes Computer
261
+ ## SSH Into A Running Sandbox
248
262
 
249
263
  ```bash
250
264
  sanbox login
251
265
  sanbox ssh <run-id>
252
266
  ```
253
267
 
254
- The run must be a currently running Hermes Computer created from the SSH-capable template artifact.
268
+ The run must be a currently running OpenCode run or Hermes Computer created from an SSH-capable
269
+ artifact.
255
270
  The signed-in user must own the run or be an organization admin. The command uses your local OpenSSH
256
271
  client, but it does not expose the microVM on a public IP or port. The CLI generates a temporary
257
272
  Ed25519 identity, pins the microVM's runtime host key, and tunnels the SSH stream over a one-time
258
273
  authenticated WebSocket. The temporary key is deleted when the connection closes, and the saved
259
274
  user token is not passed to the OpenSSH child process.
260
275
 
261
- OpenCode and Browser Use runs are one-shot agent executions. Create a new run for additional agent
262
- work. A persisted sandbox can still be resumed manually for inspection when a snapshot exists.
276
+ 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.
279
+
280
+ ## Connect The OpenCode SDK
281
+
282
+ Create a short-lived connection for a currently running OpenCode Computer:
283
+
284
+ ```bash
285
+ sanbox opencode connect <run-id> --expires 1h --json
286
+ sanbox opencode connections list <run-id> --json
287
+ sanbox opencode connections revoke <run-id> <connection-id> --json
288
+ ```
289
+
290
+ The create response contains `url`, `access_token`, and connection metadata. The plaintext token is
291
+ returned only once, but it can authenticate all OpenCode SDK requests and SSE reconnects until it
292
+ expires, is revoked, or the runtime session ends. Multiple connections can coexist. Organization API
293
+ keys use their existing organization role and require member access or higher; no additional API-key
294
+ or connection-token scopes are introduced.
295
+
296
+ ```ts
297
+ import { createOpencodeClient } from "@opencode-ai/sdk"
298
+
299
+ const client = createOpencodeClient({
300
+ baseUrl: connection.url,
301
+ headers: { Authorization: `Bearer ${connection.access_token}` },
302
+ })
303
+
304
+ const events = await client.event.subscribe()
305
+ ```
306
+
307
+ Sanbox proxies the native HTTP and SSE API through its private Firecracker management path. The
308
+ microVM never exposes an inbound port. Only security-sensitive OpenCode configuration,
309
+ authentication, credential mutation, server-administration, raw PTY, and public-share routes are
310
+ denied; core session, event, file, command, and session-shell APIs remain native. See
311
+ [OpenCode SDK access](../docs/opencode-sdk-access.md) for the API and lifecycle contract.
263
312
 
264
313
  ## Batch Work
265
314
 
package/dist/api.js CHANGED
@@ -231,6 +231,18 @@ export class SanboxClient {
231
231
  async revokeFileAccessPoint(runId, accessPointId) {
232
232
  return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/file-access-points/${encodeURIComponent(accessPointId)}`), { method: "DELETE" });
233
233
  }
234
+ async listOpenCodeConnections(runId) {
235
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/opencode-connections`));
236
+ }
237
+ async createOpenCodeConnection(runId, expiresInSeconds) {
238
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/opencode-connections`), {
239
+ method: "POST",
240
+ body: JSON.stringify({ expires_in_seconds: expiresInSeconds })
241
+ });
242
+ }
243
+ async revokeOpenCodeConnection(runId, connectionId) {
244
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/opencode-connections/${encodeURIComponent(connectionId)}`), { method: "DELETE" });
245
+ }
234
246
  async downloadArtifact(runId, artifactPath) {
235
247
  return this.rawRequest(`${await this.orgPath(`/runs/${encodeURIComponent(runId)}/artifacts`)}?path=${encodeURIComponent(artifactPath)}`);
236
248
  }
package/dist/cli.js CHANGED
@@ -40,7 +40,7 @@ Commands:
40
40
  sanbox templates list [--json]
41
41
  sanbox templates get <template-id> [--json]
42
42
  sanbox templates validate <template-id> [--json]
43
- sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--channel email|telegram] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
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
44
  sanbox run "task" --template <template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
45
45
  sanbox run --task "..." --template <template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
46
46
  sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
@@ -57,6 +57,9 @@ Commands:
57
57
  sanbox runs resume <run-id> [--wait] [--json]
58
58
  sanbox runs pause <run-id> [--wait] [--json]
59
59
  sanbox runs supabase authorize <run-id> [--open] [--return-url <https-url>] [--json]
60
+ sanbox opencode connect <run-id> [--expires 1h] [--json]
61
+ sanbox opencode connections list <run-id> [--json]
62
+ sanbox opencode connections revoke <run-id> <connection-id> [--json]
60
63
  sanbox ssh <run-id>
61
64
  sanbox init [--force]
62
65
  sanbox init agent [--write]
@@ -66,12 +69,24 @@ const sshHelp = `Sanbox SSH
66
69
  Usage:
67
70
  sanbox ssh <run-id>
68
71
 
69
- Opens the running Hermes Computer with your local OpenSSH client. Sanbox creates an ephemeral
70
- Ed25519 key, authorizes it for one connection, verifies the microVM host key, and tunnels SSH over
71
- the authenticated private management path. Run sanbox login first. The computer owner and
72
+ Opens a running OpenCode run or Hermes Computer with your local OpenSSH client. Sanbox creates an
73
+ ephemeral Ed25519 key, authorizes it for one connection, verifies the microVM host key, and tunnels
74
+ SSH over the authenticated private management path. Run sanbox login first. The run owner and
72
75
  organization admins can connect; organization API keys cannot open interactive sessions. The guest
73
76
  does not expose port 22 publicly.
74
77
  `;
78
+ const openCodeHelp = `Sanbox OpenCode SDK access
79
+
80
+ Usage:
81
+ sanbox opencode connect <run-id> [--expires 1h] [--json]
82
+ sanbox opencode connections list <run-id> [--json]
83
+ sanbox opencode connections revoke <run-id> <connection-id> [--json]
84
+
85
+ Creates short-lived bearer credentials for the native OpenCode SDK. The token is bound to one
86
+ running OpenCode Computer and its current runtime session. Its plaintext value is returned only
87
+ when created. Multiple connections may be active at once; each expires and can be revoked
88
+ independently. Organization API keys require member access or higher.
89
+ `;
75
90
  const runHelp = `Sanbox run
76
91
 
77
92
  Usage:
@@ -144,10 +159,12 @@ Usage:
144
159
  sanbox templates list [--json]
145
160
  sanbox templates get <template-id> [--json]
146
161
  sanbox templates validate <template-id> [--json]
147
- sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
162
+ 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]
148
163
 
149
164
  Template creation requires an exact provider id and that provider's exact model id.
150
165
  LiteLLM budgets are optional USD amounts and apply separately to each run.
166
+ OpenCode defaults to --mode task. Use --mode computer for a retained private OpenCode server;
167
+ per-run LiteLLM budgets are not available for that retained mode yet.
151
168
  Hermes templates are always-on computers. Use repeatable --channel email|telegram to select which
152
169
  channels runs may activate. Email and Telegram credentials are supplied only when creating a run.
153
170
  Browser Use templates run local headless Chromium inside a one-shot sandbox. They require OpenAI
@@ -435,6 +452,7 @@ const flagSets = {
435
452
  "model-provider",
436
453
  "model",
437
454
  "harness",
455
+ "mode",
438
456
  "llm-budget-usd",
439
457
  "web-access",
440
458
  "channel",
@@ -473,6 +491,8 @@ const flagSets = {
473
491
  "runs.resume": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
474
492
  "runs.pause": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
475
493
  "runs.supabase": [...commonFlags, "open", "return-url"],
494
+ "opencode.connect": [...commonFlags, "expires"],
495
+ "opencode.connections": commonFlags,
476
496
  ssh: ["api-url", "help"],
477
497
  "ssh-proxy": [],
478
498
  login: ["api-url", "json", "no-browser", "help"],
@@ -490,10 +510,13 @@ const commandKey = (command) => {
490
510
  return `auth.${command[1] || ""}`;
491
511
  if (command[0] === "runs" && command[1] === "supabase")
492
512
  return "runs.supabase";
513
+ if (command[0] === "opencode" && command[1] === "connections")
514
+ return "opencode.connections";
493
515
  if (command[0] === "model-providers" ||
494
516
  command[0] === "anthropic-environments" ||
495
517
  command[0] === "templates" ||
496
- command[0] === "runs") {
518
+ command[0] === "runs" ||
519
+ command[0] === "opencode") {
497
520
  return `${command[0]}.${command[1] || ""}`;
498
521
  }
499
522
  if (command[0] === "init" && command[1] === "agent")
@@ -506,7 +529,7 @@ const requiredValueFlags = new Set([
506
529
  "telegram-bot-token", "telegram-allowed-user", "channel",
507
530
  "tasks", "max-parallel", "batch-id", "poll-interval-ms",
508
531
  "event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
509
- "model-provider", "model", "harness", "llm-budget-usd",
532
+ "model-provider", "model", "harness", "mode", "llm-budget-usd",
510
533
  "browser-domain", "browser-max-steps", "browser-step-timeout-seconds",
511
534
  "browser-vision-mode", "browser-viewport", "browser-download-policy",
512
535
  "browser-additional-instructions", "expires", "output", "artifact"
@@ -548,7 +571,7 @@ const validateFlags = (command, flags) => {
548
571
  }
549
572
  const knownRoots = new Set([
550
573
  "auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
551
- "run", "batch", "runs", "ssh", "ssh-proxy", "login", "logout", "init", "version"
574
+ "run", "batch", "runs", "opencode", "ssh", "ssh-proxy", "login", "logout", "init", "version"
552
575
  ]);
553
576
  const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
554
577
  if (!allowed)
@@ -590,6 +613,8 @@ const validatePositionals = (command, flags) => {
590
613
  "runs.resume": 3,
591
614
  "runs.pause": 3,
592
615
  "runs.supabase": 4,
616
+ "opencode.connect": 3,
617
+ "opencode.connections": 5,
593
618
  ssh: 2,
594
619
  "ssh-proxy": 1,
595
620
  login: 1,
@@ -1144,6 +1169,14 @@ const commandTemplates = async (command, flags) => {
1144
1169
  if (harness !== "opencode" && harness !== "hermes" && harness !== "browser-use") {
1145
1170
  throw new CliError("invalid_harness", "templates create --harness must be opencode, hermes, or browser-use.");
1146
1171
  }
1172
+ const modeFlag = flagString(flags, "mode");
1173
+ const mode = modeFlag || "task";
1174
+ if (mode !== "task" && mode !== "computer") {
1175
+ throw new CliError("invalid_opencode_mode", "templates create --mode must be task or computer.");
1176
+ }
1177
+ if (harness !== "opencode" && modeFlag) {
1178
+ throw new CliError("opencode_mode_not_supported", "templates create --mode requires --harness opencode.");
1179
+ }
1147
1180
  const budgetRaw = flagString(flags, "llm-budget-usd");
1148
1181
  const parsedBudgetUsd = budgetRaw ? Number(budgetRaw) : undefined;
1149
1182
  const llmBudgetUsd = parsedBudgetUsd === undefined
@@ -1156,6 +1189,9 @@ const commandTemplates = async (command, flags) => {
1156
1189
  if (harness === "hermes" && llmBudgetUsd !== undefined) {
1157
1190
  throw new CliError("hermes_budget_unsupported", "Always-on Hermes templates do not support --llm-budget-usd yet.");
1158
1191
  }
1192
+ if (harness === "opencode" && mode === "computer" && llmBudgetUsd !== undefined) {
1193
+ throw new CliError("opencode_computer_budget_unsupported", "OpenCode Computer templates do not support --llm-budget-usd yet.");
1194
+ }
1159
1195
  const channels = [...new Set(flagList(flags, "channel").map((value) => value.trim()).filter(Boolean))];
1160
1196
  if (channels.some((channel) => channel !== "email" && channel !== "telegram")) {
1161
1197
  throw new CliError("invalid_hermes_channels", "--channel must be email or telegram.");
@@ -1194,6 +1230,7 @@ const commandTemplates = async (command, flags) => {
1194
1230
  name,
1195
1231
  provider_id: modelProvider,
1196
1232
  model_id: model,
1233
+ ...(harness === "opencode" && modeFlag ? { mode: mode } : {}),
1197
1234
  ...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd }),
1198
1235
  ...(harness === "browser-use" ? {
1199
1236
  harness,
@@ -1224,11 +1261,17 @@ const commandTemplates = async (command, flags) => {
1224
1261
  commandAction([
1225
1262
  "sanbox",
1226
1263
  "run",
1227
- harness === "hermes" ? "<agent role and instructions>" : "<task>",
1264
+ harness === "hermes"
1265
+ ? "<agent role and instructions>"
1266
+ : harness === "opencode" && mode === "computer"
1267
+ ? "<computer role and instructions>"
1268
+ : "<task>",
1228
1269
  "--template",
1229
1270
  payload.template.id
1230
- ], harness === "hermes"
1231
- ? "Start the always-on computer and activate only the channels this run needs."
1271
+ ], harness === "hermes" || (harness === "opencode" && mode === "computer")
1272
+ ? harness === "hermes"
1273
+ ? "Start the always-on computer and activate only the channels this run needs."
1274
+ : "Start the retained OpenCode computer."
1232
1275
  : "Create a run with the new template.")
1233
1276
  ]);
1234
1277
  return;
@@ -1427,6 +1470,57 @@ const commandRuns = async (command, flags) => {
1427
1470
  }
1428
1471
  throw new Error(`Unknown runs action: ${action}`);
1429
1472
  };
1473
+ const commandOpenCode = async (command, flags) => {
1474
+ const client = makeClient(flags);
1475
+ const action = command[1];
1476
+ if (action === "connect") {
1477
+ const runId = requiredPositional(command[2], "run_id_required", "opencode connect requires a run id.");
1478
+ const expiresInSeconds = parseFileAccessExpiry(flagString(flags, "expires", "1h"));
1479
+ if (expiresInSeconds > 24 * 60 * 60) {
1480
+ throw new CliError("invalid_opencode_connection_expiry", "OpenCode connection expiry must be between 1 minute and 1 day.");
1481
+ }
1482
+ const payload = await client.createOpenCodeConnection(runId, expiresInSeconds);
1483
+ if (hasFlag(flags, "json")) {
1484
+ printSuccess("opencode.connect", payload, jsonContext(client), [
1485
+ commandAction(["sanbox", "opencode", "connections", "revoke", runId, payload.connection.id, "--json"], "Revoke this OpenCode SDK connection.")
1486
+ ]);
1487
+ return;
1488
+ }
1489
+ process.stdout.write(`URL ${payload.url}\nTOKEN ${payload.access_token}\nEXPIRES ${payload.connection.expires_at}\n`);
1490
+ return;
1491
+ }
1492
+ if (action === "connections") {
1493
+ const connectionAction = command[2];
1494
+ if (connectionAction === "list") {
1495
+ const runId = requiredPositional(command[3], "run_id_required", "opencode connections list requires a run id.");
1496
+ if (command[4]) {
1497
+ throw new CliError("unexpected_argument", `Unexpected positional argument: ${command[4]}`);
1498
+ }
1499
+ const payload = await client.listOpenCodeConnections(runId);
1500
+ if (hasFlag(flags, "json")) {
1501
+ printSuccess("opencode.connections.list", payload, jsonContext(client));
1502
+ return;
1503
+ }
1504
+ for (const connection of payload.connections) {
1505
+ process.stdout.write(`${connection.id}\t${connection.status}\t${connection.expires_at}\t${connection.token_prefix}\n`);
1506
+ }
1507
+ return;
1508
+ }
1509
+ if (connectionAction === "revoke") {
1510
+ const runId = requiredPositional(command[3], "run_id_required", "opencode connections revoke requires a run id.");
1511
+ const connectionId = requiredPositional(command[4], "opencode_connection_id_required", "opencode connections revoke requires a connection id.");
1512
+ const payload = await client.revokeOpenCodeConnection(runId, connectionId);
1513
+ if (hasFlag(flags, "json")) {
1514
+ printSuccess("opencode.connections.revoke", payload, jsonContext(client));
1515
+ return;
1516
+ }
1517
+ process.stdout.write(`revoked ${payload.connection.id}\n`);
1518
+ return;
1519
+ }
1520
+ throw new CliError("opencode_connections_action_required", "opencode connections requires an action: list or revoke.");
1521
+ }
1522
+ throw new CliError("opencode_action_required", "opencode requires an action: connect or connections.");
1523
+ };
1430
1524
  const commandDoctor = async (flags) => {
1431
1525
  const localConfig = readLocalConfig();
1432
1526
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
@@ -1674,6 +1768,8 @@ const helpFor = (command) => {
1674
1768
  return anthropicEnvironmentsHelp;
1675
1769
  if (command[0] === "templates")
1676
1770
  return templatesHelp;
1771
+ if (command[0] === "opencode")
1772
+ return openCodeHelp;
1677
1773
  if (command[0] === "ssh")
1678
1774
  return sshHelp;
1679
1775
  return help;
@@ -1701,6 +1797,11 @@ const commandId = (command) => {
1701
1797
  }
1702
1798
  if (command[0] === "runs")
1703
1799
  return `runs.${command[1] || "unknown"}`;
1800
+ if (command[0] === "opencode" && command[1] === "connections") {
1801
+ return `opencode.connections.${command[2] || "unknown"}`;
1802
+ }
1803
+ if (command[0] === "opencode")
1804
+ return `opencode.${command[1] || "unknown"}`;
1704
1805
  if (command[0] === "ssh" || command[0] === "ssh-proxy")
1705
1806
  return command[0];
1706
1807
  return command[0] || "help";
@@ -1748,6 +1849,8 @@ const main = async (command, flags) => {
1748
1849
  return commandBatch(flags);
1749
1850
  if (command[0] === "runs")
1750
1851
  return commandRuns(command, flags);
1852
+ if (command[0] === "opencode")
1853
+ return commandOpenCode(command, flags);
1751
1854
  if (command[0] === "ssh")
1752
1855
  return commandSSH(command, flags);
1753
1856
  if (command[0] === "ssh-proxy") {
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.0.10";
1
+ export const version = "0.0.11";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanlabs/sanbox-cli",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",