@sanlabs/sanbox-cli 0.0.3 → 0.0.4

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,136 +1,115 @@
1
1
  # Sanbox CLI
2
2
 
3
- Run Sanbox agent tasks from a terminal, CI job, or coding agent.
3
+ Run isolated Sanbox agent tasks from a terminal, CI job, or autonomous coding agent.
4
4
 
5
- Sanbox packages a task and selected inputs, starts an isolated runner, streams events, and keeps the outputs in a retained workspace.
5
+ The full machine operating protocol is available at https://console.sanbox.cloud/agent.md and in [`web/public/agent.md`](../web/public/agent.md).
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npm install -g @sanlabs/sanbox-cli
10
+ npm install -g @sanlabs/sanbox-cli@latest
11
+ sanbox --version
11
12
  ```
12
13
 
13
- ## Configure
14
+ CLI 0.0.4 adds strict argument validation, stable JSON envelopes, existing-resource discovery, resumable watches, verified artifact downloads, and correlated post-run follow-ups.
14
15
 
15
- Create an API key in the Sanbox console, then set:
16
+ ## Configure
16
17
 
17
18
  ```bash
18
19
  export SANBOX_API_URL=https://console.sanbox.cloud
19
- export SANBOX_ORG=<org-slug>
20
20
  export SANBOX_API_KEY=sbx_live_...
21
- export SANBOX_TEMPLATE=<template-id>
21
+ sanbox orgs list --json
22
+ export SANBOX_ORG=<returned-org-slug>
23
+ export SANBOX_TEMPLATE=<template-id-or-slug>
22
24
  ```
23
25
 
24
- `SANBOX_API_URL` defaults to `https://console.sanbox.cloud`. Set it only for self-hosted or local deployments.
25
- `SANBOX_TEMPLATE` is optional when every run uses `--template` or `.sanbox/config.json` contains an explicit `default_template`.
26
+ `SANBOX_API_URL` is optional for the hosted service. `SANBOX_TEMPLATE` can instead come from `--template` or `.sanbox/config.json` `default_template`.
26
27
 
27
- `SANBOX_API_KEY` authenticates to Sanbox. Model-provider credentials are separate, organization-scoped, and configured by an admin in the console. Do not pass provider keys to the CLI.
28
+ An org admin configures provider credentials and templates in the console. Do not pass provider keys to the CLI or a runner.
28
29
 
29
- ## Check Access
30
+ ## Check Readiness
30
31
 
31
32
  ```bash
32
- sanbox doctor
33
- sanbox auth check
33
+ sanbox auth check --json
34
34
  sanbox context --json
35
+ sanbox templates list --json
36
+ sanbox templates validate "$SANBOX_TEMPLATE" --json
37
+ sanbox doctor --json
35
38
  ```
36
39
 
37
- ## Choose A Model Provider And Template
38
-
39
- List model providers configured for the selected organization, then list the exact models exposed by one provider:
40
-
41
- ```bash
42
- sanbox model-providers list --json
43
- sanbox model-providers get <provider-id> --json
44
- sanbox model-providers models <provider-id> --json
45
- ```
46
-
47
- Model ids are scoped to their provider. If two providers expose the same model id, they remain separate choices.
40
+ Model IDs are provider-scoped. The CLI never guesses or silently substitutes an organization, provider, model, or template.
48
41
 
49
- List and inspect templates:
42
+ ## Run Idempotently
50
43
 
51
44
  ```bash
52
- sanbox templates list --json
53
- sanbox templates get <template-id> --json
54
- sanbox templates validate <template-id> --json
55
- ```
56
-
57
- Organization admins can create a template with an exact provider/model pair:
45
+ sanbox run "Review this repo and write output/report.md" \
46
+ --input src/ \
47
+ --dry-run \
48
+ --json
58
49
 
