withruntime 0.5.0 → 0.5.1

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/DAYTONA.md CHANGED
@@ -54,31 +54,31 @@ package talks only to Runtime.
54
54
 
55
55
  ## Mapping
56
56
 
57
- | Daytona | On Runtime |
58
- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
- | `daytona.create()`, `create({ snapshot })` | `sandboxes.create`. Daytona's own snapshots (`daytona-*`) are Runtime's stock image. Any other name is a ready Runtime image of that name, then a Runtime snapshot of that name. |
60
- | `create({ image: "python:3.12" })` | Built once as a Runtime image from the registry reference (named after it), then reused. `onSnapshotCreateLogs` receives the build log. |
61
- | `create({ image: Image.debianSlim(...).pipInstall(...)... })` | The Dockerfile Daytona's `Image` stands for, with its local files, built as a Runtime image named after its content and reused. |
62
- | `resources`, `envVars`, `labels`, `name`, `volumes` | `vcpu`, `memoryMiB`, `diskMiB`; environment given to every command; labels; name; Runtime volumes by id or by name. |
63
- | `networkBlockAll`, `networkAllowList`, `domainAllowList`, `outboundProxyUrl` | Runtime network rules (`internet: false`, or an `allow` list); the proxy as `HTTP_PROXY` and `HTTPS_PROXY` for commands. |
64
- | `public: true` | `getPreviewLink(port)` shares the port publicly; without it, privately with a token. |
65
- | `language` | The language of `codeRun`, kept in Daytona's `code-toolbox-language` label so a sandbox found later keeps it. |
66
- | `daytona.get(idOrName)`, `daytona.list({ labels, states, name })` | `sandboxes.get`, or the newest live sandbox with that name; `sandboxes.list`. Awaiting `list()` gives the older `{ items, total }` shape. |
67
- | `process.executeCommand(cmd, cwd, env, timeout)` | `exec` under bash with stderr joined to stdout, as Daytona's `result`. A non-zero exit is a result. Past `timeout` (seconds): `DaytonaProcessExecutionTimeoutError`. |
68
- | `process.codeRun(code, { argv, env })` | `python3 -c`, `node -e` or `bun -e` by the sandbox's language. `artifacts.charts` is always empty. |
69
- | `createSession`, `executeSessionCommand`, `getSessionCommandLogs` | One bash per session, kept running: directory, variables and functions carry from one command to the next. `runAsync` returns at once; logs stream to callbacks. |
70
- | `sendSessionCommandInput`, `getSession`, `getSessionCommand`, `deleteSession` | Input to the session's shell, which the running command reads; the commands sent through this object; killing the shell. |
71
- | `fs.uploadFile(s)`, `downloadFile(s)`, `listFiles`, `getFileDetails` | Runtime's files API. Relative paths resolve from `/workspace`. `owner` and `group` are always `""`. |
72
- | `fs.createFolder`, `deleteFile`, `moveFiles`, `setFilePermissions` | `mkdir -m`, `files.remove`, `files.rename`, `chmod` and `sudo chown`. |
73
- | `fs.findFiles`, `searchFiles`, `replaceInFiles` | `grep -rn`, a files glob, and a read and write of each file. |
74
- | `git.clone`, `status`, `add`, `commit`, `push`, `pull`, branches | The sandbox's git. Credentials reach git through the environment, never the command line. |
75
- | `codeInterpreter.runCode`, contexts | Runtime's Python interpreter. The sandbox's `envVars` reach code through a context made once for them. |
76
- | `start`, `stop`, `pause`, `archive`, `delete`, `fork`, `createSnapshot` | `wake`, `pause` (or `stop` when ephemeral), `pause`, `pause`, `stop`, Runtime's fork with memory, a Runtime snapshot with that name. |
77
- | `getPreviewLink(port)` | A Runtime preview. Send its token as `x-runtime-preview-token`; Daytona's header is `x-daytona-preview-token`. |
78
- | `getUserHomeDir`, `getWorkDir`, `user` | `/workspace`, and `daytona`, so `/home/daytona` (linked to `/workspace` the first time something names it) works. |
79
- | `daytona.snapshot.create`, `get`, `list`, `delete` | Runtime images built, found, listed and deleted by name. |
80
- | `daytona.volume.get`, `list`, `delete` | Runtime volumes by name. |
81
- | Error classes | Daytona's names and parents, by HTTP status. Each also carries Runtime's `code`, `hint` and `requestId`, with the original error as `cause`. |
57
+ | Daytona | On Runtime |
58
+ | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
+ | `daytona.create()`, `create({ snapshot })` | `sandboxes.create`. Daytona's own snapshots (`daytona-*`) are Runtime's stock image. Any other name is a ready Runtime image of that name, then a Runtime snapshot of that name. |
60
+ | `create({ image: "python:3.12" })` | Built once as a Runtime image from the registry reference (named after it), then reused. `onSnapshotCreateLogs` receives the build log. |
61
+ | `create({ image: Image.debianSlim(...).pipInstall(...)... })` | The Dockerfile Daytona's `Image` stands for, with its local files, built as a Runtime image named after its content and reused. |
62
+ | `resources`, `envVars`, `labels`, `name`, `volumes` | `vcpu`, `memoryMiB`, `diskMiB`; environment given to every command; labels; name; Runtime volumes by id or by name. |
63
+ | `networkBlockAll`, `networkAllowList`, `domainAllowList`, `outboundProxyUrl` | Runtime network rules (`internet: false`, or an `allow` list); the proxy as `HTTP_PROXY` and `HTTPS_PROXY` for commands. |
64
+ | `public: true` | `getPreviewLink(port)` shares the port publicly; without it, privately with a token. |
65
+ | `language` | The language of `codeRun`, kept in Daytona's `code-toolbox-language` label so a sandbox found later keeps it. |
66
+ | `daytona.get(idOrName)`, `daytona.list({ labels, states, name })` | `sandboxes.get`, or the newest live sandbox with that name; `sandboxes.list`. Awaiting `list()` gives the older `{ items, total }` shape. |
67
+ | `process.executeCommand(cmd, cwd, env, timeout)` | `exec` under bash with stderr joined to stdout, as Daytona's `result`, streamed so the whole output comes back. A non-zero exit is a result. Past `timeout` (seconds): `DaytonaProcessExecutionTimeoutError`. |
68
+ | `process.codeRun(code, { argv, env })` | `python3 -c`, `node -e` or `bun -e` by the sandbox's language. `artifacts.charts` is always empty. |
69
+ | `createSession`, `executeSessionCommand`, `getSessionCommandLogs` | One bash per session, kept running: directory, variables and functions carry from one command to the next. `runAsync` returns at once; logs stream to callbacks. |
70
+ | `sendSessionCommandInput`, `getSession`, `getSessionCommand`, `deleteSession` | Input to the session's shell, which the running command reads; the commands sent through this object; killing the shell. |
71
+ | `fs.uploadFile(s)`, `downloadFile(s)`, `listFiles`, `getFileDetails` | Runtime's files API. Relative paths resolve from `/workspace`. `owner` and `group` are always `""`. |
72
+ | `fs.createFolder`, `deleteFile`, `moveFiles`, `setFilePermissions` | `mkdir -m`, `files.remove`, `files.rename`, `chmod` and `sudo chown`. |
73
+ | `fs.findFiles`, `searchFiles`, `replaceInFiles` | `grep -rn`, a files glob, and a read and write of each file. |
74
+ | `git.clone`, `status`, `add`, `commit`, `push`, `pull`, branches | The sandbox's git. Credentials reach git through the environment, never the command line. |
75
+ | `codeInterpreter.runCode`, contexts | Runtime's Python interpreter. The sandbox's `envVars` reach code through a context made once for them. |
76
+ | `start`, `stop`, `pause`, `archive`, `delete`, `fork`, `createSnapshot` | `wake`, `pause` (or `stop` when ephemeral), `pause`, `pause`, `stop`, Runtime's fork with memory, a Runtime snapshot with that name. |
77
+ | `getPreviewLink(port)` | A Runtime preview. Send its token as `x-runtime-preview-token`; Daytona's header is `x-daytona-preview-token`. |
78
+ | `getUserHomeDir`, `getWorkDir`, `user` | `/workspace`, and `daytona`, so `/home/daytona` (linked to `/workspace` the first time something names it) works. |
79
+ | `daytona.snapshot.create`, `get`, `list`, `delete` | Runtime images built, found, listed and deleted by name. |
80
+ | `daytona.volume.get`, `list`, `delete` | Runtime volumes by name. |
81
+ | Error classes | Daytona's names and parents, by HTTP status. Each also carries Runtime's `code`, `hint` and `requestId`, with the original error as `cause`. |
82
82
 
