@sanlabs/sanbox-cli 0.0.13 → 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 +82 -2
- package/dist/api.js +27 -0
- package/dist/args.js +6 -0
- package/dist/cli.js +108 -14
- package/dist/runs.js +1 -0
- 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,17 @@ 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
|
|
25
|
+
creating OpenCode Computer templates and `sanbox run --model` to select a configured same-provider
|
|
26
|
+
model at startup. The selected model is saved on the run and reused on Resume; SDK per-prompt
|
|
27
|
+
switching does not change that saved startup default.
|
|
28
|
+
CLI 0.0.13 allows service templates to start without an
|
|
19
29
|
initial task, including OpenCode Computers that are ready for later SDK steering, and supports
|
|
20
30
|
Custom OpenAI Proxy providers when creating templates, including Browser Use templates.
|
|
21
31
|
CLI 0.0.12 adopted the Stop, Resume, and Delete lifecycle,
|
|
@@ -99,9 +109,31 @@ sanbox templates create \
|
|
|
99
109
|
--harness opencode \
|
|
100
110
|
--mode computer \
|
|
101
111
|
--model-provider openai \
|
|
102
|
-
--model '<model-id>'
|
|
112
|
+
--model '<default-model-id>' \
|
|
113
|
+
--allowed-model '<additional-model-id>'
|
|
103
114
|
```
|
|
104
115
|
|
|
116
|
+
Repeat `--allowed-model` to make more models from the same provider available for native OpenCode
|
|
117
|
+
SDK prompt switching. The default `--model` is included automatically. At template creation, Sanbox
|
|
118
|
+
checks that each ID is in the organization's saved provider catalog; it does not infer model
|
|
119
|
+
compatibility or availability. Later catalog changes do not block Computer startup or resume, even
|
|
120
|
+
if the default model disappears. Configured IDs remain registered, and model errors surface when
|
|
121
|
+
a prompt uses them.
|
|
122
|
+
|
|
123
|
+
Choose a different startup model when creating an OpenCode Computer run:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
sanbox run --template <computer-template-id> --model '<additional-model-id>' --json
|
|
127
|
+
sanbox run "Inspect the repository" --template <computer-template-id> --model '<additional-model-id>' --json
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`sanbox run --model` accepts an exact ID from the template's configured models (`allowed_model_ids`),
|
|
131
|
+
using the template's provider. Omit it to use the template default. The selection is saved on the run;
|
|
132
|
+
`sanbox runs resume <run-id>` reuses it without another `--model` flag. Neither run creation nor SDK
|
|
133
|
+
per-prompt switching changes the template default, and SDK switches do not update the run's saved
|
|
134
|
+
startup model. This override is supported only for OpenCode Computer, not task or Hermes templates.
|
|
135
|
+
An `external_run_id` retry with a different explicit model returns `idempotency_conflict`.
|
|
136
|
+
|
|
105
137
|
Existing templates keep their current names, IDs, slugs, and task behavior. Browser Use is for one-shot
|
|
106
138
|
web tasks whose target domains are already approved. Hermes service templates are always-on. A Hermes template
|
|
107
139
|
declares which optional channels its runs may activate:
|
|
@@ -362,3 +394,51 @@ Batch fan-out is client-side. Keep the CLI process alive until all tasks are sub
|
|
|
362
394
|
Use `--json` for request/response commands and `--jsonl` for streams. Envelopes have `schema_version`, `ok`, `command`, `context`, `data` or `error`, and `next_actions`.
|
|
363
395
|
|
|
364
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,12 +1,18 @@
|
|
|
1
1
|
const multiFlags = new Set([
|
|
2
|
+
"path",
|
|
3
|
+
"secret-proxy",
|
|
2
4
|
"input",
|
|
3
5
|
"include",
|
|
4
6
|
"artifact",
|
|
5
7
|
"channel",
|
|
8
|
+
"allowed-model",
|
|
6
9
|
"telegram-allowed-user",
|
|
7
10
|
"browser-domain"
|
|
8
11
|
]);
|
|
9
12
|
export const booleanFlags = new Set([
|
|
13
|
+
"all-paths",
|
|
14
|
+
"add-network-access",
|
|
15
|
+
"secret-stdin",
|
|
10
16
|
"help",
|
|
11
17
|
"version",
|
|
12
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,11 +43,11 @@ 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] [--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
|
-
sanbox run "task" --template <template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
|
|
46
|
-
sanbox run --task "..." --template <template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
|
|
47
|
-
sanbox run --template <service-template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--json]
|
|
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]
|
|
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]
|
|
50
|
+
sanbox run --template <service-template-id> [--model <model-id>] [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--json]
|
|
48
51
|
sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
|
|
49
52
|
sanbox runs list [--limit 50] [--json]
|
|
50
53
|
sanbox runs get <run-id> [--json]
|
|
@@ -95,11 +98,14 @@ Usage:
|
|
|
95
98
|
sanbox run "Review these files" --template <template-id> --input report.pdf --input data/ --wait
|
|
96
99
|
sanbox run --task "Review these files" --template <template-id> --input report.pdf --wait --json
|
|
97
100
|
sanbox run --template <service-template-id> --json
|
|
101
|
+
sanbox run --template <opencode-computer-template-id> --model <model-id> --json
|
|
102
|
+
sanbox run "Inspect the repository" --template <opencode-computer-template-id> --model <model-id> --json
|
|
98
103
|
|
|
99
104
|
Options:
|
|
100
105
|
--task <instruction> Required for task templates; optional for service templates.
|
|
101
106
|
--input <path> File, directory, or glob to upload. Repeatable.
|
|
102
107
|
--template <id> Template id or slug. Required unless SANBOX_TEMPLATE or project config sets it.
|
|
108
|
+
--model <id> Startup model from the OpenCode Computer template's configured models.
|
|
103
109
|
--external-run-id <id> Idempotency key for retries.
|
|
104
110
|
--supabase-user-id <id> Supabase Auth user UUID expected during OAuth consent.
|
|
105
111
|
--email-address <address> Optional for Hermes computers; must use a listed AgentMail domain.
|
|
@@ -113,6 +119,10 @@ Options:
|
|
|
113
119
|
--after-event-id <id> Resume after an event cursor. Default: 0.
|
|
114
120
|
--stop-on-interrupt Request Stop when Ctrl-C is pressed.
|
|
115
121
|
--json Print JSON.
|
|
122
|
+
|
|
123
|
+
--model is supported only for OpenCode Computer, with or without an initial task.
|
|
124
|
+
Omitting --model uses the template's default. The provider stays fixed to the template.
|
|
125
|
+
The chosen startup model is saved on the run and reused on resume; SDK per-prompt switches do not change it.
|
|
116
126
|
`;
|
|
117
127
|
const runsSupabaseHelp = `Sanbox Supabase run authorization
|
|
118
128
|
|
|
@@ -163,13 +173,18 @@ Usage:
|
|
|
163
173
|
sanbox templates list [--json]
|
|
164
174
|
sanbox templates get <template-id> [--json]
|
|
165
175
|
sanbox templates validate <template-id> [--json]
|
|
166
|
-
sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--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]
|
|
167
177
|
sanbox templates delete <template-id> --force [--json]
|
|
168
178
|
|
|
169
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.
|
|
170
183
|
LiteLLM budgets are optional USD amounts and apply separately to each run.
|
|
171
184
|
OpenCode defaults to --mode task. Use --mode computer for a retained private OpenCode server;
|
|
172
185
|
per-run LiteLLM budgets are not available for that retained mode yet.
|
|
186
|
+
Use repeatable --allowed-model with OpenCode Computer to make additional models from the selected
|
|
187
|
+
provider available for SDK prompt switching. --model remains the default and is included automatically.
|
|
173
188
|
Hermes templates are always-on computers. Use repeatable --channel email|telegram to select which
|
|
174
189
|
channels runs may activate. Email and Telegram credentials are supplied only when creating a run.
|
|
175
190
|
Browser Use templates run local headless Chromium inside a one-shot sandbox. They require OpenAI
|
|
@@ -216,6 +231,13 @@ a Hermes service template for a waited task because it is always-on. Select auto
|
|
|
216
231
|
template qualifies; otherwise ask the user. Provider credentials and template administration are
|
|
217
232
|
console-only.
|
|
218
233
|
|
|
234
|
+
For an explicitly requested OpenCode Computer, use a service template with harness opencode and
|
|
235
|
+
execution_mode computer. The task is optional; omit --wait. Use sanbox run --model <model-id> to
|
|
236
|
+
select an exact ID from the template's allowed_model_ids, keeping its provider unchanged. Omit
|
|
237
|
+
--model to use the template default. The startup model is saved on the run and reused on Resume
|
|
238
|
+
without re-specifying --model. SDK per-prompt switching changes neither the template default nor
|
|
239
|
+
the run's saved startup model. This flag is not supported for task or Hermes templates.
|
|
240
|
+
|
|
219
241
|
Use --json for request/response commands and --jsonl for streams. Parse the versioned envelope:
|
|
220
242
|
schema_version, ok, command, context, data or error, and next_actions. Execute command actions as
|
|
221
243
|
argv arrays, never shell strings. Exit 0 means command success, 1 local/API failure, 2 readiness or
|
|
@@ -316,7 +338,19 @@ const providerReadinessCodes = new Set([
|
|
|
316
338
|
"model_not_found_for_provider",
|
|
317
339
|
"template_provider_missing"
|
|
318
340
|
]);
|
|
319
|
-
const
|
|
341
|
+
const runModelSelectionCodes = new Set([
|
|
342
|
+
"invalid_run_model",
|
|
343
|
+
"run_model_not_configured",
|
|
344
|
+
"run_model_override_not_supported"
|
|
345
|
+
]);
|
|
346
|
+
const runCreationError = (error, client, template, modelId) => {
|
|
347
|
+
if (error instanceof SanboxApiError && runModelSelectionCodes.has(error.code)) {
|
|
348
|
+
return new CliError(error.code, error.message, {
|
|
349
|
+
status: error.status,
|
|
350
|
+
details: { template_id: template.id, model_id: modelId },
|
|
351
|
+
nextActions: [error.code === "run_model_override_not_supported" ? commandAction(["sanbox", "run", "--help"], "Remove --model and retry. Startup model overrides require an OpenCode Computer template.") : commandAction(["sanbox", "templates", "get", template.id, "--json"], "Inspect the template's configured models. Omit --model to use its default.")]
|
|
352
|
+
});
|
|
353
|
+
}
|
|
320
354
|
if (!(error instanceof SanboxApiError) || !runReadinessCodes.has(error.code))
|
|
321
355
|
return error;
|
|
322
356
|
const nextActions = [
|
|
@@ -348,7 +382,7 @@ const templateCreationError = (error, client, providerId, modelId) => {
|
|
|
348
382
|
return new CliError(error.code, error.message, {
|
|
349
383
|
status: error.status,
|
|
350
384
|
details: { provider_id: providerId, model_id: modelId },
|
|
351
|
-
nextActions: [commandAction(["sanbox", "model-providers", "models", providerId, "--json"], "List exact
|
|
385
|
+
nextActions: [commandAction(["sanbox", "model-providers", "models", providerId, "--json"], "List exact model IDs in the selected provider's catalog.")]
|
|
352
386
|
});
|
|
353
387
|
}
|
|
354
388
|
if (error.code === "template_name_conflict") {
|
|
@@ -440,6 +474,14 @@ const positionalText = (command, startIndex) => command.slice(startIndex).join("
|
|
|
440
474
|
const runTask = (command, flags) => flagString(flags, "task") || positionalText(command, 1);
|
|
441
475
|
const commonFlags = ["api-url", "json", "help"];
|
|
442
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,
|
|
443
485
|
"auth.check": commonFlags,
|
|
444
486
|
context: [...commonFlags, "template"],
|
|
445
487
|
doctor: [...commonFlags, "template"],
|
|
@@ -456,11 +498,14 @@ const flagSets = {
|
|
|
456
498
|
"templates.delete": [...commonFlags, "force"],
|
|
457
499
|
"templates.create": [
|
|
458
500
|
...commonFlags,
|
|
501
|
+
"secret-proxy",
|
|
502
|
+
"add-network-access",
|
|
459
503
|
"name",
|
|
460
504
|
"model-provider",
|
|
461
505
|
"model",
|
|
462
506
|
"harness",
|
|
463
507
|
"mode",
|
|
508
|
+
"allowed-model",
|
|
464
509
|
"llm-budget-usd",
|
|
465
510
|
"web-access",
|
|
466
511
|
"channel",
|
|
@@ -473,7 +518,7 @@ const flagSets = {
|
|
|
473
518
|
"browser-additional-instructions"
|
|
474
519
|
],
|
|
475
520
|
run: [
|
|
476
|
-
...commonFlags, "task", "input", "template", "external-run-id", "email-address",
|
|
521
|
+
...commonFlags, "task", "input", "template", "model", "external-run-id", "email-address",
|
|
477
522
|
"supabase-user-id",
|
|
478
523
|
"telegram-bot-token", "telegram-allowed-user",
|
|
479
524
|
"dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "stop-on-interrupt",
|
|
@@ -520,7 +565,8 @@ const commandKey = (command) => {
|
|
|
520
565
|
return "runs.supabase";
|
|
521
566
|
if (command[0] === "opencode" && command[1] === "connections")
|
|
522
567
|
return "opencode.connections";
|
|
523
|
-
if (command[0] === "
|
|
568
|
+
if (command[0] === "secret-proxies" ||
|
|
569
|
+
command[0] === "model-providers" ||
|
|
524
570
|
command[0] === "anthropic-environments" ||
|
|
525
571
|
command[0] === "templates" ||
|
|
526
572
|
command[0] === "runs" ||
|
|
@@ -532,12 +578,14 @@ const commandKey = (command) => {
|
|
|
532
578
|
return command[0];
|
|
533
579
|
};
|
|
534
580
|
const requiredValueFlags = new Set([
|
|
581
|
+
"path", "secret-proxy",
|
|
582
|
+
"file", "if-revision",
|
|
535
583
|
"api-url", "template", "task", "input", "include", "external-run-id", "email-address",
|
|
536
584
|
"supabase-user-id", "return-url",
|
|
537
585
|
"telegram-bot-token", "telegram-allowed-user", "channel",
|
|
538
586
|
"tasks", "max-parallel", "batch-id", "poll-interval-ms",
|
|
539
587
|
"event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
|
|
540
|
-
"model-provider", "model", "harness", "mode", "llm-budget-usd",
|
|
588
|
+
"model-provider", "model", "harness", "mode", "allowed-model", "llm-budget-usd",
|
|
541
589
|
"browser-domain", "browser-max-steps", "browser-step-timeout-seconds",
|
|
542
590
|
"browser-vision-mode", "browser-viewport", "browser-download-policy",
|
|
543
591
|
"browser-additional-instructions", "expires", "output", "artifact"
|
|
@@ -578,7 +626,7 @@ const validateFlags = (command, flags) => {
|
|
|
578
626
|
return;
|
|
579
627
|
}
|
|
580
628
|
const knownRoots = new Set([
|
|
581
|
-
"auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
|
|
629
|
+
"secret-proxies", "auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
|
|
582
630
|
"run", "batch", "runs", "opencode", "ssh", "ssh-proxy", "login", "logout", "init", "version"
|
|
583
631
|
]);
|
|
584
632
|
const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
|
|
@@ -593,6 +641,9 @@ const validatePositionals = (command, flags) => {
|
|
|
593
641
|
if (hasFlag(flags, "help"))
|
|
594
642
|
return;
|
|
595
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,
|
|
596
647
|
"auth.check": 2,
|
|
597
648
|
context: 1,
|
|
598
649
|
doctor: 1,
|
|
@@ -729,6 +780,7 @@ const commandRun = async (command, flags) => {
|
|
|
729
780
|
throw new CliError("conflicting_arguments", "Use either --task or a positional task, not both.");
|
|
730
781
|
}
|
|
731
782
|
const task = runTask(command, flags);
|
|
783
|
+
const modelId = flags.model === undefined ? undefined : flagString(flags, "model").trim();
|
|
732
784
|
if (hasFlag(flags, "wait") && wantsWatch(flags)) {
|
|
733
785
|
throw new Error("--wait cannot be combined with --watch or --jsonl.");
|
|
734
786
|
}
|
|
@@ -773,6 +825,7 @@ const commandRun = async (command, flags) => {
|
|
|
773
825
|
payload = await createRun(client, {
|
|
774
826
|
cwd: cwd(),
|
|
775
827
|
instruction: task || undefined,
|
|
828
|
+
modelId,
|
|
776
829
|
inputs: flagList(flags, "input"),
|
|
777
830
|
externalRunId: flagString(flags, "external-run-id") || undefined,
|
|
778
831
|
supabaseUserId: supabaseUserId || undefined,
|
|
@@ -783,7 +836,7 @@ const commandRun = async (command, flags) => {
|
|
|
783
836
|
});
|
|
784
837
|
}
|
|
785
838
|
catch (error) {
|
|
786
|
-
throw runCreationError(error, client, template);
|
|
839
|
+
throw runCreationError(error, client, template, modelId);
|
|
787
840
|
}
|
|
788
841
|
if (wantsWatch(flags)) {
|
|
789
842
|
const watched = await watchRunWithOutput(client, payload.run.id, flags, payload);
|
|
@@ -972,7 +1025,7 @@ const commandModelProviders = async (command, flags) => {
|
|
|
972
1025
|
for (const model of payload.models) {
|
|
973
1026
|
const modelId = String(model.model_id || model.id || "");
|
|
974
1027
|
const displayName = String(model.display_name || model.name || "");
|
|
975
|
-
process.stdout.write(`${modelId}${displayName && displayName !== modelId ? `\t${displayName}` : ""}
|
|
1028
|
+
process.stdout.write(`${modelId}${displayName && displayName !== modelId ? `\t${displayName}` : ""}\n`);
|
|
976
1029
|
}
|
|
977
1030
|
return;
|
|
978
1031
|
}
|
|
@@ -1184,6 +1237,13 @@ const commandTemplates = async (command, flags) => {
|
|
|
1184
1237
|
if (harness !== "opencode" && modeFlag) {
|
|
1185
1238
|
throw new CliError("opencode_mode_not_supported", "templates create --mode requires --harness opencode.");
|
|
1186
1239
|
}
|
|
1240
|
+
const additionalAllowedModels = [...new Set(flagList(flags, "allowed-model").map((value) => value.trim()).filter(Boolean))];
|
|
1241
|
+
if (additionalAllowedModels.length > 0 && (harness !== "opencode" || mode !== "computer")) {
|
|
1242
|
+
throw new CliError("opencode_allowed_models_not_supported", "templates create --allowed-model requires an OpenCode Computer template.");
|
|
1243
|
+
}
|
|
1244
|
+
const allowedModelIds = additionalAllowedModels.length > 0
|
|
1245
|
+
? [...new Set([model, ...additionalAllowedModels])]
|
|
1246
|
+
: undefined;
|
|
1187
1247
|
const budgetRaw = flagString(flags, "llm-budget-usd");
|
|
1188
1248
|
const parsedBudgetUsd = budgetRaw ? Number(budgetRaw) : undefined;
|
|
1189
1249
|
const llmBudgetUsd = parsedBudgetUsd === undefined
|
|
@@ -1235,12 +1295,20 @@ const commandTemplates = async (command, flags) => {
|
|
|
1235
1295
|
if (harness !== "hermes" && channels.length > 0) {
|
|
1236
1296
|
throw new CliError("hermes_channels_not_supported", "--channel requires --harness hermes.");
|
|
1237
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);
|
|
1238
1305
|
let payload;
|
|
1239
1306
|
try {
|
|
1240
1307
|
payload = await client.createTemplate({
|
|
1241
1308
|
name,
|
|
1242
1309
|
provider_id: modelProvider,
|
|
1243
1310
|
model_id: model,
|
|
1311
|
+
...(allowedModelIds ? { allowed_model_ids: allowedModelIds } : {}),
|
|
1244
1312
|
...(harness === "opencode" && modeFlag ? { mode: mode } : {}),
|
|
1245
1313
|
...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd }),
|
|
1246
1314
|
...(harness === "browser-use" ? {
|
|
@@ -1266,8 +1334,21 @@ const commandTemplates = async (command, flags) => {
|
|
|
1266
1334
|
catch (error) {
|
|
1267
1335
|
throw templateCreationError(error, client, modelProvider, model);
|
|
1268
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
|
+
}
|
|
1269
1350
|
if (hasFlag(flags, "json")) {
|
|
1270
|
-
printSuccess("templates.create", payload, jsonContext(client), [
|
|
1351
|
+
printSuccess("templates.create", { ...payload, ...(proxySetup ? { secret_proxies: proxySetup } : {}) }, jsonContext(client), [
|
|
1271
1352
|
commandAction(["sanbox", "templates", "validate", payload.template.id, "--json"], "Validate the new template before running it."),
|
|
1272
1353
|
commandAction([
|
|
1273
1354
|
"sanbox",
|
|
@@ -1289,6 +1370,8 @@ const commandTemplates = async (command, flags) => {
|
|
|
1289
1370
|
}
|
|
1290
1371
|
process.stdout.write(`created ${payload.template.id} provider=${payload.template.provider_id || modelProvider} model=${payload.template.model_id || model}` +
|
|
1291
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)}`);
|
|
1292
1375
|
return;
|
|
1293
1376
|
}
|
|
1294
1377
|
if (action === "delete") {
|
|
@@ -1807,6 +1890,8 @@ const helpFor = (command) => {
|
|
|
1807
1890
|
return runsSupabaseHelp;
|
|
1808
1891
|
if (command[0] === "doctor")
|
|
1809
1892
|
return doctorHelp;
|
|
1893
|
+
if (command[0] === "secret-proxies" || command[1] === "secret-proxies")
|
|
1894
|
+
return secretProxiesHelp;
|
|
1810
1895
|
if (command[0] === "model-providers")
|
|
1811
1896
|
return modelProvidersHelp;
|
|
1812
1897
|
if (command[0] === "anthropic-environments")
|
|
@@ -1881,6 +1966,15 @@ const main = async (command, flags) => {
|
|
|
1881
1966
|
return commandContext(flags);
|
|
1882
1967
|
if (command[0] === "doctor")
|
|
1883
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
|
+
}
|
|
1884
1978
|
if (command[0] === "model-providers")
|
|
1885
1979
|
return commandModelProviders(command, flags);
|
|
1886
1980
|
if (command[0] === "anthropic-environments") {
|
package/dist/runs.js
CHANGED
|
@@ -52,6 +52,7 @@ export const createRun = async (client, options) => {
|
|
|
52
52
|
return client.createRun({
|
|
53
53
|
external_run_id: options.externalRunId,
|
|
54
54
|
workload_id: options.templateId,
|
|
55
|
+
...(options.modelId !== undefined ? { model_id: options.modelId } : {}),
|
|
55
56
|
...(options.instruction ? { instruction: options.instruction } : {}),
|
|
56
57
|
...(options.supabaseUserId ? { supabase_user_id: options.supabaseUserId } : {}),
|
|
57
58
|
...(options.emailAddress ? { email_address: options.emailAddress } : {}),
|
|
@@ -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";
|