59
- ```bash
60
- sanbox templates create \
61
- --name "Code review" \
62
- --model-provider <provider-id> \
63
- --model <exact-model-id> \
50
+ sanbox run "Review this repo and write output/report.md" \
51
+ --template "$SANBOX_TEMPLATE" \
52
+ --external-run-id "<stable-project-task-id>" \
53
+ --input src/ \
54
+ --wait \
64
55
  --json
65
56
  ```
66
57
 
67
- The CLI does not recommend or automatically select a provider or model.
58
+ Repeat `--input` for files, directories, or globs. The CLI excludes common secrets and applies `.sanboxignore`. `--include` is a deprecated compatibility alias.
68
59
 
69
- ## Run a Task
60
+ Reuse the same external ID when retrying an ambiguous submission. To stream activity, replace `--wait --json` with `--jsonl`. Ctrl-C detaches without canceling unless `--cancel-on-interrupt` is supplied.
70
61
 
71
- ```bash
72
- sanbox run "Review this repo and write output/report.md" --template <template-id> --include "src/**" --watch
73
- ```
74
-
75
- Use repeatable `--include` flags to choose what is sent to the runner:
62
+ ## Inspect And Recover
76
63
 
77
64
  ```bash
78
- sanbox run "Fill the report template from these notes" \
79
- --template <template-id> \
80
- --include "templates/report.docx" \
81
- --include "docs/source/**" \
82
- --watch
65
+ sanbox runs list --limit 50 --json
66
+ sanbox runs get <run-id> --json
67
+ sanbox runs events <run-id> --after-event-id <cursor> --json
68
+ sanbox runs watch <run-id> --after-event-id <cursor> --jsonl
83
69
  ```
84
70
 
85
- Sanbox excludes `.git`, `node_modules`, build output, `.env` files, private keys, and common secret-like filenames by default. Add `.sanboxignore` for project-specific excludes.
86
-
87
- ## Watch Agent Activity
71
+ ## Retrieve Outputs
88
72
 
89
- Create a run and stream its activity until completion:
73
+ Tasks should write durable deliverables under `/workspace/output`.
90
74
 
91
75
  ```bash
92
- sanbox run "Fix the authentication tests" --template <template-id> --include "src/**" --watch
76
+ sanbox runs artifacts <run-id> --json
77
+ sanbox runs download <run-id> --output .sanbox/output/<run-id> --json
78
+ sanbox runs download <run-id> \
79
+ --output .sanbox/output/<run-id> \
80
+ --artifact report.md \
81
+ --overwrite \
82
+ --json
93
83
  ```
94
84
 
95
- Reattach to an existing run:
85
+ Downloads are path-safe and return their byte counts and SHA-256 digests. Existing files are preserved unless `--overwrite` is explicit.
96
86
 
97
- ```bash
98
- sanbox runs watch <run-id>
99
- ```
100
-
101
- Use JSONL for scripts and agents:
87
+ ## Continue A Run
102
88
 
103
89
  ```bash
104
- sanbox runs watch <run-id> --jsonl
90
+ sanbox runs messages <run-id> --json
91
+ sanbox runs message <run-id> "Summarize the retained output" --wait --json
105
92
  ```
106
93
 
107
- `--view logs` shows only stdout/stderr events, while `--view compact` hides them. Ctrl-C detaches without canceling the run. Use `--cancel-on-interrupt` only when the watcher and run should share a lifetime, such as a CI job.
108
-
109
- `--wait` remains available when only the final run result is needed.
110
-
111
- OpenCode runner images normalize session, turn, tool completion/failure, file-change, assistant-message, usage, and error activity. The unmodified provider stream is retained separately at `/workspace/logs/opencode.jsonl`; provider stderr is retained at `/workspace/logs/opencode.stderr.log`.
94
+ The JSON response includes the user `message`, submitted `chat_job`, matching terminal `followup_event`, and conversation messages. The waiter matches `payload.chat_job_id`, so another concurrent follow-up cannot complete the wrong command.
112
95
 
