@sanlabs/sanbox-cli 0.0.4 → 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,34 +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_ORG Organization slug
20
- SANBOX_API_KEY Org API key
23
+ SANBOX_API_KEY Org API key for automation (no interactive access)
21
24
  SANBOX_TEMPLATE Explicit template id or slug for run and batch
22
25
 
23
26
  Commands:
24
27
  sanbox --version
28
+ sanbox login [--no-browser]
29
+ sanbox logout
25
30
  sanbox auth check [--json]
26
- sanbox orgs list [--json]
27
31
  sanbox context [--json]
28
32
  sanbox doctor [--json]
29
33
  sanbox model-providers list [--json]
30
34
  sanbox model-providers get <provider-id> [--json]
31
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]
32
40
  sanbox templates list [--json]
33
41
  sanbox templates get <template-id> [--json]
34
42
  sanbox templates validate <template-id> [--json]
35
- sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--llm-budget-usd <amount>] [--web-access] [--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]
36
44
  sanbox run "task" --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
37
45
  sanbox run --task "..." --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
38
46
  sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
@@ -41,13 +49,30 @@ Commands:
41
49
  sanbox runs events <run-id> [--after-event-id 0] [--json]
42
50
  sanbox runs messages <run-id> [--json]
43
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]
44
55
  sanbox runs download <run-id> --output <directory> [--artifact <path>] [--overwrite] [--json]
45
56
  sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--jsonl]
46
57
  sanbox runs cancel <run-id> [--json]
58
+ sanbox runs resume <run-id> [--wait] [--json]
59
+ sanbox runs pause <run-id> [--wait] [--json]
47
60
  sanbox runs message <run-id> "..." [--wait | --watch] [--json | --jsonl]
61
+ sanbox ssh <run-id>
48
62
  sanbox init [--force]
49
63
  sanbox init agent [--write]
50
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
+ `;
51
76
  const runHelp = `Sanbox run
52
77
 
53
78
  Usage:
@@ -58,7 +83,6 @@ Options:
58
83
  --input <path> File, directory, or glob to upload. Repeatable.
59
84
  --template <id> Template id or slug. Required unless SANBOX_TEMPLATE or project config sets it.
60
85
  --external-run-id <id> Idempotency key for retries.
61
- --retention-ttl-seconds <n> Workspace retention TTL. Default: 86400.
62
86
  --dry-run Preview included files without creating a run.
63
87
  --wait Poll until terminal status.
64
88
  --watch Stream activity until terminal status.
@@ -73,7 +97,7 @@ const doctorHelp = `Sanbox doctor
73
97
  Usage:
74
98
  sanbox doctor [--json]
75
99
 
76
- Checks local configuration, API health, auth, org visibility, and template access.
100
+ Checks local configuration, API health, auth, organization discovery, and template access.
77
101
  SANBOX_API_KEY is still read only from the environment.
78
102
  `;
79
103
  const modelProvidersHelp = `Sanbox model providers
@@ -86,32 +110,59 @@ Usage:
86
110
  Provider credentials are configured by organization admins in the Sanbox console.
87
111
  Model ids are scoped to their provider and are never deduplicated across providers.
88
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
+ `;
89
127
  const templatesHelp = `Sanbox templates
90
128
 
91
129
  Usage:
92
130
  sanbox templates list [--json]
93
131
  sanbox templates get <template-id> [--json]
94
132
  sanbox templates validate <template-id> [--json]
95
- sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--llm-budget-usd <amount>] [--web-access] [--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]
96
134
 
97
135
  Template creation requires an exact provider id and that provider's exact model id.
98
136
  LiteLLM budgets are optional USD amounts and apply separately to each run.
137
+ Hermes templates are always-on Telegram gateways. Set SANBOX_TELEGRAM_BOT_TOKEN
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.
99
144
  `;
