@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/cjs/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/cjs/pty.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PTY_RESTART_CLOSED_EXIT = exports.PTY_RESTART_EXIT = exports.PTY_RECONNECT_MAX_WAIT_MS = exports.PTY_RECONNECT_FIRST_WAIT_MS = exports.PTY_RECONNECT_FOR_MS = exports.PTY_RECONNECT_CODE = exports.PtySession = exports.FrameReader = exports.PTY_SECRETS_HEADER = exports.PTY_COMMAND_HEADER = exports.PTY_RESUME_HEADER = exports.PTY_INPUT_KEEP = exports.PTY_INPUT_CHUNK = void 0;
3
+ exports.PTY_RESTART_CLOSED_EXIT = exports.PTY_RESTART_EXIT = exports.PTY_RECONNECT_MAX_WAIT_MS = exports.PTY_RECONNECT_FIRST_WAIT_MS = exports.PTY_RECONNECT_FOR_MS = exports.PTY_RECONNECT_CODE = exports.PtySession = exports.FrameReader = exports.PTY_PROTOCOL = exports.PTY_RESUME_HEADER = exports.PTY_INPUT_KEEP = exports.PTY_INPUT_CHUNK = void 0;
4
+ exports.ptyStartMessage = ptyStartMessage;
4
5
  exports.wsAccept = wsAccept;
5
6
  exports.encodeFrame = encodeFrame;
6
7
  exports.connectPty = connectPty;
@@ -11,8 +12,10 @@ exports.PTY_INPUT_CHUNK = 64 * 1024;
11
12
  exports.PTY_INPUT_KEEP = 256 * 1024;
12
13
  exports.PTY_RESUME_HEADER = "promigence-resume";
13
14
  const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
14
- exports.PTY_COMMAND_HEADER = "promigence-pty-command";
15
- exports.PTY_SECRETS_HEADER = "promigence-pty-secrets";
15
+ exports.PTY_PROTOCOL = "promigence.pty.v2";
16
+ function ptyStartMessage(command, secrets) {
17
+ return JSON.stringify({ start: { ...(command ? { cmd: command } : {}), ...(secrets?.length ? { secrets } : {}) } });
18
+ }
16
19
  function wsAccept(key) {
17
20
  return (0, node_crypto_1.createHash)("sha1").update(key + WS_GUID).digest("base64");
18
21
  }
@@ -258,15 +261,7 @@ function openLeg(opts, url, resume) {
258
261
  authorization: `Bearer ${opts.apiKey}`,
259
262
  "user-agent": opts.userAgent,
260
263
  ...(opts.caller ? { [caller_js_1.CALLER_HEADER]: opts.caller } : {}),
261
- ...(resume ? { [exports.PTY_RESUME_HEADER]: resume } : {}),
262
- ...(opts.command && !resume
263
- ? { [exports.PTY_COMMAND_HEADER]: Buffer.from(opts.command, "utf8").toString("base64") }
264
- : {}),
265
- ...(opts.secrets?.length && !resume
266
- ? {
267
- [exports.PTY_SECRETS_HEADER]: Buffer.from(JSON.stringify(opts.secrets), "utf8").toString("base64"),
268
- }
269
- : {}),
264
+ ...(resume ? { [exports.PTY_RESUME_HEADER]: resume } : { "sec-websocket-protocol": exports.PTY_PROTOCOL }),
270
265
  },
271
266
  });
272
267
  let resolveEnded;
@@ -299,6 +294,8 @@ function openLeg(opts, url, resume) {
299
294
  return;
300
295
  }
301
296
  socket.setNoDelay(true);
297
+ if (!resume)
298
+ socket.write(encodeFrame(0x1, Buffer.from(ptyStartMessage(opts.command, opts.secrets), "utf8")));
302
299
  const reader = new FrameReader();
303
300
  let exit;
304
301
  let closeCode;
@@ -336,6 +333,12 @@ function openLeg(opts, url, resume) {
336
333
  ...(typeof v["wall_ms"] === "number" ? { wallMs: v["wall_ms"] } : {}),
337
334
  };
338
335
  }
