@sanlabs/sanbox-cli 0.0.1 → 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,15 +1,115 @@
1
1
  # Sanbox CLI
2
2
 
3
- Agent-facing CLI and local MCP server for creating Sanbox runs.
3
+ Run isolated Sanbox agent tasks from a terminal, CI job, or autonomous coding agent.
4
+
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
+
7
+ ## Install
4
8
 
5
9
  ```bash
6
- export SANBOX_API_URL=http://167.233.236.51
7
- export SANBOX_ORG=rheinfall-bank
10
+ npm install -g @sanlabs/sanbox-cli@latest
11
+ sanbox --version
12
+ ```
13
+
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.
15
+
16
+ ## Configure
17
+
18
+ ```bash
19
+ export SANBOX_API_URL=https://console.sanbox.cloud
8
20
  export SANBOX_API_KEY=sbx_live_...
21
+ sanbox orgs list --json
22
+ export SANBOX_ORG=<returned-org-slug>
23
+ export SANBOX_TEMPLATE=<template-id-or-slug>
24
+ ```
25
+
26
+ `SANBOX_API_URL` is optional for the hosted service. `SANBOX_TEMPLATE` can instead come from `--template` or `.sanbox/config.json` `default_template`.
27
+
28
+ An org admin configures provider credentials and templates in the console. Do not pass provider keys to the CLI or a runner.
29
+
30
+ ## Check Readiness
31
+
32
+ ```bash
33
+ sanbox auth check --json
34
+ sanbox context --json
35
+ sanbox templates list --json
36
+ sanbox templates validate "$SANBOX_TEMPLATE" --json
37
+ sanbox doctor --json
38
+ ```
39
+
40
+ Model IDs are provider-scoped. The CLI never guesses or silently substitutes an organization, provider, model, or template.
41
+
42
+ ## Run Idempotently
43
+
44
+ ```bash
45
+ sanbox run "Review this repo and write output/report.md" \
46
+ --input src/ \
47
+ --dry-run \
48
+ --json
49
+
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 \
55
+ --json
56
+ ```
57
+
58
+ Repeat `--input` for files, directories, or globs. The CLI excludes common secrets and applies `.sanboxignore`. `--include` is a deprecated compatibility alias.
59
+
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.
9
61
 
10
- sanbox auth check
11
- sanbox run --task "Inspect the auth flow" --include "app/**" --wait --json
12
- sanbox batch --tasks tasks.json --include "app/**" --max-parallel 5 --wait --json
62
+ ## Inspect And Recover
63
+
64
+ ```bash
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
69
+ ```
70
+
71
+ ## Retrieve Outputs
72
+
73
+ Tasks should write durable deliverables under `/workspace/output`.
74
+
75
+ ```bash
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
83
+ ```
84
+
85
+ Downloads are path-safe and return their byte counts and SHA-256 digests. Existing files are preserved unless `--overwrite` is explicit.
86
+
87
+ ## Continue A Run
88
+
89
+ ```bash
90
+ sanbox runs messages <run-id> --json
91
+ sanbox runs message <run-id> "Summarize the retained output" --wait --json
92
+ ```
93
+
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.
95
+
96
+ ## Batch Work
97
+
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
+ ]
13
103
  ```
14
104
 
15
- The package also exposes `sanbox-mcp`, a local stdio MCP server for tools such as Codex, Claude Code, Cursor, and Copilot.
105
+ ```bash
106
+ sanbox batch --tasks tasks.json --template "$SANBOX_TEMPLATE" --max-parallel 5 --wait --json
107
+ ```
108
+
109
+ Batch fan-out is client-side. Keep the CLI process alive until all tasks are submitted.
110
+
111
+ ## Machine Output
112
+
113
+ Use `--json` for request/response commands and `--jsonl` for streams. Envelopes have `schema_version`, `ok`, `command`, `context`, `data` or `error`, and `next_actions`.
114
+
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.
@@ -0,0 +1,72 @@
1
+ export const parseActivityView = (value) => {
2
+ const normalized = value.trim() || "activity";
3
+ if (normalized === "activity" || normalized === "logs" || normalized === "compact")
4
+ return normalized;
5
+ throw new Error("--view must be one of: activity, logs, compact.");
6
+ };
7
+ export const shouldRenderEvent = (event, view) => {
8
+ const isLog = event.kind === "log.stdout" || event.kind === "log.stderr";
9
+ if (view === "logs")
10
+ return isLog;
11
+ if (view === "compact")
12
+ return !isLog;
13
+ return true;
14
+ };
15
+ const elapsed = (createdAt, occurredAt) => {
16
+ const start = Date.parse(createdAt);
17
+ const end = Date.parse(occurredAt);
18
+ if (!Number.isFinite(start) || !Number.isFinite(end))
19
+ return "--:--";
20
+ const totalSeconds = Math.max(0, Math.floor((end - start) / 1000));
21
+ const hours = Math.floor(totalSeconds / 3600);
22
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
23
+ const seconds = totalSeconds % 60;
24
+ return hours > 0
25
+ ? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
26
+ : `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
27
+ };
28
+ const oneLine = (value) => value
29
+ .replace(/[\r\n\t]+/g, " ")
30
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
31
+ .trim();
32
+ export const formatActivityLine = (event, runCreatedAt) => {
33
+ const summary = oneLine(event.message);
34
+ const bounded = summary.length > 500 ? `${summary.slice(0, 497)}...` : summary;
35
+ return `${elapsed(runCreatedAt, event.created_at).padStart(8)} ${event.kind.padEnd(22)} ${bounded}`.trimEnd();
36
+ };
37
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
38
+ const safeEventData = (value) => {
39
+ if (Array.isArray(value))
40
+ return value.map(safeEventData);
41
+ if (!isRecord(value))
42
+ return value;
43
+ const output = {};
44
+ for (const [key, item] of Object.entries(value)) {
45
+ if (key === "workspace_id" || key === "workspace_path" || key === "host_path")
46
+ continue;
47
+ output[key] = safeEventData(item);
48
+ }
49
+ return output;
50
+ };
51
+ export const activityEnvelope = (event) => {
52
+ const normalized = event.payload.schema_version === 1 && isRecord(event.payload.source);
53
+ const occurredAt = normalized && typeof event.payload.occurred_at === "string"
54
+ ? event.payload.occurred_at
55
+ : event.created_at;
56
+ return {
57
+ schema_version: 1,
58
+ scope: "run",
59
+ run_id: event.run_id,
60
+ cursor: String(event.id),
61
+ kind: event.kind,
62
+ level: event.level,
63
+ summary: event.message,
64
+ source: normalized ? event.payload.source : { plane: "control_plane", trust: "control_plane" },
65
+ sequence: normalized && typeof event.payload.sequence === "number" ? event.payload.sequence : null,
66
+ correlation: normalized && isRecord(event.payload.correlation) ? event.payload.correlation : {},
67
+ data: safeEventData(normalized ? event.payload.data : event.payload),
68
+ occurred_at: occurredAt,
69
+ observed_at: event.created_at
70
+ };
71
+ };
72
+ export const formatActivityJsonl = (event) => JSON.stringify(activityEnvelope(event));
package/dist/api.js CHANGED
@@ -1,10 +1,39 @@
1
+ const errorRecord = (body) => {
2
+ if (!body || typeof body !== "object" || Array.isArray(body))
3
+ return null;
4
+ return body;
5
+ };
6
+ const apiErrorCode = (body) => {
7
+ const record = errorRecord(body);
8
+ if (!record)
9
+ return "api_error";
10
+ if (typeof record.code === "string" && record.code)
11
+ return record.code;
12
+ const nested = errorRecord(record.error);
13
+ return typeof nested?.code === "string" && nested.code ? nested.code : "api_error";
14
+ };
15
+ const apiErrorMessage = (body, fallback) => {
16
+ const record = errorRecord(body);
17
+ if (!record)
18
+ return fallback;
19
+ if (typeof record.error === "string" && record.error)
20
+ return record.error;
21
+ const nested = errorRecord(record.error);
22
+ if (typeof nested?.message === "string" && nested.message)
23
+ return nested.message;
24
+ if (typeof record.message === "string" && record.message)
25
+ return record.message;
26
+ return fallback;
27
+ };
1
28
  export class SanboxApiError extends Error {
2
29
  status;
3
30
  body;
31
+ code;
4
32
  constructor(message, status, body) {
5
33
  super(message);
6
34
  this.status = status;
7
35
  this.body = body;
36
+ this.code = apiErrorCode(body);
8
37
  }
9
38
  }
