@sanlabs/sanbox-cli 0.0.14 → 0.0.16

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
@@ -1,10 +1,12 @@
1
1
  # Sanbox CLI
2
2
 
3
+ Fetch the LLM guide with `sanbox skill` through `/v1/skill`, which validates `SANBOX_API_KEY` from the agent environment. `/agent.md` remains public for older clients and direct links; the new CLI does not fall back to that route. `sanbox skill --write` saves the current guide to `.sanbox/agent.md`. `sanbox init agent` remains a deprecated alias; `sanbox init` writes only a bootstrap pointer. Set `SANBOX_API_URL` to your console's API origin for staging or self-hosted installations. Remote origins require HTTPS; development HTTP is limited to literal loopback.
4
+
3
5
  Run isolated Sanbox agent tasks from a terminal, CI job, or autonomous coding agent.
4
6
 
5
7
  Sanbox packages a task and selected inputs, starts an isolated runner, streams events, and keeps the sandbox and outputs indefinitely.
6
8
 
7
- The full machine operating protocol is available at https://console.sanbox.cloud/agent.md and in [`web/public/agent.md`](../web/public/agent.md).
9
+ The full machine operating protocol is available at https://console.sanbox.cloud/agent.md and in [`app/docs/agent.md`](../app/docs/agent.md).
8
10
 
9
11
  ## Install
10
12
 
@@ -15,7 +17,19 @@ installed_cli_version="$(sanbox --version)"
15
17
  test "$installed_cli_version" = "$latest_cli_version"
16
18
  ```
17
19
 
18
- Always use the latest published CLI. CLI 0.0.14 adds repeatable `--allowed-model` options when
20
+ Always use the latest published CLI. CLI 0.0.16 adds `sanbox skill` to retrieve the current LLM
21
+ guide from `/v1/skill` using an organization API key in `SANBOX_API_KEY`. It prints Markdown by
22
+ default, supports `--json`, and saves `.sanbox/agent.md` atomically with `--write`.
23
+ `sanbox init agent` is now a deprecated alias that requires the same key; plain `sanbox init`
24
+ writes a bootstrap pointer instead of an embedded guide. The new CLI requires API support for
25
+ `/v1/skill` and does not fall back to the public `/agent.md` compatibility URL.
26
+ CLI 0.0.15 adds Secret Proxy management, template grants,
27
+ and run-scope inspection. Use `--path` or `--all-paths` to configure request scope and repeatable
28
+ `--secret-proxy` options to select proxies during template creation. Grants check HTTPS access;
29
+ `--add-network-access` explicitly adds missing host rules on port 443. Proxy details include
30
+ run-ID attachment, timestamps, and granted template IDs. If setup fails after template creation,
31
+ the CLI reports the existing template ID and recovery commands. See [Secret Proxies](#secret-proxies).
32
+ CLI 0.0.14 adds repeatable `--allowed-model` options when
19
33
  creating OpenCode Computer templates and `sanbox run --model` to select a configured same-provider
20
34
  model at startup. The selected model is saved on the run and reused on Resume; SDK per-prompt
21
35
  switching does not change that saved startup default.
@@ -388,3 +402,51 @@ Batch fan-out is client-side. Keep the CLI process alive until all tasks are sub
388
402
  Use `--json` for request/response commands and `--jsonl` for streams. Envelopes have `schema_version`, `ok`, `command`, `context`, `data` or `error`, and `next_actions`.
389
403
 
390
404
  Exit codes are `0` for command success, `1` for local/API failure, `2` for readiness or waited remote failure, and `130` for a detached watcher.
405
+
406
+ ## Secret Proxies
407
+
408
+ Create a proxy from a JSON configuration and read its credential from stdin. For example, save this as `proxy.json`:
409
+
410
+ ```json
411
+ {
412
+ "name": "customer-api",
413
+ "host": "api.example.com",
414
+ "authentication": { "type": "bearer" },
415
+ "attach_run_id": true
416
+ }
417
+ ```
418
+
419
+ ```sh
420
+ sanbox secret-proxies create --file proxy.json --secret-stdin \
421
+ --path /users --path /projects < token-file
422
+ sanbox secret-proxies get customer-api
423
+ sanbox secret-proxies update customer-api --path /users --if-revision 1
424
+ ```
425
+
426
+ Repeat `--path` to select paths and their subpaths, or use `--all-paths` for the entire API. `/users` includes `/users/42`, but excludes `/users-admin`. `/users/` includes descendants but excludes `/users`. Paths do not support wildcards.
427
+
428
+ These options replace all request rules and allow every supported HTTP method: GET, HEAD, POST, PUT, PATCH, DELETE, and OPTIONS. To retain method restrictions, supply `request_rules` in the JSON file without either path option. Creation requires an explicit path option or rules in the file. Updates accept `--file`, path options, or both, and require the current `--if-revision`. Names and hosts are immutable.
429
+
430
+ Grant a proxy to an existing template:
431
+
432
+ ```sh
433
+ sanbox templates secret-proxies grant TEMPLATE_ID customer-api
434
+ sanbox templates secret-proxies grant TEMPLATE_ID customer-api --add-network-access
435
+ ```
436
+
437
+ Grants check whether the host is allowed on HTTPS port 443. Missing or partial access produces a warning and does not prevent the grant. IP/CIDR rules are reported as DNS-dependent when hostname access cannot be confirmed. `--add-network-access` explicitly adds missing host rules on port 443, preserving existing custom rules and descriptions. Managed provider and harness rules remain server-owned.
438
+
439
+ Select proxies while creating a template with repeatable `--secret-proxy`:
440
+
441
+ ```sh
442
+ sanbox templates create --name "Customer support" \
443
+ --model-provider openai --model MODEL_ID \
444
+ --secret-proxy customer-api --secret-proxy billing-api \
445
+ --add-network-access
446
+ ```
447
+
448
+ Proxy names and IDs are resolved before creation. Network changes and grants are separate operations after the template is created. If setup fails, the CLI exits with an error, reports the existing template ID, and provides grant commands to finish setup. Successful grants and network changes remain saved. Use those recovery commands instead of creating another template.
449
+
450
+ `secret-proxies get` shows path scope, run-ID attachment, Created/Updated timestamps, and granted template IDs. With `--json`, grant results include `data.network_access`; template creation includes `data.secret_proxies`. Each network check includes its status, whether a rule was added, and any warning. Incomplete setup reports granted/pending proxy IDs under `error.details` and recovery commands under `next_actions`.
451
+
452
+ There is no enable or test step. Grants and configuration edits apply on the next start or resume. Inspect run scope with `sanbox runs secret-proxies RUN_ID`. Credentials are never accepted in command-line arguments or JSON configuration. See [Secret Proxies](../docs/secret-proxies.md) for runtime behavior, rotation, and deletion.
package/dist/api.js CHANGED
@@ -165,6 +165,30 @@ export class SanboxClient {
165
165
  async disconnectAnthropicEnvironment(environmentId) {
166
166
  return this.request(await this.orgPath(`/anthropic-environments/${encodeURIComponent(environmentId)}`), { method: "DELETE" });
167
167
  }
168
+ async listSecretProxies() {
169
+ return this.request(await this.orgPath("/secret-proxies"));
170
+ }
171
+ async getSecretProxy(id) {
172
+ return this.request(await this.orgPath(`/secret-proxies/${encodeURIComponent(id)}`));
173
+ }
174
+ async createSecretProxy(body) {
175
+ return this.request(await this.orgPath("/secret-proxies"), { method: "POST", body: JSON.stringify(body) });
176
+ }
177
+ async updateSecretProxy(id, body) {
178
+ return this.request(await this.orgPath(`/secret-proxies/${encodeURIComponent(id)}`), { method: "PATCH", body: JSON.stringify(body) });
179
+ }
180
+ async deleteSecretProxy(id) {
181
+ return this.request(await this.orgPath(`/secret-proxies/${encodeURIComponent(id)}`), { method: "DELETE" });
182
+ }
183
+ async getTemplateSecretProxies(template) {
184
+ return this.request(await this.orgPath(`/templates/${encodeURIComponent(template)}/secret-proxies`));
185
+ }
186
+ async grantTemplateSecretProxy(template, id, grant = true) {
187
+ return this.request(await this.orgPath(`/templates/${encodeURIComponent(template)}/secret-proxies/${encodeURIComponent(id)}`), { method: grant ? "PUT" : "DELETE" });
188
+ }
189
+ async getRunSecretProxies(run) {
190
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(run)}/secret-proxies`));
191
+ }
168
192
  async listTemplates() {
169
193
  return this.request(await this.orgPath("/templates"));
170
194
  }