336
+ else if (v["event"] === "error" && !wasOpen && typeof v["status"] === "number") {
337
+ const e = (v["error"] && typeof v["error"] === "object" ? v["error"] : { code: v["code"], message: v["message"] });
338
+ fail(v["status"], JSON.stringify({ error: e }), typeof v["retry_after"] === "string" ? v["retry_after"] : undefined);
339
+ socket.destroy();
340
+ return;
341
+ }
339
342
  else if (v["event"] === "error" || v["event"] === "closed") {
340
343
  exit = {
341
344
  exitCode: null, signal: null,
@@ -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
  /**
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Sandbox = exports.DEFAULT_SNAPSHOT = exports.DEFAULT_PTY_CMD = void 0;
3
+ exports.Sandbox = exports.DEFAULT_SNAPSHOT = exports.DEFAULT_PTY_CMD = exports.EXTEND_OVER_CAP_FIX = void 0;
4
4
  exports.shellQuote = shellQuote;
5
5
  exports.ptyCommand = ptyCommand;
6
6
  exports.toEntryInfo = toEntryInfo;
@@ -14,6 +14,8 @@ const client_js_1 = require("./client.js");
14
14
  const handles_js_1 = require("./handles.js");
15
15
  const command_errors_js_1 = require("./command-errors.js");
16
16
  const hint_js_1 = require("./hint.js");
17
+ exports.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 " +
18
+ "sandbox with the longer `timeoutMs` and a `spendCap` that covers it.";
17
19
  const EXEC_TIMEOUT_DEFAULT_MS = 15 * 60_000;
18
20
  function textSink(cb) {
19
21
  if (!cb)
@@ -47,6 +49,15 @@ function ptyCommand(opts) {
47
49
  parts.push(opts.cmd ?? exports.DEFAULT_PTY_CMD);
48
50
  return parts.join(" ");
49
51
  }
52
+ function allowList(hosts) {
53
+ if (!Array.isArray(hosts)) {
54
+ throw new core_js_1.InvalidArgumentError("allowHosts must be a list of hosts", 'Pass allowHosts: ["github.com"].');
55
+ }
56
+ if (!hosts.some((h) => typeof h !== "string" || h.trim() !== "")) {
57
+ throw new core_js_1.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.');
58
+ }
59
+ return hosts;
60
+ }
50
61
  function permString(mode) {
51
62
  const r = (m) => `${m & 4 ? "r" : "-"}${m & 2 ? "w" : "-"}${m & 1 ? "x" : "-"}`;
52
63
  return r((mode >> 6) & 7) + r((mode >> 3) & 7) + r(mode & 7);
@@ -99,6 +110,8 @@ async function imageSnapshot(client, opts, hooks = {}) {
99
110
  create.tier = opts.tier;
100
111
  if (opts.alias !== undefined)
101
112
  create.alias = opts.alias;
113
+ if (opts.moveAlias)
114
+ create.moveAlias = true;
102
115
  if (opts.registrySecret !== undefined)
103
116
  create.registrySecret = opts.registrySecret;
104
117
  let build = (await client.snapshots.create(create)) ?? {};
@@ -372,6 +385,9 @@ class Sandbox {
372
385
  return format === "bytes" ? bytes : new TextDecoder().decode(bytes);
373
386
  }),
374
387
  write: async (path, data, opts = {}) => {
388
+ if (typeof path !== "string") {
389
+ throw new core_js_1.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).");
390
+ }
375
391
  let body;
376
392
  if (typeof data === "string" || data instanceof Uint8Array)
377
393
  body = data;
@@ -566,8 +582,8 @@ class Sandbox {
566
582
  start.timeoutMs = opts.timeoutMs;
567
583
  if (opts.allowInternetAccess === false)
568
584
  start.network = "none";
569
- if (opts.allowHosts?.length)
570
- start.network = { allow: opts.allowHosts };
585
+ if (opts.allowHosts !== undefined && opts.allowHosts !== null)
586
+ start.network = { allow: allowList(opts.allowHosts) };
571
587
  if (opts.metadata)
572
588
  start.labels = opts.metadata;
573
589
  if (opts.secrets?.length)
@@ -705,7 +721,15 @@ class Sandbox {
705
721
  return rec.state === "ready" || rec.state === "running";
706
722
  }
707
723
  async setTimeout(ms) {
708
- await this.#client.sandboxes.patch(this.sandboxId, { timeoutMs: ms });
724
+ try {
725
+ await this.#client.sandboxes.patch(this.sandboxId, { timeoutMs: ms });
726
+ }
727
+ catch (e) {
728
+ if (e instanceof core_js_1.SpendCapError && e.code === "cap_exceeded") {
729
+ Object.defineProperty(e, "fix", { value: exports.EXTEND_OVER_CAP_FIX, enumerable: true, configurable: true });
730
+ }
731
+ throw e;
732
+ }
709
733
  this.timeoutMs = ms;
710
734
  }
711
735
  async setAutoPause(seconds, mode) {
@@ -292,6 +292,7 @@ function start(init) {
292
292
  let rrAny = 0;
293
293
  let pumpTimer;
294
294
  let pumpSoon;
295
+ let turnsHeld = false;
295
296
  const freeNow = (l, now) => {
296
297
  for (let i = 0; i < l.length; i++) {
297
298
  const k = (rr + i) % l.length;
@@ -306,6 +307,8 @@ function start(init) {
306
307
  const pump = () => {
307
308
  pumpTimer = undefined;
308
309
  pumpSoon = undefined;
310
+ if (turnsHeld)
311
+ return;
309
312
  while (waiting.length > 0) {
310
313
  const l = live();
311
314
  if (l.length === 0) {
@@ -348,7 +351,7 @@ function start(init) {
348
351
  rrAny = (rrAny % l.length) + 1;
349
352
  return e;
350
353
  }
351
- if (waiting.length === 0) {
354
+ if (waiting.length === 0 && !turnsHeld) {
352
355
  const now = hrNow();
353
356
  const e = freeNow(l, now);
354
357
  if (e !== undefined) {
@@ -529,6 +532,12 @@ function start(init) {
529
532
  }
530
533
  else if (m?.t === "crash-for-test")
531
534
  setImmediate(() => { throw new Error("transport thread crash (test)"); });
535
+ else if (m?.t === "hold-turns-for-test")
536
+ turnsHeld = true;
537
+ else if (m?.t === "release-turns-for-test") {
538
+ turnsHeld = false;
539
+ pump();
540
+ }
532
541
  });
533
542
  void open();
534
543
  }