@agentproto/sandbox-instabox 0.0.0-stage → 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/dist/index.mjs ADDED
@@ -0,0 +1,761 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { SandboxQuotaError, SandboxBoxGoneError, scrubCommandFromMessage } from '@agentproto/sandbox';
3
+
4
+ /**
5
+ * @agentproto/sandbox-instabox v0.1.0
6
+ * InstaBox SandboxProvider for @agentproto/sandbox.
7
+ */
8
+
9
+ // src/api.ts
10
+ var InstaboxApiError = class extends Error {
11
+ status;
12
+ code;
13
+ requestId;
14
+ retryable;
15
+ operationId;
16
+ constructor(message, info) {
17
+ super(message);
18
+ this.name = "InstaboxApiError";
19
+ this.status = info.status;
20
+ this.code = info.code;
21
+ this.requestId = info.requestId;
22
+ this.retryable = info.retryable;
23
+ this.operationId = info.operationId;
24
+ }
25
+ };
26
+ var DEFAULT_API_URL = "https://api.instabox.sh";
27
+ var REQUEST_TIMEOUT_MS = 3e4;
28
+ var OPERATION_WAIT = "25s";
29
+ var EXEC_HTTP_TIMEOUT_MS = 62e4;
30
+ var OPERATION_TIMEOUT_MS = 3e5;
31
+ var OPERATION_RETRY_BASE_DELAY_MS = 500;
32
+ var CREATE_RETRY_ATTEMPTS = 5;
33
+ function asString(value) {
34
+ return typeof value === "string" ? value : void 0;
35
+ }
36
+ function isInstaboxQuotaFailure(status, code, message) {
37
+ if (status === 402 || status === 403 || status === 429) return true;
38
+ return /quota|limit/i.test(code) || /quota|limit/i.test(message);
39
+ }
40
+ var InstaboxClient = class {
41
+ apiKey;
42
+ apiUrl;
43
+ fetchImpl;
44
+ retryDelayMs;
45
+ constructor(opts) {
46
+ this.apiKey = opts.apiKey;
47
+ this.apiUrl = (opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
48
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
49
+ this.retryDelayMs = opts.retryDelayMs ?? ((attempt) => new Promise((resolve) => setTimeout(resolve, OPERATION_RETRY_BASE_DELAY_MS * attempt)));
50
+ }
51
+ async request(method, path, init = {}) {
52
+ let res;
53
+ try {
54
+ res = await this.fetchImpl(`${this.apiUrl}${path}`, {
55
+ method,
56
+ headers: {
57
+ "Content-Type": "application/json",
58
+ "X-API-Key": this.apiKey,
59
+ ...init.idempotency ? { "Idempotency-Key": init.idempotency } : {}
60
+ },
61
+ ...init.body !== void 0 ? { body: JSON.stringify(init.body) } : {},
62
+ signal: AbortSignal.timeout(init.serviceTimeoutMs ?? REQUEST_TIMEOUT_MS)
63
+ });
64
+ } catch (err) {
65
+ throw new InstaboxApiError(`instabox: transport failure calling ${method} ${path}`, {
66
+ status: null,
67
+ code: "transport_error",
68
+ retryable: true
69
+ });
70
+ }
71
+ if (!res.ok) {
72
+ const text = await res.text().catch(() => "");
73
+ const body = safeParse(text);
74
+ const code = asString(body.error?.code) ?? "http_error";
75
+ const message = asString(body.error?.message) ?? `${method} ${path} failed with HTTP ${res.status}`;
76
+ throw new InstaboxApiError(`instabox: ${message}`, {
77
+ status: res.status,
78
+ code,
79
+ requestId: asString(body.error?.requestId),
80
+ retryable: body.error?.retryable === true || res.status === 429 || res.status >= 500,
81
+ operationId: asString(body.error?.operationId)
82
+ });
83
+ }
84
+ const raw = await res.json();
85
+ return raw;
86
+ }
87
+ /** POST /v1/machines. Retried on TRANSPORT errors only (idempotency key is
88
+ * fixed per instance call, so a retry after a lost response cannot mint a
89
+ * duplicate machine). Applied to the network-safe envelope, then the 202
90
+ * body is returned. */
91
+ async createMachine(input, idempotencyKey) {
92
+ return requestWithTransportRetry(
93
+ () => this.request("POST", "/v1/machines", { body: input, idempotency: idempotencyKey }),
94
+ CREATE_RETRY_ATTEMPTS
95
+ );
96
+ }
97
+ /** GET /v1/operations/{id}?wait=25s, with an overall timeout and retryable
98
+ * transport-error backoff. */
99
+ async waitForOperation(operationId, timeoutMs = OPERATION_TIMEOUT_MS) {
100
+ const deadline = Date.now() + timeoutMs;
101
+ for (let attempt = 1; ; attempt++) {
102
+ let op = null;
103
+ let transportError = false;
104
+ try {
105
+ op = await this.request(
106
+ "GET",
107
+ `/v1/operations/${encodeURIComponent(operationId)}?wait=${OPERATION_WAIT}`
108
+ );
109
+ } catch (err) {
110
+ if (!isTransportError(err)) throw err;
111
+ transportError = true;
112
+ }
113
+ if (op !== null) {
114
+ if (op.state === "succeeded") return op;
115
+ if (op.state === "failed") {
116
+ throw new InstaboxApiError(describeOperationError(op), {
117
+ status: null,
118
+ code: "operation_failed",
119
+ operationId
120
+ });
121
+ }
122
+ }
123
+ if (Date.now() >= deadline) {
124
+ throw new InstaboxApiError(
125
+ `instabox: operation ${operationId} did not settle within ${timeoutMs}ms.`,
126
+ { status: null, code: "operation_timeout", operationId }
127
+ );
128
+ }
129
+ if (transportError) await this.retryDelayMs(attempt);
130
+ }
131
+ }
132
+ /** GET /v1/machines/{id} (accepts id or name; ports/wake/files need `id`). */
133
+ getMachine(idOrName) {
134
+ return this.request("GET", `/v1/machines/${encodeURIComponent(idOrName)}`);
135
+ }
136
+ /** GET /v1/machines?metadata=k:v&...&limit=N. */
137
+ async listMachines(metadata, limit) {
138
+ const qs = new URLSearchParams();
139
+ for (const [k, v] of Object.entries(metadata)) qs.append("metadata", `${k}:${v}`);
140
+ if (limit !== void 0) qs.set("limit", String(limit));
141
+ const suffix = qs.size > 0 ? `?${qs.toString()}` : "";
142
+ const raw = await this.request("GET", `/v1/machines${suffix}`);
143
+ if (Array.isArray(raw)) return raw;
144
+ const wrapped = raw;
145
+ if (Array.isArray(wrapped.machines)) return wrapped.machines;
146
+ return [];
147
+ }
148
+ /** GET /v1/usage (limits.maxMachines). */
149
+ readUsage() {
150
+ return this.request("GET", "/v1/usage");
151
+ }
152
+ /** PATCH /v1/machines/{id}. Answers 200 with the updated MACHINE (measured
153
+ * live: `operationId` is null, there is no operation to wait on), so the
154
+ * machine's own `id` must never be handed to `waitForOperation`. A pending
155
+ * `operationId` is still awaited if the API ever returns one. */
156
+ async patchMachine(id, patch, idempotencyKey) {
157
+ const machine = await this.request("PATCH", `/v1/machines/${encodeURIComponent(id)}`, {
158
+ body: patch,
159
+ idempotency: idempotencyKey ?? crypto.randomUUID()
160
+ });
161
+ if (typeof machine.operationId === "string" && machine.operationId.length > 0) {
162
+ await this.waitForOperation(machine.operationId);
163
+ }
164
+ return machine;
165
+ }
166
+ /** DELETE /v1/machines/{id}. The API rejects a DELETE without an
167
+ * `Idempotency-Key` (HTTP 400), so one is always sent. */
168
+ deleteMachine(id) {
169
+ return this.request("DELETE", `/v1/machines/${encodeURIComponent(id)}`, {
170
+ idempotency: crypto.randomUUID()
171
+ });
172
+ }
173
+ /** POST /v1/machines/{id}/ports {port}. 201 first, 200 if already published
174
+ * (both answer the same body). */
175
+ async publishPort(id, port) {
176
+ return await this.request("POST", `/v1/machines/${encodeURIComponent(id)}/ports`, {
177
+ body: { port },
178
+ idempotency: crypto.randomUUID()
179
+ });
180
+ }
181
+ /** DELETE /v1/machines/{id}/ports/{port}. */
182
+ deletePort(id, port) {
183
+ return this.request("DELETE", `/v1/machines/${encodeURIComponent(id)}/ports/${port}`);
184
+ }
185
+ /** POST /v1/machines/{id}/{verb} for suspend|resume|stop|start. */
186
+ machineAction(id, verb) {
187
+ return this.request(
188
+ "POST",
189
+ `/v1/machines/${encodeURIComponent(id)}/${verb}`,
190
+ { idempotency: crypto.randomUUID() }
191
+ );
192
+ }
193
+ /** POST /v1/machines/{id}/wake: 200 DIRECT (no operation to wait on). */
194
+ wake(id) {
195
+ return this.request(
196
+ "POST",
197
+ `/v1/machines/${encodeURIComponent(id)}/wake`,
198
+ { idempotency: crypto.randomUUID() }
199
+ );
200
+ }
201
+ /** POST /v1/machines/{id}/exec. NEVER retried and NEVER idempotent: a
202
+ * retry could double-run a mutating command. */
203
+ exec(id, input) {
204
+ return this.request(
205
+ "POST",
206
+ `/v1/machines/${encodeURIComponent(id)}/exec`,
207
+ { body: input, serviceTimeoutMs: EXEC_HTTP_TIMEOUT_MS }
208
+ );
209
+ }
210
+ };
211
+ function describeOperationError(op) {
212
+ const detail = op.error;
213
+ if (typeof detail === "string" && detail.length > 0) return `instabox: operation ${op.id} failed: ${detail}`;
214
+ if (detail !== null && typeof detail === "object") {
215
+ const code = asString(detail.code);
216
+ const message = asString(detail.message);
217
+ return `instabox: operation ${op.id} failed${code ? ` (${code})` : ""}${message ? `: ${message}` : ""}`;
218
+ }
219
+ return `instabox: operation ${op.id} failed.`;
220
+ }
221
+ function safeParse(text) {
222
+ try {
223
+ const parsed = JSON.parse(text);
224
+ if (parsed !== null && typeof parsed === "object") return parsed;
225
+ } catch {
226
+ }
227
+ return {};
228
+ }
229
+ function isTransientInstaboxError(err) {
230
+ if (!(err instanceof InstaboxApiError)) {
231
+ return false;
232
+ }
233
+ return err.code === "transport_error" || err.status !== null && err.status >= 500;
234
+ }
235
+ function isTransportError(err) {
236
+ return err instanceof InstaboxApiError && err.code === "transport_error";
237
+ }
238
+ async function requestWithTransportRetry(fn, attempts) {
239
+ for (let attempt = 1; ; attempt++) {
240
+ try {
241
+ return await fn();
242
+ } catch (err) {
243
+ if (attempt >= attempts || !isTransportError(err)) throw err;
244
+ await new Promise((resolve) => setTimeout(resolve, OPERATION_RETRY_BASE_DELAY_MS * attempt));
245
+ }
246
+ }
247
+ }
248
+ var DEFAULT_PORT = 18790;
249
+ var DEFAULT_WORKSPACE = "/data/home";
250
+ var DEFAULT_TEMPLATE = "universal";
251
+ var DEFAULT_TIMEOUT_SECONDS = 3600;
252
+ var MIN_TIMEOUT_SECONDS = 60;
253
+ var MAX_TIMEOUT_SECONDS = 86400;
254
+ var DEFAULT_AUTO_SUSPEND_AFTER_SECONDS = 0;
255
+ var HEALTH_PROBE_TIMEOUT_MS = 5e3;
256
+ var ALREADY_UP_PROBE_TIMEOUT_MS = 2e3;
257
+ var POLL_INTERVAL_MS = 500;
258
+ var READY_TIMEOUT_MS = 12e4;
259
+ var DAEMON_READY_TIMEOUT_MS = 6e4;
260
+ var EXEC_MAX_TIMEOUT_SECONDS = 600;
261
+ var EXEC_DEFAULT_TIMEOUT_SECONDS = 600;
262
+ var DEFAULT_UPDATE_CLI_TIMEOUT_SECONDS = 600;
263
+ var FILE_CHUNK_BYTES = 700 * 1024;
264
+ var FILE_CHUNK_B64_CHARS = Math.ceil(FILE_CHUNK_BYTES / 3) * 4;
265
+ var AUTH_TOKEN_BYTES = 32;
266
+ var PROVIDER_SLUG = "instabox";
267
+ var ENV_FILE_DIR = "/etc/agentproto";
268
+ var ENV_FILE_PATH = `${ENV_FILE_DIR}/agentproto.env`;
269
+ var SYSTEMD_UNIT_PATH = "/etc/systemd/system/agentproto.service";
270
+ var DAEMON_UNIT_NAME = "agentproto";
271
+ var AUTH_TOKEN_ENV_VAR = "AGENTPROTO_AUTH_TOKEN";
272
+ var AGENTPROTO_METADATA_KEY = "agentproto";
273
+ var AGENTPROTO_METADATA_VALUE = "1";
274
+ var BACKGROUND_REDIRECT = ">/dev/null 2>&1 </dev/null";
275
+ function readConfig(rawConfig) {
276
+ const raw = rawConfig ?? {};
277
+ const num = (field, fallback, min, max) => {
278
+ if (typeof field !== "number" || !Number.isFinite(field)) return fallback;
279
+ return Math.min(max, Math.max(min, Math.floor(field)));
280
+ };
281
+ const str = (field) => typeof field === "string" && field.length > 0 ? field : void 0;
282
+ const metadata = typeof raw.metadata === "object" && raw.metadata !== null ? Object.fromEntries(
283
+ Object.entries(raw.metadata).filter(
284
+ (entry) => typeof entry[1] === "string"
285
+ )
286
+ ) : {};
287
+ return {
288
+ apiKey: str(raw.apiKey) ?? process.env.INSTABOX_API_KEY ?? "",
289
+ apiUrl: str(raw.apiUrl) ?? "https://api.instabox.sh",
290
+ template: str(raw.template) ?? DEFAULT_TEMPLATE,
291
+ timeoutSeconds: num(raw.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS),
292
+ autoSuspendAfterSeconds: typeof raw.autoSuspendAfterSeconds === "number" && Number.isFinite(raw.autoSuspendAfterSeconds) ? Math.min(86400, Math.max(0, Math.floor(raw.autoSuspendAfterSeconds))) : DEFAULT_AUTO_SUSPEND_AFTER_SECONDS,
293
+ port: num(raw.port, DEFAULT_PORT, 1, 65535),
294
+ workspace: str(raw.workspace) ?? DEFAULT_WORKSPACE,
295
+ cliVersion: str(raw.cliVersion) ?? "latest",
296
+ updateCliOnBoot: typeof raw.updateCliOnBoot === "boolean" ? raw.updateCliOnBoot : true,
297
+ installPackages: Array.isArray(raw.installPackages) ? raw.installPackages.filter((p) => typeof p === "string" && p.length > 0) : [],
298
+ updateCliTimeoutSeconds: num(
299
+ raw.updateCliTimeoutSeconds,
300
+ DEFAULT_UPDATE_CLI_TIMEOUT_SECONDS,
301
+ 1,
302
+ EXEC_MAX_TIMEOUT_SECONDS
303
+ ),
304
+ readyTimeoutMs: num(raw.readyTimeoutMs, READY_TIMEOUT_MS, 1e3, 6e5),
305
+ daemonReadyTimeoutMs: num(raw.daemonReadyTimeoutMs, DAEMON_READY_TIMEOUT_MS, 1e3, 6e5),
306
+ metadata,
307
+ setupCommands: Array.isArray(raw.setupCommands) ? raw.setupCommands.filter((c) => typeof c === "string" && c.length > 0) : []
308
+ };
309
+ }
310
+ function environmentFileContents(env, authToken) {
311
+ const lines = [
312
+ ...Object.entries(env).map(([key, value]) => `${key}=${value}`),
313
+ `${AUTH_TOKEN_ENV_VAR}=${authToken}`
314
+ ];
315
+ return `${lines.join("\n")}
316
+ `;
317
+ }
318
+ function writeDaemonUnitCommand(port, workspace, env, authToken) {
319
+ const envFileContents = environmentFileContents(env, authToken);
320
+ const unit = [
321
+ "[Unit]",
322
+ "Description=agentproto daemon",
323
+ "After=network.target",
324
+ "",
325
+ "[Service]",
326
+ `EnvironmentFile=-${ENV_FILE_PATH}`,
327
+ `ExecStart=agentproto serve --port ${port} --bind 0.0.0.0 --workspace "${workspace}" --auth-token \${${AUTH_TOKEN_ENV_VAR}}`,
328
+ "Restart=always",
329
+ "User=user",
330
+ "",
331
+ "[Install]",
332
+ "WantedBy=multi-user.target"
333
+ ].join("\n");
334
+ const command = [
335
+ "set -e",
336
+ `sudo mkdir -p ${ENV_FILE_DIR}`,
337
+ `sudo tee ${ENV_FILE_PATH} > /dev/null <<'AGENTPROTO_ENV'
338
+ ${envFileContents}AGENTPROTO_ENV`,
339
+ `sudo chmod 600 ${ENV_FILE_PATH}`,
340
+ `sudo tee ${SYSTEMD_UNIT_PATH} > /dev/null <<'AGENTPROTO_UNIT'
341
+ ${unit}
342
+ AGENTPROTO_UNIT`
343
+ ].join("\n");
344
+ return { command, envFileContents, unitContents: unit };
345
+ }
346
+ function buildCliInstallCommand(cliVersion, extras) {
347
+ const extraText = extras.map((extra) => ` '${extra.replace(/'/g, "")}'`).join("");
348
+ return `sudo env "PATH=$PATH" npm i -g "@agentproto/cli@${cliVersion}"${extraText}`;
349
+ }
350
+ async function assertQuotaAvailable(client) {
351
+ const [usage, machines] = await Promise.all([
352
+ client.readUsage().catch(() => void 0),
353
+ client.listMachines({}, 200).catch(() => void 0)
354
+ ]);
355
+ const max = usage?.limits?.maxMachines;
356
+ if (typeof max === "number" && machines !== void 0 && machines.length >= max) {
357
+ throw new SandboxQuotaError(
358
+ PROVIDER_SLUG,
359
+ `instabox: account limit is ${max} machines and ${machines.length} already exist (suspended and stopped machines count); stop one before booting another.`
360
+ );
361
+ }
362
+ }
363
+ function normalizeQuotaError(err) {
364
+ if (err instanceof InstaboxApiError && isInstaboxQuotaFailure(err.status, err.code, err.message)) {
365
+ return new SandboxQuotaError(
366
+ PROVIDER_SLUG,
367
+ `instabox: account limit reached (HTTP ${err.status ?? "?"}, code "${err.code}"): fall back to a different sandbox provider.`,
368
+ err
369
+ );
370
+ }
371
+ return err;
372
+ }
373
+ function bootMetadata(config, spec) {
374
+ const sessionId = spec.id;
375
+ const metadata = { ...config.metadata };
376
+ metadata[AGENTPROTO_METADATA_KEY] = AGENTPROTO_METADATA_VALUE;
377
+ if (typeof sessionId === "string" && sessionId.length > 0) {
378
+ metadata.session = sessionId;
379
+ }
380
+ return metadata;
381
+ }
382
+ async function waitMachineReady(client, machineId, timeoutMs) {
383
+ const deadline = Date.now() + timeoutMs;
384
+ for (; ; ) {
385
+ const machine = await client.getMachine(machineId);
386
+ if (machine.runtime?.ready === true) return machine;
387
+ if (Date.now() >= deadline) {
388
+ throw new Error(
389
+ `@agentproto/sandbox-instabox: machine ${machineId} did not reach runtime.ready within ${timeoutMs}ms.`
390
+ );
391
+ }
392
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
393
+ }
394
+ }
395
+ async function probeHealth(url, timeoutMs) {
396
+ const deadline = Date.now() + timeoutMs;
397
+ for (; ; ) {
398
+ try {
399
+ const res = await fetch(url, { signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS) });
400
+ if (res.ok) return true;
401
+ } catch {
402
+ }
403
+ if (Date.now() >= deadline) return false;
404
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
405
+ }
406
+ }
407
+ async function provisionAndProbe(state, deleteOnFailure) {
408
+ const { client, machineId, config } = state;
409
+ const published = await client.publishPort(machineId, config.port);
410
+ const publicUrl = published.url;
411
+ const healthUrl = `${publicUrl}/health`;
412
+ const alreadyUp = await probeHealth(healthUrl, ALREADY_UP_PROBE_TIMEOUT_MS);
413
+ if (!alreadyUp) {
414
+ if (config.updateCliOnBoot) {
415
+ await execChecked(client, machineId, "CLI install", {
416
+ cmd: buildCliInstallCommand(
417
+ config.cliVersion,
418
+ config.installPackages.map((extra) => extra.replace(/'/g, ""))
419
+ ),
420
+ timeoutSeconds: config.updateCliTimeoutSeconds
421
+ });
422
+ }
423
+ await execChecked(client, machineId, "workspace setup", {
424
+ cmd: `mkdir -p '${config.workspace}' && sudo mkdir -p ${ENV_FILE_DIR}`
425
+ });
426
+ await execChecked(
427
+ client,
428
+ machineId,
429
+ "daemon unit install",
430
+ { cmd: writeDaemonUnitCommand(config.port, config.workspace, state.env, state.authToken).command },
431
+ { quoteStderr: false }
432
+ );
433
+ for (const command of config.setupCommands) {
434
+ await execChecked(client, machineId, "setup command", {
435
+ cmd: command,
436
+ timeoutSeconds: EXEC_MAX_TIMEOUT_SECONDS
437
+ });
438
+ }
439
+ await execChecked(client, machineId, "daemon enable", {
440
+ cmd: `sudo systemctl daemon-reload && sudo systemctl enable --now ${DAEMON_UNIT_NAME} ${BACKGROUND_REDIRECT}`
441
+ });
442
+ const ready = await probeHealth(healthUrl, config.daemonReadyTimeoutMs);
443
+ if (!ready) {
444
+ if (deleteOnFailure) await client.deleteMachine(machineId).catch(() => void 0);
445
+ throw new Error(
446
+ `@agentproto/sandbox-instabox: agentproto daemon did not become healthy at ${healthUrl} within ${config.daemonReadyTimeoutMs}ms (machine ${machineId}).`
447
+ );
448
+ }
449
+ }
450
+ return { url: publicUrl, mcpUrl: `${publicUrl}/mcp` };
451
+ }
452
+ async function execScrubbed(client, machineId, input) {
453
+ try {
454
+ return await client.exec(machineId, input);
455
+ } catch (err) {
456
+ if (err instanceof Error) {
457
+ const scrubbed = scrubCommandFromMessage(err.message, input.cmd);
458
+ if (scrubbed !== err.message) {
459
+ const wrapped = new Error(scrubbed);
460
+ wrapped.name = err.name;
461
+ throw wrapped;
462
+ }
463
+ }
464
+ throw err;
465
+ }
466
+ }
467
+ async function execChecked(client, machineId, what, input, opts = {}) {
468
+ const result = await execScrubbed(client, machineId, input);
469
+ if (result.exitCode !== 0) {
470
+ const detail = opts.quoteStderr === false ? "" : scrubCommandFromMessage(result.stderr.trim(), input.cmd).slice(0, 300);
471
+ throw new Error(
472
+ `@agentproto/sandbox-instabox: ${what} failed on machine ${machineId} (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""}${detail ? `: ${detail}` : ""}).`
473
+ );
474
+ }
475
+ return result;
476
+ }
477
+ function newInstanceName() {
478
+ return `agentproto-${randomBytes(5).toString("hex")}`;
479
+ }
480
+ function newAuthToken() {
481
+ return randomBytes(AUTH_TOKEN_BYTES).toString("hex");
482
+ }
483
+ function machineIdOf(machine) {
484
+ if (typeof machine.id !== "string" || machine.id.length === 0) {
485
+ throw new Error("instabox: machine response carries no id");
486
+ }
487
+ return machine.id;
488
+ }
489
+ function clampTimeoutSeconds(seconds) {
490
+ return Math.min(MAX_TIMEOUT_SECONDS, Math.max(MIN_TIMEOUT_SECONDS, Math.floor(seconds)));
491
+ }
492
+ function clampExecTimeoutSeconds(timeoutMs) {
493
+ const seconds = Math.floor((timeoutMs ?? EXEC_DEFAULT_TIMEOUT_SECONDS * 1e3) / 1e3);
494
+ return Math.min(EXEC_MAX_TIMEOUT_SECONDS, Math.max(1, seconds));
495
+ }
496
+ function toBootedSandbox(client, machineId, mcpUrl, authToken, config, ports) {
497
+ return {
498
+ mcpUrl,
499
+ sandboxId: machineId,
500
+ ...Object.keys(ports).length > 0 ? { ports } : {},
501
+ // The public edge is UNGATED at InstaBox: the daemon's own bearer (minted
502
+ // here at unit install time and substituted from the 0600 EnvironmentFile)
503
+ // is the only thing gating /mcp, so it is ALWAYS surfaced.
504
+ token: authToken,
505
+ authHeaders: { Authorization: `Bearer ${authToken}` },
506
+ async stop() {
507
+ try {
508
+ const op = await client.deleteMachine(machineId);
509
+ await client.waitForOperation(op.id);
510
+ } catch (err) {
511
+ if (err instanceof InstaboxApiError && err.status === 404) return;
512
+ await client.deleteMachine(machineId).catch(() => void 0);
513
+ throw err;
514
+ }
515
+ },
516
+ async exec(opts) {
517
+ const startedAt = Date.now();
518
+ const result = await client.exec(machineId, {
519
+ cmd: opts.command,
520
+ ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {},
521
+ ...opts.env !== void 0 ? { envs: opts.env } : {},
522
+ // InstaBox clamps exec 1..600s. An omitted bound maps to the max,
523
+ // NOT the API's own 60s default (mid-work-kill trap, module docs).
524
+ timeoutSeconds: clampExecTimeoutSeconds(opts.timeoutMs)
525
+ });
526
+ return {
527
+ exitCode: result.exitCode,
528
+ stdout: result.stdout,
529
+ stderr: result.stderr,
530
+ durationMs: Date.now() - startedAt,
531
+ ...result.truncated ? { truncated: result.truncated } : {}
532
+ };
533
+ },
534
+ async readFile(path) {
535
+ const chunks = [];
536
+ for (let index = 0; ; index++) {
537
+ const result = await client.exec(machineId, {
538
+ cmd: `test -r '${path}' && dd if='${path}' ibs=${FILE_CHUNK_BYTES} skip=${index} count=1 2>/dev/null | base64 -w0`,
539
+ timeoutSeconds: EXEC_MAX_TIMEOUT_SECONDS
540
+ });
541
+ const encoded = result.stdout.trim();
542
+ if (index === 0 && result.exitCode !== 0 && encoded === "") {
543
+ throw new Error(
544
+ `instabox readFile: cannot read "${path}" (exit ${result.exitCode}, stderr: ${result.stderr.trim() || "(none)"})`
545
+ );
546
+ }
547
+ if (encoded.length === 0) break;
548
+ chunks.push(new Uint8Array(Buffer.from(encoded, "base64")));
549
+ if (encoded.length < FILE_CHUNK_B64_CHARS) break;
550
+ }
551
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
552
+ const out = new Uint8Array(total);
553
+ let offset = 0;
554
+ for (const chunk of chunks) {
555
+ out.set(chunk, offset);
556
+ offset += chunk.byteLength;
557
+ }
558
+ return out;
559
+ },
560
+ async writeFile(path, data) {
561
+ const encoded = Buffer.from(data).toString("base64");
562
+ const directory = path.slice(0, Math.max(path.lastIndexOf("/"), 0)) || "/";
563
+ for (let offset = 0, index = 0; index === 0 || offset < encoded.length; offset += FILE_CHUNK_B64_CHARS, index++) {
564
+ const slice = encoded.slice(offset, offset + FILE_CHUNK_B64_CHARS);
565
+ await client.exec(machineId, {
566
+ cmd: index === 0 ? `mkdir -p '${directory}' && base64 -d > '${path}'` : `base64 -d >> '${path}'`,
567
+ stdin: slice,
568
+ timeoutSeconds: EXEC_MAX_TIMEOUT_SECONDS
569
+ });
570
+ }
571
+ },
572
+ async expose(port) {
573
+ const published = await client.publishPort(machineId, port);
574
+ return { url: published.url };
575
+ },
576
+ async extendTimeout(ms) {
577
+ const seconds = clampTimeoutSeconds(ms / 1e3);
578
+ await client.patchMachine(machineId, { timeoutSeconds: seconds });
579
+ },
580
+ lifetimeMs: config.timeoutSeconds * 1e3
581
+ };
582
+ }
583
+ async function reviveMachine(client, machineId, state) {
584
+ if (state === "running") return;
585
+ if (state === "suspended") {
586
+ try {
587
+ await client.wake(machineId);
588
+ return;
589
+ } catch (err) {
590
+ const conflict = err instanceof InstaboxApiError && (err.status === 409 || err.code === "machine_not_running");
591
+ if (!conflict) throw err;
592
+ }
593
+ const resume = await client.machineAction(machineId, "resume").catch((err) => {
594
+ throw new Error(
595
+ `@agentproto/sandbox-instabox: machine ${machineId} is suspended and refused both wake and resume (${err instanceof Error ? err.message : String(err)}).`
596
+ );
597
+ });
598
+ await client.waitForOperation(resume.id);
599
+ return;
600
+ }
601
+ const start = await client.machineAction(machineId, "start").catch((err) => {
602
+ throw new Error(
603
+ `@agentproto/sandbox-instabox: machine ${machineId} is "${state}" and refused start (${err instanceof Error ? err.message : String(err)}).`
604
+ );
605
+ });
606
+ await client.waitForOperation(start.id);
607
+ }
608
+ async function readExistingAuthToken(client, machineId) {
609
+ const result = await client.exec(machineId, {
610
+ cmd: `sudo grep '^${AUTH_TOKEN_ENV_VAR}=' ${ENV_FILE_PATH} 2>/dev/null || true`,
611
+ timeoutSeconds: 60
612
+ });
613
+ const line = result.stdout.split("\n").map((entry) => entry.trim()).find((entry) => entry.startsWith(`${AUTH_TOKEN_ENV_VAR}=`));
614
+ const value = line?.slice(`${AUTH_TOKEN_ENV_VAR}=`.length);
615
+ if (typeof value !== "string" || value.length === 0) {
616
+ throw new Error(`instabox: ${AUTH_TOKEN_ENV_VAR} missing from the daemon env file`);
617
+ }
618
+ return value;
619
+ }
620
+ function resolveExtraPorts(spec) {
621
+ const extras = spec.extraPorts;
622
+ if (!Array.isArray(extras)) return [];
623
+ return extras.filter((port) => typeof port === "number" && Number.isInteger(port));
624
+ }
625
+ function createInstaboxSandboxProvider(baseConfig = {}) {
626
+ function resolveConfig(spec) {
627
+ const raw = spec.config ?? {};
628
+ const merged = { ...baseConfig, ...raw };
629
+ if (baseConfig.metadata !== void 0 && raw.metadata !== void 0) {
630
+ merged.metadata = {
631
+ ...baseConfig.metadata,
632
+ ...typeof raw.metadata === "object" && raw.metadata !== null ? raw.metadata : {}
633
+ };
634
+ }
635
+ return readConfig(merged);
636
+ }
637
+ function assertApiKey(config) {
638
+ if (config.apiKey === "") {
639
+ throw new Error(
640
+ "@agentproto/sandbox-instabox: no InstaBox API key \u2014 set spec.config.apiKey or the INSTABOX_API_KEY environment variable."
641
+ );
642
+ }
643
+ }
644
+ return {
645
+ async boot(spec, opts) {
646
+ const config = resolveConfig(spec);
647
+ assertApiKey(config);
648
+ const client = new InstaboxClient({ apiKey: config.apiKey, apiUrl: config.apiUrl });
649
+ const authToken = newAuthToken();
650
+ await assertQuotaAvailable(client);
651
+ const created = await client.createMachine(
652
+ {
653
+ name: newInstanceName(),
654
+ templateId: config.template,
655
+ timeoutSeconds: config.timeoutSeconds,
656
+ onTimeout: "kill",
657
+ autoSuspendAfterSeconds: config.autoSuspendAfterSeconds,
658
+ metadata: bootMetadata(config, spec),
659
+ // ALWAYS sent and ALWAYS false: a vault would write the caller's
660
+ // secrets into a box that runs agent code.
661
+ vault: false
662
+ },
663
+ crypto.randomUUID()
664
+ ).catch((err) => {
665
+ throw normalizeQuotaError(err);
666
+ });
667
+ try {
668
+ await client.waitForOperation(created.id);
669
+ const machineId = created.machineId ?? created.machine?.id;
670
+ if (machineId === void 0) {
671
+ throw new Error(
672
+ `@agentproto/sandbox-instabox: create operation ${created.id} settled with no machine id.`
673
+ );
674
+ }
675
+ await waitMachineReady(client, machineId, config.readyTimeoutMs);
676
+ const extraPorts = resolveExtraPorts(spec);
677
+ const provisioned = await provisionAndProbe(
678
+ { client, machineId, config, env: opts.env, authToken },
679
+ true
680
+ );
681
+ const ports = {};
682
+ for (const port of extraPorts) {
683
+ ports[port] = (await client.publishPort(machineId, port)).url;
684
+ }
685
+ return toBootedSandbox(client, machineId, provisioned.mcpUrl, authToken, config, ports);
686
+ } catch (err) {
687
+ const machineId = created.machineId ?? created.machine?.id;
688
+ if (machineId !== void 0) await client.deleteMachine(machineId).catch(() => void 0);
689
+ throw normalizeQuotaError(err);
690
+ }
691
+ },
692
+ /** Reconnect to an existing box, state-aware (see `reviveMachine`).
693
+ * State mapping: `running` = ok, auto-`suspended` = `wake`, manually
694
+ * `suspended` or `stopped` = `resume`/`start` as the API allows, else a
695
+ * clear error. A deleted machine surfaces as `SandboxBoxGoneError`. */
696
+ async connect(sandboxId, spec, opts) {
697
+ const config = resolveConfig(spec);
698
+ assertApiKey(config);
699
+ const client = new InstaboxClient({ apiKey: config.apiKey, apiUrl: config.apiUrl });
700
+ let machine;
701
+ try {
702
+ machine = await client.getMachine(sandboxId);
703
+ } catch (err) {
704
+ if (err instanceof InstaboxApiError && err.status === 404) {
705
+ throw new SandboxBoxGoneError(sandboxId, err);
706
+ }
707
+ throw err;
708
+ }
709
+ const machineId = machineIdOf(machine);
710
+ await reviveMachine(client, machineId, machine.state);
711
+ await waitMachineReady(client, machineId, config.readyTimeoutMs);
712
+ if (machine.state !== "running" && (await client.getMachine(machineId)).state !== "running") {
713
+ throw new Error(
714
+ `@agentproto/sandbox-instabox: machine ${machineId} did not reach "running" \u2014 cannot reconnect.`
715
+ );
716
+ }
717
+ let authToken;
718
+ try {
719
+ authToken = await readExistingAuthToken(client, machineId);
720
+ } catch (err) {
721
+ throw new Error(
722
+ `@agentproto/sandbox-instabox: machine ${machineId} is running but its daemon token could not be read from ${ENV_FILE_PATH} (${err instanceof Error ? err.message : String(err)}) \u2014 reconnect needs the token the boot that installed the daemon issued.`
723
+ );
724
+ }
725
+ await client.patchMachine(machineId, { timeoutSeconds: clampTimeoutSeconds(config.timeoutSeconds) });
726
+ const provisioned = await provisionAndProbe(
727
+ { client, machineId, config, env: opts.env, authToken },
728
+ false
729
+ );
730
+ const ports = {};
731
+ for (const port of resolveExtraPorts(spec)) {
732
+ ports[port] = (await client.publishPort(machineId, port)).url;
733
+ }
734
+ return toBootedSandbox(client, machineId, provisioned.mcpUrl, authToken, config, ports);
735
+ },
736
+ /** Provider control-plane liveness: 404 means gone; any other answer (or
737
+ * thrown error) stays verbatim so callers read "check failed", never
738
+ * "box died". */
739
+ async probe(sandboxId) {
740
+ const providerApiKey = baseConfig.apiKey ?? process.env.INSTABOX_API_KEY;
741
+ if (providerApiKey === void 0 || providerApiKey === "") {
742
+ throw new Error(
743
+ "@agentproto/sandbox-instabox: no InstaBox API key for probe \u2014 construct the provider with createInstaboxSandboxProvider({ apiKey }) or set INSTABOX_API_KEY."
744
+ );
745
+ }
746
+ const client = new InstaboxClient({ apiKey: providerApiKey, apiUrl: baseConfig.apiUrl });
747
+ try {
748
+ const machine = await client.getMachine(sandboxId);
749
+ return { alive: true, ...machine.state !== void 0 ? { state: String(machine.state) } : {} };
750
+ } catch (err) {
751
+ if (err instanceof InstaboxApiError && err.status === 404) return { alive: false };
752
+ throw err;
753
+ }
754
+ }
755
+ };
756
+ }
757
+ var instaboxSandboxProvider = createInstaboxSandboxProvider();
758
+
759
+ export { AGENTPROTO_METADATA_KEY, AUTH_TOKEN_ENV_VAR, BACKGROUND_REDIRECT, DAEMON_UNIT_NAME, ENV_FILE_PATH, InstaboxApiError, InstaboxClient, SYSTEMD_UNIT_PATH, assertQuotaAvailable, buildCliInstallCommand, createInstaboxSandboxProvider, environmentFileContents, instaboxSandboxProvider, isInstaboxQuotaFailure, isTransientInstaboxError, normalizeQuotaError, writeDaemonUnitCommand };
760
+ //# sourceMappingURL=index.mjs.map
761
+ //# sourceMappingURL=index.mjs.map