@solarisdk/core 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +4 -0
  2. package/dist/chunked-download.d.ts +0 -1
  3. package/dist/chunked-download.js +0 -11
  4. package/dist/desktop.d.ts +0 -1
  5. package/dist/desktop.js +0 -52
  6. package/dist/errors.d.ts +10 -1
  7. package/dist/errors.js +16 -39
  8. package/dist/handle.d.ts +7 -1
  9. package/dist/handle.js +87 -235
  10. package/dist/http.d.ts +2 -1
  11. package/dist/http.js +1 -49
  12. package/dist/ids.d.ts +0 -1
  13. package/dist/ids.js +0 -6
  14. package/dist/image.d.ts +0 -1
  15. package/dist/image.js +0 -40
  16. package/dist/index.d.ts +3 -4
  17. package/dist/index.js +3 -5
  18. package/dist/sandbox.d.ts +0 -1
  19. package/dist/sandbox.js +0 -6
  20. package/dist/template-client.d.ts +0 -1
  21. package/dist/template-client.js +3 -27
  22. package/dist/transport.d.ts +9 -2
  23. package/dist/transport.js +64 -102
  24. package/dist/types.d.ts +19 -7
  25. package/dist/types.js +0 -7
  26. package/dist/volume-client.d.ts +0 -1
  27. package/dist/volume-client.js +0 -17
  28. package/dist/ws.d.ts +0 -1
  29. package/dist/ws.js +0 -16
  30. package/package.json +4 -3
  31. package/dist/chunked-download.d.ts.map +0 -1
  32. package/dist/chunked-download.js.map +0 -1
  33. package/dist/desktop.d.ts.map +0 -1
  34. package/dist/desktop.js.map +0 -1
  35. package/dist/errors.d.ts.map +0 -1
  36. package/dist/errors.js.map +0 -1
  37. package/dist/handle.d.ts.map +0 -1
  38. package/dist/handle.js.map +0 -1
  39. package/dist/http.d.ts.map +0 -1
  40. package/dist/http.js.map +0 -1
  41. package/dist/ids.d.ts.map +0 -1
  42. package/dist/ids.js.map +0 -1
  43. package/dist/image.d.ts.map +0 -1
  44. package/dist/image.js.map +0 -1
  45. package/dist/index.d.ts.map +0 -1
  46. package/dist/index.js.map +0 -1
  47. package/dist/sandbox.d.ts.map +0 -1
  48. package/dist/sandbox.js.map +0 -1
  49. package/dist/template-client.d.ts.map +0 -1
  50. package/dist/template-client.js.map +0 -1
  51. package/dist/transport.d.ts.map +0 -1
  52. package/dist/transport.js.map +0 -1
  53. package/dist/types.d.ts.map +0 -1
  54. package/dist/types.js.map +0 -1
  55. package/dist/volume-client.d.ts.map +0 -1
  56. package/dist/volume-client.js.map +0 -1
  57. package/dist/ws.d.ts.map +0 -1
  58. package/dist/ws.js.map +0 -1
package/dist/handle.js CHANGED
@@ -1,40 +1,37 @@
1
- // `SessionHandle` — the shared base for {@link Sandbox} and {@link Desktop}.
2
- //
3
- // Wraps a {@link ControlChannel} and exposes the CONTRACTS-V2 §4 primitives
4
- // (commands, pty, runCode, files) plus the gateway-routed lifecycle/admin calls
5
- // (metrics, snapshot, revert, pause/resume, setTimeout, env, volumes,
6
- // kill). Both Sandbox and Desktop extend this; Desktop adds the GUI surface.
7
- //
8
- // Streaming model: async output from `commands.start`/`pty.create`/`files.watch`
9
- // arrives as v2 frames `{type:"cmd.data"|"cmd.exit"|"pty.data"|"fs.event", ...}`
10
- // dispatched by the ControlChannel by `type` + the stream's own id, NOT by the
11
- // originating request id. We register/unregister those frame handlers as
12
- // commands/ptys/watches are created and torn down.
13
1
  import { downloadAll } from "./chunked-download.js";
14
- import { GatewayError, SolariError } from "./errors.js";
2
+ import { ActionError, GatewayError, SolariError, TimeoutError } from "./errors.js";
15
3
  import { shortId } from "./ids.js";
16
4
  import { base64ToBytes, bytesToBase64, ControlChannel, } from "./transport.js";