10
39
  export class SanboxClient {
@@ -24,25 +53,91 @@ export class SanboxClient {
24
53
  const text = await res.text();
25
54
  const body = text ? JSON.parse(text) : {};
26
55
  if (!res.ok) {
27
- const message = body && typeof body === "object" && "error" in body ? String(body.error) : res.statusText;
56
+ const message = apiErrorMessage(body, res.statusText);
28
57
  throw new SanboxApiError(message, res.status, body);
29
58
  }
30
59
  return body;
31
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
+ }
32
82
  me() {
33
83
  return this.request("/v1/me");
34
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
+ }
35
91
  createRun(body) {
36
92
  return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs`, {
37
93
  method: "POST",
38
94
  body: JSON.stringify(body)
39
95
  });
40
96
  }
41
- getRun(runId) {
42
- return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}`);
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
+ }
108
+ listModelProviders() {
109
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/model-providers`);
110
+ }
111
+ async getModelProvider(providerId) {
112
+ const payload = await this.listModelProviders();
113
+ const provider = payload.providers.find((item) => (item.provider_id || item.id) === providerId);
114
+ if (!provider)
115
+ throw new SanboxApiError(`Model provider ${providerId} was not found.`, 404, { code: "model_provider_not_found" });
116
+ return { provider };
117
+ }
118
+ listProviderModels(providerId) {
119
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/model-providers/${encodeURIComponent(providerId)}/models`);
43
120
  }
44
- listEvents(runId, afterEventId = 0) {
45
- return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/events?after_event_id=${afterEventId}`);
121
+ listTemplates() {
122
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/templates`);
123
+ }
124
+ getTemplate(templateId) {
125
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/templates/${encodeURIComponent(templateId)}`);
126
+ }
127
+ validateTemplate(templateId) {
128
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/templates/${encodeURIComponent(templateId)}/validate`);
129
+ }
130
+ createTemplate(body) {
131
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/templates`, {
132
+ method: "POST",
133
+ body: JSON.stringify(body)
134
+ });
135
+ }
136
+ getRun(runId, signal) {
137
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}`, { signal });
138
+ }
139
+ listEvents(runId, afterEventId = 0, limit = 200, signal) {
140
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/events?after_event_id=${afterEventId}&limit=${limit}`, { signal });
46
141
  }
47
142
  cancelRun(runId) {
48
143
  return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/cancel`, {
@@ -56,4 +151,13 @@ export class SanboxClient {
56
151
  body: JSON.stringify({ message, payload })
57
152
  });
58
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
+ }
59
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
+ };