@sanlabs/sanbox-cli 0.0.5 → 0.0.9

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,32 +5,42 @@ 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
16
  import { WatchInterruptedError, watchEventsUntil, 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]
43
+ sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
34
44
  sanbox run "task" --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
35
45
  sanbox run --task "..." --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
36
46
  sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
@@ -39,13 +49,30 @@ Commands:
39
49
  sanbox runs events <run-id> [--after-event-id 0] [--json]
40
50
  sanbox runs messages <run-id> [--json]
41
51
  sanbox runs artifacts <run-id> [--json]
52
+ sanbox runs share <run-id> [--expires 1h] [--name "Debug access"] [--json]
53
+ sanbox runs shares <run-id> [--json]
54
+ sanbox runs unshare <run-id> <access-point-id> [--json]
42
55
  sanbox runs download <run-id> --output <directory> [--artifact <path>] [--overwrite] [--json]
43
56
  sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--jsonl]
44
57
  sanbox runs cancel <run-id> [--json]
58
+ sanbox runs resume <run-id> [--wait] [--json]
59
+ sanbox runs pause <run-id> [--wait] [--json]
45
60
  sanbox runs message <run-id> "..." [--wait | --watch] [--json | --jsonl]
61
+ sanbox ssh <run-id>
46
62
  sanbox init [--force]
47
63
  sanbox init agent [--write]
48
64
  `;
65
+ const sshHelp = `Sanbox SSH
66
+
67
+ Usage:
68
+ sanbox ssh <run-id>
69
+
70
+ Opens the running Hermes Computer with your local OpenSSH client. Sanbox creates an ephemeral
71
+ Ed25519 key, authorizes it for one connection, verifies the microVM host key, and tunnels SSH over
72
+ the authenticated private management path. Run sanbox login first. The computer owner and
73
+ organization admins can connect; organization API keys cannot open interactive sessions. The guest
74
+ does not expose port 22 publicly.
75
+ `;
49
76
  const runHelp = `Sanbox run
50
77
 
51
78
  Usage:
@@ -83,18 +110,37 @@ Usage:
83
110
  Provider credentials are configured by organization admins in the Sanbox console.
84
111
  Model ids are scoped to their provider and are never deduplicated across providers.
85
112
  `;
113
+ const anthropicEnvironmentsHelp = `Sanbox Anthropic environments
114
+
115
+ Usage:
116
+ sanbox anthropic-environments list [--json]
117
+ sanbox anthropic-environments get <environment-id> [--json]
118
+ ANTHROPIC_ENVIRONMENT_KEY=<key> sanbox anthropic-environments connect <environment-id> [--json]
119
+ sanbox anthropic-environments disconnect <environment-id> --force [--json]
120
+
121
+ Create the self-hosted environment and generate its environment key in Claude Console first.
122
+ The connect command reads the key only from ANTHROPIC_ENVIRONMENT_KEY so it does not enter argv.
123
+ Connect, key replacement, and disconnect require an organization-admin user session or operator
124
+ credential. Organization-scoped member API keys can list and inspect environments but cannot
125
+ manage their credentials.
126
+ `;
86
127
  const templatesHelp = `Sanbox templates
87
128
 
88
129
  Usage:
89
130
  sanbox templates list [--json]
90
131
  sanbox templates get <template-id> [--json]
91
132
  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]
133
+ 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
134
 
94
135
  Template creation requires an exact provider id and that provider's exact model id.
95
136
  LiteLLM budgets are optional USD amounts and apply separately to each run.
96
137
  Hermes templates are always-on Telegram gateways. Set SANBOX_TELEGRAM_BOT_TOKEN
97
138
  and repeat --telegram-allowed-user <numeric-id> for every permitted user.
139
+ Browser Use templates run local headless Chromium inside a one-shot sandbox. They require OpenAI
140
+ or Anthropic and at least one repeatable --browser-domain hostname or leading wildcard.
141
+ Optional controls: --browser-max-steps, --browser-step-timeout-seconds,
142
+ --browser-vision-mode auto|always|never, --browser-viewport WIDTHxHEIGHT,
143
+ --browser-download-policy allow|deny, and --browser-additional-instructions.
98
144
  `;
99
145
  const agentInstructions = `# Operate Sanbox Autonomously
100
146
 
@@ -127,8 +173,9 @@ sanbox doctor --json
127
173
 
128
174
  Never guess opaque IDs. The CLI derives the organization from the API key and requires the key to
129
175
  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
176
+ template_type: "runner", and runner_config.harness: "opencode" or "browser-use". Select Browser Use
177
+ only for a one-shot web task whose target domains are already approved on the template. Never select
178
+ a Hermes service template for a waited task because it is always-on. Select automatically only when exactly one task
132
179
  template qualifies; otherwise ask the user. Provider credentials and template administration are
133
180
  console-only.
134
181
 
@@ -162,18 +209,33 @@ sanbox runs download <run-id> --output .sanbox/output/<run-id> --json
162
209
  Tasks must put durable files under /workspace/output. Downloads preserve relative paths and report
163
210
  byte counts plus SHA-256 digests; existing files require explicit --overwrite.
164
211
 