100
145
  const agentInstructions = `# Operate Sanbox Autonomously
101
146
 
102
147
  Use the \`sanbox\` CLI for focused, isolated, long-running, risky, or parallel work. The canonical
103
- protocol is https://console.sanbox.cloud/agent.md. This workflow requires CLI 0.0.4 or newer.
148
+ protocol is https://console.sanbox.cloud/agent.md.
149
+
150
+ Always use the latest published CLI. Install and verify it at the start of every operating session:
151
+ \`\`\`bash
152
+ npm install -g @sanlabs/sanbox-cli@latest
153
+ latest_cli_version="$(npm view @sanlabs/sanbox-cli version)"
154
+ installed_cli_version="$(sanbox --version)"
155
+ test "$installed_cli_version" = "$latest_cli_version"
156
+ \`\`\`
104
157
 
105
158
  Human bootstrap:
106
- - A human/admin supplies SANBOX_API_KEY and configures provider credentials plus a runnable template.
159
+ - A human/admin supplies an organization-scoped SANBOX_API_KEY and configures provider credentials plus a runnable template.
107
160
  - Never print, persist, prompt with, or upload API keys or provider credentials.
108
161
  - If the key or a runnable template is unavailable, stop with the exact human action required.
109
162
 
110
163
  Deterministic startup:
111
164
  \`\`\`bash
112
165
  sanbox --version
113
- sanbox orgs list --json
114
- export SANBOX_ORG=<returned-org-slug>
115
166
  sanbox auth check --json
116
167
  sanbox context --json
117
168
  sanbox templates list --json
@@ -120,8 +171,12 @@ sanbox templates validate "$SANBOX_TEMPLATE" --json
120
171
  sanbox doctor --json
121
172
  \`\`\`
122
173
 
123
- Never guess opaque IDs. Select a discovered org/template automatically only when exactly one valid
124
- choice exists; otherwise ask the user. Provider credentials and template administration are
174
+ Never guess opaque IDs. The CLI derives the organization from the API key and requires the key to
175
+ resolve to exactly one organization. For task execution, select only a template with runnable: true,
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
179
+ template qualifies; otherwise ask the user. Provider credentials and template administration are
125
180
  console-only.
126
181
 
127
182
  Use --json for request/response commands and --jsonl for streams. Parse the versioned envelope:
@@ -139,7 +194,7 @@ sanbox run "Investigate one focused task and write output/report.md" \\
139
194
 
140
195
  Reuse the same --external-run-id after ambiguous failures. Retry network errors, HTTP 429, HTTP 5xx,
141
196
  and workspace_busy with bounded backoff. Do not retry other 4xx errors unless next_actions directs
142
- recovery.
197
+ recovery. Terminal statuses are completed, failed, and canceled.
143
198
 
144
199
  Recover and retrieve results:
145
200
  \`\`\`bash
@@ -154,33 +209,53 @@ sanbox runs download <run-id> --output .sanbox/output/<run-id> --json
154
209
  Tasks must put durable files under /workspace/output. Downloads preserve relative paths and report
155
210
  byte counts plus SHA-256 digests; existing files require explicit --overwrite.
156
211
 
157
- Continue through the retained workspace:
212
+ While a sandbox is running, create one read-only capability URL for its entire root filesystem:
158
213
  \`\`\`bash
159
- sanbox runs messages <run-id> --json
160
- sanbox runs message <run-id> "Summarize the retained output" --wait --json
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
161
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:
222
+ \`\`\`bash
223
+ sanbox runs get <run-id> --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
227
+ \`\`\`
228
+
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
235
+ are correlated to the returned chat_job.id. Never submit concurrent follow-ups to one run. Retry
236
+ sandbox_not_paused only after the run returns to paused; treat sandbox_not_resumable,
237
+ sandbox_snapshot_missing, sandbox_worker_missing, hermes_uses_channel, and
238
+ browser_use_followup_unsupported as blockers.
162
239
 
163
- Follow-up waits are correlated to the returned chat_job.id. Keep dependent follow-ups sequential.
164
240
  For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
165
241
  client alive until submission completes.
166
242
 
167
243
  Do not claim completion until the run is completed, required artifacts are downloaded and verified,
168
- and required follow-ups succeeded. Report run/external/template IDs, status, artifact paths/digests,
169
- and blockers. The CLI excludes common secrets by default; add .sanboxignore for project rules.
244
+ and required follow-ups succeeded. Report run/external/template IDs, status, sandbox state, snapshot
245
+ generation, artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
246
+ .sanboxignore for project rules.
170
247
  `;
171
248
  const cwd = () => process.cwd();
172
249
  const makeClient = (flags) => new SanboxClient(readConfig(flags));
173
- const makeAuthClient = (flags) => new SanboxClient(readConfig(flags, { requireOrg: false }));
174
250
  const jsonContext = (client) => ({
175
251
  api_url: client.config.apiUrl,
176
- org: client.config.org
252
+ ...(client.resolvedOrganizationSlug() ? { org: client.resolvedOrganizationSlug() } : {})
177
253
  });
178
254
  const localJsonContext = (flags) => {
179
255
  try {
180
256
  const local = readLocalConfig();
181
257
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || local.api_url || defaultApiUrl).replace(/\/+$/, "");
182
- const org = String(flags.org || process.env.SANBOX_ORG || local.org || "").trim();
183
- return { api_url: apiUrl, ...(org ? { org } : {}) };
258
+ return { api_url: apiUrl };
184
259
  }
