@sanlabs/sanbox-cli 0.0.9 → 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 CHANGED
@@ -15,8 +15,9 @@ installed_cli_version="$(sanbox --version)"
15
15
  test "$installed_cli_version" = "$latest_cli_version"
16
16
  ```
17
17
 
18
- Always use the latest published CLI. CLI 0.0.9 adds user login for private SSH access to running
19
- Hermes Computers.
18
+ Always use the latest published CLI. CLI 0.0.10 adds per-run Hermes email and Telegram channels,
19
+ Supabase user authorization, and removes the retired run-chat commands.
20
+ CLI 0.0.9 added user login for private SSH access to running Hermes Computers.
20
21
  CLI 0.0.6 added Anthropic self-hosted environment inspection and administration.
21
22
 
22
23
  ## Configure
@@ -82,12 +83,26 @@ sanbox doctor --json
82
83
  Model IDs are provider-scoped. The CLI never guesses or silently substitutes a provider, model, or template.
83
84
  For waited task runs, choose a template with `runnable: true`, `template_type: "runner"`, and
84
85
  `runner_config.harness: "opencode"` or `"browser-use"`. Browser Use is for one-shot web tasks whose
85
- target domains are already approved. Hermes service templates are always-on.
86
+ target domains are already approved. Hermes service templates are always-on. A Hermes template
87
+ declares which optional channels its runs may activate:
88
+
89
+ ```bash
90
+ sanbox templates create \
91
+ --name "Customer assistant" \
92
+ --harness hermes \
93
+ --model-provider openai \
94
+ --model '<model-id>' \
95
+ --channel email \
96
+ --channel telegram
97
+ ```
98
+
99
+ Omit both `--channel` flags for a WebUI-only computer. Channel credentials never belong to the
100
+ template.
86
101
 
87
102
  ## Create A Browser Use Template
88
103
 
89
104
  Browser Use runs local headless Chromium inside the Firecracker sandbox. It requires OpenAI,
90
- Anthropic, or Google Gemini and at least one explicit browser target:
105
+ Anthropic, Google Gemini, or Hetzner Inference and at least one explicit browser target:
91
106
 
92
107
  ```bash
93
108
  sanbox templates create \
@@ -106,7 +121,7 @@ sanbox templates create \
106
121
 
107
122
  Targets are limited to hostnames and leading wildcards on ports 80 and 443. The remaining browser
108
123
  security controls are fixed. Runs return `browser-use-response.md`, a sanitized run ledger, and any
109
- allowed downloads. Browser Use templates do not support retained follow-up messages.
124
+ allowed downloads. Browser Use templates are one-shot agent executions.
110
125
 
111
126
  ## Run Idempotently
112
127
 
@@ -126,6 +141,51 @@ sanbox run "Review this repo and write output/report.md" \
126
141
 
127
142
  Repeat `--input` for files, directories, or globs. The CLI excludes common secrets and applies `.sanboxignore`. `--include` is a deprecated compatibility alias.
128
143
 
