@sanlabs/sanbox-cli 0.0.10 → 0.0.12
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 +81 -17
- package/dist/activity.js +18 -1
- package/dist/api.js +22 -8
- package/dist/args.js +2 -1
- package/dist/cli.js +201 -58
- package/dist/output.js +12 -3
- package/dist/runs.js +14 -11
- package/dist/version.js +1 -1
- package/dist/watch.js +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,9 +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.
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
Always use the latest published CLI. CLI 0.0.12 adopts the Stop, Resume, and Delete lifecycle,
|
|
19
|
+
separates run state from execution outcome, retains scrubbed deleted-run tombstones, and adds
|
|
20
|
+
administrator-confirmed template deletion.
|
|
21
|
+
CLI 0.0.11 added OpenCode Computer templates and short-lived native SDK connections for HTTP, SSE
|
|
22
|
+
streaming, and interactive steering.
|
|
23
|
+
CLI 0.0.10 added per-run Hermes email and Telegram channels, Supabase user authorization, and
|
|
24
|
+
removed the retired run-chat commands.
|
|
25
|
+
CLI 0.0.9 added user login for private SSH access to supported running sandboxes.
|
|
21
26
|
CLI 0.0.6 added Anthropic self-hosted environment inspection and administration.
|
|
22
27
|
|
|
23
28
|
## Configure
|
|
@@ -82,8 +87,20 @@ sanbox doctor --json
|
|
|
82
87
|
|
|
83
88
|
Model IDs are provider-scoped. The CLI never guesses or silently substitutes a provider, model, or template.
|
|
84
89
|
For waited task runs, choose a template with `runnable: true`, `template_type: "runner"`, and
|
|
85
|
-
`runner_config.harness: "opencode"` or `"browser-use"`.
|
|
86
|
-
|
|
90
|
+
`runner_config.harness: "opencode"` or `"browser-use"`. OpenCode remains the default one-shot mode,
|
|
91
|
+
while OpenCode Computer runs a retained private OpenCode server:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
sanbox templates create \
|
|
95
|
+
--name "OpenCode Computer" \
|
|
96
|
+
--harness opencode \
|
|
97
|
+
--mode computer \
|
|
98
|
+
--model-provider openai \
|
|
99
|
+
--model '<model-id>'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Existing templates keep their current names, IDs, slugs, and task behavior. Browser Use is for one-shot
|
|
103
|
+
web tasks whose target domains are already approved. Hermes service templates are always-on. A Hermes template
|
|
87
104
|
declares which optional channels its runs may activate:
|
|
88
105
|
|
|
89
106
|
```bash
|
|
@@ -99,6 +116,15 @@ sanbox templates create \
|
|
|
99
116
|
Omit both `--channel` flags for a WebUI-only computer. Channel credentials never belong to the
|
|
100
117
|
template.
|
|
101
118
|
|
|
119
|
+
Delete a template when it should no longer be available for new runs:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
sanbox templates delete <template-id-or-slug> --force --json
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Deletion preserves existing runs and retained sandboxes. It requires organization-admin access
|
|
126
|
+
and `--force` so scripts cannot remove a template accidentally.
|
|
127
|
+
|
|
102
128
|
## Create A Browser Use Template
|
|
103
129
|
|
|
104
130
|
Browser Use runs local headless Chromium inside the Firecracker sandbox. It requires OpenAI,
|
|
@@ -186,7 +212,7 @@ callback lands on a Sanbox-hosted completion page, so a CLI demo does not need a
|
|
|
186
212
|
Customer integrations may use `--return-url <https-url>` when its origin matches the return origin
|
|
187
213
|
configured on the organization Supabase connection.
|
|
188
214
|
|
|
189
|
-
Reuse the same external ID when retrying an ambiguous submission. To stream activity, replace `--wait --json` with `--jsonl`. Ctrl-C detaches without
|
|
215
|
+
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.
|
|
190
216
|
|
|
191
217
|
## Inspect And Recover
|
|
192
218
|
|
|
@@ -229,37 +255,75 @@ working when the exact sandbox session stops, the link expires, or it is revoked
|
|
|
229
255
|
when created and must be handled as a bearer secret. See
|
|
230
256
|
[Live Filesystem Access](../docs/live-filesystem-access.md) for the HTTP contract and exclusions.
|
|
231
257
|
|
|
232
|
-
## Resume Or
|
|
258
|
+
## Stop, Resume, Or Delete A Run
|
|
233
259
|
|
|
234
260
|
```bash
|
|
235
261
|
sanbox runs get <run-id> --json
|
|
236
262
|
sanbox runs resume <run-id> --wait --json
|
|
237
263
|
sanbox runs share <run-id> --expires 1h --json
|
|
238
|
-
sanbox runs
|
|
264
|
+
sanbox runs stop <run-id> --wait --json
|
|
265
|
+
sanbox runs delete <run-id> --yes --json
|
|
239
266
|
```
|
|
240
267
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
`
|
|
245
|
-
|
|
268
|
+
Stop terminates compute and durably syncs the workspace. It does not retain RAM, process state, or
|
|
269
|
+
network connections. Resume fresh-boots the pinned runtime artifact with the retained workspace; it
|
|
270
|
+
does not silently replay a completed task. `state` is the single run phase; inspect
|
|
271
|
+
`latest_execution.outcome` for the last bounded execution outcome and `workspace.saved_at` for disk
|
|
272
|
+
persistence proof. Stopped runs have no automatic TTL. Delete permanently removes the workspace
|
|
273
|
+
after archiving usage. The control plane retains a scrubbed run tombstone, but normal CLI run listings
|
|
274
|
+
exclude deleted runs.
|
|
246
275
|
|
|
247
|
-
## SSH Into A
|
|
276
|
+
## SSH Into A Running Sandbox
|
|
248
277
|
|
|
249
278
|
```bash
|
|
250
279
|
sanbox login
|
|
251
280
|
sanbox ssh <run-id>
|
|
252
281
|
```
|
|
253
282
|
|
|
254
|
-
The run must be a currently running Hermes Computer created from
|
|
283
|
+
The run must be a currently running OpenCode run or Hermes Computer created from an SSH-capable
|
|
284
|
+
artifact.
|
|
255
285
|
The signed-in user must own the run or be an organization admin. The command uses your local OpenSSH
|
|
256
286
|
client, but it does not expose the microVM on a public IP or port. The CLI generates a temporary
|
|
257
287
|
Ed25519 identity, pins the microVM's runtime host key, and tunnels the SSH stream over a one-time
|
|
258
288
|
authenticated WebSocket. The temporary key is deleted when the connection closes, and the saved
|
|
259
289
|
user token is not passed to the OpenSSH child process.
|
|
260
290
|
|
|
261
|
-
OpenCode and Browser Use runs are one-shot agent executions.
|
|
262
|
-
|
|
291
|
+
OpenCode and Browser Use runs are one-shot agent executions. OpenCode Computer keeps its private
|
|
292
|
+
server running after the initial task so it can accept later steering. A persisted task workspace can
|
|
293
|
+
still be fresh-booted manually for inspection after the run stops.
|
|
294
|
+
|
|
295
|
+
## Connect The OpenCode SDK
|
|
296
|
+
|
|
297
|
+
Create a short-lived connection for a currently running OpenCode Computer:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
sanbox opencode connect <run-id> --expires 1h --json
|
|
301
|
+
sanbox opencode connections list <run-id> --json
|
|
302
|
+
sanbox opencode connections revoke <run-id> <connection-id> --json
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
The create response contains `url`, `access_token`, and connection metadata. The plaintext token is
|
|
306
|
+
returned only once, but it can authenticate all OpenCode SDK requests and SSE reconnects until it
|
|
307
|
+
expires, is revoked, or the runtime session ends. Multiple connections can coexist. Organization API
|
|
308
|
+
keys use their existing organization role and require member access or higher; no additional API-key
|
|
309
|
+
or connection-token scopes are introduced.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
import { createOpencodeClient } from "@opencode-ai/sdk"
|
|
313
|
+
|
|
314
|
+
const client = createOpencodeClient({
|
|
315
|
+
baseUrl: connection.url,
|
|
316
|
+
headers: { Authorization: `Bearer ${connection.access_token}` },
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const events = await client.event.subscribe()
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Sanbox proxies the native HTTP and SSE API through its private Firecracker management path. The
|
|
323
|
+
microVM never exposes an inbound port. Only security-sensitive OpenCode configuration,
|
|
324
|
+
authentication, credential mutation, server-administration, raw PTY, and public-share routes are
|
|
325
|
+
denied; core session, event, file, command, and session-shell APIs remain native. See
|
|
326
|
+
[OpenCode SDK access](../docs/opencode-sdk-access.md) for the API and lifecycle contract.
|
|
263
327
|
|
|
264
328
|
## Batch Work
|
|
265
329
|
|
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 :
|
|
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
|
|
202
|
-
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/
|
|
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
|
|
208
|
-
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}
|
|
209
|
-
method: "
|
|
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
|
|
214
|
-
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/
|
|
215
|
+
async resumeRun(runId) {
|
|
216
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/resume`), {
|
|
215
217
|
method: "POST",
|
|
216
218
|
body: "{}"
|
|
217
219
|
});
|
|
@@ -231,6 +233,18 @@ export class SanboxClient {
|
|
|
231
233
|
async revokeFileAccessPoint(runId, accessPointId) {
|
|
232
234
|
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/file-access-points/${encodeURIComponent(accessPointId)}`), { method: "DELETE" });
|
|
233
235
|
}
|
|
236
|
+
async listOpenCodeConnections(runId) {
|
|
237
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/opencode-connections`));
|
|
238
|
+
}
|
|
239
|
+
async createOpenCodeConnection(runId, expiresInSeconds) {
|
|
240
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/opencode-connections`), {
|
|
241
|
+
method: "POST",
|
|
242
|
+
body: JSON.stringify({ expires_in_seconds: expiresInSeconds })
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
async revokeOpenCodeConnection(runId, connectionId) {
|
|
246
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/opencode-connections/${encodeURIComponent(connectionId)}`), { method: "DELETE" });
|
|
247
|
+
}
|
|
234
248
|
async downloadArtifact(runId, artifactPath) {
|
|
235
249
|
return this.rawRequest(`${await this.orgPath(`/runs/${encodeURIComponent(runId)}/artifacts`)}?path=${encodeURIComponent(artifactPath)}`);
|
|
236
250
|
}
|
package/dist/args.js
CHANGED
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,
|
|
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";
|
|
@@ -40,7 +40,8 @@ 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
|
+
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]
|
|
46
47
|
sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
|
|
@@ -52,11 +53,14 @@ Commands:
|
|
|
52
53
|
sanbox runs shares <run-id> [--json]
|
|
53
54
|
sanbox runs unshare <run-id> <access-point-id> [--json]
|
|
54
55
|
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
|
|
56
|
+
sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--stop-on-interrupt] [--jsonl]
|
|
57
|
+
sanbox runs stop <run-id> [--wait] [--json]
|
|
57
58
|
sanbox runs resume <run-id> [--wait] [--json]
|
|
58
|
-
sanbox runs
|
|
59
|
+
sanbox runs delete <run-id> --yes [--json]
|
|
59
60
|
sanbox runs supabase authorize <run-id> [--open] [--return-url <https-url>] [--json]
|
|
61
|
+
sanbox opencode connect <run-id> [--expires 1h] [--json]
|
|
62
|
+
sanbox opencode connections list <run-id> [--json]
|
|
63
|
+
sanbox opencode connections revoke <run-id> <connection-id> [--json]
|
|
60
64
|
sanbox ssh <run-id>
|
|
61
65
|
sanbox init [--force]
|
|
62
66
|
sanbox init agent [--write]
|
|
@@ -66,12 +70,24 @@ const sshHelp = `Sanbox SSH
|
|
|
66
70
|
Usage:
|
|
67
71
|
sanbox ssh <run-id>
|
|
68
72
|
|
|
69
|
-
Opens
|
|
70
|
-
Ed25519 key, authorizes it for one connection, verifies the microVM host key, and tunnels
|
|
71
|
-
the authenticated private management path. Run sanbox login first. The
|
|
73
|
+
Opens a running OpenCode run or Hermes Computer with your local OpenSSH client. Sanbox creates an
|
|
74
|
+
ephemeral Ed25519 key, authorizes it for one connection, verifies the microVM host key, and tunnels
|
|
75
|
+
SSH over the authenticated private management path. Run sanbox login first. The run owner and
|
|
72
76
|
organization admins can connect; organization API keys cannot open interactive sessions. The guest
|
|
73
77
|
does not expose port 22 publicly.
|
|
74
78
|
`;
|
|
79
|
+
const openCodeHelp = `Sanbox OpenCode SDK access
|
|
80
|
+
|
|
81
|
+
Usage:
|
|
82
|
+
sanbox opencode connect <run-id> [--expires 1h] [--json]
|
|
83
|
+
sanbox opencode connections list <run-id> [--json]
|
|
84
|
+
sanbox opencode connections revoke <run-id> <connection-id> [--json]
|
|
85
|
+
|
|
86
|
+
Creates short-lived bearer credentials for the native OpenCode SDK. The token is bound to one
|
|
87
|
+
running OpenCode Computer and its current runtime session. Its plaintext value is returned only
|
|
88
|
+
when created. Multiple connections may be active at once; each expires and can be revoked
|
|
89
|
+
independently. Organization API keys require member access or higher.
|
|
90
|
+
`;
|
|
75
91
|
const runHelp = `Sanbox run
|
|
76
92
|
|
|
77
93
|
Usage:
|
|
@@ -87,12 +103,12 @@ Options:
|
|
|
87
103
|
--telegram-bot-token <token> Optional Telegram bot token; SANBOX_TELEGRAM_BOT_TOKEN is also supported.
|
|
88
104
|
--telegram-allowed-user <id> Numeric Telegram user allowed to reach this run. Repeatable.
|
|
89
105
|
--dry-run Preview included files without creating a run.
|
|
90
|
-
--wait Poll until terminal
|
|
91
|
-
--watch Stream activity until terminal
|
|
106
|
+
--wait Poll until a terminal run state.
|
|
107
|
+
--watch Stream activity until a terminal run state.
|
|
92
108
|
--jsonl Stream one versioned activity event per line. Implies --watch.
|
|
93
|
-
--view <name> activity,
|
|
109
|
+
--view <name> activity, compact, or historical logs. Default: activity.
|
|
94
110
|
--after-event-id <id> Resume after an event cursor. Default: 0.
|
|
95
|
-
--
|
|
111
|
+
--stop-on-interrupt Request Stop when Ctrl-C is pressed.
|
|
96
112
|
--json Print JSON.
|
|
97
113
|
`;
|
|
98
114
|
const runsSupabaseHelp = `Sanbox Supabase run authorization
|
|
@@ -144,10 +160,13 @@ Usage:
|
|
|
144
160
|
sanbox templates list [--json]
|
|
145
161
|
sanbox templates get <template-id> [--json]
|
|
146
162
|
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]
|
|
163
|
+
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]
|
|
164
|
+
sanbox templates delete <template-id> --force [--json]
|
|
148
165
|
|
|
149
166
|
Template creation requires an exact provider id and that provider's exact model id.
|
|
150
167
|
LiteLLM budgets are optional USD amounts and apply separately to each run.
|
|
168
|
+
OpenCode defaults to --mode task. Use --mode computer for a retained private OpenCode server;
|
|
169
|
+
per-run LiteLLM budgets are not available for that retained mode yet.
|
|
151
170
|
Hermes templates are always-on computers. Use repeatable --channel email|telegram to select which
|
|
152
171
|
channels runs may activate. Email and Telegram credentials are supplied only when creating a run.
|
|
153
172
|
Browser Use templates run local headless Chromium inside a one-shot sandbox. They require OpenAI
|
|
@@ -155,6 +174,7 @@ or Anthropic and at least one repeatable --browser-domain hostname or leading wi
|
|
|
155
174
|
Optional controls: --browser-max-steps, --browser-step-timeout-seconds,
|
|
156
175
|
--browser-vision-mode auto|always|never, --browser-viewport WIDTHxHEIGHT,
|
|
157
176
|
--browser-download-policy allow|deny, and --browser-additional-instructions.
|
|
177
|
+
Deleting a template requires --force. Existing runs and their retained sandboxes are preserved.
|
|
158
178
|
`;
|
|
159
179
|
const agentInstructions = `# Operate Sanbox Autonomously
|
|
160
180
|
|
|
@@ -208,7 +228,8 @@ sanbox run "Investigate one focused task and write output/report.md" \\
|
|
|
208
228
|
|
|
209
229
|
Reuse the same --external-run-id after ambiguous failures. Retry network errors, HTTP 429, HTTP 5xx,
|
|
210
230
|
and workspace_busy with bounded backoff. Do not retry other 4xx errors unless next_actions directs
|
|
211
|
-
recovery.
|
|
231
|
+
recovery. A run is inactive when state is stopped; inspect latest_execution.outcome for the most
|
|
232
|
+
recent bounded execution outcome.
|
|
212
233
|
|
|
213
234
|
Recover and retrieve results:
|
|
214
235
|
\`\`\`bash
|
|
@@ -237,20 +258,20 @@ Manage the retained sandbox independently of its agent harness:
|
|
|
237
258
|
sanbox runs get <run-id> --json
|
|
238
259
|
sanbox runs resume <run-id> --wait --json
|
|
239
260
|
sanbox runs share <run-id> --expires 1h --json
|
|
240
|
-
sanbox runs
|
|
261
|
+
sanbox runs stop <run-id> --wait --json
|
|
262
|
+
sanbox runs delete <run-id> --yes --json
|
|
241
263
|
\`\`\`
|
|
242
264
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
positive snapshot_generation.
|
|
265
|
+
Stop terminates compute and durably syncs the workspace without retaining RAM or VM state. Resume
|
|
266
|
+
fresh-boots the pinned runtime artifact with that workspace; it does not restore process state or
|
|
267
|
+
silently replay a finished task. Delete permanently removes the workspace after archiving usage.
|
|
247
268
|
|
|
248
269
|
For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
|
|
249
270
|
client alive until submission completes.
|
|
250
271
|
|
|
251
|
-
Do not claim completion until
|
|
252
|
-
Report run/external/template IDs,
|
|
253
|
-
|
|
272
|
+
Do not claim completion until state is stopped, latest_execution.outcome is completed, and required
|
|
273
|
+
artifacts are downloaded and verified. Report run/external/template IDs, run state, execution outcome, workspace save time,
|
|
274
|
+
artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
|
|
254
275
|
.sanboxignore for project rules.
|
|
255
276
|
`;
|
|
256
277
|
const cwd = () => process.cwd();
|
|
@@ -429,12 +450,14 @@ const flagSets = {
|
|
|
429
450
|
"templates.list": commonFlags,
|
|
430
451
|
"templates.get": commonFlags,
|
|
431
452
|
"templates.validate": commonFlags,
|
|
453
|
+
"templates.delete": [...commonFlags, "force"],
|
|
432
454
|
"templates.create": [
|
|
433
455
|
...commonFlags,
|
|
434
456
|
"name",
|
|
435
457
|
"model-provider",
|
|
436
458
|
"model",
|
|
437
459
|
"harness",
|
|
460
|
+
"mode",
|
|
438
461
|
"llm-budget-usd",
|
|
439
462
|
"web-access",
|
|
440
463
|
"channel",
|
|
@@ -450,7 +473,7 @@ const flagSets = {
|
|
|
450
473
|
...commonFlags, "task", "input", "template", "external-run-id", "email-address",
|
|
451
474
|
"supabase-user-id",
|
|
452
475
|
"telegram-bot-token", "telegram-allowed-user",
|
|
453
|
-
"dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "
|
|
476
|
+
"dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "stop-on-interrupt",
|
|
454
477
|
"poll-interval-ms", "event-page-size", "timeout-seconds", "verbose"
|
|
455
478
|
],
|
|
456
479
|
batch: [
|
|
@@ -466,13 +489,15 @@ const flagSets = {
|
|
|
466
489
|
"runs.unshare": commonFlags,
|
|
467
490
|
"runs.download": [...commonFlags, "output", "artifact", "overwrite"],
|
|
468
491
|
"runs.watch": [
|
|
469
|
-
...commonFlags, "after-event-id", "view", "jsonl", "
|
|
492
|
+
...commonFlags, "after-event-id", "view", "jsonl", "stop-on-interrupt",
|
|
470
493
|
"poll-interval-ms", "event-page-size", "timeout-seconds"
|
|
471
494
|
],
|
|
472
|
-
"runs.
|
|
495
|
+
"runs.stop": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
|
|
496
|
+
"runs.delete": [...commonFlags, "yes"],
|
|
473
497
|
"runs.resume": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
|
|
474
|
-
"runs.pause": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
|
|
475
498
|
"runs.supabase": [...commonFlags, "open", "return-url"],
|
|
499
|
+
"opencode.connect": [...commonFlags, "expires"],
|
|
500
|
+
"opencode.connections": commonFlags,
|
|
476
501
|
ssh: ["api-url", "help"],
|
|
477
502
|
"ssh-proxy": [],
|
|
478
503
|
login: ["api-url", "json", "no-browser", "help"],
|
|
@@ -490,10 +515,13 @@ const commandKey = (command) => {
|
|
|
490
515
|
return `auth.${command[1] || ""}`;
|
|
491
516
|
if (command[0] === "runs" && command[1] === "supabase")
|
|
492
517
|
return "runs.supabase";
|
|
518
|
+
if (command[0] === "opencode" && command[1] === "connections")
|
|
519
|
+
return "opencode.connections";
|
|
493
520
|
if (command[0] === "model-providers" ||
|
|
494
521
|
command[0] === "anthropic-environments" ||
|
|
495
522
|
command[0] === "templates" ||
|
|
496
|
-
command[0] === "runs"
|
|
523
|
+
command[0] === "runs" ||
|
|
524
|
+
command[0] === "opencode") {
|
|
497
525
|
return `${command[0]}.${command[1] || ""}`;
|
|
498
526
|
}
|
|
499
527
|
if (command[0] === "init" && command[1] === "agent")
|
|
@@ -506,7 +534,7 @@ const requiredValueFlags = new Set([
|
|
|
506
534
|
"telegram-bot-token", "telegram-allowed-user", "channel",
|
|
507
535
|
"tasks", "max-parallel", "batch-id", "poll-interval-ms",
|
|
508
536
|
"event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
|
|
509
|
-
"model-provider", "model", "harness", "llm-budget-usd",
|
|
537
|
+
"model-provider", "model", "harness", "mode", "llm-budget-usd",
|
|
510
538
|
"browser-domain", "browser-max-steps", "browser-step-timeout-seconds",
|
|
511
539
|
"browser-vision-mode", "browser-viewport", "browser-download-policy",
|
|
512
540
|
"browser-additional-instructions", "expires", "output", "artifact"
|
|
@@ -548,7 +576,7 @@ const validateFlags = (command, flags) => {
|
|
|
548
576
|
}
|
|
549
577
|
const knownRoots = new Set([
|
|
550
578
|
"auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
|
|
551
|
-
"run", "batch", "runs", "ssh", "ssh-proxy", "login", "logout", "init", "version"
|
|
579
|
+
"run", "batch", "runs", "opencode", "ssh", "ssh-proxy", "login", "logout", "init", "version"
|
|
552
580
|
]);
|
|
553
581
|
const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
|
|
554
582
|
if (!allowed)
|
|
@@ -575,6 +603,7 @@ const validatePositionals = (command, flags) => {
|
|
|
575
603
|
"templates.list": 2,
|
|
576
604
|
"templates.get": 3,
|
|
577
605
|
"templates.validate": 3,
|
|
606
|
+
"templates.delete": 3,
|
|
578
607
|
"templates.create": 2,
|
|
579
608
|
batch: 1,
|
|
580
609
|
"runs.list": 2,
|
|
@@ -586,10 +615,12 @@ const validatePositionals = (command, flags) => {
|
|
|
586
615
|
"runs.unshare": 4,
|
|
587
616
|
"runs.download": 3,
|
|
588
617
|
"runs.watch": 3,
|
|
589
|
-
"runs.
|
|
618
|
+
"runs.stop": 3,
|
|
619
|
+
"runs.delete": 3,
|
|
590
620
|
"runs.resume": 3,
|
|
591
|
-
"runs.pause": 3,
|
|
592
621
|
"runs.supabase": 4,
|
|
622
|
+
"opencode.connect": 3,
|
|
623
|
+
"opencode.connections": 5,
|
|
593
624
|
ssh: 2,
|
|
594
625
|
"ssh-proxy": 1,
|
|
595
626
|
login: 1,
|
|
@@ -633,7 +664,7 @@ const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
|
|
|
633
664
|
const onInterrupt = () => controller.abort();
|
|
634
665
|
process.once("SIGINT", onInterrupt);
|
|
635
666
|
if (!jsonl)
|
|
636
|
-
process.stdout.write(`Watching run ${runId}. Ctrl-C detaches without
|
|
667
|
+
process.stdout.write(`Watching run ${runId}. Ctrl-C detaches without stopping.\n`);
|
|
637
668
|
try {
|
|
638
669
|
return await watchRun(client, runId, {
|
|
639
670
|
afterEventId: integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER),
|
|
@@ -655,9 +686,9 @@ const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
|
|
|
655
686
|
catch (error) {
|
|
656
687
|
if (!(error instanceof WatchInterruptedError))
|
|
657
688
|
throw error;
|
|
658
|
-
if (hasFlag(flags, "
|
|
659
|
-
await client.
|
|
660
|
-
process.stderr.write(`
|
|
689
|
+
if (hasFlag(flags, "stop-on-interrupt")) {
|
|
690
|
+
await client.stopRun(runId);
|
|
691
|
+
process.stderr.write(`Stop requested for run ${runId}.\n`);
|
|
661
692
|
}
|
|
662
693
|
else {
|
|
663
694
|
process.stderr.write(`Detached from run ${runId}; the run is still active.\n`);
|
|
@@ -760,7 +791,7 @@ const commandRun = async (command, flags) => {
|
|
|
760
791
|
payload = watched;
|
|
761
792
|
if (!hasFlag(flags, "jsonl"))
|
|
762
793
|
printRun(payload);
|
|
763
|
-
if (isTerminalRun(payload.run) && payload.run
|
|
794
|
+
if (isTerminalRun(payload.run) && !runSucceeded(payload.run))
|
|
764
795
|
process.exitCode = 2;
|
|
765
796
|
return;
|
|
766
797
|
}
|
|
@@ -778,7 +809,7 @@ const commandRun = async (command, flags) => {
|
|
|
778
809
|
}
|
|
779
810
|
else
|
|
780
811
|
printRun(payload);
|
|
781
|
-
if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && payload.run
|
|
812
|
+
if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && !runSucceeded(payload.run))
|
|
782
813
|
process.exitCode = 2;
|
|
783
814
|
};
|
|
784
815
|
const commandBatch = async (flags) => {
|
|
@@ -825,7 +856,7 @@ const commandBatch = async (flags) => {
|
|
|
825
856
|
printSuccess("batch.create", output, jsonContext(client));
|
|
826
857
|
else
|
|
827
858
|
results.forEach(printRun);
|
|
828
|
-
if (wait && results.some((result) => result.run
|
|
859
|
+
if (wait && results.some((result) => !runSucceeded(result.run)))
|
|
829
860
|
process.exitCode = 2;
|
|
830
861
|
};
|
|
831
862
|
const commandAuthCheck = async (flags) => {
|
|
@@ -988,7 +1019,7 @@ const commandAnthropicEnvironments = async (command, flags) => {
|
|
|
988
1019
|
for (const environment of payload.environments) {
|
|
989
1020
|
process.stdout.write(`${environment.environment_id}\t${environment.status}` +
|
|
990
1021
|
`\tworker=${environment.assigned_worker_id || "unassigned"}` +
|
|
991
|
-
`\tverified=${environment.last_verified_at
|
|
1022
|
+
`\tverified=${environment.last_verified_at ? isoUtcTimestamp(environment.last_verified_at) : "never"}\n`);
|
|
992
1023
|
}
|
|
993
1024
|
return;
|
|
994
1025
|
}
|
|
@@ -1008,7 +1039,7 @@ const commandAnthropicEnvironments = async (command, flags) => {
|
|
|
1008
1039
|
const environment = payload.environment;
|
|
1009
1040
|
process.stdout.write(`${environment.environment_id} ${environment.status}` +
|
|
1010
1041
|
` worker=${environment.assigned_worker_id || "unassigned"}` +
|
|
1011
|
-
` verified=${environment.last_verified_at
|
|
1042
|
+
` verified=${environment.last_verified_at ? isoUtcTimestamp(environment.last_verified_at) : "never"}\n`);
|
|
1012
1043
|
return;
|
|
1013
1044
|
}
|
|
1014
1045
|
if (action === "connect") {
|
|
@@ -1144,6 +1175,14 @@ const commandTemplates = async (command, flags) => {
|
|
|
1144
1175
|
if (harness !== "opencode" && harness !== "hermes" && harness !== "browser-use") {
|
|
1145
1176
|
throw new CliError("invalid_harness", "templates create --harness must be opencode, hermes, or browser-use.");
|
|
1146
1177
|
}
|
|
1178
|
+
const modeFlag = flagString(flags, "mode");
|
|
1179
|
+
const mode = modeFlag || "task";
|
|
1180
|
+
if (mode !== "task" && mode !== "computer") {
|
|
1181
|
+
throw new CliError("invalid_opencode_mode", "templates create --mode must be task or computer.");
|
|
1182
|
+
}
|
|
1183
|
+
if (harness !== "opencode" && modeFlag) {
|
|
1184
|
+
throw new CliError("opencode_mode_not_supported", "templates create --mode requires --harness opencode.");
|
|
1185
|
+
}
|
|
1147
1186
|
const budgetRaw = flagString(flags, "llm-budget-usd");
|
|
1148
1187
|
const parsedBudgetUsd = budgetRaw ? Number(budgetRaw) : undefined;
|
|
1149
1188
|
const llmBudgetUsd = parsedBudgetUsd === undefined
|
|
@@ -1156,6 +1195,9 @@ const commandTemplates = async (command, flags) => {
|
|
|
1156
1195
|
if (harness === "hermes" && llmBudgetUsd !== undefined) {
|
|
1157
1196
|
throw new CliError("hermes_budget_unsupported", "Always-on Hermes templates do not support --llm-budget-usd yet.");
|
|
1158
1197
|
}
|
|
1198
|
+
if (harness === "opencode" && mode === "computer" && llmBudgetUsd !== undefined) {
|
|
1199
|
+
throw new CliError("opencode_computer_budget_unsupported", "OpenCode Computer templates do not support --llm-budget-usd yet.");
|
|
1200
|
+
}
|
|
1159
1201
|
const channels = [...new Set(flagList(flags, "channel").map((value) => value.trim()).filter(Boolean))];
|
|
1160
1202
|
if (channels.some((channel) => channel !== "email" && channel !== "telegram")) {
|
|
1161
1203
|
throw new CliError("invalid_hermes_channels", "--channel must be email or telegram.");
|
|
@@ -1194,6 +1236,7 @@ const commandTemplates = async (command, flags) => {
|
|
|
1194
1236
|
name,
|
|
1195
1237
|
provider_id: modelProvider,
|
|
1196
1238
|
model_id: model,
|
|
1239
|
+
...(harness === "opencode" && modeFlag ? { mode: mode } : {}),
|
|
1197
1240
|
...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd }),
|
|
1198
1241
|
...(harness === "browser-use" ? {
|
|
1199
1242
|
harness,
|
|
@@ -1224,11 +1267,17 @@ const commandTemplates = async (command, flags) => {
|
|
|
1224
1267
|
commandAction([
|
|
1225
1268
|
"sanbox",
|
|
1226
1269
|
"run",
|
|
1227
|
-
harness === "hermes"
|
|
1270
|
+
harness === "hermes"
|
|
1271
|
+
? "<agent role and instructions>"
|
|
1272
|
+
: harness === "opencode" && mode === "computer"
|
|
1273
|
+
? "<computer role and instructions>"
|
|
1274
|
+
: "<task>",
|
|
1228
1275
|
"--template",
|
|
1229
1276
|
payload.template.id
|
|
1230
|
-
], harness === "hermes"
|
|
1231
|
-
?
|
|
1277
|
+
], harness === "hermes" || (harness === "opencode" && mode === "computer")
|
|
1278
|
+
? harness === "hermes"
|
|
1279
|
+
? "Start the always-on computer and activate only the channels this run needs."
|
|
1280
|
+
: "Start the retained OpenCode computer."
|
|
1232
1281
|
: "Create a run with the new template.")
|
|
1233
1282
|
]);
|
|
1234
1283
|
return;
|
|
@@ -1237,7 +1286,39 @@ const commandTemplates = async (command, flags) => {
|
|
|
1237
1286
|
`${payload.template.llm_budget_usd ? ` budget=$${payload.template.llm_budget_usd}/run` : ""}\n`);
|
|
1238
1287
|
return;
|
|
1239
1288
|
}
|
|
1240
|
-
|
|
1289
|
+
if (action === "delete") {
|
|
1290
|
+
const id = requiredPositional(command[2], "template_id_required", "templates delete requires a template id or slug.");
|
|
1291
|
+
if (!hasFlag(flags, "force")) {
|
|
1292
|
+
throw new CliError("confirmation_required", "templates delete requires --force.", {
|
|
1293
|
+
details: { template_id: id },
|
|
1294
|
+
nextActions: [commandAction(["sanbox", "templates", "delete", id, "--force", "--json"], "Delete the template from this organization while preserving existing runs.")]
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
let payload;
|
|
1298
|
+
try {
|
|
1299
|
+
payload = await client.deleteTemplate(id);
|
|
1300
|
+
}
|
|
1301
|
+
catch (error) {
|
|
1302
|
+
if (error instanceof SanboxApiError) {
|
|
1303
|
+
throw new CliError(error.code, error.message, {
|
|
1304
|
+
status: error.status,
|
|
1305
|
+
details: { template_id: id },
|
|
1306
|
+
nextActions: error.code === "template_not_found"
|
|
1307
|
+
? [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization.")]
|
|
1308
|
+
: []
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
throw error;
|
|
1312
|
+
}
|
|
1313
|
+
const nextActions = [commandAction(["sanbox", "templates", "list", "--json"], "Confirm that the template is no longer available for new runs.")];
|
|
1314
|
+
if (hasFlag(flags, "json")) {
|
|
1315
|
+
printSuccess("templates.delete", payload, jsonContext(client), nextActions);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
process.stdout.write(`deleted ${payload.template_id}\n`);
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
throw new CliError("templates_action_required", "templates requires an action: list, get, validate, create, or delete.");
|
|
1241
1322
|
};
|
|
1242
1323
|
const commandRuns = async (command, flags) => {
|
|
1243
1324
|
const client = makeClient(flags);
|
|
@@ -1252,7 +1333,7 @@ const commandRuns = async (command, flags) => {
|
|
|
1252
1333
|
return;
|
|
1253
1334
|
}
|
|
1254
1335
|
for (const run of payload.runs) {
|
|
1255
|
-
process.stdout.write(`${run.id}\t${run.
|
|
1336
|
+
process.stdout.write(`${run.id}\t${run.state}\t${isoUtcTimestamp(run.created_at)}\t${run.instruction.replace(/\s+/g, " ").slice(0, 100)}\n`);
|
|
1256
1337
|
}
|
|
1257
1338
|
return;
|
|
1258
1339
|
}
|
|
@@ -1324,7 +1405,7 @@ const commandRuns = async (command, flags) => {
|
|
|
1324
1405
|
return;
|
|
1325
1406
|
}
|
|
1326
1407
|
for (const accessPoint of payload.access_points) {
|
|
1327
|
-
process.stdout.write(`${accessPoint.id}\t${accessPoint.status}\t${accessPoint.expires_at}\t${accessPoint.name}\n`);
|
|
1408
|
+
process.stdout.write(`${accessPoint.id}\t${accessPoint.status}\t${isoUtcTimestamp(accessPoint.expires_at)}\t${accessPoint.name}\n`);
|
|
1328
1409
|
}
|
|
1329
1410
|
return;
|
|
1330
1411
|
}
|
|
@@ -1392,24 +1473,26 @@ const commandRuns = async (command, flags) => {
|
|
|
1392
1473
|
return;
|
|
1393
1474
|
if (!hasFlag(flags, "jsonl"))
|
|
1394
1475
|
printRun(payload);
|
|
1395
|
-
if (isTerminalRun(payload.run) && payload.run
|
|
1476
|
+
if (isTerminalRun(payload.run) && !runSucceeded(payload.run))
|
|
1396
1477
|
process.exitCode = 2;
|
|
1397
1478
|
return;
|
|
1398
1479
|
}
|
|
1399
|
-
if (action === "
|
|
1400
|
-
|
|
1480
|
+
if (action === "delete") {
|
|
1481
|
+
if (!hasFlag(flags, "yes")) {
|
|
1482
|
+
throw new CliError("confirmation_required", "Run deletion permanently removes its workspace. Re-run with --yes.");
|
|
1483
|
+
}
|
|
1484
|
+
const payload = await client.deleteRun(runId);
|
|
1401
1485
|
if (hasFlag(flags, "json"))
|
|
1402
|
-
printSuccess("runs.
|
|
1486
|
+
printSuccess("runs.delete", publicRunPayload(payload), jsonContext(client));
|
|
1403
1487
|
else
|
|
1404
|
-
|
|
1488
|
+
process.stdout.write(`deletion requested for run ${runId}\n`);
|
|
1405
1489
|
return;
|
|
1406
1490
|
}
|
|
1407
|
-
if (action === "
|
|
1408
|
-
const submitted = action === "resume"
|
|
1409
|
-
|
|
1410
|
-
: await client.pauseRun(runId);
|
|
1491
|
+
if (action === "stop" || action === "resume") {
|
|
1492
|
+
const submitted = action === "resume" ? await client.resumeRun(runId) : await client.stopRun(runId);
|
|
1493
|
+
const target = action === "resume" ? "running" : "stopped";
|
|
1411
1494
|
const payload = hasFlag(flags, "wait")
|
|
1412
|
-
? await
|
|
1495
|
+
? await waitForRunState(client, runId, target, {
|
|
1413
1496
|
pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
|
|
1414
1497
|
timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
|
|
1415
1498
|
})
|
|
@@ -1421,12 +1504,63 @@ const commandRuns = async (command, flags) => {
|
|
|
1421
1504
|
printRun(payload);
|
|
1422
1505
|
}
|
|
1423
1506
|
else {
|
|
1424
|
-
process.stdout.write(
|
|
1507
|
+
process.stdout.write(`${action} requested for run ${runId}\n`);
|
|
1425
1508
|
}
|
|
1426
1509
|
return;
|
|
1427
1510
|
}
|
|
1428
1511
|
throw new Error(`Unknown runs action: ${action}`);
|
|
1429
1512
|
};
|
|
1513
|
+
const commandOpenCode = async (command, flags) => {
|
|
1514
|
+
const client = makeClient(flags);
|
|
1515
|
+
const action = command[1];
|
|
1516
|
+
if (action === "connect") {
|
|
1517
|
+
const runId = requiredPositional(command[2], "run_id_required", "opencode connect requires a run id.");
|
|
1518
|
+
const expiresInSeconds = parseFileAccessExpiry(flagString(flags, "expires", "1h"));
|
|
1519
|
+
if (expiresInSeconds > 24 * 60 * 60) {
|
|
1520
|
+
throw new CliError("invalid_opencode_connection_expiry", "OpenCode connection expiry must be between 1 minute and 1 day.");
|
|
1521
|
+
}
|
|
1522
|
+
const payload = await client.createOpenCodeConnection(runId, expiresInSeconds);
|
|
1523
|
+
if (hasFlag(flags, "json")) {
|
|
1524
|
+
printSuccess("opencode.connect", payload, jsonContext(client), [
|
|
1525
|
+
commandAction(["sanbox", "opencode", "connections", "revoke", runId, payload.connection.id, "--json"], "Revoke this OpenCode SDK connection.")
|
|
1526
|
+
]);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
process.stdout.write(`URL ${payload.url}\nTOKEN ${payload.access_token}\nEXPIRES ${isoUtcTimestamp(payload.connection.expires_at)}\n`);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (action === "connections") {
|
|
1533
|
+
const connectionAction = command[2];
|
|
1534
|
+
if (connectionAction === "list") {
|
|
1535
|
+
const runId = requiredPositional(command[3], "run_id_required", "opencode connections list requires a run id.");
|
|
1536
|
+
if (command[4]) {
|
|
1537
|
+
throw new CliError("unexpected_argument", `Unexpected positional argument: ${command[4]}`);
|
|
1538
|
+
}
|
|
1539
|
+
const payload = await client.listOpenCodeConnections(runId);
|
|
1540
|
+
if (hasFlag(flags, "json")) {
|
|
1541
|
+
printSuccess("opencode.connections.list", payload, jsonContext(client));
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
for (const connection of payload.connections) {
|
|
1545
|
+
process.stdout.write(`${connection.id}\t${connection.status}\t${isoUtcTimestamp(connection.expires_at)}\t${connection.token_prefix}\n`);
|
|
1546
|
+
}
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
if (connectionAction === "revoke") {
|
|
1550
|
+
const runId = requiredPositional(command[3], "run_id_required", "opencode connections revoke requires a run id.");
|
|
1551
|
+
const connectionId = requiredPositional(command[4], "opencode_connection_id_required", "opencode connections revoke requires a connection id.");
|
|
1552
|
+
const payload = await client.revokeOpenCodeConnection(runId, connectionId);
|
|
1553
|
+
if (hasFlag(flags, "json")) {
|
|
1554
|
+
printSuccess("opencode.connections.revoke", payload, jsonContext(client));
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
process.stdout.write(`revoked ${payload.connection.id}\n`);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
throw new CliError("opencode_connections_action_required", "opencode connections requires an action: list or revoke.");
|
|
1561
|
+
}
|
|
1562
|
+
throw new CliError("opencode_action_required", "opencode requires an action: connect or connections.");
|
|
1563
|
+
};
|
|
1430
1564
|
const commandDoctor = async (flags) => {
|
|
1431
1565
|
const localConfig = readLocalConfig();
|
|
1432
1566
|
const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
|
|
@@ -1674,6 +1808,8 @@ const helpFor = (command) => {
|
|
|
1674
1808
|
return anthropicEnvironmentsHelp;
|
|
1675
1809
|
if (command[0] === "templates")
|
|
1676
1810
|
return templatesHelp;
|
|
1811
|
+
if (command[0] === "opencode")
|
|
1812
|
+
return openCodeHelp;
|
|
1677
1813
|
if (command[0] === "ssh")
|
|
1678
1814
|
return sshHelp;
|
|
1679
1815
|
return help;
|
|
@@ -1701,6 +1837,11 @@ const commandId = (command) => {
|
|
|
1701
1837
|
}
|
|
1702
1838
|
if (command[0] === "runs")
|
|
1703
1839
|
return `runs.${command[1] || "unknown"}`;
|
|
1840
|
+
if (command[0] === "opencode" && command[1] === "connections") {
|
|
1841
|
+
return `opencode.connections.${command[2] || "unknown"}`;
|
|
1842
|
+
}
|
|
1843
|
+
if (command[0] === "opencode")
|
|
1844
|
+
return `opencode.${command[1] || "unknown"}`;
|
|
1704
1845
|
if (command[0] === "ssh" || command[0] === "ssh-proxy")
|
|
1705
1846
|
return command[0];
|
|
1706
1847
|
return command[0] || "help";
|
|
@@ -1748,6 +1889,8 @@ const main = async (command, flags) => {
|
|
|
1748
1889
|
return commandBatch(flags);
|
|
1749
1890
|
if (command[0] === "runs")
|
|
1750
1891
|
return commandRuns(command, flags);
|
|
1892
|
+
if (command[0] === "opencode")
|
|
1893
|
+
return commandOpenCode(command, flags);
|
|
1751
1894
|
if (command[0] === "ssh")
|
|
1752
1895
|
return commandSSH(command, flags);
|
|
1753
1896
|
if (command[0] === "ssh-proxy") {
|
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
|
-
|
|
82
|
-
|
|
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}
|
|
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
|
|
6
|
-
export const
|
|
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
|
|
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.
|
|
24
|
-
if (payload.run.
|
|
25
|
-
throw new Error(
|
|
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.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "0.0.
|
|
1
|
+
export const version = "0.0.12";
|
package/dist/watch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SanboxApiError } from "./api.js";
|
|
2
|
-
const
|
|
3
|
-
const terminalEventKinds = new Set(["run.completed", "run.failed", "run.
|
|
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 = (
|
|
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.
|
|
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)
|