@sanlabs/sanbox-cli 0.0.15 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # Sanbox CLI
2
2
 
3
+ Fetch the LLM guide with `sanbox skill` through `/v1/skill`, which validates `SANBOX_API_KEY` from the agent environment. `/agent.md` remains public for older clients and direct links; the new CLI does not fall back to that route. `sanbox skill --write` saves the current guide to `.sanbox/agent.md`. `sanbox init agent` remains a deprecated alias; `sanbox init` writes only a bootstrap pointer. Set `SANBOX_API_URL` to your console's API origin for staging or self-hosted installations. Remote origins require HTTPS; development HTTP is limited to literal loopback.
4
+
3
5
  Run isolated Sanbox agent tasks from a terminal, CI job, or autonomous coding agent.
4
6
 
5
7
  Sanbox packages a task and selected inputs, starts an isolated runner, streams events, and keeps the sandbox and outputs indefinitely.
6
8
 
7
- The full machine operating protocol is available at https://console.sanbox.cloud/agent.md and in [`web/public/agent.md`](../web/public/agent.md).
9
+ The full machine operating protocol is available at https://console.sanbox.cloud/agent.md and in [`app/docs/agent.md`](../app/docs/agent.md).
8
10
 
9
11
  ## Install
10
12
 
@@ -15,7 +17,13 @@ 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.15 adds Secret Proxy management, template grants,
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,
19
27
  and run-scope inspection. Use `--path` or `--all-paths` to configure request scope and repeatable
20
28
  `--secret-proxy` options to select proxies during template creation. Grants check HTTPS access;
21
29
  `--add-network-access` explicitly adds missing host rules on port 443. Proxy details include
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { proxyCommand, formatProxyResult, secretProxiesHelp } from "./secretProxies.js";
3
3
  import { formatNetworkAccess, grantTemplateProxies, resolveSecretProxies } from "./templateSecretProxies.js";
4
+ import { agentGuideUrl, fetchSkill, writeSkill, skillBootstrap } from "./skill.js";
4
5
  import fs from "node:fs/promises";
5
6
  import path from "node:path";
6
7
  import { formatActivityJsonl, formatActivityLine, parseActivityView, shouldRenderEvent } from "./activity.js";
@@ -67,7 +68,8 @@ Commands:
67
68
  sanbox opencode connections revoke <run-id> <connection-id> [--json]
68
69
  sanbox ssh <run-id>
69
70
  sanbox init [--force]
70
- sanbox init agent [--write]
71
+ sanbox skill [--write] [--json]
72
+ sanbox init agent [--write] (deprecated alias for skill)
71
73
  `;
72
74
  const sshHelp = `Sanbox SSH
73
75
 
@@ -194,111 +196,6 @@ Optional controls: --browser-max-steps, --browser-step-timeout-seconds,
194
196
  --browser-download-policy allow|deny, and --browser-additional-instructions.
195
197
  Deleting a template requires --force. Existing runs and their retained sandboxes are preserved.
