@amaretto-software-labs/borealis-cli 0.1.0
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/LICENSE +201 -0
- package/README.md +112 -0
- package/dist/api-client.d.ts +36 -0
- package/dist/api-client.js +673 -0
- package/dist/api-client.js.map +1 -0
- package/dist/app.d.ts +6 -0
- package/dist/app.js +344 -0
- package/dist/app.js.map +1 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +506 -0
- package/dist/auth.js.map +1 -0
- package/dist/catalog.d.ts +6 -0
- package/dist/catalog.js +15 -0
- package/dist/catalog.js.map +1 -0
- package/dist/command-grammar.d.ts +3 -0
- package/dist/command-grammar.js +274 -0
- package/dist/command-grammar.js.map +1 -0
- package/dist/completion.d.ts +1 -0
- package/dist/completion.js +18 -0
- package/dist/completion.js.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +55 -0
- package/dist/config.js.map +1 -0
- package/dist/credential-file.d.ts +1 -0
- package/dist/credential-file.js +41 -0
- package/dist/credential-file.js.map +1 -0
- package/dist/http-timeout.d.ts +5 -0
- package/dist/http-timeout.js +10 -0
- package/dist/http-timeout.js.map +1 -0
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +158 -0
- package/dist/interactive.js.map +1 -0
- package/dist/invocation.d.ts +13 -0
- package/dist/invocation.js +526 -0
- package/dist/invocation.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +14 -0
- package/dist/main.js.map +1 -0
- package/dist/operations.json +1127 -0
- package/dist/output.d.ts +1 -0
- package/dist/output.js +32 -0
- package/dist/output.js.map +1 -0
- package/dist/secret-file.d.ts +1 -0
- package/dist/secret-file.js +19 -0
- package/dist/secret-file.js.map +1 -0
- package/dist/session-store.d.ts +54 -0
- package/dist/session-store.js +470 -0
- package/dist/session-store.js.map +1 -0
- package/dist/types.d.ts +67 -0
- package/dist/types.js +28 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { link, mkdtemp, open, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { targetFromPath } from "./invocation.js";
|
|
9
|
+
import { createRequestDeadline } from "./http-timeout.js";
|
|
10
|
+
export const implementedTransportOperationIds = Object.freeze([
|
|
11
|
+
"destructive.preflight.create",
|
|
12
|
+
"sandbox.workspace.export.resource.create",
|
|
13
|
+
"sandbox.workspace.export.resource.read",
|
|
14
|
+
"sandbox.workspace.import.upload.create",
|
|
15
|
+
"sandbox.workspace.import.upload.status",
|
|
16
|
+
"sandbox.workspace.import.upload.chunk",
|
|
17
|
+
"sandbox.workspace.import.upload.complete",
|
|
18
|
+
"sandbox.exec.stream",
|
|
19
|
+
"sandbox.events.stream",
|
|
20
|
+
"sandbox.usage.batch",
|
|
21
|
+
"registry.create.interactive",
|
|
22
|
+
"service_principal.create.interactive",
|
|
23
|
+
"host_enrollment.create.interactive",
|
|
24
|
+
]);
|
|
25
|
+
export class ApiError extends Error {
|
|
26
|
+
status;
|
|
27
|
+
traceId;
|
|
28
|
+
constructor(status, message, traceId) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.status = status;
|
|
31
|
+
this.traceId = traceId;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class AmbiguousDispatchError extends Error {
|
|
35
|
+
idempotencyKey;
|
|
36
|
+
constructor(idempotencyKey, operationCommand, options) {
|
|
37
|
+
super(`${operationCommand} may have been accepted, but its result is unknown. Retry with --idempotency-key ${idempotencyKey}.`, options);
|
|
38
|
+
this.idempotencyKey = idempotencyKey;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function boundedBytes(response, limit = 10 * 1024 * 1024, signal) {
|
|
42
|
+
const length = Number(response.headers.get("content-length") ?? "0");
|
|
43
|
+
if (length > limit)
|
|
44
|
+
throw new Error(`Response exceeded ${limit} bytes.`);
|
|
45
|
+
const reader = response.body?.getReader();
|
|
46
|
+
if (!reader)
|
|
47
|
+
return new Uint8Array();
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let bytes = 0;
|
|
50
|
+
while (true) {
|
|
51
|
+
const { value, done } = signal
|
|
52
|
+
? await readWithSignal(reader, signal)
|
|
53
|
+
: await reader.read();
|
|
54
|
+
if (done)
|
|
55
|
+
break;
|
|
56
|
+
bytes += value.byteLength;
|
|
57
|
+
if (bytes > limit) {
|
|
58
|
+
await reader.cancel();
|
|
59
|
+
throw new Error(`Response exceeded ${limit} bytes.`);
|
|
60
|
+
}
|
|
61
|
+
chunks.push(value);
|
|
62
|
+
}
|
|
63
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
64
|
+
}
|
|
65
|
+
async function boundedText(response, limit = 10 * 1024 * 1024, signal) {
|
|
66
|
+
return Buffer.from(await boundedBytes(response, limit, signal)).toString("utf8");
|
|
67
|
+
}
|
|
68
|
+
async function readWithSignal(reader, signal) {
|
|
69
|
+
signal.throwIfAborted();
|
|
70
|
+
return await new Promise((resolve, reject) => {
|
|
71
|
+
const onAbort = () => reject(signal.reason);
|
|
72
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
73
|
+
reader.read().then((result) => {
|
|
74
|
+
signal.removeEventListener("abort", onAbort);
|
|
75
|
+
resolve(result);
|
|
76
|
+
}, (error) => {
|
|
77
|
+
signal.removeEventListener("abort", onAbort);
|
|
78
|
+
reject(error);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function snapshotOperationStatusUri(result) {
|
|
83
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
84
|
+
const requestId = result.requestId;
|
|
85
|
+
const sandboxId = result.sourceSandboxId;
|
|
86
|
+
if (typeof requestId !== "string" ||
|
|
87
|
+
typeof sandboxId !== "string" ||
|
|
88
|
+
!uuid.test(requestId) ||
|
|
89
|
+
!uuid.test(sandboxId))
|
|
90
|
+
return undefined;
|
|
91
|
+
return `/api/v1/sandboxes/${sandboxId}/snapshot-operations/${requestId}`;
|
|
92
|
+
}
|
|
93
|
+
export class BorealisApiClient {
|
|
94
|
+
options;
|
|
95
|
+
constructor(options) {
|
|
96
|
+
this.options = options;
|
|
97
|
+
}
|
|
98
|
+
headers(request) {
|
|
99
|
+
return {
|
|
100
|
+
authorization: `Bearer ${this.options.token}`,
|
|
101
|
+
accept: "application/json",
|
|
102
|
+
"user-agent": `borealis-cli/${process.env.npm_package_version ?? "0.1.0"}`,
|
|
103
|
+
...(this.options.organization
|
|
104
|
+
? { "x-organization-id": this.options.organization }
|
|
105
|
+
: {}),
|
|
106
|
+
...(request?.idempotencyKey
|
|
107
|
+
? { "idempotency-key": request.idempotencyKey }
|
|
108
|
+
: {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async invoke(operation, request) {
|
|
112
|
+
try {
|
|
113
|
+
return await this.invokeCore(operation, request);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (operation.idempotency === "idempotency-key" &&
|
|
117
|
+
request.idempotencyKey &&
|
|
118
|
+
isAmbiguousDispatchFailure(error))
|
|
119
|
+
throw new AmbiguousDispatchError(request.idempotencyKey, operation.command, { cause: error });
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async invokeInteractiveCredential(operation, request) {
|
|
124
|
+
try {
|
|
125
|
+
return await this.invokeInteractiveCredentialCore(operation, request);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (operation.idempotency === "idempotency-key" &&
|
|
129
|
+
request.idempotencyKey &&
|
|
130
|
+
isAmbiguousDispatchFailure(error))
|
|
131
|
+
throw new AmbiguousDispatchError(request.idempotencyKey, operation.command, { cause: error });
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async invokeInteractiveCredentialCore(operation, request) {
|
|
136
|
+
const paths = {
|
|
137
|
+
"registry.create": "/api/v1/registries/interactive",
|
|
138
|
+
"service_principal.create": "/api/v1/service-principals/interactive",
|
|
139
|
+
"host_enrollment.create": `${request.path}/interactive`,
|
|
140
|
+
};
|
|
141
|
+
const path = paths[operation.operationId];
|
|
142
|
+
if (!path)
|
|
143
|
+
throw new Error(`${operation.command} does not support interactive credential delivery.`);
|
|
144
|
+
const body = { ...(request.body ?? {}) };
|
|
145
|
+
delete body.secret;
|
|
146
|
+
if (operation.operationId === "registry.create")
|
|
147
|
+
body.delivery = "interactive";
|
|
148
|
+
const result = (await this.send("POST", path, this.headers(request), body));
|
|
149
|
+
const claimUri = result.claimUri;
|
|
150
|
+
const expiresAt = result.expiresAt;
|
|
151
|
+
const claim = typeof claimUri === "string" ? new URL(claimUri, this.options.api) : null;
|
|
152
|
+
if (!claim ||
|
|
153
|
+
claim.origin !== new URL(this.options.api).origin ||
|
|
154
|
+
!/^\/api\/v1\/credential-claims\/[0-9a-f-]{36}$/i.test(claim.pathname) ||
|
|
155
|
+
claim.search ||
|
|
156
|
+
claim.hash ||
|
|
157
|
+
result.requiresAuthentication !== true ||
|
|
158
|
+
typeof expiresAt !== "string" ||
|
|
159
|
+
!Number.isFinite(Date.parse(expiresAt)) ||
|
|
160
|
+
Date.parse(expiresAt) <= Date.now())
|
|
161
|
+
throw new Error("The API returned an invalid credential claim handle.");
|
|
162
|
+
if (operation.operationId === "host_enrollment.create") {
|
|
163
|
+
const expectedPool = decodeURIComponent(request.path.match(/\/host-pools\/([^/]+)/)?.[1] ?? "");
|
|
164
|
+
const metadata = result.metadata;
|
|
165
|
+
if (metadata?.hostPoolId !== expectedPool)
|
|
166
|
+
throw new Error("The API returned an enrollment claim for a different host pool.");
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
async invokeCore(operation, request) {
|
|
171
|
+
if (operation.operationId === "sandbox.workspace.export") {
|
|
172
|
+
return await this.exportWorkspace(request);
|
|
173
|
+
}
|
|
174
|
+
if (operation.operationId === "sandbox.workspace.import") {
|
|
175
|
+
return await this.importWorkspace(request);
|
|
176
|
+
}
|
|
177
|
+
const headers = this.headers(request);
|
|
178
|
+
if (operation.risk === "destructive") {
|
|
179
|
+
const target = targetFromPath(operation, request.path);
|
|
180
|
+
const preflight = (await this.send("POST", "/api/v1/preflights", headers, { operationId: operation.operationId, target }));
|
|
181
|
+
if (!preflight.preflightToken)
|
|
182
|
+
throw new Error("The API returned an invalid destructive-operation preflight.");
|
|
183
|
+
headers["x-borealis-preflight"] = preflight.preflightToken;
|
|
184
|
+
}
|
|
185
|
+
const path = `${request.path}${request.query.size ? `?${request.query}` : ""}`;
|
|
186
|
+
const result = await this.send(operation.method, path, headers, request.body);
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
async waitFor(result, timeoutSeconds) {
|
|
190
|
+
if (!result || typeof result !== "object")
|
|
191
|
+
return result;
|
|
192
|
+
const initial = result;
|
|
193
|
+
const rawUri = initial.statusUri ??
|
|
194
|
+
initial.operationUri ??
|
|
195
|
+
snapshotOperationStatusUri(initial);
|
|
196
|
+
if (typeof rawUri !== "string")
|
|
197
|
+
return result;
|
|
198
|
+
const uri = new URL(rawUri, this.options.api);
|
|
199
|
+
const apiUri = new URL(this.options.api);
|
|
200
|
+
const dedicatedWorkspaceStatus = uri.protocol === "https:" &&
|
|
201
|
+
uri.hostname === `uploads-${apiUri.hostname}` &&
|
|
202
|
+
(uri.port === "" || uri.port === "443") &&
|
|
203
|
+
/^\/api\/v1\/workspace-import-uploads\/[^/]+\/status$/.test(uri.pathname);
|
|
204
|
+
if ((uri.origin !== apiUri.origin && !dedicatedWorkspaceStatus) ||
|
|
205
|
+
!uri.pathname.startsWith("/api/v1/") ||
|
|
206
|
+
uri.username ||
|
|
207
|
+
uri.password ||
|
|
208
|
+
uri.hash)
|
|
209
|
+
throw new Error("The API returned an invalid operation status URI.");
|
|
210
|
+
const workspaceImport = /^\/api\/v1\/workspace-import-uploads\/[^/]+\/status$/.test(uri.pathname);
|
|
211
|
+
const workspaceUploadId = workspaceImport && typeof initial.uploadId === "string"
|
|
212
|
+
? initial.uploadId
|
|
213
|
+
: undefined;
|
|
214
|
+
const workspaceSandboxId = workspaceImport && typeof initial.sandboxId === "string"
|
|
215
|
+
? initial.sandboxId
|
|
216
|
+
: undefined;
|
|
217
|
+
if (workspaceImport) {
|
|
218
|
+
if (!workspaceUploadId || !workspaceSandboxId)
|
|
219
|
+
throw new Error("The API returned an invalid workspace import completion result.");
|
|
220
|
+
validateWorkspaceImportCompletion(initial, workspaceUploadId, workspaceSandboxId, new URL(uri.pathname.slice(0, -"/status".length), uri));
|
|
221
|
+
}
|
|
222
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
223
|
+
let current = result;
|
|
224
|
+
while (Date.now() < deadline) {
|
|
225
|
+
await delay(1_000, undefined, { signal: this.options.signal });
|
|
226
|
+
const remaining = deadline - Date.now();
|
|
227
|
+
if (remaining <= 0)
|
|
228
|
+
break;
|
|
229
|
+
current = await this.send("GET", uri.href, this.headers(), undefined, Math.min(this.options.requestTimeoutMs ?? 30_000, remaining));
|
|
230
|
+
if (workspaceImport) {
|
|
231
|
+
current = workspaceImportResultFromStatus(current, workspaceUploadId, workspaceSandboxId, uri.href);
|
|
232
|
+
}
|
|
233
|
+
const status = String(current?.status ?? "").toLowerCase();
|
|
234
|
+
if (["completed", "succeeded", "failed", "cancelled", "canceled"].includes(status))
|
|
235
|
+
return current;
|
|
236
|
+
}
|
|
237
|
+
throw new Error(`Operation did not complete within ${timeoutSeconds} seconds.`);
|
|
238
|
+
}
|
|
239
|
+
async exportWorkspace(request) {
|
|
240
|
+
if (!request.output)
|
|
241
|
+
throw new Error("Workspace export requires an output path or '-'.");
|
|
242
|
+
const key = request.idempotencyKey ?? randomUUID();
|
|
243
|
+
const resource = (await this.send("POST", request.path.replace(/\/workspace\/export$/, "/workspace/export-resource"), { ...this.headers(), "idempotency-key": key }));
|
|
244
|
+
const resourceUrl = new URL(resource.resourceUri, this.options.api);
|
|
245
|
+
const exportSandboxId = decodeURIComponent(request.path.match(/\/sandboxes\/([^/]+)/)?.[1] ?? "");
|
|
246
|
+
if (resourceUrl.origin !== new URL(this.options.api).origin ||
|
|
247
|
+
!resourceUrl.pathname.startsWith("/api/v1/workspace-export-resources/") ||
|
|
248
|
+
resource.resourceId !== key ||
|
|
249
|
+
resource.sandboxId !== exportSandboxId ||
|
|
250
|
+
!Number.isFinite(Date.parse(resource.expiresAt)) ||
|
|
251
|
+
Date.parse(resource.expiresAt) <= Date.now() ||
|
|
252
|
+
resource.contentType !== "application/x-tar" ||
|
|
253
|
+
(resource.contentLength != null &&
|
|
254
|
+
(!Number.isSafeInteger(resource.contentLength) ||
|
|
255
|
+
resource.contentLength < 0 ||
|
|
256
|
+
resource.contentLength > 100 * 1024 * 1024 * 1024)) ||
|
|
257
|
+
(resource.sha256 != null &&
|
|
258
|
+
(typeof resource.sha256 !== "string" ||
|
|
259
|
+
!/^[0-9a-f]{64}$/i.test(resource.sha256))) ||
|
|
260
|
+
resource.requiresAuthentication !== true) {
|
|
261
|
+
throw new Error("The API returned an invalid authenticated workspace export handle.");
|
|
262
|
+
}
|
|
263
|
+
const inactivity = new AbortController();
|
|
264
|
+
const streamSignal = this.options.signal
|
|
265
|
+
? AbortSignal.any([this.options.signal, inactivity.signal])
|
|
266
|
+
: inactivity.signal;
|
|
267
|
+
const deadline = createRequestDeadline(streamSignal, this.options.requestTimeoutMs ?? 30_000);
|
|
268
|
+
let response;
|
|
269
|
+
try {
|
|
270
|
+
response = await fetch(resourceUrl, {
|
|
271
|
+
headers: this.headers(),
|
|
272
|
+
redirect: "error",
|
|
273
|
+
signal: deadline.signal,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
deadline.dispose();
|
|
278
|
+
}
|
|
279
|
+
if (!response.ok)
|
|
280
|
+
throw new ApiError(response.status, `Workspace export failed (${response.status}).`);
|
|
281
|
+
if (response.headers.get("content-type")?.split(";", 1)[0] !==
|
|
282
|
+
"application/x-tar") {
|
|
283
|
+
throw new Error("Workspace export returned an unexpected content type.");
|
|
284
|
+
}
|
|
285
|
+
const temporaryDirectory = await mkdtemp(join(tmpdir(), "borealis-export-"));
|
|
286
|
+
const staged = join(temporaryDirectory, "workspace.tar");
|
|
287
|
+
let inactivityTimer;
|
|
288
|
+
const resetInactivity = () => {
|
|
289
|
+
if (inactivityTimer)
|
|
290
|
+
clearTimeout(inactivityTimer);
|
|
291
|
+
inactivityTimer = setTimeout(() => {
|
|
292
|
+
const error = new DOMException("Workspace export stalled.", "TimeoutError");
|
|
293
|
+
inactivity.abort(error);
|
|
294
|
+
}, this.options.streamInactivityTimeoutMs ?? 60_000);
|
|
295
|
+
inactivityTimer.unref();
|
|
296
|
+
};
|
|
297
|
+
try {
|
|
298
|
+
if (!response.body)
|
|
299
|
+
throw new Error("Workspace export returned no content.");
|
|
300
|
+
const hash = createHash("sha256");
|
|
301
|
+
let bytes = 0;
|
|
302
|
+
const reader = response.body.getReader();
|
|
303
|
+
const stagedFile = await open(staged, "wx", 0o600);
|
|
304
|
+
try {
|
|
305
|
+
resetInactivity();
|
|
306
|
+
while (true) {
|
|
307
|
+
const { value, done } = await readWithSignal(reader, streamSignal);
|
|
308
|
+
if (done)
|
|
309
|
+
break;
|
|
310
|
+
resetInactivity();
|
|
311
|
+
bytes += value.byteLength;
|
|
312
|
+
if (bytes > 100 * 1024 * 1024 * 1024)
|
|
313
|
+
throw new Error("Workspace export exceeded 100 GiB.");
|
|
314
|
+
hash.update(value);
|
|
315
|
+
const { bytesWritten } = await stagedFile.write(value);
|
|
316
|
+
if (bytesWritten !== value.byteLength)
|
|
317
|
+
throw new Error("Workspace export could not be fully staged.");
|
|
318
|
+
}
|
|
319
|
+
await stagedFile.sync();
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
void reader.cancel().catch(() => undefined);
|
|
323
|
+
await stagedFile.close();
|
|
324
|
+
}
|
|
325
|
+
if (resource.contentLength != null && bytes !== resource.contentLength)
|
|
326
|
+
throw new Error("Workspace export length did not match its handle.");
|
|
327
|
+
const digest = hash.digest("hex");
|
|
328
|
+
if (resource.sha256 != null &&
|
|
329
|
+
digest.toLowerCase() !== resource.sha256.toLowerCase())
|
|
330
|
+
throw new Error("Workspace export SHA-256 did not match its handle.");
|
|
331
|
+
await publishStagedWorkspaceExport(staged, request.output, this.options.signal);
|
|
332
|
+
return { output: request.output, bytes, sha256: digest };
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
if (inactivityTimer)
|
|
336
|
+
clearTimeout(inactivityTimer);
|
|
337
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async importWorkspace(request) {
|
|
341
|
+
const body = (request.body ?? {});
|
|
342
|
+
const archivePath = body.archivePath;
|
|
343
|
+
if (typeof archivePath !== "string" || archivePath === "-")
|
|
344
|
+
throw new Error("Workspace import requires a seekable archive file path; stdin is not supported.");
|
|
345
|
+
const metadata = await stat(archivePath);
|
|
346
|
+
if (!metadata.isFile() ||
|
|
347
|
+
metadata.size <= 0 ||
|
|
348
|
+
metadata.size > 100 * 1024 * 1024 * 1024)
|
|
349
|
+
throw new Error("Workspace import must be a non-empty file no larger than 100 GiB.");
|
|
350
|
+
const archiveHash = createHash("sha256");
|
|
351
|
+
for await (const chunk of createReadStream(archivePath)) {
|
|
352
|
+
this.options.signal?.throwIfAborted();
|
|
353
|
+
archiveHash.update(chunk);
|
|
354
|
+
}
|
|
355
|
+
const sha256 = archiveHash.digest("hex");
|
|
356
|
+
const key = request.idempotencyKey ?? randomUUID();
|
|
357
|
+
const upload = (await this.send("POST", request.path, { ...this.headers(), "idempotency-key": key }, {
|
|
358
|
+
clearWorkspace: body.clearWorkspace !== false,
|
|
359
|
+
contentType: "application/x-tar",
|
|
360
|
+
contentLength: metadata.size,
|
|
361
|
+
sha256,
|
|
362
|
+
}));
|
|
363
|
+
const uploadUrl = new URL(upload.uploadUri, this.options.api);
|
|
364
|
+
const apiUrl = new URL(this.options.api);
|
|
365
|
+
const importSandboxId = decodeURIComponent(request.path.match(/\/sandboxes\/([^/]+)/)?.[1] ?? "");
|
|
366
|
+
const sameOrigin = uploadUrl.origin === apiUrl.origin;
|
|
367
|
+
const dedicatedOrigin = uploadUrl.protocol === "https:" &&
|
|
368
|
+
uploadUrl.hostname === `uploads-${apiUrl.hostname}` &&
|
|
369
|
+
(uploadUrl.port === "" || uploadUrl.port === "443");
|
|
370
|
+
if ((!sameOrigin && !dedicatedOrigin) ||
|
|
371
|
+
uploadUrl.username ||
|
|
372
|
+
uploadUrl.password ||
|
|
373
|
+
uploadUrl.search ||
|
|
374
|
+
uploadUrl.hash ||
|
|
375
|
+
uploadUrl.pathname !==
|
|
376
|
+
`/api/v1/workspace-import-uploads/${encodeURIComponent(upload.uploadId)}` ||
|
|
377
|
+
upload.uploadId !== key ||
|
|
378
|
+
upload.sandboxId !== importSandboxId ||
|
|
379
|
+
![
|
|
380
|
+
"application/x-tar",
|
|
381
|
+
"application/tar",
|
|
382
|
+
"application/x-gtar",
|
|
383
|
+
"application/zip",
|
|
384
|
+
"application/x-zip-compressed",
|
|
385
|
+
"application/gzip",
|
|
386
|
+
"application/x-gzip",
|
|
387
|
+
"application/gzip-compressed",
|
|
388
|
+
].includes(upload.contentType.split(";", 1)[0].toLowerCase()) ||
|
|
389
|
+
!Number.isFinite(Date.parse(upload.expiresAt)) ||
|
|
390
|
+
Date.parse(upload.expiresAt) <= Date.now() ||
|
|
391
|
+
upload.requiresAuthentication !== true ||
|
|
392
|
+
upload.maximumBytes <= 0 ||
|
|
393
|
+
upload.maximumBytes > 100 * 1024 * 1024 * 1024 ||
|
|
394
|
+
metadata.size > upload.maximumBytes)
|
|
395
|
+
throw new Error("The API returned an invalid authenticated workspace upload handle.");
|
|
396
|
+
const file = await open(archivePath, "r");
|
|
397
|
+
try {
|
|
398
|
+
let status = await this.getWorkspaceUploadStatus(uploadUrl, upload.uploadId, metadata.size);
|
|
399
|
+
const chunkSize = status.maximumChunkBytes;
|
|
400
|
+
let offset = status.nextOffset;
|
|
401
|
+
while (offset < metadata.size) {
|
|
402
|
+
const length = Math.min(chunkSize, metadata.size - offset);
|
|
403
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
404
|
+
const { bytesRead } = await file.read(chunk, 0, length, offset);
|
|
405
|
+
if (bytesRead !== length)
|
|
406
|
+
throw new Error("Workspace archive changed while it was being uploaded.");
|
|
407
|
+
const final = offset + length === metadata.size;
|
|
408
|
+
let uploaded = false;
|
|
409
|
+
let lastError;
|
|
410
|
+
for (let attempt = 1; attempt <= 3 && !uploaded; attempt++) {
|
|
411
|
+
try {
|
|
412
|
+
const deadline = createRequestDeadline(this.options.signal, this.options.requestTimeoutMs ?? 60_000);
|
|
413
|
+
let response;
|
|
414
|
+
let responseText;
|
|
415
|
+
try {
|
|
416
|
+
response = await fetch(uploadUrl, {
|
|
417
|
+
method: "PUT",
|
|
418
|
+
headers: {
|
|
419
|
+
...this.headers(),
|
|
420
|
+
"content-type": "application/x-tar",
|
|
421
|
+
"content-range": `bytes ${offset}-${offset + length - 1}/${metadata.size}`,
|
|
422
|
+
"x-borealis-chunk-sha256": createHash("sha256")
|
|
423
|
+
.update(chunk)
|
|
424
|
+
.digest("hex"),
|
|
425
|
+
...(final ? { "x-borealis-archive-sha256": sha256 } : {}),
|
|
426
|
+
},
|
|
427
|
+
body: chunk,
|
|
428
|
+
redirect: "error",
|
|
429
|
+
signal: deadline.signal,
|
|
430
|
+
});
|
|
431
|
+
if (!response.ok)
|
|
432
|
+
throw new ApiError(response.status, `Workspace upload failed (${response.status}).`);
|
|
433
|
+
responseText = await boundedText(response, 10 * 1024 * 1024, deadline.signal);
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
deadline.dispose();
|
|
437
|
+
}
|
|
438
|
+
status = JSON.parse(responseText);
|
|
439
|
+
if (status.nextOffset !== offset + length)
|
|
440
|
+
throw new Error("The API returned an unexpected workspace upload offset.");
|
|
441
|
+
uploaded = true;
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
lastError = error;
|
|
445
|
+
if (error instanceof ApiError &&
|
|
446
|
+
error.status !== 408 &&
|
|
447
|
+
error.status !== 429 &&
|
|
448
|
+
error.status < 500)
|
|
449
|
+
throw error;
|
|
450
|
+
status = await this.getWorkspaceUploadStatus(uploadUrl, upload.uploadId, metadata.size);
|
|
451
|
+
if (status.nextOffset === offset + length)
|
|
452
|
+
uploaded = true;
|
|
453
|
+
else if (status.nextOffset !== offset || attempt === 3)
|
|
454
|
+
throw lastError;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
offset += length;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
await file.close();
|
|
462
|
+
}
|
|
463
|
+
const target = targetFromPath(operationForImport, request.path);
|
|
464
|
+
const preflight = (await this.send("POST", "/api/v1/preflights", this.headers(), { operationId: "sandbox.workspace.import", target }));
|
|
465
|
+
if (!preflight.preflightToken)
|
|
466
|
+
throw new Error("The API returned an invalid workspace import preflight.");
|
|
467
|
+
const completion = await this.send("POST", `${request.path}/${encodeURIComponent(upload.uploadId)}/complete`, { ...this.headers(), "x-borealis-preflight": preflight.preflightToken }, { contentLength: metadata.size, sha256 });
|
|
468
|
+
return validateWorkspaceImportCompletion(completion, upload.uploadId, importSandboxId, uploadUrl);
|
|
469
|
+
}
|
|
470
|
+
async getWorkspaceUploadStatus(uploadUrl, uploadId, totalLength) {
|
|
471
|
+
const deadline = createRequestDeadline(this.options.signal, this.options.requestTimeoutMs ?? 30_000);
|
|
472
|
+
let statusResponse;
|
|
473
|
+
let statusText;
|
|
474
|
+
try {
|
|
475
|
+
statusResponse = await fetch(`${uploadUrl}/status`, {
|
|
476
|
+
headers: this.headers(),
|
|
477
|
+
redirect: "error",
|
|
478
|
+
signal: deadline.signal,
|
|
479
|
+
});
|
|
480
|
+
if (!statusResponse.ok)
|
|
481
|
+
throw new ApiError(statusResponse.status, `Workspace upload status failed (${statusResponse.status}).`);
|
|
482
|
+
statusText = await boundedText(statusResponse, 10 * 1024 * 1024, deadline.signal);
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
deadline.dispose();
|
|
486
|
+
}
|
|
487
|
+
const status = JSON.parse(statusText);
|
|
488
|
+
const totalUnset = status.totalLength === 0 &&
|
|
489
|
+
status.nextOffset === 0 &&
|
|
490
|
+
status.committed === false;
|
|
491
|
+
const totalValid = totalUnset ||
|
|
492
|
+
(status.totalLength === totalLength &&
|
|
493
|
+
status.nextOffset >= 0 &&
|
|
494
|
+
status.nextOffset <= totalLength &&
|
|
495
|
+
(status.nextOffset === totalLength ||
|
|
496
|
+
status.nextOffset % (8 * 1024 * 1024) === 0) &&
|
|
497
|
+
status.committed === (status.nextOffset === totalLength));
|
|
498
|
+
if (status.uploadId !== uploadId ||
|
|
499
|
+
status.maximumChunkBytes !== 8 * 1024 * 1024 ||
|
|
500
|
+
!totalValid)
|
|
501
|
+
throw new Error("The API returned an invalid workspace upload status.");
|
|
502
|
+
return status;
|
|
503
|
+
}
|
|
504
|
+
async send(method, path, headers, body, timeoutMs = this.options.requestTimeoutMs ?? 30_000) {
|
|
505
|
+
const deadline = createRequestDeadline(this.options.signal, timeoutMs);
|
|
506
|
+
try {
|
|
507
|
+
const response = await fetch(new URL(path, this.options.api), {
|
|
508
|
+
method,
|
|
509
|
+
headers: {
|
|
510
|
+
...headers,
|
|
511
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
512
|
+
},
|
|
513
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
514
|
+
redirect: "error",
|
|
515
|
+
signal: deadline.signal,
|
|
516
|
+
});
|
|
517
|
+
if (!response.ok) {
|
|
518
|
+
const text = await boundedText(response, 1024 * 1024, deadline.signal);
|
|
519
|
+
let message = `Borealis API request failed (${response.status}).`;
|
|
520
|
+
let traceId;
|
|
521
|
+
try {
|
|
522
|
+
const problem = JSON.parse(text);
|
|
523
|
+
message = problem.detail ?? problem.title ?? message;
|
|
524
|
+
traceId = problem.traceId;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
/* use the stable status message */
|
|
528
|
+
}
|
|
529
|
+
throw new ApiError(response.status, message, traceId);
|
|
530
|
+
}
|
|
531
|
+
if (response.status === 204)
|
|
532
|
+
return { success: true };
|
|
533
|
+
const mediaType = response.headers.get("content-type") ?? "";
|
|
534
|
+
if (mediaType.includes("application/json") || mediaType.includes("+json"))
|
|
535
|
+
return JSON.parse(await boundedText(response, undefined, deadline.signal));
|
|
536
|
+
const bytes = await boundedBytes(response, 10 * 1024 * 1024, deadline.signal);
|
|
537
|
+
if (response.status === 202 && bytes.byteLength === 0)
|
|
538
|
+
return { accepted: true };
|
|
539
|
+
return bytes;
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
deadline.dispose();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
export function validateWorkspaceImportCompletion(value, uploadId, sandboxId, uploadUrl) {
|
|
547
|
+
if (!value || typeof value !== "object")
|
|
548
|
+
throw new Error("The API returned an invalid workspace import completion result.");
|
|
549
|
+
const result = value;
|
|
550
|
+
if (result.uploadId !== uploadId || result.sandboxId !== sandboxId)
|
|
551
|
+
throw new Error("The API returned a workspace import result for a different handle.");
|
|
552
|
+
if (typeof result.status !== "string")
|
|
553
|
+
throw new Error("The API returned an invalid workspace import completion result.");
|
|
554
|
+
const status = result.status;
|
|
555
|
+
const completedAtValid = typeof result.completedAt === "string" &&
|
|
556
|
+
Number.isFinite(Date.parse(result.completedAt));
|
|
557
|
+
const pending = status === "queued" || status === "processing";
|
|
558
|
+
if (!((status === "completed" && result.imported === true && completedAtValid) ||
|
|
559
|
+
(pending &&
|
|
560
|
+
result.imported === false &&
|
|
561
|
+
result.completedAt == null &&
|
|
562
|
+
typeof result.statusUri === "string")))
|
|
563
|
+
throw new Error("The API returned an invalid workspace import completion result.");
|
|
564
|
+
if (result.statusUri != null) {
|
|
565
|
+
if (typeof result.statusUri !== "string")
|
|
566
|
+
throw new Error("The API returned an invalid workspace import status URI.");
|
|
567
|
+
let statusUri;
|
|
568
|
+
try {
|
|
569
|
+
statusUri = new URL(result.statusUri);
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
throw new Error("The API returned an invalid workspace import status URI.");
|
|
573
|
+
}
|
|
574
|
+
if (statusUri.origin !== uploadUrl.origin ||
|
|
575
|
+
statusUri.username ||
|
|
576
|
+
statusUri.password ||
|
|
577
|
+
statusUri.search ||
|
|
578
|
+
statusUri.hash ||
|
|
579
|
+
statusUri.pathname !== `${uploadUrl.pathname}/status`)
|
|
580
|
+
throw new Error("The API returned an invalid workspace import status URI.");
|
|
581
|
+
}
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
function workspaceImportResultFromStatus(value, uploadId, sandboxId, statusUri) {
|
|
585
|
+
if (!value || typeof value !== "object")
|
|
586
|
+
throw new Error("The API returned an invalid workspace import status.");
|
|
587
|
+
const status = value;
|
|
588
|
+
const importStatus = typeof status.importStatus === "string" ? status.importStatus : "";
|
|
589
|
+
const totalLength = status.totalLength;
|
|
590
|
+
const nextOffset = status.nextOffset;
|
|
591
|
+
const completedAtValid = typeof status.importCompletedAt === "string" &&
|
|
592
|
+
Number.isFinite(Date.parse(status.importCompletedAt));
|
|
593
|
+
const progressValid = typeof totalLength === "number" &&
|
|
594
|
+
Number.isSafeInteger(totalLength) &&
|
|
595
|
+
totalLength > 0 &&
|
|
596
|
+
typeof nextOffset === "number" &&
|
|
597
|
+
Number.isSafeInteger(nextOffset) &&
|
|
598
|
+
nextOffset >= 0 &&
|
|
599
|
+
nextOffset <= totalLength;
|
|
600
|
+
const stateValid = ((importStatus === "queued" || importStatus === "processing") &&
|
|
601
|
+
status.importCompletedAt == null) ||
|
|
602
|
+
(importStatus === "completed" &&
|
|
603
|
+
completedAtValid &&
|
|
604
|
+
status.committed === true &&
|
|
605
|
+
nextOffset === totalLength) ||
|
|
606
|
+
(importStatus === "failed" && status.importCompletedAt == null);
|
|
607
|
+
if (status.uploadId !== uploadId || !progressValid || !stateValid)
|
|
608
|
+
throw new Error("The API returned an invalid workspace import status.");
|
|
609
|
+
return {
|
|
610
|
+
uploadId,
|
|
611
|
+
sandboxId,
|
|
612
|
+
imported: importStatus === "completed",
|
|
613
|
+
completedAt: importStatus === "completed" ? status.importCompletedAt : null,
|
|
614
|
+
status: importStatus,
|
|
615
|
+
statusUri,
|
|
616
|
+
...(typeof status.importErrorCode === "string"
|
|
617
|
+
? { importErrorCode: status.importErrorCode }
|
|
618
|
+
: {}),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
export async function publishStagedWorkspaceExport(staged, destination, signal) {
|
|
622
|
+
const copy = async (source, target) => {
|
|
623
|
+
if (signal)
|
|
624
|
+
await pipeline(source, target, { signal });
|
|
625
|
+
else
|
|
626
|
+
await pipeline(source, target);
|
|
627
|
+
};
|
|
628
|
+
if (destination === "-") {
|
|
629
|
+
await copy(createReadStream(staged), process.stdout);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const temporaryDestination = join(dirname(destination), `.${basename(destination)}.${randomUUID()}.tmp`);
|
|
633
|
+
try {
|
|
634
|
+
await copy(createReadStream(staged), createWriteStream(temporaryDestination, { flags: "wx", mode: 0o600 }));
|
|
635
|
+
signal?.throwIfAborted();
|
|
636
|
+
await link(temporaryDestination, destination);
|
|
637
|
+
await rm(temporaryDestination);
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
await rm(temporaryDestination, { force: true });
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function isAmbiguousDispatchFailure(error) {
|
|
645
|
+
if (error instanceof ApiError)
|
|
646
|
+
return error.status === 408 || error.status === 429 || error.status >= 500;
|
|
647
|
+
if (error instanceof TypeError)
|
|
648
|
+
return true;
|
|
649
|
+
if (error instanceof DOMException &&
|
|
650
|
+
["AbortError", "TimeoutError"].includes(error.name))
|
|
651
|
+
return true;
|
|
652
|
+
const code = error?.code;
|
|
653
|
+
return Boolean(code &&
|
|
654
|
+
(code === "ETIMEDOUT" ||
|
|
655
|
+
code === "ECONNRESET" ||
|
|
656
|
+
code.startsWith("UND_ERR_")));
|
|
657
|
+
}
|
|
658
|
+
const operationForImport = {
|
|
659
|
+
operationId: "sandbox.workspace.import",
|
|
660
|
+
method: "POST",
|
|
661
|
+
path: "/api/v1/sandboxes/{sandboxId}/workspace/import-uploads",
|
|
662
|
+
scope: "borealis.sandboxes.write",
|
|
663
|
+
risk: "destructive",
|
|
664
|
+
clientMethod: "ImportWorkspaceAsync",
|
|
665
|
+
command: "sandbox workspace import",
|
|
666
|
+
mcpName: "sandbox_workspace_import",
|
|
667
|
+
ownership: "organization",
|
|
668
|
+
idempotency: "idempotency-key",
|
|
669
|
+
retry: "safe",
|
|
670
|
+
paging: "none",
|
|
671
|
+
requiresPreflight: true,
|
|
672
|
+
};
|
|
673
|
+
//# sourceMappingURL=api-client.js.map
|