185
260
  catch {
186
261
  return {};
@@ -188,10 +263,12 @@ const localJsonContext = (flags) => {
188
263
  };
189
264
  const consolePathUrl = (client, pathname) => new URL(pathname, `${client.config.apiUrl}/`).toString();
190
265
  const consoleUrl = (client) => consolePathUrl(client, "/model-providers");
266
+ const anthropicEnvironmentsConsoleUrl = (client) => consolePathUrl(client, "/anthropic-environments");
191
267
  const providerConsoleAction = (client) => consoleAction(consoleUrl(client), "Ask an organization admin to configure or repair the model provider in the Sanbox console.");
192
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.");
193
270
  const templateActions = () => [
194
- commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization."),
271
+ commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization."),
195
272
  commandAction(["sanbox", "templates", "validate", "<template-id>", "--json"], "Check whether a template is runnable.")
196
273
  ];
197
274
  const runReadinessCodes = new Set([
@@ -256,6 +333,62 @@ const requiredPositional = (value, code, message) => {
256
333
  throw new CliError(code, message);
257
334
  return normalized;
258
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
+ };
259
392
  const providerId = (provider) => String(provider.provider_id || provider.id || "");
260
393
  const templateName = (template) => String(template.display_name || template.template_slug || template.id || "");
261
394
  const validationRunnable = (validation) => {
@@ -273,43 +406,71 @@ const formatBytes = (bytes) => {
273
406
  };
274
407
  const positionalText = (command, startIndex) => command.slice(startIndex).join(" ").trim();
275
408
  const runTask = (command, flags) => flagString(flags, "task") || positionalText(command, 1);
276
- const commonFlags = ["api-url", "org", "json", "help"];
409
+ const commonFlags = ["api-url", "json", "help"];
277
410
  const flagSets = {
278
411
  "auth.check": commonFlags,
279
- "orgs.list": commonFlags,
280
412
  context: [...commonFlags, "template"],
281
413
  doctor: [...commonFlags, "template"],
282
414
  "model-providers.list": commonFlags,
283
415
  "model-providers.get": commonFlags,
284
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"],
285
421
  "templates.list": commonFlags,
286
422
  "templates.get": commonFlags,
287
423
  "templates.validate": commonFlags,
288
- "templates.create": [...commonFlags, "name", "model-provider", "model", "llm-budget-usd", "web-access"],
424
+ "templates.create": [
425
+ ...commonFlags,
426
+ "name",
427
+ "model-provider",
428
+ "model",
429
+ "harness",
430
+ "llm-budget-usd",
431
+ "web-access",
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"
440
+ ],
289
441
  run: [
290
- ...commonFlags, "task", "input", "template", "external-run-id", "retention-ttl-seconds",
442
+ ...commonFlags, "task", "input", "template", "external-run-id",
291
443
  "dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "cancel-on-interrupt",
292
444
  "poll-interval-ms", "event-page-size", "timeout-seconds", "verbose"
293
445
  ],
294
446
  batch: [
295
447
  ...commonFlags, "tasks", "template", "input", "max-parallel", "wait", "batch-id",
296
- "retention-ttl-seconds", "poll-interval-ms", "timeout-seconds"
448
+ "poll-interval-ms", "timeout-seconds"
297
449
  ],
298
450
  "runs.list": [...commonFlags, "limit"],
299
451
  "runs.get": commonFlags,
300
452
  "runs.events": [...commonFlags, "after-event-id"],
301
453
  "runs.messages": commonFlags,
302
454
  "runs.artifacts": commonFlags,
455
+ "runs.share": [...commonFlags, "expires", "name"],
456
+ "runs.shares": commonFlags,
457
+ "runs.unshare": commonFlags,
303
458
  "runs.download": [...commonFlags, "output", "artifact", "overwrite"],
304
459
  "runs.watch": [
305
460
  ...commonFlags, "after-event-id", "view", "jsonl", "cancel-on-interrupt",
306
461
  "poll-interval-ms", "event-page-size", "timeout-seconds"
307
462
  ],
308
463
  "runs.cancel": commonFlags,
464
+ "runs.resume": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
465
+ "runs.pause": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
309
466
  "runs.message": [
310
467
  ...commonFlags, "message", "wait", "watch", "jsonl", "view",
311
468
  "poll-interval-ms", "event-page-size", "timeout-seconds"
312
469
  ],
470
+ ssh: ["api-url", "help"],
471
+ "ssh-proxy": [],
472
+ login: ["api-url", "json", "no-browser", "help"],
473
+ logout: ["api-url", "json", "help"],
313
474
  init: [...commonFlags, "force", "template"],
314
475
  "init.agent": [...commonFlags, "write"],
315
476
  version: ["json", "help"]
@@ -321,9 +482,10 @@ const commandKey = (command) => {
321
482
  return "version";
322
483
  if (command[0] === "auth")
323
484
  return `auth.${command[1] || ""}`;
324
- if (command[0] === "orgs")
325
- return `orgs.${command[1] || ""}`;
326
- 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") {
327
489
  return `${command[0]}.${command[1] || ""}`;
328
490
  }
329
491
  if (command[0] === "init" && command[1] === "agent")
@@ -331,10 +493,14 @@ const commandKey = (command) => {
331
493
  return command[0];
332
494
  };
333
495
  const requiredValueFlags = new Set([
334
- "api-url", "org", "template", "task", "input", "include", "external-run-id",
335
- "retention-ttl-seconds", "tasks", "max-parallel", "batch-id", "poll-interval-ms",
496
+ "api-url", "template", "task", "input", "include", "external-run-id",
497
+ "tasks", "max-parallel", "batch-id", "poll-interval-ms",
336
498
  "event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
337
- "model-provider", "model", "llm-budget-usd", "message", "output", "artifact"
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",
503
+ "message", "output", "artifact"
338
504
  ]);
339
505
  const validateFlagValues = (flags) => {
340
506
  for (const flag of requiredValueFlags) {
@@ -372,8 +538,8 @@ const validateFlags = (command, flags) => {
372
538
  return;
373
539
  }
374
540
  const knownRoots = new Set([
375
- "auth", "orgs", "context", "doctor", "model-providers", "templates",
376
- "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"
377
543
  ]);
378
544
  const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
379
545
  if (!allowed)
@@ -388,12 +554,15 @@ const validatePositionals = (command, flags) => {
388
554
  return;
389
555
  const maximums = {
390
556
  "auth.check": 2,
391
- "orgs.list": 2,
392
557
  context: 1,
393
558
  doctor: 1,
394
559
  "model-providers.list": 2,
395
560
  "model-providers.get": 3,
396
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,
397
566
  "templates.list": 2,
398
567
  "templates.get": 3,
399
568
  "templates.validate": 3,
@@ -404,9 +573,18 @@ const validatePositionals = (command, flags) => {
404
573
  "runs.events": 3,
405
574
  "runs.messages": 3,
406
575
  "runs.artifacts": 3,
576
+ "runs.share": 3,
577
+ "runs.shares": 3,
578
+ "runs.unshare": 4,
407
579
  "runs.download": 3,
408
580
  "runs.watch": 3,
409
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,
410
588
  init: 1,
411
589
  "init.agent": 2,
412
590
  version: 1
@@ -533,8 +711,7 @@ const commandRun = async (command, flags) => {
533
711
  instruction: task,
534
712
  inputs: flagList(flags, "input"),
535
713
  externalRunId: flagString(flags, "external-run-id") || undefined,
536
- templateId: template.id,
537
- retentionTtlSeconds: integerFlag(flags, "retention-ttl-seconds", 86400, 0, Number.MAX_SAFE_INTEGER)
714
+ templateId: template.id
538
715
  });
539
716
  }
540
717
  catch (error) {
@@ -587,8 +764,7 @@ const commandBatch = async (flags) => {
587
764
  instruction: task.task,
588
765
  inputs: task.input || inputs,
589
766
  externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
590
- templateId: template.id,
591
- retentionTtlSeconds: integerFlag(flags, "retention-ttl-seconds", 86400, 0, Number.MAX_SAFE_INTEGER)
767
+ templateId: template.id
592
768
  });
593
769
  }
594
770
  catch (error) {
@@ -617,50 +793,28 @@ const commandBatch = async (flags) => {
617
793
  };
618
794
  const commandAuthCheck = async (flags) => {
619
795
  const client = makeClient(flags);
620
- const me = await client.me();
621
- const org = client.config.org;
622
- const organizations = Array.isArray(me.organizations)
623
- ? me.organizations
624
- : [];
625
- const selected = organizations.find((item) => item.slug === org) || null;
626
- const output = { ok: Boolean(selected), org, selected, me };
796
+ const [me, selected] = await Promise.all([client.me(), client.organization()]);
797
+ const org = selected.slug;
798
+ const output = { ok: true, org, selected, me };
627
799
  if (hasFlag(flags, "json"))
628
800
  printSuccess("auth.check", output, jsonContext(client));
629
801
  else
630
- process.stdout.write(selected ? `ok org=${org}\n` : `authenticated, but org ${org} is not visible\n`);
631
- if (!selected)
632
- process.exitCode = 2;
633
- };
634
- const commandOrganizations = async (command, flags) => {
635
- if (command[1] !== "list")
636
- throw new CliError("orgs_action_required", "orgs requires the list action.");
637
- const client = makeAuthClient(flags);
638
- const payload = await client.listOrganizations();
639
- if (hasFlag(flags, "json")) {
640
- printSuccess("orgs.list", payload, { api_url: client.config.apiUrl });
641
- return;
642
- }
643
- for (const organization of payload.organizations) {
644
- process.stdout.write(`${organization.slug}${organization.name ? `\t${organization.name}` : ""}${organization.membership_role ? `\t${organization.membership_role}` : ""}\n`);
645
- }
802
+ process.stdout.write(`ok org=${org}\n`);
646
803
  };
647
804
  const commandContext = async (flags) => {
648
805
  const client = makeClient(flags);
649
806
  const selection = readTemplateSelection(flags, { required: false });
650
- const [me, providerPayload, templatePayload] = await Promise.all([
807
+ const [organization, me, providerPayload, templatePayload] = await Promise.all([
808
+ client.organization(),
651
809
  client.me(),
652
810
  client.listModelProviders(),
653
811
  client.listTemplates()
654
812
  ]);
655
- const organizations = Array.isArray(me.organizations)
656
- ? me.organizations
657
- : [];
658
- const selectedOrganization = organizations.find((item) => item.slug === client.config.org) || null;
659
813
  const selectedTemplate = selection
660
814
  ? templatePayload.templates.find((item) => item.id === selection.id || item.template_slug === selection.id) || null
661
815
  : null;
662
816
  const output = {
663
- organization: selectedOrganization,
817
+ organization,
664
818
  authentication: me.auth || null,
665
819
  template_selection: selection
666
820
  ? { id: selection.id, source: selection.source, found: Boolean(selectedTemplate), template: selectedTemplate }
@@ -669,22 +823,17 @@ const commandContext = async (flags) => {
669
823
  templates: templatePayload.templates
670
824
  };
671
825
  const nextActions = [];
672
- if (!selectedOrganization) {
673
- nextActions.push(commandAction(["sanbox", "auth", "check", "--json"], "Verify that the API key can access the selected organization."));
674
- }
675
826
  if (!selection) {
676
827
  nextActions.push(commandAction(["sanbox", "templates", "list", "--json"], "List available templates."), commandAction(["sanbox", "context", "--json"], "Select a template for this shell and inspect the resolved context.", { SANBOX_TEMPLATE: "<template-id>" }));
677
828
  }
678
829
  else if (!selectedTemplate) {
679
830
  nextActions.push(commandAction(["sanbox", "templates", "list", "--json"], "Choose a template that exists in this organization."));
680
831
  }
681
- if (!selectedOrganization)
682
- process.exitCode = 2;
683
832
  if (hasFlag(flags, "json")) {
684
833
  printSuccess("context.get", output, jsonContext(client), nextActions);
685
834
  return;
686
835
  }
687
- process.stdout.write(`org=${client.config.org}\n`);
836
+ process.stdout.write(`org=${organization.slug}\n`);
688
837
  process.stdout.write(`template=${selection ? `${selection.id} source=${selection.source}${selectedTemplate ? "" : " missing"}` : "not selected"}\n`);
689
838
  process.stdout.write(`model_providers=${providerPayload.providers.map((item) => providerId(item)).filter(Boolean).join(",") || "none"}\n`);
690
839
  process.stdout.write(`templates=${templatePayload.templates.map((item) => item.template_slug || item.id).join(",") || "none"}\n`);
@@ -725,7 +874,8 @@ const commandModelProviders = async (command, flags) => {
725
874
  printSuccess("model_providers.get", payload, jsonContext(client), nextActions);
726
875
  return;
727
876
  }
728
- 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`);
729
879
  return;
730
880
  }
731
881
  if (action === "models") {
@@ -760,6 +910,125 @@ const commandModelProviders = async (command, flags) => {
760
910
  }
761
911
  throw new CliError("model_providers_action_required", "model-providers requires an action: list, get, or models.");
762
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
+ };
763
1032
  const commandTemplates = async (command, flags) => {
764
1033
  const client = makeClient(flags);
765
1034
  const action = command[1];
@@ -767,7 +1036,7 @@ const commandTemplates = async (command, flags) => {
767
1036
  const payload = await client.listTemplates();
768
1037
  const nextActions = payload.templates.length === 0
769
1038
  ? [
770
- commandAction(["sanbox", "context", "--json"], "Inspect the selected organization and current authentication role."),
1039
+ commandAction(["sanbox", "context", "--json"], "Inspect the API key's organization and current authentication role."),
771
1040
  templateAdminConsoleAction(client)
772
1041
  ]
773
1042
  : [];
@@ -791,7 +1060,7 @@ const commandTemplates = async (command, flags) => {
791
1060
  throw new CliError("template_not_found", error.message, {
792
1061
  status: error.status,
793
1062
  details: { template_id: id },
794
- nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization.")]
1063
+ nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization.")]
795
1064
  });
796
1065
  }
797
1066
  throw error;
@@ -814,7 +1083,7 @@ const commandTemplates = async (command, flags) => {
814
1083
  throw new CliError("template_validation_failed", error.message, {
815
1084
  status: error.status,
816
1085
  details: { template_id: id },
817
- nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization.")]
1086
+ nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization.")]
818
1087
  });
819
1088
  }
820
1089
  throw error;
@@ -835,6 +1104,10 @@ const commandTemplates = async (command, flags) => {
835
1104
  const name = requiredPositional(flagString(flags, "name"), "template_name_required", "templates create requires --name.");
836
1105
  const modelProvider = requiredPositional(flagString(flags, "model-provider"), "model_provider_required", "templates create requires --model-provider.");
837
1106
  const model = requiredPositional(flagString(flags, "model"), "model_required", "templates create requires --model.");
1107
+ const harness = flagString(flags, "harness", "opencode");
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.");
1110
+ }
838
1111
  const budgetRaw = flagString(flags, "llm-budget-usd");
839
1112
  const parsedBudgetUsd = budgetRaw ? Number(budgetRaw) : undefined;
840
1113
  const llmBudgetUsd = parsedBudgetUsd === undefined
@@ -844,14 +1117,67 @@ const commandTemplates = async (command, flags) => {
844
1117
  (!Number.isFinite(parsedBudgetUsd) || llmBudgetUsd <= 0 || llmBudgetUsd > 100_000)) {
845
1118
  throw new CliError("invalid_llm_budget", "templates create --llm-budget-usd must be at least 0.000001 and no more than 100000.");
846
1119
  }
1120
+ if (harness === "hermes" && llmBudgetUsd !== undefined) {
1121
+ throw new CliError("hermes_budget_unsupported", "Always-on Hermes templates do not support --llm-budget-usd yet.");
1122
+ }
1123
+ const telegramBotToken = process.env.SANBOX_TELEGRAM_BOT_TOKEN?.trim() || "";
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;
1149
+ if (harness === "hermes" && !telegramBotToken) {
1150
+ throw new CliError("telegram_bot_token_required", "Hermes template creation requires SANBOX_TELEGRAM_BOT_TOKEN.");
1151
+ }
1152
+ if (harness === "hermes" && telegramAllowedUsers.length === 0) {
1153
+ throw new CliError("telegram_allowed_user_required", "Hermes template creation requires at least one --telegram-allowed-user.");
1154
+ }
847
1155
  let payload;
848
1156
  try {
849
1157
  payload = await client.createTemplate({
850
1158
  name,
851
1159
  provider_id: modelProvider,
852
1160
  model_id: model,
853
- web_access: hasFlag(flags, "web-access"),
854
- ...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd })
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
+ } : {}),
1176
+ ...(harness === "hermes" ? {
1177
+ harness,
1178
+ telegram_bot_token: telegramBotToken,
1179
+ telegram_allowed_users: telegramAllowedUsers
1180
+ } : {})
855
1181
  });
856
1182
  }
857
1183
  catch (error) {
@@ -860,7 +1186,9 @@ const commandTemplates = async (command, flags) => {
860
1186
  if (hasFlag(flags, "json")) {
861
1187
  printSuccess("templates.create", payload, jsonContext(client), [
862
1188
  commandAction(["sanbox", "templates", "validate", payload.template.id, "--json"], "Validate the new template before running it."),
863
- commandAction(["sanbox", "run", "<task>", "--template", payload.template.id], "Create a run with the new template.")
1189
+ commandAction(["sanbox", "run", harness === "hermes" ? "<agent role and instructions>" : "<task>", "--template", payload.template.id], harness === "hermes"
1190
+ ? "Start the always-on gateway; interact with it through the configured Telegram bot."
1191
+ : "Create a run with the new template.")
864
1192
  ]);
865
1193
  return;
866
1194
  }
@@ -926,6 +1254,43 @@ const commandRuns = async (command, flags) => {
926
1254
  }
927
1255
  return;
928
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
+ }
929
1294
  if (action === "download") {
930
1295
  const outputDirectory = requiredPositional(flagString(flags, "output"), "output_directory_required", "runs download requires --output.");
931
1296
  const payload = await client.listArtifacts(runId);
@@ -992,7 +1357,29 @@ const commandRuns = async (command, flags) => {
992
1357
  printRun(payload);
993
1358
  return;
994
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
+ }
995
1381
  if (action === "message") {
1382
+ const commandName = "runs.message";
996
1383
  const flaggedMessage = flagString(flags, "message");
997
1384
  const positionalMessage = positionalText(command, 3);
998
1385
  if (flaggedMessage && positionalMessage) {
@@ -1000,7 +1387,7 @@ const commandRuns = async (command, flags) => {
1000
1387
  }
1001
1388
  const message = flaggedMessage || positionalMessage;
1002
1389
  if (!message)
1003
- throw new Error("--message or a positional message is required.");
1390
+ throw new Error(`runs ${action} requires an instruction as positional text or --message.`);
1004
1391
  if (wantsWatch(flags) && hasFlag(flags, "json")) {
1005
1392
  throw new Error("--json cannot be combined with --watch or --jsonl.");
1006
1393
  }
@@ -1010,14 +1397,14 @@ const commandRuns = async (command, flags) => {
1010
1397
  const submitted = await client.sendMessage(runId, message);
1011
1398
  if (!hasFlag(flags, "wait") && !wantsWatch(flags)) {
1012
1399
  if (hasFlag(flags, "json")) {
1013
- printSuccess("runs.message", {
1400
+ printSuccess(commandName, {
1014
1401
  ...publicRunPayload(submitted),
1015
1402
  message: submitted.message,
1016
1403
  chat_job: submitted.chat_job
1017
1404
  }, jsonContext(client));
1018
1405
  }
1019
1406
  else
1020
- process.stdout.write(`queued follow-up for run ${runId}\n`);
1407
+ process.stdout.write(`queued OpenCode follow-up for run ${runId}\n`);
1021
1408
  return;
1022
1409
  }
1023
1410
  const chatJobId = submitted.chat_job.id;
@@ -1064,7 +1451,7 @@ const commandRuns = async (command, flags) => {
1064
1451
  });
1065
1452
  const messages = await client.listMessages(runId);
1066
1453
  if (hasFlag(flags, "json")) {
1067
- printSuccess("runs.message", {
1454
+ printSuccess(commandName, {
1068
1455
  ...publicRunPayload(submitted),
1069
1456
  message: submitted.message,
1070
1457
  chat_job: submitted.chat_job,
@@ -1096,9 +1483,23 @@ const commandRuns = async (command, flags) => {
1096
1483
  const commandDoctor = async (flags) => {
1097
1484
  const localConfig = readLocalConfig();
1098
1485
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
1099
- const org = String(flags.org || process.env.SANBOX_ORG || localConfig.org || "").trim();
1100
- 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 || "";
1101
1501
  const selection = readTemplateSelection(flags, { required: false });
1502
+ let resolvedOrg = "";
1102
1503
  const checks = [];
1103
1504
  const nextActions = [];
1104
1505
  const add = (name, ok, detail, data) => checks.push({
@@ -1109,13 +1510,13 @@ const commandDoctor = async (flags) => {
1109
1510
  });
1110
1511
  add("node", Number(process.versions.node.split(".")[0]) >= 20, process.version);
1111
1512
  add("api_url", Boolean(apiUrl), apiUrl);
1112
- add("org", Boolean(org), org || "missing SANBOX_ORG, --org, or .sanbox/config.json org");
1113
- add("api_key", Boolean(apiKey), apiKey ? "present in environment" : "missing SANBOX_API_KEY");
1114
- if (!org) {
1115
- nextActions.push(commandAction(["sanbox", "doctor", "--json"], "Select an organization and run the checks again.", { SANBOX_ORG: "<org-slug>" }));
1116
- }
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");
1117
1518
  if (!apiKey) {
1118
- 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."));
1119
1520
  }
1120
1521
  try {
1121
1522
  const res = await fetch(`${apiUrl}/health`);
@@ -1124,50 +1525,50 @@ const commandDoctor = async (flags) => {
1124
1525
  catch (error) {
1125
1526
  add("api_health", false, error instanceof Error ? error.message : String(error));
1126
1527
  }
1127
- if (org && apiKey) {
1128
- const client = new SanboxClient({ apiUrl, org, apiKey });
1528
+ if (apiKey) {
1529
+ const client = new SanboxClient({ apiUrl, apiKey });
1129
1530
  try {
1130
- const me = await client.me();
1131
- const organizations = Array.isArray(me.organizations)
1132
- ? me.organizations
1133
- : [];
1531
+ const organization = await client.organization();
1532
+ resolvedOrg = organization.slug;
1134
1533
  add("auth", true, "authenticated");
1135
- add("org_visible", organizations.some((item) => item.slug === org), org);
1534
+ add("organization", true, organization.slug, organization);
1136
1535
  }
1137
1536
  catch (error) {
1138
1537
  add("auth", false, error instanceof Error ? error.message : String(error));
1139
1538
  }
1140
- try {
1141
- const providers = await client.listModelProviders();
1142
- add("model_providers", true, providers.providers.map((item) => `${providerId(item)}:${item.status || (item.configured ? "configured" : "not_configured")}`).join(", ") || "none", providers.providers);
1143
- }
1144
- catch (error) {
1145
- add("model_providers", false, error instanceof Error ? error.message : String(error));
1146
- }
1147
- if (!selection) {
1148
- add("template", false, "not selected; use --template, SANBOX_TEMPLATE, or .sanbox/config.json default_template");
1149
- nextActions.push(...templateActions());
1150
- }
1151
- else {
1539
+ if (resolvedOrg) {
1152
1540
  try {
1153
- const template = await client.getTemplate(selection.id);
1154
- add("template", true, `${template.template.id}; source=${selection.source}`, template.template);
1541
+ const providers = await client.listModelProviders();
1542
+ add("model_providers", true, providers.providers.map((item) => `${providerId(item)}:${item.status || (item.configured ? "configured" : "not_configured")}`).join(", ") || "none", providers.providers);
1543
+ }
1544
+ catch (error) {
1545
+ add("model_providers", false, error instanceof Error ? error.message : String(error));
1546
+ }
1547
+ if (!selection) {
1548
+ add("template", false, "not selected; use --template, SANBOX_TEMPLATE, or .sanbox/config.json default_template");
1549
+ nextActions.push(...templateActions());
1550
+ }
1551
+ else {
1155
1552
  try {
1156
- const validation = await client.validateTemplate(selection.id);
1157
- const runnable = validationRunnable(validation);
1158
- add("template_readiness", runnable, runnable ? "ready" : "blocked", validation);
1159
- if (!runnable) {
1160
- nextActions.push(commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect model-provider status."), providerConsoleAction(client));
1553
+ const template = await client.getTemplate(selection.id);
1554
+ add("template", true, `${template.template.id}; source=${selection.source}`, template.template);
1555
+ try {
1556
+ const validation = await client.validateTemplate(selection.id);
1557
+ const runnable = validationRunnable(validation);
1558
+ add("template_readiness", runnable, runnable ? "ready" : "blocked", validation);
1559
+ if (!runnable) {
1560
+ nextActions.push(commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect model-provider status."), providerConsoleAction(client));
1561
+ }
1562
+ }
1563
+ catch (error) {
1564
+ add("template_readiness", false, error instanceof Error ? error.message : String(error));
1161
1565
  }
1162
1566
  }
1163
1567
  catch (error) {
1164
- add("template_readiness", false, error instanceof Error ? error.message : String(error));
1568
+ add("template", false, error instanceof Error ? error.message : String(error));
1569
+ nextActions.push(...templateActions());
1165
1570
  }
1166
1571
  }
1167
- catch (error) {
1168
- add("template", false, error instanceof Error ? error.message : String(error));
1169
- nextActions.push(...templateActions());
1170
- }
1171
1572
  }
1172
1573
  }
1173
1574
  try {
@@ -1183,7 +1584,7 @@ const commandDoctor = async (flags) => {
1183
1584
  checks
1184
1585
  };
1185
1586
  if (hasFlag(flags, "json")) {
1186
- printSuccess("doctor", output, { api_url: apiUrl, ...(org ? { org } : {}) }, nextActions);
1587
+ printSuccess("doctor", output, { api_url: apiUrl, ...(resolvedOrg ? { org: resolvedOrg } : {}) }, nextActions);
1187
1588
  }
1188
1589
  else {
1189
1590
  for (const check of checks) {
@@ -1193,6 +1594,69 @@ const commandDoctor = async (flags) => {
1193
1594
  if (!output.ok)
1194
1595
  process.exitCode = 2;
1195
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
+ };
1196
1660
  const commandInit = async (command, flags) => {
1197
1661
  const dir = path.join(cwd(), ".sanbox");
1198
1662
  if (command[1] === "agent") {
@@ -1220,11 +1684,9 @@ const commandInit = async (command, flags) => {
1220
1684
  const force = hasFlag(flags, "force");
1221
1685
  const localConfig = readLocalConfig();
1222
1686
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
1223
- const org = String(flags.org || process.env.SANBOX_ORG || localConfig.org || "").trim();
1224
1687
  const template = readTemplateSelection(flags, { required: false });
1225
1688
  const config = {
1226
1689
  api_url: apiUrl,
1227
- org,
1228
1690
  ...(template ? { default_template: template.id } : {})
1229
1691
  };
1230
1692
  const sanboxIgnore = [
@@ -1245,7 +1707,7 @@ const commandInit = async (command, flags) => {
1245
1707
  printSuccess("init", {
1246
1708
  files: results.map(([file, status]) => ({ file, status })),
1247
1709
  template_selection: template
1248
- }, { api_url: apiUrl, ...(org ? { org } : {}) }, template ? [] : templateActions());
1710
+ }, { api_url: apiUrl }, template ? [] : templateActions());
1249
1711
  }
1250
1712
  else {
1251
1713
  for (const [file, status] of results)
@@ -1259,8 +1721,12 @@ const helpFor = (command) => {
1259
1721
  return doctorHelp;
1260
1722
  if (command[0] === "model-providers")
1261
1723
  return modelProvidersHelp;
1724
+ if (command[0] === "anthropic-environments")
1725
+ return anthropicEnvironmentsHelp;
1262
1726
  if (command[0] === "templates")
1263
1727
  return templatesHelp;
1728
+ if (command[0] === "ssh")
1729
+ return sshHelp;
1264
1730
  return help;
1265
1731
  };
1266
1732
  const commandId = (command) => {
@@ -1268,12 +1734,13 @@ const commandId = (command) => {
1268
1734
  return "version";
1269
1735
  if (command[0] === "auth")
1270
1736
  return "auth.check";
1271
- if (command[0] === "orgs")
1272
- return `orgs.${command[1] || "unknown"}`;
1273
1737
  if (command[0] === "context")
1274
1738
  return "context.get";
1275
1739
  if (command[0] === "model-providers")
1276
1740
  return `model_providers.${command[1] || "unknown"}`;
1741
+ if (command[0] === "anthropic-environments") {
1742
+ return `anthropic_environments.${command[1] || "unknown"}`;
1743
+ }
1277
1744
  if (command[0] === "templates")
1278
1745
  return `templates.${command[1] || "unknown"}`;
1279
1746
  if (command[0] === "run")
@@ -1282,6 +1749,8 @@ const commandId = (command) => {
1282
1749
  return "batch.create";
1283
1750
  if (command[0] === "runs")
1284
1751
  return `runs.${command[1] || "unknown"}`;
1752
+ if (command[0] === "ssh" || command[0] === "ssh-proxy")
1753
+ return command[0];
1285
1754
  return command[0] || "help";
1286
1755
  };
1287
1756
  const main = async (command, flags) => {
@@ -1306,14 +1775,19 @@ const main = async (command, flags) => {
1306
1775
  }
1307
1776
  if (command[0] === "auth" && command[1] === "check")
1308
1777
  return commandAuthCheck(flags);
1309
- if (command[0] === "orgs")
1310
- return commandOrganizations(command, flags);
1778
+ if (command[0] === "login")
1779
+ return commandLogin(flags);
1780
+ if (command[0] === "logout")
1781
+ return commandLogout(flags);
1311
1782
  if (command[0] === "context")
1312
1783
  return commandContext(flags);
1313
1784
  if (command[0] === "doctor")
1314
1785
  return commandDoctor(flags);
1315
1786
  if (command[0] === "model-providers")
1316
1787
  return commandModelProviders(command, flags);
1788
+ if (command[0] === "anthropic-environments") {
1789
+ return commandAnthropicEnvironments(command, flags);
1790
+ }
1317
1791
  if (command[0] === "templates")
1318
1792
  return commandTemplates(command, flags);
1319
1793
  if (command[0] === "run")
@@ -1322,6 +1796,14 @@ const main = async (command, flags) => {
1322
1796
  return commandBatch(flags);
1323
1797
  if (command[0] === "runs")
1324
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
+ }
1325
1807
  if (command[0] === "init")
1326
1808
  return commandInit(command, flags);
1327
1809
  throw new CliError("unknown_command", `Unknown command: ${command.join(" ")}`);