withruntime 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,219 @@
1
+ import { NotFoundError, RuntimeError } from "../errors.js";
2
+ import { switchedOff } from "./jobs.js";
3
+ /** A write that reached one copy of a secret and not the other: `stored`
4
+ * names what took effect. Calling again with the same arguments is safe. */
5
+ export class SecretPartlyStoredError extends RuntimeError {
6
+ }
7
+ const unavailable = (error) => error instanceof RuntimeError && (error.code === "unavailable" || error.status === 503);
1
8
  export function secrets(t) {
9
+ const off = switchedOff("Secrets for jobs");
10
+ const store = (call) => t.json(call).catch(off);
2
11
  const path = (name) => `/v1/egress-secrets/${encodeURIComponent(name)}`;
3
- return {
4
- /** Store or replace a secret. Replacing keeps its placeholder. */
5
- set: (name, input, options) => t.json({
6
- method: "PUT",
7
- path: path(name),
8
- body: input,
9
- ...options,
10
- }),
11
- /** Names, hosts and placeholders. Never values. */
12
- list: async (options) => (await t.json({
13
- method: "GET",
14
- path: "/v1/egress-secrets",
12
+ const jobPath = (id) => `/v1/secrets/${encodeURIComponent(id)}`;
13
+ /** Every jobs copy this key's person owns, deleted ones included. */
14
+ async function jobCopies(options) {
15
+ const all = [];
16
+ let after;
17
+ for (;;) {
18
+ const page = await store({
19
+ method: "GET",
20
+ path: "/v1/secrets",
21
+ query: { limit: 100, ...(after ? { after } : {}) },
22
+ ...options,
23
+ });
24
+ all.push(...page);
25
+ if (page.length < 100)
26
+ return all;
27
+ after = page.at(-1).id;
28
+ }
29
+ }
30
+ const liveCopy = async (name, options) => (await jobCopies(options)).find((s) => s.name === name && s.deletedAt === null);
31
+ const list = async (options) => (await t.json({
32
+ method: "GET",
33
+ path: "/v1/egress-secrets",
34
+ ...options,
35
+ })).secrets;
36
+ const key = (options, part) => options?.idempotencyKey
37
+ ? { ...options, idempotencyKey: `${options.idempotencyKey}:${part}`.slice(0, 128) }
38
+ : options;
39
+ async function setJobCopy(name, value, options) {
40
+ const copies = await jobCopies(options);
41
+ const live = copies.find((s) => s.name === name && s.deletedAt === null);
42
+ if (live)
43
+ return store({
44
+ method: "POST",
45
+ path: `${jobPath(live.id)}:rotate`,
46
+ body: { value, expectedVersion: live.version },
47
+ ...options,
48
+ });
49
+ if (copies.some((s) => s.name === name))
50
+ throw new RuntimeError({
51
+ message: `A jobs secret named ${name} was deleted, and a deleted name cannot be used again.`,
52
+ code: "name_conflict",
53
+ status: 409,
54
+ hint: "Store it under another name.",
55
+ details: { field: "name" },
56
+ });
57
+ return store({
58
+ method: "POST",
59
+ path: "/v1/secrets",
60
+ body: { name, value },
15
61
  ...options,
16
- })).secrets,
17
- /** Delete a secret: its value is erased and its placeholder stops working. */
18
- delete: (name, options) => t.json({
19
- method: "DELETE",
20
- path: path(name),
21
- ...options,
22
- }),
62
+ });
63
+ }
64
+ async function all(options) {
65
+ const [forSandboxes, forJobs] = await Promise.all([
66
+ list(options),
67
+ jobCopies(options).catch((error) => {
68
+ if (unavailable(error))
69
+ return [];
70
+ throw error;
71
+ }),
72
+ ]);
73
+ const byName = new Map();
74
+ for (const s of forSandboxes)
75
+ byName.set(s.name, { name: s.name, sandboxes: s, jobs: null });
76
+ for (const j of forJobs.filter((j) => j.deletedAt === null))
77
+ byName.set(j.name, {
78
+ name: j.name,
79
+ sandboxes: byName.get(j.name)?.sandboxes ?? null,
80
+ jobs: j,
81
+ });
82
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
83
+ }
84
+ async function set(name, input, options) {
85
+ const { jobs, value, ...egress } = input;
86
+ if (!egress.hosts?.length && !jobs)
87
+ throw new RuntimeError({
88
+ message: "Say where the value may go: hosts for sandboxes, jobs: true for scheduled jobs, or both.",
89
+ code: "invalid_request",
90
+ status: 0,
91
+ details: { field: "hosts" },
92
+ });
93
+ let sandboxes;
94
+ if (egress.hosts?.length)
95
+ sandboxes = await t.json({
96
+ method: "PUT",
97
+ path: path(name),
98
+ body: { value, ...egress },
99
+ ...(jobs ? key(options, "sandboxes") : options),
100
+ });
101
+ if (!jobs)
102
+ return sandboxes;
103
+ try {
104
+ const copy = await setJobCopy(name, value, key(options, "jobs"));
105
+ return { ...(sandboxes ?? { name }), jobs: copy };
106
+ }
107
+ catch (error) {
108
+ if (!sandboxes)
109
+ throw error;
110
+ const cause = error instanceof RuntimeError ? error : undefined;
111
+ throw new SecretPartlyStoredError({
112
+ message: `Stored ${name} for sandboxes, but not for jobs: ${cause?.message ?? String(error)}`,
113
+ code: cause?.code ?? "partly_stored",
114
+ status: cause?.status ?? 0,
115
+ hint: "Run the same call again: storing it for sandboxes again changes nothing, and the jobs copy is stored or rotated.",
116
+ details: { stored: ["sandboxes"], field: "jobs" },
117
+ ...(cause?.requestId ? { requestId: cause.requestId } : {}),
118
+ cause: error,
119
+ });
120
+ }
121
+ }
122
+ return {
123
+ /** Store or replace a secret. With `hosts`, sandboxes see a placeholder
124
+ * and the egress proxy puts the value into HTTPS requests to them
125
+ * (replacing keeps the placeholder). With `jobs: true`, a copy scheduled
126
+ * jobs can put into their environment; setting it again rotates it. Both
127
+ * at once store both; if only the first lands, `SecretPartlyStoredError`
128
+ * says so and the same call again finishes it. */
129
+ set,
130
+ /** The sandboxes' secrets: names, hosts and placeholders. Never values.
131
+ * `all()` includes the jobs copies. */
132
+ list,
133
+ /** Every secret by name, with where its value may go. */
134
+ all,
135
+ /** One secret by name, with where its value may go. */
136
+ get: async (name, options) => {
137
+ const found = (await all(options)).find((s) => s.name === name);
138
+ if (!found)
139
+ throw new NotFoundError({
140
+ message: `No secret named ${name}.`,
141
+ code: "not_found",
142
+ status: 404,
143
+ hint: "List them with runtime.secrets.all().",
144
+ });
145
+ return found;
146
+ },
147
+ /** Replace the jobs copy's value with a new version. Runs already bound to
148
+ * the old version are refused it. Only the jobs copy has versions; to
149
+ * change the sandboxes' copy, `set` it again. */
150
+ rotate: async (name, value, options) => {
151
+ const live = await liveCopy(name, options);
152
+ if (!live)
153
+ throw new NotFoundError({
154
+ message: `${name} has no copy for jobs to rotate.`,
155
+ code: "not_found",
156
+ status: 404,
157
+ hint: "Store one with set(name, { value, jobs: true }). The sandboxes' copy is replaced by set, not rotated.",
158
+ });
159
+ return store({
160
+ method: "POST",
161
+ path: `${jobPath(live.id)}:rotate`,
162
+ body: { value, expectedVersion: live.version },
163
+ ...options,
164
+ });
165
+ },
166
+ /** Read the jobs copy's value back (needs the key's secrets_reveal
167
+ * permission). The sandboxes' copy can never be read back. */
168
+ reveal: async (name, query = {}, options) => {
169
+ const live = await liveCopy(name, options);
170
+ if (!live)
171
+ throw new NotFoundError({
172
+ message: `${name} has no copy for jobs; the sandboxes' copy of a secret can never be read back.`,
173
+ code: "not_found",
174
+ status: 404,
175
+ });
176
+ return store({
177
+ method: "POST",
178
+ path: `${jobPath(live.id)}:reveal`,
179
+ body: query,
180
+ ...options,
181
+ });
182
+ },
183
+ /** Delete a secret everywhere it is kept: the sandboxes' value is erased
184
+ * and its placeholder stops working, and the jobs copy stops being given
185
+ * to runs (its name stays taken). */
186
+ delete: async (name, options) => {
187
+ const live = await liveCopy(name, options).catch((error) => {
188
+ if (unavailable(error))
189
+ return undefined;
190
+ throw error;
191
+ });
192
+ let removed;
193
+ try {
194
+ removed = await t.json({
195
+ method: "DELETE",
196
+ path: path(name),
197
+ ...(live ? key(options, "sandboxes") : options),
198
+ });
199
+ }
200
+ catch (error) {
201
+ if (!(live && error instanceof NotFoundError))
202
+ throw error;
203
+ }
204
+ if (live)
205
+ await store({
206
+ method: "POST",
207
+ path: `${jobPath(live.id)}:delete`,
208
+ body: { expectedVersion: live.version },
209
+ ...key(options, "jobs"),
210
+ });
211
+ return {
212
+ name,
213
+ deleted: true,
214
+ enforced: removed?.enforced ?? true,
215
+ from: [...(removed ? ["sandboxes"] : []), ...(live ? ["jobs"] : [])],
216
+ };
217
+ },
23
218
  };
24
219
  }
package/dist/sandbox.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Page } from "./page.js";
2
2
  import { type FileEvent, type WatchHandle, type WatchOptions } from "./products/watch.js";