144
+ For a Hermes template that allows `email`, optionally request an address from one of the AgentMail
145
+ domains listed in the Sanbox console:
146
+
147
+ ```bash
148
+ sanbox run "Handle customer email for this project" \
149
+ --template "$SANBOX_TEMPLATE" \
150
+ --email-address research-agent@agentmail.to \
151
+ --json
152
+ ```
153
+
154
+ `--email-address` is optional. When supplied, AgentMail must be connected and Sanbox does not
155
+ generate a fallback address; use `agentmail.to` or one of the domains listed for the connected Pod.
156
+
157
+ For a Hermes template that allows `telegram`, pass the bot token and at least one numeric user ID on
158
+ the run. Both values are optional unless the run needs Telegram:
159
+
160
+ ```bash
161
+ sanbox run "Handle Telegram messages for this project" \
162
+ --template "$SANBOX_TEMPLATE" \
163
+ --telegram-bot-token '<telegram-bot-token>' \
164
+ --telegram-allowed-user '<numeric-telegram-user-id>' \
165
+ --json
166
+ ```
167
+
168
+ You can set `SANBOX_TELEGRAM_BOT_TOKEN` instead of passing `--telegram-bot-token`. Do not use both.
169
+
170
+ For a long-running Hermes Computer that should act as one Supabase user, provide that user's
171
+ Supabase Auth UUID. Sanbox automatically uses the organization's connected Supabase project:
172
+
173
+ ```bash
174
+ sanbox run "Act as this user's procurement assistant" \
175
+ --template "$SANBOX_TEMPLATE" \
176
+ --external-run-id "hermes:<tenant-id>:<user-id>" \
177
+ --supabase-user-id "<supabase-auth-user-uuid>" \
178
+ --json
179
+
180
+ sanbox runs supabase authorize <run-id> --open
181
+ ```
182
+
183
+ The run stays in `awaiting_grant` until the expected Supabase user approves the browser consent
184
+ flow. Without `--open`, the authorize command prints the URL for copying into any browser. The
185
+ callback lands on a Sanbox-hosted completion page, so a CLI demo does not need a customer web app.
186
+ Customer integrations may use `--return-url <https-url>` when its origin matches the return origin
187
+ configured on the organization Supabase connection.
188
+
129
189
  Reuse the same external ID when retrying an ambiguous submission. To stream activity, replace `--wait --json` with `--jsonl`. Ctrl-C detaches without canceling unless `--cancel-on-interrupt` is supplied.
130
190
 
131
191
  ## Inspect And Recover
@@ -180,8 +240,9 @@ sanbox runs pause <run-id> --wait --json
180
240
 
181
241
  Resume restores the latest writable Firecracker snapshot without starting the configured agent
182
242
  harness. It acquires an exclusive manual lease and leaves the sandbox running for live filesystem
183
- access until Pause creates the next snapshot generation. Do not submit agent work while that lease is
184
- active. Paused state has no retention TTL and remains available until explicitly deleted.
243
+ access until Pause creates the next snapshot generation. Before resuming, require
244
+ `sandbox_state: "paused"` and a positive `snapshot_generation`. Do not submit agent work while that
245
+ lease is active. Paused state has no retention TTL and remains available until explicitly deleted.
185
246
 
186
247
  ## SSH Into A Hermes Computer
187
248
 
@@ -197,22 +258,8 @@ Ed25519 identity, pins the microVM's runtime host key, and tunnels the SSH strea
197
258
  authenticated WebSocket. The temporary key is deleted when the connection closes, and the saved
198
259
  user token is not passed to the OpenSSH child process.
199
260
 
200
- ## Continue An OpenCode Run
201
-
202
- ```bash
203
- sanbox runs messages <run-id> --json
204
- sanbox runs message <run-id> "Summarize the retained output" --wait --json
205
- ```
206
-
207
- The JSON response includes the user `message`, submitted `chat_job`, matching terminal
208
- `followup_event`, and conversation messages. The waiter matches `payload.chat_job_id`, so another
209
- concurrent follow-up cannot complete the wrong command. The follow-up resumes OpenCode for one turn,
210
- then pauses the sandbox again.
211
-
212
- Check `sanbox runs get <run-id> --json` before a follow-up and require `sandbox_state: "paused"` with
213
- a positive `snapshot_generation`. Do not submit concurrent follow-ups to one run.
214
- Treat `browser_use_followup_unsupported` as a signal to create a new browser agent run; its persisted
215
- sandbox can still be resumed manually when a snapshot exists.
261
+ OpenCode and Browser Use runs are one-shot agent executions. Create a new run for additional agent
262
+ work. A persisted sandbox can still be resumed manually for inspection when a snapshot exists.
216
263
 
217
264
  ## Batch Work
218
265
 
package/dist/api.js CHANGED
@@ -183,6 +183,12 @@ export class SanboxClient {
183
183
  async getRun(runId, signal) {
184
184
  return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}`), { signal });
185
185
  }
186
+ async authorizeRunSupabase(runId, returnUrl) {
187
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/supabase/authorize`), {
188
+ method: "POST",
189
+ body: JSON.stringify(returnUrl ? { return_url: returnUrl } : {})
190
+ });
191
+ }
186
192
  async createSSHSession(runId, publicKey) {
187
193
  return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/ssh-sessions`), {
188
194
  method: "POST",
@@ -210,15 +216,6 @@ export class SanboxClient {
210
216
  body: "{}"
211
217
  });
212
218
  }
213
- async sendMessage(runId, message, payload = {}) {
214
- return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/messages`), {
215
- method: "POST",
216
- body: JSON.stringify({ message, payload })
217
- });
218
- }
219
- async listMessages(runId) {
220
- return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/messages`));
221
- }
222
219
  async listArtifacts(runId) {
223
220
  return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/artifacts`));
