@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 +64 -2
- package/dist/api.js +27 -0
- package/dist/args.js +5 -0
- package/dist/cli.js +98 -134
- package/dist/secretProxies.js +168 -0
- package/dist/skill.js +82 -0
- package/dist/templateSecretProxies.js +119 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
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 [`
|
|
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.
|
|
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
|
|
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] === "
|
|
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
|
-
|
|
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"),
|
|
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,
|
|
1917
|
+
printJsonlError(commandId(parsed.command), error, context);
|
|
1954
1918
|
}
|
|
1955
1919
|
else if (hasFlag(parsed.flags, "json")) {
|
|
1956
|
-
printError(commandId(parsed.command), error,
|
|
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.
|
|
1
|
+
export const version = "0.0.16";
|