113
96
  ## Batch Work
114
97
 
115
- ```bash
116
- sanbox batch --tasks tasks.json --template <template-id> --include "src/**" --max-parallel 5 --wait --json
98
+ ```json
99
+ [
100
+ { "task": "Review API behavior and write output/api.md", "external_run_id": "review-api", "input": ["app/"] },
101
+ { "task": "Review CLI behavior and write output/cli.md", "external_run_id": "review-cli", "input": ["cli/"] }
102
+ ]
117
103
  ```
118
104
 
119
- ## Inspect a Run Bundle
120
-
121
105
  ```bash
122
- sanbox bundle preview --include "app/**"
123
- sanbox bundle create "Review this repo" --include "app/**" --out run.zip
124
- sanbox bundle inspect run.zip
106
+ sanbox batch --tasks tasks.json --template "$SANBOX_TEMPLATE" --max-parallel 5 --wait --json
125
107
  ```
126
108
 
127
- ## Use From Agents
128
-
129
- Use Sanbox for independent, long-running, risky, or parallelizable work. Tell the runner to write durable results under `/workspace/output`.
109
+ Batch fan-out is client-side. Keep the CLI process alive until all tasks are submitted.
130
110
 
131
- Use the CLI directly from coding agents that can run shell commands, including Codex and Claude Code.
111
+ ## Machine Output
132
112
 
133
- ## Links
113
+ Use `--json` for request/response commands and `--jsonl` for streams. Envelopes have `schema_version`, `ok`, `command`, `context`, `data` or `error`, and `next_actions`.
134
114
 
135
- - Console: https://console.sanbox.cloud
136
- - Agent instructions: https://console.sanbox.cloud/agent.md
115
+ 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.
package/dist/api.js CHANGED
@@ -58,15 +58,53 @@ export class SanboxClient {
58
58
  }
59
59
  return body;
60
60
  }
61
+ async rawRequest(path, init = {}) {
62
+ const res = await fetch(`${this.config.apiUrl}${path}`, {
63
+ ...init,
64
+ headers: {
65
+ Authorization: `Bearer ${this.config.apiKey}`,
66
+ ...(init.headers || {})
67
+ }
68
+ });
69
+ if (!res.ok) {
70
+ const text = await res.text();
71
+ let body = {};
72
+ try {
73
+ body = text ? JSON.parse(text) : {};
74
+ }
75
+ catch {
76
+ body = { error: text || res.statusText };
77
+ }
78
+ throw new SanboxApiError(apiErrorMessage(body, res.statusText), res.status, body);
79
+ }
80
+ return res;
81
+ }
61
82
  me() {
62
83
  return this.request("/v1/me");
63
84
  }
85
+ listOrganizations() {
86
+ return this.request("/v1/orgs");
87
+ }
88
+ listRuns(limit = 50) {
89
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs?limit=${limit}`);
90
+ }
64
91
  createRun(body) {
65
92
  return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs`, {
66
93
  method: "POST",
67
94
  body: JSON.stringify(body)
68
95
  });
69
96
  }
97
+ uploadInputCollection(input) {
98
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/input-collections`, {
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/vnd.sanbox.input+zip",
102
+ "X-Sanbox-Content-SHA256": input.sha256,
103
+ "X-Sanbox-File-Count": String(input.fileCount)
104
+ },
105
+ body: Uint8Array.from(input.buffer).buffer
106
+ });
107
+ }
70
108
  listModelProviders() {
71
109
  return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/model-providers`);
72
110
  }
@@ -113,4 +151,13 @@ export class SanboxClient {
113
151
  body: JSON.stringify({ message, payload })
114
152
  });
115
153
  }
154
+ listMessages(runId) {
155
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/messages`);
156
+ }
157
+ listArtifacts(runId) {
158
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/artifacts`);
159
+ }
160
+ downloadArtifact(runId, artifactPath) {
161
+ return this.rawRequest(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/artifacts?path=${encodeURIComponent(artifactPath)}`);
162
+ }
116
163
  }
