@sanlabs/sanbox-cli 0.0.3 → 0.0.5
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 +70 -77
- package/dist/api.js +102 -23
- package/dist/args.js +23 -7
- package/dist/artifacts.js +124 -0
- package/dist/cli.js +548 -222
- package/dist/config.js +2 -9
- package/dist/inputs.js +150 -0
- package/dist/output.js +3 -1
- package/dist/runs.js +13 -25
- package/dist/version.js +1 -1
- package/dist/watch.js +61 -2
- package/package.json +1 -1
- package/dist/dossier.js +0 -187
package/README.md
CHANGED
|
@@ -1,136 +1,129 @@
|
|
|
1
1
|
# Sanbox CLI
|
|
2
2
|
|
|
3
|
-
Run Sanbox agent tasks from a terminal, CI job, or coding agent.
|
|
3
|
+
Run isolated Sanbox agent tasks from a terminal, CI job, or autonomous coding agent.
|
|
4
4
|
|
|
5
|
-
Sanbox packages a task and selected inputs, starts an isolated runner, streams events, and keeps the
|
|
5
|
+
Sanbox packages a task and selected inputs, starts an isolated runner, streams events, and keeps the sandbox and outputs indefinitely.
|
|
6
|
+
|
|
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
|
-
npm install -g @sanlabs/sanbox-cli
|
|
12
|
+
npm install -g @sanlabs/sanbox-cli@latest
|
|
13
|
+
latest_cli_version="$(npm view @sanlabs/sanbox-cli version)"
|
|
14
|
+
installed_cli_version="$(sanbox --version)"
|
|
15
|
+
test "$installed_cli_version" = "$latest_cli_version"
|
|
11
16
|
```
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
Always use the latest published CLI. CLI 0.0.5 adds organization discovery from the API key,
|
|
19
|
+
Hermes-aware template metadata, permanent paused sandboxes, and same-sandbox follow-ups.
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
## Configure
|
|
16
22
|
|
|
17
23
|
```bash
|
|
18
24
|
export SANBOX_API_URL=https://console.sanbox.cloud
|
|
19
|
-
export SANBOX_ORG=<org-slug>
|
|
20
25
|
export SANBOX_API_KEY=sbx_live_...
|
|
21
|
-
export SANBOX_TEMPLATE=<template-id>
|
|
26
|
+
export SANBOX_TEMPLATE=<template-id-or-slug>
|
|
22
27
|
```
|
|
23
28
|
|
|
24
|
-
`SANBOX_API_URL`
|
|
25
|
-
|
|
29
|
+
`SANBOX_API_URL` is optional for the hosted service. `SANBOX_TEMPLATE` can instead come from `--template` or `.sanbox/config.json` `default_template`.
|
|
30
|
+
The CLI derives the organization automatically from `SANBOX_API_KEY`; use an organization-scoped
|
|
31
|
+
key that has access to exactly one organization.
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
An org admin configures provider credentials and templates in the console. Do not pass provider keys to the CLI or a runner.
|
|
28
34
|
|
|
29
|
-
## Check
|
|
35
|
+
## Check Readiness
|
|
30
36
|
|
|
31
37
|
```bash
|
|
32
|
-
sanbox
|
|
33
|
-
sanbox auth check
|
|
38
|
+
sanbox auth check --json
|
|
34
39
|
sanbox context --json
|
|
40
|
+
sanbox templates list --json
|
|
41
|
+
sanbox templates validate "$SANBOX_TEMPLATE" --json
|
|
42
|
+
sanbox doctor --json
|
|
35
43
|
```
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
sanbox model-providers list --json
|
|
43
|
-
sanbox model-providers get <provider-id> --json
|
|
44
|
-
sanbox model-providers models <provider-id> --json
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Model ids are scoped to their provider. If two providers expose the same model id, they remain separate choices.
|
|
45
|
+
Model IDs are provider-scoped. The CLI never guesses or silently substitutes a provider, model, or template.
|
|
46
|
+
For waited task runs, choose a template with `runnable: true`, `template_type: "runner"`, and
|
|
47
|
+
`runner_config.harness: "opencode"`. Hermes service templates are always-on.
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
## Run Idempotently
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
|
-
sanbox
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Organization admins can create a template with an exact provider/model pair:
|
|
52
|
+
sanbox run "Review this repo and write output/report.md" \
|
|
53
|
+
--input src/ \
|
|
54
|
+
--dry-run \
|
|
55
|
+
--json
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
--
|
|
62
|
-
--
|
|
63
|
-
--
|
|
57
|
+
sanbox run "Review this repo and write output/report.md" \
|
|
58
|
+
--template "$SANBOX_TEMPLATE" \
|
|
59
|
+
--external-run-id "<stable-project-task-id>" \
|
|
60
|
+
--input src/ \
|
|
61
|
+
--wait \
|
|
64
62
|
--json
|
|
65
63
|
```
|
|
66
64
|
|
|
67
|
-
The CLI
|
|
68
|
-
|
|
69
|
-
## Run a Task
|
|
65
|
+
Repeat `--input` for files, directories, or globs. The CLI excludes common secrets and applies `.sanboxignore`. `--include` is a deprecated compatibility alias.
|
|
70
66
|
|
|
71
|
-
|
|
72
|
-
sanbox run "Review this repo and write output/report.md" --template <template-id> --include "src/**" --watch
|
|
73
|
-
```
|
|
67
|
+
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.
|
|
74
68
|
|
|
75
|
-
|
|
69
|
+
## Inspect And Recover
|
|
76
70
|
|
|
77
71
|
```bash
|
|
78
|
-
sanbox
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
--watch
|
|
72
|
+
sanbox runs list --limit 50 --json
|
|
73
|
+
sanbox runs get <run-id> --json
|
|
74
|
+
sanbox runs events <run-id> --after-event-id <cursor> --json
|
|
75
|
+
sanbox runs watch <run-id> --after-event-id <cursor> --jsonl
|
|
83
76
|
```
|
|
84
77
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
## Watch Agent Activity
|
|
78
|
+
## Retrieve Outputs
|
|
88
79
|
|
|
89
|
-
|
|
80
|
+
Tasks should write durable deliverables under `/workspace/output`.
|
|
90
81
|
|
|
91
82
|
```bash
|
|
92
|
-
sanbox
|
|
83
|
+
sanbox runs artifacts <run-id> --json
|
|
84
|
+
sanbox runs download <run-id> --output .sanbox/output/<run-id> --json
|
|
85
|
+
sanbox runs download <run-id> \
|
|
86
|
+
--output .sanbox/output/<run-id> \
|
|
87
|
+
--artifact report.md \
|
|
88
|
+
--overwrite \
|
|
89
|
+
--json
|
|
93
90
|
```
|
|
94
91
|
|
|
95
|
-
|
|
92
|
+
Downloads are path-safe and return their byte counts and SHA-256 digests. Existing files are preserved unless `--overwrite` is explicit.
|
|
96
93
|
|
|
97
|
-
|
|
98
|
-
sanbox runs watch <run-id>
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
Use JSONL for scripts and agents:
|
|
94
|
+
## Continue A Run
|
|
102
95
|
|
|
103
96
|
```bash
|
|
104
|
-
sanbox runs
|
|
97
|
+
sanbox runs messages <run-id> --json
|
|
98
|
+
sanbox runs message <run-id> "Summarize the retained output" --wait --json
|
|
105
99
|
```
|
|
106
100
|
|
|
107
|
-
|
|
101
|
+
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.
|
|
108
102
|
|
|
109
|
-
|
|
103
|
+
Completed runs pause into a durable Firecracker snapshot. `sanbox runs message <run-id> --message "..."`
|
|
104
|
+
resumes the same writable sandbox and OpenCode session, then pauses it again. There is no retention
|
|
105
|
+
TTL; paused sandboxes and their artifacts remain available until explicitly deleted.
|
|
110
106
|
|
|
111
|
-
|
|
107
|
+
Check `sanbox runs get <run-id> --json` before a follow-up and require `sandbox_state: "paused"` with
|
|
108
|
+
a positive `snapshot_generation`. Do not submit concurrent follow-ups to one run.
|
|
112
109
|
|
|
113
110
|
## Batch Work
|
|
114
111
|
|
|
115
|
-
```
|
|
116
|
-
|
|
112
|
+
```json
|
|
113
|
+
[
|
|
114
|
+
{ "task": "Review API behavior and write output/api.md", "external_run_id": "review-api", "input": ["app/"] },
|
|
115
|
+
{ "task": "Review CLI behavior and write output/cli.md", "external_run_id": "review-cli", "input": ["cli/"] }
|
|
116
|
+
]
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
## Inspect a Run Bundle
|
|
120
|
-
|
|
121
119
|
```bash
|
|
122
|
-
sanbox
|
|
123
|
-
sanbox bundle create "Review this repo" --include "app/**" --out run.zip
|
|
124
|
-
sanbox bundle inspect run.zip
|
|
120
|
+
sanbox batch --tasks tasks.json --template "$SANBOX_TEMPLATE" --max-parallel 5 --wait --json
|
|
125
121
|
```
|
|
126
122
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
Use Sanbox for independent, long-running, risky, or parallelizable work. Tell the runner to write durable results under `/workspace/output`.
|
|
123
|
+
Batch fan-out is client-side. Keep the CLI process alive until all tasks are submitted.
|
|
130
124
|
|
|
131
|
-
|
|
125
|
+
## Machine Output
|
|
132
126
|
|
|
133
|
-
|
|
127
|
+
Use `--json` for request/response commands and `--jsonl` for streams. Envelopes have `schema_version`, `ok`, `command`, `context`, `data` or `error`, and `next_actions`.
|
|
134
128
|
|
|
135
|
-
|
|
136
|
-
- Agent instructions: https://console.sanbox.cloud/agent.md
|
|
129
|
+
Exit codes are `0` for command success, `1` for local/API failure, `2` for readiness or waited remote failure, and `130` for a detached watcher.
|
package/dist/api.js
CHANGED
|
@@ -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
|
}
|
|
@@ -58,17 +62,83 @@ export class SanboxClient {
|
|
|
58
62
|
}
|
|
59
63
|
return body;
|
|
60
64
|
}
|
|
65
|
+
async rawRequest(path, init = {}) {
|
|
66
|
+
const res = await fetch(`${this.config.apiUrl}${path}`, {
|
|
67
|
+
...init,
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
70
|
+
...(init.headers || {})
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
let body = {};
|
|
76
|
+
try {
|
|
77
|
+
body = text ? JSON.parse(text) : {};
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
body = { error: text || res.statusText };
|
|
81
|
+
}
|
|
82
|
+
throw new SanboxApiError(apiErrorMessage(body, res.statusText), res.status, body);
|
|
83
|
+
}
|
|
84
|
+
return res;
|
|
85
|
+
}
|
|
61
86
|
me() {
|
|
62
|
-
|
|
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];
|
|
115
|
+
}
|
|
116
|
+
async orgPath(path) {
|
|
117
|
+
const organization = await this.organization();
|
|
118
|
+
return `/v1/orgs/${encodeURIComponent(organization.slug)}${path}`;
|
|
63
119
|
}
|
|
64
|
-
|
|
65
|
-
return this.request(
|
|
120
|
+
async listRuns(limit = 50) {
|
|
121
|
+
return this.request(`${await this.orgPath("/runs")}?limit=${limit}`);
|
|
122
|
+
}
|
|
123
|
+
async createRun(body) {
|
|
124
|
+
return this.request(await this.orgPath("/runs"), {
|
|
66
125
|
method: "POST",
|
|
67
126
|
body: JSON.stringify(body)
|
|
68
127
|
});
|
|
69
128
|
}
|
|
70
|
-
|
|
71
|
-
return this.request(
|
|
129
|
+
async uploadInputCollection(input) {
|
|
130
|
+
return this.request(await this.orgPath("/input-collections"), {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: {
|
|
133
|
+
"Content-Type": "application/vnd.sanbox.input+zip",
|
|
134
|
+
"X-Sanbox-Content-SHA256": input.sha256,
|
|
135
|
+
"X-Sanbox-File-Count": String(input.fileCount)
|
|
136
|
+
},
|
|
137
|
+
body: Uint8Array.from(input.buffer).buffer
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async listModelProviders() {
|
|
141
|
+
return this.request(await this.orgPath("/model-providers"));
|
|
72
142
|
}
|
|
73
143
|
async getModelProvider(providerId) {
|
|
74
144
|
const payload = await this.listModelProviders();
|
|
@@ -77,40 +147,49 @@ export class SanboxClient {
|
|
|
77
147
|
throw new SanboxApiError(`Model provider ${providerId} was not found.`, 404, { code: "model_provider_not_found" });
|
|
78
148
|
return { provider };
|
|
79
149
|
}
|
|
80
|
-
listProviderModels(providerId) {
|
|
81
|
-
return this.request(
|
|
150
|
+
async listProviderModels(providerId) {
|
|
151
|
+
return this.request(await this.orgPath(`/model-providers/${encodeURIComponent(providerId)}/models`));
|
|
82
152
|
}
|
|
83
|
-
listTemplates() {
|
|
84
|
-
return this.request(
|
|
153
|
+
async listTemplates() {
|
|
154
|
+
return this.request(await this.orgPath("/templates"));
|
|
85
155
|
}
|
|
86
|
-
getTemplate(templateId) {
|
|
87
|
-
return this.request(
|
|
156
|
+
async getTemplate(templateId) {
|
|
157
|
+
return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}`));
|
|
88
158
|
}
|
|
89
|
-
validateTemplate(templateId) {
|
|
90
|
-
return this.request(
|
|
159
|
+
async validateTemplate(templateId) {
|
|
160
|
+
return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}/validate`));
|
|
91
161
|
}
|
|
92
|
-
createTemplate(body) {
|
|
93
|
-
return this.request(
|
|
162
|
+
async createTemplate(body) {
|
|
163
|
+
return this.request(await this.orgPath("/templates"), {
|
|
94
164
|
method: "POST",
|
|
95
165
|
body: JSON.stringify(body)
|
|
96
166
|
});
|
|
97
167
|
}
|
|
98
|
-
getRun(runId, signal) {
|
|
99
|
-
return this.request(
|
|
168
|
+
async getRun(runId, signal) {
|
|
169
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}`), { signal });
|
|
100
170
|
}
|
|
101
|
-
listEvents(runId, afterEventId = 0, limit = 200, signal) {
|
|
102
|
-
return this.request(
|
|
171
|
+
async listEvents(runId, afterEventId = 0, limit = 200, signal) {
|
|
172
|
+
return this.request(`${await this.orgPath(`/runs/${encodeURIComponent(runId)}/events`)}?after_event_id=${afterEventId}&limit=${limit}`, { signal });
|
|
103
173
|
}
|
|
104
|
-
cancelRun(runId) {
|
|
105
|
-
return this.request(
|
|
174
|
+
async cancelRun(runId) {
|
|
175
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/cancel`), {
|
|
106
176
|
method: "POST",
|
|
107
177
|
body: "{}"
|
|
108
178
|
});
|
|
109
179
|
}
|
|
110
|
-
sendMessage(runId, message, payload = {}) {
|
|
111
|
-
return this.request(
|
|
180
|
+
async sendMessage(runId, message, payload = {}) {
|
|
181
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/messages`), {
|
|
112
182
|
method: "POST",
|
|
113
183
|
body: JSON.stringify({ message, payload })
|
|
114
184
|
});
|
|
115
185
|
}
|
|
186
|
+
async listMessages(runId) {
|
|
187
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/messages`));
|
|
188
|
+
}
|
|
189
|
+
async listArtifacts(runId) {
|
|
190
|
+
return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/artifacts`));
|
|
191
|
+
}
|
|
192
|
+
async downloadArtifact(runId, artifactPath) {
|
|
193
|
+
return this.rawRequest(`${await this.orgPath(`/runs/${encodeURIComponent(runId)}/artifacts`)}?path=${encodeURIComponent(artifactPath)}`);
|
|
194
|
+
}
|
|
116
195
|
}
|
package/dist/args.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
const multiFlags = new Set(["include"]);
|
|
1
|
+
const multiFlags = new Set(["input", "include", "artifact", "telegram-allowed-user"]);
|
|
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 ?? (
|
|
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
|
-
|
|
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
|
+
};
|