@promigence/sdk 0.1.0-beta.14 → 0.1.0-beta.16

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/pty.d.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Interactive terminals: `GET /v1/sandboxes/:id/pty` over a WebSocket (RFC 6455).
3
3
  *
4
- * The protocol:
4
+ * The protocol (`promigence.pty.v2`):
5
5
  *
6
- * client → server binary = keystrokes; text = `{"resize":{cols,rows}}` | `{"signal":N}` | `{"eof":true}`
6
+ * client → server first, one text frame `{"start":{"cmd"?,"secrets"?}}`: what to run and the secret
7
+ * names it gets, inside the connection, never in a header or the URL; then
8
+ * binary = keystrokes; text = `{"resize":{cols,rows}}` | `{"signal":N}` | `{"eof":true}`
7
9
  * server → client binary = terminal output; text = `{"event":"open",…}` first and
8
- * `{"event":"exit"|"error"|"closed",…}` last, then a Close frame
10
+ * `{"event":"exit"|"error"|"closed",…}` last, then a Close frame. A terminal that
11
+ * cannot open says so in its first frame: `{"event":"error","status",…}`, then Close.
9
12
  *
10
13
  * Zero dependencies, so this is a small client over `node:http` rather than the global
11
14
  * `WebSocket`: that one cannot send an `Authorization` header, and a key in the query string is
@@ -32,26 +35,20 @@ export interface PtyExit {
32
35
  message: string;
33
36
  };
34
37
  }
35
- /** The header the terminal's command travels in, base64. Never a query parameter. */
36
- export declare const PTY_COMMAND_HEADER = "promigence-pty-command";
37
- /** The header the terminal's secret **names** travel in, base64 JSON. */
38
- export declare const PTY_SECRETS_HEADER = "promigence-pty-secrets";
38
+ /** The WebSocket subprotocol a terminal speaks: its first message is `{"start": {"cmd"?, "secrets"?}}`. */
39
+ export declare const PTY_PROTOCOL = "promigence.pty.v2";
39
40
  export interface PtyConnectOpts {
40
41
  /** `http(s)://host[:port]/v1/sandboxes/<id>/pty?cols=&rows=` — dimensions only. */
41
42
  url: string;
42
43
  apiKey: string;
43
44
  userAgent: string;
44
45
  /**
45
- * The command to run, sent as a header rather than in the URL.
46
- *
47
- * It used to be `?cmd=`, with `envs` composed into it as `export K=v;` — so a terminal opened
48
- * with an API key in its environment wrote that key into the query string of every request,
49
- * and therefore into every access log along the path. Names and values both stay out of the
50
- * URL now.
46
+ * The command to run. Sent in the terminal's first message, inside the connection: never in a
47
+ * header or the URL, where a log on the way could keep it.
51
48
  */
52
49
  command?: string;
53
50
  /** Stored secret names (or `{name, as}` bindings), resolved server-side into the terminal's
54
- * environment. JSON, base64, in a header. */
51
+ * environment. Sent in the first message with the command. */
55
52
  secrets?: unknown[];
56
53
  /** The `promigence-caller` label, when one is set (`setCaller`). */
57
54
  caller?: string;
package/dist/pty.js CHANGED
@@ -5,8 +5,10 @@ export const PTY_INPUT_CHUNK = 64 * 1024;
5
5
  export const PTY_INPUT_KEEP = 256 * 1024;
6
6
  export const PTY_RESUME_HEADER = "promigence-resume";
7
7
  const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
8
- export const PTY_COMMAND_HEADER = "promigence-pty-command";
9
- export const PTY_SECRETS_HEADER = "promigence-pty-secrets";
8
+ export const PTY_PROTOCOL = "promigence.pty.v2";
9
+ export function ptyStartMessage(command, secrets) {
10
+ return JSON.stringify({ start: { ...(command ? { cmd: command } : {}), ...(secrets?.length ? { secrets } : {}) } });
11
+ }
10
12
  export function wsAccept(key) {
11
13
  return createHash("sha1").update(key + WS_GUID).digest("base64");
12
14
  }
@@ -250,15 +252,7 @@ function openLeg(opts, url, resume) {
250
252
  authorization: `Bearer ${opts.apiKey}`,
251
253
  "user-agent": opts.userAgent,
252
254
  ...(opts.caller ? { [CALLER_HEADER]: opts.caller } : {}),
253
- ...(resume ? { [PTY_RESUME_HEADER]: resume } : {}),
254
- ...(opts.command && !resume
255
- ? { [PTY_COMMAND_HEADER]: Buffer.from(opts.command, "utf8").toString("base64") }
256
- : {}),
257
- ...(opts.secrets?.length && !resume
258
- ? {
259
- [PTY_SECRETS_HEADER]: Buffer.from(JSON.stringify(opts.secrets), "utf8").toString("base64"),
260
- }
261
- : {}),
255
+ ...(resume ? { [PTY_RESUME_HEADER]: resume } : { "sec-websocket-protocol": PTY_PROTOCOL }),
262
256
  },
263
257
  });