165
- Continue through the same paused writable sandbox:
212
+ While a sandbox is running, create one read-only capability URL for its entire root filesystem:
213
+ \`\`\`bash
214
+ sanbox runs share <run-id> --expires 1h --json
215
+ sanbox runs shares <run-id> --json
216
+ sanbox runs unshare <run-id> <access-point-id> --json
217
+ \`\`\`
218
+ The URL starts at /, supports directory JSON with ?format=json, and stops working when the sandbox
219
+ stops, the link expires, or it is revoked. Treat the URL as a bearer secret.
220
+
221
+ Manage the retained sandbox independently of its agent harness:
166
222
  \`\`\`bash
167
- sanbox runs messages <run-id> --json
168
223
  sanbox runs get <run-id> --json
169
- sanbox runs message <run-id> "Summarize the retained output" --wait --json
224
+ sanbox runs resume <run-id> --wait --json
225
+ sanbox runs share <run-id> --expires 1h --json
226
+ sanbox runs pause <run-id> --wait --json
170
227
  \`\`\`
171
228
 
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
229
+ Resume restores the latest Firecracker snapshot without starting an agent command. It creates an
230
+ exclusive manual lease and leaves the sandbox running until pause snapshots it again. Do not submit
231
+ agent work while the manual lease is active. Before resume, require sandbox_state: "paused" and a
232
+ positive snapshot_generation.
233
+
234
+ For an OpenCode follow-up, use \`sanbox runs message <run-id> "..." --wait --json\`. Follow-up waits
174
235
  are correlated to the returned chat_job.id. Never submit concurrent follow-ups to one run. Retry
175
236
  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.
237
+ sandbox_snapshot_missing, sandbox_worker_missing, hermes_uses_channel, and
238
+ browser_use_followup_unsupported as blockers.
177
239
 
178
240
  For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
179
241
  client alive until submission completes.
@@ -201,8 +263,10 @@ const localJsonContext = (flags) => {
201
263
  };
202
264
  const consolePathUrl = (client, pathname) => new URL(pathname, `${client.config.apiUrl}/`).toString();
203
265
  const consoleUrl = (client) => consolePathUrl(client, "/model-providers");
266
+ const anthropicEnvironmentsConsoleUrl = (client) => consolePathUrl(client, "/anthropic-environments");
204
267
  const providerConsoleAction = (client) => consoleAction(consoleUrl(client), "Ask an organization admin to configure or repair the model provider in the Sanbox console.");
205
268
  const templateAdminConsoleAction = (client) => consoleAction(consolePathUrl(client, "/templates"), "Ask an organization admin to create a template in the Sanbox console.");
269
+ const anthropicEnvironmentAdminConsoleAction = (client) => consoleAction(anthropicEnvironmentsConsoleUrl(client), "Ask an organization admin to manage the Anthropic environment in the Sanbox console.");
206
270
  const templateActions = () => [
207
271
  commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization."),
208
272
  commandAction(["sanbox", "templates", "validate", "<template-id>", "--json"], "Check whether a template is runnable.")
@@ -269,6 +333,62 @@ const requiredPositional = (value, code, message) => {
269
333
  throw new CliError(code, message);
270
334
  return normalized;
271
335
  };
336
+ const browserDomainPattern = /^(?:\*\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
337
+ const parseBrowserDomains = (values) => {
338
+ const domains = [...new Set(values.map((value) => value.trim().toLowerCase().replace(/\.$/, "")))];
339
+ if (domains.length < 1) {
340
+ throw new CliError("browser_domain_required", "Browser Use template creation requires at least one --browser-domain.");
341
+ }
342
+ if (domains.length > 64 ||
343
+ domains.some((domain) => !browserDomainPattern.test(domain) ||
344
+ /^\d+(?:\.\d+){3}$/.test(domain.startsWith("*.") ? domain.slice(2) : domain))) {
345
+ throw new CliError("invalid_browser_domain", "--browser-domain must be a public hostname or leading wildcard such as example.com or *.example.com.");
346
+ }
347
+ return domains;
348
+ };
349
+ const optionalBrowserInteger = (flags, key, fallback, minimum, maximum) => {
350
+ if (flags[key] === undefined)
351
+ return fallback;
352
+ const value = Number(flagString(flags, key));
353
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
354
+ throw new CliError("invalid_browser_use_config", `--${key} must be an integer between ${minimum} and ${maximum}.`);
355
+ }
356
+ return value;
357
+ };
358
+ const parseBrowserUseConfig = (flags) => {
359
+ const maxSteps = optionalBrowserInteger(flags, "browser-max-steps", 30, 1, 100);
360
+ const stepTimeout = optionalBrowserInteger(flags, "browser-step-timeout-seconds", 120, 30, 300);
361
+ const visionMode = flagString(flags, "browser-vision-mode", "auto");
362
+ if (visionMode !== "auto" && visionMode !== "always" && visionMode !== "never") {
363
+ throw new CliError("invalid_browser_use_config", "--browser-vision-mode must be auto, always, or never.");
364
+ }
365
+ const normalizedVisionMode = visionMode;
366
+ const downloadPolicy = flagString(flags, "browser-download-policy", "allow");
367
+ if (downloadPolicy !== "allow" && downloadPolicy !== "deny") {
368
+ throw new CliError("invalid_browser_use_config", "--browser-download-policy must be allow or deny.");
369
+ }
370
+ const normalizedDownloadPolicy = downloadPolicy;
371
+ const viewportRaw = flagString(flags, "browser-viewport", "1280x720").toLowerCase();
372
+ const viewportMatch = /^(\d{3,4})x(\d{3,4})$/.exec(viewportRaw);
373
+ const width = Number(viewportMatch?.[1]);
374
+ const height = Number(viewportMatch?.[2]);
375
+ if (!viewportMatch || width < 320 || width > 1920 || height < 480 || height > 1080) {
376
+ throw new CliError("invalid_browser_use_config", "--browser-viewport must be WIDTHxHEIGHT from 320x480 through 1920x1080.");
377
+ }
378
+ const additionalInstructions = flagString(flags, "browser-additional-instructions");
379
+ if (additionalInstructions.length > 4_000 ||
380
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(additionalInstructions)) {
381
+ throw new CliError("invalid_browser_use_config", "--browser-additional-instructions must contain at most 4000 printable characters.");
382
+ }
383
+ return {
384
+ max_steps: maxSteps,
385
+ step_timeout_seconds: stepTimeout,
386
+ vision_mode: normalizedVisionMode,
387
+ viewport: { width, height },
388
+ download_policy: normalizedDownloadPolicy,
389
+ additional_instructions: additionalInstructions.trim()
390
+ };
391
+ };
272
392
  const providerId = (provider) => String(provider.provider_id || provider.id || "");
273
393
  const templateName = (template) => String(template.display_name || template.template_slug || template.id || "");
274
394
  const validationRunnable = (validation) => {
@@ -294,6 +414,10 @@ const flagSets = {
294
414
  "model-providers.list": commonFlags,
295
415
  "model-providers.get": commonFlags,
296
416
  "model-providers.models": commonFlags,
417
+ "anthropic-environments.list": commonFlags,
418
+ "anthropic-environments.get": commonFlags,
419
+ "anthropic-environments.connect": commonFlags,
420
+ "anthropic-environments.disconnect": [...commonFlags, "force"],
297
421
  "templates.list": commonFlags,
298
422
  "templates.get": commonFlags,
299
423
  "templates.validate": commonFlags,
@@ -305,7 +429,14 @@ const flagSets = {
305
429
  "harness",
306
430
  "llm-budget-usd",
307
431
  "web-access",
308
- "telegram-allowed-user"
432
+ "telegram-allowed-user",
433
+ "browser-domain",
434
+ "browser-max-steps",
435
+ "browser-step-timeout-seconds",
436
+ "browser-vision-mode",
437
+ "browser-viewport",
438
+ "browser-download-policy",
439
+ "browser-additional-instructions"
309
440
  ],
310
441
  run: [
311
442
  ...commonFlags, "task", "input", "template", "external-run-id",
@@ -321,16 +452,25 @@ const flagSets = {
321
452
  "runs.events": [...commonFlags, "after-event-id"],
322
453
  "runs.messages": commonFlags,
323
454
  "runs.artifacts": commonFlags,
455
+ "runs.share": [...commonFlags, "expires", "name"],
456
+ "runs.shares": commonFlags,
457
+ "runs.unshare": commonFlags,
324
458
  "runs.download": [...commonFlags, "output", "artifact", "overwrite"],
325
459
  "runs.watch": [
326
460
  ...commonFlags, "after-event-id", "view", "jsonl", "cancel-on-interrupt",
327
461
  "poll-interval-ms", "event-page-size", "timeout-seconds"
328
462
  ],
329
463
  "runs.cancel": commonFlags,
464
+ "runs.resume": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
465
+ "runs.pause": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
330
466
  "runs.message": [
331
467
  ...commonFlags, "message", "wait", "watch", "jsonl", "view",
332
468
  "poll-interval-ms", "event-page-size", "timeout-seconds"
333
469
  ],
470
+ ssh: ["api-url", "help"],
471
+ "ssh-proxy": [],
472
+ login: ["api-url", "json", "no-browser", "help"],
473
+ logout: ["api-url", "json", "help"],
334
474
  init: [...commonFlags, "force", "template"],
335
475
  "init.agent": [...commonFlags, "write"],
336
476
  version: ["json", "help"]
@@ -342,7 +482,10 @@ const commandKey = (command) => {
342
482
  return "version";
343
483
  if (command[0] === "auth")
344
484
  return `auth.${command[1] || ""}`;
345
- if (command[0] === "model-providers" || command[0] === "templates" || command[0] === "runs") {
485
+ if (command[0] === "model-providers" ||
486
+ command[0] === "anthropic-environments" ||
487
+ command[0] === "templates" ||
488
+ command[0] === "runs") {
346
489
  return `${command[0]}.${command[1] || ""}`;
347
490
  }
348
491
  if (command[0] === "init" && command[1] === "agent")
@@ -354,6 +497,9 @@ const requiredValueFlags = new Set([
354
497
  "tasks", "max-parallel", "batch-id", "poll-interval-ms",
355
498
  "event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
356
499
  "model-provider", "model", "harness", "llm-budget-usd", "telegram-allowed-user",
500
+ "browser-domain", "browser-max-steps", "browser-step-timeout-seconds",
501
+ "browser-vision-mode", "browser-viewport", "browser-download-policy",
502
+ "browser-additional-instructions", "expires",
357
503
  "message", "output", "artifact"
358
504
  ]);
359
505
  const validateFlagValues = (flags) => {
@@ -392,8 +538,8 @@ const validateFlags = (command, flags) => {
392
538
  return;
393
539
  }
394
540
  const knownRoots = new Set([
395
- "auth", "context", "doctor", "model-providers", "templates",
396
- "run", "batch", "runs", "init", "version"
541
+ "auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
542
+ "run", "batch", "runs", "ssh", "ssh-proxy", "login", "logout", "init", "version"
397
543
  ]);
398
544
  const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
399
545
  if (!allowed)
@@ -413,6 +559,10 @@ const validatePositionals = (command, flags) => {
413
559
  "model-providers.list": 2,
414
560
  "model-providers.get": 3,
415
561
  "model-providers.models": 3,
562
+ "anthropic-environments.list": 2,
563
+ "anthropic-environments.get": 3,
564
+ "anthropic-environments.connect": 3,
565
+ "anthropic-environments.disconnect": 3,
416
566
  "templates.list": 2,
417
567
  "templates.get": 3,
418
568
  "templates.validate": 3,
@@ -423,9 +573,18 @@ const validatePositionals = (command, flags) => {
423
573
  "runs.events": 3,
424
574
  "runs.messages": 3,
425
575
  "runs.artifacts": 3,
576
+ "runs.share": 3,
577
+ "runs.shares": 3,
578
+ "runs.unshare": 4,
426
579
  "runs.download": 3,
427
580
  "runs.watch": 3,
428
581
  "runs.cancel": 3,
582
+ "runs.resume": 3,
583
+ "runs.pause": 3,
584
+ ssh: 2,
585
+ "ssh-proxy": 1,
586
+ login: 1,
587
+ logout: 1,
429
588
  init: 1,
430
589
  "init.agent": 2,
431
590
  version: 1
@@ -715,7 +874,8 @@ const commandModelProviders = async (command, flags) => {
715
874
  printSuccess("model_providers.get", payload, jsonContext(client), nextActions);
716
875
  return;
717
876
  }
718
- process.stdout.write(`${providerId(payload.provider)}${payload.provider.status ? ` ${payload.provider.status}` : ""}\n`);
877
+ process.stdout.write(`${providerId(payload.provider)}${payload.provider.status ? ` ${payload.provider.status}` : ""}` +
878
+ `${payload.provider.base_url ? ` base_url=${payload.provider.base_url}` : ""}\n`);
719
879
  return;
720
880
  }
721
881
  if (action === "models") {
@@ -750,6 +910,125 @@ const commandModelProviders = async (command, flags) => {
750
910
  }
751
911
  throw new CliError("model_providers_action_required", "model-providers requires an action: list, get, or models.");
752
912
  };
913
+ const anthropicEnvironmentIdPattern = /^env_[A-Za-z0-9_-]{1,196}$/;
914
+ const requiredAnthropicEnvironmentId = (value, action) => {
915
+ const environmentId = requiredPositional(value, "anthropic_environment_required", `anthropic-environments ${action} requires an environment id.`);
916
+ if (!anthropicEnvironmentIdPattern.test(environmentId)) {
917
+ throw new CliError("invalid_anthropic_environment", "The environment id must start with env_ and contain only letters, numbers, underscores, or hyphens.");
918
+ }
919
+ return environmentId;
920
+ };
921
+ const anthropicEnvironmentError = (error, client, environmentId) => {
922
+ if (!(error instanceof SanboxApiError))
923
+ return error;
924
+ if (error.status === 403) {
925
+ return new CliError(error.code, error.message, {
926
+ status: error.status,
927
+ details: { environment_id: environmentId },
928
+ nextActions: [anthropicEnvironmentAdminConsoleAction(client)]
929
+ });
930
+ }
931
+ if (error.status === 404) {
932
+ return new CliError("anthropic_environment_not_found", error.message, {
933
+ status: error.status,
934
+ details: { environment_id: environmentId },
935
+ nextActions: [commandAction(["sanbox", "anthropic-environments", "list", "--json"], "List Anthropic environments connected to this organization.")]
936
+ });
937
+ }
938
+ return error;
939
+ };
940
+ const commandAnthropicEnvironments = async (command, flags) => {
941
+ const client = makeClient(flags);
942
+ const action = command[1];
943
+ if (action === "list") {
944
+ const payload = await client.listAnthropicEnvironments();
945
+ const nextActions = payload.environments.length === 0
946
+ ? [anthropicEnvironmentAdminConsoleAction(client)]
947
+ : [];
948
+ if (hasFlag(flags, "json")) {
949
+ printSuccess("anthropic_environments.list", payload, jsonContext(client), nextActions);
950
+ return;
951
+ }
952
+ for (const environment of payload.environments) {
953
+ process.stdout.write(`${environment.environment_id}\t${environment.status}` +
954
+ `\tworker=${environment.assigned_worker_id || "unassigned"}` +
955
+ `\tverified=${environment.last_verified_at || "never"}\n`);
956
+ }
957
+ return;
958
+ }
959
+ if (action === "get") {
960
+ const environmentId = requiredAnthropicEnvironmentId(command[2], action);
961
+ let payload;
962
+ try {
963
+ payload = await client.getAnthropicEnvironment(environmentId);
964
+ }
965
+ catch (error) {
966
+ throw anthropicEnvironmentError(error, client, environmentId);
967
+ }
968
+ if (hasFlag(flags, "json")) {
969
+ printSuccess("anthropic_environments.get", payload, jsonContext(client));
970
+ return;
971
+ }
972
+ const environment = payload.environment;
973
+ process.stdout.write(`${environment.environment_id} ${environment.status}` +
974
+ ` worker=${environment.assigned_worker_id || "unassigned"}` +
975
+ ` verified=${environment.last_verified_at || "never"}\n`);
976
+ return;
977
+ }
978
+ if (action === "connect") {
979
+ const environmentId = requiredAnthropicEnvironmentId(command[2], action);
980
+ const environmentKey = process.env.ANTHROPIC_ENVIRONMENT_KEY?.trim() || "";
981
+ if (!environmentKey) {
982
+ throw new CliError("anthropic_environment_key_required", "ANTHROPIC_ENVIRONMENT_KEY is required for anthropic-environments connect.", {
983
+ details: { environment_id: environmentId },
984
+ 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>" })]
985
+ });
986
+ }
987
+ if (environmentKey.length < 16 ||
988
+ environmentKey.length > 20_000 ||
989
+ /[\r\n\u0000]/.test(environmentKey)) {
990
+ throw new CliError("invalid_anthropic_environment_key", "ANTHROPIC_ENVIRONMENT_KEY must be a single-line Anthropic environment credential.");
991
+ }
992
+ let payload;
993
+ try {
994
+ payload = await client.connectAnthropicEnvironment(environmentId, environmentKey);
995
+ }
996
+ catch (error) {
997
+ throw anthropicEnvironmentError(error, client, environmentId);
998
+ }
999
+ const nextActions = [commandAction(["sanbox", "anthropic-environments", "get", environmentId, "--json"], "Check whether the first authenticated queue poll connected the environment.")];
1000
+ if (hasFlag(flags, "json")) {
1001
+ printSuccess("anthropic_environments.connect", payload, jsonContext(client), nextActions);
1002
+ return;
1003
+ }
1004
+ process.stdout.write(`${payload.environment.environment_id} ${payload.environment.status}` +
1005
+ ` key=${payload.environment.key_hint || "stored"}\n`);
1006
+ return;
1007
+ }
1008
+ if (action === "disconnect") {
1009
+ const environmentId = requiredAnthropicEnvironmentId(command[2], action);
1010
+ if (!hasFlag(flags, "force")) {
1011
+ throw new CliError("confirmation_required", "anthropic-environments disconnect requires --force.", {
1012
+ details: { environment_id: environmentId },
1013
+ nextActions: [commandAction(["sanbox", "anthropic-environments", "disconnect", environmentId, "--force", "--json"], "Disconnect the environment while preserving its session workspace mappings.")]
1014
+ });
1015
+ }
1016
+ let payload;
1017
+ try {
1018
+ payload = await client.disconnectAnthropicEnvironment(environmentId);
1019
+ }
1020
+ catch (error) {
1021
+ throw anthropicEnvironmentError(error, client, environmentId);
1022
+ }
1023
+ if (hasFlag(flags, "json")) {
1024
+ printSuccess("anthropic_environments.disconnect", payload, jsonContext(client));
1025
+ return;
1026
+ }
1027
+ process.stdout.write(`${payload.environment_id} ${payload.disconnected ? "disconnected" : "not found"}\n`);
1028
+ return;
1029
+ }
1030
+ throw new CliError("anthropic_environments_action_required", "anthropic-environments requires an action: list, get, connect, or disconnect.");
1031
+ };
753
1032
  const commandTemplates = async (command, flags) => {
754
1033
  const client = makeClient(flags);
755
1034
  const action = command[1];
@@ -826,8 +1105,8 @@ const commandTemplates = async (command, flags) => {
826
1105
  const modelProvider = requiredPositional(flagString(flags, "model-provider"), "model_provider_required", "templates create requires --model-provider.");
827
1106
  const model = requiredPositional(flagString(flags, "model"), "model_required", "templates create requires --model.");
828
1107
  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.");
1108
+ if (harness !== "opencode" && harness !== "hermes" && harness !== "browser-use") {
1109
+ throw new CliError("invalid_harness", "templates create --harness must be opencode, hermes, or browser-use.");
831
1110
  }
832
1111
  const budgetRaw = flagString(flags, "llm-budget-usd");
833
1112
  const parsedBudgetUsd = budgetRaw ? Number(budgetRaw) : undefined;
@@ -843,6 +1122,30 @@ const commandTemplates = async (command, flags) => {
843
1122
  }
844
1123
  const telegramBotToken = process.env.SANBOX_TELEGRAM_BOT_TOKEN?.trim() || "";
845
1124
  const telegramAllowedUsers = flagList(flags, "telegram-allowed-user").map((value) => value.trim()).filter(Boolean);
1125
+ const browserFlagNames = [
1126
+ "browser-domain",
1127
+ "browser-max-steps",
1128
+ "browser-step-timeout-seconds",
1129
+ "browser-vision-mode",
1130
+ "browser-viewport",
1131
+ "browser-download-policy",
1132
+ "browser-additional-instructions"
1133
+ ];
1134
+ if (harness !== "browser-use" && browserFlagNames.some((name) => flags[name] !== undefined)) {
1135
+ throw new CliError("browser_use_config_requires_browser_use", "Browser settings require --harness browser-use.");
1136
+ }
1137
+ if (harness === "browser-use" &&
1138
+ modelProvider !== "openai" &&
1139
+ modelProvider !== "anthropic" &&
1140
+ modelProvider !== "google") {
1141
+ throw new CliError("browser_use_provider_unsupported", "Browser Use templates require --model-provider openai, anthropic, or google.");
1142
+ }
1143
+ const browserDomains = harness === "browser-use"
1144
+ ? parseBrowserDomains(flagList(flags, "browser-domain"))
1145
+ : [];
1146
+ const browserUse = harness === "browser-use"
1147
+ ? parseBrowserUseConfig(flags)
1148
+ : undefined;
846
1149
  if (harness === "hermes" && !telegramBotToken) {
847
1150
  throw new CliError("telegram_bot_token_required", "Hermes template creation requires SANBOX_TELEGRAM_BOT_TOKEN.");
848
1151
  }
@@ -856,6 +1159,20 @@ const commandTemplates = async (command, flags) => {
856
1159
  provider_id: modelProvider,
857
1160
  model_id: model,
858
1161
  ...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd }),
1162
+ ...(harness === "browser-use" ? {
1163
+ harness,
1164
+ browser_use: browserUse,
1165
+ network_policy: {
1166
+ default_action: "deny",
1167
+ rules: browserDomains.map((destination) => ({
1168
+ destination,
1169
+ ports: [
1170
+ { from: 80, to: 80 },
1171
+ { from: 443, to: 443 }
1172
+ ]
1173
+ }))
1174
+ }
1175
+ } : {}),
859
1176
  ...(harness === "hermes" ? {
860
1177
  harness,
861
1178
  telegram_bot_token: telegramBotToken,
@@ -937,6 +1254,43 @@ const commandRuns = async (command, flags) => {
937
1254
  }
938
1255
  return;
939
1256
  }
1257
+ if (action === "share") {
1258
+ const expiresInSeconds = parseFileAccessExpiry(flagString(flags, "expires", "1h"));
1259
+ const name = flagString(flags, "name").trim();
1260
+ const payload = await client.createFileAccessPoint(runId, {
1261
+ expires_in_seconds: expiresInSeconds,
1262
+ ...(name ? { name } : {})
1263
+ });
1264
+ if (hasFlag(flags, "json")) {
1265
+ printSuccess("runs.share", payload, jsonContext(client), [
1266
+ commandAction(["sanbox", "runs", "unshare", runId, payload.access_point.id, "--json"], "Revoke this root filesystem link.")
1267
+ ]);
1268
+ return;
1269
+ }
1270
+ process.stdout.write(`${payload.url}\n`);
1271
+ return;
1272
+ }
1273
+ if (action === "shares") {
1274
+ const payload = await client.listFileAccessPoints(runId);
1275
+ if (hasFlag(flags, "json")) {
1276
+ printSuccess("runs.shares", payload, jsonContext(client));
1277
+ return;
1278
+ }
1279
+ for (const accessPoint of payload.access_points) {
1280
+ process.stdout.write(`${accessPoint.id}\t${accessPoint.status}\t${accessPoint.expires_at}\t${accessPoint.name}\n`);
1281
+ }
1282
+ return;
1283
+ }
1284
+ if (action === "unshare") {
1285
+ const accessPointId = requiredPositional(command[3], "file_access_point_id_required", "runs unshare requires an access point id.");
1286
+ const payload = await client.revokeFileAccessPoint(runId, accessPointId);
1287
+ if (hasFlag(flags, "json")) {
1288
+ printSuccess("runs.unshare", payload, jsonContext(client));
1289
+ return;
1290
+ }
1291
+ process.stdout.write(`revoked ${payload.access_point.id}\n`);
1292
+ return;
1293
+ }
940
1294
  if (action === "download") {
941
1295
  const outputDirectory = requiredPositional(flagString(flags, "output"), "output_directory_required", "runs download requires --output.");
942
1296
  const payload = await client.listArtifacts(runId);
@@ -1003,7 +1357,29 @@ const commandRuns = async (command, flags) => {
1003
1357
  printRun(payload);
1004
1358
  return;
1005
1359
  }
1360
+ if (action === "resume" || action === "pause") {
1361
+ const submitted = action === "resume"
1362
+ ? await client.resumeRun(runId)
1363
+ : await client.pauseRun(runId);
1364
+ const payload = hasFlag(flags, "wait")
1365
+ ? await waitForSandboxState(client, runId, action === "resume" ? "running" : "paused", {
1366
+ pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
1367
+ timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
1368
+ })
1369
+ : submitted;
1370
+ if (hasFlag(flags, "json")) {
1371
+ printSuccess(`runs.${action}`, publicRunPayload(payload), jsonContext(client));
1372
+ }
1373
+ else if (hasFlag(flags, "wait")) {
1374
+ printRun(payload);
1375
+ }
1376
+ else {
1377
+ process.stdout.write(`sandbox ${action} requested for run ${runId}\n`);
1378
+ }
1379
+ return;
1380
+ }
1006
1381
  if (action === "message") {
1382
+ const commandName = "runs.message";
1007
1383
  const flaggedMessage = flagString(flags, "message");
1008
1384
  const positionalMessage = positionalText(command, 3);
1009
1385
  if (flaggedMessage && positionalMessage) {
@@ -1011,7 +1387,7 @@ const commandRuns = async (command, flags) => {
1011
1387
  }
1012
1388
  const message = flaggedMessage || positionalMessage;
1013
1389
  if (!message)
1014
- throw new Error("--message or a positional message is required.");
1390
+ throw new Error(`runs ${action} requires an instruction as positional text or --message.`);
1015
1391
  if (wantsWatch(flags) && hasFlag(flags, "json")) {
1016
1392
  throw new Error("--json cannot be combined with --watch or --jsonl.");
1017
1393
  }
@@ -1021,14 +1397,14 @@ const commandRuns = async (command, flags) => {
1021
1397
  const submitted = await client.sendMessage(runId, message);
1022
1398
  if (!hasFlag(flags, "wait") && !wantsWatch(flags)) {
1023
1399
  if (hasFlag(flags, "json")) {
1024
- printSuccess("runs.message", {
1400
+ printSuccess(commandName, {
1025
1401
  ...publicRunPayload(submitted),
1026
1402
  message: submitted.message,
1027
1403
  chat_job: submitted.chat_job
1028
1404
  }, jsonContext(client));
1029
1405
  }
1030
1406
  else
1031
- process.stdout.write(`queued follow-up for run ${runId}\n`);
1407
+ process.stdout.write(`queued OpenCode follow-up for run ${runId}\n`);
1032
1408
  return;
1033
1409
  }
1034
1410
  const chatJobId = submitted.chat_job.id;
@@ -1075,7 +1451,7 @@ const commandRuns = async (command, flags) => {
1075
1451
  });
1076
1452
  const messages = await client.listMessages(runId);
1077
1453
  if (hasFlag(flags, "json")) {
1078
- printSuccess("runs.message", {
1454
+ printSuccess(commandName, {
1079
1455
  ...publicRunPayload(submitted),
1080
1456
  message: submitted.message,
1081
1457
  chat_job: submitted.chat_job,
@@ -1107,7 +1483,21 @@ const commandRuns = async (command, flags) => {
1107
1483
  const commandDoctor = async (flags) => {
1108
1484
  const localConfig = readLocalConfig();
1109
1485
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
1110
- const apiKey = process.env.SANBOX_API_KEY || "";
1486
+ const environmentApiKey = process.env.SANBOX_API_KEY || "";
1487
+ let savedUserSession = null;
1488
+ if (!environmentApiKey) {
1489
+ try {
1490
+ savedUserSession = userSessionStore.read(apiUrl);
1491
+ if (savedUserSession && Date.parse(savedUserSession.expiresAt) <= Date.now()) {
1492
+ userSessionStore.delete(apiUrl);
1493
+ savedUserSession = null;
1494
+ }
1495
+ }
1496
+ catch {
1497
+ savedUserSession = null;
1498
+ }
1499
+ }
1500
+ const apiKey = environmentApiKey || savedUserSession?.accessToken || "";
1111
1501
  const selection = readTemplateSelection(flags, { required: false });
1112
1502
  let resolvedOrg = "";
1113
1503
  const checks = [];
@@ -1120,9 +1510,13 @@ const commandDoctor = async (flags) => {
1120
1510
  });
1121
1511
  add("node", Number(process.versions.node.split(".")[0]) >= 20, process.version);
1122
1512
  add("api_url", Boolean(apiUrl), apiUrl);
1123
- add("api_key", Boolean(apiKey), apiKey ? "present in environment" : "missing SANBOX_API_KEY");
1513
+ add("authentication", Boolean(apiKey), environmentApiKey
1514
+ ? "organization API key present in environment"
1515
+ : savedUserSession
1516
+ ? `signed in as ${savedUserSession.email}`
1517
+ : "not signed in");
1124
1518
  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>" }));
1519
+ nextActions.push(commandAction(["sanbox", "login"], "Sign in with your Sanbox user account."));
1126
1520
  }
1127
1521
  try {
1128
1522
  const res = await fetch(`${apiUrl}/health`);
@@ -1200,6 +1594,69 @@ const commandDoctor = async (flags) => {
1200
1594
  if (!output.ok)
1201
1595
  process.exitCode = 2;
1202
1596
  };
1597
+ const commandSSH = async (command, flags) => {
1598
+ const runId = requiredPositional(command[1], "run_id_required", "ssh requires a run id.");
1599
+ const client = new SanboxClient(readUserConfig(flags));
1600
+ const exitCode = await openSSH(client, runId);
1601
+ if (exitCode !== 0)
1602
+ process.exitCode = exitCode;
1603
+ };
1604
+ const commandLogin = async (flags) => {
1605
+ const apiUrl = resolveApiUrl(flags);
1606
+ const previousSession = userSessionStore.read(apiUrl);
1607
+ const session = await loginWithBrowser({
1608
+ apiUrl,
1609
+ launchBrowser: hasFlag(flags, "no-browser") ? () => false : openBrowser,
1610
+ onPrompt: (request, browserOpened) => {
1611
+ process.stderr.write(`${browserOpened ? "Complete sign-in in your browser" : "Open this URL in your browser"}:\n` +
1612
+ `${request.verification_url}\n\nConfirm code: ${request.user_code}\n`);
1613
+ }
1614
+ });
1615
+ userSessionStore.write(session);
1616
+ if (previousSession && previousSession.accessToken !== session.accessToken) {
1617
+ try {
1618
+ await revokeUserSession(apiUrl, previousSession.accessToken);
1619
+ }
1620
+ catch {
1621
+ process.stderr.write("Warning: the previous local CLI session could not be revoked and will expire automatically.\n");
1622
+ }
1623
+ }
1624
+ if (hasFlag(flags, "json")) {
1625
+ printSuccess("auth.login", {
1626
+ email: session.email,
1627
+ organization: session.organization,
1628
+ expires_at: session.expiresAt
1629
+ }, { api_url: apiUrl, org: session.organization.slug });
1630
+ }
1631
+ else {
1632
+ process.stdout.write(`Signed in as ${session.email} for ${session.organization.name}.\n`);
1633
+ }
1634
+ };
1635
+ const commandLogout = async (flags) => {
1636
+ const apiUrl = resolveApiUrl(flags);
1637
+ const session = userSessionStore.read(apiUrl);
1638
+ if (session) {
1639
+ let revokeError = null;
1640
+ try {
1641
+ await revokeUserSession(apiUrl, session.accessToken);
1642
+ }
1643
+ catch (error) {
1644
+ revokeError = error;
1645
+ }
1646
+ finally {
1647
+ userSessionStore.delete(apiUrl);
1648
+ }
1649
+ if (revokeError) {
1650
+ throw new CliError("logout_revoke_failed", "The local session was removed, but the server session could not be revoked and will expire automatically.");
1651
+ }
1652
+ }
1653
+ if (hasFlag(flags, "json")) {
1654
+ printSuccess("auth.logout", { signed_out: Boolean(session) }, { api_url: apiUrl });
1655
+ }
1656
+ else {
1657
+ process.stdout.write(session ? "Signed out.\n" : "No saved Sanbox user session.\n");
1658
+ }
1659
+ };
1203
1660
  const commandInit = async (command, flags) => {
1204
1661
  const dir = path.join(cwd(), ".sanbox");
1205
1662
  if (command[1] === "agent") {
@@ -1264,8 +1721,12 @@ const helpFor = (command) => {
1264
1721
  return doctorHelp;
1265
1722
  if (command[0] === "model-providers")
1266
1723
  return modelProvidersHelp;
1724
+ if (command[0] === "anthropic-environments")
1725
+ return anthropicEnvironmentsHelp;
1267
1726
  if (command[0] === "templates")
1268
1727
  return templatesHelp;
1728
+ if (command[0] === "ssh")
1729
+ return sshHelp;
1269
1730
  return help;
1270
1731
  };
1271
1732
  const commandId = (command) => {
@@ -1277,6 +1738,9 @@ const commandId = (command) => {
1277
1738
  return "context.get";
1278
1739
  if (command[0] === "model-providers")
1279
1740
  return `model_providers.${command[1] || "unknown"}`;
1741
+ if (command[0] === "anthropic-environments") {
1742
+ return `anthropic_environments.${command[1] || "unknown"}`;
1743
+ }
1280
1744
  if (command[0] === "templates")
1281
1745
  return `templates.${command[1] || "unknown"}`;
1282
1746
  if (command[0] === "run")
@@ -1285,6 +1749,8 @@ const commandId = (command) => {
1285
1749
  return "batch.create";
1286
1750
  if (command[0] === "runs")
1287
1751
  return `runs.${command[1] || "unknown"}`;
1752
+ if (command[0] === "ssh" || command[0] === "ssh-proxy")
1753
+ return command[0];
1288
1754
  return command[0] || "help";
1289
1755
  };
1290
1756
  const main = async (command, flags) => {
@@ -1309,12 +1775,19 @@ const main = async (command, flags) => {
1309
1775
  }
1310
1776
  if (command[0] === "auth" && command[1] === "check")
1311
1777
  return commandAuthCheck(flags);
1778
+ if (command[0] === "login")
1779
+ return commandLogin(flags);
1780
+ if (command[0] === "logout")
1781
+ return commandLogout(flags);
1312
1782
  if (command[0] === "context")
1313
1783
  return commandContext(flags);
1314
1784
  if (command[0] === "doctor")
1315
1785
  return commandDoctor(flags);
1316
1786
  if (command[0] === "model-providers")
1317
1787
  return commandModelProviders(command, flags);
1788
+ if (command[0] === "anthropic-environments") {
1789
+ return commandAnthropicEnvironments(command, flags);
1790
+ }
1318
1791
  if (command[0] === "templates")
1319
1792
  return commandTemplates(command, flags);
1320
1793
  if (command[0] === "run")
@@ -1323,6 +1796,14 @@ const main = async (command, flags) => {
1323
1796
  return commandBatch(flags);
1324
1797
  if (command[0] === "runs")
1325
1798
  return commandRuns(command, flags);
1799
+ if (command[0] === "ssh")
1800
+ return commandSSH(command, flags);
1801
+ if (command[0] === "ssh-proxy") {
1802
+ const exitCode = await runSSHProxy();
1803
+ if (exitCode !== 0)
1804
+ process.exitCode = exitCode;
1805
+ return;
1806
+ }
1326
1807
  if (command[0] === "init")
1327
1808
  return commandInit(command, flags);
1328
1809
  throw new CliError("unknown_command", `Unknown command: ${command.join(" ")}`);