@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 +15 -0
- package/dist/api.js +59 -0
- package/dist/args.js +46 -0
- package/dist/cli.js +208 -0
- package/dist/config.js +12 -0
- package/dist/dossier.js +108 -0
- package/dist/mcp.js +131 -0
- package/dist/output.js +10 -0
- package/dist/runs.js +88 -0
- package/dist/types.js +1 -0
- package/dist/version.js +1 -0
- package/package.json +37 -0
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
|
+
};
|
package/dist/dossier.js
ADDED
|
@@ -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 {};
|
package/dist/version.js
ADDED
|
@@ -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
|
+
}
|