package/dist/args.js CHANGED
@@ -1,4 +1,19 @@
1
- const multiFlags = new Set(["include"]);
1
+ const multiFlags = new Set(["input", "include", "artifact"]);
2
+ export const booleanFlags = new Set([
3
+ "help",
4
+ "version",
5
+ "json",
6
+ "web-access",
7
+ "dry-run",
8
+ "wait",
9
+ "watch",
10
+ "jsonl",
11
+ "cancel-on-interrupt",
12
+ "verbose",
13
+ "force",
14
+ "write",
15
+ "overwrite"
16
+ ]);
2
17
  export const parseArgs = (argv) => {
3
18
  const command = [];
4
19
  const flags = {};
@@ -12,10 +27,15 @@ export const parseArgs = (argv) => {
12
27
  const eqIndex = raw.indexOf("=");
13
28
  const key = eqIndex === -1 ? raw : raw.slice(0, eqIndex);
14
29
  const inlineValue = eqIndex === -1 ? undefined : raw.slice(eqIndex + 1);
15
- const value = inlineValue ?? (argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : true);
30
+ const value = inlineValue ?? (booleanFlags.has(key)
31
+ ? true
32
+ : argv[index + 1] && !argv[index + 1].startsWith("--")
33
+ ? argv[++index]
34
+ : true);
16
35
  if (multiFlags.has(key)) {
17
36
  const existing = flags[key];
18
- flags[key] = Array.isArray(existing) ? [...existing, String(value)] : [String(value)];
37
+ const serialized = value === true ? "" : String(value);
38
+ flags[key] = Array.isArray(existing) ? [...existing, serialized] : [serialized];
19
39
  }
20
40
  else {
21
41
  flags[key] = value;
@@ -31,10 +51,6 @@ export const flagString = (flags, key, fallback = "") => {
31
51
  return fallback;
32
52
  return String(value);
33
53
  };
34
- export const flagNumber = (flags, key, fallback) => {
35
- const value = Number(flagString(flags, key));
36
- return Number.isFinite(value) ? value : fallback;
37
- };
38
54
  export const flagList = (flags, key, fallback = []) => {
39
55
  const value = flags[key];
40
56
  if (Array.isArray(value))
@@ -0,0 +1,124 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createWriteStream } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { Readable, Transform } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ export class ArtifactExistsError extends Error {
8
+ artifactPath;
9
+ destination;
10
+ constructor(artifactPath, destination) {
11
+ super(`Refusing to replace existing artifact file ${destination}. Use --overwrite or a different --output directory.`);
12
+ this.artifactPath = artifactPath;
13
+ this.destination = destination;
14
+ }
15
+ }
16
+ export const artifactDestination = (outputDirectory, artifactPath) => {
17
+ if (!artifactPath ||
18
+ artifactPath.startsWith("/") ||
19
+ artifactPath.includes("\\") ||
20
+ /[\u0000-\u001f\u007f]/.test(artifactPath) ||
21
+ artifactPath.split("/").some((component) => !component || component === "." || component === "..")) {
22
+ throw new Error(`Unsafe artifact path returned by the server: ${JSON.stringify(artifactPath)}.`);
23
+ }
24
+ const root = path.resolve(outputDirectory);
25
+ const destination = path.resolve(root, ...artifactPath.split("/"));
26
+ if (!destination.startsWith(`${root}${path.sep}`)) {
27
+ throw new Error(`Artifact path escapes the output directory: ${JSON.stringify(artifactPath)}.`);
28
+ }
29
+ return destination;
30
+ };
31
+ const ensureArtifactParent = async (root, destination) => {
32
+ const parent = path.dirname(destination);
33
+ const relative = path.relative(root, parent);
34
+ let current = root;
35
+ for (const component of relative.split(path.sep).filter(Boolean)) {
36
+ current = path.join(current, component);
37
+ let info;
38
+ try {
39
+ info = await fs.lstat(current);
40
+ }
41
+ catch (error) {
42
+ if (error.code !== "ENOENT")
43
+ throw error;
44
+ await fs.mkdir(current);
45
+ info = await fs.lstat(current);
46
+ }
47
+ if (info.isSymbolicLink() || !info.isDirectory()) {
48
+ throw new Error(`Artifact parent is not a safe directory: ${current}.`);
49
+ }
50
+ }
51
+ };
52
+ export const downloadArtifacts = async (client, runId, artifacts, outputDirectory, overwrite) => {
53
+ const root = path.resolve(outputDirectory);
54
+ await fs.mkdir(root, { recursive: true });
55
+ const downloads = [];
56
+ for (const artifact of artifacts) {
57
+ const destination = artifactDestination(root, artifact.path);
58
+ await ensureArtifactParent(root, destination);
59
+ if (!overwrite) {
60
+ try {
61
+ await fs.lstat(destination);
62
+ throw new ArtifactExistsError(artifact.path, destination);
63
+ }
64
+ catch (error) {
65
+ if (error instanceof ArtifactExistsError)
66
+ throw error;
67
+ if (error.code !== "ENOENT")
68
+ throw error;
69
+ }
70
+ }
71
+ const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.${randomUUID()}.part`);
72
+ const response = await client.downloadArtifact(runId, artifact.path);
73
+ if (!response.body)
74
+ throw new Error(`Artifact ${artifact.path} returned an empty response body.`);
75
+ const digest = createHash("sha256");
76
+ let sizeBytes = 0;
77
+ const verifier = new Transform({
78
+ transform(chunk, _encoding, callback) {
79
+ sizeBytes += chunk.byteLength;
80
+ digest.update(chunk);
81
+ callback(null, chunk);
82
+ }
83
+ });
84
+ try {
85
+ await pipeline(Readable.fromWeb(response.body), verifier, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
86
+ const expectedLength = response.headers.get("content-length");
87
+ if (expectedLength && Number(expectedLength) !== sizeBytes) {
88
+ throw new Error(`Artifact ${artifact.path} length mismatch: expected ${expectedLength}, received ${sizeBytes}.`);
89
+ }
90
+ if (artifact.size_bytes !== sizeBytes) {
91
+ throw new Error(`Artifact ${artifact.path} length mismatch: listed ${artifact.size_bytes}, received ${sizeBytes}.`);
92
+ }
93
+ const sha256 = digest.digest("hex");
94
+ const expectedDigest = response.headers.get("x-content-sha256");
95
+ if (!expectedDigest || !/^[a-f0-9]{64}$/i.test(expectedDigest)) {
96
+ throw new Error(`Artifact ${artifact.path} response is missing a valid SHA-256 digest.`);
97
+ }
98
+ if (expectedDigest.toLowerCase() !== sha256) {
99
+ throw new Error(`Artifact ${artifact.path} SHA-256 mismatch.`);
100
+ }
101
+ if (overwrite) {
102
+ await fs.rename(temporary, destination);
103
+ }
104
+ else {
105
+ await fs.link(temporary, destination);
106
+ await fs.rm(temporary);
107
+ }
108
+ downloads.push({
109
+ path: artifact.path,
110
+ local_path: destination,
111
+ size_bytes: sizeBytes,
112
+ sha256
113
+ });
114
+ }
115
+ catch (error) {
116
+ await fs.rm(temporary, { force: true }).catch(() => { });
117
+ if (error.code === "EEXIST") {
118
+ throw new ArtifactExistsError(artifact.path, destination);
119
+ }
120
+ throw error;
121
+ }
122
+ }
123
+ return downloads;
124
+ };