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