@runblackcat/sdk 0.1.0-alpha.100
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 +35 -0
- package/README.md +406 -0
- package/package.json +29 -0
- package/sdk/dist/index.d.ts +1020 -0
- package/sdk/dist/index.js +751 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
// sdk/src/index.ts
|
|
2
|
+
var CAPABILITY_OPS = {
|
|
3
|
+
EXEC: 1,
|
|
4
|
+
READ: 2,
|
|
5
|
+
WRITE: 4,
|
|
6
|
+
SNAPSHOT: 8,
|
|
7
|
+
DESTROY: 16,
|
|
8
|
+
INSPECT: 32,
|
|
9
|
+
SANDBOX_CREATE: 64,
|
|
10
|
+
TEMPLATE_RW: 128,
|
|
11
|
+
PUBLISH: 256,
|
|
12
|
+
PROCESS_START: 512,
|
|
13
|
+
PROCESS_INSPECT: 1024,
|
|
14
|
+
PROCESS_CANCEL: 2048,
|
|
15
|
+
RENEW: 4096
|
|
16
|
+
};
|
|
17
|
+
var BLACKCAT_ERROR_CODES = {
|
|
18
|
+
SANDBOX_TRANSITIONING: "sandbox_transitioning",
|
|
19
|
+
FLEET_UPGRADE_QUIESCING: "fleet_upgrade_quiescing"
|
|
20
|
+
};
|
|
21
|
+
function withTemplate(input) {
|
|
22
|
+
return { ...input, template: input.template ?? "default" };
|
|
23
|
+
}
|
|
24
|
+
function waitQuery({ wait_secs }) {
|
|
25
|
+
return wait_secs === undefined ? "" : `?wait_secs=${wait_secs}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class BlackcatApiError extends Error {
|
|
29
|
+
status;
|
|
30
|
+
code;
|
|
31
|
+
retryAfter;
|
|
32
|
+
requestId;
|
|
33
|
+
details;
|
|
34
|
+
constructor(status, message, details, retryAfter, requestId) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "BlackcatApiError";
|
|
37
|
+
this.status = status;
|
|
38
|
+
this.details = details;
|
|
39
|
+
if (typeof details === "object" && details !== null && !Array.isArray(details)) {
|
|
40
|
+
const code = details.code;
|
|
41
|
+
if (typeof code === "string")
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
this.retryAfter = retryAfter;
|
|
45
|
+
this.requestId = typeof requestId === "string" && requestId.length === 32 && /^[a-f0-9]{32}$/i.test(requestId) ? requestId : undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class BlackcatProtocolError extends Error {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "BlackcatProtocolError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
var MAX_FILE_CHUNK = 512 * 1024;
|
|
56
|
+
var MAX_FILE_READ_CHUNK = 1e6;
|
|
57
|
+
var MAX_PROCESS_STDIN = 512 * 1024;
|
|
58
|
+
var MAX_EXEC_STDIN = 512 * 1024;
|
|
59
|
+
function execDeadline(input) {
|
|
60
|
+
const transportMs = input.timeout_ms + 40000;
|
|
61
|
+
if (!Number.isSafeInteger(input.timeout_ms) || input.timeout_ms <= 0 || !Number.isSafeInteger(transportMs)) {
|
|
62
|
+
throw new RangeError("EXEC timeout_ms must be a positive safe integer with room for the transport allowance");
|
|
63
|
+
}
|
|
64
|
+
return AbortSignal.timeout(transportMs);
|
|
65
|
+
}
|
|
66
|
+
function pathSegment(value) {
|
|
67
|
+
return encodeURIComponent(value);
|
|
68
|
+
}
|
|
69
|
+
function bytes(value, field) {
|
|
70
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "number")) {
|
|
71
|
+
throw new BlackcatProtocolError(`gateway response field ${field} is not a byte array`);
|
|
72
|
+
}
|
|
73
|
+
return Uint8Array.from(value);
|
|
74
|
+
}
|
|
75
|
+
function requestBody(data) {
|
|
76
|
+
const copy = new ArrayBuffer(data.byteLength);
|
|
77
|
+
new Uint8Array(copy).set(data);
|
|
78
|
+
return copy;
|
|
79
|
+
}
|
|
80
|
+
function epochMillis(value, field) {
|
|
81
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
82
|
+
throw new BlackcatProtocolError(`${field} is not an epoch-millisecond timestamp`);
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
function isSystemTime(value) {
|
|
87
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
88
|
+
return false;
|
|
89
|
+
const row = value;
|
|
90
|
+
return Number.isSafeInteger(row.secs_since_epoch) && Number(row.secs_since_epoch) >= 0 && Number.isSafeInteger(row.nanos_since_epoch) && Number(row.nanos_since_epoch) >= 0 && Number(row.nanos_since_epoch) < 1e9;
|
|
91
|
+
}
|
|
92
|
+
function parseCreateSandboxResponse(value, requestId) {
|
|
93
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
94
|
+
throw new BlackcatProtocolError("sandbox create receipt is malformed or belongs to another request");
|
|
95
|
+
}
|
|
96
|
+
const row = value;
|
|
97
|
+
if (row.request_id === requestId && row.state === "prevented") {
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
if (row.request_id !== requestId || typeof row.id !== "string" || row.id.length === 0 || !["boot", "warm_pool", "restore"].includes(String(row.tier)) || !["placed", "pending", "failed", "destroyed"].includes(String(row.state)) || row.state === "placed" && typeof row.node !== "string" || row.state === "pending" && row.node !== null && typeof row.node !== "string" || row.state === "failed" && (row.node !== null || typeof row.error !== "string") || row.state === "destroyed" && (row.node !== null && typeof row.node !== "string" || typeof row.error !== "string") || !isSystemTime(row.expires_at) || !isSystemTime(row.renewable_until)) {
|
|
101
|
+
throw new BlackcatProtocolError("sandbox create receipt is malformed or belongs to another request");
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
var BATCH_STATES = ["pending", "placed", "failed", "destroyed"];
|
|
106
|
+
function parseSandboxBatch(value, requestId) {
|
|
107
|
+
const malformed = () => new BlackcatProtocolError("sandbox batch is malformed or belongs to another request");
|
|
108
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
109
|
+
throw malformed();
|
|
110
|
+
const row = value;
|
|
111
|
+
if (row.request_id === requestId && row.state === "prevented") {
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
if (row.request_id !== requestId || typeof row.count !== "number" || typeof row.counts !== "object" || row.counts === null || !Array.isArray(row.members) || row.members.length !== row.count || !row.members.every((member) => {
|
|
115
|
+
const m = member;
|
|
116
|
+
return typeof m === "object" && m !== null && typeof m.request_id === "string" && typeof m.id === "string" && BATCH_STATES.includes(String(m.state));
|
|
117
|
+
})) {
|
|
118
|
+
throw malformed();
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
function parseMintTokenResponse(value) {
|
|
123
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
124
|
+
throw new BlackcatProtocolError("token mint response is not an object");
|
|
125
|
+
}
|
|
126
|
+
const row = value;
|
|
127
|
+
if (typeof row.token !== "string" || typeof row.token_id !== "string") {
|
|
128
|
+
throw new BlackcatProtocolError("token mint response is missing the token");
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
token: row.token,
|
|
132
|
+
token_id: row.token_id,
|
|
133
|
+
expires_at: epochMillis(row.expires_at, "token expires_at"),
|
|
134
|
+
renewable_until: epochMillis(row.renewable_until, "token renewable_until")
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function assertSecureBlackcatGatewayUrl(baseUrl) {
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = new URL(baseUrl);
|
|
141
|
+
} catch {
|
|
142
|
+
throw new Error(`Blackcat gateway URL is invalid: ${baseUrl}`);
|
|
143
|
+
}
|
|
144
|
+
if (parsed.protocol !== "https:") {
|
|
145
|
+
throw new Error("Blackcat gateway URL must use https when API-key authenticated");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function execResult(value) {
|
|
149
|
+
if (typeof value !== "object" || value === null) {
|
|
150
|
+
throw new BlackcatProtocolError("gateway exec response is not an object");
|
|
151
|
+
}
|
|
152
|
+
const row = value;
|
|
153
|
+
const exitCode = row.exit_code;
|
|
154
|
+
if (exitCode !== null && typeof exitCode !== "number") {
|
|
155
|
+
throw new BlackcatProtocolError("gateway exec response has an invalid exit_code");
|
|
156
|
+
}
|
|
157
|
+
if (typeof row.timed_out !== "boolean" || row.timed_out && exitCode !== null) {
|
|
158
|
+
throw new BlackcatProtocolError("gateway exec response has an invalid timed_out field");
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
exit_code: exitCode,
|
|
162
|
+
timed_out: row.timed_out,
|
|
163
|
+
stdout: bytes(row.stdout, "stdout"),
|
|
164
|
+
stderr: bytes(row.stderr, "stderr")
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function execStreamChunk(value) {
|
|
168
|
+
if (typeof value !== "object" || value === null) {
|
|
169
|
+
throw new BlackcatProtocolError("gateway exec stream item is not an object");
|
|
170
|
+
}
|
|
171
|
+
const row = value;
|
|
172
|
+
if (typeof row.done !== "boolean") {
|
|
173
|
+
throw new BlackcatProtocolError("gateway exec stream item has an invalid done field");
|
|
174
|
+
}
|
|
175
|
+
const exitCode = row.exit_code;
|
|
176
|
+
if (exitCode !== null && typeof exitCode !== "number") {
|
|
177
|
+
throw new BlackcatProtocolError("gateway exec stream item has an invalid exit_code");
|
|
178
|
+
}
|
|
179
|
+
if (typeof row.timed_out !== "boolean" || row.timed_out && (!row.done || exitCode !== null)) {
|
|
180
|
+
throw new BlackcatProtocolError("gateway exec stream item has an invalid timed_out field");
|
|
181
|
+
}
|
|
182
|
+
if (!row.done && exitCode !== null) {
|
|
183
|
+
throw new BlackcatProtocolError("gateway exec stream item has an exit_code before completion");
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
stdout: bytes(row.stdout, "stdout"),
|
|
187
|
+
stderr: bytes(row.stderr, "stderr"),
|
|
188
|
+
done: row.done,
|
|
189
|
+
exit_code: exitCode,
|
|
190
|
+
timed_out: row.timed_out
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
async function apiError(response) {
|
|
194
|
+
const text = await response.text();
|
|
195
|
+
let details = text;
|
|
196
|
+
let message = text || response.statusText || `Blackcat request failed (${response.status})`;
|
|
197
|
+
if (text) {
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(text);
|
|
200
|
+
details = parsed;
|
|
201
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
202
|
+
const error = parsed.error;
|
|
203
|
+
if (typeof error === "string")
|
|
204
|
+
message = error;
|
|
205
|
+
}
|
|
206
|
+
} catch {}
|
|
207
|
+
}
|
|
208
|
+
const retryHeader = response.headers.get("retry-after");
|
|
209
|
+
const retryAfter = retryHeader === null ? undefined : Number(retryHeader);
|
|
210
|
+
return new BlackcatApiError(response.status, message, details, retryAfter !== undefined && Number.isFinite(retryAfter) ? retryAfter : undefined, response.headers.get("x-blackcat-request-id") ?? undefined);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
class GatewayClient {
|
|
214
|
+
baseUrl;
|
|
215
|
+
apiKey;
|
|
216
|
+
token;
|
|
217
|
+
tokenId;
|
|
218
|
+
tokenExpiresAt;
|
|
219
|
+
renewCapability;
|
|
220
|
+
caPem;
|
|
221
|
+
fetcher;
|
|
222
|
+
renewalInFlight;
|
|
223
|
+
constructor(options) {
|
|
224
|
+
this.baseUrl = options.baseUrl.endsWith("/") ? options.baseUrl : `${options.baseUrl}/`;
|
|
225
|
+
this.apiKey = options.apiKey;
|
|
226
|
+
this.token = options.token;
|
|
227
|
+
this.tokenId = options.tokenId ?? options.token_id;
|
|
228
|
+
this.tokenExpiresAt = options.tokenExpiresAt ?? options.token_expires_at;
|
|
229
|
+
this.renewCapability = options.renewCapability;
|
|
230
|
+
this.caPem = options.caPem;
|
|
231
|
+
this.fetcher = options.fetch ?? globalThis.fetch;
|
|
232
|
+
}
|
|
233
|
+
credential(kind) {
|
|
234
|
+
const credential = kind === "apiKey" ? this.apiKey : kind === "token" ? this.token : this.token ?? this.apiKey;
|
|
235
|
+
if (!credential) {
|
|
236
|
+
throw new Error(kind === "apiKey" ? "Blackcat API key is required for this operation" : "Blackcat bearer credential is required for this operation");
|
|
237
|
+
}
|
|
238
|
+
return credential;
|
|
239
|
+
}
|
|
240
|
+
async json(method, path, body, kind = "either", timeout, signal, extraHeaders) {
|
|
241
|
+
if (kind !== "apiKey" && !path.endsWith("/renew"))
|
|
242
|
+
await this.renewIfNeeded(signal);
|
|
243
|
+
const headers = new Headers({ Authorization: `Bearer ${this.credential(kind)}` });
|
|
244
|
+
for (const [name, value] of new Headers(extraHeaders))
|
|
245
|
+
headers.set(name, value);
|
|
246
|
+
if (body !== undefined)
|
|
247
|
+
headers.set("content-type", "application/json");
|
|
248
|
+
const response = await this.fetcher(new URL(path, this.baseUrl), {
|
|
249
|
+
method,
|
|
250
|
+
headers,
|
|
251
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
252
|
+
...timeout === false ? { timeout } : {},
|
|
253
|
+
...this.caPem === undefined ? {} : { tls: { ca: this.caPem } },
|
|
254
|
+
signal
|
|
255
|
+
});
|
|
256
|
+
if (!response.ok)
|
|
257
|
+
throw await apiError(response);
|
|
258
|
+
if (response.status === 204)
|
|
259
|
+
return;
|
|
260
|
+
return await response.json();
|
|
261
|
+
}
|
|
262
|
+
async raw(method, path, body, kind = "either", contentType, timeout, signal) {
|
|
263
|
+
if (kind !== "apiKey" && !path.endsWith("/renew"))
|
|
264
|
+
await this.renewIfNeeded(signal);
|
|
265
|
+
const headers = new Headers({ Authorization: `Bearer ${this.credential(kind)}` });
|
|
266
|
+
if (contentType)
|
|
267
|
+
headers.set("content-type", contentType);
|
|
268
|
+
const response = await this.fetcher(new URL(path, this.baseUrl), {
|
|
269
|
+
method,
|
|
270
|
+
headers,
|
|
271
|
+
body,
|
|
272
|
+
signal,
|
|
273
|
+
...timeout === false ? { timeout } : {},
|
|
274
|
+
...this.caPem === undefined ? {} : { tls: { ca: this.caPem } }
|
|
275
|
+
});
|
|
276
|
+
if (!response.ok)
|
|
277
|
+
throw await apiError(response);
|
|
278
|
+
return response;
|
|
279
|
+
}
|
|
280
|
+
async renewIfNeeded(signal) {
|
|
281
|
+
if (!this.tokenId || !this.tokenExpiresAt || this.tokenExpiresAt - Date.now() > 30000)
|
|
282
|
+
return;
|
|
283
|
+
if (!this.renewalInFlight) {
|
|
284
|
+
const tokenId = this.tokenId;
|
|
285
|
+
this.renewalInFlight = (async () => {
|
|
286
|
+
if (this.renewCapability) {
|
|
287
|
+
const minted = parseMintTokenResponse(await this.renewCapability());
|
|
288
|
+
this.token = minted.token;
|
|
289
|
+
this.tokenId = minted.token_id;
|
|
290
|
+
this.tokenExpiresAt = minted.expires_at;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const result = await this.json("POST", `/v1/tokens/${pathSegment(tokenId)}/renew`, undefined, "token", undefined, signal);
|
|
294
|
+
this.tokenExpiresAt = epochMillis(result.expires_at, "token renewal expires_at");
|
|
295
|
+
})().finally(() => {
|
|
296
|
+
this.renewalInFlight = undefined;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
await this.renewalInFlight;
|
|
300
|
+
}
|
|
301
|
+
async* stream(method, path, body, kind = "either", contentType, renew = false, signal) {
|
|
302
|
+
if (renew)
|
|
303
|
+
await this.renewIfNeeded(signal);
|
|
304
|
+
const response = await this.raw(method, path, body, kind, contentType, false, signal);
|
|
305
|
+
if (!response.body)
|
|
306
|
+
throw new BlackcatProtocolError("gateway response has no stream body");
|
|
307
|
+
const reader = response.body.getReader();
|
|
308
|
+
let timer;
|
|
309
|
+
let closed = false;
|
|
310
|
+
let rejectRenewal;
|
|
311
|
+
const renewalFailure = new Promise((_, reject) => {
|
|
312
|
+
rejectRenewal = reject;
|
|
313
|
+
});
|
|
314
|
+
const scheduleRenewal = () => {
|
|
315
|
+
if (!renew || !this.tokenId || !this.tokenExpiresAt)
|
|
316
|
+
return;
|
|
317
|
+
const delay = Math.max(250, this.tokenExpiresAt - Date.now() - 30000);
|
|
318
|
+
timer = setTimeout(async () => {
|
|
319
|
+
if (closed)
|
|
320
|
+
return;
|
|
321
|
+
try {
|
|
322
|
+
await this.renewIfNeeded(signal);
|
|
323
|
+
if (!closed)
|
|
324
|
+
scheduleRenewal();
|
|
325
|
+
} catch (error) {
|
|
326
|
+
rejectRenewal(error);
|
|
327
|
+
}
|
|
328
|
+
}, delay);
|
|
329
|
+
};
|
|
330
|
+
scheduleRenewal();
|
|
331
|
+
try {
|
|
332
|
+
while (true) {
|
|
333
|
+
const part = await Promise.race([reader.read(), renewalFailure]);
|
|
334
|
+
if (part.done)
|
|
335
|
+
return;
|
|
336
|
+
yield part.value;
|
|
337
|
+
}
|
|
338
|
+
} finally {
|
|
339
|
+
closed = true;
|
|
340
|
+
if (timer !== undefined)
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
try {
|
|
343
|
+
await reader.cancel();
|
|
344
|
+
} finally {
|
|
345
|
+
reader.releaseLock();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
class TemplatesResource {
|
|
352
|
+
client;
|
|
353
|
+
constructor(client) {
|
|
354
|
+
this.client = client;
|
|
355
|
+
}
|
|
356
|
+
create(input) {
|
|
357
|
+
return this.client.json("POST", "/v1/templates", input, "either");
|
|
358
|
+
}
|
|
359
|
+
list() {
|
|
360
|
+
return this.client.json("GET", "/v1/templates", undefined, "either");
|
|
361
|
+
}
|
|
362
|
+
async uploadImage(archive, options) {
|
|
363
|
+
const response = await this.client.raw("POST", "/v1/template-images", archive, "either", "application/octet-stream", false, options?.signal);
|
|
364
|
+
return await response.json();
|
|
365
|
+
}
|
|
366
|
+
activate(id) {
|
|
367
|
+
return this.client.json("POST", `/v1/templates/${pathSegment(id)}/activate`, undefined, "either");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
class TemplateResourcesResource {
|
|
372
|
+
client;
|
|
373
|
+
constructor(client) {
|
|
374
|
+
this.client = client;
|
|
375
|
+
}
|
|
376
|
+
get(name) {
|
|
377
|
+
return this.client.json("GET", `/v1/template-resources/${pathSegment(name)}`, undefined, "either");
|
|
378
|
+
}
|
|
379
|
+
configure(name, input) {
|
|
380
|
+
return this.client.json("PUT", `/v1/template-resources/${pathSegment(name)}`, input, "either");
|
|
381
|
+
}
|
|
382
|
+
delete(name) {
|
|
383
|
+
return this.client.json("DELETE", `/v1/template-resources/${pathSegment(name)}`, undefined, "either");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
class TemplateBuildsResource {
|
|
388
|
+
client;
|
|
389
|
+
constructor(client) {
|
|
390
|
+
this.client = client;
|
|
391
|
+
}
|
|
392
|
+
create(input) {
|
|
393
|
+
return this.client.json("POST", "/v1/template-builds", input, "either");
|
|
394
|
+
}
|
|
395
|
+
get(id) {
|
|
396
|
+
return this.client.json("GET", `/v1/template-builds/${pathSegment(id)}`, undefined, "either");
|
|
397
|
+
}
|
|
398
|
+
retry(id) {
|
|
399
|
+
return this.client.json("POST", `/v1/template-builds/${pathSegment(id)}/retry`, undefined, "either");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
class TokensResource {
|
|
404
|
+
client;
|
|
405
|
+
constructor(client) {
|
|
406
|
+
this.client = client;
|
|
407
|
+
}
|
|
408
|
+
async mint(input) {
|
|
409
|
+
return parseMintTokenResponse(await this.client.json("POST", "/v1/tokens", input, "apiKey"));
|
|
410
|
+
}
|
|
411
|
+
renew(id) {
|
|
412
|
+
return this.client.json("POST", `/v1/tokens/${pathSegment(id)}/renew`, undefined, "token").then((response) => {
|
|
413
|
+
const parsed = { expires_at: epochMillis(response.expires_at, "token renewal expires_at") };
|
|
414
|
+
if (this.client.tokenId === id)
|
|
415
|
+
this.client.tokenExpiresAt = parsed.expires_at;
|
|
416
|
+
return parsed;
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
revoke(id) {
|
|
420
|
+
return this.client.json("DELETE", `/v1/tokens/${pathSegment(id)}`, undefined, "either");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
class FleetResource {
|
|
425
|
+
client;
|
|
426
|
+
constructor(client) {
|
|
427
|
+
this.client = client;
|
|
428
|
+
}
|
|
429
|
+
list() {
|
|
430
|
+
return this.client.json("GET", "/v1/fleet", undefined, "either");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
class ReservationsResource {
|
|
435
|
+
client;
|
|
436
|
+
constructor(client) {
|
|
437
|
+
this.client = client;
|
|
438
|
+
}
|
|
439
|
+
create(input) {
|
|
440
|
+
return this.client.json("POST", "/v1/capacity-reservations", input, "either");
|
|
441
|
+
}
|
|
442
|
+
get(id) {
|
|
443
|
+
return this.client.json("GET", `/v1/capacity-reservations/${pathSegment(id)}`, undefined, "either");
|
|
444
|
+
}
|
|
445
|
+
release(id) {
|
|
446
|
+
return this.client.json("DELETE", `/v1/capacity-reservations/${pathSegment(id)}`, undefined, "either");
|
|
447
|
+
}
|
|
448
|
+
renew(id, input) {
|
|
449
|
+
return this.client.json("POST", `/v1/capacity-reservations/${pathSegment(id)}/renew`, input, "either");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
class SandboxesResource {
|
|
454
|
+
client;
|
|
455
|
+
constructor(client) {
|
|
456
|
+
this.client = client;
|
|
457
|
+
}
|
|
458
|
+
list() {
|
|
459
|
+
return this.client.json("GET", "/v1/sandboxes", undefined, "either");
|
|
460
|
+
}
|
|
461
|
+
async create(input, options = {}) {
|
|
462
|
+
return parseCreateSandboxResponse(await this.client.json("POST", `/v1/sandboxes${waitQuery(options)}`, withTemplate(input), "either", false), input.request_id);
|
|
463
|
+
}
|
|
464
|
+
async createBatch(input) {
|
|
465
|
+
return parseSandboxBatch(await this.client.json("POST", "/v1/sandboxes", withTemplate(input), "either", false), input.request_id);
|
|
466
|
+
}
|
|
467
|
+
async batch(requestId) {
|
|
468
|
+
return parseSandboxBatch(await this.client.json("GET", `/v1/sandboxes/batches/${pathSegment(requestId)}`, undefined, "either"), requestId);
|
|
469
|
+
}
|
|
470
|
+
async acquisition(requestId, options = {}) {
|
|
471
|
+
return parseCreateSandboxResponse(await this.client.json("GET", `/v1/sandboxes/acquisitions/${pathSegment(requestId)}${waitQuery(options)}`, undefined, "either", options.wait_secs === undefined ? undefined : false), requestId);
|
|
472
|
+
}
|
|
473
|
+
async cancelAcquisition(requestId) {
|
|
474
|
+
return parseCreateSandboxResponse(await this.client.json("DELETE", `/v1/sandboxes/acquisitions/${pathSegment(requestId)}`, undefined, "either"), requestId);
|
|
475
|
+
}
|
|
476
|
+
get(id) {
|
|
477
|
+
return this.client.json("GET", `/v1/sandboxes/${pathSegment(id)}`, undefined, "either");
|
|
478
|
+
}
|
|
479
|
+
renew(id, ttl_secs) {
|
|
480
|
+
return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/renew`, { ttl_secs }, "either");
|
|
481
|
+
}
|
|
482
|
+
grantSshKey(id, input) {
|
|
483
|
+
return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/ssh-keys`, input, "either");
|
|
484
|
+
}
|
|
485
|
+
async portConnection(id, port, path = "/") {
|
|
486
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
487
|
+
throw new RangeError("port must be 1-65535");
|
|
488
|
+
if (!path.startsWith("/"))
|
|
489
|
+
throw new RangeError("path must start with /");
|
|
490
|
+
await this.client.renewIfNeeded();
|
|
491
|
+
const base = new URL(`v1/sandboxes/${pathSegment(id)}/ports/${port}/connect`, this.client.baseUrl);
|
|
492
|
+
const url = `${base.href}${path.startsWith("/?") || path === "/" ? path.slice(1) : path}`;
|
|
493
|
+
return {
|
|
494
|
+
url,
|
|
495
|
+
websocketUrl: url.replace(/^http/, "ws"),
|
|
496
|
+
headers: { Authorization: `Bearer ${this.client.credential("either")}` }
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
async exec(id, input) {
|
|
500
|
+
return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/exec`, input, "either", false, execDeadline(input)).then(execResult);
|
|
501
|
+
}
|
|
502
|
+
async* execStream(id, input) {
|
|
503
|
+
if (input.stdin !== undefined && input.stdin.length > MAX_EXEC_STDIN) {
|
|
504
|
+
throw new RangeError(`EXEC stdin exceeds the ${MAX_EXEC_STDIN / 1024} KiB limit`);
|
|
505
|
+
}
|
|
506
|
+
let lineBuffer = "";
|
|
507
|
+
let done = false;
|
|
508
|
+
const accept = (value) => {
|
|
509
|
+
if (done)
|
|
510
|
+
throw new BlackcatProtocolError("gateway sent data after exec completed");
|
|
511
|
+
const chunk = execStreamChunk(value);
|
|
512
|
+
done = chunk.done;
|
|
513
|
+
return chunk;
|
|
514
|
+
};
|
|
515
|
+
const decoder = new TextDecoder;
|
|
516
|
+
for await (const chunk of this.client.stream("POST", `/v1/sandboxes/${pathSegment(id)}/exec/stream`, JSON.stringify(input), "either", "application/json", true, execDeadline(input))) {
|
|
517
|
+
lineBuffer += decoder.decode(chunk, { stream: true });
|
|
518
|
+
const lines = lineBuffer.split(`
|
|
519
|
+
`);
|
|
520
|
+
lineBuffer = lines.pop() ?? "";
|
|
521
|
+
for (const line of lines) {
|
|
522
|
+
if (line.trim())
|
|
523
|
+
yield accept(JSON.parse(line));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
lineBuffer += decoder.decode();
|
|
527
|
+
if (lineBuffer.trim())
|
|
528
|
+
yield accept(JSON.parse(lineBuffer));
|
|
529
|
+
if (!done)
|
|
530
|
+
throw new BlackcatProtocolError("gateway exec stream ended before its terminal result");
|
|
531
|
+
}
|
|
532
|
+
startProcess(id, requestId, input) {
|
|
533
|
+
if (input.stdin && input.stdin.length > MAX_PROCESS_STDIN) {
|
|
534
|
+
throw new RangeError(`process stdin exceeds ${MAX_PROCESS_STDIN} bytes`);
|
|
535
|
+
}
|
|
536
|
+
return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/processes`, {
|
|
537
|
+
...input,
|
|
538
|
+
...input.stdin === undefined ? {} : { stdin: Array.from(input.stdin) }
|
|
539
|
+
}, "either", false, undefined, { "idempotency-key": requestId });
|
|
540
|
+
}
|
|
541
|
+
getProcess(id, processId) {
|
|
542
|
+
return this.client.json("GET", `/v1/sandboxes/${pathSegment(id)}/processes/${pathSegment(processId)}`, undefined, "either");
|
|
543
|
+
}
|
|
544
|
+
async processLogs(id, processId, input = {}) {
|
|
545
|
+
const options = typeof input === "number" ? { stdout_offset: 0, stderr_offset: 0, max_bytes: input } : input;
|
|
546
|
+
const query = new URLSearchParams;
|
|
547
|
+
if (options.stdout_offset !== undefined)
|
|
548
|
+
query.set("stdout_offset", String(options.stdout_offset));
|
|
549
|
+
if (options.stderr_offset !== undefined)
|
|
550
|
+
query.set("stderr_offset", String(options.stderr_offset));
|
|
551
|
+
if (options.max_bytes !== undefined)
|
|
552
|
+
query.set("max_bytes", String(options.max_bytes));
|
|
553
|
+
const suffix = query.size === 0 ? "" : `?${query.toString()}`;
|
|
554
|
+
const response = await this.client.json("GET", `/v1/sandboxes/${pathSegment(id)}/processes/${pathSegment(processId)}/logs${suffix}`, undefined, "either");
|
|
555
|
+
return {
|
|
556
|
+
...response,
|
|
557
|
+
logs: {
|
|
558
|
+
...response.logs,
|
|
559
|
+
stdout: bytes(response.logs.stdout, "logs.stdout"),
|
|
560
|
+
stderr: bytes(response.logs.stderr, "logs.stderr")
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
cancelProcess(id, processId) {
|
|
565
|
+
return this.client.json("DELETE", `/v1/sandboxes/${pathSegment(id)}/processes/${pathSegment(processId)}`, undefined, "either");
|
|
566
|
+
}
|
|
567
|
+
prepare(id, input, idempotencyKey) {
|
|
568
|
+
return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/prepare`, input, "either", undefined, undefined, { "idempotency-key": idempotencyKey });
|
|
569
|
+
}
|
|
570
|
+
logs(id) {
|
|
571
|
+
return this.client.stream("GET", `/v1/sandboxes/${pathSegment(id)}/logs`, undefined, "either", undefined, true);
|
|
572
|
+
}
|
|
573
|
+
async readFileChunk(id, input) {
|
|
574
|
+
if (input.offset < 0 || input.length <= 0 || input.length > MAX_FILE_READ_CHUNK) {
|
|
575
|
+
throw new RangeError(`file read offset must be non-negative and length must be between 1 and ${MAX_FILE_READ_CHUNK}`);
|
|
576
|
+
}
|
|
577
|
+
const query = new URLSearchParams({
|
|
578
|
+
path: input.path,
|
|
579
|
+
offset: String(input.offset),
|
|
580
|
+
length: String(input.length)
|
|
581
|
+
});
|
|
582
|
+
const response = await this.client.raw("GET", `/v1/sandboxes/${pathSegment(id)}/files/read?${query.toString()}`, undefined, "either");
|
|
583
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
584
|
+
const offset = Number(response.headers.get("x-blackcat-offset") ?? input.offset);
|
|
585
|
+
const eof = response.headers.get("x-blackcat-eof") === "true";
|
|
586
|
+
return { data, offset, eof };
|
|
587
|
+
}
|
|
588
|
+
async* readFileStream(id, input) {
|
|
589
|
+
const start = input.offset ?? 0;
|
|
590
|
+
const chunkSize = input.chunk_size ?? MAX_FILE_READ_CHUNK;
|
|
591
|
+
if (start < 0 || input.length !== undefined && input.length < 0) {
|
|
592
|
+
throw new RangeError("file read offset and length must be non-negative");
|
|
593
|
+
}
|
|
594
|
+
if (chunkSize <= 0 || chunkSize > MAX_FILE_READ_CHUNK) {
|
|
595
|
+
throw new RangeError(`file read chunk_size must be between 1 and ${MAX_FILE_READ_CHUNK}`);
|
|
596
|
+
}
|
|
597
|
+
let offset = start;
|
|
598
|
+
let remaining = input.length;
|
|
599
|
+
while (remaining === undefined || remaining > 0) {
|
|
600
|
+
const length = Math.min(chunkSize, remaining ?? chunkSize);
|
|
601
|
+
const chunk = await this.readFileChunk(id, { path: input.path, offset, length });
|
|
602
|
+
yield chunk;
|
|
603
|
+
offset += chunk.data.length;
|
|
604
|
+
if (chunk.eof || chunk.data.length === 0)
|
|
605
|
+
return;
|
|
606
|
+
if (remaining !== undefined)
|
|
607
|
+
remaining -= chunk.data.length;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async readFile(id, input) {
|
|
611
|
+
const chunks = [];
|
|
612
|
+
for await (const chunk of this.readFileStream(id, input)) {
|
|
613
|
+
chunks.push(chunk.data);
|
|
614
|
+
}
|
|
615
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
616
|
+
const output = new Uint8Array(total);
|
|
617
|
+
let cursor = 0;
|
|
618
|
+
for (const chunk of chunks) {
|
|
619
|
+
output.set(chunk, cursor);
|
|
620
|
+
cursor += chunk.length;
|
|
621
|
+
}
|
|
622
|
+
return output;
|
|
623
|
+
}
|
|
624
|
+
async writeFileChunk(id, input) {
|
|
625
|
+
if (input.data.length > MAX_FILE_CHUNK) {
|
|
626
|
+
throw new RangeError(`file write chunk exceeds ${MAX_FILE_CHUNK} bytes`);
|
|
627
|
+
}
|
|
628
|
+
const query = new URLSearchParams({
|
|
629
|
+
path: input.path,
|
|
630
|
+
offset: String(input.offset),
|
|
631
|
+
truncate: String(input.truncate)
|
|
632
|
+
});
|
|
633
|
+
await this.client.raw("POST", `/v1/sandboxes/${pathSegment(id)}/files/write?${query.toString()}`, requestBody(input.data), "either", "application/octet-stream");
|
|
634
|
+
}
|
|
635
|
+
async writeFileStream(id, input) {
|
|
636
|
+
const start = input.offset ?? 0;
|
|
637
|
+
if (start < 0)
|
|
638
|
+
throw new RangeError("file write offset must be non-negative");
|
|
639
|
+
let offset = start;
|
|
640
|
+
let written = 0;
|
|
641
|
+
let first = true;
|
|
642
|
+
for await (const chunk of input.data) {
|
|
643
|
+
for (let cursor = 0;cursor < chunk.length; cursor += MAX_FILE_CHUNK) {
|
|
644
|
+
const data = chunk.slice(cursor, cursor + MAX_FILE_CHUNK);
|
|
645
|
+
await this.writeFileChunk(id, {
|
|
646
|
+
path: input.path,
|
|
647
|
+
offset,
|
|
648
|
+
data,
|
|
649
|
+
truncate: (input.truncate ?? true) && first
|
|
650
|
+
});
|
|
651
|
+
first = false;
|
|
652
|
+
offset += data.length;
|
|
653
|
+
written += data.length;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (first && (input.truncate ?? true)) {
|
|
657
|
+
await this.writeFileChunk(id, {
|
|
658
|
+
path: input.path,
|
|
659
|
+
offset,
|
|
660
|
+
data: new Uint8Array(0),
|
|
661
|
+
truncate: true
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
return written;
|
|
665
|
+
}
|
|
666
|
+
async writeFile(id, input) {
|
|
667
|
+
async function* oneChunk() {
|
|
668
|
+
yield input.data;
|
|
669
|
+
}
|
|
670
|
+
return this.writeFileStream(id, {
|
|
671
|
+
path: input.path,
|
|
672
|
+
data: oneChunk(),
|
|
673
|
+
offset: input.offset,
|
|
674
|
+
truncate: input.truncate
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
snapshot(id) {
|
|
678
|
+
return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/snapshot`, undefined, "either");
|
|
679
|
+
}
|
|
680
|
+
pause(id) {
|
|
681
|
+
return this.client.raw("POST", `/v1/sandboxes/${pathSegment(id)}/pause`, undefined, "either").then(() => {
|
|
682
|
+
return;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
hibernate(id, input) {
|
|
686
|
+
return this.client.raw("POST", `/v1/sandboxes/${pathSegment(id)}/hibernate`, input === undefined ? undefined : JSON.stringify(input), "either", input === undefined ? undefined : "application/json", false).then(() => {
|
|
687
|
+
return;
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
resize(id, input) {
|
|
691
|
+
return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/resize`, { ...input, request_id: input.request_id ?? `resize-${crypto.randomUUID()}` }, "either", false);
|
|
692
|
+
}
|
|
693
|
+
resume(id) {
|
|
694
|
+
return this.client.raw("POST", `/v1/sandboxes/${pathSegment(id)}/resume`, undefined, "either").then(() => {
|
|
695
|
+
return;
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
destroy(id) {
|
|
699
|
+
return this.client.json("DELETE", `/v1/sandboxes/${pathSegment(id)}`, undefined, "either");
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
class BlackcatClient extends GatewayClient {
|
|
704
|
+
templates;
|
|
705
|
+
templateResources;
|
|
706
|
+
templateBuilds;
|
|
707
|
+
sandboxes;
|
|
708
|
+
tokens;
|
|
709
|
+
fleet;
|
|
710
|
+
reservations;
|
|
711
|
+
constructor(options) {
|
|
712
|
+
if (options.apiKey !== undefined)
|
|
713
|
+
assertSecureBlackcatGatewayUrl(options.baseUrl);
|
|
714
|
+
super(options);
|
|
715
|
+
this.templates = new TemplatesResource(this);
|
|
716
|
+
this.templateResources = new TemplateResourcesResource(this);
|
|
717
|
+
this.templateBuilds = new TemplateBuildsResource(this);
|
|
718
|
+
this.sandboxes = new SandboxesResource(this);
|
|
719
|
+
this.tokens = new TokensResource(this);
|
|
720
|
+
this.fleet = new FleetResource(this);
|
|
721
|
+
this.reservations = new ReservationsResource(this);
|
|
722
|
+
}
|
|
723
|
+
withToken(token, renewCapability) {
|
|
724
|
+
const capability = typeof token === "string" ? undefined : token;
|
|
725
|
+
return new BlackcatClient({
|
|
726
|
+
baseUrl: this.baseUrl,
|
|
727
|
+
apiKey: this.apiKey,
|
|
728
|
+
token: capability?.token ?? (typeof token === "string" ? token : undefined),
|
|
729
|
+
tokenId: capability?.token_id,
|
|
730
|
+
tokenExpiresAt: capability?.expires_at,
|
|
731
|
+
renewCapability,
|
|
732
|
+
caPem: this.caPem,
|
|
733
|
+
fetch: this.fetcher
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
export {
|
|
738
|
+
parseSandboxBatch,
|
|
739
|
+
parseCreateSandboxResponse,
|
|
740
|
+
epochMillis,
|
|
741
|
+
assertSecureBlackcatGatewayUrl,
|
|
742
|
+
MAX_PROCESS_STDIN,
|
|
743
|
+
MAX_FILE_CHUNK as MAX_FILE_WRITE_CHUNK,
|
|
744
|
+
MAX_FILE_READ_CHUNK,
|
|
745
|
+
MAX_FILE_CHUNK,
|
|
746
|
+
MAX_EXEC_STDIN,
|
|
747
|
+
CAPABILITY_OPS,
|
|
748
|
+
BlackcatClient,
|
|
749
|
+
BlackcatApiError,
|
|
750
|
+
BLACKCAT_ERROR_CODES
|
|
751
|
+
};
|