@@ -177,6 +201,9 @@ export class SanboxClient {
177
201
  async deleteTemplate(templateId) {
178
202
  return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}`), { method: "DELETE" });
179
203
  }
204
+ async updateTemplateNetworkPolicy(templateId, networkPolicy) {
205
+ return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}/network-policy`), { method: "PATCH", body: JSON.stringify({ network_policy: networkPolicy }) });
206
+ }
180
207
  async createTemplate(body) {
181
208
  return this.request(await this.orgPath("/templates"), {
182
209
  method: "POST",
package/dist/args.js CHANGED
@@ -1,4 +1,6 @@
1
1
  const multiFlags = new Set([
2
+ "path",
3
+ "secret-proxy",
2
4
  "input",
3
5
  "include",
4
6
  "artifact",
@@ -8,6 +10,9 @@ const multiFlags = new Set([
8
10
  "browser-domain"
9
11
  ]);
10
12
  export const booleanFlags = new Set([
13
+ "all-paths",
14
+ "add-network-access",
15
+ "secret-stdin",
11
16
  "help",
12
17
  "version",
13
18
  "json",
package/dist/cli.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { proxyCommand, formatProxyResult, secretProxiesHelp } from "./secretProxies.js";
3
+ import { formatNetworkAccess, grantTemplateProxies, resolveSecretProxies } from "./templateSecretProxies.js";
4
+ import { agentGuideUrl, fetchSkill, writeSkill, skillBootstrap } from "./skill.js";
2
5
  import fs from "node:fs/promises";
3
6
  import path from "node:path";
4
7
  import { formatActivityJsonl, formatActivityLine, parseActivityView, shouldRenderEvent } from "./activity.js";
@@ -30,6 +33,7 @@ Commands:
30
33
  sanbox auth check [--json]
31
34
  sanbox context [--json]
32
35
  sanbox doctor [--json]
36
+ sanbox secret-proxies --help
33
37
  sanbox model-providers list [--json]
34
38
  sanbox model-providers get <provider-id> [--json]
35
39
  sanbox model-providers models <provider-id> [--json]
@@ -40,7 +44,7 @@ Commands:
40
44
  sanbox templates list [--json]
41
45
  sanbox templates get <template-id> [--json]
42
46
  sanbox templates validate <template-id> [--json]
43
- sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--allowed-model <model-id>] [--channel email|telegram] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
47
+ sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--allowed-model <model-id>] [--channel email|telegram] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--secret-proxy <name-or-id>] [--add-network-access] [--json]
44
48
  sanbox templates delete <template-id> --force [--json]
45
49
  sanbox run "task" --template <template-id> [--model <model-id>] [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
46
50
  sanbox run --task "..." --template <template-id> [--model <model-id>] [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
@@ -64,7 +68,8 @@ Commands:
64
68
  sanbox opencode connections revoke <run-id> <connection-id> [--json]
65
69
  sanbox ssh <run-id>
66
70
  sanbox init [--force]
67
- sanbox init agent [--write]
71
+ sanbox skill [--write] [--json]
72
+ sanbox init agent [--write] (deprecated alias for skill)
68
73
  `;
69
74
  const sshHelp = `Sanbox SSH
70
75
 
@@ -170,10 +175,13 @@ Usage:
170
175
  sanbox templates list [--json]
171
176
  sanbox templates get <template-id> [--json]
172
177
  sanbox templates validate <template-id> [--json]
173
- sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--allowed-model <model-id>] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
178
+ sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--allowed-model <model-id>] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--secret-proxy <name-or-id>] [--add-network-access] [--json]
174
179
  sanbox templates delete <template-id> --force [--json]
175
180
 
176
181
  Template creation requires an exact provider id and that provider's exact model id.
182
+ Use repeatable --secret-proxy <name-or-id> to grant saved proxies to the new template.
183
+ Grants check HTTPS access. Add --add-network-access to explicitly allow their hosts on port 443.
184
+ If setup fails after creation, use the reported template id and grant commands to finish setup.
177
185
  LiteLLM budgets are optional USD amounts and apply separately to each run.
178
186
  OpenCode defaults to --mode task. Use --mode computer for a retained private OpenCode server;
179
187
  per-run LiteLLM budgets are not available for that retained mode yet.
@@ -188,111 +196,6 @@ Optional controls: --browser-max-steps, --browser-step-timeout-seconds,
188
196
  --browser-download-policy allow|deny, and --browser-additional-instructions.
189
197
  Deleting a template requires --force. Existing runs and their retained sandboxes are preserved.
190
198
  `;
191
- const agentInstructions = `# Operate Sanbox Autonomously
192
-
193
- Use the \`sanbox\` CLI for focused, isolated, long-running, risky, or parallel work. The canonical
194
- protocol is https://console.sanbox.cloud/agent.md.
195
-
196
- Always use the latest published CLI. Install and verify it at the start of every operating session:
197
- \`\`\`bash
198
- npm install -g @sanlabs/sanbox-cli@latest
199
- latest_cli_version="$(npm view @sanlabs/sanbox-cli version)"
200
- installed_cli_version="$(sanbox --version)"
201
- test "$installed_cli_version" = "$latest_cli_version"
202
- \`\`\`
203
-
204
- Human bootstrap:
205
- - A human/admin supplies an organization-scoped SANBOX_API_KEY and configures provider credentials plus a runnable template.
206
- - Never print, persist, prompt with, or upload API keys or provider credentials.
207
- - If the key or a runnable template is unavailable, stop with the exact human action required.
208
-
209
- Deterministic startup:
210
- \`\`\`bash
211
- sanbox --version
212
- sanbox auth check --json
213
- sanbox context --json
214
- sanbox templates list --json
215
- export SANBOX_TEMPLATE=<returned-template-id-or-slug>
216
- sanbox templates validate "$SANBOX_TEMPLATE" --json
217
- sanbox doctor --json
218
- \`\`\`
219
-
220
- Never guess opaque IDs. The CLI derives the organization from the API key and requires the key to
221
- resolve to exactly one organization. For task execution, select only a template with runnable: true,
222
- template_type: "runner", and runner_config.harness: "opencode" or "browser-use". Select Browser Use
223
- only for a one-shot web task whose target domains are already approved on the template. Never select
224
- a Hermes service template for a waited task because it is always-on. Select automatically only when exactly one task
225
- template qualifies; otherwise ask the user. Provider credentials and template administration are
226
- console-only.
227
-
228
- For an explicitly requested OpenCode Computer, use a service template with harness opencode and
229
- execution_mode computer. The task is optional; omit --wait. Use sanbox run --model <model-id> to
230
- select an exact ID from the template's allowed_model_ids, keeping its provider unchanged. Omit
231
- --model to use the template default. The startup model is saved on the run and reused on Resume
232
- without re-specifying --model. SDK per-prompt switching changes neither the template default nor
233
- the run's saved startup model. This flag is not supported for task or Hermes templates.
234
-
235
- Use --json for request/response commands and --jsonl for streams. Parse the versioned envelope:
236
- schema_version, ok, command, context, data or error, and next_actions. Execute command actions as
237
- argv arrays, never shell strings. Exit 0 means command success, 1 local/API failure, 2 readiness or
238
- waited remote failure, and 130 detached while remote work continues.
239
-
240
- Preview and submit each logical task with a stable idempotency key:
241
- \`\`\`bash
242
- sanbox run "Investigate one focused task and write output/report.md" --input app/ --dry-run --json
243
- sanbox run "Investigate one focused task and write output/report.md" \\
244
- --template "$SANBOX_TEMPLATE" --external-run-id "<stable-project-task-id>" \\
245
- --input app/ --wait --json
246
- \`\`\`
247
-
248
- Reuse the same --external-run-id after ambiguous failures. Retry network errors, HTTP 429, HTTP 5xx,
249
- and workspace_busy with bounded backoff. Do not retry other 4xx errors unless next_actions directs
250
- recovery. A run is inactive when state is stopped; inspect latest_execution.outcome for the most
251
- recent bounded execution outcome.
252
-
253
- Recover and retrieve results:
254
- \`\`\`bash
255
- sanbox runs list --limit 50 --json
256
- sanbox runs get <run-id> --json
257
- sanbox runs events <run-id> --after-event-id <cursor> --json
258
- sanbox runs watch <run-id> --after-event-id <cursor> --jsonl
259
- sanbox runs artifacts <run-id> --json
260
- sanbox runs download <run-id> --output .sanbox/output/<run-id> --json
261
- \`\`\`
262
-
263
- Tasks must put durable files under /workspace/output. Downloads preserve relative paths and report
264
- byte counts plus SHA-256 digests; existing files require explicit --overwrite.
265
-
266
- While a sandbox is running, create one read-only capability URL for its entire root filesystem:
267
- \`\`\`bash
268
- sanbox runs share <run-id> --expires 1h --json
269
- sanbox runs shares <run-id> --json
270
- sanbox runs unshare <run-id> <access-point-id> --json
271
- \`\`\`
272
- The URL starts at /, supports directory JSON with ?format=json, and stops working when the sandbox
273
- stops, the link expires, or it is revoked. Treat the URL as a bearer secret.
274
-
275
- Manage the retained sandbox independently of its agent harness:
276
- \`\`\`bash
277
- sanbox runs get <run-id> --json
278
- sanbox runs resume <run-id> --wait --json
279
- sanbox runs share <run-id> --expires 1h --json
280
- sanbox runs stop <run-id> --wait --json
281
- sanbox runs delete <run-id> --yes --json
282
- \`\`\`
283
-
284
- Stop terminates compute and durably syncs the workspace without retaining RAM or VM state. Resume
285
- fresh-boots the pinned runtime artifact with that workspace; it does not restore process state or
286
- silently replay a finished task. Delete permanently removes the workspace after archiving usage.
287
-
288
- For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
289
- client alive until submission completes.
290
-
291
- Do not claim completion until state is stopped, latest_execution.outcome is completed, and required
292
- artifacts are downloaded and verified. Report run/external/template IDs, run state, execution outcome, workspace save time,
293
- artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
294
- .sanboxignore for project rules.
295
- `;
296
199
  const cwd = () => process.cwd();
297
200
  const makeClient = (flags) => new SanboxClient(readConfig(flags));
298
201
  const jsonContext = (client) => ({
@@ -468,6 +371,14 @@ const positionalText = (command, startIndex) => command.slice(startIndex).join("
468
371
  const runTask = (command, flags) => flagString(flags, "task") || positionalText(command, 1);
469
372
  const commonFlags = ["api-url", "json", "help"];
470
373
  const flagSets = {
374
+ "secret-proxies.list": commonFlags,
375
+ "secret-proxies.get": commonFlags,
376
+ "secret-proxies.create": [...commonFlags, "file", "secret-stdin", "path", "all-paths"],
377
+ "secret-proxies.update": [...commonFlags, "file", "if-revision", "path", "all-paths"],
378
+ "secret-proxies.rotate": [...commonFlags, "secret-stdin"],
379
+ "secret-proxies.delete": [...commonFlags, "yes"],
380
+ "templates.secret-proxies": [...commonFlags, "add-network-access"],
381
+ "runs.secret-proxies": commonFlags,
471
382
  "auth.check": commonFlags,
472
383
  context: [...commonFlags, "template"],
473
384
  doctor: [...commonFlags, "template"],
@@ -484,6 +395,8 @@ const flagSets = {
484
395
  "templates.delete": [...commonFlags, "force"],
485
396
  "templates.create": [
486
397
  ...commonFlags,
398
+ "secret-proxy",
399
+ "add-network-access",
487
400
  "name",
488
401
  "model-provider",
489
402
  "model",
@@ -536,6 +449,7 @@ const flagSets = {
536
449
  logout: ["api-url", "json", "help"],
537
450
  init: [...commonFlags, "force", "template"],
538
451
  "init.agent": [...commonFlags, "write"],
452
+ skill: ["api-url", "json", "help", "write"],
539
453
  version: ["json", "help"]
540
454
  };
541
455
  const commandKey = (command) => {
@@ -549,7 +463,8 @@ const commandKey = (command) => {
549
463
  return "runs.supabase";
550
464
  if (command[0] === "opencode" && command[1] === "connections")
551
465
  return "opencode.connections";
552
- if (command[0] === "model-providers" ||
466
+ if (command[0] === "secret-proxies" ||
467
+ command[0] === "model-providers" ||
553
468
  command[0] === "anthropic-environments" ||
554
469
  command[0] === "templates" ||
555
470
  command[0] === "runs" ||
@@ -561,6 +476,8 @@ const commandKey = (command) => {
561
476
  return command[0];
562
477
  };
563
478
  const requiredValueFlags = new Set([
479
+ "path", "secret-proxy",
480
+ "file", "if-revision",
564
481
  "api-url", "template", "task", "input", "include", "external-run-id", "email-address",
565
482
  "supabase-user-id", "return-url",
566
483
  "telegram-bot-token", "telegram-allowed-user", "channel",
@@ -607,7 +524,7 @@ const validateFlags = (command, flags) => {
607
524
  return;
608
525
  }
609
526
  const knownRoots = new Set([
610
- "auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
527
+ "secret-proxies", "auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
611
528
  "run", "batch", "runs", "opencode", "ssh", "ssh-proxy", "login", "logout", "init", "version"
612
529
  ]);
613
530
  const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
@@ -622,7 +539,11 @@ const validatePositionals = (command, flags) => {
622
539
  if (hasFlag(flags, "help"))
623
540
  return;
624
541
  const maximums = {
542
+ "secret-proxies.list": 2, "secret-proxies.get": 3, "secret-proxies.create": 2,
543
+ "secret-proxies.update": 3, "secret-proxies.rotate": 3, "secret-proxies.delete": 3,
544
+ "templates.secret-proxies": 5, "runs.secret-proxies": 3,
625
545
  "auth.check": 2,
546
+ skill: 1,
626
547
  context: 1,
627
548
  doctor: 1,
628
549
  "model-providers.list": 2,
@@ -1273,6 +1194,13 @@ const commandTemplates = async (command, flags) => {
1273
1194
  if (harness !== "hermes" && channels.length > 0) {
1274
1195
  throw new CliError("hermes_channels_not_supported", "--channel requires --harness hermes.");
1275
1196
  }
1197
+ const proxyNames = flagList(flags, "secret-proxy");
1198
+ const addNetworkAccess = hasFlag(flags, "add-network-access");
1199
+ if (addNetworkAccess && !proxyNames.length) {
1200
+ throw new CliError("secret_proxy_required", "--add-network-access requires at least one --secret-proxy.");
1201
+ }
1202
+ // Resolve every selection before creating anything, including name/id aliases.
1203
+ const selectedProxies = await resolveSecretProxies(client, proxyNames);
1276
1204
  let payload;
1277
1205
  try {
1278
1206
  payload = await client.createTemplate({
@@ -1305,8 +1233,21 @@ const commandTemplates = async (command, flags) => {
1305
1233
  catch (error) {
1306
1234
  throw templateCreationError(error, client, modelProvider, model);
1307
1235
  }
1236
+ let proxySetup;
1237
+ if (selectedProxies.length) {
1238
+ try {
1239
+ const { template, ...setup } = await grantTemplateProxies(client, payload.template.id, selectedProxies, addNetworkAccess);
1240
+ payload.template = template;
1241
+ proxySetup = setup;
1242
+ }
1243
+ catch (error) {
1244
+ if (error instanceof CliError)
1245
+ throw new CliError(error.code, `Template ${payload.template.id} was created. Do not rerun templates create.\n${error.message}`, { ...error.options, details: { ...error.options.details, template_created: true } });
1246
+ throw error;
1247
+ }
1248
+ }
1308
1249
  if (hasFlag(flags, "json")) {
1309
- printSuccess("templates.create", payload, jsonContext(client), [
1250
+ printSuccess("templates.create", { ...payload, ...(proxySetup ? { secret_proxies: proxySetup } : {}) }, jsonContext(client), [
1310
1251
  commandAction(["sanbox", "templates", "validate", payload.template.id, "--json"], "Validate the new template before running it."),
1311
1252
  commandAction([
1312
1253
  "sanbox",
@@ -1328,6 +1269,8 @@ const commandTemplates = async (command, flags) => {
1328
1269
  }
1329
1270
  process.stdout.write(`created ${payload.template.id} provider=${payload.template.provider_id || modelProvider} model=${payload.template.model_id || model}` +
1330
1271
  `${payload.template.llm_budget_usd ? ` budget=$${payload.template.llm_budget_usd}/run` : ""}\n`);
1272
+ if (proxySetup)
1273
+ process.stdout.write(`Granted proxies: ${selectedProxies.map(proxy => proxy.name).join(", ")}\n${formatNetworkAccess(proxySetup.network_access)}`);
1331
1274
  return;
1332
1275
  }
1333
1276
  if (action === "delete") {
@@ -1782,33 +1725,31 @@ const commandLogout = async (flags) => {
1782
1725
  process.stdout.write(session ? "Signed out.\n" : "No saved Sanbox user session.\n");
1783
1726
  }
1784
1727
  };
1728
+ const commandSkill = async (flags, command = "skill") => {
1729
+ const guide = await fetchSkill(resolveApiUrl(flags));
1730
+ if (hasFlag(flags, "write")) {
1731
+ await writeSkill(cwd(), guide);
1732
+ if (hasFlag(flags, "json"))
1733
+ printSuccess(command, { path: ".sanbox/agent.md", status: "written" }, localJsonContext(flags));
1734
+ else
1735
+ process.stdout.write(".sanbox/agent.md written\n");
1736
+ }
1737
+ else if (hasFlag(flags, "json")) {
1738
+ printSuccess(command, { instructions: guide }, localJsonContext(flags));
1739
+ }
1740
+ else
1741
+ process.stdout.write(guide);
1742
+ };
1785
1743
  const commandInit = async (command, flags) => {
1786
1744
  const dir = path.join(cwd(), ".sanbox");
1787
- if (command[1] === "agent") {
1788
- if (hasFlag(flags, "write")) {
1789
- await fs.mkdir(dir, { recursive: true });
1790
- await fs.writeFile(path.join(dir, "agent.md"), agentInstructions, "utf8");
1791
- if (hasFlag(flags, "json")) {
1792
- printSuccess("init.agent", { path: ".sanbox/agent.md", status: "written" }, localJsonContext(flags));
1793
- }
1794
- else {
1795
- process.stdout.write(".sanbox/agent.md written\n");
1796
- }
1797
- return;
1798
- }
1799
- if (hasFlag(flags, "json")) {
1800
- printSuccess("init.agent", { instructions: agentInstructions }, localJsonContext(flags));
1801
- }
1802
- else {
1803
- process.stdout.write(agentInstructions);
1804
- }
1805
- return;
1806
- }
1745
+ if (command[1] === "agent")
1746
+ return commandSkill(flags, "init.agent");
1807
1747
  if (command[1])
1808
1748
  throw new Error("init supports no subcommand or `agent`.");
1809
1749
  const force = hasFlag(flags, "force");
1810
1750
  const localConfig = readLocalConfig();
1811
1751
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
1752
+ const bootstrap = skillBootstrap(apiUrl);
1812
1753
  const template = readTemplateSelection(flags, { required: false });
1813
1754
  const config = {
1814
1755
  api_url: apiUrl,
@@ -1825,7 +1766,7 @@ const commandInit = async (command, flags) => {
1825
1766
  await fs.mkdir(dir, { recursive: true });
1826
1767
  const results = [
1827
1768
  [".sanbox/config.json", await writeIfNeeded(path.join(dir, "config.json"), `${JSON.stringify(config, null, 2)}\n`, force)],
1828
- [".sanbox/agent.md", await writeIfNeeded(path.join(dir, "agent.md"), agentInstructions, force)],
1769
+ [".sanbox/agent.md", await writeIfNeeded(path.join(dir, "agent.md"), bootstrap, force)],
1829
1770
  [".sanboxignore", await writeIfNeeded(path.join(cwd(), ".sanboxignore"), sanboxIgnore, force)]
1830
1771
  ];
1831
1772
  if (hasFlag(flags, "json")) {
@@ -1846,6 +1787,8 @@ const helpFor = (command) => {
1846
1787
  return runsSupabaseHelp;
1847
1788
  if (command[0] === "doctor")
1848
1789
  return doctorHelp;
1790
+ if (command[0] === "secret-proxies" || command[1] === "secret-proxies")
1791
+ return secretProxiesHelp;
1849
1792
  if (command[0] === "model-providers")
1850
1793
  return modelProvidersHelp;
1851
1794
  if (command[0] === "anthropic-environments")
@@ -1920,6 +1863,15 @@ const main = async (command, flags) => {
1920
1863
  return commandContext(flags);
1921
1864
  if (command[0] === "doctor")
1922
1865
  return commandDoctor(flags);
1866
+ if (command[0] === "secret-proxies" || command[1] === "secret-proxies") {
1867
+ const client = makeClient(flags);
1868
+ const payload = await proxyCommand(client, command, flags);
1869
+ if (hasFlag(flags, "json"))
1870
+ printSuccess(commandKey(command) || "secret-proxies", payload, jsonContext(client));
1871
+ else
1872
+ process.stdout.write(formatProxyResult(payload));
1873
+ return;
1874
+ }
1923
1875
  if (command[0] === "model-providers")
1924
1876
  return commandModelProviders(command, flags);
1925
1877
  if (command[0] === "anthropic-environments") {
@@ -1943,17 +1895,29 @@ const main = async (command, flags) => {
1943
1895
  process.exitCode = exitCode;
1944
1896
  return;
1945
1897
  }
1898
+ if (command[0] === "skill")
1899
+ return commandSkill(flags);
1946
1900
  if (command[0] === "init")
1947
1901
  return commandInit(command, flags);
1948
1902
  throw new CliError("unknown_command", `Unknown command: ${command.join(" ")}`);
1949
1903
  };
1950
1904
  const parsed = parseArgs(process.argv.slice(2));
1951
1905
  main(parsed.command, parsed.flags).catch((error) => {
1906
+ let context = localJsonContext(parsed.flags);
1907
+ if (parsed.command[0] === "skill" || parsed.command[0] === "init") {
1908
+ // Rejected origins may contain a password/query secret; never echo them.
1909
+ try {
1910
+ context = { api_url: agentGuideUrl(resolveApiUrl(parsed.flags)).origin };
1911
+ }
1912
+ catch {
1913
+ context = {};
1914
+ }
1915
+ }
1952
1916
  if (hasFlag(parsed.flags, "jsonl")) {
1953
- printJsonlError(commandId(parsed.command), error, localJsonContext(parsed.flags));
1917
+ printJsonlError(commandId(parsed.command), error, context);
1954
1918
  }
1955
1919
  else if (hasFlag(parsed.flags, "json")) {
1956
- printError(commandId(parsed.command), error, localJsonContext(parsed.flags));
1920
+ printError(commandId(parsed.command), error, context);
1957
1921
  }
1958
1922
  else {
1959
1923
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
@@ -0,0 +1,168 @@
1
+ import fs from "node:fs/promises";
2
+ import { posix } from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ import { isoUtcTimestamp } from "./output.js";
5
+ import { formatNetworkAccess, grantTemplateProxies, resolveSecretProxies } from "./templateSecretProxies.js";
6
+ export const secretProxiesHelp = `Secret Proxies
7
+ sanbox secret-proxies list
8
+ sanbox secret-proxies get <name-or-id>
9
+ sanbox secret-proxies create --file proxy.json --secret-stdin [--path /users | --all-paths]
10
+ sanbox secret-proxies update <name-or-id> --if-revision <revision> [--file proxy.json] [--path /users | --all-paths]
11
+ sanbox secret-proxies rotate <name-or-id> --secret-stdin
12
+ sanbox secret-proxies delete <name-or-id> --yes
13
+ sanbox templates secret-proxies list <template>
14
+ sanbox templates secret-proxies grant <template> <name-or-id> [--add-network-access]
15
+ sanbox templates secret-proxies revoke <template> <name-or-id>
16
+ sanbox runs secret-proxies <run>
17
+
18
+ Use --json for structured output. Credentials are read only from stdin.
19
+ Repeat --path for multiple paths. Each includes its subpaths; path wildcards are not supported.
20
+ --path and --all-paths replace request_rules with all supported HTTP methods.
21
+ Without either option, request_rules in the JSON file retain their method restrictions.
22
+ Updates require --file or a path option, and always require --if-revision.
23
+ Names and hosts cannot be updated. Create a new proxy to change either.
24
+ Saving makes a proxy available to granted templates on their next start/resume.
25
+ Call the original HTTPS URL. Network rules must allow its host; matching requests receive credentials automatically.
26
+ Grants check HTTPS access. --add-network-access explicitly adds missing host rules on port 443.
27
+ `;
28
+ export const proxyMethods = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
29
+ export function proxyPathRules(flags) {
30
+ if (flags.path !== undefined && flags["all-paths"]) {
31
+ throw new CliError("conflicting_paths", "Use either --path or --all-paths, not both.");
32
+ }
33
+ if (flags["all-paths"])
34
+ return [{ path_prefix: "/", methods: [...proxyMethods] }];
35
+ if (flags.path === undefined)
36
+ return undefined;
37
+ const paths = Array.isArray(flags.path) ? flags.path : [flags.path];
38
+ if (!paths.length || paths.length > 32)
39
+ throw new CliError("invalid_paths", "Provide between 1 and 32 paths.");
40
+ for (const path of paths) {
41
+ if (typeof path !== "string" || !path.startsWith("/") || /[*%\\?#\s\x00-\x20\x7f]/.test(path) ||
42
+ path.includes("//") || posix.normalize(path) !== path) {
43
+ throw new CliError("invalid_path", "Use an absolute path such as /users, without wildcards, encoding, queries, whitespace, or dot segments.");
44
+ }
45
+ }
46
+ return [...new Set(paths)].map(path_prefix => ({ path_prefix, methods: [...proxyMethods] }));
47
+ }
48
+ export async function proxyCommand(client, command, flags) {
49
+ const required = (value) => {
50
+ if (!value)
51
+ throw new CliError("argument_required", secretProxiesHelp);
52
+ return value;
53
+ };
54
+ const resolve = async (value) => {
55
+ return (await resolveSecretProxies(client, [required(value)]))[0];
56
+ };
57
+ const readCredential = async () => {
58
+ if (!flags["secret-stdin"] || process.stdin.isTTY)
59
+ throw new CliError("secret_stdin_required", "Pipe the credential into stdin and pass --secret-stdin.");
60
+ let value = "";
61
+ for await (const chunk of process.stdin) {
62
+ value += chunk.toString();
63
+ if (value.length > 8194)
64
+ throw new CliError("credential_too_long", "Credential exceeds 8192 characters.");
65
+ }
66
+ value = value.replace(/\r?\n$/, "");
67
+ if (!value)
68
+ throw new CliError("credential_required", "Credential is empty.");
69
+ return value;
70
+ };
71
+ const readConfig = async () => {
72
+ if (typeof flags.file !== "string")
73
+ throw new CliError("file_required", "Pass --file with a JSON configuration path.");
74
+ const stat = await fs.stat(flags.file);
75
+ if (stat.size > 64_000)
76
+ throw new CliError("file_too_large", "Configuration exceeds 64 KB.");
77
+ let config;
78
+ try {
79
+ config = JSON.parse(await fs.readFile(flags.file, "utf8"));
80
+ }
81
+ catch {
82
+ throw new CliError("invalid_proxy_json", "Configuration must contain valid JSON.");
83
+ }
84
+ if (!config ||
85
+ typeof config !== "object" ||
86
+ Array.isArray(config) ||
87
+ "credential" in config)
88
+ throw new CliError("invalid_proxy_config", "Use a configuration object without credentials. Pass credentials through stdin.");
89
+ return config;
90
+ };
91
+ if (command[0] === "runs")
92
+ return client.getRunSecretProxies(required(command[2]));
93
+ if (command[0] === "templates") {
94
+ const action = command[2], template = required(command[3]);
95
+ if (flags["add-network-access"] && action !== "grant") {
96
+ throw new CliError("unsupported_flag", "--add-network-access is only supported for grants and template creation.");
97
+ }
98
+ if (action === "list")
99
+ return client.getTemplateSecretProxies(template);
100
+ const proxy = await resolve(command[4]);
101
+ if (!["grant", "revoke"].includes(action || ""))
102
+ throw new CliError("invalid_action", secretProxiesHelp);
103
+ if (action === "revoke")
104
+ return client.grantTemplateSecretProxy(template, proxy.id, false);
105
+ const { template: _template, ...setup } = await grantTemplateProxies(client, template, [proxy], Boolean(flags["add-network-access"]));
106
+ return { granted: true, ...setup };
107
+ }
108
+ const action = command[1];
109
+ if (action === "list")
110
+ return client.listSecretProxies();
111
+ const rules = proxyPathRules(flags);
112
+ if (action === "create") {
113
+ const config = await readConfig();
114
+ if (!rules && config.request_rules === undefined) {
115
+ throw new CliError("paths_required", "Choose --path or --all-paths, or provide request_rules in the configuration file.");
116
+ }
117
+ return client.createSecretProxy({
118
+ ...config,
119
+ ...(rules ? { request_rules: rules } : {}),
120
+ credential: await readCredential(),
121
+ });
122
+ }
123
+ const proxy = await resolve(command[2]);
124
+ if (action === "get")
125
+ return client.getSecretProxy(proxy.id);
126
+ if (action === "delete") {
127
+ if (!flags.yes)
128
+ throw new CliError("confirmation_required", "Pass --yes to delete the proxy and revoke its active access.");
129
+ return client.deleteSecretProxy(proxy.id);
130
+ }
131
+ if (action === "rotate")
132
+ return client.updateSecretProxy(proxy.id, {
133
+ revision: proxy.revision,
134
+ credential: await readCredential(),
135
+ });
136
+ if (action === "update") {
137
+ const revision = Number(flags["if-revision"]);
138
+ if (!Number.isInteger(revision) || revision < 1)
139
+ throw new CliError("revision_required", "Pass --if-revision with the revision you edited.");
140
+ return client.updateSecretProxy(proxy.id, {
141
+ ...(flags.file !== undefined || !rules ? await readConfig() : {}),
142
+ ...(rules ? { request_rules: rules } : {}),
143
+ revision,
144
+ });
145
+ }
146
+ throw new CliError("invalid_action", secretProxiesHelp);
147
+ }
148
+ export function formatProxyResult(payload) {
149
+ if ("proxies" in payload)
150
+ return payload.proxies.length
151
+ ? payload.proxies.map(proxy => `${proxy.id}\t${proxy.name}\t${proxy.host}\trevision=${proxy.revision}\tattach_run_id=${proxy.attach_run_id}${proxy.updated_at ? `\tupdated=${isoUtcTimestamp(proxy.updated_at)}` : ""}${proxy.granted === undefined ? "" : `\tgranted=${proxy.granted}`}${proxy.removed ? "\tremoved" : ""}`).join("\n") + "\n"
152
+ : "No secret proxies.\n";
153
+ if ("proxy" in payload) {
154
+ const proxy = payload.proxy;
155
+ const templateIds = payload.template_ids ?? proxy.template_ids;
156
+ const rules = proxy.request_rules.map(rule => {
157
+ const allMethods = proxyMethods.every(method => rule.methods.includes(method));
158
+ const scope = rule.path_prefix === "/" ? "All paths" : `${rule.path_prefix} and subpaths`;
159
+ return ` ${scope} (${allMethods ? "all supported methods" : rule.methods.join(", ")})`;
160
+ }).join("\n");
161
+ return `${proxy.id} ${proxy.name}\nHost: ${proxy.host}\nRevision: ${proxy.revision}\nAuthentication: ${proxy.authentication.type} (${proxy.authentication.header})\nAttach Sanbox run ID: ${proxy.attach_run_id ? "Yes (X-Sanbox-Run-ID)" : "No"}\nCreated: ${proxy.created_at ? isoUtcTimestamp(proxy.created_at) : "Unavailable"}\nUpdated: ${proxy.updated_at ? isoUtcTimestamp(proxy.updated_at) : "Unavailable"}\nPaths:\n${rules}\n${templateIds === undefined ? "" : `Granted templates: ${templateIds.length ? templateIds.join(", ") : "None"}\n`}`;
162
+ }
163
+ if ("deleted" in payload)
164
+ return "Proxy deleted. Future requests receive no injected credential.\n";
165
+ return payload.granted
166
+ ? "Granted for the next start or resume.\n" + formatNetworkAccess(payload.network_access ?? [])
167
+ : "Grant removed for the next start or resume.\n";
168
+ }
package/dist/skill.js ADDED
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { CliError } from "./errors.js";
5
+ export function agentGuideUrl(apiUrl) {
6
+ try {
7
+ const url = new URL(apiUrl);
8
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "[::1]";
9
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
10
+ url.username || url.password || url.search || url.hash || !/^\/*$/.test(url.pathname))
11
+ throw new Error();
12
+ return new URL("/v1/skill", url);
13
+ }
14
+ catch {
15
+ throw new CliError("invalid_api_url", "Use an HTTPS API origin, or HTTP on literal loopback for local development.");
16
+ }
17
+ }
18
+ export async function fetchSkill(apiUrl) {
19
+ const url = agentGuideUrl(apiUrl);
20
+ const apiKey = process.env.SANBOX_API_KEY;
21
+ if (!apiKey?.startsWith("sbx_live_")) {
22
+ throw new CliError("api_key_required", "Set SANBOX_API_KEY to an organization API key in your agent environment, then run sanbox skill.");
23
+ }
24
+ let response;
25
+ try {
26
+ response = await fetch(url, {
27
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "text/markdown" },
28
+ redirect: "error",
29
+ signal: AbortSignal.timeout(15_000)
30
+ });
31
+ }
32
+ catch {
33
+ throw new CliError("skill_fetch_failed", "Could not fetch the guide. Check SANBOX_API_URL and connectivity. Redirects are not followed.");
34
+ }
35
+ if (!response.ok) {
36
+ await response.body?.cancel();
37
+ // Do not echo an untrusted response body or credential-bearing URL.
38
+ throw new CliError("skill_fetch_failed", response.status === 401 || response.status === 403
39
+ ? "The guide requires an active organization API key. Check SANBOX_API_KEY."
40
+ : `The guide could not be fetched (HTTP ${response.status}).`, { status: response.status });
41
+ }
42
+ if (response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "text/markdown") {
43
+ await response.body?.cancel();
44
+ throw new CliError("invalid_skill_response", "The API did not return a Markdown guide.");
45
+ }
46
+ try {
47
+ const reader = response.body.getReader();
48
+ const chunks = [];
49
+ let size = 0;
50
+ while (true) {
51
+ const { value, done } = await reader.read();
52
+ if (done)
53
+ break;
54
+ size += value.length;
55
+ if (size > 1_048_576) {
56
+ await reader.cancel();
57
+ throw new Error();
58
+ }
59
+ chunks.push(value);
60
+ }
61
+ const guide = Buffer.concat(chunks).toString("utf8");
62
+ if (!guide.trim())
63
+ throw new Error();
64
+ return guide;
65
+ }
66
+ catch {
67
+ throw new CliError("invalid_skill_response", "The guide was empty, incomplete, or exceeded 1 MiB. Try again.");
68
+ }
69
+ }
70
+ export async function writeSkill(cwd, guide) {
71
+ const directory = path.join(cwd, ".sanbox");
72
+ await fs.mkdir(directory, { recursive: true });
73
+ const temporary = path.join(directory, `.agent-${randomUUID()}.tmp`);
74
+ try {
75
+ await fs.writeFile(temporary, guide, { encoding: "utf8", mode: 0o600, flag: "wx" });
76
+ await fs.rename(temporary, path.join(directory, "agent.md"));
77
+ }
78
+ finally {
79
+ await fs.rm(temporary, { force: true });
80
+ }
81
+ }
82
+ export const skillBootstrap = (apiUrl) => `# Sanbox skill\n\nSet SANBOX_API_KEY in your agent environment or secret settings. Never put the key in chat or tracked files.\n\nUse SANBOX_API_URL=${agentGuideUrl(apiUrl).origin} and run \`sanbox skill\` to read the guide through the authenticated API, or \`sanbox skill --write\` to replace this file.\n`;
@@ -0,0 +1,119 @@
1
+ import { isIP } from "node:net";
2
+ import { SanboxApiError } from "./api.js";
3
+ import { CliError, commandAction } from "./errors.js";
4
+ export async function resolveSecretProxies(client, names) {
5
+ if (!names.length)
6
+ return [];
7
+ const { proxies } = await client.listSecretProxies();
8
+ const selected = names.map(name => {
9
+ const proxy = proxies.find(item => item.id === name || item.name === name);
10
+ if (!proxy)
11
+ throw new CliError("proxy_not_found", `Secret proxy ${name} was not found in this organization.`);
12
+ return proxy;
13
+ });
14
+ return [...new Map(selected.map(proxy => [proxy.id, proxy])).values()];
15
+ }
16
+ // Keep static coverage consistent with the console's hostNetworkAccess helper.
17
+ // IP/CIDR rules require runtime DNS, so they cannot prove hostname coverage here.
18
+ export function hostNetworkAccess(host, rules) {
19
+ const normalize = (value) => value.trim().toLowerCase().replace(/\.$/, "");
20
+ const target = normalize(host);
21
+ const covers = (pattern, candidate) => pattern === candidate || pattern.startsWith("*.") && candidate.endsWith(pattern.slice(1));
22
+ const destinations = rules
23
+ .filter(rule => rule.ports.some(range => range.from <= 443 && range.to >= 443))
24
+ .map(rule => normalize(rule.destination));
25
+ if (destinations.some(destination => covers(destination, target)) ||
26
+ destinations.includes("0.0.0.0/0") && destinations.includes("::/0"))
27
+ return "allowed";
28
+ const addressRule = (destination) => destination.includes("/") || Boolean(isIP(destination));
29
+ if (target.startsWith("*.") && destinations.some(destination => !addressRule(destination) && covers(target, destination))) {
30
+ return "partial";
31
+ }
32
+ return destinations.some(addressRule) ? "ip-dependent" : "missing";
33
+ }
34
+ function checkNetworkAccess(template, proxy, added = false) {
35
+ const effective = template.network_access?.effective_rules;
36
+ const rules = effective ?? template.default_network_policy?.rules ?? [];
37
+ const coverage = hostNetworkAccess(proxy.host, rules);
38
+ const status = !effective && coverage !== "allowed" ? "unknown" : coverage;
39
+ const messages = {
40
+ missing: `${proxy.host} is not allowed on HTTPS port 443.`,
41
+ partial: `Only some subdomains of ${proxy.host} are allowed on HTTPS port 443.`,
42
+ "ip-dependent": `HTTPS access to ${proxy.host} depends on runtime DNS matching the IP/CIDR rules.`,
43
+ unknown: `The API did not provide enough network policy information to check HTTPS access to ${proxy.host}.`
44
+ };
45
+ return {
46
+ proxy_id: proxy.id, host: proxy.host, status, added,
47
+ ...(status === "allowed" ? {} : {
48
+ warning: `${messages[status]} Review the template's network settings or grant again with --add-network-access.`
49
+ })
50
+ };
51
+ }
52
+ export function formatNetworkAccess(checks) {
53
+ return checks.map(check => check.warning
54
+ ? `Warning: ${check.warning}\n`
55
+ : `HTTPS access: ${check.host}:443${check.added ? " (network rule added)" : " (already allowed)"}\n`).join("");
56
+ }
57
+ /** Grants and network edits are separate API operations. Keep successful work recoverable. */
58
+ export async function grantTemplateProxies(client, templateId, proxies, addNetworkAccess) {
59
+ const granted = [];
60
+ let checks = [];
61
+ try {
62
+ let { template } = await client.getTemplate(templateId);
63
+ templateId = template.id;
64
+ checks = proxies.map(proxy => checkNetworkAccess(template, proxy));
65
+ if (addNetworkAccess && checks.some(check => check.status !== "allowed")) {
66
+ const policy = template.default_network_policy;
67
+ if (!policy || policy.default_action !== "deny" || !Array.isArray(policy.rules)) {
68
+ throw new CliError("network_policy_unavailable", "Cannot add network access without the template's existing custom rules.");
69
+ }
70
+ // Send only stored custom rules. Managed provider/harness rules remain server-owned.
71
+ const rules = policy.rules.map(({ destination, ports, description }) => ({
72
+ destination, ports, ...(description === undefined ? {} : { description })
73
+ }));
74
+ const addedHosts = new Set();
75
+ for (const check of checks) {
76
+ if (check.status === "allowed" || hostNetworkAccess(check.host, rules) === "allowed")
77
+ continue;
78
+ rules.push({ destination: check.host, ports: [{ from: 443, to: 443 }] });
79
+ addedHosts.add(check.host);
80
+ }
81
+ if (rules.length > 16) {
82
+ throw new CliError("network_rule_limit", "Adding proxy hosts would exceed the template's limit of 16 custom network rules.");
83
+ }
84
+ ({ template } = await client.updateTemplateNetworkPolicy(templateId, { default_action: "deny", rules }));
85
+ checks = proxies.map(proxy => checkNetworkAccess(template, proxy, addedHosts.has(proxy.host)));
86
+ }
87
+ const failed = [];
88
+ for (const proxy of proxies) {
89
+ try {
90
+ await client.grantTemplateSecretProxy(templateId, proxy.id, true);
91
+ granted.push(proxy.id);
92
+ }
93
+ catch (error) {
94
+ // Do not echo upstream response bodies, which may contain sensitive context.
95
+ failed.push(`${proxy.name}${error instanceof SanboxApiError ? ` (HTTP ${error.status}, ${error.code})` : ""}`);
96
+ }
97
+ }
98
+ if (failed.length)
99
+ throw new CliError("proxy_grant_failed", `Could not save access for: ${failed.join(", ")}.`);
100
+ return { template, template_id: templateId, granted_proxy_ids: granted, network_access: checks };
101
+ }
102
+ catch (error) {
103
+ const pending = proxies.filter(proxy => !granted.includes(proxy.id));
104
+ const reason = error instanceof CliError ? ` ${error.message}`
105
+ : error instanceof SanboxApiError ? ` API request failed (HTTP ${error.status}, ${error.code}).` : "";
106
+ const nextActions = pending.map(proxy => commandAction(["sanbox", "templates", "secret-proxies", "grant", templateId, proxy.id,
107
+ ...(addNetworkAccess ? ["--add-network-access"] : []), "--api-url", client.config.apiUrl, "--json"], `Finish setting up ${proxy.name} on the existing template.`));
108
+ const commands = nextActions.map(action => action.type === "command" ? ` ${action.argv.join(" ")}` : "").join("\n");
109
+ throw new CliError("proxy_setup_incomplete", `Secret Proxy setup is incomplete for template ${templateId}.${reason}\nRetry on this template:\n${commands}`, {
110
+ details: {
111
+ template_id: templateId,
112
+ granted_proxy_ids: granted,
113
+ pending_proxy_ids: pending.map(proxy => proxy.id),
114
+ network_access: checks
115
+ },
116
+ nextActions
117
+ });
118
+ }
119
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.0.14";
1
+ export const version = "0.0.16";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanlabs/sanbox-cli",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",