@sanlabs/sanbox-cli 0.0.1

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 ADDED
@@ -0,0 +1,15 @@
1
+ # Sanbox CLI
2
+
3
+ Agent-facing CLI and local MCP server for creating Sanbox runs.
4
+
5
+ ```bash
6
+ export SANBOX_API_URL=http://167.233.236.51
7
+ export SANBOX_ORG=rheinfall-bank
8
+ export SANBOX_API_KEY=sbx_live_...
9
+
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
13
+ ```
14
+
15
+ The package also exposes `sanbox-mcp`, a local stdio MCP server for tools such as Codex, Claude Code, Cursor, and Copilot.
package/dist/api.js ADDED
@@ -0,0 +1,59 @@
1
+ export class SanboxApiError extends Error {
2
+ status;
3
+ body;
4
+ constructor(message, status, body) {
5
+ super(message);
6
+ this.status = status;
7
+ this.body = body;
8
+ }
9
+ }
10
+ export class SanboxClient {
11
+ config;
12
+ constructor(config) {
13
+ this.config = config;
14
+ }
15
+ async request(path, init = {}) {
16
+ const res = await fetch(`${this.config.apiUrl}${path}`, {
17
+ ...init,
18
+ headers: {
19
+ Authorization: `Bearer ${this.config.apiKey}`,
20
+ "Content-Type": "application/json",
21
+ ...(init.headers || {})
22
+ }
23
+ });
24
+ const text = await res.text();
25
+ const body = text ? JSON.parse(text) : {};
26
+ if (!res.ok) {
27
+ const message = body && typeof body === "object" && "error" in body ? String(body.error) : res.statusText;
28
+ throw new SanboxApiError(message, res.status, body);
29
+ }
30
+ return body;
31
+ }
32
+ me() {
33
+ return this.request("/v1/me");
34
+ }
35
+ createRun(body) {
36
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs`, {
37
+ method: "POST",
38
+ body: JSON.stringify(body)
39
+ });
40
+ }
41
+ getRun(runId) {
42
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}`);
43
+ }
44
+ listEvents(runId, afterEventId = 0) {
45
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/events?after_event_id=${afterEventId}`);
46
+ }
47
+ cancelRun(runId) {
48
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/cancel`, {
49
+ method: "POST",
50
+ body: "{}"
51
+ });
52
+ }
53
+ sendMessage(runId, message, payload = {}) {
54
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/messages`, {
55
+ method: "POST",
56
+ body: JSON.stringify({ message, payload })
57
+ });
58
+ }
59
+ }
package/dist/args.js ADDED
@@ -0,0 +1,46 @@
1
+ const multiFlags = new Set(["include"]);
2
+ export const parseArgs = (argv) => {
3
+ const command = [];
4
+ const flags = {};
5
+ for (let index = 0; index < argv.length; index += 1) {
6
+ const arg = argv[index];
7
+ if (!arg.startsWith("--")) {
8
+ command.push(arg);
9
+ continue;
10
+ }
11
+ const raw = arg.slice(2);
12
+ const eqIndex = raw.indexOf("=");
13
+ const key = eqIndex === -1 ? raw : raw.slice(0, eqIndex);
14
+ const inlineValue = eqIndex === -1 ? undefined : raw.slice(eqIndex + 1);
15
+ const value = inlineValue ?? (argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : true);
16
+ if (multiFlags.has(key)) {
17
+ const existing = flags[key];
18
+ flags[key] = Array.isArray(existing) ? [...existing, String(value)] : [String(value)];
19
+ }
20
+ else {
21
+ flags[key] = value;
22
+ }
23
+ }
24
+ return { command, flags };
25
+ };
26
+ export const flagString = (flags, key, fallback = "") => {
27
+ const value = flags[key];
28
+ if (Array.isArray(value))
29
+ return value[0] || fallback;
30
+ if (value === undefined || value === true || value === false)
31
+ return fallback;
32
+ return String(value);
33
+ };
34
+ export const flagNumber = (flags, key, fallback) => {
35
+ const value = Number(flagString(flags, key));
36
+ return Number.isFinite(value) ? value : fallback;
37
+ };
38
+ export const flagList = (flags, key, fallback = []) => {
39
+ const value = flags[key];
40
+ if (Array.isArray(value))
41
+ return value;
42
+ if (typeof value === "string")
43
+ return [value];
44
+ return fallback;
45
+ };
46
+ export const hasFlag = (flags, key) => Boolean(flags[key]);
package/dist/cli.js ADDED
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { flagList, flagNumber, flagString, hasFlag, parseArgs } from "./args.js";
5
+ import { SanboxClient } from "./api.js";
6
+ import { defaultWorkloadId, readConfig } from "./config.js";
7
+ import { printJson, printRun } from "./output.js";
8
+ import { createRun, isTerminalRun, readTasks, runPool, stableBatchId, waitForRun } from "./runs.js";
9
+ import { version } from "./version.js";
10
+ const help = `Sanbox CLI
11
+
12
+ Environment:
13
+ SANBOX_API_URL Sanbox API base URL
14
+ SANBOX_ORG Organization slug
15
+ SANBOX_API_KEY Org API key
16
+
17
+ Commands:
18
+ sanbox auth check [--json]
19
+ sanbox run --task "..." [--include "app/**"] [--wait] [--json]
20
+ sanbox run --dossier ./task.zip [--wait] [--json]
21
+ sanbox batch --tasks tasks.json [--include "app/**"] [--max-parallel 5] [--wait] [--json]
22
+ sanbox runs get <run-id> [--json]
23
+ sanbox runs events <run-id> [--after-event-id 0] [--json]
24
+ sanbox runs cancel <run-id> [--json]
25
+ sanbox runs message <run-id> --message "..." [--json]
26
+ sanbox init agent [--write]
27
+ `;
28
+ const agentInstructions = `# Sanbox Agent Integration
29
+
30
+ Use Sanbox when a task is independent, long-running, risky to run locally, or can be split into parallel subtasks.
31
+
32
+ Environment expected by the CLI:
33
+ - SANBOX_API_URL
34
+ - SANBOX_ORG
35
+ - SANBOX_API_KEY
36
+
37
+ Useful commands:
38
+ \`\`\`bash
39
+ sanbox auth check
40
+ sanbox run --task "Investigate one focused task" --include "app/**" --wait --json
41
+ sanbox batch --tasks .sanbox/tasks.json --include "app/**" --max-parallel 5 --wait --json
42
+ sanbox runs get <run-id> --json
43
+ sanbox runs events <run-id> --json
44
+ sanbox runs message <run-id> --message "Summarize the retained output" --json
45
+ \`\`\`
46
+
47
+ Do not put secrets in dossiers. The CLI excludes common env and secret-like filenames by default.
48
+ `;
49
+ const cwd = () => process.cwd();
50
+ const makeClient = (flags) => new SanboxClient(readConfig(flags));
51
+ const commandRun = async (flags) => {
52
+ const client = makeClient(flags);
53
+ const task = flagString(flags, "task");
54
+ const dossierPath = flagString(flags, "dossier");
55
+ if (!task && !dossierPath)
56
+ throw new Error("--task or --dossier is required.");
57
+ let payload = await createRun(client, {
58
+ cwd: cwd(),
59
+ cliVersion: version,
60
+ task,
61
+ dossierPath,
62
+ include: flagList(flags, "include"),
63
+ externalRunId: flagString(flags, "external-run-id") || undefined,
64
+ workloadId: flagString(flags, "workload-id", defaultWorkloadId),
65
+ retentionTtlSeconds: flagNumber(flags, "retention-ttl-seconds", 86400)
66
+ });
67
+ if (hasFlag(flags, "wait")) {
68
+ payload = await waitForRun(client, payload.run.id, {
69
+ pollIntervalMs: flagNumber(flags, "poll-interval-ms", 2000),
70
+ timeoutSeconds: flagNumber(flags, "timeout-seconds", 1800)
71
+ });
72
+ }
73
+ if (hasFlag(flags, "json"))
74
+ printJson(payload);
75
+ else
76
+ printRun(payload);
77
+ if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && payload.run.status !== "completed")
78
+ process.exitCode = 2;
79
+ };
80
+ const commandBatch = async (flags) => {
81
+ const tasksPath = flagString(flags, "tasks");
82
+ if (!tasksPath)
83
+ throw new Error("--tasks is required.");
84
+ const client = makeClient(flags);
85
+ const tasks = await readTasks(tasksPath);
86
+ const batchId = flagString(flags, "batch-id") || await stableBatchId(tasksPath);
87
+ const include = flagList(flags, "include");
88
+ const maxParallel = flagNumber(flags, "max-parallel", 5);
89
+ const wait = hasFlag(flags, "wait");
90
+ const results = await runPool(tasks, maxParallel, async (task, index) => {
91
+ let payload = await createRun(client, {
92
+ cwd: cwd(),
93
+ cliVersion: version,
94
+ task: task.task,
95
+ include: task.include || include,
96
+ externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
97
+ workloadId: flagString(flags, "workload-id", defaultWorkloadId),
98
+ retentionTtlSeconds: flagNumber(flags, "retention-ttl-seconds", 86400)
99
+ });
100
+ if (wait) {
101
+ payload = await waitForRun(client, payload.run.id, {
102
+ pollIntervalMs: flagNumber(flags, "poll-interval-ms", 2000),
103
+ timeoutSeconds: flagNumber(flags, "timeout-seconds", 1800)
104
+ });
105
+ }
106
+ return payload;
107
+ });
108
+ const output = { batch_id: batchId, runs: results.map((result) => result.run), results };
109
+ if (hasFlag(flags, "json"))
110
+ printJson(output);
111
+ else
112
+ results.forEach(printRun);
113
+ if (wait && results.some((result) => result.run.status !== "completed"))
114
+ process.exitCode = 2;
115
+ };
116
+ const commandAuthCheck = async (flags) => {
117
+ const client = makeClient(flags);
118
+ const me = await client.me();
119
+ const org = client.config.org;
120
+ const organizations = Array.isArray(me.organizations)
121
+ ? me.organizations
122
+ : [];
123
+ const selected = organizations.find((item) => item.slug === org) || null;
124
+ const output = { ok: Boolean(selected), org, selected, me };
125
+ if (hasFlag(flags, "json"))
126
+ printJson(output);
127
+ else
128
+ process.stdout.write(selected ? `ok org=${org}\n` : `authenticated, but org ${org} is not visible\n`);
129
+ if (!selected)
130
+ process.exitCode = 2;
131
+ };
132
+ const commandRuns = async (command, flags) => {
133
+ const client = makeClient(flags);
134
+ const action = command[1];
135
+ const runId = command[2];
136
+ if (!action || !runId)
137
+ throw new Error("runs command requires an action and run id.");
138
+ if (action === "get") {
139
+ const payload = await client.getRun(runId);
140
+ if (hasFlag(flags, "json"))
141
+ printJson(payload);
142
+ else
143
+ printRun(payload);
144
+ return;
145
+ }
146
+ if (action === "events") {
147
+ const payload = await client.listEvents(runId, flagNumber(flags, "after-event-id", 0));
148
+ if (hasFlag(flags, "json"))
149
+ printJson(payload);
150
+ else
151
+ payload.events.forEach((event) => process.stdout.write(`${event.id} ${event.level} ${event.kind} ${event.message}\n`));
152
+ return;
153
+ }
154
+ if (action === "cancel") {
155
+ const payload = await client.cancelRun(runId);
156
+ if (hasFlag(flags, "json"))
157
+ printJson(payload);
158
+ else
159
+ printRun(payload);
160
+ return;
161
+ }
162
+ if (action === "message") {
163
+ const message = flagString(flags, "message");
164
+ if (!message)
165
+ throw new Error("--message is required.");
166
+ const payload = await client.sendMessage(runId, message);
167
+ if (hasFlag(flags, "json"))
168
+ printJson(payload);
169
+ else
170
+ printRun(payload);
171
+ return;
172
+ }
173
+ throw new Error(`Unknown runs action: ${action}`);
174
+ };
175
+ const commandInit = async (command, flags) => {
176
+ if (command[1] !== "agent")
177
+ throw new Error("Only `sanbox init agent` is supported.");
178
+ if (hasFlag(flags, "write")) {
179
+ const dir = path.join(cwd(), ".sanbox");
180
+ await fs.mkdir(dir, { recursive: true });
181
+ await fs.writeFile(path.join(dir, "agent.md"), agentInstructions, "utf8");
182
+ process.stdout.write(".sanbox/agent.md written\n");
183
+ return;
184
+ }
185
+ process.stdout.write(agentInstructions);
186
+ };
187
+ const main = async () => {
188
+ const { command, flags } = parseArgs(process.argv.slice(2));
189
+ if (command.length === 0 || hasFlag(flags, "help")) {
190
+ process.stdout.write(help);
191
+ return;
192
+ }
193
+ if (command[0] === "auth" && command[1] === "check")
194
+ return commandAuthCheck(flags);
195
+ if (command[0] === "run")
196
+ return commandRun(flags);
197
+ if (command[0] === "batch")
198
+ return commandBatch(flags);
199
+ if (command[0] === "runs")
200
+ return commandRuns(command, flags);
201
+ if (command[0] === "init")
202
+ return commandInit(command, flags);
203
+ throw new Error(`Unknown command: ${command.join(" ")}`);
204
+ };
205
+ main().catch((error) => {
206
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
207
+ process.exit(1);
208
+ });
package/dist/config.js ADDED
@@ -0,0 +1,12 @@
1
+ export const defaultApiUrl = "http://127.0.0.1:8788";
2
+ export const defaultWorkloadId = "opencode-runner";
3
+ export const readConfig = (flags = {}) => {
4
+ const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || defaultApiUrl).replace(/\/+$/, "");
5
+ const org = String(flags.org || process.env.SANBOX_ORG || "").trim();
6
+ const apiKey = process.env.SANBOX_API_KEY || "";
7
+ if (!org)
8
+ throw new Error("SANBOX_ORG or --org is required.");
9
+ if (!apiKey)
10
+ throw new Error("SANBOX_API_KEY is required.");
11
+ return { apiUrl, org, apiKey };
12
+ };
@@ -0,0 +1,108 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import fg from "fast-glob";
5
+ import ignore from "ignore";
6
+ import yazl from "yazl";
7
+ const defaultIgnorePatterns = [
8
+ ".git/**",
9
+ "**/.git/**",
10
+ "node_modules/**",
11
+ "**/node_modules/**",
12
+ "dist/**",
13
+ "**/dist/**",
14
+ "build/**",
15
+ "**/build/**",
16
+ "coverage/**",
17
+ "**/coverage/**",
18
+ ".next/**",
19
+ "**/.next/**",
20
+ ".turbo/**",
21
+ "**/.turbo/**",
22
+ ".env",
23
+ ".env.*",
24
+ "**/.env",
25
+ "**/.env.*",
26
+ "**/*secret*",
27
+ "**/*Secret*",
28
+ "**/*token*",
29
+ "**/*Token*",
30
+ "**/*credential*",
31
+ "**/*Credential*",
32
+ "**/*.pem",
33
+ "**/*.key",
34
+ "**/id_rsa",
35
+ "**/id_ed25519"
36
+ ];
37
+ const normalizeInclude = (patterns) => patterns.length === 0 ? ["**/*"] : patterns.map((pattern) => (pattern === "." ? "**/*" : pattern));
38
+ const readSanboxIgnore = async (cwd) => {
39
+ try {
40
+ const raw = await fs.readFile(path.join(cwd, ".sanboxignore"), "utf8");
41
+ return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
42
+ }
43
+ catch (error) {
44
+ if (error.code === "ENOENT")
45
+ return [];
46
+ throw error;
47
+ }
48
+ };
49
+ const zipToBuffer = async (zip) => new Promise((resolve, reject) => {
50
+ const chunks = [];
51
+ zip.outputStream.on("data", (chunk) => chunks.push(chunk));
52
+ zip.outputStream.on("error", reject);
53
+ zip.outputStream.on("end", () => resolve(Buffer.concat(chunks)));
54
+ zip.end();
55
+ });
56
+ export const sha256 = (buffer) => crypto.createHash("sha256").update(buffer).digest("hex");
57
+ export const buildTaskDossier = async (input) => {
58
+ const patterns = normalizeInclude(input.include);
59
+ const ig = ignore().add([...defaultIgnorePatterns, ...(await readSanboxIgnore(input.cwd))]);
60
+ const candidates = await fg(patterns, {
61
+ cwd: input.cwd,
62
+ dot: true,
63
+ onlyFiles: true,
64
+ followSymbolicLinks: false,
65
+ unique: true
66
+ });
67
+ const files = candidates
68
+ .map((item) => item.replaceAll(path.sep, "/"))
69
+ .filter((item) => !item.startsWith("../") && !path.isAbsolute(item))
70
+ .filter((item) => !ig.ignores(item))
71
+ .sort();
72
+ const zip = new yazl.ZipFile();
73
+ const fileMetadata = [];
74
+ const runbook = [
75
+ "# Sanbox Task",
76
+ "",
77
+ "Follow these instructions exactly.",
78
+ "",
79
+ input.task.trim(),
80
+ "",
81
+ "Write durable results under `/workspace/output`.",
82
+ "Use files under `/workspace/dossier/input/repo` as the provided source context."
83
+ ].join("\n");
84
+ zip.addBuffer(Buffer.from(runbook, "utf8"), "RUNBOOK.md");
85
+ for (const rel of files) {
86
+ const abs = path.join(input.cwd, rel);
87
+ const stat = await fs.stat(abs);
88
+ if (!stat.isFile())
89
+ continue;
90
+ const content = await fs.readFile(abs);
91
+ fileMetadata.push({ path: rel, size: stat.size, sha256: sha256(content) });
92
+ zip.addBuffer(content, `input/repo/${rel}`);
93
+ }
94
+ const manifest = {
95
+ source: "sanbox-cli",
96
+ cli_version: input.cliVersion,
97
+ created_at: new Date().toISOString(),
98
+ task: input.task,
99
+ files: fileMetadata
100
+ };
101
+ zip.addBuffer(Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"), "manifest.json");
102
+ const buffer = await zipToBuffer(zip);
103
+ return { buffer, sha256: sha256(buffer), files: fileMetadata };
104
+ };
105
+ export const readDossierFile = async (dossierPath) => {
106
+ const buffer = await fs.readFile(dossierPath);
107
+ return { buffer, sha256: sha256(buffer), files: [] };
108
+ };
package/dist/mcp.js ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import * as z from "zod/v4";
5
+ import { SanboxClient } from "./api.js";
6
+ import { defaultWorkloadId, readConfig } from "./config.js";
7
+ import { createRun, readTasks, runPool, stableBatchId, waitForRun } from "./runs.js";
8
+ import { version } from "./version.js";
9
+ const textAndJson = (summary, structuredContent) => ({
10
+ content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(structuredContent, null, 2)}` }],
11
+ structuredContent
12
+ });
13
+ const client = () => new SanboxClient(readConfig());
14
+ const server = new McpServer({
15
+ name: "sanbox",
16
+ version
17
+ });
18
+ server.registerTool("create_sandbox_run", {
19
+ title: "Create Sanbox Run",
20
+ description: "Create one Sanbox run from a task or existing dossier ZIP.",
21
+ inputSchema: {
22
+ task: z.string().optional().describe("Task instructions. Required unless dossier_path is provided."),
23
+ dossier_path: z.string().optional().describe("Path to an existing dossier ZIP."),
24
+ include: z.array(z.string()).optional().describe("Repo file globs to include when task is provided."),
25
+ external_run_id: z.string().optional().describe("Optional idempotency key."),
26
+ workload_id: z.string().optional().default(defaultWorkloadId),
27
+ wait: z.boolean().optional().default(false)
28
+ }
29
+ }, async (args) => {
30
+ if (!args.task && !args.dossier_path)
31
+ throw new Error("task or dossier_path is required.");
32
+ const api = client();
33
+ let payload = await createRun(api, {
34
+ cwd: process.cwd(),
35
+ cliVersion: version,
36
+ task: args.task,
37
+ dossierPath: args.dossier_path,
38
+ include: args.include || [],
39
+ externalRunId: args.external_run_id,
40
+ workloadId: args.workload_id || defaultWorkloadId
41
+ });
42
+ if (args.wait)
43
+ payload = await waitForRun(api, payload.run.id);
44
+ return textAndJson(`Run ${payload.run.id} is ${payload.run.status}.`, payload);
45
+ });
46
+ server.registerTool("create_sandbox_runs", {
47
+ title: "Create Multiple Sanbox Runs",
48
+ description: "Create independent Sanbox runs for parallel task delegation.",
49
+ inputSchema: {
50
+ tasks: z.array(z.object({
51
+ task: z.string(),
52
+ external_run_id: z.string().optional(),
53
+ include: z.array(z.string()).optional()
54
+ })).optional().describe("Inline tasks to run."),
55
+ tasks_path: z.string().optional().describe("Path to a JSON task array."),
56
+ include: z.array(z.string()).optional().describe("Default repo file globs for generated dossiers."),
57
+ batch_id: z.string().optional().describe("Optional stable batch id for idempotency."),
58
+ max_parallel: z.number().int().min(1).max(20).optional().default(5),
59
+ wait: z.boolean().optional().default(false)
60
+ }
61
+ }, async (args) => {
62
+ const tasks = args.tasks_path ? await readTasks(args.tasks_path) : args.tasks;
63
+ if (!tasks || tasks.length === 0)
64
+ throw new Error("tasks or tasks_path is required.");
65
+ const batchId = args.batch_id || (args.tasks_path ? await stableBatchId(args.tasks_path) : `mcp-${Date.now().toString(36)}`);
66
+ const api = client();
67
+ const results = await runPool(tasks, args.max_parallel || 5, async (task, index) => {
68
+ let payload = await createRun(api, {
69
+ cwd: process.cwd(),
70
+ cliVersion: version,
71
+ task: task.task,
72
+ include: task.include || args.include || [],
73
+ externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
74
+ workloadId: defaultWorkloadId
75
+ });
76
+ if (args.wait)
77
+ payload = await waitForRun(api, payload.run.id);
78
+ return payload;
79
+ });
80
+ const structured = { batch_id: batchId, runs: results.map((result) => result.run), results };
81
+ return textAndJson(`Created ${results.length} Sanbox runs.`, structured);
82
+ });
83
+ server.registerTool("get_sandbox_run", {
84
+ title: "Get Sanbox Run",
85
+ description: "Fetch a Sanbox run and recent events.",
86
+ inputSchema: {
87
+ run_id: z.string()
88
+ }
89
+ }, async ({ run_id }) => {
90
+ const payload = await client().getRun(run_id);
91
+ return textAndJson(`Run ${payload.run.id} is ${payload.run.status}.`, payload);
92
+ });
93
+ server.registerTool("list_sandbox_events", {
94
+ title: "List Sanbox Events",
95
+ description: "List events for a Sanbox run.",
96
+ inputSchema: {
97
+ run_id: z.string(),
98
+ after_event_id: z.number().int().min(0).optional().default(0)
99
+ }
100
+ }, async ({ run_id, after_event_id }) => {
101
+ const payload = await client().listEvents(run_id, after_event_id || 0);
102
+ return textAndJson(`Found ${payload.events.length} events.`, payload);
103
+ });
104
+ server.registerTool("cancel_sandbox_run", {
105
+ title: "Cancel Sanbox Run",
106
+ description: "Request cancellation for a queued or running Sanbox run.",
107
+ inputSchema: {
108
+ run_id: z.string()
109
+ }
110
+ }, async ({ run_id }) => {
111
+ const payload = await client().cancelRun(run_id);
112
+ return textAndJson(`Cancel requested for ${payload.run.id}.`, payload);
113
+ });
114
+ server.registerTool("send_sandbox_message", {
115
+ title: "Send Sanbox Message",
116
+ description: "Ask a follow-up question against a retained Sanbox run workspace.",
117
+ inputSchema: {
118
+ run_id: z.string(),
119
+ message: z.string()
120
+ }
121
+ }, async ({ run_id, message }) => {
122
+ const payload = await client().sendMessage(run_id, message);
123
+ return textAndJson(`Message sent to ${payload.run.id}.`, payload);
124
+ });
125
+ const main = async () => {
126
+ await server.connect(new StdioServerTransport());
127
+ };
128
+ main().catch((error) => {
129
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
130
+ process.exit(1);
131
+ });
package/dist/output.js ADDED
@@ -0,0 +1,10 @@
1
+ export const printJson = (value) => {
2
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
3
+ };
4
+ export const summarizeRun = (payload) => {
5
+ const { run } = payload;
6
+ return `${run.id} ${run.status}${run.exit_code === null ? "" : ` exit=${run.exit_code}`}${run.error ? ` error=${run.error}` : ""}`;
7
+ };
8
+ export const printRun = (payload) => {
9
+ process.stdout.write(`${summarizeRun(payload)}\n`);
10
+ };
package/dist/runs.js ADDED
@@ -0,0 +1,88 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { defaultWorkloadId } from "./config.js";
5
+ import { buildTaskDossier, readDossierFile } from "./dossier.js";
6
+ const terminalStatuses = new Set(["completed", "failed", "canceled", "expired"]);
7
+ const inlineLimitBytes = 8 * 1024 * 1024;
8
+ export const isTerminalRun = (run) => terminalStatuses.has(run.status);
9
+ export const waitForRun = async (client, runId, options = {}) => {
10
+ const pollIntervalMs = options.pollIntervalMs ?? 2000;
11
+ const deadline = Date.now() + (options.timeoutSeconds ?? 1800) * 1000;
12
+ let payload = await client.getRun(runId);
13
+ while (!isTerminalRun(payload.run)) {
14
+ if (Date.now() > deadline)
15
+ throw new Error(`Timed out waiting for run ${runId}.`);
16
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
17
+ payload = await client.getRun(runId);
18
+ }
19
+ return payload;
20
+ };
21
+ export const createRun = async (client, options) => {
22
+ const dossier = options.dossierPath
23
+ ? await readDossierFile(path.resolve(options.cwd, options.dossierPath))
24
+ : await buildTaskDossier({
25
+ cwd: options.cwd,
26
+ task: options.task || "",
27
+ include: options.include,
28
+ cliVersion: options.cliVersion
29
+ });
30
+ if (dossier.buffer.byteLength > inlineLimitBytes) {
31
+ throw new Error(`Dossier is ${dossier.buffer.byteLength} bytes; inline dossier limit is ${inlineLimitBytes} bytes.`);
32
+ }
33
+ const manifest = {
34
+ dossier: {
35
+ inline_base64: dossier.buffer.toString("base64"),
36
+ sha256: dossier.sha256
37
+ },
38
+ sanbox_cli: {
39
+ files: dossier.files,
40
+ source: options.dossierPath ? "dossier_file" : "generated_task"
41
+ }
42
+ };
43
+ return client.createRun({
44
+ external_run_id: options.externalRunId,
45
+ workload_id: options.workloadId || defaultWorkloadId,
46
+ manifest,
47
+ retention_ttl_seconds: options.retentionTtlSeconds
48
+ });
49
+ };
50
+ export const readTasks = async (tasksPath) => {
51
+ const raw = await fs.readFile(tasksPath, "utf8");
52
+ const parsed = JSON.parse(raw);
53
+ if (!Array.isArray(parsed))
54
+ throw new Error("tasks file must be a JSON array.");
55
+ return parsed.map((item, index) => {
56
+ if (typeof item === "string")
57
+ return { task: item };
58
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
59
+ throw new Error(`tasks[${index}] must be a string or object.`);
60
+ }
61
+ const record = item;
62
+ const task = String(record.task || "").trim();
63
+ if (!task)
64
+ throw new Error(`tasks[${index}].task is required.`);
65
+ return {
66
+ task,
67
+ external_run_id: record.external_run_id ? String(record.external_run_id) : undefined,
68
+ include: Array.isArray(record.include) ? record.include.map(String) : undefined
69
+ };
70
+ });
71
+ };
72
+ export const stableBatchId = async (tasksPath) => {
73
+ const abs = path.resolve(tasksPath);
74
+ const raw = await fs.readFile(abs);
75
+ return crypto.createHash("sha256").update(abs).update("\0").update(raw).digest("hex").slice(0, 16);
76
+ };
77
+ export const runPool = async (items, concurrency, worker) => {
78
+ const results = new Array(items.length);
79
+ let cursor = 0;
80
+ const count = Math.max(1, Math.min(items.length || 1, concurrency));
81
+ await Promise.all(Array.from({ length: count }, async () => {
82
+ while (cursor < items.length) {
83
+ const index = cursor++;
84
+ results[index] = await worker(items[index], index);
85
+ }
86
+ }));
87
+ return results;
88
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export const version = "0.0.1";
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@sanlabs/sanbox-cli",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "bin": {
7
+ "sanbox": "dist/cli.js",
8
+ "sanbox-mcp": "dist/mcp.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "engines": {
18
+ "node": ">=20.19.0"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "dev": "tsx src/cli.ts"
23
+ },
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.21.0",
26
+ "fast-glob": "^3.3.3",
27
+ "ignore": "^7.0.5",
28
+ "yazl": "^3.3.1",
29
+ "zod": "^3.25.76"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^25.6.0",
33
+ "@types/yazl": "^3.3.0",
34
+ "tsx": "^4.21.0",
35
+ "typescript": "^6.0.3"
36
+ }
37
+ }