withruntime 0.9.0 → 0.10.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 (90) hide show
  1. package/BLAXEL.md +4 -4
  2. package/DAYTONA.md +13 -7
  3. package/E2B.md +12 -8
  4. package/README.md +15 -7
  5. package/README.src.md +15 -7
  6. package/VERCEL.md +13 -3
  7. package/compatibility-lock.json +84 -8
  8. package/dist/blaxel/errors.js +1 -1
  9. package/dist/blaxel/lookup.d.ts +3 -2
  10. package/dist/blaxel/lookup.js +3 -2
  11. package/dist/blaxel/pagination.d.ts +2 -2
  12. package/dist/blaxel/pagination.js +45 -17
  13. package/dist/blaxel/preview.d.ts +2 -1
  14. package/dist/blaxel/preview.js +15 -13
  15. package/dist/blaxel/process.js +56 -10
  16. package/dist/blaxel/sandbox.d.ts +2 -0
  17. package/dist/blaxel/sandbox.js +2 -0
  18. package/dist/blaxel/snapshot.js +3 -5
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +189 -54
  21. package/dist/client.d.ts +29 -3
  22. package/dist/client.js +40 -11
  23. package/dist/cloudflare/identity.d.ts +7 -0
  24. package/dist/cloudflare/identity.js +13 -0
  25. package/dist/cloudflare/index.d.ts +9 -0
  26. package/dist/cloudflare/index.js +47 -9
  27. package/dist/cloudflare/worker.d.ts +4 -0
  28. package/dist/cloudflare/worker.js +166 -16
  29. package/dist/codesandbox/guest-service.js +57 -20
  30. package/dist/codesandbox/index.js +42 -8
  31. package/dist/compat/core.d.ts +9 -8
  32. package/dist/compat/core.js +60 -21
  33. package/dist/compat/files.js +22 -4
  34. package/dist/credentials.d.ts +6 -1
  35. package/dist/credentials.js +107 -38
  36. package/dist/daytona/daytona.js +11 -4
  37. package/dist/daytona/filesystem.d.ts +17 -4
  38. package/dist/daytona/filesystem.js +121 -38
  39. package/dist/daytona/index.d.ts +1 -1
  40. package/dist/daytona/sandbox.d.ts +1 -1
  41. package/dist/daytona/sandbox.js +16 -7
  42. package/dist/e2b/client.js +1 -1
  43. package/dist/e2b/commands.d.ts +30 -8
  44. package/dist/e2b/commands.js +142 -54
  45. package/dist/e2b/index.d.ts +1 -0
  46. package/dist/e2b/index.js +1 -0
  47. package/dist/e2b/pty.d.ts +24 -0
  48. package/dist/e2b/pty.js +80 -0
  49. package/dist/e2b/sandbox.d.ts +2 -1
  50. package/dist/e2b/sandbox.js +49 -15
  51. package/dist/file-source.d.ts +16 -0
  52. package/dist/file-source.js +162 -0
  53. package/dist/freestyle/index.d.ts +9 -1
  54. package/dist/freestyle/index.js +38 -5
  55. package/dist/index.d.ts +2 -1
  56. package/dist/login.js +16 -10
  57. package/dist/modal/index.js +2 -1
  58. package/dist/network-cli.js +111 -43
  59. package/dist/openai-agents/index.js +9 -45
  60. package/dist/page.d.ts +1 -1
  61. package/dist/page.js +2 -2
  62. package/dist/products/index.d.ts +12 -0
  63. package/dist/products/index.js +10 -0
  64. package/dist/products/observability.d.ts +44 -0
  65. package/dist/products/observability.js +6 -0
  66. package/dist/products/previews.d.ts +8 -2
  67. package/dist/products/previews.js +11 -3
  68. package/dist/products/volumes.d.ts +42 -0
  69. package/dist/products/volumes.js +36 -0
  70. package/dist/products/watch.js +61 -8
  71. package/dist/proxy.js +3 -1
  72. package/dist/runloop/index.d.ts +13 -27
  73. package/dist/runloop/index.js +149 -52
  74. package/dist/sandbox.d.ts +37 -8
  75. package/dist/sandbox.js +432 -68
  76. package/dist/snapshots.d.ts +9 -4
  77. package/dist/sprites/index.js +69 -33
  78. package/dist/tar.d.ts +3 -1
  79. package/dist/tar.js +140 -14
  80. package/dist/transport.d.ts +4 -7
  81. package/dist/transport.js +205 -122
  82. package/dist/vercel/command.d.ts +1 -1
  83. package/dist/vercel/command.js +3 -2
  84. package/dist/vercel/errors.d.ts +1 -1
  85. package/dist/vercel/errors.js +7 -1
  86. package/dist/vercel/sandbox.d.ts +8 -8
  87. package/dist/vercel/sandbox.js +154 -59
  88. package/dist/vercel/snapshot.d.ts +3 -2
  89. package/dist/vercel/snapshot.js +9 -5
  90. package/package.json +2 -1
