@promigence/sdk 0.1.0-beta.15 → 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/client.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { inspect, type InspectOptions } from "node:util";
3
3
  import { PromigenceError, type CommandResult, type NetworkMode, type OptimizeFor, type Tier } from "./core.js";
4
4
  import { type Bundle } from "./bundle.js";
5
+ import { type CredentialStore } from "./credstore.js";
5
6
  import { type PtyExit, type PtySession } from "./pty.js";
6
7
  import { type SshTunnel } from "./ssh.js";
7
8
  import type * as M from "./models.gen.js";
@@ -280,6 +281,10 @@ export interface ReportSandbox extends QueuedInfo {
280
281
  sandbox_id: string;
281
282
  state: SandboxState;
282
283
  attempt?: number;
284
+ /** `PROMIGENCE_INDEX`: its place in the run, from 0. Absent when the run's command set it itself. */
285
+ index?: number;
286
+ /** `PROMIGENCE_SEED`: the number its commands were given. Absent when the run's command set it itself. */
287
+ seed?: number;
283
288
  queued_ms?: number;
284
289
  ready_ms?: number;
285
290
  e2e_ms?: number;
@@ -319,11 +324,16 @@ export interface RunReport {
319
324
  snapshot_id?: string;
320
325
  alias?: string;
321
326
  labels?: Record<string, string>;
327
+ /**
328
+ * What the run asked for. `network` is `"allow"` for a run behind an allowlist, whose hosts are
329
+ * `allow` (a service before it said `"egress"` for one).
330
+ */
322
331
  requested?: {
323
332
  count: number;
324
333
  timeout_ms: number;
325
334
  verify_each: boolean;
326
- network: NetworkMode;
335
+ network: NetworkMode | "allow";
336
+ allow?: string[];
327
337
  allow_partial: boolean;
328
338
  secrets?: string[];
329
339
  grade?: GradeSpec;
@@ -437,13 +447,19 @@ export interface SnapshotBuild {
437
447
  repo?: string;
438
448
  commit?: string;
439
449
  verification?: Record<string, unknown>;
440
- /** A rejected build names the guard and the measured value; nothing was stored. */
450
+ /**
451
+ * A rejected build names the guard and the measured value; nothing was stored. `log_tail` is
452
+ * your build's own output: for a step of yours that failed (its start command, a setup step, its
453
+ * verify), that step's last lines. `fix`, when present, is what to change.
454
+ */
441
455
  error?: {
442
456
  code?: string;
443
457
  guard?: string;
444
458
  message?: string;
445
459
  values?: number[];
446
460
  stored?: boolean;
461
+ log_tail?: string[];
462
+ fix?: string;
447
463
  } | null;
448
464
  /** Set when an identical build already existed. */
449
465
  deduped?: boolean;
@@ -1153,8 +1169,9 @@ export interface SandboxPatchResponse {
1153
1169
  /**
1154
1170
  * What the idle rule counts as idle. `"requests"` (the default): nobody has called the sandbox
1155
1171
  * for the window and no command, terminal or SSH session is open. `"activity"`: all of that, and
1156
- * for the whole window nothing inside used CPU or sent or received a byte, and no network
1157
- * connection to the outside is open — for an agent that runs inside the sandbox on its own.
1172
+ * for the whole window the sandbox used under 5% of one core, sent and received no network byte
1173
+ * (disk writes do not count), and no network connection to the outside is open — for an agent
1174
+ * that runs inside the sandbox on its own.
1158
1175
  */
1159
1176
  export type AutoPauseMode = M.AutoPauseMode;
1160
1177
  /** `POST /snapshots/:id/verify` — re-run the recorded `verify` in a fresh fork. */
@@ -1372,6 +1389,8 @@ export interface PortRecord {
1372
1389
  /** The same token as a DNS label, for a wildcard-DNS deployment (`<label>.<suffix>`). */
1373
1390
  host_url_label?: string;
1374
1391
  created_unix_ns?: number;
1392
+ /** When the URL stops working on its own (`ttl_s`); `null` for one that works until revoked. */
1393
+ expires_unix_ns?: number | null;
1375
1394
  }
1376
1395
  /** A key named without its secret (`/me`'s `key`, the key lists). */
1377
1396
  export interface KeyRef {
@@ -1518,6 +1537,12 @@ export interface PromigenceClientOpts {
1518
1537
  */
1519
1538
  onTiming?: (timing: Timing) => void;
1520
1539
  }
1540
+ /** Where a client's key came from: the option, PROMIGENCE_API_KEY (`env`), the stored sign-in, or a fallback. */
1541
+ export interface KeyOrigin {
1542
+ source: "option" | "env" | "stored" | "fallback";
1543
+ /** The stored sign-in's store, for `stored`. */
1544
+ store?: CredentialStore;
1545
+ }
1521
1546
  /** `GET /sandboxes/:id/network`. */
1522
1547
  export interface SandboxNetwork {
1523
1548
  sandbox_id: string;
@@ -1756,6 +1781,11 @@ export interface SnapshotCreateOpts {
1756
1781
  */
1757
1782
  setupUser?: string;
1758
1783
  alias?: string;
1784
+ /**
1785
+ * With `alias`: take the name from the snapshot that has it, which stays, unnamed. Without it, an
1786
+ * alias another snapshot has is refused (`usage`, 400) and nothing is built.
1787
+ */
1788
+ moveAlias?: boolean;
1759
1789
  /**
1760
1790
  * A DEFAULT grader for this snapshot: a run on it with no `grade` of its own uses this one.
1761
1791
  * The same shape as `runs.start({ grade })`.
@@ -1841,11 +1871,12 @@ export declare function credentialsExposure(path: string): string | undefined;
1841
1871
  * `credentialsTrustProblem`).
1842
1872
  */
1843
1873
  export declare function readCredentials(env?: NodeJS.ProcessEnv): Credentials;
1844
- export declare function apiUrlFromCompatUrl(url: string | undefined): string | undefined;
1845
- export declare function apiUrlFromCompatDomain(domain: string | undefined): string | undefined;
1846
- export declare function compatApiKey(key: string | undefined): string | undefined;
1847
1874
  /** Which variable a key came from, for `promigence doctor`. */
1848
1875
  export declare function keyOrigin(env?: NodeJS.ProcessEnv): string | undefined;
1876
+ /** A request with no key found anywhere: none passed, none in the environment, none stored. */
1877
+ export declare const NO_KEY_MESSAGE = "no API key: none was given, PROMIGENCE_API_KEY is not set, and none is stored";
1878
+ /** What a caller of this SDK does about it. */
1879
+ export declare const NO_KEY_FIX = "Pass { apiKey } or set PROMIGENCE_API_KEY. No key yet: `promigence login` signs you up or in and stores one.";
1849
1880
  /** `name@sha256:<64 hex>`, optionally with a tag before the `@`: a reference already pinned. */
1850
1881
  export declare const IMAGE_DIGEST_RE: RegExp;
1851
1882
  /**
@@ -2510,6 +2541,10 @@ export declare class PromigenceClient {
2510
2541
  /**
2511
2542
  * Preview URLs for ports inside the sandbox. A URL is a capability — anyone holding it reaches
2512
2543
  * that port — and dies with `revoke`, with a pause, or with the sandbox.
2544
+ *
2545
+ * If your account's preview URLs aren't on, `expose` throws `PreviewsUnavailableError`; write to
2546
+ * support@promigence.ai to have them turned on, and meanwhile reach the port with
2547
+ * `promigence sandbox ssh <sid> -- -N -L <port>:localhost:<port>`.
2513
2548
  */
2514
2549
  ports: {
2515
2550
  expose: (sandboxId: string, port: number) => Promise<PortRecord>;
@@ -3049,8 +3084,14 @@ export declare class PromigenceClient {
3049
3084
  alias?: string | null;
3050
3085
  deleted: boolean;
3051
3086
  }>;
3052
- /** `PUT /snapshots/:id/alias` — the sidecar's one mutable field. */
3053
- alias: (idOrAlias: string, alias: string | null) => Promise<SnapshotRecord>;
3087
+ /**
3088
+ * `PUT /snapshots/:id/alias` — the sidecar's one mutable field. A name another snapshot
3089
+ * has is refused (`usage`, 400) unless `moveAlias: true`, which takes it; that one stays,
3090
+ * unnamed.
3091
+ */
3092
+ alias: (idOrAlias: string, alias: string | null, opts?: {
3093
+ moveAlias?: boolean;
3094
+ }) => Promise<SnapshotRecord>;
3054
3095
  /**
3055
3096
  * Wait for a build to leave `running`, bounded by `waitTimeoutMs` (default 100 s).
3056
3097
  *
package/dist/client.js CHANGED
@@ -2,7 +2,7 @@ import { readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { inspect } from "node:util";
4
4
  import { dirname, join } from "node:path";
5
- import { ApiError, AuthError, EnvFailedError, PromigenceError, InvalidArgumentError, NotFoundError, NotReleasedError, NotVerifiedError, RateLimitError, ServiceRestartError, SpendCapError, TimeoutError, UnreachableError, DEFAULT_API_URL, ERRORS_DOCS_URL, FILE_PUT_CHUNK_BYTES, MAX_OUTPUT_BYTES_DEFAULT, VERSION, WAIT_TIMEOUT_MS, PreviewsUnavailableError, exitForCode, previewsOff, withPreviewsFix, } from "./core.js";
5
+ import { ApiError, AuthError, EnvFailedError, PromigenceError, InvalidArgumentError, NotFoundError, NotReleasedError, NotVerifiedError, RateLimitError, ServiceRestartError, SpendCapError, TimeoutError, UnreachableError, DEFAULT_API_URL, FILE_PUT_CHUNK_BYTES, MAX_OUTPUT_BYTES_DEFAULT, VERSION, WAIT_TIMEOUT_MS, PreviewsUnavailableError, SANDBOX_ENDED_FIX, SandboxEndedError, errorsDocsFor, exitForCode, previewsOff, sandboxEndedIn, withPreviewsFix, } from "./core.js";
6
6
  import { buildBundle } from "./bundle.js";
7
7
  import { keychainReadCached, keychainStore, storeLabel } from "./credstore.js";
8
8
  import { CALLER_HEADER, resolveCaller, userAgentWithCaller } from "./caller.js";
@@ -276,30 +276,6 @@ export function readCredentials(env = process.env) {
276
276
  function storedName(store, env) {
277
277
  return store === undefined || store === "file" ? `the credentials file (${credentialsPath(env)})` : storeLabel(store);
278
278
  }
279
- const COMPAT_REFUSED_HOSTS = /(^|\.)e2b(-staging)?\.(app|dev|pro)$/i;
280
- export function apiUrlFromCompatUrl(url) {
281
- const u = (url ?? "").trim();
282
- if (!u)
283
- return undefined;
284
- let host = "";
285
- try {
286
- host = new URL(u.includes("://") ? u : `https://${u}`).hostname;
287
- }
288
- catch {
289
- return undefined;
290
- }
291
- return COMPAT_REFUSED_HOSTS.test(host) ? undefined : u;
292
- }
293
- export function apiUrlFromCompatDomain(domain) {
294
- const d = (domain ?? "").trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
295
- if (!d || COMPAT_REFUSED_HOSTS.test(d))
296
- return undefined;
297
- return `https://api.${d}/v1`;
298
- }
299
- export function compatApiKey(key) {
300
- const k = key?.trim();
301
- return k && !k.startsWith("e2b_") ? k : undefined;
302
- }
303
279
  export function keyOrigin(env = process.env) {
304
280
  if (env["PROMIGENCE_API_KEY"]?.trim())
305
281
  return "PROMIGENCE_API_KEY";
@@ -326,12 +302,13 @@ function resolveCredentials(opts, env = process.env) {
326
302
  const inputs = optKey === undefined && envKey === undefined
327
303
  ? undefined
328
304
  : `${optKey ?? "\0"}\n${envKey ?? "\0"}\n${envUrl ?? "\0"}\n${opts.apiUrl ?? "\0"}`;
329
- if (inputs !== undefined && inputs === credsLastInputs && credsLast !== undefined)
330
- return { apiKey: credsLast.apiKey, apiUrl: credsLast.apiUrl };
305
+ if (inputs !== undefined && inputs === credsLastInputs && credsLast !== undefined) {
306
+ return { apiKey: credsLast.apiKey, apiUrl: credsLast.apiUrl, ...(credsLast.origin ? { origin: credsLast.origin } : {}) };
307
+ }
331
308
  const worked = resolveCredentialsFrom(opts, env, set, trim, optKey, envKey, envUrl);
332
309
  if (inputs !== undefined && worked.refusal === undefined) {
333
310
  credsLastInputs = inputs;
334
- credsLast = { apiKey: worked.apiKey, apiUrl: worked.apiUrl };
311
+ credsLast = { apiKey: worked.apiKey, apiUrl: worked.apiUrl, ...(worked.origin ? { origin: worked.origin } : {}) };
335
312
  }
336
313
  return worked;
337
314
  }
@@ -365,8 +342,15 @@ function resolveCredentialsFrom(opts, env, set, trim, optKey, envKey, envUrl) {
365
342
  if (refusal)
366
343
  return { apiKey: undefined, apiUrl, refusal };
367
344
  }
368
- return { apiKey: key, apiUrl };
345
+ const origin = !key ? undefined
346
+ : source === "opts" ? { source: "option" }
347
+ : source === "env" ? { source: "env" }
348
+ : source === "file" ? { source: "stored", ...(store === undefined ? {} : { store }) }
349
+ : { source: "fallback" };
350
+ return { apiKey: key, apiUrl, ...(origin ? { origin } : {}) };
369
351
  }
352
+ export const NO_KEY_MESSAGE = "no API key: none was given, PROMIGENCE_API_KEY is not set, and none is stored";
353
+ export const NO_KEY_FIX = "Pass { apiKey } or set PROMIGENCE_API_KEY. No key yet: `promigence login` signs you up or in and stores one.";
370
354
  function keyRefusal(key, from) {
371
355
  if (/^[\x21-\x7E]+$/.test(key))
372
356
  return undefined;
@@ -646,6 +630,8 @@ export function snapshotCreateBody(opts) {
646
630
  }
647
631
  if (o.alias)
648
632
  body["alias"] = o.alias;
633
+ if (o.alias && o.moveAlias)
634
+ body["move_alias"] = true;
649
635
  if (o.grade) {
650
636
  if (!o.grade.cmd)
651
637
  throw new InvalidArgumentError("snapshots.create grade needs a cmd", "Pass { grade: { cmd: 'pytest -q tests/' } }.");
@@ -680,6 +666,9 @@ const REFUSED_503_CODES = new Set([
680
666
  "builder_unavailable", "builder_busy", "overloaded", "tier_unavailable", "registry_unavailable",
681
667
  "secrets_unavailable",
682
668
  ]);
669
+ const SDK_FIXES = {
670
+ cap_exceeded: "Raise `spendCap`, lower `timeoutMs` or `count`, or pass `allowPartial` to launch what fits.",
671
+ };
683
672
  function withoutCliHelp(fix) {
684
673
  const out = fix.replace(/\s*See `promigence [^`]*--help`\.?\s*$/, "").trim();
685
674
  if (out === fix.trim())
@@ -689,7 +678,7 @@ function withoutCliHelp(fix) {
689
678
  export function errorFor(code, message, fix, extra = {}) {
690
679
  const attach = (e) => {
691
680
  const w = e;
692
- w.docs = extra.docs ?? `${ERRORS_DOCS_URL}#${code}`;
681
+ w.docs = extra.docs !== undefined && e.code === code ? extra.docs : errorsDocsFor(e.code);
693
682
  if (extra.status !== undefined)
694
683
  w.status = extra.status;
695
684
  if (extra.requestId !== undefined)
@@ -705,6 +694,8 @@ export function errorFor(code, message, fix, extra = {}) {
705
694
  if ((code === "usage" || code === "invalid_argument" || code === "unsupported") && previewsOff(message)) {
706
695
  return attach(new PreviewsUnavailableError());
707
696
  }
697
+ if (!extra.cli && Object.prototype.hasOwnProperty.call(SDK_FIXES, code))
698
+ fix = SDK_FIXES[code];
708
699
  switch (code) {
709
700
  case "auth": return attach(new AuthError(message, fix));
710
701
  case "not_found": return attach(new NotFoundError(message, fix));
@@ -718,8 +709,13 @@ export function errorFor(code, message, fix, extra = {}) {
718
709
  case "not_verified": return attach(new NotVerifiedError(message, fix));
719
710
  case "timeout": return attach(new TimeoutError(message, fix));
720
711
  case "usage":
721
- case "invalid_argument":
712
+ case "invalid_argument": {
713
+ const ended = code === "usage" ? sandboxEndedIn(message) : undefined;
714
+ if (ended !== undefined) {
715
+ return attach(new SandboxEndedError(message, ended.sandboxId, ended.state, extra.cli ? fix : SANDBOX_ENDED_FIX));
716
+ }
722
717
  return attach(new InvalidArgumentError(message, extra.cli ? fix : withoutCliHelp(fix), code));
718
+ }
723
719
  case "not_released": return attach(new NotReleasedError(message));
724
720
  case "service_restart": return attach(new ServiceRestartError(message, fix));
725
721
  default:
@@ -790,6 +786,21 @@ const WAIT_MS_CAP = 30_000;
790
786
  export const LONG_POLL_MS = 5_000;
791
787
  const BUILD_POLL_MS = 10_000;
792
788
  const PROGRESS_POLL_MS = 200;
789
+ export const RUN_SETTLE_MS = 5_000;
790
+ export function runWorkDone(run, endedSince, now = Date.now()) {
791
+ if (run.state === "completed" || run.state === "cap_killed" || run.state === "aborted")
792
+ return true;
793
+ const sbs = Array.isArray(run.sandboxes) ? run.sandboxes : [];
794
+ const allDone = sbs.length > 0 && sbs.every((s) => s.state === "ended" || s.command_outcome != null);
795
+ if (!allDone) {
796
+ delete endedSince.at;
797
+ return false;
798
+ }
799
+ if (sbs.some((s) => s.state !== "ended"))
800
+ return true;
801
+ endedSince.at ??= now;
802
+ return now - endedSince.at >= RUN_SETTLE_MS;
803
+ }
793
804
  const WAITED_HEADER = "promigence-waited-ms";
794
805
  const seg = (s) => encodeURIComponent(s).replace(/%40/g, "@");
795
806
  function waitQuery(waitMs) {
@@ -827,6 +838,9 @@ export class PromigenceClient {
827
838
  apiUrl;
828
839
  #apiKey;
829
840
  #keyRefusal;
841
+ #keyOrigin;
842
+ #onKeyUse;
843
+ #keyUseSaid = false;
830
844
  #bearer;
831
845
  fetchImpl;
832
846
  ownFetch;
@@ -848,6 +862,8 @@ export class PromigenceClient {
848
862
  const resolved = resolveCredentials(opts);
849
863
  this.#apiKey = resolved.apiKey;
850
864
  this.#keyRefusal = resolved.refusal;
865
+ this.#keyOrigin = resolved.origin;
866
+ this.#onKeyUse = opts.onKeyUse;
851
867
  this.apiUrl = resolved.apiUrl;
852
868
  const env = opts.fetch === undefined ? transportEnv() : undefined;
853
869
  const proxy = env !== undefined ? proxyFor(this.apiUrl, env) : undefined;
@@ -913,11 +929,18 @@ export class PromigenceClient {
913
929
  catch {
914
930
  }
915
931
  }
932
+ get keyOrigin() {
933
+ return this.#keyOrigin;
934
+ }
916
935
  requireKey() {
917
936
  if (this.#keyRefusal)
918
937
  throw this.#keyRefusal;
919
938
  if (!this.#apiKey)
920
- throw new AuthError();
939
+ throw this.cliErrors ? new AuthError(NO_KEY_MESSAGE) : new AuthError(NO_KEY_MESSAGE, NO_KEY_FIX);
940
+ if (!this.#keyUseSaid && this.#onKeyUse !== undefined && this.#keyOrigin !== undefined) {
941
+ this.#keyUseSaid = true;
942
+ this.#onKeyUse(this.#keyOrigin);
943
+ }
921
944
  return this.#apiKey;
922
945
  }
923
946
  bearer() {
@@ -1239,10 +1262,12 @@ export class PromigenceClient {
1239
1262
  continue;
1240
1263
  }
1241
1264
  const code = payload.error?.code ?? codeForStatus(res.status);
1265
+ const rid = headerOf(res, "request-id") ?? headerOf(res, "x-request-id");
1242
1266
  throw errorFor(code, payload.error?.message ?? `HTTP ${res.status} from ${method} ${path}`, payload.error?.fix ?? "See the API response for details.", {
1243
1267
  status: res.status,
1244
1268
  ...bodyExtra(payload),
1245
1269
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
1270
+ ...(rid ? { requestId: rid } : {}),
1246
1271
  });
1247
1272
  }
1248
1273
  }
@@ -1322,10 +1347,12 @@ export class PromigenceClient {
1322
1347
  continue;
1323
1348
  }
1324
1349
  const code = payload.error?.code ?? codeForStatus(res.status);
1350
+ const rid = headerOf(res, "request-id") ?? headerOf(res, "x-request-id");
1325
1351
  throw errorFor(code, payload.error?.message ?? `HTTP ${res.status} from ${method} ${path}`, payload.error?.fix ?? "See the API response for details.", {
1326
1352
  status: res.status,
1327
1353
  ...bodyExtra(payload),
1328
1354
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
1355
+ ...(rid ? { requestId: rid } : {}),
1329
1356
  });
1330
1357
  }
1331
1358
  }
@@ -1701,12 +1728,8 @@ export class PromigenceClient {
1701
1728
  return { sandboxes: r.value, timedOut: r.timedOut, waitedMs: r.waitedMs };
1702
1729
  },
1703
1730
  waitDone: async (runId, opts = {}) => {
1704
- const r = await this.waitLoop(opts, LONG_POLL_MS, { run_id: runId, state: "running" }, (read) => this.runs.get(runId, read), (run) => {
1705
- const terminal = run.state === "completed" || run.state === "cap_killed" || run.state === "aborted";
1706
- const allDone = Array.isArray(run.sandboxes) && run.sandboxes.length > 0 &&
1707
- run.sandboxes.every((s) => s.state === "ended" || s.command_outcome != null);
1708
- return terminal || allDone;
1709
- });
1731
+ const endedSince = {};
1732
+ const r = await this.waitLoop(opts, LONG_POLL_MS, { run_id: runId, state: "running" }, (read) => this.runs.get(runId, read), (run) => runWorkDone(run, endedSince));
1710
1733
  return { run: r.value, timedOut: r.timedOut, waitedMs: r.waitedMs };
1711
1734
  },
1712
1735
  };
@@ -2115,10 +2138,19 @@ export class PromigenceClient {
2115
2138
  }), sandboxId);
2116
2139
  return r.ports ?? [];
2117
2140
  },
2118
- revoke: (sandboxId, tokenOrPort) => this.request({
2119
- method: "DELETE",
2120
- path: `/sandboxes/${encodeURIComponent(sandboxId)}/ports/${encodeURIComponent(String(tokenOrPort))}`,
2121
- }),
2141
+ revoke: (sandboxId, tokenOrPort) => {
2142
+ const which = String(tokenOrPort).trim();
2143
+ return /^[0-9]+$/.test(which)
2144
+ ? this.request({
2145
+ method: "DELETE",
2146
+ path: `/sandboxes/${encodeURIComponent(sandboxId)}/ports/${which}`,
2147
+ })
2148
+ : this.request({
2149
+ method: "POST",
2150
+ path: `/sandboxes/${encodeURIComponent(sandboxId)}/ports/revoke`,
2151
+ body: { token: which },
2152
+ });
2153
+ },
2122
2154
  },
2123
2155
  ssh: {
2124
2156
  open: (sandboxId, opts) => {
@@ -2615,10 +2647,10 @@ export class PromigenceClient {
2615
2647
  throw new InvalidArgumentError("snapshots.delete needs a snapshot", "Pass a snap_ id, an alias, or alias@commit7.");
2616
2648
  return this.request({ method: "DELETE", path: `/snapshots/${seg(idOrAlias)}` });
2617
2649
  },
2618
- alias: (idOrAlias, alias) => this.request({
2650
+ alias: (idOrAlias, alias, opts = {}) => this.request({
2619
2651
  method: "PUT",
2620
2652
  path: `/snapshots/${seg(idOrAlias)}/alias`,
2621
- body: { alias },
2653
+ body: { alias, ...(alias !== null && opts.moveAlias ? { move_alias: true } : {}) },
2622
2654
  }),
2623
2655
  waitBuild: async (buildId, opts = {}) => {
2624
2656
  const r = await this.waitLoop(opts, BUILD_POLL_MS, { build_id: buildId, state: "running" }, (read) => this.snapshots.status(buildId, read), (b) => b.state !== "running" && b.state !== "queued");
@@ -18,6 +18,7 @@ export declare class CommandTimeoutError extends CommandExitError {
18
18
  readonly code: string;
19
19
  readonly fix: string;
20
20
  readonly guide: string;
21
+ readonly docs: string;
21
22
  constructor(result: CommandResult, timeoutMs: number | undefined);
22
23
  }
23
24
  /**
@@ -1,4 +1,4 @@
1
- import { CommandExitError } from "./core.js";
1
+ import { CommandExitError, errorsDocsFor } from "./core.js";
2
2
  import { hintArg } from "./hint.js";
3
3
  export function secondsText(ms) {
4
4
  const s = ms / 1000;
@@ -10,9 +10,11 @@ export class CommandTimeoutError extends CommandExitError {
10
10
  code;
11
11
  fix;
12
12
  guide;
13
+ docs;
13
14
  constructor(result, timeoutMs) {
14
15
  super(result);
15
16
  this.code = "task_timeout";
17
+ this.docs = errorsDocsFor(this.code);
16
18
  this.timeoutMs = timeoutMs;
17
19
  this.message = timeoutMs === undefined
18
20
  ? `command timed out (killed at its timeout, exit ${result.exitCode})`
package/dist/context.d.ts CHANGED
@@ -15,9 +15,11 @@
15
15
  * - Empty directories and permission bits travel as they are on disk; a set-user-ID or
16
16
  * set-group-ID bit is cleared, with a note.
17
17
  * - Files that look like credentials (`.env`, private keys, cloud credentials, ...) are refused,
18
- * by name, unless `allowSecrets` says otherwise. The check goes by file name: it is
19
- * best-effort and cannot see what a file holds, so look over the list (`listContext`) before
20
- * you send a directory.
18
+ * by name, unless `allowSecretFiles` names them (each one) or `allowSecrets` says otherwise. The
19
+ * check goes by file name and reads only two kinds of file, to clear the plain false
20
+ * positives: a `.pem` holding certificates or public keys and no private key, and an `.npmrc`
21
+ * with no token or password in it. It is best-effort, so look over the list (`listContext`)
22
+ * before you send a directory.
21
23
  * - Symbolic links travel as links, never as the file they point at.
22
24
  */
23
25
  /** The prefix a packed directory travels under in `dockerfile.context`. */
@@ -65,9 +67,17 @@ export declare function dockerignoreMatcher(text: string): {
65
67
  /**
66
68
  * Does this path look like a credential (`.env`, a private key, cloud or registry credentials, a
67
69
  * token file, ...)? By name only: best-effort, it cannot see what a file holds. Templates such as
68
- * `.env.example` are not.
70
+ * `.env.example` are not. [`secretLikeFile`] also reads the few files whose contents can clear them.
69
71
  */
70
72
  export declare function secretLike(path: string): boolean;
73
+ /** The most of a file [`secretLikeFile`] reads; a larger one is judged by its name alone. */
74
+ export declare const CONTENT_CHECK_MAX_BYTES: number;
75
+ /**
76
+ * [`secretLike`] for a file on disk at `abs` (`rel` is its path in the upload): a name that looks
77
+ * like a credential, unless what the file holds clears it ([`clearedByContent`]). A link, a file
78
+ * over [`CONTENT_CHECK_MAX_BYTES`] and a file that cannot be read keep the name's verdict.
79
+ */
80
+ export declare function secretLikeFile(rel: string, abs: string): boolean;
71
81
  /** What a build context is packed from and sent with. */
72
82
  export interface ContextOptions {
73
83
  /**
@@ -84,6 +94,11 @@ export interface ContextOptions {
84
94
  * nothing is sent.
85
95
  */
86
96
  allowSecrets?: boolean;
97
+ /**
98
+ * Send these files that look like credentials, and only these: paths inside the context,
99
+ * `/`-separated, as `listContext` names them. Every other one is still refused.
100
+ */
101
+ allowSecretFiles?: readonly string[];
87
102
  /** Called with each note: what was left out by default, or changed on the way. */
88
103
  onNote?: (note: string) => void;
89
104
  }
package/dist/context.js CHANGED
@@ -387,6 +387,74 @@ export function secretLike(path) {
387
387
  return false;
388
388
  return SECRET_NAMES.some((re) => re.test(name));
389
389
  }
390
+ export const CONTENT_CHECK_MAX_BYTES = 1024 * 1024;
391
+ const PUBLIC_PEM = new Set([
392
+ "CERTIFICATE", "TRUSTED CERTIFICATE", "X509 CERTIFICATE", "X509 CRL", "CERTIFICATE REQUEST",
393
+ "NEW CERTIFICATE REQUEST", "PUBLIC KEY", "RSA PUBLIC KEY", "PKCS7", "CMS", "DH PARAMETERS",
394
+ "X9.42 DH PARAMETERS", "EC PARAMETERS",
395
+ ]);
396
+ function publicPem(text) {
397
+ if (/private[\s_-]*key/i.test(text))
398
+ return false;
399
+ const types = [...text.matchAll(/-----BEGIN ([^\r\n-]+)-----/g)].map((m) => m[1].trim().toUpperCase());
400
+ if (types.length === 0 || !types.every((t) => PUBLIC_PEM.has(t)))
401
+ return false;
402
+ const outside = text.replace(/-----BEGIN ([^\r\n-]+)-----[\s\S]*?-----END \1-----/g, "");
403
+ if (/-----(BEGIN|END) /.test(outside))
404
+ return false;
405
+ return !outside.split(/\r?\n/).some((l) => /^(?=.*[A-Za-z0-9+/])[A-Za-z0-9+/=]{40,}$/.test(l.trim()));
406
+ }
407
+ const NPMRC_CREDENTIAL = /_auth|token|passw|secret|credential|certfile|keyfile|otp|username/i;
408
+ function npmrcWithoutCredentials(text) {
409
+ for (const raw of text.split(/\r?\n/)) {
410
+ const line = raw.trim().replace(/^[;#]+\s*/, "");
411
+ if (!line)
412
+ continue;
413
+ if (NPMRC_CREDENTIAL.test(line) || /:\/\/[^\s/]*@/.test(line))
414
+ return false;
415
+ const eq = line.indexOf("=");
416
+ const key = (eq < 0 ? line : line.slice(0, eq)).trim();
417
+ const own = key.startsWith("//") ? key.slice(key.lastIndexOf(":") + 1) : key;
418
+ if (/^(key|cert|email)$/i.test(own.trim()))
419
+ return false;
420
+ }
421
+ return true;
422
+ }
423
+ export function clearedByContent(path, content) {
424
+ const name = path.split("/").filter(Boolean).pop() ?? "";
425
+ let text;
426
+ if (typeof content === "string") {
427
+ text = content;
428
+ }
429
+ else {
430
+ try {
431
+ text = new TextDecoder("utf-8", { fatal: true }).decode(content);
432
+ }
433
+ catch {
434
+ return false;
435
+ }
436
+ }
437
+ if (text.includes("\u0000"))
438
+ return false;
439
+ if (/\.pem$/i.test(name))
440
+ return publicPem(text);
441
+ if (/^\.npmrc$/i.test(name))
442
+ return npmrcWithoutCredentials(text);
443
+ return false;
444
+ }
445
+ export function secretLikeFile(rel, abs) {
446
+ if (!secretLike(rel))
447
+ return false;
448
+ try {
449
+ const st = lstatSync(abs);
450
+ if (!st.isFile() || st.size > CONTENT_CHECK_MAX_BYTES)
451
+ return true;
452
+ return !clearedByContent(rel, readFileSync(abs));
453
+ }
454
+ catch {
455
+ return true;
456
+ }
457
+ }
390
458
  function namedList(items) {
391
459
  const more = items.length > NAMED ? ` and ${items.length - NAMED} more` : "";
392
460
  return items.slice(0, NAMED).join(", ") + more;
@@ -395,7 +463,7 @@ export function credentialRefusalMessage(hits) {
395
463
  const one = hits.length === 1;
396
464
  return `${hits.length} file${one ? " looks" : "s look"} like ${one ? "a credential" : "credentials"} and ${one ? "was" : "were"} not uploaded: ${namedList(hits)}; nothing was created`;
397
465
  }
398
- export const CREDENTIAL_CHECK_NOTE = "The check goes by file name and is best-effort: it cannot see what a file holds, so look over what you send.";
466
+ export const CREDENTIAL_CHECK_NOTE = "The check goes by file name and is best-effort: it reads only a .pem (one with no private key passes) and an .npmrc (one with no token or password passes), so look over what you send.";
399
467
  function insidePath(p) {
400
468
  return goClean(p.split(sep).join("/").replace(/^\.\/+/, ""));
401
469
  }
@@ -495,7 +563,7 @@ function collect(dir, opts) {
495
563
  if (special.length > 0) {
496
564
  notes.push(`Left out ${namedList(special)}: a socket, device or pipe; a build context holds only files, directories and links.`);
497
565
  }
498
- const secrets = entries.filter((e) => e.kind === "file" && secretLike(e.rel)).map((e) => e.rel);
566
+ const secrets = entries.filter((e) => e.kind === "file" && secretLikeFile(e.rel, e.abs)).map((e) => e.rel);
499
567
  return { entries, bytes, notes, secrets, ignore };
500
568
  }
501
569
  export function listContext(dir, opts = {}) {
@@ -518,8 +586,10 @@ export function packContext(dir, opts = {}) {
518
586
  const ig = c.ignore?.file;
519
587
  throw new InvalidArgumentError(`the build context is over ${max} bytes${ig ? ` even without what its ${ig} excludes` : ""}`, `${ig ? `Exclude more in ${ig}` : "Add a .dockerignore"}, point --context at a smaller directory, or pass a git URL pinned to a full 40-character sha.`);
520
588
  }
521
- if (c.secrets.length > 0 && !opts.allowSecrets) {
522
- throw new InvalidArgumentError(credentialRefusalMessage(c.secrets), `A snapshot keeps every file it holds, and every sandbox started from it has them. Exclude them in ${c.ignore?.file ?? ".dockerignore"}, pass the values as secrets, or pass { allowSecrets: true } if they are not real credentials. ${CREDENTIAL_CHECK_NOTE}`);
589
+ const allowed = new Set((opts.allowSecretFiles ?? []).map(insidePath));
590
+ const refused = c.secrets.filter((s) => !allowed.has(s));
591
+ if (refused.length > 0 && !opts.allowSecrets) {
592
+ throw new InvalidArgumentError(credentialRefusalMessage(refused), `A snapshot keeps every file it holds, and every sandbox started from it has them. Exclude them in ${c.ignore?.file ?? ".dockerignore"}, pass the values as secrets, or name each that is not a real credential in { allowSecretFiles: [...] } ({ allowSecrets: true } for all of them). ${CREDENTIAL_CHECK_NOTE}`);
523
593
  }
524
594
  if (c.entries.length === 0) {
525
595
  throw new InvalidArgumentError(`the build context ${JSON.stringify(dir)} is empty`, "Point --context at the directory holding your Dockerfile.");
package/dist/core.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /** Promigence SDK — constants, error hierarchy and shared types. */
2
- export declare const VERSION = "0.1.0-beta.15";
2
+ export declare const VERSION = "0.1.0-beta.16";
3
3
  export declare const DOCS_URL = "https://promigence.ai";
4
4
  /** The error reference; each code is an anchor on it (the API's `docs` link has the same form). */
5
5
  export declare const ERRORS_DOCS_URL = "https://www.promigence.ai/docs/errors";
6
+ /** The row of `code` in the error reference. */
7
+ export declare function errorsDocsFor(code: string): string;
6
8
  export declare const DEFAULT_API_URL = "https://api.promigence.ai/v1";
7
9
  /** Where to write about a key. A key is self-serve: `promigence login` signs up or in and stores one. */
8
10
  export declare const KEY_CONTACT = "support@promigence.ai";
@@ -43,8 +45,8 @@ export interface PromigenceErrorInit {
43
45
  /** The API error's `retryable`: nothing ran and the same request, sent again as is, can succeed. */
44
46
  retryable?: boolean;
45
47
  }
46
- /** What a caller reads instead: a fact about this beta, not a mistake of theirs. */
47
- export declare const PREVIEWS_UNAVAILABLE = "preview URLs are not available in this beta yet";
48
+ /** What a caller reads instead: a fact about their account, not a mistake of theirs. */
49
+ export declare const PREVIEWS_UNAVAILABLE = "preview URLs are not turned on for this account";
48
50
  export declare function previewsFix(sandboxId?: string, port?: string | number): string;
49
51
  /**
50
52
  * A `fix` that opens by repeating the message says only what it adds: the API's `usage` fix is
@@ -97,7 +99,13 @@ export declare class PromigenceError extends Error {
97
99
  guide: string;
98
100
  };
99
101
  }
100
- /** Bad arguments (e.g. an unknown template, a missing spend cap). exit 2 */
102
+ /**
103
+ * Bad arguments (e.g. an unknown template, a missing spend cap). exit 2
104
+ *
105
+ * `code` says who refused: `invalid_argument` is the SDK, before anything was sent; `usage` is the
106
+ * API, refusing what it was sent. Nothing ran and nothing was billed either way, so catch this
107
+ * class (or exit 2) to handle both.
108
+ */
101
109
  export declare class InvalidArgumentError extends PromigenceError {
102
110
  /**
103
111
  * `code` defaults to `invalid_argument` — what the SDK raises for a bad argument it catches
@@ -122,6 +130,26 @@ export declare function previewsOff(message: string): boolean;
122
130
  export declare class PreviewsUnavailableError extends InvalidArgumentError {
123
131
  constructor(sandboxId?: string, port?: string | number);
124
132
  }
133
+ /** The sandbox and its state when an API message is the "has ended" refusal, else `undefined`. */
134
+ export declare function sandboxEndedIn(message: string): {
135
+ sandboxId: string;
136
+ state: string;
137
+ } | undefined;
138
+ /** What a caller of this SDK does about an ended sandbox. */
139
+ export declare const SANDBOX_ENDED_FIX = "Start a new sandbox: Sandbox.create(). This one's getInfo() says how it ended (`outcome`).";
140
+ /**
141
+ * A call to a sandbox that has ended (`state` `ended` or `failed`): it does not come back. Still an
142
+ * `InvalidArgumentError` with code `usage` and exit 2, as the API answers it, so a catch written
143
+ * against earlier releases still catches it; catch this class to tell an ended sandbox from a bad
144
+ * argument. `sandboxId` and `state` name the sandbox and how it stands.
145
+ */
146
+ export declare class SandboxEndedError extends InvalidArgumentError {
147
+ /** The sandbox that has ended. */
148
+ readonly sandboxId: string;
149
+ /** Its state: `ended`, or `failed` for one that ended on a failure. */
150
+ readonly state: string;
151
+ constructor(message: string, sandboxId: string, state: string, fix?: string);
152
+ }
125
153
  /** The environment failed (our fault, never the task's). exit 3 */
126
154
  export declare class EnvFailedError extends PromigenceError {
127
155
  constructor(code: string, message: string, fix: string);