@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/README.md +119 -12
- package/dist/api.js +45 -0
- package/dist/args.js +9 -2
- package/dist/cli.js +508 -27
- package/dist/config.js +22 -5
- package/dist/deviceLogin.js +106 -0
- package/dist/fileAccess.js +16 -0
- package/dist/runs.js +19 -0
- package/dist/ssh.js +129 -0
- package/dist/userSession.js +124 -0
- package/dist/version.js +1 -1
- package/package.json +3 -1
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"
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
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,
|
|
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" ||
|
|
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}` : ""}
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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("
|
|
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", "
|
|
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(" ")}`);
|