@@ -0,0 +1,162 @@
1
+ import { RuntimeError } from "./errors.js";
2
+ const CHUNK = 1_048_576;
3
+ function failure(message, code, cause) {
4
+ return new RuntimeError({ message, code, status: 0, ...(cause === undefined ? {} : { cause }) });
5
+ }
6
+ async function exact(handle, offset, length) {
7
+ const data = new Uint8Array(length);
8
+ let held = 0;
9
+ while (held < length) {
10
+ const read = await handle.read(data, held, length - held, offset + held);
11
+ if (read.bytesRead === 0)
12
+ throw failure("The local upload source changed or ended before its declared size.", "source_changed");
13
+ held += read.bytesRead;
14
+ }
15
+ return data;
16
+ }
17
+ /** ARCHITECTURE.md section 10, Folders over HTTP: unknown-length inputs use
18
+ * bounded private disk staging; known files and Blobs remain repeatable inputs.
19
+ * Size and digest are established before the remote upload can begin. */
20
+ export async function prepareFileSource(input, scope) {
21
+ const fs = await import("node:fs/promises");
22
+ const { createHash } = await import("node:crypto");
23
+ const { Readable } = await import("node:stream");
24
+ scope.check();
25
+ const hash = createHash("sha256");
26
+ let size = 0;
27
+ let handle;
28
+ let staging;
29
+ let iterator;
30
+ // What is used of a reader, so the code compiles against Node's stream types
31
+ // as well as the DOM's (packages/cloud compiles this file without DOM).
32
+ let reader;
33
+ const cleanup = async () => {
34
+ try {
35
+ await handle?.close();
36
+ }
37
+ finally {
38
+ if (staging)
39
+ await fs.rm(staging, { recursive: true, force: true });
40
+ }
41
+ };
42
+ try {
43
+ if (typeof input === "string") {
44
+ handle = await fs.open(input, "r");
45
+ const info = await handle.stat();
46
+ if (!info.isFile() || !Number.isSafeInteger(info.size))
47
+ throw failure("A streamed file upload requires a regular local file.", "invalid_upload_source");
48
+ size = info.size;
49
+ for (let offset = 0; offset < size; offset += CHUNK) {
50
+ scope.check();
51
+ hash.update(await scope.wait(exact(handle, offset, Math.min(CHUNK, size - offset))));
52
+ }
53
+ }
54
+ else if (input instanceof Blob) {
55
+ // Bun's types leave out Blob.slice when the DOM library is off, as it is
56
+ // where packages/cloud compiles this file; every runtime has it.
57
+ const blob = input;
58
+ size = blob.size;
59
+ for (let offset = 0; offset < size; offset += CHUNK) {
60
+ scope.check();
61
+ hash.update(new Uint8Array(await scope.wait(blob.slice(offset, offset + CHUNK).arrayBuffer())));
62
+ }
63
+ return {
64
+ size,
65
+ sha256: hash.digest("hex"),
66
+ chunkAt: async (offset, length) => new Uint8Array(await blob.slice(offset, offset + length).arrayBuffer()),
67
+ close: async () => undefined,
68
+ };
69
+ }
70
+ else {
71
+ if (!input ||
72
+ (typeof input.getReader !== "function" &&
73
+ typeof input[Symbol.asyncIterator] !== "function"))
74
+ throw failure("A streamed upload requires a byte stream or local file.", "invalid_upload_source");
75
+ const { tmpdir } = await import("node:os");
76
+ const path = await import("node:path");
77
+ staging = await fs.mkdtemp(path.join(tmpdir(), "runtime-upload-"));
78
+ await fs.chmod(staging, 0o700);
79
+ handle = await fs.open(path.join(staging, "source"), "wx+", 0o600);
80
+ const space = await fs.statfs(staging, { bigint: true });
81
+ const available = space.bavail * space.bsize;
82
+ if ("getReader" in input)
83
+ reader = input.getReader();
84
+ else
85
+ iterator = input[Symbol.asyncIterator]();
86
+ for (;;) {
87
+ scope.check();
88
+ const next = await scope.wait(reader ? reader.read() : iterator.next());
89
+ if (next.done)
90
+ break;
91
+ if (!(next.value instanceof Uint8Array))
92
+ throw failure("A streamed upload must produce byte chunks.", "invalid_upload_source");
93
+ for (let offset = 0; offset < next.value.length; offset += CHUNK) {
94
+ scope.check();
95
+ const part = next.value.subarray(offset, offset + CHUNK);
96
+ if (!Number.isSafeInteger(size + part.length))
97
+ throw failure("The streamed upload exceeds the native file size range.", "upload_too_large");
98
+ if (BigInt(size + part.length) > available)
99
+ throw failure("There is not enough local temporary disk space to stage the streamed upload.", "local_disk_full");
100
+ let written = 0;
101
+ while (written < part.length) {
102
+ scope.check();
103
+ const result = await handle.write(part, written, part.length - written, size + written);
104
+ if (result.bytesWritten === 0)
105
+ throw failure("The local temporary file accepted no upload bytes.", "local_disk_full");
106
+ written += result.bytesWritten;
107
+ }
108
+ size += part.length;
109
+ hash.update(part);
110
+ }
111
+ }
112
+ }
113
+ scope.check();
114
+ const owned = handle;
115
+ if (!owned)
116
+ throw failure("The local upload file did not open.", "invalid_upload_source");
117
+ return {
118
+ size,
119
+ sha256: hash.digest("hex"),
120
+ chunkAt: (offset, length) => exact(owned, offset, length),
121
+ close: cleanup,
122
+ };
123
+ }
124
+ catch (error) {
125
+ // Calling return/cancel can itself throw synchronously. Cleanup still owns
126
+ // the file and directory, and must preserve the original source failure.
127
+ let timer;
128
+ try {
129
+ // Node's pending next() must settle before iterator.return() can finish.
130
+ // Web readers own cancellation directly; arbitrary iterators may not.
131
+ if (input instanceof Readable)
132
+ input.destroy();
133
+ const closing = reader ? reader.cancel(scope.signal?.reason) : iterator?.return?.();
134
+ if (closing)
135
+ await Promise.race([
136
+ closing,
137
+ new Promise((resolve) => {
138
+ timer = setTimeout(resolve, 1000);
139
+ }),
140
+ ]);
141
+ }
142
+ catch {
143
+ /* Preserve the caller's original failure. */
144
+ }
145
+ finally {
146
+ if (timer)
147
+ clearTimeout(timer);
148
+ await cleanup().catch(() => undefined);
149
+ }
150
+ if (error?.code === "ENOSPC")
151
+ throw failure("There is not enough local temporary disk space to stage the streamed upload.", "local_disk_full", error);
152
+ throw error;
153
+ }
154
+ finally {
155
+ try {
156
+ reader?.releaseLock();
157
+ }
158
+ catch {
159
+ /* Cancellation may leave a pending caller-owned read. */
160
+ }
161
+ }
162
+ }
@@ -38,22 +38,29 @@ export declare class VmsNamespace {
38
38
  id: string;
39
39
  sourceVmId: string;
40
40
  slug: string | null;
41
+ displayName: string | null;
41
42
  public: boolean;
42
43
  createdAt: string;
43
44
  updatedAt: string;
44
45
  ttlSeconds: number;
45
46
  }>;