3
- import type { RequestOptions, Transport } from "./transport.js";
3
+ import { Transport, type RequestOptions } from "./transport.js";
4
4
  import type { CommandResult, ExecOptions, FileEntry, OutputEvent, KeepAliveOptions, ProcessInfo, SandboxInfo, SandboxSettings } from "./types.js";
5
5
  import { type SandboxExtensions } from "./products/index.js";
6
6
  import type { Snapshot, SnapshotOptions } from "./snapshots.js";
@@ -14,8 +14,27 @@ export declare class Sandbox implements AsyncDisposable {
14
14
  #private;
15
15
  readonly files: Files;
16
16
  readonly processes: Processes;
17
+ /** Short-lived tokens that let a browser reach this sandbox directly. */
18
+ readonly sessions: SandboxSessions;
17
19
  constructor(transport: Transport, info: SandboxInfo);
18
20
  get id(): string;
21
+ /** A sandbox reached with a session token instead of an API key, for code in
22
+ * a browser: commands and their streams, processes, files and previews of
23
+ * this one sandbox. Makes no call; `info` holds only the id until
24
+ * `refresh()`. Your backend makes the session with `sbx.sessions.create()`
25
+ * and hands the page `token` and `sandboxId`.
26
+ *
27
+ * const sbx = Sandbox.fromSession({ token, sandboxId });
28
+ * const { stdout } = await sbx.exec("ls /workspace");
29
+ */
30
+ static fromSession(session: {
31
+ token: string;
32
+ sandboxId: string;
33
+ /** The API's origin, `apiUrl` in the session; https://api.withruntime.com
34
+ * unless you run your own. */
35
+ apiUrl?: string;
36
+ fetch?: typeof fetch;
37
+ }): Sandbox;
19
38
  /** What the API last said about this sandbox. `refresh()` asks again. */
20
39
  get info(): SandboxInfo;
21
40
  get state(): SandboxInfo["state"];
@@ -124,6 +143,49 @@ export declare class Sandbox implements AsyncDisposable {
124
143
  [Symbol.asyncDispose](): Promise<void>;
125
144
  toJSON(): SandboxInfo;
126
145
  }
146
+ /** A sandbox session, as the API answers it. `token` is set only in the
147
+ * answer to `create`. */
148
+ export type SandboxSession = {
149
+ id: string;
150
+ sandboxId: string;
151
+ name: string | null;
152
+ origins: string[];
153
+ /** The agent whose key made it; the session acts as it. */
154
+ createdBy: string;
155
+ createdAt: string;
156
+ expiresAt: string;
157
+ revokedAt: string | null;
158
+ state: "active" | "expired" | "revoked";
159
+ };
160
+ export type CreatedSandboxSession = SandboxSession & {
161
+ /** The bearer token, `rtsess_...`: hand it to the page, never log it. */
162
+ token: string;
163
+ /** The API's origin, for `Sandbox.fromSession({ apiUrl })`. */
164
+ apiUrl: string;
165
+ };
166
+ /** Sessions: a token your backend makes and hands to your own frontend, which
167
+ * then runs commands, reads and writes files and reaches previews in this one
168
+ * sandbox directly, without proxying through your servers. A session cannot
169
+ * stop, pause, extend, fork, snapshot or change the sandbox, create anything,
170
+ * or reach anything else. It lasts an hour unless asked (a day at most), ends
171
+ * when the key that made it is revoked, and is revocable at once. */
172
+ export declare class SandboxSessions {
173
+ private readonly t;
174
+ private readonly sandboxId;
175
+ constructor(t: Transport, sandboxId: string);
176
+ /** A new session. `origins` are the exact pages that will use it
177
+ * (`https://app.example.com`, or `http://localhost:5173` while developing):
178
+ * the API answers CORS for them and refuses any other page. */
179
+ create(input?: {
180
+ ttlSeconds?: number;
181
+ origins?: string[];
182
+ name?: string;
183
+ }, options?: RequestOptions): Promise<CreatedSandboxSession>;
184
+ /** Sessions still active, and those that ended in the last day, newest first. */
185
+ list(options?: RequestOptions): Promise<SandboxSession[]>;
186
+ /** Ends a session now; its next request is refused. */
187
+ revoke(sessionId: string, options?: RequestOptions): Promise<SandboxSession>;
188
+ }
127
189
  export declare class Processes {
128
190
  private readonly t;
129
191
  private readonly sandboxId;
package/dist/sandbox.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CommandError, ConnectionError, NotFoundError, RuntimeError } from "./errors.js";
2
2
  import { Page } from "./page.js";
3
3
  import { sandboxWatches, watchDirectory, } from "./products/watch.js";
4
+ import { Transport } from "./transport.js";
4
5
  import { sandboxFactories } from "./products/index.js";
5
6
  import { Tunnel } from "./tunnel.js";
6
7
  const CHUNK = 1_048_576;
@@ -11,7 +12,21 @@ const CHUNK = 1_048_576;
11
12
  * connections at once. */
12
13
  const PARALLEL = 8;
13
14
  const enc = (id) => encodeURIComponent(id);
14
- const toBase64 = (data) => (typeof data === "string" ? Buffer.from(data) : Buffer.from(data)).toString("base64");
15
+ /* Web APIs only, so the sandbox's commands, processes and files work in a
16
+ browser with a session token (Sandbox.fromSession): no Buffer, no
17
+ node:crypto. */
18
+ const utf8 = (data) => typeof data === "string" ? new TextEncoder().encode(data) : data;
19
+ function base64(bytes) {
20
+ let text = "";
21
+ for (let i = 0; i < bytes.length; i += 0x8000)
22
+ text += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
23
+ return btoa(text);
24
+ }
25
+ const toBase64 = (data) => base64(utf8(data));
26
+ async function sha256Hex(data) {
27
+ const digest = await crypto.subtle.digest("SHA-256", utf8(data));
28
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
29
+ }
15
30
  function commandBody(command, options) {
16
31
  return {
17
32
  ...(typeof command === "string" ? { command } : { argv: [...command] }),
@@ -32,17 +47,44 @@ export class Sandbox {
32
47
  #t;
33
48
  files;
34
49
  processes;
50
+ /** Short-lived tokens that let a browser reach this sandbox directly. */
51
+ sessions;
35
52
  constructor(transport, info) {
36
53
  this.#t = transport;
37
54
  this.#info = info;
38
55
  this.files = new Files(transport, info.id);
39
56
  this.processes = new Processes(transport, info.id);
57
+ this.sessions = new SandboxSessions(transport, info.id);
40
58
  for (const [name, make] of sandboxFactories())
41
59
  Object.defineProperty(this, name, { value: make(transport, this), enumerable: false });
42
60
  }
43
61
  get id() {
44
62
  return this.#info.id;
45
63
  }
64
+ /** A sandbox reached with a session token instead of an API key, for code in
65
+ * a browser: commands and their streams, processes, files and previews of
66
+ * this one sandbox. Makes no call; `info` holds only the id until
67
+ * `refresh()`. Your backend makes the session with `sbx.sessions.create()`
68
+ * and hands the page `token` and `sandboxId`.
69
+ *
70
+ * const sbx = Sandbox.fromSession({ token, sandboxId });
71
+ * const { stdout } = await sbx.exec("ls /workspace");
72
+ */
73
+ static fromSession(session) {
74
+ if (!/^rtsess_/.test(session.token))
75
+ throw new RuntimeError({
76
+ message: "That is not a sandbox session token (they start rtsess_).",
77
+ code: "invalid_request",
78
+ status: 0,
79
+ hint: "Make one on your backend with sbx.sessions.create() and pass its token.",
80
+ });
81
+ const transport = new Transport({
82
+ apiKey: session.token,
83
+ ...(session.apiUrl ? { baseUrl: session.apiUrl } : {}),
84
+ ...(session.fetch ? { fetch: session.fetch } : {}),
85
+ });
86
+ return new Sandbox(transport, { id: session.sandboxId });
87
+ }
46
88
  /** What the API last said about this sandbox. `refresh()` asks again. */
47
89
  get info() {
48
90
  return this.#info;
@@ -439,7 +481,7 @@ class OutputCursor {
439
481
  droppedBytes: event.offset - this.cursor,
440
482
  resumeAt: event.offset,
441
483
  };
442
- this.cursor = Math.max(this.cursor, event.offset + Buffer.byteLength(event.data));
484
+ this.cursor = Math.max(this.cursor, event.offset + utf8(event.data).byteLength);
443
485
  }
444
486
  yield event;
445
487
  }
@@ -463,6 +505,69 @@ function pick(options) {
463
505
  ...(options.timeoutMs ? { timeoutMs: options.timeoutMs + 60_000 } : {}),
464
506
  };
465
507
  }
508
+ /** Sessions: a token your backend makes and hands to your own frontend, which
509
+ * then runs commands, reads and writes files and reaches previews in this one
510
+ * sandbox directly, without proxying through your servers. A session cannot
511
+ * stop, pause, extend, fork, snapshot or change the sandbox, create anything,
512
+ * or reach anything else. It lasts an hour unless asked (a day at most), ends
513
+ * when the key that made it is revoked, and is revocable at once. */
514
+ export class SandboxSessions {
515
+ t;
516
+ sandboxId;
517
+ constructor(t, sandboxId) {
518
+ this.t = t;
519
+ this.sandboxId = sandboxId;
520
+ }
521
+ /** A new session. `origins` are the exact pages that will use it
522
+ * (`https://app.example.com`, or `http://localhost:5173` while developing):
523
+ * the API answers CORS for them and refuses any other page. */
524
+ async create(input = {}, options = {}) {
525
+ const path = `/v1/sandboxes/${enc(this.sandboxId)}/sessions`;
526
+ const made = await this.t.json({
527
+ method: "POST",
528
+ path,
529
+ body: input,
530
+ ...options,
531
+ });
532
+ if (made.token)
533
+ return made;
534
+ /* The answer replayed an earlier create whose reply was lost, and a token
535
+ is shown only once: end that session and make a fresh one. */
536
+ await this.revoke(made.id).catch(() => undefined);
537
+ const fresh = await this.t.json({
538
+ method: "POST",
539
+ path,
540
+ body: input,
541
+ ...(options.signal ? { signal: options.signal } : {}),
542
+ ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}),
543
+ });
544
+ if (!fresh.token)
545
+ throw new RuntimeError({
546
+ message: "The session was made but its token did not arrive.",
547
+ code: "session_token_lost",
548
+ status: 0,
549
+ hint: `Revoke session ${fresh.id} and create another.`,
550
+ });
551
+ return fresh;
552
+ }
553
+ /** Sessions still active, and those that ended in the last day, newest first. */
554
+ async list(options = {}) {
555
+ return (await this.t.json({
556
+ method: "GET",
557
+ path: `/v1/sandboxes/${enc(this.sandboxId)}/sessions`,
558
+ ...options,
559
+ })).data;
560
+ }
561
+ /** Ends a session now; its next request is refused. */
562
+ async revoke(sessionId, options = {}) {
563
+ return this.t.json({
564
+ method: "POST",
565
+ path: `/v1/sandboxes/${enc(this.sandboxId)}/sessions/${enc(sessionId)}:revoke`,
566
+ body: {},
567
+ ...options,
568
+ });
569
+ }
570
+ }
466
571
  export class Processes {
467
572
  t;
468
573
  sandboxId;
@@ -568,14 +673,14 @@ export class Process {
568
673
  }
569
674
  /** Sends input. Offsets are tracked for you, so a retried write is never typed twice. */
570
675
  async write(data, options = {}) {
571
- const bytes = typeof data === "string" ? Buffer.from(data) : Buffer.from(data);
676
+ const bytes = utf8(data);
572
677
  let sent = 0;
573
678
  do {
574
679
  const reply = await this.t.json({
575
680
  method: "POST",
576
681
  path: `/v1/sandboxes/${enc(this.sandboxId)}/processes/${enc(this.id)}:write`,
577
682
  body: {
578
- base64: bytes.subarray(sent).toString("base64"),
683
+ base64: base64(bytes.subarray(sent)),
579
684
  offset: this.#inputOffset,
580
685
  ...(options.eof ? { eof: true } : {}),
581
686
  },
@@ -743,16 +848,16 @@ export class Files {
743
848
  });
744
849
  return { path, size: bytes.length };
745
850
  }
746
- const { createHash } = await import("node:crypto");
747
- const sha256 = createHash("sha256").update(bytes).digest("hex");
851
+ const sha256 = await sha256Hex(bytes);
748
852
  // A guest journals mutations across operations, not per URL. One logical
749
853
  // write therefore owns separate stable keys for each mutation phase.
750
854
  const uploadKey = options.idempotencyKey ?? crypto.randomUUID();
855
+ const phaseKeys = new Map();
856
+ for (const phase of ["begin", "legacy-begin", "commit", "chmod", "abort"])
857
+ phaseKeys.set(phase, await sha256Hex(JSON.stringify(["files.write", uploadKey, phase])));
751
858
  const phaseOptions = (phase) => ({
752
859
  ...options,
753
- idempotencyKey: createHash("sha256")
754
- .update(JSON.stringify(["files.write", uploadKey, phase]))
755
- .digest("hex"),
860
+ idempotencyKey: phaseKeys.get(phase),
756
861
  });
757
862
  let begin;
758
863
  try {
@@ -1,5 +1,5 @@
1
1
  import { RuntimeError } from "./errors.js";
2
- export declare const VERSION = "0.8.2";
2
+ export declare const VERSION = "0.8.3";
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.8.2";
3
+ export const VERSION = "0.8.3";
4
4
  export const DEFAULT_BASE_URL = "https://api.withruntime.com";
5
5
  export function apiOrigin(value) {
6
6
  const url = new URL(value);
@@ -59,7 +59,7 @@ export function networkRules(policy) {
59
59
  else if (allow) {
60
60
  for (const [domain, rules] of Object.entries(allow)) {
61
61
  if (rules.length)
62
- throw new NotSupportedError(`Network rules that transform or forward requests (for ${domain})`, "Allow the domain with an empty rule list and pass credentials to the command as env instead.");
62
+ throw new NotSupportedError(`Network rules that transform or forward requests (for ${domain})`, `Allow the domain with an empty rule list and store the credential as a Runtime secret for it: \`npx withruntime secrets set NAME --host ${domain}\`. The sandbox sees a placeholder, and the egress proxy adds the value.`);
63
63
  domains.push(domain);
64
64
  }
65
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "withruntime",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "license": "Apache-2.0",
5
5
  "mcpName": "com.withruntime/runtime",
6
6
  "type": "module",
@@ -126,6 +126,12 @@
126
126
  },
127
127
  "bugs": "https://github.com/withruntime/runtime/issues",
128
128
  "sideEffects": false,
129
+ "browser": {
130
+ "./dist/tar.js": "./dist/browser-stub.js",
131
+ "./dist/image-context.js": "./dist/browser-stub.js",
132
+ "./dist/credentials.js": "./dist/browser-stub.js",
133
+ "undici": false
134
+ },
129
135
  "keywords": [
130
136
  "sandbox",
131
137
  "cloud",