264
258
  let resolveEnded;
@@ -291,6 +285,8 @@ function openLeg(opts, url, resume) {
291
285
  return;
292
286
  }
293
287
  socket.setNoDelay(true);
288
+ if (!resume)
289
+ socket.write(encodeFrame(0x1, Buffer.from(ptyStartMessage(opts.command, opts.secrets), "utf8")));
294
290
  const reader = new FrameReader();
295
291
  let exit;
296
292
  let closeCode;
@@ -328,6 +324,12 @@ function openLeg(opts, url, resume) {
328
324
  ...(typeof v["wall_ms"] === "number" ? { wallMs: v["wall_ms"] } : {}),
329
325
  };
330
326
  }
327
+ else if (v["event"] === "error" && !wasOpen && typeof v["status"] === "number") {
328
+ const e = (v["error"] && typeof v["error"] === "object" ? v["error"] : { code: v["code"], message: v["message"] });
329
+ fail(v["status"], JSON.stringify({ error: e }), typeof v["retry_after"] === "string" ? v["retry_after"] : undefined);
330
+ socket.destroy();
331
+ return;
332
+ }
331
333
  else if (v["event"] === "error" || v["event"] === "closed") {
332
334
  exit = {
333
335
  exitCode: null, signal: null,
package/dist/sandbox.d.ts CHANGED
@@ -14,6 +14,8 @@ import { type CommandResult, type CommandRunOpts, type ForkOpts, type OptimizeFo
14
14
  import { PromigenceClient, type BackgroundExecRecord, type DirEntry, type DirListOpts, type ExtractResult, type FileInfo, type FileMatch, type Page, type PortRecord, type AutoPauseMode, type SandboxRecord, type SearchOpts, type SecretBinding, type SnapshotBuild, type VerifySpec } from "./client.js";
15
15
  import { CommandHandle } from "./handles.js";
16
16
  import type { PtyExit, PtySession, PtySize } from "./pty.js";
17
+ /** `setTimeout`'s fix when the longer timeout does not fit under the run's spend cap. */
18
+ export declare const EXTEND_OVER_CAP_FIX: string;
17
19
  /**
18
20
  * `commands.run` options for a foreground command. The call resolves with the whole result when
19
21
  * the command ends; `onStdout` / `onStderr` also receive its output as it is written.
@@ -150,6 +152,8 @@ export interface SandboxCreateOpts extends SandboxOpts {
150
152
  start?: string | string[];
151
153
  tier?: Tier;
152
154
  alias?: string;
155
+ /** With `alias`: take the name from the snapshot that has it (refused without this). */
156
+ moveAlias?: boolean;
153
157
  /** Name of a stored secret holding the registry credentials. */
154
158
  registrySecret?: string;
155
159
  /** Bound on the first build of an image (default 15 min). Expiry cancels nothing. */
@@ -196,7 +200,7 @@ export interface SandboxCreateOpts extends SandboxOpts {
196
200
  * inputs) was built before, or 202 with a `build_id`. The **body**, not the status, decides: an
197
201
  * idempotency replay answers 202 with a document that may already be `verified`.
198
202
  */
199
- export declare function imageSnapshot(client: PromigenceClient, opts: Pick<SandboxCreateOpts, "image" | "setup" | "verify" | "start" | "tier" | "alias" | "registrySecret" | "buildTimeoutMs">, hooks?: {
203
+ export declare function imageSnapshot(client: PromigenceClient, opts: Pick<SandboxCreateOpts, "image" | "setup" | "verify" | "start" | "tier" | "alias" | "moveAlias" | "registrySecret" | "buildTimeoutMs">, hooks?: {
200
204
  /**
201
205
  * The `POST /snapshots` answer, before any wait: `build_price` (the most it can cost) and
202
206
  * whether it was `deduped` or `joined` (nothing new charged). An implicit build costs money, so a caller that prints prices prints this one too.
@@ -312,7 +316,14 @@ export declare class Sandbox {
312
316
  resize(pid: number, size: PtySize): Promise<void>;
313
317
  kill(pid: number): Promise<boolean>;
314
318
  };
315
- /** Preview URLs for ports inside the sandbox (`getHost` gives the address alone). */
319
+ /**
320
+ * Preview URLs for ports inside the sandbox (`getHost` gives the address alone).
321
+ *
322
+ * Anyone holding a preview URL can open it; revoke it any time, and it ends with the sandbox.
323
+ * If your account's preview URLs aren't on, `expose` throws `PreviewsUnavailableError`; write to
324
+ * support@promigence.ai to have them turned on, and meanwhile reach the port with
325
+ * `promigence sandbox ssh <sid> -- -N -L <port>:localhost:<port>`.
326
+ */
316
327
  readonly ports: {
317
328
  expose(port: number): Promise<PortRecord>;
318
329
  list(): Promise<PortRecord[]>;
@@ -458,13 +469,16 @@ export declare class Sandbox {
458
469
  }>;
459
470
  isRunning(): Promise<boolean>;
460
471
  /**
461
- * `PATCH /sandboxes/:id` — extend this sandbox's timeout.
472
+ * `PATCH /sandboxes/:id` — set this sandbox's timeout, in milliseconds.
462
473
  *
463
- * The timeout is the sandbox's total lifetime, not "from now", and the difference is billing:
464
- * commits *seconds* up front and `billable_seconds <= committed_seconds`, so an extension is a new
465
- * commitment that has to fit under the spend cap you set (the run's, or the org default). It throws
466
- * `SpendCapError` (409) rather than silently extending — which is the behaviour a spend
467
- * cap exists to produce, and the one difference here worth a caller knowing about.
474
+ * The timeout is the sandbox's total lifetime, counted from its start, not from now:
475
+ * `setTimeout(600_000)` on a sandbox that has run 2 minutes leaves it 8 more.
476
+ *
477
+ * - Raising it commits the added seconds, which has to fit in what is left of the spend cap you set
478
+ * (the run's, or the org default): past it, it throws `SpendCapError` (409) rather than silently
479
+ * extending.
480
+ * - Lowering it goes down to what the sandbox has run plus 60 s, and no further
481
+ * (`InvalidArgumentError` below that). To stop it now, `kill()` it.
468
482
  */
469
483
  setTimeout(ms: number): Promise<void>;
470
484
  /**
@@ -500,6 +514,10 @@ export declare class Sandbox {
500
514
  *
501
515
  * Async, because the address is a capability minted on demand rather than
502
516
  * a hostname derived from the id. An existing URL for the port is reused, not re-minted.
517
+ *
518
+ * If your account's preview URLs aren't on, this throws `PreviewsUnavailableError`; write to
519
+ * support@promigence.ai to have them turned on, and meanwhile reach the port with
520
+ * `promigence sandbox ssh <sid> -- -N -L <port>:localhost:<port>`.
503
521
  */
504
522
  getHost(port: number): Promise<string>;
505
523
  /**
package/dist/sandbox.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { inspect } from "node:util";
2
- import { ApiError, CommandExitError, EnvFailedError, IMAGE_BUILD_TIMEOUT_MS, InvalidArgumentError, NotFoundError, TimeoutError, UnsupportedError, WAIT_TIMEOUT_MS, withPreviewsFix, } from "./core.js";
2
+ import { ApiError, CommandExitError, EnvFailedError, IMAGE_BUILD_TIMEOUT_MS, InvalidArgumentError, NotFoundError, SpendCapError, TimeoutError, UnsupportedError, WAIT_TIMEOUT_MS, withPreviewsFix, } from "./core.js";
3
3
  import { PromigenceClient, LONG_POLL_MS, checkImageRef, checkImageVersioned, checkSandboxId, errorFor, newIdempotencyKey, toCommandResult, isWaiting, } from "./client.js";
4
4
  import { CommandHandle } from "./handles.js";
5
5
  import { CommandTimeoutError, answerTimedOut } from "./command-errors.js";
6
6
  import { hintArg } from "./hint.js";
7
+ export const EXTEND_OVER_CAP_FIX = "Raising the timeout must fit in what is left of the run's spend cap, and this does not: start a new " +
8
+ "sandbox with the longer `timeoutMs` and a `spendCap` that covers it.";
7
9
  const EXEC_TIMEOUT_DEFAULT_MS = 15 * 60_000;
8
10
  function textSink(cb) {
9
11
  if (!cb)
@@ -37,6 +39,15 @@ export function ptyCommand(opts) {
37
39
  parts.push(opts.cmd ?? DEFAULT_PTY_CMD);
38
40
  return parts.join(" ");
39
41
  }
42
+ function allowList(hosts) {
43
+ if (!Array.isArray(hosts)) {
44
+ throw new InvalidArgumentError("allowHosts must be a list of hosts", 'Pass allowHosts: ["github.com"].');
45
+ }
46
+ if (!hosts.some((h) => typeof h !== "string" || h.trim() !== "")) {
47
+ throw new InvalidArgumentError("allowHosts names no host, so the allow-list is empty", 'Name each host the sandbox may reach: allowHosts: ["github.com"]. For no network pass allowInternetAccess: false; to reach anything leave allowHosts out.');
48
+ }
49
+ return hosts;
50
+ }
40
51
  function permString(mode) {
41
52
  const r = (m) => `${m & 4 ? "r" : "-"}${m & 2 ? "w" : "-"}${m & 1 ? "x" : "-"}`;
42
53
  return r((mode >> 6) & 7) + r((mode >> 3) & 7) + r(mode & 7);
@@ -89,6 +100,8 @@ export async function imageSnapshot(client, opts, hooks = {}) {
89
100
  create.tier = opts.tier;
90
101
  if (opts.alias !== undefined)
91
102
  create.alias = opts.alias;
103
+ if (opts.moveAlias)
104
+ create.moveAlias = true;
92
105
  if (opts.registrySecret !== undefined)
93
106
  create.registrySecret = opts.registrySecret;
94
107
  let build = (await client.snapshots.create(create)) ?? {};
@@ -362,6 +375,9 @@ export class Sandbox {
362
375
  return format === "bytes" ? bytes : new TextDecoder().decode(bytes);
363
376
  }),
364
377
  write: async (path, data, opts = {}) => {
378
+ if (typeof path !== "string") {
379
+ throw new InvalidArgumentError(`files.write takes one path and its data; got ${Array.isArray(path) ? "an array" : path === null ? "null" : typeof path} as the path`, "Write each file with its own call: for (const f of files) await sandbox.files.write(f.path, f.data).");
380
+ }
365
381
  let body;
366
382
  if (typeof data === "string" || data instanceof Uint8Array)
367
383
  body = data;
@@ -556,8 +572,8 @@ export class Sandbox {
556
572
  start.timeoutMs = opts.timeoutMs;
557
573
  if (opts.allowInternetAccess === false)
558
574
  start.network = "none";
559
- if (opts.allowHosts?.length)
560
- start.network = { allow: opts.allowHosts };
575
+ if (opts.allowHosts !== undefined && opts.allowHosts !== null)
576
+ start.network = { allow: allowList(opts.allowHosts) };
561
577
  if (opts.metadata)
562
578
  start.labels = opts.metadata;
563
579
  if (opts.secrets?.length)
@@ -695,7 +711,15 @@ export class Sandbox {
695
711
  return rec.state === "ready" || rec.state === "running";
696
712
  }
697
713
  async setTimeout(ms) {
698
- await this.#client.sandboxes.patch(this.sandboxId, { timeoutMs: ms });
714
+ try {
715
+ await this.#client.sandboxes.patch(this.sandboxId, { timeoutMs: ms });
716
+ }
717
+ catch (e) {
718
+ if (e instanceof SpendCapError && e.code === "cap_exceeded") {
719
+ Object.defineProperty(e, "fix", { value: EXTEND_OVER_CAP_FIX, enumerable: true, configurable: true });
720
+ }
721
+ throw e;
722
+ }
699
723
  this.timeoutMs = ms;
700
724
  }
701
725
  async setAutoPause(seconds, mode) {
@@ -290,6 +290,7 @@ function start(init) {
290
290
  let rrAny = 0;
291
291
  let pumpTimer;
292
292
  let pumpSoon;
293
+ let turnsHeld = false;
293
294
  const freeNow = (l, now) => {
294
295
  for (let i = 0; i < l.length; i++) {
295
296
  const k = (rr + i) % l.length;
@@ -304,6 +305,8 @@ function start(init) {
304
305
  const pump = () => {
305
306
  pumpTimer = undefined;
306
307
  pumpSoon = undefined;
308
+ if (turnsHeld)
309
+ return;
307
310
  while (waiting.length > 0) {
308
311
  const l = live();
309
312
  if (l.length === 0) {
@@ -346,7 +349,7 @@ function start(init) {
346
349
  rrAny = (rrAny % l.length) + 1;
347
350
  return e;
348
351
  }
349
- if (waiting.length === 0) {
352
+ if (waiting.length === 0 && !turnsHeld) {
350
353
  const now = hrNow();
351
354
  const e = freeNow(l, now);
352
355
  if (e !== undefined) {
@@ -527,6 +530,12 @@ function start(init) {
527
530
  }
528
531
  else if (m?.t === "crash-for-test")
529
532
  setImmediate(() => { throw new Error("transport thread crash (test)"); });
533
+ else if (m?.t === "hold-turns-for-test")
534
+ turnsHeld = true;
535
+ else if (m?.t === "release-turns-for-test") {
536
+ turnsHeld = false;
537
+ pump();
538
+ }
530
539
  });
531
540
  void open();
532
541
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promigence/sdk",
3
- "version": "0.1.0-beta.14",
3
+ "version": "0.1.0-beta.16",
4
4
  "description": "The fastest sandboxes for AI agents and code. The Promigence TypeScript SDK: snapshot an environment once, fork it as often as you need, run commands in every copy. A simple Sandbox class and the full PromigenceClient, with zero runtime dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://www.promigence.ai",