@sanlabs/sanbox-cli 0.0.14 → 0.0.15
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 +55 -1
- package/dist/api.js +27 -0
- package/dist/args.js +5 -0
- package/dist/cli.js +60 -5
- package/dist/secretProxies.js +168 -0
- package/dist/templateSecretProxies.js +119 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,13 @@ 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.
|
|
18
|
+
Always use the latest published CLI. CLI 0.0.15 adds Secret Proxy management, template grants,
|
|
19
|
+
and run-scope inspection. Use `--path` or `--all-paths` to configure request scope and repeatable
|
|
20
|
+
`--secret-proxy` options to select proxies during template creation. Grants check HTTPS access;
|
|
21
|
+
`--add-network-access` explicitly adds missing host rules on port 443. Proxy details include
|
|
22
|
+
run-ID attachment, timestamps, and granted template IDs. If setup fails after template creation,
|
|
23
|
+
the CLI reports the existing template ID and recovery commands. See [Secret Proxies](#secret-proxies).
|
|
24
|
+
CLI 0.0.14 adds repeatable `--allowed-model` options when
|
|
19
25
|
creating OpenCode Computer templates and `sanbox run --model` to select a configured same-provider
|
|
20
26
|
model at startup. The selected model is saved on the run and reused on Resume; SDK per-prompt
|
|
21
27
|
switching does not change that saved startup default.
|
|
@@ -388,3 +394,51 @@ Batch fan-out is client-side. Keep the CLI process alive until all tasks are sub
|
|
|
388
394
|
Use `--json` for request/response commands and `--jsonl` for streams. Envelopes have `schema_version`, `ok`, `command`, `context`, `data` or `error`, and `next_actions`.
|
|
389
395
|
|
|
390
396
|
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.
|
|
397
|
+
|
|
398
|
+
## Secret Proxies
|
|
399
|
+
|
|
400
|
+
Create a proxy from a JSON configuration and read its credential from stdin. For example, save this as `proxy.json`:
|
|
401
|
+
|
|
402
|
+
```json
|
|
403
|
+
{
|
|
404
|
+
"name": "customer-api",
|
|
405
|
+
"host": "api.example.com",
|
|
406
|
+
"authentication": { "type": "bearer" },
|
|
407
|
+
"attach_run_id": true
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
```sh
|
|
412
|
+
sanbox secret-proxies create --file proxy.json --secret-stdin \
|
|
413
|
+
--path /users --path /projects < token-file
|
|
414
|
+
sanbox secret-proxies get customer-api
|
|
415
|
+
sanbox secret-proxies update customer-api --path /users --if-revision 1
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
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.
|
|
419
|
+
|
|
420
|
+
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.
|
|
421
|
+
|
|
422
|
+
Grant a proxy to an existing template:
|
|
423
|
+
|
|
424
|
+
```sh
|
|
425
|
+
sanbox templates secret-proxies grant TEMPLATE_ID customer-api
|
|
426
|
+
sanbox templates secret-proxies grant TEMPLATE_ID customer-api --add-network-access
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
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.
|
|
430
|
+
|
|
431
|
+
Select proxies while creating a template with repeatable `--secret-proxy`:
|
|
432
|
+
|
|
433
|
+
```sh
|
|
434
|
+
sanbox templates create --name "Customer support" \
|
|
435
|
+
--model-provider openai --model MODEL_ID \
|
|
436
|
+
--secret-proxy customer-api --secret-proxy billing-api \
|
|
437
|
+
--add-network-access
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
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.
|
|
441
|
+
|
|
442
|
+
`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`.
|
|
443
|
+
|
|
444
|
+
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,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { proxyCommand, formatProxyResult, secretProxiesHelp } from "./secretProxies.js";
|
|
3
|
+
import { formatNetworkAccess, grantTemplateProxies, resolveSecretProxies } from "./templateSecretProxies.js";
|
|
2
4
|
import fs from "node:fs/promises";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
import { formatActivityJsonl, formatActivityLine, parseActivityView, shouldRenderEvent } from "./activity.js";
|
|
@@ -30,6 +32,7 @@ Commands:
|
|
|
30
32
|
sanbox auth check [--json]
|
|
31
33
|
sanbox context [--json]
|
|
32
34
|
sanbox doctor [--json]
|
|
35
|
+
sanbox secret-proxies --help
|
|
33
36
|
sanbox model-providers list [--json]
|
|
34
37
|
sanbox model-providers get <provider-id> [--json]
|
|
35
38
|
sanbox model-providers models <provider-id> [--json]
|
|
@@ -40,7 +43,7 @@ Commands:
|
|
|
40
43
|
sanbox templates list [--json]
|
|
41
44
|
sanbox templates get <template-id> [--json]
|
|
42
45
|
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]
|
|
46
|
+
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
47
|
sanbox templates delete <template-id> --force [--json]
|
|
45
48
|
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
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]
|
|
@@ -170,10 +173,13 @@ Usage:
|
|
|
170
173
|
sanbox templates list [--json]
|
|
171
174
|
sanbox templates get <template-id> [--json]
|
|
172
175
|
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]
|
|
176
|
+
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
177
|
sanbox templates delete <template-id> --force [--json]
|
|
175
178
|
|
|
176
179
|
Template creation requires an exact provider id and that provider's exact model id.
|
|
180
|
+
Use repeatable --secret-proxy <name-or-id> to grant saved proxies to the new template.
|
|
181
|
+
Grants check HTTPS access. Add --add-network-access to explicitly allow their hosts on port 443.
|
|
182
|
+
If setup fails after creation, use the reported template id and grant commands to finish setup.
|
|
177
183
|
LiteLLM budgets are optional USD amounts and apply separately to each run.
|
|
178
184
|
OpenCode defaults to --mode task. Use --mode computer for a retained private OpenCode server;
|
|
179
185
|
per-run LiteLLM budgets are not available for that retained mode yet.
|
|
@@ -468,6 +474,14 @@ const positionalText = (command, startIndex) => command.slice(startIndex).join("
|
|
|
468
474
|
const runTask = (command, flags) => flagString(flags, "task") || positionalText(command, 1);
|
|
469
475
|
const commonFlags = ["api-url", "json", "help"];
|
|
470
476
|
const flagSets = {
|
|
477
|
+
"secret-proxies.list": commonFlags,
|
|
478
|
+
"secret-proxies.get": commonFlags,
|
|
479
|
+
"secret-proxies.create": [...commonFlags, "file", "secret-stdin", "path", "all-paths"],
|
|
480
|
+
"secret-proxies.update": [...commonFlags, "file", "if-revision", "path", "all-paths"],
|
|
481
|
+
"secret-proxies.rotate": [...commonFlags, "secret-stdin"],
|
|
482
|
+
"secret-proxies.delete": [...commonFlags, "yes"],
|
|
483
|
+
"templates.secret-proxies": [...commonFlags, "add-network-access"],
|
|
484
|
+
"runs.secret-proxies": commonFlags,
|
|
471
485
|
"auth.check": commonFlags,
|
|
472
486
|
context: [...commonFlags, "template"],
|
|
473
487
|
doctor: [...commonFlags, "template"],
|
|
@@ -484,6 +498,8 @@ const flagSets = {
|
|
|
484
498
|
"templates.delete": [...commonFlags, "force"],
|
|
485
499
|
"templates.create": [
|
|
486
500
|
...commonFlags,
|
|
501
|
+
"secret-proxy",
|
|
502
|
+
"add-network-access",
|
|
487
503
|
"name",
|
|
488
504
|
"model-provider",
|
|
489
505
|
"model",
|
|
@@ -549,7 +565,8 @@ const commandKey = (command) => {
|
|
|
549
565
|
return "runs.supabase";
|
|
550
566
|
if (command[0] === "opencode" && command[1] === "connections")
|
|
551
567
|
return "opencode.connections";
|
|
552
|
-
if (command[0] === "
|
|
568
|
+
if (command[0] === "secret-proxies" ||
|
|
569
|
+
command[0] === "model-providers" ||
|
|
553
570
|
command[0] === "anthropic-environments" ||
|
|
554
571
|
command[0] === "templates" ||
|
|
555
572
|
command[0] === "runs" ||
|
|
@@ -561,6 +578,8 @@ const commandKey = (command) => {
|
|
|
561
578
|
return command[0];
|
|
562
579
|
};
|
|
563
580
|
const requiredValueFlags = new Set([
|
|
581
|
+
"path", "secret-proxy",
|
|
582
|
+
"file", "if-revision",
|
|
564
583
|
"api-url", "template", "task", "input", "include", "external-run-id", "email-address",
|
|
565
584
|
"supabase-user-id", "return-url",
|
|
566
585
|
"telegram-bot-token", "telegram-allowed-user", "channel",
|
|
@@ -607,7 +626,7 @@ const validateFlags = (command, flags) => {
|
|
|
607
626
|
return;
|
|
608
627
|
}
|
|
609
628
|
const knownRoots = new Set([
|
|
610
|
-
"auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
|
|
629
|
+
"secret-proxies", "auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
|
|
611
630
|
"run", "batch", "runs", "opencode", "ssh", "ssh-proxy", "login", "logout", "init", "version"
|
|
612
631
|
]);
|
|
613
632
|
const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
|
|
@@ -622,6 +641,9 @@ const validatePositionals = (command, flags) => {
|
|
|
622
641
|
if (hasFlag(flags, "help"))
|
|
623
642
|
return;
|
|
624
643
|
const maximums = {
|
|
644
|
+
"secret-proxies.list": 2, "secret-proxies.get": 3, "secret-proxies.create": 2,
|
|
645
|
+
"secret-proxies.update": 3, "secret-proxies.rotate": 3, "secret-proxies.delete": 3,
|
|
646
|
+
"templates.secret-proxies": 5, "runs.secret-proxies": 3,
|
|
625
647
|
"auth.check": 2,
|
|
626
648
|
context: 1,
|
|
627
649
|
doctor: 1,
|
|
@@ -1273,6 +1295,13 @@ const commandTemplates = async (command, flags) => {
|
|
|
1273
1295
|
if (harness !== "hermes" && channels.length > 0) {
|
|
1274
1296
|
throw new CliError("hermes_channels_not_supported", "--channel requires --harness hermes.");
|
|
1275
1297
|
}
|
|
1298
|
+
const proxyNames = flagList(flags, "secret-proxy");
|
|
1299
|
+
const addNetworkAccess = hasFlag(flags, "add-network-access");
|
|
1300
|
+
if (addNetworkAccess && !proxyNames.length) {
|
|
1301
|
+
throw new CliError("secret_proxy_required", "--add-network-access requires at least one --secret-proxy.");
|
|
1302
|
+
}
|
|
1303
|
+
// Resolve every selection before creating anything, including name/id aliases.
|
|
1304
|
+
const selectedProxies = await resolveSecretProxies(client, proxyNames);
|
|
1276
1305
|
let payload;
|
|
1277
1306
|
try {
|
|
1278
1307
|
payload = await client.createTemplate({
|
|
@@ -1305,8 +1334,21 @@ const commandTemplates = async (command, flags) => {
|
|
|
1305
1334
|
catch (error) {
|
|
1306
1335
|
throw templateCreationError(error, client, modelProvider, model);
|
|
1307
1336
|
}
|
|
1337
|
+
let proxySetup;
|
|
1338
|
+
if (selectedProxies.length) {
|
|
1339
|
+
try {
|
|
1340
|
+
const { template, ...setup } = await grantTemplateProxies(client, payload.template.id, selectedProxies, addNetworkAccess);
|
|
1341
|
+
payload.template = template;
|
|
1342
|
+
proxySetup = setup;
|
|
1343
|
+
}
|
|
1344
|
+
catch (error) {
|
|
1345
|
+
if (error instanceof CliError)
|
|
1346
|
+
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 } });
|
|
1347
|
+
throw error;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1308
1350
|
if (hasFlag(flags, "json")) {
|
|
1309
|
-
printSuccess("templates.create", payload, jsonContext(client), [
|
|
1351
|
+
printSuccess("templates.create", { ...payload, ...(proxySetup ? { secret_proxies: proxySetup } : {}) }, jsonContext(client), [
|
|
1310
1352
|
commandAction(["sanbox", "templates", "validate", payload.template.id, "--json"], "Validate the new template before running it."),
|
|
1311
1353
|
commandAction([
|
|
1312
1354
|
"sanbox",
|
|
@@ -1328,6 +1370,8 @@ const commandTemplates = async (command, flags) => {
|
|
|
1328
1370
|
}
|
|
1329
1371
|
process.stdout.write(`created ${payload.template.id} provider=${payload.template.provider_id || modelProvider} model=${payload.template.model_id || model}` +
|
|
1330
1372
|
`${payload.template.llm_budget_usd ? ` budget=$${payload.template.llm_budget_usd}/run` : ""}\n`);
|
|
1373
|
+
if (proxySetup)
|
|
1374
|
+
process.stdout.write(`Granted proxies: ${selectedProxies.map(proxy => proxy.name).join(", ")}\n${formatNetworkAccess(proxySetup.network_access)}`);
|
|
1331
1375
|
return;
|
|
1332
1376
|
}
|
|
1333
1377
|
if (action === "delete") {
|
|
@@ -1846,6 +1890,8 @@ const helpFor = (command) => {
|
|
|
1846
1890
|
return runsSupabaseHelp;
|
|
1847
1891
|
if (command[0] === "doctor")
|
|
1848
1892
|
return doctorHelp;
|
|
1893
|
+
if (command[0] === "secret-proxies" || command[1] === "secret-proxies")
|
|
1894
|
+
return secretProxiesHelp;
|
|
1849
1895
|
if (command[0] === "model-providers")
|
|
1850
1896
|
return modelProvidersHelp;
|
|
1851
1897
|
if (command[0] === "anthropic-environments")
|
|
@@ -1920,6 +1966,15 @@ const main = async (command, flags) => {
|
|
|
1920
1966
|
return commandContext(flags);
|
|
1921
1967
|
if (command[0] === "doctor")
|
|
1922
1968
|
return commandDoctor(flags);
|
|
1969
|
+
if (command[0] === "secret-proxies" || command[1] === "secret-proxies") {
|
|
1970
|
+
const client = makeClient(flags);
|
|
1971
|
+
const payload = await proxyCommand(client, command, flags);
|
|
1972
|
+
if (hasFlag(flags, "json"))
|
|
1973
|
+
printSuccess(commandKey(command) || "secret-proxies", payload, jsonContext(client));
|
|
1974
|
+
else
|
|
1975
|
+
process.stdout.write(formatProxyResult(payload));
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1923
1978
|
if (command[0] === "model-providers")
|
|
1924
1979
|
return commandModelProviders(command, flags);
|
|
1925
1980
|
if (command[0] === "anthropic-environments") {
|
|
@@ -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
|
+
}
|
|
@@ -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.
|
|
1
|
+
export const version = "0.0.15";
|