196
198
  `;
197
- const agentInstructions = `# Operate Sanbox Autonomously
198
-
199
- Use the \`sanbox\` CLI for focused, isolated, long-running, risky, or parallel work. The canonical
200
- protocol is https://console.sanbox.cloud/agent.md.
201
-
202
- Always use the latest published CLI. Install and verify it at the start of every operating session:
203
- \`\`\`bash
204
- npm install -g @sanlabs/sanbox-cli@latest
205
- latest_cli_version="$(npm view @sanlabs/sanbox-cli version)"
206
- installed_cli_version="$(sanbox --version)"
207
- test "$installed_cli_version" = "$latest_cli_version"
208
- \`\`\`
209
-
210
- Human bootstrap:
211
- - A human/admin supplies an organization-scoped SANBOX_API_KEY and configures provider credentials plus a runnable template.
212
- - Never print, persist, prompt with, or upload API keys or provider credentials.
213
- - If the key or a runnable template is unavailable, stop with the exact human action required.
214
-
215
- Deterministic startup:
216
- \`\`\`bash
217
- sanbox --version
218
- sanbox auth check --json
219
- sanbox context --json
220
- sanbox templates list --json
221
- export SANBOX_TEMPLATE=<returned-template-id-or-slug>
222
- sanbox templates validate "$SANBOX_TEMPLATE" --json
223
- sanbox doctor --json
224
- \`\`\`
225
-
226
- Never guess opaque IDs. The CLI derives the organization from the API key and requires the key to
227
- resolve to exactly one organization. For task execution, select only a template with runnable: true,
228
- template_type: "runner", and runner_config.harness: "opencode" or "browser-use". Select Browser Use
229
- only for a one-shot web task whose target domains are already approved on the template. Never select
230
- a Hermes service template for a waited task because it is always-on. Select automatically only when exactly one task
231
- template qualifies; otherwise ask the user. Provider credentials and template administration are
232
- console-only.
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
-
241
- Use --json for request/response commands and --jsonl for streams. Parse the versioned envelope:
242
- schema_version, ok, command, context, data or error, and next_actions. Execute command actions as
243
- argv arrays, never shell strings. Exit 0 means command success, 1 local/API failure, 2 readiness or
244
- waited remote failure, and 130 detached while remote work continues.
245
-
246
- Preview and submit each logical task with a stable idempotency key:
247
- \`\`\`bash
248
- sanbox run "Investigate one focused task and write output/report.md" --input app/ --dry-run --json
249
- sanbox run "Investigate one focused task and write output/report.md" \\
250
- --template "$SANBOX_TEMPLATE" --external-run-id "<stable-project-task-id>" \\
251
- --input app/ --wait --json
252
- \`\`\`
253
-
254
- Reuse the same --external-run-id after ambiguous failures. Retry network errors, HTTP 429, HTTP 5xx,
255
- and workspace_busy with bounded backoff. Do not retry other 4xx errors unless next_actions directs
256
- recovery. A run is inactive when state is stopped; inspect latest_execution.outcome for the most
257
- recent bounded execution outcome.
258
-
259
- Recover and retrieve results:
260
- \`\`\`bash
261
- sanbox runs list --limit 50 --json
262
- sanbox runs get <run-id> --json
263
- sanbox runs events <run-id> --after-event-id <cursor> --json
264
- sanbox runs watch <run-id> --after-event-id <cursor> --jsonl
265
- sanbox runs artifacts <run-id> --json
266
- sanbox runs download <run-id> --output .sanbox/output/<run-id> --json
267
- \`\`\`
268
-
269
- Tasks must put durable files under /workspace/output. Downloads preserve relative paths and report
270
- byte counts plus SHA-256 digests; existing files require explicit --overwrite.
271
-
272
- While a sandbox is running, create one read-only capability URL for its entire root filesystem:
273
- \`\`\`bash
274
- sanbox runs share <run-id> --expires 1h --json
275
- sanbox runs shares <run-id> --json
276
- sanbox runs unshare <run-id> <access-point-id> --json
277
- \`\`\`
278
- The URL starts at /, supports directory JSON with ?format=json, and stops working when the sandbox
279
- stops, the link expires, or it is revoked. Treat the URL as a bearer secret.
280
-
281
- Manage the retained sandbox independently of its agent harness:
282
- \`\`\`bash
283
- sanbox runs get <run-id> --json
284
- sanbox runs resume <run-id> --wait --json
285
- sanbox runs share <run-id> --expires 1h --json
286
- sanbox runs stop <run-id> --wait --json
287
- sanbox runs delete <run-id> --yes --json
288
- \`\`\`
289
-
290
- Stop terminates compute and durably syncs the workspace without retaining RAM or VM state. Resume
291
- fresh-boots the pinned runtime artifact with that workspace; it does not restore process state or
292
- silently replay a finished task. Delete permanently removes the workspace after archiving usage.
293
-
294
- For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
295
- client alive until submission completes.
296
-
297
- Do not claim completion until state is stopped, latest_execution.outcome is completed, and required
298
- artifacts are downloaded and verified. Report run/external/template IDs, run state, execution outcome, workspace save time,
299
- artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
300
- .sanboxignore for project rules.
301
- `;
302
199
  const cwd = () => process.cwd();
303
200
  const makeClient = (flags) => new SanboxClient(readConfig(flags));