46
47
  delete: (id: string) => Promise<void>;
47
- list: () => Promise<{
48
+ list: (options?: {
49
+ sourceVmId?: string;
50
+ limit?: number;
51
+ offset?: number;
52
+ }) => Promise<{
48
53
  snapshots: {
49
54
  id: string;
50
55
  sourceVmId: string;
51
56
  slug: string | null;
57
+ displayName: string | null;
52
58
  public: boolean;
53
59
  createdAt: string;
54
60
  updatedAt: string;
55
61
  ttlSeconds: number;
56
62
  }[];
63
+ totalCount: number;
57
64
  }>;
58
65
  };
59
66
  constructor(runtime: Runtime);
@@ -292,6 +299,7 @@ export declare class Vm {
292
299
  id: string;
293
300
  sourceVmId: string;
294
301
  slug: string | null;
302
+ displayName: string | null;
295
303
  public: boolean;
296
304
  createdAt: string;
297
305
  updatedAt: string;
@@ -1,6 +1,11 @@
1
1
  import { all, client, CompatibilityError, create, destroy, execOptions, lookup, only, ready, } from "../compat/core.js";
2
2
  import { GuestFiles } from "../compat/files.js";
3
3
  export { CompatibilityError };
4
+ function validateMetadata(metadata) {
5
+ for (const key of ["compat.provider", "fs.displayName"])
6
+ if (metadata !== undefined && Object.prototype.hasOwnProperty.call(metadata, key))
7
+ throw new CompatibilityError("Freestyle", `metadata key ${key} reserved by this adapter`);
8
+ }
4
9
  const data = (s) => ({
5
10
  id: s.id,
6
11
  state: s.state,
@@ -18,6 +23,7 @@ const snapshotData = (s) => ({
18
23
  id: s.id,
19
24
  sourceVmId: s.sourceSandboxId,
20
25
  slug: s.name,
26
+ displayName: s.labels?.["fs.displayName"] ?? null,
21
27
  public: false,
22
28
  createdAt: s.createdAt,
23
29
  updatedAt: s.createdAt,
@@ -37,9 +43,32 @@ export class VmsNamespace {
37
43
  this.snapshots = {
38
44
  get: async (id) => snapshotData(await this.resolveSnapshot(id)),
39
45
  delete: async (id) => runtime.snapshots.delete((await this.resolveSnapshot(id)).id),
40
- list: async () => ({
41
- snapshots: (await (await runtime.snapshots.list()).toArray()).map(snapshotData),
42
- }),
46
+ list: async (options = {}) => {
47
+ only("Freestyle snapshot list", options, ["sourceVmId", "limit", "offset"]);
48
+ if (options.sourceVmId !== undefined && typeof options.sourceVmId !== "string")
49
+ throw new TypeError("sourceVmId must be a string");
50
+ if (options.limit !== undefined &&
51
+ (!Number.isSafeInteger(options.limit) || options.limit <= 0))
52
+ throw new TypeError("limit must be a positive safe integer");
53
+ if (options.offset !== undefined &&
54
+ (!Number.isSafeInteger(options.offset) || options.offset < 0))
55
+ throw new TypeError("offset must be a nonnegative safe integer");
56
+ const offset = options.offset ?? 0;
57
+ const snapshots = [];
58
+ let totalCount = 0;
59
+ // Count the filtered catalog across every native page without retaining
60
+ // snapshots outside the requested page or Page.toArray's default cap.
61
+ const page = await runtime.snapshots.list(options.sourceVmId === undefined ? {} : { sandboxId: options.sourceVmId });
62
+ for await (const snapshot of page) {
63
+ if (options.sourceVmId !== undefined && snapshot.sourceSandboxId !== options.sourceVmId)
64
+ continue;
65
+ if (totalCount >= offset &&
66
+ (options.limit === undefined || snapshots.length < options.limit))
67
+ snapshots.push(snapshotData(snapshot));
68
+ totalCount++;
69
+ }
70
+ return { snapshots, totalCount };
71
+ },
43
72
  };
44
73
  }
45
74
  async resolveSnapshot(id) {
@@ -59,6 +88,7 @@ export class VmsNamespace {
59
88
  "metadata",
60
89
  "firewall",
61
90
  ]);
91
+ validateMetadata(options.metadata);
62
92
  if (!options.firewall || !Array.isArray(options.firewall.rules))
63
93
  throw new TypeError("firewall.rules is required");
64
94
  only("Freestyle firewall", options.firewall, ["rules"]);
@@ -266,6 +296,7 @@ export class Vm {
266
296
  }
267
297
  async update(options) {
268
298
  only("Freestyle update", options, ["slug", "displayName", "idleTimeoutSeconds", "metadata"]);
299
+ validateMetadata(options.metadata);
269
300
  const s = await lookup(this.runtime, this.id);
270
301
  await s.update({
271
302
  name: options.slug,
@@ -298,8 +329,10 @@ export class Vm {
298
329
  async snapshot(options = {}) {
299
330
  only("Freestyle snapshot", options, ["slug", "displayName", "ttlSeconds"]);
300
331
  if (options.ttlSeconds !== undefined &&
301
- (options.ttlSeconds <= 0 || options.ttlSeconds % 86400 !== 0))
302
- throw new CompatibilityError("Freestyle", "snapshot retention other than whole positive days");
332
+ (options.ttlSeconds <= 0 ||
333
+ options.ttlSeconds > 365 * 86400 ||
334
+ options.ttlSeconds % 86400 !== 0))
335
+ throw new CompatibilityError("Freestyle", "snapshot retention outside 1 to 365 whole days");
303
336
  const s = await (await lookup(this.runtime, this.id)).snapshot({
304
337
  name: options.slug,
305
338
  labels: options.displayName ? { "fs.displayName": options.displayName } : undefined,
package/dist/index.d.ts CHANGED
@@ -15,13 +15,14 @@ export { Tunnel, TunnelStream, type PortForward } from "./tunnel.js";
15
15
  export { Page } from "./page.js";
16
16
  export type { KeyLimits } from "./products/limits.js";
17
17
  export type { AuditEvent, AuditPage } from "./products/audit.js";
18
+ export type { UsageExportQuery, UsageExportRow, UsageExportPage, } from "./products/observability.js";
18
19
  export type { JobSecret, Secret, SecretRule, SecretUse, SetSecret } from "./products/secrets.js";
19
20
  export { SecretPartlyStoredError } from "./products/secrets.js";
20
21
  export type { McpCatalogEntry, McpGateway, McpServerRequest } from "./products/mcp.js";
21
22
  export type { FileEvent, FileEventType, WatchHandle, WatchNotice, WatchOptions, } from "./products/watch.js";
22
23
  export type { Mount, MountBucket } from "./products/mounts.js";
23
24
  export type { TailscaleJoin, TailscaleJoined, TailscaleNode, TailscaleStatus, } from "./products/tailscale.js";
24
- export type { Volume, VolumeBackup, CreateVolume } from "./products/volumes.js";
25
+ export type { Volume, VolumeBackup, CreateVolume, VolumeAttachment } from "./products/volumes.js";
25
26
  export type { CreateJob, Job, JobCompute, JobDefinition, JobLogs, JobRun, JobRunState, JobSchedule, } from "./products/jobs.js";
26
27
  export type { CheckedDomain, DnsRecord, Domain, EgressAddress, TcpPort, Tunnel as PrivateTunnel, TunnelPeer, TunnelPeerCreated, SetUpstreamProxy, UpstreamProxy, } from "./products/network-products.js";
27
28
  export { verifyWebhook, WebhookVerificationError, type MetricPoint, type MetricRange, type OtelExport, type RuntimeEvent, type SandboxMetrics, type Webhook, type WebhookDelivery, type WebhookEventType, } from "./products/observability.js";
package/dist/login.js CHANGED
@@ -4,7 +4,7 @@ import { hostname } from "node:os";
4
4
  import { promisify } from "node:util";
5
5
  import { me, named } from "./cli-name.js";
6
6
  import { Runtime } from "./client.js";
7
- import { connectionOrigins, connectionStore } from "./credentials.js";
7
+ import { checkBrowserOrigins, connectionOrigins, connectionStore } from "./credentials.js";
8
8
  import { describeRoute, envFetch } from "./proxy.js";
9
9
  class ConnectionError extends Error {
10
10
  status;
@@ -100,7 +100,16 @@ function connection(env, options) {
100
100
  : response.status === 429
101
101
  ? "Too many connection attempts. Try again later."
102
102
  : "Runtime could not complete this connection. Try again.");
103
- return (await response.json());
103
+ try {
104
+ return (await response.json());
105
+ }
106
+ catch (error) {
107
+ // A complete malformed answer remains terminal. A failed body read can
108
+ // hide an issued token, so keep its private key and retry the same request.
109
+ if (error instanceof SyntaxError)
110
+ throw error;
111
+ throw new ConnectionError(503, "Runtime's connection response was interrupted. Retry the connection.");
112
+ }
104
113
  }
105
114
  return { origins, store, request, fetcher };
106
115
  }
@@ -118,7 +127,7 @@ export async function authenticationCommand(args, env, options = {}) {
118
127
  // A pasted key belongs to the website's key list: forget it here, and
119
128
  // revoke it there if it should stop working everywhere.
120
129
  if (saved.connectionId === PASTED) {
121
- await store.remove();
130
+ await store.remove(saved.key);
122
131
  return { disconnected: true };
123
132
  }
124
133
  try {
@@ -128,7 +137,7 @@ export async function authenticationCommand(args, env, options = {}) {
128
137
  if (!(error instanceof ConnectionError && error.status === 401))
129
138
  throw error;
130
139
  }
131
- await store.remove();
140
+ await store.remove(saved.key);
132
141
  return { disconnected: true };
133
142
  }
134
143
  if (command !== "login")
@@ -160,11 +169,7 @@ export async function authenticationCommand(args, env, options = {}) {
160
169
  agentName.length > 80 ||
161
170
  [...agentName].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127))
162
171
  throw new Error("Give the agent a name of up to 80 characters.");
163
- // Never obtain a production key and then direct it at an unrelated API.
164
- const local = (origin) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(origin).hostname);
165
- if (!((origins.auth === "https://withruntime.com" && origins.api === "https://api.withruntime.com") ||
166
- (local(origins.auth) && local(origins.api))))
167
- throw new Error("Browser login supports Runtime's production endpoints or local test endpoints. Check RUNTIME_AUTH_URL and RUNTIME_API_URL.");
172
+ checkBrowserOrigins(origins);
168
173
  if (withKey) {
169
174
  // The fallback to a browser approval: a key from the website, read from
170
175
  // standard input so it never sits in a command line or shell history.
@@ -200,7 +205,7 @@ export async function authenticationCommand(args, env, options = {}) {
200
205
  catch (error) {
201
206
  if (!(error instanceof ConnectionError && error.status === 401))
202
207
  throw error;
203
- await store.remove();
208
+ await store.remove(saved.key);
204
209
  }
205
210
  }
206
211
  const pending = await beginDeviceLogin(env, agentName, options);
@@ -245,6 +250,7 @@ export async function authenticationCommand(args, env, options = {}) {
245
250
  * the agent, which shows it to the person it works for. */
246
251
  export async function beginDeviceLogin(env, agentName, options = {}) {
247
252
  const { origins, store, request } = connection(env, options);
253
+ checkBrowserOrigins(origins);
248
254
  // A request this machine already started and nobody has answered yet: pick
249
255
  // it up, so a command stopped while it waited (an agent's tool timing out)
250
256
  // shows the same link and code again rather than a new one to approve.
@@ -149,6 +149,7 @@ export class SandboxService {
149
149
  throw new CompatibilityError("Modal", "fractional CPU reservations");
150
150
  if (params.blockNetwork && (params.outboundDomainAllowlist || params.outboundCidrAllowlist))
151
151
  throw new TypeError("blockNetwork cannot be combined with allowlists");
152
+ const placement = region("Modal", params.regions?.[0]);
152
153
  const built = await image.build(app);
153
154
  let primary;
154
155
  const s = await create(this.runtime, "modal", {
@@ -162,7 +163,7 @@ export class SandboxService {
162
163
  cpu: "reserved",
163
164
  vcpu: params.cpu,
164
165
  memoryMiB: params.memoryMiB,
165
- region: region("Modal", params.regions?.[0]),
166
+ region: placement,
166
167
  timeoutSeconds: Math.ceil((params.timeoutMs ?? 300_000) / 1000),
167
168
  idlePauseSeconds: 0,
168
169
  onLeaseEnd: "stop",
@@ -1,4 +1,7 @@
1
- import { chmod, writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, rename, unlink } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
2
5
  import { RuntimeError } from "./errors.js";
3
6
  import { named } from "./cli-name.js";
4
7
  import { generateWireGuardKeyPair } from "./wireguard.js";
@@ -92,15 +95,63 @@ function portNumber(product, text) {
92
95
  throw usage(product, "Give a port from 1 to 65535.");
93
96
  return value;
94
97
  }
98
+ /** A fresh private inode prevents disclosure through permissive files, symlinks
99
+ * and hard links. Rename publishes complete contents without a readable window. */
100
+ async function privateOutput(path, text) {
101
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
102
+ try {
103
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
104
+ try {
105
+ await handle.writeFile(text);
106
+ await handle.sync();
107
+ }
108
+ finally {
109
+ await handle.close();
110
+ }
111
+ return temporary;
112
+ }
113
+ catch (error) {
114
+ await unlink(temporary).catch(() => undefined);
115
+ throw error;
116
+ }
117
+ }
118
+ /** Check and stage before changing a remote peer. If the request or final write
119
+ * fails, this private draft preserves the newly generated key for recovery. */
120
+ async function prepareOutput(path, privateKey) {
121
+ try {
122
+ const info = await lstat(path);
123
+ if (!info.isFile() || info.isSymbolicLink())
124
+ throw usage("tunnel", "The output must be a regular file, not a directory or symbolic link.");
125
+ }
126
+ catch (error) {
127
+ if (error.code !== "ENOENT")
128
+ throw error;
129
+ }
130
+ const recovery = await privateOutput(path, `# DRAFT: request or output incomplete. Keep this file for recovery.\n[Interface]\nPrivateKey = ${privateKey ?? "<your private key>"}\n`);
131
+ return {
132
+ recovery,
133
+ async publish(config) {
134
+ const temporary = await privateOutput(path, config);
135
+ try {
136
+ await rename(temporary, path);
137
+ }
138
+ finally {
139
+ await unlink(temporary).catch(() => undefined);
140
+ }
141
+ await unlink(recovery);
142
+ },
143
+ };
144
+ }
95
145
  /** Writes a wg-quick file readable by its owner alone, or prints it. */
96
- async function deliver(out, config, path, hint, ready = true) {
146
+ async function deliver(out, config, path, hint, ready = true, prepared) {
97
147
  if (!config) {
98
148
  out.error(hint);
99
149
  return;
100
150
  }
101
151
  if (path) {
102
- await writeFile(path, config, { mode: 0o600 });
103
- await chmod(path, 0o600);
152
+ if (!prepared)
153
+ throw new Error("Tunnel output was not prepared.");
154
+ await prepared.publish(config);
104
155
  out.error(ready
105
156
  ? `Wrote ${path}. Bring it up with: sudo wg-quick up ${path.startsWith("/") ? path : `./${path}`}`
106
157
  : `Wrote ${path}. ${hint}`);
@@ -276,52 +327,69 @@ export async function networkProductCommand(product, argv, args, out, client) {
276
327
  case "peers": {
277
328
  const action = rest[0];
278
329
  if (action === "add" || action === "rotate") {
330
+ const argument = need(rest[1], action === "add" ? "the peer's name, such as office" : "a peer id");
279
331
  // The pair is made here unless a key is given: the private key never
280
332
  // leaves this machine, and goes only into the file written below.
281
333
  const given = flag("public-key");
282
334
  const pair = given ? undefined : generateWireGuardKeyPair();
283
335
  const publicKey = given ?? pair.publicKey;
284
- const answer = action === "add"
285
- ? await runtime.tunnel.addPeer({
286
- name: need(rest[1], "the peer's name, such as office"),
287
- publicKey,
288
- routes: args.flags.get("route") ?? [],
289
- })
290
- : await runtime.tunnel.rotatePeer(need(rest[1], "a peer id"), { publicKey });
291
- const configReady = answer.configReady ?? !!answer.config;
292
- const draftHint = "Keep this file: it preserves your private key. Read runtime tunnel get when the gateway is ready, replace PublicKey in [Peer] with gatewayPublicKey, then run wg-quick up.";
293
- // Older servers returned no config while the gateway was starting.
294
- // Preserve the locally generated key in a draft rather than discard it.
295
- const supplied = answer.config ??
296
- [
297
- "# DRAFT: gateway key missing. Keep this file; it preserves your private key.",
298
- "[Interface]",
299
- `PrivateKey = ${pair?.privateKey ?? "<your private key>"}`,
300
- `Address = ${answer.peer.address}/32`,
301
- "",
302
- "[Peer]",
303
- `PublicKey = ${answer.tunnel.gatewayPublicKey ?? "<gatewayPublicKey from runtime tunnel get>"}`,
304
- `Endpoint = ${answer.tunnel.endpoint}`,
305
- `AllowedIPs = ${answer.tunnel.subnet}`,
306
- "PersistentKeepalive = 25",
307
- "",
308
- ].join("\n");
309
- const config = pair
310
- ? supplied.replace(/^PrivateKey = .*$/m, `PrivateKey = ${pair.privateKey}`)
311
- : supplied;
312
- if (out.json) {
313
- print("", {
314
- ...answer,
315
- config,
316
- configReady,
317
- ...(!configReady ? { hint: draftHint } : {}),
318
- });
336
+ const path = out.json ? undefined : (flag("out") ?? (pair ? "runtime.conf" : undefined));
337
+ const prepared = path ? await prepareOutput(path, pair?.privateKey) : undefined;
338
+ try {
339
+ const answer = action === "add"
340
+ ? await runtime.tunnel.addPeer({
341
+ name: argument,
342
+ publicKey,
343
+ routes: args.flags.get("route") ?? [],
344
+ })
345
+ : await runtime.tunnel.rotatePeer(argument, { publicKey });
346
+ const configReady = answer.configReady ?? !!answer.config;
347
+ const draftHint = "Keep this file: it preserves your private key. Read runtime tunnel get when the gateway is ready, replace PublicKey in [Peer] with gatewayPublicKey, then run wg-quick up.";
348
+ // Older servers returned no config while the gateway was starting.
349
+ // Preserve the locally generated key in a draft rather than discard it.
350
+ const supplied = answer.config ??
351
+ [
352
+ "# DRAFT: gateway key missing. Keep this file; it preserves your private key.",
353
+ "[Interface]",
354
+ `PrivateKey = ${pair?.privateKey ?? "<your private key>"}`,
355
+ `Address = ${answer.peer.address}/32`,
356
+ "",
357
+ "[Peer]",
358
+ `PublicKey = ${answer.tunnel.gatewayPublicKey ?? "<gatewayPublicKey from runtime tunnel get>"}`,
359
+ `Endpoint = ${answer.tunnel.endpoint}`,
360
+ `AllowedIPs = ${answer.tunnel.subnet}`,
361
+ "PersistentKeepalive = 25",
362
+ "",
363
+ ].join("\n");
364
+ const privateKeyRow = /^[ \t]*PrivateKey[ \t]*=[^\r\n]*$/gm;
365
+ if (pair && (supplied.match(privateKeyRow)?.length ?? 0) !== 1)
366
+ throw new RuntimeError({
367
+ code: "invalid_response",
368
+ status: 0,
369
+ message: "Runtime returned a tunnel configuration without exactly one private-key field.",
370
+ });
371
+ const config = pair
372
+ ? supplied.replace(privateKeyRow, `PrivateKey = ${pair.privateKey}`)
373
+ : supplied;
374
+ if (out.json) {
375
+ print("", {
376
+ ...answer,
377
+ config,
378
+ configReady,
379
+ ...(!configReady ? { hint: draftHint } : {}),
380
+ });
381
+ return 0;
382
+ }
383
+ if (answer.tunnel.funded === false)
384
+ out.error(fundingState(answer.tunnel));
385
+ await deliver(out, config, path, configReady ? answer.hint : draftHint, configReady, prepared);
319
386
  return 0;
320
387
  }
321
- if (answer.tunnel.funded === false)
322
- out.error(fundingState(answer.tunnel));
323
- await deliver(out, config, flag("out") ?? (pair ? "runtime.conf" : undefined), configReady ? answer.hint : draftHint, configReady);
324
- return 0;
388
+ catch (error) {
389
+ if (prepared)
390
+ out.error(`The peer request or output did not finish. Your private draft is saved at ${prepared.recovery}; keep it for recovery.`);
391
+ throw error;
392
+ }
325
393
  }
326
394
  if (action === "rm" || action === "remove") {
327
395
  const after = await runtime.tunnel.removePeer(need(rest[1], "a peer id"));
@@ -531,29 +531,11 @@ export class RuntimeCloudSandboxSession {
531
531
  // Workspace archives.
532
532
  async persistWorkspace() {
533
533
  const root = this.state.manifest.root;
534
- const staging = `${STAGING}/${crypto.randomUUID()}.tar`;
535
- const skip = root === FILES_HOME ? ["--exclude=./.openai-agents-staging"] : [];
536
- try {
537
- const packed = await this.#call((sbx) => sbx.exec([
538
- "sh",
539
- "-c",
540
- `mkdir -p -- ${STAGING} && tar ${skip.join(" ")} -C "$1" -cf - . > "$2"`,
541
- "sh",
542
- root,
543
- staging,
544
- ], {
545
- cwd: "/",
546
- timeoutMs: this.state.execTimeoutMs,
547
- }));
548
- if (packed.exitCode !== 0)
549
- throw new SandboxProviderError(`${PROVIDER} could not archive the workspace.`, {
550
- stderr: packed.stderr,
551
- });
552
- return await this.#call((sbx) => sbx.files.read(staging));
553
- }
554
- finally {
555
- await this.#call((sbx) => sbx.files.remove(staging)).catch(() => undefined);
556
- }
534
+ return this.#call((sbx) => sbx.files.archive(root, {
535
+ gzip: false,
536
+ ...(root === FILES_HOME ? { exclude: [".openai-agents-staging"] } : {}),
537
+ timeoutMs: this.state.execTimeoutMs,
538
+ }));
557
539
  }
558
540
  async hydrateWorkspace(data) {
559
541
  const bytes = typeof data === "string"
@@ -561,28 +543,10 @@ export class RuntimeCloudSandboxSession {
561
543
  : data instanceof Uint8Array
562
544
  ? data
563
545
  : new Uint8Array(data);
564
- const staging = `${STAGING}/${crypto.randomUUID()}.tar`;
565
- try {
566
- await this.#call((sbx) => sbx.files.write(staging, bytes));
567
- const unpacked = await this.#call((sbx) => sbx.exec([
568
- "sh",
569
- "-c",
570
- 'mkdir -p -- "$1" && tar -C "$1" -xf "$2"',
571
- "sh",
572
- this.state.manifest.root,
573
- staging,
574
- ], {
575
- cwd: "/",
576
- timeoutMs: this.state.execTimeoutMs,
577
- }));
578
- if (unpacked.exitCode !== 0)
579
- throw new SandboxProviderError(`${PROVIDER} could not restore the workspace.`, {
580
- stderr: unpacked.stderr,
581
- });
582
- }
583
- finally {
584
- await this.#call((sbx) => sbx.files.remove(staging)).catch(() => undefined);
585
- }
546
+ await this.#call((sbx) => sbx.files.unarchive(this.state.manifest.root, bytes, {
547
+ gzip: false,
548
+ timeoutMs: this.state.execTimeoutMs,
549
+ }));
586
550
  }
587
551
  // Lifecycle.
588
552
  async running() {