@sanlabs/sanbox-cli 0.0.5 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,47 +5,73 @@ import { formatActivityJsonl, formatActivityLine, parseActivityView, shouldRende
5
5
  import { ArtifactExistsError, downloadArtifacts } from "./artifacts.js";
6
6
  import { booleanFlags, flagList, flagString, hasFlag, parseArgs } from "./args.js";
7
7
  import { SanboxApiError, SanboxClient } from "./api.js";
8
- import { defaultApiUrl, readConfig, readLocalConfig, readTemplateSelection } from "./config.js";
8
+ import { defaultApiUrl, readConfig, readLocalConfig, readTemplateSelection, readUserConfig, resolveApiUrl } from "./config.js";
9
+ import { loginWithBrowser, openBrowser, revokeUserSession } from "./deviceLogin.js";
9
10
  import { CliError, commandAction, consoleAction } from "./errors.js";
11
+ import { parseFileAccessExpiry } from "./fileAccess.js";
10
12
  import { previewInputs } from "./inputs.js";
11
13
  import { printError, printJsonlError, printRun, printSuccess, publicRun, publicRunPayload } from "./output.js";
12
- import { createRun, isTerminalRun, readTasks, runPool, stableBatchId, waitForRun } from "./runs.js";
14
+ import { createRun, isTerminalRun, readTasks, runPool, stableBatchId, waitForRun, waitForSandboxState } from "./runs.js";
13
15
  import { version } from "./version.js";
14
- import { WatchInterruptedError, watchEventsUntil, watchRun } from "./watch.js";
16
+ import { WatchInterruptedError, watchRun } from "./watch.js";
17
+ import { openSSH, runSSHProxy } from "./ssh.js";
18
+ import { userSessionStore } from "./userSession.js";
15
19
  const help = `Sanbox CLI
16
20
 
17
21
  Environment:
18
22
  SANBOX_API_URL Sanbox API base URL
19
- SANBOX_API_KEY Org API key
23
+ SANBOX_API_KEY Org API key for automation (no interactive access)
20
24
  SANBOX_TEMPLATE Explicit template id or slug for run and batch
21
25
 
22
26
  Commands:
23
27
  sanbox --version
28
+ sanbox login [--no-browser]
29
+ sanbox logout
24
30
  sanbox auth check [--json]
25
31
  sanbox context [--json]
26
32
  sanbox doctor [--json]
27
33
  sanbox model-providers list [--json]
28
34
  sanbox model-providers get <provider-id> [--json]
29
35
  sanbox model-providers models <provider-id> [--json]
36
+ sanbox anthropic-environments list [--json]
37
+ sanbox anthropic-environments get <environment-id> [--json]
38
+ sanbox anthropic-environments connect <environment-id> [--json]
39
+ sanbox anthropic-environments disconnect <environment-id> --force [--json]
30
40
  sanbox templates list [--json]
31
41
  sanbox templates get <template-id> [--json]
32
42
  sanbox templates validate <template-id> [--json]
33
- sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes] [--llm-budget-usd <amount>] [--json]
34
- sanbox run "task" --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
35
- sanbox run --task "..." --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
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]
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
+ sanbox run --task "..." --template <template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
36
46
  sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
37
47
  sanbox runs list [--limit 50] [--json]
38
48
  sanbox runs get <run-id> [--json]
39
49
  sanbox runs events <run-id> [--after-event-id 0] [--json]
40
- sanbox runs messages <run-id> [--json]
41
50
  sanbox runs artifacts <run-id> [--json]
51
+ sanbox runs share <run-id> [--expires 1h] [--name "Debug access"] [--json]
52
+ sanbox runs shares <run-id> [--json]
53
+ sanbox runs unshare <run-id> <access-point-id> [--json]
42
54
  sanbox runs download <run-id> --output <directory> [--artifact <path>] [--overwrite] [--json]
43
55
  sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--jsonl]
44
56
  sanbox runs cancel <run-id> [--json]
45
- sanbox runs message <run-id> "..." [--wait | --watch] [--json | --jsonl]
57
+ sanbox runs resume <run-id> [--wait] [--json]
58
+ sanbox runs pause <run-id> [--wait] [--json]
59
+ sanbox runs supabase authorize <run-id> [--open] [--return-url <https-url>] [--json]
60
+ sanbox ssh <run-id>
46
61
  sanbox init [--force]
47
62
  sanbox init agent [--write]
48
63
  `;
64
+ const sshHelp = `Sanbox SSH
65
+
66
+ Usage:
67
+ sanbox ssh <run-id>
68
+
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
+ organization admins can connect; organization API keys cannot open interactive sessions. The guest
73
+ does not expose port 22 publicly.
74
+ `;
49
75
  const runHelp = `Sanbox run
50
76
 
51
77
  Usage:
@@ -56,6 +82,10 @@ Options:
56
82
  --input <path> File, directory, or glob to upload. Repeatable.
57
83
  --template <id> Template id or slug. Required unless SANBOX_TEMPLATE or project config sets it.
58
84
  --external-run-id <id> Idempotency key for retries.
85
+ --supabase-user-id <id> Supabase Auth user UUID expected during OAuth consent.
86
+ --email-address <address> Optional for Hermes computers; must use a listed AgentMail domain.
87
+ --telegram-bot-token <token> Optional Telegram bot token; SANBOX_TELEGRAM_BOT_TOKEN is also supported.
88
+ --telegram-allowed-user <id> Numeric Telegram user allowed to reach this run. Repeatable.
59
89
  --dry-run Preview included files without creating a run.
60
90
  --wait Poll until terminal status.
61
91
  --watch Stream activity until terminal status.
@@ -65,6 +95,17 @@ Options:
65
95
  --cancel-on-interrupt Request run cancellation when Ctrl-C is pressed.
66
96
  --json Print JSON.
67
97
  `;
98
+ const runsSupabaseHelp = `Sanbox Supabase run authorization
99
+
100
+ Usage:
101
+ sanbox runs supabase authorize <run-id>
102
+ sanbox runs supabase authorize <run-id> --open
103
+
104
+ Options:
105
+ --open Open the consent URL in the default browser.
106
+ --return-url <https-url> Override the built-in Sanbox completion page with an allowlisted customer URL.
107
+ --json Print JSON.
108
+ `;
68
109
  const doctorHelp = `Sanbox doctor
69
110
 
70
111
  Usage:
@@ -83,18 +124,37 @@ Usage:
83
124
  Provider credentials are configured by organization admins in the Sanbox console.
84
125
  Model ids are scoped to their provider and are never deduplicated across providers.
85
126
  `;
127
+ const anthropicEnvironmentsHelp = `Sanbox Anthropic environments
128
+
129
+ Usage:
130
+ sanbox anthropic-environments list [--json]
131
+ sanbox anthropic-environments get <environment-id> [--json]
132
+ ANTHROPIC_ENVIRONMENT_KEY=<key> sanbox anthropic-environments connect <environment-id> [--json]
133
+ sanbox anthropic-environments disconnect <environment-id> --force [--json]
134
+
135
+ Create the self-hosted environment and generate its environment key in Claude Console first.
136
+ The connect command reads the key only from ANTHROPIC_ENVIRONMENT_KEY so it does not enter argv.
137
+ Connect, key replacement, and disconnect require an organization-admin user session or operator
138
+ credential. Organization-scoped member API keys can list and inspect environments but cannot
139
+ manage their credentials.
140
+ `;
86
141
  const templatesHelp = `Sanbox templates
87
142
 
88
143
  Usage:
89
144
  sanbox templates list [--json]
90
145
  sanbox templates get <template-id> [--json]
91
146
  sanbox templates validate <template-id> [--json]
92
- sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes] [--llm-budget-usd <amount>] [--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]
93
148
 
94
149
  Template creation requires an exact provider id and that provider's exact model id.
95
150
  LiteLLM budgets are optional USD amounts and apply separately to each run.
96
- Hermes templates are always-on Telegram gateways. Set SANBOX_TELEGRAM_BOT_TOKEN
97
- and repeat --telegram-allowed-user <numeric-id> for every permitted user.
151
+ Hermes templates are always-on computers. Use repeatable --channel email|telegram to select which
152
+ channels runs may activate. Email and Telegram credentials are supplied only when creating a run.
153
+ Browser Use templates run local headless Chromium inside a one-shot sandbox. They require OpenAI
154
+ or Anthropic and at least one repeatable --browser-domain hostname or leading wildcard.
155
+ Optional controls: --browser-max-steps, --browser-step-timeout-seconds,
156
+ --browser-vision-mode auto|always|never, --browser-viewport WIDTHxHEIGHT,
157
+ --browser-download-policy allow|deny, and --browser-additional-instructions.
98
158
  `;
99
159
  const agentInstructions = `# Operate Sanbox Autonomously
100
160
 
@@ -127,8 +187,9 @@ sanbox doctor --json
127
187
 
128
188
  Never guess opaque IDs. The CLI derives the organization from the API key and requires the key to
129
189
  resolve to exactly one organization. For task execution, select only a template with runnable: true,
130
- template_type: "runner", and runner_config.harness: "opencode". Never select a Hermes service
131
- template for a waited task because it is always-on. Select automatically only when exactly one task
190
+ template_type: "runner", and runner_config.harness: "opencode" or "browser-use". Select Browser Use
191
+ only for a one-shot web task whose target domains are already approved on the template. Never select
192
+ a Hermes service template for a waited task because it is always-on. Select automatically only when exactly one task
132
193
  template qualifies; otherwise ask the user. Provider credentials and template administration are
133
194
  console-only.
134
195
 
@@ -162,24 +223,33 @@ sanbox runs download <run-id> --output .sanbox/output/<run-id> --json
162
223
  Tasks must put durable files under /workspace/output. Downloads preserve relative paths and report
163
224
  byte counts plus SHA-256 digests; existing files require explicit --overwrite.
164
225
 
165
- Continue through the same paused writable sandbox:
226
+ While a sandbox is running, create one read-only capability URL for its entire root filesystem:
227
+ \`\`\`bash
228
+ sanbox runs share <run-id> --expires 1h --json
229
+ sanbox runs shares <run-id> --json
230
+ sanbox runs unshare <run-id> <access-point-id> --json
231
+ \`\`\`
232
+ The URL starts at /, supports directory JSON with ?format=json, and stops working when the sandbox
233
+ stops, the link expires, or it is revoked. Treat the URL as a bearer secret.
234
+
235
+ Manage the retained sandbox independently of its agent harness:
166
236
  \`\`\`bash
167
- sanbox runs messages <run-id> --json
168
237
  sanbox runs get <run-id> --json
169
- sanbox runs message <run-id> "Summarize the retained output" --wait --json
238
+ sanbox runs resume <run-id> --wait --json
239
+ sanbox runs share <run-id> --expires 1h --json
240
+ sanbox runs pause <run-id> --wait --json
170
241
  \`\`\`
171
242
 
172
- Before a follow-up, require sandbox_state: "paused" and a positive snapshot_generation. The message
173
- resumes the same sandbox and OpenCode session, then pauses it as a new generation. Follow-up waits
174
- are correlated to the returned chat_job.id. Never submit concurrent follow-ups to one run. Retry
175
- sandbox_not_paused only after the run returns to paused; treat sandbox_not_resumable,
176
- sandbox_snapshot_missing, sandbox_worker_missing, and hermes_uses_channel as blockers.
243
+ Resume restores the latest Firecracker snapshot without starting an agent command. It creates an
244
+ exclusive manual lease and leaves the sandbox running until pause snapshots it again. Do not submit
245
+ agent work while the manual lease is active. Before resume, require sandbox_state: "paused" and a
246
+ positive snapshot_generation.
177
247
 
178
248
  For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
179
249
  client alive until submission completes.
180
250
 
181
- Do not claim completion until the run is completed, required artifacts are downloaded and verified,
182
- and required follow-ups succeeded. Report run/external/template IDs, status, sandbox state, snapshot
251
+ Do not claim completion until the run is completed and required artifacts are downloaded and verified.
252
+ Report run/external/template IDs, status, sandbox state, snapshot
183
253
  generation, artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
184
254
  .sanboxignore for project rules.
185
255
  `;
@@ -201,8 +271,10 @@ const localJsonContext = (flags) => {
201
271
  };
202
272
  const consolePathUrl = (client, pathname) => new URL(pathname, `${client.config.apiUrl}/`).toString();
203
273
  const consoleUrl = (client) => consolePathUrl(client, "/model-providers");
274
+ const anthropicEnvironmentsConsoleUrl = (client) => consolePathUrl(client, "/anthropic-environments");
204
275
  const providerConsoleAction = (client) => consoleAction(consoleUrl(client), "Ask an organization admin to configure or repair the model provider in the Sanbox console.");
205
276
  const templateAdminConsoleAction = (client) => consoleAction(consolePathUrl(client, "/templates"), "Ask an organization admin to create a template in the Sanbox console.");
277
+ const anthropicEnvironmentAdminConsoleAction = (client) => consoleAction(anthropicEnvironmentsConsoleUrl(client), "Ask an organization admin to manage the Anthropic environment in the Sanbox console.");
206
278
  const templateActions = () => [
207
279
  commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization."),
208
280
  commandAction(["sanbox", "templates", "validate", "<template-id>", "--json"], "Check whether a template is runnable.")
@@ -269,6 +341,62 @@ const requiredPositional = (value, code, message) => {
269
341
  throw new CliError(code, message);
270
342
  return normalized;
271
343
  };
344
+ const browserDomainPattern = /^(?:\*\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
345
+ const parseBrowserDomains = (values) => {
346
+ const domains = [...new Set(values.map((value) => value.trim().toLowerCase().replace(/\.$/, "")))];
347
+ if (domains.length < 1) {
348
+ throw new CliError("browser_domain_required", "Browser Use template creation requires at least one --browser-domain.");
349
+ }
350
+ if (domains.length > 64 ||
351
+ domains.some((domain) => !browserDomainPattern.test(domain) ||
352
+ /^\d+(?:\.\d+){3}$/.test(domain.startsWith("*.") ? domain.slice(2) : domain))) {
353
+ throw new CliError("invalid_browser_domain", "--browser-domain must be a public hostname or leading wildcard such as example.com or *.example.com.");
354
+ }
355
+ return domains;
356
+ };
357
+ const optionalBrowserInteger = (flags, key, fallback, minimum, maximum) => {
358
+ if (flags[key] === undefined)
359
+ return fallback;
360
+ const value = Number(flagString(flags, key));
361
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
362
+ throw new CliError("invalid_browser_use_config", `--${key} must be an integer between ${minimum} and ${maximum}.`);
363
+ }
364
+ return value;
365
+ };
366
+ const parseBrowserUseConfig = (flags) => {
367
+ const maxSteps = optionalBrowserInteger(flags, "browser-max-steps", 30, 1, 100);
368
+ const stepTimeout = optionalBrowserInteger(flags, "browser-step-timeout-seconds", 120, 30, 300);
369
+ const visionMode = flagString(flags, "browser-vision-mode", "auto");
370
+ if (visionMode !== "auto" && visionMode !== "always" && visionMode !== "never") {
371
+ throw new CliError("invalid_browser_use_config", "--browser-vision-mode must be auto, always, or never.");
372
+ }
373
+ const normalizedVisionMode = visionMode;
374
+ const downloadPolicy = flagString(flags, "browser-download-policy", "allow");
375
+ if (downloadPolicy !== "allow" && downloadPolicy !== "deny") {
376
+ throw new CliError("invalid_browser_use_config", "--browser-download-policy must be allow or deny.");
377
+ }
378
+ const normalizedDownloadPolicy = downloadPolicy;
379
+ const viewportRaw = flagString(flags, "browser-viewport", "1280x720").toLowerCase();
380
+ const viewportMatch = /^(\d{3,4})x(\d{3,4})$/.exec(viewportRaw);
381
+ const width = Number(viewportMatch?.[1]);
382
+ const height = Number(viewportMatch?.[2]);
383
+ if (!viewportMatch || width < 320 || width > 1920 || height < 480 || height > 1080) {
384
+ throw new CliError("invalid_browser_use_config", "--browser-viewport must be WIDTHxHEIGHT from 320x480 through 1920x1080.");
385
+ }
386
+ const additionalInstructions = flagString(flags, "browser-additional-instructions");
387
+ if (additionalInstructions.length > 4_000 ||
388
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(additionalInstructions)) {
389
+ throw new CliError("invalid_browser_use_config", "--browser-additional-instructions must contain at most 4000 printable characters.");
390
+ }
391
+ return {
392
+ max_steps: maxSteps,
393
+ step_timeout_seconds: stepTimeout,
394
+ vision_mode: normalizedVisionMode,
395
+ viewport: { width, height },
396
+ download_policy: normalizedDownloadPolicy,
397
+ additional_instructions: additionalInstructions.trim()
398
+ };
399
+ };
272
400
  const providerId = (provider) => String(provider.provider_id || provider.id || "");
273
401
  const templateName = (template) => String(template.display_name || template.template_slug || template.id || "");
274
402
  const validationRunnable = (validation) => {
@@ -294,6 +422,10 @@ const flagSets = {
294
422
  "model-providers.list": commonFlags,
295
423
  "model-providers.get": commonFlags,
296
424
  "model-providers.models": commonFlags,
425
+ "anthropic-environments.list": commonFlags,
426
+ "anthropic-environments.get": commonFlags,
427
+ "anthropic-environments.connect": commonFlags,
428
+ "anthropic-environments.disconnect": [...commonFlags, "force"],
297
429
  "templates.list": commonFlags,
298
430
  "templates.get": commonFlags,
299
431
  "templates.validate": commonFlags,
@@ -305,10 +437,19 @@ const flagSets = {
305
437
  "harness",
306
438
  "llm-budget-usd",
307
439
  "web-access",
308
- "telegram-allowed-user"
440
+ "channel",
441
+ "browser-domain",
442
+ "browser-max-steps",
443
+ "browser-step-timeout-seconds",
444
+ "browser-vision-mode",
445
+ "browser-viewport",
446
+ "browser-download-policy",
447
+ "browser-additional-instructions"
309
448
  ],
310
449
  run: [
311
- ...commonFlags, "task", "input", "template", "external-run-id",
450
+ ...commonFlags, "task", "input", "template", "external-run-id", "email-address",
451
+ "supabase-user-id",
452
+ "telegram-bot-token", "telegram-allowed-user",
312
453
  "dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "cancel-on-interrupt",
313
454
  "poll-interval-ms", "event-page-size", "timeout-seconds", "verbose"
314
455
  ],
@@ -319,18 +460,23 @@ const flagSets = {
319
460
  "runs.list": [...commonFlags, "limit"],
320
461
  "runs.get": commonFlags,
321
462
  "runs.events": [...commonFlags, "after-event-id"],
322
- "runs.messages": commonFlags,
323
463
  "runs.artifacts": commonFlags,
464
+ "runs.share": [...commonFlags, "expires", "name"],
465
+ "runs.shares": commonFlags,
466
+ "runs.unshare": commonFlags,
324
467
  "runs.download": [...commonFlags, "output", "artifact", "overwrite"],
325
468
  "runs.watch": [
326
469
  ...commonFlags, "after-event-id", "view", "jsonl", "cancel-on-interrupt",
327
470
  "poll-interval-ms", "event-page-size", "timeout-seconds"
328
471
  ],
329
472
  "runs.cancel": commonFlags,
330
- "runs.message": [
331
- ...commonFlags, "message", "wait", "watch", "jsonl", "view",
332
- "poll-interval-ms", "event-page-size", "timeout-seconds"
333
- ],
473
+ "runs.resume": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
474
+ "runs.pause": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
475
+ "runs.supabase": [...commonFlags, "open", "return-url"],
476
+ ssh: ["api-url", "help"],
477
+ "ssh-proxy": [],
478
+ login: ["api-url", "json", "no-browser", "help"],
479
+ logout: ["api-url", "json", "help"],
334
480
  init: [...commonFlags, "force", "template"],
335
481
  "init.agent": [...commonFlags, "write"],
336
482
  version: ["json", "help"]
@@ -342,7 +488,12 @@ const commandKey = (command) => {
342
488
  return "version";
343
489
  if (command[0] === "auth")
344
490
  return `auth.${command[1] || ""}`;
345
- if (command[0] === "model-providers" || command[0] === "templates" || command[0] === "runs") {
491
+ if (command[0] === "runs" && command[1] === "supabase")
492
+ return "runs.supabase";
493
+ if (command[0] === "model-providers" ||
494
+ command[0] === "anthropic-environments" ||
495
+ command[0] === "templates" ||
496
+ command[0] === "runs") {
346
497
  return `${command[0]}.${command[1] || ""}`;
347
498
  }
348
499
  if (command[0] === "init" && command[1] === "agent")
@@ -350,11 +501,15 @@ const commandKey = (command) => {
350
501
  return command[0];
351
502
  };
352
503
  const requiredValueFlags = new Set([
353
- "api-url", "template", "task", "input", "include", "external-run-id",
504
+ "api-url", "template", "task", "input", "include", "external-run-id", "email-address",
505
+ "supabase-user-id", "return-url",
506
+ "telegram-bot-token", "telegram-allowed-user", "channel",
354
507
  "tasks", "max-parallel", "batch-id", "poll-interval-ms",
355
508
  "event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
356
- "model-provider", "model", "harness", "llm-budget-usd", "telegram-allowed-user",
357
- "message", "output", "artifact"
509
+ "model-provider", "model", "harness", "llm-budget-usd",
510
+ "browser-domain", "browser-max-steps", "browser-step-timeout-seconds",
511
+ "browser-vision-mode", "browser-viewport", "browser-download-policy",
512
+ "browser-additional-instructions", "expires", "output", "artifact"
358
513
  ]);
359
514
  const validateFlagValues = (flags) => {
360
515
  for (const flag of requiredValueFlags) {
@@ -392,8 +547,8 @@ const validateFlags = (command, flags) => {
392
547
  return;
393
548
  }
394
549
  const knownRoots = new Set([
395
- "auth", "context", "doctor", "model-providers", "templates",
396
- "run", "batch", "runs", "init", "version"
550
+ "auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
551
+ "run", "batch", "runs", "ssh", "ssh-proxy", "login", "logout", "init", "version"
397
552
  ]);
398
553
  const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
399
554
  if (!allowed)
@@ -413,6 +568,10 @@ const validatePositionals = (command, flags) => {
413
568
  "model-providers.list": 2,
414
569
  "model-providers.get": 3,
415
570
  "model-providers.models": 3,
571
+ "anthropic-environments.list": 2,
572
+ "anthropic-environments.get": 3,
573
+ "anthropic-environments.connect": 3,
574
+ "anthropic-environments.disconnect": 3,
416
575
  "templates.list": 2,
417
576
  "templates.get": 3,
418
577
  "templates.validate": 3,
@@ -421,11 +580,20 @@ const validatePositionals = (command, flags) => {
421
580
  "runs.list": 2,
422
581
  "runs.get": 3,
423
582
  "runs.events": 3,
424
- "runs.messages": 3,
425
583
  "runs.artifacts": 3,
584
+ "runs.share": 3,
585
+ "runs.shares": 3,
586
+ "runs.unshare": 4,
426
587
  "runs.download": 3,
427
588
  "runs.watch": 3,
428
589
  "runs.cancel": 3,
590
+ "runs.resume": 3,
591
+ "runs.pause": 3,
592
+ "runs.supabase": 4,
593
+ ssh: 2,
594
+ "ssh-proxy": 1,
595
+ login: 1,
596
+ logout: 1,
429
597
  init: 1,
430
598
  "init.agent": 2,
431
599
  version: 1
@@ -533,6 +701,11 @@ const commandRun = async (command, flags) => {
533
701
  throw new Error("--wait cannot be combined with --watch or --jsonl.");
534
702
  }
535
703
  validateWatchFlags(flags);
704
+ const supabaseUserId = flagString(flags, "supabase-user-id").trim();
705
+ if (supabaseUserId &&
706
+ !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(supabaseUserId)) {
707
+ throw new CliError("invalid_supabase_user_id", "--supabase-user-id must be a UUID.");
708
+ }
536
709
  if (hasFlag(flags, "dry-run")) {
537
710
  if (wantsWatch(flags))
538
711
  throw new Error("--dry-run cannot be combined with --watch or --jsonl.");
@@ -543,6 +716,24 @@ const commandRun = async (command, flags) => {
543
716
  printPreview(preview, hasFlag(flags, "verbose"));
544
717
  return;
545
718
  }
719
+ const tokenFlag = flagString(flags, "telegram-bot-token").trim();
720
+ const telegramAllowedUsers = [...new Set(flagList(flags, "telegram-allowed-user")
721
+ .map((value) => value.trim())
722
+ .filter(Boolean))];
723
+ const telegramRequested = Boolean(tokenFlag || telegramAllowedUsers.length > 0);
724
+ const tokenEnvironment = telegramRequested
725
+ ? process.env.SANBOX_TELEGRAM_BOT_TOKEN?.trim() || ""
726
+ : "";
727
+ if (tokenFlag && tokenEnvironment) {
728
+ throw new CliError("telegram_token_ambiguous", "Use either --telegram-bot-token or SANBOX_TELEGRAM_BOT_TOKEN, not both.");
729
+ }
730
+ const telegramBotToken = tokenFlag || tokenEnvironment;
731
+ if (!telegramBotToken && telegramAllowedUsers.length > 0) {
732
+ throw new CliError("telegram_bot_token_required", "--telegram-allowed-user requires --telegram-bot-token or SANBOX_TELEGRAM_BOT_TOKEN.");
733
+ }
734
+ if (telegramBotToken && telegramAllowedUsers.length === 0) {
735
+ throw new CliError("telegram_allowed_user_required", "Telegram requires at least one --telegram-allowed-user.");
736
+ }
546
737
  const client = makeClient(flags);
547
738
  const template = readTemplateSelection(flags);
548
739
  let payload;
@@ -552,6 +743,10 @@ const commandRun = async (command, flags) => {
552
743
  instruction: task,
553
744
  inputs: flagList(flags, "input"),
554
745
  externalRunId: flagString(flags, "external-run-id") || undefined,
746
+ supabaseUserId: supabaseUserId || undefined,
747
+ emailAddress: flagString(flags, "email-address") || undefined,
748
+ telegramBotToken: telegramBotToken || undefined,
749
+ telegramAllowedUsers,
555
750
  templateId: template.id
556
751
  });
557
752
  }
@@ -605,6 +800,7 @@ const commandBatch = async (flags) => {
605
800
  instruction: task.task,
606
801
  inputs: task.input || inputs,
607
802
  externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
803
+ emailAddress: task.email_address,
608
804
  templateId: template.id
609
805
  });
610
806
  }
@@ -750,6 +946,125 @@ const commandModelProviders = async (command, flags) => {
750
946
  }
751
947
  throw new CliError("model_providers_action_required", "model-providers requires an action: list, get, or models.");
752
948
  };
949
+ const anthropicEnvironmentIdPattern = /^env_[A-Za-z0-9_-]{1,196}$/;
950
+ const requiredAnthropicEnvironmentId = (value, action) => {
951
+ const environmentId = requiredPositional(value, "anthropic_environment_required", `anthropic-environments ${action} requires an environment id.`);
952
+ if (!anthropicEnvironmentIdPattern.test(environmentId)) {
953
+ throw new CliError("invalid_anthropic_environment", "The environment id must start with env_ and contain only letters, numbers, underscores, or hyphens.");
954
+ }
955
+ return environmentId;
956
+ };
957
+ const anthropicEnvironmentError = (error, client, environmentId) => {
958
+ if (!(error instanceof SanboxApiError))
959
+ return error;
960
+ if (error.status === 403) {
961
+ return new CliError(error.code, error.message, {
962
+ status: error.status,
963
+ details: { environment_id: environmentId },
964
+ nextActions: [anthropicEnvironmentAdminConsoleAction(client)]
965
+ });
966
+ }
967
+ if (error.status === 404) {
968
+ return new CliError("anthropic_environment_not_found", error.message, {
969
+ status: error.status,
970
+ details: { environment_id: environmentId },
971
+ nextActions: [commandAction(["sanbox", "anthropic-environments", "list", "--json"], "List Anthropic environments connected to this organization.")]
972
+ });
973
+ }
974
+ return error;
975
+ };
976
+ const commandAnthropicEnvironments = async (command, flags) => {
977
+ const client = makeClient(flags);
978
+ const action = command[1];
979
+ if (action === "list") {
980
+ const payload = await client.listAnthropicEnvironments();
981
+ const nextActions = payload.environments.length === 0
982
+ ? [anthropicEnvironmentAdminConsoleAction(client)]
983
+ : [];
984
+ if (hasFlag(flags, "json")) {
985
+ printSuccess("anthropic_environments.list", payload, jsonContext(client), nextActions);
986
+ return;
987
+ }
988
+ for (const environment of payload.environments) {
989
+ process.stdout.write(`${environment.environment_id}\t${environment.status}` +
990
+ `\tworker=${environment.assigned_worker_id || "unassigned"}` +
991
+ `\tverified=${environment.last_verified_at || "never"}\n`);
992
+ }
993
+ return;
994
+ }
995
+ if (action === "get") {
996
+ const environmentId = requiredAnthropicEnvironmentId(command[2], action);
997
+ let payload;
998
+ try {
999
+ payload = await client.getAnthropicEnvironment(environmentId);
1000
+ }
1001
+ catch (error) {
1002
+ throw anthropicEnvironmentError(error, client, environmentId);
1003
+ }
1004
+ if (hasFlag(flags, "json")) {
1005
+ printSuccess("anthropic_environments.get", payload, jsonContext(client));
1006
+ return;
1007
+ }
1008
+ const environment = payload.environment;
1009
+ process.stdout.write(`${environment.environment_id} ${environment.status}` +
1010
+ ` worker=${environment.assigned_worker_id || "unassigned"}` +
1011
+ ` verified=${environment.last_verified_at || "never"}\n`);
1012
+ return;
1013
+ }
1014
+ if (action === "connect") {
1015
+ const environmentId = requiredAnthropicEnvironmentId(command[2], action);
1016
+ const environmentKey = process.env.ANTHROPIC_ENVIRONMENT_KEY?.trim() || "";
1017
+ if (!environmentKey) {
1018
+ throw new CliError("anthropic_environment_key_required", "ANTHROPIC_ENVIRONMENT_KEY is required for anthropic-environments connect.", {
1019
+ details: { environment_id: environmentId },
1020
+ nextActions: [commandAction(["sanbox", "anthropic-environments", "connect", environmentId, "--json"], "Connect or replace the environment key without placing the secret in argv.", { ANTHROPIC_ENVIRONMENT_KEY: "<environment-key>" })]
1021
+ });
1022
+ }
1023
+ if (environmentKey.length < 16 ||
1024
+ environmentKey.length > 20_000 ||
1025
+ /[\r\n\u0000]/.test(environmentKey)) {
1026
+ throw new CliError("invalid_anthropic_environment_key", "ANTHROPIC_ENVIRONMENT_KEY must be a single-line Anthropic environment credential.");
1027
+ }
1028
+ let payload;
1029
+ try {
1030
+ payload = await client.connectAnthropicEnvironment(environmentId, environmentKey);
1031
+ }
1032
+ catch (error) {
1033
+ throw anthropicEnvironmentError(error, client, environmentId);
1034
+ }
1035
+ const nextActions = [commandAction(["sanbox", "anthropic-environments", "get", environmentId, "--json"], "Check whether the first authenticated queue poll connected the environment.")];
1036
+ if (hasFlag(flags, "json")) {
1037
+ printSuccess("anthropic_environments.connect", payload, jsonContext(client), nextActions);
1038
+ return;
1039
+ }
1040
+ process.stdout.write(`${payload.environment.environment_id} ${payload.environment.status}` +
1041
+ ` key=${payload.environment.key_hint || "stored"}\n`);
1042
+ return;
1043
+ }
1044
+ if (action === "disconnect") {
1045
+ const environmentId = requiredAnthropicEnvironmentId(command[2], action);
1046
+ if (!hasFlag(flags, "force")) {
1047
+ throw new CliError("confirmation_required", "anthropic-environments disconnect requires --force.", {
1048
+ details: { environment_id: environmentId },
1049
+ nextActions: [commandAction(["sanbox", "anthropic-environments", "disconnect", environmentId, "--force", "--json"], "Disconnect the environment while preserving its session workspace mappings.")]
1050
+ });
1051
+ }
1052
+ let payload;
1053
+ try {
1054
+ payload = await client.disconnectAnthropicEnvironment(environmentId);
1055
+ }
1056
+ catch (error) {
1057
+ throw anthropicEnvironmentError(error, client, environmentId);
1058
+ }
1059
+ if (hasFlag(flags, "json")) {
1060
+ printSuccess("anthropic_environments.disconnect", payload, jsonContext(client));
1061
+ return;
1062
+ }
1063
+ process.stdout.write(`${payload.environment_id} ${payload.disconnected ? "disconnected" : "not found"}\n`);
1064
+ return;
1065
+ }
1066
+ throw new CliError("anthropic_environments_action_required", "anthropic-environments requires an action: list, get, connect, or disconnect.");
1067
+ };
753
1068
  const commandTemplates = async (command, flags) => {
754
1069
  const client = makeClient(flags);
755
1070
  const action = command[1];
@@ -826,8 +1141,8 @@ const commandTemplates = async (command, flags) => {
826
1141
  const modelProvider = requiredPositional(flagString(flags, "model-provider"), "model_provider_required", "templates create requires --model-provider.");
827
1142
  const model = requiredPositional(flagString(flags, "model"), "model_required", "templates create requires --model.");
828
1143
  const harness = flagString(flags, "harness", "opencode");
829
- if (harness !== "opencode" && harness !== "hermes") {
830
- throw new CliError("invalid_harness", "templates create --harness must be opencode or hermes.");
1144
+ if (harness !== "opencode" && harness !== "hermes" && harness !== "browser-use") {
1145
+ throw new CliError("invalid_harness", "templates create --harness must be opencode, hermes, or browser-use.");
831
1146
  }
832
1147
  const budgetRaw = flagString(flags, "llm-budget-usd");
833
1148
  const parsedBudgetUsd = budgetRaw ? Number(budgetRaw) : undefined;
@@ -841,13 +1156,37 @@ const commandTemplates = async (command, flags) => {
841
1156
  if (harness === "hermes" && llmBudgetUsd !== undefined) {
842
1157
  throw new CliError("hermes_budget_unsupported", "Always-on Hermes templates do not support --llm-budget-usd yet.");
843
1158
  }
844
- const telegramBotToken = process.env.SANBOX_TELEGRAM_BOT_TOKEN?.trim() || "";
845
- const telegramAllowedUsers = flagList(flags, "telegram-allowed-user").map((value) => value.trim()).filter(Boolean);
846
- if (harness === "hermes" && !telegramBotToken) {
847
- throw new CliError("telegram_bot_token_required", "Hermes template creation requires SANBOX_TELEGRAM_BOT_TOKEN.");
1159
+ const channels = [...new Set(flagList(flags, "channel").map((value) => value.trim()).filter(Boolean))];
1160
+ if (channels.some((channel) => channel !== "email" && channel !== "telegram")) {
1161
+ throw new CliError("invalid_hermes_channels", "--channel must be email or telegram.");
1162
+ }
1163
+ const browserFlagNames = [
1164
+ "browser-domain",
1165
+ "browser-max-steps",
1166
+ "browser-step-timeout-seconds",
1167
+ "browser-vision-mode",
1168
+ "browser-viewport",
1169
+ "browser-download-policy",
1170
+ "browser-additional-instructions"
1171
+ ];
1172
+ if (harness !== "browser-use" && browserFlagNames.some((name) => flags[name] !== undefined)) {
1173
+ throw new CliError("browser_use_config_requires_browser_use", "Browser settings require --harness browser-use.");
1174
+ }
1175
+ if (harness === "browser-use" &&
1176
+ modelProvider !== "openai" &&
1177
+ modelProvider !== "anthropic" &&
1178
+ modelProvider !== "google" &&
1179
+ modelProvider !== "hetzner") {
1180
+ throw new CliError("browser_use_provider_unsupported", "Browser Use templates require --model-provider openai, anthropic, google, or hetzner.");
848
1181
  }
849
- if (harness === "hermes" && telegramAllowedUsers.length === 0) {
850
- throw new CliError("telegram_allowed_user_required", "Hermes template creation requires at least one --telegram-allowed-user.");
1182
+ const browserDomains = harness === "browser-use"
1183
+ ? parseBrowserDomains(flagList(flags, "browser-domain"))
1184
+ : [];
1185
+ const browserUse = harness === "browser-use"
1186
+ ? parseBrowserUseConfig(flags)
1187
+ : undefined;
1188
+ if (harness !== "hermes" && channels.length > 0) {
1189
+ throw new CliError("hermes_channels_not_supported", "--channel requires --harness hermes.");
851
1190
  }
852
1191
  let payload;
853
1192
  try {
@@ -856,10 +1195,23 @@ const commandTemplates = async (command, flags) => {
856
1195
  provider_id: modelProvider,
857
1196
  model_id: model,
858
1197
  ...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd }),
1198
+ ...(harness === "browser-use" ? {
1199
+ harness,
1200
+ browser_use: browserUse,
1201
+ network_policy: {
1202
+ default_action: "deny",
1203
+ rules: browserDomains.map((destination) => ({
1204
+ destination,
1205
+ ports: [
1206
+ { from: 80, to: 80 },
1207
+ { from: 443, to: 443 }
1208
+ ]
1209
+ }))
1210
+ }
1211
+ } : {}),
859
1212
  ...(harness === "hermes" ? {
860
1213
  harness,
861
- telegram_bot_token: telegramBotToken,
862
- telegram_allowed_users: telegramAllowedUsers
1214
+ channels: channels
863
1215
  } : {})
864
1216
  });
865
1217
  }
@@ -869,8 +1221,14 @@ const commandTemplates = async (command, flags) => {
869
1221
  if (hasFlag(flags, "json")) {
870
1222
  printSuccess("templates.create", payload, jsonContext(client), [
871
1223
  commandAction(["sanbox", "templates", "validate", payload.template.id, "--json"], "Validate the new template before running it."),
872
- commandAction(["sanbox", "run", harness === "hermes" ? "<agent role and instructions>" : "<task>", "--template", payload.template.id], harness === "hermes"
873
- ? "Start the always-on gateway; interact with it through the configured Telegram bot."
1224
+ commandAction([
1225
+ "sanbox",
1226
+ "run",
1227
+ harness === "hermes" ? "<agent role and instructions>" : "<task>",
1228
+ "--template",
1229
+ payload.template.id
1230
+ ], harness === "hermes"
1231
+ ? "Start the always-on computer and activate only the channels this run needs."
874
1232
  : "Create a run with the new template.")
875
1233
  ]);
876
1234
  return;
@@ -898,6 +1256,23 @@ const commandRuns = async (command, flags) => {
898
1256
  }
899
1257
  return;
900
1258
  }
1259
+ if (action === "supabase") {
1260
+ if (command[2] !== "authorize") {
1261
+ throw new CliError("supabase_action_required", "runs supabase requires the action: authorize.");
1262
+ }
1263
+ const runId = requiredPositional(command[3], "run_id_required", "runs supabase authorize requires a run id.");
1264
+ const payload = await client.authorizeRunSupabase(runId, flagString(flags, "return-url").trim() || undefined);
1265
+ if (hasFlag(flags, "open") && !openBrowser(payload.authorization_url)) {
1266
+ process.stderr.write("Could not open a browser. Open the printed consent URL manually.\n");
1267
+ }
1268
+ if (hasFlag(flags, "json")) {
1269
+ printSuccess("runs.supabase.authorize", payload, jsonContext(client));
1270
+ }
1271
+ else {
1272
+ process.stdout.write(`${payload.authorization_url}\n`);
1273
+ }
1274
+ return;
1275
+ }
901
1276
  const runId = requiredPositional(command[2], "run_id_required", `runs ${action} requires a run id.`);
902
1277
  if (action === "get") {
903
1278
  const payload = await client.getRun(runId);
@@ -915,26 +1290,52 @@ const commandRuns = async (command, flags) => {
915
1290
  payload.events.forEach((event) => process.stdout.write(`${event.id} ${event.level} ${event.kind} ${event.message}\n`));
916
1291
  return;
917
1292
  }
918
- if (action === "messages") {
919
- const payload = await client.listMessages(runId);
1293
+ if (action === "artifacts") {
1294
+ const payload = await client.listArtifacts(runId);
920
1295
  if (hasFlag(flags, "json")) {
921
- printSuccess("runs.messages", payload, jsonContext(client));
1296
+ printSuccess("runs.artifacts", payload, jsonContext(client));
922
1297
  return;
923
1298
  }
924
- for (const message of payload.messages) {
925
- process.stdout.write(`${message.role}\t${message.created_at}\t${message.message}\n`);
1299
+ for (const artifact of payload.artifacts) {
1300
+ process.stdout.write(`${artifact.path}\t${artifact.size_bytes}\n`);
926
1301
  }
927
1302
  return;
928
1303
  }
929
- if (action === "artifacts") {
930
- const payload = await client.listArtifacts(runId);
1304
+ if (action === "share") {
1305
+ const expiresInSeconds = parseFileAccessExpiry(flagString(flags, "expires", "1h"));
1306
+ const name = flagString(flags, "name").trim();
1307
+ const payload = await client.createFileAccessPoint(runId, {
1308
+ expires_in_seconds: expiresInSeconds,
1309
+ ...(name ? { name } : {})
1310
+ });
931
1311
  if (hasFlag(flags, "json")) {
932
- printSuccess("runs.artifacts", payload, jsonContext(client));
1312
+ printSuccess("runs.share", payload, jsonContext(client), [
1313
+ commandAction(["sanbox", "runs", "unshare", runId, payload.access_point.id, "--json"], "Revoke this root filesystem link.")
1314
+ ]);
933
1315
  return;
934
1316
  }
935
- for (const artifact of payload.artifacts) {
936
- process.stdout.write(`${artifact.path}\t${artifact.size_bytes}\n`);
1317
+ process.stdout.write(`${payload.url}\n`);
1318
+ return;
1319
+ }
1320
+ if (action === "shares") {
1321
+ const payload = await client.listFileAccessPoints(runId);
1322
+ if (hasFlag(flags, "json")) {
1323
+ printSuccess("runs.shares", payload, jsonContext(client));
1324
+ return;
1325
+ }
1326
+ for (const accessPoint of payload.access_points) {
1327
+ process.stdout.write(`${accessPoint.id}\t${accessPoint.status}\t${accessPoint.expires_at}\t${accessPoint.name}\n`);
1328
+ }
1329
+ return;
1330
+ }
1331
+ if (action === "unshare") {
1332
+ const accessPointId = requiredPositional(command[3], "file_access_point_id_required", "runs unshare requires an access point id.");
1333
+ const payload = await client.revokeFileAccessPoint(runId, accessPointId);
1334
+ if (hasFlag(flags, "json")) {
1335
+ printSuccess("runs.unshare", payload, jsonContext(client));
1336
+ return;
937
1337
  }
1338
+ process.stdout.write(`revoked ${payload.access_point.id}\n`);
938
1339
  return;
939
1340
  }
940
1341
  if (action === "download") {
@@ -1003,102 +1404,24 @@ const commandRuns = async (command, flags) => {
1003
1404
  printRun(payload);
1004
1405
  return;
1005
1406
  }
1006
- if (action === "message") {
1007
- const flaggedMessage = flagString(flags, "message");
1008
- const positionalMessage = positionalText(command, 3);
1009
- if (flaggedMessage && positionalMessage) {
1010
- throw new CliError("conflicting_arguments", "Use either --message or a positional message, not both.");
1011
- }
1012
- const message = flaggedMessage || positionalMessage;
1013
- if (!message)
1014
- throw new Error("--message or a positional message is required.");
1015
- if (wantsWatch(flags) && hasFlag(flags, "json")) {
1016
- throw new Error("--json cannot be combined with --watch or --jsonl.");
1017
- }
1018
- if (hasFlag(flags, "wait") && wantsWatch(flags)) {
1019
- throw new Error("--wait cannot be combined with --watch or --jsonl.");
1020
- }
1021
- const submitted = await client.sendMessage(runId, message);
1022
- if (!hasFlag(flags, "wait") && !wantsWatch(flags)) {
1023
- if (hasFlag(flags, "json")) {
1024
- printSuccess("runs.message", {
1025
- ...publicRunPayload(submitted),
1026
- message: submitted.message,
1027
- chat_job: submitted.chat_job
1028
- }, jsonContext(client));
1029
- }
1030
- else
1031
- process.stdout.write(`queued follow-up for run ${runId}\n`);
1032
- return;
1033
- }
1034
- const chatJobId = submitted.chat_job.id;
1035
- const belongsToFollowup = (event) => event.payload.chat_job_id === chatJobId;
1036
- const queuedEventId = submitted.events.reduce((latest, event) => event.kind === "chat.queued" && belongsToFollowup(event) ? Math.max(latest, event.id) : latest, 0);
1037
- const receivedEventId = [...submitted.events]
1038
- .reverse()
1039
- .find((event) => event.kind === "message.received" && belongsToFollowup(event))?.id ?? queuedEventId;
1040
- const cursor = submitted.events.reduce((latest, event) => Math.max(latest, event.id), 0);
1041
- const completedInResponse = [...submitted.events]
1042
- .reverse()
1043
- .find((event) => (event.kind === "chat.completed" || event.kind === "chat.failed") && belongsToFollowup(event));
1044
- const view = parseActivityView(flagString(flags, "view", "activity"));
1045
- const jsonl = hasFlag(flags, "jsonl");
1046
- const controller = new AbortController();
1047
- const onInterrupt = () => controller.abort();
1048
- process.once("SIGINT", onInterrupt);
1049
- if (wantsWatch(flags) && !jsonl)
1050
- process.stdout.write(`Watching follow-up for run ${runId}. Ctrl-C detaches.\n`);
1051
- try {
1052
- const renderFollowupEvent = (event) => {
1053
- if (!belongsToFollowup(event))
1054
- return;
1055
- if (!wantsWatch(flags) || !shouldRenderEvent(event, view))
1056
- return;
1057
- process.stdout.write(`${jsonl ? formatActivityJsonl(event) : formatActivityLine(event, submitted.run.created_at)}\n`);
1058
- };
1059
- for (const event of submitted.events) {
1060
- if (event.id >= receivedEventId)
1061
- renderFollowupEvent(event);
1062
- }
1063
- const terminalEvent = completedInResponse ?? await watchEventsUntil(client, runId, {
1064
- afterEventId: cursor,
1065
- pageSize: integerFlag(flags, "event-page-size", 200, 1, 500),
1407
+ if (action === "resume" || action === "pause") {
1408
+ const submitted = action === "resume"
1409
+ ? await client.resumeRun(runId)
1410
+ : await client.pauseRun(runId);
1411
+ const payload = hasFlag(flags, "wait")
1412
+ ? await waitForSandboxState(client, runId, action === "resume" ? "running" : "paused", {
1066
1413
  pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
1067
- timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800),
1068
- signal: controller.signal,
1069
- stopWhen: (event) => (event.kind === "chat.completed" || event.kind === "chat.failed") && belongsToFollowup(event),
1070
- onRetry: ({ error, attempt, delayMs }) => {
1071
- const detail = error instanceof Error ? error.message : String(error);
1072
- process.stderr.write(`Follow-up connection lost (${detail}); retry ${attempt} in ${delayMs}ms.\n`);
1073
- },
1074
- onEvent: renderFollowupEvent
1075
- });
1076
- const messages = await client.listMessages(runId);
1077
- if (hasFlag(flags, "json")) {
1078
- printSuccess("runs.message", {
1079
- ...publicRunPayload(submitted),
1080
- message: submitted.message,
1081
- chat_job: submitted.chat_job,
1082
- messages: messages.messages,
1083
- followup_event: terminalEvent
1084
- }, jsonContext(client));
1085
- }
1086
- else if (!jsonl) {
1087
- const assistant = [...messages.messages].reverse().find((item) => item.role === "assistant" && item.payload.chat_job_id === chatJobId);
1088
- if (assistant)
1089
- process.stdout.write(`${assistant.message}\n`);
1090
- }
1091
- if (terminalEvent.kind === "chat.failed")
1092
- process.exitCode = 2;
1414
+ timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
1415
+ })
1416
+ : submitted;
1417
+ if (hasFlag(flags, "json")) {
1418
+ printSuccess(`runs.${action}`, publicRunPayload(payload), jsonContext(client));
1093
1419
  }
1094
- catch (error) {
1095
- if (!(error instanceof WatchInterruptedError))
1096
- throw error;
1097
- process.stderr.write(`Detached from follow-up for run ${runId}; processing continues.\n`);
1098
- process.exitCode = 130;
1420
+ else if (hasFlag(flags, "wait")) {
1421
+ printRun(payload);
1099
1422
  }
1100
- finally {
1101
- process.off("SIGINT", onInterrupt);
1423
+ else {
1424
+ process.stdout.write(`sandbox ${action} requested for run ${runId}\n`);
1102
1425
  }
1103
1426
  return;
1104
1427
  }
@@ -1107,7 +1430,21 @@ const commandRuns = async (command, flags) => {
1107
1430
  const commandDoctor = async (flags) => {
1108
1431
  const localConfig = readLocalConfig();
1109
1432
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
1110
- const apiKey = process.env.SANBOX_API_KEY || "";
1433
+ const environmentApiKey = process.env.SANBOX_API_KEY || "";
1434
+ let savedUserSession = null;
1435
+ if (!environmentApiKey) {
1436
+ try {
1437
+ savedUserSession = userSessionStore.read(apiUrl);
1438
+ if (savedUserSession && Date.parse(savedUserSession.expiresAt) <= Date.now()) {
1439
+ userSessionStore.delete(apiUrl);
1440
+ savedUserSession = null;
1441
+ }
1442
+ }
1443
+ catch {
1444
+ savedUserSession = null;
1445
+ }
1446
+ }
1447
+ const apiKey = environmentApiKey || savedUserSession?.accessToken || "";
1111
1448
  const selection = readTemplateSelection(flags, { required: false });
1112
1449
  let resolvedOrg = "";
1113
1450
  const checks = [];
@@ -1120,9 +1457,13 @@ const commandDoctor = async (flags) => {
1120
1457
  });
1121
1458
  add("node", Number(process.versions.node.split(".")[0]) >= 20, process.version);
1122
1459
  add("api_url", Boolean(apiUrl), apiUrl);
1123
- add("api_key", Boolean(apiKey), apiKey ? "present in environment" : "missing SANBOX_API_KEY");
1460
+ add("authentication", Boolean(apiKey), environmentApiKey
1461
+ ? "organization API key present in environment"
1462
+ : savedUserSession
1463
+ ? `signed in as ${savedUserSession.email}`
1464
+ : "not signed in");
1124
1465
  if (!apiKey) {
1125
- nextActions.push(commandAction(["sanbox", "doctor", "--json"], "Set a Sanbox control-plane API key and run the checks again.", { SANBOX_API_KEY: "<sanbox-api-key>" }));
1466
+ nextActions.push(commandAction(["sanbox", "login"], "Sign in with your Sanbox user account."));
1126
1467
  }
1127
1468
  try {
1128
1469
  const res = await fetch(`${apiUrl}/health`);
@@ -1200,6 +1541,69 @@ const commandDoctor = async (flags) => {
1200
1541
  if (!output.ok)
1201
1542
  process.exitCode = 2;
1202
1543
  };
1544
+ const commandSSH = async (command, flags) => {
1545
+ const runId = requiredPositional(command[1], "run_id_required", "ssh requires a run id.");
1546
+ const client = new SanboxClient(readUserConfig(flags));
1547
+ const exitCode = await openSSH(client, runId);
1548
+ if (exitCode !== 0)
1549
+ process.exitCode = exitCode;
1550
+ };
1551
+ const commandLogin = async (flags) => {
1552
+ const apiUrl = resolveApiUrl(flags);
1553
+ const previousSession = userSessionStore.read(apiUrl);
1554
+ const session = await loginWithBrowser({
1555
+ apiUrl,
1556
+ launchBrowser: hasFlag(flags, "no-browser") ? () => false : openBrowser,
1557
+ onPrompt: (request, browserOpened) => {
1558
+ process.stderr.write(`${browserOpened ? "Complete sign-in in your browser" : "Open this URL in your browser"}:\n` +
1559
+ `${request.verification_url}\n\nConfirm code: ${request.user_code}\n`);
1560
+ }
1561
+ });
1562
+ userSessionStore.write(session);
1563
+ if (previousSession && previousSession.accessToken !== session.accessToken) {
1564
+ try {
1565
+ await revokeUserSession(apiUrl, previousSession.accessToken);
1566
+ }
1567
+ catch {
1568
+ process.stderr.write("Warning: the previous local CLI session could not be revoked and will expire automatically.\n");
1569
+ }
1570
+ }
1571
+ if (hasFlag(flags, "json")) {
1572
+ printSuccess("auth.login", {
1573
+ email: session.email,
1574
+ organization: session.organization,
1575
+ expires_at: session.expiresAt
1576
+ }, { api_url: apiUrl, org: session.organization.slug });
1577
+ }
1578
+ else {
1579
+ process.stdout.write(`Signed in as ${session.email} for ${session.organization.name}.\n`);
1580
+ }
1581
+ };
1582
+ const commandLogout = async (flags) => {
1583
+ const apiUrl = resolveApiUrl(flags);
1584
+ const session = userSessionStore.read(apiUrl);
1585
+ if (session) {
1586
+ let revokeError = null;
1587
+ try {
1588
+ await revokeUserSession(apiUrl, session.accessToken);
1589
+ }
1590
+ catch (error) {
1591
+ revokeError = error;
1592
+ }
1593
+ finally {
1594
+ userSessionStore.delete(apiUrl);
1595
+ }
1596
+ if (revokeError) {
1597
+ throw new CliError("logout_revoke_failed", "The local session was removed, but the server session could not be revoked and will expire automatically.");
1598
+ }
1599
+ }
1600
+ if (hasFlag(flags, "json")) {
1601
+ printSuccess("auth.logout", { signed_out: Boolean(session) }, { api_url: apiUrl });
1602
+ }
1603
+ else {
1604
+ process.stdout.write(session ? "Signed out.\n" : "No saved Sanbox user session.\n");
1605
+ }
1606
+ };
1203
1607
  const commandInit = async (command, flags) => {
1204
1608
  const dir = path.join(cwd(), ".sanbox");
1205
1609
  if (command[1] === "agent") {
@@ -1260,12 +1664,18 @@ const commandInit = async (command, flags) => {
1260
1664
  const helpFor = (command) => {
1261
1665
  if (command[0] === "run")
1262
1666
  return runHelp;
1667
+ if (command[0] === "runs" && command[1] === "supabase")
1668
+ return runsSupabaseHelp;
1263
1669
  if (command[0] === "doctor")
1264
1670
  return doctorHelp;
1265
1671
  if (command[0] === "model-providers")
1266
1672
  return modelProvidersHelp;
1673
+ if (command[0] === "anthropic-environments")
1674
+ return anthropicEnvironmentsHelp;
1267
1675
  if (command[0] === "templates")
1268
1676
  return templatesHelp;
1677
+ if (command[0] === "ssh")
1678
+ return sshHelp;
1269
1679
  return help;
1270
1680
  };
1271
1681
  const commandId = (command) => {
@@ -1277,14 +1687,22 @@ const commandId = (command) => {
1277
1687
  return "context.get";
1278
1688
  if (command[0] === "model-providers")
1279
1689
  return `model_providers.${command[1] || "unknown"}`;
1690
+ if (command[0] === "anthropic-environments") {
1691
+ return `anthropic_environments.${command[1] || "unknown"}`;
1692
+ }
1280
1693
  if (command[0] === "templates")
1281
1694
  return `templates.${command[1] || "unknown"}`;
1282
1695
  if (command[0] === "run")
1283
1696
  return "run.create";
1284
1697
  if (command[0] === "batch")
1285
1698
  return "batch.create";
1699
+ if (command[0] === "runs" && command[1] === "supabase") {
1700
+ return `runs.supabase.${command[2] || "unknown"}`;
1701
+ }
1286
1702
  if (command[0] === "runs")
1287
1703
  return `runs.${command[1] || "unknown"}`;
1704
+ if (command[0] === "ssh" || command[0] === "ssh-proxy")
1705
+ return command[0];
1288
1706
  return command[0] || "help";
1289
1707
  };
1290
1708
  const main = async (command, flags) => {
@@ -1309,12 +1727,19 @@ const main = async (command, flags) => {
1309
1727
  }
1310
1728
  if (command[0] === "auth" && command[1] === "check")
1311
1729
  return commandAuthCheck(flags);
1730
+ if (command[0] === "login")
1731
+ return commandLogin(flags);
1732
+ if (command[0] === "logout")
1733
+ return commandLogout(flags);
1312
1734
  if (command[0] === "context")
1313
1735
  return commandContext(flags);
1314
1736
  if (command[0] === "doctor")
1315
1737
  return commandDoctor(flags);
1316
1738
  if (command[0] === "model-providers")
1317
1739
  return commandModelProviders(command, flags);
1740
+ if (command[0] === "anthropic-environments") {
1741
+ return commandAnthropicEnvironments(command, flags);
1742
+ }
1318
1743
  if (command[0] === "templates")
1319
1744
  return commandTemplates(command, flags);
1320
1745
  if (command[0] === "run")
@@ -1323,6 +1748,14 @@ const main = async (command, flags) => {
1323
1748
  return commandBatch(flags);
1324
1749
  if (command[0] === "runs")
1325
1750
  return commandRuns(command, flags);
1751
+ if (command[0] === "ssh")
1752
+ return commandSSH(command, flags);
1753
+ if (command[0] === "ssh-proxy") {
1754
+ const exitCode = await runSSHProxy();
1755
+ if (exitCode !== 0)
1756
+ process.exitCode = exitCode;
1757
+ return;
1758
+ }
1326
1759
  if (command[0] === "init")
1327
1760
  return commandInit(command, flags);
1328
1761
  throw new CliError("unknown_command", `Unknown command: ${command.join(" ")}`);