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