@sanlabs/sanbox-cli 0.0.4 → 0.0.9
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 +129 -8
- package/dist/api.js +112 -35
- package/dist/args.js +9 -2
- package/dist/cli.js +620 -138
- package/dist/config.js +24 -14
- package/dist/deviceLogin.js +106 -0
- package/dist/fileAccess.js +16 -0
- package/dist/output.js +3 -1
- package/dist/runs.js +21 -3
- package/dist/ssh.js +129 -0
- package/dist/userSession.js +124 -0
- package/dist/version.js +1 -1
- package/dist/watch.js +2 -2
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -2,31 +2,73 @@
|
|
|
2
2
|
|
|
3
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 sandbox and outputs indefinitely.
|
|
6
|
+
|
|
5
7
|
The full machine operating protocol is available at https://console.sanbox.cloud/agent.md and in [`web/public/agent.md`](../web/public/agent.md).
|
|
6
8
|
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
11
|
```bash
|
|
10
12
|
npm install -g @sanlabs/sanbox-cli@latest
|
|
11
|
-
sanbox
|
|
13
|
+
latest_cli_version="$(npm view @sanlabs/sanbox-cli version)"
|
|
14
|
+
installed_cli_version="$(sanbox --version)"
|
|
15
|
+
test "$installed_cli_version" = "$latest_cli_version"
|
|
12
16
|
```
|
|
13
17
|
|
|
14
|
-
CLI 0.0.
|
|
18
|
+
Always use the latest published CLI. CLI 0.0.9 adds user login for private SSH access to running
|
|
19
|
+
Hermes Computers.
|
|
20
|
+
CLI 0.0.6 added Anthropic self-hosted environment inspection and administration.
|
|
15
21
|
|
|
16
22
|
## Configure
|
|
17
23
|
|
|
24
|
+
For an interactive local terminal, sign in with your Sanbox account:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
sanbox login
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The CLI opens the console for approval, binds the session to one organization, and stores it in
|
|
31
|
+
`~/.sanbox/credentials.json`. The file is written atomically and limited to the current user on
|
|
32
|
+
POSIX systems. Run `sanbox logout` to revoke and remove that session.
|
|
33
|
+
|
|
34
|
+
For non-interactive automation, continue to use an organization API key:
|
|
35
|
+
|
|
18
36
|
```bash
|
|
19
37
|
export SANBOX_API_URL=https://console.sanbox.cloud
|
|
20
38
|
export SANBOX_API_KEY=sbx_live_...
|
|
21
|
-
sanbox orgs list --json
|
|
22
|
-
export SANBOX_ORG=<returned-org-slug>
|
|
23
39
|
export SANBOX_TEMPLATE=<template-id-or-slug>
|
|
24
40
|
```
|
|
25
41
|
|
|
26
|
-
`SANBOX_API_URL` is optional for the hosted service. `SANBOX_TEMPLATE` can instead come from
|
|
42
|
+
`SANBOX_API_URL` is optional for the hosted service. `SANBOX_TEMPLATE` can instead come from
|
|
43
|
+
`--template` or `.sanbox/config.json` `default_template`. Organization API keys are for automation;
|
|
44
|
+
they cannot open terminal or SSH sessions.
|
|
27
45
|
|
|
28
46
|
An org admin configures provider credentials and templates in the console. Do not pass provider keys to the CLI or a runner.
|
|
29
47
|
|
|
48
|
+
## Connect An Anthropic Self-Hosted Environment
|
|
49
|
+
|
|
50
|
+
Create a `self_hosted` environment in Claude Console and generate its environment key. Then connect
|
|
51
|
+
it to Sanbox:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
export ANTHROPIC_ENVIRONMENT_KEY='<environment-key>'
|
|
55
|
+
sanbox anthropic-environments connect env_example --json
|
|
56
|
+
unset ANTHROPIC_ENVIRONMENT_KEY
|
|
57
|
+
|
|
58
|
+
sanbox anthropic-environments get env_example --json
|
|
59
|
+
sanbox anthropic-environments list --json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The environment key is read only from `ANTHROPIC_ENVIRONMENT_KEY`; there is no secret-bearing CLI
|
|
63
|
+
flag. Connect, key replacement, and disconnect require an organization-admin user session or
|
|
64
|
+
operator credential. Regular organization-scoped member keys can list and inspect connections.
|
|
65
|
+
|
|
66
|
+
Disconnecting requires explicit confirmation and preserves existing session-to-workspace mappings:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
sanbox anthropic-environments disconnect env_example --force --json
|
|
70
|
+
```
|
|
71
|
+
|
|
30
72
|
## Check Readiness
|
|
31
73
|
|
|
32
74
|
```bash
|
|
@@ -37,7 +79,34 @@ sanbox templates validate "$SANBOX_TEMPLATE" --json
|
|
|
37
79
|
sanbox doctor --json
|
|
38
80
|
```
|
|
39
81
|
|
|
40
|
-
Model IDs are provider-scoped. The CLI never guesses or silently substitutes
|
|
82
|
+
Model IDs are provider-scoped. The CLI never guesses or silently substitutes a provider, model, or template.
|
|
83
|
+
For waited task runs, choose a template with `runnable: true`, `template_type: "runner"`, and
|
|
84
|
+
`runner_config.harness: "opencode"` or `"browser-use"`. Browser Use is for one-shot web tasks whose
|
|
85
|
+
target domains are already approved. Hermes service templates are always-on.
|
|
86
|
+
|
|
87
|
+
## Create A Browser Use Template
|
|
88
|
+
|
|
89
|
+
Browser Use runs local headless Chromium inside the Firecracker sandbox. It requires OpenAI,
|
|
90
|
+
Anthropic, or Google Gemini and at least one explicit browser target:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
sanbox templates create \
|
|
94
|
+
--name "Web research" \
|
|
95
|
+
--harness browser-use \
|
|
96
|
+
--model-provider openai \
|
|
97
|
+
--model '<model-id>' \
|
|
98
|
+
--browser-domain example.com \
|
|
99
|
+
--browser-domain '*.example.org' \
|
|
100
|
+
--browser-max-steps 30 \
|
|
101
|
+
--browser-step-timeout-seconds 120 \
|
|
102
|
+
--browser-vision-mode auto \
|
|
103
|
+
--browser-viewport 1280x720 \
|
|
104
|
+
--browser-download-policy allow
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Targets are limited to hostnames and leading wildcards on ports 80 and 443. The remaining browser
|
|
108
|
+
security controls are fixed. Runs return `browser-use-response.md`, a sanitized run ledger, and any
|
|
109
|
+
allowed downloads. Browser Use templates do not support retained follow-up messages.
|
|
41
110
|
|
|
42
111
|
## Run Idempotently
|
|
43
112
|
|
|
@@ -84,14 +153,66 @@ sanbox runs download <run-id> \
|
|
|
84
153
|
|
|
85
154
|
Downloads are path-safe and return their byte counts and SHA-256 digests. Existing files are preserved unless `--overwrite` is explicit.
|
|
86
155
|
|
|
87
|
-
##
|
|
156
|
+
## Share A Running Filesystem
|
|
157
|
+
|
|
158
|
+
Create one read-only bearer URL for the running sandbox's entire root filesystem:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
sanbox runs share <run-id> --expires 1h --json
|
|
162
|
+
sanbox runs shares <run-id> --json
|
|
163
|
+
sanbox runs unshare <run-id> <access-point-id> --json
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The URL always starts at `/`; there is no root or output selector. Append a path to read an arbitrary
|
|
167
|
+
ordinary file, or add `?format=json` to a directory URL for agent-friendly navigation. The URL stops
|
|
168
|
+
working when the exact sandbox session stops, the link expires, or it is revoked. It is shown only
|
|
169
|
+
when created and must be handled as a bearer secret. See
|
|
170
|
+
[Live Filesystem Access](../docs/live-filesystem-access.md) for the HTTP contract and exclusions.
|
|
171
|
+
|
|
172
|
+
## Resume Or Pause A Sandbox
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
sanbox runs get <run-id> --json
|
|
176
|
+
sanbox runs resume <run-id> --wait --json
|
|
177
|
+
sanbox runs share <run-id> --expires 1h --json
|
|
178
|
+
sanbox runs pause <run-id> --wait --json
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Resume restores the latest writable Firecracker snapshot without starting the configured agent
|
|
182
|
+
harness. It acquires an exclusive manual lease and leaves the sandbox running for live filesystem
|
|
183
|
+
access until Pause creates the next snapshot generation. Do not submit agent work while that lease is
|
|
184
|
+
active. Paused state has no retention TTL and remains available until explicitly deleted.
|
|
185
|
+
|
|
186
|
+
## SSH Into A Hermes Computer
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
sanbox login
|
|
190
|
+
sanbox ssh <run-id>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The run must be a currently running Hermes Computer created from the SSH-capable template artifact.
|
|
194
|
+
The signed-in user must own the run or be an organization admin. The command uses your local OpenSSH
|
|
195
|
+
client, but it does not expose the microVM on a public IP or port. The CLI generates a temporary
|
|
196
|
+
Ed25519 identity, pins the microVM's runtime host key, and tunnels the SSH stream over a one-time
|
|
197
|
+
authenticated WebSocket. The temporary key is deleted when the connection closes, and the saved
|
|
198
|
+
user token is not passed to the OpenSSH child process.
|
|
199
|
+
|
|
200
|
+
## Continue An OpenCode Run
|
|
88
201
|
|
|
89
202
|
```bash
|
|
90
203
|
sanbox runs messages <run-id> --json
|
|
91
204
|
sanbox runs message <run-id> "Summarize the retained output" --wait --json
|
|
92
205
|
```
|
|
93
206
|
|
|
94
|
-
The JSON response includes the user `message`, submitted `chat_job`, matching terminal
|
|
207
|
+
The JSON response includes the user `message`, submitted `chat_job`, matching terminal
|
|
208
|
+
`followup_event`, and conversation messages. The waiter matches `payload.chat_job_id`, so another
|
|
209
|
+
concurrent follow-up cannot complete the wrong command. The follow-up resumes OpenCode for one turn,
|
|
210
|
+
then pauses the sandbox again.
|
|
211
|
+
|
|
212
|
+
Check `sanbox runs get <run-id> --json` before a follow-up and require `sandbox_state: "paused"` with
|
|
213
|
+
a positive `snapshot_generation`. Do not submit concurrent follow-ups to one run.
|
|
214
|
+
Treat `browser_use_followup_unsupported` as a signal to create a new browser agent run; its persisted
|
|
215
|
+
sandbox can still be resumed manually when a snapshot exists.
|
|
95
216
|
|
|
96
217
|
## Batch Work
|
|
97
218
|
|
package/dist/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
1
2
|
const errorRecord = (body) => {
|
|
2
3
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
3
4
|
return null;
|
|
@@ -38,6 +39,9 @@ export class SanboxApiError extends Error {
|
|
|
38
39
|
}
|
|
39
40
|
export class SanboxClient {
|
|
40
41
|
config;
|
|
42
|
+
mePromise = null;
|
|
43
|
+
organizationPromise = null;
|
|
44
|
+
organizationSlug = null;
|
|
41
45
|
constructor(config) {
|
|
42
46
|
this.config = config;
|
|
43
47
|
}
|
|
@@ -80,22 +84,50 @@ export class SanboxClient {
|
|
|
80
84
|
return res;
|
|
81
85
|
}
|
|
82
86
|
me() {
|
|
83
|
-
|
|
87
|
+
this.mePromise ||= this.request("/v1/me");
|
|
88
|
+
return this.mePromise;
|
|
89
|
+
}
|
|
90
|
+
organization() {
|
|
91
|
+
this.organizationPromise ||= this.resolveOrganization();
|
|
92
|
+
return this.organizationPromise;
|
|
93
|
+
}
|
|
94
|
+
resolvedOrganizationSlug() {
|
|
95
|
+
return this.organizationSlug;
|
|
96
|
+
}
|
|
97
|
+
async resolveOrganization() {
|
|
98
|
+
const me = await this.me();
|
|
99
|
+
const organizations = Array.isArray(me.organizations)
|
|
100
|
+
? me.organizations.filter((item) => Boolean(item)
|
|
101
|
+
&& typeof item === "object"
|
|
102
|
+
&& !Array.isArray(item)
|
|
103
|
+
&& typeof item.id === "string"
|
|
104
|
+
&& typeof item.slug === "string"
|
|
105
|
+
&& Boolean(String(item.slug).trim()))
|
|
106
|
+
: [];
|
|
107
|
+
if (organizations.length === 0) {
|
|
108
|
+
throw new CliError("organization_not_found", "The API key is not associated with an organization.");
|
|
109
|
+
}
|
|
110
|
+
if (organizations.length > 1) {
|
|
111
|
+
throw new CliError("organization_ambiguous", "The API key can access multiple organizations. Use an organization-scoped API key.");
|
|
112
|
+
}
|
|
113
|
+
this.organizationSlug = organizations[0].slug;
|
|
114
|
+
return organizations[0];
|
|
84
115
|
}
|
|
85
|
-
|
|
86
|
-
|
|
116
|
+
async orgPath(path) {
|
|
117
|
+
const organization = await this.organization();
|
|
118
|
+
return `/v1/orgs/${encodeURIComponent(organization.slug)}${path}`;
|
|
87
119
|
}
|
|
88
|
-
listRuns(limit = 50) {
|
|
89
|
-
return this.request(
|
|
120
|
+
async listRuns(limit = 50) {
|
|
121
|
+
return this.request(`${await this.orgPath("/runs")}?limit=${limit}`);
|
|
90
122
|
}
|
|
91
|
-
createRun(body) {
|
|
92
|
-
return this.request(
|
|
123
|
+
async createRun(body) {
|
|
124
|
+
return this.request(await this.orgPath("/runs"), {
|
|
93
125
|
method: "POST",
|
|
94
126
|
body: JSON.stringify(body)
|
|
95
127
|
});
|
|
96
128
|
}
|
|
97
|
-
uploadInputCollection(input) {
|
|
98
|
-
return this.request(
|
|
129
|
+
async uploadInputCollection(input) {
|
|
130
|
+
return this.request(await this.orgPath("/input-collections"), {
|
|
99
131
|
method: "POST",
|
|
100
132
|
headers: {
|
|
101
133
|
"Content-Type": "application/vnd.sanbox.input+zip",
|
|
@@ -105,8 +137,8 @@ export class SanboxClient {
|
|
|
105
137
|
body: Uint8Array.from(input.buffer).buffer
|
|
106
138
|
});
|
|
107
139
|
}
|
|
108
|
-
listModelProviders() {
|
|
109
|
-
return this.request(
|
|
140
|
+
async listModelProviders() {
|
|
141
|
+
return this.request(await this.orgPath("/model-providers"));
|
|
110
142
|
}
|
|
111
143
|
async getModelProvider(providerId) {
|
|
112
144
|
const payload = await this.listModelProviders();
|
|
@@ -115,49 +147,94 @@ export class SanboxClient {
|
|
|
115
147
|
throw new SanboxApiError(`Model provider ${providerId} was not found.`, 404, { code: "model_provider_not_found" });
|
|
116
148
|
return { provider };
|
|
117
149
|
}
|
|
118
|
-
listProviderModels(providerId) {
|
|
119
|
-
return this.request(
|
|
150
|
+
async listProviderModels(providerId) {
|
|
151
|
+
return this.request(await this.orgPath(`/model-providers/${encodeURIComponent(providerId)}/models`));
|
|
152
|
+
}
|
|
153
|
+
async listAnthropicEnvironments() {
|
|
154
|
+
return this.request(await this.orgPath("/anthropic-environments"));
|
|
155
|
+
}
|
|
156
|
+
async getAnthropicEnvironment(environmentId) {
|
|
157
|
+
return this.request(await this.orgPath(`/anthropic-environments/${encodeURIComponent(environmentId)}`));
|
|
158
|
+
}
|
|
159
|
+
async connectAnthropicEnvironment(environmentId, environmentKey) {
|
|
160
|
+
return this.request(await this.orgPath(`/anthropic-environments/${encodeURIComponent(environmentId)}`), {
|
|
161
|
+
method: "PUT",
|
|
162
|
+
body: JSON.stringify({ environment_key: environmentKey })
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async disconnectAnthropicEnvironment(environmentId) {
|
|
166
|
+
return this.request(await this.orgPath(`/anthropic-environments/${encodeURIComponent(environmentId)}`), { method: "DELETE" });
|
|
120
167
|
}
|
|
121
|
-
listTemplates() {
|
|
122
|
-
return this.request(
|
|
168
|
+
async listTemplates() {
|
|
169
|
+
return this.request(await this.orgPath("/templates"));
|
|
123
170
|
}
|
|
124
|
-
getTemplate(templateId) {
|
|
125
|
-
return this.request(
|
|
171
|
+
async getTemplate(templateId) {
|
|
172
|
+
return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}`));
|
|
126
173
|
}
|
|
127
|
-
validateTemplate(templateId) {
|
|
128
|
-
return this.request(
|
|
174
|
+
async validateTemplate(templateId) {
|
|
175
|
+
return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}/validate`));
|
|
129
176
|
}
|
|
130
|
-
createTemplate(body) {
|
|
131
|
-
return this.request(
|
|
177
|
+
async createTemplate(body) {
|
|
178
|
+
return this.request(await this.orgPath("/templates"), {
|
|
132
179
|
method: "POST",
|
|
133
180
|
body: JSON.stringify(body)
|
|
134
181
|
});
|
|
135
182
|
}
|
|
136
|
-
getRun(runId, signal) {
|
|
137
|
-
return this.request(
|
|
183
|
+
async getRun(runId, signal) {
|
|
184
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}`), { signal });
|
|
185
|
+
}
|
|
186
|
+
async createSSHSession(runId, publicKey) {
|
|
187
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/ssh-sessions`), {
|
|
188
|
+
method: "POST",
|
|
189
|
+
body: JSON.stringify({ public_key: publicKey })
|
|
190
|
+
});
|
|
138
191
|
}
|
|
139
|
-
listEvents(runId, afterEventId = 0, limit = 200, signal) {
|
|
140
|
-
return this.request(
|
|
192
|
+
async listEvents(runId, afterEventId = 0, limit = 200, signal) {
|
|
193
|
+
return this.request(`${await this.orgPath(`/runs/${encodeURIComponent(runId)}/events`)}?after_event_id=${afterEventId}&limit=${limit}`, { signal });
|
|
141
194
|
}
|
|
142
|
-
cancelRun(runId) {
|
|
143
|
-
return this.request(
|
|
195
|
+
async cancelRun(runId) {
|
|
196
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/cancel`), {
|
|
144
197
|
method: "POST",
|
|
145
198
|
body: "{}"
|
|
146
199
|
});
|
|
147
200
|
}
|
|
148
|
-
|
|
149
|
-
return this.request(
|
|
201
|
+
async resumeRun(runId) {
|
|
202
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/resume`), {
|
|
203
|
+
method: "POST",
|
|
204
|
+
body: "{}"
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async pauseRun(runId) {
|
|
208
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/pause`), {
|
|
209
|
+
method: "POST",
|
|
210
|
+
body: "{}"
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async sendMessage(runId, message, payload = {}) {
|
|
214
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/messages`), {
|
|
150
215
|
method: "POST",
|
|
151
216
|
body: JSON.stringify({ message, payload })
|
|
152
217
|
});
|
|
153
218
|
}
|
|
154
|
-
listMessages(runId) {
|
|
155
|
-
return this.request(
|
|
219
|
+
async listMessages(runId) {
|
|
220
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/messages`));
|
|
221
|
+
}
|
|
222
|
+
async listArtifacts(runId) {
|
|
223
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/artifacts`));
|
|
224
|
+
}
|
|
225
|
+
async listFileAccessPoints(runId) {
|
|
226
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/file-access-points`));
|
|
227
|
+
}
|
|
228
|
+
async createFileAccessPoint(runId, input) {
|
|
229
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/file-access-points`), {
|
|
230
|
+
method: "POST",
|
|
231
|
+
body: JSON.stringify(input)
|
|
232
|
+
});
|
|
156
233
|
}
|
|
157
|
-
|
|
158
|
-
return this.request(`/
|
|
234
|
+
async revokeFileAccessPoint(runId, accessPointId) {
|
|
235
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/file-access-points/${encodeURIComponent(accessPointId)}`), { method: "DELETE" });
|
|
159
236
|
}
|
|
160
|
-
downloadArtifact(runId, artifactPath) {
|
|
161
|
-
return this.rawRequest(
|
|
237
|
+
async downloadArtifact(runId, artifactPath) {
|
|
238
|
+
return this.rawRequest(`${await this.orgPath(`/runs/${encodeURIComponent(runId)}/artifacts`)}?path=${encodeURIComponent(artifactPath)}`);
|
|
162
239
|
}
|
|
163
240
|
}
|
package/dist/args.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
const multiFlags = new Set([
|
|
1
|
+
const multiFlags = new Set([
|
|
2
|
+
"input",
|
|
3
|
+
"include",
|
|
4
|
+
"artifact",
|
|
5
|
+
"telegram-allowed-user",
|
|
6
|
+
"browser-domain"
|
|
7
|
+
]);
|
|
2
8
|
export const booleanFlags = new Set([
|
|
3
9
|
"help",
|
|
4
10
|
"version",
|
|
@@ -12,7 +18,8 @@ export const booleanFlags = new Set([
|
|
|
12
18
|
"verbose",
|
|
13
19
|
"force",
|
|
14
20
|
"write",
|
|
15
|
-
"overwrite"
|
|
21
|
+
"overwrite",
|
|
22
|
+
"no-browser"
|
|
16
23
|
]);
|
|
17
24
|
export const parseArgs = (argv) => {
|
|
18
25
|
const command = [];
|