304
201
  const jsonContext = (client) => ({
@@ -552,6 +449,7 @@ const flagSets = {
552
449
  logout: ["api-url", "json", "help"],
553
450
  init: [...commonFlags, "force", "template"],
554
451
  "init.agent": [...commonFlags, "write"],
452
+ skill: ["api-url", "json", "help", "write"],
555
453
  version: ["json", "help"]
556
454
  };
557
455
  const commandKey = (command) => {
@@ -645,6 +543,7 @@ const validatePositionals = (command, flags) => {
645
543
  "secret-proxies.update": 3, "secret-proxies.rotate": 3, "secret-proxies.delete": 3,
646
544
  "templates.secret-proxies": 5, "runs.secret-proxies": 3,
647
545
  "auth.check": 2,
546
+ skill: 1,
648
547
  context: 1,
649
548
  doctor: 1,
650
549
  "model-providers.list": 2,
@@ -1826,33 +1725,31 @@ const commandLogout = async (flags) => {
1826
1725
  process.stdout.write(session ? "Signed out.\n" : "No saved Sanbox user session.\n");
1827
1726
  }
1828
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
+ };
1829
1743
  const commandInit = async (command, flags) => {
1830
1744
  const dir = path.join(cwd(), ".sanbox");
1831
- if (command[1] === "agent") {
1832
- if (hasFlag(flags, "write")) {
1833
- await fs.mkdir(dir, { recursive: true });
1834
- await fs.writeFile(path.join(dir, "agent.md"), agentInstructions, "utf8");
1835
- if (hasFlag(flags, "json")) {
1836
- printSuccess("init.agent", { path: ".sanbox/agent.md", status: "written" }, localJsonContext(flags));
1837
- }
1838
- else {
1839
- process.stdout.write(".sanbox/agent.md written\n");
1840
- }
1841
- return;
1842
- }
1843
- if (hasFlag(flags, "json")) {
1844
- printSuccess("init.agent", { instructions: agentInstructions }, localJsonContext(flags));
1845
- }
1846
- else {
1847
- process.stdout.write(agentInstructions);
1848
- }
1849
- return;
1850
- }
1745
+ if (command[1] === "agent")
1746
+ return commandSkill(flags, "init.agent");
1851
1747
  if (command[1])
1852
1748
  throw new Error("init supports no subcommand or `agent`.");
1853
1749
  const force = hasFlag(flags, "force");
1854
1750
  const localConfig = readLocalConfig();
1855
1751
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
1752
+ const bootstrap = skillBootstrap(apiUrl);
1856
1753
  const template = readTemplateSelection(flags, { required: false });
1857
1754
  const config = {
1858
1755
  api_url: apiUrl,
@@ -1869,7 +1766,7 @@ const commandInit = async (command, flags) => {
1869
1766
  await fs.mkdir(dir, { recursive: true });
1870
1767
  const results = [
1871
1768
  [".sanbox/config.json", await writeIfNeeded(path.join(dir, "config.json"), `${JSON.stringify(config, null, 2)}\n`, force)],
1872
- [".sanbox/agent.md", await writeIfNeeded(path.join(dir, "agent.md"), agentInstructions, force)],
1769
+ [".sanbox/agent.md", await writeIfNeeded(path.join(dir, "agent.md"), bootstrap, force)],
1873
1770
  [".sanboxignore", await writeIfNeeded(path.join(cwd(), ".sanboxignore"), sanboxIgnore, force)]
1874
1771
  ];
1875
1772
  if (hasFlag(flags, "json")) {
@@ -1998,17 +1895,29 @@ const main = async (command, flags) => {
1998
1895
  process.exitCode = exitCode;
1999
1896
  return;
2000
1897
  }
1898
+ if (command[0] === "skill")
1899
+ return commandSkill(flags);
2001
1900
  if (command[0] === "init")
2002
1901
  return commandInit(command, flags);
2003
1902
  throw new CliError("unknown_command", `Unknown command: ${command.join(" ")}`);
2004
1903
  };
2005
1904
  const parsed = parseArgs(process.argv.slice(2));
2006
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
+ }
2007
1916
  if (hasFlag(parsed.flags, "jsonl")) {
2008
- printJsonlError(commandId(parsed.command), error, localJsonContext(parsed.flags));
1917
+ printJsonlError(commandId(parsed.command), error, context);
2009
1918
  }
2010
1919
  else if (hasFlag(parsed.flags, "json")) {
2011
- printError(commandId(parsed.command), error, localJsonContext(parsed.flags));
1920
+ printError(commandId(parsed.command), error, context);
2012
1921
  }
2013
1922
  else {
2014
1923
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
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`;
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.0.15";
1
+ export const version = "0.0.16";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanlabs/sanbox-cli",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",