224
221
  }
package/dist/args.js CHANGED
@@ -2,6 +2,7 @@ const multiFlags = new Set([
2
2
  "input",
3
3
  "include",
4
4
  "artifact",
5
+ "channel",
5
6
  "telegram-allowed-user",
6
7
  "browser-domain"
7
8
  ]);
@@ -19,6 +20,7 @@ export const booleanFlags = new Set([
19
20
  "force",
20
21
  "write",
21
22
  "overwrite",
23
+ "open",
22
24
  "no-browser"
23
25
  ]);
24
26
  export const parseArgs = (argv) => {
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { previewInputs } from "./inputs.js";
13
13
  import { printError, printJsonlError, printRun, printSuccess, publicRun, publicRunPayload } from "./output.js";
14
14
  import { createRun, isTerminalRun, readTasks, runPool, stableBatchId, waitForRun, waitForSandboxState } from "./runs.js";
15
15
  import { version } from "./version.js";
16
- import { WatchInterruptedError, watchEventsUntil, watchRun } from "./watch.js";
16
+ import { WatchInterruptedError, watchRun } from "./watch.js";
17
17
  import { openSSH, runSSHProxy } from "./ssh.js";
18
18
  import { userSessionStore } from "./userSession.js";
19
19
  const help = `Sanbox CLI
@@ -40,14 +40,13 @@ Commands:
40
40
  sanbox templates list [--json]
41
41
  sanbox templates get <template-id> [--json]
42
42
  sanbox templates validate <template-id> [--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]
44
- sanbox run "task" --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
45
- 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]
46
46
  sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
47
47
  sanbox runs list [--limit 50] [--json]
48
48
  sanbox runs get <run-id> [--json]
49
49
  sanbox runs events <run-id> [--after-event-id 0] [--json]
50
- sanbox runs messages <run-id> [--json]
51
50
  sanbox runs artifacts <run-id> [--json]
52
51
  sanbox runs share <run-id> [--expires 1h] [--name "Debug access"] [--json]
53
52
  sanbox runs shares <run-id> [--json]
@@ -57,7 +56,7 @@ Commands:
57
56
  sanbox runs cancel <run-id> [--json]
58
57
  sanbox runs resume <run-id> [--wait] [--json]
59
58
  sanbox runs pause <run-id> [--wait] [--json]
60
- sanbox runs message <run-id> "..." [--wait | --watch] [--json | --jsonl]
59
+ sanbox runs supabase authorize <run-id> [--open] [--return-url <https-url>] [--json]
61
60
  sanbox ssh <run-id>
62
61
  sanbox init [--force]
63
62
  sanbox init agent [--write]
@@ -83,6 +82,10 @@ Options:
83
82
  --input <path> File, directory, or glob to upload. Repeatable.
84
83
  --template <id> Template id or slug. Required unless SANBOX_TEMPLATE or project config sets it.
85
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.
86
89
  --dry-run Preview included files without creating a run.
87
90
  --wait Poll until terminal status.
88
91
  --watch Stream activity until terminal status.
@@ -92,6 +95,17 @@ Options:
92
95
  --cancel-on-interrupt Request run cancellation when Ctrl-C is pressed.
93
96
  --json Print JSON.
94
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
+ `;
95
109
  const doctorHelp = `Sanbox doctor
96
110
 
97
111
  Usage:
@@ -134,8 +148,8 @@ Usage:
134
148
 
135
149
  Template creation requires an exact provider id and that provider's exact model id.
136
150
  LiteLLM budgets are optional USD amounts and apply separately to each run.
137
- Hermes templates are always-on Telegram gateways. Set SANBOX_TELEGRAM_BOT_TOKEN
138
- and repeat --telegram-allowed-user <numeric-id> for every permitted user.
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.
139
153
  Browser Use templates run local headless Chromium inside a one-shot sandbox. They require OpenAI
140
154
  or Anthropic and at least one repeatable --browser-domain hostname or leading wildcard.
141
155
  Optional controls: --browser-max-steps, --browser-step-timeout-seconds,
@@ -231,17 +245,11 @@ exclusive manual lease and leaves the sandbox running until pause snapshots it a
231
245
  agent work while the manual lease is active. Before resume, require sandbox_state: "paused" and a
232
246
  positive snapshot_generation.
233
247
 
234
- For an OpenCode follow-up, use \`sanbox runs message <run-id> "..." --wait --json\`. Follow-up waits
235
- are correlated to the returned chat_job.id. Never submit concurrent follow-ups to one run. Retry
236
- sandbox_not_paused only after the run returns to paused; treat sandbox_not_resumable,
237
- sandbox_snapshot_missing, sandbox_worker_missing, hermes_uses_channel, and
238
- browser_use_followup_unsupported as blockers.
239
-
240
248
  For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
241
249
  client alive until submission completes.
242
250
 
243
- Do not claim completion until the run is completed, required artifacts are downloaded and verified,
244
- and required follow-ups succeeded. Report run/external/template IDs, status, sandbox state, snapshot
251
+ Do not claim completion until the run is completed and required artifacts are downloaded and verified.
252
+ Report run/external/template IDs, status, sandbox state, snapshot
245
253
  generation, artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
246
254
  .sanboxignore for project rules.
247
255
  `;
@@ -429,7 +437,7 @@ const flagSets = {
429
437
  "harness",
430
438
  "llm-budget-usd",
431
439
  "web-access",
432
- "telegram-allowed-user",
440
+ "channel",
433
441
  "browser-domain",
434
442
  "browser-max-steps",
435
443
  "browser-step-timeout-seconds",
@@ -439,7 +447,9 @@ const flagSets = {
439
447
  "browser-additional-instructions"
440
448
  ],
441
449
  run: [
442
- ...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",
443
453
  "dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "cancel-on-interrupt",
444
454
  "poll-interval-ms", "event-page-size", "timeout-seconds", "verbose"
445
455
  ],
@@ -450,7 +460,6 @@ const flagSets = {
450
460
  "runs.list": [...commonFlags, "limit"],
451
461
  "runs.get": commonFlags,
452
462
  "runs.events": [...commonFlags, "after-event-id"],
453
- "runs.messages": commonFlags,
454
463
  "runs.artifacts": commonFlags,
455
464
  "runs.share": [...commonFlags, "expires", "name"],
456
465
  "runs.shares": commonFlags,
@@ -463,10 +472,7 @@ const flagSets = {
463
472
  "runs.cancel": commonFlags,
464
473
  "runs.resume": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
465
474
  "runs.pause": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
466
- "runs.message": [
467
- ...commonFlags, "message", "wait", "watch", "jsonl", "view",
468
- "poll-interval-ms", "event-page-size", "timeout-seconds"
469
- ],
475
+ "runs.supabase": [...commonFlags, "open", "return-url"],
470
476
  ssh: ["api-url", "help"],
471
477
  "ssh-proxy": [],
472
478
  login: ["api-url", "json", "no-browser", "help"],
@@ -482,6 +488,8 @@ const commandKey = (command) => {
482
488
  return "version";
483
489
  if (command[0] === "auth")
484
490
  return `auth.${command[1] || ""}`;
491
+ if (command[0] === "runs" && command[1] === "supabase")
492
+ return "runs.supabase";
485
493
  if (command[0] === "model-providers" ||
486
494
  command[0] === "anthropic-environments" ||
487
495
  command[0] === "templates" ||
@@ -493,14 +501,15 @@ const commandKey = (command) => {
493
501
  return command[0];
494
502
  };
495
503
  const requiredValueFlags = new Set([
496
- "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",
497
507
  "tasks", "max-parallel", "batch-id", "poll-interval-ms",
498
508
  "event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
499
- "model-provider", "model", "harness", "llm-budget-usd", "telegram-allowed-user",
509
+ "model-provider", "model", "harness", "llm-budget-usd",
500
510
  "browser-domain", "browser-max-steps", "browser-step-timeout-seconds",
501
511
  "browser-vision-mode", "browser-viewport", "browser-download-policy",
502
- "browser-additional-instructions", "expires",
503
- "message", "output", "artifact"
512
+ "browser-additional-instructions", "expires", "output", "artifact"
504
513
  ]);
505
514
  const validateFlagValues = (flags) => {
506
515
  for (const flag of requiredValueFlags) {
@@ -571,7 +580,6 @@ const validatePositionals = (command, flags) => {
571
580
  "runs.list": 2,
572
581
  "runs.get": 3,
573
582
  "runs.events": 3,
574
- "runs.messages": 3,
575
583
  "runs.artifacts": 3,
576
584
  "runs.share": 3,
577
585
  "runs.shares": 3,
@@ -581,6 +589,7 @@ const validatePositionals = (command, flags) => {
581
589
  "runs.cancel": 3,
582
590
  "runs.resume": 3,
583
591
  "runs.pause": 3,
592
+ "runs.supabase": 4,
584
593
  ssh: 2,
585
594
  "ssh-proxy": 1,
586
595
  login: 1,
@@ -692,6 +701,11 @@ const commandRun = async (command, flags) => {
692
701
  throw new Error("--wait cannot be combined with --watch or --jsonl.");
693
702
  }
694
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
+ }
695
709
  if (hasFlag(flags, "dry-run")) {
696
710
  if (wantsWatch(flags))
697
711
  throw new Error("--dry-run cannot be combined with --watch or --jsonl.");
@@ -702,6 +716,24 @@ const commandRun = async (command, flags) => {
702
716
  printPreview(preview, hasFlag(flags, "verbose"));
703
717
  return;
704
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
+ }
705
737
  const client = makeClient(flags);
706
738
  const template = readTemplateSelection(flags);
707
739
  let payload;
@@ -711,6 +743,10 @@ const commandRun = async (command, flags) => {
711
743
  instruction: task,
712
744
  inputs: flagList(flags, "input"),
713
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,
714
750
  templateId: template.id
715
751
  });
716
752
  }
@@ -764,6 +800,7 @@ const commandBatch = async (flags) => {
764
800
  instruction: task.task,
765
801
  inputs: task.input || inputs,
766
802
  externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
803
+ emailAddress: task.email_address,
767
804
  templateId: template.id
768
805
  });
769
806
  }
@@ -874,8 +911,7 @@ const commandModelProviders = async (command, flags) => {
874
911
  printSuccess("model_providers.get", payload, jsonContext(client), nextActions);
875
912
  return;
876
913
  }
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`);
914
+ process.stdout.write(`${providerId(payload.provider)}${payload.provider.status ? ` ${payload.provider.status}` : ""}\n`);
879
915
  return;
880
916
  }
881
917
  if (action === "models") {
@@ -1120,8 +1156,10 @@ const commandTemplates = async (command, flags) => {
1120
1156
  if (harness === "hermes" && llmBudgetUsd !== undefined) {
1121
1157
  throw new CliError("hermes_budget_unsupported", "Always-on Hermes templates do not support --llm-budget-usd yet.");
1122
1158
  }
1123
- const telegramBotToken = process.env.SANBOX_TELEGRAM_BOT_TOKEN?.trim() || "";
1124
- const telegramAllowedUsers = flagList(flags, "telegram-allowed-user").map((value) => value.trim()).filter(Boolean);
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
+ }
1125
1163
  const browserFlagNames = [
1126
1164
  "browser-domain",
1127
1165
  "browser-max-steps",
@@ -1137,8 +1175,9 @@ const commandTemplates = async (command, flags) => {
1137
1175
  if (harness === "browser-use" &&
1138
1176
  modelProvider !== "openai" &&
1139
1177
  modelProvider !== "anthropic" &&
1140
- modelProvider !== "google") {
1141
- throw new CliError("browser_use_provider_unsupported", "Browser Use templates require --model-provider openai, anthropic, or google.");
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.");
1142
1181
  }
1143
1182
  const browserDomains = harness === "browser-use"
1144
1183
  ? parseBrowserDomains(flagList(flags, "browser-domain"))
@@ -1146,11 +1185,8 @@ const commandTemplates = async (command, flags) => {
1146
1185
  const browserUse = harness === "browser-use"
1147
1186
  ? parseBrowserUseConfig(flags)
1148
1187
  : undefined;
1149
- if (harness === "hermes" && !telegramBotToken) {
1150
- throw new CliError("telegram_bot_token_required", "Hermes template creation requires SANBOX_TELEGRAM_BOT_TOKEN.");
1151
- }
1152
- if (harness === "hermes" && telegramAllowedUsers.length === 0) {
1153
- throw new CliError("telegram_allowed_user_required", "Hermes template creation requires at least one --telegram-allowed-user.");
1188
+ if (harness !== "hermes" && channels.length > 0) {
1189
+ throw new CliError("hermes_channels_not_supported", "--channel requires --harness hermes.");
1154
1190
  }
1155
1191
  let payload;
1156
1192
  try {
@@ -1175,8 +1211,7 @@ const commandTemplates = async (command, flags) => {
1175
1211
  } : {}),
1176
1212
  ...(harness === "hermes" ? {
1177
1213
  harness,
1178
- telegram_bot_token: telegramBotToken,
1179
- telegram_allowed_users: telegramAllowedUsers
1214
+ channels: channels
1180
1215
  } : {})
1181
1216
  });
1182
1217
  }
@@ -1186,8 +1221,14 @@ const commandTemplates = async (command, flags) => {
1186
1221
  if (hasFlag(flags, "json")) {
1187
1222
  printSuccess("templates.create", payload, jsonContext(client), [
1188
1223
  commandAction(["sanbox", "templates", "validate", payload.template.id, "--json"], "Validate the new template before running it."),
1189
- commandAction(["sanbox", "run", harness === "hermes" ? "<agent role and instructions>" : "<task>", "--template", payload.template.id], harness === "hermes"
1190
- ? "Start the always-on gateway; interact with it through the configured Telegram bot."
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."
1191
1232
  : "Create a run with the new template.")
1192
1233
  ]);
1193
1234
  return;
@@ -1215,6 +1256,23 @@ const commandRuns = async (command, flags) => {
1215
1256
  }
1216
1257
  return;
1217
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
+ }
1218
1276
  const runId = requiredPositional(command[2], "run_id_required", `runs ${action} requires a run id.`);
1219
1277
  if (action === "get") {
1220
1278
  const payload = await client.getRun(runId);
@@ -1232,17 +1290,6 @@ const commandRuns = async (command, flags) => {
1232
1290
  payload.events.forEach((event) => process.stdout.write(`${event.id} ${event.level} ${event.kind} ${event.message}\n`));
1233
1291
  return;
1234
1292
  }
1235
- if (action === "messages") {
1236
- const payload = await client.listMessages(runId);
1237
- if (hasFlag(flags, "json")) {
1238
- printSuccess("runs.messages", payload, jsonContext(client));
1239
- return;
1240
- }
1241
- for (const message of payload.messages) {
1242
- process.stdout.write(`${message.role}\t${message.created_at}\t${message.message}\n`);
1243
- }
1244
- return;
1245
- }
1246
1293
  if (action === "artifacts") {
1247
1294
  const payload = await client.listArtifacts(runId);
1248
1295
  if (hasFlag(flags, "json")) {
@@ -1378,106 +1425,6 @@ const commandRuns = async (command, flags) => {
1378
1425
  }
1379
1426
  return;
1380
1427
  }
1381
- if (action === "message") {
1382
- const commandName = "runs.message";
1383
- const flaggedMessage = flagString(flags, "message");
1384
- const positionalMessage = positionalText(command, 3);
1385
- if (flaggedMessage && positionalMessage) {
1386
- throw new CliError("conflicting_arguments", "Use either --message or a positional message, not both.");
1387
- }
1388
- const message = flaggedMessage || positionalMessage;
1389
- if (!message)
1390
- throw new Error(`runs ${action} requires an instruction as positional text or --message.`);
1391
- if (wantsWatch(flags) && hasFlag(flags, "json")) {
1392
- throw new Error("--json cannot be combined with --watch or --jsonl.");
1393
- }
1394
- if (hasFlag(flags, "wait") && wantsWatch(flags)) {
1395
- throw new Error("--wait cannot be combined with --watch or --jsonl.");
1396
- }
1397
- const submitted = await client.sendMessage(runId, message);
1398
- if (!hasFlag(flags, "wait") && !wantsWatch(flags)) {
1399
- if (hasFlag(flags, "json")) {
1400
- printSuccess(commandName, {
1401
- ...publicRunPayload(submitted),
1402
- message: submitted.message,
1403
- chat_job: submitted.chat_job
1404
- }, jsonContext(client));
1405
- }
1406
- else
1407
- process.stdout.write(`queued OpenCode follow-up for run ${runId}\n`);
1408
- return;
1409
- }
1410
- const chatJobId = submitted.chat_job.id;
1411
- const belongsToFollowup = (event) => event.payload.chat_job_id === chatJobId;
1412
- const queuedEventId = submitted.events.reduce((latest, event) => event.kind === "chat.queued" && belongsToFollowup(event) ? Math.max(latest, event.id) : latest, 0);
1413
- const receivedEventId = [...submitted.events]
1414
- .reverse()
1415
- .find((event) => event.kind === "message.received" && belongsToFollowup(event))?.id ?? queuedEventId;
1416
- const cursor = submitted.events.reduce((latest, event) => Math.max(latest, event.id), 0);
1417
- const completedInResponse = [...submitted.events]
1418
- .reverse()
1419
- .find((event) => (event.kind === "chat.completed" || event.kind === "chat.failed") && belongsToFollowup(event));
1420
- const view = parseActivityView(flagString(flags, "view", "activity"));
1421
- const jsonl = hasFlag(flags, "jsonl");
1422
- const controller = new AbortController();
1423
- const onInterrupt = () => controller.abort();
1424
- process.once("SIGINT", onInterrupt);
1425
- if (wantsWatch(flags) && !jsonl)
1426
- process.stdout.write(`Watching follow-up for run ${runId}. Ctrl-C detaches.\n`);
1427
- try {
1428
- const renderFollowupEvent = (event) => {
1429
- if (!belongsToFollowup(event))
1430
- return;
1431
- if (!wantsWatch(flags) || !shouldRenderEvent(event, view))
1432
- return;
1433
- process.stdout.write(`${jsonl ? formatActivityJsonl(event) : formatActivityLine(event, submitted.run.created_at)}\n`);
1434
- };
1435
- for (const event of submitted.events) {
1436
- if (event.id >= receivedEventId)
1437
- renderFollowupEvent(event);
1438
- }
1439
- const terminalEvent = completedInResponse ?? await watchEventsUntil(client, runId, {
1440
- afterEventId: cursor,
1441
- pageSize: integerFlag(flags, "event-page-size", 200, 1, 500),
1442
- pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
1443
- timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800),
1444
- signal: controller.signal,
1445
- stopWhen: (event) => (event.kind === "chat.completed" || event.kind === "chat.failed") && belongsToFollowup(event),
1446
- onRetry: ({ error, attempt, delayMs }) => {
1447
- const detail = error instanceof Error ? error.message : String(error);
1448
- process.stderr.write(`Follow-up connection lost (${detail}); retry ${attempt} in ${delayMs}ms.\n`);
1449
- },
1450
- onEvent: renderFollowupEvent
1451
- });
1452
- const messages = await client.listMessages(runId);
1453
- if (hasFlag(flags, "json")) {
1454
- printSuccess(commandName, {
1455
- ...publicRunPayload(submitted),
1456
- message: submitted.message,
1457
- chat_job: submitted.chat_job,
1458
- messages: messages.messages,
1459
- followup_event: terminalEvent
1460
- }, jsonContext(client));
1461
- }
1462
- else if (!jsonl) {
1463
- const assistant = [...messages.messages].reverse().find((item) => item.role === "assistant" && item.payload.chat_job_id === chatJobId);
1464
- if (assistant)
1465
- process.stdout.write(`${assistant.message}\n`);
1466
- }
1467
- if (terminalEvent.kind === "chat.failed")
1468
- process.exitCode = 2;
1469
- }
1470
- catch (error) {
1471
- if (!(error instanceof WatchInterruptedError))
1472
- throw error;
1473
- process.stderr.write(`Detached from follow-up for run ${runId}; processing continues.\n`);
1474
- process.exitCode = 130;
1475
- }
1476
- finally {
1477
- process.off("SIGINT", onInterrupt);
1478
- }
1479
- return;
1480
- }
1481
1428
  throw new Error(`Unknown runs action: ${action}`);
1482
1429
  };
1483
1430
  const commandDoctor = async (flags) => {
@@ -1717,6 +1664,8 @@ const commandInit = async (command, flags) => {
1717
1664
  const helpFor = (command) => {
1718
1665
  if (command[0] === "run")
1719
1666
  return runHelp;
1667
+ if (command[0] === "runs" && command[1] === "supabase")
1668
+ return runsSupabaseHelp;
1720
1669
  if (command[0] === "doctor")
1721
1670
  return doctorHelp;
1722
1671
  if (command[0] === "model-providers")
@@ -1747,6 +1696,9 @@ const commandId = (command) => {
1747
1696
  return "run.create";
1748
1697
  if (command[0] === "batch")
1749
1698
  return "batch.create";
1699
+ if (command[0] === "runs" && command[1] === "supabase") {
1700
+ return `runs.supabase.${command[2] || "unknown"}`;
1701
+ }
1750
1702
  if (command[0] === "runs")
1751
1703
  return `runs.${command[1] || "unknown"}`;
1752
1704
  if (command[0] === "ssh" || command[0] === "ssh-proxy")
package/dist/output.js CHANGED
@@ -79,7 +79,10 @@ export const summarizeRun = (payload) => {
79
79
  run.provider_id ? `provider=${run.provider_id}` : "",
80
80
  run.model_id ? `model=${run.model_id}` : "",
81
81
  run.sandbox_state ? `sandbox=${run.sandbox_state}` : "",
82
- run.snapshot_generation ? `snapshot=${run.snapshot_generation}` : ""
82
+ run.snapshot_generation ? `snapshot=${run.snapshot_generation}` : "",
83
+ run.email?.address ? `email=${run.email.address}` : run.email ? `email=${run.email.status}` : "",
84
+ run.channels?.telegram.enabled ? "telegram=enabled" : "",
85
+ run.supabase ? `supabase=${run.supabase.status}` : ""
83
86
  ].filter(Boolean).join(" ");
84
87
  return `${run.id} ${run.status}${selection ? ` ${selection}` : ""}${run.exit_code === null ? "" : ` exit=${run.exit_code}`}${run.error ? ` error=${run.error}` : ""}`;
85
88
  };
package/dist/runs.js CHANGED
@@ -50,6 +50,12 @@ export const createRun = async (client, options) => {
50
50
  external_run_id: options.externalRunId,
51
51
  workload_id: options.templateId,
52
52
  instruction: options.instruction,
53
+ ...(options.supabaseUserId ? { supabase_user_id: options.supabaseUserId } : {}),
54
+ ...(options.emailAddress ? { email_address: options.emailAddress } : {}),
55
+ ...(options.telegramBotToken ? {
56
+ telegram_bot_token: options.telegramBotToken,
57
+ telegram_allowed_users: options.telegramAllowedUsers || []
58
+ } : {}),
53
59
  ...(inputCollectionId ? { input_collection_id: inputCollectionId } : {})
54
60
  });
55
61
  };
@@ -71,6 +77,7 @@ export const readTasks = async (tasksPath) => {
71
77
  return {
72
78
  task,
73
79
  external_run_id: record.external_run_id ? String(record.external_run_id) : undefined,
80
+ email_address: record.email_address ? String(record.email_address) : undefined,
74
81
  input: Array.isArray(record.input) ? record.input.map(String) : undefined
75
82
  };
76
83
  });
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.0.9";
1
+ export const version = "0.0.10";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanlabs/sanbox-cli",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",