83
83
  ## Gaps
84
84
 
package/E2B.md CHANGED
@@ -58,7 +58,7 @@ machine. An E2B key (`e2b_...`) is never sent anywhere. If one is passed as
58
58
  | `envs` | Given to every command and code run through this object. |
59
59
  | `allowInternetAccess: false` | `network: { internet: false }`. |
60
60
  | `lifecycle.onTimeout` | `onLeaseEnd`: `kill` is `stop`, `pause` is `pause`. |
61
- | `commands.run(cmd, { cwd, envs, timeoutMs, onStdout, onStderr })` | `exec`. Default timeout 60 s. `0` means no limit (Runtime's longest, 24 hours). |
61
+ | `commands.run(cmd, { cwd, envs, timeoutMs, onStdout, onStderr })` | `exec`, streamed, so the whole output comes back however large. Default timeout 60 s. `0` means no limit (Runtime's longest, 24 hours). |
62
62
  | Non-zero exit | Throws `CommandExitError` with `exitCode`, `stdout`, `stderr` and `error` (`exit status N`). A command killed by a signal has `exitCode` -1. |
63
63
  | Command past its timeout | Throws `TimeoutError`. |
64
64
  | `commands.run(cmd, { background: true })` | `spawn`. Returns a `CommandHandle` with `pid`, `wait()`, `kill()` (SIGKILL), `sendStdin`, `closeStdin`, `disconnect`, `stdout`, `stderr`, `exitCode`. |
package/dist/cli.js CHANGED
@@ -684,7 +684,16 @@ const RIVAL_NAMES = {
684
684
  cloudflare: "Cloudflare Sandbox",
685
685
  fly: "Fly",
686
686
  };
687
- /** One line on the switching credit: how to claim it, or what it did. */
687
+ /** A date as the CLI writes one: 23 September 2026. */
688
+ const longDate = (iso) => new Date(iso).toLocaleDateString("en-GB", {
689
+ day: "numeric",
690
+ month: "long",
691
+ year: "numeric",
692
+ timeZone: "UTC",
693
+ });
694
+ /** One line on the switching credit: how to claim it, or what it did. A
695
+ * switch already on record is named with its provider and date, so it never
696
+ * reads as if this command recorded it, or recorded the provider it names. */
688
697
  function switchingLine(summary, provider) {
689
698
  const cap = dollars(summary.maxMicros);
690
699
  const sw = summary.switch;
@@ -694,21 +703,32 @@ function switchingLine(summary, provider) {
694
703
  return `Switching from ${RIVAL_NAMES[provider]}? Run \`${me} switch --from ${provider}\` before your first top-up and we match that top-up, up to ${cap}.`;
695
704
  }
696
705
  const from = RIVAL_NAMES[sw.provider];
706
+ const recorded = `This organization's switch from ${from} was recorded on ${longDate(sw.recordedAt)}`;
707
+ const once = provider && provider !== sw.provider
708
+ ? ` A switch is recorded once, so it stays ${from}, not ${RIVAL_NAMES[provider]}.`
709
+ : "";
697
710
  switch (sw.status) {
698
711
  case "pending":
699
- return `Your switch from ${from} is recorded: your first top-up is matched, up to ${cap}, once the payment settles.`;
712
+ return `${recorded}: its first top-up is matched, up to ${cap}, once the payment settles.${once}`;
700
713
  case "paid":
701
- return `Your switch from ${from} matched your first top-up: ${dollars(sw.creditMicros)} of credit.`;
714
+ return `${recorded} and matched its first top-up: ${dollars(sw.creditMicros)} of credit.${once}`;
702
715
  case "superseded":
703
- return `Your first top-up was matched by your referral instead, which is never less than the switching credit.`;
716
+ return `${recorded}; its first top-up was matched by a referral instead, which is never less than the switching credit.${once}`;
704
717
  case "reversed":
705
- return `The top-up your switching credit matched was refunded, so the credit was taken back.`;
718
+ return `${recorded}; the top-up it matched was refunded, so the credit was taken back.${once}`;
706
719
  case "refused":
707
720
  return sw.refusedReason === "program_off"
708
- ? "The switching credit was paused when your first top-up arrived."
709
- : "Your first top-up was not matched. Ask support if you think that is wrong.";
721
+ ? `${recorded}; the switching credit was paused when its first top-up arrived.${once}`
722
+ : `${recorded}; its first top-up was not matched. Ask support if you think that is wrong.${once}`;
710
723
  }
711
724
  }
725
+ /** The sandboxes the free trial paid for, said beside what was priced. */
726
+ function trialNote(trial, all) {
727
+ if (!trial)
728
+ return "";
729
+ const which = trial === all ? (all === 1 ? "It" : "All of them") : `${trial} of them`;
730
+ return ` ${which} ran on the free trial, which charged nothing; Runtime's side prices ${trial === 1 ? "it" : "them"} at the standard rates, what the same work costs on paid credit.`;
731
+ }
712
732
  async function switching(command, argv, env, out) {
713
733
  const args = parse(argv);
714
734
  const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
@@ -756,13 +776,13 @@ async function switching(command, argv, env, out) {
756
776
  : month >= 0n
757
777
  ? `, about ${dollars(month)} a month.`
758
778
  : `, about ${dollars(-month)} a month more on Runtime.`}`,
759
- `Priced: ${c.usage.sandboxes.toLocaleString("en-US")} sandbox${c.usage.sandboxes === 1 ? "" : "es"}, ${(c.usage.runSeconds / 3600).toLocaleString("en-US", { maximumFractionDigits: 2 })} hours running.${c.usage.unpricedSandboxes ? ` ${c.usage.unpricedSandboxes} left out: ${c.rival.name} publishes no price for their size.` : ""}`,
779
+ `Priced: ${c.usage.sandboxes.toLocaleString("en-US")} sandbox${c.usage.sandboxes === 1 ? "" : "es"}, ${(c.usage.runSeconds / 3600).toLocaleString("en-US", { maximumFractionDigits: 2 })} hours running.${trialNote(c.usage.trialSandboxes, c.usage.sandboxes)}${c.usage.unpricedSandboxes ? ` ${c.usage.unpricedSandboxes} left out: ${c.rival.name} publishes no price for their size.` : ""}`,
760
780
  ]
761
781
  : [
762
782
  `No settled sandboxes in your last ${c.window.days} days yet, so this prices an example: ${c.usage.sandboxes.toLocaleString("en-US")} runs of ${(c.usage.runSeconds / c.usage.sandboxes).toLocaleString("en-US")} seconds using ${(c.usage.activeCpuSeconds / c.usage.sandboxes).toLocaleString("en-US")} CPU-seconds each.`,
763
783
  `The example: ${figures}.`,
764
784
  ];
765
- lines.push(`${c.rival.name}'s published rates, checked ${new Date(`${c.rival.checked}T12:00:00Z`).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" })}: ${c.rival.rates.join("; ")}. Compute only: storage, network, plan fees and free allowances are left out.`);
785
+ lines.push(`${c.rival.name}'s published rates, checked ${longDate(`${c.rival.checked}T12:00:00Z`)}: ${c.rival.rates.join("; ")}. Compute only: storage, network, plan fees and free allowances are left out.`);
766
786
  const line = switchingLine(c.switching, Object.hasOwn(SWITCH_FROM, from) ? SWITCH_FROM[from] : undefined);
767
787
  if (line)
768
788
  lines.push("", line);
@@ -19,6 +19,11 @@ export declare const HOME = "/workspace";
19
19
  export declare const HOME_LINK = "[ -e /home/daytona ] || sudo ln -s /workspace /home/daytona";
20
20
  /** Commands without a timeout run until the sandbox ends: Runtime's longest. */
21
21
  export declare const LONGEST_MS = 86400000;
22
+ /** Given to exec so it streams and returns the whole output, as Daytona does:
23
+ * an exec with no output callback returns at most 64 KiB of each stream. */
24
+ export declare const WHOLE_OUTPUT: {
25
+ readonly onStdout: () => undefined;
26
+ };
22
27
  /** A Daytona path as a Runtime one: relative paths (and `~`) resolve from the
23
28
  * working directory, /workspace on Runtime. */
24
29
  export declare function resolvePath(path: string): string;
@@ -5,6 +5,9 @@ export const HOME = "/workspace";
5
5
  export const HOME_LINK = `[ -e ${DAYTONA_HOME} ] || sudo ln -s ${HOME} ${DAYTONA_HOME}`;
6
6
  /** Commands without a timeout run until the sandbox ends: Runtime's longest. */
7
7
  export const LONGEST_MS = 86_400_000;
8
+ /** Given to exec so it streams and returns the whole output, as Daytona does:
9
+ * an exec with no output callback returns at most 64 KiB of each stream. */
10
+ export const WHOLE_OUTPUT = { onStdout: () => undefined };
8
11
  /** A Daytona path as a Runtime one: relative paths (and `~`) resolve from the
9
12
  * working directory, /workspace on Runtime. */
10
13
  export function resolvePath(path) {
@@ -1,4 +1,4 @@
1
- import { resolvePath } from "./context.js";
1
+ import { resolvePath, WHOLE_OUTPUT } from "./context.js";
2
2
  import { DaytonaError, DaytonaFileNotFoundError, guard } from "./errors.js";
3
3
  const PERMISSIONS = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"];
4
4
  function fileInfo(entry) {
@@ -112,7 +112,7 @@ export class FileSystem {
112
112
  async findFiles(path, pattern) {
113
113
  const target = await this.#path(path);
114
114
  const runtime = await this.#ctx.live();
115
- const result = await guard("sandbox", () => runtime.exec(["grep", "-rnI", "--", pattern, target]));
115
+ const result = await guard("sandbox", () => runtime.exec(["grep", "-rnI", "--", pattern, target], WHOLE_OUTPUT));
116
116
  if (result.exitCode !== 0 && result.exitCode !== 1)
117
117
  throw new DaytonaError(result.stderr.trim() || "grep failed", 400);
118
118
  return result.stdout
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { HOME, LONGEST_MS, quote, resolvePath } from "./context.js";
2
+ import { HOME, LONGEST_MS, quote, resolvePath, WHOLE_OUTPUT, } from "./context.js";
3
3
  import { DaytonaCommandAlreadyCompletedError, DaytonaConflictError, DaytonaNotFoundError, DaytonaProcessExecutionTimeoutError, DaytonaSessionEndedError, guard, NotSupportedError, } from "./errors.js";
4
4
  export class CodeRunParams {
5
5
  argv;
@@ -76,6 +76,7 @@ export class Process {
76
76
  cwd: cwd === undefined ? HOME : resolvePath(cwd),
77
77
  ...this.#env(env),
78
78
  timeoutMs,
79
+ ...WHOLE_OUTPUT,
79
80
  }));
80
81
  if (result.timedOut)
81
82
  throw new DaytonaProcessExecutionTimeoutError(`Command timed out after ${timeout} s. Pass a larger timeout, or 0 for none.`, 408);
@@ -97,6 +98,7 @@ export class Process {
97
98
  cwd: HOME,
98
99
  ...this.#env(params.env),
99
100
  timeoutMs,
101
+ ...WHOLE_OUTPUT,
100
102
  }));
101
103
  if (result.timedOut)
102
104
  throw new DaytonaProcessExecutionTimeoutError(`Code run timed out after ${timeout} s.`, 408);
@@ -68,12 +68,16 @@ export class Commands {
68
68
  const handle = await this.#start(cmd, opts, timeoutMs);
69
69
  return opts.background ? handle : handle.wait();
70
70
  }
71
+ /* Always streamed, so the result holds the whole output as E2B's does: an
72
+ exec with no output callback returns at most 64 KiB of each stream. */
73
+ const onStdout = opts.onStdout;
74
+ const onStderr = opts.onStderr;
71
75
  const result = await guard("sandbox", () => this.#ctx.runtime.exec(cmd, {
72
76
  ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
73
77
  ...this.#env(opts.envs),
74
78
  timeoutMs,
75
- ...(opts.onStdout ? { onStdout: (text) => void opts.onStdout(text) } : {}),
76
- ...(opts.onStderr ? { onStderr: (text) => void opts.onStderr(text) } : {}),
79
+ onStdout: (text) => void onStdout?.(text),
80
+ ...(onStderr ? { onStderr: (text) => void onStderr(text) } : {}),
77
81
  ...(opts.signal ? { signal: opts.signal } : {}),
78
82
  }));
79
83
  return settle(result, timeoutMs);
@@ -52,7 +52,14 @@ export type UsageComparison = {
52
52
  runSeconds: number;
53
53
  activeCpuSeconds: number;
54
54
  unpricedSandboxes: number;
55
+ /** Of `sandboxes`, how many the free trial paid for, and their running
56
+ * time. The trial charged nothing for them; `runtimeMicros` prices them at
57
+ * Runtime's standard rates, what the same work costs on paid credit. */
58
+ trialSandboxes: number;
59
+ trialRunSeconds: number;
55
60
  };
61
+ /** What the usage costs on Runtime on paid credit: each paid sandbox at the
62
+ * rates it was quoted, a trial sandbox at the standard rates. */
56
63
  runtimeMicros: string;
57
64
  rivalMicros: string;
58
65
  /** Rival less Runtime: negative when the rival would cost less. */
package/dist/ssh.js CHANGED
@@ -265,9 +265,21 @@ export async function portForwardCommand(argv, out, connect, me) {
265
265
  const sbx = await resolveSandbox(await connect(), target);
266
266
  const tunnel = await sbx.tunnel();
267
267
  const forwards = [];
268
+ /* A connection the sandbox refused is closed at once, which a browser or
269
+ curl reports only as an empty reply, so say why here, once per reason. */
270
+ const said = new Set();
271
+ const refused = (remote) => (error) => {
272
+ const line = `Port ${remote}: ${error.message}${error.hint ? ` ${error.hint}` : ""}`;
273
+ if (said.has(line))
274
+ return;
275
+ said.add(line);
276
+ out.error(out.json
277
+ ? JSON.stringify({ port: remote, error: { code: error.code, message: error.message } })
278
+ : line);
279
+ };
268
280
  try {
269
281
  for (const { local, remote } of ports)
270
- forwards.push(await tunnel.forward(remote, { localPort: local, host: address }));
282
+ forwards.push(await tunnel.forward(remote, { localPort: local, host: address, onError: refused(remote) }));
271
283
  }
272
284
  catch (error) {
273
285
  tunnel.close();
@@ -1,5 +1,5 @@
1
1
  import { RuntimeError } from "./errors.js";
2
- export declare const VERSION = "0.5.0";
2
+ export declare const VERSION = "0.5.1";
3
3
  export declare const DEFAULT_BASE_URL = "https://api.withruntime.com";
4
4
  export type RequestOptions = {
5
5
  /** Sent as Idempotency-Key. Omit it: the SDK makes one per call and keeps
package/dist/transport.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ConnectionError, errorFor, RuntimeError, WAITS_FOR_ROOM } from "./errors.js";
2
2
  import { describeRoute, envFetch, openWebSocket } from "./proxy.js";
3
- export const VERSION = "0.5.0";
3
+ export const VERSION = "0.5.1";
4
4
  export const DEFAULT_BASE_URL = "https://api.withruntime.com";
5
5
  export function apiOrigin(value) {
6
6
  const url = new URL(value);
package/dist/tunnel.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { RuntimeError } from "./errors.js";
1
2
  /** One TCP connection (or SSH login) inside a tunnel. Set the handlers before
2
3
  * data can arrive, which is as soon as the promise that gave you this resolves. */
3
4
  export declare class TunnelStream {
@@ -49,10 +50,12 @@ export declare class Tunnel {
49
50
  * (the text of an OpenSSH `.pub` file). Speak SSH over the stream. */
50
51
  ssh(publicKey: string): Promise<TunnelStream>;
51
52
  /** Listens on a local port and carries each connection to `port` in the
52
- * sandbox. Node and Bun only. */
53
+ * sandbox. Node and Bun only. A connection the sandbox cannot open, such as
54
+ * one to a port nothing listens on, is closed at once; `onError` says why. */
53
55
  forward(port: number, options?: {
54
56
  localPort?: number;
55
57
  host?: string;
58
+ onError?: (error: RuntimeError) => void;
56
59
  }): Promise<PortForward>;
57
60
  /** Closes the tunnel and every connection in it. */
58
61
  close(): void;
package/dist/tunnel.js CHANGED
@@ -160,7 +160,8 @@ export class Tunnel {
160
160
  return this.#open(`ssh ${publicKey.trim()}`);
161
161
  }
162
162
  /** Listens on a local port and carries each connection to `port` in the
163
- * sandbox. Node and Bun only. */
163
+ * sandbox. Node and Bun only. A connection the sandbox cannot open, such as
164
+ * one to a port nothing listens on, is closed at once; `onError` says why. */
164
165
  async forward(port, options = {}) {
165
166
  const net = await import("node:net");
166
167
  const host = options.host ?? "127.0.0.1";
@@ -183,7 +184,11 @@ export class Tunnel {
183
184
  socket.on("end", () => stream.end());
184
185
  socket.on("close", () => stream.close());
185
186
  socket.resume();
186
- }, () => socket.destroy());
187
+ }, (error) => {
188
+ socket.destroy();
189
+ if (error instanceof RuntimeError)
190
+ options.onError?.(error);
191
+ });
187
192
  });
188
193
  await new Promise((resolve, reject) => {
189
194
  server.once("error", reject);
@@ -217,7 +222,7 @@ export class Tunnel {
217
222
  message: `Nothing is listening on port ${port} in the sandbox.`,
218
223
  code: "port_closed",
219
224
  status: 0,
220
- hint: "Start the server first, and let it listen on 127.0.0.1, ::1 or every address.",
225
+ hint: "Start the server with spawn (`runtime sandbox spawn <id> -- <command>`, sbx.spawn()), which keeps it running: a process started by exec ends with its command. Let it listen on 127.0.0.1, ::1 or every address.",
221
226
  })
222
227
  : error.message === "reserved"
223
228
  ? new RuntimeError({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "withruntime",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "mcpName": "com.withruntime/runtime",
5
5
  "type": "module",
6
6
  "exports": {