17
- /** Cap on PTY output held before the first onData() subscriber (bytes, total). */
5
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 600_000;
6
+ const unreleased = new Map();
7
+ const MAX_TRACKED_UNRELEASED = 1000;
8
+ let exitHookInstalled = false;
9
+ function trackUnreleased(token, id) {
10
+ unreleased.set(token, id);
11
+ if (unreleased.size > MAX_TRACKED_UNRELEASED)
12
+ unreleased.delete(unreleased.keys().next().value);
13
+ if (exitHookInstalled || typeof process === "undefined" || typeof process.on !== "function")
14
+ return;
15
+ exitHookInstalled = true;
16
+ process.on("exit", () => {
17
+ for (const id of unreleased.values()) {
18
+ console.warn(`[solari] this process is exiting but session ${shortId(id)} is still running and will bill until its ` +
19
+ `idle timeout. Call kill() to release it (or declare it with \`await using\`).`);
20
+ }
21
+ unreleased.clear();
22
+ });
23
+ }
24
+ const MIN_REST_TIMEOUT_MS = 1000;
18
25
  export const PTY_EARLY_BUFFER_MAX = 1024 * 1024;
19
26
  export class SessionHandle {
20
- /** The session id (sandboxId / sessionId). */
21
27
  id;
22
28
  controlUrl;
23
29
  expiresAt;
24
30
  channel;
25
31
  hooks;
26
- /** Set by {@link kill} so {@link close} doesn't warn about a machine we
27
- * have just released. */
28
32
  killed = false;
33
+ releaseToken = {};
29
34
  constructor(id, controlUrl, expiresAt, config = {}) {
30
- // A blank id is the one bad value that survives to the wire looking almost
31
- // right: it builds `/sandboxes//exec` (a double slash), not an obviously
32
- // wrong path with the word `undefined` in it, so it goes unnoticed while
33
- // every call the handle makes is malformed. Reject it where it enters.
34
- // Guard the blank STRING specifically (the sneaky value described above) —
35
- // a non-string id is either the obvious `undefined`/`null` case this comment
36
- // excludes or a hand-built bare handle, and calling `.trim()` on it would
37
- // throw an opaque TypeError instead of this clear message.
38
35
  if (typeof id === "string" && id.trim() === "") {
39
36
  throw new SolariError("Solari: session id must not be blank — an empty id builds a malformed `/sandboxes//…` request.");
40
37
  }
@@ -43,68 +40,35 @@ export class SessionHandle {
43
40
  this.expiresAt = expiresAt;
44
41
  this.channel = new ControlChannel(controlUrl, config);
45
42
  this.hooks = config.hooks ?? {};
43
+ trackUnreleased(this.releaseToken, id);
46
44
  }
47
- /** Whether the control channel is open. */
48
45
  get connected() {
49
46
  return this.channel.connected;
50
47
  }
51
- /** Subclass hook: send a raw JSON-RPC call over the control channel. */
52
48
  rpc(method, params) {
53
49
  return this.channel.call(method, params);
54
50
  }
55
- /** Subclass hook: send a v1 streamed call (per-`id` stdout/stderr frames). */
56
51
  rpcStream(method, params, onStream) {
57
52
  return this.channel.call(method, params, { onStream });
58
53
  }
59
- /** Open the control WebSocket. Idempotent. */
60
54
  connect() {
61
55
  return this.channel.connect();
62
56
  }
63
- /** Reconnect after a drop (re-opens the socket). */
64
57
  reconnect() {
65
- // No id check here on purpose: reconnect reuses this handle's id, which was
66
- // already validated as non-blank in the constructor. The two real entrances
67
- // for an untrusted id are the constructor (create-response wrapping) and
68
- // the client's connect(id) — not reconnect().
69
58
  return this.channel.reconnect();
70
59
  }
71
- /**
72
- * Close the control channel LOCALLY. This does **not** release the remote
73
- * session — the sandbox/desktop keeps running (and keeps billing) until its
74
- * idle timeout, or until you call {@link kill}.
75
- *
76
- * If you meant "I'm done with this machine", call {@link kill}.
77
- *
78
- * Pass `{ quiet: true }` when the machine is released some other way (for
79
- * example a wrapper that pauses or kills it by id right after closing its own
80
- * channel) and the "still running" nudge would be wrong.
81
- */
82
60
  close(opts = {}) {
61
+ unreleased.delete(this.releaseToken);
83
62
  if (!this.killed && !opts.quiet && typeof console !== "undefined") {
84
- // Cheap, one-shot nudge: the close/kill distinction is the single most
85
- // common misread of this API, and silently leaving a machine running is
86
- // expensive in a way nothing else surfaces.
87
63
  console.warn(`[solari] close() on session ${shortId(this.id)} closed the local channel only — ` +
88
64
  `the remote machine is still running and will bill until its idle ` +
89
65
  `timeout. Call kill() to release it now.`);
90
66
  }
91
67
  this.channel.close();
92
68
  }
93
- // --- commands -------------------------------------------------------------
94
69
  commands = {
95
- /**
96
- * Run a command to completion. `onStdout`/`onStderr` receive output as it
97
- * streams (via `cmd.data` frames); the promise resolves with the terminal
98
- * exit code (from `cmd.exit`).
99
- */
100
70
  run: (cmd, opts = {}) => this.runCommand(cmd, opts),
101
- /**
102
- * Start a command and return a handle immediately (does not wait for exit).
103
- * Output streams as `cmd.data`; the handle exposes `stdin`, `onData`,
104
- * `wait()`, and `kill()`.
105
- */
106
71
  start: (cmd, opts = {}) => this.startCommand(cmd, opts),
107
- /** Reconnect the control channel (e.g. to resume receiving frames). */
108
72
  connect: () => this.channel.reconnect(),
109
73
  };
110
74
  async startCommand(cmd, opts, acc) {
@@ -117,10 +81,6 @@ export class SessionHandle {
117
81
  });
118
82
  const cmdId = r.cmdId;
119
83
  const dataCbs = [];
120
- // Frames can arrive in the microtask gap between this method returning the
121
- // handle and the caller (e.g. runCommand) attaching its onData accumulator.
122
- // Buffer chunks while no consumer is attached, then replay on first onData
123
- // so early stdout/stderr is never dropped (would surface as exit=0 + "").
124
84
  const buffered = [];
125
85
  let exitResolve = null;
126
86
  let exitReject = null;
@@ -128,14 +88,22 @@ export class SessionHandle {
128
88
  exitResolve = res;
129
89
  exitReject = rej;
130
90
  });
131
- // If the control channel drops before `cmd.exit` arrives, reject the wait so
132
- // callers get a ConnectionError instead of hanging forever.
133
- this.channel.onStreamClose(cmdId, (err) => exitReject?.(err));
134
- // The result accumulator is subscribed BEFORE any frame handler is registered:
135
- // registering the frame handler flushes frames that beat the cmd.start reply
136
- // (orphans), and those must reach the accumulator as well as the caller's
137
- // callbacks. Attaching it afterwards (via the public onData) lost them whenever
138
- // a callback was set, because a non-empty dataCbs skips the replay buffer.
91
+ let timedOut = false;
92
+ let deadline;
93
+ this.channel.onStreamClose(cmdId, (err) => {
94
+ clearTimeout(deadline);
95
+ exitReject?.(err);
96
+ });
97
+ if (typeof opts.timeoutMs === "number" && opts.timeoutMs > 0) {
98
+ deadline = setTimeout(() => {
99
+ timedOut = true;
100
+ this.channel.call("cmd.kill", { cmdId, signal: 9 }).catch(() => undefined);
101
+ this.channel.offFrame("cmd.data", cmdId);
102
+ this.channel.offFrame("cmd.exit", cmdId);
103
+ this.channel.offStreamClose(cmdId);
104
+ exitResolve?.(-1);
105
+ }, opts.timeoutMs);
106
+ }
139
107
  if (acc) {
140
108
  dataCbs.push((c) => {
141
109
  if (c.stream === "stdout")
@@ -166,6 +134,7 @@ export class SessionHandle {
166
134
  this.channel.offFrame("cmd.data", cmdId);
167
135
  this.channel.offFrame("cmd.exit", cmdId);
168
136
  this.channel.offStreamClose(cmdId);
137
+ clearTimeout(deadline);
169
138
  exitResolve?.(typeof f.exitCode === "number" ? f.exitCode : 0);
170
139
  });
171
140
  return {
@@ -182,16 +151,15 @@ export class SessionHandle {
182
151
  dataCbs.push(cb);
183
152
  },
184
153
  wait: () => exitPromise,
154
+ get timedOut() {
155
+ return timedOut;
156
+ },
185
157
  kill: (signal) => this.channel
186
158
  .call("cmd.kill", { cmdId, signal })
187
159
  .then(() => undefined),
188
160
  };
189
161
  }
190
162
  async runCommand(cmd, opts) {
191
- // Direct exec means a shell line fails as "no such file or directory",
192
- // which reads like a missing tool. Let the call run, then explain that
193
- // specific failure rather than rejecting up front (see
194
- // explainShellCommandFailure — rejecting would break working callers).
195
163
  try {
196
164
  return await this.runCommandInner(cmd, opts);
197
165
  }
@@ -200,71 +168,58 @@ export class SessionHandle {
200
168
  }
201
169
  }
202
170
  async runCommandInner(cmd, opts) {
203
- // One-shot HTTP fast path: for a plain run-to-completion (no streaming
204
- // callbacks, not backgrounded, no per-command env/user) run the command
205
- // over the already-warm REST connection instead of paying a cold control-WS
206
- // handshake. That handshake (~180ms TCP+TLS to the gateway) dominates the
207
- // "create → run one command" latency, so skipping it is the single biggest
208
- // TTI win. We only take this path when the control channel isn't already
209
- // open — if it is, the WS is cheaper and streams. env/user are excluded
210
- // because the one-shot guest path runs under the session env only; routing
211
- // those through the streaming path keeps behavior identical.
171
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
172
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
173
+ throw new SolariError(`Solari: timeoutMs must be a non-negative number of milliseconds, got ${String(timeoutMs)}`);
174
+ }
212
175
  if (this.hooks.exec &&
213
176
  !opts.background &&
214
177
  !opts.onStdout &&
215
178
  !opts.onStderr &&
216
179
  !opts.env &&
217
180
  !opts.user &&
181
+ timeoutMs >= MIN_REST_TIMEOUT_MS &&
182
+ timeoutMs <= DEFAULT_COMMAND_TIMEOUT_MS &&
218
183
  !this.channel.connected) {
219
184
  try {
220
- return await this.hooks.exec(this.id, {
185
+ const r = await this.hooks.exec(this.id, {
221
186
  cmd,
222
187
  args: opts.args,
223
188
  cwd: opts.cwd,
224
- timeoutMs: opts.timeoutMs,
189
+ timeoutMs,
225
190
  });
191
+ return { ...r, timedOut: r.timedOut === true };
226
192
  }
227
193
  catch (err) {
228
- // Fall back to the WS path ONLY when the one-shot route is unavailable
229
- // (an older gateway without /exec). Any other failure — including a
230
- // real host/command error — propagates, so we never silently
231
- // double-execute a command that may have already run guest-side.
232
194
  if (!isRouteUnavailable(err))
233
- throw err;
195
+ throw normalizeExecError(err, timeoutMs);
234
196
  }
235
197
  }
236
198
  const acc = { stdout: "", stderr: "" };
237
- const handle = await this.startCommand(cmd, opts, acc);
199
+ const handle = await this.startCommand(cmd, { ...opts, timeoutMs: undefined }, acc);
238
200
  if (opts.background) {
239
- // Detached: don't block on exit. Caller drives via callbacks/handle.
240
- return { exitCode: 0, stdout: "", stderr: "" };
201
+ return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
241
202
  }
242
- // `timeoutMs` is a deadline for the command, as on the REST /exec path: when it elapses
243
- // the command is killed and the result is exit -1 with whatever output arrived (the
244
- // guest's own timeout result). Without this a hung command waited forever here.
245
- if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) {
203
+ if (timeoutMs > 0) {
246
204
  let timer;
247
205
  const timedOut = new Promise((resolve) => {
248
- timer = setTimeout(() => resolve("timeout"), opts.timeoutMs);
206
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
249
207
  });
250
- // Keep a late rejection (channel closed after we gave up) from becoming unhandled.
251
208
  const exited = handle.wait().then((code) => ({ code }), (err) => ({ err }));
252
209
  const first = await Promise.race([exited, timedOut]);
253
210
  clearTimeout(timer);
254
211
  if (first === "timeout") {
255
212
  await handle.kill(9).catch(() => undefined);
256
- return { exitCode: -1, stdout: acc.stdout, stderr: acc.stderr };
213
+ return { exitCode: -1, stdout: acc.stdout, stderr: acc.stderr, timedOut: true };
257
214
  }
258
215
  if ("err" in first)
259
216
  throw first.err;
260
- return { exitCode: first.code, stdout: acc.stdout, stderr: acc.stderr };
217
+ return { exitCode: first.code, stdout: acc.stdout, stderr: acc.stderr, timedOut: false };
261
218
  }
262
219
  const exitCode = await handle.wait();
263
- return { exitCode, stdout: acc.stdout, stderr: acc.stderr };
220
+ return { exitCode, stdout: acc.stdout, stderr: acc.stderr, timedOut: false };
264
221
  }
265
- // --- pty ------------------------------------------------------------------
266
222
  pty = {
267
- /** Open a PTY; output streams as `pty.data` frames. */
268
223
  create: (opts) => this.createPty(opts),
269
224
  };
270
225
  async createPty(opts) {
@@ -277,12 +232,6 @@ export class SessionHandle {
277
232
  });
278
233
  const ptyId = r.ptyId;
279
234
  const dataCbs = [];
280
- // Output that arrives before the first onData() (e.g. a shell prompt printed
281
- // right after pty.create) is held and replayed to the first subscriber, in
282
- // order, instead of being dropped. The hold is capped at PTY_EARLY_BUFFER_MAX
283
- // bytes in total: when a PTY nobody subscribes to keeps writing, the OLDEST
284
- // output is discarded so client memory stays bounded and the newest output is
285
- // what a late subscriber sees.
286
235
  let buffered = [];
287
236
  let bufferedBytes = 0;
288
237
  this.channel.onFrame("pty.data", ptyId, (f) => {
@@ -308,8 +257,6 @@ export class SessionHandle {
308
257
  .call("pty.resize", { ptyId, cols, rows })
309
258
  .then(() => undefined),
310
259
  onData: (cb) => {
311
- // Detach the held output BEFORE replaying it, so a callback that throws
312
- // cannot leave it behind for the next subscriber to receive twice.
313
260
  const early = buffered;
314
261
  buffered = [];
315
262
  bufferedBytes = 0;
@@ -325,11 +272,6 @@ export class SessionHandle {
325
272
  },
326
273
  };
327
274
  }
328
- // --- runCode --------------------------------------------------------------
329
- /**
330
- * Run code in a stateful kernel (`code.run`). Rich outputs (png/html/json/…)
331
- * come back as `results` items; `onStdout`/`onStderr` receive streamed text.
332
- */
333
275
  async runCode(code, opts = {}) {
334
276
  const r = await this.channel.call("code.run", {
335
277
  code,
@@ -348,12 +290,10 @@ export class SessionHandle {
348
290
  .map((i) => i.chart);
349
291
  return { results, charts, ...(r.error !== undefined ? { error: r.error } : {}) };
350
292
  }
351
- /** Create a fresh stateful kernel context (`code.context.create`). */
352
293
  async createCodeContext(language = "python") {
353
294
  const r = await this.channel.call("code.context.create", { language });
354
295
  return r.contextId;
355
296
  }
356
- // --- files ----------------------------------------------------------------
357
297
  files = {
358
298
  read: async (path) => {
359
299
  const r = await this.channel.call("fs.read", { path });
@@ -382,15 +322,10 @@ export class SessionHandle {
382
322
  mkdir: (path) => this.channel
383
323
  .call("fs.mkdir", { path })
384
324
  .then(() => undefined),
385
- /** Recursively grep `query` under `path`. */
386
325
  search: async (path, query, maxResults) => {
387
326
  const r = await this.channel.call("fs.search", { path, query, maxResults });
388
327
  return r.matches ?? [];
389
328
  },
390
- /**
391
- * Watch `path` for changes; `cb` is invoked for each `fs.event` frame.
392
- * Returns an unwatch function.
393
- */
394
329
  watch: async (path, cb, recursive = false) => {
395
330
  const r = await this.channel.call("fs.watch", {
396
331
  path,
@@ -398,8 +333,6 @@ export class SessionHandle {
398
333
  });
399
334
  const watchId = r.watchId;
400
335
  this.channel.onFrame("fs.event", watchId, (f) => {
401
- // `f.type` is always the frame type ("fs.event"); the change kind
402
- // (create|write|remove|rename|chmod) is in `f.event`.
403
336
  cb({ type: String(f.event ?? "change"), path: String(f.path ?? "") });
404
337
  });
405
338
  return async () => {
@@ -407,77 +340,47 @@ export class SessionHandle {
407
340
  await this.channel.call("fs.unwatch", { watchId });
408
341
  };
409
342
  },
410
- /** Upload local bytes to an in-guest path (chunked base64 under the hood). */
411
343
  upload: (path, data) => this.channel
412
344
  .call("fs.upload", { path, base64: toB64(data) })
413
345
  .then(() => undefined),
414
- /** Download an in-guest file's bytes. */
415
346
  download: (path) => downloadAll((method, params) => this.channel.call(method, params), path),
416
347
  };
417
- // --- env ------------------------------------------------------------------
418
- /** Inject/replace per-session env vars in the guest (`setEnv`). */
419
348
  env(vars) {
420
349
  return this.channel
421
350
  .call("setEnv", { env: vars })
422
351
  .then(() => undefined);
423
352
  }
424
- /** Signed, time-limited URL to download an in-guest file directly over HTTP
425
- * (bypasses the control channel — good for large files). */
426
353
  downloadUrl(path) {
427
354
  if (!this.hooks.downloadUrl) {
428
355
  return Promise.reject(new Error("downloadUrl requires a client-created handle"));
429
356
  }
430
357
  return this.hooks.downloadUrl(this.id, path);
431
358
  }
432
- /** Signed, time-limited URL to upload a file into the guest directly over
433
- * HTTP. `path` is the destination (optional; some backends return a prefix). */
434
359
  uploadUrl(path) {
435
360
  if (!this.hooks.uploadUrl) {
436
361
  return Promise.reject(new Error("uploadUrl requires a client-created handle"));
437
362
  }
438
363
  return this.hooks.uploadUrl(this.id, path);
439
364
  }
440
- // --- volumes --------------------------------------------------------------
441
365
  volumes = {
442
- /**
443
- * Mount a volume into the guest at `path`. Thin pass-through to the guest
444
- * `volume.mount` RPC; the gateway volume routes may be a 501 stub today.
445
- */
446
366
  mount: (volId, path) => this.channel
447
367
  .call("volume.mount", { volumeId: volId, path })
448
368
  .then(() => undefined),
449
369
  };
450
- // --- git ------------------------------------------------------------------
451
- // A first-class version-control surface (Daytona-parity). Every method is a
452
- // safe, non-shell `git` invocation over the existing command RPC (`cmd` +
453
- // `args`, never a shell string), so there is no injection surface and it runs
454
- // against any guest that has `git` on PATH — which the base image ships. No
455
- // guest/gateway change is required.
456
370
  git = {
457
- /** Clone `url` into `opts.path` (or git's default dir) under `opts.cwd`. */
458
371
  clone: (url, opts = {}) => this.gitClone(url, opts),
459
- /** Parsed working-tree status (branch, ahead/behind, staged/modified/untracked). */
460
372
  status: (cwd) => this.gitStatus(cwd),
461
- /** Stage `paths` (use `["."]` for everything). */
462
373
  add: (paths, cwd) => this.gitAdd(paths, cwd),
463
- /** Commit staged changes; returns the new commit hash. */
464
374
  commit: (message, opts = {}) => this.gitCommit(message, opts),
465
- /** Push to a remote (default `origin` + current branch). */
466
375
  push: (opts = {}) => this.gitPushPull("push", opts),
467
- /** Pull from a remote (default `origin` + current branch). */
468
376
  pull: (opts = {}) => this.gitPushPull("pull", opts),
469
- /** Check out an existing ref, or create a branch with `{ create: true }`. */
470
377
  checkout: (ref, opts = {}) => this.gitCheckout(ref, opts),
471
- /** List local branches (name, short commit, whether it's HEAD). */
472
378
  branches: (cwd) => this.gitBranches(cwd),
473
- /** Recent commits, newest first. */
474
379
  log: (opts = {}) => this.gitLog(opts),
475
380
  };
476
- /** Run `git <args>` in the guest via the command RPC (no shell). */
477
381
  async runGit(args, cwd) {
478
382
  return this.runCommand("git", { args, ...(cwd ? { cwd } : {}) });
479
383
  }
480
- /** Run `git <args>`; throw with the stderr on a non-zero exit. */
481
384
  async mustGit(args, cwd) {
482
385
  const r = await this.runGit(args, cwd);
483
386
  if (r.exitCode !== 0) {
@@ -486,7 +389,6 @@ export class SessionHandle {
486
389
  }
487
390
  return r.stdout;
488
391
  }
489
- /** Splice basic-auth creds into an https remote URL (for private remotes). */
490
392
  authUrl(url, username, password) {
491
393
  if (!username && !password)
492
394
  return url;
@@ -499,7 +401,7 @@ export class SessionHandle {
499
401
  return u.toString();
500
402
  }
501
403
  catch {
502
- return url; // ssh/scp-style or malformed — leave untouched
404
+ return url;
503
405
  }
504
406
  }
505
407
  async gitClone(url, opts) {
@@ -533,7 +435,6 @@ export class SessionHandle {
533
435
  st.detached = true;
534
436
  }
535
437
  else {
536
- // "main...origin/main [ahead 1, behind 2]" | "main" | "No commits yet on main"
537
438
  const noCommits = head.match(/^No commits yet on (.+)$/);
538
439
  const body = noCommits?.[1] ?? head;
539
440
  st.branch = (body.split("...")[0] ?? "").split(" ")[0] ?? "";
@@ -573,8 +474,6 @@ export class SessionHandle {
573
474
  await this.mustGit(["add", "--", ...paths], cwd);
574
475
  }
575
476
  async gitCommit(message, opts) {
576
- // `-c user.name/email` scopes identity to this one commit without mutating
577
- // the repo/global config — handy in an ephemeral sandbox with no git identity.
578
477
  const args = [];
579
478
  if (opts.author)
580
479
  args.push("-c", `user.name=${opts.author}`);
@@ -589,8 +488,6 @@ export class SessionHandle {
589
488
  }
590
489
  async gitPushPull(op, opts) {
591
490
  const remote = opts.remote || "origin";
592
- // One-off HTTPS auth without persisting a credential: rewrite the remote's
593
- // URL for just this invocation via `-c url.<authed>.insteadOf=<plain>`.
594
491
  const cfg = [];
595
492
  if (opts.username || opts.password) {
596
493
  const plain = (await this.runGit(["remote", "get-url", remote], opts.cwd)).stdout.trim();
@@ -613,8 +510,6 @@ export class SessionHandle {
613
510
  await this.mustGit(args, opts.cwd);
614
511
  }
615
512
  async gitBranches(cwd) {
616
- // A stable, parse-friendly format (unit-separated) instead of the porcelain
617
- // `* main` layout, so branch names with spaces/`*` never confuse the parser.
618
513
  const out = await this.mustGit(["branch", "--format=%(HEAD)%1f%(refname:short)%1f%(objectname:short)"], cwd);
619
514
  const branches = [];
620
515
  for (const line of out.split("\n")) {
@@ -645,15 +540,12 @@ export class SessionHandle {
645
540
  }
646
541
  return commits;
647
542
  }
648
- // --- lifecycle / admin (gateway-routed via hooks) -------------------------
649
- /** Live resource metrics. */
650
543
  metrics() {
651
544
  if (!this.hooks.metrics) {
652
545
  return Promise.reject(new Error("metrics requires a client-created handle"));
653
546
  }
654
547
  return this.hooks.metrics(this.id);
655
548
  }
656
- /** Checkpoint this RUNNING session; it keeps running. Returns the snapshot id. */
657
549
  async snapshot(name) {
658
550
  if (!this.hooks.snapshot) {
659
551
  throw new Error("snapshot requires a client-created handle");
@@ -661,22 +553,24 @@ export class SessionHandle {
661
553
  const r = await this.hooks.snapshot(this.id, name);
662
554
  return r.snapshotId;
663
555
  }
664
- /** Restore this session in place from a snapshot (id stays stable). */
665
556
  revert(snapshotId) {
666
557
  if (!this.hooks.revert) {
667
558
  return Promise.reject(new Error("revert requires a client-created handle"));
668
559
  }
669
- return this.hooks.revert(this.id, snapshotId);
560
+ return this.hooks.revert(this.id, snapshotId).then(async () => {
561
+ if (this.channel.connected) {
562
+ this.close({ quiet: true });
563
+ await this.channel.connect().catch(() => undefined);
564
+ }
565
+ });
670
566
  }
671
- /** Pause (snapshot RAM+disk, free the slot). Closes the control channel. */
672
567
  async pause() {
673
568
  if (!this.hooks.pause) {
674
569
  throw new Error("pause requires a client-created handle");
675
570
  }
676
571
  await this.hooks.pause(this.id);
677
- this.close();
572
+ this.close({ quiet: true });
678
573
  }
679
- /** Resume a paused session; re-points the control channel at the fresh slot. */
680
574
  async resume() {
681
575
  if (!this.hooks.resume) {
682
576
  throw new Error("resume requires a client-created handle");
@@ -685,17 +579,6 @@ export class SessionHandle {
685
579
  this.channel.setControlUrl(r.controlUrl);
686
580
  await this.channel.reconnect();
687
581
  }
688
- /**
689
- * Start a **stopped** sandbox (its guest powered itself off) from the disk the
690
- * host kept, and re-point the control channel at the fresh `controlUrl`.
691
- *
692
- * This is a cold boot, not a resume: running processes and memory are gone,
693
- * files on disk are kept. Use {@link resume} for a paused sandbox. Idempotent:
694
- * a sandbox that is already running just reconnects. Throws
695
- * {@link SandboxNotStoppedError} (`409 NotStopped` / `StartInProgress`) for any
696
- * other state; if the start fails the sandbox stays stopped with its disk, so
697
- * you can call this again.
698
- */
699
582
  async start() {
700
583
  if (!this.hooks.start) {
701
584
  throw new Error("start requires a client-created handle");
@@ -704,79 +587,50 @@ export class SessionHandle {
704
587
  this.channel.setControlUrl(r.controlUrl);
705
588
  await this.channel.reconnect();
706
589
  }
707
- /**
708
- * Park (deschedule) this session: freeze the guest's vCPUs while leaving its
709
- * RAM resident on the same host. Milliseconds to park, milliseconds to wake.
710
- *
711
- * Unlike {@link pause}, this does NOT close the control channel and does not
712
- * free the slot -- the session keeps its host, its ports and its routing, so
713
- * anything already pointing at it keeps working. There is no need to call
714
- * {@link unpark} first: exec, the control/stream channels, port preview and
715
- * signed file reads all wake a parked session automatically.
716
- *
717
- * Use it around a long LLM or tool call to stop paying for vCPU you are not
718
- * using. For hands-off behaviour set `lifecycle.parkAfterMs` at create time
719
- * instead and the gateway parks on an idle timer.
720
- */
721
590
  async park() {
722
591
  if (!this.hooks.park) {
723
592
  throw new Error("park requires a client-created handle");
724
593
  }
725
594
  await this.hooks.park(this.id);
726
595
  }
727
- /**
728
- * Wake a parked session explicitly. Rarely needed -- every normal request
729
- * path unparks on touch -- but lets a caller pay the (millisecond) wake up
730
- * front rather than on the next real request.
731
- */
732
596
  async unpark() {
733
597
  if (!this.hooks.unpark) {
734
598
  throw new Error("unpark requires a client-created handle");
735
599
  }
736
600
  await this.hooks.unpark(this.id);
737
601
  }
738
- /** Extend the idle/keep-alive window. */
739
602
  setTimeout(timeoutMs) {
740
603
  if (!this.hooks.setTimeout) {
741
604
  return Promise.reject(new Error("setTimeout requires a client-created handle"));
742
605
  }
743
606
  return this.hooks.setTimeout(this.id, timeoutMs);
744
607
  }
745
- /**
746
- * Resolve a public preview URL for an in-guest port (e.g. a dev server on
747
- * `:3000`). Returns the routable `https://` URL and, when the gateway signs
748
- * one, an access `token`.
749
- */
750
608
  previewUrl(port) {
751
609
  if (!this.hooks.previewUrl) {
752
610
  return Promise.reject(new Error("previewUrl requires a client-created handle"));
753
611
  }
754
612
  return this.hooks.previewUrl(this.id, port);
755
613
  }
756
- /** Destroy the remote session and close the channel. Idempotent. */
614
+ async [Symbol.asyncDispose ?? Symbol.for("Symbol.asyncDispose")]() {
615
+ await this.kill();
616
+ }
757
617
  async kill() {
758
618
  if (this.hooks.kill)
759
619
  await this.hooks.kill(this.id);
760
- // Mark first: close() warns about a still-running machine, which is exactly
761
- // wrong once we have just released it.
762
620
  this.killed = true;
763
621
  this.close();
764
622
  }
765
623
  }
766
- /**
767
- * Add the missing explanation to a failed exec that looks like a shell line.
768
- *
769
- * `commands.run(cmd, {args})` exec's `cmd` directly — no shell — which is what
770
- * makes it injection-safe: nothing in `args` can be reinterpreted as a shell
771
- * metacharacter. The cost is that `run("ls -la")` looks up a binary literally
772
- * named `ls -la` and the guest answers "no such file or directory", which reads
773
- * like a missing tool rather than a misused argument.
774
- *
775
- * We do NOT reject such a call up front. Rejecting would be a breaking change
776
- * for anyone doing it today, and the complaint was never that the call was
777
- * accepted — it was that the failure did not say why. So let it run, and if it
778
- * fails the way a shell line fails, explain it.
779
- */
624
+ function normalizeExecError(err, timeoutMs) {
625
+ if (err instanceof GatewayError) {
626
+ if (err.status === 400 && err.code === "ExecFailed")
627
+ return new ActionError("cmd.start", err.message, err.code);
628
+ if (err.status === 502 && /i\/o timeout/.test(err.message)) {
629
+ return new TimeoutError("cmd.run", timeoutMs);
630
+ }
631
+ }
632
+ return err;
633
+ }
780
634
  function explainShellCommandFailure(cmd, err) {
781
635
  const msg = err instanceof Error ? err.message : String(err);
782
636
  if (!/no such file or directory|ENOENT|not found/i.test(msg))
@@ -785,20 +639,19 @@ function explainShellCommandFailure(cmd, err) {
785
639
  if (!/\s/.test(trimmed))
786
640
  return err;
787
641
  const [head = trimmed, ...rest] = trimmed.split(/\s+/);
642
+ const viaShell = `run("sh", { args: ["-lc", ${JSON.stringify(trimmed)}] })`;
643
+ const split = /['"\\$`|&;<>*?()]/.test(trimmed)
644
+ ? ""
645
+ : `Pass the arguments separately: run(${JSON.stringify(head)}, { args: ${JSON.stringify(rest)} }), or `;
788
646
  const hint = ` — commands.run() executes the binary directly and does not interpret a ` +
789
647
  `shell line, so ${JSON.stringify(cmd)} was looked up as a single filename. ` +
790
- `Pass the arguments separately: run(${JSON.stringify(head)}, { args: ` +
791
- `${JSON.stringify(rest)} }), or run it through a shell explicitly: ` +
792
- `run("sh", { args: ["-lc", ${JSON.stringify(trimmed)}] }).`;
648
+ `${split}${split ? "run" : "Run"} it through a shell explicitly: ${viaShell}.`;
793
649
  if (err instanceof Error) {
794
650
  err.message += hint;
795
651
  return err;
796
652
  }
797
653
  return new Error(msg + hint);
798
654
  }
799
- /** True when an error means the one-shot `/exec` route isn't served (an older
800
- * gateway) — the only case where `runCommand` retries over the control WS. A
801
- * real host/command failure is NOT route-unavailable and must propagate. */
802
655
  function isRouteUnavailable(err) {
803
656
  return (err instanceof GatewayError &&
804
657
  (err.status === 404 || err.status === 405 || err.status === 501));
@@ -807,4 +660,3 @@ function toB64(data) {
807
660
  const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
808
661
  return bytesToBase64(bytes);
809
662
  }
810
- //# sourceMappingURL=handle.js.map