@solarisdk/core 0.4.1 → 0.4.3

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/README.md CHANGED
@@ -18,7 +18,7 @@ npm install @solarisdk/sdk # unified client + `solari` CLI
18
18
 
19
19
  ## Requirements
20
20
 
21
- Node 18+ (or a browser with `WebSocket` and `fetch`). The type declarations need TypeScript 5.0 or newer; `await using` needs TypeScript 5.2+ and a runtime with `Symbol.asyncDispose` (Node 24, or a polyfill).
21
+ Node 18+ (or a browser with `WebSocket` and `fetch`). The type declarations need TypeScript 5.0 or newer; `await using` works in compiled TypeScript 5.2+ on Node 18.18+ (which provide `Symbol.asyncDispose`); running the syntax uncompiled needs Node 24.
22
22
 
23
23
  ## License
24
24
 
package/dist/desktop.js CHANGED
@@ -46,11 +46,19 @@ export class Desktop extends SessionHandle {
46
46
  stream: opts.stream,
47
47
  });
48
48
  }
49
- execStream(cmd, onChunk, opts = {}) {
50
- return this.rpcStream("exec", { cmd, args: opts.args ?? [], cwd: opts.cwd, timeoutMs: opts.timeoutMs, stream: true }, (c) => {
49
+ async execStream(cmd, onChunk, opts = {}) {
50
+ const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
51
+ const result = await this.rpcStream("exec", { cmd, args: opts.args ?? [], cwd: opts.cwd, timeoutMs: opts.timeoutMs, stream: true }, (c) => {
51
52
  const bytes = c.data ? base64ToBytes(c.data) : new Uint8Array();
52
- onChunk({ stream: c.stream, text: new TextDecoder().decode(bytes), bytes });
53
+ const dec = decoders[c.stream] ?? decoders.stdout;
54
+ onChunk({ stream: c.stream, text: dec.decode(bytes, { stream: true }), bytes });
53
55
  });
56
+ for (const stream of ["stdout", "stderr"]) {
57
+ const rest = decoders[stream].decode();
58
+ if (rest)
59
+ onChunk({ stream, text: rest, bytes: new Uint8Array() });
60
+ }
61
+ return result;
54
62
  }
55
63
  fs = {
56
64
  read: (path) => this.files.read(path),
@@ -126,7 +134,18 @@ export class Desktop extends SessionHandle {
126
134
  process = {
127
135
  list: async () => {
128
136
  const r = await this.guiCall("process.list", {});
129
- return r.processes;
137
+ return (r.processes ?? []).map((p) => {
138
+ const info = {
139
+ pid: Number(p.pid),
140
+ name: String(p.name ?? p.comm ?? ""),
141
+ };
142
+ const cmd = p.cmd ?? p.cmdline;
143
+ if (typeof cmd === "string")
144
+ info.cmd = cmd;
145
+ if (typeof p.state === "string")
146
+ info.state = p.state;
147
+ return info;
148
+ });
130
149
  },
131
150
  kill: (pid) => this.guiCall("process.kill", { pid }).then(() => undefined),
132
151
  start: async (cmd, opts = {}) => {
package/dist/errors.d.ts CHANGED
@@ -10,6 +10,12 @@ export declare class GatewayError extends SolariError {
10
10
  readonly body?: GatewayErrorBody;
11
11
  /** The gateway's hint that retrying may help (`body.retryable`), on every gateway error. */
12
12
  readonly retryable?: boolean;
13
+ /** The gateway's request id (`x-request-id`); include it when reporting a problem. */
14
+ readonly requestId?: string;
15
+ /** On a `429`: `"rate_limited"` (transient, honour {@link retryAfterMs}) or `"quota_exceeded"` (a cap; do not retry). */
16
+ readonly errorType?: "rate_limited" | "quota_exceeded";
17
+ /** How long the gateway asked callers to wait (`Retry-After`), in ms; only set on retryable errors. */
18
+ readonly retryAfterMs?: number;
13
19
  constructor(status: number, message: string, body?: GatewayErrorBody);
14
20
  }
15
21
  /** `404` — the sandbox, desktop, snapshot, template or volume does not exist (or is not yours). */
@@ -60,6 +66,20 @@ export declare class SandboxNotStoppedError extends GatewayError {
60
66
  readonly state?: string;
61
67
  constructor(message?: string, body?: GatewayErrorBody);
62
68
  }
69
+ /**
70
+ * `409 {code:"SnapshotNotRestorable"}` — a restore (create from a snapshot, resume of a paused sandbox, revert)
71
+ * the platform knows cannot succeed, so it is refused at once instead of timing out as a capacity error.
72
+ * Never retryable (`retryable === false`). `reason` says why when known: `"golden_mismatch"` means the snapshot
73
+ * was taken on a different template build (`golden.imageKey`) than the fleet runs, and `snapshotImageKey` /
74
+ * `hostImageKey` name the two. Take a fresh snapshot from a new sandbox instead.
75
+ */
76
+ export declare class SnapshotNotRestorableError extends GatewayError {
77
+ /** Why it cannot be restored, when the gateway says (`"golden_mismatch"`). */
78
+ readonly reason?: string;
79
+ readonly snapshotImageKey?: string;
80
+ readonly hostImageKey?: string;
81
+ constructor(message?: string, body?: GatewayErrorBody);
82
+ }
63
83
  /** A computer-use RPC returned `{ok:false}`. */
64
84
  export declare class ActionError extends SolariError {
65
85
  readonly method: string;
package/dist/errors.js CHANGED
@@ -9,12 +9,20 @@ export class GatewayError extends SolariError {
9
9
  code;
10
10
  body;
11
11
  retryable;
12
+ requestId;
13
+ errorType;
14
+ retryAfterMs;
12
15
  constructor(status, message, body) {
13
16
  super(message);
14
17
  this.status = status;
15
18
  this.code = body?.code;
16
19
  this.body = body;
17
20
  this.retryable = body?.retryable;
21
+ this.requestId = body?.requestId;
22
+ this.errorType = body?.errorType;
23
+ if (body?.retryable === true && typeof body.retryAfterSeconds === "number" && body.retryAfterSeconds >= 0) {
24
+ this.retryAfterMs = Math.round(body.retryAfterSeconds * 1000);
25
+ }
18
26
  }
19
27
  }
20
28
  export class NotFoundError extends GatewayError {
@@ -54,6 +62,17 @@ export class SandboxNotStoppedError extends GatewayError {
54
62
  this.state = body?.state;
55
63
  }
56
64
  }
65
+ export class SnapshotNotRestorableError extends GatewayError {
66
+ reason;
67
+ snapshotImageKey;
68
+ hostImageKey;
69
+ constructor(message = "Snapshot cannot be restored", body) {
70
+ super(409, message, body);
71
+ this.reason = body?.reason;
72
+ this.snapshotImageKey = body?.snapshotImageKey;
73
+ this.hostImageKey = body?.hostImageKey;
74
+ }
75
+ }
57
76
  export class ActionError extends SolariError {
58
77
  method;
59
78
  code;
@@ -101,6 +120,8 @@ export function mapGatewayError(status, body) {
101
120
  if (code === "NotStopped" || code === "StartInProgress") {
102
121
  return new SandboxNotStoppedError(msg, body);
103
122
  }
123
+ if (code === "SnapshotNotRestorable")
124
+ return new SnapshotNotRestorableError(msg, body);
104
125
  return new GatewayError(409, msg ?? "Conflict", body);
105
126
  case 503:
106
127
  if (code === undefined || code === "NoCapacity" || code === "StartTimeout")
package/dist/handle.d.ts CHANGED
@@ -58,12 +58,18 @@ export interface SessionConfig extends ControlChannelConfig {
58
58
  export declare const DEFAULT_COMMAND_TIMEOUT_MS = 600000;
59
59
  /** Cap on PTY output held before the first onData() subscriber (bytes, total). */
60
60
  export declare const PTY_EARLY_BUFFER_MAX: number;
61
+ export interface SessionHandle {
62
+ /** `await using sbx = await client.create(...)` releases the machine (`kill()`) when the scope ends. */
63
+ [Symbol.asyncDispose](): Promise<void>;
64
+ }
61
65
  export declare class SessionHandle {
62
66
  /** The session id (sandboxId / sessionId). */
63
67
  readonly id: string;
64
68
  readonly controlUrl: string;
65
69
  readonly expiresAt: string;
66
70
  protected readonly channel: ControlChannel;
71
+ /** Set once the gateway has refused a command stream because this sandbox is `shared`. */
72
+ private sharedContext;
67
73
  protected readonly hooks: SessionHooks;
68
74
  /** Set by {@link kill} so {@link close} doesn't warn about a machine we
69
75
  * have just released. */
@@ -198,10 +204,19 @@ export declare class SessionHandle {
198
204
  };
199
205
  /** Run `git <args>` in the guest via the command RPC (no shell). */
200
206
  private runGit;
201
- /** Run `git <args>`; throw with the stderr on a non-zero exit. */
207
+ /** Run `git <args>`; throw with the stderr on a non-zero exit. A password passed in `secret` is masked in the message. */
202
208
  private mustGit;
203
- /** Splice basic-auth creds into an https remote URL (for private remotes). */
204
- private authUrl;
209
+ /**
210
+ * Credentials for ONE git command, for a private http(s) remote at `remoteUrl`. They go to the guest as environment
211
+ * variables of that command and git reads them back through an inline credential helper, so they are never in the
212
+ * clone URL (and so not in `.git/config`, a snapshot or a template), never in argv (`/proc/<pid>/cmdline`), and never
213
+ * in a config file: the `-c` options last for this command only and the helper script holds no secret.
214
+ *
215
+ * The helper is bound to the remote's origin (`credential.<scheme>://<host>.helper`), so git never hands the password
216
+ * to another host: a redirect to a different host, or a downgrade to http, gets nothing. Returns undefined when
217
+ * there are no credentials, or the remote is not http(s) (ssh remotes do not use them).
218
+ */
219
+ private gitAuth;
205
220
  private gitClone;
206
221
  private gitStatus;
207
222
  private gitAdd;
package/dist/handle.js CHANGED
@@ -23,11 +23,23 @@ function trackUnreleased(token, id) {
23
23
  }
24
24
  const MIN_REST_TIMEOUT_MS = 1000;
25
25
  export const PTY_EARLY_BUFFER_MAX = 1024 * 1024;
26
+ const GIT_CREDENTIAL_HELPER = '!f() { test "$1" = get && printf "username=%s\\npassword=%s\\n" "$SOLARI_GIT_USERNAME" "$SOLARI_GIT_PASSWORD"; }; f';
27
+ function gitSubcommand(args) {
28
+ let i = 0;
29
+ while (args[i] === "-c")
30
+ i += 2;
31
+ return args[i] ?? "";
32
+ }
33
+ function rejectOptionLike(what, v) {
34
+ if (v.startsWith("-"))
35
+ throw new Error(`git ${what} must not start with "-": ${JSON.stringify(v)}`);
36
+ }
26
37
  export class SessionHandle {
27
38
  id;
28
39
  controlUrl;
29
40
  expiresAt;
30
41
  channel;
42
+ sharedContext = false;
31
43
  hooks;
32
44
  killed = false;
33
45
  releaseToken = {};
@@ -120,17 +132,26 @@ export class SessionHandle {
120
132
  opts.onStderr?.(c.data);
121
133
  });
122
134
  }
123
- this.channel.onFrame("cmd.data", cmdId, (f) => {
124
- const stream = f.stream ?? "stdout";
125
- const data = f.base64 ? new TextDecoder().decode(base64ToBytes(f.base64)) : "";
126
- const chunk = { stream, data };
135
+ const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
136
+ const emit = (chunk) => {
127
137
  if (dataCbs.length === 0)
128
138
  buffered.push(chunk);
129
139
  else
130
140
  for (const cb of dataCbs)
131
141
  cb(chunk);
142
+ };
143
+ this.channel.onFrame("cmd.data", cmdId, (f) => {
144
+ const stream = f.stream ?? "stdout";
145
+ const dec = decoders[stream] ?? decoders.stdout;
146
+ const data = f.base64 ? dec.decode(base64ToBytes(f.base64), { stream: true }) : "";
147
+ emit({ stream, data });
132
148
  });
133
149
  this.channel.onFrame("cmd.exit", cmdId, (f) => {
150
+ for (const stream of ["stdout", "stderr"]) {
151
+ const rest = decoders[stream].decode();
152
+ if (rest)
153
+ emit({ stream, data: rest });
154
+ }
134
155
  this.channel.offFrame("cmd.data", cmdId);
135
156
  this.channel.offFrame("cmd.exit", cmdId);
136
157
  this.channel.offStreamClose(cmdId);
@@ -172,23 +193,21 @@ export class SessionHandle {
172
193
  if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
173
194
  throw new SolariError(`Solari: timeoutMs must be a non-negative number of milliseconds, got ${String(timeoutMs)}`);
174
195
  }
175
- if (this.hooks.exec &&
196
+ const restEligible = !!this.hooks.exec &&
176
197
  !opts.background &&
177
198
  !opts.onStdout &&
178
199
  !opts.onStderr &&
179
200
  !opts.env &&
180
201
  !opts.user &&
181
202
  timeoutMs >= MIN_REST_TIMEOUT_MS &&
182
- timeoutMs <= DEFAULT_COMMAND_TIMEOUT_MS &&
183
- !this.channel.connected) {
203
+ timeoutMs <= DEFAULT_COMMAND_TIMEOUT_MS;
204
+ const viaRest = async () => {
205
+ const r = await this.hooks.exec(this.id, { cmd, args: opts.args, cwd: opts.cwd, timeoutMs });
206
+ return { ...r, timedOut: r.timedOut === true };
207
+ };
208
+ if (restEligible && (!this.channel.connected || this.sharedContext)) {
184
209
  try {
185
- const r = await this.hooks.exec(this.id, {
186
- cmd,
187
- args: opts.args,
188
- cwd: opts.cwd,
189
- timeoutMs,
190
- });
191
- return { ...r, timedOut: r.timedOut === true };
210
+ return await viaRest();
192
211
  }
193
212
  catch (err) {
194
213
  if (!isRouteUnavailable(err))
@@ -196,7 +215,24 @@ export class SessionHandle {
196
215
  }
197
216
  }
198
217
  const acc = { stdout: "", stderr: "" };
199
- const handle = await this.startCommand(cmd, { ...opts, timeoutMs: undefined }, acc);
218
+ let handle;
219
+ try {
220
+ handle = await this.startCommand(cmd, { ...opts, timeoutMs: undefined }, acc);
221
+ }
222
+ catch (err) {
223
+ if (!isSharedRefusal(err))
224
+ throw err;
225
+ this.sharedContext = true;
226
+ if (restEligible) {
227
+ try {
228
+ return await viaRest();
229
+ }
230
+ catch (e) {
231
+ throw normalizeExecError(e, timeoutMs);
232
+ }
233
+ }
234
+ throw err;
235
+ }
200
236
  if (opts.background) {
201
237
  return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
202
238
  }
@@ -378,42 +414,61 @@ export class SessionHandle {
378
414
  branches: (cwd) => this.gitBranches(cwd),
379
415
  log: (opts = {}) => this.gitLog(opts),
380
416
  };
381
- async runGit(args, cwd) {
382
- return this.runCommand("git", { args, ...(cwd ? { cwd } : {}) });
417
+ async runGit(args, cwd, env) {
418
+ return this.runCommand("git", { args, ...(cwd ? { cwd } : {}), ...(env ? { env } : {}) });
383
419
  }
384
- async mustGit(args, cwd) {
385
- const r = await this.runGit(args, cwd);
420
+ async mustGit(args, cwd, env, secret) {
421
+ const r = await this.runGit(args, cwd, env);
386
422
  if (r.exitCode !== 0) {
387
- const detail = (r.stderr || r.stdout || "").trim();
388
- throw new Error(`git ${args[0]} failed (exit ${r.exitCode}): ${detail}`);
423
+ let detail = (r.stderr || r.stdout || "").trim();
424
+ if (secret)
425
+ detail = detail.split(secret).join("***");
426
+ throw new Error(`git ${gitSubcommand(args)} failed (exit ${r.exitCode}): ${detail}`);
389
427
  }
390
428
  return r.stdout;
391
429
  }
392
- authUrl(url, username, password) {
430
+ gitAuth(remoteUrl, username, password) {
393
431
  if (!username && !password)
394
- return url;
432
+ return undefined;
433
+ for (const v of [username, password]) {
434
+ if (v !== undefined && /[\r\n\0]/.test(v))
435
+ throw new Error("git credentials must not contain line breaks or NUL");
436
+ }
437
+ let origin;
395
438
  try {
396
- const u = new URL(url);
397
- if (username)
398
- u.username = encodeURIComponent(username);
399
- if (password)
400
- u.password = encodeURIComponent(password);
401
- return u.toString();
439
+ const u = new URL(remoteUrl);
440
+ if (u.protocol !== "https:" && u.protocol !== "http:")
441
+ return undefined;
442
+ origin = `${u.protocol}//${u.host}`;
402
443
  }
403
444
  catch {
404
- return url;
445
+ return undefined;
405
446
  }
447
+ if (/[=\s]/.test(origin))
448
+ return undefined;
449
+ return {
450
+ cfg: [
451
+ "-c", "credential.helper=",
452
+ "-c", `credential.${origin}.helper=${GIT_CREDENTIAL_HELPER}`,
453
+ ],
454
+ env: {
455
+ GIT_TERMINAL_PROMPT: "0",
456
+ SOLARI_GIT_USERNAME: username ?? "",
457
+ SOLARI_GIT_PASSWORD: password ?? "",
458
+ },
459
+ };
406
460
  }
407
461
  async gitClone(url, opts) {
408
- const args = ["clone"];
462
+ const auth = this.gitAuth(url, opts.username, opts.password);
463
+ const args = [...(auth?.cfg ?? []), "clone"];
409
464
  if (opts.depth && opts.depth > 0)
410
465
  args.push("--depth", String(opts.depth));
411
466
  if (opts.branch)
412
467
  args.push("--branch", opts.branch);
413
- args.push(this.authUrl(url, opts.username, opts.password));
468
+ args.push("--", url);
414
469
  if (opts.path)
415
470
  args.push(opts.path);
416
- await this.mustGit(args, opts.cwd);
471
+ await this.mustGit(args, opts.cwd, auth?.env, opts.password);
417
472
  }
418
473
  async gitStatus(cwd) {
419
474
  const out = await this.mustGit(["status", "--porcelain=v1", "--branch"], cwd);
@@ -488,25 +543,25 @@ export class SessionHandle {
488
543
  }
489
544
  async gitPushPull(op, opts) {
490
545
  const remote = opts.remote || "origin";
491
- const cfg = [];
546
+ rejectOptionLike("remote", remote);
547
+ if (opts.branch)
548
+ rejectOptionLike("branch", opts.branch);
549
+ let auth;
492
550
  if (opts.username || opts.password) {
493
- const plain = (await this.runGit(["remote", "get-url", remote], opts.cwd)).stdout.trim();
494
- if (plain) {
495
- const authed = this.authUrl(plain, opts.username, opts.password);
496
- if (authed !== plain)
497
- cfg.push("-c", `url.${authed}.insteadOf=${plain}`);
498
- }
551
+ const plain = (await this.mustGit(["remote", "get-url", "--", remote], opts.cwd)).trim();
552
+ auth = this.gitAuth(plain, opts.username, opts.password);
499
553
  }
500
- const args = [...cfg, op, remote];
554
+ const args = [...(auth?.cfg ?? []), op, "--", remote];
501
555
  if (opts.branch)
502
556
  args.push(opts.branch);
503
- await this.mustGit(args, opts.cwd);
557
+ await this.mustGit(args, opts.cwd, auth?.env, opts.password);
504
558
  }
505
559
  async gitCheckout(ref, opts) {
560
+ rejectOptionLike("ref", ref);
506
561
  const args = ["checkout"];
507
562
  if (opts.create)
508
563
  args.push("-b");
509
- args.push(ref);
564
+ args.push(ref, "--");
510
565
  await this.mustGit(args, opts.cwd);
511
566
  }
512
567
  async gitBranches(cwd) {
@@ -652,6 +707,11 @@ function explainShellCommandFailure(cmd, err) {
652
707
  }
653
708
  return new Error(msg + hint);
654
709
  }
710
+ function isSharedRefusal(err) {
711
+ if (err instanceof ActionError && err.code === "NotAvailableOnSharedIsolation")
712
+ return true;
713
+ return err instanceof Error && /NotAvailableOnSharedIsolation|not available on a shared/.test(err.message);
714
+ }
655
715
  function isRouteUnavailable(err) {
656
716
  return (err instanceof GatewayError &&
657
717
  (err.status === 404 || err.status === 405 || err.status === 501));
package/dist/http.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface HttpTransportOptions {
5
5
  fetch?: typeof fetch;
6
6
  /** Max retry attempts for idempotent requests. Default 5. */
7
7
  maxRetries?: number;
8
+ /** Longest server-requested `Retry-After` the transport will wait out (default 30 000 ms); a longer one surfaces the error. */
9
+ maxRetryAfterMs?: number;
8
10
  /** Per-request timeout in ms (AbortSignal). Default 300000. */
9
11
  requestTimeoutMs?: number;
10
12
  /** Fixed retry delay in ms. When unset, uses exponential backoff + jitter.
@@ -24,6 +26,7 @@ export declare class HttpTransport {
24
26
  readonly baseUrl: string;
25
27
  private readonly fetchImpl;
26
28
  private readonly maxRetries;
29
+ private readonly maxRetryAfterMs;
27
30
  private readonly requestTimeoutMs;
28
31
  private readonly retryDelayMs;
29
32
  constructor(o: HttpTransportOptions);
@@ -34,3 +37,5 @@ export declare class HttpTransport {
34
37
  request<T>(method: string, path: string, body?: unknown, opts?: HttpRequestOptions): Promise<T>;
35
38
  private backoffMs;
36
39
  }
40
+ /** `Retry-After` is delta-seconds or an HTTP date; anything else (or negative) is ignored. */
41
+ export declare function parseRetryAfterSeconds(value: string | null): number | undefined;
package/dist/http.js CHANGED
@@ -10,6 +10,7 @@ export class HttpTransport {
10
10
  baseUrl;
11
11
  fetchImpl;
12
12
  maxRetries;
13
+ maxRetryAfterMs;
13
14
  requestTimeoutMs;
14
15
  retryDelayMs;
15
16
  constructor(o) {
@@ -25,6 +26,7 @@ export class HttpTransport {
25
26
  }
26
27
  this.fetchImpl = f;
27
28
  this.maxRetries = o.maxRetries ?? 5;
29
+ this.maxRetryAfterMs = o.maxRetryAfterMs ?? 30_000;
28
30
  this.requestTimeoutMs = o.requestTimeoutMs ?? 300_000;
29
31
  this.retryDelayMs = o.retryDelayMs;
30
32
  }
@@ -70,10 +72,21 @@ export class HttpTransport {
70
72
  catch {
71
73
  errBody = undefined;
72
74
  }
75
+ const headerId = res.headers.get("x-request-id");
76
+ const headerRetryAfter = parseRetryAfterSeconds(res.headers.get("retry-after"));
77
+ if (headerId || headerRetryAfter !== undefined) {
78
+ errBody = {
79
+ ...errBody,
80
+ ...(headerId && !errBody?.requestId ? { requestId: headerId } : {}),
81
+ ...(headerRetryAfter !== undefined ? { retryAfterSeconds: headerRetryAfter } : {}),
82
+ };
83
+ }
73
84
  const RETRYABLE_STATUSES = new Set([502, 503, 504, 507]);
74
85
  const retryable = RETRYABLE_STATUSES.has(res.status) || errBody?.retryable === true;
75
- if (idempotent && retryable && attempt < this.maxRetries) {
76
- await sleep(this.backoffMs(attempt++));
86
+ const retryAfterMs = retryable && typeof errBody?.retryAfterSeconds === "number" ? errBody.retryAfterSeconds * 1000 : undefined;
87
+ const tooLong = retryAfterMs !== undefined && retryAfterMs > this.maxRetryAfterMs;
88
+ if (idempotent && retryable && !tooLong && attempt < this.maxRetries) {
89
+ await sleep(Math.max(this.backoffMs(attempt++), retryAfterMs ?? 0));
77
90
  continue;
78
91
  }
79
92
  throw mapGatewayError(res.status, errBody);
@@ -90,3 +103,14 @@ export class HttpTransport {
90
103
  return Math.min(150 * 2 ** attempt, 8000) + Math.floor(Math.random() * 250);
91
104
  }
92
105
  }
106
+ export function parseRetryAfterSeconds(value) {
107
+ if (value === null)
108
+ return undefined;
109
+ const v = value.trim();
110
+ if (/^\d+$/.test(v))
111
+ return Number(v);
112
+ const t = Date.parse(v);
113
+ if (Number.isNaN(t))
114
+ return undefined;
115
+ return Math.max(0, Math.ceil((t - Date.now()) / 1000));
116
+ }
@@ -0,0 +1,7 @@
1
+ import type { IdlePolicyStages } from "./types.js";
2
+ /**
3
+ * Build the string form of an idle policy from stages, e.g. `{ pause: "5m", delete: "1d" }` becomes
4
+ * `"pause@5m,delete@1d"` (the format the gateway echoes back). Stages come out in order: park, hibernate, pause, delete.
5
+ * A number is milliseconds. The gateway validates the result (ordering, bounds, conflicts); this only formats it.
6
+ */
7
+ export declare function formatIdlePolicy(stages: IdlePolicyStages): string;
@@ -0,0 +1,10 @@
1
+ export function formatIdlePolicy(stages) {
2
+ const order = ["park", "hibernate", "pause", "delete"];
3
+ return order
4
+ .filter((k) => stages[k] !== undefined)
5
+ .map((k) => {
6
+ const v = stages[k];
7
+ return `${k}@${typeof v === "number" ? `${v}ms` : v}`;
8
+ })
9
+ .join(",");
10
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { DEFAULT_COMMAND_TIMEOUT_MS, SessionHandle } from "./handle.js";
2
2
  export { shortId } from "./ids.js";
3
+ export { formatIdlePolicy } from "./idle-policy.js";
3
4
  export type { SessionConfig, SessionHooks } from "./handle.js";
4
5
  export { Desktop } from "./desktop.js";
5
6
  export type { DesktopConfig } from "./desktop.js";
@@ -11,5 +12,5 @@ export type { TemplateClientOptions } from "./template-client.js";
11
12
  export { VolumeClient } from "./volume-client.js";
12
13
  export type { VolumeClientOptions } from "./volume-client.js";
13
14
  export { DEFAULT_BASE_URL, HttpTransport, newIdempotencyKey } from "./http.js";
14
- export { ActionError, AuthError, ConcurrencyLimitError, ConnectionError, GatewayError, NoCapacityError, NotFoundError, SandboxNotStoppedError, SolariError, PlanError, TimeoutError, ValidationError, } from "./errors.js";
15
+ export { ActionError, AuthError, ConcurrencyLimitError, ConnectionError, GatewayError, NoCapacityError, NotFoundError, SandboxNotStoppedError, SnapshotNotRestorableError, SolariError, PlanError, TimeoutError, ValidationError, } from "./errors.js";
15
16
  export type * from "./types.js";
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  export { DEFAULT_COMMAND_TIMEOUT_MS, SessionHandle } from "./handle.js";
2
2
  export { shortId } from "./ids.js";
3
+ export { formatIdlePolicy } from "./idle-policy.js";
3
4
  export { Desktop } from "./desktop.js";
4
5
  export { Sandbox } from "./sandbox.js";
5
6
  export { Image } from "./image.js";
6
7
  export { TemplateClient } from "./template-client.js";
7
8
  export { VolumeClient } from "./volume-client.js";
8
9
  export { DEFAULT_BASE_URL, HttpTransport, newIdempotencyKey } from "./http.js";
9
- export { ActionError, AuthError, ConcurrencyLimitError, ConnectionError, GatewayError, NoCapacityError, NotFoundError, SandboxNotStoppedError, SolariError, PlanError, TimeoutError, ValidationError, } from "./errors.js";
10
+ export { ActionError, AuthError, ConcurrencyLimitError, ConnectionError, GatewayError, NoCapacityError, NotFoundError, SandboxNotStoppedError, SnapshotNotRestorableError, SolariError, PlanError, TimeoutError, ValidationError, } from "./errors.js";
package/dist/types.d.ts CHANGED
@@ -1,6 +1,36 @@
1
1
  import type { CompiledImage } from "./image.js";
2
2
  /** Session lifecycle status reported by `GET /desktops/:sessionId`. */
3
3
  export type DesktopStatus = "starting" | "ready" | "paused" | "releasing" | "gone";
4
+ /**
5
+ * One stage of an {@link IdlePolicy}. All stages are optional and every offset is measured from the sandbox's LAST
6
+ * ACTIVITY, so the offsets must increase in this order:
7
+ *
8
+ * - `park`: freeze the vCPUs but keep RAM and the slot; the next request wakes it in milliseconds. Parked time is free.
9
+ * - `hibernate`: checkpoint to disk and free the slot (needs a `park` stage; a parked sandbox escalates to it).
10
+ * - `pause`: snapshot and free the slot; `connect`/resume brings it back. Same as `lifecycle.onTimeout: "pause"`.
11
+ * - `delete`: destroy it. After a `pause` it deletes the paused sandbox (at most 27 days after the pause on Solari
12
+ * cloud); with no `pause` stage it is `lifecycle.onTimeout: "kill"`.
13
+ *
14
+ * Values are a duration string (`"30s"`, `"5m"`, `"1h30m"`, `"1d"`; units ms, s, m, h, d) or a number of milliseconds.
15
+ */
16
+ export interface IdlePolicyStages {
17
+ park?: string | number;
18
+ hibernate?: string | number;
19
+ pause?: string | number;
20
+ delete?: string | number;
21
+ }
22
+ /**
23
+ * Staged idle policy: what happens as a sandbox sits idle, in one setting. Either the string form
24
+ * (`"park@30s,hibernate@2m,pause@5m,delete@1d"`) or {@link IdlePolicyStages}. Do not combine it with
25
+ * `idleTimeoutMs`/`timeoutMs`/`ttlSeconds` or `lifecycle.onTimeout`/`parkAfterMs`/`parkMaxMs`/`hibernateAfterParkedMs`;
26
+ * the gateway answers 400 naming the clashing field. `lifecycle.autoResume` and `lifecycle.checkpointEveryMs` still apply.
27
+ *
28
+ * If the policy has a `park` (or `hibernate`) stage but no `pause` or `delete`, the gateway adds `pause` 24 hours after
29
+ * the last stage so a parked sandbox cannot hold its slot for the plan's whole maximum; the sandbox view's `idlePolicy`
30
+ * shows the policy as applied. What is billed: running time at the normal rate; parked time is free; hibernated and
31
+ * paused sandboxes are not billed for compute.
32
+ */
33
+ export type IdlePolicy = string | IdlePolicyStages;
4
34
  /** What the gateway does when a session's idle/ttl timeout fires. */
5
35
  export type OnTimeout = "pause" | "kill";
6
36
  /**
@@ -105,6 +135,8 @@ export interface CreateDesktopOptions {
105
135
  idleTimeoutMs?: number;
106
136
  /** Idle lifecycle policy (pause/kill on timeout; optional auto-resume). */
107
137
  lifecycle?: SandboxLifecycle;
138
+ /** Staged idle policy (park, hibernate, pause, delete as the sandbox sits idle); see {@link IdlePolicy}. Replaces `idleTimeoutMs` + most of `lifecycle`. */
139
+ idlePolicy?: IdlePolicy;
108
140
  /** Persistent volumes to mount before the session starts (Part V.4). */
109
141
  volumes?: VolumeAttachment[];
110
142
  }
@@ -120,6 +152,7 @@ export interface CreateDesktopRequest {
120
152
  record?: boolean;
121
153
  timeoutMs?: number;
122
154
  lifecycle?: SandboxLifecycle;
155
+ idlePolicy?: IdlePolicy;
123
156
  volumes?: VolumeAttachment[];
124
157
  }
125
158
  /** `201` response from `POST /desktops`. */
@@ -155,10 +188,25 @@ export interface GatewayErrorBody {
155
188
  /** Gateway hint that the failure is transient (e.g. 503 "No desktop host
156
189
  * available"). The HTTP transport retries idempotent requests when set. */
157
190
  retryable?: boolean;
191
+ /** The gateway's id for this request (also the `x-request-id` response header). Quote it in support requests. */
192
+ requestId?: string;
193
+ /** On every `429`: `"rate_limited"` (retryable, honour `Retry-After`) or `"quota_exceeded"` (a cap; retrying cannot help). */
194
+ errorType?: "rate_limited" | "quota_exceeded";
195
+ /** Seconds to wait before retrying, mirrored from the `Retry-After` header by the transport. */
196
+ retryAfterSeconds?: number;
158
197
  /** Current sandbox state, set on `409 NotStopped` / `409 StartInProgress`. */
159
198
  state?: string;
160
199
  /** Set on `409 HostCapabilityMissing`: capabilities the host pool lacks (e.g. `["start"]`). */
161
200
  missingCapabilities?: string[];
201
+ /** Set on `409 SnapshotNotRestorable`: why. `"golden_mismatch"` = the snapshot was taken on a different
202
+ * template build than the fleet runs now. Absent on the older (CPU-platform / pre-migration) refusals. */
203
+ reason?: string;
204
+ /** Set on `409 SnapshotNotRestorable`: the snapshot, where the request named one. */
205
+ snapshotId?: string;
206
+ /** Set with `reason: "golden_mismatch"`: the `golden.imageKey` the snapshot was taken on (when the host reports it). */
207
+ snapshotImageKey?: string;
208
+ /** Set with `reason: "golden_mismatch"`: the `golden.imageKey` of the host that refused. */
209
+ hostImageKey?: string;
162
210
  }
163
211
  /** `kind` of a session: headless `sandbox` or GUI `desktop`. */
164
212
  export type SandboxKind = "sandbox" | "desktop";
@@ -228,6 +276,8 @@ export interface CreateSandboxOptions {
228
276
  isolation?: SandboxIsolation;
229
277
  /** Idle lifecycle policy (pause/kill on timeout; optional auto-resume). */
230
278
  lifecycle?: SandboxLifecycle;
279
+ /** Staged idle policy (park, hibernate, pause, delete as the sandbox sits idle); see {@link IdlePolicy}. Replaces `idleTimeoutMs` + most of `lifecycle`. */
280
+ idlePolicy?: IdlePolicy;
231
281
  /**
232
282
  * Persistent volumes to mount into the session before it starts (Part V.4).
233
283
  * Each `volumeId` must belong to your org (create one via `client.volumes`).
@@ -295,10 +345,27 @@ export interface CreateSandboxRequest {
295
345
  resolution?: string;
296
346
  record?: boolean;
297
347
  lifecycle?: SandboxLifecycle;
348
+ idlePolicy?: IdlePolicy;
298
349
  volumes?: VolumeAttachment[];
299
350
  user?: string;
300
351
  cwd?: string;
301
352
  }
353
+ /**
354
+ * What a sandbox (or the sandbox a snapshot was taken from) actually ran on. Goldens are content-
355
+ * addressed: `imageKey` is `sha256(rootfsSha256 | kernelSha256 | chVersion)`, so equal `imageKey`s
356
+ * mean byte-identical rootfs, kernel and VMM. `template` is only the name; this is the bits behind it.
357
+ */
358
+ export interface GoldenInfo {
359
+ imageKey: string;
360
+ /** Template name the golden was built for. */
361
+ template?: string;
362
+ /** SHA-256 of the root filesystem image. */
363
+ rootfsSha256?: string;
364
+ /** SHA-256 of the guest kernel. */
365
+ kernelSha256?: string;
366
+ /** Cloud Hypervisor (VMM) version. */
367
+ chVersion?: string;
368
+ }
302
369
  /** `201` response from `POST /sandboxes`. */
303
370
  export interface CreateSandboxResponse {
304
371
  sandboxId: string;
@@ -316,6 +383,10 @@ export interface CreateSandboxResponse {
316
383
  /** The default user and working directory, echoed when the gateway honoured `user` / `cwd` on create. */
317
384
  user?: string;
318
385
  cwd?: string;
386
+ /** The template the sandbox was created from. */
387
+ template?: string;
388
+ /** The golden it runs on (rootfs / kernel / VMM digests); absent when the host did not report one. */
389
+ golden?: GoldenInfo;
319
390
  }
320
391
  /** `GET /sandboxes/:id` response. */
321
392
  export interface SandboxView {
@@ -341,6 +412,14 @@ export interface SandboxView {
341
412
  diskGb?: number;
342
413
  /** Presigned playback URL, present for a recorded `kind:"desktop"` session. */
343
414
  recordingUrl?: string;
415
+ /** The staged idle policy as the gateway applies it (including a `pause` stage it added), when one was set. */
416
+ idlePolicy?: string;
417
+ /** Set while the sandbox is paused under an `idlePolicy` with a `delete` stage: when it will be deleted (ISO 8601). */
418
+ deleteAt?: string;
419
+ /** The template this sandbox was created from. */
420
+ template?: string;
421
+ /** The golden it runs on (rootfs / kernel / VMM digests); absent when the host did not report one. */
422
+ golden?: GoldenInfo;
344
423
  }
345
424
  /** `GET /sandboxes` response (paginated). */
346
425
  export interface ListSandboxesResponse {
@@ -371,6 +450,8 @@ export interface SnapshotView {
371
450
  createdAt: string;
372
451
  kind: SandboxKind;
373
452
  template: string;
453
+ /** The golden the source sandbox ran on when the snapshot was taken; absent for older snapshots. */
454
+ golden?: GoldenInfo;
374
455
  }
375
456
  /** `POST /sandboxes/:id/snapshots` response. */
376
457
  export interface CreateSnapshotResponse {
@@ -605,8 +686,12 @@ export interface ProcessListParams {
605
686
  }
606
687
  export interface ProcessInfo {
607
688
  pid: number;
689
+ /** The process name (the guest agent's `comm`). */
608
690
  name: string;
691
+ /** The full command line (the guest agent's `cmdline`). */
609
692
  cmd?: string;
693
+ /** Process state letter as the kernel reports it (`R`, `S`, `Z`, ...). */
694
+ state?: string;
610
695
  }
611
696
  export interface ProcessListResult {
612
697
  processes: ProcessInfo[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solarisdk/core",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Shared runtime for the Solari SDK \u2014 transport, session handles, images, templates, and types used by @solarisdk/desktop and @solarisdk/sandbox.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",